Compare commits

..

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

436 changed files with 13318 additions and 37978 deletions

View file

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

View file

@ -1,12 +0,0 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

View file

@ -33,15 +33,6 @@ ADMIN_EMAIL=
# Direct phone-call visibility
PHONE_AGENT_INTERNAL_TOKEN=
PHONE_CALL_SUMMARY_FROM_EMAIL=
ENABLE_GHL_SYNC=false
GOOGLE_CALENDAR_CLIENT_ID=
GOOGLE_CALENDAR_CLIENT_SECRET=
GOOGLE_CALENDAR_REFRESH_TOKEN=
GOOGLE_CALENDAR_ID=
GOOGLE_CALENDAR_TIMEZONE=America/Denver
GOOGLE_CALENDAR_CALLBACK_SLOT_MINUTES=15
GOOGLE_CALENDAR_CALLBACK_START_HOUR=8
GOOGLE_CALENDAR_CALLBACK_END_HOUR=17
# Placeholder for a later LiveKit rollout
LIVEKIT_URL=

View file

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

12
.gitignore vendored
View file

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

View file

@ -2,42 +2,45 @@
module.exports = {
ci: {
collect: {
url: ["http://localhost:3000"],
url: ['http://localhost:3000'],
numberOfRuns: 3,
startServerCommand: "npm run start",
startServerReadyPattern: "ready",
startServerCommand: 'npm run start',
startServerReadyPattern: 'ready',
startServerReadyTimeout: 30000,
},
assert: {
assertions: {
"categories:performance": ["error", { minScore: 1 }],
"categories:accessibility": ["error", { minScore: 1 }],
"categories:best-practices": ["error", { minScore: 1 }],
"categories:seo": ["error", { minScore: 1 }],
'categories:performance': ['error', { minScore: 1 }],
'categories:accessibility': ['error', { minScore: 1 }],
'categories:best-practices': ['error', { minScore: 1 }],
'categories:seo': ['error', { minScore: 1 }],
// Core Web Vitals
"first-contentful-paint": ["error", { maxNumericValue: 1800 }],
"largest-contentful-paint": ["error", { maxNumericValue: 2500 }],
"cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }],
"total-blocking-time": ["error", { maxNumericValue: 200 }],
"speed-index": ["error", { maxNumericValue: 3400 }],
'first-contentful-paint': ['error', { maxNumericValue: 1800 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
'total-blocking-time': ['error', { maxNumericValue: 200 }],
'speed-index': ['error', { maxNumericValue: 3400 }],
// Performance metrics
interactive: ["error", { maxNumericValue: 3800 }],
"uses-optimized-images": "error",
"uses-text-compression": "error",
"uses-responsive-images": "error",
"modern-image-formats": "error",
"offscreen-images": "error",
"render-blocking-resources": "error",
"unused-css-rules": "error",
"unused-javascript": "error",
"efficient-animated-content": "error",
"preload-lcp-image": "error",
"uses-long-cache-ttl": "error",
"total-byte-weight": ["error", { maxNumericValue: 1600000 }],
'interactive': ['error', { maxNumericValue: 3800 }],
'uses-optimized-images': 'error',
'uses-text-compression': 'error',
'uses-responsive-images': 'error',
'modern-image-formats': 'error',
'offscreen-images': 'error',
'render-blocking-resources': 'error',
'unused-css-rules': 'error',
'unused-javascript': 'error',
'efficient-animated-content': 'error',
'preload-lcp-image': 'error',
'uses-long-cache-ttl': 'error',
'total-byte-weight': ['error', { maxNumericValue: 1600000 }],
},
},
upload: {
target: "temporary-public-storage",
target: 'temporary-public-storage',
},
},
}
};

View file

@ -1,15 +0,0 @@
.cursor
.next
.playwright-cli
.pnpm-store
artifacts
node_modules
out
output
pnpm-lock.yaml
public/json-ld
public/manual_inventory.json
public/manual_pages_full.json
public/manual_pages_parts.json
public/manual_pages_text.json
public/manual_parts_lookup.json

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,14 +3,12 @@
## Color Palette
### Primary Colors
- **Background**: `#fff8eb` (Warm cream)
- **Foreground**: Dark text color for body text
- **Primary**: Purple/blue accent color
- **Secondary**: Green accent color
### Link Colors
- **Link Default**: Standard foreground color
- **Link Hover/Active**: `#c4142c` (Red - RGB: 196, 20, 44)
- **Link Highlight**: Red background highlight on hover
@ -18,12 +16,10 @@
## Typography
### Fonts
- **Body Font**: Inter (with fallback)
- **Mono Font**: Geist Mono (with fallback)
### Font Sizes
- Base: 16px
- Small: 14px
- Medium: 16px
@ -32,10 +28,9 @@
## Hyperlinks
### Standard Link Styling
All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these rules:
1. **Default State**:
1. **Default State**:
- Color: Foreground color (readable text color)
- No underline
- Smooth transition on hover
@ -50,7 +45,6 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
- May include underline for emphasis
### 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

View file

@ -1,39 +1,58 @@
import { notFound } from "next/navigation"
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
import { getPageBySlug } from "@/lib/wordpress-data-loader"
import { AboutPage } from "@/components/about-page"
import type { Metadata } from "next"
import { notFound } from 'next/navigation';
import { loadImageMapping } from '@/lib/wordpress-content';
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo';
import { getPageBySlug } from '@/lib/wordpress-data-loader';
import { AboutPage } from '@/components/about-page';
import type { Metadata } from 'next';
const WORDPRESS_SLUG = "about-us"
const WORDPRESS_SLUG = 'about-us';
export async function generateMetadata(): Promise<Metadata> {
const page = getPageBySlug(WORDPRESS_SLUG)
const page = getPageBySlug(WORDPRESS_SLUG);
if (!page) {
return {
title: "Page Not Found | Rocky Mountain Vending",
}
title: 'Page Not Found | Rocky Mountain Vending',
};
}
return generateRegistryMetadata("aboutUs", {
return generateSEOMetadata({
title: page.title || 'About Us',
description: page.seoDescription || page.excerpt || '',
excerpt: page.excerpt,
date: page.date,
modified: page.modified,
image: page.images?.[0]?.localPath,
})
});
}
export default async function AboutUsPage() {
try {
const page = getPageBySlug(WORDPRESS_SLUG)
const page = getPageBySlug(WORDPRESS_SLUG);
if (!page) {
notFound()
notFound();
}
const structuredData = generateRegistryStructuredData("aboutUs", {
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();
}
}

View file

@ -1,74 +1,83 @@
import { notFound } from "next/navigation"
import { buildAbsoluteUrl } from "@/lib/seo-registry"
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
import { Breadcrumbs } from "@/components/breadcrumbs"
import { getPageBySlug } from "@/lib/wordpress-data-loader"
import { FAQSchema } from "@/components/faq-schema"
import { FAQSection } from "@/components/faq-section"
import type { Metadata } from "next"
import { notFound } from 'next/navigation';
import { loadImageMapping } from '@/lib/wordpress-content';
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo';
import { getPageBySlug } from '@/lib/wordpress-data-loader';
import { FAQSchema } from '@/components/faq-schema';
import { FAQSection } from '@/components/faq-section';
import type { Metadata } from 'next';
const WORDPRESS_SLUG = "faqs"
const WORDPRESS_SLUG = 'faqs';
export async function generateMetadata(): Promise<Metadata> {
const page = getPageBySlug(WORDPRESS_SLUG)
const page = getPageBySlug(WORDPRESS_SLUG);
if (!page) {
return {
title: "Page Not Found | Rocky Mountain Vending",
}
title: 'Page Not Found | Rocky Mountain Vending',
};
}
return generateRegistryMetadata("faqs", {
return generateSEOMetadata({
title: page.title || 'FAQs',
description: page.seoDescription || page.excerpt || '',
excerpt: page.excerpt,
date: page.date,
modified: page.modified,
image: page.images?.[0]?.localPath,
})
});
}
export default async function FAQsPage() {
try {
const page = getPageBySlug(WORDPRESS_SLUG)
const page = getPageBySlug(WORDPRESS_SLUG);
if (!page) {
notFound()
notFound();
}
// Extract FAQs from content
const faqs: Array<{ question: string; answer: string }> = []
const faqs: Array<{ question: string; answer: string }> = [];
if (page.content) {
const contentStr = String(page.content)
const contentStr = String(page.content);
// Extract FAQ items from accordion structure
const questionMatches = contentStr.matchAll(
/<span class="ekit-accordion-title">([^<]+)<\/span>/g
)
const questionMatches = contentStr.matchAll(/<span class="ekit-accordion-title">([^<]+)<\/span>/g);
// Extract full answer content
const answerMatches = contentStr.matchAll(
/<div class="elementskit-card-body ekit-accordion--content">([\s\S]*?)<\/div>\s*<\/div>\s*<!-- \.elementskit-card END -->/g
)
const 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 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;
});
// 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={page.link || page.urlPath || `https://rockymountainvending.com/about/faqs/`}
/>
<FAQSchema
faqs={faqs}
pageUrl={pageUrl}
/>
<FAQSection faqs={faqs} className="pt-0" />
</div>
<FAQSection faqs={faqs} />
</>
)}
</>
)
);
} catch (error) {
if (process.env.NODE_ENV === "development") {
console.error("Error rendering FAQs page:", error)
if (process.env.NODE_ENV === 'development') {
console.error('Error rendering FAQs page:', error);
}
notFound()
notFound();
}
}

View file

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

View file

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

View file

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

View file

@ -1,199 +0,0 @@
import Link from "next/link"
import { notFound } from "next/navigation"
import { fetchQuery } from "convex/nextjs"
import { ArrowLeft, ContactRound, MessageSquare } from "lucide-react"
import { api } from "@/convex/_generated/api"
import { Badge } from "@/components/ui/badge"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
type PageProps = {
params: Promise<{
id: string
}>
}
function formatTimestamp(value?: number) {
if (!value) {
return "—"
}
return new Date(value).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
export default async function AdminContactDetailPage({ params }: PageProps) {
const { id } = await params
const detail = await fetchQuery(api.crm.getAdminContactDetail, {
contactId: id,
})
if (!detail) {
notFound()
}
return (
<div className="container mx-auto px-4 py-8">
<div className="space-y-8">
<div className="space-y-2">
<Link
href="/admin/contacts"
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back to contacts
</Link>
<h1 className="text-4xl font-bold tracking-tight text-balance">
{detail.contact.displayName}
</h1>
<p className="text-muted-foreground">
Contact details and activity history.
</p>
</div>
<div className="grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ContactRound className="h-5 w-5" />
Contact Profile
</CardTitle>
<CardDescription>Basic details and connected records.</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Email
</p>
<p className="font-medium break-all">
{detail.contact.email || "—"}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Phone
</p>
<p className="font-medium">{detail.contact.phone || "—"}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Company
</p>
<p className="font-medium">{detail.contact.company || "—"}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Status
</p>
<Badge className="mt-1" variant="secondary">
{detail.contact.status}
</Badge>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
GHL Contact ID
</p>
<p className="font-medium break-all">
{detail.contact.ghlContactId || "—"}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Last Activity
</p>
<p className="font-medium">
{formatTimestamp(detail.contact.lastActivityAt)}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Conversations
</CardTitle>
<CardDescription>
Conversations linked to this contact.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{detail.conversations.length === 0 ? (
<p className="text-sm text-muted-foreground">
No conversations are linked to this contact yet.
</p>
) : (
detail.conversations.map((conversation: any) => (
<div key={conversation.id} className="rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-medium">
{conversation.title || detail.contact.displayName}
</p>
<p className="text-xs text-muted-foreground">
{conversation.channel} {" "}
{formatTimestamp(conversation.lastMessageAt)}
</p>
</div>
<Link href={`/admin/conversations/${conversation.id}`}>
<Badge variant="outline">{conversation.status}</Badge>
</Link>
</div>
<p className="mt-2 text-sm text-muted-foreground">
{conversation.lastMessagePreview || "No preview yet"}
</p>
</div>
))
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Timeline</CardTitle>
<CardDescription>
Calls, messages, recordings, and lead events in one stream.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{detail.timeline.length === 0 ? (
<p className="text-sm text-muted-foreground">
No timeline activity for this contact yet.
</p>
) : (
detail.timeline.map((item: any) => (
<div key={`${item.type}-${item.id}`} className="rounded-lg border p-3">
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span className="uppercase tracking-wide">{item.type}</span>
<span>{formatTimestamp(item.timestamp)}</span>
</div>
<p className="mt-1 font-medium">{item.title || "Untitled"}</p>
<p className="mt-1 text-sm text-muted-foreground whitespace-pre-wrap">
{item.body || "—"}
</p>
</div>
))
)}
</CardContent>
</Card>
</div>
</div>
)
}
export const metadata = {
title: "Contact Detail | Admin",
description: "Review a contact and full interaction timeline",
}

View file

@ -1,202 +0,0 @@
import Link from "next/link"
import { fetchQuery } from "convex/nextjs"
import { ContactRound, Search } from "lucide-react"
import { api } from "@/convex/_generated/api"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
type PageProps = {
searchParams: Promise<{
search?: string
page?: string
}>
}
function formatTimestamp(value?: number) {
if (!value) {
return "—"
}
return new Date(value).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
function getSyncMessage(sync: any) {
if (!sync.ghlConfigured) {
return "Connect GHL to load contacts and conversations."
}
if (sync.stages.contacts.status === "running") {
return "Contacts are syncing now."
}
if (sync.stages.contacts.error) {
return "Contacts could not be loaded from GHL yet."
}
if (!sync.latestSyncAt) {
return "No contacts yet."
}
return "Your contact list stays up to date from forms, calls, and GHL."
}
export default async function AdminContactsPage({ searchParams }: PageProps) {
const params = await searchParams
const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1)
const search = params.search?.trim() || undefined
const data = await fetchQuery(api.crm.listAdminContacts, {
search,
page,
limit: 25,
})
return (
<div className="container mx-auto px-4 py-8">
<div className="space-y-8">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<h1 className="text-4xl font-bold tracking-tight text-balance">
Contacts
</h1>
<p className="mt-2 text-muted-foreground">
All customer contacts in one place.
</p>
</div>
<Link href="/admin">
<Button variant="outline">Back to Admin</Button>
</Link>
</div>
<Card>
<CardHeader>
<CardTitle>Sync Status</CardTitle>
<CardDescription>{getSyncMessage(data.sync)}</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-3 text-sm text-muted-foreground">
<Badge variant="outline">{data.sync.overallStatus}</Badge>
<span>
Last sync: {formatTimestamp(data.sync.latestSyncAt || undefined)}
</span>
{!data.sync.ghlConfigured ? (
<span>GHL is not connected.</span>
) : null}
{data.sync.stages.contacts.error ? (
<span>{data.sync.stages.contacts.error}</span>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ContactRound className="h-5 w-5" />
Contact Directory
</CardTitle>
<CardDescription>
Search by name, email, phone, company, or tag.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
name="search"
defaultValue={search || ""}
placeholder="Search contacts"
className="pl-9"
/>
</div>
<Button type="submit">Filter</Button>
</form>
<div className="overflow-x-auto">
<table className="w-full min-w-[980px] text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-3 pr-4 font-medium">Contact</th>
<th className="py-3 pr-4 font-medium">Company</th>
<th className="py-3 pr-4 font-medium">Status</th>
<th className="py-3 pr-4 font-medium">Conversations</th>
<th className="py-3 pr-4 font-medium">Leads</th>
<th className="py-3 pr-4 font-medium">Last Activity</th>
<th className="py-3 font-medium">Open</th>
</tr>
</thead>
<tbody>
{data.items.length === 0 ? (
<tr>
<td
colSpan={7}
className="py-8 text-center text-muted-foreground"
>
{search
? "No contacts matched this search."
: getSyncMessage(data.sync)}
</td>
</tr>
) : (
data.items.map((contact: any) => (
<tr
key={contact.id}
className="border-b align-top last:border-b-0"
>
<td className="py-3 pr-4">
<div className="font-medium">{contact.displayName}</div>
{contact.email ? (
<div className="text-xs text-muted-foreground">
{contact.email}
</div>
) : null}
{contact.phone ? (
<div className="text-xs text-muted-foreground">
{contact.phone}
</div>
) : null}
</td>
<td className="py-3 pr-4">
{contact.company || "—"}
</td>
<td className="py-3 pr-4">
<Badge variant="secondary">{contact.status}</Badge>
</td>
<td className="py-3 pr-4">{contact.conversationCount}</td>
<td className="py-3 pr-4">{contact.leadCount}</td>
<td className="py-3 pr-4">
{formatTimestamp(contact.lastActivityAt)}
</td>
<td className="py-3">
<Link href={`/admin/contacts/${contact.id}`}>
<Button size="sm" variant="outline">
View
</Button>
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
</div>
)
}
export const metadata = {
title: "Contacts | Admin",
description: "View Rocky customer contacts",
}

View file

@ -1,19 +0,0 @@
import { redirect } from "next/navigation"
type PageProps = {
params: Promise<{
id: string
}>
}
export default async function AdminConversationDetailRedirect({
params,
}: PageProps) {
const { id } = await params
redirect(`/admin/conversations?conversationId=${encodeURIComponent(id)}`)
}
export const metadata = {
title: "Conversation Detail | Admin",
description: "Open a conversation in the inbox view",
}

View file

@ -1,518 +0,0 @@
import Link from "next/link"
import { fetchAction, fetchQuery } from "convex/nextjs"
import { MessageSquare, Phone, Search } from "lucide-react"
import { api } from "@/convex/_generated/api"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
type PageProps = {
searchParams: Promise<{
search?: string
channel?: "call" | "sms" | "chat" | "unknown"
status?: "open" | "closed" | "archived"
conversationId?: string
error?: string
page?: string
}>
}
function formatTimestamp(value?: number) {
if (!value) {
return "—"
}
return new Date(value).toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})
}
function formatSidebarTimestamp(value?: number) {
if (!value) {
return ""
}
const date = new Date(value)
const now = new Date()
const sameDay = date.toDateString() === now.toDateString()
return sameDay
? date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
})
: date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
}
function formatDuration(value?: number) {
if (!value) {
return "—"
}
const totalSeconds = Math.max(0, Math.round(value / 1000))
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${minutes}:${String(seconds).padStart(2, "0")}`
}
function getSyncMessage(sync: any) {
if (!sync.ghlConfigured) {
return "Connect GHL to load contacts and conversations."
}
if (sync.stages.conversations.status === "running") {
return "Conversations are syncing now."
}
if (sync.stages.conversations.error) {
return "Conversations could not be loaded from GHL yet."
}
if (!sync.latestSyncAt) {
return "No conversations yet."
}
return "Browse contacts and conversations in one inbox."
}
function getInitials(value?: string) {
const text = String(value || "").trim()
if (!text) {
return "RM"
}
const parts = text.split(/\s+/).filter(Boolean)
if (parts.length === 1) {
return parts[0].slice(0, 2).toUpperCase()
}
return `${parts[0][0] || ""}${parts[1][0] || ""}`.toUpperCase()
}
function buildConversationHref(params: {
search?: string
channel?: string
status?: string
conversationId?: string
}) {
const nextParams = new URLSearchParams()
if (params.search) {
nextParams.set("search", params.search)
}
if (params.channel) {
nextParams.set("channel", params.channel)
}
if (params.status) {
nextParams.set("status", params.status)
}
if (params.conversationId) {
nextParams.set("conversationId", params.conversationId)
}
const query = nextParams.toString()
return query ? `/admin/conversations?${query}` : "/admin/conversations"
}
export default async function AdminConversationsPage({
searchParams,
}: PageProps) {
const params = await searchParams
const search = params.search?.trim() || undefined
const data = await fetchQuery(api.crm.listAdminConversations, {
search,
page: 1,
limit: 100,
channel: params.channel,
status: params.status,
})
const selectedConversationId =
(params.conversationId &&
data.items.find((item: any) => item.id === params.conversationId)?.id) ||
data.items[0]?.id
const detail = selectedConversationId
? await fetchQuery(api.crm.getAdminConversationDetail, {
conversationId: selectedConversationId,
})
: null
const hydratedDetail =
detail &&
detail.messages.length === 0 &&
detail.conversation.ghlConversationId
? await fetchAction(api.crm.hydrateConversationHistory, {
conversationId: detail.conversation.id,
}).then(async (result) => {
if (result?.imported) {
return await fetchQuery(api.crm.getAdminConversationDetail, {
conversationId: detail.conversation.id,
})
}
return detail
})
: detail
const timeline = hydratedDetail
? [
...hydratedDetail.messages.map((message: any) => ({
id: `message-${message.id}`,
type: "message" as const,
timestamp: message.sentAt || 0,
message,
})),
...hydratedDetail.recordings.map((recording: any) => ({
id: `recording-${recording.id}`,
type: "recording" as const,
timestamp: recording.startedAt || recording.endedAt || 0,
recording,
})),
].sort((a, b) => a.timestamp - b.timestamp)
: []
return (
<div className="container mx-auto px-4 py-8">
<div className="space-y-6">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<h1 className="text-4xl font-bold tracking-tight text-balance">
Conversations
</h1>
<p className="mt-2 text-muted-foreground">
Review calls and messages in one inbox.
</p>
</div>
<Link href="/admin">
<Button variant="outline">Back to Admin</Button>
</Link>
</div>
<Card className="rounded-[2rem]">
<CardContent className="flex flex-wrap items-center gap-3 px-6 py-4 text-sm text-muted-foreground">
<Badge variant="outline">{data.sync.overallStatus}</Badge>
<span>{getSyncMessage(data.sync)}</span>
<span>Last sync: {formatTimestamp(data.sync.latestSyncAt || undefined)}</span>
</CardContent>
</Card>
<Card className="overflow-hidden rounded-[2rem] p-0">
<div className="grid min-h-[720px] lg:grid-cols-[360px_minmax(0,1fr)]">
<div className="border-b bg-white lg:border-b-0 lg:border-r">
<div className="space-y-4 border-b px-5 py-5">
<div className="flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="font-semibold">Conversation Inbox</h2>
<p className="text-sm text-muted-foreground">
Search and pick a conversation to review.
</p>
</div>
</div>
<form className="space-y-3">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
name="search"
defaultValue={search || ""}
placeholder="Search contacts or messages"
className="pl-9"
/>
</div>
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
<select
name="channel"
defaultValue={params.channel || ""}
className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="">All channels</option>
<option value="call">Call</option>
<option value="sms">SMS</option>
<option value="chat">Chat</option>
<option value="unknown">Unknown</option>
</select>
<select
name="status"
defaultValue={params.status || ""}
className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="">All statuses</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="archived">Archived</option>
</select>
<Button type="submit">Filter</Button>
</div>
</form>
</div>
<ScrollArea className="h-[520px] lg:h-[640px]">
<div className="divide-y">
{data.items.length === 0 ? (
<div className="px-5 py-8 text-sm text-muted-foreground">
{search || params.channel || params.status
? "No conversations matched this search."
: getSyncMessage(data.sync)}
</div>
) : (
data.items.map((conversation: any) => {
const isSelected = conversation.id === selectedConversationId
return (
<Link
key={conversation.id}
href={buildConversationHref({
search,
channel: params.channel,
status: params.status,
conversationId: conversation.id,
})}
className={[
"flex gap-3 px-5 py-4 transition-colors",
isSelected
? "bg-primary/5 ring-1 ring-inset ring-primary/20"
: "hover:bg-muted/40",
].join(" ")}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
{getInitials(conversation.displayName)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate font-medium">
{conversation.displayName}
</p>
{conversation.secondaryLine ? (
<p className="truncate text-xs text-muted-foreground">
{conversation.secondaryLine}
</p>
) : null}
</div>
<span className="shrink-0 text-xs text-muted-foreground">
{formatSidebarTimestamp(conversation.lastMessageAt)}
</span>
</div>
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">
{conversation.lastMessagePreview ||
"No messages or call notes yet."}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Badge variant="outline">{conversation.channel}</Badge>
<Badge variant="secondary">{conversation.status}</Badge>
{conversation.recordingCount ? (
<Badge variant="outline">
{conversation.recordingCount} recording
{conversation.recordingCount === 1 ? "" : "s"}
</Badge>
) : null}
</div>
</div>
</Link>
)
})
)}
</div>
</ScrollArea>
</div>
<div className="bg-[#faf8f3]">
{hydratedDetail ? (
<div className="flex h-full flex-col">
<div className="border-b bg-white px-6 py-5">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-2">
<div>
<h2 className="text-2xl font-semibold">
{hydratedDetail.contact?.name ||
hydratedDetail.conversation.title ||
"Conversation"}
</h2>
{hydratedDetail.contact?.secondaryLine ||
hydratedDetail.contact?.email ||
hydratedDetail.contact?.phone ? (
<p className="text-sm text-muted-foreground">
{hydratedDetail.contact?.secondaryLine ||
hydratedDetail.contact?.phone ||
hydratedDetail.contact?.email}
</p>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">
{hydratedDetail.conversation.channel}
</Badge>
<Badge variant="secondary">
{hydratedDetail.conversation.status}
</Badge>
<Badge variant="outline">
{timeline.filter((item) => item.type === "message").length}{" "}
messages
</Badge>
{hydratedDetail.recordings.length ? (
<Badge variant="outline">
{hydratedDetail.recordings.length} recording
{hydratedDetail.recordings.length === 1 ? "" : "s"}
</Badge>
) : null}
</div>
</div>
<div className="text-sm text-muted-foreground">
Last activity:{" "}
{formatTimestamp(hydratedDetail.conversation.lastMessageAt)}
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-3">
<form
action={`/api/admin/conversations/${hydratedDetail.conversation.id}/sync`}
method="post"
>
<Button type="submit" variant="outline" size="sm">
Refresh history
</Button>
</form>
{params.error === "send" ? (
<p className="text-sm text-destructive">
Rocky could not send that message through GHL.
</p>
) : null}
{params.error === "sync" ? (
<p className="text-sm text-destructive">
Rocky could not refresh that conversation from GHL.
</p>
) : null}
</div>
</div>
<ScrollArea className="h-[520px] px-4 py-5 lg:h-[640px] lg:px-6">
<div className="space-y-4 pb-2">
{timeline.length === 0 ? (
<div className="rounded-2xl border border-dashed bg-white/70 px-6 py-10 text-center text-sm text-muted-foreground">
No messages or recordings have been mirrored into this
conversation yet. Use refresh history to pull the latest
thread from GHL.
</div>
) : (
timeline.map((item: any) => {
if (item.type === "recording") {
const recording = item.recording
return (
<div key={item.id} className="max-w-2xl rounded-2xl border bg-white p-4 shadow-sm">
<div className="flex items-center gap-2 text-sm font-medium">
<Phone className="h-4 w-4 text-muted-foreground" />
Call recording
<Badge variant="outline" className="ml-2">
{recording.recordingStatus || "recording"}
</Badge>
</div>
<div className="mt-2 flex flex-wrap gap-4 text-xs text-muted-foreground">
<span>{formatTimestamp(recording.startedAt)}</span>
<span>Duration: {formatDuration(recording.durationMs)}</span>
</div>
{recording.recordingUrl ? (
<div className="mt-3">
<a
href={recording.recordingUrl}
target="_blank"
className="text-sm font-medium text-primary hover:underline"
>
Open recording
</a>
</div>
) : null}
{recording.transcriptionText ? (
<div className="mt-3 rounded-xl border bg-muted/30 p-3 text-sm whitespace-pre-wrap text-foreground/90">
{recording.transcriptionText}
</div>
) : null}
</div>
)
}
const message = item.message
const isOutbound = message.direction === "outbound"
return (
<div
key={item.id}
className={`flex ${isOutbound ? "justify-end" : "justify-start"}`}
>
<div
className={[
"max-w-[85%] rounded-3xl px-4 py-3 shadow-sm",
isOutbound
? "bg-primary text-primary-foreground"
: "border bg-white",
].join(" ")}
>
<div className="mb-2 flex items-center gap-2 text-[11px] uppercase tracking-wide opacity-70">
<span>{message.channel}</span>
<span>{message.direction}</span>
{message.status ? <span>{message.status}</span> : null}
</div>
<p className="whitespace-pre-wrap text-sm leading-6">
{message.body}
</p>
<div className="mt-2 text-right text-xs opacity-70">
{formatTimestamp(message.sentAt)}
</div>
</div>
</div>
)
})
)}
</div>
</ScrollArea>
<div className="border-t bg-white px-4 py-4 lg:px-6">
<form
action={`/api/admin/conversations/${hydratedDetail.conversation.id}/messages`}
method="post"
className="space-y-3"
>
<textarea
name="body"
rows={4}
placeholder="Reply to this conversation"
className="border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 min-h-24 w-full rounded-2xl border bg-background px-4 py-3 text-sm shadow-sm outline-none focus-visible:ring-[3px]"
/>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
Sends through GHL and mirrors the reply back into Rocky.
</p>
<Button type="submit">Send message</Button>
</div>
</form>
</div>
</div>
) : (
<div className="flex h-full min-h-[520px] items-center justify-center px-6 py-16">
<div className="max-w-md text-center">
<h2 className="text-2xl font-semibold">No conversation selected</h2>
<p className="mt-2 text-sm text-muted-foreground">
Choose a conversation from the left to open the full thread.
</p>
</div>
</div>
)}
</div>
</div>
</Card>
</div>
</div>
)
}
export const metadata = {
title: "Conversations | Admin",
description: "View Rocky customer conversations",
}

View file

@ -1,9 +1,5 @@
import Link from "next/link"
import { redirect } from "next/navigation"
import {
getAdminUserFromCookies,
isAdminUiEnabled,
} from "@/lib/server/admin-auth"
import { redirect } from "next/navigation";
import { isAdminUiEnabled } from "@/lib/server/admin-auth";
export default async function AdminLayout({
children,
@ -11,32 +7,8 @@ export default async function AdminLayout({
children: React.ReactNode
}) {
if (!isAdminUiEnabled()) {
redirect("/")
redirect("/");
}
const adminUser = await getAdminUserFromCookies()
if (!adminUser) {
redirect("/sign-in")
}
return (
<div className="min-h-screen bg-muted/30">
<div className="border-b bg-background">
<div className="container mx-auto flex items-center justify-between px-4 py-3 text-sm">
<div className="flex items-center gap-3">
<Link href="/admin" className="font-semibold hover:text-primary">
Rocky Admin
</Link>
<span className="text-muted-foreground">{adminUser.email}</span>
</div>
<form action="/api/admin/auth/logout" method="post">
<button className="text-muted-foreground hover:text-foreground">
Sign out
</button>
</form>
</div>
</div>
{children}
</div>
)
return <>{children}</>;
}

View file

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

View file

@ -1,32 +1,22 @@
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 {
ShoppingCart,
Package,
Users,
TrendingUp,
DollarSign,
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,
Users,
TrendingUp,
DollarSign,
Clock,
CheckCircle,
Truck,
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>
@ -446,7 +371,7 @@ export default async function AdminDashboard() {
</CardContent>
</Card>
</Link>
<Link href="/admin/products">
<Card className="h-full cursor-pointer hover:shadow-md transition-shadow">
<CardContent className="p-6 flex flex-col items-center text-center">
@ -458,7 +383,7 @@ export default async function AdminDashboard() {
</CardContent>
</Card>
</Link>
<Link href="/orders">
<Card className="h-full cursor-pointer hover:shadow-md transition-shadow">
<CardContent className="p-6 flex flex-col items-center text-center">
@ -470,7 +395,7 @@ export default async function AdminDashboard() {
</CardContent>
</Card>
</Link>
<Card className="h-full hover:shadow-md transition-shadow">
<CardContent className="p-6 flex flex-col items-center text-center">
<CheckCircle className="h-8 w-8 text-orange-600 mb-3" />
@ -489,7 +414,6 @@ export default async function AdminDashboard() {
}
export const metadata = {
title: "Admin Dashboard | Rocky Mountain Vending",
description:
"Administrative dashboard for managing your vending machine business",
title: 'Admin Dashboard | Rocky Mountain Vending',
description: 'Administrative dashboard for managing your vending machine business',
}

View file

@ -1,4 +1,4 @@
import { ProductAdmin } from "@/components/product-admin"
import { ProductAdmin } from '@/components/product-admin'
export default function AdminProductsPage() {
return (
@ -9,6 +9,6 @@ export default function AdminProductsPage() {
}
export const metadata = {
title: "Product Management | Admin",
description: "Manage your Stripe products and inventory",
title: 'Product Management | Admin',
description: 'Manage your Stripe products and inventory',
}

View file

@ -1,70 +0,0 @@
import { headers } from "next/headers"
import { NextResponse } from "next/server"
import {
ADMIN_SESSION_COOKIE,
createAdminSession,
isAdminCredentialLoginConfigured,
isAdminCredentialMatch,
} from "@/lib/server/admin-auth"
export async function POST(request: Request) {
if (!isAdminCredentialLoginConfigured()) {
return NextResponse.redirect(
new URL("/sign-in?error=config", await getPublicOrigin(request))
)
}
const formData = await request.formData()
const email = String(formData.get("email") || "")
.trim()
.toLowerCase()
const password = String(formData.get("password") || "")
if (!isAdminCredentialMatch(email, password)) {
return NextResponse.redirect(
new URL("/sign-in?error=invalid", await getPublicOrigin(request))
)
}
const session = await createAdminSession(email)
const response = NextResponse.redirect(
new URL("/admin", await getPublicOrigin(request))
)
response.cookies.set(ADMIN_SESSION_COOKIE, session.token, {
httpOnly: true,
sameSite: "lax",
secure: true,
path: "/",
expires: new Date(session.expiresAt),
})
return response
}
async function getPublicOrigin(request: Request) {
const headerStore = await headers()
const origin = headerStore.get("origin")
if (origin) {
return origin
}
const referer = headerStore.get("referer")
if (referer) {
return new URL(referer).origin
}
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL
if (siteUrl) {
return siteUrl
}
const forwardedProto = headerStore.get("x-forwarded-proto")
const forwardedHost = headerStore.get("x-forwarded-host")
const host = forwardedHost || headerStore.get("host")
if (host) {
return `${forwardedProto || "https"}://${host}`
}
return new URL(request.url).origin
}

View file

@ -1,53 +0,0 @@
import { NextResponse } from "next/server"
import { cookies, headers } from "next/headers"
import {
ADMIN_SESSION_COOKIE,
destroyAdminSession,
} from "@/lib/server/admin-auth"
export async function POST(request: Request) {
const cookieStore = await cookies()
const rawToken = cookieStore.get(ADMIN_SESSION_COOKIE)?.value || null
await destroyAdminSession(rawToken)
const response = NextResponse.redirect(
new URL("/sign-in", await getPublicOrigin(request))
)
response.cookies.set(ADMIN_SESSION_COOKIE, "", {
httpOnly: true,
sameSite: "lax",
secure: true,
path: "/",
expires: new Date(0),
})
return response
}
async function getPublicOrigin(request: Request) {
const headerStore = await headers()
const origin = headerStore.get("origin")
if (origin) {
return origin
}
const referer = headerStore.get("referer")
if (referer) {
return new URL(referer).origin
}
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL
if (siteUrl) {
return siteUrl
}
const forwardedProto = headerStore.get("x-forwarded-proto")
const forwardedHost = headerStore.get("x-forwarded-host")
const host = forwardedHost || headerStore.get("host")
if (host) {
return `${forwardedProto || "https"}://${host}`
}
return new URL(request.url).origin
}

View file

@ -1,39 +1,33 @@
import { NextResponse } from "next/server"
import { fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireAdminToken } from "@/lib/server/admin-auth"
import { NextResponse } from "next/server";
import { fetchQuery } from "convex/nextjs";
import { api } from "@/convex/_generated/api";
import { requireAdminToken } from "@/lib/server/admin-auth";
type RouteContext = {
params: Promise<{
id: string
}>
}
id: string;
}>;
};
export async function GET(request: Request, { params }: RouteContext) {
const authError = requireAdminToken(request)
const authError = requireAdminToken(request);
if (authError) {
return authError
return authError;
}
try {
const { id } = await params
const { id } = await params;
const detail = await fetchQuery(api.voiceSessions.getAdminPhoneCallDetail, {
callId: id,
})
});
if (!detail) {
return NextResponse.json(
{ error: "Phone call not found" },
{ status: 404 }
)
return NextResponse.json({ error: "Phone call not found" }, { status: 404 });
}
return NextResponse.json(detail)
return NextResponse.json(detail);
} catch (error) {
console.error("Failed to load admin phone call detail:", error)
return NextResponse.json(
{ error: "Failed to load phone call detail" },
{ status: 500 }
)
console.error("Failed to load admin phone call detail:", error);
return NextResponse.json({ error: "Failed to load phone call detail" }, { status: 500 });
}
}

View file

@ -1,37 +1,31 @@
import { NextResponse } from "next/server"
import { fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireAdminToken } from "@/lib/server/admin-auth"
import { NextResponse } from "next/server";
import { fetchQuery } from "convex/nextjs";
import { api } from "@/convex/_generated/api";
import { requireAdminToken } from "@/lib/server/admin-auth";
export async function GET(request: Request) {
const authError = requireAdminToken(request)
const authError = requireAdminToken(request);
if (authError) {
return authError
return authError;
}
try {
const { searchParams } = new URL(request.url)
const search = searchParams.get("search")?.trim() || undefined
const status = searchParams.get("status")
const page = Number.parseInt(searchParams.get("page") || "1", 10) || 1
const limit = Number.parseInt(searchParams.get("limit") || "25", 10) || 25
const { searchParams } = new URL(request.url);
const search = searchParams.get("search")?.trim() || undefined;
const status = searchParams.get("status");
const page = Number.parseInt(searchParams.get("page") || "1", 10) || 1;
const limit = Number.parseInt(searchParams.get("limit") || "25", 10) || 25;
const data = await fetchQuery(api.voiceSessions.listAdminPhoneCalls, {
search,
status:
status === "started" || status === "completed" || status === "failed"
? status
: undefined,
status: status === "started" || status === "completed" || status === "failed" ? status : undefined,
page,
limit,
})
});
return NextResponse.json(data)
return NextResponse.json(data);
} catch (error) {
console.error("Failed to load admin phone calls:", error)
return NextResponse.json(
{ error: "Failed to load phone calls" },
{ status: 500 }
)
console.error("Failed to load admin phone calls:", error);
return NextResponse.json({ error: "Failed to load phone calls" }, { status: 500 });
}
}

View file

@ -1,36 +0,0 @@
import { NextResponse } from "next/server"
import { fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireAdminToken } from "@/lib/server/admin-auth"
type RouteContext = {
params: Promise<{
id: string
}>
}
export async function GET(request: Request, { params }: RouteContext) {
const authError = requireAdminToken(request)
if (authError) {
return authError
}
try {
const { id } = await params
const detail = await fetchQuery(api.crm.getAdminContactDetail, {
contactId: id,
})
if (!detail) {
return NextResponse.json({ error: "Contact not found" }, { status: 404 })
}
return NextResponse.json(detail)
} catch (error) {
console.error("Failed to load admin contact detail:", error)
return NextResponse.json(
{ error: "Failed to load contact detail" },
{ status: 500 }
)
}
}

View file

@ -1,32 +0,0 @@
import { NextResponse } from "next/server"
import { fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireAdminToken } from "@/lib/server/admin-auth"
export async function GET(request: Request) {
const authError = requireAdminToken(request)
if (authError) {
return authError
}
try {
const { searchParams } = new URL(request.url)
const search = searchParams.get("search")?.trim() || undefined
const page = Number.parseInt(searchParams.get("page") || "1", 10) || 1
const limit = Number.parseInt(searchParams.get("limit") || "25", 10) || 25
const data = await fetchQuery(api.crm.listAdminContacts, {
search,
page,
limit,
})
return NextResponse.json(data)
} catch (error) {
console.error("Failed to load admin contacts:", error)
return NextResponse.json(
{ error: "Failed to load contacts" },
{ status: 500 }
)
}
}

View file

@ -1,49 +0,0 @@
import { NextResponse } from "next/server"
import { fetchAction } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireAdminSession } from "@/lib/server/admin-auth"
type RouteContext = {
params: Promise<{
id: string
}>
}
export async function POST(request: Request, { params }: RouteContext) {
const adminUser = await requireAdminSession(request)
if (!adminUser) {
return NextResponse.redirect(new URL("/sign-in", request.url))
}
const { id } = await params
const formData = await request.formData()
const body = String(formData.get("body") || "").trim()
if (!body) {
return NextResponse.redirect(
new URL(`/admin/conversations?conversationId=${encodeURIComponent(id)}`, request.url)
)
}
try {
await fetchAction(api.crm.sendAdminConversationMessage, {
conversationId: id,
body,
})
return NextResponse.redirect(
new URL(
`/admin/conversations?conversationId=${encodeURIComponent(id)}`,
request.url
)
)
} catch (error) {
console.error("Failed to send admin conversation message:", error)
return NextResponse.redirect(
new URL(
`/admin/conversations?conversationId=${encodeURIComponent(id)}&error=send`,
request.url
)
)
}
}

View file

@ -1,39 +0,0 @@
import { NextResponse } from "next/server"
import { fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireAdminToken } from "@/lib/server/admin-auth"
type RouteContext = {
params: Promise<{
id: string
}>
}
export async function GET(request: Request, { params }: RouteContext) {
const authError = requireAdminToken(request)
if (authError) {
return authError
}
try {
const { id } = await params
const detail = await fetchQuery(api.crm.getAdminConversationDetail, {
conversationId: id,
})
if (!detail) {
return NextResponse.json(
{ error: "Conversation not found" },
{ status: 404 }
)
}
return NextResponse.json(detail)
} catch (error) {
console.error("Failed to load admin conversation detail:", error)
return NextResponse.json(
{ error: "Failed to load conversation detail" },
{ status: 500 }
)
}
}

View file

@ -1,40 +0,0 @@
import { NextResponse } from "next/server"
import { fetchAction } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireAdminSession } from "@/lib/server/admin-auth"
type RouteContext = {
params: Promise<{
id: string
}>
}
export async function POST(request: Request, { params }: RouteContext) {
const adminUser = await requireAdminSession(request)
if (!adminUser) {
return NextResponse.redirect(new URL("/sign-in", request.url))
}
const { id } = await params
try {
await fetchAction(api.crm.hydrateConversationHistory, {
conversationId: id,
})
return NextResponse.redirect(
new URL(
`/admin/conversations?conversationId=${encodeURIComponent(id)}`,
request.url
)
)
} catch (error) {
console.error("Failed to refresh conversation history:", error)
return NextResponse.redirect(
new URL(
`/admin/conversations?conversationId=${encodeURIComponent(id)}&error=sync`,
request.url
)
)
}
}

View file

@ -1,45 +0,0 @@
import { NextResponse } from "next/server"
import { fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireAdminToken } from "@/lib/server/admin-auth"
export async function GET(request: Request) {
const authError = requireAdminToken(request)
if (authError) {
return authError
}
try {
const { searchParams } = new URL(request.url)
const search = searchParams.get("search")?.trim() || undefined
const page = Number.parseInt(searchParams.get("page") || "1", 10) || 1
const limit = Number.parseInt(searchParams.get("limit") || "25", 10) || 25
const channel = searchParams.get("channel")
const status = searchParams.get("status")
const data = await fetchQuery(api.crm.listAdminConversations, {
search,
page,
limit,
channel:
channel === "call" ||
channel === "sms" ||
channel === "chat" ||
channel === "unknown"
? channel
: undefined,
status:
status === "open" || status === "closed" || status === "archived"
? status
: undefined,
})
return NextResponse.json(data)
} catch (error) {
console.error("Failed to load admin conversations:", error)
return NextResponse.json(
{ error: "Failed to load conversations" },
{ status: 500 }
)
}
}

View file

@ -1,31 +0,0 @@
import { NextResponse } from "next/server"
import { fetchAction } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireAdminToken } from "@/lib/server/admin-auth"
export async function POST(request: Request) {
const authError = requireAdminToken(request)
if (authError) {
return authError
}
try {
const result = await fetchAction(api.ebay.refreshCache, {
reason: "admin",
force: true,
})
return NextResponse.json(result)
} catch (error) {
console.error("Failed to refresh eBay cache:", error)
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to refresh eBay cache",
},
{ status: 500 }
)
}
}

View file

@ -1,39 +0,0 @@
import { NextResponse } from "next/server"
import { fetchAction } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireAdminToken } from "@/lib/server/admin-auth"
export async function POST(request: Request) {
const authError = requireAdminToken(request)
if (authError) {
return authError
}
try {
const body = await request.json().catch(() => ({}))
const result = await fetchAction(api.crm.runGhlMirror, {
reason: "admin",
forceFullBackfill: Boolean(body.forceFullBackfill),
maxPagesPerRun:
typeof body.maxPagesPerRun === "number" ? body.maxPagesPerRun : undefined,
contactsLimit:
typeof body.contactsLimit === "number" ? body.contactsLimit : undefined,
messagesLimit:
typeof body.messagesLimit === "number" ? body.messagesLimit : undefined,
recordingsPageSize:
typeof body.recordingsPageSize === "number"
? body.recordingsPageSize
: undefined,
})
return NextResponse.json(result)
} catch (error) {
console.error("Failed to run admin GHL sync:", error)
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to run GHL sync",
},
{ status: 500 }
)
}
}

View file

@ -1,48 +0,0 @@
import assert from "node:assert/strict"
import test from "node:test"
import { GET } from "@/app/api/admin/manuals-knowledge/route"
const ORIGINAL_ADMIN_API_TOKEN = process.env.ADMIN_API_TOKEN
test.afterEach(() => {
if (typeof ORIGINAL_ADMIN_API_TOKEN === "string") {
process.env.ADMIN_API_TOKEN = ORIGINAL_ADMIN_API_TOKEN
} else {
delete process.env.ADMIN_API_TOKEN
}
})
test("manuals knowledge admin route requires admin auth", async () => {
process.env.ADMIN_API_TOKEN = "secret-token"
const response = await GET(
new Request("http://localhost/api/admin/manuals-knowledge?query=rvv+660")
)
assert.equal(response.status, 401)
})
test("manuals knowledge admin route returns retrieval details for authorized queries", async () => {
process.env.ADMIN_API_TOKEN = "secret-token"
const response = await GET(
new Request(
"http://localhost/api/admin/manuals-knowledge?query=RVV+660+service+manual",
{
headers: {
"x-admin-token": "secret-token",
},
}
)
)
assert.equal(response.status, 200)
const body = await response.json()
assert.equal(body.summary.ran, true)
assert.equal(Array.isArray(body.result.manualCandidates), true)
assert.equal(body.result.manualCandidates.length > 0, true)
assert.equal(Array.isArray(body.result.topChunks), true)
assert.equal(Array.isArray(body.summary.topChunkCitations), true)
})

View file

@ -1,79 +0,0 @@
import { NextResponse } from "next/server"
import {
getManualCitationContext,
retrieveManualContext,
summarizeManualRetrieval,
} from "@/lib/manuals-knowledge"
import { requireAdminToken } from "@/lib/server/admin-auth"
function normalizeQuery(value: string | null) {
return (value || "").trim().slice(0, 400)
}
export async function GET(request: Request) {
const authError = requireAdminToken(request)
if (authError) {
return authError
}
try {
const { searchParams } = new URL(request.url)
const query = normalizeQuery(searchParams.get("query"))
const manufacturer = normalizeQuery(searchParams.get("manufacturer")) || null
const model = normalizeQuery(searchParams.get("model")) || null
const manualId = normalizeQuery(searchParams.get("manualId")) || null
const pageParam = searchParams.get("page")
const pageNumber =
pageParam && Number.isFinite(Number(pageParam))
? Number.parseInt(pageParam, 10)
: undefined
if (!query) {
return NextResponse.json(
{ error: "A query parameter is required." },
{ status: 400 }
)
}
const result = await retrieveManualContext(query, {
manufacturer,
model,
manualId,
})
const citationContext =
manualId || result.bestManual?.manualId
? await getManualCitationContext(
manualId || result.bestManual?.manualId || "",
pageNumber
)
: null
return NextResponse.json({
query,
filters: {
manufacturer,
model,
manualId,
pageNumber: pageNumber ?? null,
},
summary: summarizeManualRetrieval({
ran: true,
query,
result,
}),
result,
citationContext,
})
} catch (error) {
console.error("Failed to inspect manuals knowledge:", error)
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to inspect manuals knowledge",
},
{ status: 500 }
)
}
}

View file

@ -1,181 +0,0 @@
import assert from "node:assert/strict"
import test from "node:test"
import { NextRequest } from "next/server"
import { POST } from "@/app/api/chat/route"
type CapturedPayload = {
model: string
messages: Array<{ role: string; content: string }>
}
const ORIGINAL_FETCH = globalThis.fetch
const ORIGINAL_XAI_KEY = process.env.XAI_API_KEY
function buildVisitor(intent: string) {
return {
name: "Taylor",
phone: "(801) 555-1000",
email: "taylor@example.com",
intent,
serviceTextConsent: true,
marketingTextConsent: false,
consentVersion: "sms-consent-v1-2026-03-26",
consentCapturedAt: "2026-03-25T00:00:00.000Z",
consentSourcePage: "/contact-us",
}
}
function buildRequest(message: string, intent = "Manuals") {
return new NextRequest("http://localhost/api/chat", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
pathname: "/manuals",
sessionId: "test-session",
visitor: buildVisitor(intent),
messages: [{ role: "user", content: message }],
}),
})
}
async function runChatRouteWithSpy(
message: string,
intent = "Manuals"
): Promise<{ response: Response; payload: CapturedPayload }> {
process.env.XAI_API_KEY = "test-xai-key"
let capturedPayload: CapturedPayload | null = null
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
capturedPayload = JSON.parse(String(init?.body || "{}")) as CapturedPayload
return new Response(
JSON.stringify({
choices: [
{
message: {
content: "Mock Jessica reply.",
},
},
],
}),
{
status: 200,
headers: {
"content-type": "application/json",
},
}
)
}) as typeof fetch
const response = await POST(buildRequest(message, intent))
assert.ok(capturedPayload)
return { response, payload: capturedPayload }
}
test.afterEach(() => {
globalThis.fetch = ORIGINAL_FETCH
if (typeof ORIGINAL_XAI_KEY === "string") {
process.env.XAI_API_KEY = ORIGINAL_XAI_KEY
} else {
delete process.env.XAI_API_KEY
}
})
test("chat route includes grounded manual context for RVV alias lookups", async () => {
const { response, payload } = await runChatRouteWithSpy(
"RVV 660 service manual"
)
assert.equal(response.status, 200)
assert.equal(
payload.messages.some(
(message) =>
message.role === "system" &&
message.content.includes("Manual knowledge context:")
),
true
)
assert.equal(
payload.messages.some(
(message) =>
message.role === "system" &&
/Royal Vendors|660/i.test(message.content)
),
true
)
})
test("chat route resolves Narco alias lookups into manual context", async () => {
const { payload } = await runChatRouteWithSpy("Narco bevmax not cooling")
const manualContext = payload.messages.find(
(message) =>
message.role === "system" &&
message.content.includes("Manual knowledge context:")
)
assert.ok(manualContext)
assert.match(manualContext.content, /Dixie-Narco|Narco/i)
})
test("chat route low-confidence manual queries instruct Jessica to ask for brand model or photo", async () => {
const { payload } = await runChatRouteWithSpy(
"manual for flibbertigibbet machine"
)
const manualContext = payload.messages.find(
(message) =>
message.role === "system" &&
message.content.includes("Manual knowledge context:")
)
assert.ok(manualContext)
assert.match(
manualContext.content,
/brand on the front|model sticker|photo\/video/i
)
})
test("chat route risky technical manual queries inject conservative safety context", async () => {
const { payload } = await runChatRouteWithSpy(
"Royal wiring diagram voltage manual",
"Repairs"
)
const systemPrompt = payload.messages[0]?.content || ""
const manualContext = payload.messages.find(
(message) =>
message.role === "system" &&
message.content.includes("Manual knowledge context:")
)
assert.match(
systemPrompt,
/Do not provide step-by-step repair procedures, wiring guidance, voltage guidance/i
)
assert.ok(manualContext)
assert.match(manualContext.content, /technical or risky/i)
})
test("chat route skips manuals retrieval for non-manual conversations", async () => {
const { payload } = await runChatRouteWithSpy(
"Can someone call me back about free placement?",
"Free Placement"
)
const systemMessages = payload.messages.filter(
(message) => message.role === "system"
)
assert.equal(systemMessages.length, 1)
assert.equal(
systemMessages.some((message) =>
message.content.includes("Manual knowledge context:")
),
false
)
})

View file

@ -17,18 +17,8 @@ import {
SITE_CHAT_TEMPERATURE,
isSiteChatSuppressedRoute,
} from "@/lib/site-chat/config"
import { buildSiteChatSystemPrompt } from "@/lib/site-chat/prompt"
import {
consumeChatOutput,
consumeChatRequest,
getChatRateLimitStatus,
} from "@/lib/site-chat/rate-limit"
import {
formatManualContextForPrompt,
retrieveManualContext,
shouldUseManualKnowledgeForChat,
summarizeManualRetrieval,
} from "@/lib/manuals-knowledge"
import { SITE_CHAT_SYSTEM_PROMPT } from "@/lib/site-chat/prompt"
import { consumeChatOutput, consumeChatRequest, getChatRateLimitStatus } from "@/lib/site-chat/rate-limit"
import { createSmsConsentPayload } from "@/lib/sms-compliance"
type ChatRole = "user" | "assistant"
@ -91,10 +81,7 @@ function normalizeSessionId(rawSessionId: string | undefined | null) {
}
function normalizePathname(rawPathname: string | undefined) {
const pathname =
typeof rawPathname === "string" && rawPathname.trim()
? rawPathname.trim()
: "/"
const pathname = typeof rawPathname === "string" && rawPathname.trim() ? rawPathname.trim() : "/"
return pathname.startsWith("/") ? pathname : `/${pathname}`
}
@ -102,46 +89,24 @@ function normalizeMessages(messages: ChatMessage[] | undefined) {
const safeMessages = Array.isArray(messages) ? messages : []
return safeMessages
.filter(
(message) =>
message && (message.role === "user" || message.role === "assistant")
)
.filter((message) => message && (message.role === "user" || message.role === "assistant"))
.map((message) => ({
role: message.role,
content: String(message.content || "")
.replace(/\s+/g, " ")
.trim()
.slice(0, SITE_CHAT_MAX_MESSAGE_CHARS),
content: String(message.content || "").replace(/\s+/g, " ").trim().slice(0, SITE_CHAT_MAX_MESSAGE_CHARS),
}))
.filter((message) => message.content.length > 0)
.slice(-SITE_CHAT_MAX_HISTORY_MESSAGES)
}
function normalizeVisitorProfile(
rawVisitor: ChatRequestBody["visitor"],
pathname: string
): ChatVisitorProfile | null {
function normalizeVisitorProfile(rawVisitor: ChatRequestBody["visitor"], pathname: string): ChatVisitorProfile | null {
if (!rawVisitor) {
return null
}
const name = String(rawVisitor.name || "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 80)
const phone = String(rawVisitor.phone || "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 40)
const email = String(rawVisitor.email || "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 120)
.toLowerCase()
const intent = String(rawVisitor.intent || "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 80)
const name = String(rawVisitor.name || "").replace(/\s+/g, " ").trim().slice(0, 80)
const phone = String(rawVisitor.phone || "").replace(/\s+/g, " ").trim().slice(0, 40)
const email = String(rawVisitor.email || "").replace(/\s+/g, " ").trim().slice(0, 120).toLowerCase()
const intent = String(rawVisitor.intent || "").replace(/\s+/g, " ").trim().slice(0, 80)
if (!name || !phone || !email || !intent) {
return null
@ -214,15 +179,6 @@ function extractAssistantText(data: any) {
return ""
}
function buildManualKnowledgeQuery(messages: ChatMessage[]) {
return messages
.filter((message) => message.role === "user")
.slice(-3)
.map((message) => message.content.trim())
.filter(Boolean)
.join(" ")
}
export async function POST(request: NextRequest) {
const responseHeaders: Record<string, string> = {
"Cache-Control": "no-store",
@ -234,36 +190,25 @@ export async function POST(request: NextRequest) {
const visitor = normalizeVisitorProfile(body.visitor, pathname)
if (isSiteChatSuppressedRoute(pathname)) {
return NextResponse.json(
{ error: "Chat is not available on this route." },
{ status: 403, headers: responseHeaders }
)
return NextResponse.json({ error: "Chat is not available on this route." }, { status: 403, headers: responseHeaders })
}
if (!visitor) {
return NextResponse.json(
{
error:
"Name, phone, email, intent, and required service SMS consent are needed to start chat.",
error: "Name, phone, email, intent, and required service SMS consent are needed to start chat.",
},
{ status: 400, headers: responseHeaders }
{ status: 400, headers: responseHeaders },
)
}
const sessionId = normalizeSessionId(
body.sessionId || request.cookies.get(SITE_CHAT_SESSION_COOKIE)?.value
)
const sessionId = normalizeSessionId(body.sessionId || request.cookies.get(SITE_CHAT_SESSION_COOKIE)?.value)
const ip = getClientIp(request)
const messages = normalizeMessages(body.messages)
const latestUserMessage = [...messages]
.reverse()
.find((message) => message.role === "user")
const latestUserMessage = [...messages].reverse().find((message) => message.role === "user")
if (!latestUserMessage) {
return NextResponse.json(
{ error: "A user message is required.", sessionId },
{ status: 400, headers: responseHeaders }
)
return NextResponse.json({ error: "A user message is required.", sessionId }, { status: 400, headers: responseHeaders })
}
if (latestUserMessage.content.length > SITE_CHAT_MAX_INPUT_CHARS) {
@ -272,7 +217,7 @@ export async function POST(request: NextRequest) {
error: `Please keep each message under ${SITE_CHAT_MAX_INPUT_CHARS} characters.`,
sessionId,
},
{ status: 400, headers: responseHeaders }
{ status: 400, headers: responseHeaders },
)
}
@ -289,12 +234,11 @@ export async function POST(request: NextRequest) {
if (limitStatus.blocked) {
const blockedResponse = NextResponse.json(
{
error:
"Chat is temporarily limited right now. Please wait a bit or call Rocky Mountain Vending directly.",
error: "Chat is temporarily limited right now. Please wait a bit or call Rocky Mountain Vending directly.",
sessionId,
limits: limitStatus,
},
{ status: 429, headers: responseHeaders }
{ status: 429, headers: responseHeaders },
)
blockedResponse.cookies.set(SITE_CHAT_SESSION_COOKIE, sessionId, {
@ -308,41 +252,7 @@ export async function POST(request: NextRequest) {
return blockedResponse
}
consumeChatRequest({
ip,
requestWindowMs: SITE_CHAT_REQUEST_WINDOW_MS,
sessionId,
})
const manualKnowledgeQuery = buildManualKnowledgeQuery(messages)
const shouldUseManualKnowledge = shouldUseManualKnowledgeForChat(
visitor.intent,
manualKnowledgeQuery
)
let manualKnowledge = null
let manualKnowledgeError: unknown = null
if (shouldUseManualKnowledge) {
try {
manualKnowledge = await retrieveManualContext(manualKnowledgeQuery)
} catch (error) {
manualKnowledgeError = error
console.error("[site-chat] manuals knowledge lookup failed", {
pathname,
sessionId,
error,
})
}
}
console.info(
"[site-chat] manuals retrieval",
summarizeManualRetrieval({
ran: shouldUseManualKnowledge,
query: manualKnowledgeQuery,
result: manualKnowledge,
error: manualKnowledgeError,
})
)
const systemPrompt = buildSiteChatSystemPrompt()
consumeChatRequest({ ip, requestWindowMs: SITE_CHAT_REQUEST_WINDOW_MS, sessionId })
const xaiApiKey = getOptionalEnv("XAI_API_KEY")
if (!xaiApiKey) {
@ -353,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 },
)
}
}

View file

@ -1,13 +1,13 @@
import assert from "node:assert/strict"
import test from "node:test"
import assert from "node:assert/strict";
import test from "node:test";
import {
processLeadSubmission,
type ContactLeadPayload,
type RequestMachineLeadPayload,
} from "@/lib/server/contact-submission"
} from "@/lib/server/contact-submission";
test("processLeadSubmission stores and syncs a contact lead", async () => {
const calls: string[] = []
const calls: string[] = [];
const payload: ContactLeadPayload = {
kind: "contact",
firstName: "John",
@ -26,7 +26,7 @@ test("processLeadSubmission stores and syncs a contact lead", async () => {
page: "/contact",
timestamp: "2026-03-25T00:00:00.000Z",
url: "https://rmv.example/contact",
}
};
const result = await processLeadSubmission(payload, "rmv.example", {
storageConfigured: true,
@ -36,37 +36,37 @@ test("processLeadSubmission stores and syncs a contact lead", async () => {
tenantName: "Rocky Mountain Vending",
tenantDomains: ["rockymountainvending.com"],
ingest: async () => {
calls.push("ingest")
calls.push("ingest");
return {
inserted: true,
leadId: "lead_123",
idempotencyKey: "abc",
tenantId: "tenant_123",
}
};
},
updateLeadStatus: async () => {
calls.push("update")
return { ok: true }
calls.push("update");
return { ok: true };
},
sendEmail: async () => {
calls.push("email")
return {}
calls.push("email");
return {};
},
createContact: async () => {
calls.push("ghl")
return { contact: { id: "ghl_123" } }
calls.push("ghl");
return { contact: { id: "ghl_123" } };
},
logger: console,
})
});
assert.equal(result.status, 200)
assert.equal(result.body.success, true)
assert.deepEqual(result.body.deliveredVia, ["convex", "email", "ghl"])
assert.equal(calls.filter((call) => call === "email").length, 2)
assert.ok(calls.includes("ingest"))
assert.ok(calls.includes("update"))
assert.ok(calls.includes("ghl"))
})
assert.equal(result.status, 200);
assert.equal(result.body.success, true);
assert.deepEqual(result.body.deliveredVia, ["convex", "email", "ghl"]);
assert.equal(calls.filter((call) => call === "email").length, 2);
assert.ok(calls.includes("ingest"));
assert.ok(calls.includes("update"));
assert.ok(calls.includes("ghl"));
});
test("processLeadSubmission validates request-machine submissions", async () => {
const payload: RequestMachineLeadPayload = {
@ -84,7 +84,7 @@ test("processLeadSubmission validates request-machine submissions", async () =>
consentVersion: "sms-consent-v1-2026-03-26",
consentCapturedAt: "2026-03-25T00:00:00.000Z",
consentSourcePage: "/",
}
};
const result = await processLeadSubmission(payload, "rmv.example", {
storageConfigured: false,
@ -94,24 +94,24 @@ test("processLeadSubmission validates request-machine submissions", async () =>
tenantName: "Rocky Mountain Vending",
tenantDomains: [],
ingest: async () => {
throw new Error("should not run")
throw new Error("should not run");
},
updateLeadStatus: async () => {
throw new Error("should not run")
throw new Error("should not run");
},
sendEmail: async () => {
throw new Error("should not run")
throw new Error("should not run");
},
createContact: async () => {
throw new Error("should not run")
throw new Error("should not run");
},
logger: console,
})
});
assert.equal(result.status, 400)
assert.equal(result.body.success, false)
assert.match(result.body.error || "", /Invalid number of employees/)
})
assert.equal(result.status, 400);
assert.equal(result.body.success, false);
assert.match(result.body.error || "", /Invalid number of employees/);
});
test("processLeadSubmission returns deduped success when Convex already has the lead", async () => {
const payload: ContactLeadPayload = {
@ -126,7 +126,7 @@ test("processLeadSubmission returns deduped success when Convex already has the
consentVersion: "sms-consent-v1-2026-03-26",
consentCapturedAt: "2026-03-25T00:00:00.000Z",
consentSourcePage: "/contact-us",
}
};
const result = await processLeadSubmission(payload, "rmv.example", {
storageConfigured: true,
@ -145,10 +145,10 @@ test("processLeadSubmission returns deduped success when Convex already has the
sendEmail: async () => ({}),
createContact: async () => null,
logger: console,
})
});
assert.equal(result.status, 200)
assert.equal(result.body.success, true)
assert.equal(result.body.deduped, true)
assert.deepEqual(result.body.deliveredVia, ["convex"])
})
assert.equal(result.status, 200);
assert.equal(result.body.success, true);
assert.equal(result.body.deduped, true);
assert.deepEqual(result.body.deliveredVia, ["convex"]);
});

View file

@ -1,5 +1,5 @@
import { handleLeadRequest } from "@/lib/server/contact-submission"
import { handleLeadRequest } from "@/lib/server/contact-submission";
export async function POST(request: Request) {
return handleLeadRequest(request)
return handleLeadRequest(request);
}

View file

@ -1,213 +0,0 @@
import { NextResponse } from "next/server"
import { fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { hasConvexUrl } from "@/lib/convex-config"
import {
filterTrustedEbayListings,
rankListingsForPart,
type CachedEbayListing,
type EbayCacheState,
type ManualPartInput,
} from "@/lib/ebay-parts-match"
type MatchPart = ManualPartInput & {
key?: string
ebayListings?: CachedEbayListing[]
}
type ManualPartsMatchResponse = {
manualFilename: string
parts: Array<
MatchPart & {
ebayListings: CachedEbayListing[]
}
>
cache: EbayCacheState
cacheSource: "convex" | "fallback"
error?: string
}
type ManualPartsRequest = {
manualFilename?: string
parts?: unknown[]
limit?: number
}
function getDisabledCacheState(message: string): EbayCacheState {
return {
key: "manual-parts",
status: "disabled",
lastSuccessfulAt: null,
lastAttemptAt: null,
nextEligibleAt: null,
lastError: message,
consecutiveFailures: 0,
queryCount: 0,
itemCount: 0,
sourceQueries: [],
freshnessMs: null,
isStale: true,
listingCount: 0,
activeListingCount: 0,
message,
}
}
function getErrorCacheState(message: string): EbayCacheState {
const now = Date.now()
return {
key: "manual-parts",
status: "error",
lastSuccessfulAt: null,
lastAttemptAt: now,
nextEligibleAt: null,
lastError: message,
consecutiveFailures: 1,
queryCount: 0,
itemCount: 0,
sourceQueries: [],
freshnessMs: null,
isStale: true,
listingCount: 0,
activeListingCount: 0,
message,
}
}
function createEmptyListingsParts(parts: MatchPart[]) {
return parts.map((part) => ({
...part,
ebayListings: [],
}))
}
function normalizePartInput(value: unknown): MatchPart | null {
if (!value || typeof value !== "object") {
return null
}
const part = value as Record<string, unknown>
const partNumber = typeof part.partNumber === "string" ? part.partNumber.trim() : ""
const description = typeof part.description === "string" ? part.description.trim() : ""
if (!partNumber && !description) {
return null
}
return {
key: typeof part.key === "string" ? part.key : undefined,
partNumber,
description,
manufacturer:
typeof part.manufacturer === "string" ? part.manufacturer.trim() : undefined,
category:
typeof part.category === "string" ? part.category.trim() : undefined,
manualFilename:
typeof part.manualFilename === "string"
? part.manualFilename.trim()
: undefined,
ebayListings: Array.isArray(part.ebayListings)
? (part.ebayListings as CachedEbayListing[])
: undefined,
}
}
export async function POST(request: Request) {
let payload: ManualPartsRequest | null = null
try {
payload = (await request.json()) as ManualPartsRequest
} catch {
payload = null
}
const manualFilename = payload?.manualFilename?.trim() || ""
const limit = Math.min(
Math.max(Number.parseInt(String(payload?.limit ?? 5), 10) || 5, 1),
10
)
const parts: MatchPart[] = (payload?.parts || [])
.map(normalizePartInput)
.filter((part): part is MatchPart => Boolean(part))
if (!manualFilename) {
return NextResponse.json(
{ error: "manualFilename is required" },
{ status: 400 }
)
}
if (!parts.length) {
const message = "No manual parts were provided."
return NextResponse.json({
manualFilename,
parts: [],
cache: getDisabledCacheState(message),
cacheSource: "fallback",
error: message,
} satisfies ManualPartsMatchResponse)
}
if (!hasConvexUrl()) {
const message =
"Cached eBay backend is disabled because NEXT_PUBLIC_CONVEX_URL is not configured."
return NextResponse.json({
manualFilename,
parts: createEmptyListingsParts(parts),
cache: getDisabledCacheState(message),
cacheSource: "fallback",
error: message,
} satisfies ManualPartsMatchResponse)
}
try {
const [overview, listings] = await Promise.all([
fetchQuery(api.ebay.getCacheOverview, {}),
fetchQuery(api.ebay.listCachedListings, { limit: 200 }),
])
const trustedListings = filterTrustedEbayListings(
listings as CachedEbayListing[]
)
const rankedParts = parts
.map((part) => ({
...part,
ebayListings: rankListingsForPart(part, trustedListings, limit),
}))
.sort((a, b) => {
const aCount = a.ebayListings.length
const bCount = b.ebayListings.length
if (aCount !== bCount) {
return bCount - aCount
}
const aFreshness = a.ebayListings[0]?.lastSeenAt ?? a.ebayListings[0]?.fetchedAt ?? 0
const bFreshness = b.ebayListings[0]?.lastSeenAt ?? b.ebayListings[0]?.fetchedAt ?? 0
return bFreshness - aFreshness
})
.slice(0, limit)
return NextResponse.json({
manualFilename,
parts: rankedParts,
cache: overview,
cacheSource: "convex",
} satisfies ManualPartsMatchResponse)
} catch (error) {
console.error("Failed to load cached eBay matches:", error)
const message =
error instanceof Error
? `Cached eBay listings are unavailable: ${error.message}`
: "Cached eBay listings are unavailable."
return NextResponse.json(
{
manualFilename,
parts: createEmptyListingsParts(parts),
cache: getErrorCacheState(message),
cacheSource: "fallback",
error: message,
} satisfies ManualPartsMatchResponse,
{ status: 200 }
)
}
}

View file

@ -1,159 +0,0 @@
import { NextRequest, NextResponse } from "next/server"
import {
computeEbayChallengeResponse,
getEbayNotificationEndpoint,
getEbayNotificationVerificationToken,
verifyEbayNotificationSignature,
} from "@/lib/ebay-notifications"
export const runtime = "nodejs"
type EbayNotificationPayload = {
metadata?: {
topic?: string
schemaVersion?: string
deprecated?: boolean
}
notification?: {
notificationId?: string
eventDate?: string
publishDate?: string
publishAttemptCount?: number
data?: {
username?: string
userId?: string
eiasToken?: string
}
}
}
function parseNotificationBody(rawBody: string) {
if (!rawBody.trim()) {
return null
}
try {
return JSON.parse(rawBody) as EbayNotificationPayload
} catch {
return null
}
}
function getChallengeCode(request: NextRequest) {
return (
request.nextUrl.searchParams.get("challenge_code") ||
request.nextUrl.searchParams.get("challengeCode") ||
""
).trim()
}
export async function GET(request: NextRequest) {
const challengeCode = getChallengeCode(request)
if (!challengeCode) {
return NextResponse.json(
{ error: "Missing challenge_code query parameter." },
{ status: 400 }
)
}
const verificationToken = getEbayNotificationVerificationToken()
if (!verificationToken) {
return NextResponse.json(
{ error: "EBAY_NOTIFICATION_VERIFICATION_TOKEN is not configured." },
{ status: 500 }
)
}
const endpoint = getEbayNotificationEndpoint(request.url)
if (!endpoint) {
return NextResponse.json(
{ error: "EBAY_NOTIFICATION_ENDPOINT is not configured." },
{ status: 500 }
)
}
const challengeResponse = computeEbayChallengeResponse({
challengeCode,
endpoint,
verificationToken,
})
return NextResponse.json(
{ challengeResponse },
{
headers: {
"Cache-Control": "no-store",
},
}
)
}
export async function POST(request: NextRequest) {
const body = await request.text()
const signatureHeader = request.headers.get("x-ebay-signature")
try {
const verification = await verifyEbayNotificationSignature({
body,
signatureHeader,
})
if (!verification.verified) {
if (
verification.reason ===
"Notification verification credentials are not configured."
) {
console.warn(
"[ebay/notifications] accepted notification without signature verification",
{
reason: verification.reason,
}
)
const payload = parseNotificationBody(body)
const notification = payload?.notification
console.info(
"[ebay/notifications] accepted notification without verification",
{
topic: payload?.metadata?.topic || "unknown",
notificationId: notification?.notificationId || "unknown",
publishAttemptCount: notification?.publishAttemptCount ?? null,
}
)
return new NextResponse(null, { status: 204 })
}
console.warn("[ebay/notifications] signature rejected", {
reason: verification.reason,
})
return NextResponse.json({ error: verification.reason }, { status: 412 })
}
const payload = parseNotificationBody(body)
const notification = payload?.notification
console.info("[ebay/notifications] accepted notification", {
keyId: verification.keyId,
topic: payload?.metadata?.topic || "unknown",
notificationId: notification?.notificationId || "unknown",
publishAttemptCount: notification?.publishAttemptCount ?? null,
})
return new NextResponse(null, { status: 204 })
} catch (error) {
console.error("[ebay/notifications] failed to process notification", {
error: error instanceof Error ? error.message : String(error),
})
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to verify eBay notification.",
},
{ status: 500 }
)
}
}

View file

@ -1,120 +0,0 @@
import { NextResponse } from "next/server"
import { fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { hasConvexUrl } from "@/lib/convex-config"
import {
filterTrustedEbayListings,
rankListingsForQuery,
type CachedEbayListing,
type EbayCacheState,
} from "@/lib/ebay-parts-match"
type CacheSource = "convex" | "fallback"
function getDisabledCacheState(message: string): EbayCacheState {
return {
key: "manual-parts",
status: "disabled",
lastSuccessfulAt: null,
lastAttemptAt: null,
nextEligibleAt: null,
lastError: message,
consecutiveFailures: 0,
queryCount: 0,
itemCount: 0,
sourceQueries: [],
freshnessMs: null,
isStale: true,
listingCount: 0,
activeListingCount: 0,
message,
}
}
function getErrorCacheState(message: string): EbayCacheState {
const now = Date.now()
return {
key: "manual-parts",
status: "error",
lastSuccessfulAt: null,
lastAttemptAt: now,
nextEligibleAt: null,
lastError: message,
consecutiveFailures: 1,
queryCount: 0,
itemCount: 0,
sourceQueries: [],
freshnessMs: null,
isStale: true,
listingCount: 0,
activeListingCount: 0,
message,
}
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const keywords = searchParams.get("keywords")?.trim() || ""
const maxResults = Math.min(
Math.max(Number.parseInt(searchParams.get("maxResults") || "6", 10) || 6, 1),
20
)
if (!keywords) {
return NextResponse.json(
{ error: "Keywords parameter is required" },
{ status: 400 }
)
}
if (!hasConvexUrl()) {
const message =
"Cached eBay backend is disabled because NEXT_PUBLIC_CONVEX_URL is not configured."
return NextResponse.json({
query: keywords,
results: [],
cache: getDisabledCacheState(message),
cacheSource: "fallback" satisfies CacheSource,
error: message,
})
}
try {
const [overview, listings] = await Promise.all([
fetchQuery(api.ebay.getCacheOverview, {}),
fetchQuery(api.ebay.listCachedListings, { limit: 200 }),
])
const trustedListings = filterTrustedEbayListings(
listings as CachedEbayListing[]
)
const ranked = rankListingsForQuery(
keywords,
trustedListings,
maxResults
)
return NextResponse.json({
query: keywords,
results: ranked,
cache: overview,
cacheSource: "convex" satisfies CacheSource,
})
} catch (error) {
console.error("Failed to load cached eBay listings:", error)
const message =
error instanceof Error
? `Cached eBay listings are unavailable: ${error.message}`
: "Cached eBay listings are unavailable."
return NextResponse.json(
{
query: keywords,
results: [],
cache: getErrorCacheState(message),
cacheSource: "fallback" satisfies CacheSource,
error: message,
},
{ status: 200 }
)
}
}

View file

@ -1,51 +0,0 @@
import { timingSafeEqual } from "node:crypto"
import { NextResponse } from "next/server"
import { hasConvexUrl } from "@/lib/convex-config"
function readBearerToken(request: Request) {
const authHeader = request.headers.get("authorization") || ""
if (!authHeader.toLowerCase().startsWith("bearer ")) {
return ""
}
return authHeader.slice("bearer ".length).trim()
}
function tokensMatch(expected: string, provided: string) {
const expectedBuffer = Buffer.from(expected)
const providedBuffer = Buffer.from(provided)
if (expectedBuffer.length !== providedBuffer.length) {
return false
}
return timingSafeEqual(expectedBuffer, providedBuffer)
}
export function getGhlSyncToken() {
return String(process.env.GHL_SYNC_CRON_TOKEN || "").trim()
}
export async function requireGhlSyncAuth(request: Request) {
if (!hasConvexUrl()) {
return NextResponse.json(
{ error: "Convex is not configured for GHL sync" },
{ status: 503 }
)
}
const configuredToken = getGhlSyncToken()
if (!configuredToken) {
return NextResponse.json(
{ error: "GHL sync token is not configured" },
{ status: 503 }
)
}
const providedToken = readBearerToken(request)
if (!providedToken || !tokensMatch(configuredToken, providedToken)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
return null
}

View file

@ -1,60 +0,0 @@
import { NextResponse } from "next/server"
import { fetchMutation } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
import { fetchGhlContacts } from "@/lib/server/ghl-sync"
export async function POST(request: Request) {
const authError = await requireGhlSyncAuth(request)
if (authError) {
return authError
}
try {
const body = await request.json().catch(() => ({}))
const providedItems = Array.isArray(body.items) ? body.items : null
const fetched = providedItems
? {
items: providedItems,
nextCursor:
typeof body.nextCursor === "string" ? body.nextCursor : undefined,
}
: await fetchGhlContacts({
limit: typeof body.limit === "number" ? body.limit : undefined,
cursor: body.cursor ? String(body.cursor) : undefined,
})
const imported = []
for (const item of fetched.items) {
const result = await fetchMutation(api.crm.importContact, {
provider: "ghl",
entityId: String(item.id || ""),
payload: item,
})
imported.push(result?._id || result?.id || null)
}
await fetchMutation(api.crm.updateSyncCheckpoint, {
provider: "ghl",
entityType: "contacts",
entityId: "contacts",
cursor: fetched.nextCursor,
status: "synced",
metadata: JSON.stringify({
imported: imported.length,
}),
})
return NextResponse.json({
success: true,
imported: imported.length,
nextCursor: fetched.nextCursor,
})
} catch (error) {
console.error("Failed to sync GHL contacts:", error)
return NextResponse.json(
{ error: "Failed to sync GHL contacts" },
{ status: 500 }
)
}
}

View file

@ -1,70 +0,0 @@
import { NextResponse } from "next/server"
import { fetchMutation } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
import { fetchGhlMessages } from "@/lib/server/ghl-sync"
export async function POST(request: Request) {
const authError = await requireGhlSyncAuth(request)
if (authError) {
return authError
}
try {
const body = await request.json().catch(() => ({}))
const providedItems = Array.isArray(body.items) ? body.items : null
const fetched = providedItems
? {
items: providedItems,
nextCursor:
typeof body.nextCursor === "string" ? body.nextCursor : undefined,
}
: await fetchGhlMessages({
limit: typeof body.limit === "number" ? body.limit : undefined,
cursor: body.cursor ? String(body.cursor) : undefined,
channel: body.channel === "Call" ? "Call" : "SMS",
})
const grouped = new Map<string, any>()
for (const item of fetched.items) {
const conversationId = String(item.conversationId || item.id || "")
if (!conversationId || grouped.has(conversationId)) {
continue
}
grouped.set(conversationId, item)
}
let imported = 0
for (const [entityId, item] of grouped.entries()) {
await fetchMutation(api.crm.importConversation, {
provider: "ghl",
entityId,
payload: item,
})
imported += 1
}
await fetchMutation(api.crm.updateSyncCheckpoint, {
provider: "ghl",
entityType: "conversations",
entityId: "conversations",
cursor: fetched.nextCursor,
status: "synced",
metadata: JSON.stringify({
imported,
}),
})
return NextResponse.json({
success: true,
imported,
nextCursor: fetched.nextCursor,
})
} catch (error) {
console.error("Failed to sync GHL conversations:", error)
return NextResponse.json(
{ error: "Failed to sync GHL conversations" },
{ status: 500 }
)
}
}

View file

@ -1,61 +0,0 @@
import { NextResponse } from "next/server"
import { fetchMutation } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
import { fetchGhlMessages } from "@/lib/server/ghl-sync"
export async function POST(request: Request) {
const authError = await requireGhlSyncAuth(request)
if (authError) {
return authError
}
try {
const body = await request.json().catch(() => ({}))
const providedItems = Array.isArray(body.items) ? body.items : null
const fetched = providedItems
? {
items: providedItems,
nextCursor:
typeof body.nextCursor === "string" ? body.nextCursor : undefined,
}
: await fetchGhlMessages({
limit: typeof body.limit === "number" ? body.limit : undefined,
cursor: body.cursor ? String(body.cursor) : undefined,
channel: body.channel === "Call" ? "Call" : "SMS",
})
let imported = 0
for (const item of fetched.items) {
await fetchMutation(api.crm.importMessage, {
provider: "ghl",
entityId: String(item.id || ""),
payload: item,
})
imported += 1
}
await fetchMutation(api.crm.updateSyncCheckpoint, {
provider: "ghl",
entityType: "messages",
entityId: "messages",
cursor: fetched.nextCursor,
status: "synced",
metadata: JSON.stringify({
imported,
}),
})
return NextResponse.json({
success: true,
imported,
nextCursor: fetched.nextCursor,
})
} catch (error) {
console.error("Failed to sync GHL messages:", error)
return NextResponse.json(
{ error: "Failed to sync GHL messages" },
{ status: 500 }
)
}
}

View file

@ -1,29 +0,0 @@
import { NextResponse } from "next/server"
import { fetchMutation } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
export async function POST(request: Request) {
const authError = await requireGhlSyncAuth(request)
if (authError) {
return authError
}
try {
const body = await request.json().catch(() => ({}))
const result = await fetchMutation(api.crm.reconcileExternalState, {
provider: body.provider ? String(body.provider) : "ghl",
})
return NextResponse.json({
success: true,
...result,
})
} catch (error) {
console.error("Failed to reconcile mirrored external state:", error)
return NextResponse.json(
{ error: "Failed to reconcile mirrored external state" },
{ status: 500 }
)
}
}

View file

@ -1,69 +0,0 @@
import { NextResponse } from "next/server"
import { fetchMutation } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
import { fetchGhlCallLogs } from "@/lib/server/ghl-sync"
export async function POST(request: Request) {
const authError = await requireGhlSyncAuth(request)
if (authError) {
return authError
}
try {
const body = await request.json().catch(() => ({}))
const providedItems = Array.isArray(body.items) ? body.items : null
const fetched = providedItems
? {
items: providedItems,
page: typeof body.page === "number" ? body.page : 1,
total: providedItems.length,
pageSize: providedItems.length,
}
: await fetchGhlCallLogs({
page: typeof body.page === "number" ? body.page : undefined,
pageSize: typeof body.pageSize === "number" ? body.pageSize : undefined,
})
let imported = 0
for (const item of fetched.items) {
await fetchMutation(api.crm.importRecording, {
provider: "ghl",
entityId: String(item.id || item.messageId || ""),
payload: {
...item,
recordingId: item.messageId || item.id,
transcript: item.transcript,
recordingUrl: item.recordingUrl,
recordingStatus: item.transcript ? "completed" : "pending",
},
})
imported += 1
}
await fetchMutation(api.crm.updateSyncCheckpoint, {
provider: "ghl",
entityType: "recordings",
entityId: "recordings",
cursor: `${fetched.page}`,
status: "synced",
metadata: JSON.stringify({
imported,
total: fetched.total,
}),
})
return NextResponse.json({
success: true,
imported,
page: fetched.page,
total: fetched.total,
})
} catch (error) {
console.error("Failed to sync GHL recordings:", error)
return NextResponse.json(
{ error: "Failed to sync GHL recordings" },
{ status: 500 }
)
}
}

View file

@ -1,39 +0,0 @@
import { NextResponse } from "next/server"
import { fetchAction } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
export async function POST(request: Request) {
const authError = await requireGhlSyncAuth(request)
if (authError) {
return authError
}
try {
const body = await request.json().catch(() => ({}))
const result = await fetchAction(api.crm.runGhlMirror, {
reason: body.reason ? String(body.reason) : "internal",
forceFullBackfill: Boolean(body.forceFullBackfill),
maxPagesPerRun:
typeof body.maxPagesPerRun === "number" ? body.maxPagesPerRun : undefined,
contactsLimit:
typeof body.contactsLimit === "number" ? body.contactsLimit : undefined,
messagesLimit:
typeof body.messagesLimit === "number" ? body.messagesLimit : undefined,
recordingsPageSize:
typeof body.recordingsPageSize === "number"
? body.recordingsPageSize
: undefined,
})
return NextResponse.json(result)
} catch (error) {
console.error("Failed to run GHL sync:", error)
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to run GHL sync",
},
{ status: 500 }
)
}
}

View file

@ -1,40 +0,0 @@
import { NextResponse } from "next/server"
import { fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
import { normalizePhoneE164 } from "@/lib/phone-normalization"
export async function POST(request: Request) {
const authError = await requirePhoneAgentInternalAuth(request)
if (authError) {
return authError
}
try {
const body = await request.json()
const normalizedPhone = normalizePhoneE164(body.phone)
if (!normalizedPhone) {
return NextResponse.json(
{ error: "phone is required" },
{ status: 400 }
)
}
const context = await fetchQuery(api.voiceSessions.getPhoneAgentContextByPhone, {
normalizedPhone,
})
return NextResponse.json({
success: true,
normalizedPhone,
...context,
})
} catch (error) {
console.error("Failed to look up phone agent contact context:", error)
return NextResponse.json(
{ error: "Failed to look up phone agent contact context" },
{ status: 500 }
)
}
}

View file

@ -1,179 +0,0 @@
import { NextResponse } from "next/server"
import { fetchMutation } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
import {
buildSameDayReminderWindow,
createFollowupReminderEvent,
isGoogleCalendarConfigured,
} from "@/lib/google-calendar"
import { normalizePhoneE164, splitDisplayName } from "@/lib/phone-normalization"
function buildReminderTitle(args: {
kind: "scheduled" | "same-day"
callerName?: string
company?: string
phone?: string
}) {
const label = args.kind === "same-day" ? "Same-day callback" : "Callback reminder"
const identity = [args.callerName, args.company, args.phone]
.map((value) => String(value || "").trim())
.filter(Boolean)
.join(" | ")
return identity ? `${label}: ${identity}` : label
}
function buildReminderDescription(args: {
callerName?: string
company?: string
phone?: string
reason?: string
summaryText?: string
adminCallUrl: string
}) {
return [
args.callerName ? `Caller: ${args.callerName}` : "",
args.company ? `Company: ${args.company}` : "",
args.phone ? `Phone: ${args.phone}` : "",
args.reason ? `Reason: ${args.reason}` : "",
args.summaryText ? `Summary: ${args.summaryText}` : "",
`RMV admin call detail: ${args.adminCallUrl}`,
]
.filter(Boolean)
.join("\n")
}
export async function POST(request: Request) {
const authError = await requirePhoneAgentInternalAuth(request)
if (authError) {
return authError
}
try {
const body = await request.json()
const sessionId = String(body.sessionId || "").trim()
const kind =
body.kind === "same-day" ? ("same-day" as const) : ("scheduled" as const)
if (!sessionId) {
return NextResponse.json(
{ error: "sessionId is required" },
{ status: 400 }
)
}
const url = new URL(request.url)
const adminCallUrl = `${url.origin}/admin/calls/${sessionId}`
const normalizedPhone = normalizePhoneE164(body.phone)
const callerName = String(body.callerName || "").trim()
const company = String(body.company || "").trim()
const reason = String(body.reason || "").trim()
const summaryText = String(body.summaryText || "").trim()
const calendarConfigured = isGoogleCalendarConfigured()
let startAt: Date
let endAt: Date
if (kind === "same-day") {
const reminderWindow = buildSameDayReminderWindow()
startAt = reminderWindow.startAt
endAt = reminderWindow.endAt
} else {
startAt = new Date(String(body.startAt || ""))
endAt = new Date(String(body.endAt || ""))
if (Number.isNaN(endAt.getTime()) && !Number.isNaN(startAt.getTime())) {
endAt = new Date(startAt.getTime() + 15 * 60 * 1000)
}
if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime()) || startAt.getTime() <= Date.now()) {
return NextResponse.json(
{ error: "A future startAt and endAt are required" },
{ status: 400 }
)
}
}
if (kind === "scheduled" && !calendarConfigured) {
return NextResponse.json(
{ error: "Google Calendar follow-up scheduling is not configured" },
{ status: 503 }
)
}
const reminder = calendarConfigured
? await createFollowupReminderEvent({
title: buildReminderTitle({
kind,
callerName,
company,
phone: normalizedPhone || String(body.phone || "").trim(),
}),
description: buildReminderDescription({
callerName,
company,
phone: normalizedPhone || String(body.phone || "").trim(),
reason,
summaryText,
adminCallUrl,
}),
startAt,
endAt,
})
: {
eventId: "",
htmlLink: "",
}
let contactProfileId: string | undefined
if (normalizedPhone) {
const nameParts = splitDisplayName(callerName)
const profile = await fetchMutation(api.contactProfiles.upsertByPhone, {
normalizedPhone,
displayName: callerName || undefined,
firstName: nameParts.firstName || undefined,
lastName: nameParts.lastName || undefined,
company: company || undefined,
lastSummaryText: summaryText || reason || undefined,
lastReminderAt: Date.now(),
reminderNotes: reason || undefined,
source: "phone-agent",
})
contactProfileId = profile?._id
}
const call = await fetchMutation(api.voiceSessions.linkPhoneCallLead, {
sessionId,
contactProfileId,
contactDisplayName: callerName || undefined,
contactCompany: company || undefined,
reminderStatus: kind === "same-day" ? "sameDay" : "scheduled",
reminderRequestedAt: Date.now(),
reminderStartAt: startAt.getTime(),
reminderEndAt: endAt.getTime(),
reminderCalendarEventId: reminder.eventId || undefined,
reminderCalendarHtmlLink: reminder.htmlLink || undefined,
reminderNote:
reason ||
summaryText ||
(!calendarConfigured ? "Manual follow-up reminder created without Google Calendar." : undefined),
})
return NextResponse.json({
success: true,
calendarConfigured,
reminder: {
kind,
startAt: startAt.toISOString(),
endAt: endAt.toISOString(),
eventId: reminder.eventId || null,
htmlLink: reminder.htmlLink || null,
},
call,
})
} catch (error) {
console.error("Failed to create phone agent follow-up reminder:", error)
return NextResponse.json(
{ error: "Failed to create phone agent follow-up reminder" },
{ status: 500 }
)
}
}

View file

@ -1,42 +0,0 @@
import { NextResponse } from "next/server"
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
import {
isGoogleCalendarConfigured,
listFutureCallbackSlots,
} from "@/lib/google-calendar"
export async function POST(request: Request) {
const authError = await requirePhoneAgentInternalAuth(request)
if (authError) {
return authError
}
try {
const body = await request.json().catch(() => ({}))
const limit =
typeof body.limit === "number" && body.limit > 0
? Math.min(body.limit, 5)
: 3
if (!isGoogleCalendarConfigured()) {
return NextResponse.json({
success: true,
calendarConfigured: false,
slots: [],
})
}
const slots = await listFutureCallbackSlots(limit)
return NextResponse.json({
success: true,
calendarConfigured: true,
slots,
})
} catch (error) {
console.error("Failed to list phone agent callback slots:", error)
return NextResponse.json(
{ error: "Failed to list phone agent callback slots" },
{ status: 500 }
)
}
}

View file

@ -1,37 +0,0 @@
import { NextResponse } from "next/server"
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
import { searchServiceKnowledge } from "@/lib/service-knowledge"
export async function POST(request: Request) {
const authError = await requirePhoneAgentInternalAuth(request)
if (authError) {
return authError
}
try {
const body = await request.json()
const query = String(body.query || "").trim()
if (!query) {
return NextResponse.json({ error: "query is required" }, { status: 400 })
}
const results = await searchServiceKnowledge({
query,
limit:
typeof body.limit === "number" && body.limit > 0
? Math.min(body.limit, 6)
: 4,
})
return NextResponse.json({
success: true,
results,
})
} catch (error) {
console.error("Failed to search phone agent service knowledge:", error)
return NextResponse.json(
{ error: "Failed to search phone agent service knowledge" },
{ status: 500 }
)
}
}

View file

@ -1,41 +1,32 @@
import { NextResponse } from "next/server"
import { fetchMutation, fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
import {
buildPhoneCallSummary,
sendPhoneCallSummaryEmail,
} from "@/lib/phone-calls"
import { NextResponse } from "next/server";
import { fetchMutation, fetchQuery } from "convex/nextjs";
import { api } from "@/convex/_generated/api";
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared";
import { buildPhoneCallSummary, sendPhoneCallSummaryEmail } from "@/lib/phone-calls";
export async function POST(request: Request) {
const authError = await requirePhoneAgentInternalAuth(request)
const authError = await requirePhoneAgentInternalAuth(request);
if (authError) {
return authError
return authError;
}
try {
const body = await request.json()
const callId = String(body.sessionId || body.roomName || "")
const body = await request.json();
const callId = String(body.sessionId || body.roomName || "");
if (!callId) {
return NextResponse.json(
{ error: "sessionId or roomName is required" },
{ status: 400 }
)
return NextResponse.json({ error: "sessionId or roomName is required" }, { status: 400 });
}
const detail = await fetchQuery(api.voiceSessions.getAdminPhoneCallDetail, {
callId,
})
});
if (!detail) {
return NextResponse.json(
{ error: "Phone call not found" },
{ status: 404 }
)
return NextResponse.json({ error: "Phone call not found" }, { status: 404 });
}
const url = new URL(request.url)
const summaryText = buildPhoneCallSummary(detail)
const url = new URL(request.url);
const summaryText = buildPhoneCallSummary(detail);
const notificationResult = await sendPhoneCallSummaryEmail({
detail: {
...detail,
@ -45,7 +36,7 @@ export async function POST(request: Request) {
},
},
adminUrl: url.origin,
})
});
const result = await fetchMutation(api.voiceSessions.completeSession, {
sessionId: detail.call.id,
@ -54,26 +45,20 @@ export async function POST(request: Request) {
recordingStatus: body.recordingStatus,
recordingId: body.recordingId ? String(body.recordingId) : undefined,
recordingUrl: body.recordingUrl ? String(body.recordingUrl) : undefined,
recordingError: body.recordingError
? String(body.recordingError)
: undefined,
recordingError: body.recordingError ? String(body.recordingError) : undefined,
summaryText,
notificationStatus: notificationResult.status,
notificationSentAt:
notificationResult.status === "sent" ? Date.now() : undefined,
notificationSentAt: notificationResult.status === "sent" ? Date.now() : undefined,
notificationError: notificationResult.error,
})
});
return NextResponse.json({
success: true,
call: result,
notification: notificationResult,
})
});
} catch (error) {
console.error("Failed to complete phone call sync:", error)
return NextResponse.json(
{ error: "Failed to complete phone call sync" },
{ status: 500 }
)
console.error("Failed to complete phone call sync:", error);
return NextResponse.json({ error: "Failed to complete phone call sync" }, { status: 500 });
}
}

View file

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

View file

@ -1,51 +1,51 @@
import { timingSafeEqual } from "node:crypto"
import { NextResponse } from "next/server"
import { hasConvexUrl } from "@/lib/convex-config"
import { timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
import { hasConvexUrl } from "@/lib/convex-config";
function readBearerToken(request: Request) {
const authHeader = request.headers.get("authorization") || ""
const authHeader = request.headers.get("authorization") || "";
if (!authHeader.toLowerCase().startsWith("bearer ")) {
return ""
return "";
}
return authHeader.slice("bearer ".length).trim()
return authHeader.slice("bearer ".length).trim();
}
function tokensMatch(expected: string, provided: string) {
const expectedBuffer = Buffer.from(expected)
const providedBuffer = Buffer.from(provided)
const expectedBuffer = Buffer.from(expected);
const providedBuffer = Buffer.from(provided);
if (expectedBuffer.length !== providedBuffer.length) {
return false
return false;
}
return timingSafeEqual(expectedBuffer, providedBuffer)
return timingSafeEqual(expectedBuffer, providedBuffer);
}
export function getPhoneAgentInternalToken() {
return String(process.env.PHONE_AGENT_INTERNAL_TOKEN || "").trim()
return String(process.env.PHONE_AGENT_INTERNAL_TOKEN || "").trim();
}
export async function requirePhoneAgentInternalAuth(request: Request) {
if (!hasConvexUrl()) {
return NextResponse.json(
{ error: "Convex is not configured for phone call sync" },
{ status: 503 }
)
{ status: 503 },
);
}
const configuredToken = getPhoneAgentInternalToken()
const configuredToken = getPhoneAgentInternalToken();
if (!configuredToken) {
return NextResponse.json(
{ error: "Phone call sync token is not configured" },
{ status: 503 }
)
{ status: 503 },
);
}
const providedToken = readBearerToken(request)
const providedToken = readBearerToken(request);
if (!providedToken || !tokensMatch(configuredToken, providedToken)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return null
return null;
}

View file

@ -1,79 +1,37 @@
import { NextResponse } from "next/server"
import { fetchMutation, fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
import { normalizePhoneE164 } from "@/lib/phone-normalization"
import { NextResponse } from "next/server";
import { fetchMutation } from "convex/nextjs";
import { api } from "@/convex/_generated/api";
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared";
export async function POST(request: Request) {
const authError = await requirePhoneAgentInternalAuth(request)
const authError = await requirePhoneAgentInternalAuth(request);
if (authError) {
return authError
return authError;
}
try {
const body = await request.json()
let metadata: Record<string, unknown> = {}
if (typeof body.metadata === "string" && body.metadata.trim()) {
try {
metadata = JSON.parse(body.metadata)
} catch {
metadata = {}
}
}
const callerPhone = normalizePhoneE164(
metadata.participantPhone || body.participantIdentity
)
const contactContext = callerPhone
? await fetchQuery(api.voiceSessions.getPhoneAgentContextByPhone, {
normalizedPhone: callerPhone,
})
: null
const result = await fetchMutation(
api.voiceSessions.upsertPhoneCallSession,
{
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 });
}
}

View file

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

View file

@ -11,19 +11,11 @@ type TokenRequestBody = {
export async function POST(request: NextRequest) {
try {
const body = (await request.json().catch(() => ({}))) as TokenRequestBody
const pathname =
typeof body.pathname === "string" && body.pathname.trim()
? body.pathname.trim()
: "/"
const pathname = typeof body.pathname === "string" && body.pathname.trim() ? body.pathname.trim() : "/"
if (isVoiceAssistantSuppressedRoute(pathname)) {
console.info("[voice-assistant/token] blocked on suppressed route", {
pathname,
})
return NextResponse.json(
{ error: "Voice assistant is not available on this route." },
{ status: 403 }
)
console.info("[voice-assistant/token] blocked on suppressed route", { pathname })
return NextResponse.json({ error: "Voice assistant is not available on this route." }, { status: 403 })
}
const tokenResponse = await createVoiceAssistantTokenResponse(pathname)
@ -38,12 +30,9 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to create voice assistant token",
error: error instanceof Error ? error.message : "Failed to create voice assistant token",
},
{ status: 500 }
{ status: 500 },
)
}
}

View file

@ -27,7 +27,7 @@ function invalidPath(pathValue: string) {
export async function GET(
_request: Request,
{ params }: { params: Promise<{ path: string[] }> }
{ params }: { params: Promise<{ path: string[] }> },
) {
try {
const { path: pathArray } = await params
@ -59,9 +59,7 @@ export async function GET(
return new NextResponse("File not found", { status: 404 })
}
const fileToRead = existsSync(normalizedFullPath)
? normalizedFullPath
: fullPath
const fileToRead = existsSync(normalizedFullPath) ? normalizedFullPath : fullPath
const resolvedPath = fileToRead.replace(/\\/g, "/")
if (!resolvedPath.startsWith(normalizedManualsDir)) {
return new NextResponse("Invalid path", { status: 400 })

View file

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

View file

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

View file

@ -2,12 +2,12 @@ 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
* POST /api/request-indexing
*
*
* Body: { url: string }
* NOTE: This route is disabled for static export.
*/
@ -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