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/
|
||||
temp/
|
||||
.pnpm-store/
|
||||
.formatting-backups/
|
||||
.cursor/
|
||||
.playwright-cli/
|
||||
output/
|
||||
docs/
|
||||
artifacts/
|
||||
|
||||
# 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
|
||||
PHONE_AGENT_INTERNAL_TOKEN=
|
||||
PHONE_CALL_SUMMARY_FROM_EMAIL=
|
||||
ENABLE_GHL_SYNC=false
|
||||
GOOGLE_CALENDAR_CLIENT_ID=
|
||||
GOOGLE_CALENDAR_CLIENT_SECRET=
|
||||
GOOGLE_CALENDAR_REFRESH_TOKEN=
|
||||
GOOGLE_CALENDAR_ID=
|
||||
GOOGLE_CALENDAR_TIMEZONE=America/Denver
|
||||
GOOGLE_CALENDAR_CALLBACK_SLOT_MINUTES=15
|
||||
GOOGLE_CALENDAR_CALLBACK_START_HOUR=8
|
||||
GOOGLE_CALENDAR_CALLBACK_END_HOUR=17
|
||||
|
||||
# Placeholder for a later LiveKit rollout
|
||||
LIVEKIT_URL=
|
||||
|
|
|
|||
|
|
@ -1,81 +1,28 @@
|
|||
# Current Rocky Mountain Vending staging env contract.
|
||||
# Fill these in through Coolify-managed environment variables only.
|
||||
NEXT_PUBLIC_SITE_DOMAIN=rmv.abundancepartners.app
|
||||
NEXT_PUBLIC_SITE_URL=https://rmv.abundancepartners.app
|
||||
|
||||
# Core site
|
||||
NEXT_PUBLIC_SITE_URL=https://rockymountainvending.com
|
||||
NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com
|
||||
NEXT_PUBLIC_CONVEX_URL=
|
||||
CONVEX_URL=
|
||||
CONVEX_SELF_HOSTED_URL=
|
||||
CONVEX_SELF_HOSTED_ADMIN_KEY=
|
||||
CONVEX_TENANT_SLUG=rocky_mountain_vending
|
||||
CONVEX_TENANT_NAME=Rocky Mountain Vending
|
||||
|
||||
ADMIN_UI_ENABLED=true
|
||||
ADMIN_API_TOKEN=
|
||||
ADMIN_EMAIL=
|
||||
|
||||
PHONE_AGENT_INTERNAL_TOKEN=
|
||||
PHONE_CALL_SUMMARY_FROM_EMAIL=
|
||||
|
||||
USESEND_API_KEY=
|
||||
USESEND_BASE_URL=
|
||||
USESEND_FROM_EMAIL=info@rockymountainvending.com
|
||||
CONTACT_FORM_TO_EMAIL=info@rockymountainvending.com
|
||||
|
||||
GHL_API_TOKEN=
|
||||
GHL_LOCATION_ID=
|
||||
|
||||
# Voice and chat
|
||||
LIVEKIT_URL=
|
||||
LIVEKIT_API_KEY=
|
||||
LIVEKIT_API_SECRET=
|
||||
XAI_API_KEY=
|
||||
XAI_REALTIME_MODEL=grok-4-1-fast-non-reasoning
|
||||
VOICE_ASSISTANT_SITE_URL=https://rockymountainvending.com
|
||||
PHONE_AGENT_INTERNAL_TOKEN=
|
||||
NEXT_PUBLIC_CALL_PHONE_DISPLAY=(435) 233-9668
|
||||
NEXT_PUBLIC_CALL_PHONE_E164=+14352339668
|
||||
NEXT_PUBLIC_SMS_PHONE_DISPLAY=(435) 233-9668
|
||||
NEXT_PUBLIC_SMS_PHONE_E164=+14352339668
|
||||
NEXT_PUBLIC_MANUALS_BASE_URL=
|
||||
NEXT_PUBLIC_THUMBNAILS_BASE_URL=
|
||||
VOICE_RECORDING_ENABLED=false
|
||||
VOICE_RECORDING_BUCKET=
|
||||
VOICE_RECORDING_ENDPOINT=
|
||||
VOICE_RECORDING_PUBLIC_BASE_URL=
|
||||
VOICE_RECORDING_ACCESS_KEY_ID=
|
||||
VOICE_RECORDING_SECRET_ACCESS_KEY=
|
||||
VOICE_RECORDING_REGION=auto
|
||||
|
||||
# Admin and auth
|
||||
ADMIN_EMAIL=
|
||||
ADMIN_PASSWORD=
|
||||
RESEND_API_KEY=
|
||||
PHONE_CALL_SUMMARY_FROM_EMAIL=
|
||||
ENABLE_GHL_SYNC=false
|
||||
GOOGLE_CALENDAR_CLIENT_ID=
|
||||
GOOGLE_CALENDAR_CLIENT_SECRET=
|
||||
GOOGLE_CALENDAR_REFRESH_TOKEN=
|
||||
GOOGLE_CALENDAR_ID=
|
||||
GOOGLE_CALENDAR_TIMEZONE=America/Denver
|
||||
GOOGLE_CALENDAR_CALLBACK_SLOT_MINUTES=15
|
||||
GOOGLE_CALENDAR_CALLBACK_START_HOUR=8
|
||||
GOOGLE_CALENDAR_CALLBACK_END_HOUR=17
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
|
||||
# Stripe
|
||||
STRIPE_SECRET_KEY=
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
# GHL handoff + sync (API-only mode)
|
||||
GHL_PRIVATE_INTEGRATION_TOKEN=
|
||||
GHL_LOCATION_ID=YAoWLgNSid8oG44j9BjG
|
||||
GHL_API_VERSION=2021-07-28
|
||||
GHL_API_BASE_URL=https://services.leadconnectorhq.com
|
||||
GHL_SYNC_CRON_TOKEN=
|
||||
GHL_SYNC_INTERVAL_MINUTES=15
|
||||
GHL_SYNC_PAGE_SIZE=100
|
||||
|
||||
# Optional/deprecated in API-only mode
|
||||
GHL_WEBHOOK_SHARED_SECRET=
|
||||
GHL_CONTACT_WEBHOOK_URL=
|
||||
GHL_REQUEST_MACHINE_WEBHOOK_URL=
|
||||
|
||||
# eBay API credentials for manuals-side fallback
|
||||
EBAY_APP_ID=
|
||||
EBAY_DEV_ID=
|
||||
EBAY_CERT_ID=
|
||||
EBAY_SANDBOX_TOKEN=
|
||||
EBAY_AFFILIATE_CAMPAIGN_ID=
|
||||
|
||||
# eBay marketplace account deletion notifications
|
||||
# Use the exact public HTTPS endpoint that eBay validates in the developer portal.
|
||||
EBAY_NOTIFICATION_ENDPOINT=https://rmv.abundancepartners.app/api/ebay/notifications
|
||||
EBAY_NOTIFICATION_VERIFICATION_TOKEN=
|
||||
EBAY_NOTIFICATION_APP_ID=
|
||||
EBAY_NOTIFICATION_CERT_ID=
|
||||
EBAY_NOTIFICATION_API_BASE_URL=https://api.ebay.com
|
||||
EBAY_NOTIFICATION_SCOPE=https://api.ebay.com/oauth/api_scope
|
||||
VOICE_ASSISTANT_SITE_URL=https://rmv.abundancepartners.app
|
||||
|
|
|
|||
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -1,10 +1,11 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
/output/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
|
@ -14,12 +15,10 @@ npm-debug.log*
|
|||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
/dev.log
|
||||
|
||||
# env files
|
||||
.env*
|
||||
!.env.example
|
||||
!.env.staging.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
|
@ -27,10 +26,3 @@ yarn-error.log*
|
|||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# local tooling caches
|
||||
/.playwright-cli/
|
||||
/.pnpm-store/
|
||||
|
||||
# package manager drift
|
||||
/package-lock.json
|
||||
|
|
|
|||
|
|
@ -2,42 +2,45 @@
|
|||
module.exports = {
|
||||
ci: {
|
||||
collect: {
|
||||
url: ["http://localhost:3000"],
|
||||
url: ['http://localhost:3000'],
|
||||
numberOfRuns: 3,
|
||||
startServerCommand: "npm run start",
|
||||
startServerReadyPattern: "ready",
|
||||
startServerCommand: 'npm run start',
|
||||
startServerReadyPattern: 'ready',
|
||||
startServerReadyTimeout: 30000,
|
||||
},
|
||||
assert: {
|
||||
assertions: {
|
||||
"categories:performance": ["error", { minScore: 1 }],
|
||||
"categories:accessibility": ["error", { minScore: 1 }],
|
||||
"categories:best-practices": ["error", { minScore: 1 }],
|
||||
"categories:seo": ["error", { minScore: 1 }],
|
||||
'categories:performance': ['error', { minScore: 1 }],
|
||||
'categories:accessibility': ['error', { minScore: 1 }],
|
||||
'categories:best-practices': ['error', { minScore: 1 }],
|
||||
'categories:seo': ['error', { minScore: 1 }],
|
||||
// Core Web Vitals
|
||||
"first-contentful-paint": ["error", { maxNumericValue: 1800 }],
|
||||
"largest-contentful-paint": ["error", { maxNumericValue: 2500 }],
|
||||
"cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }],
|
||||
"total-blocking-time": ["error", { maxNumericValue: 200 }],
|
||||
"speed-index": ["error", { maxNumericValue: 3400 }],
|
||||
'first-contentful-paint': ['error', { maxNumericValue: 1800 }],
|
||||
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
|
||||
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
|
||||
'total-blocking-time': ['error', { maxNumericValue: 200 }],
|
||||
'speed-index': ['error', { maxNumericValue: 3400 }],
|
||||
// Performance metrics
|
||||
interactive: ["error", { maxNumericValue: 3800 }],
|
||||
"uses-optimized-images": "error",
|
||||
"uses-text-compression": "error",
|
||||
"uses-responsive-images": "error",
|
||||
"modern-image-formats": "error",
|
||||
"offscreen-images": "error",
|
||||
"render-blocking-resources": "error",
|
||||
"unused-css-rules": "error",
|
||||
"unused-javascript": "error",
|
||||
"efficient-animated-content": "error",
|
||||
"preload-lcp-image": "error",
|
||||
"uses-long-cache-ttl": "error",
|
||||
"total-byte-weight": ["error", { maxNumericValue: 1600000 }],
|
||||
'interactive': ['error', { maxNumericValue: 3800 }],
|
||||
'uses-optimized-images': 'error',
|
||||
'uses-text-compression': 'error',
|
||||
'uses-responsive-images': 'error',
|
||||
'modern-image-formats': 'error',
|
||||
'offscreen-images': 'error',
|
||||
'render-blocking-resources': 'error',
|
||||
'unused-css-rules': 'error',
|
||||
'unused-javascript': 'error',
|
||||
'efficient-animated-content': 'error',
|
||||
'preload-lcp-image': 'error',
|
||||
'uses-long-cache-ttl': 'error',
|
||||
'total-byte-weight': ['error', { maxNumericValue: 1600000 }],
|
||||
},
|
||||
},
|
||||
upload: {
|
||||
target: "temporary-public-storage",
|
||||
target: 'temporary-public-storage',
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
All WordPress-related code has been archived to:
|
||||
|
||||
- `_archive/wordpress/code-lib/`
|
||||
|
||||
## Current State
|
||||
|
|
@ -34,7 +33,6 @@ All WordPress-related code has been archived to:
|
|||
3. **DO NOT** add WordPress route handling back to catch-all routes
|
||||
|
||||
If you need specific content from WordPress:
|
||||
|
||||
- Extract the content manually
|
||||
- Create new pages/components following the style guide
|
||||
- Use the existing component patterns from `code/components/`
|
||||
|
|
@ -42,3 +40,13 @@ If you need specific content from WordPress:
|
|||
## Last Updated
|
||||
|
||||
December 2, 2025
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
## Shopping Cart Components - ✅ Converted
|
||||
|
||||
### Cart Component (`code/components/cart.tsx`)
|
||||
|
||||
- **Status**: ✅ Converted to use shadcn Sheet component
|
||||
- **Changes**:
|
||||
- Replaced custom sidebar/backdrop with `Sheet`, `SheetContent`, `SheetHeader`, `SheetFooter`
|
||||
|
|
@ -12,14 +11,12 @@
|
|||
- Maintains all existing functionality
|
||||
|
||||
### Cart Button (`code/components/cart-button.tsx`)
|
||||
|
||||
- **Status**: ✅ Updated to use shadcn Badge component
|
||||
- **Changes**:
|
||||
- Replaced custom badge span with shadcn `Badge` component
|
||||
- Uses Badge variant instead of CSS variables
|
||||
|
||||
### Add to Cart Button (`code/components/add-to-cart-button.tsx`)
|
||||
|
||||
- **Status**: ✅ Updated to use button variant
|
||||
- **Changes**:
|
||||
- Uses new `brand` variant instead of CSS variables
|
||||
|
|
@ -27,7 +24,6 @@
|
|||
## Button Component Updates
|
||||
|
||||
### Brand Variant Added (`code/components/ui/button.tsx`)
|
||||
|
||||
- **Status**: ✅ Added `brand` variant
|
||||
- **Purpose**: Centralizes brand color (--link-hover-color) usage
|
||||
- **Usage**: Use `variant="brand"` instead of CSS variables
|
||||
|
|
@ -35,20 +31,17 @@
|
|||
## Form Components Audit
|
||||
|
||||
### Standard Form Component (`code/components/ui/form.tsx`)
|
||||
|
||||
- **Status**: ✅ Keep - Standard shadcn form with react-hook-form integration
|
||||
- **Usage**: Use for react-hook-form based forms
|
||||
- **Components**: Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage
|
||||
|
||||
### Field Component (`code/components/ui/field.tsx`)
|
||||
|
||||
- **Status**: ⚠️ Unused - Not imported anywhere in codebase
|
||||
- **Recommendation**: Keep for now as alternative form system, but consider removing if not needed
|
||||
- **Components**: Field, FieldLabel, FieldDescription, FieldError, FieldGroup, FieldLegend, FieldSeparator, FieldSet, FieldContent, FieldTitle
|
||||
- **Note**: This appears to be an alternative form system that doesn't use react-hook-form
|
||||
|
||||
### Input Group Component (`code/components/ui/input-group.tsx`)
|
||||
|
||||
- **Status**: ⚠️ Unused - Not imported anywhere in codebase
|
||||
- **Recommendation**: Keep for now, but consider removing if not needed
|
||||
- **Components**: InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupInput, InputGroupTextarea
|
||||
|
|
@ -57,7 +50,6 @@
|
|||
## CSS Variable Usage Cleanup
|
||||
|
||||
### Components Updated to Use Brand Variant:
|
||||
|
||||
- ✅ `code/components/cart.tsx` - Checkout button
|
||||
- ✅ `code/components/add-to-cart-button.tsx` - Add to cart button
|
||||
- ✅ `code/components/error-boundary.tsx` - Reload button
|
||||
|
|
@ -65,7 +57,6 @@
|
|||
- ✅ `code/app/checkout/cancel/page.tsx` - Continue shopping button
|
||||
|
||||
### Remaining CSS Variable Usage:
|
||||
|
||||
- `code/components/ui/button.tsx` - Brand variant definition (intentional, centralized)
|
||||
- Other components may use CSS variables for non-button purposes (gradients, text colors, etc.)
|
||||
|
||||
|
|
@ -93,3 +84,13 @@
|
|||
|
||||
- `code/components/ui/field.tsx` - Alternative form system, not currently used
|
||||
- `code/components/ui/input-group.tsx` - Input grouping, not currently used
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
# Rocky Mountain Coolify Launch
|
||||
|
||||
## Staging
|
||||
|
||||
- Coolify project: `Rocky Mountain Vending`
|
||||
- Coolify app UUID: `bsowk840kccg08coocwwc44c`
|
||||
- Environment UUID: `ew8k8og0gw48swck4ckk84kk`
|
||||
|
|
@ -18,12 +17,10 @@
|
|||
- Manual Gitea secret: `deploy123`
|
||||
|
||||
## DNS Note
|
||||
|
||||
- `rmv.abundancepartners.app` is managed in Cloudflare.
|
||||
- The active DNS record is an unproxied `A` record to `85.239.237.247`.
|
||||
|
||||
## Required App Env
|
||||
|
||||
- `NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com`
|
||||
- `NEXT_PUBLIC_SITE_URL=https://rockymountainvending.com`
|
||||
- `CONVEX_URL`
|
||||
|
|
@ -46,29 +43,24 @@
|
|||
- `LIVEKIT_API_SECRET`
|
||||
|
||||
## Current Runtime Defaults
|
||||
|
||||
- Email is launch-ready through AWS SES fallback with `AWS_ACCESS_KEY`, `AWS_SECRET_KEY`, and `AWS_DEFAULT_REGION`.
|
||||
- GHL is not complete until `GHL_API_TOKEN` is added.
|
||||
- Dedicated Usesend remains optional follow-up until `USESEND_API_KEY` and `USESEND_BASE_URL` exist.
|
||||
- Self-hosted Convex tenant deployment is still blocked upstream by a `502`, so live tenant-scoped backend writes are not fully confirmed yet.
|
||||
|
||||
## Email Notes
|
||||
|
||||
- The app prefers Usesend when `USESEND_API_KEY` is present.
|
||||
- If Usesend is not ready yet, the lead pipeline can fall back to AWS SES using `AWS_ACCESS_KEY`, `AWS_SECRET_KEY`, and `AWS_DEFAULT_REGION`.
|
||||
- Keep all email secrets in Coolify-managed env vars.
|
||||
|
||||
## Local Handoff Files
|
||||
|
||||
- `.env.rocky-workstation` contains the other-workstation operational variables and webhook details.
|
||||
- `.env.rocky-coolify` contains the current Coolify app env bundle with blanks for the still-missing secrets.
|
||||
|
||||
## Canonical Handoff Variables
|
||||
|
||||
Use this markdown section as the source of truth if you need to recreate the handoff elsewhere.
|
||||
|
||||
### Other Workstation
|
||||
|
||||
```env
|
||||
COOLIFY_API_URL=http://85.239.237.247:8000/api/v1
|
||||
COOLIFY_API_TOKEN=4|ScZUAuJPRPWBSdXWhtW3tkYBcoALayIYnw5zsgCse0a02bb5
|
||||
|
|
@ -89,7 +81,6 @@ ADMIN_API_TOKEN=
|
|||
```
|
||||
|
||||
### Coolify App Env
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com
|
||||
NEXT_PUBLIC_SITE_URL=https://rockymountainvending.com
|
||||
|
|
@ -117,7 +108,6 @@ ADMIN_API_TOKEN=
|
|||
```
|
||||
|
||||
## Validation Snapshot
|
||||
|
||||
- `https://rmv.abundancepartners.app` returns `200 OK`
|
||||
- `POST /api/contact` with `{}` on `https://rmv.abundancepartners.app` returns `400`
|
||||
- Pushes to Forgejo `main` queue Coolify deployments automatically
|
||||
|
|
@ -5,7 +5,6 @@ This guide explains how to test Lighthouse performance scores locally and achiev
|
|||
## Prerequisites
|
||||
|
||||
1. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
# or
|
||||
|
|
@ -25,7 +24,6 @@ npm run lighthouse:build
|
|||
```
|
||||
|
||||
This command will:
|
||||
|
||||
1. Build the production bundle (`next build`)
|
||||
2. Start the production server (`next start`)
|
||||
3. Run Lighthouse tests on multiple pages
|
||||
|
|
@ -66,7 +64,6 @@ The script also reports these critical metrics:
|
|||
### Target Scores
|
||||
|
||||
For 100% scores, all categories must achieve:
|
||||
|
||||
- Performance: 100/100
|
||||
- Accessibility: 100/100
|
||||
- Best Practices: 100/100
|
||||
|
|
@ -112,7 +109,6 @@ For 100% scores, all categories must achieve:
|
|||
### Images Not Optimizing
|
||||
|
||||
If images aren't being optimized:
|
||||
|
||||
- Ensure `unoptimized: false` in `next.config.mjs`
|
||||
- Check that images are using Next.js `Image` component, not `<img>` tags
|
||||
- Verify image domains are in `remotePatterns` if using external images
|
||||
|
|
@ -128,7 +124,6 @@ If images aren't being optimized:
|
|||
The `.lighthouserc.js` file is configured for Lighthouse CI. To use it:
|
||||
|
||||
1. Install Lighthouse CI:
|
||||
|
||||
```bash
|
||||
npm install -g @lhci/cli
|
||||
```
|
||||
|
|
@ -152,3 +147,6 @@ The `.lighthouserc.js` file is configured for Lighthouse CI. To use it:
|
|||
- [Next.js Image Optimization](https://nextjs.org/docs/app/api-reference/components/image)
|
||||
- [Core Web Vitals](https://web.dev/vitals/)
|
||||
- [Web Performance Best Practices](https://web.dev/performance/)
|
||||
|
||||
|
||||
|
||||
|
|
@ -5,7 +5,6 @@ Complete guide for setting up Cloudflare R2 storage for vending machine manuals
|
|||
## Overview
|
||||
|
||||
This guide covers:
|
||||
|
||||
1. Creating R2 buckets
|
||||
2. Generating API credentials
|
||||
3. Uploading files to R2
|
||||
|
|
@ -72,14 +71,12 @@ wrangler r2 bucket create vending-vm-thumbnails
|
|||
## Step 3: Configure Environment Variables
|
||||
|
||||
1. Copy `.env.example` to `.env.local`:
|
||||
|
||||
```bash
|
||||
cd code
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
2. Edit `.env.local` and fill in:
|
||||
|
||||
```bash
|
||||
CLOUDFLARE_R2_ACCESS_KEY_ID=your_access_key_id_here
|
||||
CLOUDFLARE_R2_SECRET_ACCESS_KEY=your_secret_access_key_here
|
||||
|
|
@ -138,7 +135,6 @@ node scripts/upload-to-r2.js --type all --incremental
|
|||
### Get Public URLs
|
||||
|
||||
After enabling public access, you'll get URLs like:
|
||||
|
||||
- Manuals: `https://pub-xxxxx.r2.dev` (or custom domain)
|
||||
- Thumbnails: `https://pub-yyyyy.r2.dev` (or custom domain)
|
||||
|
||||
|
|
@ -161,10 +157,7 @@ If you need to access R2 from a different domain:
|
|||
```json
|
||||
[
|
||||
{
|
||||
"AllowedOrigins": [
|
||||
"https://rockymountainvending.com",
|
||||
"https://www.rockymountainvending.com"
|
||||
],
|
||||
"AllowedOrigins": ["https://rockymountainvending.com", "https://www.rockymountainvending.com"],
|
||||
"AllowedMethods": ["GET", "HEAD"],
|
||||
"AllowedHeaders": ["*"],
|
||||
"ExposeHeaders": ["ETag"],
|
||||
|
|
@ -228,7 +221,6 @@ NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com
|
|||
```
|
||||
|
||||
This will:
|
||||
|
||||
1. Upload manuals and thumbnails to R2
|
||||
2. Build the Next.js app
|
||||
3. Create deployment ZIP (for GHL if needed)
|
||||
|
|
@ -254,27 +246,22 @@ Visit `http://localhost:3000` and verify manuals load from R2 URLs.
|
|||
### Upload Script Fails
|
||||
|
||||
**Error**: "CLOUDFLARE_R2_ACCESS_KEY_ID must be set"
|
||||
|
||||
- **Solution**: Make sure `.env.local` exists and has correct credentials
|
||||
|
||||
**Error**: "Bucket not found"
|
||||
|
||||
- **Solution**: Create buckets in Cloudflare Dashboard first
|
||||
|
||||
### Files Not Accessible
|
||||
|
||||
**Issue**: 403 Forbidden when accessing R2 URLs
|
||||
|
||||
- **Solution**: Enable public access in bucket settings
|
||||
|
||||
**Issue**: CORS errors
|
||||
|
||||
- **Solution**: Configure CORS policy in bucket settings
|
||||
|
||||
### Build Fails in Pages
|
||||
|
||||
**Error**: Build command fails
|
||||
|
||||
- **Solution**: Check build logs in Pages dashboard
|
||||
- Verify `package.json` has all dependencies
|
||||
- Ensure build output directory is correct (`code/out`)
|
||||
|
|
@ -282,7 +269,6 @@ Visit `http://localhost:3000` and verify manuals load from R2 URLs.
|
|||
### Manuals Not Loading
|
||||
|
||||
**Issue**: Manuals show 404
|
||||
|
||||
- **Solution**:
|
||||
1. Verify R2 buckets have files uploaded
|
||||
2. Check `NEXT_PUBLIC_MANUALS_BASE_URL` is set correctly
|
||||
|
|
@ -320,8 +306,9 @@ For large-scale migrations, consider:
|
|||
## Support
|
||||
|
||||
For issues or questions:
|
||||
|
||||
1. Check Cloudflare Dashboard for error messages
|
||||
2. Review build logs in Pages dashboard
|
||||
3. Check R2 bucket settings and permissions
|
||||
4. Verify environment variables are set correctly
|
||||
|
||||
|
||||
|
|
@ -114,7 +114,6 @@ Open the downloaded JSON file and extract:
|
|||
### Current Implementation
|
||||
|
||||
The API endpoints are set up at:
|
||||
|
||||
- `/api/sitemap-submit` - Submit sitemap to Google Search Console
|
||||
- `/api/request-indexing` - Request URL indexing
|
||||
|
||||
|
|
@ -127,7 +126,6 @@ npm install googleapis
|
|||
```
|
||||
|
||||
Then update the API route files to use the actual Google Search Console API. See the comments in:
|
||||
|
||||
- `code/app/api/sitemap-submit/route.ts`
|
||||
- `code/app/api/request-indexing/route.ts`
|
||||
|
||||
|
|
@ -160,7 +158,6 @@ You should see a valid XML sitemap with all your pages listed.
|
|||
Visit: `https://rockymountainvending.com/robots.txt`
|
||||
|
||||
You should see:
|
||||
|
||||
```
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
|
@ -277,7 +274,7 @@ Before deploying to production:
|
|||
## Support
|
||||
|
||||
For issues or questions:
|
||||
|
||||
- Check the troubleshooting section above
|
||||
- Review Google Search Console documentation
|
||||
- Contact your development team
|
||||
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
# Shadcn MCP Server Troubleshooting Guide
|
||||
|
||||
## Current Status
|
||||
|
||||
- ✅ MCP configuration file exists at: `code/.cursor/mcp.json` (original)
|
||||
- ✅ MCP configuration file created at: `.cursor/mcp.json` (workspace root) - **FIXED**
|
||||
- ✅ Configuration looks correct
|
||||
|
|
@ -11,7 +10,6 @@
|
|||
## Configuration Found
|
||||
|
||||
The MCP configuration file at `code/.cursor/mcp.json` contains:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
|
|
@ -26,7 +24,6 @@ The MCP configuration file at `code/.cursor/mcp.json` contains:
|
|||
## Troubleshooting Steps
|
||||
|
||||
### 1. ✅ FIXED: MCP Configuration Location
|
||||
|
||||
**Issue Found**: The MCP configuration was in `code/.cursor/mcp.json`, but Cursor looks for it at the workspace root.
|
||||
|
||||
**Fix Applied**: Configuration has been copied to `.cursor/mcp.json` at the workspace root.
|
||||
|
|
@ -34,46 +31,36 @@ The MCP configuration file at `code/.cursor/mcp.json` contains:
|
|||
**Next Step**: Restart Cursor completely to load the MCP server.
|
||||
|
||||
### 2. Restart Cursor
|
||||
|
||||
After any configuration changes:
|
||||
|
||||
1. Completely quit Cursor (not just close the window)
|
||||
2. Reopen Cursor
|
||||
3. Wait a few seconds for MCP servers to initialize
|
||||
|
||||
### 3. Check MCP Server Logs
|
||||
|
||||
1. Open Cursor
|
||||
2. Go to `View` → `Output`
|
||||
3. In the dropdown, select `MCP: project-*` or look for shadcn-related logs
|
||||
4. Check for any error messages
|
||||
|
||||
### 4. Test MCP Server Manually
|
||||
|
||||
Test if the shadcn MCP server can run independently:
|
||||
|
||||
```bash
|
||||
cd code
|
||||
npx shadcn@latest mcp
|
||||
```
|
||||
|
||||
If you have a GitHub token (for higher rate limits):
|
||||
|
||||
```bash
|
||||
npx shadcn@latest mcp --github-api-key YOUR_GITHUB_TOKEN
|
||||
```
|
||||
|
||||
### 5. Verify Project Structure
|
||||
|
||||
Ensure `components.json` exists and is properly configured:
|
||||
|
||||
- Location: `code/components.json`
|
||||
- Should reference the correct paths for your project
|
||||
|
||||
### 6. Check Node.js and npm
|
||||
|
||||
Ensure you have the required versions:
|
||||
|
||||
```bash
|
||||
node --version # Should be v18 or higher
|
||||
npm --version
|
||||
|
|
@ -81,9 +68,7 @@ npx --version
|
|||
```
|
||||
|
||||
### 7. Alternative Configuration
|
||||
|
||||
If the current configuration doesn't work, try specifying the full path:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
|
|
@ -97,16 +82,13 @@ If the current configuration doesn't work, try specifying the full path:
|
|||
```
|
||||
|
||||
### 8. Check Cursor Settings
|
||||
|
||||
1. Open Cursor Settings (Cmd+, on Mac)
|
||||
2. Search for "MCP" or "Model Context Protocol"
|
||||
3. Verify that MCP servers are enabled
|
||||
4. Check if there are any error indicators
|
||||
|
||||
## Expected Tools
|
||||
|
||||
When working correctly, the shadcn MCP server should provide these tools:
|
||||
|
||||
1. `get_project_registries` - List available component registries
|
||||
2. `list_items_in_registries` - List components in registries
|
||||
3. `search_items_in_registries` - Search for components
|
||||
|
|
@ -116,15 +98,12 @@ When working correctly, the shadcn MCP server should provide these tools:
|
|||
7. `get_audit_checklist` - Get component audit checklist
|
||||
|
||||
## Verification
|
||||
|
||||
To verify the MCP server is working:
|
||||
|
||||
1. Ask the AI assistant to use shadcn MCP tools
|
||||
2. The assistant should be able to call functions like `get_project_registries`
|
||||
3. If tools are not available, the server is not properly connected
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Try moving the MCP configuration to workspace root
|
||||
2. Restart Cursor completely
|
||||
3. Check MCP server logs for errors
|
||||
|
|
@ -132,6 +111,6 @@ To verify the MCP server is working:
|
|||
5. If issues persist, check the shadcn documentation: https://ui.shadcn.com/docs/mcp
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Shadcn MCP Documentation](https://ui.shadcn.com/docs/mcp)
|
||||
- [Cursor MCP Setup Guide](https://docs.cursor.com/context/model-context-protocol)
|
||||
|
||||
|
|
@ -15,13 +15,11 @@ The system supports three site tiers with different manufacturer counts:
|
|||
Set the `NEXT_PUBLIC_SITE_DOMAIN` environment variable to configure which site tier to use.
|
||||
|
||||
### For rockymountainvending.com (Tier 1)
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com
|
||||
```
|
||||
|
||||
**Manufacturers included:**
|
||||
|
||||
- Crane (includes BevMax, Merchant-Series)
|
||||
- Royal Vendors
|
||||
- GPL
|
||||
|
|
@ -34,13 +32,11 @@ NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com
|
|||
**Minimum manual count:** 3 per manufacturer
|
||||
|
||||
### For vending.support (Tier 2)
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_SITE_DOMAIN=vending.support
|
||||
```
|
||||
|
||||
**Manufacturers included:**
|
||||
|
||||
- All Tier 1 manufacturers, plus:
|
||||
- Merchant-Series
|
||||
- BevMax
|
||||
|
|
@ -53,13 +49,11 @@ NEXT_PUBLIC_SITE_DOMAIN=vending.support
|
|||
**Minimum manual count:** 2 per manufacturer
|
||||
|
||||
### For quickfreshvending.com (Tier 3)
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_SITE_DOMAIN=quickfreshvending.com
|
||||
```
|
||||
|
||||
**Manufacturers included:**
|
||||
|
||||
- ALL manufacturers including:
|
||||
- All from Tier 1 and Tier 2
|
||||
- Other folder (uncategorized)
|
||||
|
|
@ -70,17 +64,13 @@ NEXT_PUBLIC_SITE_DOMAIN=quickfreshvending.com
|
|||
## Configuration Files
|
||||
|
||||
### site-manufacturer-mapping.json
|
||||
|
||||
This file contains the complete mapping of manufacturers to each site tier. It's located at:
|
||||
|
||||
```
|
||||
code/lib/site-manufacturer-mapping.json
|
||||
```
|
||||
|
||||
### site-config.ts
|
||||
|
||||
This file provides helper functions to:
|
||||
|
||||
- Get the current site domain
|
||||
- Get allowed manufacturers for a site
|
||||
- Get minimum manual count
|
||||
|
|
@ -101,16 +91,12 @@ This file provides helper functions to:
|
|||
For each site deployment, set the appropriate environment variable:
|
||||
|
||||
### Vercel/Netlify
|
||||
|
||||
Add the environment variable in your deployment platform's settings:
|
||||
|
||||
- Key: `NEXT_PUBLIC_SITE_DOMAIN`
|
||||
- Value: `rockymountainvending.com`, `vending.support`, or `quickfreshvending.com`
|
||||
|
||||
### Local Development
|
||||
|
||||
Create a `.env.local` file in the `code` directory:
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com
|
||||
```
|
||||
|
|
@ -118,7 +104,6 @@ NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com
|
|||
## Manufacturer Aliases
|
||||
|
||||
The system automatically handles manufacturer name variations using aliases defined in `site-manufacturer-mapping.json`. For example:
|
||||
|
||||
- "Royal-Vendors" (directory name) → "Royal Vendors" (canonical)
|
||||
- "BevMax" → "Crane"
|
||||
- "Merchant-Series" → "Crane"
|
||||
|
|
@ -128,3 +113,6 @@ This ensures manuals are correctly matched regardless of how the manufacturer na
|
|||
## Payment Components
|
||||
|
||||
Payment components (Coin-Mechs, Bill-Mechs, Card-Readers) are included on all sites as they are universal and work with all vending machines.
|
||||
|
||||
|
||||
|
||||
|
|
@ -3,14 +3,12 @@
|
|||
## Color Palette
|
||||
|
||||
### Primary Colors
|
||||
|
||||
- **Background**: `#fff8eb` (Warm cream)
|
||||
- **Foreground**: Dark text color for body text
|
||||
- **Primary**: Purple/blue accent color
|
||||
- **Secondary**: Green accent color
|
||||
|
||||
### Link Colors
|
||||
|
||||
- **Link Default**: Standard foreground color
|
||||
- **Link Hover/Active**: `#c4142c` (Red - RGB: 196, 20, 44)
|
||||
- **Link Highlight**: Red background highlight on hover
|
||||
|
|
@ -18,12 +16,10 @@
|
|||
## Typography
|
||||
|
||||
### Fonts
|
||||
|
||||
- **Body Font**: Inter (with fallback)
|
||||
- **Mono Font**: Geist Mono (with fallback)
|
||||
|
||||
### Font Sizes
|
||||
|
||||
- Base: 16px
|
||||
- Small: 14px
|
||||
- Medium: 16px
|
||||
|
|
@ -32,7 +28,6 @@
|
|||
## Hyperlinks
|
||||
|
||||
### Standard Link Styling
|
||||
|
||||
All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these rules:
|
||||
|
||||
1. **Default State**:
|
||||
|
|
@ -50,7 +45,6 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
|
|||
- May include underline for emphasis
|
||||
|
||||
### Implementation
|
||||
|
||||
- Use CSS variable `--link-color` for default state
|
||||
- Use CSS variable `--link-hover-color` for hover/active states
|
||||
- Apply globally via `@layer base` in `globals.css`
|
||||
|
|
@ -58,25 +52,21 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
|
|||
## Components
|
||||
|
||||
### Navigation Links
|
||||
|
||||
- Follow standard link styling
|
||||
- Hover state changes to red
|
||||
- Smooth transitions
|
||||
|
||||
### Footer Links
|
||||
|
||||
- Follow standard link styling
|
||||
- May include underline on hover
|
||||
|
||||
### Button Links
|
||||
|
||||
- Use button component styling
|
||||
- May override link colors for consistency
|
||||
|
||||
## Spacing
|
||||
|
||||
### Standard Spacing Scale
|
||||
|
||||
- XS: 0.5rem (8px)
|
||||
- SM: 1rem (16px)
|
||||
- MD: 1.5rem (24px)
|
||||
|
|
@ -94,13 +84,11 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
|
|||
## Layout & Containers
|
||||
|
||||
### Page Containers
|
||||
|
||||
- **Standard pages**: `container mx-auto px-4 py-8 md:py-12 max-w-4xl`
|
||||
- **Wide pages**: `container mx-auto px-4 py-8 md:py-12 max-w-6xl`
|
||||
- **Full-width pages**: `container mx-auto px-4 py-8 md:py-12` (no max-width)
|
||||
|
||||
### Section Containers
|
||||
|
||||
- **Standard sections**: `py-20 md:py-28` with optional `bg-muted/30` for alternating backgrounds
|
||||
- **Hero sections**: `py-20 md:py-32`
|
||||
- **Container wrapper**: Always use `container mx-auto px-4` inside sections
|
||||
|
|
@ -110,23 +98,19 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
|
|||
### Headings
|
||||
|
||||
#### H1 (Page Titles)
|
||||
|
||||
- **Classes**: `text-4xl md:text-5xl font-bold mb-4` (or `mb-6` for more spacing)
|
||||
- **Usage**: Only one H1 per page (page title)
|
||||
- **Example**: `<h1 className="text-4xl md:text-5xl font-bold mb-4">Page Title</h1>`
|
||||
|
||||
#### H2 (Section Headers)
|
||||
|
||||
- **Section headers** (centered): `text-3xl font-bold tracking-tight md:text-4xl lg:text-5xl mb-4 text-balance`
|
||||
- **Page headers** (left-aligned): `text-2xl md:text-3xl font-semibold mb-6`
|
||||
- **Header wrapper**: `text-center mb-12 md:mb-16` for section headers
|
||||
|
||||
#### H3
|
||||
|
||||
- **Standard**: `text-xl font-semibold mb-2` or `mb-3`
|
||||
|
||||
### Paragraphs
|
||||
|
||||
- **Standard text**: `text-lg text-muted-foreground text-pretty leading-relaxed`
|
||||
- **Centered text**: Add `max-w-2xl mx-auto` for centered paragraphs
|
||||
- **Small text**: `text-sm text-muted-foreground`
|
||||
|
|
@ -134,14 +118,12 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
|
|||
## Cards
|
||||
|
||||
### Standard Card Styling
|
||||
|
||||
- **Border**: `border-border/50` (preferred) or `border-2` for emphasis
|
||||
- **Hover states**: `hover:border-secondary/50 transition-colors`
|
||||
- **Shadow**: `shadow-lg` or `shadow-md` as needed
|
||||
- **Padding**: `p-6` or `p-6 md:p-8` for CardContent
|
||||
|
||||
### Card Examples
|
||||
|
||||
```tsx
|
||||
// Standard card
|
||||
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
|
||||
|
|
@ -163,7 +145,6 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
|
|||
### Image Sizing Standards
|
||||
|
||||
#### Maximum Dimensions
|
||||
|
||||
- **Grid/Card images**: Maximum 300px per dimension
|
||||
- **Full-width images**: Maximum 600px per dimension
|
||||
- **Always preserve aspect ratio** when constraining
|
||||
|
|
@ -171,7 +152,6 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
|
|||
#### Image Layout Patterns
|
||||
|
||||
**Single Image (Centered)**
|
||||
|
||||
```tsx
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="relative w-full overflow-hidden rounded-lg bg-muted shadow-sm">
|
||||
|
|
@ -187,7 +167,6 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
|
|||
```
|
||||
|
||||
**Grid Images (2-4 images)**
|
||||
|
||||
```tsx
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{images.map((img) => (
|
||||
|
|
@ -207,18 +186,23 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
|
|||
```
|
||||
|
||||
**Card Images (Aspect Ratio)**
|
||||
|
||||
```tsx
|
||||
<Card>
|
||||
<div className="aspect-video relative bg-muted">
|
||||
<Image src={src} alt={alt} fill className="object-cover" />
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<CardContent>{/* content */}</CardContent>
|
||||
<CardContent>
|
||||
{/* content */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
### Image Requirements
|
||||
|
||||
1. **Always constrain large images**: Use `Math.min(width, MAX)` pattern
|
||||
2. **Add max-width constraints**: Use `max-w-xs` (320px), `max-w-md` (448px), or `max-w-lg` (512px)
|
||||
3. **Use proper object-fit**: `object-contain` for preserving aspect ratio, `object-cover` for filling containers
|
||||
|
|
@ -226,7 +210,6 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
|
|||
5. **Wrapper classes**: `relative w-full overflow-hidden rounded-lg bg-muted shadow-sm`
|
||||
|
||||
### Image Size Constraints by Context
|
||||
|
||||
- **In grid layouts**: Max 300px per dimension
|
||||
- **In cards**: Use `aspect-video` or `aspect-square` with `fill` prop
|
||||
- **Standalone images**: Max 600px per dimension with `max-w-md mx-auto` wrapper
|
||||
|
|
@ -234,13 +217,11 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
|
|||
## Spacing Standards
|
||||
|
||||
### Section Spacing
|
||||
|
||||
- **Section padding**: `py-20 md:py-28` (standard sections)
|
||||
- **Section margin bottom**: `mb-12 md:mb-16` for section headers
|
||||
- **Page padding**: `py-8 md:py-12` (page containers)
|
||||
|
||||
### Element Spacing
|
||||
|
||||
- **Header margin**: `mb-12 md:mb-16` for section headers, `mb-8` for page headers
|
||||
- **Card gaps**: `gap-6` or `gap-8` in grid layouts
|
||||
- **Paragraph spacing**: `mb-4` or `mb-6` between paragraphs
|
||||
|
|
@ -260,7 +241,6 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
|
|||
A formatting standardization script is available at `scripts/standardize-formatting.py` that automatically applies these standards across all TSX files.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Test mode (dry run)
|
||||
python3 scripts/standardize-formatting.py --dry-run
|
||||
|
|
@ -273,7 +253,6 @@ python3 scripts/standardize-formatting.py
|
|||
```
|
||||
|
||||
The script will:
|
||||
|
||||
- Standardize container classes and spacing
|
||||
- Fix typography classes (H1, H2, paragraphs)
|
||||
- Standardize card borders and shadows
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,39 +1,58 @@
|
|||
import { notFound } from "next/navigation"
|
||||
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
|
||||
import { getPageBySlug } from "@/lib/wordpress-data-loader"
|
||||
import { AboutPage } from "@/components/about-page"
|
||||
import type { Metadata } from "next"
|
||||
import { notFound } from 'next/navigation';
|
||||
import { loadImageMapping } from '@/lib/wordpress-content';
|
||||
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo';
|
||||
import { getPageBySlug } from '@/lib/wordpress-data-loader';
|
||||
import { AboutPage } from '@/components/about-page';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
const WORDPRESS_SLUG = "about-us"
|
||||
const WORDPRESS_SLUG = 'about-us';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const page = getPageBySlug(WORDPRESS_SLUG)
|
||||
const page = getPageBySlug(WORDPRESS_SLUG);
|
||||
|
||||
if (!page) {
|
||||
return {
|
||||
title: "Page Not Found | Rocky Mountain Vending",
|
||||
}
|
||||
title: 'Page Not Found | Rocky Mountain Vending',
|
||||
};
|
||||
}
|
||||
|
||||
return generateRegistryMetadata("aboutUs", {
|
||||
return generateSEOMetadata({
|
||||
title: page.title || 'About Us',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
excerpt: page.excerpt,
|
||||
date: page.date,
|
||||
modified: page.modified,
|
||||
image: page.images?.[0]?.localPath,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export default async function AboutUsPage() {
|
||||
try {
|
||||
const page = getPageBySlug(WORDPRESS_SLUG)
|
||||
const page = getPageBySlug(WORDPRESS_SLUG);
|
||||
|
||||
if (!page) {
|
||||
notFound()
|
||||
notFound();
|
||||
}
|
||||
|
||||
const structuredData = generateRegistryStructuredData("aboutUs", {
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
})
|
||||
let structuredData;
|
||||
try {
|
||||
structuredData = generateStructuredData({
|
||||
title: page.title || 'About Us',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
url: page.link || page.urlPath || `https://rockymountainvending.com/about-us/`,
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
type: 'WebPage',
|
||||
});
|
||||
} catch (e) {
|
||||
structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
headline: page.title || 'About Us',
|
||||
description: page.seoDescription || '',
|
||||
url: `https://rockymountainvending.com/about-us/`,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -43,11 +62,19 @@ export default async function AboutUsPage() {
|
|||
/>
|
||||
<AboutPage />
|
||||
</>
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error("Error rendering About Us page:", error)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error rendering About Us page:', error);
|
||||
}
|
||||
notFound()
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,74 +1,83 @@
|
|||
import { notFound } from "next/navigation"
|
||||
import { buildAbsoluteUrl } from "@/lib/seo-registry"
|
||||
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
|
||||
import { Breadcrumbs } from "@/components/breadcrumbs"
|
||||
import { getPageBySlug } from "@/lib/wordpress-data-loader"
|
||||
import { FAQSchema } from "@/components/faq-schema"
|
||||
import { FAQSection } from "@/components/faq-section"
|
||||
import type { Metadata } from "next"
|
||||
import { notFound } from 'next/navigation';
|
||||
import { loadImageMapping } from '@/lib/wordpress-content';
|
||||
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo';
|
||||
import { getPageBySlug } from '@/lib/wordpress-data-loader';
|
||||
import { FAQSchema } from '@/components/faq-schema';
|
||||
import { FAQSection } from '@/components/faq-section';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
const WORDPRESS_SLUG = "faqs"
|
||||
const WORDPRESS_SLUG = 'faqs';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const page = getPageBySlug(WORDPRESS_SLUG)
|
||||
const page = getPageBySlug(WORDPRESS_SLUG);
|
||||
|
||||
if (!page) {
|
||||
return {
|
||||
title: "Page Not Found | Rocky Mountain Vending",
|
||||
}
|
||||
title: 'Page Not Found | Rocky Mountain Vending',
|
||||
};
|
||||
}
|
||||
|
||||
return generateRegistryMetadata("faqs", {
|
||||
return generateSEOMetadata({
|
||||
title: page.title || 'FAQs',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
excerpt: page.excerpt,
|
||||
date: page.date,
|
||||
modified: page.modified,
|
||||
image: page.images?.[0]?.localPath,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export default async function FAQsPage() {
|
||||
try {
|
||||
const page = getPageBySlug(WORDPRESS_SLUG)
|
||||
const page = getPageBySlug(WORDPRESS_SLUG);
|
||||
|
||||
if (!page) {
|
||||
notFound()
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Extract FAQs from content
|
||||
const faqs: Array<{ question: string; answer: string }> = []
|
||||
const faqs: Array<{ question: string; answer: string }> = [];
|
||||
if (page.content) {
|
||||
const contentStr = String(page.content)
|
||||
const contentStr = String(page.content);
|
||||
// Extract FAQ items from accordion structure
|
||||
const questionMatches = contentStr.matchAll(
|
||||
/<span class="ekit-accordion-title">([^<]+)<\/span>/g
|
||||
)
|
||||
const questionMatches = contentStr.matchAll(/<span class="ekit-accordion-title">([^<]+)<\/span>/g);
|
||||
// Extract full answer content
|
||||
const answerMatches = contentStr.matchAll(
|
||||
/<div class="elementskit-card-body ekit-accordion--content">([\s\S]*?)<\/div>\s*<\/div>\s*<!-- \.elementskit-card END -->/g
|
||||
)
|
||||
const answerMatches = contentStr.matchAll(/<div class="elementskit-card-body ekit-accordion--content">([\s\S]*?)<\/div>\s*<\/div>\s*<!-- \.elementskit-card END -->/g);
|
||||
|
||||
const questions = Array.from(questionMatches).map((m) => m[1].trim())
|
||||
const answers = Array.from(answerMatches).map((m) => {
|
||||
let answer = m[1].trim()
|
||||
answer = answer
|
||||
.replace(/\n\s*\n/g, "\n")
|
||||
.replace(/>\s+</g, "><")
|
||||
.trim()
|
||||
return answer
|
||||
})
|
||||
const questions = Array.from(questionMatches).map(m => m[1].trim());
|
||||
const answers = Array.from(answerMatches).map(m => {
|
||||
let answer = m[1].trim();
|
||||
answer = answer.replace(/\n\s*\n/g, '\n').replace(/>\s+</g, '><').trim();
|
||||
return answer;
|
||||
});
|
||||
|
||||
// Match questions with answers
|
||||
questions.forEach((question, index) => {
|
||||
if (answers[index]) {
|
||||
faqs.push({ question, answer: answers[index] })
|
||||
faqs.push({ question, answer: answers[index] });
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
const pageUrl = buildAbsoluteUrl("/about/faqs")
|
||||
const structuredData = generateRegistryStructuredData("faqs", {
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
})
|
||||
let structuredData;
|
||||
try {
|
||||
structuredData = generateStructuredData({
|
||||
title: page.title || 'FAQs',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
url: page.link || page.urlPath || `https://rockymountainvending.com/about/faqs/`,
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
type: 'WebPage',
|
||||
});
|
||||
} catch (e) {
|
||||
structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
headline: page.title || 'FAQs',
|
||||
description: page.seoDescription || '',
|
||||
url: `https://rockymountainvending.com/about/faqs/`,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -77,27 +86,28 @@ export default async function FAQsPage() {
|
|||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
{faqs.length > 0 && (
|
||||
<div className="public-page">
|
||||
<Breadcrumbs
|
||||
className="mb-6"
|
||||
items={[
|
||||
{ label: "About", href: "/about" },
|
||||
{ label: "FAQs", href: "/about/faqs" },
|
||||
]}
|
||||
/>
|
||||
<>
|
||||
<FAQSchema
|
||||
faqs={faqs}
|
||||
pageUrl={pageUrl}
|
||||
pageUrl={page.link || page.urlPath || `https://rockymountainvending.com/about/faqs/`}
|
||||
/>
|
||||
<FAQSection faqs={faqs} className="pt-0" />
|
||||
</div>
|
||||
<FAQSection faqs={faqs} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error("Error rendering FAQs page:", error)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error rendering FAQs page:', error);
|
||||
}
|
||||
notFound()
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +1,21 @@
|
|||
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
|
||||
import { AboutPage } from "@/components/about-page"
|
||||
import type { Metadata } from "next"
|
||||
import { businessConfig } from "@/lib/seo-config"
|
||||
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo';
|
||||
import { AboutPage } from '@/components/about-page';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
...generateRegistryMetadata("aboutLegacy"),
|
||||
alternates: {
|
||||
canonical: `${businessConfig.website}/about-us`,
|
||||
},
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
},
|
||||
}
|
||||
return generateSEOMetadata({
|
||||
title: 'About Us | Rocky Mountain Vending',
|
||||
description: 'Learn more about Rocky Mountain Vending, a family-owned business dedicated to providing exceptional vending services across Utah',
|
||||
});
|
||||
}
|
||||
|
||||
export default function About() {
|
||||
const structuredData = generateRegistryStructuredData("aboutUs")
|
||||
const structuredData = generateStructuredData({
|
||||
title: 'About Us',
|
||||
description: 'Learn more about Rocky Mountain Vending, a family-owned business dedicated to providing exceptional vending services across Utah',
|
||||
url: 'https://rockymountainvending.com/about/',
|
||||
type: 'WebPage',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -27,5 +25,5 @@ export default function About() {
|
|||
/>
|
||||
<AboutPage />
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +1,31 @@
|
|||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { ArrowLeft, ExternalLink, Phone } from "lucide-react"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { fetchQuery } from "convex/nextjs";
|
||||
import { ArrowLeft, ExternalLink, Phone } from "lucide-react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
formatPhoneCallDuration,
|
||||
formatPhoneCallTimestamp,
|
||||
normalizePhoneFromIdentity,
|
||||
} from "@/lib/phone-calls"
|
||||
} from "@/lib/phone-calls";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function AdminCallDetailPage({ params }: PageProps) {
|
||||
const { id } = await params
|
||||
const { id } = await params;
|
||||
const detail = await fetchQuery(api.voiceSessions.getAdminPhoneCallDetail, {
|
||||
callId: id,
|
||||
})
|
||||
});
|
||||
|
||||
if (!detail) {
|
||||
notFound()
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -39,20 +33,13 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
|||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
href="/admin/calls"
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Link href="/admin/calls" className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to calls
|
||||
</Link>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-balance">
|
||||
Phone Call Detail
|
||||
</h1>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-balance">Phone Call Detail</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{detail.call.contactDisplayName ||
|
||||
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
|
||||
detail.call.participantIdentity}
|
||||
{normalizePhoneFromIdentity(detail.call.participantIdentity) || detail.call.participantIdentity}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -64,159 +51,58 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
|||
<Phone className="h-5 w-5" />
|
||||
Call Status
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Operational detail for this direct phone session.
|
||||
</CardDescription>
|
||||
<CardDescription>Operational detail for this direct phone session.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Started
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{formatPhoneCallTimestamp(detail.call.startedAt)}
|
||||
</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Started</p>
|
||||
<p className="font-medium">{formatPhoneCallTimestamp(detail.call.startedAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Room
|
||||
</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Room</p>
|
||||
<p className="font-medium break-all">{detail.call.roomName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Duration
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{formatPhoneCallDuration(detail.call.durationMs)}
|
||||
</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Duration</p>
|
||||
<p className="font-medium">{formatPhoneCallDuration(detail.call.durationMs)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Participant Identity
|
||||
</p>
|
||||
<p className="font-medium break-all">
|
||||
{detail.call.participantIdentity || "Unknown"}
|
||||
</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Participant Identity</p>
|
||||
<p className="font-medium break-all">{detail.call.participantIdentity || "Unknown"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Caller Phone
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{detail.call.callerPhone ||
|
||||
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
|
||||
"Unknown"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Company
|
||||
</p>
|
||||
<p className="font-medium">{detail.call.contactCompany || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Call Status
|
||||
</p>
|
||||
<Badge
|
||||
className="mt-1"
|
||||
variant={
|
||||
detail.call.callStatus === "failed"
|
||||
? "destructive"
|
||||
: detail.call.callStatus === "started"
|
||||
? "secondary"
|
||||
: "default"
|
||||
}
|
||||
>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Call Status</p>
|
||||
<Badge className="mt-1" variant={detail.call.callStatus === "failed" ? "destructive" : detail.call.callStatus === "started" ? "secondary" : "default"}>
|
||||
{detail.call.callStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Jessica Answered
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{detail.call.answered ? "Yes" : "No"}
|
||||
</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Jessica Answered</p>
|
||||
<p className="font-medium">{detail.call.answered ? "Yes" : "No"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Lead Outcome
|
||||
</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Lead Outcome</p>
|
||||
<p className="font-medium">{detail.call.leadOutcome}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Email Summary
|
||||
</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Email Summary</p>
|
||||
<p className="font-medium">{detail.call.notificationStatus}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Reminder
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{detail.call.reminderStatus || "none"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Warm Transfer
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{detail.call.warmTransferStatus || "none"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Summary
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-wrap">
|
||||
{detail.call.summaryText || "No summary available yet."}
|
||||
</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Summary</p>
|
||||
<p className="text-sm whitespace-pre-wrap">{detail.call.summaryText || "No summary available yet."}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Recording Status
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{detail.call.recordingStatus || "Unavailable"}
|
||||
</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Recording Status</p>
|
||||
<p className="font-medium">{detail.call.recordingStatus || "Unavailable"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Transcript Turns
|
||||
</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Transcript Turns</p>
|
||||
<p className="font-medium">{detail.call.transcriptTurnCount}</p>
|
||||
</div>
|
||||
{detail.call.reminderStartAt ? (
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Reminder Time
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{formatPhoneCallTimestamp(detail.call.reminderStartAt)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{detail.call.warmTransferFailureReason ? (
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Transfer Detail
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{detail.call.warmTransferFailureReason}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{detail.call.recordingUrl ? (
|
||||
<div className="md:col-span-2">
|
||||
<Link
|
||||
href={detail.call.recordingUrl}
|
||||
target="_blank"
|
||||
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
<Link href={detail.call.recordingUrl} target="_blank" className="inline-flex items-center gap-2 text-sm text-primary hover:underline">
|
||||
Open recording
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
|
|
@ -224,24 +110,8 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
|||
) : null}
|
||||
{detail.call.notificationError ? (
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Email Error
|
||||
</p>
|
||||
<p className="text-sm text-destructive">
|
||||
{detail.call.notificationError}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{detail.call.reminderCalendarHtmlLink ? (
|
||||
<div className="md:col-span-2">
|
||||
<Link
|
||||
href={detail.call.reminderCalendarHtmlLink}
|
||||
target="_blank"
|
||||
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
Open reminder
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Email Error</p>
|
||||
<p className="text-sm text-destructive">{detail.call.notificationError}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
|
@ -251,74 +121,38 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
|||
<CardHeader>
|
||||
<CardTitle>Linked Lead</CardTitle>
|
||||
<CardDescription>
|
||||
{detail.linkedLead
|
||||
? "Lead created from this phone call."
|
||||
: "No lead was created from this call."}
|
||||
{detail.linkedLead ? "Lead created from this phone call." : "No lead was created from this call."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{detail.linkedLead ? (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Contact
|
||||
</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Contact</p>
|
||||
<p className="font-medium">
|
||||
{detail.linkedLead.firstName} {detail.linkedLead.lastName}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Lead Type
|
||||
</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Lead Type</p>
|
||||
<p className="font-medium">{detail.linkedLead.type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Email
|
||||
</p>
|
||||
<p className="font-medium break-all">
|
||||
{detail.linkedLead.email}
|
||||
</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Email</p>
|
||||
<p className="font-medium break-all">{detail.linkedLead.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Phone
|
||||
</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Phone</p>
|
||||
<p className="font-medium">{detail.linkedLead.phone}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Message
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-wrap">
|
||||
{detail.linkedLead.message || "—"}
|
||||
</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Message</p>
|
||||
<p className="text-sm whitespace-pre-wrap">{detail.linkedLead.message || "—"}</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Jessica handled the call, but it did not result in a submitted
|
||||
lead.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Jessica handled the call, but it did not result in a submitted lead.</p>
|
||||
)}
|
||||
{detail.contactProfile ? (
|
||||
<div className="border-t pt-3">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Contact Profile
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{detail.contactProfile.displayName ||
|
||||
[detail.contactProfile.firstName, detail.contactProfile.lastName]
|
||||
.filter(Boolean)
|
||||
.join(" ") ||
|
||||
"Known caller"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{detail.contactProfile.company || detail.contactProfile.email || "No company or email yet"}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
@ -326,15 +160,11 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
|||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Transcript</CardTitle>
|
||||
<CardDescription>
|
||||
Complete mirrored transcript for this phone call.
|
||||
</CardDescription>
|
||||
<CardDescription>Complete mirrored transcript for this phone call.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{detail.turns.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No transcript turns were captured for this call.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">No transcript turns were captured for this call.</p>
|
||||
) : (
|
||||
detail.turns.map((turn: any) => (
|
||||
<div key={turn.id} className="rounded-lg border p-3">
|
||||
|
|
@ -350,11 +180,10 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
|||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: "Phone Call Detail | Admin",
|
||||
description:
|
||||
"Review a mirrored direct phone call transcript and linked lead details",
|
||||
}
|
||||
description: "Review a mirrored direct phone call transcript and linked lead details",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,67 +1,58 @@
|
|||
import Link from "next/link"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { Phone, Search } from "lucide-react"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import Link from "next/link";
|
||||
import { fetchQuery } from "convex/nextjs";
|
||||
import { Phone, Search } from "lucide-react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
formatPhoneCallDuration,
|
||||
formatPhoneCallTimestamp,
|
||||
normalizePhoneFromIdentity,
|
||||
} from "@/lib/phone-calls"
|
||||
} from "@/lib/phone-calls";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{
|
||||
search?: string
|
||||
status?: "started" | "completed" | "failed"
|
||||
page?: string
|
||||
}>
|
||||
}
|
||||
search?: string;
|
||||
status?: "started" | "completed" | "failed";
|
||||
page?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function getStatusVariant(status: "started" | "completed" | "failed") {
|
||||
if (status === "failed") {
|
||||
return "destructive" as const
|
||||
return "destructive" as const;
|
||||
}
|
||||
|
||||
if (status === "started") {
|
||||
return "secondary" as const
|
||||
return "secondary" as const;
|
||||
}
|
||||
|
||||
return "default" as const
|
||||
return "default" as const;
|
||||
}
|
||||
|
||||
export default async function AdminCallsPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams
|
||||
const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1)
|
||||
const status = params.status
|
||||
const search = params.search?.trim() || undefined
|
||||
const params = await searchParams;
|
||||
const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1);
|
||||
const status = params.status;
|
||||
const search = params.search?.trim() || undefined;
|
||||
|
||||
const data = await fetchQuery(api.voiceSessions.listAdminPhoneCalls, {
|
||||
search,
|
||||
status,
|
||||
page,
|
||||
limit: 25,
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-balance">
|
||||
Phone Calls
|
||||
</h1>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-balance">Phone Calls</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Every direct LiveKit phone call mirrored into RMV admin, including
|
||||
partial and non-lead calls.
|
||||
Every direct LiveKit phone call mirrored into RMV admin, including partial and non-lead calls.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin">
|
||||
|
|
@ -75,20 +66,13 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
|||
<Phone className="h-5 w-5" />
|
||||
Call Inbox
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Search by caller number, room, summary, or linked lead ID.
|
||||
</CardDescription>
|
||||
<CardDescription>Search by caller number, room, summary, or linked lead ID.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form className="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px_auto]">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
name="search"
|
||||
defaultValue={search || ""}
|
||||
placeholder="Search calls"
|
||||
className="pl-9"
|
||||
/>
|
||||
<Input name="search" defaultValue={search || ""} placeholder="Search calls" className="pl-9" />
|
||||
</div>
|
||||
<select
|
||||
name="status"
|
||||
|
|
@ -104,7 +88,7 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
|||
</form>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1240px] text-sm">
|
||||
<table className="w-full min-w-[1050px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-3 pr-4 font-medium">Caller</th>
|
||||
|
|
@ -116,8 +100,6 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
|||
<th className="py-3 pr-4 font-medium">Recording</th>
|
||||
<th className="py-3 pr-4 font-medium">Lead</th>
|
||||
<th className="py-3 pr-4 font-medium">Email</th>
|
||||
<th className="py-3 pr-4 font-medium">Reminder</th>
|
||||
<th className="py-3 pr-4 font-medium">Transfer</th>
|
||||
<th className="py-3 pr-4 font-medium">Summary</th>
|
||||
<th className="py-3 font-medium">Open</th>
|
||||
</tr>
|
||||
|
|
@ -125,81 +107,35 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
|||
<tbody>
|
||||
{data.items.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={13}
|
||||
className="py-8 text-center text-muted-foreground"
|
||||
>
|
||||
<td colSpan={11} className="py-8 text-center text-muted-foreground">
|
||||
No phone calls matched this filter.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.items.map((call: any) => (
|
||||
<tr
|
||||
key={call.id}
|
||||
className="border-b align-top last:border-b-0"
|
||||
>
|
||||
<tr key={call.id} className="border-b align-top last:border-b-0">
|
||||
<td className="py-3 pr-4 font-medium">
|
||||
<div>
|
||||
{call.contactDisplayName ||
|
||||
normalizePhoneFromIdentity(
|
||||
call.participantIdentity
|
||||
) ||
|
||||
call.participantIdentity}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{call.contactCompany ||
|
||||
normalizePhoneFromIdentity(
|
||||
call.participantIdentity
|
||||
) ||
|
||||
call.roomName}
|
||||
</div>
|
||||
<div>{normalizePhoneFromIdentity(call.participantIdentity) || call.participantIdentity}</div>
|
||||
<div className="text-xs text-muted-foreground">{call.roomName}</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4">{formatPhoneCallTimestamp(call.startedAt)}</td>
|
||||
<td className="py-3 pr-4">{formatPhoneCallDuration(call.durationMs)}</td>
|
||||
<td className="py-3 pr-4">
|
||||
{formatPhoneCallTimestamp(call.startedAt)}
|
||||
<Badge variant={getStatusVariant(call.callStatus)}>{call.callStatus}</Badge>
|
||||
</td>
|
||||
<td className="py-3 pr-4">{call.answered ? "Yes" : "No"}</td>
|
||||
<td className="py-3 pr-4">
|
||||
{formatPhoneCallDuration(call.durationMs)}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<Badge variant={getStatusVariant(call.callStatus)}>
|
||||
{call.callStatus}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
{call.answered ? "Yes" : "No"}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
{call.transcriptTurnCount > 0
|
||||
? `${call.transcriptTurnCount} turns`
|
||||
: "No transcript"}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
{call.recordingStatus || "Unavailable"}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
{call.leadOutcome === "none" ? "—" : call.leadOutcome}
|
||||
{call.transcriptTurnCount > 0 ? `${call.transcriptTurnCount} turns` : "No transcript"}
|
||||
</td>
|
||||
<td className="py-3 pr-4">{call.recordingStatus || "Unavailable"}</td>
|
||||
<td className="py-3 pr-4">{call.leadOutcome === "none" ? "—" : call.leadOutcome}</td>
|
||||
<td className="py-3 pr-4">{call.notificationStatus}</td>
|
||||
<td className="py-3 pr-4">
|
||||
{call.reminderStatus === "none"
|
||||
? "—"
|
||||
: call.reminderStatus}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
{call.warmTransferStatus === "none"
|
||||
? "—"
|
||||
: call.warmTransferStatus}
|
||||
</td>
|
||||
<td className="max-w-[320px] py-3 pr-4 text-muted-foreground">
|
||||
<span className="line-clamp-2">
|
||||
{call.summaryText || "No summary yet"}
|
||||
</span>
|
||||
<span className="line-clamp-2">{call.summaryText || "No summary yet"}</span>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<Link href={`/admin/calls/${call.id}`}>
|
||||
<Button size="sm" variant="outline">
|
||||
View
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">View</Button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -211,8 +147,7 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
|||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing page {data.pagination.page} of{" "}
|
||||
{data.pagination.totalPages} ({data.pagination.total} calls)
|
||||
Showing page {data.pagination.page} of {data.pagination.totalPages} ({data.pagination.total} calls)
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{data.pagination.page > 1 ? (
|
||||
|
|
@ -223,9 +158,7 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
|||
page: String(data.pagination.page - 1),
|
||||
}).toString()}`}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
Previous
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">Previous</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
{data.pagination.page < data.pagination.totalPages ? (
|
||||
|
|
@ -236,9 +169,7 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
|||
page: String(data.pagination.page + 1),
|
||||
}).toString()}`}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
Next
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">Next</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -247,10 +178,10 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
|||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: "Phone Calls | Admin",
|
||||
description: "View direct phone calls, transcript history, and lead outcomes",
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
getAdminUserFromCookies,
|
||||
isAdminUiEnabled,
|
||||
} from "@/lib/server/admin-auth"
|
||||
import { redirect } from "next/navigation";
|
||||
import { isAdminUiEnabled } from "@/lib/server/admin-auth";
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
|
|
@ -11,32 +7,8 @@ export default async function AdminLayout({
|
|||
children: React.ReactNode
|
||||
}) {
|
||||
if (!isAdminUiEnabled()) {
|
||||
redirect("/")
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
const adminUser = await getAdminUserFromCookies()
|
||||
if (!adminUser) {
|
||||
redirect("/sign-in")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/30">
|
||||
<div className="border-b bg-background">
|
||||
<div className="container mx-auto flex items-center justify-between px-4 py-3 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin" className="font-semibold hover:text-primary">
|
||||
Rocky Admin
|
||||
</Link>
|
||||
<span className="text-muted-foreground">{adminUser.email}</span>
|
||||
</div>
|
||||
<form action="/api/admin/auth/logout" method="post">
|
||||
<button className="text-muted-foreground hover:text-foreground">
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { OrderManagement } from "@/components/order-management"
|
||||
import { OrderManagement } from '@/components/order-management'
|
||||
|
||||
export default function AdminOrdersPage() {
|
||||
return (
|
||||
|
|
@ -9,6 +9,6 @@ export default function AdminOrdersPage() {
|
|||
}
|
||||
|
||||
export const metadata = {
|
||||
title: "Order Management | Admin",
|
||||
description: "View and manage customer orders",
|
||||
title: 'Order Management | Admin',
|
||||
description: 'View and manage customer orders',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,7 @@
|
|||
import Link from "next/link"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
ShoppingCart,
|
||||
Package,
|
||||
|
|
@ -22,11 +14,9 @@ import {
|
|||
AlertTriangle,
|
||||
Settings,
|
||||
BarChart3,
|
||||
Phone,
|
||||
MessageSquare,
|
||||
ContactRound,
|
||||
} from "lucide-react"
|
||||
import { fetchAllProducts } from "@/lib/stripe/products"
|
||||
Phone
|
||||
} from 'lucide-react'
|
||||
import { fetchAllProducts } from '@/lib/stripe/products'
|
||||
|
||||
// Mock analytics data for demo
|
||||
const mockAnalytics = {
|
||||
|
|
@ -37,7 +27,7 @@ const mockAnalytics = {
|
|||
lowStockProducts: 3,
|
||||
avgOrderValue: 311.46,
|
||||
conversionRate: 2.8,
|
||||
monthlyGrowth: 15.2,
|
||||
monthlyGrowth: 15.2
|
||||
}
|
||||
|
||||
async function getProductsCount() {
|
||||
|
|
@ -51,7 +41,7 @@ async function getProductsCount() {
|
|||
|
||||
async function getOrdersCount() {
|
||||
try {
|
||||
const response = await fetch("/api/orders")
|
||||
const response = await fetch('/api/orders')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
return data.pagination.total || 0
|
||||
|
|
@ -60,145 +50,130 @@ async function getOrdersCount() {
|
|||
return mockAnalytics.totalOrders
|
||||
}
|
||||
|
||||
function formatTimestamp(value?: number | null) {
|
||||
if (!value) {
|
||||
return "—"
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
export default async function AdminDashboard() {
|
||||
const [productsCount, ordersCount, sync] = await Promise.all([
|
||||
const [productsCount, ordersCount] = await Promise.all([
|
||||
getProductsCount(),
|
||||
getOrdersCount(),
|
||||
fetchQuery(api.crm.getAdminSyncOverview, {}),
|
||||
getOrdersCount()
|
||||
])
|
||||
|
||||
const dashboardCards = [
|
||||
{
|
||||
title: "Total Revenue",
|
||||
title: 'Total Revenue',
|
||||
value: `$${mockAnalytics.totalRevenue.toLocaleString()}`,
|
||||
description: "Total revenue from all orders",
|
||||
description: 'Total revenue from all orders',
|
||||
icon: DollarSign,
|
||||
trend: "+15.2%",
|
||||
trend: '+15.2%',
|
||||
trendPositive: true,
|
||||
color: "text-green-600",
|
||||
color: 'text-green-600'
|
||||
},
|
||||
{
|
||||
title: "Total Orders",
|
||||
title: 'Total Orders',
|
||||
value: mockAnalytics.totalOrders.toString(),
|
||||
description: "Total number of orders",
|
||||
description: 'Total number of orders',
|
||||
icon: ShoppingCart,
|
||||
trend: "+12.8%",
|
||||
trend: '+12.8%',
|
||||
trendPositive: true,
|
||||
color: "text-blue-600",
|
||||
color: 'text-blue-600'
|
||||
},
|
||||
{
|
||||
title: "Products",
|
||||
title: 'Products',
|
||||
value: productsCount.toString(),
|
||||
description: "Active products in inventory",
|
||||
description: 'Active products in inventory',
|
||||
icon: Package,
|
||||
trend: "+5",
|
||||
trend: '+5',
|
||||
trendPositive: true,
|
||||
color: "text-purple-600",
|
||||
color: 'text-purple-600'
|
||||
},
|
||||
{
|
||||
title: "Pending Orders",
|
||||
title: 'Pending Orders',
|
||||
value: mockAnalytics.pendingOrders.toString(),
|
||||
description: "Orders awaiting processing",
|
||||
description: 'Orders awaiting processing',
|
||||
icon: Clock,
|
||||
trend: "-3",
|
||||
trend: '-3',
|
||||
trendPositive: false,
|
||||
color: "text-orange-600",
|
||||
},
|
||||
color: 'text-orange-600'
|
||||
}
|
||||
]
|
||||
|
||||
const quickStats = [
|
||||
{
|
||||
title: "Average Order Value",
|
||||
title: 'Average Order Value',
|
||||
value: `$${mockAnalytics.avgOrderValue.toFixed(2)}`,
|
||||
description: "Average value per order",
|
||||
icon: TrendingUp,
|
||||
description: 'Average value per order',
|
||||
icon: TrendingUp
|
||||
},
|
||||
{
|
||||
title: "Conversion Rate",
|
||||
title: 'Conversion Rate',
|
||||
value: `${mockAnalytics.conversionRate}%`,
|
||||
description: "Visitors to orders ratio",
|
||||
icon: Users,
|
||||
description: 'Visitors to orders ratio',
|
||||
icon: Users
|
||||
},
|
||||
{
|
||||
title: "Monthly Growth",
|
||||
title: 'Monthly Growth',
|
||||
value: `${mockAnalytics.monthlyGrowth}%`,
|
||||
description: "Revenue growth this month",
|
||||
icon: BarChart3,
|
||||
description: 'Revenue growth this month',
|
||||
icon: BarChart3
|
||||
},
|
||||
{
|
||||
title: "Low Stock Alert",
|
||||
title: 'Low Stock Alert',
|
||||
value: mockAnalytics.lowStockProducts.toString(),
|
||||
description: "Products need restocking",
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
description: 'Products need restocking',
|
||||
icon: AlertTriangle
|
||||
}
|
||||
]
|
||||
|
||||
const recentOrders = [
|
||||
{
|
||||
id: "ORD-001234",
|
||||
customer: "john.doe@email.com",
|
||||
id: 'ORD-001234',
|
||||
customer: 'john.doe@email.com',
|
||||
amount: 2799.98,
|
||||
status: "paid",
|
||||
date: "2024-01-15 10:30",
|
||||
status: 'paid',
|
||||
date: '2024-01-15 10:30'
|
||||
},
|
||||
{
|
||||
id: "ORD-001233",
|
||||
customer: "jane.smith@email.com",
|
||||
id: 'ORD-001233',
|
||||
customer: 'jane.smith@email.com',
|
||||
amount: 1499.99,
|
||||
status: "fulfilled",
|
||||
date: "2024-01-15 09:45",
|
||||
status: 'fulfilled',
|
||||
date: '2024-01-15 09:45'
|
||||
},
|
||||
{
|
||||
id: "ORD-001232",
|
||||
customer: "bob.johnson@email.com",
|
||||
id: 'ORD-001232',
|
||||
customer: 'bob.johnson@email.com',
|
||||
amount: 899.97,
|
||||
status: "pending",
|
||||
date: "2024-01-15 08:20",
|
||||
status: 'pending',
|
||||
date: '2024-01-15 08:20'
|
||||
},
|
||||
{
|
||||
id: "ORD-001231",
|
||||
customer: "alice.wilson@email.com",
|
||||
id: 'ORD-001231',
|
||||
customer: 'alice.wilson@email.com',
|
||||
amount: 3499.99,
|
||||
status: "cancelled",
|
||||
date: "2024-01-14 16:15",
|
||||
},
|
||||
status: 'cancelled',
|
||||
date: '2024-01-14 16:15'
|
||||
}
|
||||
]
|
||||
|
||||
const popularProducts = [
|
||||
{
|
||||
name: "SEAGA HY900 Vending Machine",
|
||||
name: 'SEAGA HY900 Vending Machine',
|
||||
orders: 45,
|
||||
revenue: 112499.55,
|
||||
revenue: 112499.55
|
||||
},
|
||||
{
|
||||
name: "Vending Machine Stand",
|
||||
name: 'Vending Machine Stand',
|
||||
orders: 38,
|
||||
revenue: 11399.62,
|
||||
revenue: 11399.62
|
||||
},
|
||||
{
|
||||
name: "Snack Vending Machine Combo",
|
||||
name: 'Snack Vending Machine Combo',
|
||||
orders: 23,
|
||||
revenue: 45999.77,
|
||||
revenue: 45999.77
|
||||
},
|
||||
{
|
||||
name: "Drink Vending Machine",
|
||||
name: 'Drink Vending Machine',
|
||||
orders: 19,
|
||||
revenue: 37999.81,
|
||||
},
|
||||
revenue: 37999.81
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
|
|
@ -207,26 +182,12 @@ export default async function AdminDashboard() {
|
|||
{/* Header */}
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance">
|
||||
Admin Dashboard
|
||||
</h1>
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance">Admin Dashboard</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Manage orders, contacts, conversations, and calls
|
||||
Overview of your store performance and management tools
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href="/admin/contacts">
|
||||
<Button variant="outline">
|
||||
<ContactRound className="h-4 w-4 mr-2" />
|
||||
Contacts
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/admin/conversations">
|
||||
<Button variant="outline">
|
||||
<MessageSquare className="h-4 w-4 mr-2" />
|
||||
Conversations
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/admin/calls">
|
||||
<Button variant="outline">
|
||||
<Phone className="h-4 w-4 mr-2" />
|
||||
|
|
@ -243,25 +204,6 @@ export default async function AdminDashboard() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>CRM Sync Status</CardTitle>
|
||||
<CardDescription>
|
||||
{!sync.ghlConfigured
|
||||
? "Connect GHL to load contacts and conversations."
|
||||
: "Customer data is mirrored here from GHL and your call flows."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-3 text-sm text-muted-foreground">
|
||||
<Badge variant="outline">{sync.overallStatus}</Badge>
|
||||
<span>Last sync: {formatTimestamp(sync.latestSyncAt)}</span>
|
||||
{!sync.ghlConfigured ? <span>GHL is not connected.</span> : null}
|
||||
{!sync.livekitConfigured ? (
|
||||
<span>LiveKit recordings are not connected yet.</span>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Main Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{dashboardCards.map((card, index) => {
|
||||
|
|
@ -278,7 +220,7 @@ export default async function AdminDashboard() {
|
|||
<div className="text-2xl font-bold">{card.value}</div>
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
<span className={`text-sm ${card.color}`}>
|
||||
{card.trend} {card.trendPositive ? "↑" : "↓"}
|
||||
{card.trend} {card.trendPositive ? '↑' : '↓'}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
from last month
|
||||
|
|
@ -324,9 +266,7 @@ export default async function AdminDashboard() {
|
|||
<CardTitle className="flex items-center justify-between">
|
||||
Recent Orders
|
||||
<Link href="/admin/orders">
|
||||
<Button variant="outline" size="sm">
|
||||
View All
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">View All</Button>
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
|
|
@ -336,10 +276,7 @@ export default async function AdminDashboard() {
|
|||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{recentOrders.map((order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
className="flex items-center justify-between py-3 border-b last:border-b-0"
|
||||
>
|
||||
<div key={order.id} className="flex items-center justify-between py-3 border-b last:border-b-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">{order.id}</div>
|
||||
|
|
@ -352,23 +289,16 @@ export default async function AdminDashboard() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-medium">
|
||||
${order.amount.toFixed(2)}
|
||||
</div>
|
||||
<div className="font-medium">${order.amount.toFixed(2)}</div>
|
||||
<Badge
|
||||
variant={
|
||||
order.status === "paid"
|
||||
? "default"
|
||||
: order.status === "fulfilled"
|
||||
? "default"
|
||||
: order.status === "pending"
|
||||
? "secondary"
|
||||
: "destructive"
|
||||
order.status === 'paid' ? 'default' :
|
||||
order.status === 'fulfilled' ? 'default' :
|
||||
order.status === 'pending' ? 'secondary' : 'destructive'
|
||||
}
|
||||
className="mt-1"
|
||||
>
|
||||
{order.status.charAt(0).toUpperCase() +
|
||||
order.status.slice(1)}
|
||||
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -383,20 +313,17 @@ export default async function AdminDashboard() {
|
|||
<CardTitle className="flex items-center justify-between">
|
||||
Popular Products
|
||||
<Link href="/admin/products">
|
||||
<Button variant="outline" size="sm">
|
||||
View All
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">View All</Button>
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<CardDescription>Top-selling products this month</CardDescription>
|
||||
<CardDescription>
|
||||
Top-selling products this month
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{popularProducts.map((product, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between py-3 border-b last:border-b-0"
|
||||
>
|
||||
<div key={index} className="flex items-center justify-between py-3 border-b last:border-b-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-md bg-muted flex items-center justify-center text-xs font-bold text-muted-foreground">
|
||||
{index + 1}
|
||||
|
|
@ -411,9 +338,7 @@ export default async function AdminDashboard() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-medium">
|
||||
${product.revenue.toLocaleString()}
|
||||
</div>
|
||||
<div className="font-medium">${product.revenue.toLocaleString()}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
${(product.revenue / product.orders).toFixed(2)} avg
|
||||
</div>
|
||||
|
|
@ -489,7 +414,6 @@ export default async function AdminDashboard() {
|
|||
}
|
||||
|
||||
export const metadata = {
|
||||
title: "Admin Dashboard | Rocky Mountain Vending",
|
||||
description:
|
||||
"Administrative dashboard for managing your vending machine business",
|
||||
title: 'Admin Dashboard | Rocky Mountain Vending',
|
||||
description: 'Administrative dashboard for managing your vending machine business',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ProductAdmin } from "@/components/product-admin"
|
||||
import { ProductAdmin } from '@/components/product-admin'
|
||||
|
||||
export default function AdminProductsPage() {
|
||||
return (
|
||||
|
|
@ -9,6 +9,6 @@ export default function AdminProductsPage() {
|
|||
}
|
||||
|
||||
export const metadata = {
|
||||
title: "Product Management | Admin",
|
||||
description: "Manage your Stripe products and inventory",
|
||||
title: 'Product Management | Admin',
|
||||
description: 'Manage your Stripe products and inventory',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { fetchQuery } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth"
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchQuery } from "convex/nextjs";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function GET(request: Request, { params }: RouteContext) {
|
||||
const authError = requireAdminToken(request)
|
||||
const authError = requireAdminToken(request);
|
||||
if (authError) {
|
||||
return authError
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
const { id } = await params;
|
||||
const detail = await fetchQuery(api.voiceSessions.getAdminPhoneCallDetail, {
|
||||
callId: id,
|
||||
})
|
||||
});
|
||||
|
||||
if (!detail) {
|
||||
return NextResponse.json(
|
||||
{ error: "Phone call not found" },
|
||||
{ status: 404 }
|
||||
)
|
||||
return NextResponse.json({ error: "Phone call not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(detail)
|
||||
return NextResponse.json(detail);
|
||||
} catch (error) {
|
||||
console.error("Failed to load admin phone call detail:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to load phone call detail" },
|
||||
{ status: 500 }
|
||||
)
|
||||
console.error("Failed to load admin phone call detail:", error);
|
||||
return NextResponse.json({ error: "Failed to load phone call detail" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +1,31 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth"
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchQuery } from "convex/nextjs";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = requireAdminToken(request)
|
||||
const authError = requireAdminToken(request);
|
||||
if (authError) {
|
||||
return authError
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const search = searchParams.get("search")?.trim() || undefined
|
||||
const status = searchParams.get("status")
|
||||
const page = Number.parseInt(searchParams.get("page") || "1", 10) || 1
|
||||
const limit = Number.parseInt(searchParams.get("limit") || "25", 10) || 25
|
||||
const { searchParams } = new URL(request.url);
|
||||
const search = searchParams.get("search")?.trim() || undefined;
|
||||
const status = searchParams.get("status");
|
||||
const page = Number.parseInt(searchParams.get("page") || "1", 10) || 1;
|
||||
const limit = Number.parseInt(searchParams.get("limit") || "25", 10) || 25;
|
||||
|
||||
const data = await fetchQuery(api.voiceSessions.listAdminPhoneCalls, {
|
||||
search,
|
||||
status:
|
||||
status === "started" || status === "completed" || status === "failed"
|
||||
? status
|
||||
: undefined,
|
||||
status: status === "started" || status === "completed" || status === "failed" ? status : undefined,
|
||||
page,
|
||||
limit,
|
||||
})
|
||||
});
|
||||
|
||||
return NextResponse.json(data)
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load admin phone calls:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to load phone calls" },
|
||||
{ status: 500 }
|
||||
)
|
||||
console.error("Failed to load admin phone calls:", error);
|
||||
return NextResponse.json({ error: "Failed to load phone calls" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
isSiteChatSuppressedRoute,
|
||||
} from "@/lib/site-chat/config"
|
||||
import { buildSiteChatSystemPrompt } from "@/lib/site-chat/prompt"
|
||||
import {
|
||||
consumeChatOutput,
|
||||
consumeChatRequest,
|
||||
getChatRateLimitStatus,
|
||||
} from "@/lib/site-chat/rate-limit"
|
||||
import {
|
||||
formatManualContextForPrompt,
|
||||
retrieveManualContext,
|
||||
shouldUseManualKnowledgeForChat,
|
||||
summarizeManualRetrieval,
|
||||
} from "@/lib/manuals-knowledge"
|
||||
import { SITE_CHAT_SYSTEM_PROMPT } from "@/lib/site-chat/prompt"
|
||||
import { consumeChatOutput, consumeChatRequest, getChatRateLimitStatus } from "@/lib/site-chat/rate-limit"
|
||||
import { createSmsConsentPayload } from "@/lib/sms-compliance"
|
||||
|
||||
type ChatRole = "user" | "assistant"
|
||||
|
|
@ -91,10 +81,7 @@ function normalizeSessionId(rawSessionId: string | undefined | null) {
|
|||
}
|
||||
|
||||
function normalizePathname(rawPathname: string | undefined) {
|
||||
const pathname =
|
||||
typeof rawPathname === "string" && rawPathname.trim()
|
||||
? rawPathname.trim()
|
||||
: "/"
|
||||
const pathname = typeof rawPathname === "string" && rawPathname.trim() ? rawPathname.trim() : "/"
|
||||
return pathname.startsWith("/") ? pathname : `/${pathname}`
|
||||
}
|
||||
|
||||
|
|
@ -102,46 +89,24 @@ function normalizeMessages(messages: ChatMessage[] | undefined) {
|
|||
const safeMessages = Array.isArray(messages) ? messages : []
|
||||
|
||||
return safeMessages
|
||||
.filter(
|
||||
(message) =>
|
||||
message && (message.role === "user" || message.role === "assistant")
|
||||
)
|
||||
.filter((message) => message && (message.role === "user" || message.role === "assistant"))
|
||||
.map((message) => ({
|
||||
role: message.role,
|
||||
content: String(message.content || "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, SITE_CHAT_MAX_MESSAGE_CHARS),
|
||||
content: String(message.content || "").replace(/\s+/g, " ").trim().slice(0, SITE_CHAT_MAX_MESSAGE_CHARS),
|
||||
}))
|
||||
.filter((message) => message.content.length > 0)
|
||||
.slice(-SITE_CHAT_MAX_HISTORY_MESSAGES)
|
||||
}
|
||||
|
||||
function normalizeVisitorProfile(
|
||||
rawVisitor: ChatRequestBody["visitor"],
|
||||
pathname: string
|
||||
): ChatVisitorProfile | null {
|
||||
function normalizeVisitorProfile(rawVisitor: ChatRequestBody["visitor"], pathname: string): ChatVisitorProfile | null {
|
||||
if (!rawVisitor) {
|
||||
return null
|
||||
}
|
||||
|
||||
const name = String(rawVisitor.name || "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 80)
|
||||
const phone = String(rawVisitor.phone || "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 40)
|
||||
const email = String(rawVisitor.email || "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 120)
|
||||
.toLowerCase()
|
||||
const intent = String(rawVisitor.intent || "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 80)
|
||||
const name = String(rawVisitor.name || "").replace(/\s+/g, " ").trim().slice(0, 80)
|
||||
const phone = String(rawVisitor.phone || "").replace(/\s+/g, " ").trim().slice(0, 40)
|
||||
const email = String(rawVisitor.email || "").replace(/\s+/g, " ").trim().slice(0, 120).toLowerCase()
|
||||
const intent = String(rawVisitor.intent || "").replace(/\s+/g, " ").trim().slice(0, 80)
|
||||
|
||||
if (!name || !phone || !email || !intent) {
|
||||
return null
|
||||
|
|
@ -214,15 +179,6 @@ function extractAssistantText(data: any) {
|
|||
return ""
|
||||
}
|
||||
|
||||
function buildManualKnowledgeQuery(messages: ChatMessage[]) {
|
||||
return messages
|
||||
.filter((message) => message.role === "user")
|
||||
.slice(-3)
|
||||
.map((message) => message.content.trim())
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const responseHeaders: Record<string, string> = {
|
||||
"Cache-Control": "no-store",
|
||||
|
|
@ -234,36 +190,25 @@ export async function POST(request: NextRequest) {
|
|||
const visitor = normalizeVisitorProfile(body.visitor, pathname)
|
||||
|
||||
if (isSiteChatSuppressedRoute(pathname)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Chat is not available on this route." },
|
||||
{ status: 403, headers: responseHeaders }
|
||||
)
|
||||
return NextResponse.json({ error: "Chat is not available on this route." }, { status: 403, headers: responseHeaders })
|
||||
}
|
||||
|
||||
if (!visitor) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Name, phone, email, intent, and required service SMS consent are needed to start chat.",
|
||||
error: "Name, phone, email, intent, and required service SMS consent are needed to start chat.",
|
||||
},
|
||||
{ status: 400, headers: responseHeaders }
|
||||
{ status: 400, headers: responseHeaders },
|
||||
)
|
||||
}
|
||||
|
||||
const sessionId = normalizeSessionId(
|
||||
body.sessionId || request.cookies.get(SITE_CHAT_SESSION_COOKIE)?.value
|
||||
)
|
||||
const sessionId = normalizeSessionId(body.sessionId || request.cookies.get(SITE_CHAT_SESSION_COOKIE)?.value)
|
||||
const ip = getClientIp(request)
|
||||
const messages = normalizeMessages(body.messages)
|
||||
const latestUserMessage = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === "user")
|
||||
const latestUserMessage = [...messages].reverse().find((message) => message.role === "user")
|
||||
|
||||
if (!latestUserMessage) {
|
||||
return NextResponse.json(
|
||||
{ error: "A user message is required.", sessionId },
|
||||
{ status: 400, headers: responseHeaders }
|
||||
)
|
||||
return NextResponse.json({ error: "A user message is required.", sessionId }, { status: 400, headers: responseHeaders })
|
||||
}
|
||||
|
||||
if (latestUserMessage.content.length > SITE_CHAT_MAX_INPUT_CHARS) {
|
||||
|
|
@ -272,7 +217,7 @@ export async function POST(request: NextRequest) {
|
|||
error: `Please keep each message under ${SITE_CHAT_MAX_INPUT_CHARS} characters.`,
|
||||
sessionId,
|
||||
},
|
||||
{ status: 400, headers: responseHeaders }
|
||||
{ status: 400, headers: responseHeaders },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -289,12 +234,11 @@ export async function POST(request: NextRequest) {
|
|||
if (limitStatus.blocked) {
|
||||
const blockedResponse = NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Chat is temporarily limited right now. Please wait a bit or call Rocky Mountain Vending directly.",
|
||||
error: "Chat is temporarily limited right now. Please wait a bit or call Rocky Mountain Vending directly.",
|
||||
sessionId,
|
||||
limits: limitStatus,
|
||||
},
|
||||
{ status: 429, headers: responseHeaders }
|
||||
{ status: 429, headers: responseHeaders },
|
||||
)
|
||||
|
||||
blockedResponse.cookies.set(SITE_CHAT_SESSION_COOKIE, sessionId, {
|
||||
|
|
@ -308,41 +252,7 @@ export async function POST(request: NextRequest) {
|
|||
return blockedResponse
|
||||
}
|
||||
|
||||
consumeChatRequest({
|
||||
ip,
|
||||
requestWindowMs: SITE_CHAT_REQUEST_WINDOW_MS,
|
||||
sessionId,
|
||||
})
|
||||
|
||||
const manualKnowledgeQuery = buildManualKnowledgeQuery(messages)
|
||||
const shouldUseManualKnowledge = shouldUseManualKnowledgeForChat(
|
||||
visitor.intent,
|
||||
manualKnowledgeQuery
|
||||
)
|
||||
let manualKnowledge = null
|
||||
let manualKnowledgeError: unknown = null
|
||||
if (shouldUseManualKnowledge) {
|
||||
try {
|
||||
manualKnowledge = await retrieveManualContext(manualKnowledgeQuery)
|
||||
} catch (error) {
|
||||
manualKnowledgeError = error
|
||||
console.error("[site-chat] manuals knowledge lookup failed", {
|
||||
pathname,
|
||||
sessionId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
console.info(
|
||||
"[site-chat] manuals retrieval",
|
||||
summarizeManualRetrieval({
|
||||
ran: shouldUseManualKnowledge,
|
||||
query: manualKnowledgeQuery,
|
||||
result: manualKnowledge,
|
||||
error: manualKnowledgeError,
|
||||
})
|
||||
)
|
||||
const systemPrompt = buildSiteChatSystemPrompt()
|
||||
consumeChatRequest({ ip, requestWindowMs: SITE_CHAT_REQUEST_WINDOW_MS, sessionId })
|
||||
|
||||
const xaiApiKey = getOptionalEnv("XAI_API_KEY")
|
||||
if (!xaiApiKey) {
|
||||
|
|
@ -353,46 +263,32 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Jessica is temporarily unavailable right now. Please call us or use the contact form.",
|
||||
error: "Jessica is temporarily unavailable right now. Please call us or use the contact form.",
|
||||
sessionId,
|
||||
},
|
||||
{ status: 503, headers: responseHeaders }
|
||||
{ status: 503, headers: responseHeaders },
|
||||
)
|
||||
}
|
||||
|
||||
const completionResponse = await fetch(
|
||||
"https://api.x.ai/v1/chat/completions",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${xaiApiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: SITE_CHAT_MODEL,
|
||||
temperature: SITE_CHAT_TEMPERATURE,
|
||||
max_tokens: SITE_CHAT_MAX_OUTPUT_TOKENS,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `${systemPrompt}\n\nConversation context:\n- Current pathname: ${pathname}\n- Source: ${SITE_CHAT_SOURCE}\n- Visitor name: ${visitor.name}\n- Visitor email: ${visitor.email}\n- Visitor phone: ${visitor.phone}\n- Visitor intent: ${visitor.intent}\n- Service SMS consent: ${visitor.serviceTextConsent ? "yes" : "no"}\n- Marketing SMS consent: ${visitor.marketingTextConsent ? "yes" : "no"}`,
|
||||
},
|
||||
...(shouldUseManualKnowledge
|
||||
? [
|
||||
{
|
||||
role: "system" as const,
|
||||
content: manualKnowledge
|
||||
? formatManualContextForPrompt(manualKnowledge)
|
||||
: "Manual knowledge context:\n- A manual lookup was attempted, but no reliable manual context is available.\n- Do not guess. Ask for the brand, model sticker, or a clear photo/video that can be texted in.",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...messages,
|
||||
],
|
||||
}),
|
||||
}
|
||||
)
|
||||
const completionResponse = await fetch("https://api.x.ai/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${xaiApiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: SITE_CHAT_MODEL,
|
||||
temperature: SITE_CHAT_TEMPERATURE,
|
||||
max_tokens: SITE_CHAT_MAX_OUTPUT_TOKENS,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `${SITE_CHAT_SYSTEM_PROMPT}\n\nConversation context:\n- Current pathname: ${pathname}\n- Source: ${SITE_CHAT_SOURCE}\n- Visitor name: ${visitor.name}\n- Visitor email: ${visitor.email}\n- Visitor phone: ${visitor.phone}\n- Visitor intent: ${visitor.intent}\n- Service SMS consent: ${visitor.serviceTextConsent ? "yes" : "no"}\n- Marketing SMS consent: ${visitor.marketingTextConsent ? "yes" : "no"}`,
|
||||
},
|
||||
...messages,
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
const completionData = await completionResponse.json().catch(() => ({}))
|
||||
|
||||
|
|
@ -406,11 +302,10 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Jessica is having trouble replying right now. Please try again or call us directly.",
|
||||
error: "Jessica is having trouble replying right now. Please try again or call us directly.",
|
||||
sessionId,
|
||||
},
|
||||
{ status: 502, headers: responseHeaders }
|
||||
{ status: 502, headers: responseHeaders },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -422,15 +317,11 @@ export async function POST(request: NextRequest) {
|
|||
error: "Jessica did not return a usable reply. Please try again.",
|
||||
sessionId,
|
||||
},
|
||||
{ status: 502, headers: responseHeaders }
|
||||
{ status: 502, headers: responseHeaders },
|
||||
)
|
||||
}
|
||||
|
||||
consumeChatOutput({
|
||||
chars: assistantReply.length,
|
||||
outputWindowMs: SITE_CHAT_OUTPUT_WINDOW_MS,
|
||||
sessionId,
|
||||
})
|
||||
consumeChatOutput({ chars: assistantReply.length, outputWindowMs: SITE_CHAT_OUTPUT_WINDOW_MS, sessionId })
|
||||
|
||||
const nextLimitStatus = getChatRateLimitStatus({
|
||||
ip,
|
||||
|
|
@ -448,7 +339,7 @@ export async function POST(request: NextRequest) {
|
|||
sessionId,
|
||||
limits: nextLimitStatus,
|
||||
},
|
||||
{ headers: responseHeaders }
|
||||
{ headers: responseHeaders },
|
||||
)
|
||||
|
||||
response.cookies.set(SITE_CHAT_SESSION_COOKIE, sessionId, {
|
||||
|
|
@ -464,10 +355,7 @@ export async function POST(request: NextRequest) {
|
|||
console.error("[site-chat] request failed", error)
|
||||
|
||||
const safeError =
|
||||
error instanceof Error &&
|
||||
error.message.startsWith(
|
||||
"Missing required site chat environment variable:"
|
||||
)
|
||||
error instanceof Error && error.message.startsWith("Missing required site chat environment variable:")
|
||||
? "Jessica is temporarily unavailable right now. Please call us or use the contact form."
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
|
|
@ -477,7 +365,7 @@ export async function POST(request: NextRequest) {
|
|||
{
|
||||
error: safeError,
|
||||
},
|
||||
{ status: 500, headers: responseHeaders }
|
||||
{ status: 500, headers: responseHeaders },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
processLeadSubmission,
|
||||
type ContactLeadPayload,
|
||||
type RequestMachineLeadPayload,
|
||||
} from "@/lib/server/contact-submission"
|
||||
} from "@/lib/server/contact-submission";
|
||||
|
||||
test("processLeadSubmission stores and syncs a contact lead", async () => {
|
||||
const calls: string[] = []
|
||||
const calls: string[] = [];
|
||||
const payload: ContactLeadPayload = {
|
||||
kind: "contact",
|
||||
firstName: "John",
|
||||
|
|
@ -26,7 +26,7 @@ test("processLeadSubmission stores and syncs a contact lead", async () => {
|
|||
page: "/contact",
|
||||
timestamp: "2026-03-25T00:00:00.000Z",
|
||||
url: "https://rmv.example/contact",
|
||||
}
|
||||
};
|
||||
|
||||
const result = await processLeadSubmission(payload, "rmv.example", {
|
||||
storageConfigured: true,
|
||||
|
|
@ -36,37 +36,37 @@ test("processLeadSubmission stores and syncs a contact lead", async () => {
|
|||
tenantName: "Rocky Mountain Vending",
|
||||
tenantDomains: ["rockymountainvending.com"],
|
||||
ingest: async () => {
|
||||
calls.push("ingest")
|
||||
calls.push("ingest");
|
||||
return {
|
||||
inserted: true,
|
||||
leadId: "lead_123",
|
||||
idempotencyKey: "abc",
|
||||
tenantId: "tenant_123",
|
||||
}
|
||||
};
|
||||
},
|
||||
updateLeadStatus: async () => {
|
||||
calls.push("update")
|
||||
return { ok: true }
|
||||
calls.push("update");
|
||||
return { ok: true };
|
||||
},
|
||||
sendEmail: async () => {
|
||||
calls.push("email")
|
||||
return {}
|
||||
calls.push("email");
|
||||
return {};
|
||||
},
|
||||
createContact: async () => {
|
||||
calls.push("ghl")
|
||||
return { contact: { id: "ghl_123" } }
|
||||
calls.push("ghl");
|
||||
return { contact: { id: "ghl_123" } };
|
||||
},
|
||||
logger: console,
|
||||
})
|
||||
});
|
||||
|
||||
assert.equal(result.status, 200)
|
||||
assert.equal(result.body.success, true)
|
||||
assert.deepEqual(result.body.deliveredVia, ["convex", "email", "ghl"])
|
||||
assert.equal(calls.filter((call) => call === "email").length, 2)
|
||||
assert.ok(calls.includes("ingest"))
|
||||
assert.ok(calls.includes("update"))
|
||||
assert.ok(calls.includes("ghl"))
|
||||
})
|
||||
assert.equal(result.status, 200);
|
||||
assert.equal(result.body.success, true);
|
||||
assert.deepEqual(result.body.deliveredVia, ["convex", "email", "ghl"]);
|
||||
assert.equal(calls.filter((call) => call === "email").length, 2);
|
||||
assert.ok(calls.includes("ingest"));
|
||||
assert.ok(calls.includes("update"));
|
||||
assert.ok(calls.includes("ghl"));
|
||||
});
|
||||
|
||||
test("processLeadSubmission validates request-machine submissions", async () => {
|
||||
const payload: RequestMachineLeadPayload = {
|
||||
|
|
@ -84,7 +84,7 @@ test("processLeadSubmission validates request-machine submissions", async () =>
|
|||
consentVersion: "sms-consent-v1-2026-03-26",
|
||||
consentCapturedAt: "2026-03-25T00:00:00.000Z",
|
||||
consentSourcePage: "/",
|
||||
}
|
||||
};
|
||||
|
||||
const result = await processLeadSubmission(payload, "rmv.example", {
|
||||
storageConfigured: false,
|
||||
|
|
@ -94,24 +94,24 @@ test("processLeadSubmission validates request-machine submissions", async () =>
|
|||
tenantName: "Rocky Mountain Vending",
|
||||
tenantDomains: [],
|
||||
ingest: async () => {
|
||||
throw new Error("should not run")
|
||||
throw new Error("should not run");
|
||||
},
|
||||
updateLeadStatus: async () => {
|
||||
throw new Error("should not run")
|
||||
throw new Error("should not run");
|
||||
},
|
||||
sendEmail: async () => {
|
||||
throw new Error("should not run")
|
||||
throw new Error("should not run");
|
||||
},
|
||||
createContact: async () => {
|
||||
throw new Error("should not run")
|
||||
throw new Error("should not run");
|
||||
},
|
||||
logger: console,
|
||||
})
|
||||
});
|
||||
|
||||
assert.equal(result.status, 400)
|
||||
assert.equal(result.body.success, false)
|
||||
assert.match(result.body.error || "", /Invalid number of employees/)
|
||||
})
|
||||
assert.equal(result.status, 400);
|
||||
assert.equal(result.body.success, false);
|
||||
assert.match(result.body.error || "", /Invalid number of employees/);
|
||||
});
|
||||
|
||||
test("processLeadSubmission returns deduped success when Convex already has the lead", async () => {
|
||||
const payload: ContactLeadPayload = {
|
||||
|
|
@ -126,7 +126,7 @@ test("processLeadSubmission returns deduped success when Convex already has the
|
|||
consentVersion: "sms-consent-v1-2026-03-26",
|
||||
consentCapturedAt: "2026-03-25T00:00:00.000Z",
|
||||
consentSourcePage: "/contact-us",
|
||||
}
|
||||
};
|
||||
|
||||
const result = await processLeadSubmission(payload, "rmv.example", {
|
||||
storageConfigured: true,
|
||||
|
|
@ -145,10 +145,10 @@ test("processLeadSubmission returns deduped success when Convex already has the
|
|||
sendEmail: async () => ({}),
|
||||
createContact: async () => null,
|
||||
logger: console,
|
||||
})
|
||||
});
|
||||
|
||||
assert.equal(result.status, 200)
|
||||
assert.equal(result.body.success, true)
|
||||
assert.equal(result.body.deduped, true)
|
||||
assert.deepEqual(result.body.deliveredVia, ["convex"])
|
||||
})
|
||||
assert.equal(result.status, 200);
|
||||
assert.equal(result.body.success, true);
|
||||
assert.equal(result.body.deduped, true);
|
||||
assert.deepEqual(result.body.deliveredVia, ["convex"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { handleLeadRequest } from "@/lib/server/contact-submission"
|
||||
import { handleLeadRequest } from "@/lib/server/contact-submission";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return handleLeadRequest(request)
|
||||
return handleLeadRequest(request);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { fetchMutation, fetchQuery } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
|
||||
import {
|
||||
buildPhoneCallSummary,
|
||||
sendPhoneCallSummaryEmail,
|
||||
} from "@/lib/phone-calls"
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchMutation, fetchQuery } from "convex/nextjs";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared";
|
||||
import { buildPhoneCallSummary, sendPhoneCallSummaryEmail } from "@/lib/phone-calls";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requirePhoneAgentInternalAuth(request)
|
||||
const authError = await requirePhoneAgentInternalAuth(request);
|
||||
if (authError) {
|
||||
return authError
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const callId = String(body.sessionId || body.roomName || "")
|
||||
const body = await request.json();
|
||||
const callId = String(body.sessionId || body.roomName || "");
|
||||
if (!callId) {
|
||||
return NextResponse.json(
|
||||
{ error: "sessionId or roomName is required" },
|
||||
{ status: 400 }
|
||||
)
|
||||
return NextResponse.json({ error: "sessionId or roomName is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const detail = await fetchQuery(api.voiceSessions.getAdminPhoneCallDetail, {
|
||||
callId,
|
||||
})
|
||||
});
|
||||
|
||||
if (!detail) {
|
||||
return NextResponse.json(
|
||||
{ error: "Phone call not found" },
|
||||
{ status: 404 }
|
||||
)
|
||||
return NextResponse.json({ error: "Phone call not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const summaryText = buildPhoneCallSummary(detail)
|
||||
const url = new URL(request.url);
|
||||
const summaryText = buildPhoneCallSummary(detail);
|
||||
const notificationResult = await sendPhoneCallSummaryEmail({
|
||||
detail: {
|
||||
...detail,
|
||||
|
|
@ -45,7 +36,7 @@ export async function POST(request: Request) {
|
|||
},
|
||||
},
|
||||
adminUrl: url.origin,
|
||||
})
|
||||
});
|
||||
|
||||
const result = await fetchMutation(api.voiceSessions.completeSession, {
|
||||
sessionId: detail.call.id,
|
||||
|
|
@ -54,26 +45,20 @@ export async function POST(request: Request) {
|
|||
recordingStatus: body.recordingStatus,
|
||||
recordingId: body.recordingId ? String(body.recordingId) : undefined,
|
||||
recordingUrl: body.recordingUrl ? String(body.recordingUrl) : undefined,
|
||||
recordingError: body.recordingError
|
||||
? String(body.recordingError)
|
||||
: undefined,
|
||||
recordingError: body.recordingError ? String(body.recordingError) : undefined,
|
||||
summaryText,
|
||||
notificationStatus: notificationResult.status,
|
||||
notificationSentAt:
|
||||
notificationResult.status === "sent" ? Date.now() : undefined,
|
||||
notificationSentAt: notificationResult.status === "sent" ? Date.now() : undefined,
|
||||
notificationError: notificationResult.error,
|
||||
})
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
call: result,
|
||||
notification: notificationResult,
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to complete phone call sync:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to complete phone call sync" },
|
||||
{ status: 500 }
|
||||
)
|
||||
console.error("Failed to complete phone call sync:", error);
|
||||
return NextResponse.json({ error: "Failed to complete phone call sync" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,77 +1,27 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchMutation } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchMutation } from "convex/nextjs";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requirePhoneAgentInternalAuth(request)
|
||||
const authError = await requirePhoneAgentInternalAuth(request);
|
||||
if (authError) {
|
||||
return authError
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const body = await request.json();
|
||||
const result = await fetchMutation(api.voiceSessions.linkPhoneCallLead, {
|
||||
sessionId: body.sessionId,
|
||||
linkedLeadId: body.linkedLeadId ? String(body.linkedLeadId) : undefined,
|
||||
contactProfileId: body.contactProfileId || undefined,
|
||||
contactDisplayName: body.contactDisplayName
|
||||
? String(body.contactDisplayName)
|
||||
: undefined,
|
||||
contactCompany: body.contactCompany
|
||||
? String(body.contactCompany)
|
||||
: undefined,
|
||||
leadOutcome: body.leadOutcome || "none",
|
||||
handoffRequested:
|
||||
typeof body.handoffRequested === "boolean"
|
||||
? body.handoffRequested
|
||||
: undefined,
|
||||
handoffReason: body.handoffReason
|
||||
? String(body.handoffReason)
|
||||
: undefined,
|
||||
reminderStatus: body.reminderStatus || undefined,
|
||||
reminderRequestedAt:
|
||||
typeof body.reminderRequestedAt === "number"
|
||||
? body.reminderRequestedAt
|
||||
: undefined,
|
||||
reminderStartAt:
|
||||
typeof body.reminderStartAt === "number"
|
||||
? body.reminderStartAt
|
||||
: undefined,
|
||||
reminderEndAt:
|
||||
typeof body.reminderEndAt === "number"
|
||||
? body.reminderEndAt
|
||||
: undefined,
|
||||
reminderCalendarEventId: body.reminderCalendarEventId
|
||||
? String(body.reminderCalendarEventId)
|
||||
: undefined,
|
||||
reminderCalendarHtmlLink: body.reminderCalendarHtmlLink
|
||||
? String(body.reminderCalendarHtmlLink)
|
||||
: undefined,
|
||||
reminderNote: body.reminderNote ? String(body.reminderNote) : undefined,
|
||||
warmTransferStatus: body.warmTransferStatus || undefined,
|
||||
warmTransferTarget: body.warmTransferTarget
|
||||
? String(body.warmTransferTarget)
|
||||
: undefined,
|
||||
warmTransferAttemptedAt:
|
||||
typeof body.warmTransferAttemptedAt === "number"
|
||||
? body.warmTransferAttemptedAt
|
||||
: undefined,
|
||||
warmTransferConnectedAt:
|
||||
typeof body.warmTransferConnectedAt === "number"
|
||||
? body.warmTransferConnectedAt
|
||||
: undefined,
|
||||
warmTransferFailureReason: body.warmTransferFailureReason
|
||||
? String(body.warmTransferFailureReason)
|
||||
: undefined,
|
||||
})
|
||||
handoffRequested: typeof body.handoffRequested === "boolean" ? body.handoffRequested : undefined,
|
||||
handoffReason: body.handoffReason ? String(body.handoffReason) : undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, call: result })
|
||||
return NextResponse.json({ success: true, call: result });
|
||||
} catch (error) {
|
||||
console.error("Failed to link phone call lead:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to link phone call lead" },
|
||||
{ status: 500 }
|
||||
)
|
||||
console.error("Failed to link phone call lead:", error);
|
||||
return NextResponse.json({ error: "Failed to link phone call lead" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,51 +1,51 @@
|
|||
import { timingSafeEqual } from "node:crypto"
|
||||
import { NextResponse } from "next/server"
|
||||
import { hasConvexUrl } from "@/lib/convex-config"
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { hasConvexUrl } from "@/lib/convex-config";
|
||||
|
||||
function readBearerToken(request: Request) {
|
||||
const authHeader = request.headers.get("authorization") || ""
|
||||
const authHeader = request.headers.get("authorization") || "";
|
||||
if (!authHeader.toLowerCase().startsWith("bearer ")) {
|
||||
return ""
|
||||
return "";
|
||||
}
|
||||
|
||||
return authHeader.slice("bearer ".length).trim()
|
||||
return authHeader.slice("bearer ".length).trim();
|
||||
}
|
||||
|
||||
function tokensMatch(expected: string, provided: string) {
|
||||
const expectedBuffer = Buffer.from(expected)
|
||||
const providedBuffer = Buffer.from(provided)
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
const providedBuffer = Buffer.from(provided);
|
||||
|
||||
if (expectedBuffer.length !== providedBuffer.length) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(expectedBuffer, providedBuffer)
|
||||
return timingSafeEqual(expectedBuffer, providedBuffer);
|
||||
}
|
||||
|
||||
export function getPhoneAgentInternalToken() {
|
||||
return String(process.env.PHONE_AGENT_INTERNAL_TOKEN || "").trim()
|
||||
return String(process.env.PHONE_AGENT_INTERNAL_TOKEN || "").trim();
|
||||
}
|
||||
|
||||
export async function requirePhoneAgentInternalAuth(request: Request) {
|
||||
if (!hasConvexUrl()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Convex is not configured for phone call sync" },
|
||||
{ status: 503 }
|
||||
)
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const configuredToken = getPhoneAgentInternalToken()
|
||||
const configuredToken = getPhoneAgentInternalToken();
|
||||
if (!configuredToken) {
|
||||
return NextResponse.json(
|
||||
{ error: "Phone call sync token is not configured" },
|
||||
{ status: 503 }
|
||||
)
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const providedToken = readBearerToken(request)
|
||||
const providedToken = readBearerToken(request);
|
||||
if (!providedToken || !tokensMatch(configuredToken, providedToken)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,79 +1,37 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchMutation, fetchQuery } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
|
||||
import { normalizePhoneE164 } from "@/lib/phone-normalization"
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchMutation } from "convex/nextjs";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requirePhoneAgentInternalAuth(request)
|
||||
const authError = await requirePhoneAgentInternalAuth(request);
|
||||
if (authError) {
|
||||
return authError
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
let metadata: Record<string, unknown> = {}
|
||||
if (typeof body.metadata === "string" && body.metadata.trim()) {
|
||||
try {
|
||||
metadata = JSON.parse(body.metadata)
|
||||
} catch {
|
||||
metadata = {}
|
||||
}
|
||||
}
|
||||
const callerPhone = normalizePhoneE164(
|
||||
metadata.participantPhone || body.participantIdentity
|
||||
)
|
||||
const contactContext = callerPhone
|
||||
? await fetchQuery(api.voiceSessions.getPhoneAgentContextByPhone, {
|
||||
normalizedPhone: callerPhone,
|
||||
})
|
||||
: null
|
||||
|
||||
const result = await fetchMutation(
|
||||
api.voiceSessions.upsertPhoneCallSession,
|
||||
{
|
||||
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",
|
||||
}
|
||||
)
|
||||
const body = await request.json();
|
||||
const result = await fetchMutation(api.voiceSessions.upsertPhoneCallSession, {
|
||||
roomName: String(body.roomName || ""),
|
||||
participantIdentity: String(body.participantIdentity || ""),
|
||||
siteUrl: body.siteUrl ? String(body.siteUrl) : undefined,
|
||||
pathname: body.pathname ? String(body.pathname) : undefined,
|
||||
pageUrl: body.pageUrl ? String(body.pageUrl) : undefined,
|
||||
source: "phone-agent",
|
||||
metadata: body.metadata ? String(body.metadata) : undefined,
|
||||
startedAt: typeof body.startedAt === "number" ? body.startedAt : undefined,
|
||||
recordingDisclosureAt:
|
||||
typeof body.recordingDisclosureAt === "number" ? body.recordingDisclosureAt : undefined,
|
||||
recordingStatus: body.recordingStatus || "pending",
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
sessionId: result?._id,
|
||||
roomName: result?.roomName,
|
||||
callerPhone,
|
||||
contactProfile: contactContext?.contactProfile || null,
|
||||
recentLead: contactContext?.recentLead || null,
|
||||
recentSession: contactContext?.recentSession || null,
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to start phone call sync:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to start phone call sync" },
|
||||
{ status: 500 }
|
||||
)
|
||||
console.error("Failed to start phone call sync:", error);
|
||||
return NextResponse.json({ error: "Failed to start phone call sync" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchMutation } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchMutation } from "convex/nextjs";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requirePhoneAgentInternalAuth(request)
|
||||
const authError = await requirePhoneAgentInternalAuth(request);
|
||||
if (authError) {
|
||||
return authError
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const body = await request.json();
|
||||
await fetchMutation(api.voiceSessions.addTranscriptTurn, {
|
||||
sessionId: body.sessionId,
|
||||
roomName: String(body.roomName || ""),
|
||||
|
|
@ -21,16 +21,12 @@ export async function POST(request: Request) {
|
|||
isFinal: typeof body.isFinal === "boolean" ? body.isFinal : undefined,
|
||||
language: body.language ? String(body.language) : undefined,
|
||||
source: "phone-agent",
|
||||
createdAt:
|
||||
typeof body.createdAt === "number" ? body.createdAt : undefined,
|
||||
})
|
||||
createdAt: typeof body.createdAt === "number" ? body.createdAt : undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Failed to append phone call turn:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to append phone call turn" },
|
||||
{ status: 500 }
|
||||
)
|
||||
console.error("Failed to append phone call turn:", error);
|
||||
return NextResponse.json({ error: "Failed to append phone call turn" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,19 +11,11 @@ type TokenRequestBody = {
|
|||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = (await request.json().catch(() => ({}))) as TokenRequestBody
|
||||
const pathname =
|
||||
typeof body.pathname === "string" && body.pathname.trim()
|
||||
? body.pathname.trim()
|
||||
: "/"
|
||||
const pathname = typeof body.pathname === "string" && body.pathname.trim() ? body.pathname.trim() : "/"
|
||||
|
||||
if (isVoiceAssistantSuppressedRoute(pathname)) {
|
||||
console.info("[voice-assistant/token] blocked on suppressed route", {
|
||||
pathname,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: "Voice assistant is not available on this route." },
|
||||
{ status: 403 }
|
||||
)
|
||||
console.info("[voice-assistant/token] blocked on suppressed route", { pathname })
|
||||
return NextResponse.json({ error: "Voice assistant is not available on this route." }, { status: 403 })
|
||||
}
|
||||
|
||||
const tokenResponse = await createVoiceAssistantTokenResponse(pathname)
|
||||
|
|
@ -38,12 +30,9 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to create voice assistant token",
|
||||
error: error instanceof Error ? error.message : "Failed to create voice assistant token",
|
||||
},
|
||||
{ status: 500 }
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ function invalidPath(pathValue: string) {
|
|||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
{ params }: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
try {
|
||||
const { path: pathArray } = await params
|
||||
|
|
@ -59,9 +59,7 @@ export async function GET(
|
|||
return new NextResponse("File not found", { status: 404 })
|
||||
}
|
||||
|
||||
const fileToRead = existsSync(normalizedFullPath)
|
||||
? normalizedFullPath
|
||||
: fullPath
|
||||
const fileToRead = existsSync(normalizedFullPath) ? normalizedFullPath : fullPath
|
||||
const resolvedPath = fileToRead.replace(/\\/g, "/")
|
||||
if (!resolvedPath.startsWith(normalizedManualsDir)) {
|
||||
return new NextResponse("Invalid path", { status: 400 })
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth"
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireAdminToken } from '@/lib/server/admin-auth'
|
||||
|
||||
// Order types
|
||||
interface OrderItem {
|
||||
|
|
@ -17,7 +17,7 @@ interface Order {
|
|||
items: OrderItem[]
|
||||
totalAmount: number
|
||||
currency: string
|
||||
status: "pending" | "paid" | "fulfilled" | "cancelled" | "refunded"
|
||||
status: 'pending' | 'paid' | 'fulfilled' | 'cancelled' | 'refunded'
|
||||
paymentIntentId: string | null
|
||||
stripeSessionId: string | null
|
||||
createdAt: string
|
||||
|
|
@ -38,7 +38,7 @@ let orders: Order[] = []
|
|||
|
||||
// Generate a simple ID for demo
|
||||
function generateOrderId(): string {
|
||||
return "ORD-" + Date.now() + "-" + Math.floor(Math.random() * 1000)
|
||||
return 'ORD-' + Date.now() + '-' + Math.floor(Math.random() * 1000)
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
|
|
@ -49,29 +49,26 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = parseInt(searchParams.get("page") || "1")
|
||||
const limit = parseInt(searchParams.get("limit") || "10")
|
||||
const status = searchParams.get("status") || undefined
|
||||
const customerEmail = searchParams.get("customerEmail") || undefined
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '10')
|
||||
const status = searchParams.get('status') || undefined
|
||||
const customerEmail = searchParams.get('customerEmail') || undefined
|
||||
|
||||
// Filter orders
|
||||
let filteredOrders = [...orders]
|
||||
|
||||
if (status) {
|
||||
filteredOrders = filteredOrders.filter((order) => order.status === status)
|
||||
filteredOrders = filteredOrders.filter(order => order.status === status)
|
||||
}
|
||||
|
||||
if (customerEmail) {
|
||||
filteredOrders = filteredOrders.filter((order) =>
|
||||
filteredOrders = filteredOrders.filter(order =>
|
||||
order.customerEmail.toLowerCase().includes(customerEmail.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
// Sort by creation date (newest first)
|
||||
filteredOrders.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)
|
||||
filteredOrders.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
|
||||
// Pagination
|
||||
const startIndex = (page - 1) * limit
|
||||
|
|
@ -84,13 +81,13 @@ export async function GET(request: NextRequest) {
|
|||
page,
|
||||
limit,
|
||||
total: filteredOrders.length,
|
||||
totalPages: Math.ceil(filteredOrders.length / limit),
|
||||
},
|
||||
totalPages: Math.ceil(filteredOrders.length / limit)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error fetching orders:", error)
|
||||
console.error('Error fetching orders:', error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch orders" },
|
||||
{ error: 'Failed to fetch orders' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
|
@ -104,43 +101,40 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const {
|
||||
items,
|
||||
customerEmail,
|
||||
paymentIntentId,
|
||||
stripeSessionId,
|
||||
shippingAddress,
|
||||
} = body
|
||||
const { items, customerEmail, paymentIntentId, stripeSessionId, shippingAddress } = body
|
||||
|
||||
// Validate required fields
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return NextResponse.json({ error: "Items are required" }, { status: 400 })
|
||||
return NextResponse.json(
|
||||
{ error: 'Items are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!customerEmail) {
|
||||
return NextResponse.json(
|
||||
{ error: "Customer email is required" },
|
||||
{ error: 'Customer email is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!paymentIntentId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Payment intent ID is required" },
|
||||
{ error: 'Payment intent ID is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!stripeSessionId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Stripe session ID is required" },
|
||||
{ error: 'Stripe session ID is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate total
|
||||
const totalAmount = items.reduce((total: number, item: OrderItem) => {
|
||||
return total + item.price * item.quantity
|
||||
return total + (item.price * item.quantity)
|
||||
}, 0)
|
||||
|
||||
// Create order
|
||||
|
|
@ -150,22 +144,22 @@ export async function POST(request: NextRequest) {
|
|||
customerEmail,
|
||||
items,
|
||||
totalAmount,
|
||||
currency: "usd",
|
||||
status: "paid", // Assume payment was successful since webhook was triggered
|
||||
currency: 'usd',
|
||||
status: 'paid', // Assume payment was successful since webhook was triggered
|
||||
paymentIntentId,
|
||||
stripeSessionId,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
shippingAddress,
|
||||
shippingAddress
|
||||
}
|
||||
|
||||
orders.unshift(newOrder) // Add to beginning of array
|
||||
|
||||
return NextResponse.json(newOrder, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Error creating order:", error)
|
||||
console.error('Error creating order:', error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create order" },
|
||||
{ error: 'Failed to create order' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getStripeClient } from "@/lib/stripe/client"
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth"
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getStripeClient } from '@/lib/stripe/client'
|
||||
import { requireAdminToken } from '@/lib/server/admin-auth'
|
||||
import {
|
||||
fetchAllProducts,
|
||||
fetchProductById,
|
||||
createProductInStripe,
|
||||
updateProductInStripe,
|
||||
deactivateProductInStripe,
|
||||
} from "@/lib/stripe/products"
|
||||
deactivateProductInStripe
|
||||
} from '@/lib/stripe/products'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = requireAdminToken(request)
|
||||
|
|
@ -17,10 +17,10 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = parseInt(searchParams.get("page") || "1")
|
||||
const limit = parseInt(searchParams.get("limit") || "20")
|
||||
const search = searchParams.get("search") || undefined
|
||||
const category = searchParams.get("category") || undefined
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const search = searchParams.get('search') || undefined
|
||||
const category = searchParams.get('category') || undefined
|
||||
|
||||
// Get all products from Stripe
|
||||
const products = await fetchAllProducts()
|
||||
|
|
@ -30,10 +30,9 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
if (search) {
|
||||
const searchTerm = search.toLowerCase()
|
||||
filteredProducts = filteredProducts.filter(
|
||||
(product) =>
|
||||
product.name.toLowerCase().includes(searchTerm) ||
|
||||
product.description?.toLowerCase().includes(searchTerm)
|
||||
filteredProducts = filteredProducts.filter(product =>
|
||||
product.name.toLowerCase().includes(searchTerm) ||
|
||||
product.description?.toLowerCase().includes(searchTerm)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -58,13 +57,13 @@ export async function GET(request: NextRequest) {
|
|||
page,
|
||||
limit,
|
||||
total: filteredProducts.length,
|
||||
totalPages: Math.ceil(filteredProducts.length / limit),
|
||||
},
|
||||
totalPages: Math.ceil(filteredProducts.length / limit)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error fetching admin products:", error)
|
||||
console.error('Error fetching admin products:', error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch products" },
|
||||
{ error: 'Failed to fetch products' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
|
@ -83,14 +82,14 @@ export async function POST(request: NextRequest) {
|
|||
// Validate required fields
|
||||
if (!name || !price) {
|
||||
return NextResponse.json(
|
||||
{ error: "Name and price are required" },
|
||||
{ error: 'Name and price are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof price !== "number" || price <= 0) {
|
||||
if (typeof price !== 'number' || price <= 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Price must be a positive number" },
|
||||
{ error: 'Price must be a positive number' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
|
@ -98,25 +97,25 @@ export async function POST(request: NextRequest) {
|
|||
// Create product in Stripe
|
||||
const result = await createProductInStripe({
|
||||
name,
|
||||
description: description || "",
|
||||
description: description || '',
|
||||
price,
|
||||
currency: currency || "usd",
|
||||
currency: currency || 'usd',
|
||||
images: images || [],
|
||||
metadata: metadata || {},
|
||||
metadata: metadata || {}
|
||||
})
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create product in Stripe" },
|
||||
{ error: 'Failed to create product in Stripe' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(result, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Error creating product:", error)
|
||||
console.error('Error creating product:', error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create product" },
|
||||
{ error: 'Failed to create product' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
|
@ -135,7 +134,7 @@ export async function PUT(request: NextRequest) {
|
|||
|
||||
if (!action || !Array.isArray(productIds) || productIds.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Action and product IDs are required" },
|
||||
{ error: 'Action and product IDs are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
|
@ -144,10 +143,10 @@ export async function PUT(request: NextRequest) {
|
|||
const results = []
|
||||
|
||||
switch (action) {
|
||||
case "update":
|
||||
case 'update':
|
||||
if (!updates) {
|
||||
return NextResponse.json(
|
||||
{ error: "Updates are required for action: update" },
|
||||
{ error: 'Updates are required for action: update' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
|
@ -158,31 +157,31 @@ export async function PUT(request: NextRequest) {
|
|||
results.push({
|
||||
productId,
|
||||
success: true,
|
||||
data: result,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
results.push({
|
||||
productId,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case "deactivate":
|
||||
case 'deactivate':
|
||||
for (const productId of productIds) {
|
||||
try {
|
||||
const success = await deactivateProductInStripe(productId)
|
||||
results.push({
|
||||
productId,
|
||||
success,
|
||||
success
|
||||
})
|
||||
} catch (error) {
|
||||
results.push({
|
||||
productId,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -200,14 +199,14 @@ export async function PUT(request: NextRequest) {
|
|||
results,
|
||||
summary: {
|
||||
total: productIds.length,
|
||||
successful: results.filter((r) => r.success).length,
|
||||
failed: results.filter((r) => !r.success).length,
|
||||
},
|
||||
successful: results.filter(r => r.success).length,
|
||||
failed: results.filter(r => !r.success).length
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error bulk updating products:", error)
|
||||
console.error('Error bulk updating products:', error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to bulk update products" },
|
||||
{ error: 'Failed to bulk update products' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"
|
|||
import { getGSCConfig } from "@/lib/google-search-console"
|
||||
|
||||
// API routes are not supported in static export (GHL hosting)
|
||||
export const dynamic = "force-static"
|
||||
export const dynamic = 'force-static'
|
||||
|
||||
/**
|
||||
* API Route for requesting URL indexing in Google Search Console
|
||||
|
|
@ -68,14 +68,13 @@ export async function POST(request: NextRequest) {
|
|||
success: true,
|
||||
message: "Indexing request endpoint ready",
|
||||
url,
|
||||
note: "See docs/operations/SEO_SETUP.md for complete Google Search Console API setup instructions",
|
||||
note: "See SEO_SETUP.md for complete Google Search Console API setup instructions",
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
error: error instanceof Error ? error.message : "Unknown error occurred",
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
|
|
@ -91,7 +90,9 @@ export async function GET() {
|
|||
return NextResponse.json({
|
||||
message: "Google Search Console URL Indexing Request API",
|
||||
configured: !!(config.serviceAccountEmail && config.privateKey),
|
||||
instructions:
|
||||
"POST to this endpoint with { url: 'https://example.com/page' } in body",
|
||||
instructions: "POST to this endpoint with { url: 'https://example.com/page' } in body",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue