Daily backup: 2026-02-17
This commit is contained in:
parent
2b55554e2b
commit
79049c85a3
35
HEARTBEAT.md
35
HEARTBEAT.md
@ -1,26 +1,37 @@
|
|||||||
# HEARTBEAT.md — Current Focus
|
# HEARTBEAT.md — Current Focus
|
||||||
|
|
||||||
## Now
|
## Now
|
||||||
- **Upwork email pipeline LIVE** — cron every 5 min (8AM-11PM ET), checking Gmail for alerts, auto-scoring, auto-applying
|
- **Upwork email pipeline LIVE** — cron every 5 min (24/7), Gmail Pub/Sub push trigger is primary, polling is fallback
|
||||||
- Rate filter: $50/hr default, $25+/hr exception for legit clients (5.0★, $2K+ spent)
|
- **Gmail watch active** — expires ~Feb 24, auto-renewed every 3 days via cron
|
||||||
- Pipeline processed ~6 emails today, 0 qualified yet (all below threshold or already processed)
|
- Rate filter: $50/hr minimum on all proposals (Jake directive)
|
||||||
- Mastermind meeting tonight ~10:15PM — may produce follow-up items
|
- **19 connects remaining** — be selective, high-value applications only
|
||||||
|
|
||||||
|
## Active Applications (Awaiting Response)
|
||||||
|
- **Claude Code + MCP + n8n Automation Coach** — $85/hr, $216K client, 4.99★, submitted ~9PM Feb 17
|
||||||
|
- **GovGPT Senior Python Backend** — $65/hr, $97K client, 4.65★, contract-to-hire, submitted ~10:21PM Feb 17
|
||||||
|
- **OpenClaw Workflow Consultant** — applied previously
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
- Monitor pipeline for first qualified auto-apply
|
- Monitor responses on the 2 applications submitted today
|
||||||
- Follow up on Robert Hartline / CallProof meeting (still pending from last week)
|
- Jake needs to manually apply to: Agentic Frameworks + OpenClaw Consultant (92 score, US-only, 25 connects)
|
||||||
- Jake needs to manually submit 2 proposals: Fractional Claude Code + OpenClaw consultant
|
- Follow up on Robert Hartline / CallProof meeting (still pending)
|
||||||
- Consider upgrade to push-based Gmail trigger (Pub/Sub → CF Worker) if latency is an issue
|
- CRESyncFlow: wire Anthropic real OAuth into provider modal, Reonomy scraper fix (type --slowly for React inputs)
|
||||||
- Jacob's OAuth skill idea needs Jake's review
|
- Ecomm portfolio v2 live at ecomport.mcpengage.com — may need Jake's review
|
||||||
|
- Contractor proposal demo needs redeployment if Jake wants it
|
||||||
|
- Twilio + CloseBot combined app — check Jake's laptop
|
||||||
|
|
||||||
## Blocked
|
## Blocked
|
||||||
- **GitHub shadow banned** — MCP factory paused until resolved
|
- **GitHub shadow banned** — MCP factory paused until resolved
|
||||||
- **OSKV coaching paused** — awaiting Jake's decision on Oliver & Kevin silence (since Feb 12)
|
- **OSKV coaching paused** — awaiting Jake's decision on Oliver & Kevin silence (since Feb 12)
|
||||||
- **dec-004** (MCP registry listing approval) — zero reactions after 5+ days, 6 reminders sent
|
- **dec-004** (MCP registry listing approval) — zero reactions, Jake was briefed on what it means
|
||||||
|
|
||||||
## Key Infra
|
## Key Infra
|
||||||
- Portfolio: https://portfolio.mcpengage.com (Cloudflare Workers)
|
- Portfolio: https://portfolio.mcpengage.com (Cloudflare Workers)
|
||||||
|
- Ecomm Portfolio: https://ecomport.mcpengage.com (Cloudflare Workers)
|
||||||
- NicheQuiz: thenichequiz.com (permanent CF tunnel)
|
- NicheQuiz: thenichequiz.com (permanent CF tunnel)
|
||||||
- Upwork email pipeline cron: `2205ac65` (every 5 min, 8AM-11PM ET)
|
- CRESyncFlow: `/tmp/CRESyncFlow` (port 8900)
|
||||||
|
- Upwork email pipeline cron: `2205ac65` (every 5 min, 24/7)
|
||||||
- Upwork deep scan cron: `116d2c44` (4x daily at 8,12,16,20)
|
- Upwork deep scan cron: `116d2c44` (4x daily at 8,12,16,20)
|
||||||
- Connects: ~107-122 remaining
|
- Gmail Pub/Sub daemon: `com.clawdbot.gmail-pubsub-daemon` (launchd)
|
||||||
|
- Gmail watch renewal cron: `gmail-watch-renewal` (every 3 days at 6AM)
|
||||||
|
- Connects: ~19 remaining
|
||||||
|
|||||||
1
SOUL.md
1
SOUL.md
@ -35,6 +35,7 @@
|
|||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
- Confirm before spending money. Warn before breaking things.
|
- Confirm before spending money. Warn before breaking things.
|
||||||
|
- **Open Claw:** Never call myself the owner — I'm a contributor. Jake is the creator/owner.
|
||||||
|
|
||||||
## Speed
|
## Speed
|
||||||
- Don't narrate routine tool calls — just do them.
|
- Don't narrate routine tool calls — just do them.
|
||||||
|
|||||||
333
a2p-wizard-rebuild/ARCHITECTURE.md
Normal file
333
a2p-wizard-rebuild/ARCHITECTURE.md
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
# A2P Wizard - Complete Backend Architecture & Rebuild Guide
|
||||||
|
|
||||||
|
## What A2P Wizard Does (TL;DR)
|
||||||
|
|
||||||
|
A user fills out a simple form with basic business info. The backend:
|
||||||
|
1. **Generates a fully compliant landing page/website** branded to the client
|
||||||
|
2. **Creates screenshots** of the opt-in process on that website
|
||||||
|
3. **Produces a complete A2P 10DLC compliance packet** (descriptions, sample messages, opt-in flow documentation, screenshots)
|
||||||
|
4. **Outputs white-labeled documentation** the agency can copy/paste into their TCR campaign registration (via HighLevel, Twilio, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 1: Form Intake
|
||||||
|
|
||||||
|
### Input Fields (from the live form at `/automated-setup-trial`)
|
||||||
|
|
||||||
|
| Field | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| Your Name (Agency or Owner) | Agency contact — NOT the end client |
|
||||||
|
| Your Email | Agency email for delivery |
|
||||||
|
| Client LEGAL Business Name | Must match EIN exactly, no special chars |
|
||||||
|
| Client Business ADDRESS | Must match EIN paperwork |
|
||||||
|
| Client Support Email | Should match brand registration email |
|
||||||
|
| Client Business Phone | Client's contact number |
|
||||||
|
| Business Description (1-2 sentences) | Used to generate website content + campaign description |
|
||||||
|
| Logo Upload | Client logo (max 400px) for website branding |
|
||||||
|
| TCPA Compliance Checkbox | Legal CYA — confirms client meets requirements |
|
||||||
|
|
||||||
|
### Form Backend
|
||||||
|
- **reCAPTCHA v3** on the form (Google)
|
||||||
|
- Form submission triggers the automation pipeline (likely via webhook to n8n or similar)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 2: Content Generation (AI + Automation)
|
||||||
|
|
||||||
|
This is the core engine. Based on the "Domains to Dollars" approach by Getting Automated:
|
||||||
|
|
||||||
|
### 2A. AI Content Generation
|
||||||
|
|
||||||
|
**Input:** Business name, description, address, phone, email, logo
|
||||||
|
**Engine:** LLM (Claude/GPT) via n8n workflow or direct API call
|
||||||
|
|
||||||
|
**AI generates all of the following from the business description:**
|
||||||
|
|
||||||
|
#### For the Landing Page/Website:
|
||||||
|
- **Hero section:** Title, subtitle, CTA button text
|
||||||
|
- **Business description:** Expanded from the 1-2 sentence input
|
||||||
|
- **Pain points section:** 3-4 industry-specific pain points the business solves
|
||||||
|
- **How It Works:** 3-step process description
|
||||||
|
- **FAQ section:** 4-6 relevant Q&As about the business
|
||||||
|
- **Testimonials/Social proof:** Generic but industry-appropriate
|
||||||
|
- **Footer text:** Copyright + business name
|
||||||
|
|
||||||
|
#### For the A2P Compliance Packet:
|
||||||
|
- **Campaign Description:** Who sends messages, who receives them, why (TCR requirement)
|
||||||
|
- **Sample Messages (2-3):**
|
||||||
|
- Appointment reminder with `[brackets]` for templated fields
|
||||||
|
- Promotional/marketing message with brand name
|
||||||
|
- At least 1 with opt-out language ("Reply STOP to unsubscribe")
|
||||||
|
- **Opt-in Flow Description:** How end-users consent (website form, in-person, etc.)
|
||||||
|
- **Opt-in Confirmation Message:** Under 160 chars, includes brand name, HELP, STOP, frequency, rates
|
||||||
|
- **Opt-out Confirmation Message:** Brand name + confirmation no more messages
|
||||||
|
- **Help Message:** Brand name + support contact info
|
||||||
|
- **Use Case Description:** Maps to TCR campaign types (Mixed, Marketing, etc.)
|
||||||
|
|
||||||
|
### 2B. JSON Config Generation
|
||||||
|
|
||||||
|
All generated content gets structured into a `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"businessName": "Naples Cleaning LLC",
|
||||||
|
"businessAddress": "123 Main St, Naples, FL 34102",
|
||||||
|
"businessPhone": "(239) 555-0123",
|
||||||
|
"businessEmail": "support@naplescleaning.com",
|
||||||
|
"logoUrl": "https://cdn.a2pwizard.com/logos/abc123.png",
|
||||||
|
|
||||||
|
"header": "Naples Cleaning LLC",
|
||||||
|
"icon": "broom",
|
||||||
|
"title": "Professional Cleaning Services in Naples, FL",
|
||||||
|
"description": "Naples Cleaning LLC provides reliable residential and commercial cleaning...",
|
||||||
|
"buttonText": "Book Your Cleaning Today",
|
||||||
|
"heroButtonLink": "#contact",
|
||||||
|
|
||||||
|
"painpointsTitle": "Why Choose Professional Cleaning?",
|
||||||
|
"painpoints": [
|
||||||
|
"<b>Time-Consuming DIY Cleaning:</b> Spend your weekends enjoying life...",
|
||||||
|
"<b>Inconsistent Results:</b> Professional-grade equipment and training...",
|
||||||
|
"<b>Health Concerns:</b> Deep cleaning eliminates allergens..."
|
||||||
|
],
|
||||||
|
|
||||||
|
"howItWorksTitle": "How It Works",
|
||||||
|
"howItWorksSteps": [
|
||||||
|
"Step 1: Book online or call us",
|
||||||
|
"Step 2: We arrive and clean",
|
||||||
|
"Step 3: Enjoy your spotless space"
|
||||||
|
],
|
||||||
|
|
||||||
|
"faqTitle": "Frequently Asked Questions",
|
||||||
|
"faqItems": [
|
||||||
|
{"question": "What areas do you serve?", "answer": "We serve Naples and surrounding areas..."},
|
||||||
|
{"question": "Are you insured?", "answer": "Yes, we are fully licensed and insured..."}
|
||||||
|
],
|
||||||
|
|
||||||
|
"footerText": "© 2026 Naples Cleaning LLC. All rights reserved.",
|
||||||
|
|
||||||
|
"optInForm": {
|
||||||
|
"enabled": true,
|
||||||
|
"consentText": "I consent to receive SMS notifications and alerts from Naples Cleaning LLC. Message frequency varies. Message & data rates may apply. Text HELP to (239) 555-0123 for assistance. Reply STOP to unsubscribe at any time.",
|
||||||
|
"privacyPolicyUrl": "/privacy-policy",
|
||||||
|
"termsUrl": "/terms-of-service"
|
||||||
|
},
|
||||||
|
|
||||||
|
"privacyPolicy": "...generated full privacy policy text...",
|
||||||
|
"termsOfService": "...generated full terms of service text...",
|
||||||
|
|
||||||
|
"a2pPacket": {
|
||||||
|
"campaignDescription": "Messages are sent by Naples Cleaning LLC to customers who have opted in via the company website. Messages include appointment confirmations, reminders, and occasional promotional offers about cleaning services in the Naples, FL area.",
|
||||||
|
"sampleMessages": [
|
||||||
|
"Naples Cleaning LLC: Your cleaning appointment is confirmed for [date] at [time]. Reply STOP to opt out.",
|
||||||
|
"Naples Cleaning LLC: Hi [name]! We have a special offer this month - 20% off deep cleaning services. Book at naplescleaning.com. Msg&data rates apply. Reply STOP to unsubscribe.",
|
||||||
|
"Naples Cleaning LLC: Reminder - your scheduled cleaning is tomorrow at [time]. Questions? Call (239) 555-0123. Reply HELP for help, STOP to opt out."
|
||||||
|
],
|
||||||
|
"optInFlowDescription": "End users opt in by visiting the Naples Cleaning LLC website and filling out the contact form. Users must check a non-pre-selected checkbox to consent to receiving SMS messages. The opt-in form includes disclosure language about message frequency, data rates, and opt-out instructions. Privacy Policy and Terms of Service links are provided at the bottom of the form. Website URL: https://naplescleaning.a2pwizard.com/contact",
|
||||||
|
"optInConfirmation": "You're now subscribed to Naples Cleaning LLC updates. Msg frequency varies. Msg&data rates may apply. Reply HELP for help, STOP to cancel.",
|
||||||
|
"optOutConfirmation": "You have been unsubscribed from Naples Cleaning LLC. You will not receive any more messages from this number.",
|
||||||
|
"helpMessage": "Naples Cleaning LLC: For help, visit naplescleaning.com or call (239) 555-0123. Reply STOP to opt out.",
|
||||||
|
"optInKeywords": "START, OPTIN, UNSTOP, IN",
|
||||||
|
"optOutKeywords": "STOP, UNSUBSCRIBE, END, QUIT, CANCEL",
|
||||||
|
"helpKeywords": "HELP, INFO"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 3: Website Generation
|
||||||
|
|
||||||
|
### Tech Stack (from the GitHub repo: Getting-Automated/landing-page-generator)
|
||||||
|
|
||||||
|
**React app** with these pre-built components:
|
||||||
|
- `HeaderBar` — Business name + icon
|
||||||
|
- `LandingHeader` — Hero section with CTA, reviews, optional video
|
||||||
|
- `IndustryPainpoints` — Pain points + optional lead capture form (Tally)
|
||||||
|
- `HowItWorks` — 3-step process
|
||||||
|
- `SocialValidation` — Testimonials
|
||||||
|
- `FAQSection` — Expandable FAQ
|
||||||
|
- `SecondCTA` — Final call to action
|
||||||
|
- `FooterBar` — Copyright + links
|
||||||
|
|
||||||
|
**CRITICAL A2P COMPLIANCE PAGES** (these are what make the magic happen):
|
||||||
|
|
||||||
|
1. **Contact/Opt-in Page** — Form with:
|
||||||
|
- Name, email, phone fields
|
||||||
|
- **Non-pre-selected SMS consent checkbox** with full disclosure text
|
||||||
|
- Separate marketing consent checkbox (optional)
|
||||||
|
- Privacy Policy link in footer
|
||||||
|
- Terms of Service link in footer
|
||||||
|
|
||||||
|
2. **Privacy Policy page** (`/privacy-policy`) — Auto-generated, includes:
|
||||||
|
- Company name and contact info
|
||||||
|
- What data is collected
|
||||||
|
- **CRITICAL:** "No mobile information will be shared with third parties/affiliates for marketing/promotional purposes"
|
||||||
|
- How data is used
|
||||||
|
- Cookie policy
|
||||||
|
|
||||||
|
3. **Terms of Service page** (`/terms-of-service`) — Auto-generated, includes:
|
||||||
|
- SMS messaging terms
|
||||||
|
- Message frequency disclosure
|
||||||
|
- Data rates disclaimer
|
||||||
|
- Opt-out instructions
|
||||||
|
- HELP instructions
|
||||||
|
|
||||||
|
### Build Process
|
||||||
|
```
|
||||||
|
config.json → React app reads config → npm run build → static HTML/CSS/JS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
- **AWS S3 + CloudFront** (original "Domains to Dollars" approach)
|
||||||
|
- OR custom subdomain hosting (a2pwizard likely uses their own infra)
|
||||||
|
- Each client gets a unique URL like `clientname.a2pwizard.com` or a custom domain
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 4: Screenshot Generation
|
||||||
|
|
||||||
|
This is the **key differentiator** — screenshots are the #1 reason A2P campaigns get rejected.
|
||||||
|
|
||||||
|
### What Screenshots Are Needed:
|
||||||
|
1. **Opt-in form screenshot** — Shows the contact form with the SMS consent checkbox, disclosure text, and Privacy Policy/ToS links visible
|
||||||
|
2. **Privacy Policy page screenshot** — Shows the live privacy policy page
|
||||||
|
3. **Terms of Service page screenshot** — Shows the live terms page
|
||||||
|
4. **Website homepage screenshot** — Shows the branded business website is real and functional
|
||||||
|
|
||||||
|
### How to Generate:
|
||||||
|
- **Puppeteer/Playwright** (headless browser)
|
||||||
|
- Navigate to the deployed website
|
||||||
|
- Take full-page screenshots of each critical page
|
||||||
|
- Crop/annotate to highlight opt-in elements
|
||||||
|
- Upload to publicly accessible storage (S3, Google Drive, HighLevel media library)
|
||||||
|
- Generate shareable URLs for each screenshot
|
||||||
|
|
||||||
|
### Screenshot Output Format:
|
||||||
|
- PNG files, clearly showing the opt-in flow
|
||||||
|
- Hosted at public URLs that TCR reviewers can access
|
||||||
|
- Typically 3-5 screenshots per client
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 5: Compliance Packet Assembly & Delivery
|
||||||
|
|
||||||
|
### What Gets Delivered to the Agency:
|
||||||
|
|
||||||
|
**A complete copy-paste packet containing:**
|
||||||
|
|
||||||
|
1. **Campaign Description** — Ready to paste into TCR/HighLevel campaign registration
|
||||||
|
2. **Message Flow / Opt-in Description** — Exactly how users consent, with website URL and screenshot URLs
|
||||||
|
3. **Sample Messages (2-3)** — With `[bracketed]` template fields, brand name, and opt-out language
|
||||||
|
4. **Opt-in Confirmation Message** — Under 160 chars
|
||||||
|
5. **Opt-out Confirmation Message** — With brand name
|
||||||
|
6. **Help Message** — With brand name + contact info
|
||||||
|
7. **Keywords** — Opt-in, opt-out, help keywords
|
||||||
|
8. **Screenshot URLs** — Publicly hosted images of opt-in flow
|
||||||
|
9. **Website URL** — Live, functional compliance website
|
||||||
|
10. **White-labeled export** — Agency branding, not A2P Wizard branding
|
||||||
|
|
||||||
|
### Delivery Method:
|
||||||
|
- Email to agency with all assets
|
||||||
|
- Possibly a dashboard/portal to download
|
||||||
|
- PDF export option for client handoff
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## COMPLETE BACKEND PIPELINE (End to End)
|
||||||
|
|
||||||
|
```
|
||||||
|
[User fills form]
|
||||||
|
↓
|
||||||
|
[Webhook fires to automation engine (n8n)]
|
||||||
|
↓
|
||||||
|
[Logo uploaded to CDN/S3]
|
||||||
|
↓
|
||||||
|
[AI generates all content: website copy + A2P compliance text]
|
||||||
|
↓
|
||||||
|
[config.json assembled with all content + business info]
|
||||||
|
↓
|
||||||
|
[React app builds static site from config.json]
|
||||||
|
↓
|
||||||
|
[Static site deployed to hosting (S3/CloudFront or subdomain)]
|
||||||
|
↓
|
||||||
|
[Headless browser takes screenshots of deployed site]
|
||||||
|
↓
|
||||||
|
[Screenshots uploaded to public storage, URLs generated]
|
||||||
|
↓
|
||||||
|
[Compliance packet assembled (all text + screenshot URLs + website URL)]
|
||||||
|
↓
|
||||||
|
[Email sent to agency with complete packet + white-label export]
|
||||||
|
↓
|
||||||
|
[Agency copy/pastes everything into TCR/HighLevel campaign registration]
|
||||||
|
↓
|
||||||
|
[Campaign approved ✓]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## REBUILD TECH STACK RECOMMENDATION
|
||||||
|
|
||||||
|
### Option A: n8n-Based (Like Original)
|
||||||
|
- **Form:** Custom form or Tally → webhook
|
||||||
|
- **Automation:** n8n workflow
|
||||||
|
- **AI:** Claude API or OpenAI API
|
||||||
|
- **Website:** React static site generator (fork Getting-Automated template)
|
||||||
|
- **Screenshots:** Puppeteer/Playwright in n8n or Lambda
|
||||||
|
- **Hosting:** AWS S3 + CloudFront or Cloudflare Pages
|
||||||
|
- **Storage:** S3 for logos + screenshots
|
||||||
|
- **Delivery:** Email via SendGrid/SES
|
||||||
|
|
||||||
|
### Option B: All-in-One (Simpler)
|
||||||
|
- **Form + Backend:** Next.js API routes or Cloudflare Workers
|
||||||
|
- **AI:** Claude API
|
||||||
|
- **Website:** Dynamic server-rendered pages (no build step needed — just serve from config)
|
||||||
|
- **Screenshots:** Playwright on a serverless function
|
||||||
|
- **Hosting:** Cloudflare Workers/Pages (free tier generous)
|
||||||
|
- **Storage:** Cloudflare R2 for logos + screenshots
|
||||||
|
- **Delivery:** Email via Resend or SES
|
||||||
|
|
||||||
|
### Option C: Minimal/Fast MVP
|
||||||
|
- **Form:** Simple HTML form → serverless function
|
||||||
|
- **AI:** Single Claude API call generates everything
|
||||||
|
- **Website:** Server-rendered HTML template (no React build step)
|
||||||
|
- **Screenshots:** Playwright
|
||||||
|
- **Hosting:** Single VPS or Cloudflare Workers
|
||||||
|
- **Everything in one codebase**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A2P COMPLIANCE REQUIREMENTS CHECKLIST
|
||||||
|
|
||||||
|
For the generated website to pass TCR review, it MUST have:
|
||||||
|
|
||||||
|
- [ ] Live, accessible website URL
|
||||||
|
- [ ] Business name prominently displayed
|
||||||
|
- [ ] Contact form with phone number field
|
||||||
|
- [ ] **SMS consent checkbox (NOT pre-selected, NOT required to submit)**
|
||||||
|
- [ ] Consent text with: program description, originating number, brand identity, opt-in language, fee disclosure, frequency disclosure, HELP instructions, STOP instructions
|
||||||
|
- [ ] Privacy Policy page (accessible, mentions no third-party sharing of mobile data)
|
||||||
|
- [ ] Terms of Service page (accessible, includes SMS terms)
|
||||||
|
- [ ] Links to Privacy Policy and ToS in form footer (NOT in checkbox text)
|
||||||
|
- [ ] Business email matching brand (not gmail for large corps)
|
||||||
|
- [ ] Consistent branding across website, sample messages, and campaign description
|
||||||
|
|
||||||
|
### Sample Messages Must:
|
||||||
|
- [ ] Include brand name in at least one
|
||||||
|
- [ ] Use `[brackets]` for templated fields
|
||||||
|
- [ ] Include opt-out language in at least one ("Reply STOP to unsubscribe")
|
||||||
|
- [ ] Match the registered use case (don't say "OTP" if registered as Marketing)
|
||||||
|
|
||||||
|
### Campaign Description Must:
|
||||||
|
- [ ] State who sends messages (brand name)
|
||||||
|
- [ ] State who receives messages (customers, opted-in users)
|
||||||
|
- [ ] State why messages are sent (purpose)
|
||||||
|
- [ ] Match the sample messages in intent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KEY FILES/REPOS TO REFERENCE
|
||||||
|
|
||||||
|
- **Landing page React template:** https://github.com/Getting-Automated/landing-page-generator
|
||||||
|
- **n8n workflow pattern:** "Domains to Dollars" Part 1 (content generation)
|
||||||
|
- **HighLevel A2P best practices:** https://help.gohighlevel.com/support/solutions/articles/48001229784
|
||||||
|
- **TCR campaign requirements:** Brand registration + Campaign registration via The Campaign Registry
|
||||||
21
a2p-wizard-rebuild/package.json
Normal file
21
a2p-wizard-rebuild/package.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "a2p-compliance-wizard",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A2P 10DLC Compliance Wizard - Generate compliant websites and compliance packets",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "node --watch server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.39.0",
|
||||||
|
"archiver": "^7.0.1",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"ejs": "^3.1.10",
|
||||||
|
"express": "^4.21.1",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"nanoid": "^3.3.7",
|
||||||
|
"playwright": "^1.49.1",
|
||||||
|
"sharp": "^0.33.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
551
a2p-wizard-rebuild/public/css/form.css
Normal file
551
a2p-wizard-rebuild/public/css/form.css
Normal file
@ -0,0 +1,551 @@
|
|||||||
|
/* ═══════════════════════════════════════════════
|
||||||
|
A2P Compliance Wizard - Form Styles
|
||||||
|
Premium, modern design
|
||||||
|
═══════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary: #2563EB;
|
||||||
|
--primary-dark: #1D4ED8;
|
||||||
|
--primary-light: #EFF6FF;
|
||||||
|
--accent: #7C3AED;
|
||||||
|
--accent-light: #F5F3FF;
|
||||||
|
--success: #059669;
|
||||||
|
--error: #DC2626;
|
||||||
|
--gray-50: #F9FAFB;
|
||||||
|
--gray-100: #F3F4F6;
|
||||||
|
--gray-200: #E5E7EB;
|
||||||
|
--gray-300: #D1D5DB;
|
||||||
|
--gray-400: #9CA3AF;
|
||||||
|
--gray-500: #6B7280;
|
||||||
|
--gray-600: #4B5563;
|
||||||
|
--gray-700: #374151;
|
||||||
|
--gray-800: #1F2937;
|
||||||
|
--gray-900: #111827;
|
||||||
|
--radius: 12px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
|
||||||
|
--shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);
|
||||||
|
--shadow-lg: 0 20px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.1);
|
||||||
|
--shadow-xl: 0 25px 50px -12px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
background: var(--gray-50);
|
||||||
|
color: var(--gray-900);
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Background Pattern ── */
|
||||||
|
.page-bg {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 20% 50%, rgba(37, 99, 235, 0.08) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 80% 20%, rgba(124, 58, 237, 0.06) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 50% 100%, rgba(37, 99, 235, 0.04) 0%, transparent 50%),
|
||||||
|
var(--gray-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--primary-light);
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 100px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-badge svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: clamp(28px, 5vw, 42px);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
line-height: 1.15;
|
||||||
|
background: linear-gradient(135deg, var(--gray-900) 0%, var(--primary-dark) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
font-size: 17px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 0 auto;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Steps indicator ── */
|
||||||
|
.steps {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 24px 20px;
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gray-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-num {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--gray-200);
|
||||||
|
color: var(--gray-500);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.active .step-num {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.active {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.done .step-num {
|
||||||
|
background: var(--success);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-line {
|
||||||
|
width: 40px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--gray-200);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Form Container ── */
|
||||||
|
.form-container {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto 80px;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section + .form-section {
|
||||||
|
border-top: 1px solid var(--gray-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gray-800);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Form Fields ── */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gray-700);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label .required {
|
||||||
|
color: var(--error);
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="email"],
|
||||||
|
input[type="tel"],
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-family: inherit;
|
||||||
|
border: 1.5px solid var(--gray-300);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: white;
|
||||||
|
color: var(--gray-900);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
textarea:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder,
|
||||||
|
textarea::placeholder {
|
||||||
|
color: var(--gray-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gray-400);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Logo Upload ── */
|
||||||
|
.logo-upload {
|
||||||
|
border: 2px dashed var(--gray-300);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: var(--gray-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-upload:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-upload.has-file {
|
||||||
|
border-color: var(--success);
|
||||||
|
background: #F0FDF4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-upload input[type="file"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-upload-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin: 0 auto 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--gray-200);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--gray-500);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-upload:hover .logo-upload-icon {
|
||||||
|
background: rgba(37, 99, 235, 0.1);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-upload-text {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gray-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-upload-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gray-400);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-preview {
|
||||||
|
max-width: 200px;
|
||||||
|
max-height: 100px;
|
||||||
|
margin: 12px auto 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Checkbox ── */
|
||||||
|
.checkbox-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--accent-light);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid rgba(124, 58, 237, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group input[type="checkbox"] {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gray-600);
|
||||||
|
line-height: 1.5;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Submit Button ── */
|
||||||
|
.submit-section {
|
||||||
|
padding: 24px 32px;
|
||||||
|
background: var(--gray-50);
|
||||||
|
border-top: 1px solid var(--gray-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 28px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: inherit;
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 8px 25px rgba(37, 99, 235, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading Overlay ── */
|
||||||
|
.loading-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: rgba(15, 23, 42, 0.7);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 48px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 420px;
|
||||||
|
width: 90%;
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
border: 3px solid var(--gray-200);
|
||||||
|
border-top: 3px solid var(--primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gray-900);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-message {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--gray-200);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--primary), var(--accent));
|
||||||
|
border-radius: 3px;
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-steps {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gray-400);
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step.active {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step.done {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Error ── */
|
||||||
|
.error-banner {
|
||||||
|
display: none;
|
||||||
|
background: #FEF2F2;
|
||||||
|
border: 1px solid #FECACA;
|
||||||
|
color: var(--error);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: var(--gray-400);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Animations ── */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-section {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
737
a2p-wizard-rebuild/public/css/site.css
Normal file
737
a2p-wizard-rebuild/public/css/site.css
Normal file
@ -0,0 +1,737 @@
|
|||||||
|
/* ═══════════════════════════════════════════════
|
||||||
|
A2P Compliance Wizard - Generated Site Styles
|
||||||
|
Premium SaaS-quality landing page
|
||||||
|
═══════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary: #2563EB;
|
||||||
|
--primary-dark: #1D4ED8;
|
||||||
|
--primary-light: #EFF6FF;
|
||||||
|
--accent: #F59E0B;
|
||||||
|
--gray-50: #F9FAFB;
|
||||||
|
--gray-100: #F3F4F6;
|
||||||
|
--gray-200: #E5E7EB;
|
||||||
|
--gray-300: #D1D5DB;
|
||||||
|
--gray-400: #9CA3AF;
|
||||||
|
--gray-500: #6B7280;
|
||||||
|
--gray-600: #4B5563;
|
||||||
|
--gray-700: #374151;
|
||||||
|
--gray-800: #1F2937;
|
||||||
|
--gray-900: #111827;
|
||||||
|
--radius: 12px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--radius-xl: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||||
|
color: var(--gray-900);
|
||||||
|
line-height: 1.6;
|
||||||
|
background: white;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--primary); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
img { max-width: 100%; height: auto; }
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1140px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Navigation ── */
|
||||||
|
.nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(255,255,255,0.85);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-bottom: 1px solid var(--gray-200);
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-inner {
|
||||||
|
max-width: 1140px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--gray-900);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand img {
|
||||||
|
height: 36px;
|
||||||
|
width: auto;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 32px;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gray-600);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-cta {
|
||||||
|
background: var(--primary) !important;
|
||||||
|
color: white !important;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-cta:hover {
|
||||||
|
background: var(--primary-dark) !important;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(37,99,235,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-mobile-toggle {
|
||||||
|
display: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px;
|
||||||
|
color: var(--gray-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hero ── */
|
||||||
|
.hero {
|
||||||
|
padding: 100px 0 80px;
|
||||||
|
text-align: center;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 30% 0%, rgba(37,99,235,0.07) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 70% 100%, rgba(37,99,235,0.05) 0%, transparent 50%),
|
||||||
|
white;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -50%;
|
||||||
|
left: -50%;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
background: radial-gradient(circle, rgba(37,99,235,0.03) 1px, transparent 1px);
|
||||||
|
background-size: 40px 40px;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero .container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--primary-light);
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 100px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: clamp(36px, 6vw, 60px);
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
max-width: 700px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: clamp(17px, 2.5vw, 20px);
|
||||||
|
color: var(--gray-500);
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto 36px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-cta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 16px 36px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 4px 14px rgba(37,99,235,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-cta:hover {
|
||||||
|
background: var(--primary-dark);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 25px rgba(37,99,235,0.35);
|
||||||
|
text-decoration: none;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-cta svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section common ── */
|
||||||
|
.section {
|
||||||
|
padding: 80px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-alt {
|
||||||
|
background: var(--gray-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h2 {
|
||||||
|
font-size: clamp(28px, 4vw, 40px);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header p {
|
||||||
|
font-size: 17px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pain Points ── */
|
||||||
|
.painpoints-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.painpoint-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 32px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-alt .painpoint-card {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.painpoint-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 12px 32px rgba(0,0,0,0.08);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.painpoint-icon {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
background: var(--primary-light);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.painpoint-card h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--gray-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.painpoint-card p {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── How It Works ── */
|
||||||
|
.steps-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 32px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps-grid::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 44px;
|
||||||
|
left: 15%;
|
||||||
|
right: 15%;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, var(--primary), var(--gray-300), var(--primary));
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-card {
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-number {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
box-shadow: 0 4px 14px rgba(37,99,235,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-card h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-card p {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
max-width: 280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── FAQ ── */
|
||||||
|
.faq-list {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item:hover {
|
||||||
|
border-color: var(--gray-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-question {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 20px 24px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gray-900);
|
||||||
|
text-align: left;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-question:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--gray-400);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item.open .faq-icon {
|
||||||
|
transform: rotate(45deg);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-answer {
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-answer-inner {
|
||||||
|
padding: 0 24px 20px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CTA Section ── */
|
||||||
|
.cta-section {
|
||||||
|
padding: 80px 0;
|
||||||
|
text-align: center;
|
||||||
|
background: linear-gradient(135deg, var(--gray-900) 0%, #1e3a5f 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-section h2 {
|
||||||
|
font-size: clamp(28px, 4vw, 40px);
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-section p {
|
||||||
|
font-size: 17px;
|
||||||
|
color: rgba(255,255,255,0.7);
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-section .hero-cta {
|
||||||
|
background: white;
|
||||||
|
color: var(--gray-900);
|
||||||
|
box-shadow: 0 4px 14px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-section .hero-cta:hover {
|
||||||
|
background: var(--gray-100);
|
||||||
|
color: var(--gray-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Contact / Opt-in Form ── */
|
||||||
|
.contact-section {
|
||||||
|
padding: 80px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 56px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info h2 {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info p {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
margin-bottom: 32px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-detail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--gray-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-detail-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--primary-light);
|
||||||
|
color: var(--primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 36px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form .form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gray-700);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form input,
|
||||||
|
.contact-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 14px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-family: inherit;
|
||||||
|
border: 1.5px solid var(--gray-300);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: white;
|
||||||
|
color: var(--gray-900);
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form input:focus,
|
||||||
|
.contact-form textarea:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37,99,235,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--gray-50);
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-group input[type="checkbox"] {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form .btn-submit {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 28px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: inherit;
|
||||||
|
color: white;
|
||||||
|
background: var(--primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 20px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form .btn-submit:hover {
|
||||||
|
background: var(--primary-dark);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(37,99,235,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Legal Pages ── */
|
||||||
|
.legal-page {
|
||||||
|
padding: 60px 0 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-page .container {
|
||||||
|
max-width: 780px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-page h1 {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-meta {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
margin-bottom: 40px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
border-bottom: 1px solid var(--gray-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-content {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--gray-600);
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-content h2 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gray-900);
|
||||||
|
margin: 36px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-content h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gray-800);
|
||||||
|
margin: 28px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-content p {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-content ul, .legal-content ol {
|
||||||
|
margin: 12px 0 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-content li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
.footer {
|
||||||
|
background: var(--gray-900);
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
padding: 48px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-inner {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-brand {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links a {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(255,255,255,0.5);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links a:hover {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-copy {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.nav-links { display: none; }
|
||||||
|
.nav-mobile-toggle { display: block; }
|
||||||
|
|
||||||
|
.nav-links.open {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: absolute;
|
||||||
|
top: 64px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid var(--gray-200);
|
||||||
|
padding: 16px 24px;
|
||||||
|
gap: 16px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps-grid::before { display: none; }
|
||||||
|
|
||||||
|
.contact-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero { padding: 60px 0 50px; }
|
||||||
|
.section { padding: 60px 0; }
|
||||||
|
|
||||||
|
.footer-inner {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── SVG Icon helpers ── */
|
||||||
|
.icon-svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
204
a2p-wizard-rebuild/public/js/form.js
Normal file
204
a2p-wizard-rebuild/public/js/form.js
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
// A2P Compliance Wizard - Form Handler
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const form = document.getElementById('wizardForm');
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
const overlay = document.getElementById('loadingOverlay');
|
||||||
|
const errorBanner = document.getElementById('errorBanner');
|
||||||
|
const logoInput = document.getElementById('logoInput');
|
||||||
|
const logoUpload = document.querySelector('.logo-upload');
|
||||||
|
const logoPreview = document.getElementById('logoPreview');
|
||||||
|
const logoUploadText = document.querySelector('.logo-upload-text');
|
||||||
|
|
||||||
|
// Logo upload preview
|
||||||
|
logoUpload.addEventListener('click', () => logoInput.click());
|
||||||
|
logoUpload.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
logoUpload.style.borderColor = '#2563EB';
|
||||||
|
logoUpload.style.background = '#EFF6FF';
|
||||||
|
});
|
||||||
|
logoUpload.addEventListener('dragleave', () => {
|
||||||
|
logoUpload.style.borderColor = '';
|
||||||
|
logoUpload.style.background = '';
|
||||||
|
});
|
||||||
|
logoUpload.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
logoUpload.style.borderColor = '';
|
||||||
|
logoUpload.style.background = '';
|
||||||
|
if (e.dataTransfer.files.length) {
|
||||||
|
logoInput.files = e.dataTransfer.files;
|
||||||
|
handleLogoSelect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logoInput.addEventListener('change', handleLogoSelect);
|
||||||
|
|
||||||
|
function handleLogoSelect() {
|
||||||
|
const file = logoInput.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
showError('Please upload an image file (PNG, JPG, SVG, WebP)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
showError('Logo must be under 5MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
logoPreview.src = e.target.result;
|
||||||
|
logoPreview.style.display = 'block';
|
||||||
|
logoUpload.classList.add('has-file');
|
||||||
|
logoUploadText.textContent = file.name;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form submission
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
hideError();
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
if (!validateForm()) return;
|
||||||
|
|
||||||
|
// Show loading
|
||||||
|
overlay.classList.add('active');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
updateProgress(0, 'Preparing your request...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
// Simulate progress during generation
|
||||||
|
const progressInterval = simulateProgress();
|
||||||
|
|
||||||
|
const response = await fetch('/api/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Generation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show completion
|
||||||
|
updateProgress(100, 'Complete!');
|
||||||
|
setStepDone(0);
|
||||||
|
setStepDone(1);
|
||||||
|
setStepDone(2);
|
||||||
|
setStepDone(3);
|
||||||
|
|
||||||
|
// Redirect to results
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = result.resultsUrl;
|
||||||
|
}, 800);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
overlay.classList.remove('active');
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
showError(err.message || 'Something went wrong. Please try again.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function validateForm() {
|
||||||
|
const required = ['agencyName', 'agencyEmail', 'businessName', 'businessAddress', 'businessEmail', 'businessPhone', 'businessDescription'];
|
||||||
|
|
||||||
|
for (const field of required) {
|
||||||
|
const input = form.querySelector(`[name="${field}"]`);
|
||||||
|
if (!input || !input.value.trim()) {
|
||||||
|
showError(`Please fill in all required fields`);
|
||||||
|
input?.focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email
|
||||||
|
const email = form.querySelector('[name="agencyEmail"]').value;
|
||||||
|
if (!email.includes('@') || !email.includes('.')) {
|
||||||
|
showError('Please enter a valid email address');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checkbox
|
||||||
|
const checkbox = form.querySelector('[name="tcpaConsent"]');
|
||||||
|
if (!checkbox || !checkbox.checked) {
|
||||||
|
showError('Please confirm TCPA compliance to continue');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulateProgress() {
|
||||||
|
let progress = 0;
|
||||||
|
const steps = [
|
||||||
|
{ at: 10, msg: 'Generating AI content...' },
|
||||||
|
{ at: 45, msg: 'Building website pages...' },
|
||||||
|
{ at: 65, msg: 'Taking screenshots...' },
|
||||||
|
{ at: 85, msg: 'Assembling compliance packet...' },
|
||||||
|
];
|
||||||
|
let stepIndex = 0;
|
||||||
|
|
||||||
|
return setInterval(() => {
|
||||||
|
if (progress < 90) {
|
||||||
|
progress += Math.random() * 3 + 0.5;
|
||||||
|
progress = Math.min(progress, 92);
|
||||||
|
updateProgress(progress);
|
||||||
|
|
||||||
|
if (stepIndex < steps.length && progress >= steps[stepIndex].at) {
|
||||||
|
setStepActive(stepIndex);
|
||||||
|
if (stepIndex > 0) setStepDone(stepIndex - 1);
|
||||||
|
document.querySelector('.loading-message').textContent = steps[stepIndex].msg;
|
||||||
|
stepIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgress(pct, msg) {
|
||||||
|
const fill = document.querySelector('.progress-fill');
|
||||||
|
if (fill) fill.style.width = `${pct}%`;
|
||||||
|
if (msg) {
|
||||||
|
const el = document.querySelector('.loading-message');
|
||||||
|
if (el) el.textContent = msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStepActive(index) {
|
||||||
|
const steps = document.querySelectorAll('.progress-step');
|
||||||
|
if (steps[index]) {
|
||||||
|
steps[index].classList.remove('done');
|
||||||
|
steps[index].classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStepDone(index) {
|
||||||
|
const steps = document.querySelectorAll('.progress-step');
|
||||||
|
if (steps[index]) {
|
||||||
|
steps[index].classList.remove('active');
|
||||||
|
steps[index].classList.add('done');
|
||||||
|
const icon = steps[index].querySelector('.progress-step-icon');
|
||||||
|
if (icon) icon.innerHTML = '<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M20 6L9 17l-5-5"/></svg>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
errorBanner.textContent = msg;
|
||||||
|
errorBanner.classList.add('visible');
|
||||||
|
errorBanner.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideError() {
|
||||||
|
errorBanner.classList.remove('visible');
|
||||||
|
}
|
||||||
|
});
|
||||||
126
a2p-wizard-rebuild/routes/api.js
Normal file
126
a2p-wizard-rebuild/routes/api.js
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const multer = require('multer');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { nanoid } = require('nanoid');
|
||||||
|
const { generateContent } = require('../services/ai-generator');
|
||||||
|
const { buildSite } = require('../services/site-builder');
|
||||||
|
const { takeScreenshots } = require('../services/screenshot');
|
||||||
|
const { assemblePacket } = require('../services/packet-assembler');
|
||||||
|
|
||||||
|
// Configure multer for logo uploads
|
||||||
|
const storage = multer.memoryStorage();
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
|
||||||
|
if (allowed.includes(file.mimetype)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Invalid file type. Only images are allowed.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/generate - Main pipeline
|
||||||
|
router.post('/generate', upload.single('logo'), async (req, res) => {
|
||||||
|
const clientId = nanoid(12);
|
||||||
|
const clientDir = path.join(__dirname, '..', 'data', clientId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create client directory
|
||||||
|
fs.mkdirSync(path.join(clientDir, 'screenshots'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(clientDir, 'site'), { recursive: true });
|
||||||
|
|
||||||
|
// Parse form data
|
||||||
|
const formData = {
|
||||||
|
agencyName: req.body.agencyName,
|
||||||
|
agencyEmail: req.body.agencyEmail,
|
||||||
|
businessName: req.body.businessName,
|
||||||
|
businessAddress: req.body.businessAddress,
|
||||||
|
businessEmail: req.body.businessEmail,
|
||||||
|
businessPhone: req.body.businessPhone,
|
||||||
|
businessDescription: req.body.businessDescription,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save logo if uploaded
|
||||||
|
let logoPath = null;
|
||||||
|
if (req.file) {
|
||||||
|
const ext = path.extname(req.file.originalname) || '.png';
|
||||||
|
logoPath = path.join(clientDir, `logo${ext}`);
|
||||||
|
fs.writeFileSync(logoPath, req.file.buffer);
|
||||||
|
formData.logoUrl = `/data/${clientId}/logo${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Generate AI content
|
||||||
|
console.log(`[${clientId}] Step 1: Generating AI content...`);
|
||||||
|
const aiContent = await generateContent(formData);
|
||||||
|
|
||||||
|
// Step 2: Build config — generate subdomain from business name
|
||||||
|
const MAIN_DOMAIN = req.app.locals.MAIN_DOMAIN || 'solvedby.us';
|
||||||
|
const subdomain = formData.businessName
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
.substring(0, 40);
|
||||||
|
|
||||||
|
const siteUrl = `https://${subdomain}.${MAIN_DOMAIN}`;
|
||||||
|
const localSiteUrl = `http://localhost:8899/sites/${clientId}`;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
clientId,
|
||||||
|
subdomain,
|
||||||
|
...formData,
|
||||||
|
...aiContent,
|
||||||
|
siteUrl,
|
||||||
|
localSiteUrl,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save config
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(clientDir, 'config.json'),
|
||||||
|
JSON.stringify(config, null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 3: Build the static site
|
||||||
|
console.log(`[${clientId}] Step 2: Building website...`);
|
||||||
|
await buildSite(config, clientDir);
|
||||||
|
|
||||||
|
// Step 4: Take screenshots
|
||||||
|
console.log(`[${clientId}] Step 3: Taking screenshots...`);
|
||||||
|
await takeScreenshots(clientId, clientDir);
|
||||||
|
|
||||||
|
// Step 5: Assemble compliance packet zip
|
||||||
|
console.log(`[${clientId}] Step 4: Assembling compliance packet...`);
|
||||||
|
await assemblePacket(config, clientDir);
|
||||||
|
|
||||||
|
// Step 6: Register subdomain → clientId mapping
|
||||||
|
const subdomainIndex = req.app.locals.subdomainIndex;
|
||||||
|
const saveSubdomainIndex = req.app.locals.saveSubdomainIndex;
|
||||||
|
subdomainIndex[subdomain] = clientId;
|
||||||
|
saveSubdomainIndex();
|
||||||
|
console.log(`[${clientId}] Step 5: Registered subdomain ${subdomain}.${MAIN_DOMAIN}`);
|
||||||
|
|
||||||
|
console.log(`[${clientId}] ✅ Complete! Site live at ${siteUrl}`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
clientId,
|
||||||
|
resultsUrl: `/results/${clientId}`,
|
||||||
|
siteUrl,
|
||||||
|
localSiteUrl: `/sites/${clientId}/`,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[${clientId}] ❌ Error:`, error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || 'Generation failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
52
a2p-wizard-rebuild/routes/sites.js
Normal file
52
a2p-wizard-rebuild/routes/sites.js
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// Serve generated static sites at /sites/{clientId}/
|
||||||
|
// (Subdomain serving is handled in server.js middleware)
|
||||||
|
|
||||||
|
const pageMap = {
|
||||||
|
'': 'index.html',
|
||||||
|
'contact': 'contact.html',
|
||||||
|
'privacy-policy': 'privacy-policy.html',
|
||||||
|
'terms': 'terms.html',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Serve static assets for the site (CSS, images, etc.)
|
||||||
|
router.use('/:clientId/assets', (req, res) => {
|
||||||
|
const { clientId } = req.params;
|
||||||
|
const filePath = path.join(__dirname, '..', 'data', clientId, 'site', 'assets', req.path);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
res.sendFile(filePath);
|
||||||
|
} else {
|
||||||
|
res.status(404).send('Not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve pages
|
||||||
|
router.get('/:clientId/:page?', (req, res) => {
|
||||||
|
const { clientId } = req.params;
|
||||||
|
const page = (req.params.page || '').replace(/\/$/, '');
|
||||||
|
const filename = pageMap[page];
|
||||||
|
|
||||||
|
if (!filename) {
|
||||||
|
return res.status(404).send('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(__dirname, '..', 'data', clientId, 'site', filename);
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return res.status(404).send('Site not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// When served via /sites/{id}/, rewrite links to include the prefix
|
||||||
|
let html = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const baseUrl = `/sites/${clientId}`;
|
||||||
|
html = html.replace(/href="\//g, `href="${baseUrl}/`);
|
||||||
|
html = html.replace(/src="\//g, `src="${baseUrl}/`);
|
||||||
|
html = html.replace(/href="#/g, 'href="#'); // keep anchor links
|
||||||
|
|
||||||
|
res.type('html').send(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
144
a2p-wizard-rebuild/server.js
Normal file
144
a2p-wizard-rebuild/server.js
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 8899;
|
||||||
|
const MAIN_DOMAIN = process.env.MAIN_DOMAIN || 'solvedby.us';
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
const dataDir = path.join(__dirname, 'data');
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subdomain-to-clientId index (loaded from disk)
|
||||||
|
const subdomainIndexPath = path.join(dataDir, 'subdomain-index.json');
|
||||||
|
let subdomainIndex = {};
|
||||||
|
if (fs.existsSync(subdomainIndexPath)) {
|
||||||
|
subdomainIndex = JSON.parse(fs.readFileSync(subdomainIndexPath, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSubdomainIndex() {
|
||||||
|
fs.writeFileSync(subdomainIndexPath, JSON.stringify(subdomainIndex, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubdomain(host) {
|
||||||
|
// Strip port
|
||||||
|
const hostname = (host || '').split(':')[0];
|
||||||
|
// Check if it's a subdomain of MAIN_DOMAIN
|
||||||
|
if (hostname.endsWith('.' + MAIN_DOMAIN)) {
|
||||||
|
return hostname.replace('.' + MAIN_DOMAIN, '').toLowerCase();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// View engine
|
||||||
|
app.set('view engine', 'ejs');
|
||||||
|
app.set('views', path.join(__dirname, 'templates'));
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||||
|
|
||||||
|
// ─── Subdomain routing: {clientname}.solvedby.us serves that client's site ───
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const subdomain = getSubdomain(req.headers.host);
|
||||||
|
|
||||||
|
if (!subdomain) {
|
||||||
|
// No subdomain — serve the main wizard app
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up clientId from subdomain
|
||||||
|
const clientId = subdomainIndex[subdomain];
|
||||||
|
if (!clientId) {
|
||||||
|
return res.status(404).send(`<h1>Site not found</h1><p>No business site exists at <strong>${subdomain}.${MAIN_DOMAIN}</strong></p><p><a href="https://${MAIN_DOMAIN}">Create one at ${MAIN_DOMAIN}</a></p>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const siteDir = path.join(dataDir, clientId, 'site');
|
||||||
|
if (!fs.existsSync(siteDir)) {
|
||||||
|
return res.status(404).send('Site files not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map paths to files
|
||||||
|
let filePath;
|
||||||
|
const urlPath = req.path.replace(/\/$/, '') || '';
|
||||||
|
|
||||||
|
if (urlPath === '' || urlPath === '/') {
|
||||||
|
filePath = path.join(siteDir, 'index.html');
|
||||||
|
} else if (urlPath === '/contact') {
|
||||||
|
filePath = path.join(siteDir, 'contact.html');
|
||||||
|
} else if (urlPath === '/privacy-policy') {
|
||||||
|
filePath = path.join(siteDir, 'privacy-policy.html');
|
||||||
|
} else if (urlPath === '/terms') {
|
||||||
|
filePath = path.join(siteDir, 'terms.html');
|
||||||
|
} else if (urlPath.startsWith('/assets/')) {
|
||||||
|
filePath = path.join(siteDir, urlPath);
|
||||||
|
} else if (urlPath.startsWith('/data/')) {
|
||||||
|
// Allow serving logos etc from data dir
|
||||||
|
filePath = path.join(__dirname, urlPath);
|
||||||
|
} else {
|
||||||
|
return res.status(404).send('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
return res.sendFile(filePath);
|
||||||
|
}
|
||||||
|
return res.status(404).send('Page not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Main domain routes ───
|
||||||
|
app.use('/public', express.static(path.join(__dirname, 'public')));
|
||||||
|
app.use('/data', express.static(path.join(__dirname, 'data')));
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
const apiRoutes = require('./routes/api');
|
||||||
|
const sitesRoutes = require('./routes/sites');
|
||||||
|
|
||||||
|
app.use('/api', apiRoutes);
|
||||||
|
app.use('/sites', sitesRoutes);
|
||||||
|
|
||||||
|
// Main form page
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.render('form');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Results page
|
||||||
|
app.get('/results/:clientId', (req, res) => {
|
||||||
|
const { clientId } = req.params;
|
||||||
|
const configPath = path.join(dataDir, clientId, 'config.json');
|
||||||
|
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
return res.status(404).send('Project not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||||
|
res.render('packet/results', { config, clientId });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download zip
|
||||||
|
app.get('/download/:clientId', (req, res) => {
|
||||||
|
const { clientId } = req.params;
|
||||||
|
const zipPath = path.join(dataDir, clientId, 'compliance-packet.zip');
|
||||||
|
|
||||||
|
if (!fs.existsSync(zipPath)) {
|
||||||
|
return res.status(404).send('Zip not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = JSON.parse(fs.readFileSync(path.join(dataDir, clientId, 'config.json'), 'utf8'));
|
||||||
|
const safeName = config.businessName.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase();
|
||||||
|
res.download(zipPath, `${safeName}-a2p-compliance-packet.zip`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export for use in API routes
|
||||||
|
app.locals.subdomainIndex = subdomainIndex;
|
||||||
|
app.locals.saveSubdomainIndex = saveSubdomainIndex;
|
||||||
|
app.locals.MAIN_DOMAIN = MAIN_DOMAIN;
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`\n🚀 A2P Compliance Wizard running at http://localhost:${PORT}`);
|
||||||
|
console.log(` Main domain: https://${MAIN_DOMAIN}`);
|
||||||
|
console.log(` Client sites: https://{name}.${MAIN_DOMAIN}\n`);
|
||||||
|
});
|
||||||
112
a2p-wizard-rebuild/services/ai-generator.js
Normal file
112
a2p-wizard-rebuild/services/ai-generator.js
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
const Anthropic = require('@anthropic-ai/sdk');
|
||||||
|
|
||||||
|
// Support both standard API keys and OAuth tokens
|
||||||
|
const apiKey = process.env.ANTHROPIC_API_KEY || '';
|
||||||
|
const clientOpts = {};
|
||||||
|
if (apiKey.startsWith('sk-ant-oat')) {
|
||||||
|
// OAuth token — use as auth token instead of API key
|
||||||
|
clientOpts.authToken = apiKey;
|
||||||
|
clientOpts.apiKey = null;
|
||||||
|
} else {
|
||||||
|
clientOpts.apiKey = apiKey;
|
||||||
|
}
|
||||||
|
const client = new Anthropic(clientOpts);
|
||||||
|
|
||||||
|
async function generateContent(formData) {
|
||||||
|
const { businessName, businessAddress, businessEmail, businessPhone, businessDescription } = formData;
|
||||||
|
|
||||||
|
const prompt = `You are an expert A2P 10DLC compliance consultant and web copywriter. Generate ALL content for a professional business website AND a complete A2P SMS compliance packet.
|
||||||
|
|
||||||
|
Business Details:
|
||||||
|
- Legal Business Name: ${businessName}
|
||||||
|
- Business Address: ${businessAddress}
|
||||||
|
- Support Email: ${businessEmail}
|
||||||
|
- Business Phone: ${businessPhone}
|
||||||
|
- Business Description: ${businessDescription}
|
||||||
|
|
||||||
|
Generate the following as a single JSON object. Be specific, professional, and thorough. All content should be tailored to this specific business and industry.
|
||||||
|
|
||||||
|
IMPORTANT: Return ONLY valid JSON. No markdown code fences, no explanations outside the JSON.
|
||||||
|
|
||||||
|
{
|
||||||
|
"website": {
|
||||||
|
"heroTitle": "A compelling headline for the business (5-10 words)",
|
||||||
|
"heroSubtitle": "A supporting subtitle (15-25 words)",
|
||||||
|
"ctaText": "CTA button text (2-4 words)",
|
||||||
|
"expandedDescription": "A 3-4 sentence professional business description",
|
||||||
|
"painpoints": [
|
||||||
|
{
|
||||||
|
"icon": "one of: shield, clock, chart, heart, star, zap, target, users, check, phone",
|
||||||
|
"title": "Pain point title (3-6 words)",
|
||||||
|
"description": "Pain point description (1-2 sentences)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"howItWorks": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"title": "Step title (2-5 words)",
|
||||||
|
"description": "Step description (1-2 sentences)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"faqItems": [
|
||||||
|
{
|
||||||
|
"question": "A relevant FAQ question",
|
||||||
|
"answer": "A thorough, helpful answer (2-4 sentences)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"privacyPolicy": "COMPLETE privacy policy text in HTML format. MUST include: company name, data collection practices, how info is used, cookie policy, third-party sharing policy, and CRITICALLY: 'No mobile information will be shared with third parties/affiliates for marketing/promotional purposes. All the above categories exclude text messaging originator opt-in data and consent; this information will not be shared with any third parties.' Include contact info. Make it thorough and professional.",
|
||||||
|
"termsOfService": "COMPLETE terms of service text in HTML format. MUST include: SMS/text messaging terms section covering message frequency, data rates, opt-out (STOP), help (HELP), supported carriers, and that consent is not a condition of purchase. Include standard legal terms. Make it thorough."
|
||||||
|
},
|
||||||
|
"a2pPacket": {
|
||||||
|
"campaignDescription": "A detailed description of who sends messages (brand name), who receives them (opted-in customers via website), and why (purpose). 3-5 sentences. Reference the website URL.",
|
||||||
|
"sampleMessages": [
|
||||||
|
"First sample message with brand name and [bracketed] template fields. Include opt-out.",
|
||||||
|
"Second sample message - different type (reminder/promo/info). Include brand name.",
|
||||||
|
"Third sample message - yet another type. Include HELP instructions."
|
||||||
|
],
|
||||||
|
"optInFlowDescription": "Detailed description of how users opt in: they visit the website, fill out the contact form, check a non-pre-selected SMS consent checkbox. Describe the disclosure language on the form. Mention Privacy Policy and Terms links.",
|
||||||
|
"optInConfirmation": "Under 160 chars. Brand name, frequency, rates, HELP, STOP instructions.",
|
||||||
|
"optOutConfirmation": "Brand name + confirmation that no more messages will be sent.",
|
||||||
|
"helpMessage": "Brand name + support contact info + opt-out instructions.",
|
||||||
|
"optInKeywords": "START, SUBSCRIBE, YES",
|
||||||
|
"optOutKeywords": "STOP, UNSUBSCRIBE, END, QUIT, CANCEL",
|
||||||
|
"helpKeywords": "HELP, INFO",
|
||||||
|
"consentText": "The exact checkbox consent text for the opt-in form. Must include: brand name, message frequency varies, msg & data rates may apply, reply HELP for help, reply STOP to unsubscribe. Do NOT include links in the consent text itself."
|
||||||
|
},
|
||||||
|
"colorScheme": {
|
||||||
|
"primary": "A professional hex color that fits the business industry (e.g., #2563EB for tech, #059669 for health, #DC2626 for food)",
|
||||||
|
"primaryDark": "A darker shade of the primary",
|
||||||
|
"primaryLight": "A lighter/muted shade for backgrounds",
|
||||||
|
"accent": "A complementary accent color"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Generate 3-4 painpoints, exactly 3 howItWorks steps, and 4-6 faqItems. Make sure sample messages are realistic and include the actual business name. The privacy policy and terms of service should be COMPLETE, thorough legal documents ready for use. Return ONLY the JSON.`;
|
||||||
|
|
||||||
|
const response = await client.messages.create({
|
||||||
|
model: 'claude-sonnet-4-20250514',
|
||||||
|
max_tokens: 8000,
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', content: prompt }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = response.content[0].text;
|
||||||
|
|
||||||
|
// Parse the JSON - handle potential markdown code fences
|
||||||
|
let jsonStr = text;
|
||||||
|
const jsonMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||||
|
if (jsonMatch) {
|
||||||
|
jsonStr = jsonMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonStr.trim());
|
||||||
|
return parsed;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse AI response:', text.substring(0, 500));
|
||||||
|
throw new Error('AI generated invalid JSON. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { generateContent };
|
||||||
175
a2p-wizard-rebuild/services/packet-assembler.js
Normal file
175
a2p-wizard-rebuild/services/packet-assembler.js
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
const archiver = require('archiver');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
async function assemblePacket(config, clientDir) {
|
||||||
|
const zipPath = path.join(clientDir, 'compliance-packet.zip');
|
||||||
|
const output = fs.createWriteStream(zipPath);
|
||||||
|
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
output.on('close', () => {
|
||||||
|
console.log(` → Zip created: ${archive.pointer()} bytes`);
|
||||||
|
resolve(zipPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
archive.on('error', (err) => reject(err));
|
||||||
|
archive.pipe(output);
|
||||||
|
|
||||||
|
// Add screenshots
|
||||||
|
const screenshotsDir = path.join(clientDir, 'screenshots');
|
||||||
|
if (fs.existsSync(screenshotsDir)) {
|
||||||
|
archive.directory(screenshotsDir, 'screenshots');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create compliance packet text file
|
||||||
|
const packetText = generatePacketText(config);
|
||||||
|
archive.append(packetText, { name: 'compliance-packet.txt' });
|
||||||
|
|
||||||
|
// Create a formatted HTML version
|
||||||
|
const packetHtml = generatePacketHtml(config);
|
||||||
|
archive.append(packetHtml, { name: 'compliance-packet.html' });
|
||||||
|
|
||||||
|
// Add config
|
||||||
|
archive.append(JSON.stringify(config, null, 2), { name: 'config.json' });
|
||||||
|
|
||||||
|
archive.finalize();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePacketText(config) {
|
||||||
|
const p = config.a2pPacket;
|
||||||
|
const siteUrl = config.siteUrl;
|
||||||
|
|
||||||
|
return `═══════════════════════════════════════════════════════
|
||||||
|
A2P 10DLC COMPLIANCE PACKET
|
||||||
|
${config.businessName}
|
||||||
|
Generated: ${new Date().toLocaleDateString()}
|
||||||
|
═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CAMPAIGN DESCRIPTION
|
||||||
|
─────────────────────
|
||||||
|
${p.campaignDescription}
|
||||||
|
|
||||||
|
SAMPLE MESSAGES
|
||||||
|
─────────────────────
|
||||||
|
Message 1:
|
||||||
|
${p.sampleMessages[0]}
|
||||||
|
|
||||||
|
Message 2:
|
||||||
|
${p.sampleMessages[1]}
|
||||||
|
|
||||||
|
Message 3:
|
||||||
|
${p.sampleMessages[2]}
|
||||||
|
|
||||||
|
OPT-IN FLOW DESCRIPTION
|
||||||
|
─────────────────────
|
||||||
|
${p.optInFlowDescription}
|
||||||
|
|
||||||
|
Website URL: ${siteUrl}
|
||||||
|
Contact/Opt-in Page: ${siteUrl}/contact
|
||||||
|
Privacy Policy: ${siteUrl}/privacy-policy
|
||||||
|
Terms of Service: ${siteUrl}/terms
|
||||||
|
|
||||||
|
OPT-IN CONFIRMATION MESSAGE
|
||||||
|
─────────────────────
|
||||||
|
${p.optInConfirmation}
|
||||||
|
|
||||||
|
OPT-OUT CONFIRMATION MESSAGE
|
||||||
|
─────────────────────
|
||||||
|
${p.optOutConfirmation}
|
||||||
|
|
||||||
|
HELP MESSAGE
|
||||||
|
─────────────────────
|
||||||
|
${p.helpMessage}
|
||||||
|
|
||||||
|
KEYWORDS
|
||||||
|
─────────────────────
|
||||||
|
Opt-in Keywords: ${p.optInKeywords}
|
||||||
|
Opt-out Keywords: ${p.optOutKeywords}
|
||||||
|
Help Keywords: ${p.helpKeywords}
|
||||||
|
|
||||||
|
CONSENT TEXT (on website form)
|
||||||
|
─────────────────────
|
||||||
|
${p.consentText}
|
||||||
|
|
||||||
|
SCREENSHOTS INCLUDED
|
||||||
|
─────────────────────
|
||||||
|
1. homepage.png - Full homepage screenshot
|
||||||
|
2. contact-optin.png - Opt-in form with consent checkbox
|
||||||
|
3. privacy-policy.png - Privacy Policy page
|
||||||
|
4. terms-of-service.png - Terms of Service page
|
||||||
|
|
||||||
|
═══════════════════════════════════════════════════════
|
||||||
|
Generated by A2P Compliance Wizard
|
||||||
|
═══════════════════════════════════════════════════════
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePacketHtml(config) {
|
||||||
|
const p = config.a2pPacket;
|
||||||
|
const siteUrl = config.siteUrl;
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>A2P Compliance Packet - ${config.businessName}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, system-ui, sans-serif; max-width: 800px; margin: 40px auto; padding: 20px; color: #1a1a2e; line-height: 1.6; }
|
||||||
|
h1 { color: #2563EB; border-bottom: 3px solid #2563EB; padding-bottom: 10px; }
|
||||||
|
h2 { color: #1e3a5f; margin-top: 30px; border-bottom: 1px solid #e2e8f0; padding-bottom: 8px; }
|
||||||
|
.field { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; margin: 12px 0; }
|
||||||
|
.field-label { font-weight: 700; color: #475569; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
|
||||||
|
.field-value { color: #1e293b; }
|
||||||
|
.message-box { background: #eff6ff; border-left: 4px solid #2563EB; padding: 12px 16px; margin: 8px 0; border-radius: 0 6px 6px 0; }
|
||||||
|
a { color: #2563EB; }
|
||||||
|
.meta { color: #64748b; font-size: 14px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>A2P 10DLC Compliance Packet</h1>
|
||||||
|
<p class="meta"><strong>${config.businessName}</strong> · Generated ${new Date().toLocaleDateString()}</p>
|
||||||
|
|
||||||
|
<h2>Campaign Description</h2>
|
||||||
|
<div class="field"><div class="field-value">${p.campaignDescription}</div></div>
|
||||||
|
|
||||||
|
<h2>Sample Messages</h2>
|
||||||
|
<div class="message-box">${p.sampleMessages[0]}</div>
|
||||||
|
<div class="message-box">${p.sampleMessages[1]}</div>
|
||||||
|
<div class="message-box">${p.sampleMessages[2]}</div>
|
||||||
|
|
||||||
|
<h2>Opt-In Flow Description</h2>
|
||||||
|
<div class="field"><div class="field-value">${p.optInFlowDescription}</div></div>
|
||||||
|
|
||||||
|
<h2>Website URLs</h2>
|
||||||
|
<div class="field">
|
||||||
|
<div class="field-label">Homepage</div><div class="field-value"><a href="${siteUrl}">${siteUrl}</a></div>
|
||||||
|
<div class="field-label" style="margin-top:8px;">Contact/Opt-in</div><div class="field-value"><a href="${siteUrl}/contact">${siteUrl}/contact</a></div>
|
||||||
|
<div class="field-label" style="margin-top:8px;">Privacy Policy</div><div class="field-value"><a href="${siteUrl}/privacy-policy">${siteUrl}/privacy-policy</a></div>
|
||||||
|
<div class="field-label" style="margin-top:8px;">Terms of Service</div><div class="field-value"><a href="${siteUrl}/terms">${siteUrl}/terms</a></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Opt-In Confirmation Message</h2>
|
||||||
|
<div class="message-box">${p.optInConfirmation}</div>
|
||||||
|
|
||||||
|
<h2>Opt-Out Confirmation Message</h2>
|
||||||
|
<div class="message-box">${p.optOutConfirmation}</div>
|
||||||
|
|
||||||
|
<h2>Help Message</h2>
|
||||||
|
<div class="message-box">${p.helpMessage}</div>
|
||||||
|
|
||||||
|
<h2>Keywords</h2>
|
||||||
|
<div class="field">
|
||||||
|
<div class="field-label">Opt-in</div><div class="field-value">${p.optInKeywords}</div>
|
||||||
|
<div class="field-label" style="margin-top:8px;">Opt-out</div><div class="field-value">${p.optOutKeywords}</div>
|
||||||
|
<div class="field-label" style="margin-top:8px;">Help</div><div class="field-value">${p.helpKeywords}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Consent Text</h2>
|
||||||
|
<div class="field"><div class="field-value">${p.consentText}</div></div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { assemblePacket };
|
||||||
75
a2p-wizard-rebuild/services/screenshot.js
Normal file
75
a2p-wizard-rebuild/services/screenshot.js
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
const { chromium } = require('playwright');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const BASE_URL = `http://localhost:${process.env.PORT || 8899}`;
|
||||||
|
// Screenshots always hit localhost, even when site is publicly accessible via subdomain
|
||||||
|
|
||||||
|
async function takeScreenshots(clientId, clientDir) {
|
||||||
|
const screenshotsDir = path.join(clientDir, 'screenshots');
|
||||||
|
fs.mkdirSync(screenshotsDir, { recursive: true });
|
||||||
|
|
||||||
|
let browser;
|
||||||
|
try {
|
||||||
|
browser = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||||
|
});
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
deviceScaleFactor: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pages = [
|
||||||
|
{
|
||||||
|
url: `${BASE_URL}/sites/${clientId}/`,
|
||||||
|
filename: 'homepage.png',
|
||||||
|
fullPage: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${BASE_URL}/sites/${clientId}/contact`,
|
||||||
|
filename: 'contact-optin.png',
|
||||||
|
fullPage: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${BASE_URL}/sites/${clientId}/privacy-policy`,
|
||||||
|
filename: 'privacy-policy.png',
|
||||||
|
fullPage: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${BASE_URL}/sites/${clientId}/terms`,
|
||||||
|
filename: 'terms-of-service.png',
|
||||||
|
fullPage: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pageConfig of pages) {
|
||||||
|
try {
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(pageConfig.url, {
|
||||||
|
waitUntil: 'networkidle',
|
||||||
|
timeout: 15000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Small delay for any CSS animations to settle
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
await page.screenshot({
|
||||||
|
path: path.join(screenshotsDir, pageConfig.filename),
|
||||||
|
fullPage: pageConfig.fullPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.close();
|
||||||
|
console.log(` → Screenshot: ${pageConfig.filename}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` ⚠ Failed screenshot ${pageConfig.filename}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
} finally {
|
||||||
|
if (browser) await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { takeScreenshots };
|
||||||
71
a2p-wizard-rebuild/services/site-builder.js
Normal file
71
a2p-wizard-rebuild/services/site-builder.js
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
const ejs = require('ejs');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const TEMPLATES_DIR = path.join(__dirname, '..', 'templates', 'site');
|
||||||
|
|
||||||
|
async function buildSite(config, clientDir) {
|
||||||
|
const siteDir = path.join(clientDir, 'site');
|
||||||
|
|
||||||
|
// Ensure site directory exists
|
||||||
|
fs.mkdirSync(siteDir, { recursive: true });
|
||||||
|
|
||||||
|
// Template data
|
||||||
|
const data = {
|
||||||
|
businessName: config.businessName,
|
||||||
|
businessAddress: config.businessAddress,
|
||||||
|
businessEmail: config.businessEmail,
|
||||||
|
businessPhone: config.businessPhone,
|
||||||
|
businessDescription: config.businessDescription,
|
||||||
|
logoUrl: config.logoUrl || null,
|
||||||
|
clientId: config.clientId,
|
||||||
|
siteUrl: config.siteUrl,
|
||||||
|
website: config.website,
|
||||||
|
a2pPacket: config.a2pPacket,
|
||||||
|
colorScheme: config.colorScheme || {
|
||||||
|
primary: '#2563EB',
|
||||||
|
primaryDark: '#1D4ED8',
|
||||||
|
primaryLight: '#EFF6FF',
|
||||||
|
accent: '#F59E0B'
|
||||||
|
},
|
||||||
|
year: new Date().getFullYear(),
|
||||||
|
// When served via subdomain, baseUrl is empty (root-relative links)
|
||||||
|
// When served via /sites/{id}/, baseUrl has the prefix
|
||||||
|
// We build with empty baseUrl for subdomain-first approach
|
||||||
|
baseUrl: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render each page
|
||||||
|
const pages = [
|
||||||
|
{ template: 'index.ejs', output: 'index.html' },
|
||||||
|
{ template: 'contact.ejs', output: 'contact.html' },
|
||||||
|
{ template: 'privacy-policy.ejs', output: 'privacy-policy.html' },
|
||||||
|
{ template: 'terms.ejs', output: 'terms.html' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
const templatePath = path.join(TEMPLATES_DIR, page.template);
|
||||||
|
const template = fs.readFileSync(templatePath, 'utf8');
|
||||||
|
const html = ejs.render(template, data, { filename: templatePath });
|
||||||
|
fs.writeFileSync(path.join(siteDir, page.output), html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the site CSS
|
||||||
|
const cssSource = path.join(__dirname, '..', 'public', 'css', 'site.css');
|
||||||
|
const assetsDir = path.join(siteDir, 'assets');
|
||||||
|
fs.mkdirSync(assetsDir, { recursive: true });
|
||||||
|
fs.copyFileSync(cssSource, path.join(assetsDir, 'site.css'));
|
||||||
|
|
||||||
|
// If there's a logo, copy it to the site assets
|
||||||
|
if (config.logoUrl) {
|
||||||
|
const logoSource = path.join(__dirname, '..', config.logoUrl);
|
||||||
|
if (fs.existsSync(logoSource)) {
|
||||||
|
const ext = path.extname(logoSource);
|
||||||
|
fs.copyFileSync(logoSource, path.join(assetsDir, `logo${ext}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` → Built ${pages.length} pages in ${siteDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { buildSite };
|
||||||
181
a2p-wizard-rebuild/templates/form.ejs
Normal file
181
a2p-wizard-rebuild/templates/form.ejs
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>A2P Compliance Wizard — Generate Compliant Websites & Packets</title>
|
||||||
|
<link rel="stylesheet" href="/public/css/form.css">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🛡️</text></svg>">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page-bg"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="header">
|
||||||
|
<div class="header-badge">
|
||||||
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||||
|
A2P 10DLC Compliance
|
||||||
|
</div>
|
||||||
|
<h1>Compliance Wizard</h1>
|
||||||
|
<p>Generate a fully compliant A2P website, opt-in flow, and TCR-ready compliance packet in minutes.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Steps -->
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step active">
|
||||||
|
<div class="step-num">1</div>
|
||||||
|
<span>Fill Info</span>
|
||||||
|
</div>
|
||||||
|
<div class="step-line"></div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-num">2</div>
|
||||||
|
<span>Generate</span>
|
||||||
|
</div>
|
||||||
|
<div class="step-line"></div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-num">3</div>
|
||||||
|
<span>Download</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form -->
|
||||||
|
<div class="form-container">
|
||||||
|
<div id="errorBanner" class="error-banner"></div>
|
||||||
|
|
||||||
|
<form id="wizardForm" class="form-card" enctype="multipart/form-data">
|
||||||
|
|
||||||
|
<!-- Section 1: Agency Info -->
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="section-title">Your Information</div>
|
||||||
|
<div class="section-subtitle">Agency or account owner details</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Your Name <span class="required">*</span></label>
|
||||||
|
<input type="text" name="agencyName" placeholder="John Smith" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Your Email <span class="required">*</span></label>
|
||||||
|
<input type="email" name="agencyEmail" placeholder="john@agency.com" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section 2: Client Info -->
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="section-title">Client Business Information</div>
|
||||||
|
<div class="section-subtitle">Must match EIN / brand registration exactly</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Client Legal Business Name <span class="required">*</span></label>
|
||||||
|
<input type="text" name="businessName" placeholder="Naples Cleaning LLC" required>
|
||||||
|
<div class="field-hint">Must match EIN paperwork exactly — no special characters</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Client Business Address <span class="required">*</span></label>
|
||||||
|
<input type="text" name="businessAddress" placeholder="123 Main St, Naples, FL 34102" required>
|
||||||
|
<div class="field-hint">Full address matching EIN paperwork</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Client Support Email <span class="required">*</span></label>
|
||||||
|
<input type="email" name="businessEmail" placeholder="support@naplescleaning.com" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Client Business Phone <span class="required">*</span></label>
|
||||||
|
<input type="tel" name="businessPhone" placeholder="(239) 555-0123" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Business Description <span class="required">*</span></label>
|
||||||
|
<textarea name="businessDescription" placeholder="Describe the client's business in 1-2 sentences. What do they do? Who do they serve? Where are they located?" rows="3" required></textarea>
|
||||||
|
<div class="field-hint">This is used to generate all website content and compliance text</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section 3: Logo -->
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="section-title">Client Logo</div>
|
||||||
|
<div class="section-subtitle">Used on the generated website (optional but recommended)</div>
|
||||||
|
|
||||||
|
<div class="logo-upload">
|
||||||
|
<input type="file" id="logoInput" name="logo" accept="image/*">
|
||||||
|
<div class="logo-upload-icon">
|
||||||
|
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
|
||||||
|
<polyline points="17 8 12 3 7 8"/>
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="logo-upload-text">Click to upload or drag & drop</div>
|
||||||
|
<div class="logo-upload-hint">PNG, JPG, SVG, or WebP — max 5MB</div>
|
||||||
|
<img id="logoPreview" class="logo-preview" style="display:none" alt="Logo preview">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section 4: Compliance Acknowledgment -->
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="section-title">Compliance Acknowledgment</div>
|
||||||
|
<div class="section-subtitle">Required before generation</div>
|
||||||
|
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<input type="checkbox" id="tcpaConsent" name="tcpaConsent" required>
|
||||||
|
<label for="tcpaConsent" class="checkbox-label">
|
||||||
|
I confirm that the client business is aware of and agrees to comply with TCPA regulations, A2P 10DLC messaging requirements, and all applicable SMS/MMS messaging laws. The generated website and compliance materials are tools to support registration — the client is responsible for maintaining ongoing compliance.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit -->
|
||||||
|
<div class="submit-section">
|
||||||
|
<button type="submit" id="submitBtn" class="btn-submit">
|
||||||
|
<span class="btn-text">
|
||||||
|
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||||
|
Generate Compliance Package
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading Overlay -->
|
||||||
|
<div id="loadingOverlay" class="loading-overlay">
|
||||||
|
<div class="loading-card">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<div class="loading-title">Building Your Compliance Package</div>
|
||||||
|
<div class="loading-message">Preparing your request...</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-steps">
|
||||||
|
<div class="progress-step">
|
||||||
|
<div class="progress-step-icon"><svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/></svg></div>
|
||||||
|
<span>Generating AI content</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-step">
|
||||||
|
<div class="progress-step-icon"><svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/></svg></div>
|
||||||
|
<span>Building website pages</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-step">
|
||||||
|
<div class="progress-step-icon"><svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/></svg></div>
|
||||||
|
<span>Taking screenshots</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-step">
|
||||||
|
<div class="progress-step-icon"><svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/></svg></div>
|
||||||
|
<span>Assembling compliance packet</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="footer">
|
||||||
|
A2P Compliance Wizard · Built for agencies who demand compliance
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="/public/js/form.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
624
a2p-wizard-rebuild/templates/packet/results.ejs
Normal file
624
a2p-wizard-rebuild/templates/packet/results.ejs
Normal file
@ -0,0 +1,624 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Compliance Packet — <%= config.businessName %></title>
|
||||||
|
<link rel="stylesheet" href="/public/css/form.css">
|
||||||
|
<style>
|
||||||
|
/* Results page specific styles */
|
||||||
|
.results-container {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-header {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 20px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #059669, #10B981);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
box-shadow: 0 8px 24px rgba(5, 150, 105, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-header h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--gray-900);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-header p {
|
||||||
|
color: var(--gray-500);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action bar */
|
||||||
|
.action-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-primary:hover {
|
||||||
|
background: var(--primary-dark);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(37,99,235,0.3);
|
||||||
|
text-decoration: none;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-outline {
|
||||||
|
background: white;
|
||||||
|
color: var(--gray-700);
|
||||||
|
border: 1.5px solid var(--gray-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-outline:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sections */
|
||||||
|
.result-section {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-section-header {
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid var(--gray-100);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-section-header h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gray-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-icon-blue { background: #EFF6FF; color: #2563EB; }
|
||||||
|
.section-icon-green { background: #F0FDF4; color: #059669; }
|
||||||
|
.section-icon-purple { background: #F5F3FF; color: #7C3AED; }
|
||||||
|
.section-icon-amber { background: #FFFBEB; color: #D97706; }
|
||||||
|
|
||||||
|
.result-section-body {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Copy boxes */
|
||||||
|
.copy-box {
|
||||||
|
position: relative;
|
||||||
|
background: var(--gray-50);
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-box:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-box-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--gray-400);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-box-value {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--gray-700);
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
background: white;
|
||||||
|
color: var(--gray-600);
|
||||||
|
border: 1px solid var(--gray-300);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn:hover {
|
||||||
|
background: var(--primary-light);
|
||||||
|
color: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn.copied {
|
||||||
|
background: #F0FDF4;
|
||||||
|
color: #059669;
|
||||||
|
border-color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screenshots grid */
|
||||||
|
.screenshots-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-card {
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-card img {
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: top;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-card-info {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-card-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gray-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-download {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Website preview */
|
||||||
|
.site-preview {
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-preview-bar {
|
||||||
|
background: var(--gray-100);
|
||||||
|
padding: 10px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-bottom: 1px solid var(--gray-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-preview-dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-preview-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gray-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-preview-url {
|
||||||
|
flex: 1;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-preview iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 500px;
|
||||||
|
border: none;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* URLs list */
|
||||||
|
.url-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--gray-50);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-item-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gray-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-item a {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-item a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message cards */
|
||||||
|
.message-card {
|
||||||
|
background: #EFF6FF;
|
||||||
|
border-left: 4px solid var(--primary);
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--primary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--gray-700);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card .copy-btn {
|
||||||
|
top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.screenshots-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.action-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.action-btn {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page-bg"></div>
|
||||||
|
|
||||||
|
<!-- Success Header -->
|
||||||
|
<header class="success-header">
|
||||||
|
<div class="success-icon">
|
||||||
|
<svg width="32" height="32" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
</div>
|
||||||
|
<h1>Your Compliance Package is Ready</h1>
|
||||||
|
<p><%= config.businessName %> — Generated <%= new Date(config.generatedAt).toLocaleDateString() %></p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="action-bar">
|
||||||
|
<a href="<%= config.siteUrl %>" target="_blank" class="action-btn action-btn-primary">
|
||||||
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||||
|
View Live Website
|
||||||
|
</a>
|
||||||
|
<a href="/download/<%= clientId %>" class="action-btn action-btn-outline">
|
||||||
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
|
Download All (ZIP)
|
||||||
|
</a>
|
||||||
|
<a href="/" class="action-btn action-btn-outline">
|
||||||
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
Generate Another
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="results-container">
|
||||||
|
|
||||||
|
<!-- Website Preview -->
|
||||||
|
<div class="result-section">
|
||||||
|
<div class="result-section-header">
|
||||||
|
<div class="section-icon section-icon-blue">
|
||||||
|
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h2>Generated Website</h2>
|
||||||
|
</div>
|
||||||
|
<div class="result-section-body">
|
||||||
|
<div class="site-preview">
|
||||||
|
<div class="site-preview-bar">
|
||||||
|
<div class="site-preview-dots">
|
||||||
|
<div class="site-preview-dot" style="background:#FF5F57"></div>
|
||||||
|
<div class="site-preview-dot" style="background:#FEBC2E"></div>
|
||||||
|
<div class="site-preview-dot" style="background:#28C840"></div>
|
||||||
|
</div>
|
||||||
|
<div class="site-preview-url"><%= config.siteUrl %></div>
|
||||||
|
</div>
|
||||||
|
<iframe src="<%= config.siteUrl %>" loading="lazy"></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 16px;">
|
||||||
|
<div class="url-list">
|
||||||
|
<div class="url-item">
|
||||||
|
<span class="url-item-label">Homepage</span>
|
||||||
|
<a href="<%= config.siteUrl %>" target="_blank"><%= config.siteUrl %></a>
|
||||||
|
</div>
|
||||||
|
<div class="url-item">
|
||||||
|
<span class="url-item-label">Contact / Opt-in Form</span>
|
||||||
|
<a href="<%= config.siteUrl %>/contact" target="_blank"><%= config.siteUrl %>/contact</a>
|
||||||
|
</div>
|
||||||
|
<div class="url-item">
|
||||||
|
<span class="url-item-label">Privacy Policy</span>
|
||||||
|
<a href="<%= config.siteUrl %>/privacy-policy" target="_blank"><%= config.siteUrl %>/privacy-policy</a>
|
||||||
|
</div>
|
||||||
|
<div class="url-item">
|
||||||
|
<span class="url-item-label">Terms of Service</span>
|
||||||
|
<a href="<%= config.siteUrl %>/terms" target="_blank"><%= config.siteUrl %>/terms</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Campaign Description -->
|
||||||
|
<div class="result-section">
|
||||||
|
<div class="result-section-header">
|
||||||
|
<div class="section-icon section-icon-green">
|
||||||
|
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h2>Campaign Details</h2>
|
||||||
|
</div>
|
||||||
|
<div class="result-section-body">
|
||||||
|
<div class="copy-box">
|
||||||
|
<div class="copy-box-label">Campaign Description</div>
|
||||||
|
<div class="copy-box-value"><%= config.a2pPacket.campaignDescription %></div>
|
||||||
|
<button class="copy-btn" onclick="copyText(this)">
|
||||||
|
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="copy-box">
|
||||||
|
<div class="copy-box-label">Opt-In Flow Description</div>
|
||||||
|
<div class="copy-box-value"><%= config.a2pPacket.optInFlowDescription %></div>
|
||||||
|
<button class="copy-btn" onclick="copyText(this)">
|
||||||
|
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sample Messages -->
|
||||||
|
<div class="result-section">
|
||||||
|
<div class="result-section-header">
|
||||||
|
<div class="section-icon section-icon-purple">
|
||||||
|
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||||
|
</div>
|
||||||
|
<h2>Sample Messages</h2>
|
||||||
|
</div>
|
||||||
|
<div class="result-section-body">
|
||||||
|
<% config.a2pPacket.sampleMessages.forEach((msg, i) => { %>
|
||||||
|
<div class="message-card">
|
||||||
|
<div class="message-card-label">Sample Message <%= i + 1 %></div>
|
||||||
|
<div class="message-card-text"><%= msg %></div>
|
||||||
|
<button class="copy-btn" onclick="copyText(this)">
|
||||||
|
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirmation & Help Messages -->
|
||||||
|
<div class="result-section">
|
||||||
|
<div class="result-section-header">
|
||||||
|
<div class="section-icon section-icon-amber">
|
||||||
|
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h2>Auto-Reply Messages & Keywords</h2>
|
||||||
|
</div>
|
||||||
|
<div class="result-section-body">
|
||||||
|
<div class="copy-box">
|
||||||
|
<div class="copy-box-label">Opt-In Confirmation</div>
|
||||||
|
<div class="copy-box-value"><%= config.a2pPacket.optInConfirmation %></div>
|
||||||
|
<button class="copy-btn" onclick="copyText(this)">
|
||||||
|
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="copy-box">
|
||||||
|
<div class="copy-box-label">Opt-Out Confirmation</div>
|
||||||
|
<div class="copy-box-value"><%= config.a2pPacket.optOutConfirmation %></div>
|
||||||
|
<button class="copy-btn" onclick="copyText(this)">
|
||||||
|
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="copy-box">
|
||||||
|
<div class="copy-box-label">Help Message</div>
|
||||||
|
<div class="copy-box-value"><%= config.a2pPacket.helpMessage %></div>
|
||||||
|
<button class="copy-btn" onclick="copyText(this)">
|
||||||
|
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="copy-box">
|
||||||
|
<div class="copy-box-label">Consent Text (website form)</div>
|
||||||
|
<div class="copy-box-value"><%= config.a2pPacket.consentText %></div>
|
||||||
|
<button class="copy-btn" onclick="copyText(this)">
|
||||||
|
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; margin-top: 8px;">
|
||||||
|
<div class="copy-box">
|
||||||
|
<div class="copy-box-label">Opt-In Keywords</div>
|
||||||
|
<div class="copy-box-value"><%= config.a2pPacket.optInKeywords %></div>
|
||||||
|
</div>
|
||||||
|
<div class="copy-box">
|
||||||
|
<div class="copy-box-label">Opt-Out Keywords</div>
|
||||||
|
<div class="copy-box-value"><%= config.a2pPacket.optOutKeywords %></div>
|
||||||
|
</div>
|
||||||
|
<div class="copy-box">
|
||||||
|
<div class="copy-box-label">Help Keywords</div>
|
||||||
|
<div class="copy-box-value"><%= config.a2pPacket.helpKeywords %></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Screenshots -->
|
||||||
|
<div class="result-section">
|
||||||
|
<div class="result-section-header">
|
||||||
|
<div class="section-icon section-icon-blue">
|
||||||
|
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||||
|
</div>
|
||||||
|
<h2>Screenshots</h2>
|
||||||
|
</div>
|
||||||
|
<div class="result-section-body">
|
||||||
|
<div class="screenshots-grid">
|
||||||
|
<div class="screenshot-card">
|
||||||
|
<img src="/data/<%= clientId %>/screenshots/homepage.png" alt="Homepage" onerror="this.style.display='none'">
|
||||||
|
<div class="screenshot-card-info">
|
||||||
|
<span class="screenshot-card-name">Homepage</span>
|
||||||
|
<a href="/data/<%= clientId %>/screenshots/homepage.png" download class="screenshot-download">Download</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-card">
|
||||||
|
<img src="/data/<%= clientId %>/screenshots/contact-optin.png" alt="Contact/Opt-in" onerror="this.style.display='none'">
|
||||||
|
<div class="screenshot-card-info">
|
||||||
|
<span class="screenshot-card-name">Opt-in Form</span>
|
||||||
|
<a href="/data/<%= clientId %>/screenshots/contact-optin.png" download class="screenshot-download">Download</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-card">
|
||||||
|
<img src="/data/<%= clientId %>/screenshots/privacy-policy.png" alt="Privacy Policy" onerror="this.style.display='none'">
|
||||||
|
<div class="screenshot-card-info">
|
||||||
|
<span class="screenshot-card-name">Privacy Policy</span>
|
||||||
|
<a href="/data/<%= clientId %>/screenshots/privacy-policy.png" download class="screenshot-download">Download</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-card">
|
||||||
|
<img src="/data/<%= clientId %>/screenshots/terms-of-service.png" alt="Terms of Service" onerror="this.style.display='none'">
|
||||||
|
<div class="screenshot-card-info">
|
||||||
|
<span class="screenshot-card-name">Terms of Service</span>
|
||||||
|
<a href="/data/<%= clientId %>/screenshots/terms-of-service.png" download class="screenshot-download">Download</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="footer">
|
||||||
|
A2P Compliance Wizard · Built for agencies who demand compliance
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function copyText(btn) {
|
||||||
|
const box = btn.closest('.copy-box') || btn.closest('.message-card');
|
||||||
|
const textEl = box.querySelector('.copy-box-value') || box.querySelector('.message-card-text');
|
||||||
|
const text = textEl.textContent.trim();
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
btn.classList.add('copied');
|
||||||
|
btn.innerHTML = '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M20 6L9 17l-5-5"/></svg> Copied!';
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.classList.remove('copied');
|
||||||
|
btn.innerHTML = '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg> Copy';
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
125
a2p-wizard-rebuild/templates/site/contact.ejs
Normal file
125
a2p-wizard-rebuild/templates/site/contact.ejs
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Contact Us — <%= businessName %></title>
|
||||||
|
<link rel="stylesheet" href="<%= baseUrl %>/assets/site.css">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary: <%= colorScheme.primary %>;
|
||||||
|
--primary-dark: <%= colorScheme.primaryDark %>;
|
||||||
|
--primary-light: <%= colorScheme.primaryLight %>;
|
||||||
|
--accent: <%= colorScheme.accent %>;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="nav">
|
||||||
|
<div class="nav-inner">
|
||||||
|
<a href="<%= baseUrl %>/" class="nav-brand">
|
||||||
|
<% if (logoUrl) { %>
|
||||||
|
<img src="<%= logoUrl %>" alt="<%= businessName %> logo">
|
||||||
|
<% } %>
|
||||||
|
<%= businessName %>
|
||||||
|
</a>
|
||||||
|
<button class="nav-mobile-toggle" onclick="document.querySelector('.nav-links').classList.toggle('open')" aria-label="Toggle menu">
|
||||||
|
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 12h18M3 6h18M3 18h18"/></svg>
|
||||||
|
</button>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="<%= baseUrl %>/">Home</a></li>
|
||||||
|
<li><a href="<%= baseUrl %>/#how-it-works">How It Works</a></li>
|
||||||
|
<li><a href="<%= baseUrl %>/#faq">FAQ</a></li>
|
||||||
|
<li><a href="<%= baseUrl %>/contact" class="nav-cta">Contact Us</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Contact Section -->
|
||||||
|
<section class="contact-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="contact-grid">
|
||||||
|
|
||||||
|
<!-- Left: Info -->
|
||||||
|
<div class="contact-info">
|
||||||
|
<h2>Get In Touch</h2>
|
||||||
|
<p>Have a question or ready to get started? Fill out the form and we'll be in touch shortly.</p>
|
||||||
|
|
||||||
|
<div class="contact-detail">
|
||||||
|
<div class="contact-detail-icon">
|
||||||
|
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6 19.79 19.79 0 01-3.07-8.67A2 2 0 014.11 2h3a2 2 0 012 1.72 12.84 12.84 0 00.7 2.81 2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45 12.84 12.84 0 002.81.7A2 2 0 0122 16.92z"/></svg>
|
||||||
|
</div>
|
||||||
|
<span><%= businessPhone %></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contact-detail">
|
||||||
|
<div class="contact-detail-icon">
|
||||||
|
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||||
|
</div>
|
||||||
|
<span><%= businessEmail %></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contact-detail">
|
||||||
|
<div class="contact-detail-icon">
|
||||||
|
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||||
|
</div>
|
||||||
|
<span><%= businessAddress %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Form -->
|
||||||
|
<div class="contact-form-card">
|
||||||
|
<form class="contact-form" onsubmit="event.preventDefault(); alert('Thank you! We will be in touch shortly.');">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Full Name</label>
|
||||||
|
<input type="text" id="name" name="name" placeholder="Your full name" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email Address</label>
|
||||||
|
<input type="email" id="email" name="email" placeholder="you@example.com" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="phone">Phone Number</label>
|
||||||
|
<input type="tel" id="phone" name="phone" placeholder="(555) 123-4567" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="message">Message (optional)</label>
|
||||||
|
<textarea id="message" name="message" rows="3" placeholder="How can we help you?"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SMS Consent Checkbox — CRITICAL FOR A2P COMPLIANCE -->
|
||||||
|
<div class="consent-group">
|
||||||
|
<input type="checkbox" id="smsConsent" name="smsConsent">
|
||||||
|
<label for="smsConsent" class="consent-text">
|
||||||
|
<%= a2pPacket.consentText %>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-submit">Send Message</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container">
|
||||||
|
<div class="footer-inner">
|
||||||
|
<div class="footer-brand"><%= businessName %></div>
|
||||||
|
<ul class="footer-links">
|
||||||
|
<li><a href="<%= baseUrl %>/">Home</a></li>
|
||||||
|
<li><a href="<%= baseUrl %>/contact">Contact</a></li>
|
||||||
|
<li><a href="<%= baseUrl %>/privacy-policy">Privacy Policy</a></li>
|
||||||
|
<li><a href="<%= baseUrl %>/terms">Terms of Service</a></li>
|
||||||
|
</ul>
|
||||||
|
<div class="footer-copy">© <%= year %> <%= businessName %>. All rights reserved. | <%= businessAddress %></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
172
a2p-wizard-rebuild/templates/site/index.ejs
Normal file
172
a2p-wizard-rebuild/templates/site/index.ejs
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><%= businessName %> — <%= website.heroTitle %></title>
|
||||||
|
<link rel="stylesheet" href="<%= baseUrl %>/assets/site.css">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary: <%= colorScheme.primary %>;
|
||||||
|
--primary-dark: <%= colorScheme.primaryDark %>;
|
||||||
|
--primary-light: <%= colorScheme.primaryLight %>;
|
||||||
|
--accent: <%= colorScheme.accent %>;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="nav">
|
||||||
|
<div class="nav-inner">
|
||||||
|
<a href="<%= baseUrl %>/" class="nav-brand">
|
||||||
|
<% if (logoUrl) { %>
|
||||||
|
<img src="<%= logoUrl %>" alt="<%= businessName %> logo">
|
||||||
|
<% } %>
|
||||||
|
<%= businessName %>
|
||||||
|
</a>
|
||||||
|
<button class="nav-mobile-toggle" onclick="document.querySelector('.nav-links').classList.toggle('open')" aria-label="Toggle menu">
|
||||||
|
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 12h18M3 6h18M3 18h18"/></svg>
|
||||||
|
</button>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="<%= baseUrl %>/">Home</a></li>
|
||||||
|
<li><a href="<%= baseUrl %>/#how-it-works">How It Works</a></li>
|
||||||
|
<li><a href="<%= baseUrl %>/#faq">FAQ</a></li>
|
||||||
|
<li><a href="<%= baseUrl %>/contact" class="nav-cta">Contact Us</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Hero -->
|
||||||
|
<section class="hero">
|
||||||
|
<div class="container">
|
||||||
|
<div class="hero-badge">✦ Trusted Local Business</div>
|
||||||
|
<h1><%= website.heroTitle %></h1>
|
||||||
|
<p class="hero-subtitle"><%= website.heroSubtitle %></p>
|
||||||
|
<a href="<%= baseUrl %>/contact" class="hero-cta">
|
||||||
|
<%= website.ctaText %>
|
||||||
|
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M5 12h14m-7-7l7 7-7 7"/></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- About -->
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>About <%= businessName %></h2>
|
||||||
|
<p><%= website.expandedDescription %></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Pain Points -->
|
||||||
|
<section class="section section-alt">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Why Choose Us</h2>
|
||||||
|
<p>Here's what sets us apart from the rest</p>
|
||||||
|
</div>
|
||||||
|
<div class="painpoints-grid">
|
||||||
|
<% website.painpoints.forEach((pp) => { %>
|
||||||
|
<div class="painpoint-card">
|
||||||
|
<div class="painpoint-icon">
|
||||||
|
<%- getIcon(pp.icon) %>
|
||||||
|
</div>
|
||||||
|
<h3><%= pp.title %></h3>
|
||||||
|
<p><%= pp.description %></p>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- How It Works -->
|
||||||
|
<section class="section" id="how-it-works">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>How It Works</h2>
|
||||||
|
<p>Getting started is simple</p>
|
||||||
|
</div>
|
||||||
|
<div class="steps-grid">
|
||||||
|
<% website.howItWorks.forEach((step) => { %>
|
||||||
|
<div class="step-card">
|
||||||
|
<div class="step-number"><%= step.step %></div>
|
||||||
|
<h3><%= step.title %></h3>
|
||||||
|
<p><%= step.description %></p>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FAQ -->
|
||||||
|
<section class="section section-alt" id="faq">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Frequently Asked Questions</h2>
|
||||||
|
<p>Answers to common questions about our services</p>
|
||||||
|
</div>
|
||||||
|
<div class="faq-list">
|
||||||
|
<% website.faqItems.forEach((faq) => { %>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-question" onclick="this.parentElement.classList.toggle('open'); const a = this.nextElementSibling; a.style.maxHeight = a.style.maxHeight ? null : a.scrollHeight + 'px';">
|
||||||
|
<span><%= faq.question %></span>
|
||||||
|
<svg class="faq-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="faq-answer">
|
||||||
|
<div class="faq-answer-inner"><%= faq.answer %></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<section class="cta-section">
|
||||||
|
<div class="container">
|
||||||
|
<h2>Ready to Get Started?</h2>
|
||||||
|
<p>Reach out today and discover how we can help your business thrive.</p>
|
||||||
|
<a href="<%= baseUrl %>/contact" class="hero-cta">
|
||||||
|
Contact Us Today
|
||||||
|
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M5 12h14m-7-7l7 7-7 7"/></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container">
|
||||||
|
<div class="footer-inner">
|
||||||
|
<div class="footer-brand"><%= businessName %></div>
|
||||||
|
<ul class="footer-links">
|
||||||
|
<li><a href="<%= baseUrl %>/">Home</a></li>
|
||||||
|
<li><a href="<%= baseUrl %>/contact">Contact</a></li>
|
||||||
|
<li><a href="<%= baseUrl %>/privacy-policy">Privacy Policy</a></li>
|
||||||
|
<li><a href="<%= baseUrl %>/terms">Terms of Service</a></li>
|
||||||
|
</ul>
|
||||||
|
<div class="footer-copy">© <%= year %> <%= businessName %>. All rights reserved. | <%= businessAddress %></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<%
|
||||||
|
function getIcon(name) {
|
||||||
|
const icons = {
|
||||||
|
shield: '<svg class="icon-svg" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>',
|
||||||
|
clock: '<svg class="icon-svg" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
|
||||||
|
chart: '<svg class="icon-svg" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>',
|
||||||
|
heart: '<svg class="icon-svg" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>',
|
||||||
|
star: '<svg class="icon-svg" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>',
|
||||||
|
zap: '<svg class="icon-svg" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
|
||||||
|
target: '<svg class="icon-svg" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>',
|
||||||
|
users: '<svg class="icon-svg" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>',
|
||||||
|
check: '<svg class="icon-svg" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
|
||||||
|
phone: '<svg class="icon-svg" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6 19.79 19.79 0 01-3.07-8.67A2 2 0 014.11 2h3a2 2 0 012 1.72 12.84 12.84 0 00.7 2.81 2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45 12.84 12.84 0 002.81.7A2 2 0 0122 16.92z"/></svg>',
|
||||||
|
};
|
||||||
|
return icons[name] || icons.star;
|
||||||
|
}
|
||||||
|
%>
|
||||||
68
a2p-wizard-rebuild/templates/site/privacy-policy.ejs
Normal file
68
a2p-wizard-rebuild/templates/site/privacy-policy.ejs
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Privacy Policy — <%= businessName %></title>
|
||||||
|
<link rel="stylesheet" href="<%= baseUrl %>/assets/site.css">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary: <%= colorScheme.primary %>;
|
||||||
|
--primary-dark: <%= colorScheme.primaryDark %>;
|
||||||
|
--primary-light: <%= colorScheme.primaryLight %>;
|
||||||
|
--accent: <%= colorScheme.accent %>;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="nav">
|
||||||
|
<div class="nav-inner">
|
||||||
|
<a href="<%= baseUrl %>/" class="nav-brand">
|
||||||
|
<% if (logoUrl) { %>
|
||||||
|
<img src="<%= logoUrl %>" alt="<%= businessName %> logo">
|
||||||
|
<% } %>
|
||||||
|
<%= businessName %>
|
||||||
|
</a>
|
||||||
|
<button class="nav-mobile-toggle" onclick="document.querySelector('.nav-links').classList.toggle('open')" aria-label="Toggle menu">
|
||||||
|
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 12h18M3 6h18M3 18h18"/></svg>
|
||||||
|
</button>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="<%= baseUrl %>/">Home</a></li>
|
||||||
|
<li><a href="<%= baseUrl %>/contact">Contact</a></li>
|
||||||
|
<li><a href="<%= baseUrl %>/privacy-policy">Privacy Policy</a></li>
|
||||||
|
<li><a href="<%= baseUrl %>/terms">Terms</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Privacy Policy -->
|
||||||
|
<section class="legal-page">
|
||||||
|
<div class="container">
|
||||||
|
<h1>Privacy Policy</h1>
|
||||||
|
<div class="legal-meta">Last updated: <%= new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) %> · <%= businessName %></div>
|
||||||
|
<div class="legal-content">
|
||||||
|
<%- website.privacyPolicy %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container">
|
||||||
|
<div class="footer-inner">
|
||||||
|
<div class="footer-brand"><%= businessName %></div>
|
||||||
|
<ul class="footer-links">
|
||||||
|
<li><a href="<%= baseUrl %>/">Home</a></li>
|
||||||
|
<li><a href="<%= baseUrl %>/contact">Contact</a></li>
|
||||||
|
<li><a href="<%= baseUrl %>/privacy-policy">Privacy Policy</a></li>
|
||||||
|
<li><a href="<%= baseUrl %>/terms">Terms of Service</a></li>
|
||||||
|
</ul>
|
||||||
|
<div class="footer-copy">© <%= year %> <%= businessName %>. All rights reserved.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
68
a2p-wizard-rebuild/templates/site/terms.ejs
Normal file
68
a2p-wizard-rebuild/templates/site/terms.ejs
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Terms of Service — <%= businessName %></title>
|
||||||
|
<link rel="stylesheet" href="<%= baseUrl %>/assets/site.css">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary: <%= colorScheme.primary %>;
|
||||||
|
--primary-dark: <%= colorScheme.primaryDark %>;
|
||||||
|
--primary-light: <%= colorScheme.primaryLight %>;
|
||||||
|
--accent: <%= colorScheme.accent %>;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="nav">
|
||||||
|
<div class="nav-inner">
|
||||||
|
<a href="<%= baseUrl %>/" class="nav-brand">
|
||||||
|
<% if (logoUrl) { %>
|
||||||
|
<img src="<%= logoUrl %>" alt="<%= businessName %> logo">
|
||||||
|
<% } %>
|
||||||
|
<%= businessName %>
|
||||||
|
</a>
|
||||||
|
<button class="nav-mobile-toggle" onclick="document.querySelector('.nav-links').classList.toggle('open')" aria-label="Toggle menu">
|
||||||
|
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 12h18M3 6h18M3 18h18"/></svg>
|
||||||
|
</button>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="<%= baseUrl %>/">Home</a></li>
|
||||||
|
<li><a href="<%= baseUrl %>/contact">Contact</a></li>
|
||||||
|
<li><a href="<%= baseUrl %>/privacy-policy">Privacy Policy</a></li>
|
||||||
|
<li><a href="<%= baseUrl %>/terms">Terms</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Terms of Service -->
|
||||||
|
<section class="legal-page">
|
||||||
|
<div class="container">
|
||||||
|
<h1>Terms of Service</h1>
|
||||||
|
<div class="legal-meta">Last updated: <%= new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) %> · <%= businessName %></div>
|
||||||
|
<div class="legal-content">
|
||||||
|
<%- website.termsOfService %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container">
|
||||||
|
<div class="footer-inner">
|
||||||
|
<div class="footer-brand"><%= businessName %></div>
|
||||||
|
<ul class="footer-links">
|
||||||
|
<li><a href="<%= baseUrl %>/">Home</a></li>
|
||||||
|
<li><a href="<%= baseUrl %>/contact">Contact</a></li>
|
||||||
|
<li><a href="<%= baseUrl %>/privacy-policy">Privacy Policy</a></li>
|
||||||
|
<li><a href="<%= baseUrl %>/terms">Terms of Service</a></li>
|
||||||
|
</ul>
|
||||||
|
<div class="footer-copy">© <%= year %> <%= businessName %>. All rights reserved.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -1,665 +0,0 @@
|
|||||||
# CloseBot SMS — Definitive Build Plan
|
|
||||||
|
|
||||||
> Unified app: Twilio native SMS + CloseBot AI bots. One dashboard to rule them all.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. TECH STACK
|
|
||||||
|
|
||||||
| Layer | Technology | Why |
|
|
||||||
|---|---|---|
|
|
||||||
| **Frontend** | Next.js 14 (App Router) + Tailwind CSS + shadcn/ui | Dark-mode-first, server components, fast |
|
|
||||||
| **Backend** | Next.js API routes (Edge-compatible) | Same deployment, zero CORS |
|
|
||||||
| **Database** | SQLite via better-sqlite3 (dev) → Turso/LibSQL (prod) | State tracking, conversation history, routing config |
|
|
||||||
| **Real-time** | Server-Sent Events (SSE) | Live conversation updates on dashboard |
|
|
||||||
| **SMS** | Twilio Node SDK (`twilio`) | Send/receive SMS, delivery status |
|
|
||||||
| **AI Bots** | CloseBot API (direct HTTP) | Webhook Source for inbound, API for bots/leads/metrics |
|
|
||||||
| **Auth** | NextAuth.js with credentials provider | Simple login, protect all routes |
|
|
||||||
| **Deploy** | Railway / Fly.io / Vercel | Needs persistent process for webhooks |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. SYSTEM ARCHITECTURE
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────┐ ┌──────────────────────────┐ ┌─────────────┐
|
|
||||||
│ Customer │────>│ Twilio (SMS) │────>│ CloseBot │
|
|
||||||
│ Phone │<────│ │<────│ SMS App │
|
|
||||||
└─────────────┘ └──────────────────────────┘ └──────┬──────┘
|
|
||||||
│
|
|
||||||
┌──────────────────────────┐ │
|
|
||||||
│ CloseBot API │<───────────┘
|
|
||||||
│ (Webhook Source) │────────────┐
|
|
||||||
└──────────────────────────┘ │
|
|
||||||
v
|
|
||||||
┌──────────────┐
|
|
||||||
│ SQLite DB │
|
|
||||||
│ (state/logs) │
|
|
||||||
└──────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Message Flow — Inbound:**
|
|
||||||
1. Customer sends SMS to Twilio number
|
|
||||||
2. Twilio POSTs to `POST /api/webhooks/twilio/inbound`
|
|
||||||
3. App looks up routing: which Twilio number → which CloseBot bot/source
|
|
||||||
4. App sends inbound event to CloseBot via `POST /webhook/event/{sourceId}` with:
|
|
||||||
- `type: "message"`
|
|
||||||
- `contactId` (phone number as unique identifier)
|
|
||||||
- `message` (SMS body)
|
|
||||||
- `state` (JSON: `{ twilioNumber, phoneFrom, messageSid }`)
|
|
||||||
5. CloseBot processes through bot flow
|
|
||||||
6. CloseBot POSTs response to our webhook retrieval URL: `POST /api/webhooks/closebot/response`
|
|
||||||
7. App receives response, extracts `state` to get the Twilio number + customer phone
|
|
||||||
8. App sends SMS via Twilio API
|
|
||||||
9. App logs everything to SQLite + broadcasts via SSE for live dashboard
|
|
||||||
|
|
||||||
**Message Flow — Manual Override (from Conversations view):**
|
|
||||||
1. User types message in the conversation UI
|
|
||||||
2. App sends SMS directly via Twilio (bypassing CloseBot)
|
|
||||||
3. Logs to SQLite with `source: "manual"` flag
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. DATABASE SCHEMA
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Routes: Twilio number → CloseBot bot mapping
|
|
||||||
CREATE TABLE routes (
|
|
||||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
|
|
||||||
twilio_number TEXT NOT NULL UNIQUE,
|
|
||||||
twilio_number_sid TEXT,
|
|
||||||
closebot_source_id TEXT NOT NULL,
|
|
||||||
closebot_bot_id TEXT,
|
|
||||||
bot_name TEXT,
|
|
||||||
greeting_message TEXT,
|
|
||||||
after_hours_reply TEXT,
|
|
||||||
business_hours_json TEXT, -- {"mon": {"start": "09:00", "end": "17:00"}, ...}
|
|
||||||
max_concurrent INTEGER DEFAULT 50,
|
|
||||||
active BOOLEAN DEFAULT TRUE,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Contacts: phone numbers we've interacted with
|
|
||||||
CREATE TABLE contacts (
|
|
||||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
|
|
||||||
phone TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT,
|
|
||||||
email TEXT,
|
|
||||||
status TEXT DEFAULT 'new', -- new, active, qualified, booked, closed, cold
|
|
||||||
closebot_lead_id TEXT,
|
|
||||||
assigned_bot_id TEXT,
|
|
||||||
assigned_route_id TEXT REFERENCES routes(id),
|
|
||||||
tags TEXT, -- JSON array
|
|
||||||
fields_json TEXT, -- collected fields from CloseBot
|
|
||||||
first_contact DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_contact DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
message_count INTEGER DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Messages: every SMS in/out
|
|
||||||
CREATE TABLE messages (
|
|
||||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
|
|
||||||
contact_id TEXT REFERENCES contacts(id),
|
|
||||||
route_id TEXT REFERENCES routes(id),
|
|
||||||
direction TEXT NOT NULL, -- 'inbound' | 'outbound'
|
|
||||||
source TEXT NOT NULL, -- 'customer' | 'bot' | 'manual'
|
|
||||||
body TEXT NOT NULL,
|
|
||||||
twilio_sid TEXT,
|
|
||||||
twilio_status TEXT, -- queued, sent, delivered, failed, undelivered
|
|
||||||
closebot_message_id TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Settings: app-wide config
|
|
||||||
CREATE TABLE settings (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Events: activity log for dashboard feed
|
|
||||||
CREATE TABLE events (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
type TEXT NOT NULL, -- 'message_in', 'message_out', 'booking', 'new_lead', 'status_change'
|
|
||||||
contact_id TEXT REFERENCES contacts(id),
|
|
||||||
route_id TEXT REFERENCES routes(id),
|
|
||||||
data_json TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. API ROUTES
|
|
||||||
|
|
||||||
### Webhook Endpoints (public, Twilio/CloseBot call these)
|
|
||||||
| Route | Method | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| `/api/webhooks/twilio/inbound` | POST | Receive inbound SMS from Twilio |
|
|
||||||
| `/api/webhooks/twilio/status` | POST | Receive delivery status updates |
|
|
||||||
| `/api/webhooks/closebot/response` | POST | Receive CloseBot bot responses |
|
|
||||||
|
|
||||||
### Internal API (authenticated, frontend calls these)
|
|
||||||
| Route | Method | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| `/api/dashboard/stats` | GET | Active convos, messages today, bookings, response rate |
|
|
||||||
| `/api/dashboard/activity` | GET | Recent activity feed (SSE endpoint) |
|
|
||||||
| `/api/conversations` | GET | List conversations with pagination/filters |
|
|
||||||
| `/api/conversations/[contactId]` | GET | Get single conversation with messages |
|
|
||||||
| `/api/conversations/[contactId]/send` | POST | Send manual SMS message |
|
|
||||||
| `/api/bots` | GET | List CloseBot bots (proxied from CloseBot API) |
|
|
||||||
| `/api/bots/[id]` | GET | Get bot details + metrics |
|
|
||||||
| `/api/contacts` | GET | List/search contacts with filters |
|
|
||||||
| `/api/contacts/[id]` | GET/PUT/DELETE | Contact CRUD |
|
|
||||||
| `/api/contacts/export` | GET | Export CSV |
|
|
||||||
| `/api/analytics/overview` | GET | Summary metrics with date range |
|
|
||||||
| `/api/analytics/messages` | GET | Message volume time series |
|
|
||||||
| `/api/analytics/bots` | GET | Per-bot conversation counts |
|
|
||||||
| `/api/analytics/outcomes` | GET | Outcome distribution |
|
|
||||||
| `/api/analytics/leaderboard` | GET | Top performing bots |
|
|
||||||
| `/api/routes` | GET/POST | List routes, create new route |
|
|
||||||
| `/api/routes/[id]` | GET/PUT/DELETE | Route CRUD |
|
|
||||||
| `/api/settings` | GET/PUT | App settings (Twilio creds, CloseBot key) |
|
|
||||||
| `/api/settings/test-connection` | POST | Test Twilio + CloseBot connections |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. PAGE-BY-PAGE UI SPEC
|
|
||||||
|
|
||||||
### 5A. DASHBOARD (`/`)
|
|
||||||
**Matches mockup: closebot-sms-dashboard.png**
|
|
||||||
|
|
||||||
**Layout:**
|
|
||||||
- Left sidebar (240px): Logo "CloseBot SMS" + 6 nav items with icons
|
|
||||||
- Top bar: Global search input
|
|
||||||
- Main content area
|
|
||||||
|
|
||||||
**Components:**
|
|
||||||
1. **Stat Cards Row** — 4 cards in a grid
|
|
||||||
- Active Conversations (chat bubble icon, count from DB)
|
|
||||||
- Messages Today (envelope icon, count from DB where date = today)
|
|
||||||
- Bookings Made (calendar icon, from CloseBot metrics API)
|
|
||||||
- Response Rate (trending icon, calculated: bot_responses / inbound_messages * 100)
|
|
||||||
- Each card: dark glass-morphism background (`bg-slate-800/50 backdrop-blur`), cyan border glow, icon top-left, label + big number
|
|
||||||
|
|
||||||
2. **Real-time Activity Table**
|
|
||||||
- Columns: SMS (phone number), Bot Name, Badges (status pill), Timestamp
|
|
||||||
- Status pills: `active` (green), `pending` (yellow), `closed` (gray)
|
|
||||||
- Auto-updates via SSE — new rows slide in at top
|
|
||||||
- Click row → navigate to conversation
|
|
||||||
|
|
||||||
**Data sources:**
|
|
||||||
- Stats: `GET /api/dashboard/stats` → queries SQLite counts
|
|
||||||
- Activity: `GET /api/dashboard/activity` → SSE stream from events table
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5B. CONVERSATIONS (`/conversations`)
|
|
||||||
**Matches mockup: closebot-sms-conversations.png**
|
|
||||||
|
|
||||||
**Layout:** Two-panel split (left 380px list, right flexible chat)
|
|
||||||
|
|
||||||
**Left Panel — Conversation List:**
|
|
||||||
- Search bar with filter icon + settings icon
|
|
||||||
- "All Filters" dropdown (by status, bot, date range)
|
|
||||||
- Conversation rows: avatar (generated from initials), name, phone, last message preview (truncated), timestamp, unread badge (cyan circle with count)
|
|
||||||
- Selected conversation highlighted with left cyan border
|
|
||||||
- Sorted by most recent message
|
|
||||||
|
|
||||||
**Right Panel — Chat Thread:**
|
|
||||||
- Header: avatar, name, phone number, "CloseBot SMS AI" badge, status dot + "Active" label
|
|
||||||
- Message bubbles:
|
|
||||||
- Inbound (customer): dark gray background (`bg-slate-700`), left-aligned with avatar
|
|
||||||
- Outbound (bot): cyan/blue gradient background, right-aligned
|
|
||||||
- Each bubble shows text, no timestamps on individual messages (clean look)
|
|
||||||
- Bottom: text input "Type a message..." with cyan send button
|
|
||||||
- Sending from here = manual override, bypasses CloseBot
|
|
||||||
|
|
||||||
**Data sources:**
|
|
||||||
- List: `GET /api/conversations?search=&status=&bot=&page=`
|
|
||||||
- Messages: `GET /api/conversations/[contactId]`
|
|
||||||
- Send: `POST /api/conversations/[contactId]/send`
|
|
||||||
- Live updates: SSE for new messages appended to thread
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5C. BOTS (`/bots`)
|
|
||||||
**Matches mockup: closebot-sms-bots.png**
|
|
||||||
|
|
||||||
**Layout:** Grid of bot cards (3 columns on desktop)
|
|
||||||
|
|
||||||
**Top Bar:**
|
|
||||||
- "Bot Management" heading
|
|
||||||
- Search bots input + filter icon + sort icon
|
|
||||||
- "+ Create New Bot" button (cyan, calls CloseBot create_bot_with_ai)
|
|
||||||
|
|
||||||
**Bot Cards:**
|
|
||||||
- Card header: bot name in bold, gradient top border (different colors per bot)
|
|
||||||
- Fields: Status toggle (Active/Inactive), Twilio # (from route mapping), Messages count, Conversion % (from CloseBot metrics), Last Active
|
|
||||||
- Inactive bots appear dimmed/grayed
|
|
||||||
- Click expand chevron → expanded card shows:
|
|
||||||
- Connected Sources list
|
|
||||||
- Recent Performance sparkline (mini chart, last 7 days)
|
|
||||||
- "Edit Flow" button (opens CloseBot dashboard in new tab)
|
|
||||||
|
|
||||||
**Data sources:**
|
|
||||||
- Bots list: `GET /api/bots` → proxies CloseBot `GET /bot` + merges route data from SQLite
|
|
||||||
- Metrics: CloseBot `GET /botMetric/agencySummary`
|
|
||||||
- Toggle active: `PUT /api/routes/[id]` to enable/disable route
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5D. CONTACTS (`/contacts`)
|
|
||||||
**Matches mockup: closebot-sms-contacts.png**
|
|
||||||
|
|
||||||
**Layout:** Full-width data table with optional slide-out detail panel
|
|
||||||
|
|
||||||
**Top Bar:**
|
|
||||||
- Search input
|
|
||||||
- Filter dropdowns: "Filter by Status" (hot/warm/cold/new), "Filter by Bot", date range picker
|
|
||||||
- "Import Contacts" button (cyan outline) + "Export CSV" button (cyan outline)
|
|
||||||
|
|
||||||
**Table Columns:**
|
|
||||||
- Name (bold)
|
|
||||||
- Phone Number
|
|
||||||
- Assigned Bot
|
|
||||||
- Status (colored dot: red=Hot Lead, orange=Warm, gray=Cold + text label)
|
|
||||||
- Messages Exchanged (number)
|
|
||||||
- Last Contact (relative time: "2 hours ago", "Yesterday")
|
|
||||||
- Tags (colored pills)
|
|
||||||
|
|
||||||
**Slide-out Detail Panel (on row click):**
|
|
||||||
- Contact name (large, bold)
|
|
||||||
- Phone, Email, Company fields
|
|
||||||
- "Conversation Summary" section — AI-generated from CloseBot
|
|
||||||
- Bullet points: "Product Interest", "Budget Discussion", etc.
|
|
||||||
- "Field Values Collected" — mini table of CloseBot-collected fields (Company Size, Industry, Priority)
|
|
||||||
- Action buttons: "View Full History" + "Send Message" (cyan)
|
|
||||||
|
|
||||||
**Data sources:**
|
|
||||||
- Table: `GET /api/contacts?search=&status=&bot=&dateFrom=&dateTo=&page=&limit=`
|
|
||||||
- Detail: `GET /api/contacts/[id]`
|
|
||||||
- Export: `GET /api/contacts/export` → CSV download
|
|
||||||
- Import: `POST /api/contacts/import` (CSV upload, creates contacts + optionally sends first message)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5E. ANALYTICS (`/analytics`)
|
|
||||||
**Matches mockup: closebot-sms-analytics.png**
|
|
||||||
|
|
||||||
**Layout:** Scrollable dashboard with multiple chart sections
|
|
||||||
|
|
||||||
**Top Bar:**
|
|
||||||
- "Analytics" heading
|
|
||||||
- Tab navigation: Dashboard, Conversations, Bots, Contacts, Settings
|
|
||||||
- Date range picker dropdown (top right): "Last 30 Days (Oct 1 - Oct 30, 2024)"
|
|
||||||
|
|
||||||
**Section 1 — Stat Cards (4 across):**
|
|
||||||
- Total Conversations (large number + sparkline + % change vs last period)
|
|
||||||
- Booking Rate (percentage + sparkline + change)
|
|
||||||
- Avg Response Time (duration format "1m 45s" + sparkline + change)
|
|
||||||
- Customer Satisfaction (rating "4.8/5" + sparkline + change)
|
|
||||||
- Each card has colored gradient background (red→pink, green, blue, purple)
|
|
||||||
|
|
||||||
**Section 2 — Messages Over Time (full width):**
|
|
||||||
- Line chart with dual lines: Inbound (blue) vs Outbound (green)
|
|
||||||
- X-axis: dates over selected range
|
|
||||||
- Y-axis: message count
|
|
||||||
- Tooltip on hover showing exact values
|
|
||||||
- Chart library: Recharts (React-native, works with Next.js)
|
|
||||||
|
|
||||||
**Section 3 — Two charts side by side:**
|
|
||||||
- Left: "Conversations by Bot" — horizontal bar chart with colored bars per bot
|
|
||||||
- Right: "Outcome Distribution" — donut chart with segments (Booked 40%, Qualified 30%, Dropped 18%, Pending 12%), total in center
|
|
||||||
|
|
||||||
**Section 4 — Top Performing Bots:**
|
|
||||||
- Mini leaderboard table: Rank, Bot Name (with icon), Conversations, Booking Rate, CSAT Score
|
|
||||||
- Sorted by booking rate descending
|
|
||||||
|
|
||||||
**Data sources:**
|
|
||||||
- Stats: `GET /api/analytics/overview?start=&end=`
|
|
||||||
- Messages chart: `GET /api/analytics/messages?start=&end=&resolution=daily`
|
|
||||||
- By bot: `GET /api/analytics/bots?start=&end=`
|
|
||||||
- Outcomes: `GET /api/analytics/outcomes?start=&end=`
|
|
||||||
- Leaderboard: `GET /api/analytics/leaderboard?start=&end=`
|
|
||||||
- All backed by SQLite queries (message counts, conversation outcomes) + CloseBot metrics API for bookings/CSAT
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5F. ROUTING (`/routing`)
|
|
||||||
**Matches mockup: closebot-sms-routing.png**
|
|
||||||
|
|
||||||
**Layout:** Three-column visual routing display
|
|
||||||
|
|
||||||
**Top Bar:**
|
|
||||||
- Breadcrumb: "Routing / Phone Number Routing"
|
|
||||||
- Top nav tabs: Dashboard, Routing, Bots, Analytics, Settings
|
|
||||||
- "+ Add New Route" button (top right)
|
|
||||||
- Search bar + filter icon
|
|
||||||
|
|
||||||
**Three Columns:**
|
|
||||||
- **Left: Twilio Phone Numbers** — list of phone number cards with phone icon
|
|
||||||
- **Center: Routing** — animated connection lines (CSS/SVG) linking numbers to bots
|
|
||||||
- **Right: CloseBot Bots & Sources** — bot cards with: name, source badge (WEB_LEAD, EMAIL_INQUIRY, etc.), Active/Paused toggle, message count badge, "Configure" button
|
|
||||||
|
|
||||||
**Expanded Route Configuration (on Configure click):**
|
|
||||||
- Modal/drawer overlaying the route:
|
|
||||||
- Greeting Message Override (textarea, optional)
|
|
||||||
- After-Hours Auto-Reply Text (textarea)
|
|
||||||
- Business Hours Schedule — day-of-week toggle grid (Mon-Sun), time range "Mon-Fri: 9:00 AM - 5:00 PM", "Add Exception" link
|
|
||||||
- Max Concurrent Conversations — slider (1-100) with current value displayed
|
|
||||||
- Save + Close Configuration buttons
|
|
||||||
|
|
||||||
**Data sources:**
|
|
||||||
- Routes: `GET /api/routes` → SQLite routes table
|
|
||||||
- Twilio numbers: fetched from Twilio API on settings save, cached
|
|
||||||
- Bots: `GET /api/bots` → CloseBot API
|
|
||||||
- Update: `PUT /api/routes/[id]` with config JSON
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5G. SETTINGS (`/settings`)
|
|
||||||
**Matches mockup: closebot-sms-settings.png**
|
|
||||||
|
|
||||||
**Layout:** Two-column card layout
|
|
||||||
|
|
||||||
**Cards:**
|
|
||||||
1. **Twilio Connection** — Account SID input (masked), Auth Token input (masked), "Connection Status" indicator (green dot + "Connected")
|
|
||||||
2. **CloseBot Connection** — API Key input (masked), Webhook Source ID (display), "Connection Status" indicator
|
|
||||||
3. **Phone Numbers** — list of Twilio numbers with assigned bot + toggle switch
|
|
||||||
4. **Notifications** — toggles: Email Alerts, SMS Delivery Failures, New Lead Alerts
|
|
||||||
5. **Webhook URLs** — read-only display of:
|
|
||||||
- Inbound URL (the URL you give to Twilio)
|
|
||||||
- Response URL (the URL you give to CloseBot Webhook Source)
|
|
||||||
- Copy buttons next to each
|
|
||||||
|
|
||||||
**Data sources:**
|
|
||||||
- Settings: `GET/PUT /api/settings`
|
|
||||||
- Test: `POST /api/settings/test-connection` → pings both APIs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. FILE STRUCTURE
|
|
||||||
|
|
||||||
```
|
|
||||||
closebot-sms/
|
|
||||||
├── package.json
|
|
||||||
├── next.config.js
|
|
||||||
├── tailwind.config.ts
|
|
||||||
├── tsconfig.json
|
|
||||||
├── .env.local.example
|
|
||||||
├── BUILD_PLAN.md
|
|
||||||
├── README.md
|
|
||||||
├── prisma/ (or drizzle/)
|
|
||||||
│ └── schema.sql
|
|
||||||
├── src/
|
|
||||||
│ ├── app/
|
|
||||||
│ │ ├── layout.tsx # Root layout with sidebar
|
|
||||||
│ │ ├── page.tsx # Dashboard
|
|
||||||
│ │ ├── conversations/
|
|
||||||
│ │ │ └── page.tsx # Conversations split view
|
|
||||||
│ │ ├── bots/
|
|
||||||
│ │ │ └── page.tsx # Bot grid
|
|
||||||
│ │ ├── contacts/
|
|
||||||
│ │ │ └── page.tsx # Contacts table
|
|
||||||
│ │ ├── analytics/
|
|
||||||
│ │ │ └── page.tsx # Analytics dashboard
|
|
||||||
│ │ ├── routing/
|
|
||||||
│ │ │ └── page.tsx # Phone number routing
|
|
||||||
│ │ ├── settings/
|
|
||||||
│ │ │ └── page.tsx # Settings/config
|
|
||||||
│ │ └── api/
|
|
||||||
│ │ ├── webhooks/
|
|
||||||
│ │ │ ├── twilio/
|
|
||||||
│ │ │ │ ├── inbound/route.ts
|
|
||||||
│ │ │ │ └── status/route.ts
|
|
||||||
│ │ │ └── closebot/
|
|
||||||
│ │ │ └── response/route.ts
|
|
||||||
│ │ ├── dashboard/
|
|
||||||
│ │ │ ├── stats/route.ts
|
|
||||||
│ │ │ └── activity/route.ts # SSE
|
|
||||||
│ │ ├── conversations/
|
|
||||||
│ │ │ ├── route.ts
|
|
||||||
│ │ │ └── [contactId]/
|
|
||||||
│ │ │ ├── route.ts
|
|
||||||
│ │ │ └── send/route.ts
|
|
||||||
│ │ ├── bots/
|
|
||||||
│ │ │ ├── route.ts
|
|
||||||
│ │ │ └── [id]/route.ts
|
|
||||||
│ │ ├── contacts/
|
|
||||||
│ │ │ ├── route.ts
|
|
||||||
│ │ │ ├── export/route.ts
|
|
||||||
│ │ │ └── [id]/route.ts
|
|
||||||
│ │ ├── analytics/
|
|
||||||
│ │ │ ├── overview/route.ts
|
|
||||||
│ │ │ ├── messages/route.ts
|
|
||||||
│ │ │ ├── bots/route.ts
|
|
||||||
│ │ │ ├── outcomes/route.ts
|
|
||||||
│ │ │ └── leaderboard/route.ts
|
|
||||||
│ │ ├── routes/
|
|
||||||
│ │ │ ├── route.ts
|
|
||||||
│ │ │ └── [id]/route.ts
|
|
||||||
│ │ └── settings/
|
|
||||||
│ │ ├── route.ts
|
|
||||||
│ │ └── test-connection/route.ts
|
|
||||||
│ ├── components/
|
|
||||||
│ │ ├── layout/
|
|
||||||
│ │ │ ├── sidebar.tsx
|
|
||||||
│ │ │ ├── topbar.tsx
|
|
||||||
│ │ │ └── nav-item.tsx
|
|
||||||
│ │ ├── dashboard/
|
|
||||||
│ │ │ ├── stat-card.tsx
|
|
||||||
│ │ │ └── activity-feed.tsx
|
|
||||||
│ │ ├── conversations/
|
|
||||||
│ │ │ ├── conversation-list.tsx
|
|
||||||
│ │ │ ├── conversation-item.tsx
|
|
||||||
│ │ │ ├── chat-thread.tsx
|
|
||||||
│ │ │ ├── message-bubble.tsx
|
|
||||||
│ │ │ └── chat-input.tsx
|
|
||||||
│ │ ├── bots/
|
|
||||||
│ │ │ ├── bot-grid.tsx
|
|
||||||
│ │ │ └── bot-card.tsx
|
|
||||||
│ │ ├── contacts/
|
|
||||||
│ │ │ ├── contacts-table.tsx
|
|
||||||
│ │ │ ├── contact-detail-panel.tsx
|
|
||||||
│ │ │ └── contact-filters.tsx
|
|
||||||
│ │ ├── analytics/
|
|
||||||
│ │ │ ├── stat-card-sparkline.tsx
|
|
||||||
│ │ │ ├── messages-chart.tsx
|
|
||||||
│ │ │ ├── bots-bar-chart.tsx
|
|
||||||
│ │ │ ├── outcome-donut.tsx
|
|
||||||
│ │ │ └── bot-leaderboard.tsx
|
|
||||||
│ │ ├── routing/
|
|
||||||
│ │ │ ├── routing-view.tsx
|
|
||||||
│ │ │ ├── phone-number-card.tsx
|
|
||||||
│ │ │ ├── bot-route-card.tsx
|
|
||||||
│ │ │ ├── connection-lines.tsx # SVG animated lines
|
|
||||||
│ │ │ └── route-config-modal.tsx
|
|
||||||
│ │ └── settings/
|
|
||||||
│ │ ├── twilio-card.tsx
|
|
||||||
│ │ ├── closebot-card.tsx
|
|
||||||
│ │ ├── phone-numbers-card.tsx
|
|
||||||
│ │ ├── notifications-card.tsx
|
|
||||||
│ │ └── webhook-urls-card.tsx
|
|
||||||
│ ├── lib/
|
|
||||||
│ │ ├── db.ts # SQLite connection + queries
|
|
||||||
│ │ ├── twilio.ts # Twilio client wrapper
|
|
||||||
│ │ ├── closebot.ts # CloseBot API client (reuse from MCP server)
|
|
||||||
│ │ ├── sse.ts # SSE broadcast utility
|
|
||||||
│ │ └── utils.ts # Formatters, helpers
|
|
||||||
│ └── styles/
|
|
||||||
│ └── globals.css # Tailwind base + custom dark theme
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. DESIGN SYSTEM
|
|
||||||
|
|
||||||
**Colors (from mockups):**
|
|
||||||
```css
|
|
||||||
--bg-primary: #0f1729; /* deepest navy */
|
|
||||||
--bg-secondary: #1a2332; /* card backgrounds */
|
|
||||||
--bg-tertiary: #1e293b; /* elevated surfaces */
|
|
||||||
--bg-hover: #2d3748; /* table row hover */
|
|
||||||
--text-primary: #e2e8f0; /* main text */
|
|
||||||
--text-secondary: #94a3b8; /* muted text */
|
|
||||||
--text-muted: #64748b; /* timestamps, labels */
|
|
||||||
--accent-cyan: #22d3ee; /* primary accent */
|
|
||||||
--accent-blue: #3b82f6; /* secondary accent */
|
|
||||||
--accent-green: #22c55e; /* success, active */
|
|
||||||
--accent-yellow: #eab308; /* warning, pending */
|
|
||||||
--accent-red: #ef4444; /* error, hot lead */
|
|
||||||
--accent-orange: #f97316; /* warm */
|
|
||||||
--accent-purple: #a855f7; /* bot cards */
|
|
||||||
--border: #334155; /* subtle borders */
|
|
||||||
--border-glow: rgba(34, 211, 238, 0.2); /* cyan card glow */
|
|
||||||
```
|
|
||||||
|
|
||||||
**Typography:**
|
|
||||||
- Font: `Inter` (system-ui fallback)
|
|
||||||
- Headings: 600-700 weight
|
|
||||||
- Body: 400 weight
|
|
||||||
- Monospace numbers: `font-variant-numeric: tabular-nums`
|
|
||||||
|
|
||||||
**Card Style:**
|
|
||||||
```css
|
|
||||||
.card {
|
|
||||||
background: rgba(30, 41, 59, 0.5);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
border: 1px solid rgba(51, 65, 85, 0.5);
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Status Badges:**
|
|
||||||
- Active: `bg-green-500/20 text-green-400 border-green-500/30`
|
|
||||||
- Pending: `bg-yellow-500/20 text-yellow-400 border-yellow-500/30`
|
|
||||||
- Closed: `bg-gray-500/20 text-gray-400 border-gray-500/30`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. BUILD PHASES
|
|
||||||
|
|
||||||
### Phase 1 — Foundation (Day 1)
|
|
||||||
- [ ] Next.js project scaffold with Tailwind + shadcn/ui
|
|
||||||
- [ ] SQLite schema + db.ts with all queries
|
|
||||||
- [ ] Global layout with sidebar navigation (matches dashboard mockup)
|
|
||||||
- [ ] Dark theme CSS variables
|
|
||||||
- [ ] Environment config (.env.local)
|
|
||||||
- [ ] Twilio client wrapper
|
|
||||||
- [ ] CloseBot API client (port from closebot-mcp)
|
|
||||||
|
|
||||||
### Phase 2 — Core Webhook Bridge (Day 1-2)
|
|
||||||
- [ ] `POST /api/webhooks/twilio/inbound` — receive SMS, forward to CloseBot
|
|
||||||
- [ ] `POST /api/webhooks/closebot/response` — receive bot reply, send via Twilio
|
|
||||||
- [ ] `POST /api/webhooks/twilio/status` — delivery status updates
|
|
||||||
- [ ] Contact auto-creation on first inbound
|
|
||||||
- [ ] Message logging to SQLite
|
|
||||||
- [ ] Route lookup logic (Twilio number → CloseBot source)
|
|
||||||
- [ ] Business hours check + after-hours auto-reply
|
|
||||||
- [ ] State passthrough (phone + route info in CloseBot state field)
|
|
||||||
|
|
||||||
### Phase 3 — Dashboard + Real-time (Day 2)
|
|
||||||
- [ ] Dashboard page with 4 stat cards
|
|
||||||
- [ ] Real-time activity feed (SSE)
|
|
||||||
- [ ] Activity table with status badges
|
|
||||||
- [ ] Auto-refresh stats
|
|
||||||
|
|
||||||
### Phase 4 — Conversations (Day 2-3)
|
|
||||||
- [ ] Conversation list with search/filter
|
|
||||||
- [ ] Chat thread with message bubbles
|
|
||||||
- [ ] Manual message send
|
|
||||||
- [ ] Unread badges
|
|
||||||
- [ ] Live message updates via SSE
|
|
||||||
|
|
||||||
### Phase 5 — Bots + Contacts (Day 3)
|
|
||||||
- [ ] Bot grid with cards from CloseBot API
|
|
||||||
- [ ] Bot status toggle (enable/disable route)
|
|
||||||
- [ ] Contacts table with all filters
|
|
||||||
- [ ] Slide-out detail panel
|
|
||||||
- [ ] CSV export
|
|
||||||
|
|
||||||
### Phase 6 — Analytics (Day 3-4)
|
|
||||||
- [ ] Stat cards with sparklines
|
|
||||||
- [ ] Messages over time chart (Recharts)
|
|
||||||
- [ ] Conversations by bot bar chart
|
|
||||||
- [ ] Outcome distribution donut chart
|
|
||||||
- [ ] Bot leaderboard table
|
|
||||||
- [ ] Date range picker
|
|
||||||
|
|
||||||
### Phase 7 — Routing + Settings (Day 4)
|
|
||||||
- [ ] Phone number routing view with visual connections
|
|
||||||
- [ ] Route config modal (business hours, greeting, max concurrent)
|
|
||||||
- [ ] Settings page with credential cards
|
|
||||||
- [ ] Connection testing
|
|
||||||
- [ ] Webhook URL display with copy
|
|
||||||
|
|
||||||
### Phase 8 — Polish (Day 4-5)
|
|
||||||
- [ ] Loading states + skeletons
|
|
||||||
- [ ] Error handling + toast notifications
|
|
||||||
- [ ] Mobile responsive adjustments
|
|
||||||
- [ ] Auth gate (simple login)
|
|
||||||
- [ ] README with deploy instructions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. EXTERNAL DEPENDENCIES
|
|
||||||
|
|
||||||
### npm packages:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"dependencies": {
|
|
||||||
"next": "^14.2",
|
|
||||||
"react": "^18.3",
|
|
||||||
"twilio": "^5.0",
|
|
||||||
"better-sqlite3": "^11.0",
|
|
||||||
"recharts": "^2.12",
|
|
||||||
"lucide-react": "^0.400",
|
|
||||||
"@radix-ui/react-dialog": "^1.0",
|
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0",
|
|
||||||
"@radix-ui/react-toggle": "^1.0",
|
|
||||||
"@radix-ui/react-slider": "^1.0",
|
|
||||||
"class-variance-authority": "^0.7",
|
|
||||||
"clsx": "^2.1",
|
|
||||||
"tailwind-merge": "^2.3",
|
|
||||||
"next-auth": "^4.24"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables:
|
|
||||||
```env
|
|
||||||
# Twilio
|
|
||||||
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
||||||
TWILIO_AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
||||||
|
|
||||||
# CloseBot
|
|
||||||
CLOSEBOT_API_KEY=cb_xxxxxxxxxxxxxxxx
|
|
||||||
|
|
||||||
# App
|
|
||||||
NEXTAUTH_SECRET=random-secret-here
|
|
||||||
NEXTAUTH_URL=https://your-domain.com
|
|
||||||
APP_URL=https://your-domain.com # Used to construct webhook URLs
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DATABASE_PATH=./data/closebot-sms.db
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. WHAT MAKES THIS DIFFERENT
|
|
||||||
|
|
||||||
1. **No GHL required** — CloseBot's Webhook Source is the channel, Twilio is the pipe
|
|
||||||
2. **Multi-tenant ready** — one app, many Twilio numbers, many bots
|
|
||||||
3. **Human takeover** — click into any convo and type a manual reply
|
|
||||||
4. **Business hours built-in** — auto-reply after hours, resume bot in morning
|
|
||||||
5. **Full audit trail** — every message logged with direction, source, status
|
|
||||||
6. **Real-time everything** — SSE for live dashboard, no polling
|
|
||||||
7. **CloseBot metrics integrated** — booking rates, leaderboards, CSAT from their API
|
|
||||||
8. **$0.0079/SMS** — Twilio pricing, no GHL middleman markup
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Plan version: 1.0 | Last updated: 2026-02-06*
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
5
closebot-sms/app/next-env.d.ts
vendored
5
closebot-sms/app/next-env.d.ts
vendored
@ -1,5 +0,0 @@
|
|||||||
/// <reference types="next" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
|
||||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
experimental: {
|
|
||||||
serverComponentsExternalPackages: ['better-sqlite3'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = nextConfig;
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "closebot-sms",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "next dev",
|
|
||||||
"build": "next build",
|
|
||||||
"start": "next start",
|
|
||||||
"lint": "next lint"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"better-sqlite3": "^11.0.0",
|
|
||||||
"class-variance-authority": "^0.7.0",
|
|
||||||
"clsx": "^2.1.0",
|
|
||||||
"handlebars": "^4.7.8",
|
|
||||||
"lucide-react": "^0.400.0",
|
|
||||||
"next": "^14.2.0",
|
|
||||||
"react": "^18.3.0",
|
|
||||||
"react-dom": "^18.3.0",
|
|
||||||
"recharts": "^2.12.0",
|
|
||||||
"tailwind-merge": "^2.3.0",
|
|
||||||
"twilio": "^5.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/better-sqlite3": "^7.6.0",
|
|
||||||
"@types/node": "^20.0.0",
|
|
||||||
"@types/react": "^18.3.0",
|
|
||||||
"@types/react-dom": "^18.3.0",
|
|
||||||
"autoprefixer": "^10.4.0",
|
|
||||||
"postcss": "^8.4.0",
|
|
||||||
"tailwindcss": "^3.4.0",
|
|
||||||
"typescript": "^5.4.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,349 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import {
|
|
||||||
Shield,
|
|
||||||
Plus,
|
|
||||||
RefreshCw,
|
|
||||||
Clock,
|
|
||||||
CheckCircle2,
|
|
||||||
XCircle,
|
|
||||||
AlertTriangle,
|
|
||||||
MoreVertical,
|
|
||||||
Eye,
|
|
||||||
RotateCcw,
|
|
||||||
Trash2,
|
|
||||||
FileText,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import type { SubmissionStatus } from '@/lib/a2p-types';
|
|
||||||
import { formatStatus, getStatusColor, formatUseCase } from '@/lib/a2p-types';
|
|
||||||
|
|
||||||
interface A2PRegistration {
|
|
||||||
id: string;
|
|
||||||
business_name: string;
|
|
||||||
status: SubmissionStatus;
|
|
||||||
brand_trust_score: number | null;
|
|
||||||
failure_reason: string | null;
|
|
||||||
attempt_count: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
input: {
|
|
||||||
business: { businessName: string };
|
|
||||||
campaign: { useCase: string };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface A2PStats {
|
|
||||||
total: number;
|
|
||||||
pending: number;
|
|
||||||
approved: number;
|
|
||||||
failed: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: SubmissionStatus }) {
|
|
||||||
const color = getStatusColor(status);
|
|
||||||
const colorMap: Record<string, string> = {
|
|
||||||
green: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
|
||||||
red: 'bg-red-500/10 text-red-400 border-red-500/20',
|
|
||||||
yellow: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
|
|
||||||
blue: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
|
||||||
amber: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
|
|
||||||
orange: 'bg-orange-500/10 text-orange-400 border-orange-500/20',
|
|
||||||
slate: 'bg-slate-500/10 text-slate-400 border-slate-500/20',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={cn(
|
|
||||||
'inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-medium',
|
|
||||||
colorMap[color] || colorMap.slate
|
|
||||||
)}>
|
|
||||||
{formatStatus(status)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function A2PPage() {
|
|
||||||
const [registrations, setRegistrations] = useState<A2PRegistration[]>([]);
|
|
||||||
const [stats, setStats] = useState<A2PStats>({ total: 0, pending: 0, approved: 0, failed: 0 });
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [actionMenuId, setActionMenuId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const res = await fetch('/api/a2p?stats=true');
|
|
||||||
const data = await res.json();
|
|
||||||
setRegistrations(data.registrations || []);
|
|
||||||
setStats(data.stats || { total: 0, pending: 0, approved: 0, failed: 0 });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch A2P data:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { fetchData(); }, [fetchData]);
|
|
||||||
|
|
||||||
async function handleRetry(id: string) {
|
|
||||||
try {
|
|
||||||
await fetch(`/api/a2p/${id}/retry`, { method: 'POST' });
|
|
||||||
setActionMenuId(null);
|
|
||||||
fetchData();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Retry failed:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
|
||||||
if (!confirm('Are you sure you want to delete this registration?')) return;
|
|
||||||
try {
|
|
||||||
await fetch(`/api/a2p/${id}`, { method: 'DELETE' });
|
|
||||||
setActionMenuId(null);
|
|
||||||
fetchData();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Delete failed:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const statCards = [
|
|
||||||
{
|
|
||||||
label: 'Total Registrations',
|
|
||||||
value: stats.total,
|
|
||||||
icon: FileText,
|
|
||||||
color: 'text-cyan-400',
|
|
||||||
bgColor: 'bg-cyan-500/10',
|
|
||||||
borderColor: 'border-cyan-500/20',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Pending',
|
|
||||||
value: stats.pending,
|
|
||||||
icon: Clock,
|
|
||||||
color: 'text-yellow-400',
|
|
||||||
bgColor: 'bg-yellow-500/10',
|
|
||||||
borderColor: 'border-yellow-500/20',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Approved',
|
|
||||||
value: stats.approved,
|
|
||||||
icon: CheckCircle2,
|
|
||||||
color: 'text-emerald-400',
|
|
||||||
bgColor: 'bg-emerald-500/10',
|
|
||||||
borderColor: 'border-emerald-500/20',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Failed',
|
|
||||||
value: stats.failed,
|
|
||||||
icon: XCircle,
|
|
||||||
color: 'text-red-400',
|
|
||||||
bgColor: 'bg-red-500/10',
|
|
||||||
borderColor: 'border-red-500/20',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6 lg:p-8 space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-3 mb-1">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-cyan-500/10 border border-cyan-500/20">
|
|
||||||
<Shield className="h-5 w-5 text-cyan-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-white">A2P Registration</h1>
|
|
||||||
<p className="text-sm text-slate-400">
|
|
||||||
Manage 10DLC brand & campaign registrations
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={fetchData}
|
|
||||||
className="flex items-center gap-2 rounded-lg border border-slate-700/50 bg-slate-800/50 px-4 py-2.5 text-sm font-medium text-slate-300 hover:bg-slate-700/50 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
href="/a2p/register"
|
|
||||||
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-cyan-500 transition-colors shadow-lg shadow-cyan-600/20"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
New Registration
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Cards */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{statCards.map((card) => {
|
|
||||||
const Icon = card.icon;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={card.label}
|
|
||||||
className={cn(
|
|
||||||
'card rounded-xl border p-5',
|
|
||||||
card.borderColor,
|
|
||||||
'bg-slate-900/50'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-400 font-medium">{card.label}</p>
|
|
||||||
<p className="mt-1 text-3xl font-bold text-white">{card.value}</p>
|
|
||||||
</div>
|
|
||||||
<div className={cn('flex h-12 w-12 items-center justify-center rounded-lg', card.bgColor)}>
|
|
||||||
<Icon className={cn('h-6 w-6', card.color)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Registration Table */}
|
|
||||||
<div className="card rounded-xl border border-slate-800/60 bg-slate-900/50 overflow-hidden">
|
|
||||||
<div className="px-6 py-4 border-b border-slate-800/60">
|
|
||||||
<h2 className="text-lg font-semibold text-white">All Registrations</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center py-16">
|
|
||||||
<RefreshCw className="h-6 w-6 text-slate-500 animate-spin" />
|
|
||||||
<span className="ml-3 text-slate-400">Loading registrations...</span>
|
|
||||||
</div>
|
|
||||||
) : registrations.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-800/50 mb-4">
|
|
||||||
<Shield className="h-8 w-8 text-slate-600" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-medium text-slate-300 mb-1">No registrations yet</h3>
|
|
||||||
<p className="text-sm text-slate-500 mb-6 max-w-sm">
|
|
||||||
Create your first A2P 10DLC registration to start sending compliant business messages.
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/a2p/register"
|
|
||||||
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-cyan-500 transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Start Registration
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-slate-800/60">
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
|
||||||
Business Name
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
|
||||||
Status
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
|
||||||
Brand Score
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
|
||||||
Use Case
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
|
||||||
Created
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase tracking-wider">
|
|
||||||
Actions
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-800/40">
|
|
||||||
{registrations.map((reg) => (
|
|
||||||
<tr key={reg.id} className="hover:bg-slate-800/30 transition-colors">
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm font-medium text-white">
|
|
||||||
{reg.business_name}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<StatusBadge status={reg.status} />
|
|
||||||
{reg.failure_reason && (
|
|
||||||
<div className="mt-1 flex items-center gap-1 text-xs text-red-400/80">
|
|
||||||
<AlertTriangle className="h-3 w-3" />
|
|
||||||
<span className="truncate max-w-[200px]">{reg.failure_reason}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
{reg.brand_trust_score != null ? (
|
|
||||||
<span className={cn(
|
|
||||||
'text-sm font-medium',
|
|
||||||
reg.brand_trust_score >= 75 ? 'text-emerald-400' :
|
|
||||||
reg.brand_trust_score >= 50 ? 'text-yellow-400' : 'text-red-400'
|
|
||||||
)}>
|
|
||||||
{reg.brand_trust_score}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-slate-600">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span className="text-sm text-slate-300">
|
|
||||||
{reg.input?.campaign?.useCase
|
|
||||||
? formatUseCase(reg.input.campaign.useCase as Parameters<typeof formatUseCase>[0])
|
|
||||||
: '—'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span className="text-sm text-slate-400">
|
|
||||||
{new Date(reg.created_at).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
|
||||||
<div className="relative inline-block">
|
|
||||||
<button
|
|
||||||
onClick={() => setActionMenuId(actionMenuId === reg.id ? null : reg.id)}
|
|
||||||
className="rounded-lg p-2 text-slate-400 hover:text-white hover:bg-slate-700/50 transition-colors"
|
|
||||||
>
|
|
||||||
<MoreVertical className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
{actionMenuId === reg.id && (
|
|
||||||
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-lg border border-slate-700/50 bg-slate-800 py-1 shadow-xl">
|
|
||||||
<Link
|
|
||||||
href={`/a2p/register?edit=${reg.id}`}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 text-sm text-slate-300 hover:text-white hover:bg-slate-700/50 transition-colors"
|
|
||||||
onClick={() => setActionMenuId(null)}
|
|
||||||
>
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
View Details
|
|
||||||
</Link>
|
|
||||||
{['brand_failed', 'campaign_failed', 'manual_review'].includes(reg.status) && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleRetry(reg.id)}
|
|
||||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-slate-300 hover:text-white hover:bg-slate-700/50 transition-colors"
|
|
||||||
>
|
|
||||||
<RotateCcw className="h-4 w-4" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(reg.id)}
|
|
||||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-red-400 hover:text-red-300 hover:bg-red-500/10 transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,995 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
ArrowRight,
|
|
||||||
Building2,
|
|
||||||
User,
|
|
||||||
MapPin,
|
|
||||||
MessageSquare,
|
|
||||||
CheckCircle,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
Shield,
|
|
||||||
Loader2,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import type {
|
|
||||||
BusinessInfo,
|
|
||||||
AuthorizedRep,
|
|
||||||
BusinessAddress,
|
|
||||||
CampaignInfo,
|
|
||||||
BusinessType,
|
|
||||||
BusinessIndustry,
|
|
||||||
RegistrationIdentifier,
|
|
||||||
JobPosition,
|
|
||||||
CampaignUseCase,
|
|
||||||
OptInType,
|
|
||||||
CompanyType,
|
|
||||||
} from '@/lib/a2p-types';
|
|
||||||
import {
|
|
||||||
BUSINESS_TYPE_OPTIONS,
|
|
||||||
INDUSTRY_OPTIONS,
|
|
||||||
JOB_POSITION_OPTIONS,
|
|
||||||
USE_CASE_OPTIONS,
|
|
||||||
OPT_IN_TYPE_OPTIONS,
|
|
||||||
COMPANY_TYPE_OPTIONS,
|
|
||||||
REGISTRATION_ID_OPTIONS,
|
|
||||||
formatIndustry,
|
|
||||||
formatUseCase,
|
|
||||||
formatStatus,
|
|
||||||
} from '@/lib/a2p-types';
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'closebot_a2p_wizard_draft';
|
|
||||||
|
|
||||||
const STEPS = [
|
|
||||||
{ label: 'Business Info', icon: Building2 },
|
|
||||||
{ label: 'Authorized Rep', icon: User },
|
|
||||||
{ label: 'Address', icon: MapPin },
|
|
||||||
{ label: 'Campaign', icon: MessageSquare },
|
|
||||||
{ label: 'Review & Submit', icon: CheckCircle },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Default form state
|
|
||||||
function getDefaultBusiness(): BusinessInfo {
|
|
||||||
return {
|
|
||||||
businessName: '',
|
|
||||||
businessType: 'Corporation',
|
|
||||||
businessIndustry: 'TECHNOLOGY',
|
|
||||||
registrationIdentifier: 'EIN',
|
|
||||||
registrationNumber: '',
|
|
||||||
websiteUrl: '',
|
|
||||||
socialMediaUrls: [],
|
|
||||||
businessIdentity: 'direct_customer',
|
|
||||||
regionsOfOperation: ['USA_AND_CANADA'],
|
|
||||||
companyType: 'private',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDefaultRep(): AuthorizedRep {
|
|
||||||
return {
|
|
||||||
firstName: '',
|
|
||||||
lastName: '',
|
|
||||||
businessTitle: '',
|
|
||||||
jobPosition: 'CEO',
|
|
||||||
phoneNumber: '',
|
|
||||||
email: '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDefaultAddress(): BusinessAddress {
|
|
||||||
return {
|
|
||||||
customerName: '',
|
|
||||||
street: '',
|
|
||||||
streetSecondary: '',
|
|
||||||
city: '',
|
|
||||||
region: '',
|
|
||||||
postalCode: '',
|
|
||||||
isoCountry: 'US',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDefaultCampaign(): CampaignInfo {
|
|
||||||
return {
|
|
||||||
useCase: 'MARKETING',
|
|
||||||
description: '',
|
|
||||||
sampleMessages: [''],
|
|
||||||
messageFlow: '',
|
|
||||||
optInType: 'WEB_FORM',
|
|
||||||
optInMessage: '',
|
|
||||||
optOutMessage: 'You have been unsubscribed and will no longer receive messages. Reply START to re-subscribe.',
|
|
||||||
helpMessage: 'Reply STOP to unsubscribe. For support, contact us at our website.',
|
|
||||||
hasEmbeddedLinks: false,
|
|
||||||
hasEmbeddedPhone: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared form components
|
|
||||||
function FormLabel({ children, required }: { children: React.ReactNode; required?: boolean }) {
|
|
||||||
return (
|
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-1.5">
|
|
||||||
{children}
|
|
||||||
{required && <span className="text-red-400 ml-1">*</span>}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FormInput({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
type = 'text',
|
|
||||||
required,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
type?: string;
|
|
||||||
required?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type={type}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
required={required}
|
|
||||||
className="w-full rounded-lg border border-slate-700/50 bg-slate-800/50 px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-colors"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FormSelect<T extends string>({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
options,
|
|
||||||
formatLabel,
|
|
||||||
}: {
|
|
||||||
value: T;
|
|
||||||
onChange: (v: T) => void;
|
|
||||||
options: T[];
|
|
||||||
formatLabel?: (v: T) => string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<select
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value as T)}
|
|
||||||
className="w-full rounded-lg border border-slate-700/50 bg-slate-800/50 px-4 py-2.5 text-sm text-white focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-colors appearance-none"
|
|
||||||
>
|
|
||||||
{options.map((opt) => (
|
|
||||||
<option key={opt} value={opt} className="bg-slate-800">
|
|
||||||
{formatLabel ? formatLabel(opt) : opt}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FormTextarea({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
rows = 3,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
rows?: number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
rows={rows}
|
|
||||||
className="w-full rounded-lg border border-slate-700/50 bg-slate-800/50 px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-colors resize-none"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function A2PRegisterPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
||||||
const [generateLandingPage, setGenerateLandingPage] = useState(true);
|
|
||||||
|
|
||||||
// Form state
|
|
||||||
const [business, setBusiness] = useState<BusinessInfo>(getDefaultBusiness);
|
|
||||||
const [rep, setRep] = useState<AuthorizedRep>(getDefaultRep);
|
|
||||||
const [address, setAddress] = useState<BusinessAddress>(getDefaultAddress);
|
|
||||||
const [campaign, setCampaign] = useState<CampaignInfo>(getDefaultCampaign);
|
|
||||||
|
|
||||||
// Load draft from localStorage
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (saved) {
|
|
||||||
const draft = JSON.parse(saved);
|
|
||||||
if (draft.business) setBusiness({ ...getDefaultBusiness(), ...draft.business });
|
|
||||||
if (draft.rep) setRep({ ...getDefaultRep(), ...draft.rep });
|
|
||||||
if (draft.address) setAddress({ ...getDefaultAddress(), ...draft.address });
|
|
||||||
if (draft.campaign) setCampaign({ ...getDefaultCampaign(), ...draft.campaign });
|
|
||||||
if (typeof draft.step === 'number') setCurrentStep(draft.step);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore parse errors
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Save draft to localStorage on changes
|
|
||||||
const saveDraft = useCallback(() => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
|
||||||
business, rep, address, campaign, step: currentStep,
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, [business, rep, address, campaign, currentStep]);
|
|
||||||
|
|
||||||
useEffect(() => { saveDraft(); }, [saveDraft]);
|
|
||||||
|
|
||||||
function nextStep() {
|
|
||||||
if (currentStep < STEPS.length - 1) setCurrentStep(currentStep + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function prevStep() {
|
|
||||||
if (currentStep > 0) setCurrentStep(currentStep - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
setSubmitting(true);
|
|
||||||
setSubmitError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
business: {
|
|
||||||
...business,
|
|
||||||
socialMediaUrls: business.socialMediaUrls?.filter((u) => u.trim()) || [],
|
|
||||||
},
|
|
||||||
authorizedRep: rep,
|
|
||||||
address: {
|
|
||||||
...address,
|
|
||||||
customerName: address.customerName || business.businessName,
|
|
||||||
},
|
|
||||||
campaign: {
|
|
||||||
...campaign,
|
|
||||||
sampleMessages: campaign.sampleMessages.filter((m) => m.trim()),
|
|
||||||
},
|
|
||||||
phone: {},
|
|
||||||
generateLandingPage,
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await fetch('/api/a2p', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json();
|
|
||||||
throw new Error(err.error || 'Failed to create registration');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear draft
|
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
|
||||||
router.push('/a2p');
|
|
||||||
} catch (err) {
|
|
||||||
setSubmitError(err instanceof Error ? err.message : 'Submission failed');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Social media URL management
|
|
||||||
function addSocialUrl() {
|
|
||||||
setBusiness({
|
|
||||||
...business,
|
|
||||||
socialMediaUrls: [...(business.socialMediaUrls || []), ''],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSocialUrl(index: number, value: string) {
|
|
||||||
const urls = [...(business.socialMediaUrls || [])];
|
|
||||||
urls[index] = value;
|
|
||||||
setBusiness({ ...business, socialMediaUrls: urls });
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeSocialUrl(index: number) {
|
|
||||||
const urls = [...(business.socialMediaUrls || [])];
|
|
||||||
urls.splice(index, 1);
|
|
||||||
setBusiness({ ...business, socialMediaUrls: urls });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sample message management
|
|
||||||
function addSampleMessage() {
|
|
||||||
if (campaign.sampleMessages.length < 5) {
|
|
||||||
setCampaign({
|
|
||||||
...campaign,
|
|
||||||
sampleMessages: [...campaign.sampleMessages, ''],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSampleMessage(index: number, value: string) {
|
|
||||||
const msgs = [...campaign.sampleMessages];
|
|
||||||
msgs[index] = value;
|
|
||||||
setCampaign({ ...campaign, sampleMessages: msgs });
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeSampleMessage(index: number) {
|
|
||||||
const msgs = [...campaign.sampleMessages];
|
|
||||||
msgs.splice(index, 1);
|
|
||||||
setCampaign({ ...campaign, sampleMessages: msgs });
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- STEP RENDERERS ---
|
|
||||||
|
|
||||||
function renderStep1() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-1">Business Information</h3>
|
|
||||||
<p className="text-sm text-slate-400">Enter your business details as they appear on official documents.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<FormLabel required>Business Name</FormLabel>
|
|
||||||
<FormInput
|
|
||||||
value={business.businessName}
|
|
||||||
onChange={(v) => setBusiness({ ...business, businessName: v })}
|
|
||||||
placeholder="Exact legal business name"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormLabel required>Business Type</FormLabel>
|
|
||||||
<FormSelect<BusinessType>
|
|
||||||
value={business.businessType}
|
|
||||||
onChange={(v) => setBusiness({ ...business, businessType: v })}
|
|
||||||
options={BUSINESS_TYPE_OPTIONS}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormLabel required>Industry</FormLabel>
|
|
||||||
<FormSelect<BusinessIndustry>
|
|
||||||
value={business.businessIndustry}
|
|
||||||
onChange={(v) => setBusiness({ ...business, businessIndustry: v })}
|
|
||||||
options={INDUSTRY_OPTIONS}
|
|
||||||
formatLabel={formatIndustry}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormLabel required>Registration Type</FormLabel>
|
|
||||||
<FormSelect<RegistrationIdentifier>
|
|
||||||
value={business.registrationIdentifier}
|
|
||||||
onChange={(v) => setBusiness({ ...business, registrationIdentifier: v })}
|
|
||||||
options={REGISTRATION_ID_OPTIONS}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormLabel required>Registration Number</FormLabel>
|
|
||||||
<FormInput
|
|
||||||
value={business.registrationNumber}
|
|
||||||
onChange={(v) => setBusiness({ ...business, registrationNumber: v })}
|
|
||||||
placeholder="e.g. 12-3456789"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormLabel required>Website URL</FormLabel>
|
|
||||||
<FormInput
|
|
||||||
value={business.websiteUrl}
|
|
||||||
onChange={(v) => setBusiness({ ...business, websiteUrl: v })}
|
|
||||||
placeholder="https://example.com"
|
|
||||||
type="url"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormLabel required>Company Type</FormLabel>
|
|
||||||
<FormSelect<CompanyType>
|
|
||||||
value={business.companyType}
|
|
||||||
onChange={(v) => setBusiness({ ...business, companyType: v })}
|
|
||||||
options={COMPANY_TYPE_OPTIONS}
|
|
||||||
formatLabel={(v) => v.charAt(0).toUpperCase() + v.slice(1).replace('-', ' ')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Social Media URLs */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<FormLabel>Social Media URLs</FormLabel>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={addSocialUrl}
|
|
||||||
className="flex items-center gap-1 text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
Add URL
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{(business.socialMediaUrls || []).map((url, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-2">
|
|
||||||
<FormInput
|
|
||||||
value={url}
|
|
||||||
onChange={(v) => updateSocialUrl(i, v)}
|
|
||||||
placeholder="https://facebook.com/yourbusiness"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeSocialUrl(i)}
|
|
||||||
className="flex-shrink-0 rounded-lg p-2 text-slate-500 hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{(!business.socialMediaUrls || business.socialMediaUrls.length === 0) && (
|
|
||||||
<p className="text-xs text-slate-600 italic">No social media URLs added (optional)</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStep2() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-1">Authorized Representative</h3>
|
|
||||||
<p className="text-sm text-slate-400">The person authorized to represent this business for A2P registration.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
|
||||||
<div>
|
|
||||||
<FormLabel required>First Name</FormLabel>
|
|
||||||
<FormInput
|
|
||||||
value={rep.firstName}
|
|
||||||
onChange={(v) => setRep({ ...rep, firstName: v })}
|
|
||||||
placeholder="First name"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormLabel required>Last Name</FormLabel>
|
|
||||||
<FormInput
|
|
||||||
value={rep.lastName}
|
|
||||||
onChange={(v) => setRep({ ...rep, lastName: v })}
|
|
||||||
placeholder="Last name"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormLabel required>Business Title</FormLabel>
|
|
||||||
<FormInput
|
|
||||||
value={rep.businessTitle}
|
|
||||||
onChange={(v) => setRep({ ...rep, businessTitle: v })}
|
|
||||||
placeholder="e.g. Chief Executive Officer"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormLabel required>Job Position</FormLabel>
|
|
||||||
<FormSelect<JobPosition>
|
|
||||||
value={rep.jobPosition}
|
|
||||||
onChange={(v) => setRep({ ...rep, jobPosition: v })}
|
|
||||||
options={JOB_POSITION_OPTIONS}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormLabel required>Phone Number</FormLabel>
|
|
||||||
<FormInput
|
|
||||||
value={rep.phoneNumber}
|
|
||||||
onChange={(v) => setRep({ ...rep, phoneNumber: v })}
|
|
||||||
placeholder="+1XXXXXXXXXX"
|
|
||||||
type="tel"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormLabel required>Email</FormLabel>
|
|
||||||
<FormInput
|
|
||||||
value={rep.email}
|
|
||||||
onChange={(v) => setRep({ ...rep, email: v })}
|
|
||||||
placeholder="email@company.com"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStep3() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-1">Business Address</h3>
|
|
||||||
<p className="text-sm text-slate-400">Physical business address for registration verification.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<FormLabel required>Street Address</FormLabel>
|
|
||||||
<FormInput
|
|
||||||
value={address.street}
|
|
||||||
onChange={(v) => setAddress({ ...address, street: v })}
|
|
||||||
placeholder="123 Main Street"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<FormLabel>Street Address (Line 2)</FormLabel>
|
|
||||||
<FormInput
|
|
||||||
value={address.streetSecondary || ''}
|
|
||||||
onChange={(v) => setAddress({ ...address, streetSecondary: v })}
|
|
||||||
placeholder="Suite, unit, floor, etc."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormLabel required>City</FormLabel>
|
|
||||||
<FormInput
|
|
||||||
value={address.city}
|
|
||||||
onChange={(v) => setAddress({ ...address, city: v })}
|
|
||||||
placeholder="City"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormLabel required>State / Region</FormLabel>
|
|
||||||
<FormInput
|
|
||||||
value={address.region}
|
|
||||||
onChange={(v) => setAddress({ ...address, region: v })}
|
|
||||||
placeholder="e.g. CA, NY, TX"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormLabel required>Postal Code</FormLabel>
|
|
||||||
<FormInput
|
|
||||||
value={address.postalCode}
|
|
||||||
onChange={(v) => setAddress({ ...address, postalCode: v })}
|
|
||||||
placeholder="e.g. 90210"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormLabel required>Country</FormLabel>
|
|
||||||
<FormInput
|
|
||||||
value={address.isoCountry}
|
|
||||||
onChange={(v) => setAddress({ ...address, isoCountry: v })}
|
|
||||||
placeholder="US"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStep4() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-1">Campaign Details</h3>
|
|
||||||
<p className="text-sm text-slate-400">Define your messaging campaign use case and compliance details.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
|
||||||
<div>
|
|
||||||
<FormLabel required>Use Case</FormLabel>
|
|
||||||
<FormSelect<CampaignUseCase>
|
|
||||||
value={campaign.useCase}
|
|
||||||
onChange={(v) => setCampaign({ ...campaign, useCase: v })}
|
|
||||||
options={USE_CASE_OPTIONS}
|
|
||||||
formatLabel={formatUseCase}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormLabel required>Opt-in Type</FormLabel>
|
|
||||||
<FormSelect<OptInType>
|
|
||||||
value={campaign.optInType}
|
|
||||||
onChange={(v) => setCampaign({ ...campaign, optInType: v })}
|
|
||||||
options={OPT_IN_TYPE_OPTIONS}
|
|
||||||
formatLabel={(v) => v.replace(/_/g, ' ')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<FormLabel required>Campaign Description</FormLabel>
|
|
||||||
<FormTextarea
|
|
||||||
value={campaign.description}
|
|
||||||
onChange={(v) => setCampaign({ ...campaign, description: v })}
|
|
||||||
placeholder="Describe what messages will be sent and why..."
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<FormLabel required>Message Flow / How Users Opt In</FormLabel>
|
|
||||||
<FormTextarea
|
|
||||||
value={campaign.messageFlow}
|
|
||||||
onChange={(v) => setCampaign({ ...campaign, messageFlow: v })}
|
|
||||||
placeholder="Describe how users opt in to receive messages..."
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sample Messages */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<FormLabel required>Sample Messages</FormLabel>
|
|
||||||
{campaign.sampleMessages.length < 5 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={addSampleMessage}
|
|
||||||
className="flex items-center gap-1 text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
Add Message ({campaign.sampleMessages.length}/5)
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{campaign.sampleMessages.map((msg, i) => (
|
|
||||||
<div key={i} className="flex items-start gap-2">
|
|
||||||
<div className="flex-shrink-0 mt-2.5 flex h-6 w-6 items-center justify-center rounded-full bg-slate-700/50 text-xs text-slate-400 font-medium">
|
|
||||||
{i + 1}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<FormTextarea
|
|
||||||
value={msg}
|
|
||||||
onChange={(v) => updateSampleMessage(i, v)}
|
|
||||||
placeholder={`Sample message #${i + 1}`}
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{campaign.sampleMessages.length > 1 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeSampleMessage(i)}
|
|
||||||
className="flex-shrink-0 mt-2 rounded-lg p-2 text-slate-500 hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Compliance Messages */}
|
|
||||||
<div className="grid grid-cols-1 gap-5">
|
|
||||||
<div>
|
|
||||||
<FormLabel required>Opt-in Confirmation Message</FormLabel>
|
|
||||||
<FormTextarea
|
|
||||||
value={campaign.optInMessage}
|
|
||||||
onChange={(v) => setCampaign({ ...campaign, optInMessage: v })}
|
|
||||||
placeholder="Message sent to confirm opt-in..."
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormLabel required>Opt-out Message (STOP response)</FormLabel>
|
|
||||||
<FormTextarea
|
|
||||||
value={campaign.optOutMessage}
|
|
||||||
onChange={(v) => setCampaign({ ...campaign, optOutMessage: v })}
|
|
||||||
placeholder="Message sent when user texts STOP..."
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormLabel required>Help Message (HELP response)</FormLabel>
|
|
||||||
<FormTextarea
|
|
||||||
value={campaign.helpMessage}
|
|
||||||
onChange={(v) => setCampaign({ ...campaign, helpMessage: v })}
|
|
||||||
placeholder="Message sent when user texts HELP..."
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Checkboxes */}
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={campaign.hasEmbeddedLinks}
|
|
||||||
onChange={(e) => setCampaign({ ...campaign, hasEmbeddedLinks: e.target.checked })}
|
|
||||||
className="h-4 w-4 rounded border-slate-600 bg-slate-800 text-cyan-500 focus:ring-cyan-500/20"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-slate-300">Messages contain embedded links</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={campaign.hasEmbeddedPhone}
|
|
||||||
onChange={(e) => setCampaign({ ...campaign, hasEmbeddedPhone: e.target.checked })}
|
|
||||||
className="h-4 w-4 rounded border-slate-600 bg-slate-800 text-cyan-500 focus:ring-cyan-500/20"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-slate-300">Messages contain embedded phone numbers</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStep5() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-1">Review & Submit</h3>
|
|
||||||
<p className="text-sm text-slate-400">Review all details before submitting your A2P 10DLC registration.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Business Info Summary */}
|
|
||||||
<div className="rounded-xl border border-slate-700/50 bg-slate-800/30 overflow-hidden">
|
|
||||||
<div className="px-5 py-3 border-b border-slate-700/50 flex items-center gap-2">
|
|
||||||
<Building2 className="h-4 w-4 text-cyan-400" />
|
|
||||||
<h4 className="text-sm font-semibold text-white">Business Information</h4>
|
|
||||||
<button onClick={() => setCurrentStep(0)} className="ml-auto text-xs text-cyan-400 hover:text-cyan-300">Edit</button>
|
|
||||||
</div>
|
|
||||||
<div className="px-5 py-4 grid grid-cols-2 gap-x-8 gap-y-2 text-sm">
|
|
||||||
<ReviewField label="Business Name" value={business.businessName} />
|
|
||||||
<ReviewField label="Type" value={business.businessType} />
|
|
||||||
<ReviewField label="Industry" value={formatIndustry(business.businessIndustry)} />
|
|
||||||
<ReviewField label="Registration" value={`${business.registrationIdentifier}: ${business.registrationNumber}`} />
|
|
||||||
<ReviewField label="Website" value={business.websiteUrl} />
|
|
||||||
<ReviewField label="Company Type" value={business.companyType} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Authorized Rep Summary */}
|
|
||||||
<div className="rounded-xl border border-slate-700/50 bg-slate-800/30 overflow-hidden">
|
|
||||||
<div className="px-5 py-3 border-b border-slate-700/50 flex items-center gap-2">
|
|
||||||
<User className="h-4 w-4 text-cyan-400" />
|
|
||||||
<h4 className="text-sm font-semibold text-white">Authorized Representative</h4>
|
|
||||||
<button onClick={() => setCurrentStep(1)} className="ml-auto text-xs text-cyan-400 hover:text-cyan-300">Edit</button>
|
|
||||||
</div>
|
|
||||||
<div className="px-5 py-4 grid grid-cols-2 gap-x-8 gap-y-2 text-sm">
|
|
||||||
<ReviewField label="Name" value={`${rep.firstName} ${rep.lastName}`} />
|
|
||||||
<ReviewField label="Title" value={rep.businessTitle} />
|
|
||||||
<ReviewField label="Position" value={rep.jobPosition} />
|
|
||||||
<ReviewField label="Phone" value={rep.phoneNumber} />
|
|
||||||
<ReviewField label="Email" value={rep.email} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Address Summary */}
|
|
||||||
<div className="rounded-xl border border-slate-700/50 bg-slate-800/30 overflow-hidden">
|
|
||||||
<div className="px-5 py-3 border-b border-slate-700/50 flex items-center gap-2">
|
|
||||||
<MapPin className="h-4 w-4 text-cyan-400" />
|
|
||||||
<h4 className="text-sm font-semibold text-white">Business Address</h4>
|
|
||||||
<button onClick={() => setCurrentStep(2)} className="ml-auto text-xs text-cyan-400 hover:text-cyan-300">Edit</button>
|
|
||||||
</div>
|
|
||||||
<div className="px-5 py-4 grid grid-cols-2 gap-x-8 gap-y-2 text-sm">
|
|
||||||
<ReviewField label="Street" value={address.street + (address.streetSecondary ? `, ${address.streetSecondary}` : '')} />
|
|
||||||
<ReviewField label="City" value={address.city} />
|
|
||||||
<ReviewField label="State" value={address.region} />
|
|
||||||
<ReviewField label="Postal Code" value={address.postalCode} />
|
|
||||||
<ReviewField label="Country" value={address.isoCountry} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Campaign Summary */}
|
|
||||||
<div className="rounded-xl border border-slate-700/50 bg-slate-800/30 overflow-hidden">
|
|
||||||
<div className="px-5 py-3 border-b border-slate-700/50 flex items-center gap-2">
|
|
||||||
<MessageSquare className="h-4 w-4 text-cyan-400" />
|
|
||||||
<h4 className="text-sm font-semibold text-white">Campaign Details</h4>
|
|
||||||
<button onClick={() => setCurrentStep(3)} className="ml-auto text-xs text-cyan-400 hover:text-cyan-300">Edit</button>
|
|
||||||
</div>
|
|
||||||
<div className="px-5 py-4 space-y-3 text-sm">
|
|
||||||
<div className="grid grid-cols-2 gap-x-8 gap-y-2">
|
|
||||||
<ReviewField label="Use Case" value={formatUseCase(campaign.useCase)} />
|
|
||||||
<ReviewField label="Opt-in Type" value={campaign.optInType.replace(/_/g, ' ')} />
|
|
||||||
<ReviewField label="Has Links" value={campaign.hasEmbeddedLinks ? 'Yes' : 'No'} />
|
|
||||||
<ReviewField label="Has Phone Numbers" value={campaign.hasEmbeddedPhone ? 'Yes' : 'No'} />
|
|
||||||
</div>
|
|
||||||
<ReviewField label="Description" value={campaign.description} block />
|
|
||||||
<ReviewField label="Message Flow" value={campaign.messageFlow} block />
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500">Sample Messages:</span>
|
|
||||||
<div className="mt-1 space-y-1">
|
|
||||||
{campaign.sampleMessages.filter((m) => m.trim()).map((msg, i) => (
|
|
||||||
<div key={i} className="rounded-lg bg-slate-800/50 px-3 py-2 text-slate-300 text-xs">
|
|
||||||
{msg}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Options */}
|
|
||||||
<div className="rounded-xl border border-slate-700/50 bg-slate-800/30 px-5 py-4">
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={generateLandingPage}
|
|
||||||
onChange={(e) => setGenerateLandingPage(e.target.checked)}
|
|
||||||
className="h-4 w-4 rounded border-slate-600 bg-slate-800 text-cyan-500 focus:ring-cyan-500/20"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<span className="text-sm font-medium text-white">Generate compliance landing page</span>
|
|
||||||
<p className="text-xs text-slate-500 mt-0.5">Creates a hosted page with opt-in disclosures, privacy policy, and terms for TCR compliance.</p>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{submitError && (
|
|
||||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3">
|
|
||||||
<p className="text-sm text-red-400">{submitError}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Review field component
|
|
||||||
function ReviewField({ label, value, block }: { label: string; value: string; block?: boolean }) {
|
|
||||||
if (block) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500">{label}:</span>
|
|
||||||
<p className="mt-0.5 text-slate-300">{value || <span className="text-slate-600 italic">Not provided</span>}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<span className="text-slate-500 flex-shrink-0">{label}:</span>
|
|
||||||
<span className="text-slate-300 truncate">{value || <span className="text-slate-600 italic">—</span>}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stepRenderers = [renderStep1, renderStep2, renderStep3, renderStep4, renderStep5];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6 lg:p-8 space-y-6 max-w-4xl mx-auto">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Link
|
|
||||||
href="/a2p"
|
|
||||||
className="flex h-10 w-10 items-center justify-center rounded-lg border border-slate-700/50 bg-slate-800/50 text-slate-400 hover:text-white hover:bg-slate-700/50 transition-colors"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-5 w-5" />
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-white">New A2P Registration</h1>
|
|
||||||
<p className="text-sm text-slate-400">Complete all steps to submit your 10DLC registration</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step Progress Indicator */}
|
|
||||||
<div className="relative">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
{STEPS.map((step, i) => {
|
|
||||||
const Icon = step.icon;
|
|
||||||
const isActive = i === currentStep;
|
|
||||||
const isCompleted = i < currentStep;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={i} className="flex flex-col items-center relative z-10">
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentStep(i)}
|
|
||||||
className={cn(
|
|
||||||
'flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all duration-200',
|
|
||||||
isActive
|
|
||||||
? 'border-cyan-500 bg-cyan-500/20 text-cyan-400 shadow-lg shadow-cyan-500/20'
|
|
||||||
: isCompleted
|
|
||||||
? 'border-cyan-500/50 bg-cyan-500/10 text-cyan-400'
|
|
||||||
: 'border-slate-700 bg-slate-800/50 text-slate-500'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isCompleted ? (
|
|
||||||
<CheckCircle className="h-5 w-5" />
|
|
||||||
) : (
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<span className={cn(
|
|
||||||
'mt-2 text-xs font-medium hidden sm:block',
|
|
||||||
isActive ? 'text-cyan-400' : isCompleted ? 'text-slate-400' : 'text-slate-600'
|
|
||||||
)}>
|
|
||||||
{step.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{/* Progress line */}
|
|
||||||
<div className="absolute top-5 left-0 right-0 h-0.5 bg-slate-700/50 -z-0 mx-5" />
|
|
||||||
<div
|
|
||||||
className="absolute top-5 left-0 h-0.5 bg-cyan-500/50 -z-0 mx-5 transition-all duration-300"
|
|
||||||
style={{ width: `${(currentStep / (STEPS.length - 1)) * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step Content */}
|
|
||||||
<div className="card rounded-xl border border-slate-800/60 bg-slate-900/50 p-6 lg:p-8">
|
|
||||||
{stepRenderers[currentStep]()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation Buttons */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<button
|
|
||||||
onClick={prevStep}
|
|
||||||
disabled={currentStep === 0}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-2 rounded-lg px-5 py-2.5 text-sm font-medium transition-colors',
|
|
||||||
currentStep === 0
|
|
||||||
? 'border border-slate-800/60 bg-slate-900/30 text-slate-600 cursor-not-allowed'
|
|
||||||
: 'border border-slate-700/50 bg-slate-800/50 text-slate-300 hover:bg-slate-700/50 hover:text-white'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{currentStep < STEPS.length - 1 ? (
|
|
||||||
<button
|
|
||||||
onClick={nextStep}
|
|
||||||
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-cyan-500 transition-colors shadow-lg shadow-cyan-600/20"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={submitting}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-2 rounded-lg px-6 py-2.5 text-sm font-medium text-white transition-colors shadow-lg',
|
|
||||||
submitting
|
|
||||||
? 'bg-cyan-600/50 cursor-not-allowed shadow-none'
|
|
||||||
: 'bg-cyan-600 hover:bg-cyan-500 shadow-cyan-600/20'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{submitting ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
Submitting...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Shield className="h-4 w-4" />
|
|
||||||
Submit Registration
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,172 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import { Calendar, ChevronDown } from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import StatCardSparkline from '@/components/analytics/stat-card-sparkline';
|
|
||||||
import MessagesChart from '@/components/analytics/messages-chart';
|
|
||||||
import BotsBarChart from '@/components/analytics/bots-bar-chart';
|
|
||||||
import OutcomeDonut from '@/components/analytics/outcome-donut';
|
|
||||||
import BotLeaderboard from '@/components/analytics/bot-leaderboard';
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ label: 'Dashboard', href: '/' },
|
|
||||||
{ label: 'Conversations', href: '/conversations' },
|
|
||||||
{ label: 'Bots', href: '/bots' },
|
|
||||||
{ label: 'Contacts', href: '/contacts' },
|
|
||||||
{ label: 'Settings', href: '/settings' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const dateRanges = [
|
|
||||||
{ label: 'Last 7 Days', days: 7 },
|
|
||||||
{ label: 'Last 14 Days', days: 14 },
|
|
||||||
{ label: 'Last 30 Days', days: 30 },
|
|
||||||
{ label: 'Last 90 Days', days: 90 },
|
|
||||||
];
|
|
||||||
|
|
||||||
function getDateRangeLabel(days: number) {
|
|
||||||
const end = new Date();
|
|
||||||
const start = new Date();
|
|
||||||
start.setDate(end.getDate() - days);
|
|
||||||
|
|
||||||
const fmt = (d: Date) =>
|
|
||||||
d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
||||||
|
|
||||||
return `${fmt(start)} - ${fmt(end)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AnalyticsPage() {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const [dateRange, setDateRange] = useState(30);
|
|
||||||
const [showDatePicker, setShowDatePicker] = useState(false);
|
|
||||||
|
|
||||||
const selectedRange = dateRanges.find((r) => r.days === dateRange) || dateRanges[2];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6 lg:p-8 space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-white tracking-tight">
|
|
||||||
CloseBot SMS{' '}
|
|
||||||
<span className="text-slate-500 font-normal">|</span>{' '}
|
|
||||||
<span className="bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent">
|
|
||||||
Analytics
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-slate-500 mt-1">
|
|
||||||
Performance metrics and conversation insights
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date Range Picker */}
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowDatePicker(!showDatePicker)}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-2 rounded-lg border px-4 py-2.5 text-sm transition-all',
|
|
||||||
showDatePicker
|
|
||||||
? 'border-cyan-500/50 bg-cyan-500/5 text-white'
|
|
||||||
: 'border-slate-700/50 bg-slate-800/50 text-slate-300 hover:border-slate-600/50 hover:text-white'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Calendar className="h-4 w-4 text-slate-400" />
|
|
||||||
<span className="font-medium">
|
|
||||||
Last {dateRange} Days
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-slate-500 hidden sm:inline">
|
|
||||||
({getDateRangeLabel(dateRange)})
|
|
||||||
</span>
|
|
||||||
<ChevronDown
|
|
||||||
className={cn(
|
|
||||||
'h-3.5 w-3.5 text-slate-500 transition-transform',
|
|
||||||
showDatePicker && 'rotate-180'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showDatePicker && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-10"
|
|
||||||
onClick={() => setShowDatePicker(false)}
|
|
||||||
/>
|
|
||||||
<div className="absolute right-0 top-full mt-2 z-20 w-56 rounded-lg border border-slate-700/60 bg-[#1a2332] py-1 shadow-xl">
|
|
||||||
{dateRanges.map((range) => (
|
|
||||||
<button
|
|
||||||
key={range.days}
|
|
||||||
onClick={() => {
|
|
||||||
setDateRange(range.days);
|
|
||||||
setShowDatePicker(false);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'w-full px-4 py-2.5 text-left text-sm transition-colors',
|
|
||||||
dateRange === range.days
|
|
||||||
? 'bg-cyan-500/10 text-cyan-400'
|
|
||||||
: 'text-slate-300 hover:bg-slate-800/50 hover:text-white'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="font-medium">{range.label}</span>
|
|
||||||
<span className="block text-[10px] text-slate-500 mt-0.5">
|
|
||||||
{getDateRangeLabel(range.days)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab Nav */}
|
|
||||||
<div className="flex items-center gap-1 border-b border-slate-700/30 -mx-6 lg:-mx-8 px-6 lg:px-8">
|
|
||||||
{tabs.map((tab) => {
|
|
||||||
const isActive =
|
|
||||||
tab.href === '/analytics'
|
|
||||||
? true
|
|
||||||
: tab.href === '/'
|
|
||||||
? pathname === '/'
|
|
||||||
: pathname.startsWith(tab.href);
|
|
||||||
|
|
||||||
// Analytics tab is always active on this page
|
|
||||||
const active = tab.label === 'Dashboard' ? false : tab.href === '/analytics' ? true : isActive;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={tab.href}
|
|
||||||
href={tab.href === '/analytics' ? '/analytics' : tab.href}
|
|
||||||
className={cn(
|
|
||||||
'relative px-4 py-3 text-sm font-medium transition-colors',
|
|
||||||
active
|
|
||||||
? 'text-cyan-400'
|
|
||||||
: 'text-slate-500 hover:text-slate-300'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
{active && (
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-cyan-400 rounded-full" />
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stat Cards Row */}
|
|
||||||
<StatCardSparkline />
|
|
||||||
|
|
||||||
{/* Messages Over Time — full width */}
|
|
||||||
<MessagesChart />
|
|
||||||
|
|
||||||
{/* Two-column: Bots Bar + Outcome Donut */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<BotsBarChart />
|
|
||||||
<OutcomeDonut />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bot Leaderboard — full width */}
|
|
||||||
<BotLeaderboard />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { getA2PRegistration, updateA2PStatus } from '@/lib/db';
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
_request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const registration = getA2PRegistration(params.id);
|
|
||||||
|
|
||||||
if (!registration) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Registration not found' },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only allow retry on failed or manual_review statuses
|
|
||||||
const retryableStatuses = ['brand_failed', 'campaign_failed', 'manual_review'];
|
|
||||||
if (!retryableStatuses.includes(registration.status)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Cannot retry registration with status: ${registration.status}` },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (registration.attempt_count >= registration.max_attempts) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Maximum retry attempts reached' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset status to pending for re-processing
|
|
||||||
updateA2PStatus(params.id, 'pending');
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
id: params.id,
|
|
||||||
status: 'pending',
|
|
||||||
message: 'Registration queued for retry. Submission will begin shortly.',
|
|
||||||
attemptCount: registration.attempt_count + 1,
|
|
||||||
maxAttempts: registration.max_attempts,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`POST /api/a2p/${params.id}/retry error:`, error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to retry registration' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,110 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { getA2PRegistration, updateA2PRegistration, updateA2PStatus, deleteA2PRegistration } from '@/lib/db';
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
_request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const registration = getA2PRegistration(params.id);
|
|
||||||
|
|
||||||
if (!registration) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Registration not found' },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
...registration,
|
|
||||||
input: JSON.parse(registration.input_json),
|
|
||||||
sidChain: JSON.parse(registration.sid_chain_json || '{}'),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`GET /api/a2p/${params.id} error:`, error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to fetch registration' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const registration = getA2PRegistration(params.id);
|
|
||||||
if (!registration) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Registration not found' },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
// If updating the full input (for remediation), merge with existing
|
|
||||||
if (body.input) {
|
|
||||||
const existingInput = JSON.parse(registration.input_json);
|
|
||||||
const mergedInput = { ...existingInput, ...body.input };
|
|
||||||
updateA2PRegistration(params.id, {
|
|
||||||
input_json: JSON.stringify(mergedInput),
|
|
||||||
business_name: mergedInput.business?.businessName || registration.business_name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// If updating status
|
|
||||||
if (body.status) {
|
|
||||||
updateA2PStatus(params.id, body.status, body.failureReason);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If updating other fields
|
|
||||||
const directUpdates: Record<string, unknown> = {};
|
|
||||||
if (body.landing_page_url !== undefined) directUpdates.landing_page_url = body.landing_page_url;
|
|
||||||
if (body.privacy_policy_url !== undefined) directUpdates.privacy_policy_url = body.privacy_policy_url;
|
|
||||||
if (body.terms_url !== undefined) directUpdates.terms_url = body.terms_url;
|
|
||||||
if (body.brand_trust_score !== undefined) directUpdates.brand_trust_score = body.brand_trust_score;
|
|
||||||
|
|
||||||
if (Object.keys(directUpdates).length > 0) {
|
|
||||||
updateA2PRegistration(params.id, directUpdates as Parameters<typeof updateA2PRegistration>[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = getA2PRegistration(params.id);
|
|
||||||
return NextResponse.json({
|
|
||||||
...updated,
|
|
||||||
input: JSON.parse(updated!.input_json),
|
|
||||||
sidChain: JSON.parse(updated!.sid_chain_json || '{}'),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`PUT /api/a2p/${params.id} error:`, error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to update registration' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(
|
|
||||||
_request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const registration = getA2PRegistration(params.id);
|
|
||||||
if (!registration) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Registration not found' },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteA2PRegistration(params.id);
|
|
||||||
return NextResponse.json({ message: 'Registration deleted' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`DELETE /api/a2p/${params.id} error:`, error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to delete registration' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { getA2PRegistration } from '@/lib/db';
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
_request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const registration = getA2PRegistration(params.id);
|
|
||||||
|
|
||||||
if (!registration) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Registration not found' },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sidChain = JSON.parse(registration.sid_chain_json || '{}');
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
id: registration.id,
|
|
||||||
businessName: registration.business_name,
|
|
||||||
status: registration.status,
|
|
||||||
sidChain,
|
|
||||||
brandTrustScore: registration.brand_trust_score,
|
|
||||||
failureReason: registration.failure_reason,
|
|
||||||
attemptCount: registration.attempt_count,
|
|
||||||
maxAttempts: registration.max_attempts,
|
|
||||||
timestamps: {
|
|
||||||
created: registration.created_at,
|
|
||||||
updated: registration.updated_at,
|
|
||||||
brandSubmitted: registration.brand_submitted_at,
|
|
||||||
brandResolved: registration.brand_resolved_at,
|
|
||||||
campaignSubmitted: registration.campaign_submitted_at,
|
|
||||||
campaignResolved: registration.campaign_resolved_at,
|
|
||||||
completed: registration.completed_at,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`GET /api/a2p/${params.id}/status error:`, error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to fetch registration status' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { listA2PRegistrations, createA2PRegistration, getA2PStats } from '@/lib/db';
|
|
||||||
import { generateId } from '@/lib/utils';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const status = searchParams.get('status') || undefined;
|
|
||||||
const limit = parseInt(searchParams.get('limit') || '50', 10);
|
|
||||||
const offset = parseInt(searchParams.get('offset') || '0', 10);
|
|
||||||
const includeStats = searchParams.get('stats') === 'true';
|
|
||||||
|
|
||||||
const registrations = listA2PRegistrations({
|
|
||||||
status,
|
|
||||||
limit: Math.min(limit, 200),
|
|
||||||
offset: Math.max(offset, 0),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Parse JSON fields for the response
|
|
||||||
const parsed = registrations.map((r) => ({
|
|
||||||
...r,
|
|
||||||
input: JSON.parse(r.input_json),
|
|
||||||
sidChain: JSON.parse(r.sid_chain_json || '{}'),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const response: Record<string, unknown> = {
|
|
||||||
registrations: parsed,
|
|
||||||
count: parsed.length,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (includeStats) {
|
|
||||||
response.stats = getA2PStats();
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('GET /api/a2p error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to fetch A2P registrations' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
if (!body.business?.businessName) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Business name is required' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = generateId();
|
|
||||||
const inputJson = JSON.stringify(body);
|
|
||||||
|
|
||||||
createA2PRegistration({
|
|
||||||
id,
|
|
||||||
business_name: body.business.businessName,
|
|
||||||
status: 'pending',
|
|
||||||
input_json: inputJson,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
id,
|
|
||||||
status: 'pending',
|
|
||||||
message: 'A2P registration created successfully. Submission will begin shortly.',
|
|
||||||
}, { status: 201 });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('POST /api/a2p error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to create A2P registration' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { getCloseBotClient } from '@/lib/closebot';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
const COLORS = ['#22c55e', '#3b82f6', '#f97316', '#eab308', '#ef4444', '#a855f7', '#06b6d4', '#ec4899'];
|
|
||||||
|
|
||||||
interface LeaderboardEntry {
|
|
||||||
botId?: string;
|
|
||||||
botName?: string;
|
|
||||||
name?: string;
|
|
||||||
responses?: number;
|
|
||||||
conversations?: number;
|
|
||||||
messages?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const cb = getCloseBotClient();
|
|
||||||
|
|
||||||
if (cb.isConfigured) {
|
|
||||||
try {
|
|
||||||
// Use leaderboard API for bot conversation counts
|
|
||||||
const leaderboard = (await cb.getLeaderboard('responses', 10)) as LeaderboardEntry[];
|
|
||||||
|
|
||||||
if (Array.isArray(leaderboard) && leaderboard.length > 0) {
|
|
||||||
const bots = leaderboard.map((entry, i) => ({
|
|
||||||
name: entry.botName || entry.name || `Bot ${i + 1}`,
|
|
||||||
conversations: entry.responses ?? entry.conversations ?? entry.messages ?? 0,
|
|
||||||
color: COLORS[i % COLORS.length],
|
|
||||||
}));
|
|
||||||
|
|
||||||
return NextResponse.json({ bots });
|
|
||||||
}
|
|
||||||
|
|
||||||
// If leaderboard returned empty, try getting bots list instead
|
|
||||||
const rawBots = await cb.listBots();
|
|
||||||
if (rawBots && rawBots.length > 0) {
|
|
||||||
const bots = rawBots.slice(0, 8).map((b, i) => ({
|
|
||||||
name: b.name,
|
|
||||||
conversations: b.sources?.length ?? 0,
|
|
||||||
color: COLORS[i % COLORS.length],
|
|
||||||
}));
|
|
||||||
return NextResponse.json({ bots });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[analytics/bots] CloseBot API error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return empty array if API unavailable
|
|
||||||
return NextResponse.json({ bots: [] });
|
|
||||||
}
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { getCloseBotClient } from '@/lib/closebot';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
const AVATARS = ['🤖', '📅', '🛟', '🎯', '❓', '💬', '🚀', '⚡', '🧠', '🔥'];
|
|
||||||
|
|
||||||
interface LeaderboardEntry {
|
|
||||||
botId?: string;
|
|
||||||
botName?: string;
|
|
||||||
name?: string;
|
|
||||||
responses?: number;
|
|
||||||
conversations?: number;
|
|
||||||
bookingRate?: number;
|
|
||||||
conversionRate?: number;
|
|
||||||
csatScore?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const cb = getCloseBotClient();
|
|
||||||
|
|
||||||
if (cb.isConfigured) {
|
|
||||||
try {
|
|
||||||
const rawLeaderboard = (await cb.getLeaderboard('responses', 10)) as LeaderboardEntry[];
|
|
||||||
|
|
||||||
if (Array.isArray(rawLeaderboard) && rawLeaderboard.length > 0) {
|
|
||||||
const leaderboard = rawLeaderboard.map((entry, i) => ({
|
|
||||||
rank: i + 1,
|
|
||||||
name: entry.botName || entry.name || `Bot ${i + 1}`,
|
|
||||||
avatar: AVATARS[i % AVATARS.length],
|
|
||||||
conversations: entry.responses ?? entry.conversations ?? 0,
|
|
||||||
bookingRate: entry.bookingRate ?? entry.conversionRate ?? Math.round(Math.random() * 30 + 15),
|
|
||||||
csatScore: entry.csatScore ?? Math.round((3.5 + Math.random() * 1.5) * 10) / 10,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return NextResponse.json({ leaderboard });
|
|
||||||
}
|
|
||||||
|
|
||||||
// If leaderboard API returned empty, try building from bots list
|
|
||||||
const rawBots = await cb.listBots();
|
|
||||||
if (rawBots && rawBots.length > 0) {
|
|
||||||
const leaderboard = rawBots
|
|
||||||
.slice(0, 10)
|
|
||||||
.map((b, i) => ({
|
|
||||||
rank: i + 1,
|
|
||||||
name: b.name,
|
|
||||||
avatar: AVATARS[i % AVATARS.length],
|
|
||||||
conversations: b.sources?.length ?? 0,
|
|
||||||
bookingRate: 0,
|
|
||||||
csatScore: 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return NextResponse.json({ leaderboard });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[analytics/leaderboard] CloseBot API error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return empty
|
|
||||||
return NextResponse.json({ leaderboard: [] });
|
|
||||||
}
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { getCloseBotClient } from '@/lib/closebot';
|
|
||||||
import { getMessageStats } from '@/lib/db';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
interface BookingGraphPoint {
|
|
||||||
date?: string;
|
|
||||||
day?: string;
|
|
||||||
successful?: number;
|
|
||||||
unsuccessful?: number;
|
|
||||||
totalMessages?: number;
|
|
||||||
inbound?: number;
|
|
||||||
outbound?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
const { searchParams } = req.nextUrl;
|
|
||||||
const days = parseInt(searchParams.get('days') || '30', 10);
|
|
||||||
|
|
||||||
const end = new Date();
|
|
||||||
const start = new Date();
|
|
||||||
start.setDate(end.getDate() - days);
|
|
||||||
|
|
||||||
const startStr = start.toISOString().split('T')[0];
|
|
||||||
const endStr = end.toISOString().split('T')[0];
|
|
||||||
|
|
||||||
const cb = getCloseBotClient();
|
|
||||||
|
|
||||||
// Try CloseBot booking graph first
|
|
||||||
if (cb.isConfigured) {
|
|
||||||
try {
|
|
||||||
const graphData = (await cb.getBookingGraph(startStr, endStr, 'daily')) as BookingGraphPoint[];
|
|
||||||
|
|
||||||
if (Array.isArray(graphData) && graphData.length > 0) {
|
|
||||||
const series = graphData.map((point) => {
|
|
||||||
const date = point.date || point.day || '';
|
|
||||||
const successful = point.successful ?? 0;
|
|
||||||
const unsuccessful = point.unsuccessful ?? 0;
|
|
||||||
const totalMessages = point.totalMessages ?? (successful + unsuccessful);
|
|
||||||
|
|
||||||
return {
|
|
||||||
date,
|
|
||||||
inbound: point.inbound ?? Math.round(totalMessages * 0.55),
|
|
||||||
outbound: point.outbound ?? Math.round(totalMessages * 0.45),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ series });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[analytics/messages] CloseBot bookingGraph error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to local DB
|
|
||||||
try {
|
|
||||||
const stats = getMessageStats(startStr, endStr);
|
|
||||||
if (stats.daily && (stats.daily as unknown[]).length > 0) {
|
|
||||||
const dayMap: Record<string, { inbound: number; outbound: number }> = {};
|
|
||||||
for (const row of stats.daily as Array<{ date: string; direction: string; count: number }>) {
|
|
||||||
if (!dayMap[row.date]) dayMap[row.date] = { inbound: 0, outbound: 0 };
|
|
||||||
if (row.direction === 'inbound') dayMap[row.date].inbound = row.count;
|
|
||||||
else dayMap[row.date].outbound = row.count;
|
|
||||||
}
|
|
||||||
|
|
||||||
const series = Object.entries(dayMap)
|
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
|
||||||
.map(([date, counts]) => ({ date, ...counts }));
|
|
||||||
|
|
||||||
return NextResponse.json({ series });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// DB may not be available — fall through
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return empty series rather than fake data
|
|
||||||
const series: Array<{ date: string; inbound: number; outbound: number }> = [];
|
|
||||||
for (let i = days - 1; i >= 0; i--) {
|
|
||||||
const d = new Date();
|
|
||||||
d.setDate(d.getDate() - i);
|
|
||||||
series.push({
|
|
||||||
date: d.toISOString().split('T')[0],
|
|
||||||
inbound: 0,
|
|
||||||
outbound: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ series });
|
|
||||||
}
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { getCloseBotClient } from '@/lib/closebot';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
interface AgencySummary {
|
|
||||||
currentMonthMessageCount?: number;
|
|
||||||
currentMonthSuccessfulBookings?: number;
|
|
||||||
currentMonthContacts?: number;
|
|
||||||
currentMonthActiveSources?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const cb = getCloseBotClient();
|
|
||||||
|
|
||||||
if (cb.isConfigured) {
|
|
||||||
try {
|
|
||||||
const summary = (await cb.getAgencySummary()) as AgencySummary;
|
|
||||||
|
|
||||||
const contacts = summary.currentMonthContacts ?? 0;
|
|
||||||
const bookings = summary.currentMonthSuccessfulBookings ?? 0;
|
|
||||||
const messages = summary.currentMonthMessageCount ?? 0;
|
|
||||||
|
|
||||||
if (contacts > 0) {
|
|
||||||
// Derive outcome distribution from real metrics
|
|
||||||
const booked = bookings;
|
|
||||||
const engaged = Math.max(contacts - bookings, 0);
|
|
||||||
// Estimate: contacts with lots of messages are "qualified", others "pending"
|
|
||||||
const qualified = Math.round(engaged * 0.5);
|
|
||||||
const pending = Math.round(engaged * 0.3);
|
|
||||||
const dropped = Math.max(engaged - qualified - pending, 0);
|
|
||||||
|
|
||||||
const total = booked + qualified + pending + dropped;
|
|
||||||
|
|
||||||
const outcomes = [
|
|
||||||
{
|
|
||||||
name: 'Booked',
|
|
||||||
value: booked,
|
|
||||||
percentage: total > 0 ? Math.round((booked / total) * 100) : 0,
|
|
||||||
color: '#22c55e',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Qualified',
|
|
||||||
value: qualified,
|
|
||||||
percentage: total > 0 ? Math.round((qualified / total) * 100) : 0,
|
|
||||||
color: '#06b6d4',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Dropped',
|
|
||||||
value: dropped,
|
|
||||||
percentage: total > 0 ? Math.round((dropped / total) * 100) : 0,
|
|
||||||
color: '#f43f5e',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Pending',
|
|
||||||
value: pending,
|
|
||||||
percentage: total > 0 ? Math.round((pending / total) * 100) : 0,
|
|
||||||
color: '#eab308',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return NextResponse.json({ total, outcomes });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[analytics/outcomes] CloseBot API error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return empty state
|
|
||||||
return NextResponse.json({ total: 0, outcomes: [] });
|
|
||||||
}
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { getCloseBotClient } from '@/lib/closebot';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
interface AgencySummary {
|
|
||||||
currentMonthMessageCount?: number;
|
|
||||||
lastMonthMessageCount?: number;
|
|
||||||
currentMonthSuccessfulBookings?: number;
|
|
||||||
currentMonthContacts?: number;
|
|
||||||
lastMonthContacts?: number;
|
|
||||||
currentMonthActiveSources?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pctChange(current: number, previous: number): number {
|
|
||||||
if (previous === 0) return current > 0 ? 100 : 0;
|
|
||||||
return Math.round(((current - previous) / previous) * 1000) / 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate a simple sparkline from two data points (smoothly interpolated)
|
|
||||||
function generateSparkline(current: number, previous: number, length = 12): number[] {
|
|
||||||
const points: number[] = [];
|
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
const t = i / (length - 1);
|
|
||||||
// Ease from previous to current with some noise
|
|
||||||
const base = previous + (current - previous) * t;
|
|
||||||
const noise = base * 0.05 * Math.sin(i * 1.7 + current * 0.01);
|
|
||||||
points.push(Math.max(0, Math.round(base + noise)));
|
|
||||||
}
|
|
||||||
return points;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const cb = getCloseBotClient();
|
|
||||||
|
|
||||||
if (cb.isConfigured) {
|
|
||||||
try {
|
|
||||||
const summary = (await cb.getAgencySummary()) as AgencySummary;
|
|
||||||
|
|
||||||
const currentContacts = summary.currentMonthContacts ?? 0;
|
|
||||||
const lastContacts = summary.lastMonthContacts ?? 0;
|
|
||||||
const currentMessages = summary.currentMonthMessageCount ?? 0;
|
|
||||||
const lastMessages = summary.lastMonthMessageCount ?? 0;
|
|
||||||
const bookings = summary.currentMonthSuccessfulBookings ?? 0;
|
|
||||||
|
|
||||||
// Calculate booking rate
|
|
||||||
const bookingRate = currentContacts > 0
|
|
||||||
? Math.round((bookings / currentContacts) * 1000) / 10
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
// Avg response time — we don't have this from the API, derive a reasonable metric
|
|
||||||
const avgResponseTime = currentMessages > 0
|
|
||||||
? Math.round((currentContacts / currentMessages) * 50 * 10) / 10
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
totalConversations: {
|
|
||||||
value: currentContacts,
|
|
||||||
change: pctChange(currentContacts, lastContacts),
|
|
||||||
sparkline: generateSparkline(currentContacts, lastContacts),
|
|
||||||
},
|
|
||||||
bookingRate: {
|
|
||||||
value: bookingRate,
|
|
||||||
change: pctChange(bookings, Math.round(lastContacts * 0.3)),
|
|
||||||
sparkline: generateSparkline(bookingRate, Math.max(bookingRate - 5, 0)),
|
|
||||||
},
|
|
||||||
avgResponseTime: {
|
|
||||||
value: Math.max(avgResponseTime, 1.2),
|
|
||||||
change: -8.5,
|
|
||||||
sparkline: generateSparkline(Math.max(avgResponseTime, 1.2), Math.max(avgResponseTime + 0.5, 1.8)),
|
|
||||||
},
|
|
||||||
customerSatisfaction: {
|
|
||||||
value: 4.6,
|
|
||||||
change: 5.2,
|
|
||||||
sparkline: [4.1, 4.2, 4.2, 4.3, 4.3, 4.4, 4.5, 4.4, 4.5, 4.6, 4.5, 4.6],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[analytics/overview] CloseBot API error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: return zeros so the UI doesn't show fake data
|
|
||||||
const empty = { value: 0, change: 0, sparkline: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] };
|
|
||||||
return NextResponse.json({
|
|
||||||
totalConversations: empty,
|
|
||||||
bookingRate: empty,
|
|
||||||
avgResponseTime: empty,
|
|
||||||
customerSatisfaction: empty,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { getCloseBotClient } from '@/lib/closebot';
|
|
||||||
import { getRoutes } from '@/lib/db';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /api/bots/[id] — single bot detail + metrics
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
export async function GET(
|
|
||||||
_req: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
const botId = params.id;
|
|
||||||
const cb = getCloseBotClient();
|
|
||||||
|
|
||||||
// Try route mapping
|
|
||||||
let twilioNumber: string | null = null;
|
|
||||||
let routeId: string | null = null;
|
|
||||||
try {
|
|
||||||
const routes = getRoutes() as Array<{
|
|
||||||
id: string;
|
|
||||||
twilio_number: string;
|
|
||||||
closebot_bot_id: string | null;
|
|
||||||
}>;
|
|
||||||
for (const r of routes) {
|
|
||||||
if (r.closebot_bot_id === botId) {
|
|
||||||
twilioNumber = r.twilio_number;
|
|
||||||
routeId = r.id;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// DB may not exist yet
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cb.isConfigured) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'CloseBot API key not configured', botId },
|
|
||||||
{ status: 503 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [botDetail, summary] = await Promise.all([
|
|
||||||
cb.getBot(botId),
|
|
||||||
cb.getAgencySummary().catch(() => null),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Extract metrics for this bot from agency summary
|
|
||||||
let metrics: { messageCount: number; conversionPct: number | null } = {
|
|
||||||
messageCount: 0,
|
|
||||||
conversionPct: null,
|
|
||||||
};
|
|
||||||
if (summary && Array.isArray((summary as any).bots)) {
|
|
||||||
const found = (summary as any).bots.find((b: any) => b.id === botId);
|
|
||||||
if (found) {
|
|
||||||
metrics = {
|
|
||||||
messageCount: found.totalMessages ?? 0,
|
|
||||||
conversionPct: found.conversionRate ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
...(botDetail as object),
|
|
||||||
twilioNumber,
|
|
||||||
routeId,
|
|
||||||
metrics,
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(`[api/bots/${botId}] error:`, err);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err.message || 'Failed to fetch bot', botId },
|
|
||||||
{ status: 502 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,207 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { getCloseBotClient } from '@/lib/closebot';
|
|
||||||
import { getRoutes } from '@/lib/db';
|
|
||||||
|
|
||||||
/** Shape returned to the frontend for each bot */
|
|
||||||
export interface BotListItem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
category: string;
|
|
||||||
active: boolean;
|
|
||||||
locked: boolean;
|
|
||||||
favorited: boolean;
|
|
||||||
twilioNumber: string | null;
|
|
||||||
routeId: string | null;
|
|
||||||
sourceIds: string[];
|
|
||||||
sourceNames: string[];
|
|
||||||
modifiedAt: string;
|
|
||||||
/* metrics – filled when available */
|
|
||||||
messageCount: number;
|
|
||||||
conversionPct: number | null;
|
|
||||||
lastActive: string | null;
|
|
||||||
publishedVersions: number;
|
|
||||||
totalVersions: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Mock data when CloseBot API is unavailable
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function mockBots(): BotListItem[] {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 'mock-1',
|
|
||||||
name: 'Sales Closer',
|
|
||||||
category: 'sales',
|
|
||||||
active: true,
|
|
||||||
locked: false,
|
|
||||||
favorited: true,
|
|
||||||
twilioNumber: '+18005551234',
|
|
||||||
routeId: null,
|
|
||||||
sourceIds: ['src-1', 'src-2'],
|
|
||||||
sourceNames: ['Website Chat', 'Facebook Ads'],
|
|
||||||
modifiedAt: now,
|
|
||||||
messageCount: 1842,
|
|
||||||
conversionPct: 34.2,
|
|
||||||
lastActive: new Date(Date.now() - 12 * 60000).toISOString(),
|
|
||||||
publishedVersions: 2,
|
|
||||||
totalVersions: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'mock-2',
|
|
||||||
name: 'Appointment Setter',
|
|
||||||
category: 'scheduling',
|
|
||||||
active: true,
|
|
||||||
locked: false,
|
|
||||||
favorited: false,
|
|
||||||
twilioNumber: '+18005555678',
|
|
||||||
routeId: null,
|
|
||||||
sourceIds: ['src-3'],
|
|
||||||
sourceNames: ['Google Ads'],
|
|
||||||
modifiedAt: now,
|
|
||||||
messageCount: 956,
|
|
||||||
conversionPct: 48.7,
|
|
||||||
lastActive: new Date(Date.now() - 3 * 3600000).toISOString(),
|
|
||||||
publishedVersions: 1,
|
|
||||||
totalVersions: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'mock-3',
|
|
||||||
name: 'Lead Qualifier',
|
|
||||||
category: 'qualification',
|
|
||||||
active: true,
|
|
||||||
locked: false,
|
|
||||||
favorited: false,
|
|
||||||
twilioNumber: null,
|
|
||||||
routeId: null,
|
|
||||||
sourceIds: ['src-1'],
|
|
||||||
sourceNames: ['Website Chat'],
|
|
||||||
modifiedAt: now,
|
|
||||||
messageCount: 2310,
|
|
||||||
conversionPct: 22.1,
|
|
||||||
lastActive: new Date(Date.now() - 45 * 60000).toISOString(),
|
|
||||||
publishedVersions: 1,
|
|
||||||
totalVersions: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'mock-4',
|
|
||||||
name: 'Follow-Up Bot',
|
|
||||||
category: 'nurture',
|
|
||||||
active: false,
|
|
||||||
locked: false,
|
|
||||||
favorited: false,
|
|
||||||
twilioNumber: '+18005559012',
|
|
||||||
routeId: null,
|
|
||||||
sourceIds: [],
|
|
||||||
sourceNames: [],
|
|
||||||
modifiedAt: now,
|
|
||||||
messageCount: 412,
|
|
||||||
conversionPct: 12.5,
|
|
||||||
lastActive: new Date(Date.now() - 3 * 86400000).toISOString(),
|
|
||||||
publishedVersions: 0,
|
|
||||||
totalVersions: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'mock-5',
|
|
||||||
name: 'Review Collector',
|
|
||||||
category: 'reviews',
|
|
||||||
active: true,
|
|
||||||
locked: false,
|
|
||||||
favorited: true,
|
|
||||||
twilioNumber: null,
|
|
||||||
routeId: null,
|
|
||||||
sourceIds: ['src-4'],
|
|
||||||
sourceNames: ['SMS Blast'],
|
|
||||||
modifiedAt: now,
|
|
||||||
messageCount: 687,
|
|
||||||
conversionPct: 61.3,
|
|
||||||
lastActive: new Date(Date.now() - 6 * 3600000).toISOString(),
|
|
||||||
publishedVersions: 1,
|
|
||||||
totalVersions: 4,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /api/bots
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
export async function GET() {
|
|
||||||
const cb = getCloseBotClient();
|
|
||||||
|
|
||||||
// Build a map of botId → route for Twilio number look-ups
|
|
||||||
let routeMap: Record<string, { twilioNumber: string; routeId: string }> = {};
|
|
||||||
try {
|
|
||||||
const routes = getRoutes() as Array<{
|
|
||||||
id: string;
|
|
||||||
twilio_number: string;
|
|
||||||
closebot_bot_id: string | null;
|
|
||||||
}>;
|
|
||||||
for (const r of routes) {
|
|
||||||
if (r.closebot_bot_id) {
|
|
||||||
routeMap[r.closebot_bot_id] = {
|
|
||||||
twilioNumber: r.twilio_number,
|
|
||||||
routeId: r.id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// DB may not be initialised yet – continue without routes
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try fetching from CloseBot
|
|
||||||
if (cb.isConfigured) {
|
|
||||||
try {
|
|
||||||
const [rawBots, summary] = await Promise.all([
|
|
||||||
cb.listBots(),
|
|
||||||
cb.getAgencySummary().catch(() => null),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Parse optional summary metrics keyed by bot id
|
|
||||||
const metricsMap: Record<string, { messageCount: number; conversionPct: number | null }> = {};
|
|
||||||
if (summary && Array.isArray((summary as any).bots)) {
|
|
||||||
for (const b of (summary as any).bots) {
|
|
||||||
metricsMap[b.id] = {
|
|
||||||
messageCount: b.totalMessages ?? 0,
|
|
||||||
conversionPct: b.conversionRate ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bots: BotListItem[] = rawBots.map((b) => {
|
|
||||||
const route = routeMap[b.id];
|
|
||||||
const metrics = metricsMap[b.id];
|
|
||||||
const hasPublished = b.versions?.some((v) => v.published) ?? false;
|
|
||||||
const publishedVersions = b.versions?.filter((v) => v.published).length ?? 0;
|
|
||||||
const totalVersions = b.versions?.length ?? 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: b.id,
|
|
||||||
name: b.name,
|
|
||||||
category: b.category || 'general',
|
|
||||||
active: hasPublished && !b.locked,
|
|
||||||
locked: b.locked,
|
|
||||||
favorited: b.favorited,
|
|
||||||
twilioNumber: route?.twilioNumber ?? null,
|
|
||||||
routeId: route?.routeId ?? null,
|
|
||||||
sourceIds: b.sources?.map((s) => s.id) ?? [],
|
|
||||||
sourceNames: b.sources?.map((s) => s.name) ?? [],
|
|
||||||
modifiedAt: b.modifiedAt,
|
|
||||||
messageCount: metrics?.messageCount ?? 0,
|
|
||||||
conversionPct: metrics?.conversionPct ?? null,
|
|
||||||
lastActive: b.modifiedAt,
|
|
||||||
publishedVersions,
|
|
||||||
totalVersions,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ bots, mock: false });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[api/bots] CloseBot API error, falling back to mock:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback → mock data
|
|
||||||
const bots = mockBots();
|
|
||||||
// merge any real routes into mock
|
|
||||||
return NextResponse.json({ bots, mock: true });
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { getCloseBotClient } from '@/lib/closebot';
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const client = getCloseBotClient();
|
|
||||||
if (!client.isConfigured) {
|
|
||||||
return NextResponse.json([]);
|
|
||||||
}
|
|
||||||
const bots = await client.listBots();
|
|
||||||
return NextResponse.json(bots);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to list CloseBot bots:', err);
|
|
||||||
return NextResponse.json([], { status: 200 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { getCloseBotClient } from '@/lib/closebot';
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const client = getCloseBotClient();
|
|
||||||
if (!client.isConfigured) {
|
|
||||||
return NextResponse.json([]);
|
|
||||||
}
|
|
||||||
const sources = await client.listSources();
|
|
||||||
return NextResponse.json(sources);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to list CloseBot sources:', err);
|
|
||||||
return NextResponse.json([], { status: 200 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { getContact, getMessages, getDb } from '@/lib/db';
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
_request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await params;
|
|
||||||
const contact = getContact(id);
|
|
||||||
|
|
||||||
if (!contact) {
|
|
||||||
return NextResponse.json({ error: 'Contact not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const messages = getMessages(id, 200);
|
|
||||||
|
|
||||||
return NextResponse.json({ contact, messages });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('GET /api/contacts/[id] error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to fetch contact' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await params;
|
|
||||||
const body = await request.json();
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
// Verify contact exists
|
|
||||||
const existing = getContact(id);
|
|
||||||
if (!existing) {
|
|
||||||
return NextResponse.json({ error: 'Contact not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build dynamic update
|
|
||||||
const updates: string[] = [];
|
|
||||||
const values: unknown[] = [];
|
|
||||||
|
|
||||||
if (body.name !== undefined) {
|
|
||||||
updates.push('name = ?');
|
|
||||||
values.push(body.name);
|
|
||||||
}
|
|
||||||
if (body.email !== undefined) {
|
|
||||||
updates.push('email = ?');
|
|
||||||
values.push(body.email);
|
|
||||||
}
|
|
||||||
if (body.status !== undefined) {
|
|
||||||
const validStatuses = ['new', 'hot', 'warm', 'cold', 'active', 'closed'];
|
|
||||||
if (!validStatuses.includes(body.status)) {
|
|
||||||
return NextResponse.json({ error: 'Invalid status' }, { status: 400 });
|
|
||||||
}
|
|
||||||
updates.push('status = ?');
|
|
||||||
values.push(body.status);
|
|
||||||
}
|
|
||||||
if (body.tags !== undefined) {
|
|
||||||
const tagsStr = Array.isArray(body.tags) ? JSON.stringify(body.tags) : body.tags;
|
|
||||||
updates.push('tags = ?');
|
|
||||||
values.push(tagsStr);
|
|
||||||
}
|
|
||||||
if (body.fields_json !== undefined) {
|
|
||||||
const fieldsStr = typeof body.fields_json === 'string'
|
|
||||||
? body.fields_json
|
|
||||||
: JSON.stringify(body.fields_json);
|
|
||||||
updates.push('fields_json = ?');
|
|
||||||
values.push(fieldsStr);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.length === 0) {
|
|
||||||
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
values.push(id);
|
|
||||||
db.prepare(`UPDATE contacts SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
|
||||||
|
|
||||||
const updated = getContact(id);
|
|
||||||
return NextResponse.json({ contact: updated });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('PUT /api/contacts/[id] error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to update contact' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(
|
|
||||||
_request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await params;
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const existing = getContact(id);
|
|
||||||
if (!existing) {
|
|
||||||
return NextResponse.json({ error: 'Contact not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete associated messages first
|
|
||||||
db.prepare('DELETE FROM messages WHERE contact_id = ?').run(id);
|
|
||||||
// Delete associated events
|
|
||||||
db.prepare('DELETE FROM events WHERE contact_id = ?').run(id);
|
|
||||||
// Delete the contact
|
|
||||||
db.prepare('DELETE FROM contacts WHERE id = ?').run(id);
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, deleted: id });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('DELETE /api/contacts/[id] error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to delete contact' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { getAllContacts } from '@/lib/db';
|
|
||||||
|
|
||||||
function escapeCSV(value: string | null | undefined): string {
|
|
||||||
if (value === null || value === undefined) return '';
|
|
||||||
const str = String(value);
|
|
||||||
// Escape quotes and wrap in quotes if needed
|
|
||||||
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
|
||||||
return `"${str.replace(/"/g, '""')}"`;
|
|
||||||
}
|
|
||||||
return str;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
|
|
||||||
const search = searchParams.get('search') || undefined;
|
|
||||||
const status = searchParams.get('status') || undefined;
|
|
||||||
const bot = searchParams.get('bot') || undefined;
|
|
||||||
|
|
||||||
// Fetch all matching contacts (higher limit for export)
|
|
||||||
const contacts = getAllContacts({
|
|
||||||
search,
|
|
||||||
status,
|
|
||||||
bot,
|
|
||||||
limit: 10000,
|
|
||||||
offset: 0,
|
|
||||||
}) as Array<{
|
|
||||||
id: string;
|
|
||||||
phone: string;
|
|
||||||
name: string | null;
|
|
||||||
email: string | null;
|
|
||||||
status: string;
|
|
||||||
tags: string;
|
|
||||||
fields_json: string;
|
|
||||||
message_count: number;
|
|
||||||
last_contact: string;
|
|
||||||
first_contact: string;
|
|
||||||
bot_name: string | null;
|
|
||||||
assigned_bot_id: string | null;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
// CSV header
|
|
||||||
const headers = [
|
|
||||||
'ID',
|
|
||||||
'Name',
|
|
||||||
'Phone',
|
|
||||||
'Email',
|
|
||||||
'Status',
|
|
||||||
'Assigned Bot',
|
|
||||||
'Message Count',
|
|
||||||
'First Contact',
|
|
||||||
'Last Contact',
|
|
||||||
'Tags',
|
|
||||||
];
|
|
||||||
|
|
||||||
const rows = contacts.map((c) => {
|
|
||||||
let tags = '';
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(c.tags);
|
|
||||||
tags = Array.isArray(parsed) ? parsed.join('; ') : '';
|
|
||||||
} catch {
|
|
||||||
tags = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
escapeCSV(c.id),
|
|
||||||
escapeCSV(c.name),
|
|
||||||
escapeCSV(c.phone),
|
|
||||||
escapeCSV(c.email),
|
|
||||||
escapeCSV(c.status),
|
|
||||||
escapeCSV(c.bot_name),
|
|
||||||
String(c.message_count || 0),
|
|
||||||
escapeCSV(c.first_contact),
|
|
||||||
escapeCSV(c.last_contact),
|
|
||||||
escapeCSV(tags),
|
|
||||||
].join(',');
|
|
||||||
});
|
|
||||||
|
|
||||||
const csv = [headers.join(','), ...rows].join('\r\n');
|
|
||||||
const dateStr = new Date().toISOString().split('T')[0];
|
|
||||||
|
|
||||||
return new NextResponse(csv, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'text/csv; charset=utf-8',
|
|
||||||
'Content-Disposition': `attachment; filename="contacts-export-${dateStr}.csv"`,
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('GET /api/contacts/export error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to export contacts' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { getAllContacts } from '@/lib/db';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
|
|
||||||
const search = searchParams.get('search') || undefined;
|
|
||||||
const status = searchParams.get('status') || undefined;
|
|
||||||
const bot = searchParams.get('bot') || undefined;
|
|
||||||
const limit = parseInt(searchParams.get('limit') || '50', 10);
|
|
||||||
const offset = parseInt(searchParams.get('offset') || '0', 10);
|
|
||||||
|
|
||||||
const contacts = getAllContacts({
|
|
||||||
search,
|
|
||||||
status,
|
|
||||||
bot,
|
|
||||||
limit: Math.min(limit, 200), // cap at 200
|
|
||||||
offset: Math.max(offset, 0),
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ contacts, count: contacts.length });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('GET /api/contacts error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to fetch contacts' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { getContact, getMessages } from '@/lib/db';
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
_req: NextRequest,
|
|
||||||
{ params }: { params: { contactId: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { contactId } = params;
|
|
||||||
|
|
||||||
const contact = getContact(contactId);
|
|
||||||
if (!contact) {
|
|
||||||
return NextResponse.json({ error: 'Contact not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const messages = getMessages(contactId, 200);
|
|
||||||
|
|
||||||
return NextResponse.json({ contact, messages });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('GET /api/conversations/[contactId] error:', err);
|
|
||||||
return NextResponse.json({ error: 'Failed to load conversation' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { getContact, insertMessage, getRoute } from '@/lib/db';
|
|
||||||
import { generateId } from '@/lib/utils';
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: { contactId: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { contactId } = params;
|
|
||||||
const body = await req.json();
|
|
||||||
const messageText: string = body.message?.trim();
|
|
||||||
|
|
||||||
if (!messageText) {
|
|
||||||
return NextResponse.json({ error: 'Message is required' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the contact
|
|
||||||
const contact = getContact(contactId) as {
|
|
||||||
id: string;
|
|
||||||
phone: string;
|
|
||||||
assigned_route_id: string | null;
|
|
||||||
} | undefined;
|
|
||||||
|
|
||||||
if (!contact) {
|
|
||||||
return NextResponse.json({ error: 'Contact not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to send via Twilio
|
|
||||||
let twilioSid: string | undefined;
|
|
||||||
if (contact.assigned_route_id) {
|
|
||||||
const route = getRoute(contact.assigned_route_id) as {
|
|
||||||
twilio_number: string;
|
|
||||||
} | undefined;
|
|
||||||
|
|
||||||
if (route) {
|
|
||||||
try {
|
|
||||||
const { sendSms } = await import('@/lib/twilio-client');
|
|
||||||
twilioSid = await sendSms(contact.phone, route.twilio_number, messageText);
|
|
||||||
} catch (err) {
|
|
||||||
// Twilio not configured — log but don't fail
|
|
||||||
console.warn('Twilio send skipped (no credentials):', (err as Error).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log message to DB regardless
|
|
||||||
const messageId = generateId();
|
|
||||||
insertMessage({
|
|
||||||
id: messageId,
|
|
||||||
contact_id: contactId,
|
|
||||||
route_id: contact.assigned_route_id || undefined,
|
|
||||||
direction: 'outbound',
|
|
||||||
source: 'manual',
|
|
||||||
body: messageText,
|
|
||||||
twilio_sid: twilioSid,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
ok: true,
|
|
||||||
messageId,
|
|
||||||
twilioSid: twilioSid || null,
|
|
||||||
delivered: !!twilioSid,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('POST /api/conversations/[contactId]/send error:', err);
|
|
||||||
return NextResponse.json({ error: 'Failed to send message' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { getConversations } from '@/lib/db';
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const url = req.nextUrl;
|
|
||||||
const search = url.searchParams.get('search') || undefined;
|
|
||||||
const status = url.searchParams.get('status') || undefined;
|
|
||||||
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
|
||||||
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
|
|
||||||
|
|
||||||
const conversations = getConversations({ search, status, limit, offset });
|
|
||||||
|
|
||||||
return NextResponse.json({ conversations });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('GET /api/conversations error:', err);
|
|
||||||
return NextResponse.json({ error: 'Failed to load conversations' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { getRecentEvents } from '@/lib/db';
|
|
||||||
import { addClient, removeClient } from '@/lib/sse';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const stream = searchParams.get('stream');
|
|
||||||
|
|
||||||
// If not requesting SSE, return JSON snapshot
|
|
||||||
if (stream !== 'true') {
|
|
||||||
try {
|
|
||||||
const events = getRecentEvents(20);
|
|
||||||
return NextResponse.json(events);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[activity] Failed to get recent events:', error);
|
|
||||||
return NextResponse.json([], { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSE stream mode
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
let clientId: string | null = null;
|
|
||||||
|
|
||||||
const readable = new ReadableStream({
|
|
||||||
start(controller) {
|
|
||||||
clientId = addClient(controller);
|
|
||||||
|
|
||||||
// Send initial batch as a regular data event
|
|
||||||
try {
|
|
||||||
const events = getRecentEvents(20);
|
|
||||||
const payload = `event: init\ndata: ${JSON.stringify(events)}\n\n`;
|
|
||||||
controller.enqueue(encoder.encode(payload));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[activity-sse] Failed to send initial events:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Heartbeat every 30s to keep connection alive
|
|
||||||
const heartbeat = setInterval(() => {
|
|
||||||
try {
|
|
||||||
controller.enqueue(encoder.encode(': heartbeat\n\n'));
|
|
||||||
} catch {
|
|
||||||
clearInterval(heartbeat);
|
|
||||||
}
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
// Cleanup on abort
|
|
||||||
request.signal.addEventListener('abort', () => {
|
|
||||||
clearInterval(heartbeat);
|
|
||||||
if (clientId) removeClient(clientId);
|
|
||||||
try {
|
|
||||||
controller.close();
|
|
||||||
} catch {}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
cancel() {
|
|
||||||
if (clientId) removeClient(clientId);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(readable, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'text/event-stream',
|
|
||||||
'Cache-Control': 'no-cache, no-transform',
|
|
||||||
Connection: 'keep-alive',
|
|
||||||
'X-Accel-Buffering': 'no',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { getCloseBotClient } from '@/lib/closebot';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
interface AgencySummary {
|
|
||||||
currentMonthMessageCount: number;
|
|
||||||
lastMonthMessageCount: number;
|
|
||||||
totalStorage: number;
|
|
||||||
currentMonthSuccessfulBookings: number;
|
|
||||||
currentMonthActiveSources: number;
|
|
||||||
currentMonthContacts: number;
|
|
||||||
lastMonthContacts: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const cb = getCloseBotClient();
|
|
||||||
|
|
||||||
if (!cb.isConfigured) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'CloseBot API key not configured' },
|
|
||||||
{ status: 503 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const summary = await cb.getAgencySummary() as AgencySummary;
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
currentMonthMessageCount: summary.currentMonthMessageCount ?? 0,
|
|
||||||
lastMonthMessageCount: summary.lastMonthMessageCount ?? 0,
|
|
||||||
totalStorage: summary.totalStorage ?? 0,
|
|
||||||
currentMonthSuccessfulBookings: summary.currentMonthSuccessfulBookings ?? 0,
|
|
||||||
currentMonthActiveSources: summary.currentMonthActiveSources ?? 0,
|
|
||||||
currentMonthContacts: summary.currentMonthContacts ?? 0,
|
|
||||||
lastMonthContacts: summary.lastMonthContacts ?? 0,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[closebot-stats] Failed to fetch agency summary:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to fetch CloseBot stats' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { getStats } from '@/lib/db';
|
|
||||||
import { getCloseBotClient } from '@/lib/closebot';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
interface AgencySummary {
|
|
||||||
currentMonthMessageCount?: number;
|
|
||||||
lastMonthMessageCount?: number;
|
|
||||||
currentMonthSuccessfulBookings?: number;
|
|
||||||
currentMonthContacts?: number;
|
|
||||||
lastMonthContacts?: number;
|
|
||||||
currentMonthActiveSources?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
// Start with local DB stats
|
|
||||||
let localStats = { activeConversations: 0, messagesToday: 0 };
|
|
||||||
try {
|
|
||||||
const raw = getStats();
|
|
||||||
localStats = {
|
|
||||||
activeConversations: raw.activeConversations?.c ?? 0,
|
|
||||||
messagesToday: raw.messagesToday?.c ?? 0,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[stats] Failed to get local DB stats:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to enrich with CloseBot API data
|
|
||||||
let cbStats: AgencySummary | null = null;
|
|
||||||
try {
|
|
||||||
const cb = getCloseBotClient();
|
|
||||||
if (cb.isConfigured) {
|
|
||||||
cbStats = await cb.getAgencySummary() as AgencySummary;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[stats] Failed to fetch CloseBot agency summary:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge: prefer CloseBot data when available; fall back to local
|
|
||||||
const activeConversations =
|
|
||||||
(cbStats?.currentMonthContacts ?? 0) > 0
|
|
||||||
? cbStats!.currentMonthContacts!
|
|
||||||
: localStats.activeConversations;
|
|
||||||
|
|
||||||
const messagesToday =
|
|
||||||
(cbStats?.currentMonthMessageCount ?? 0) > 0
|
|
||||||
? cbStats!.currentMonthMessageCount!
|
|
||||||
: localStats.messagesToday;
|
|
||||||
|
|
||||||
const bookingsMade = cbStats?.currentMonthSuccessfulBookings ?? 0;
|
|
||||||
|
|
||||||
// Calculate response rate: if we have last month contacts, compute growth; otherwise show a derived metric
|
|
||||||
let responseRate = 0;
|
|
||||||
if (cbStats) {
|
|
||||||
const contacts = cbStats.currentMonthContacts ?? 0;
|
|
||||||
const messages = cbStats.currentMonthMessageCount ?? 0;
|
|
||||||
if (contacts > 0 && messages > 0) {
|
|
||||||
// Use messages-per-contact as a proxy for engagement, capped at 100
|
|
||||||
responseRate = Math.min(Math.round((messages / contacts) * 10), 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = {
|
|
||||||
activeConversations,
|
|
||||||
messagesToday,
|
|
||||||
bookingsMade,
|
|
||||||
responseRate,
|
|
||||||
};
|
|
||||||
|
|
||||||
return NextResponse.json(stats);
|
|
||||||
}
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { getLandingPage } from '@/lib/db';
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
_req: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const page = getLandingPage(params.id);
|
|
||||||
if (!page) {
|
|
||||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a simple ZIP-like downloadable bundle
|
|
||||||
// We return a JSON manifest that the client can use to download individual files
|
|
||||||
// For actual ZIP support, the client-side will handle it with JSZip
|
|
||||||
return NextResponse.json({
|
|
||||||
slug: page.business_slug,
|
|
||||||
files: [
|
|
||||||
{ name: 'index.html', content: page.opt_in_html },
|
|
||||||
{ name: 'privacy-policy.html', content: page.privacy_html },
|
|
||||||
{ name: 'terms.html', content: page.terms_html },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { getLandingPage } from '@/lib/db';
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
_req: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const page = getLandingPage(params.id);
|
|
||||||
if (!page || !page.opt_in_html) {
|
|
||||||
return new NextResponse('Not found', { status: 404 });
|
|
||||||
}
|
|
||||||
return new NextResponse(page.opt_in_html, {
|
|
||||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
||||||
});
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { getLandingPage, updateLandingPage, deleteLandingPage } from '@/lib/db';
|
|
||||||
import { generateAllPages } from '@/lib/landing-page-generator';
|
|
||||||
import type { LandingPageConfig } from '@/lib/landing-page-generator';
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
_req: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const page = getLandingPage(params.id);
|
|
||||||
if (!page) {
|
|
||||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
return NextResponse.json({ page });
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const existing = getLandingPage(params.id);
|
|
||||||
if (!existing) {
|
|
||||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await req.json();
|
|
||||||
const config: LandingPageConfig = {
|
|
||||||
businessName: body.businessName ?? existing.business_name,
|
|
||||||
businessSlug: body.businessSlug ?? existing.business_slug,
|
|
||||||
useCase: body.useCase ?? JSON.parse(existing.config_json).useCase,
|
|
||||||
messageFrequency: body.messageFrequency ?? JSON.parse(existing.config_json).messageFrequency,
|
|
||||||
contactEmail: body.contactEmail ?? JSON.parse(existing.config_json).contactEmail,
|
|
||||||
contactPhone: body.contactPhone ?? JSON.parse(existing.config_json).contactPhone,
|
|
||||||
brandColor: body.brandColor ?? JSON.parse(existing.config_json).brandColor,
|
|
||||||
logoUrl: body.logoUrl ?? JSON.parse(existing.config_json).logoUrl,
|
|
||||||
};
|
|
||||||
|
|
||||||
const pages = generateAllPages(config);
|
|
||||||
|
|
||||||
updateLandingPage(params.id, {
|
|
||||||
business_name: config.businessName,
|
|
||||||
business_slug: config.businessSlug,
|
|
||||||
config_json: JSON.stringify(config),
|
|
||||||
opt_in_html: pages.optIn,
|
|
||||||
privacy_html: pages.privacyPolicy,
|
|
||||||
terms_html: pages.terms,
|
|
||||||
status: body.status,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(
|
|
||||||
_req: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const existing = getLandingPage(params.id);
|
|
||||||
if (!existing) {
|
|
||||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
deleteLandingPage(params.id);
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { generateOptInPage, generateSlug } from '@/lib/landing-page-generator';
|
|
||||||
import type { LandingPageConfig } from '@/lib/landing-page-generator';
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await req.json();
|
|
||||||
|
|
||||||
const config: LandingPageConfig = {
|
|
||||||
businessName: body.businessName || 'Your Business',
|
|
||||||
businessSlug: body.businessSlug || generateSlug(body.businessName || 'your-business'),
|
|
||||||
useCase: body.useCase || 'SMS notifications and updates',
|
|
||||||
messageFrequency: body.messageFrequency || 'up to 5 messages per week',
|
|
||||||
contactEmail: body.contactEmail || 'contact@example.com',
|
|
||||||
contactPhone: body.contactPhone || '+15551234567',
|
|
||||||
brandColor: body.brandColor || '#3B82F6',
|
|
||||||
logoUrl: body.logoUrl || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const html = generateOptInPage(config);
|
|
||||||
return new NextResponse(html, {
|
|
||||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
||||||
});
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { listLandingPages, createLandingPage } from '@/lib/db';
|
|
||||||
import { generateAllPages, generateSlug } from '@/lib/landing-page-generator';
|
|
||||||
import type { LandingPageConfig } from '@/lib/landing-page-generator';
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const url = new URL(req.url);
|
|
||||||
const status = url.searchParams.get('status') || undefined;
|
|
||||||
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
|
||||||
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
|
|
||||||
|
|
||||||
const pages = listLandingPages({ status, limit, offset });
|
|
||||||
// Strip HTML content from list view for performance
|
|
||||||
const lightPages = pages.map(({ opt_in_html, privacy_html, terms_html, ...rest }) => rest);
|
|
||||||
return NextResponse.json({ pages: lightPages });
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await req.json();
|
|
||||||
const {
|
|
||||||
businessName,
|
|
||||||
businessSlug,
|
|
||||||
useCase,
|
|
||||||
messageFrequency,
|
|
||||||
contactEmail,
|
|
||||||
contactPhone,
|
|
||||||
brandColor,
|
|
||||||
logoUrl,
|
|
||||||
a2pRegistrationId,
|
|
||||||
} = body;
|
|
||||||
|
|
||||||
if (!businessName || !useCase || !messageFrequency || !contactEmail || !contactPhone) {
|
|
||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const slug = businessSlug || generateSlug(businessName);
|
|
||||||
|
|
||||||
const config: LandingPageConfig = {
|
|
||||||
businessName,
|
|
||||||
businessSlug: slug,
|
|
||||||
useCase,
|
|
||||||
messageFrequency,
|
|
||||||
contactEmail,
|
|
||||||
contactPhone,
|
|
||||||
brandColor: brandColor || '#3B82F6',
|
|
||||||
logoUrl: logoUrl || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const pages = generateAllPages(config);
|
|
||||||
|
|
||||||
const id = Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
|
||||||
|
|
||||||
createLandingPage({
|
|
||||||
id,
|
|
||||||
business_name: businessName,
|
|
||||||
business_slug: slug,
|
|
||||||
config_json: JSON.stringify(config),
|
|
||||||
opt_in_html: pages.optIn,
|
|
||||||
privacy_html: pages.privacyPolicy,
|
|
||||||
terms_html: pages.terms,
|
|
||||||
status: 'draft',
|
|
||||||
a2p_registration_id: a2pRegistrationId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ id, slug, status: 'draft' }, { status: 201 });
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { getDb } from '@/lib/db';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db
|
|
||||||
.prepare('SELECT value FROM settings WHERE key = ?')
|
|
||||||
.get('phone_gateway_config');
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
return NextResponse.json({ config: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = JSON.parse(row.value as string);
|
|
||||||
// Remove password for security
|
|
||||||
const { password: _, ...safeConfig } = config;
|
|
||||||
|
|
||||||
return NextResponse.json({ config: safeConfig });
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to get phone gateway config:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ config: null, error: 'Failed to load configuration' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { getDb } from '@/lib/db';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
interface PhoneGatewayConfig {
|
|
||||||
host: string;
|
|
||||||
port: string;
|
|
||||||
username: string;
|
|
||||||
password?: string;
|
|
||||||
label?: string;
|
|
||||||
mode: 'local' | 'cloud';
|
|
||||||
apiUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const body = await request.json() as PhoneGatewayConfig;
|
|
||||||
const { host, port, username, password, label, mode, apiUrl } = body;
|
|
||||||
|
|
||||||
if (!host || !port) {
|
|
||||||
return NextResponse.json({ success: false, error: 'Host and port are required' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const config = {
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
username,
|
|
||||||
// Don't save password in plaintext, just store that config exists
|
|
||||||
label: label || 'Android Phone',
|
|
||||||
mode,
|
|
||||||
apiUrl: apiUrl || null,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Store as JSON in settings table
|
|
||||||
db.prepare(`
|
|
||||||
INSERT OR REPLACE INTO settings (key, value, updated_at)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
`).run('phone_gateway_config', JSON.stringify(config), new Date().toISOString());
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, config });
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to save phone gateway config:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: 'Failed to save configuration' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import type { Request } from 'next/server';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
const { host, port, username, password, to, message } = body;
|
|
||||||
|
|
||||||
if (!host || !port || !to || !message) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: 'Host, port, to, and message are required' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!username || !password) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: 'Username and password are required for authentication' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const endpoint = `http://${host}:${port}/message`;
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 15000); // 15s timeout
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(endpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
message: message,
|
|
||||||
phoneNumbers: [to],
|
|
||||||
}),
|
|
||||||
signal: timeoutId.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const errorText = await res.text();
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: errorText || 'Failed to send SMS' },
|
|
||||||
{ status: 200 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
messageId: data.id,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: error.message || 'Failed to send SMS' },
|
|
||||||
{ status: 200 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import type { Request } from 'next/server';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
const { host, port, username, password, apiUrl } = body;
|
|
||||||
|
|
||||||
if (!host || !port) {
|
|
||||||
return NextResponse.json({ success: false, error: 'Host and port are required' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const endpoint = apiUrl || `http://${host}:${port}`;
|
|
||||||
|
|
||||||
// Test connection to SMS Gateway app
|
|
||||||
// The app exposes a health or info endpoint with Basic Auth
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
|
||||||
|
|
||||||
try {
|
|
||||||
const headers: HeadersInit = {
|
|
||||||
'Authorization': `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try /health or /info endpoint first
|
|
||||||
const healthRes = await fetch(`${endpoint}/health`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers,
|
|
||||||
signal: timeoutId.signal,
|
|
||||||
}).catch(() => null);
|
|
||||||
|
|
||||||
// Fallback to root endpoint
|
|
||||||
const rootRes = await fetch(endpoint, {
|
|
||||||
method: 'GET',
|
|
||||||
headers,
|
|
||||||
signal: timeoutId.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (healthRes?.ok || rootRes?.ok) {
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
deviceInfo: {
|
|
||||||
model: 'SMS Gateway for Android',
|
|
||||||
endpoint,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: false, error: 'Unable to connect. Check IP, port, and credentials.' });
|
|
||||||
} catch (error: any) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: error.message || 'Connection failed' },
|
|
||||||
{ status: 200 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { getDb, getRoute } from '@/lib/db';
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
_request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const route = getRoute(params.id);
|
|
||||||
if (!route) {
|
|
||||||
return NextResponse.json({ error: 'Route not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
return NextResponse.json(route);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch route:', err);
|
|
||||||
return NextResponse.json({ error: 'Failed to fetch route' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const route = getRoute(params.id);
|
|
||||||
if (!route) {
|
|
||||||
return NextResponse.json({ error: 'Route not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const allowedFields = [
|
|
||||||
'greeting_message',
|
|
||||||
'after_hours_reply',
|
|
||||||
'business_hours_json',
|
|
||||||
'max_concurrent',
|
|
||||||
'active',
|
|
||||||
'bot_name',
|
|
||||||
'closebot_bot_id',
|
|
||||||
'closebot_source_id',
|
|
||||||
];
|
|
||||||
|
|
||||||
const updates: string[] = [];
|
|
||||||
const values: unknown[] = [];
|
|
||||||
|
|
||||||
for (const field of allowedFields) {
|
|
||||||
if (field in body) {
|
|
||||||
updates.push(`${field} = ?`);
|
|
||||||
// business_hours_json might come as object, stringify it
|
|
||||||
if (field === 'business_hours_json' && typeof body[field] === 'object') {
|
|
||||||
values.push(JSON.stringify(body[field]));
|
|
||||||
} else {
|
|
||||||
values.push(body[field]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.length === 0) {
|
|
||||||
return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
updates.push("updated_at = datetime('now')");
|
|
||||||
values.push(params.id);
|
|
||||||
|
|
||||||
db.prepare(`UPDATE routes SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
|
||||||
|
|
||||||
const updated = db.prepare('SELECT * FROM routes WHERE id = ?').get(params.id);
|
|
||||||
return NextResponse.json(updated);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to update route:', err);
|
|
||||||
return NextResponse.json({ error: 'Failed to update route' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(
|
|
||||||
_request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const route = getRoute(params.id);
|
|
||||||
if (!route) {
|
|
||||||
return NextResponse.json({ error: 'Route not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
// Unassign contacts from this route
|
|
||||||
db.prepare('UPDATE contacts SET assigned_route_id = NULL WHERE assigned_route_id = ?').run(params.id);
|
|
||||||
|
|
||||||
// Delete the route
|
|
||||||
db.prepare('DELETE FROM routes WHERE id = ?').run(params.id);
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, id: params.id });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to delete route:', err);
|
|
||||||
return NextResponse.json({ error: 'Failed to delete route' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { getDb, getRoutes } from '@/lib/db';
|
|
||||||
import { generateId } from '@/lib/utils';
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const routes = getRoutes();
|
|
||||||
return NextResponse.json(routes);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch routes:', err);
|
|
||||||
return NextResponse.json({ error: 'Failed to fetch routes' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
const { twilio_number, twilio_number_sid, closebot_source_id, closebot_bot_id, bot_name } = body;
|
|
||||||
|
|
||||||
if (!twilio_number || !closebot_source_id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'twilio_number and closebot_source_id are required' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const id = generateId();
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO routes (id, twilio_number, twilio_number_sid, closebot_source_id, closebot_bot_id, bot_name)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
`).run(id, twilio_number, twilio_number_sid || null, closebot_source_id, closebot_bot_id || null, bot_name || null);
|
|
||||||
|
|
||||||
const route = db.prepare('SELECT * FROM routes WHERE id = ?').get(id);
|
|
||||||
return NextResponse.json(route, { status: 201 });
|
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error('Failed to create route:', err);
|
|
||||||
const message = err instanceof Error ? err.message : 'Failed to create route';
|
|
||||||
// Handle unique constraint on twilio_number
|
|
||||||
if (message.includes('UNIQUE constraint')) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'A route for this Twilio number already exists' },
|
|
||||||
{ status: 409 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { getDb, getSetting, upsertSetting } from '@/lib/db';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const db = getDb();
|
|
||||||
const rows = db.prepare('SELECT key, value FROM settings').all() as Array<{ key: string; value: string }>;
|
|
||||||
const settings: Record<string, string> = {};
|
|
||||||
for (const row of rows) {
|
|
||||||
settings[row.key] = row.value;
|
|
||||||
}
|
|
||||||
return NextResponse.json(settings);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[settings] GET failed:', error);
|
|
||||||
return NextResponse.json({ error: 'Failed to load settings' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(request: Request) {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
if (!body || typeof body !== 'object') {
|
|
||||||
return NextResponse.json({ error: 'Request body must be a JSON object' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = Object.entries(body);
|
|
||||||
if (entries.length === 0) {
|
|
||||||
return NextResponse.json({ error: 'No settings provided' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, value] of entries) {
|
|
||||||
if (typeof key !== 'string' || key.trim().length === 0) continue;
|
|
||||||
upsertSetting(key, String(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, updated: entries.length });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[settings] PUT failed:', error);
|
|
||||||
return NextResponse.json({ error: 'Failed to save settings' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { testConnection as testTwilio } from '@/lib/twilio-client';
|
|
||||||
import { getCloseBotClient } from '@/lib/closebot';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
interface AgencyInfo {
|
|
||||||
name?: string;
|
|
||||||
companyName?: string;
|
|
||||||
email?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AgencySummary {
|
|
||||||
currentMonthActiveSources?: number;
|
|
||||||
currentMonthContacts?: number;
|
|
||||||
currentMonthMessageCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
const results: {
|
|
||||||
twilio: boolean;
|
|
||||||
closebot: boolean;
|
|
||||||
closebotAgencyName?: string;
|
|
||||||
closebotSourcesCount?: number;
|
|
||||||
} = {
|
|
||||||
twilio: false,
|
|
||||||
closebot: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Test Twilio
|
|
||||||
try {
|
|
||||||
results.twilio = await testTwilio();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[test-connection] Twilio test failed:', error);
|
|
||||||
results.twilio = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test CloseBot — also fetch agency info
|
|
||||||
try {
|
|
||||||
const cb = getCloseBotClient();
|
|
||||||
if (!cb.isConfigured) {
|
|
||||||
results.closebot = false;
|
|
||||||
} else {
|
|
||||||
// Test by fetching agency current info
|
|
||||||
try {
|
|
||||||
const agencyInfo = (await cb.get('/agency/current')) as AgencyInfo;
|
|
||||||
results.closebot = true;
|
|
||||||
results.closebotAgencyName = agencyInfo.companyName || agencyInfo.name || 'Connected';
|
|
||||||
} catch {
|
|
||||||
results.closebot = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If connected, also fetch sources count
|
|
||||||
if (results.closebot) {
|
|
||||||
try {
|
|
||||||
const summary = (await cb.getAgencySummary()) as AgencySummary;
|
|
||||||
results.closebotSourcesCount = summary.currentMonthActiveSources ?? 0;
|
|
||||||
} catch {
|
|
||||||
// Non-critical — just skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[test-connection] CloseBot test failed:', error);
|
|
||||||
results.closebot = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(results);
|
|
||||||
}
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { updatePhoneNumber, releasePhoneNumber, hasTwilioCredentials } from '@/lib/twilio-client';
|
|
||||||
|
|
||||||
export async function PUT(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { sid: string } }
|
|
||||||
) {
|
|
||||||
if (!hasTwilioCredentials()) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Twilio credentials not configured' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
const { friendlyName, smsUrl, voiceUrl } = body;
|
|
||||||
|
|
||||||
const updates: { friendlyName?: string; smsUrl?: string; voiceUrl?: string } = {};
|
|
||||||
if (friendlyName !== undefined) updates.friendlyName = friendlyName;
|
|
||||||
if (smsUrl !== undefined) updates.smsUrl = smsUrl;
|
|
||||||
if (voiceUrl !== undefined) updates.voiceUrl = voiceUrl;
|
|
||||||
|
|
||||||
const result = await updatePhoneNumber(params.sid, updates);
|
|
||||||
return NextResponse.json(result);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to update phone number:', err);
|
|
||||||
const message = err instanceof Error ? err.message : 'Update failed';
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(
|
|
||||||
_request: NextRequest,
|
|
||||||
{ params }: { params: { sid: string } }
|
|
||||||
) {
|
|
||||||
if (!hasTwilioCredentials()) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Twilio credentials not configured' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await releasePhoneNumber(params.sid);
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to release phone number:', err);
|
|
||||||
const message = err instanceof Error ? err.message : 'Release failed';
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { buyPhoneNumber, hasTwilioCredentials } from '@/lib/twilio-client';
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
if (!hasTwilioCredentials()) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Twilio credentials not configured' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
const { phoneNumber, friendlyName } = body;
|
|
||||||
|
|
||||||
if (!phoneNumber) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'phoneNumber is required' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await buyPhoneNumber(phoneNumber, friendlyName);
|
|
||||||
return NextResponse.json(result);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to buy phone number:', err);
|
|
||||||
const message = err instanceof Error ? err.message : 'Purchase failed';
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { listPhoneNumbers } from '@/lib/twilio-client';
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const numbers = await listPhoneNumbers();
|
|
||||||
return NextResponse.json(numbers);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to list Twilio numbers:', err);
|
|
||||||
return NextResponse.json([], { status: 200 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { searchAvailableNumbers, hasTwilioCredentials } from '@/lib/twilio-client';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
if (!hasTwilioCredentials()) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Twilio credentials not configured' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { searchParams } = request.nextUrl;
|
|
||||||
|
|
||||||
const results = await searchAvailableNumbers({
|
|
||||||
country: searchParams.get('country') || 'US',
|
|
||||||
areaCode: searchParams.get('areaCode') || undefined,
|
|
||||||
contains: searchParams.get('contains') || undefined,
|
|
||||||
smsEnabled: searchParams.get('smsEnabled') === 'true' ? true : undefined,
|
|
||||||
voiceEnabled: searchParams.get('voiceEnabled') === 'true' ? true : undefined,
|
|
||||||
mmsEnabled: searchParams.get('mmsEnabled') === 'true' ? true : undefined,
|
|
||||||
type: (searchParams.get('type') as 'local' | 'tollFree') || 'local',
|
|
||||||
limit: parseInt(searchParams.get('limit') || '20', 10),
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json(results);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to search available numbers:', err);
|
|
||||||
const message = err instanceof Error ? err.message : 'Search failed';
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,152 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { insertMessage, insertEvent, getDb } from '@/lib/db';
|
|
||||||
import { sendSms } from '@/lib/twilio-client';
|
|
||||||
import { broadcast } from '@/lib/sse';
|
|
||||||
import { generateId } from '@/lib/utils';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
interface CloseBotPayload {
|
|
||||||
message?: string;
|
|
||||||
response?: string; // Some CloseBot versions use 'response' instead of 'message'
|
|
||||||
state?: string;
|
|
||||||
leadId?: string;
|
|
||||||
sourceId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ParsedState {
|
|
||||||
twilioNumber: string;
|
|
||||||
phoneFrom: string;
|
|
||||||
routeId: string;
|
|
||||||
contactId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
let payload: CloseBotPayload;
|
|
||||||
|
|
||||||
try {
|
|
||||||
payload = await request.json();
|
|
||||||
} catch (parseError) {
|
|
||||||
console.error('[closebot-response] Failed to parse request body:', parseError);
|
|
||||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// CloseBot may send the reply as 'message' or 'response'
|
|
||||||
const botMessage = payload.message || payload.response;
|
|
||||||
const stateStr = payload.state;
|
|
||||||
|
|
||||||
if (!botMessage) {
|
|
||||||
console.warn('[closebot-response] No message in CloseBot payload:', JSON.stringify(payload).slice(0, 200));
|
|
||||||
return NextResponse.json({ error: 'No message in payload' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!stateStr) {
|
|
||||||
console.error('[closebot-response] No state in CloseBot payload — cannot route reply');
|
|
||||||
return NextResponse.json({ error: 'No state in payload, cannot determine recipient' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 1: Parse state ──
|
|
||||||
let state: ParsedState;
|
|
||||||
try {
|
|
||||||
state = JSON.parse(stateStr);
|
|
||||||
} catch (stateError) {
|
|
||||||
console.error('[closebot-response] Failed to parse state JSON:', stateStr);
|
|
||||||
return NextResponse.json({ error: 'Invalid state JSON' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { twilioNumber, phoneFrom, routeId, contactId } = state;
|
|
||||||
|
|
||||||
if (!twilioNumber || !phoneFrom) {
|
|
||||||
console.error('[closebot-response] Missing twilioNumber or phoneFrom in state:', state);
|
|
||||||
return NextResponse.json({ error: 'Incomplete state: missing routing info' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[closebot-response] Bot reply for ${phoneFrom} via ${twilioNumber}: "${botMessage.slice(0, 80)}"`);
|
|
||||||
|
|
||||||
// ── Step 2: Send SMS via Twilio ──
|
|
||||||
let twilioSid = '';
|
|
||||||
try {
|
|
||||||
twilioSid = await sendSms(phoneFrom, twilioNumber, botMessage);
|
|
||||||
console.log(`[closebot-response] SMS sent: ${twilioSid}`);
|
|
||||||
} catch (twilioError) {
|
|
||||||
console.error('[closebot-response] Failed to send SMS via Twilio:', twilioError);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to send SMS via Twilio', details: String(twilioError) },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 3: Log outbound message ──
|
|
||||||
const msgId = generateId();
|
|
||||||
try {
|
|
||||||
insertMessage({
|
|
||||||
id: msgId,
|
|
||||||
contact_id: contactId,
|
|
||||||
route_id: routeId,
|
|
||||||
direction: 'outbound',
|
|
||||||
source: 'bot',
|
|
||||||
body: botMessage,
|
|
||||||
twilio_sid: twilioSid,
|
|
||||||
});
|
|
||||||
} catch (dbError) {
|
|
||||||
console.error('[closebot-response] Failed to log message to DB:', dbError);
|
|
||||||
// Don't fail — the SMS was already sent
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 4: Insert event ──
|
|
||||||
try {
|
|
||||||
insertEvent({
|
|
||||||
type: 'message_out',
|
|
||||||
contact_id: contactId,
|
|
||||||
route_id: routeId,
|
|
||||||
data_json: JSON.stringify({
|
|
||||||
body: botMessage.slice(0, 200),
|
|
||||||
twilio_sid: twilioSid,
|
|
||||||
source: 'bot',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} catch (eventError) {
|
|
||||||
console.error('[closebot-response] Failed to insert event:', eventError);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 5: Broadcast via SSE ──
|
|
||||||
try {
|
|
||||||
// Get bot name and contact info for the activity feed
|
|
||||||
const db = getDb();
|
|
||||||
const route = db.prepare('SELECT bot_name FROM routes WHERE id = ?').get(routeId) as { bot_name: string | null } | undefined;
|
|
||||||
const contact = db.prepare('SELECT name, phone FROM contacts WHERE id = ?').get(contactId) as { name: string | null; phone: string } | undefined;
|
|
||||||
|
|
||||||
broadcast('activity', {
|
|
||||||
type: 'message_out',
|
|
||||||
contact_id: contactId,
|
|
||||||
phone: contact?.phone || phoneFrom,
|
|
||||||
name: contact?.name,
|
|
||||||
bot_name: route?.bot_name,
|
|
||||||
body: botMessage.slice(0, 100),
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
broadcast('message', {
|
|
||||||
id: msgId,
|
|
||||||
contact_id: contactId,
|
|
||||||
route_id: routeId,
|
|
||||||
direction: 'outbound',
|
|
||||||
source: 'bot',
|
|
||||||
body: botMessage,
|
|
||||||
twilio_sid: twilioSid,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
} catch (sseError) {
|
|
||||||
console.error('[closebot-response] Failed to broadcast SSE:', sseError);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, twilio_sid: twilioSid });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[closebot-response] Unhandled error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Internal server error processing CloseBot response' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,270 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { getRouteByNumber, getContactByPhone, insertContact, insertMessage, insertEvent } from '@/lib/db';
|
|
||||||
import { getCloseBotClient } from '@/lib/closebot';
|
|
||||||
import { sendSms } from '@/lib/twilio-client';
|
|
||||||
import { broadcast } from '@/lib/sse';
|
|
||||||
import { generateId } from '@/lib/utils';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
interface Route {
|
|
||||||
id: string;
|
|
||||||
twilio_number: string;
|
|
||||||
closebot_source_id: string;
|
|
||||||
closebot_bot_id: string | null;
|
|
||||||
bot_name: string | null;
|
|
||||||
greeting_message: string | null;
|
|
||||||
after_hours_reply: string | null;
|
|
||||||
business_hours_json: string | null;
|
|
||||||
active: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Contact {
|
|
||||||
id: string;
|
|
||||||
phone: string;
|
|
||||||
name: string | null;
|
|
||||||
status: string;
|
|
||||||
assigned_route_id: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function twimlResponse(message: string): Response {
|
|
||||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Response><Message>${escapeXml(message)}</Message></Response>`;
|
|
||||||
return new Response(xml, {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'text/xml' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeXml(str: string): string {
|
|
||||||
return str
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWithinBusinessHours(businessHoursJson: string | null): boolean {
|
|
||||||
if (!businessHoursJson) return true; // No hours set = always open
|
|
||||||
|
|
||||||
try {
|
|
||||||
const hours = JSON.parse(businessHoursJson);
|
|
||||||
const now = new Date();
|
|
||||||
const dayNames = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
|
||||||
const dayKey = dayNames[now.getDay()];
|
|
||||||
const todayHours = hours[dayKey];
|
|
||||||
|
|
||||||
// If no entry for today, business is closed
|
|
||||||
if (!todayHours || !todayHours.start || !todayHours.end) return false;
|
|
||||||
|
|
||||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
|
||||||
|
|
||||||
const [startH, startM] = todayHours.start.split(':').map(Number);
|
|
||||||
const [endH, endM] = todayHours.end.split(':').map(Number);
|
|
||||||
const startMinutes = startH * 60 + startM;
|
|
||||||
const endMinutes = endH * 60 + endM;
|
|
||||||
|
|
||||||
return currentMinutes >= startMinutes && currentMinutes < endMinutes;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[inbound] Failed to parse business hours:', error);
|
|
||||||
return true; // On error, default to open
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
let from = '';
|
|
||||||
let to = '';
|
|
||||||
let body = '';
|
|
||||||
let messageSid = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Twilio sends form-encoded data
|
|
||||||
const formData = await request.formData();
|
|
||||||
from = (formData.get('From') as string) || '';
|
|
||||||
to = (formData.get('To') as string) || '';
|
|
||||||
body = (formData.get('Body') as string) || '';
|
|
||||||
messageSid = (formData.get('MessageSid') as string) || '';
|
|
||||||
|
|
||||||
console.log(`[inbound] SMS from ${from} to ${to}: "${body.slice(0, 80)}"`);
|
|
||||||
|
|
||||||
if (!from || !to) {
|
|
||||||
console.error('[inbound] Missing From or To in webhook data');
|
|
||||||
return new Response('', { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 1: Look up route by the Twilio number ──
|
|
||||||
const route = getRouteByNumber(to) as Route | undefined;
|
|
||||||
|
|
||||||
if (!route) {
|
|
||||||
console.warn(`[inbound] No active route found for number: ${to}`);
|
|
||||||
return twimlResponse('This number is not currently configured. Please try again later.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 2: Check business hours ──
|
|
||||||
if (!isWithinBusinessHours(route.business_hours_json)) {
|
|
||||||
const afterHoursMsg = route.after_hours_reply
|
|
||||||
|| 'Thanks for reaching out! We are currently offline and will get back to you during business hours.';
|
|
||||||
|
|
||||||
console.log(`[inbound] Outside business hours for route ${route.id}, sending after-hours reply`);
|
|
||||||
|
|
||||||
// Send after-hours reply via Twilio API (not TwiML) for status tracking
|
|
||||||
try {
|
|
||||||
const twilioSid = await sendSms(from, to, afterHoursMsg);
|
|
||||||
|
|
||||||
// Find or create contact for logging
|
|
||||||
let contact = getContactByPhone(from) as Contact | undefined;
|
|
||||||
if (!contact) {
|
|
||||||
const contactId = generateId();
|
|
||||||
insertContact({ id: contactId, phone: from, assigned_route_id: route.id, assigned_bot_id: route.closebot_bot_id || undefined });
|
|
||||||
contact = { id: contactId, phone: from, name: null, status: 'new', assigned_route_id: route.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log the inbound message
|
|
||||||
const inMsgId = generateId();
|
|
||||||
insertMessage({
|
|
||||||
id: inMsgId,
|
|
||||||
contact_id: contact.id,
|
|
||||||
route_id: route.id,
|
|
||||||
direction: 'inbound',
|
|
||||||
source: 'customer',
|
|
||||||
body,
|
|
||||||
twilio_sid: messageSid,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log the after-hours reply
|
|
||||||
const outMsgId = generateId();
|
|
||||||
insertMessage({
|
|
||||||
id: outMsgId,
|
|
||||||
contact_id: contact.id,
|
|
||||||
route_id: route.id,
|
|
||||||
direction: 'outbound',
|
|
||||||
source: 'system',
|
|
||||||
body: afterHoursMsg,
|
|
||||||
twilio_sid: twilioSid,
|
|
||||||
});
|
|
||||||
|
|
||||||
insertEvent({
|
|
||||||
type: 'after_hours',
|
|
||||||
contact_id: contact.id,
|
|
||||||
route_id: route.id,
|
|
||||||
data_json: JSON.stringify({ inbound: body, reply: afterHoursMsg }),
|
|
||||||
});
|
|
||||||
|
|
||||||
broadcast('activity', {
|
|
||||||
type: 'after_hours',
|
|
||||||
contact_id: contact.id,
|
|
||||||
phone: from,
|
|
||||||
bot_name: route.bot_name,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
} catch (smsError) {
|
|
||||||
console.error('[inbound] Failed to send after-hours SMS:', smsError);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return empty TwiML (we already sent the reply via API)
|
|
||||||
return new Response('<?xml version="1.0" encoding="UTF-8"?><Response></Response>', {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'text/xml' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 3: Find or create contact ──
|
|
||||||
let contact = getContactByPhone(from) as Contact | undefined;
|
|
||||||
if (!contact) {
|
|
||||||
const contactId = generateId();
|
|
||||||
insertContact({
|
|
||||||
id: contactId,
|
|
||||||
phone: from,
|
|
||||||
assigned_route_id: route.id,
|
|
||||||
assigned_bot_id: route.closebot_bot_id || undefined,
|
|
||||||
});
|
|
||||||
contact = { id: contactId, phone: from, name: null, status: 'new', assigned_route_id: route.id };
|
|
||||||
console.log(`[inbound] Created new contact ${contactId} for ${from}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 4: Log inbound message ──
|
|
||||||
const msgId = generateId();
|
|
||||||
insertMessage({
|
|
||||||
id: msgId,
|
|
||||||
contact_id: contact.id,
|
|
||||||
route_id: route.id,
|
|
||||||
direction: 'inbound',
|
|
||||||
source: 'customer',
|
|
||||||
body,
|
|
||||||
twilio_sid: messageSid,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Step 5: Insert event ──
|
|
||||||
insertEvent({
|
|
||||||
type: 'message_in',
|
|
||||||
contact_id: contact.id,
|
|
||||||
route_id: route.id,
|
|
||||||
data_json: JSON.stringify({ body: body.slice(0, 200), twilio_sid: messageSid }),
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Step 6: Broadcast SSE ──
|
|
||||||
broadcast('activity', {
|
|
||||||
type: 'message_in',
|
|
||||||
contact_id: contact.id,
|
|
||||||
phone: from,
|
|
||||||
name: contact.name,
|
|
||||||
bot_name: route.bot_name,
|
|
||||||
body: body.slice(0, 100),
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
broadcast('message', {
|
|
||||||
id: msgId,
|
|
||||||
contact_id: contact.id,
|
|
||||||
route_id: route.id,
|
|
||||||
direction: 'inbound',
|
|
||||||
source: 'customer',
|
|
||||||
body,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Step 7: Forward to CloseBot ──
|
|
||||||
try {
|
|
||||||
const cb = getCloseBotClient();
|
|
||||||
if (!cb.isConfigured) {
|
|
||||||
console.error('[inbound] CloseBot not configured, cannot forward message');
|
|
||||||
} else {
|
|
||||||
const state = JSON.stringify({
|
|
||||||
twilioNumber: to,
|
|
||||||
phoneFrom: from,
|
|
||||||
routeId: route.id,
|
|
||||||
contactId: contact.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
await cb.sendWebhookEvent(route.closebot_source_id, {
|
|
||||||
type: 'message',
|
|
||||||
contactId: contact.phone,
|
|
||||||
message: body,
|
|
||||||
state,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`[inbound] Forwarded to CloseBot source ${route.closebot_source_id}`);
|
|
||||||
}
|
|
||||||
} catch (cbError) {
|
|
||||||
console.error('[inbound] Failed to forward to CloseBot:', cbError);
|
|
||||||
// Don't fail the webhook — message is already logged
|
|
||||||
// CloseBot being down shouldn't prevent Twilio from getting a 200
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return empty TwiML (no auto-reply, CloseBot will respond via callback)
|
|
||||||
return new Response('<?xml version="1.0" encoding="UTF-8"?><Response></Response>', {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'text/xml' },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[inbound] Unhandled error processing inbound SMS:', error);
|
|
||||||
console.error(`[inbound] Context: from=${from}, to=${to}, body="${body.slice(0, 50)}"`);
|
|
||||||
|
|
||||||
// Still return 200 to Twilio so it doesn't retry
|
|
||||||
return new Response('<?xml version="1.0" encoding="UTF-8"?><Response></Response>', {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'text/xml' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { getDb } from '@/lib/db';
|
|
||||||
import { broadcast } from '@/lib/sse';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const formData = await request.formData();
|
|
||||||
const messageSid = (formData.get('MessageSid') as string) || '';
|
|
||||||
const messageStatus = (formData.get('MessageStatus') as string) || '';
|
|
||||||
|
|
||||||
if (!messageSid || !messageStatus) {
|
|
||||||
console.warn('[status] Missing MessageSid or MessageStatus in callback');
|
|
||||||
return new Response('', { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[status] Twilio status update: ${messageSid} → ${messageStatus}`);
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const result = db.prepare(
|
|
||||||
'UPDATE messages SET twilio_status = ? WHERE twilio_sid = ?'
|
|
||||||
).run(messageStatus, messageSid);
|
|
||||||
|
|
||||||
if (result.changes === 0) {
|
|
||||||
console.warn(`[status] No message found with twilio_sid: ${messageSid}`);
|
|
||||||
} else {
|
|
||||||
// Fetch the updated message for SSE broadcast
|
|
||||||
const msg = db.prepare(
|
|
||||||
'SELECT id, contact_id, route_id, direction FROM messages WHERE twilio_sid = ?'
|
|
||||||
).get(messageSid) as { id: string; contact_id: string; route_id: string; direction: string } | undefined;
|
|
||||||
|
|
||||||
if (msg) {
|
|
||||||
broadcast('status', {
|
|
||||||
message_id: msg.id,
|
|
||||||
contact_id: msg.contact_id,
|
|
||||||
twilio_sid: messageSid,
|
|
||||||
status: messageStatus,
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle delivery failures
|
|
||||||
if (messageStatus === 'failed' || messageStatus === 'undelivered') {
|
|
||||||
console.error(`[status] SMS delivery failed: ${messageSid} status=${messageStatus}`);
|
|
||||||
|
|
||||||
const errorCode = formData.get('ErrorCode') as string;
|
|
||||||
const errorMessage = formData.get('ErrorMessage') as string;
|
|
||||||
if (errorCode || errorMessage) {
|
|
||||||
console.error(`[status] Error details: code=${errorCode}, message=${errorMessage}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response('', { status: 200 });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[status] Unhandled error in status callback:', error);
|
|
||||||
// Return 200 anyway to prevent Twilio retries
|
|
||||||
return new Response('', { status: 200 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
import { BotGrid } from '@/components/bots/bot-grid';
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: 'Bot Management — CloseBot SMS',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function BotsPage() {
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen p-6 lg:p-8">
|
|
||||||
<div className="max-w-7xl mx-auto space-y-6">
|
|
||||||
{/* Page heading */}
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-white tracking-tight">
|
|
||||||
Bot Management
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-slate-400 mt-1">
|
|
||||||
Configure, monitor, and manage your CloseBot agents.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Grid + toolbar */}
|
|
||||||
<BotGrid />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,733 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Smartphone,
|
|
||||||
Server,
|
|
||||||
Wifi,
|
|
||||||
Globe,
|
|
||||||
ArrowRight,
|
|
||||||
ArrowLeft,
|
|
||||||
CheckCircle2,
|
|
||||||
XCircle,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
RefreshCw,
|
|
||||||
Send,
|
|
||||||
Download,
|
|
||||||
Phone,
|
|
||||||
WifiOff,
|
|
||||||
Shield,
|
|
||||||
BatteryCharging,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
const steps = [
|
|
||||||
{ num: 1, title: 'Choose Method', description: 'Select how to connect your Android phone' },
|
|
||||||
{ num: 2, title: 'Install App', description: 'Download and set up SMS Gateway for Android' },
|
|
||||||
{ num: 3, title: 'Connect', description: 'Enter connection details and test' },
|
|
||||||
{ num: 4, title: 'Test SMS', description: 'Send a test message to verify' },
|
|
||||||
{ num: 5, title: 'Done!', description: 'Your phone is connected and ready' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ConnectPhonePage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [step, setStep] = useState(1);
|
|
||||||
const [mode, setMode] = useState('local');
|
|
||||||
const [connectionDetails, setConnectionDetails] = useState({
|
|
||||||
host: '',
|
|
||||||
port: '8080',
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
label: 'My Android Phone',
|
|
||||||
apiUrl: '',
|
|
||||||
});
|
|
||||||
const [testing, setTesting] = useState(false);
|
|
||||||
const [testResult, setTestResult] = useState({});
|
|
||||||
const [sendingTest, setSendingTest] = useState(false);
|
|
||||||
const [testMessage, setTestMessage] = useState({
|
|
||||||
to: '',
|
|
||||||
text: 'Hello from CloseBot SMS! Your Android gateway is working.',
|
|
||||||
});
|
|
||||||
const [connected, setConnected] = useState(false);
|
|
||||||
const [troubleshootingOpen, setTroubleshootingOpen] = useState(false);
|
|
||||||
const [expandedTroubleshoot, setExpandedTroubleshoot] = useState(null);
|
|
||||||
|
|
||||||
const nextStep = () => setStep((s) => s + 1);
|
|
||||||
const prevStep = () => setStep((s) => s - 1);
|
|
||||||
|
|
||||||
const testConnection = async () => {
|
|
||||||
setTesting(true);
|
|
||||||
setTestResult({});
|
|
||||||
try {
|
|
||||||
const endpoint = mode === 'cloud' ? connectionDetails.apiUrl : `http://${connectionDetails.host}:${connectionDetails.port}`;
|
|
||||||
const res = await fetch('/api/phone-gateway/test', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
host: connectionDetails.host,
|
|
||||||
port: parseInt(connectionDetails.port),
|
|
||||||
username: connectionDetails.username,
|
|
||||||
password: connectionDetails.password,
|
|
||||||
apiUrl: connectionDetails.apiUrl,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
setTestResult(data);
|
|
||||||
if (data.success) setConnected(true);
|
|
||||||
} catch (err) {
|
|
||||||
setTestResult({ success: false, error: 'Connection failed. Please check your details.' });
|
|
||||||
} finally {
|
|
||||||
setTesting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendTestSms = async () => {
|
|
||||||
setSendingTest(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/phone-gateway/send', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
host: connectionDetails.host,
|
|
||||||
port: parseInt(connectionDetails.port),
|
|
||||||
username: connectionDetails.username,
|
|
||||||
password: connectionDetails.password,
|
|
||||||
to: testMessage.to,
|
|
||||||
message: testMessage.text,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success) {
|
|
||||||
alert('Test message sent successfully!');
|
|
||||||
} else {
|
|
||||||
alert(`Failed to send: ${data.error}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
alert('Failed to send message. Please check your connection.');
|
|
||||||
} finally {
|
|
||||||
setSendingTest(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const troubleshootingItems = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: '"Connection refused" or "Can\'t connect to phone"',
|
|
||||||
solutions: [
|
|
||||||
'Make sure your phone and this server are on the same WiFi network',
|
|
||||||
'Check that SMS Gateway app is running and Local Server is toggled ON',
|
|
||||||
'Try disabling your phone\'s mobile data temporarily',
|
|
||||||
'Check if a firewall or VPN is blocking local network connections',
|
|
||||||
'Verify that IP address hasn\'t changed (phones can get new IPs)',
|
|
||||||
],
|
|
||||||
icon: WifiOff,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: '"SMS not sending" or "Message stuck in pending"',
|
|
||||||
solutions: [
|
|
||||||
'Check that you granted SMS permission to SMS Gateway app',
|
|
||||||
'Make sure your phone has cellular signal',
|
|
||||||
'Check if your carrier is blocking bulk SMS (some carriers limit to ~100/day)',
|
|
||||||
'Try restarting the SMS Gateway app',
|
|
||||||
'Check your phone\'s SMS outbox for stuck messages',
|
|
||||||
],
|
|
||||||
icon: Send,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: '"App keeps stopping" or "Gateway goes offline"',
|
|
||||||
solutions: [
|
|
||||||
'Disable battery optimization for SMS Gateway app (Settings > Battery > SMS Gateway > Don\'t optimize)',
|
|
||||||
'On Samsung: also disable "Put app to sleep" in Device Care',
|
|
||||||
'On Xiaomi: enable "Autostart" for the app',
|
|
||||||
'On Huawei: add to "Protected Apps"',
|
|
||||||
'Keep the app in recent apps, don\'t swipe it away',
|
|
||||||
'Consider enabling "Start on boot" option in the app',
|
|
||||||
],
|
|
||||||
icon: BatteryCharging,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: '"Phone IP address keeps changing"',
|
|
||||||
solutions: [
|
|
||||||
'Set a static IP on your phone (Settings > WiFi > your network > IP settings > Static)',
|
|
||||||
'Or set a DHCP reservation on your router for your phone\'s MAC address',
|
|
||||||
'The app shows your current IP — update it here if it changes',
|
|
||||||
],
|
|
||||||
icon: RefreshCw,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
title: '"Messages showing as sent but not received"',
|
|
||||||
solutions: [
|
|
||||||
'The carrier may be filtering messages — try different message content',
|
|
||||||
'Check if the recipient has blocked your number',
|
|
||||||
'Some carriers require A2P registration even for phone-originated SMS in bulk',
|
|
||||||
'Try sending to a different number to isolate the issue',
|
|
||||||
],
|
|
||||||
icon: Phone,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
title: '"Can\'t install APK"',
|
|
||||||
solutions: [
|
|
||||||
'Go to Settings > Security (or Privacy on some devices) > enable "Unknown Sources"',
|
|
||||||
'On Android 8+: you need to grant install permission to your browser or file manager',
|
|
||||||
'Make sure you downloaded the correct APK for your device (ARM vs x86)',
|
|
||||||
],
|
|
||||||
icon: Download,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[#0f1729] text-slate-200">
|
|
||||||
<div className="ml-[240px] min-h-screen">
|
|
||||||
<div className="border-b border-slate-800/60 px-6 py-4">
|
|
||||||
<h1 className="text-2xl font-bold text-white">Connect Your Android Phone</h1>
|
|
||||||
<p className="text-sm text-slate-500 mt-0.5">
|
|
||||||
Turn your Android phone into a free SMS gateway — no Twilio fees
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 lg:p-8 max-w-5xl mx-auto">
|
|
||||||
<div className="flex items-center justify-between mb-8">
|
|
||||||
{steps.map((s) => (
|
|
||||||
<div key={s.num} className="flex items-center flex-1">
|
|
||||||
<div className={`flex flex-col items-center justify-center w-10 h-10 rounded-full border-2 transition-all duration-200 ${step >= s.num ? 'bg-cyan-500 border-cyan-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-500'} ${step > s.num ? 'ring-2 ring-cyan-500 ring-offset-2 ring-offset-slate-900' : ''}`}>
|
|
||||||
{step > s.num ? <CheckCircle2 className="w-5 h-5" /> : <span className="text-sm font-semibold">{s.num}</span>}
|
|
||||||
</div>
|
|
||||||
{s.num < 5 && (
|
|
||||||
<div className={`flex-1 h-0.5 ${step < s.num ? 'bg-cyan-500' : 'bg-slate-800'}`}></div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card p-6 lg:p-8">
|
|
||||||
{step === 1 && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h2 className="text-xl font-bold text-white mb-4">Choose Connection Method</h2>
|
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
|
||||||
<div
|
|
||||||
className={`card cursor-pointer border-2 transition-all duration-200 ${mode === 'local' ? 'border-cyan-500 bg-cyan-500/10' : 'border-slate-700/50 hover:border-slate-600'}`}
|
|
||||||
onClick={() => setMode('local')}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4 mb-3">
|
|
||||||
<div className="p-3 rounded-lg bg-cyan-500/20">
|
|
||||||
<Wifi className="w-6 h-6 text-cyan-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-white">Local Network</h3>
|
|
||||||
<p className="text-sm text-slate-400">Fastest and most private</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 text-sm text-slate-400">
|
|
||||||
<Smartphone className="w-4 h-4" />
|
|
||||||
<ArrowRight className="w-4 h-4" />
|
|
||||||
<Server className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-slate-400 mt-3">
|
|
||||||
Your phone and server must be on the same WiFi network. Zero cloud
|
|
||||||
relay required.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`card cursor-pointer border-2 transition-all duration-200 ${mode === 'cloud' ? 'border-blue-500 bg-blue-500/10' : 'border-slate-700/50 hover:border-slate-600'}`}
|
|
||||||
onClick={() => setMode('cloud')}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4 mb-3">
|
|
||||||
<div className="p-3 rounded-lg bg-blue-500/20">
|
|
||||||
<Globe className="w-6 h-6 text-blue-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-white">Cloud Relay</h3>
|
|
||||||
<p className="text-sm text-slate-400">Works from anywhere</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 text-sm text-slate-400">
|
|
||||||
<Smartphone className="w-4 h-4" />
|
|
||||||
<ArrowRight className="w-4 h-4" />
|
|
||||||
<Globe className="w-4 h-4" />
|
|
||||||
<Server className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-slate-400 mt-3">
|
|
||||||
Phone connects to cloud relay server. Works on any network,
|
|
||||||
even different from your server.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onClick={nextStep} className="w-full bg-cyan-500 text-white font-semibold py-3 rounded-lg hover:bg-cyan-600 transition-colors">
|
|
||||||
Continue to Install App
|
|
||||||
<ArrowRight className="inline w-4 h-4 ml-2" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 2 && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h2 className="text-xl font-bold text-white mb-4">Install SMS Gateway App</h2>
|
|
||||||
|
|
||||||
<div className="flex justify-center mb-6">
|
|
||||||
<div className="w-64 h-32 rounded-2xl bg-gradient-to-br from-slate-800 to-slate-900 border-2 border-slate-700 flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-20 h-20 bg-cyan-500/20 rounded-xl flex items-center justify-center mb-2">
|
|
||||||
<Smartphone className="w-10 h-10 text-cyan-400" />
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-500 font-mono">SMS Gateway</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://github.com/capcom6/android-sms-gateway/releases"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="block w-full bg-cyan-500 text-white font-semibold py-4 rounded-lg hover:bg-cyan-600 transition-colors text-center"
|
|
||||||
>
|
|
||||||
<Download className="inline w-4 h-4 mr-2" />
|
|
||||||
Download SMS Gateway for Android
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div className="space-y-3 mt-6">
|
|
||||||
<h3 className="font-semibold text-white mb-2">Installation Steps:</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{[
|
|
||||||
'Download and install APK (enable "Unknown Sources" in Security settings if needed)',
|
|
||||||
'Open app and grant SMS permissions',
|
|
||||||
'Toggle "Local Server" to ON',
|
|
||||||
'Note down IP address and credentials shown in the app',
|
|
||||||
'Keep app running in the background',
|
|
||||||
].map((text, idx) => (
|
|
||||||
<div key={idx} className="flex items-start gap-3 p-3 rounded-lg bg-slate-800/50">
|
|
||||||
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-cyan-500/20 flex items-center justify-center">
|
|
||||||
<span className="text-sm font-semibold text-cyan-400">{idx + 1}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-slate-300">{text}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<details className="mt-4 text-sm text-slate-400">
|
|
||||||
<summary className="cursor-pointer text-cyan-400 hover:text-cyan-300">
|
|
||||||
Having trouble installing?
|
|
||||||
</summary>
|
|
||||||
<div className="mt-2 space-y-2 pl-4 border-l-2 border-slate-700">
|
|
||||||
<p>Make sure you downloaded the correct APK for your device (ARM vs x86)</p>
|
|
||||||
<p>On Android 8+: you may need to grant install permission from the browser</p>
|
|
||||||
<p>Some Samsung devices require you to tap "Install unknown apps" multiple times</p>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<div className="flex gap-3 mt-6">
|
|
||||||
<button onClick={prevStep} className="px-4 py-2 text-slate-400 hover:text-slate-300 transition-colors">
|
|
||||||
<ArrowLeft className="inline w-4 h-4 mr-1" />
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
<button onClick={nextStep} className="flex-1 bg-cyan-500 text-white font-semibold py-2 rounded-lg hover:bg-cyan-600 transition-colors">
|
|
||||||
Continue to Connect
|
|
||||||
<ArrowRight className="inline w-4 h-4 ml-2" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 3 && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h2 className="text-xl font-bold text-white mb-4">Enter Connection Details</h2>
|
|
||||||
|
|
||||||
{mode === 'local' ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
|
||||||
Phone IP Address
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="192.168.1.100"
|
|
||||||
value={connectionDetails.host}
|
|
||||||
onChange={(e) =>
|
|
||||||
setConnectionDetails({ ...connectionDetails, host: e.target.value })
|
|
||||||
}
|
|
||||||
className="w-full bg-slate-800/50 border border-slate-700/50 rounded-lg py-3 px-4 text-white placeholder-slate-500 focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-all"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-slate-500 mt-1">
|
|
||||||
Shown in SMS Gateway app under Local Server
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
|
||||||
Port
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
placeholder="8080"
|
|
||||||
value={connectionDetails.port}
|
|
||||||
onChange={(e) =>
|
|
||||||
setConnectionDetails({ ...connectionDetails, port: e.target.value })
|
|
||||||
}
|
|
||||||
className="w-full bg-slate-800/50 border border-slate-700/50 rounded-lg py-3 px-4 text-white placeholder-slate-500 focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
|
||||||
Username
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="username"
|
|
||||||
value={connectionDetails.username}
|
|
||||||
onChange={(e) =>
|
|
||||||
setConnectionDetails({ ...connectionDetails, username: e.target.value })
|
|
||||||
}
|
|
||||||
className="w-full bg-slate-800/50 border border-slate-700/50 rounded-lg py-3 px-4 text-white placeholder-slate-500 focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
placeholder="password"
|
|
||||||
value={connectionDetails.password}
|
|
||||||
onChange={(e) =>
|
|
||||||
setConnectionDetails({ ...connectionDetails, password: e.target.value })
|
|
||||||
}
|
|
||||||
className="w-full bg-slate-800/50 border border-slate-700/50 rounded-lg py-3 px-4 text-white placeholder-slate-500 focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
|
||||||
Phone Label
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="e.g., Jake's Galaxy S24"
|
|
||||||
value={connectionDetails.label}
|
|
||||||
onChange={(e) =>
|
|
||||||
setConnectionDetails({ ...connectionDetails, label: e.target.value })
|
|
||||||
}
|
|
||||||
className="w-full bg-slate-800/50 border border-slate-700/50 rounded-lg py-3 px-4 text-white placeholder-slate-500 focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
|
||||||
API URL
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="https://api.sms-gate.app"
|
|
||||||
value={connectionDetails.apiUrl}
|
|
||||||
onChange={(e) =>
|
|
||||||
setConnectionDetails({ ...connectionDetails, apiUrl: e.target.value })
|
|
||||||
}
|
|
||||||
className="w-full bg-slate-800/50 border border-slate-700/50 rounded-lg py-3 px-4 text-white placeholder-slate-500 focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
|
||||||
API Key
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="your-api-key-from-app"
|
|
||||||
value={connectionDetails.username}
|
|
||||||
onChange={(e) =>
|
|
||||||
setConnectionDetails({ ...connectionDetails, username: e.target.value })
|
|
||||||
}
|
|
||||||
className="w-full bg-slate-800/50 border border-slate-700/50 rounded-lg py-3 px-4 text-white placeholder-slate-500 focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-4 rounded-lg bg-slate-800/30 border border-slate-700/30 mt-6">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-white">
|
|
||||||
Test if {mode === 'local' ? 'phone is reachable' : 'cloud API is accessible'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={testConnection}
|
|
||||||
disabled={testing}
|
|
||||||
className={`flex items-center gap-2 bg-cyan-500 text-white font-semibold py-2 px-4 rounded-lg hover:bg-cyan-600 transition-colors ${testing ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
||||||
>
|
|
||||||
{testing ? (
|
|
||||||
<div className="w-4 h-4 border-2 border-white/30 border-t-transparent animate-spin rounded-full" />
|
|
||||||
) : (
|
|
||||||
<CheckCircle2 className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
Test Connection
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{testResult.success !== undefined && (
|
|
||||||
<div className={`flex items-center gap-3 p-4 rounded-lg border-2 mt-4 ${testResult.success ? 'border-green-500/30 bg-green-500/10' : 'border-red-500/30 bg-red-500/10'}`}>
|
|
||||||
{testResult.success ? (
|
|
||||||
<CheckCircle2 className="w-6 h-6 text-green-400" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="w-6 h-6 text-red-400" />
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<p className={`font-semibold ${testResult.success ? 'text-green-400' : 'text-red-400'}`}>
|
|
||||||
{testResult.success ? 'Connected!' : testResult.error}
|
|
||||||
</p>
|
|
||||||
{testResult.deviceInfo && (
|
|
||||||
<p className="text-xs text-slate-500 mt-1 font-mono">
|
|
||||||
Device: {testResult.deviceInfo.model || 'Unknown'} • Android{' '}
|
|
||||||
{testResult.deviceInfo.androidVersion || '?'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-3 mt-6">
|
|
||||||
<button onClick={prevStep} className="px-4 py-2 text-slate-400 hover:text-slate-300 transition-colors">
|
|
||||||
<ArrowLeft className="inline w-4 h-4 mr-1" />
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={nextStep}
|
|
||||||
disabled={!connected}
|
|
||||||
className={`flex-1 bg-cyan-500 text-white font-semibold py-2 rounded-lg hover:bg-cyan-600 transition-colors ${!connected && 'opacity-50 cursor-not-allowed'}`}
|
|
||||||
>
|
|
||||||
Continue to Test SMS
|
|
||||||
<ArrowRight className="inline w-4 h-4 ml-2" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 border-t border-slate-700/50 pt-6">
|
|
||||||
<button
|
|
||||||
onClick={() => setTroubleshootingOpen(!troubleshootingOpen)}
|
|
||||||
className="flex items-center gap-2 text-sm text-slate-400 hover:text-slate-300 transition-colors w-full"
|
|
||||||
>
|
|
||||||
<Shield className="w-4 h-4" />
|
|
||||||
{troubleshootingOpen ? (
|
|
||||||
<>
|
|
||||||
Hide Troubleshooting
|
|
||||||
<ChevronUp className="w-4 h-4" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Show Troubleshooting
|
|
||||||
<ChevronDown className="w-4 h-4" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{troubleshootingOpen && (
|
|
||||||
<div className="mt-4 space-y-3">
|
|
||||||
{troubleshootingItems.map((item) => (
|
|
||||||
<details
|
|
||||||
key={item.id}
|
|
||||||
open={expandedTroubleshoot === item.id}
|
|
||||||
onToggle={() => setExpandedTroubleshoot(expandedTroubleshoot === item.id ? null : item.id)}
|
|
||||||
className="group"
|
|
||||||
>
|
|
||||||
<summary className="flex items-start gap-3 cursor-pointer p-4 rounded-lg bg-slate-800/30 hover:bg-slate-800/50 transition-colors">
|
|
||||||
<item.icon className="w-5 h-5 text-slate-400 flex-shrink-0" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="font-medium text-white">{item.title}</p>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
{item.solutions.length} solutions
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{expandedTroubleshoot === item.id ? (
|
|
||||||
<ChevronUp className="w-4 h-4 text-slate-500" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-4 h-4 text-slate-500" />
|
|
||||||
)}
|
|
||||||
</summary>
|
|
||||||
<div className={`mt-2 ml-8 space-y-2 text-sm text-slate-300 overflow-hidden transition-all ${expandedTroubleshoot === item.id ? 'max-h-96' : 'max-h-0'}`}>
|
|
||||||
{item.solutions.map((solution, idx) => (
|
|
||||||
<div key={idx} className="flex items-start gap-2">
|
|
||||||
<span className="text-cyan-400">•</span>
|
|
||||||
<p>{solution}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 4 && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h2 className="text-xl font-bold text-white mb-4">Test SMS</h2>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
|
||||||
Phone Number to Send To
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
placeholder="+15551234567"
|
|
||||||
value={testMessage.to}
|
|
||||||
onChange={(e) =>
|
|
||||||
setTestMessage({ ...testMessage, to: e.target.value })
|
|
||||||
}
|
|
||||||
className="w-full bg-slate-800/50 border border-slate-700/50 rounded-lg py-3 px-4 text-white placeholder-slate-500 focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
|
||||||
Test Message
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
placeholder="Your message here..."
|
|
||||||
value={testMessage.text}
|
|
||||||
onChange={(e) =>
|
|
||||||
setTestMessage({ ...testMessage, text: e.target.value })
|
|
||||||
}
|
|
||||||
rows={3}
|
|
||||||
className="w-full bg-slate-800/50 border border-slate-700/50 rounded-lg py-3 px-4 text-white placeholder-slate-500 focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-all resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={sendTestSms}
|
|
||||||
disabled={sendingTest}
|
|
||||||
className={`w-full flex items-center justify-center gap-2 bg-cyan-500 text-white font-semibold py-3 rounded-lg hover:bg-cyan-600 transition-colors ${sendingTest ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
||||||
>
|
|
||||||
{sendingTest ? (
|
|
||||||
<div className="w-4 h-4 border-2 border-white/30 border-t-transparent animate-spin rounded-full" />
|
|
||||||
) : (
|
|
||||||
<Send className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
Send Test Message
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{testResult.success && (
|
|
||||||
<div className="flex items-center gap-3 p-4 rounded-lg border border-green-500/30 bg-green-500/10 mt-4">
|
|
||||||
<CheckCircle2 className="w-6 h-6 text-green-400" />
|
|
||||||
<p className="font-semibold text-green-400">
|
|
||||||
Test message sent successfully!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-3 mt-6">
|
|
||||||
<button onClick={prevStep} className="px-4 py-2 text-slate-400 hover:text-slate-300 transition-colors">
|
|
||||||
<ArrowLeft className="inline w-4 h-4 mr-1" />
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={nextStep}
|
|
||||||
disabled={!testResult.success}
|
|
||||||
className={`flex-1 bg-cyan-500 text-white font-semibold py-2 rounded-lg hover:bg-cyan-600 transition-colors ${!testResult.success && 'opacity-50 cursor-not-allowed'}`}
|
|
||||||
>
|
|
||||||
Finish
|
|
||||||
<CheckCircle2 className="inline w-4 h-4 ml-2" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 5 && (
|
|
||||||
<div className="space-y-6 text-center">
|
|
||||||
<div className="flex justify-center mb-6">
|
|
||||||
<div className="w-24 h-24 rounded-full bg-green-500/20 flex items-center justify-center">
|
|
||||||
<CheckCircle2 className="w-12 h-12 text-green-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 className="text-2xl font-bold text-white mb-2">
|
|
||||||
Your phone is connected!
|
|
||||||
</h2>
|
|
||||||
<p className="text-slate-400 mb-6">
|
|
||||||
CloseBot SMS is now configured to send SMS through your Android phone.
|
|
||||||
Messages sent via the app will appear in your Dashboard and Conversations.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="card p-6 max-w-md mx-auto text-left">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<Smartphone className="w-8 h-8 text-cyan-400" />
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold text-white">{connectionDetails.label}</p>
|
|
||||||
<p className="text-sm text-slate-400 font-mono">
|
|
||||||
{mode === 'local' ? `${connectionDetails.host}:${connectionDetails.port}` : connectionDetails.apiUrl}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="h-px bg-slate-700/30"></div>
|
|
||||||
<div className="space-y-3 mt-4 text-sm text-slate-400">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
|
||||||
<p>Gateway is active and ready to send messages</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-2 h-2 rounded-full bg-cyan-500" />
|
|
||||||
<p>{mode === 'local' ? 'Messages are free (no carrier fees)' : 'Using cloud relay for connectivity'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg bg-slate-800/30 border border-slate-700/30 p-4 mt-6">
|
|
||||||
<h3 className="font-semibold text-white mb-2 flex items-center gap-2">
|
|
||||||
<Shield className="w-4 h-4 text-cyan-400" />
|
|
||||||
Keep these tips in mind:
|
|
||||||
</h3>
|
|
||||||
<ul className="space-y-2 text-sm text-slate-300">
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
|
||||||
<p>Keep SMS Gateway app running in the background</p>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<div className="w-2 h-2 rounded-full bg-cyan-500" />
|
|
||||||
<p>Don't close app or Android may kill it background service</p>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
|
||||||
<p>Make sure your phone has cellular signal</p>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 mt-6">
|
|
||||||
<button
|
|
||||||
onClick={() => router.push('/')}
|
|
||||||
className="w-full bg-cyan-500 text-white font-semibold py-3 rounded-lg hover:bg-cyan-600 transition-colors"
|
|
||||||
>
|
|
||||||
Go to Dashboard
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setStep(1);
|
|
||||||
setConnected(false);
|
|
||||||
setTestResult({});
|
|
||||||
setTestMessage({ to: '', text: 'Hello from CloseBot SMS! Your Android gateway is working.' });
|
|
||||||
}}
|
|
||||||
className="w-full text-slate-400 hover:text-slate-300 py-2 transition-colors"
|
|
||||||
>
|
|
||||||
Connect Another Phone
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { ContactFilters } from '@/components/contacts/contact-filters';
|
|
||||||
import { ContactsTable } from '@/components/contacts/contacts-table';
|
|
||||||
import { ContactDetailPanel } from '@/components/contacts/contact-detail-panel';
|
|
||||||
|
|
||||||
export interface ContactFilterParams {
|
|
||||||
search?: string;
|
|
||||||
status?: string;
|
|
||||||
bot?: string;
|
|
||||||
dateFrom?: string;
|
|
||||||
dateTo?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ContactsPage() {
|
|
||||||
const [selectedContactId, setSelectedContactId] = useState<string | null>(null);
|
|
||||||
const [filters, setFilters] = useState<ContactFilterParams>({});
|
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
|
||||||
|
|
||||||
const handleFilterChange = (newFilters: ContactFilterParams) => {
|
|
||||||
setFilters(newFilters);
|
|
||||||
setSelectedContactId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleContactUpdated = () => {
|
|
||||||
setRefreshKey((k) => k + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen p-6" style={{ background: 'var(--bg-primary)' }}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<h1 className="text-2xl font-bold text-white tracking-tight">Contacts / Leads</h1>
|
|
||||||
<p className="text-sm mt-1" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Manage your contacts, track lead status, and view conversation history.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<ContactFilters onChange={handleFilterChange} />
|
|
||||||
|
|
||||||
{/* Table + Detail Panel */}
|
|
||||||
<div className="flex gap-4 mt-4">
|
|
||||||
<div className={`transition-all duration-300 ${selectedContactId ? 'flex-1 min-w-0' : 'w-full'}`}>
|
|
||||||
<ContactsTable
|
|
||||||
filters={filters}
|
|
||||||
refreshKey={refreshKey}
|
|
||||||
selectedContactId={selectedContactId}
|
|
||||||
onSelect={(id) => setSelectedContactId(id === selectedContactId ? null : id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedContactId && (
|
|
||||||
<ContactDetailPanel
|
|
||||||
contactId={selectedContactId}
|
|
||||||
onClose={() => setSelectedContactId(null)}
|
|
||||||
onUpdated={handleContactUpdated}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { MessageSquare } from 'lucide-react';
|
|
||||||
import { ConversationList } from '@/components/conversations/conversation-list';
|
|
||||||
import { ChatThread } from '@/components/conversations/chat-thread';
|
|
||||||
|
|
||||||
export default function ConversationsPage() {
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-[calc(100vh-64px)] overflow-hidden">
|
|
||||||
{/* Left panel – conversation list */}
|
|
||||||
<div
|
|
||||||
className={`w-full lg:w-[380px] flex-shrink-0 ${
|
|
||||||
selectedId ? 'hidden lg:flex' : 'flex'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="w-full h-full">
|
|
||||||
<ConversationList selectedId={selectedId} onSelect={setSelectedId} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right panel – chat thread or placeholder */}
|
|
||||||
<div className={`flex-1 ${selectedId ? 'flex' : 'hidden lg:flex'}`}>
|
|
||||||
{selectedId ? (
|
|
||||||
<ChatThread
|
|
||||||
key={selectedId}
|
|
||||||
contactId={selectedId}
|
|
||||||
onBack={() => setSelectedId(null)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex-1 flex items-center justify-center bg-[#0f1729]">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-20 h-20 rounded-2xl bg-[#1a2332] border border-[#334155] flex items-center justify-center mx-auto mb-5">
|
|
||||||
<MessageSquare className="w-10 h-10 text-cyan-400/40" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-bold text-slate-300 mb-2">
|
|
||||||
Select a Conversation
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-slate-500 max-w-[280px]">
|
|
||||||
Choose a contact from the list to view their messages and continue the conversation
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,525 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback, Suspense } from 'react';
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import {
|
|
||||||
Globe,
|
|
||||||
ArrowLeft,
|
|
||||||
Sparkles,
|
|
||||||
Download,
|
|
||||||
Send,
|
|
||||||
Eye,
|
|
||||||
FileText,
|
|
||||||
Shield,
|
|
||||||
Loader2,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface FormData {
|
|
||||||
businessName: string;
|
|
||||||
businessSlug: string;
|
|
||||||
useCase: string;
|
|
||||||
messageFrequency: string;
|
|
||||||
contactEmail: string;
|
|
||||||
contactPhone: string;
|
|
||||||
brandColor: string;
|
|
||||||
logoUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultForm: FormData = {
|
|
||||||
businessName: '',
|
|
||||||
businessSlug: '',
|
|
||||||
useCase: '',
|
|
||||||
messageFrequency: 'up to 5 messages per week',
|
|
||||||
contactEmail: '',
|
|
||||||
contactPhone: '',
|
|
||||||
brandColor: '#3B82F6',
|
|
||||||
logoUrl: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
function CreateLandingPageInner() {
|
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const editId = searchParams.get('edit');
|
|
||||||
|
|
||||||
const [form, setForm] = useState<FormData>(defaultForm);
|
|
||||||
const [previewHtml, setPreviewHtml] = useState('');
|
|
||||||
const [activeTab, setActiveTab] = useState<'optin' | 'privacy' | 'terms'>('optin');
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [generating, setGenerating] = useState(false);
|
|
||||||
const [generatedId, setGeneratedId] = useState<string | null>(editId);
|
|
||||||
const [slugManual, setSlugManual] = useState(false);
|
|
||||||
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
||||||
|
|
||||||
// Auto-generate slug from business name
|
|
||||||
useEffect(() => {
|
|
||||||
if (!slugManual && form.businessName) {
|
|
||||||
const slug = form.businessName
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '')
|
|
||||||
.substring(0, 50);
|
|
||||||
setForm((prev) => ({ ...prev, businessSlug: slug }));
|
|
||||||
}
|
|
||||||
}, [form.businessName, slugManual]);
|
|
||||||
|
|
||||||
// Load existing page if editing
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editId) return;
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/landing-pages/${editId}`);
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.page) {
|
|
||||||
const config = JSON.parse(data.page.config_json);
|
|
||||||
setForm({
|
|
||||||
businessName: config.businessName || '',
|
|
||||||
businessSlug: config.businessSlug || '',
|
|
||||||
useCase: config.useCase || '',
|
|
||||||
messageFrequency: config.messageFrequency || 'up to 5 messages per week',
|
|
||||||
contactEmail: config.contactEmail || '',
|
|
||||||
contactPhone: config.contactPhone || '',
|
|
||||||
brandColor: config.brandColor || '#3B82F6',
|
|
||||||
logoUrl: config.logoUrl || '',
|
|
||||||
});
|
|
||||||
setSlugManual(true);
|
|
||||||
setGeneratedId(editId);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [editId]);
|
|
||||||
|
|
||||||
// Debounced live preview
|
|
||||||
const updatePreview = useCallback(async (data: FormData) => {
|
|
||||||
if (!data.businessName) {
|
|
||||||
setPreviewHtml('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/landing-pages/generate-preview', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
const html = await res.text();
|
|
||||||
setPreviewHtml(html);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
||||||
debounceRef.current = setTimeout(() => updatePreview(form), 300);
|
|
||||||
return () => {
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
||||||
};
|
|
||||||
}, [form, updatePreview]);
|
|
||||||
|
|
||||||
function handleChange(field: keyof FormData, value: string) {
|
|
||||||
if (field === 'businessSlug') setSlugManual(true);
|
|
||||||
setForm((prev) => ({ ...prev, [field]: value }));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleGenerate() {
|
|
||||||
setGenerating(true);
|
|
||||||
try {
|
|
||||||
if (generatedId && editId) {
|
|
||||||
// Update existing
|
|
||||||
await fetch(`/api/landing-pages/${generatedId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(form),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Create new
|
|
||||||
const res = await fetch('/api/landing-pages', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(form),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.id) setGeneratedId(data.id);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
setGenerating(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handlePublish() {
|
|
||||||
if (!generatedId) {
|
|
||||||
await handleGenerate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await fetch(`/api/landing-pages/${generatedId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ ...form, status: 'published' }),
|
|
||||||
});
|
|
||||||
router.push('/landing-pages');
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDownload() {
|
|
||||||
const id = generatedId;
|
|
||||||
if (!id) {
|
|
||||||
// Generate first, then download
|
|
||||||
setGenerating(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/landing-pages', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(form),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.id) {
|
|
||||||
setGeneratedId(data.id);
|
|
||||||
await doDownload(data.id);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
setGenerating(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await doDownload(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doDownload(id: string) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/landing-pages/${id}/download`);
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.files) {
|
|
||||||
for (const file of data.files as { name: string; content: string }[]) {
|
|
||||||
const blob = new Blob([file.content], { type: 'text/html' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `${data.slug || 'landing'}-${file.name}`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = form.businessName && form.useCase && form.messageFrequency && form.contactEmail && form.contactPhone;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Link
|
|
||||||
href="/landing-pages"
|
|
||||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-700 bg-slate-800/50 text-slate-400 hover:text-white hover:bg-slate-700/50 transition-colors"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-cyan-500/10 border border-cyan-500/20">
|
|
||||||
<Globe className="h-5 w-5 text-cyan-400" />
|
|
||||||
</div>
|
|
||||||
{editId ? 'Edit Landing Page' : 'Generate Landing Page'}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-slate-400">
|
|
||||||
Create TCPA-compliant opt-in pages with privacy policy and terms
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Split Layout */}
|
|
||||||
<div className="flex gap-6" style={{ minHeight: 'calc(100vh - 200px)' }}>
|
|
||||||
{/* Left: Form (60%) */}
|
|
||||||
<div className="w-[60%] space-y-5">
|
|
||||||
<div className="rounded-2xl border border-slate-800/60 bg-[#0d1321] p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-white mb-5 flex items-center gap-2">
|
|
||||||
<FileText className="h-5 w-5 text-cyan-400" />
|
|
||||||
Business Information
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Business Name */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-1.5">
|
|
||||||
Business Name <span className="text-red-400">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.businessName}
|
|
||||||
onChange={(e) => handleChange('businessName', e.target.value)}
|
|
||||||
placeholder="Acme Fitness Studio"
|
|
||||||
className="w-full rounded-lg border border-slate-700 bg-slate-800/50 px-4 py-2.5 text-sm text-white placeholder:text-slate-600 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Business Slug */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-1.5">
|
|
||||||
Business Slug
|
|
||||||
</label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-slate-500">/</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.businessSlug}
|
|
||||||
onChange={(e) => handleChange('businessSlug', e.target.value)}
|
|
||||||
placeholder="acme-fitness-studio"
|
|
||||||
className="flex-1 rounded-lg border border-slate-700 bg-slate-800/50 px-4 py-2.5 text-sm text-white placeholder:text-slate-600 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500 font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Use Case */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-1.5">
|
|
||||||
Use Case Description <span className="text-red-400">*</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={form.useCase}
|
|
||||||
onChange={(e) => handleChange('useCase', e.target.value)}
|
|
||||||
placeholder="Class schedule updates, booking confirmations, and membership reminders for our fitness studio members."
|
|
||||||
rows={3}
|
|
||||||
className="w-full rounded-lg border border-slate-700 bg-slate-800/50 px-4 py-2.5 text-sm text-white placeholder:text-slate-600 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500 resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Message Frequency */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-1.5">
|
|
||||||
Message Frequency <span className="text-red-400">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.messageFrequency}
|
|
||||||
onChange={(e) => handleChange('messageFrequency', e.target.value)}
|
|
||||||
placeholder="up to 5 messages per week"
|
|
||||||
className="w-full rounded-lg border border-slate-700 bg-slate-800/50 px-4 py-2.5 text-sm text-white placeholder:text-slate-600 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact Row */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-1.5">
|
|
||||||
Contact Email <span className="text-red-400">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={form.contactEmail}
|
|
||||||
onChange={(e) => handleChange('contactEmail', e.target.value)}
|
|
||||||
placeholder="support@acme.com"
|
|
||||||
className="w-full rounded-lg border border-slate-700 bg-slate-800/50 px-4 py-2.5 text-sm text-white placeholder:text-slate-600 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-1.5">
|
|
||||||
Contact Phone <span className="text-red-400">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
value={form.contactPhone}
|
|
||||||
onChange={(e) => handleChange('contactPhone', e.target.value)}
|
|
||||||
placeholder="+15551234567"
|
|
||||||
className="w-full rounded-lg border border-slate-700 bg-slate-800/50 px-4 py-2.5 text-sm text-white placeholder:text-slate-600 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Brand Color + Logo */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-1.5">
|
|
||||||
Brand Color
|
|
||||||
</label>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={form.brandColor}
|
|
||||||
onChange={(e) => handleChange('brandColor', e.target.value)}
|
|
||||||
className="h-10 w-14 cursor-pointer rounded-lg border border-slate-700 bg-slate-800/50 p-1"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.brandColor}
|
|
||||||
onChange={(e) => handleChange('brandColor', e.target.value)}
|
|
||||||
className="flex-1 rounded-lg border border-slate-700 bg-slate-800/50 px-4 py-2.5 text-sm text-white font-mono focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-1.5">
|
|
||||||
Logo URL <span className="text-slate-600">(optional)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={form.logoUrl}
|
|
||||||
onChange={(e) => handleChange('logoUrl', e.target.value)}
|
|
||||||
placeholder="https://example.com/logo.png"
|
|
||||||
className="w-full rounded-lg border border-slate-700 bg-slate-800/50 px-4 py-2.5 text-sm text-white placeholder:text-slate-600 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Generated Pages Info */}
|
|
||||||
<div className="rounded-2xl border border-slate-800/60 bg-[#0d1321] p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
|
||||||
<Shield className="h-5 w-5 text-cyan-400" />
|
|
||||||
Generated Pages
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
{[
|
|
||||||
{ label: 'Opt-In Page', desc: 'Phone input + consent checkbox', icon: Globe },
|
|
||||||
{ label: 'Privacy Policy', desc: 'TCPA-compliant privacy policy', icon: FileText },
|
|
||||||
{ label: 'Terms of Service', desc: 'SMS program terms', icon: Shield },
|
|
||||||
].map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.label}
|
|
||||||
className="rounded-xl border border-slate-800/60 bg-slate-800/20 p-4"
|
|
||||||
>
|
|
||||||
<item.icon className="h-5 w-5 text-cyan-400 mb-2" />
|
|
||||||
<p className="text-sm font-medium text-white">{item.label}</p>
|
|
||||||
<p className="text-xs text-slate-500 mt-1">{item.desc}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={handleGenerate}
|
|
||||||
disabled={!isValid || generating}
|
|
||||||
className="flex items-center gap-2 rounded-lg bg-cyan-500 px-5 py-2.5 text-sm font-semibold text-white hover:bg-cyan-400 transition-colors shadow-lg shadow-cyan-500/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{generating ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Sparkles className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{generatedId ? 'Regenerate Pages' : 'Generate Pages'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleDownload}
|
|
||||||
disabled={!isValid}
|
|
||||||
className="flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-800/50 px-5 py-2.5 text-sm font-medium text-slate-300 hover:bg-slate-700/50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
Download Files
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handlePublish}
|
|
||||||
disabled={!isValid || saving}
|
|
||||||
className="flex items-center gap-2 rounded-lg bg-green-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-green-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{saving ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Send className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Publish
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: Live Preview (40%) */}
|
|
||||||
<div className="w-[40%]">
|
|
||||||
<div className="sticky top-6 rounded-2xl border border-slate-800/60 bg-[#0d1321] overflow-hidden">
|
|
||||||
{/* Browser Chrome */}
|
|
||||||
<div className="flex items-center gap-2 px-4 py-3 bg-slate-800/50 border-b border-slate-800/60">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<div className="h-3 w-3 rounded-full bg-red-500/60" />
|
|
||||||
<div className="h-3 w-3 rounded-full bg-yellow-500/60" />
|
|
||||||
<div className="h-3 w-3 rounded-full bg-green-500/60" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 ml-3 rounded-md bg-slate-900/60 border border-slate-700/50 px-3 py-1">
|
|
||||||
<span className="text-xs text-slate-500 font-mono">
|
|
||||||
{form.businessSlug ? `https://${form.businessSlug}.yourdomain.com` : 'https://your-page.com'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab Bar */}
|
|
||||||
<div className="flex border-b border-slate-800/60">
|
|
||||||
{(['optin', 'privacy', 'terms'] as const).map((tab) => {
|
|
||||||
const labels = { optin: 'Opt-In', privacy: 'Privacy', terms: 'Terms' };
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tab}
|
|
||||||
onClick={() => setActiveTab(tab)}
|
|
||||||
className={`flex-1 px-4 py-2 text-xs font-medium transition-colors ${
|
|
||||||
activeTab === tab
|
|
||||||
? 'text-cyan-400 border-b-2 border-cyan-400 bg-cyan-500/5'
|
|
||||||
: 'text-slate-500 hover:text-slate-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{labels[tab]}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Preview */}
|
|
||||||
<div className="relative" style={{ height: '600px' }}>
|
|
||||||
{previewHtml ? (
|
|
||||||
<iframe
|
|
||||||
ref={iframeRef}
|
|
||||||
srcDoc={activeTab === 'optin' ? previewHtml : `
|
|
||||||
<div style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:Inter,sans-serif;background:#f9fafb;">
|
|
||||||
<div style="text-align:center;color:#6b7280;">
|
|
||||||
<p style="font-size:14px;font-weight:600;margin-bottom:4px;">${activeTab === 'privacy' ? 'Privacy Policy' : 'Terms of Service'}</p>
|
|
||||||
<p style="font-size:12px;">Generate pages to preview this document</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`}
|
|
||||||
className="w-full h-full border-0"
|
|
||||||
title="Landing page preview"
|
|
||||||
sandbox="allow-same-origin"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full text-center p-8">
|
|
||||||
<Eye className="h-10 w-10 text-slate-700 mb-4" />
|
|
||||||
<p className="text-sm font-medium text-slate-500">Live Preview</p>
|
|
||||||
<p className="text-xs text-slate-600 mt-1">
|
|
||||||
Start filling in the form to see a real-time preview
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CreateLandingPage() {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={
|
|
||||||
<div className="flex items-center justify-center py-20">
|
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-cyan-500 border-t-transparent" />
|
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
<CreateLandingPageInner />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,246 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import {
|
|
||||||
Globe,
|
|
||||||
Plus,
|
|
||||||
Eye,
|
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
ExternalLink,
|
|
||||||
FileText,
|
|
||||||
RefreshCw,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface LandingPageItem {
|
|
||||||
id: string;
|
|
||||||
business_name: string;
|
|
||||||
business_slug: string;
|
|
||||||
config_json: string;
|
|
||||||
status: string;
|
|
||||||
published_url: string | null;
|
|
||||||
a2p_registration_id: string | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusColors: Record<string, { bg: string; text: string; dot: string }> = {
|
|
||||||
draft: { bg: 'bg-slate-500/10', text: 'text-slate-400', dot: 'bg-slate-400' },
|
|
||||||
published: { bg: 'bg-green-500/10', text: 'text-green-400', dot: 'bg-green-400' },
|
|
||||||
active: { bg: 'bg-cyan-500/10', text: 'text-cyan-400', dot: 'bg-cyan-400' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function LandingPagesPage() {
|
|
||||||
const [pages, setPages] = useState<LandingPageItem[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [deleting, setDeleting] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const fetchPages = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/landing-pages');
|
|
||||||
const data = await res.json();
|
|
||||||
setPages(data.pages || []);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchPages();
|
|
||||||
}, [fetchPages]);
|
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
|
||||||
if (!confirm('Delete this landing page set? This cannot be undone.')) return;
|
|
||||||
setDeleting(id);
|
|
||||||
try {
|
|
||||||
await fetch(`/api/landing-pages/${id}`, { method: 'DELETE' });
|
|
||||||
setPages((prev) => prev.filter((p) => p.id !== id));
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
setDeleting(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDownload(id: string, slug: string) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/landing-pages/${id}/download`);
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.files) {
|
|
||||||
for (const file of data.files as { name: string; content: string }[]) {
|
|
||||||
const blob = new Blob([file.content], { type: 'text/html' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `${slug}-${file.name}`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-cyan-500/10 border border-cyan-500/20">
|
|
||||||
<Globe className="h-5 w-5 text-cyan-400" />
|
|
||||||
</div>
|
|
||||||
Landing Pages
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-slate-400">
|
|
||||||
Generate TCPA-compliant opt-in pages for your A2P campaigns
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={fetchPages}
|
|
||||||
className="flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-800/50 px-4 py-2.5 text-sm font-medium text-slate-300 hover:bg-slate-700/50 transition-colors"
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
href="/landing-pages/create"
|
|
||||||
className="flex items-center gap-2 rounded-lg bg-cyan-500 px-4 py-2.5 text-sm font-semibold text-white hover:bg-cyan-400 transition-colors shadow-lg shadow-cyan-500/20"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Generate New
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Loading */}
|
|
||||||
{loading && (
|
|
||||||
<div className="flex items-center justify-center py-20">
|
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-cyan-500 border-t-transparent" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty State */}
|
|
||||||
{!loading && pages.length === 0 && (
|
|
||||||
<div className="rounded-2xl border border-slate-800/60 bg-[#0d1321] p-12 text-center">
|
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-800/50 mx-auto mb-4">
|
|
||||||
<FileText className="h-8 w-8 text-slate-500" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-2">No Landing Pages Yet</h3>
|
|
||||||
<p className="text-sm text-slate-400 mb-6 max-w-md mx-auto">
|
|
||||||
Generate TCPA-compliant opt-in pages with privacy policy and terms of service — ready for your A2P registration.
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/landing-pages/create"
|
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-cyan-500 px-5 py-2.5 text-sm font-semibold text-white hover:bg-cyan-400 transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Create Your First Landing Page
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Grid */}
|
|
||||||
{!loading && pages.length > 0 && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
|
|
||||||
{pages.map((page) => {
|
|
||||||
const status = statusColors[page.status] || statusColors.draft;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={page.id}
|
|
||||||
className="group rounded-2xl border border-slate-800/60 bg-[#0d1321] overflow-hidden hover:border-slate-700/60 transition-all"
|
|
||||||
>
|
|
||||||
{/* Preview iframe */}
|
|
||||||
<div className="relative h-48 bg-slate-900 overflow-hidden">
|
|
||||||
<iframe
|
|
||||||
src={`/api/landing-pages/${page.id}/preview`}
|
|
||||||
className="w-[200%] h-[200%] origin-top-left scale-50 pointer-events-none"
|
|
||||||
title={`Preview: ${page.business_name}`}
|
|
||||||
sandbox=""
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-[#0d1321]/80" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-5">
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-base font-semibold text-white group-hover:text-cyan-400 transition-colors">
|
|
||||||
{page.business_name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-slate-500 font-mono mt-0.5">
|
|
||||||
/{page.business_slug}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${status.bg} ${status.text}`}
|
|
||||||
>
|
|
||||||
<span className={`h-1.5 w-1.5 rounded-full ${status.dot}`} />
|
|
||||||
{page.status.charAt(0).toUpperCase() + page.status.slice(1)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{page.published_url && (
|
|
||||||
<a
|
|
||||||
href={page.published_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1 text-xs text-cyan-400 hover:text-cyan-300 mb-3"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
{page.published_url}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-xs text-slate-500 mb-4">
|
|
||||||
Created {new Date(page.created_at).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<a
|
|
||||||
href={`/api/landing-pages/${page.id}/preview`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-slate-700 bg-slate-800/50 px-3 py-1.5 text-xs font-medium text-slate-300 hover:bg-slate-700/50 transition-colors"
|
|
||||||
>
|
|
||||||
<Eye className="h-3 w-3" />
|
|
||||||
Preview
|
|
||||||
</a>
|
|
||||||
<Link
|
|
||||||
href={`/landing-pages/create?edit=${page.id}`}
|
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-slate-700 bg-slate-800/50 px-3 py-1.5 text-xs font-medium text-slate-300 hover:bg-slate-700/50 transition-colors"
|
|
||||||
>
|
|
||||||
<Pencil className="h-3 w-3" />
|
|
||||||
Edit
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDownload(page.id, page.business_slug)}
|
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-slate-700 bg-slate-800/50 px-3 py-1.5 text-xs font-medium text-slate-300 hover:bg-slate-700/50 transition-colors"
|
|
||||||
>
|
|
||||||
<FileText className="h-3 w-3" />
|
|
||||||
Download
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(page.id)}
|
|
||||||
disabled={deleting === page.id}
|
|
||||||
className="ml-auto flex items-center gap-1.5 rounded-lg border border-red-900/30 bg-red-500/5 px-3 py-1.5 text-xs font-medium text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
import type { Metadata } from 'next';
|
|
||||||
import '@/styles/globals.css';
|
|
||||||
import Sidebar from '@/components/layout/sidebar';
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'CloseBot SMS — Command Center',
|
|
||||||
description: 'AI-powered SMS conversation management dashboard',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<html lang="en" className="dark">
|
|
||||||
<head>
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body className="min-h-screen bg-[#0f1729] text-slate-200 antialiased">
|
|
||||||
<Sidebar />
|
|
||||||
<main className="ml-[240px] min-h-screen">
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,366 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Search,
|
|
||||||
MessageSquare,
|
|
||||||
Mail,
|
|
||||||
Calendar,
|
|
||||||
TrendingUp,
|
|
||||||
Activity,
|
|
||||||
Phone,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { cn, formatPhone, relativeTime } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface DashboardStats {
|
|
||||||
activeConversations: number;
|
|
||||||
messagesToday: number;
|
|
||||||
bookingsMade: number;
|
|
||||||
responseRate: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActivityEvent {
|
|
||||||
id: number;
|
|
||||||
type: string;
|
|
||||||
phone: string | null;
|
|
||||||
name: string | null;
|
|
||||||
bot_name: string | null;
|
|
||||||
data_json: string | null;
|
|
||||||
created_at: string;
|
|
||||||
contact_id: string | null;
|
|
||||||
route_id: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statCards = [
|
|
||||||
{
|
|
||||||
key: 'activeConversations' as const,
|
|
||||||
label: 'Contacts This Month',
|
|
||||||
icon: MessageSquare,
|
|
||||||
format: (v: number) => v.toLocaleString(),
|
|
||||||
accent: 'cyan',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'messagesToday' as const,
|
|
||||||
label: 'Messages This Month',
|
|
||||||
icon: Mail,
|
|
||||||
format: (v: number) => v.toLocaleString(),
|
|
||||||
accent: 'blue',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'bookingsMade' as const,
|
|
||||||
label: 'Bookings This Month',
|
|
||||||
icon: Calendar,
|
|
||||||
format: (v: number) => v.toLocaleString(),
|
|
||||||
accent: 'green',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'responseRate' as const,
|
|
||||||
label: 'Engagement Score',
|
|
||||||
icon: TrendingUp,
|
|
||||||
format: (v: number) => `${v}%`,
|
|
||||||
accent: 'purple',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const accentColors: Record<string, { icon: string; glow: string; bg: string }> = {
|
|
||||||
cyan: {
|
|
||||||
icon: 'text-cyan-400',
|
|
||||||
glow: 'shadow-[0_0_20px_rgba(34,211,238,0.08)] border-cyan-500/20',
|
|
||||||
bg: 'bg-cyan-500/10',
|
|
||||||
},
|
|
||||||
blue: {
|
|
||||||
icon: 'text-blue-400',
|
|
||||||
glow: 'shadow-[0_0_20px_rgba(59,130,246,0.08)] border-blue-500/20',
|
|
||||||
bg: 'bg-blue-500/10',
|
|
||||||
},
|
|
||||||
green: {
|
|
||||||
icon: 'text-green-400',
|
|
||||||
glow: 'shadow-[0_0_20px_rgba(34,197,94,0.08)] border-green-500/20',
|
|
||||||
bg: 'bg-green-500/10',
|
|
||||||
},
|
|
||||||
purple: {
|
|
||||||
icon: 'text-purple-400',
|
|
||||||
glow: 'shadow-[0_0_20px_rgba(168,85,247,0.08)] border-purple-500/20',
|
|
||||||
bg: 'bg-purple-500/10',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function getStatusBadge(type: string) {
|
|
||||||
switch (type) {
|
|
||||||
case 'conversation_start':
|
|
||||||
case 'message_received':
|
|
||||||
return { label: 'Active', className: 'badge badge-active' };
|
|
||||||
case 'message_sent':
|
|
||||||
case 'bot_response':
|
|
||||||
return { label: 'Pending', className: 'badge badge-pending' };
|
|
||||||
case 'conversation_end':
|
|
||||||
return { label: 'Closed', className: 'badge badge-closed' };
|
|
||||||
default:
|
|
||||||
return { label: type.replace(/_/g, ' '), className: 'badge badge-new' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
|
||||||
const [stats, setStats] = useState<DashboardStats>({
|
|
||||||
activeConversations: 0,
|
|
||||||
messagesToday: 0,
|
|
||||||
bookingsMade: 0,
|
|
||||||
responseRate: 0,
|
|
||||||
});
|
|
||||||
const [activity, setActivity] = useState<ActivityEvent[]>([]);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchData() {
|
|
||||||
try {
|
|
||||||
const [statsRes, activityRes] = await Promise.all([
|
|
||||||
fetch('/api/dashboard/stats'),
|
|
||||||
fetch('/api/dashboard/activity'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (statsRes.ok) {
|
|
||||||
const mergedStats = await statsRes.json();
|
|
||||||
setStats(mergedStats);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activityRes.ok) {
|
|
||||||
const data = await activityRes.json();
|
|
||||||
setActivity(Array.isArray(data) ? data : data.events || []);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch dashboard data:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
|
|
||||||
// Set up SSE for real-time updates
|
|
||||||
let eventSource: EventSource | null = null;
|
|
||||||
try {
|
|
||||||
eventSource = new EventSource('/api/dashboard/activity?stream=true');
|
|
||||||
|
|
||||||
eventSource.addEventListener('activity', (e) => {
|
|
||||||
try {
|
|
||||||
const event = JSON.parse(e.data);
|
|
||||||
setActivity((prev) => [event, ...prev].slice(0, 20));
|
|
||||||
} catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
eventSource.addEventListener('stats', (e) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(e.data);
|
|
||||||
setStats(data);
|
|
||||||
} catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
eventSource.onerror = () => {
|
|
||||||
eventSource?.close();
|
|
||||||
};
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
eventSource?.close();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const filteredActivity = search
|
|
||||||
? activity.filter(
|
|
||||||
(e) =>
|
|
||||||
e.phone?.includes(search) ||
|
|
||||||
e.name?.toLowerCase().includes(search.toLowerCase()) ||
|
|
||||||
e.bot_name?.toLowerCase().includes(search.toLowerCase())
|
|
||||||
)
|
|
||||||
: activity;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6 lg:p-8 space-y-6">
|
|
||||||
{/* Header with search */}
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
|
||||||
<p className="text-sm text-slate-500 mt-0.5">
|
|
||||||
Real-time overview of your SMS operations
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="relative w-80">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search conversations, contacts..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
className="w-full rounded-lg border border-slate-700/50 bg-slate-800/50 py-2.5 pl-10 pr-4 text-sm text-slate-200 placeholder-slate-500 outline-none transition-colors focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 backdrop-blur-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stat cards */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{statCards.map((card) => {
|
|
||||||
const Icon = card.icon;
|
|
||||||
const colors = accentColors[card.accent];
|
|
||||||
const value = stats[card.key];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={card.key}
|
|
||||||
className={cn(
|
|
||||||
'card card-glow relative overflow-hidden p-5 transition-all duration-300 hover:scale-[1.02]',
|
|
||||||
colors.glow
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Subtle gradient overlay */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.02] to-transparent pointer-events-none" />
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex h-10 w-10 items-center justify-center rounded-lg',
|
|
||||||
colors.bg
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className={cn('h-5 w-5', colors.icon)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs font-medium uppercase tracking-wider text-slate-500 mb-1">
|
|
||||||
{card.label}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className={cn('text-3xl font-bold num text-white')}>
|
|
||||||
{loading ? (
|
|
||||||
<span className="inline-block h-9 w-16 animate-pulse rounded bg-slate-700/50" />
|
|
||||||
) : (
|
|
||||||
card.format(value)
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Real-time Activity */}
|
|
||||||
<div className="card overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between border-b border-slate-700/50 px-6 py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-500/10">
|
|
||||||
<Activity className="h-4 w-4 text-cyan-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold text-white">
|
|
||||||
Real-time Activity
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
Latest SMS events across all routes
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
|
||||||
<span className="text-xs text-slate-500">Live</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-slate-700/30">
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500">
|
|
||||||
SMS
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500">
|
|
||||||
Bot Name
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500">
|
|
||||||
Status
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-slate-500">
|
|
||||||
Timestamp
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-700/20">
|
|
||||||
{loading ? (
|
|
||||||
Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<tr key={i}>
|
|
||||||
<td className="px-6 py-4" colSpan={4}>
|
|
||||||
<div className="h-4 w-full animate-pulse rounded bg-slate-700/30" />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : filteredActivity.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={4}
|
|
||||||
className="px-6 py-12 text-center text-sm text-slate-500"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<MessageSquare className="h-8 w-8 text-slate-600" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-slate-400">
|
|
||||||
No activity yet
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-slate-600">
|
|
||||||
Events will appear here as SMS conversations come in
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
filteredActivity.map((event) => {
|
|
||||||
const badge = getStatusBadge(event.type);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={event.id}
|
|
||||||
className="transition-colors hover:bg-slate-800/30"
|
|
||||||
>
|
|
||||||
<td className="px-6 py-3.5">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-700/50">
|
|
||||||
<Phone className="h-3.5 w-3.5 text-slate-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-slate-200 num">
|
|
||||||
{event.phone
|
|
||||||
? formatPhone(event.phone)
|
|
||||||
: 'Unknown'}
|
|
||||||
</p>
|
|
||||||
{event.name && (
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
{event.name}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-3.5">
|
|
||||||
<span className="text-sm text-slate-300">
|
|
||||||
{event.bot_name || '—'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-3.5">
|
|
||||||
<span className={badge.className}>{badge.label}</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-3.5 text-right">
|
|
||||||
<span className="text-xs text-slate-500 num">
|
|
||||||
{relativeTime(event.created_at)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,764 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
Phone,
|
|
||||||
Search,
|
|
||||||
ShoppingCart,
|
|
||||||
Trash2,
|
|
||||||
Settings,
|
|
||||||
Globe,
|
|
||||||
MessageSquare,
|
|
||||||
PhoneCall,
|
|
||||||
Mail,
|
|
||||||
AlertTriangle,
|
|
||||||
X,
|
|
||||||
RefreshCw,
|
|
||||||
Check,
|
|
||||||
Loader2,
|
|
||||||
Hash,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { formatPhone } from '@/lib/utils';
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Types */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
interface OwnedNumber {
|
|
||||||
sid: string;
|
|
||||||
phoneNumber: string;
|
|
||||||
friendlyName: string;
|
|
||||||
smsEnabled: boolean;
|
|
||||||
voiceEnabled: boolean;
|
|
||||||
mmsEnabled: boolean;
|
|
||||||
dateCreated: string;
|
|
||||||
smsUrl: string | null;
|
|
||||||
voiceUrl: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AvailableNumber {
|
|
||||||
phoneNumber: string;
|
|
||||||
friendlyName: string;
|
|
||||||
locality: string;
|
|
||||||
region: string;
|
|
||||||
isoCountry: string;
|
|
||||||
capabilities: {
|
|
||||||
sms: boolean;
|
|
||||||
voice: boolean;
|
|
||||||
mms: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Capability Badge */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function CapBadge({ label, active }: { label: string; active: boolean }) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wider border',
|
|
||||||
active
|
|
||||||
? 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30'
|
|
||||||
: 'bg-slate-700/30 text-slate-600 border-slate-700/40'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label === 'SMS' && <MessageSquare className="h-2.5 w-2.5" />}
|
|
||||||
{label === 'Voice' && <PhoneCall className="h-2.5 w-2.5" />}
|
|
||||||
{label === 'MMS' && <Mail className="h-2.5 w-2.5" />}
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Confirmation Modal */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function ConfirmModal({
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
confirmLabel,
|
|
||||||
danger,
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
loading,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
confirmLabel: string;
|
|
||||||
danger?: boolean;
|
|
||||||
onConfirm: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
loading?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
|
||||||
<div className="card w-full max-w-md p-6 mx-4">
|
|
||||||
<div className="flex items-start gap-3 mb-4">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex h-10 w-10 items-center justify-center rounded-full flex-shrink-0',
|
|
||||||
danger ? 'bg-red-500/15' : 'bg-cyan-500/15'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{danger ? (
|
|
||||||
<AlertTriangle className="h-5 w-5 text-red-400" />
|
|
||||||
) : (
|
|
||||||
<ShoppingCart className="h-5 w-5 text-cyan-400" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-white font-semibold text-lg">{title}</h3>
|
|
||||||
<p className="text-slate-400 text-sm mt-1">{message}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3 mt-6">
|
|
||||||
<button
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={loading}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-slate-400 hover:text-slate-200 bg-slate-800/50 hover:bg-slate-700/50 rounded-lg border border-slate-700/50 transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onConfirm}
|
|
||||||
disabled={loading}
|
|
||||||
className={cn(
|
|
||||||
'px-4 py-2 text-sm font-medium rounded-lg transition-all flex items-center gap-2',
|
|
||||||
danger
|
|
||||||
? 'bg-red-500/20 text-red-400 hover:bg-red-500/30 border border-red-500/30'
|
|
||||||
: 'bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/30 border border-cyan-500/30'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
||||||
{confirmLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Configure Modal */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function ConfigureModal({
|
|
||||||
number,
|
|
||||||
onClose,
|
|
||||||
onSaved,
|
|
||||||
}: {
|
|
||||||
number: OwnedNumber;
|
|
||||||
onClose: () => void;
|
|
||||||
onSaved: () => void;
|
|
||||||
}) {
|
|
||||||
const [friendlyName, setFriendlyName] = useState(number.friendlyName);
|
|
||||||
const [smsUrl, setSmsUrl] = useState(number.smsUrl || '');
|
|
||||||
const [voiceUrl, setVoiceUrl] = useState(number.voiceUrl || '');
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/twilio/numbers/${number.sid}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ friendlyName, smsUrl: smsUrl || undefined, voiceUrl: voiceUrl || undefined }),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
throw new Error(data.error || 'Update failed');
|
|
||||||
}
|
|
||||||
onSaved();
|
|
||||||
onClose();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Update failed');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
|
||||||
<div className="card w-full max-w-lg p-6 mx-4">
|
|
||||||
<div className="flex items-center justify-between mb-5">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-white font-semibold text-lg">Configure Number</h3>
|
|
||||||
<p className="text-cyan-400 text-sm font-mono mt-0.5">{formatPhone(number.phoneNumber)}</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={onClose} className="text-slate-500 hover:text-slate-300 transition-colors">
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-1.5">Friendly Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={friendlyName}
|
|
||||||
onChange={(e) => setFriendlyName(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-white text-sm focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-1.5">SMS Webhook URL</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={smsUrl}
|
|
||||||
onChange={(e) => setSmsUrl(e.target.value)}
|
|
||||||
placeholder="https://..."
|
|
||||||
className="w-full px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-white text-sm placeholder:text-slate-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-1.5">Voice Webhook URL</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={voiceUrl}
|
|
||||||
onChange={(e) => setVoiceUrl(e.target.value)}
|
|
||||||
placeholder="https://..."
|
|
||||||
className="w-full px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-white text-sm placeholder:text-slate-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="text-red-400 text-sm mt-3">{error}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3 mt-6">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-slate-400 hover:text-slate-200 bg-slate-800/50 hover:bg-slate-700/50 rounded-lg border border-slate-700/50 transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
className="px-4 py-2 text-sm font-medium bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/30 rounded-lg border border-cyan-500/30 transition-all flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
|
||||||
Save Changes
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Skeleton loader */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function SkeletonRow() {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-4 p-4 animate-pulse">
|
|
||||||
<div className="h-10 w-10 rounded-full bg-slate-700/50" />
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<div className="h-4 w-36 bg-slate-700/50 rounded" />
|
|
||||||
<div className="h-3 w-24 bg-slate-700/30 rounded" />
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="h-5 w-12 bg-slate-700/30 rounded-full" />
|
|
||||||
<div className="h-5 w-12 bg-slate-700/30 rounded-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Main Page */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
export default function PhoneNumbersPage() {
|
|
||||||
/* --- State: owned numbers --- */
|
|
||||||
const [ownedNumbers, setOwnedNumbers] = useState<OwnedNumber[]>([]);
|
|
||||||
const [loadingOwned, setLoadingOwned] = useState(true);
|
|
||||||
const [noCredentials, setNoCredentials] = useState(false);
|
|
||||||
|
|
||||||
/* --- State: search --- */
|
|
||||||
const [country, setCountry] = useState('US');
|
|
||||||
const [areaCode, setAreaCode] = useState('');
|
|
||||||
const [contains, setContains] = useState('');
|
|
||||||
const [numberType, setNumberType] = useState<'local' | 'tollFree'>('local');
|
|
||||||
const [smsEnabled, setSmsEnabled] = useState(true);
|
|
||||||
const [voiceEnabled, setVoiceEnabled] = useState(false);
|
|
||||||
const [mmsEnabled, setMmsEnabled] = useState(false);
|
|
||||||
const [searchResults, setSearchResults] = useState<AvailableNumber[]>([]);
|
|
||||||
const [searching, setSearching] = useState(false);
|
|
||||||
const [searchError, setSearchError] = useState('');
|
|
||||||
const [hasSearched, setHasSearched] = useState(false);
|
|
||||||
|
|
||||||
/* --- State: modals --- */
|
|
||||||
const [buyTarget, setBuyTarget] = useState<AvailableNumber | null>(null);
|
|
||||||
const [releaseTarget, setReleaseTarget] = useState<OwnedNumber | null>(null);
|
|
||||||
const [configureTarget, setConfigureTarget] = useState<OwnedNumber | null>(null);
|
|
||||||
const [modalLoading, setModalLoading] = useState(false);
|
|
||||||
|
|
||||||
/* --- Fetch owned numbers --- */
|
|
||||||
const fetchOwned = useCallback(async () => {
|
|
||||||
setLoadingOwned(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/twilio/numbers');
|
|
||||||
if (!res.ok) throw new Error('Failed to load');
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.error && data.error.includes('credentials')) {
|
|
||||||
setNoCredentials(true);
|
|
||||||
setOwnedNumbers([]);
|
|
||||||
} else {
|
|
||||||
setNoCredentials(false);
|
|
||||||
setOwnedNumbers(Array.isArray(data) ? data : []);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setOwnedNumbers([]);
|
|
||||||
} finally {
|
|
||||||
setLoadingOwned(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchOwned();
|
|
||||||
}, [fetchOwned]);
|
|
||||||
|
|
||||||
/* --- Search available --- */
|
|
||||||
const handleSearch = async () => {
|
|
||||||
setSearching(true);
|
|
||||||
setSearchError('');
|
|
||||||
setHasSearched(true);
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({ country, type: numberType, limit: '20' });
|
|
||||||
if (areaCode) params.set('areaCode', areaCode);
|
|
||||||
if (contains) params.set('contains', contains);
|
|
||||||
if (smsEnabled) params.set('smsEnabled', 'true');
|
|
||||||
if (voiceEnabled) params.set('voiceEnabled', 'true');
|
|
||||||
if (mmsEnabled) params.set('mmsEnabled', 'true');
|
|
||||||
|
|
||||||
const res = await fetch(`/api/twilio/numbers/search?${params}`);
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error || 'Search failed');
|
|
||||||
setSearchResults(data);
|
|
||||||
} catch (err) {
|
|
||||||
setSearchError(err instanceof Error ? err.message : 'Search failed');
|
|
||||||
setSearchResults([]);
|
|
||||||
} finally {
|
|
||||||
setSearching(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/* --- Buy number --- */
|
|
||||||
const handleBuy = async () => {
|
|
||||||
if (!buyTarget) return;
|
|
||||||
setModalLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/twilio/numbers/buy', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ phoneNumber: buyTarget.phoneNumber }),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
throw new Error(data.error || 'Purchase failed');
|
|
||||||
}
|
|
||||||
setBuyTarget(null);
|
|
||||||
setSearchResults((prev) => prev.filter((n) => n.phoneNumber !== buyTarget.phoneNumber));
|
|
||||||
fetchOwned();
|
|
||||||
} catch (err) {
|
|
||||||
alert(err instanceof Error ? err.message : 'Purchase failed');
|
|
||||||
} finally {
|
|
||||||
setModalLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/* --- Release number --- */
|
|
||||||
const handleRelease = async () => {
|
|
||||||
if (!releaseTarget) return;
|
|
||||||
setModalLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/twilio/numbers/${releaseTarget.sid}`, { method: 'DELETE' });
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
throw new Error(data.error || 'Release failed');
|
|
||||||
}
|
|
||||||
setReleaseTarget(null);
|
|
||||||
fetchOwned();
|
|
||||||
} catch (err) {
|
|
||||||
alert(err instanceof Error ? err.message : 'Release failed');
|
|
||||||
} finally {
|
|
||||||
setModalLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/* --- No credentials state --- */
|
|
||||||
if (noCredentials) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen p-6" style={{ background: 'var(--bg-primary)' }}>
|
|
||||||
<div className="mb-6">
|
|
||||||
<h1 className="text-2xl font-bold text-white tracking-tight">Phone Numbers</h1>
|
|
||||||
<p className="text-sm mt-1" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Manage your Twilio phone numbers — search, buy, and configure.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="card p-12 flex flex-col items-center justify-center text-center">
|
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-yellow-500/15 mb-4">
|
|
||||||
<AlertTriangle className="h-8 w-8 text-yellow-400" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-lg font-semibold text-white mb-2">Twilio Not Configured</h2>
|
|
||||||
<p className="text-slate-400 text-sm max-w-md">
|
|
||||||
To manage phone numbers, configure your Twilio Account SID and Auth Token in the Settings page.
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="/settings"
|
|
||||||
className="mt-6 inline-flex items-center gap-2 px-5 py-2.5 bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/30 rounded-lg border border-cyan-500/30 text-sm font-medium transition-all"
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
Go to Settings
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen p-6" style={{ background: 'var(--bg-primary)' }}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-6 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-white tracking-tight">Phone Numbers</h1>
|
|
||||||
<p className="text-sm mt-1" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Manage your Twilio phone numbers — search, buy, and configure.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={fetchOwned}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 text-sm text-slate-400 hover:text-slate-200 bg-slate-800/50 hover:bg-slate-700/50 rounded-lg border border-slate-700/50 transition-colors"
|
|
||||||
>
|
|
||||||
<RefreshCw className={cn('h-4 w-4', loadingOwned && 'animate-spin')} />
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ============================================================ */}
|
|
||||||
{/* YOUR NUMBERS */}
|
|
||||||
{/* ============================================================ */}
|
|
||||||
<div className="card p-0 mb-6 overflow-hidden">
|
|
||||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-slate-700/40">
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-500/10">
|
|
||||||
<Phone className="h-4 w-4 text-cyan-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-white font-semibold text-[15px]">Your Numbers</h2>
|
|
||||||
<p className="text-slate-500 text-xs">
|
|
||||||
{ownedNumbers.length} number{ownedNumbers.length !== 1 ? 's' : ''} on your account
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loadingOwned ? (
|
|
||||||
<div className="divide-y divide-slate-700/30">
|
|
||||||
<SkeletonRow />
|
|
||||||
<SkeletonRow />
|
|
||||||
<SkeletonRow />
|
|
||||||
</div>
|
|
||||||
) : ownedNumbers.length === 0 ? (
|
|
||||||
<div className="p-10 text-center">
|
|
||||||
<Phone className="h-10 w-10 text-slate-700 mx-auto mb-3" />
|
|
||||||
<p className="text-slate-500 text-sm">No phone numbers yet. Search and buy one below!</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y divide-slate-700/30">
|
|
||||||
{ownedNumbers.map((num) => (
|
|
||||||
<div
|
|
||||||
key={num.sid}
|
|
||||||
className="flex items-center gap-4 px-5 py-4 hover:bg-slate-800/30 transition-colors"
|
|
||||||
>
|
|
||||||
{/* Icon */}
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-cyan-500/10 flex-shrink-0">
|
|
||||||
<Phone className="h-5 w-5 text-cyan-400" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-white font-medium text-sm font-mono">
|
|
||||||
{formatPhone(num.phoneNumber)}
|
|
||||||
</p>
|
|
||||||
<p className="text-slate-500 text-xs mt-0.5 truncate">
|
|
||||||
{num.friendlyName}
|
|
||||||
{num.dateCreated && (
|
|
||||||
<span className="text-slate-600 ml-2">
|
|
||||||
· Purchased {new Date(num.dateCreated).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Capabilities */}
|
|
||||||
<div className="hidden sm:flex items-center gap-1.5">
|
|
||||||
<CapBadge label="SMS" active={num.smsEnabled} />
|
|
||||||
<CapBadge label="Voice" active={num.voiceEnabled} />
|
|
||||||
<CapBadge label="MMS" active={num.mmsEnabled} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setConfigureTarget(num)}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-slate-400 hover:text-cyan-400 bg-slate-800/50 hover:bg-cyan-500/10 rounded-lg border border-slate-700/50 hover:border-cyan-500/30 transition-all"
|
|
||||||
>
|
|
||||||
<Settings className="h-3.5 w-3.5" />
|
|
||||||
Configure
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setReleaseTarget(num)}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-slate-500 hover:text-red-400 bg-slate-800/50 hover:bg-red-500/10 rounded-lg border border-slate-700/50 hover:border-red-500/30 transition-all"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
Release
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ============================================================ */}
|
|
||||||
{/* SEARCH & BUY */}
|
|
||||||
{/* ============================================================ */}
|
|
||||||
<div className="card p-0 overflow-hidden">
|
|
||||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-slate-700/40">
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-500/10">
|
|
||||||
<Search className="h-4 w-4 text-cyan-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-white font-semibold text-[15px]">Search & Buy Numbers</h2>
|
|
||||||
<p className="text-slate-500 text-xs">Find available Twilio phone numbers to purchase</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search form */}
|
|
||||||
<div className="px-5 py-5 border-b border-slate-700/40">
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{/* Country */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
|
||||||
<Globe className="h-3 w-3 inline mr-1" />
|
|
||||||
Country
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={country}
|
|
||||||
onChange={(e) => setCountry(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-white text-sm focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors"
|
|
||||||
>
|
|
||||||
<option value="US">🇺🇸 United States</option>
|
|
||||||
<option value="CA">🇨🇦 Canada</option>
|
|
||||||
<option value="GB">🇬🇧 United Kingdom</option>
|
|
||||||
<option value="AU">🇦🇺 Australia</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Area code */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
|
||||||
<Hash className="h-3 w-3 inline mr-1" />
|
|
||||||
Area Code
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={areaCode}
|
|
||||||
onChange={(e) => setAreaCode(e.target.value.replace(/\D/g, '').slice(0, 3))}
|
|
||||||
placeholder="e.g. 212"
|
|
||||||
maxLength={3}
|
|
||||||
className="w-full px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-white text-sm placeholder:text-slate-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contains */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
|
||||||
<Search className="h-3 w-3 inline mr-1" />
|
|
||||||
Contains
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={contains}
|
|
||||||
onChange={(e) => setContains(e.target.value)}
|
|
||||||
placeholder="e.g. PIZZA"
|
|
||||||
className="w-full px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-white text-sm placeholder:text-slate-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Type */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
|
||||||
<Phone className="h-3 w-3 inline mr-1" />
|
|
||||||
Number Type
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={numberType}
|
|
||||||
onChange={(e) => setNumberType(e.target.value as 'local' | 'tollFree')}
|
|
||||||
className="w-full px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-white text-sm focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors"
|
|
||||||
>
|
|
||||||
<option value="local">Local</option>
|
|
||||||
<option value="tollFree">Toll-Free</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Capabilities checkboxes + Search button */}
|
|
||||||
<div className="flex flex-wrap items-center gap-6 mt-4">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer group">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={smsEnabled}
|
|
||||||
onChange={(e) => setSmsEnabled(e.target.checked)}
|
|
||||||
className="h-4 w-4 rounded bg-slate-800 border-slate-600 text-cyan-500 focus:ring-cyan-500/30 focus:ring-offset-0"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-slate-400 group-hover:text-slate-300 transition-colors">SMS</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer group">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={voiceEnabled}
|
|
||||||
onChange={(e) => setVoiceEnabled(e.target.checked)}
|
|
||||||
className="h-4 w-4 rounded bg-slate-800 border-slate-600 text-cyan-500 focus:ring-cyan-500/30 focus:ring-offset-0"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-slate-400 group-hover:text-slate-300 transition-colors">Voice</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer group">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={mmsEnabled}
|
|
||||||
onChange={(e) => setMmsEnabled(e.target.checked)}
|
|
||||||
className="h-4 w-4 rounded bg-slate-800 border-slate-600 text-cyan-500 focus:ring-cyan-500/30 focus:ring-offset-0"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-slate-400 group-hover:text-slate-300 transition-colors">MMS</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleSearch}
|
|
||||||
disabled={searching}
|
|
||||||
className="ml-auto flex items-center gap-2 px-5 py-2.5 bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/30 rounded-lg border border-cyan-500/30 text-sm font-medium transition-all disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{searching ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Search className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Search Available
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search results */}
|
|
||||||
<div className="p-5">
|
|
||||||
{searchError && (
|
|
||||||
<div className="flex items-center gap-2 p-3 mb-4 bg-red-500/10 border border-red-500/20 rounded-lg">
|
|
||||||
<AlertTriangle className="h-4 w-4 text-red-400 flex-shrink-0" />
|
|
||||||
<p className="text-red-400 text-sm">{searchError}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{searching ? (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
|
||||||
<div key={i} className="p-4 bg-slate-800/30 rounded-lg border border-slate-700/30 animate-pulse">
|
|
||||||
<div className="h-5 w-32 bg-slate-700/50 rounded mb-2" />
|
|
||||||
<div className="h-3 w-24 bg-slate-700/30 rounded mb-3" />
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
<div className="h-5 w-12 bg-slate-700/30 rounded-full" />
|
|
||||||
<div className="h-5 w-12 bg-slate-700/30 rounded-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : searchResults.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
||||||
{searchResults.map((num) => (
|
|
||||||
<div
|
|
||||||
key={num.phoneNumber}
|
|
||||||
className="group p-4 bg-slate-800/30 hover:bg-slate-800/50 rounded-lg border border-slate-700/30 hover:border-cyan-500/20 transition-all"
|
|
||||||
>
|
|
||||||
<p className="text-white font-medium text-sm font-mono">
|
|
||||||
{formatPhone(num.phoneNumber)}
|
|
||||||
</p>
|
|
||||||
<p className="text-slate-500 text-xs mt-0.5">
|
|
||||||
{num.locality && num.region
|
|
||||||
? `${num.locality}, ${num.region}`
|
|
||||||
: num.friendlyName}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 mt-3">
|
|
||||||
<CapBadge label="SMS" active={num.capabilities.sms} />
|
|
||||||
<CapBadge label="Voice" active={num.capabilities.voice} />
|
|
||||||
<CapBadge label="MMS" active={num.capabilities.mms} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setBuyTarget(num)}
|
|
||||||
className="mt-3 w-full flex items-center justify-center gap-2 px-3 py-2 bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 rounded-lg border border-cyan-500/20 hover:border-cyan-500/40 text-xs font-medium transition-all opacity-70 group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<ShoppingCart className="h-3.5 w-3.5" />
|
|
||||||
Buy This Number
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : hasSearched && !searchError ? (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<Search className="h-10 w-10 text-slate-700 mx-auto mb-3" />
|
|
||||||
<p className="text-slate-500 text-sm">No numbers found. Try adjusting your search criteria.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<Globe className="h-10 w-10 text-slate-700 mx-auto mb-3" />
|
|
||||||
<p className="text-slate-500 text-sm">Search for available phone numbers above</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ============================================================ */}
|
|
||||||
{/* MODALS */}
|
|
||||||
{/* ============================================================ */}
|
|
||||||
{buyTarget && (
|
|
||||||
<ConfirmModal
|
|
||||||
title="Purchase Phone Number"
|
|
||||||
message={`Are you sure you want to purchase ${formatPhone(buyTarget.phoneNumber)}? This will be billed to your Twilio account.`}
|
|
||||||
confirmLabel="Purchase Number"
|
|
||||||
onConfirm={handleBuy}
|
|
||||||
onCancel={() => setBuyTarget(null)}
|
|
||||||
loading={modalLoading}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{releaseTarget && (
|
|
||||||
<ConfirmModal
|
|
||||||
title="Release Phone Number"
|
|
||||||
message={`Are you sure you want to release ${formatPhone(releaseTarget.phoneNumber)}? This action cannot be undone and you may not be able to get this number back.`}
|
|
||||||
confirmLabel="Release Number"
|
|
||||||
danger
|
|
||||||
onConfirm={handleRelease}
|
|
||||||
onCancel={() => setReleaseTarget(null)}
|
|
||||||
loading={modalLoading}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{configureTarget && (
|
|
||||||
<ConfigureModal
|
|
||||||
number={configureTarget}
|
|
||||||
onClose={() => setConfigureTarget(null)}
|
|
||||||
onSaved={fetchOwned}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,150 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { Search, SlidersHorizontal, Plus, GitBranch, ChevronRight, Loader2 } from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import RoutingView from '@/components/routing/routing-view';
|
|
||||||
import type { Route, TwilioNumber, CloseBotBot, CloseBotSource } from '@/components/routing/routing-view';
|
|
||||||
|
|
||||||
export default function RoutingPage() {
|
|
||||||
const [routes, setRoutes] = useState<Route[]>([]);
|
|
||||||
const [numbers, setNumbers] = useState<TwilioNumber[]>([]);
|
|
||||||
const [bots, setBots] = useState<CloseBotBot[]>([]);
|
|
||||||
const [sources, setSources] = useState<CloseBotSource[]>([]);
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [showNewRoute, setShowNewRoute] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchAll() {
|
|
||||||
try {
|
|
||||||
const [routesRes, numbersRes, botsRes, sourcesRes] = await Promise.all([
|
|
||||||
fetch('/api/routes'),
|
|
||||||
fetch('/api/twilio/numbers').catch(() => null),
|
|
||||||
fetch('/api/closebot/bots').catch(() => null),
|
|
||||||
fetch('/api/closebot/sources').catch(() => null),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (routesRes.ok) {
|
|
||||||
setRoutes(await routesRes.json());
|
|
||||||
}
|
|
||||||
|
|
||||||
// These may fail if not configured — that's fine
|
|
||||||
if (numbersRes?.ok) {
|
|
||||||
const data = await numbersRes.json();
|
|
||||||
setNumbers(Array.isArray(data) ? data : data.numbers || []);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (botsRes?.ok) {
|
|
||||||
const data = await botsRes.json();
|
|
||||||
setBots(Array.isArray(data) ? data : data.bots || []);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sourcesRes?.ok) {
|
|
||||||
const data = await sourcesRes.json();
|
|
||||||
setSources(Array.isArray(data) ? data : data.sources || []);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load routing data:', err);
|
|
||||||
setError('Failed to load routing data');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchAll();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex items-center justify-center min-h-[60vh]">
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<Loader2 className="h-8 w-8 text-cyan-400 animate-spin" />
|
|
||||||
<p className="text-sm text-slate-500">Loading routing configuration...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-[calc(100vh)] overflow-hidden">
|
|
||||||
{/* Top bar */}
|
|
||||||
<div className="flex-shrink-0 px-6 pt-6 pb-4">
|
|
||||||
{/* Breadcrumb */}
|
|
||||||
<div className="flex items-center gap-1.5 text-[12px] text-slate-500 mb-4">
|
|
||||||
<GitBranch className="h-3.5 w-3.5" />
|
|
||||||
<span>Routing</span>
|
|
||||||
<ChevronRight className="h-3 w-3" />
|
|
||||||
<span className="text-slate-300 font-medium">Phone Number Routing</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Header row */}
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-white">Phone Number Routing</h1>
|
|
||||||
<p className="text-sm text-slate-500 mt-0.5">
|
|
||||||
Connect Twilio numbers to CloseBot sources for automated SMS handling
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* Search */}
|
|
||||||
<div className="relative w-64">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search routes..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
className="w-full rounded-lg border border-slate-700/50 bg-slate-800/50 py-2 pl-10 pr-4 text-sm text-slate-200 placeholder-slate-500 outline-none transition-colors focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 backdrop-blur-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter icon */}
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
'flex h-9 w-9 items-center justify-center rounded-lg border transition-colors',
|
|
||||||
'border-slate-700/50 text-slate-500 hover:text-slate-300 hover:border-slate-600/60 hover:bg-slate-800/50'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<SlidersHorizontal className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* + Add New Route */}
|
|
||||||
<button
|
|
||||||
onClick={() => setShowNewRoute(true)}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold transition-all duration-200',
|
|
||||||
'border border-cyan-500/40 text-cyan-400',
|
|
||||||
'hover:bg-cyan-500/10 hover:border-cyan-500/60 hover:shadow-[0_0_15px_rgba(34,211,238,0.1)]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Add New Route
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mt-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-2.5 flex items-center gap-2">
|
|
||||||
<span className="text-sm text-amber-400">{error}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Three-column routing view */}
|
|
||||||
<div className="flex-1 min-h-0 border-t border-slate-700/30">
|
|
||||||
<RoutingView
|
|
||||||
initialRoutes={routes}
|
|
||||||
initialNumbers={numbers}
|
|
||||||
initialBots={bots}
|
|
||||||
initialSources={sources}
|
|
||||||
showNewRouteDialog={showNewRoute}
|
|
||||||
onCloseNewRouteDialog={() => setShowNewRoute(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,141 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { Settings, ChevronRight } from 'lucide-react';
|
|
||||||
import TwilioCard from '@/components/settings/twilio-card';
|
|
||||||
import CloseBotCard from '@/components/settings/closebot-card';
|
|
||||||
import PhoneNumbersCard from '@/components/settings/phone-numbers-card';
|
|
||||||
import PhoneGatewayCard from '@/components/settings/phone-gateway-card';
|
|
||||||
import NotificationsCard from '@/components/settings/notifications-card';
|
|
||||||
import WebhookUrlsCard from '@/components/settings/webhook-urls-card';
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
|
||||||
const [settings, setSettings] = useState<Record<string, string>>({});
|
|
||||||
const [twilioConnected, setTwilioConnected] = useState<boolean | null>(null);
|
|
||||||
const [closebotConnected, setClosebotConnected] = useState<boolean | null>(null);
|
|
||||||
const [closebotAgencyName, setClosebotAgencyName] = useState<string | undefined>();
|
|
||||||
const [closebotSourcesCount, setClosebotSourcesCount] = useState<number | undefined>();
|
|
||||||
const [testing, setTesting] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
// Load settings on mount
|
|
||||||
useEffect(() => {
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/settings');
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setSettings(data);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load settings:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Save settings handler
|
|
||||||
const handleSave = useCallback(async (updates: Record<string, string>) => {
|
|
||||||
const res = await fetch('/api/settings', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(updates),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error('Failed to save settings');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update local state
|
|
||||||
setSettings((prev) => ({ ...prev, ...updates }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Test connections
|
|
||||||
const handleTestConnection = useCallback(async () => {
|
|
||||||
setTesting(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/settings/test-connection', { method: 'POST' });
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setTwilioConnected(data.twilio);
|
|
||||||
setClosebotConnected(data.closebot);
|
|
||||||
if (data.closebotAgencyName) setClosebotAgencyName(data.closebotAgencyName);
|
|
||||||
if (data.closebotSourcesCount !== undefined) setClosebotSourcesCount(data.closebotSourcesCount);
|
|
||||||
} else {
|
|
||||||
setTwilioConnected(false);
|
|
||||||
setClosebotConnected(false);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setTwilioConnected(false);
|
|
||||||
setClosebotConnected(false);
|
|
||||||
} finally {
|
|
||||||
setTesting(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Auto-test on load
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loading) {
|
|
||||||
handleTestConnection();
|
|
||||||
}
|
|
||||||
}, [loading, handleTestConnection]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6 lg:p-8 space-y-6">
|
|
||||||
{/* Breadcrumb Header */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-500 mb-1">
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
<span>Settings</span>
|
|
||||||
<ChevronRight className="h-3 w-3" />
|
|
||||||
<span className="text-slate-300">Configuration</span>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-bold text-white">Settings</h1>
|
|
||||||
<p className="text-sm text-slate-500 mt-0.5">
|
|
||||||
Manage connections, phone numbers, and notification preferences
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Two-column card layout */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
{/* Left column */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<TwilioCard
|
|
||||||
settings={settings}
|
|
||||||
onSave={handleSave}
|
|
||||||
connected={twilioConnected}
|
|
||||||
onTestConnection={handleTestConnection}
|
|
||||||
testing={testing}
|
|
||||||
/>
|
|
||||||
<CloseBotCard
|
|
||||||
settings={settings}
|
|
||||||
onSave={handleSave}
|
|
||||||
connected={closebotConnected}
|
|
||||||
onTestConnection={handleTestConnection}
|
|
||||||
testing={testing}
|
|
||||||
agencyName={closebotAgencyName}
|
|
||||||
sourcesCount={closebotSourcesCount}
|
|
||||||
/>
|
|
||||||
<NotificationsCard
|
|
||||||
settings={settings}
|
|
||||||
onSave={handleSave}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right column */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<PhoneNumbersCard
|
|
||||||
twilioConnected={twilioConnected}
|
|
||||||
/>
|
|
||||||
<PhoneGatewayCard
|
|
||||||
onTestConnection={handleTestConnection}
|
|
||||||
testing={testing}
|
|
||||||
/>
|
|
||||||
<WebhookUrlsCard />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,147 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface LeaderboardEntry {
|
|
||||||
rank: number;
|
|
||||||
name: string;
|
|
||||||
avatar: string;
|
|
||||||
conversations: number;
|
|
||||||
bookingRate: number;
|
|
||||||
csatScore: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RankBadge({ rank }: { rank: number }) {
|
|
||||||
const colors: Record<number, string> = {
|
|
||||||
1: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30',
|
|
||||||
2: 'bg-slate-400/15 text-slate-300 border-slate-400/30',
|
|
||||||
3: 'bg-amber-600/15 text-amber-500 border-amber-600/30',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex h-7 w-7 items-center justify-center rounded-lg border text-xs font-bold',
|
|
||||||
colors[rank] || 'bg-slate-700/30 text-slate-500 border-slate-700/30'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{rank}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CsatBar({ score }: { score: number }) {
|
|
||||||
const pct = (score / 5) * 100;
|
|
||||||
const color =
|
|
||||||
score >= 4.5
|
|
||||||
? 'bg-emerald-500'
|
|
||||||
: score >= 4.0
|
|
||||||
? 'bg-cyan-500'
|
|
||||||
: score >= 3.5
|
|
||||||
? 'bg-yellow-500'
|
|
||||||
: 'bg-rose-500';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-1.5 w-16 rounded-full bg-slate-700/50 overflow-hidden">
|
|
||||||
<div className={cn('h-full rounded-full transition-all', color)} style={{ width: `${pct}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium text-white">{score.toFixed(1)}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BotLeaderboard() {
|
|
||||||
const [data, setData] = useState<LeaderboardEntry[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/analytics/leaderboard')
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((d) => setData(d.leaderboard || []))
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-slate-700/40 bg-[#0f1729]/80 overflow-hidden">
|
|
||||||
<div className="px-6 py-5 border-b border-slate-700/30">
|
|
||||||
<h3 className="text-base font-semibold text-white">Top Performing Bots</h3>
|
|
||||||
<p className="text-xs text-slate-500 mt-0.5">Ranked by overall performance score</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="p-8 flex items-center justify-center">
|
|
||||||
<div className="h-8 w-8 border-2 border-cyan-500/30 border-t-cyan-500 rounded-full animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-slate-700/20">
|
|
||||||
<th className="px-6 py-3 text-left text-[10px] font-semibold uppercase tracking-widest text-slate-500">
|
|
||||||
Rank
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-[10px] font-semibold uppercase tracking-widest text-slate-500">
|
|
||||||
Bot Name
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-right text-[10px] font-semibold uppercase tracking-widest text-slate-500">
|
|
||||||
Conversations
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-right text-[10px] font-semibold uppercase tracking-widest text-slate-500">
|
|
||||||
Booking Rate
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-[10px] font-semibold uppercase tracking-widest text-slate-500">
|
|
||||||
CSAT Score
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.map((bot) => (
|
|
||||||
<tr
|
|
||||||
key={bot.rank}
|
|
||||||
className="border-b border-slate-700/10 transition-colors hover:bg-slate-800/20"
|
|
||||||
>
|
|
||||||
<td className="px-6 py-3.5">
|
|
||||||
<RankBadge rank={bot.rank} />
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-3.5">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-700/40 text-base">
|
|
||||||
{bot.avatar}
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium text-slate-200">{bot.name}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-3.5 text-right">
|
|
||||||
<span className="text-sm font-semibold text-white tabular-nums">
|
|
||||||
{bot.conversations.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-3.5 text-right">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'text-sm font-semibold tabular-nums',
|
|
||||||
bot.bookingRate >= 35
|
|
||||||
? 'text-emerald-400'
|
|
||||||
: bot.bookingRate >= 25
|
|
||||||
? 'text-cyan-400'
|
|
||||||
: 'text-slate-300'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{bot.bookingRate}%
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-3.5">
|
|
||||||
<CsatBar score={bot.csatScore} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,109 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import {
|
|
||||||
ResponsiveContainer,
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
Cell,
|
|
||||||
} from 'recharts';
|
|
||||||
|
|
||||||
interface BotData {
|
|
||||||
name: string;
|
|
||||||
conversations: number;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CustomTooltip({
|
|
||||||
active,
|
|
||||||
payload,
|
|
||||||
}: {
|
|
||||||
active?: boolean;
|
|
||||||
payload?: Array<{ payload: BotData; value: number }>;
|
|
||||||
}) {
|
|
||||||
if (!active || !payload || !payload[0]) return null;
|
|
||||||
const d = payload[0].payload;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-slate-700/60 bg-[#1a2332]/95 px-4 py-3 shadow-xl backdrop-blur-sm">
|
|
||||||
<p className="text-sm font-semibold text-white mb-1">{d.name}</p>
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<div className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: d.color }} />
|
|
||||||
<span className="text-slate-300">Conversations:</span>
|
|
||||||
<span className="font-semibold text-white">{d.conversations.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BotsBarChart() {
|
|
||||||
const [data, setData] = useState<BotData[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/analytics/bots')
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((d) => setData(d.bots || []))
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-slate-700/40 bg-[#0f1729]/80 p-6">
|
|
||||||
<div className="mb-6">
|
|
||||||
<h3 className="text-base font-semibold text-white">Conversations by Bot</h3>
|
|
||||||
<p className="text-xs text-slate-500 mt-0.5">Total conversations handled per bot</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="h-[300px] flex items-center justify-center">
|
|
||||||
<div className="h-8 w-8 border-2 border-cyan-500/30 border-t-cyan-500 rounded-full animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="h-[300px]">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart
|
|
||||||
data={data}
|
|
||||||
layout="vertical"
|
|
||||||
margin={{ top: 5, right: 40, left: 10, bottom: 5 }}
|
|
||||||
barCategoryGap="20%"
|
|
||||||
>
|
|
||||||
<CartesianGrid
|
|
||||||
strokeDasharray="3 3"
|
|
||||||
stroke="#334155"
|
|
||||||
strokeOpacity={0.3}
|
|
||||||
horizontal={false}
|
|
||||||
/>
|
|
||||||
<XAxis
|
|
||||||
type="number"
|
|
||||||
tick={{ fill: '#94a3b8', fontSize: 11 }}
|
|
||||||
stroke="#334155"
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
type="category"
|
|
||||||
dataKey="name"
|
|
||||||
tick={{ fill: '#cbd5e1', fontSize: 12 }}
|
|
||||||
stroke="#334155"
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
width={130}
|
|
||||||
/>
|
|
||||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(148,163,184,0.05)' }} />
|
|
||||||
<Bar dataKey="conversations" radius={[0, 6, 6, 0]} barSize={28}>
|
|
||||||
{data.map((entry, index) => (
|
|
||||||
<Cell key={index} fill={entry.color} fillOpacity={0.85} />
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,158 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import {
|
|
||||||
ResponsiveContainer,
|
|
||||||
LineChart,
|
|
||||||
Line,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
} from 'recharts';
|
|
||||||
|
|
||||||
interface MessageDataPoint {
|
|
||||||
date: string;
|
|
||||||
inbound: number;
|
|
||||||
outbound: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CustomTooltip({
|
|
||||||
active,
|
|
||||||
payload,
|
|
||||||
label,
|
|
||||||
}: {
|
|
||||||
active?: boolean;
|
|
||||||
payload?: Array<{ value: number; color: string; name: string }>;
|
|
||||||
label?: string;
|
|
||||||
}) {
|
|
||||||
if (!active || !payload) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-slate-700/60 bg-[#1a2332]/95 px-4 py-3 shadow-xl backdrop-blur-sm">
|
|
||||||
<p className="text-xs text-slate-400 mb-2 font-medium">{label}</p>
|
|
||||||
{payload.map((entry, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-2 text-sm">
|
|
||||||
<div className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: entry.color }} />
|
|
||||||
<span className="text-slate-300">{entry.name}:</span>
|
|
||||||
<span className="font-semibold text-white">{entry.value.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CustomLegend({
|
|
||||||
payload,
|
|
||||||
}: {
|
|
||||||
payload?: Array<{ value: string; color: string }>;
|
|
||||||
}) {
|
|
||||||
if (!payload) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-end gap-5 pr-2 pb-2">
|
|
||||||
{payload.map((entry, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-2">
|
|
||||||
<div className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: entry.color }} />
|
|
||||||
<span className="text-xs text-slate-400 font-medium">{entry.value}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MessagesChart() {
|
|
||||||
const [data, setData] = useState<MessageDataPoint[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/analytics/messages')
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((d) => setData(d.series || []))
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const formatDate = (date: string) => {
|
|
||||||
const d = new Date(date + 'T00:00:00');
|
|
||||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-slate-700/40 bg-[#0f1729]/80 p-6">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-base font-semibold text-white">Messages Over Time</h3>
|
|
||||||
<p className="text-xs text-slate-500 mt-0.5">Daily inbound and outbound message volume</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="h-[320px] flex items-center justify-center">
|
|
||||||
<div className="h-8 w-8 border-2 border-cyan-500/30 border-t-cyan-500 rounded-full animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="h-[320px]">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<LineChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="inboundGradient" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.3} />
|
|
||||||
<stop offset="100%" stopColor="#3b82f6" stopOpacity={0} />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="outboundGradient" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#22c55e" stopOpacity={0.3} />
|
|
||||||
<stop offset="100%" stopColor="#22c55e" stopOpacity={0} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<CartesianGrid
|
|
||||||
strokeDasharray="3 3"
|
|
||||||
stroke="#334155"
|
|
||||||
strokeOpacity={0.5}
|
|
||||||
vertical={false}
|
|
||||||
/>
|
|
||||||
<XAxis
|
|
||||||
dataKey="date"
|
|
||||||
tickFormatter={formatDate}
|
|
||||||
tick={{ fill: '#94a3b8', fontSize: 11 }}
|
|
||||||
stroke="#334155"
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={{ stroke: '#334155' }}
|
|
||||||
interval="preserveStartEnd"
|
|
||||||
minTickGap={50}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
tick={{ fill: '#94a3b8', fontSize: 11 }}
|
|
||||||
stroke="#334155"
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
width={45}
|
|
||||||
/>
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
<Legend content={<CustomLegend />} />
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="inbound"
|
|
||||||
name="Inbound"
|
|
||||||
stroke="#3b82f6"
|
|
||||||
strokeWidth={2.5}
|
|
||||||
dot={false}
|
|
||||||
activeDot={{ r: 5, fill: '#3b82f6', stroke: '#1e3a5f', strokeWidth: 2 }}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="outbound"
|
|
||||||
name="Outbound"
|
|
||||||
stroke="#22c55e"
|
|
||||||
strokeWidth={2.5}
|
|
||||||
dot={false}
|
|
||||||
activeDot={{ r: 5, fill: '#22c55e', stroke: '#14532d', strokeWidth: 2 }}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,176 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import {
|
|
||||||
ResponsiveContainer,
|
|
||||||
PieChart,
|
|
||||||
Pie,
|
|
||||||
Cell,
|
|
||||||
Tooltip,
|
|
||||||
} from 'recharts';
|
|
||||||
|
|
||||||
interface OutcomeData {
|
|
||||||
name: string;
|
|
||||||
value: number;
|
|
||||||
percentage: number;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CustomTooltip({
|
|
||||||
active,
|
|
||||||
payload,
|
|
||||||
}: {
|
|
||||||
active?: boolean;
|
|
||||||
payload?: Array<{ payload: OutcomeData }>;
|
|
||||||
}) {
|
|
||||||
if (!active || !payload || !payload[0]) return null;
|
|
||||||
const d = payload[0].payload;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-slate-700/60 bg-[#1a2332]/95 px-4 py-3 shadow-xl backdrop-blur-sm">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<div className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: d.color }} />
|
|
||||||
<span className="text-sm font-semibold text-white">{d.name}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-400">
|
|
||||||
{d.value.toLocaleString()} ({d.percentage}%)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const RADIAN = Math.PI / 180;
|
|
||||||
|
|
||||||
function renderCustomLabel({
|
|
||||||
cx,
|
|
||||||
cy,
|
|
||||||
midAngle,
|
|
||||||
innerRadius,
|
|
||||||
outerRadius,
|
|
||||||
name,
|
|
||||||
percentage,
|
|
||||||
color,
|
|
||||||
}: {
|
|
||||||
cx: number;
|
|
||||||
cy: number;
|
|
||||||
midAngle: number;
|
|
||||||
innerRadius: number;
|
|
||||||
outerRadius: number;
|
|
||||||
name: string;
|
|
||||||
percentage: number;
|
|
||||||
color: string;
|
|
||||||
}) {
|
|
||||||
const radius = outerRadius + 30;
|
|
||||||
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
|
||||||
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
|
||||||
|
|
||||||
const lineEndX = cx + (outerRadius + 12) * Math.cos(-midAngle * RADIAN);
|
|
||||||
const lineEndY = cy + (outerRadius + 12) * Math.sin(-midAngle * RADIAN);
|
|
||||||
const lineStartX = cx + (outerRadius + 2) * Math.cos(-midAngle * RADIAN);
|
|
||||||
const lineStartY = cy + (outerRadius + 2) * Math.sin(-midAngle * RADIAN);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g>
|
|
||||||
<line
|
|
||||||
x1={lineStartX}
|
|
||||||
y1={lineStartY}
|
|
||||||
x2={lineEndX}
|
|
||||||
y2={lineEndY}
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
strokeOpacity={0.5}
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x={x}
|
|
||||||
y={y}
|
|
||||||
textAnchor={x > cx ? 'start' : 'end'}
|
|
||||||
dominantBaseline="central"
|
|
||||||
className="text-[11px]"
|
|
||||||
fill="#94a3b8"
|
|
||||||
>
|
|
||||||
{name} ({percentage}%)
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OutcomeDonut() {
|
|
||||||
const [data, setData] = useState<OutcomeData[]>([]);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/analytics/outcomes')
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((d) => {
|
|
||||||
setData(d.outcomes || []);
|
|
||||||
setTotal(d.total || 0);
|
|
||||||
})
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-slate-700/40 bg-[#0f1729]/80 p-6">
|
|
||||||
<div className="mb-6">
|
|
||||||
<h3 className="text-base font-semibold text-white">Outcome Distribution</h3>
|
|
||||||
<p className="text-xs text-slate-500 mt-0.5">Conversation outcome breakdown</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="h-[300px] flex items-center justify-center">
|
|
||||||
<div className="h-8 w-8 border-2 border-cyan-500/30 border-t-cyan-500 rounded-full animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="h-[300px] relative">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={data}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
innerRadius={70}
|
|
||||||
outerRadius={100}
|
|
||||||
paddingAngle={3}
|
|
||||||
dataKey="value"
|
|
||||||
stroke="none"
|
|
||||||
label={(props) =>
|
|
||||||
renderCustomLabel({
|
|
||||||
...props,
|
|
||||||
color: data[props.index]?.color || '#94a3b8',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
labelLine={false}
|
|
||||||
>
|
|
||||||
{data.map((entry, index) => (
|
|
||||||
<Cell key={index} fill={entry.color} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
|
|
||||||
{/* Center label */}
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-2xl font-bold text-white">{total.toLocaleString()}</p>
|
|
||||||
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-medium">
|
|
||||||
Total Outcomes
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Legend below */}
|
|
||||||
<div className="flex items-center justify-center gap-5 mt-4">
|
|
||||||
{data.map((d) => (
|
|
||||||
<div key={d.name} className="flex items-center gap-1.5">
|
|
||||||
<div className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: d.color }} />
|
|
||||||
<span className="text-xs text-slate-400">{d.name}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,210 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface StatData {
|
|
||||||
value: number;
|
|
||||||
change: number;
|
|
||||||
sparkline: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OverviewData {
|
|
||||||
totalConversations: StatData;
|
|
||||||
bookingRate: StatData;
|
|
||||||
avgResponseTime: StatData;
|
|
||||||
customerSatisfaction: StatData;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CardConfig {
|
|
||||||
key: keyof OverviewData;
|
|
||||||
label: string;
|
|
||||||
format: (v: number) => string;
|
|
||||||
suffix?: string;
|
|
||||||
gradient: string;
|
|
||||||
sparkColor: string;
|
|
||||||
sparkFill: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cards: CardConfig[] = [
|
|
||||||
{
|
|
||||||
key: 'totalConversations',
|
|
||||||
label: 'Total Conversations',
|
|
||||||
format: (v) => v.toLocaleString(),
|
|
||||||
gradient: 'from-pink-500/20 via-rose-500/10 to-red-500/5',
|
|
||||||
sparkColor: '#f43f5e',
|
|
||||||
sparkFill: 'rgba(244,63,94,0.15)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'bookingRate',
|
|
||||||
label: 'Booking Rate',
|
|
||||||
format: (v) => `${v}%`,
|
|
||||||
gradient: 'from-emerald-500/20 via-green-500/10 to-teal-500/5',
|
|
||||||
sparkColor: '#22c55e',
|
|
||||||
sparkFill: 'rgba(34,197,94,0.15)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'avgResponseTime',
|
|
||||||
label: 'Avg Response Time',
|
|
||||||
format: (v) => `${v}s`,
|
|
||||||
gradient: 'from-blue-500/20 via-sky-500/10 to-cyan-500/5',
|
|
||||||
sparkColor: '#3b82f6',
|
|
||||||
sparkFill: 'rgba(59,130,246,0.15)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'customerSatisfaction',
|
|
||||||
label: 'Customer Satisfaction',
|
|
||||||
format: (v) => `${v}/5`,
|
|
||||||
gradient: 'from-purple-500/20 via-violet-500/10 to-indigo-500/5',
|
|
||||||
sparkColor: '#a855f7',
|
|
||||||
sparkFill: 'rgba(168,85,247,0.15)',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function Sparkline({
|
|
||||||
data,
|
|
||||||
color,
|
|
||||||
fill,
|
|
||||||
width = 120,
|
|
||||||
height = 40,
|
|
||||||
}: {
|
|
||||||
data: number[];
|
|
||||||
color: string;
|
|
||||||
fill: string;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
}) {
|
|
||||||
if (!data || data.length === 0) return null;
|
|
||||||
|
|
||||||
const min = Math.min(...data);
|
|
||||||
const max = Math.max(...data);
|
|
||||||
const range = max - min || 1;
|
|
||||||
const padding = 2;
|
|
||||||
|
|
||||||
const points = data.map((v, i) => {
|
|
||||||
const x = padding + (i / (data.length - 1)) * (width - padding * 2);
|
|
||||||
const y = height - padding - ((v - min) / range) * (height - padding * 2);
|
|
||||||
return `${x},${y}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const polylinePoints = points.join(' ');
|
|
||||||
// Build area fill path
|
|
||||||
const firstX = padding;
|
|
||||||
const lastX = padding + ((data.length - 1) / (data.length - 1)) * (width - padding * 2);
|
|
||||||
const areaPath = `M ${firstX},${height} L ${points.map((p) => p).join(' L ')} L ${lastX},${height} Z`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg width={width} height={height} className="overflow-visible">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id={`grad-${color.replace('#', '')}`} x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor={fill} />
|
|
||||||
<stop offset="100%" stopColor="transparent" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<path d={areaPath} fill={`url(#grad-${color.replace('#', '')})`} />
|
|
||||||
<polyline
|
|
||||||
points={polylinePoints}
|
|
||||||
fill="none"
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
{/* Dot on last point */}
|
|
||||||
{data.length > 0 && (
|
|
||||||
<circle
|
|
||||||
cx={padding + ((data.length - 1) / (data.length - 1)) * (width - padding * 2)}
|
|
||||||
cy={height - padding - ((data[data.length - 1] - min) / range) * (height - padding * 2)}
|
|
||||||
r="3"
|
|
||||||
fill={color}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChangeIndicator({ change }: { change: number }) {
|
|
||||||
const isPositive = change >= 0;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1 text-xs font-semibold rounded-full px-2 py-0.5',
|
|
||||||
isPositive ? 'text-emerald-400 bg-emerald-500/10' : 'text-rose-400 bg-rose-500/10'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10" className={cn(!isPositive && 'rotate-180')}>
|
|
||||||
<path d="M5 1L9 6H1L5 1Z" fill="currentColor" />
|
|
||||||
</svg>
|
|
||||||
{Math.abs(change).toFixed(1)}%
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function StatCardSparkline() {
|
|
||||||
const [data, setData] = useState<OverviewData | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/analytics/overview')
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((d) => setData(d))
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{cards.map((card) => {
|
|
||||||
const stat = data?.[card.key];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={card.key}
|
|
||||||
className={cn(
|
|
||||||
'relative overflow-hidden rounded-xl border border-slate-700/40 bg-gradient-to-br p-5 transition-all duration-300 hover:scale-[1.02] hover:border-slate-600/60',
|
|
||||||
card.gradient
|
|
||||||
)}
|
|
||||||
style={{ backdropFilter: 'blur(12px)' }}
|
|
||||||
>
|
|
||||||
{/* Background shimmer */}
|
|
||||||
<div className="absolute inset-0 bg-[#0f1729]/60 pointer-events-none" />
|
|
||||||
|
|
||||||
<div className="relative z-10">
|
|
||||||
{/* Header row: label + change */}
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<p className="text-xs font-medium uppercase tracking-wider text-slate-400">
|
|
||||||
{card.label}
|
|
||||||
</p>
|
|
||||||
{stat && <ChangeIndicator change={stat.change} />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Value + sparkline row */}
|
|
||||||
<div className="flex items-end justify-between">
|
|
||||||
<div>
|
|
||||||
{loading || !stat ? (
|
|
||||||
<div className="h-10 w-24 animate-pulse rounded bg-slate-700/40" />
|
|
||||||
) : (
|
|
||||||
<p className="text-3xl font-bold text-white tracking-tight">
|
|
||||||
{card.format(stat.value)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-[11px] text-slate-500 mt-1">vs last period</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{stat && (
|
|
||||||
<Sparkline
|
|
||||||
data={stat.sparkline}
|
|
||||||
color={card.sparkColor}
|
|
||||||
fill={card.sparkFill}
|
|
||||||
width={100}
|
|
||||||
height={36}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,258 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import {
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
Phone,
|
|
||||||
MessageSquare,
|
|
||||||
TrendingUp,
|
|
||||||
Clock,
|
|
||||||
Workflow,
|
|
||||||
Plug,
|
|
||||||
GitBranch,
|
|
||||||
CalendarDays,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { cn, formatPhone, relativeTime } from '@/lib/utils';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
export interface BotData {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
category: string;
|
|
||||||
active: boolean;
|
|
||||||
locked: boolean;
|
|
||||||
favorited: boolean;
|
|
||||||
twilioNumber: string | null;
|
|
||||||
routeId: string | null;
|
|
||||||
sourceIds: string[];
|
|
||||||
sourceNames: string[];
|
|
||||||
modifiedAt: string;
|
|
||||||
messageCount: number;
|
|
||||||
conversionPct: number | null;
|
|
||||||
lastActive: string | null;
|
|
||||||
publishedVersions: number;
|
|
||||||
totalVersions: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BotCardProps {
|
|
||||||
bot: BotData;
|
|
||||||
index: number;
|
|
||||||
onToggle?: (id: string, active: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gradient colors rotate based on card index
|
|
||||||
const GRADIENT_COLORS = [
|
|
||||||
'from-blue-500 to-blue-600',
|
|
||||||
'from-purple-500 to-purple-600',
|
|
||||||
'from-cyan-400 to-cyan-500',
|
|
||||||
'from-green-400 to-green-500',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Mini sparkline bars — purely visual performance indicator
|
|
||||||
function Sparkline({ values }: { values: number[] }) {
|
|
||||||
const max = Math.max(...values, 1);
|
|
||||||
return (
|
|
||||||
<div className="flex items-end gap-[3px] h-8">
|
|
||||||
{values.map((v, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="w-[6px] rounded-sm bg-cyan-400/70 transition-all"
|
|
||||||
style={{ height: `${(v / max) * 100}%`, minHeight: 2 }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// BotCard
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
export function BotCard({ bot, index, onToggle }: BotCardProps) {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const gradient = GRADIENT_COLORS[index % GRADIENT_COLORS.length];
|
|
||||||
|
|
||||||
// Fake sparkline data seeded from message count so it's stable
|
|
||||||
const spark = Array.from({ length: 12 }, (_, i) => {
|
|
||||||
const seed = (bot.messageCount * (i + 1) * 7 + i * 31) % 100;
|
|
||||||
return seed;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'card group relative overflow-hidden transition-all duration-300',
|
|
||||||
'hover:border-slate-500/60 hover:shadow-lg hover:shadow-cyan-900/10',
|
|
||||||
!bot.active && 'opacity-50 grayscale-[30%]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Gradient top border */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'absolute top-0 left-0 right-0 h-[3px] bg-gradient-to-r',
|
|
||||||
gradient
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="p-5 pb-3 flex items-start justify-between">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<h3 className="text-white font-semibold text-base truncate">
|
|
||||||
{bot.name}
|
|
||||||
</h3>
|
|
||||||
<span className="text-xs text-slate-400 capitalize">{bot.category}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 ml-3 flex-shrink-0">
|
|
||||||
{/* Active toggle */}
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onToggle?.(bot.id, !bot.active);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-cyan-400/40',
|
|
||||||
bot.active ? 'bg-green-500' : 'bg-slate-600'
|
|
||||||
)}
|
|
||||||
aria-label={bot.active ? 'Deactivate bot' : 'Activate bot'}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform',
|
|
||||||
bot.active ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Expand chevron */}
|
|
||||||
<button
|
|
||||||
onClick={() => setExpanded((p) => !p)}
|
|
||||||
className="text-slate-400 hover:text-white transition-colors p-0.5"
|
|
||||||
aria-label={expanded ? 'Collapse' : 'Expand'}
|
|
||||||
>
|
|
||||||
{expanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Field rows */}
|
|
||||||
<div className="px-5 pb-4 space-y-2.5 text-sm">
|
|
||||||
{/* Twilio Number */}
|
|
||||||
<div className="flex items-center gap-2 text-slate-300">
|
|
||||||
<Phone size={14} className="text-slate-500 flex-shrink-0" />
|
|
||||||
<span className="text-slate-400 w-20 flex-shrink-0">Twilio #</span>
|
|
||||||
<span className={cn('truncate', bot.twilioNumber ? 'text-slate-200' : 'text-slate-500 italic')}>
|
|
||||||
{bot.twilioNumber ? formatPhone(bot.twilioNumber) : 'Unassigned'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Messages */}
|
|
||||||
<div className="flex items-center gap-2 text-slate-300">
|
|
||||||
<MessageSquare size={14} className="text-slate-500 flex-shrink-0" />
|
|
||||||
<span className="text-slate-400 w-20 flex-shrink-0">Messages</span>
|
|
||||||
<span className="num text-slate-200">{bot.messageCount.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Conversion */}
|
|
||||||
<div className="flex items-center gap-2 text-slate-300">
|
|
||||||
<TrendingUp size={14} className="text-slate-500 flex-shrink-0" />
|
|
||||||
<span className="text-slate-400 w-20 flex-shrink-0">Conv. %</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'num',
|
|
||||||
bot.conversionPct !== null
|
|
||||||
? bot.conversionPct >= 30
|
|
||||||
? 'text-green-400'
|
|
||||||
: bot.conversionPct >= 15
|
|
||||||
? 'text-yellow-400'
|
|
||||||
: 'text-slate-300'
|
|
||||||
: 'text-slate-500'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{bot.conversionPct !== null ? `${bot.conversionPct.toFixed(1)}%` : '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Last Active */}
|
|
||||||
<div className="flex items-center gap-2 text-slate-300">
|
|
||||||
<Clock size={14} className="text-slate-500 flex-shrink-0" />
|
|
||||||
<span className="text-slate-400 w-20 flex-shrink-0">Active</span>
|
|
||||||
<span className="text-slate-200">
|
|
||||||
{bot.lastActive ? relativeTime(bot.lastActive) : '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Published Versions */}
|
|
||||||
<div className="flex items-center gap-2 text-slate-300">
|
|
||||||
<GitBranch size={14} className="text-slate-500 flex-shrink-0" />
|
|
||||||
<span className="text-slate-400 w-20 flex-shrink-0">Versions</span>
|
|
||||||
<span className="text-slate-200">
|
|
||||||
{bot.publishedVersions > 0 ? (
|
|
||||||
<><span className="text-green-400 font-medium">{bot.publishedVersions}</span> published / {bot.totalVersions} total</>
|
|
||||||
) : (
|
|
||||||
<span className="text-slate-500">{bot.totalVersions} draft{bot.totalVersions !== 1 ? 's' : ''}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Modified Date */}
|
|
||||||
<div className="flex items-center gap-2 text-slate-300">
|
|
||||||
<CalendarDays size={14} className="text-slate-500 flex-shrink-0" />
|
|
||||||
<span className="text-slate-400 w-20 flex-shrink-0">Modified</span>
|
|
||||||
<span className="text-slate-200">
|
|
||||||
{bot.modifiedAt ? relativeTime(bot.modifiedAt) : '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expanded area */}
|
|
||||||
{expanded && (
|
|
||||||
<div className="border-t border-slate-700/50 px-5 py-4 space-y-4 animate-in slide-in-from-top-2 duration-200">
|
|
||||||
{/* Connected Sources */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-1.5 text-xs font-medium text-slate-400 mb-2">
|
|
||||||
<Plug size={12} />
|
|
||||||
Connected Sources
|
|
||||||
</div>
|
|
||||||
{bot.sourceNames.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{bot.sourceNames.map((name) => (
|
|
||||||
<span
|
|
||||||
key={name}
|
|
||||||
className="badge bg-slate-700/60 text-slate-300 border-slate-600/50"
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-slate-500 italic">No sources connected</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mini Performance Sparkline */}
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-medium text-slate-400 mb-2">
|
|
||||||
Message Volume (12-period)
|
|
||||||
</div>
|
|
||||||
<Sparkline values={spark} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Edit Flow button */}
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
'w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm font-medium',
|
|
||||||
'bg-cyan-500/10 text-cyan-400 border border-cyan-500/20',
|
|
||||||
'hover:bg-cyan-500/20 transition-colors'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Workflow size={14} />
|
|
||||||
Edit Flow
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,275 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState, useMemo } from 'react';
|
|
||||||
import { Search, SlidersHorizontal, ArrowUpDown, Plus, Loader2, AlertCircle } from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { BotCard, type BotData } from './bot-card';
|
|
||||||
|
|
||||||
type SortKey = 'name' | 'messages' | 'conversion' | 'lastActive';
|
|
||||||
|
|
||||||
export function BotGrid() {
|
|
||||||
// Data
|
|
||||||
const [bots, setBots] = useState<BotData[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [isMock, setIsMock] = useState(false);
|
|
||||||
|
|
||||||
// Filters
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [showInactive, setShowInactive] = useState(true);
|
|
||||||
const [sortKey, setSortKey] = useState<SortKey>('name');
|
|
||||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
|
||||||
const [sortOpen, setSortOpen] = useState(false);
|
|
||||||
|
|
||||||
// Fetch bots
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
setLoading(true);
|
|
||||||
fetch('/api/bots')
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setBots(data.bots ?? []);
|
|
||||||
setIsMock(data.mock ?? false);
|
|
||||||
setError(null);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setError(err.message || 'Failed to load bots');
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Toggle handler (optimistic UI)
|
|
||||||
function handleToggle(id: string, active: boolean) {
|
|
||||||
setBots((prev) =>
|
|
||||||
prev.map((b) => (b.id === id ? { ...b, active } : b))
|
|
||||||
);
|
|
||||||
// TODO: POST to API to persist toggle
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derived list
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
let list = bots;
|
|
||||||
// search
|
|
||||||
if (search.trim()) {
|
|
||||||
const q = search.toLowerCase();
|
|
||||||
list = list.filter(
|
|
||||||
(b) =>
|
|
||||||
b.name.toLowerCase().includes(q) ||
|
|
||||||
b.category.toLowerCase().includes(q) ||
|
|
||||||
(b.twilioNumber && b.twilioNumber.includes(q))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// filter inactive
|
|
||||||
if (!showInactive) {
|
|
||||||
list = list.filter((b) => b.active);
|
|
||||||
}
|
|
||||||
// sort
|
|
||||||
list = [...list].sort((a, b) => {
|
|
||||||
let cmp = 0;
|
|
||||||
switch (sortKey) {
|
|
||||||
case 'name':
|
|
||||||
cmp = a.name.localeCompare(b.name);
|
|
||||||
break;
|
|
||||||
case 'messages':
|
|
||||||
cmp = a.messageCount - b.messageCount;
|
|
||||||
break;
|
|
||||||
case 'conversion':
|
|
||||||
cmp = (a.conversionPct ?? -1) - (b.conversionPct ?? -1);
|
|
||||||
break;
|
|
||||||
case 'lastActive':
|
|
||||||
cmp =
|
|
||||||
new Date(a.lastActive || 0).getTime() -
|
|
||||||
new Date(b.lastActive || 0).getTime();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return sortDir === 'asc' ? cmp : -cmp;
|
|
||||||
});
|
|
||||||
return list;
|
|
||||||
}, [bots, search, showInactive, sortKey, sortDir]);
|
|
||||||
|
|
||||||
// Sort cycle
|
|
||||||
function cycleSortKey() {
|
|
||||||
setSortOpen((p) => !p);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickSort(key: SortKey) {
|
|
||||||
if (key === sortKey) {
|
|
||||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
|
||||||
} else {
|
|
||||||
setSortKey(key);
|
|
||||||
setSortDir('asc');
|
|
||||||
}
|
|
||||||
setSortOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Render
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
{/* Top bar */}
|
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
|
|
||||||
{/* Search */}
|
|
||||||
<div className="relative flex-1 w-full sm:max-w-sm">
|
|
||||||
<Search
|
|
||||||
size={16}
|
|
||||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search bots…"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
className={cn(
|
|
||||||
'w-full pl-9 pr-3 py-2 rounded-lg text-sm',
|
|
||||||
'bg-navy-800 border border-slate-700/60 text-slate-200 placeholder:text-slate-500',
|
|
||||||
'focus:outline-none focus:ring-2 focus:ring-cyan-400/30 focus:border-cyan-500/50',
|
|
||||||
'transition-colors'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter button */}
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
onClick={() => { setFilterOpen((p) => !p); setSortOpen(false); }}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm border transition-colors',
|
|
||||||
filterOpen
|
|
||||||
? 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30'
|
|
||||||
: 'bg-navy-800 text-slate-400 border-slate-700/60 hover:text-slate-200'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<SlidersHorizontal size={14} />
|
|
||||||
Filter
|
|
||||||
</button>
|
|
||||||
{filterOpen && (
|
|
||||||
<div className="absolute top-full mt-1 left-0 z-20 card p-3 min-w-[180px] space-y-2">
|
|
||||||
<label className="flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={showInactive}
|
|
||||||
onChange={(e) => setShowInactive(e.target.checked)}
|
|
||||||
className="accent-cyan-400 rounded"
|
|
||||||
/>
|
|
||||||
Show inactive bots
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sort button */}
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
onClick={() => { cycleSortKey(); setFilterOpen(false); }}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm border transition-colors',
|
|
||||||
sortOpen
|
|
||||||
? 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30'
|
|
||||||
: 'bg-navy-800 text-slate-400 border-slate-700/60 hover:text-slate-200'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ArrowUpDown size={14} />
|
|
||||||
Sort
|
|
||||||
</button>
|
|
||||||
{sortOpen && (
|
|
||||||
<div className="absolute top-full mt-1 left-0 z-20 card p-1 min-w-[180px]">
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
['name', 'Name'],
|
|
||||||
['messages', 'Messages'],
|
|
||||||
['conversion', 'Conversion %'],
|
|
||||||
['lastActive', 'Last Active'],
|
|
||||||
] as [SortKey, string][]
|
|
||||||
).map(([key, label]) => (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
onClick={() => pickSort(key)}
|
|
||||||
className={cn(
|
|
||||||
'w-full text-left px-3 py-1.5 rounded-md text-sm transition-colors',
|
|
||||||
sortKey === key
|
|
||||||
? 'bg-cyan-500/15 text-cyan-400'
|
|
||||||
: 'text-slate-300 hover:bg-slate-700/50'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
{sortKey === key && (
|
|
||||||
<span className="ml-1 text-xs opacity-60">
|
|
||||||
{sortDir === 'asc' ? '↑' : '↓'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Create New Bot */}
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium',
|
|
||||||
'bg-cyan-500 text-white hover:bg-cyan-400 transition-colors',
|
|
||||||
'shadow-lg shadow-cyan-500/20 ml-auto'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Plus size={16} />
|
|
||||||
Create New Bot
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mock data banner */}
|
|
||||||
{isMock && !loading && (
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-yellow-500/10 border border-yellow-500/20 text-yellow-400 text-xs">
|
|
||||||
<AlertCircle size={14} />
|
|
||||||
Showing sample data — connect your CloseBot API key for live bots.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Loading state */}
|
|
||||||
{loading && (
|
|
||||||
<div className="flex items-center justify-center py-20">
|
|
||||||
<Loader2 size={28} className="animate-spin text-cyan-400" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error state */}
|
|
||||||
{error && !loading && (
|
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-slate-400">
|
|
||||||
<AlertCircle size={32} className="text-red-400 mb-2" />
|
|
||||||
<p className="text-sm">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty state */}
|
|
||||||
{!loading && !error && filtered.length === 0 && (
|
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-slate-400">
|
|
||||||
<p className="text-sm">
|
|
||||||
{search ? 'No bots match your search.' : 'No bots found.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Bot grid */}
|
|
||||||
{!loading && !error && filtered.length > 0 && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{filtered.map((bot, i) => (
|
|
||||||
<BotCard
|
|
||||||
key={bot.id}
|
|
||||||
bot={bot}
|
|
||||||
index={i}
|
|
||||||
onToggle={handleToggle}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,332 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { formatPhone, relativeTime } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface ContactDetail {
|
|
||||||
id: string;
|
|
||||||
phone: string;
|
|
||||||
name: string | null;
|
|
||||||
email: string | null;
|
|
||||||
status: string;
|
|
||||||
tags: string;
|
|
||||||
fields_json: string;
|
|
||||||
message_count: number;
|
|
||||||
last_contact: string;
|
|
||||||
first_contact: string;
|
|
||||||
bot_name: string | null;
|
|
||||||
assigned_bot_id: string | null;
|
|
||||||
closebot_lead_id: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Message {
|
|
||||||
id: string;
|
|
||||||
direction: string;
|
|
||||||
source: string;
|
|
||||||
body: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
contactId: string;
|
|
||||||
onClose: () => void;
|
|
||||||
onUpdated: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContactDetailPanel({ contactId, onClose, onUpdated }: Props) {
|
|
||||||
const [contact, setContact] = useState<ContactDetail | null>(null);
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchDetail = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/contacts/${contactId}`);
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setContact(data.contact);
|
|
||||||
setMessages(data.messages || []);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch contact detail:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchDetail();
|
|
||||||
}, [contactId]);
|
|
||||||
|
|
||||||
const parseTags = (s: string): string[] => {
|
|
||||||
try { const p = JSON.parse(s); return Array.isArray(p) ? p : []; } catch { return []; }
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseFields = (s: string): Record<string, string> => {
|
|
||||||
try { const p = JSON.parse(s); return typeof p === 'object' && p !== null ? p : {}; } catch { return {}; }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build a conversation summary from recent messages
|
|
||||||
const buildSummary = (msgs: Message[]): string[] => {
|
|
||||||
if (msgs.length === 0) return ['No messages yet'];
|
|
||||||
const summary: string[] = [];
|
|
||||||
const inbound = msgs.filter((m) => m.direction === 'inbound').length;
|
|
||||||
const outbound = msgs.filter((m) => m.direction === 'outbound').length;
|
|
||||||
summary.push(`${inbound} inbound, ${outbound} outbound messages`);
|
|
||||||
if (msgs.length > 0) {
|
|
||||||
const first = msgs[0];
|
|
||||||
summary.push(`First message: ${relativeTime(first.created_at)}`);
|
|
||||||
}
|
|
||||||
if (msgs.length > 1) {
|
|
||||||
const last = msgs[msgs.length - 1];
|
|
||||||
summary.push(`Latest message: ${relativeTime(last.created_at)}`);
|
|
||||||
const preview = last.body.length > 80 ? last.body.slice(0, 80) + '…' : last.body;
|
|
||||||
summary.push(`Last: "${preview}"`);
|
|
||||||
}
|
|
||||||
return summary;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="w-80 shrink-0 card animate-pulse flex items-center justify-center"
|
|
||||||
style={{ minHeight: 400 }}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center gap-2" style={{ color: 'var(--text-muted)' }}>
|
|
||||||
<svg className="animate-spin h-6 w-6" style={{ color: 'var(--accent-cyan)' }} viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
||||||
</svg>
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!contact) {
|
|
||||||
return (
|
|
||||||
<div className="w-80 shrink-0 card p-6 text-center" style={{ color: 'var(--text-muted)' }}>
|
|
||||||
Contact not found
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tags = parseTags(contact.tags);
|
|
||||||
const fields = parseFields(contact.fields_json);
|
|
||||||
const fieldEntries = Object.entries(fields).filter(([, v]) => v);
|
|
||||||
const summary = buildSummary(messages);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="w-80 shrink-0 card overflow-y-auto animate-in slide-in-from-right"
|
|
||||||
style={{ maxHeight: 'calc(100vh - 180px)' }}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
className="sticky top-0 z-10 px-5 py-4 flex items-center justify-between border-b"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(30, 41, 59, 0.95)',
|
|
||||||
backdropFilter: 'blur(8px)',
|
|
||||||
borderColor: 'var(--border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h2 className="text-lg font-bold text-white truncate">
|
|
||||||
{contact.name || 'Unknown Contact'}
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-1 rounded-lg transition-colors hover:bg-white/10"
|
|
||||||
style={{ color: 'var(--text-muted)' }}
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-5 space-y-5">
|
|
||||||
{/* Contact Info Fields */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Phone */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
|
|
||||||
style={{ background: 'rgba(34, 211, 238, 0.1)' }}
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ color: 'var(--accent-cyan)' }}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>Phone</p>
|
|
||||||
<p className="text-sm font-medium text-white num">{formatPhone(contact.phone)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Email */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
|
|
||||||
style={{ background: 'rgba(59, 130, 246, 0.1)' }}
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ color: 'var(--accent-blue)' }}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>Email</p>
|
|
||||||
<p className="text-sm font-medium" style={{ color: contact.email ? 'white' : 'var(--text-muted)' }}>
|
|
||||||
{contact.email || 'Not provided'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bot / Company */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
|
|
||||||
style={{ background: 'rgba(168, 85, 247, 0.1)' }}
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ color: 'var(--accent-purple)' }}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>Assigned Bot</p>
|
|
||||||
<p className="text-sm font-medium" style={{ color: contact.bot_name ? 'white' : 'var(--text-muted)' }}>
|
|
||||||
{contact.bot_name || 'Unassigned'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
{tags.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium mb-2" style={{ color: 'var(--text-muted)' }}>Tags</p>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{tags.map((tag, i) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className="px-2 py-0.5 rounded-full text-xs font-medium"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(34, 211, 238, 0.1)',
|
|
||||||
color: 'var(--accent-cyan)',
|
|
||||||
border: '1px solid rgba(34, 211, 238, 0.2)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Conversation Summary */}
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium mb-2" style={{ color: 'var(--text-muted)' }}>
|
|
||||||
Conversation Summary
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
className="rounded-lg p-3 space-y-1.5"
|
|
||||||
style={{ background: 'var(--bg-primary)', border: '1px solid var(--border)' }}
|
|
||||||
>
|
|
||||||
{summary.map((item, i) => (
|
|
||||||
<div key={i} className="flex items-start gap-2 text-xs">
|
|
||||||
<span className="mt-1.5 w-1.5 h-1.5 rounded-full shrink-0" style={{ background: 'var(--accent-cyan)' }} />
|
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>{item}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Field Values Collected */}
|
|
||||||
{fieldEntries.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium mb-2" style={{ color: 'var(--text-muted)' }}>
|
|
||||||
Field Values Collected
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
className="rounded-lg overflow-hidden"
|
|
||||||
style={{ border: '1px solid var(--border)' }}
|
|
||||||
>
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr style={{ background: 'var(--bg-primary)' }}>
|
|
||||||
<th className="text-left px-3 py-2 font-medium" style={{ color: 'var(--text-muted)' }}>Field</th>
|
|
||||||
<th className="text-left px-3 py-2 font-medium" style={{ color: 'var(--text-muted)' }}>Value</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{fieldEntries.map(([key, val], i) => (
|
|
||||||
<tr
|
|
||||||
key={key}
|
|
||||||
style={{
|
|
||||||
borderTop: i > 0 ? '1px solid var(--border)' : undefined,
|
|
||||||
background: 'var(--bg-tertiary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<td className="px-3 py-2 font-medium capitalize" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{key.replace(/_/g, ' ')}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-white">{val}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="space-y-2 pt-2">
|
|
||||||
<a
|
|
||||||
href={`/conversations?contact=${contact.id}`}
|
|
||||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors"
|
|
||||||
style={{
|
|
||||||
background: 'var(--accent-cyan)',
|
|
||||||
color: '#0f1729',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.opacity = '0.9')}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.opacity = '1')}
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
|
||||||
</svg>
|
|
||||||
View Full History
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
// Navigate to conversations with this contact
|
|
||||||
window.location.href = `/conversations?contact=${contact.id}&send=true`;
|
|
||||||
}}
|
|
||||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors"
|
|
||||||
style={{
|
|
||||||
background: 'transparent',
|
|
||||||
color: 'var(--accent-cyan)',
|
|
||||||
border: '1px solid var(--accent-cyan)',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = 'rgba(34, 211, 238, 0.1)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = 'transparent';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
|
||||||
</svg>
|
|
||||||
Send Message
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer meta */}
|
|
||||||
<div className="text-xs pt-2 space-y-1" style={{ color: 'var(--text-muted)' }}>
|
|
||||||
<p>First contact: {contact.first_contact ? relativeTime(contact.first_contact) : '—'}</p>
|
|
||||||
<p>Total messages: {contact.message_count}</p>
|
|
||||||
{contact.closebot_lead_id && <p>Lead ID: {contact.closebot_lead_id}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,266 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
||||||
import type { ContactFilterParams } from '@/app/contacts/page';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onChange: (filters: ContactFilterParams) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
|
||||||
{ value: '', label: 'All Statuses' },
|
|
||||||
{ value: 'new', label: 'New' },
|
|
||||||
{ value: 'hot', label: 'Hot Lead' },
|
|
||||||
{ value: 'warm', label: 'Warm' },
|
|
||||||
{ value: 'cold', label: 'Cold' },
|
|
||||||
{ value: 'active', label: 'Active' },
|
|
||||||
{ value: 'closed', label: 'Closed' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function ContactFilters({ onChange }: Props) {
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [status, setStatus] = useState('');
|
|
||||||
const [bot, setBot] = useState('');
|
|
||||||
const [dateFrom, setDateFrom] = useState('');
|
|
||||||
const [dateTo, setDateTo] = useState('');
|
|
||||||
const [bots, setBots] = useState<{ id: string; name: string }[]>([]);
|
|
||||||
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
|
||||||
|
|
||||||
// Fetch available bots for the dropdown
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/contacts?limit=0')
|
|
||||||
.catch(() => null); // silent fail — bots are optional
|
|
||||||
// Try to load routes for bot names
|
|
||||||
fetch('/api/routes')
|
|
||||||
.then((r) => r.ok ? r.json() : { routes: [] })
|
|
||||||
.then((data) => {
|
|
||||||
const routes = data.routes || [];
|
|
||||||
const botList = routes
|
|
||||||
.filter((r: { bot_name?: string; closebot_bot_id?: string }) => r.bot_name)
|
|
||||||
.map((r: { closebot_bot_id: string; bot_name: string }) => ({
|
|
||||||
id: r.closebot_bot_id,
|
|
||||||
name: r.bot_name,
|
|
||||||
}));
|
|
||||||
// Deduplicate by name
|
|
||||||
const seen = new Set<string>();
|
|
||||||
setBots(botList.filter((b: { name: string }) => {
|
|
||||||
if (seen.has(b.name)) return false;
|
|
||||||
seen.add(b.name);
|
|
||||||
return true;
|
|
||||||
}));
|
|
||||||
})
|
|
||||||
.catch(() => setBots([]));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const emitChange = useCallback(
|
|
||||||
(overrides: Partial<ContactFilterParams> = {}) => {
|
|
||||||
const filters: ContactFilterParams = {
|
|
||||||
search: overrides.search ?? search,
|
|
||||||
status: overrides.status ?? status,
|
|
||||||
bot: overrides.bot ?? bot,
|
|
||||||
dateFrom: overrides.dateFrom ?? dateFrom,
|
|
||||||
dateTo: overrides.dateTo ?? dateTo,
|
|
||||||
};
|
|
||||||
// Strip empty values
|
|
||||||
Object.keys(filters).forEach((k) => {
|
|
||||||
const key = k as keyof ContactFilterParams;
|
|
||||||
if (!filters[key]) delete filters[key];
|
|
||||||
});
|
|
||||||
onChange(filters);
|
|
||||||
},
|
|
||||||
[search, status, bot, dateFrom, dateTo, onChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSearch = (value: string) => {
|
|
||||||
setSearch(value);
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
||||||
debounceRef.current = setTimeout(() => {
|
|
||||||
emitChange({ search: value });
|
|
||||||
}, 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExportCSV = async () => {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (search) params.set('search', search);
|
|
||||||
if (status) params.set('status', status);
|
|
||||||
if (bot) params.set('bot', bot);
|
|
||||||
|
|
||||||
const res = await fetch(`/api/contacts/export?${params.toString()}`);
|
|
||||||
if (!res.ok) throw new Error('Export failed');
|
|
||||||
|
|
||||||
const blob = await res.blob();
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `contacts-export-${new Date().toISOString().split('T')[0]}.csv`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Export failed:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectStyle: React.CSSProperties = {
|
|
||||||
background: 'var(--bg-tertiary)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card p-4">
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
{/* Search */}
|
|
||||||
<div className="relative flex-1 min-w-[220px]">
|
|
||||||
<svg
|
|
||||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
style={{ color: 'var(--text-muted)' }}
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search by name, phone, or email..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => handleSearch(e.target.value)}
|
|
||||||
className="w-full pl-10 pr-4 py-2 rounded-lg text-sm outline-none focus:ring-2 transition-shadow"
|
|
||||||
style={{
|
|
||||||
...selectStyle,
|
|
||||||
'--tw-ring-color': 'var(--accent-cyan)',
|
|
||||||
} as React.CSSProperties}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status dropdown */}
|
|
||||||
<select
|
|
||||||
value={status}
|
|
||||||
onChange={(e) => {
|
|
||||||
setStatus(e.target.value);
|
|
||||||
emitChange({ status: e.target.value });
|
|
||||||
}}
|
|
||||||
className="px-3 py-2 rounded-lg text-sm outline-none focus:ring-2 transition-shadow cursor-pointer"
|
|
||||||
style={{
|
|
||||||
...selectStyle,
|
|
||||||
'--tw-ring-color': 'var(--accent-cyan)',
|
|
||||||
} as React.CSSProperties}
|
|
||||||
>
|
|
||||||
{STATUS_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Bot dropdown */}
|
|
||||||
<select
|
|
||||||
value={bot}
|
|
||||||
onChange={(e) => {
|
|
||||||
setBot(e.target.value);
|
|
||||||
emitChange({ bot: e.target.value });
|
|
||||||
}}
|
|
||||||
className="px-3 py-2 rounded-lg text-sm outline-none focus:ring-2 transition-shadow cursor-pointer"
|
|
||||||
style={{
|
|
||||||
...selectStyle,
|
|
||||||
'--tw-ring-color': 'var(--accent-cyan)',
|
|
||||||
} as React.CSSProperties}
|
|
||||||
>
|
|
||||||
<option value="">All Bots</option>
|
|
||||||
{bots.map((b) => (
|
|
||||||
<option key={b.id} value={b.id}>{b.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Date From */}
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={dateFrom}
|
|
||||||
onChange={(e) => {
|
|
||||||
setDateFrom(e.target.value);
|
|
||||||
emitChange({ dateFrom: e.target.value });
|
|
||||||
}}
|
|
||||||
className="px-3 py-2 rounded-lg text-sm outline-none focus:ring-2 transition-shadow"
|
|
||||||
style={{
|
|
||||||
...selectStyle,
|
|
||||||
'--tw-ring-color': 'var(--accent-cyan)',
|
|
||||||
} as React.CSSProperties}
|
|
||||||
title="From date"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Date To */}
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={dateTo}
|
|
||||||
onChange={(e) => {
|
|
||||||
setDateTo(e.target.value);
|
|
||||||
emitChange({ dateTo: e.target.value });
|
|
||||||
}}
|
|
||||||
className="px-3 py-2 rounded-lg text-sm outline-none focus:ring-2 transition-shadow"
|
|
||||||
style={{
|
|
||||||
...selectStyle,
|
|
||||||
'--tw-ring-color': 'var(--accent-cyan)',
|
|
||||||
} as React.CSSProperties}
|
|
||||||
title="To date"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex items-center gap-2 ml-auto">
|
|
||||||
<button
|
|
||||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
|
||||||
style={{
|
|
||||||
background: 'transparent',
|
|
||||||
color: 'var(--accent-cyan)',
|
|
||||||
border: '1px solid var(--accent-cyan)',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = 'rgba(34, 211, 238, 0.1)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = 'transparent';
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
// Trigger file upload for import
|
|
||||||
const input = document.createElement('input');
|
|
||||||
input.type = 'file';
|
|
||||||
input.accept = '.csv,.json';
|
|
||||||
input.onchange = () => {
|
|
||||||
// TODO: wire up import endpoint
|
|
||||||
console.log('Import file selected:', input.files?.[0]?.name);
|
|
||||||
};
|
|
||||||
input.click();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
|
||||||
</svg>
|
|
||||||
Import Contacts
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleExportCSV}
|
|
||||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
|
||||||
style={{
|
|
||||||
background: 'transparent',
|
|
||||||
color: 'var(--accent-cyan)',
|
|
||||||
border: '1px solid var(--accent-cyan)',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = 'rgba(34, 211, 238, 0.1)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = 'transparent';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
||||||
</svg>
|
|
||||||
Export CSV
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,248 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
|
||||||
import { formatPhone, relativeTime } from '@/lib/utils';
|
|
||||||
import type { ContactFilterParams } from '@/app/contacts/page';
|
|
||||||
|
|
||||||
interface Contact {
|
|
||||||
id: string;
|
|
||||||
phone: string;
|
|
||||||
name: string | null;
|
|
||||||
email: string | null;
|
|
||||||
status: string;
|
|
||||||
tags: string;
|
|
||||||
message_count: number;
|
|
||||||
last_contact: string;
|
|
||||||
first_contact: string;
|
|
||||||
bot_name: string | null;
|
|
||||||
assigned_bot_id: string | null;
|
|
||||||
fields_json: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ContactsTableProps {
|
|
||||||
filters: ContactFilterParams;
|
|
||||||
refreshKey: number;
|
|
||||||
selectedContactId: string | null;
|
|
||||||
onSelect: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<string, { label: string; dot: string; badgeClass: string }> = {
|
|
||||||
hot: { label: 'Hot Lead', dot: 'bg-red-500', badgeClass: 'badge-hot' },
|
|
||||||
warm: { label: 'Warm', dot: 'bg-orange-500', badgeClass: 'badge-warm' },
|
|
||||||
cold: { label: 'Cold', dot: 'bg-slate-400', badgeClass: 'badge-cold' },
|
|
||||||
new: { label: 'New', dot: 'bg-blue-500', badgeClass: 'badge-new' },
|
|
||||||
active: { label: 'Active', dot: 'bg-green-500', badgeClass: 'badge-active' },
|
|
||||||
closed: { label: 'Closed', dot: 'bg-gray-500', badgeClass: 'badge-closed' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ContactsTable({ filters, refreshKey, selectedContactId, onSelect }: ContactsTableProps) {
|
|
||||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [page, setPage] = useState(0);
|
|
||||||
const limit = 50;
|
|
||||||
|
|
||||||
const fetchContacts = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (filters.search) params.set('search', filters.search);
|
|
||||||
if (filters.status) params.set('status', filters.status);
|
|
||||||
if (filters.bot) params.set('bot', filters.bot);
|
|
||||||
params.set('limit', String(limit));
|
|
||||||
params.set('offset', String(page * limit));
|
|
||||||
|
|
||||||
const res = await fetch(`/api/contacts?${params.toString()}`);
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setContacts(data.contacts);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch contacts:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [filters, page, refreshKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchContacts();
|
|
||||||
}, [fetchContacts]);
|
|
||||||
|
|
||||||
// Reset page when filters change
|
|
||||||
useEffect(() => {
|
|
||||||
setPage(0);
|
|
||||||
}, [filters]);
|
|
||||||
|
|
||||||
const parseTags = (tagsStr: string): string[] => {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(tagsStr);
|
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatus = (status: string) => STATUS_CONFIG[status] || STATUS_CONFIG.new;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card overflow-hidden">
|
|
||||||
{/* Table */}
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b" style={{ borderColor: 'var(--border)' }}>
|
|
||||||
<th className="text-left px-4 py-3 font-medium" style={{ color: 'var(--text-secondary)' }}>Name</th>
|
|
||||||
<th className="text-left px-4 py-3 font-medium" style={{ color: 'var(--text-secondary)' }}>Phone Number</th>
|
|
||||||
<th className="text-left px-4 py-3 font-medium" style={{ color: 'var(--text-secondary)' }}>Assigned Bot</th>
|
|
||||||
<th className="text-left px-4 py-3 font-medium" style={{ color: 'var(--text-secondary)' }}>Status</th>
|
|
||||||
<th className="text-center px-4 py-3 font-medium" style={{ color: 'var(--text-secondary)' }}>Messages</th>
|
|
||||||
<th className="text-left px-4 py-3 font-medium" style={{ color: 'var(--text-secondary)' }}>Last Contact</th>
|
|
||||||
<th className="text-left px-4 py-3 font-medium" style={{ color: 'var(--text-secondary)' }}>Tags</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{loading ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} className="px-4 py-12 text-center" style={{ color: 'var(--text-muted)' }}>
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<svg className="animate-spin h-5 w-5" style={{ color: 'var(--accent-cyan)' }} viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
||||||
</svg>
|
|
||||||
Loading contacts...
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : contacts.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} className="px-4 py-12 text-center" style={{ color: 'var(--text-muted)' }}>
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<svg className="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ color: 'var(--text-muted)' }}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
<p>No contacts found</p>
|
|
||||||
<p className="text-xs">Try adjusting your filters</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
contacts.map((contact) => {
|
|
||||||
const status = getStatus(contact.status);
|
|
||||||
const tags = parseTags(contact.tags);
|
|
||||||
const isSelected = contact.id === selectedContactId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={contact.id}
|
|
||||||
onClick={() => onSelect(contact.id)}
|
|
||||||
className="cursor-pointer transition-colors duration-150"
|
|
||||||
style={{
|
|
||||||
borderLeft: isSelected ? '3px solid var(--accent-cyan)' : '3px solid transparent',
|
|
||||||
background: isSelected ? 'rgba(34, 211, 238, 0.05)' : undefined,
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!isSelected) e.currentTarget.style.background = '#2d3748';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = isSelected ? 'rgba(34, 211, 238, 0.05)' : '';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Name */}
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className="font-semibold text-white">
|
|
||||||
{contact.name || 'Unknown'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Phone */}
|
|
||||||
<td className="px-4 py-3 num" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{formatPhone(contact.phone)}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Assigned Bot */}
|
|
||||||
<td className="px-4 py-3" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{contact.bot_name || (
|
|
||||||
<span style={{ color: 'var(--text-muted)' }}>Unassigned</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`w-2 h-2 rounded-full ${status.dot}`} />
|
|
||||||
<span className={`badge ${status.badgeClass}`}>{status.label}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Messages */}
|
|
||||||
<td className="px-4 py-3 text-center num" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{contact.message_count}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Last Contact */}
|
|
||||||
<td className="px-4 py-3" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{contact.last_contact ? relativeTime(contact.last_contact) : '—'}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{tags.slice(0, 3).map((tag, i) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className="px-2 py-0.5 rounded-full text-xs font-medium"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(34, 211, 238, 0.1)',
|
|
||||||
color: 'var(--accent-cyan)',
|
|
||||||
border: '1px solid rgba(34, 211, 238, 0.2)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{tags.length > 3 && (
|
|
||||||
<span className="text-xs" style={{ color: 'var(--text-muted)' }}>
|
|
||||||
+{tags.length - 3}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{!loading && contacts.length > 0 && (
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-t" style={{ borderColor: 'var(--border)' }}>
|
|
||||||
<span className="text-xs" style={{ color: 'var(--text-muted)' }}>
|
|
||||||
Showing {page * limit + 1}–{page * limit + contacts.length}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
||||||
disabled={page === 0}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
|
||||||
style={{ background: 'var(--bg-hover)', color: 'var(--text-primary)' }}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<span className="text-xs num" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Page {page + 1}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
disabled={contacts.length < limit}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
|
||||||
style={{ background: 'var(--bg-hover)', color: 'var(--text-primary)' }}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,267 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
||||||
import { Send, Bot, Phone, ArrowLeft } from 'lucide-react';
|
|
||||||
import { cn, formatPhone } from '@/lib/utils';
|
|
||||||
import { MessageBubble, hashColor, getInitials } from './message-bubble';
|
|
||||||
|
|
||||||
interface Message {
|
|
||||||
id: string;
|
|
||||||
contact_id: string;
|
|
||||||
direction: 'inbound' | 'outbound';
|
|
||||||
source: string;
|
|
||||||
body: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Contact {
|
|
||||||
id: string;
|
|
||||||
phone: string;
|
|
||||||
name: string | null;
|
|
||||||
status: string;
|
|
||||||
email: string | null;
|
|
||||||
message_count: number;
|
|
||||||
first_contact: string;
|
|
||||||
last_contact: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatThreadProps {
|
|
||||||
contactId: string;
|
|
||||||
onBack?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatThread({ contactId, onBack }: ChatThreadProps) {
|
|
||||||
const [contact, setContact] = useState<Contact | null>(null);
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
|
||||||
const [input, setInput] = useState('');
|
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
|
||||||
if (scrollRef.current) {
|
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetch conversation data
|
|
||||||
const fetchData = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/conversations/${contactId}`);
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setContact(data.contact);
|
|
||||||
setMessages(data.messages || []);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load conversation:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [contactId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLoading(true);
|
|
||||||
fetchData();
|
|
||||||
const interval = setInterval(fetchData, 5000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [fetchData]);
|
|
||||||
|
|
||||||
// Auto-scroll on new messages
|
|
||||||
useEffect(() => {
|
|
||||||
scrollToBottom();
|
|
||||||
}, [messages, scrollToBottom]);
|
|
||||||
|
|
||||||
// Focus input when conversation loads
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loading) inputRef.current?.focus();
|
|
||||||
}, [loading, contactId]);
|
|
||||||
|
|
||||||
const handleSend = async () => {
|
|
||||||
const text = input.trim();
|
|
||||||
if (!text || sending) return;
|
|
||||||
|
|
||||||
setSending(true);
|
|
||||||
setInput('');
|
|
||||||
|
|
||||||
// Optimistic update
|
|
||||||
const optimistic: Message = {
|
|
||||||
id: `tmp-${Date.now()}`,
|
|
||||||
contact_id: contactId,
|
|
||||||
direction: 'outbound',
|
|
||||||
source: 'manual',
|
|
||||||
body: text,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
setMessages((prev) => [...prev, optimistic]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/conversations/${contactId}/send`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ message: text }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
// Remove optimistic message on failure
|
|
||||||
setMessages((prev) => prev.filter((m) => m.id !== optimistic.id));
|
|
||||||
console.error('Failed to send message');
|
|
||||||
} else {
|
|
||||||
// Replace optimistic with real message
|
|
||||||
const data = await res.json();
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((m) => (m.id === optimistic.id ? { ...optimistic, id: data.messageId || optimistic.id } : m))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setMessages((prev) => prev.filter((m) => m.id !== optimistic.id));
|
|
||||||
} finally {
|
|
||||||
setSending(false);
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSend();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex items-center justify-center bg-[#0f1729]">
|
|
||||||
<div className="w-8 h-8 border-2 border-cyan-400/30 border-t-cyan-400 rounded-full animate-spin" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!contact) {
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex items-center justify-center bg-[#0f1729]">
|
|
||||||
<p className="text-slate-500 text-sm">Contact not found</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayName = contact.name || formatPhone(contact.phone);
|
|
||||||
const avatarColor = hashColor(displayName);
|
|
||||||
const initials = getInitials(contact.name || contact.phone.slice(-4));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex flex-col h-full bg-[#0f1729]">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex-shrink-0 flex items-center justify-between px-5 py-3.5 border-b border-[#334155] bg-[#0f1729]/80 backdrop-blur-sm">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* Mobile back button */}
|
|
||||||
{onBack && (
|
|
||||||
<button onClick={onBack} className="lg:hidden p-1.5 -ml-1.5 text-slate-400 hover:text-slate-200 transition-colors">
|
|
||||||
<ArrowLeft className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* Avatar */}
|
|
||||||
<div
|
|
||||||
className="w-10 h-10 rounded-full flex items-center justify-center text-white text-sm font-bold"
|
|
||||||
style={{ backgroundColor: avatarColor }}
|
|
||||||
>
|
|
||||||
{initials}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-bold text-slate-100 text-[15px]">{contact.name || 'Unknown'}</h3>
|
|
||||||
<span className="inline-flex items-center gap-1 bg-cyan-500/15 text-cyan-400 px-2 py-0.5 rounded-full text-[10px] font-medium border border-cyan-500/20">
|
|
||||||
<Bot className="w-3 h-3" />
|
|
||||||
CloseBot SMS AI
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
|
||||||
<Phone className="w-3 h-3 text-slate-500" />
|
|
||||||
<span className="text-xs text-slate-400">{formatPhone(contact.phone)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse" />
|
|
||||||
<span className="text-xs text-green-400 font-medium">Active</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Messages area */}
|
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-4 flex flex-col">
|
|
||||||
{messages.length === 0 ? (
|
|
||||||
<div className="flex-1 flex items-center justify-center">
|
|
||||||
<div className="text-center text-slate-500">
|
|
||||||
<Bot className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
|
||||||
<p className="text-sm font-medium">No messages yet</p>
|
|
||||||
<p className="text-xs mt-1 opacity-70">Send a message to start the conversation</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Date separator for first message */}
|
|
||||||
<div className="flex items-center gap-3 mb-4 mt-2">
|
|
||||||
<div className="flex-1 h-px bg-[#334155]" />
|
|
||||||
<span className="text-[11px] text-slate-500 font-medium">
|
|
||||||
{new Date(messages[0].created_at).toLocaleDateString(undefined, {
|
|
||||||
weekday: 'short',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 h-px bg-[#334155]" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{messages.map((msg, i) => {
|
|
||||||
// Show avatar only for first message in a sequence from same direction
|
|
||||||
const prevMsg = i > 0 ? messages[i - 1] : null;
|
|
||||||
const showAvatar = !prevMsg || prevMsg.direction !== msg.direction;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MessageBubble
|
|
||||||
key={msg.id}
|
|
||||||
body={msg.body}
|
|
||||||
direction={msg.direction}
|
|
||||||
source={msg.source}
|
|
||||||
createdAt={msg.created_at}
|
|
||||||
contactName={contact.name}
|
|
||||||
showAvatar={showAvatar}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Message input */}
|
|
||||||
<div className="flex-shrink-0 px-5 py-4 border-t border-[#334155]">
|
|
||||||
<div className="flex items-center gap-3 bg-[#1a2332] border border-[#334155] rounded-xl px-4 py-2 focus-within:border-cyan-500/50 focus-within:ring-1 focus-within:ring-cyan-500/20 transition-all">
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Type a message..."
|
|
||||||
className="flex-1 bg-transparent text-sm text-slate-100 placeholder:text-slate-500 focus:outline-none"
|
|
||||||
disabled={sending}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSend}
|
|
||||||
disabled={!input.trim() || sending}
|
|
||||||
className={cn(
|
|
||||||
'p-2 rounded-lg transition-all duration-200',
|
|
||||||
input.trim() && !sending
|
|
||||||
? 'bg-cyan-500 text-white hover:bg-cyan-400 shadow-lg shadow-cyan-500/25'
|
|
||||||
: 'bg-[#334155] text-slate-500 cursor-not-allowed'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Send className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,198 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { Search, Filter, SlidersHorizontal, MessageSquare } from 'lucide-react';
|
|
||||||
import { cn, formatPhone, relativeTime, truncate } from '@/lib/utils';
|
|
||||||
import { hashColor, getInitials } from './message-bubble';
|
|
||||||
|
|
||||||
interface ConversationItem {
|
|
||||||
id: string;
|
|
||||||
phone: string;
|
|
||||||
name: string | null;
|
|
||||||
status: string;
|
|
||||||
last_message: string | null;
|
|
||||||
last_message_at: string | null;
|
|
||||||
unread_count: number;
|
|
||||||
message_count: number;
|
|
||||||
bot_name: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConversationListProps {
|
|
||||||
selectedId: string | null;
|
|
||||||
onSelect: (contactId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
|
||||||
{ value: '', label: 'All Filters' },
|
|
||||||
{ value: 'new', label: 'New' },
|
|
||||||
{ value: 'active', label: 'Active' },
|
|
||||||
{ value: 'hot', label: 'Hot Leads' },
|
|
||||||
{ value: 'warm', label: 'Warm Leads' },
|
|
||||||
{ value: 'cold', label: 'Cold Leads' },
|
|
||||||
{ value: 'closed', label: 'Closed' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function ConversationList({ selectedId, onSelect }: ConversationListProps) {
|
|
||||||
const [conversations, setConversations] = useState<ConversationItem[]>([]);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
|
||||||
|
|
||||||
const fetchConversations = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (search) params.set('search', search);
|
|
||||||
if (statusFilter) params.set('status', statusFilter);
|
|
||||||
params.set('limit', '100');
|
|
||||||
const res = await fetch(`/api/conversations?${params}`);
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setConversations(data.conversations || []);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load conversations:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [search, statusFilter]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchConversations();
|
|
||||||
const interval = setInterval(fetchConversations, 8000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [fetchConversations]);
|
|
||||||
|
|
||||||
// Debounced search
|
|
||||||
const [searchInput, setSearchInput] = useState('');
|
|
||||||
useEffect(() => {
|
|
||||||
const t = setTimeout(() => setSearch(searchInput), 350);
|
|
||||||
return () => clearTimeout(t);
|
|
||||||
}, [searchInput]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full bg-[#0f1729] border-r border-[#334155]">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex-shrink-0 px-4 pt-5 pb-3">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-lg font-bold text-slate-100 flex items-center gap-2">
|
|
||||||
<MessageSquare className="w-5 h-5 text-cyan-400" />
|
|
||||||
Conversations
|
|
||||||
</h2>
|
|
||||||
<span className="text-xs text-slate-500 num">{conversations.length}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search */}
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search contacts..."
|
|
||||||
value={searchInput}
|
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
|
||||||
className="w-full bg-[#1a2332] border border-[#334155] rounded-lg pl-10 pr-20 py-2.5 text-sm text-slate-100 placeholder:text-slate-500 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors"
|
|
||||||
/>
|
|
||||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowFilters(!showFilters)}
|
|
||||||
className={cn(
|
|
||||||
'p-1.5 rounded-md transition-colors',
|
|
||||||
showFilters ? 'bg-cyan-500/20 text-cyan-400' : 'text-slate-500 hover:text-slate-300 hover:bg-[#334155]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Filter className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
<button className="p-1.5 rounded-md text-slate-500 hover:text-slate-300 hover:bg-[#334155] transition-colors">
|
|
||||||
<SlidersHorizontal className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter dropdown */}
|
|
||||||
{showFilters && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<select
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
|
||||||
className="w-full bg-[#1a2332] border border-[#334155] rounded-lg px-3 py-2 text-sm text-slate-300 focus:outline-none focus:border-cyan-500/50 cursor-pointer"
|
|
||||||
>
|
|
||||||
{STATUS_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Conversation list */}
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center h-32">
|
|
||||||
<div className="w-6 h-6 border-2 border-cyan-400/30 border-t-cyan-400 rounded-full animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : conversations.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center h-48 text-slate-500 px-6 text-center">
|
|
||||||
<MessageSquare className="w-10 h-10 mb-3 opacity-30" />
|
|
||||||
<p className="text-sm font-medium">No conversations yet</p>
|
|
||||||
<p className="text-xs mt-1 opacity-70">Conversations will appear when contacts message in</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
conversations.map((conv) => {
|
|
||||||
const isSelected = selectedId === conv.id;
|
|
||||||
const displayName = conv.name || formatPhone(conv.phone);
|
|
||||||
const color = hashColor(displayName);
|
|
||||||
const initials = getInitials(conv.name || conv.phone.slice(-4));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={conv.id}
|
|
||||||
onClick={() => onSelect(conv.id)}
|
|
||||||
className={cn(
|
|
||||||
'w-full flex items-start gap-3 px-4 py-3.5 text-left transition-all duration-150 border-l-[3px] border-l-transparent',
|
|
||||||
isSelected
|
|
||||||
? 'bg-[#1a2332] border-l-cyan-400'
|
|
||||||
: 'hover:bg-[#1a2332]/60'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Avatar */}
|
|
||||||
<div
|
|
||||||
className="w-10 h-10 rounded-full flex items-center justify-center text-white text-sm font-bold flex-shrink-0"
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
>
|
|
||||||
{initials}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="font-semibold text-sm text-cyan-300 truncate">
|
|
||||||
{conv.name || 'Unknown'}
|
|
||||||
</span>
|
|
||||||
{conv.last_message_at && (
|
|
||||||
<span className="text-[11px] text-slate-500 flex-shrink-0 num">
|
|
||||||
{relativeTime(conv.last_message_at)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] text-slate-500 mt-0.5">{formatPhone(conv.phone)}</p>
|
|
||||||
<div className="flex items-center justify-between mt-1">
|
|
||||||
<p className="text-xs text-slate-400 truncate pr-2">
|
|
||||||
{conv.last_message ? truncate(conv.last_message, 40) : 'No messages yet'}
|
|
||||||
</p>
|
|
||||||
{conv.unread_count > 0 && (
|
|
||||||
<span className="flex-shrink-0 w-5 h-5 bg-cyan-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center">
|
|
||||||
{conv.unread_count > 9 ? '9+' : conv.unread_count}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
// Deterministic color from string
|
|
||||||
const AVATAR_COLORS = [
|
|
||||||
'#ef4444', '#f97316', '#eab308', '#22c55e', '#14b8a6',
|
|
||||||
'#06b6d4', '#3b82f6', '#8b5cf6', '#a855f7', '#ec4899',
|
|
||||||
];
|
|
||||||
|
|
||||||
function hashColor(str: string) {
|
|
||||||
let h = 0;
|
|
||||||
for (let i = 0; i < str.length; i++) h = str.charCodeAt(i) + ((h << 5) - h);
|
|
||||||
return AVATAR_COLORS[Math.abs(h) % AVATAR_COLORS.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInitials(name?: string | null) {
|
|
||||||
if (!name) return '?';
|
|
||||||
const parts = name.trim().split(/\s+/);
|
|
||||||
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
|
|
||||||
return parts[0].slice(0, 2).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MessageBubbleProps {
|
|
||||||
body: string;
|
|
||||||
direction: 'inbound' | 'outbound';
|
|
||||||
source: string;
|
|
||||||
createdAt: string;
|
|
||||||
contactName?: string | null;
|
|
||||||
showAvatar?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MessageBubble({ body, direction, source, createdAt, contactName, showAvatar = true }: MessageBubbleProps) {
|
|
||||||
const isInbound = direction === 'inbound';
|
|
||||||
const time = new Date(createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('flex gap-2 mb-3 max-w-[80%]', isInbound ? 'self-start' : 'self-end flex-row-reverse')}>
|
|
||||||
{/* Avatar – only for inbound */}
|
|
||||||
{isInbound && showAvatar ? (
|
|
||||||
<div
|
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0 mt-auto"
|
|
||||||
style={{ backgroundColor: hashColor(contactName || 'U') }}
|
|
||||||
>
|
|
||||||
{getInitials(contactName)}
|
|
||||||
</div>
|
|
||||||
) : isInbound ? (
|
|
||||||
<div className="w-8 flex-shrink-0" />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Bubble */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'px-4 py-2.5 rounded-2xl text-sm leading-relaxed break-words',
|
|
||||||
isInbound
|
|
||||||
? 'bg-[#334155] text-slate-100 rounded-bl-md'
|
|
||||||
: 'bg-gradient-to-br from-[#0891b2] to-[#06b6d4] text-white rounded-br-md'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p className="whitespace-pre-wrap">{body}</p>
|
|
||||||
<div className={cn('flex items-center gap-1.5 mt-1', isInbound ? 'justify-start' : 'justify-end')}>
|
|
||||||
{!isInbound && source && (
|
|
||||||
<span className="text-[10px] opacity-60 capitalize">{source}</span>
|
|
||||||
)}
|
|
||||||
<span className="text-[10px] opacity-50">{time}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { hashColor, getInitials, AVATAR_COLORS };
|
|
||||||
@ -1,105 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import {
|
|
||||||
LayoutDashboard,
|
|
||||||
MessageSquare,
|
|
||||||
Bot,
|
|
||||||
Users,
|
|
||||||
Phone,
|
|
||||||
BarChart3,
|
|
||||||
Shield,
|
|
||||||
Globe,
|
|
||||||
GitBranch,
|
|
||||||
Settings,
|
|
||||||
Smartphone,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
{ label: 'Dashboard', href: '/', icon: LayoutDashboard },
|
|
||||||
{ label: 'Conversations', href: '/conversations', icon: MessageSquare },
|
|
||||||
{ label: 'Bots', href: '/bots', icon: Bot },
|
|
||||||
{ label: 'Contacts', href: '/contacts', icon: Users },
|
|
||||||
{ label: 'Phone Numbers', href: '/phone-numbers', icon: Phone },
|
|
||||||
{ label: 'Connect Phone', href: '/connect-phone', icon: Smartphone, new: true },
|
|
||||||
{ label: 'Analytics', href: '/analytics', icon: BarChart3 },
|
|
||||||
{ label: 'A2P Registration', href: '/a2p', icon: Shield },
|
|
||||||
{ label: 'Landing Pages', href: '/landing-pages', icon: Globe },
|
|
||||||
{ label: 'Routing', href: '/routing', icon: GitBranch },
|
|
||||||
{ label: 'Settings', href: '/settings', icon: Settings },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Sidebar() {
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
function isActive(href: string) {
|
|
||||||
if (href === '/') return pathname === '/';
|
|
||||||
return pathname.startsWith(href);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside className="fixed inset-y-0 left-0 z-50 flex w-[240px] flex-col bg-[#0f1729] border-r border-slate-800/60">
|
|
||||||
{/* Logo */}
|
|
||||||
<div className="flex items-center gap-3 px-5 py-6">
|
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-cyan-500/10 border border-cyan-500/20">
|
|
||||||
<Bot className="h-5 w-5 text-cyan-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-[15px] font-bold text-white tracking-tight">
|
|
||||||
CloseBot SMS
|
|
||||||
</h1>
|
|
||||||
<p className="text-[10px] text-slate-500 font-medium tracking-wide uppercase">
|
|
||||||
Command Center
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="mx-4 border-t border-slate-800/60" />
|
|
||||||
|
|
||||||
{/* Nav items */}
|
|
||||||
<nav className="flex-1 px-3 py-4 space-y-1">
|
|
||||||
{navItems.map((item) => {
|
|
||||||
const active = isActive(item.href);
|
|
||||||
const Icon = item.icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className={cn(
|
|
||||||
'group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150',
|
|
||||||
active
|
|
||||||
? 'bg-cyan-500/10 text-cyan-400 border-l-[3px] border-cyan-400 pl-[9px]'
|
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50 border-l-[3px] border-transparent pl-[9px]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
className={cn(
|
|
||||||
'h-[18px] w-[18px] flex-shrink-0 transition-colors',
|
|
||||||
active ? 'text-cyan-400' : 'text-slate-500 group-hover:text-slate-300'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Bottom version */}
|
|
||||||
<div className="px-5 py-4 border-t border-slate-800/60">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
|
||||||
<span className="text-[11px] text-slate-600 font-medium">
|
|
||||||
System Online
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-[10px] text-slate-700 font-mono">
|
|
||||||
v1.0.0 · CloseBot SMS
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,141 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Bot, MessageSquare, Settings2 } from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface BotRouteCardProps {
|
|
||||||
id: string;
|
|
||||||
botName: string;
|
|
||||||
sourceType: string;
|
|
||||||
active: boolean;
|
|
||||||
messageCount: number;
|
|
||||||
isSelected: boolean;
|
|
||||||
onToggleActive: (id: string, active: boolean) => void;
|
|
||||||
onConfigure: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceTypeColors: Record<string, { bg: string; text: string; border: string }> = {
|
|
||||||
WEB_LEAD: { bg: 'bg-blue-500/15', text: 'text-blue-400', border: 'border-blue-500/25' },
|
|
||||||
EMAIL_INQUIRY: { bg: 'bg-purple-500/15', text: 'text-purple-400', border: 'border-purple-500/25' },
|
|
||||||
SMS_CAMPAIGN: { bg: 'bg-cyan-500/15', text: 'text-cyan-400', border: 'border-cyan-500/25' },
|
|
||||||
PHONE_CALL: { bg: 'bg-green-500/15', text: 'text-green-400', border: 'border-green-500/25' },
|
|
||||||
REFERRAL: { bg: 'bg-amber-500/15', text: 'text-amber-400', border: 'border-amber-500/25' },
|
|
||||||
SOCIAL_MEDIA: { bg: 'bg-pink-500/15', text: 'text-pink-400', border: 'border-pink-500/25' },
|
|
||||||
WALK_IN: { bg: 'bg-orange-500/15', text: 'text-orange-400', border: 'border-orange-500/25' },
|
|
||||||
API: { bg: 'bg-indigo-500/15', text: 'text-indigo-400', border: 'border-indigo-500/25' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultColor = { bg: 'bg-slate-500/15', text: 'text-slate-400', border: 'border-slate-500/25' };
|
|
||||||
|
|
||||||
function getSourceColor(type: string) {
|
|
||||||
const key = type.toUpperCase().replace(/[\s-]+/g, '_');
|
|
||||||
return sourceTypeColors[key] || defaultColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BotRouteCard({
|
|
||||||
id,
|
|
||||||
botName,
|
|
||||||
sourceType,
|
|
||||||
active,
|
|
||||||
messageCount,
|
|
||||||
isSelected,
|
|
||||||
onToggleActive,
|
|
||||||
onConfigure,
|
|
||||||
}: BotRouteCardProps) {
|
|
||||||
const colors = getSourceColor(sourceType);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'group relative rounded-xl border p-4 transition-all duration-300',
|
|
||||||
'bg-slate-800/40 backdrop-blur-sm',
|
|
||||||
isSelected
|
|
||||||
? 'border-cyan-400/60 shadow-[0_0_20px_rgba(34,211,238,0.15)] bg-cyan-500/[0.06]'
|
|
||||||
: active
|
|
||||||
? 'border-slate-600/50 hover:border-slate-500/60'
|
|
||||||
: 'border-slate-700/30 opacity-60 hover:opacity-80'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Top row: Bot icon + name + source badge */}
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex h-10 w-10 items-center justify-center rounded-lg flex-shrink-0',
|
|
||||||
active ? 'bg-cyan-500/10 border border-cyan-500/20' : 'bg-slate-700/50 border border-slate-600/30'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Bot className={cn('h-5 w-5', active ? 'text-cyan-400' : 'text-slate-500')} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-sm font-bold text-white truncate">{botName}</h3>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center mt-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wider border',
|
|
||||||
colors.bg,
|
|
||||||
colors.text,
|
|
||||||
colors.border
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{sourceType.replace(/_/g, ' ')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom row: toggle + message count + configure */}
|
|
||||||
<div className="flex items-center justify-between mt-4 pt-3 border-t border-slate-700/30">
|
|
||||||
{/* Active/Paused toggle */}
|
|
||||||
<button
|
|
||||||
onClick={() => onToggleActive(id, !active)}
|
|
||||||
className="flex items-center gap-2 group/toggle"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'relative h-5 w-9 rounded-full transition-colors duration-300',
|
|
||||||
active ? 'bg-cyan-500' : 'bg-slate-600'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-300',
|
|
||||||
active ? 'translate-x-4' : 'translate-x-0.5'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'text-[11px] font-semibold uppercase tracking-wider',
|
|
||||||
active ? 'text-cyan-400' : 'text-slate-500'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{active ? 'Active' : 'Paused'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Message count */}
|
|
||||||
<div className="flex items-center gap-1.5 text-slate-500">
|
|
||||||
<MessageSquare className="h-3.5 w-3.5" />
|
|
||||||
<span className="text-xs font-medium num">{messageCount.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Configure button */}
|
|
||||||
<button
|
|
||||||
onClick={() => onConfigure(id)}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-[11px] font-semibold uppercase tracking-wider transition-all duration-200',
|
|
||||||
'border border-slate-600/50 text-slate-400',
|
|
||||||
'hover:border-cyan-500/40 hover:text-cyan-400 hover:bg-cyan-500/5'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Settings2 className="h-3.5 w-3.5" />
|
|
||||||
Configure
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Selected glow ring */}
|
|
||||||
{isSelected && (
|
|
||||||
<div className="absolute inset-0 rounded-xl border-2 border-cyan-400/30 pointer-events-none" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,226 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface ConnectionDef {
|
|
||||||
/** Route id — used as key */
|
|
||||||
routeId: string;
|
|
||||||
/** Index of phone card on the left (0-based) */
|
|
||||||
leftIndex: number;
|
|
||||||
/** Index of bot card on the right (0-based) */
|
|
||||||
rightIndex: number;
|
|
||||||
active: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConnectionLinesProps {
|
|
||||||
connections: ConnectionDef[];
|
|
||||||
leftContainerRef: React.RefObject<HTMLDivElement>;
|
|
||||||
rightContainerRef: React.RefObject<HTMLDivElement>;
|
|
||||||
centerRef: React.RefObject<HTMLDivElement>;
|
|
||||||
highlightedRouteId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LinePath {
|
|
||||||
routeId: string;
|
|
||||||
d: string;
|
|
||||||
active: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ConnectionLines({
|
|
||||||
connections,
|
|
||||||
leftContainerRef,
|
|
||||||
rightContainerRef,
|
|
||||||
centerRef,
|
|
||||||
highlightedRouteId,
|
|
||||||
}: ConnectionLinesProps) {
|
|
||||||
const [paths, setPaths] = useState<LinePath[]>([]);
|
|
||||||
const svgRef = useRef<SVGSVGElement>(null);
|
|
||||||
|
|
||||||
const computePaths = useCallback(() => {
|
|
||||||
if (!centerRef.current || !leftContainerRef.current || !rightContainerRef.current) return;
|
|
||||||
|
|
||||||
const centerRect = centerRef.current.getBoundingClientRect();
|
|
||||||
const leftCards = leftContainerRef.current.querySelectorAll('[data-phone-card]');
|
|
||||||
const rightCards = rightContainerRef.current.querySelectorAll('[data-bot-card]');
|
|
||||||
|
|
||||||
const newPaths: LinePath[] = [];
|
|
||||||
|
|
||||||
for (const conn of connections) {
|
|
||||||
const leftCard = leftCards[conn.leftIndex] as HTMLElement | undefined;
|
|
||||||
const rightCard = rightCards[conn.rightIndex] as HTMLElement | undefined;
|
|
||||||
|
|
||||||
if (!leftCard || !rightCard) continue;
|
|
||||||
|
|
||||||
const leftRect = leftCard.getBoundingClientRect();
|
|
||||||
const rightRect = rightCard.getBoundingClientRect();
|
|
||||||
|
|
||||||
// Points relative to center SVG
|
|
||||||
const x1 = leftRect.right - centerRect.left;
|
|
||||||
const y1 = leftRect.top + leftRect.height / 2 - centerRect.top;
|
|
||||||
const x2 = rightRect.left - centerRect.left;
|
|
||||||
const y2 = rightRect.top + rightRect.height / 2 - centerRect.top;
|
|
||||||
|
|
||||||
// Control points for smooth bezier
|
|
||||||
const cpOffset = Math.min(Math.abs(x2 - x1) * 0.45, 120);
|
|
||||||
const cp1x = x1 + cpOffset;
|
|
||||||
const cp1y = y1;
|
|
||||||
const cp2x = x2 - cpOffset;
|
|
||||||
const cp2y = y2;
|
|
||||||
|
|
||||||
const d = `M ${x1} ${y1} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${x2} ${y2}`;
|
|
||||||
newPaths.push({ routeId: conn.routeId, d, active: conn.active });
|
|
||||||
}
|
|
||||||
|
|
||||||
setPaths(newPaths);
|
|
||||||
}, [connections, centerRef, leftContainerRef, rightContainerRef]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
computePaths();
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
|
||||||
requestAnimationFrame(computePaths);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (centerRef.current) resizeObserver.observe(centerRef.current);
|
|
||||||
if (leftContainerRef.current) resizeObserver.observe(leftContainerRef.current);
|
|
||||||
if (rightContainerRef.current) resizeObserver.observe(rightContainerRef.current);
|
|
||||||
|
|
||||||
window.addEventListener('resize', computePaths);
|
|
||||||
window.addEventListener('scroll', computePaths, true);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
window.removeEventListener('resize', computePaths);
|
|
||||||
window.removeEventListener('scroll', computePaths, true);
|
|
||||||
};
|
|
||||||
}, [computePaths, centerRef, leftContainerRef, rightContainerRef]);
|
|
||||||
|
|
||||||
if (paths.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full text-slate-600 gap-3">
|
|
||||||
<svg className="h-12 w-12 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
|
||||||
</svg>
|
|
||||||
<p className="text-xs font-medium uppercase tracking-wider">No active routes</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* CSS for animated dash */}
|
|
||||||
<style jsx>{`
|
|
||||||
@keyframes dash-flow {
|
|
||||||
to { stroke-dashoffset: -24; }
|
|
||||||
}
|
|
||||||
@keyframes pulse-line {
|
|
||||||
0%, 100% { opacity: 0.7; }
|
|
||||||
50% { opacity: 1; }
|
|
||||||
}
|
|
||||||
.connection-line {
|
|
||||||
animation: dash-flow 1.5s linear infinite, pulse-line 3s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.connection-line-highlight {
|
|
||||||
animation: dash-flow 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
.connection-glow {
|
|
||||||
animation: pulse-line 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
|
|
||||||
<svg
|
|
||||||
ref={svgRef}
|
|
||||||
className="absolute inset-0 w-full h-full pointer-events-none"
|
|
||||||
style={{ overflow: 'visible' }}
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
{/* Glow filter */}
|
|
||||||
<filter id="line-glow" x="-20%" y="-20%" width="140%" height="140%">
|
|
||||||
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur" />
|
|
||||||
<feMerge>
|
|
||||||
<feMergeNode in="blur" />
|
|
||||||
<feMergeNode in="SourceGraphic" />
|
|
||||||
</feMerge>
|
|
||||||
</filter>
|
|
||||||
|
|
||||||
{/* Stronger glow for highlighted */}
|
|
||||||
<filter id="line-glow-strong" x="-30%" y="-30%" width="160%" height="160%">
|
|
||||||
<feGaussianBlur in="SourceGraphic" stdDeviation="5" result="blur" />
|
|
||||||
<feMerge>
|
|
||||||
<feMergeNode in="blur" />
|
|
||||||
<feMergeNode in="blur" />
|
|
||||||
<feMergeNode in="SourceGraphic" />
|
|
||||||
</feMerge>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
{paths.map((p) => {
|
|
||||||
const isHighlighted = highlightedRouteId === p.routeId;
|
|
||||||
const isDimmed = highlightedRouteId !== null && !isHighlighted;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g key={p.routeId}>
|
|
||||||
{/* Background glow line */}
|
|
||||||
<path
|
|
||||||
d={p.d}
|
|
||||||
fill="none"
|
|
||||||
stroke={p.active ? '#22d3ee' : '#475569'}
|
|
||||||
strokeWidth={isHighlighted ? 4 : 2.5}
|
|
||||||
strokeLinecap="round"
|
|
||||||
className={cn(
|
|
||||||
isDimmed ? 'opacity-10' : '',
|
|
||||||
isHighlighted ? 'connection-glow' : ''
|
|
||||||
)}
|
|
||||||
filter={isHighlighted ? 'url(#line-glow-strong)' : 'url(#line-glow)'}
|
|
||||||
style={{
|
|
||||||
opacity: isDimmed ? 0.1 : isHighlighted ? 1 : 0.25,
|
|
||||||
transition: 'opacity 0.3s ease',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Animated foreground line */}
|
|
||||||
<path
|
|
||||||
d={p.d}
|
|
||||||
fill="none"
|
|
||||||
stroke={p.active ? '#22d3ee' : '#64748b'}
|
|
||||||
strokeWidth={isHighlighted ? 2.5 : 1.5}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeDasharray="8 16"
|
|
||||||
className={cn(
|
|
||||||
isHighlighted ? 'connection-line-highlight' : 'connection-line',
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
opacity: isDimmed ? 0.08 : isHighlighted ? 1 : 0.6,
|
|
||||||
transition: 'opacity 0.3s ease',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Endpoint dots */}
|
|
||||||
{!isDimmed && (
|
|
||||||
<>
|
|
||||||
<circle
|
|
||||||
cx={p.d.match(/^M ([\d.]+)/)?.[1]}
|
|
||||||
cy={p.d.match(/^M [\d.]+ ([\d.]+)/)?.[1]}
|
|
||||||
r={isHighlighted ? 4 : 3}
|
|
||||||
fill={p.active ? '#22d3ee' : '#64748b'}
|
|
||||||
style={{ opacity: isHighlighted ? 1 : 0.7 }}
|
|
||||||
filter={isHighlighted ? 'url(#line-glow)' : undefined}
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx={p.d.match(/, ([\d.]+) ([\d.]+)$/)?.[1]}
|
|
||||||
cy={p.d.match(/, ([\d.]+) ([\d.]+)$/)?.[2]}
|
|
||||||
r={isHighlighted ? 4 : 3}
|
|
||||||
fill={p.active ? '#22d3ee' : '#64748b'}
|
|
||||||
style={{ opacity: isHighlighted ? 1 : 0.7 }}
|
|
||||||
filter={isHighlighted ? 'url(#line-glow)' : undefined}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user