repo split: move projects to their own repos as submodules; bulk data purged to Gitea-Storage (Jupiter)

This commit is contained in:
2026-06-18 19:02:53 -07:00
parent d5bfe76780
commit 4fcd3088af
1266 changed files with 80 additions and 198742 deletions

View File

@@ -1,5 +0,0 @@
# Scanner binaries — downloaded at runtime, not committed
downloads/
# Scan output — machine-local, can be large
C:\ScanLogs\

View File

@@ -1,115 +0,0 @@
<#
.SYNOPSIS
Downloads or refreshes all scanner executables defined in scanners.json.
.DESCRIPTION
Reads scanner definitions from scanners.json and downloads each scanner EXE
to the downloads\ subdirectory. Skips scanners with null download URLs.
.PARAMETER Force
Re-download all scanners even if they already exist locally.
.EXAMPLE
.\Download-Scanners.ps1
.\Download-Scanners.ps1 -Force
#>
[CmdletBinding()]
param(
[switch]$Force
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$ConfigPath = Join-Path $PSScriptRoot 'scanners.json'
$DownloadsDir = Join-Path $PSScriptRoot 'downloads'
if (-not (Test-Path $ConfigPath)) {
Write-Host "[ERROR] scanners.json not found at: $ConfigPath" -ForegroundColor Red
exit 1
}
$config = Get-Content $ConfigPath -Raw | ConvertFrom-Json
if (-not (Test-Path $DownloadsDir)) {
New-Item -ItemType Directory -Path $DownloadsDir -Force | Out-Null
Write-Host "[INFO] Created downloads directory: $DownloadsDir" -ForegroundColor Cyan
}
$results = [System.Collections.Generic.List[pscustomobject]]::new()
foreach ($scanner in $config.scanners) {
$urlToUse = $null
$fileToSave = $null
# Manual-download entries: print instructions and skip
if ($scanner.PSObject.Properties['manual_download'] -and $scanner.manual_download -eq $true) {
$note = if ($scanner.PSObject.Properties['manual_download_note']) { $scanner.manual_download_note } else { 'Place EXE manually in downloads\' }
Write-Host " [MANUAL] $($scanner.name)$note" -ForegroundColor Yellow
$results.Add([pscustomobject]@{ Scanner = $scanner.name; Status = 'MANUAL'; File = ''; Size = 0 })
continue
}
if ($scanner.installer_exe -and $scanner.download_url) {
$urlToUse = $scanner.download_url
$fileToSave = Join-Path $DownloadsDir (Split-Path $scanner.installer_exe -Leaf)
}
elseif ($scanner.download_url) {
$urlToUse = $scanner.download_url
$leafName = Split-Path $scanner.exe -Leaf
$fileToSave = Join-Path $DownloadsDir $leafName
}
else {
Write-Host " [SKIP] $($scanner.name) — no download URL configured" -ForegroundColor Yellow
$results.Add([pscustomobject]@{ Scanner = $scanner.name; Status = 'SKIPPED'; File = ''; Size = 0 })
continue
}
if ((Test-Path $fileToSave) -and -not $Force) {
$existingSize = (Get-Item $fileToSave).Length
Write-Host " [OK] $($scanner.name) already exists ($([math]::Round($existingSize / 1MB, 1)) MB)" -ForegroundColor Green
$results.Add([pscustomobject]@{ Scanner = $scanner.name; Status = 'EXISTS'; File = $fileToSave; Size = $existingSize })
continue
}
Write-Host " [....] $($scanner.name) — downloading from $urlToUse" -ForegroundColor Cyan
try {
$wc = New-Object System.Net.WebClient
$wc.Headers.Add('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)')
$wc.DownloadFile($urlToUse, $fileToSave)
$wc.Dispose()
if (-not (Test-Path $fileToSave)) {
throw "File not found after download attempt"
}
$downloadedSize = (Get-Item $fileToSave).Length
if ($downloadedSize -eq 0) {
throw "Downloaded file is 0 bytes — URL may be invalid or redirected"
}
Write-Host " [OK] $($scanner.name) downloaded ($([math]::Round($downloadedSize / 1MB, 1)) MB) -> $fileToSave" -ForegroundColor Green
$results.Add([pscustomobject]@{ Scanner = $scanner.name; Status = 'DOWNLOADED'; File = $fileToSave; Size = $downloadedSize })
}
catch {
Write-Host " [ERROR] $($scanner.name)$($_.Exception.Message)" -ForegroundColor Red
if (Test-Path $fileToSave) {
Remove-Item $fileToSave -Force -ErrorAction SilentlyContinue
}
$results.Add([pscustomobject]@{ Scanner = $scanner.name; Status = 'FAILED'; File = $fileToSave; Size = 0 })
}
}
Write-Host ""
Write-Host "=== Download Summary ===" -ForegroundColor Cyan
$results | Format-Table -AutoSize
$manual = $results | Where-Object { $_.Status -eq 'MANUAL' }
if ($manual) {
Write-Host "[INFO] $($manual.Count) scanner(s) require manual download — see notes above." -ForegroundColor Yellow
}
$failed = $results | Where-Object { $_.Status -eq 'FAILED' }
if ($failed) {
Write-Host "[WARNING] $($failed.Count) scanner(s) failed to download. Scan coverage will be incomplete." -ForegroundColor Yellow
exit 1
}
exit 0

View File

@@ -1,43 +0,0 @@
<#
.SYNOPSIS
Parses a GuruScan results.json into a human-readable report.
.DESCRIPTION
Locates a results.json (defaults to the most recent scan in C:\ScanLogs\),
prints a formatted summary with per-scanner results, threat counts, and
a remediation recommendation if threats were found.
Use -AI to send log content to a local Ollama model for threat analysis
and AI-generated remediation recommendations.
.PARAMETER ResultsFile
Full path to a specific results.json. If omitted, the latest file in
C:\ScanLogs\ is used automatically.
.PARAMETER ShowAll
Show every scanner including those that completed clean (no threats).
.PARAMETER AI
Send scan logs to local Ollama (http://localhost:11434) for AI-powered
threat analysis and prioritized remediation recommendations.
.PARAMETER OllamaUrl
Ollama base URL. Defaults to http://localhost:11434.
.PARAMETER OllamaModel
Ollama model to use. Defaults to qwen3.6:latest.
.EXAMPLE
.\Get-ScanSummary.ps1
.\Get-ScanSummary.ps1 -AI
.\Get-ScanSummary.ps1 -ResultsFile "C:\ScanLogs\DESKTOP-20260523-143000\results.json" -AI
#>
[CmdletBinding()]
param(
[string]$ResultsFile = '',
[switch]$ShowAll,
[switch]$AI,
[string]$OllamaUrl = 'http://localhost:11434',
[string]$OllamaModel = 'qwen3.6:latest'
)
$moduleManifest = Join-Path $PSScriptRoot 'GuruScan.psd1'
if (-not (Test-Path $moduleManifest)) {
Write-Host "[ERROR] GuruScan module not found: $moduleManifest" -ForegroundColor Red
exit 1
}
Import-Module $moduleManifest -Force
Get-ScanSummary @PSBoundParameters

View File

@@ -1,27 +0,0 @@
@{
RootModule = 'GuruScan.psm1'
ModuleVersion = '1.0.0'
GUID = 'a3f2c1d4-8e5b-4a7f-9c2e-1b3d5f7a9e0c'
Author = 'Arizona Computer Guru'
CompanyName = 'Arizona Computer Guru LLC'
Description = 'Multi-engine malware scan orchestrator for GuruRMM'
PowerShellVersion = '5.1'
FunctionsToExport = @(
'Invoke-GuruScan',
'Invoke-Remediation',
'Get-ScanSummary',
'Invoke-PostRebootCleanup'
)
CmdletsToExport = @()
VariablesToExport = @()
AliasesToExport = @()
PrivateData = @{
PSData = @{
Tags = @('malware', 'scanner', 'remediation', 'msp', 'security')
ProjectUri = 'https://git.azcomputerguru.com/azcomputerguru/claudetools'
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,73 +0,0 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
GuruScan - multi-engine malware scanning orchestrator (single-file, RMM-ready).
.DESCRIPTION
Runs a suite of portable malware scanners in sequence, captures logs,
and writes a structured results.json plus a zip archive of all logs.
Scanner definitions are read from scanners.json in the same directory.
By default runs all scanners in clean (remediation) mode.
Use -ScanOnly to detect without cleaning.
NOTE: MSERT is no longer included in the default scanner list because it
takes too long for routine runs. To run MSERT, invoke it directly or add
it back to scanners.json.
.PARAMETER ScanOnly
Use scan args (detect only) instead of clean args for every scanner.
.PARAMETER AutoRemediate
After a scan-only pass, if threats are found, automatically re-run all
scanners in clean mode.
.PARAMETER Scanners
Run only the named scanners (comma-separated or multiple values).
Names must match the Name field in scanners.json exactly.
.PARAMETER TimeoutMin
Override the per-scanner timeout (in minutes) for all scanners.
.PARAMETER SkipScanners
Skip one or more named scanners by name. Names must match the Name field
in scanners.json exactly. Useful for excluding a single scanner without
respecifying the entire list.
.PARAMETER Headless
Suppress scanner windows (used when dispatching via RMM).
.EXAMPLE
.\Invoke-GuruScan.ps1
.\Invoke-GuruScan.ps1 -ScanOnly -AutoRemediate
.\Invoke-GuruScan.ps1 -SkipScanners Emsisoft
.\Invoke-GuruScan.ps1 -Headless
#>
[CmdletBinding()]
param(
[switch]$ScanOnly,
[switch]$AutoRemediate,
[string[]]$Scanners,
[int]$TimeoutMin = 0,
[string[]]$SkipScanners = @(),
[switch]$Headless
)
$moduleManifest = Join-Path $PSScriptRoot 'GuruScan.psd1'
if (-not (Test-Path $moduleManifest)) {
Write-Host "[ERROR] GuruScan module not found: $moduleManifest" -ForegroundColor Red
exit 1
}
Import-Module $moduleManifest -Force
$scanStart = Get-Date
Invoke-GuruScan @PSBoundParameters -OutputSink Disk
# Emit structured JSON to stdout for GuruRMM CommandResult capture.
# Read from results.json written during this run (newer than $scanStart).
$resultsFile = Get-ChildItem -Path 'C:\ScanLogs' -Recurse -Filter 'results.json' -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -gt $scanStart } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($resultsFile) {
$json = Get-Content -Path $resultsFile.FullName -Raw -Encoding UTF8 -ErrorAction SilentlyContinue
if ($json) {
# Compress to single line so the agent's stdout parser sees it as one line
$compressed = ($json | ConvertFrom-Json | ConvertTo-Json -Depth 10 -Compress)
Write-Output ''
Write-Output "GURUSCAN_RESULT_JSON:$compressed"
}
}

View File

@@ -1,13 +0,0 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Manually triggers GuruScan post-scan cleanup (removes scanner files).
Normally this runs automatically via the GuruRMM-ScannerCleanup scheduled task.
#>
$moduleManifest = Join-Path $PSScriptRoot 'GuruScan.psd1'
if (-not (Test-Path $moduleManifest)) {
Write-Host "[ERROR] GuruScan module not found: $moduleManifest" -ForegroundColor Red
exit 1
}
Import-Module $moduleManifest -Force
Invoke-PostRebootCleanup

View File

@@ -1,35 +0,0 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Re-runs GuruScan scanners in clean mode against a previous scan's log folder.
.DESCRIPTION
Reads the results.json from a prior scan run, then re-launches each scanner
that completed (or any subset via -Scanners) using clean_args to remove
detected threats. Writes remediation-results.json to the same log folder.
.PARAMETER LogRoot
Path to the scan results folder produced by Invoke-GuruScan.ps1.
This folder must contain a results.json file.
.PARAMETER Scanners
Run only the named scanners. Names must match the "name" field in
scanners.json exactly. If omitted, all scanners that previously ran
successfully are re-run.
.EXAMPLE
.\Invoke-Remediation.ps1 -LogRoot "C:\ScanLogs\DESKTOP-20260523-143000"
.\Invoke-Remediation.ps1 -LogRoot "C:\ScanLogs\DESKTOP-20260523-143000" -Scanners AdwCleaner,MSERT
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$LogRoot,
[string[]]$Scanners
)
$moduleManifest = Join-Path $PSScriptRoot 'GuruScan.psd1'
if (-not (Test-Path $moduleManifest)) {
Write-Host "[ERROR] GuruScan module not found: $moduleManifest" -ForegroundColor Red
exit 1
}
Import-Module $moduleManifest -Force
Invoke-Remediation @PSBoundParameters

View File

@@ -1,50 +0,0 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Post-reboot scanner cleanup. Registered as a SYSTEM logon task by
Register-ScannerCleanupTask; removes scanner installation paths, writes
logs-ready.json for GuruRMM to pull, then unregisters itself.
Run directly to trigger cleanup immediately without waiting for the task:
.\Invoke-ScannerCleanup.ps1
#>
$Base = 'C:\GuruScan'
$stateFile = "$Base\cleanup-state.json"
$state = @{ scan_id = ''; log_root = '' }
if (Test-Path $stateFile) {
try { $state = Get-Content $stateFile -Raw | ConvertFrom-Json } catch {}
}
$scannerPaths = @(
'C:\EmsisoftCmd',
'C:\AdwCleaner',
'C:\ProgramData\HitmanPro',
'C:\ProgramData\HitmanPro.Alert'
)
foreach ($p in $scannerPaths) {
if (Test-Path $p) {
Remove-Item -Path $p -Recurse -Force -ErrorAction SilentlyContinue
}
}
# Remove scanner download EXEs (leave C:\GuruScan\ itself intact)
$downloadsPath = "$Base\downloads"
if (Test-Path $downloadsPath) {
Remove-Item -Path $downloadsPath -Recurse -Force -ErrorAction SilentlyContinue
}
# Flag logs as ready for GuruRMM to pull
$zipPath = "$Base\reports\$($state.scan_id).zip"
@{
scan_id = $state.scan_id
log_root = $state.log_root
zip_path = $zipPath
cleaned_at = (Get-Date).ToUniversalTime().ToString('o')
} | ConvertTo-Json | Set-Content "$Base\logs-ready.json" -Encoding UTF8
Remove-Item -Path $stateFile -Force -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName 'GuruRMM-ScannerCleanup' -Confirm:$false -ErrorAction SilentlyContinue

View File

@@ -1,204 +0,0 @@
# GuruScan
Multi-engine malware scan orchestrator for GuruRMM. Runs a sequenced chain of
portable security scanners, captures all logs, and writes structured JSON results
for downstream processing by the RMM agent.
---
## Deploy Layout
| Path | Purpose |
|------|---------|
| `C:\GuruScan\` | Base directory — module files, whitelist, state files |
| `C:\GuruScan\downloads\` | Scanner EXEs (populated by `Download-Scanners.ps1`) |
| `C:\GuruScan\reports\` | Per-scan zip archives |
| `C:\ScanLogs\` | Per-scan log folders (`<HOSTNAME>-<YYYYMMDD-HHmmss>\`) |
The module files (`GuruScan.psm1`, `GuruScan.psd1`, `scanners.json`) live in the
same directory as the launcher scripts (`Invoke-GuruScan.ps1`, etc.). This is
typically `C:\GuruScan\` or the RMM deployment path.
---
## Scanner Chain
Scanners run in this order. Each stage hands off to the next regardless of findings.
| # | Scanner | Category | Notes |
|---|---------|----------|-------|
| 1 | **RKill** | process-killer | Kills malware-related processes before scanners run. Exit 1 = processes were killed (not a threat indicator). |
| 2 | **Emsisoft Command Line Scanner** | antimalware | Two-step: NSIS installer extracts to `C:\EmsisoftCmd\`, then `/update` fetches latest definitions, then deep-scans `C:\`. |
| 3 | **HitmanPro** | antimalware | Cloud-assisted second-opinion scanner. Trial registry is reset before each run via `Invoke-HitmanProTrialReset`. Runs with `/quiet` (no GUI). |
**AdwCleaner** is not currently in the chain. It requires both elevated privileges
and an interactive desktop session simultaneously — SYSTEM context is elevated but
Session 0 (no desktop), user_session has a desktop but a non-elevated WTS token.
It will be re-added once a scheduled-task `InteractiveToken` dispatch is implemented.
MSERT (Microsoft Safety Scanner) is also excluded from the default chain — too slow
for routine runs. Add it to `scanners.json` if needed.
---
## Exit Code Semantics
| Scanner | Exit 0 | Exit 1 | Exit 2 | Exit 3 | Other |
|---------|--------|--------|--------|--------|-------|
| RKill | Clean run | Processes killed (not a threat) | — | — | — |
| Emsisoft | Clean | Threats found/cleaned | Cleaned, reboot required | — | — |
| HitmanPro | Clean | Cleaned | Cleaned, reboot required | — | — |
| MSERT | Clean | Threats found/cleaned | — | — | Non-zero = threats |
| TDSSKiller | Clean | Threats found | — | — | — |
| Stinger | Clean | — | — | — | 13 = threats |
Reboot-required exit codes: HitmanPro 2, Emsisoft 2.
---
## Post-Scan Cleanup Lifecycle
When any scanner exits with a reboot-required code (exit 2), the following sequence
runs automatically — no forced reboot:
1. `Register-ScannerCleanupTask` writes `cleanup-state.json` (scan ID + log path) to `C:\GuruScan\`.
2. `Invoke-ScannerCleanup.ps1` is written to `C:\GuruScan\`.
3. A SYSTEM scheduled task (`GuruRMM-ScannerCleanup`) is registered with an **at-logon + 30-minute delay** trigger.
4. The scan completes and prints a message to reboot at your convenience.
5. After the next natural reboot and user login, the task fires 30 minutes later (silently, as SYSTEM).
6. The cleanup script removes all scanner installation paths (`C:\EmsisoftCmd`, `C:\ProgramData\HitmanPro*`, `C:\GuruScan\downloads\`), writes `logs-ready.json` for GuruRMM to pick up, and unregisters itself.
To run cleanup immediately without waiting:
```powershell
.\Invoke-PostRebootCleanup.ps1
```
---
## Headless / SYSTEM Behavior
- `-Headless` launches all scanner processes with `WindowStyle=Hidden`, suppressing
UI windows and preventing child processes from inheriting the PowerShell pipe
handles. Use this when dispatching from an RMM agent with no interactive desktop.
- Scanners with `session0_compatible: false` in `scanners.json` are automatically
skipped when the module detects it is running as SYSTEM (Session 0). The result
record shows `SKIPPED (requires user session)` rather than a failure.
- The whitelist (`C:\GuruScan\whitelist.txt`) is honoured by Emsisoft (`/wl=`) and
HitmanPro (`/excludelist=`). RKill does not support a whitelist.
---
## GuruRMM Integration
When dispatched via GuruRMM in `system` context with `-Headless`, the launcher
emits a `GURUSCAN_RESULT_JSON:<compressed-json>` line to stdout at the end of the
run. The GuruRMM agent captures this in `command.stdout` for structured result
reporting on the dashboard.
```powershell
# GuruRMM dispatch command (system context, elevated):
C:\GuruScan\Invoke-GuruScan.ps1 -Headless
```
The JSON structure matches `results.json` written to disk:
```json
{
"scan_id": "HOSTNAME-20260527-010203",
"machine": "HOSTNAME",
"started_at": "...",
"completed_at": "...",
"total_threats": 0,
"reboot_required": false,
"scan_mode": "clean",
"scanners": [
{ "name": "RKill", "status": "completed", "exit_code": 1, "threats_found": 0, "duration_min": 0.09 },
{ "name": "Emsisoft", "status": "completed", "exit_code": 0, "threats_found": 0, "duration_min": 4.8 },
{ "name": "HitmanPro","status": "completed", "exit_code": 0, "threats_found": 0, "duration_min": 2.2 }
]
}
```
---
## Licensing
| Scanner | License for MSP use |
|---------|---------------------|
| RKill | Free (BleepingComputer) |
| Emsisoft Command Line Scanner | Free for personal and MSP remediation use |
| HitmanPro | Commercial license required. Each scan uses trial mode; `Invoke-HitmanProTrialReset` resets the trial window. Verify current licensing terms at https://www.hitmanpro.com before deploying at scale. |
Always verify current licensing terms with each vendor before large-scale deployment.
---
## Stand-alone Usage
```powershell
# Run all scanners in clean mode (default)
.\Invoke-GuruScan.ps1
# Detect only, then auto-remediate if threats found
.\Invoke-GuruScan.ps1 -ScanOnly -AutoRemediate
# Suppress scanner windows (RMM dispatch)
.\Invoke-GuruScan.ps1 -Headless
# View the latest scan results
.\Get-ScanSummary.ps1
# View with AI analysis (requires Ollama)
.\Get-ScanSummary.ps1 -AI
# Re-run clean pass against a prior scan
.\Invoke-Remediation.ps1 -LogRoot "C:\ScanLogs\DESKTOP-20260523-143000"
# Download/refresh scanner EXEs
.\Download-Scanners.ps1
```
---
## Module Usage
The launcher scripts are thin wrappers. Import the module directly for
scripted/pipeline use:
```powershell
Import-Module .\GuruScan.psd1
# Disk sink (default) — writes results.json + CSV + zip
Invoke-GuruScan
# RMM sink — returns result object to the pipeline, no disk writes
$result = Invoke-GuruScan -OutputSink RMM -Headless
if ($result.total_threats -gt 0) { ... }
# Remediation from a prior scan
Invoke-Remediation -LogRoot "C:\ScanLogs\DESKTOP-20260523-143000"
# Summary report
Get-ScanSummary -AI
# Manual scanner cleanup (normally runs via scheduled task)
Invoke-PostRebootCleanup
```
---
## Module Structure
```
guru-scan\
GuruScan.psm1 # Core module — all helpers + exported cmdlets
GuruScan.psd1 # Module manifest
scanners.json # Scanner definitions (single source of truth)
Invoke-GuruScan.ps1 # Thin launcher -> Invoke-GuruScan
Invoke-Remediation.ps1 # Thin launcher -> Invoke-Remediation
Get-ScanSummary.ps1 # Thin launcher -> Get-ScanSummary
Invoke-PostRebootCleanup.ps1 # Thin launcher -> Invoke-PostRebootCleanup
Invoke-ScannerCleanup.ps1 # Post-reboot cleanup; copied to C:\GuruScan\ when reboot is needed
Download-Scanners.ps1 # Downloads scanner EXEs from scanners.json URLs
downloads\ # Scanner EXEs (gitignored)
```

View File

@@ -1,125 +0,0 @@
{
"scanners": [
{
"name": "RKill",
"category": "process-killer",
"exe": "C:\\GuruScan\\downloads\\rkill.exe",
"installer_exe": null,
"installer_args": null,
"run_update_after_install": false,
"download_url": "https://download.bleepingcomputer.com/grinler/rkill.exe",
"manual_download": false,
"manual_download_note": null,
"scan_args": ["-s", "-l \"{LOG_ROOT}\\rkill.log\""],
"clean_args": ["-s", "-l \"{LOG_ROOT}\\rkill.log\""],
"log_src": "{LOG_ROOT}\\rkill.log",
"timeout_min": 10,
"randomize_exe": false,
"pre_close_processes": [],
"pre_clean_paths": [],
"post_clean_paths": [],
"service_names": [],
"hitmanpro_trial_reset": false,
"whitelist_arg": null,
"wait_on_process": null,
"session0_compatible": true
},
{
"name": "Emsisoft",
"category": "antimalware",
"exe": "C:\\EmsisoftCmd\\a2cmd.exe",
"installer_exe": "C:\\GuruScan\\downloads\\EmsisoftCommandlineScanner64.exe",
"installer_args": ["/S"],
"run_update_after_install": true,
"download_url": "https://dl.emsisoft.com/EmsisoftCommandlineScanner64.exe",
"manual_download": false,
"manual_download_note": null,
"scan_args": [
"/f=C:\\",
"/deep",
"/rk",
"/m",
"/t",
"/pup",
"/a",
"/n",
"/ac",
"/d",
"/wl=\"C:\\GuruScan\\whitelist.txt\"",
"/la=\"{LOG_ROOT}\\a2cmd_deep_log.txt\""
],
"clean_args": [
"/f=C:\\",
"/deep",
"/rk",
"/m",
"/t",
"/c",
"/pup",
"/a",
"/n",
"/ac",
"/d",
"/wl=\"C:\\GuruScan\\whitelist.txt\"",
"/la=\"{LOG_ROOT}\\a2cmd_deep_log.txt\""
],
"log_src": null,
"timeout_min": 120,
"randomize_exe": false,
"pre_close_processes": [],
"pre_clean_paths": ["C:\\EmsisoftCmd"],
"post_clean_paths": ["C:\\EmsisoftCmd"],
"service_names": [],
"hitmanpro_trial_reset": false,
"whitelist_arg": "emsisoft",
"wait_on_process": "a2cmd",
"session0_compatible": true
},
{
"name": "HitmanPro",
"category": "antimalware",
"exe": "C:\\GuruScan\\downloads\\HitmanPro_x64.exe",
"installer_exe": null,
"installer_args": null,
"run_update_after_install": false,
"download_url": null,
"manual_download": true,
"manual_download_note": "Requires a trial/license — download from https://www.hitmanpro.com/en-us/hmp.aspx",
"scan_args": [
"/noinstall",
"/scan",
"/quiet",
"/log=\"{LOG_ROOT}\\HitmanPro_Scan_Log.txt\"",
"/excludelist=\"C:\\GuruScan\\whitelist.txt\""
],
"clean_args": [
"/noinstall",
"/clean",
"/quiet",
"/log=\"{LOG_ROOT}\\HitmanPro_Scan_Log.txt\"",
"/excludelist=\"C:\\GuruScan\\whitelist.txt\""
],
"log_src": null,
"timeout_min": 60,
"randomize_exe": false,
"pre_close_processes": ["chrome", "firefox", "msedge", "brave", "opera", "iexplore", "operagx", "MicrosoftEdge"],
"pre_clean_paths": [
"C:\\ProgramData\\HitmanPro",
"C:\\ProgramData\\HitmanPro.Alert",
"%LOCALAPPDATA%\\HitmanPro",
"%LOCALAPPDATA%\\HitmanPro.Alert"
],
"post_clean_paths": [
"C:\\ProgramData\\HitmanPro",
"C:\\ProgramData\\HitmanPro.Alert",
"%LOCALAPPDATA%\\HitmanPro",
"%LOCALAPPDATA%\\HitmanPro.Alert"
],
"service_names": [],
"hitmanpro_trial_reset": true,
"whitelist_arg": "hitmanpro",
"wait_on_process": "HitmanPro_x64",
"session0_compatible": true
}
]
}

View File

@@ -1,126 +0,0 @@
## User
- **User:** Howard Enos (howard)
- **Machine:** Howard-Home
- **Role:** tech
---
## Session Summary
The session focused on verifying and extending the GuruScan pipeline for the GuruRMM platform. The GURUSCAN_RESULT_JSON pipeline was confirmed end-to-end: the launcher script reads results.json after the scan, compresses it into a single-line JSON, and emits it as `GURUSCAN_RESULT_JSON:<json>` to stdout. The GuruRMM agent captures this in `command.stdout`. This had been implemented in a prior session and was verified working with scan 2c531967.
AdwCleaner integration was thoroughly tested and found to be incompatible with both GuruRMM dispatch contexts. Dispatching in `user_session` context failed with `ERROR_CANCELLED` — the WTS token from `WTSQueryUserToken` is a non-elevated standard user token, and AdwCleaner's manifest requests administrator; CreateProcess returns the error immediately without showing a UAC dialog. Dispatching in `system` context caused the process to hang indefinitely before the scan started — AdwCleaner initializes its GUI framework before accepting CLI args, and in Session 0 (SYSTEM, no window station) that initialization blocks forever. Both paths were tested directly against the VM. AdwCleaner was removed from `scanners.json` pending a future `schtasks InteractiveToken + HighestAvailable` dispatch mechanism.
A critical 3-hour pipe hang bug was discovered via a background task notification. A full GuruScan command dispatched at 05:51 UTC (scan `RMM-TEST-MACHIN-20260526-225146`) completed its scan work in 6.5 minutes — results.json was written at 05:58 — but the GuruRMM command stayed `running` for 3 hours until the server-side reaper killed it. Root cause: `NoNewWindow = [bool]$Headless` in `Invoke-ScanPass` caused all scanner processes to share the parent PowerShell's console when running headless. Sharing the console means child processes inherit the parent's stdout/stderr pipe handles. AdwCleaner started (or partially started) in Session 0, inherited the pipe handle, then hung. After PowerShell exited, AdwCleaner still held the write end of the stdout pipe open, so the GuruRMM agent's read side never saw EOF and waited indefinitely.
The fix was to replace `NoNewWindow = [bool]$Headless` with `WindowStyle = 'Hidden'` when `$Headless` is set. This gives each scanner its own window/console, preventing pipe handle inheritance entirely. Any scanner that hangs will be killed by `Wait-ProcessWithTimeout` at its configured timeout; the overall GuruScan process exits normally regardless. Verification scan `84059ee7` (dispatched post-fix) completed in 7.5 minutes with EICAR test file detected and removed by Emsisoft, `GURUSCAN_RESULT_JSON` captured in stdout, no pipe hang. Documentation was updated to reflect the 3-scanner chain and the corrected headless behavior description.
---
## Key Decisions
- **Removed AdwCleaner from scanners.json**: AdwCleaner needs elevated token AND interactive desktop simultaneously. `system` context provides elevation but no desktop (Session 0). `user_session` provides desktop but a non-elevated WTS token. Neither context satisfies both requirements. Will be re-added when a `schtasks` `InteractiveToken + HighestAvailable` dispatch path is implemented in GuruScan.psm1.
- **`WindowStyle = 'Hidden'` instead of `NoNewWindow`**: `NoNewWindow` shares the parent console and causes child processes to inherit the PowerShell pipe handles. `WindowStyle = 'Hidden'` creates an independent process with its own window/console — no handle inheritance. The only visible behavioral difference is that scanners no longer write their output to the RMM agent's pipe (which was never used anyway — GuruScan collects output via log files).
- **AdwCleaner re-add path is schtasks with `InteractiveToken`**: From SYSTEM context, create a task XML with `<LogonType>InteractiveToken</LogonType>` and `<RunLevel>HighestAvailable</RunLevel>`. This creates the process as the currently-logged-in user at their highest privilege level, in their desktop session. If the user is a local admin this gets an elevated token with a visible window — no UAC prompt because Task Scheduler handles the elevation internally.
- **Kept AdwCleaner exit-code mappings in GuruScan.psm1**: The `Get-ExitCodeThreats` and `Get-ExitCodeReboot` functions retain AdwCleaner cases. No harm in keeping them, and they'll be needed when AdwCleaner is re-added.
---
## Problems Encountered
- **AdwCleaner `user_session` dispatch: `ERROR_CANCELLED`**: PowerShell's `&` operator uses `CreateProcess`, which cannot handle elevation requests from a non-elevated token. Returns `ERROR_CANCELLED` (0x800704C7) immediately, no UAC dialog. Resolution: documented as architecture limitation; using `Start-Process -Verb RunAs` would allow the UAC prompt to appear on the user's desktop, but requires user interaction. Removed AdwCleaner for now.
- **AdwCleaner `system` context: process hung indefinitely**: Even with `WindowStyle = 'Hidden'` (tested directly), AdwCleaner enters its GUI initialization loop before processing CLI args. In Session 0 there is no window station; the initialization blocks forever. Confirmed by checking VM process list and scan logs — no `[S01].txt` created during today's system-context attempts, and the only existing log (`[S00].txt` from 2026-05-26) was from a prior non-SYSTEM run. Resolution: same as above.
- **3-hour pipe hang on GuruRMM command `0d05681f`**: Background monitor reported the command ran for 180 minutes before server-side reaper. Scan itself completed in 6.5 min (verified via results.json timestamps). Root cause: `NoNewWindow = [bool]$Headless` caused pipe handle inheritance; AdwCleaner (or a residual process from it) held the write end of the stdout pipe after PowerShell exited. Fix: `WindowStyle = 'Hidden'`. Verified with next scan (`84059ee7`), completed in 7.5 min with no hang.
- **Vault path resolution for JWT token**: First attempt used `projects/gururmm/api.sops.yaml` which doesn't exist. Correct path is `infrastructure/gururmm-server.sops.yaml`, field `credentials.gururmm-api.admin-password`. Used login endpoint `/api/auth/login` with `claude-api@azcomputerguru.com` to obtain JWT.
---
## Configuration Changes
- `projects/msp-tools/guru-scan/GuruScan.psm1` — 5 changes:
- `NoNewWindow = [bool]$Headless``WindowStyle = 'Hidden'` when `$Headless` (pipe hang fix)
- `Headless` param docstring corrected ("WindowStyle=Hidden", not "NoNewWindow")
- Whitelist comment: removed AdwCleaner ("Emsisoft and HitmanPro only")
- `$defenderExclusions`: removed `'C:\AdwCleaner'`
- `Invoke-Remediation` example: `AdwCleaner,MSERT``Emsisoft,MSERT`
- `projects/msp-tools/guru-scan/scanners.json` — AdwCleaner entry removed entirely
- `projects/msp-tools/guru-scan/README.md` — full rewrite for 3-scanner chain; added GuruRMM integration section with `GURUSCAN_RESULT_JSON` structure; fixed Headless description; removed AdwCleaner from all tables; added note on AdwCleaner re-add path
---
## Credentials & Secrets
No new credentials created or discovered. GuruRMM API access used existing vault entry:
- Vault: `infrastructure/gururmm-server.sops.yaml``credentials.gururmm-api.admin-email` / `admin-password`
- Login endpoint: `POST http://172.16.3.30:3001/api/auth/login`
---
## Infrastructure & Servers
- **GuruRMM server**: `172.16.3.30:3001`
- **RMM-TEST-MACHINE**: agent ID `7d3456f5-d811-4eac-8629-d88e9d1c594a`, VM at `172.20.12.192`, online
- **HTTP deploy server**: `172.20.0.1:8888` (Python, serving `C:/claudetools/projects/msp-tools/guru-scan/`)
---
## Commands & Outputs
```powershell
# GuruRMM dispatch (system context, post-fix)
C:\GuruScan\Invoke-GuruScan.ps1 -Headless
# → completed in 7.5 min, GURUSCAN_RESULT_JSON captured, no pipe hang
# → EICAR test file C:\Users\User\Downloads\eicar.com_.txt detected by Emsisoft, removed
```
Verify fix deployed on VM:
```powershell
Get-Content 'C:\GuruScan\GuruScan.psm1' | Select-String 'WindowStyle|NoNewWindow'
# Output: WindowStyle = 'Hidden' (no NoNewWindow line in scanner launch block)
```
Scan result from verification run (command `84059ee7`):
```json
{
"scan_id": "RMM-TEST-MACHIN-20260527-015912",
"total_threats": 1,
"scanners": [
{ "name": "RKill", "status": "completed", "exit_code": 1, "duration_min": 0.09 },
{ "name": "AdwCleaner","status": "SKIPPED (requires user session)" },
{ "name": "Emsisoft", "status": "completed", "exit_code": 1, "duration_min": 4.86 },
{ "name": "HitmanPro","status": "completed", "exit_code": 0, "duration_min": 2.25 }
]
}
```
(AdwCleaner was still in scanners.json at time of this scan; removed immediately after.)
---
## Pending / Incomplete Tasks
- **AdwCleaner re-add via schtasks `InteractiveToken`**: Implement in `GuruScan.psm1`. When `session0_compatible: false` and running as SYSTEM, instead of skipping, create a task XML with `<LogonType>InteractiveToken</LogonType>` + `<RunLevel>HighestAvailable</RunLevel>`, register + trigger, poll for log file as completion signal, unregister. No GuruRMM agent changes required.
- **GuruRMM dashboard UI**: Parse `GURUSCAN_RESULT_JSON` from `command.stdout` and display per-scanner results on the agent detail page. Separate GuruRMM feature.
- **GURUSCAN_RESULT_JSON when SYSTEM detection causes old version mismatch**: The 2026-05-26 22:51 scan showed AdwCleaner as `FAILED` (not `SKIPPED`), suggesting the VM had a module version without the `session0_compatible` check at that time. Monitor for recurrence; current deployed version is correct.
---
## Reference Information
- Commits this session:
- `a980ef1` — fix: use WindowStyle=Hidden instead of NoNewWindow in headless scanner dispatch
- `a655b52` — chore: remove AdwCleaner from scanner chain
- `47517e9` — docs: update GuruScan README and module comments for current state
- GuruRMM API base: `http://172.16.3.30:3001/api`
- GuruRMM login endpoint: `POST /api/auth/login` (body: `email`, `password`)
- Test machine agent ID: `7d3456f5-d811-4eac-8629-d88e9d1c594a`
- Scan log dir: `C:\ScanLogs\` on VM
- EICAR test file on VM: `C:\Users\User\Downloads\eicar.com_.txt`
- AdwCleaner CLI reference: https://help.malwarebytes.com/hc/en-us/articles/31589247152027-Command-line-options-for-AdwCleaner
- `Wait-ProcessWithTimeout` in GuruScan.psm1:69 — timeout-kills processes exceeding `timeout_min`
- `Test-RunningAsSystem` in GuruScan.psm1:684 — uses `WindowsIdentity.IsSystem`

View File

@@ -1,51 +0,0 @@
# MSP Audit Scripts — Project State
> READ THIS before starting work on this project.
> UPDATE THIS when you begin work (claim a lock) and when you finish (release lock + log changes).
> Last updated: 2026-04-20
---
## Active Session Locks
| Session | Working On | Status | Started |
|---------|-----------|--------|---------|
| _(none active)_ | | | |
**How to claim a lock:** Add a row before starting work. Remove it when done. Locks older than 2 hours with no update are considered stale.
---
## Current State
**Status:** MAINTENANCE
**Last Activity:** 2026-04-17 (estimated from directory contents)
PowerShell audit scripts for MSP client environments. Two scripts: `server_audit.ps1` (server-side audit) and `workstation_audit.ps1` (workstation audit). Used ad-hoc during client engagements. No active development — maintained as stable reference scripts.
---
## Infrastructure / Access
No dedicated infrastructure. Scripts run against client environments via SSH or remote PowerShell. Credentials come from vault per-client.
---
## Pending / Next Up
- [ ] No active items — scripts are stable
---
## Recent Changes
| Date | By | Change | Status |
|------|-----|--------|--------|
| (no logged changes) | | | |
---
## How to Update
**When starting:** Add your session to Active Session Locks.
**When finishing:** Remove your lock row, add entries to Recent Changes.

View File

@@ -1,46 +0,0 @@
# MSP Audit Scripts
Universal Windows audit scripts for MSP use. Run via ScreenConnect Toolbox as SYSTEM.
## Scripts
| Script | Target | Output |
|--------|--------|--------|
| `server_audit.ps1` | Windows Server 2012-2025 | `C:\Temp\HOSTNAME_server_audit_YYYY-MM-DD.json` |
| `workstation_audit.ps1` | Windows 10/11 | `C:\Temp\HOSTNAME_workstation_audit_YYYY-MM-DD.json` |
## ScreenConnect Toolbox Commands
### Server Audit
```powershell
#!ps
#maxlength=500000
#timeout=600000
Set-ExecutionPolicy Bypass -Scope Process -Force
New-Item -Path C:\Temp -ItemType Directory -Force | Out-Null
$u = "https://raw.githubusercontent.com/Howweird/msp-audit-scripts/master/server_audit.ps1"
Invoke-WebRequest -Uri $u -OutFile "C:\Temp\server_audit.ps1" -UseBasicParsing
. C:\Temp\server_audit.ps1
```
### Workstation Audit
```powershell
#!ps
#maxlength=500000
#timeout=600000
Set-ExecutionPolicy Bypass -Scope Process -Force
New-Item -Path C:\Temp -ItemType Directory -Force | Out-Null
$u = "https://raw.githubusercontent.com/Howweird/msp-audit-scripts/master/workstation_audit.ps1"
Invoke-WebRequest -Uri $u -OutFile "C:\Temp\workstation_audit.ps1" -UseBasicParsing
. C:\Temp\workstation_audit.ps1
```
## Requirements
- Must run as SYSTEM or local Administrator
- Output directory: `C:\Temp` (created automatically)
- No dependencies beyond built-in Windows PowerShell modules
## Output
- `.json` — Structured data with hostname in filename for multi-machine audits

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,426 +0,0 @@
<?php
/**
* MSP Quote Wizard - Admin Dashboard
* Simple PHP admin interface for viewing and managing quotes
*/
session_start();
// Configuration
define('ADMIN_PASSWORD', 'QuoteAdmin2026!'); // Change in production
define('DB_HOST', '172.16.3.30');
define('DB_NAME', 'claudetools');
define('DB_USER', 'claudetools');
define('DB_PASS', 'CT_e8fcd5a3952030a79ed6debae6c954ed');
// Handle logout
if (isset($_GET['logout'])) {
session_destroy();
header('Location: index.php');
exit;
}
// Handle login
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['password'])) {
if ($_POST['password'] === ADMIN_PASSWORD) {
$_SESSION['admin_authenticated'] = true;
header('Location: index.php');
exit;
} else {
$login_error = 'Invalid password';
}
}
// Check authentication
if (!isset($_SESSION['admin_authenticated']) || !$_SESSION['admin_authenticated']) {
showLoginPage($login_error ?? null);
exit;
}
// Database connection
try {
$pdo = new PDO(
"mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4",
DB_USER,
DB_PASS,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
} catch (PDOException $e) {
die("Database connection failed: " . $e->getMessage());
}
// Handle status update
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['update_status'])) {
$quote_id = $_POST['quote_id'];
$new_status = $_POST['new_status'];
$stmt = $pdo->prepare("UPDATE quotes SET status = ?, updated_at = NOW() WHERE id = ?");
$stmt->execute([$new_status, $quote_id]);
header('Location: index.php?updated=1');
exit;
}
// Get statistics
$stats = getStats($pdo);
// Get quotes with filtering
$status_filter = $_GET['status'] ?? '';
$search = $_GET['search'] ?? '';
$quotes = getQuotes($pdo, $status_filter, $search);
// Get single quote details if requested
$quote_detail = null;
if (isset($_GET['id'])) {
$quote_detail = getQuoteDetail($pdo, $_GET['id']);
}
// Helper functions
function getStats($pdo) {
$stats = [];
// Total quotes
$stmt = $pdo->query("SELECT COUNT(*) FROM quotes");
$stats['total'] = $stmt->fetchColumn();
// By status
$stmt = $pdo->query("SELECT status, COUNT(*) as count FROM quotes GROUP BY status");
$stats['by_status'] = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
// Total monthly value (submitted quotes only)
$stmt = $pdo->query("SELECT COALESCE(SUM(monthly_total), 0) FROM quotes WHERE status = 'submitted'");
$stats['total_monthly'] = $stmt->fetchColumn();
// This month
$stmt = $pdo->query("SELECT COUNT(*) FROM quotes WHERE created_at >= DATE_FORMAT(NOW(), '%Y-%m-01')");
$stats['this_month'] = $stmt->fetchColumn();
return $stats;
}
function getQuotes($pdo, $status_filter = '', $search = '') {
$sql = "SELECT q.*,
(SELECT COUNT(*) FROM quote_items WHERE quote_id = q.id) as item_count
FROM quotes q WHERE 1=1";
$params = [];
if ($status_filter) {
$sql .= " AND q.status = ?";
$params[] = $status_filter;
}
if ($search) {
$sql .= " AND (q.company_name LIKE ? OR q.contact_name LIKE ? OR q.contact_email LIKE ?)";
$params[] = "%$search%";
$params[] = "%$search%";
$params[] = "%$search%";
}
$sql .= " ORDER BY q.created_at DESC LIMIT 100";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
function getQuoteDetail($pdo, $id) {
// Get quote
$stmt = $pdo->prepare("SELECT * FROM quotes WHERE id = ?");
$stmt->execute([$id]);
$quote = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$quote) return null;
// Get items
$stmt = $pdo->prepare("SELECT * FROM quote_items WHERE quote_id = ? ORDER BY category, created_at");
$stmt->execute([$id]);
$quote['items'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Get activity
$stmt = $pdo->prepare("SELECT * FROM quote_activity WHERE quote_id = ? ORDER BY created_at DESC");
$stmt->execute([$id]);
$quote['activities'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
return $quote;
}
function showLoginPage($error = null) {
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quote Admin - Login</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-900 min-h-screen flex items-center justify-center">
<div class="bg-gray-800 p-8 rounded-lg shadow-xl w-full max-w-md">
<h1 class="text-2xl font-bold text-white mb-6 text-center">Quote Admin</h1>
<?php if ($error): ?>
<div class="bg-red-500/20 border border-red-500 text-red-400 px-4 py-2 rounded mb-4">
<?= htmlspecialchars($error) ?>
</div>
<?php endif; ?>
<form method="POST">
<div class="mb-4">
<label class="block text-gray-400 text-sm mb-2">Password</label>
<input type="password" name="password" required autofocus
class="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white focus:outline-none focus:border-blue-500">
</div>
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded transition">
Login
</button>
</form>
</div>
</body>
</html>
<?php
exit;
}
function formatMoney($amount) {
return '$' . number_format((float)$amount, 2);
}
function statusBadge($status) {
$colors = [
'draft' => 'bg-gray-500',
'submitted' => 'bg-blue-500',
'viewed' => 'bg-purple-500',
'followed_up' => 'bg-yellow-500',
'converted' => 'bg-green-500',
'expired' => 'bg-red-500',
];
$color = $colors[$status] ?? 'bg-gray-500';
return "<span class=\"px-2 py-1 rounded text-xs font-medium text-white $color\">" . ucfirst(str_replace('_', ' ', $status)) . "</span>";
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quote Admin Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
.stat-card { transition: transform 0.2s; }
.stat-card:hover { transform: translateY(-2px); }
</style>
</head>
<body class="bg-gray-900 min-h-screen text-gray-100">
<!-- Header -->
<header class="bg-gray-800 border-b border-gray-700">
<div class="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
<div class="flex items-center gap-4">
<h1 class="text-xl font-bold text-white">Quote Admin</h1>
<span class="text-gray-400 text-sm">azcomputerguru.com</span>
</div>
<a href="?logout=1" class="text-gray-400 hover:text-white text-sm">Logout</a>
</div>
</header>
<main class="max-w-7xl mx-auto px-4 py-6">
<?php if (isset($_GET['updated'])): ?>
<div class="bg-green-500/20 border border-green-500 text-green-400 px-4 py-2 rounded mb-6">
Quote status updated successfully.
</div>
<?php endif; ?>
<!-- Stats Cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="stat-card bg-gray-800 rounded-lg p-4 border border-gray-700">
<div class="text-gray-400 text-sm">Total Quotes</div>
<div class="text-3xl font-bold text-white"><?= $stats['total'] ?></div>
</div>
<div class="stat-card bg-gray-800 rounded-lg p-4 border border-gray-700">
<div class="text-gray-400 text-sm">Submitted</div>
<div class="text-3xl font-bold text-blue-400"><?= $stats['by_status']['submitted'] ?? 0 ?></div>
</div>
<div class="stat-card bg-gray-800 rounded-lg p-4 border border-gray-700">
<div class="text-gray-400 text-sm">Converted</div>
<div class="text-3xl font-bold text-green-400"><?= $stats['by_status']['converted'] ?? 0 ?></div>
</div>
<div class="stat-card bg-gray-800 rounded-lg p-4 border border-gray-700">
<div class="text-gray-400 text-sm">Monthly Value</div>
<div class="text-2xl font-bold text-emerald-400"><?= formatMoney($stats['total_monthly']) ?></div>
</div>
</div>
<?php if ($quote_detail): ?>
<!-- Quote Detail View -->
<div class="bg-gray-800 rounded-lg border border-gray-700 mb-6">
<div class="p-4 border-b border-gray-700 flex items-center justify-between">
<h2 class="text-lg font-semibold">Quote Details</h2>
<a href="index.php" class="text-blue-400 hover:text-blue-300 text-sm">&larr; Back to List</a>
</div>
<div class="p-4">
<div class="grid md:grid-cols-2 gap-6">
<!-- Contact Info -->
<div>
<h3 class="text-sm font-medium text-gray-400 mb-3">Contact Information</h3>
<div class="space-y-2">
<div><span class="text-gray-500">Company:</span> <span class="text-white"><?= htmlspecialchars($quote_detail['company_name'] ?: '(not provided)') ?></span></div>
<div><span class="text-gray-500">Contact:</span> <span class="text-white"><?= htmlspecialchars($quote_detail['contact_name'] ?: '(not provided)') ?></span></div>
<div><span class="text-gray-500">Email:</span> <span class="text-white"><?= htmlspecialchars($quote_detail['contact_email'] ?: '(not provided)') ?></span></div>
<div><span class="text-gray-500">Phone:</span> <span class="text-white"><?= htmlspecialchars($quote_detail['contact_phone'] ?: '(not provided)') ?></span></div>
<div><span class="text-gray-500">Employees:</span> <span class="text-white"><?= $quote_detail['employee_count'] ?></span></div>
</div>
</div>
<!-- Quote Summary -->
<div>
<h3 class="text-sm font-medium text-gray-400 mb-3">Quote Summary</h3>
<div class="space-y-2">
<div><span class="text-gray-500">Status:</span> <?= statusBadge($quote_detail['status']) ?></div>
<div><span class="text-gray-500">Monthly Total:</span> <span class="text-2xl font-bold text-emerald-400"><?= formatMoney($quote_detail['monthly_total']) ?></span></div>
<div><span class="text-gray-500">Setup Total:</span> <span class="text-white"><?= formatMoney($quote_detail['setup_total']) ?></span></div>
<div><span class="text-gray-500">Created:</span> <span class="text-white"><?= $quote_detail['created_at'] ?></span></div>
<?php if ($quote_detail['submitted_at']): ?>
<div><span class="text-gray-500">Submitted:</span> <span class="text-white"><?= $quote_detail['submitted_at'] ?></span></div>
<?php endif; ?>
</div>
<!-- Status Update Form -->
<form method="POST" class="mt-4">
<input type="hidden" name="quote_id" value="<?= $quote_detail['id'] ?>">
<div class="flex gap-2">
<select name="new_status" class="bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white text-sm">
<option value="draft" <?= $quote_detail['status'] === 'draft' ? 'selected' : '' ?>>Draft</option>
<option value="submitted" <?= $quote_detail['status'] === 'submitted' ? 'selected' : '' ?>>Submitted</option>
<option value="viewed" <?= $quote_detail['status'] === 'viewed' ? 'selected' : '' ?>>Viewed</option>
<option value="followed_up" <?= $quote_detail['status'] === 'followed_up' ? 'selected' : '' ?>>Followed Up</option>
<option value="converted" <?= $quote_detail['status'] === 'converted' ? 'selected' : '' ?>>Converted</option>
<option value="expired" <?= $quote_detail['status'] === 'expired' ? 'selected' : '' ?>>Expired</option>
</select>
<button type="submit" name="update_status" value="1" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm">
Update Status
</button>
</div>
</form>
</div>
</div>
<!-- Line Items -->
<?php if (!empty($quote_detail['items'])): ?>
<div class="mt-6">
<h3 class="text-sm font-medium text-gray-400 mb-3">Line Items</h3>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-gray-400 border-b border-gray-700">
<th class="pb-2">Category</th>
<th class="pb-2">Product</th>
<th class="pb-2">Qty</th>
<th class="pb-2 text-right">Unit Price</th>
<th class="pb-2 text-right">Monthly</th>
</tr>
</thead>
<tbody>
<?php foreach ($quote_detail['items'] as $item): ?>
<tr class="border-b border-gray-700/50">
<td class="py-2 text-gray-400"><?= ucfirst(str_replace('_', ' ', $item['category'])) ?></td>
<td class="py-2 text-white"><?= htmlspecialchars($item['product_name']) ?></td>
<td class="py-2"><?= $item['quantity'] ?></td>
<td class="py-2 text-right"><?= formatMoney($item['unit_price']) ?></td>
<td class="py-2 text-right text-emerald-400"><?= formatMoney($item['monthly_amount']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<!-- Activity Log -->
<?php if (!empty($quote_detail['activities'])): ?>
<div class="mt-6">
<h3 class="text-sm font-medium text-gray-400 mb-3">Activity Log</h3>
<div class="space-y-2">
<?php foreach ($quote_detail['activities'] as $activity): ?>
<div class="text-sm">
<span class="text-gray-500"><?= $activity['created_at'] ?></span>
<span class="text-gray-400 mx-2">|</span>
<span class="text-white"><?= ucfirst($activity['action']) ?></span>
<?php if ($activity['step_name']): ?>
<span class="text-gray-500">(<?= $activity['step_name'] ?>)</span>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<!-- Filters -->
<div class="bg-gray-800 rounded-lg border border-gray-700 mb-6">
<form method="GET" class="p-4 flex flex-wrap gap-4 items-end">
<div>
<label class="block text-gray-400 text-sm mb-1">Status</label>
<select name="status" class="bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white text-sm">
<option value="">All Statuses</option>
<option value="draft" <?= $status_filter === 'draft' ? 'selected' : '' ?>>Draft</option>
<option value="submitted" <?= $status_filter === 'submitted' ? 'selected' : '' ?>>Submitted</option>
<option value="viewed" <?= $status_filter === 'viewed' ? 'selected' : '' ?>>Viewed</option>
<option value="followed_up" <?= $status_filter === 'followed_up' ? 'selected' : '' ?>>Followed Up</option>
<option value="converted" <?= $status_filter === 'converted' ? 'selected' : '' ?>>Converted</option>
<option value="expired" <?= $status_filter === 'expired' ? 'selected' : '' ?>>Expired</option>
</select>
</div>
<div class="flex-1 min-w-[200px]">
<label class="block text-gray-400 text-sm mb-1">Search</label>
<input type="text" name="search" value="<?= htmlspecialchars($search) ?>" placeholder="Company, contact, or email..."
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white text-sm">
</div>
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm">
Filter
</button>
<?php if ($status_filter || $search): ?>
<a href="index.php" class="text-gray-400 hover:text-white text-sm px-4 py-2">Clear</a>
<?php endif; ?>
</form>
</div>
<!-- Quotes Table -->
<div class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-gray-400 bg-gray-800/50">
<th class="px-4 py-3">Date</th>
<th class="px-4 py-3">Company</th>
<th class="px-4 py-3">Contact</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Items</th>
<th class="px-4 py-3 text-right">Monthly</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody>
<?php if (empty($quotes)): ?>
<tr>
<td colspan="7" class="px-4 py-8 text-center text-gray-500">No quotes found</td>
</tr>
<?php else: ?>
<?php foreach ($quotes as $quote): ?>
<tr class="border-t border-gray-700/50 hover:bg-gray-700/30">
<td class="px-4 py-3 text-gray-400"><?= date('M j, Y', strtotime($quote['created_at'])) ?></td>
<td class="px-4 py-3 text-white"><?= htmlspecialchars($quote['company_name'] ?: '(not provided)') ?></td>
<td class="px-4 py-3">
<div class="text-white"><?= htmlspecialchars($quote['contact_name'] ?: '-') ?></div>
<div class="text-gray-500 text-xs"><?= htmlspecialchars($quote['contact_email'] ?: '') ?></div>
</td>
<td class="px-4 py-3"><?= statusBadge($quote['status']) ?></td>
<td class="px-4 py-3 text-gray-400"><?= $quote['item_count'] ?></td>
<td class="px-4 py-3 text-right text-emerald-400 font-medium"><?= formatMoney($quote['monthly_total']) ?></td>
<td class="px-4 py-3 text-right">
<a href="?id=<?= $quote['id'] ?>" class="text-blue-400 hover:text-blue-300">View</a>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</main>
</body>
</html>

View File

@@ -1,69 +0,0 @@
#!/usr/bin/env python3
"""Fix email_service.py f-string error and quotes.py field name mismatch."""
# Fix 1: email_service.py - backslash in f-string
with open('/opt/claudetools/api/services/email_service.py', 'r') as f:
lines = f.read().split('\n')
# Find and replace the problematic line
fixed_email = False
insert_idx = None
for i, line in enumerate(lines):
if 'One-Time Costs' in line and 'fff7ed' in line:
lines[i] = ' {setup_costs_html}'
fixed_email = True
print(f'Replaced problematic f-string at line {i+1}')
break
# Find the 'return f"""' line (after line 100) and insert variable before it
for i, line in enumerate(lines):
if 'return f"""' in line and i > 100:
insert_idx = i
break
if insert_idx is not None:
var_lines = [
' setup_costs_html = ""',
' if float(setup_total or 0) > 0:',
' setup_costs_html = (',
' "<div style=\'background: #fff7ed; border-radius: 8px; padding: 12px 20px; "',
' "margin-bottom: 20px;\'><span style=\'color: #9a3412; font-size: 14px;\'>"',
' "One-Time Costs: <strong>$" + setup_total + "</strong></span></div>"',
' )',
'',
]
for j, vl in enumerate(var_lines):
lines.insert(insert_idx + j, vl)
print(f'Inserted setup_costs_html variable before line {insert_idx+1}')
with open('/opt/claudetools/api/services/email_service.py', 'w') as f:
f.write('\n'.join(lines))
print('email_service.py saved')
# Fix 2: quotes.py - item.service_name -> item.product_name
with open('/opt/claudetools/api/routers/quotes.py', 'r') as f:
content = f.read()
if 'item.service_name' in content:
content = content.replace('item.service_name', 'item.product_name')
print('Fixed item.service_name -> item.product_name in quotes.py')
else:
print('item.service_name not found in quotes.py (may already be fixed)')
with open('/opt/claudetools/api/routers/quotes.py', 'w') as f:
f.write(content)
print('quotes.py saved')
# Verify no syntax errors
import py_compile
try:
py_compile.compile('/opt/claudetools/api/services/email_service.py', doraise=True)
print('[OK] email_service.py: syntax OK')
except py_compile.PyCompileError as e:
print(f'[ERROR] email_service.py SYNTAX ERROR: {e}')
try:
py_compile.compile('/opt/claudetools/api/routers/quotes.py', doraise=True)
print('[OK] quotes.py: syntax OK')
except py_compile.PyCompileError as e:
print(f'[ERROR] quotes.py SYNTAX ERROR: {e}')

View File

@@ -1 +0,0 @@
VITE_API_URL=/quote/api

View File

@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,73 +0,0 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -1,23 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@@ -1,17 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect rx='20' width='100' height='100' fill='%23fe7400'/><text x='50' y='68' text-anchor='middle' font-size='52' font-weight='bold' fill='white' font-family='sans-serif'>AZ</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Get a custom IT services quote for your business from AZ Computer Guru - Arizona's trusted managed service provider." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,400&family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet" />
<title>Get Your IT Services Quote | AZ Computer Guru</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +0,0 @@
{
"name": "msp-quote-wizard",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.6",
"clsx": "^2.1.1",
"framer-motion": "^12.35.2",
"lucide-react": "^0.577.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwindcss": "^4.2.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

View File

@@ -1,70 +0,0 @@
import { WizardContainer } from '@/components/wizard/WizardContainer'
import { Shield, Phone, MapPin } from 'lucide-react'
function App() {
return (
<div className="min-h-screen bg-[#f8f9fb] flex flex-col">
{/* Header */}
<header className="bg-white border-b border-gray-200">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-gradient-accent">
<span className="text-white font-extrabold text-sm tracking-tight" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
AZ
</span>
</div>
<div>
<h1 className="text-lg font-bold text-[#333d49] leading-tight" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
AZ Computer Guru
</h1>
<p className="text-xs text-gray-400 leading-tight">IT Services Quote Builder</p>
</div>
</div>
<div className="hidden sm:flex items-center gap-5 text-xs text-gray-500">
<a href="tel:15203048300" className="flex items-center gap-1.5 hover:text-[#fe7400] transition-colors">
<Phone className="w-3.5 h-3.5 text-[#fe7400]" />
(520) 304-8300
</a>
<span className="flex items-center gap-1.5">
<MapPin className="w-3.5 h-3.5 text-[#fe7400]" />
Serving Arizona
</span>
</div>
</div>
</header>
{/* Main content */}
<main className="flex-1 py-8 sm:py-10">
<WizardContainer />
</main>
{/* Footer */}
<footer className="bg-gradient-navy text-white py-8 px-4 sm:px-6">
<div className="max-w-6xl mx-auto">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-md bg-white/10">
<span className="text-white font-bold text-xs" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
AZ
</span>
</div>
<div>
<p className="text-sm font-medium text-white/90">AZ Computer Guru</p>
<p className="text-xs text-white/50">Managed IT Services for Arizona Businesses</p>
</div>
</div>
<div className="flex items-center gap-6 text-xs text-white/50">
<span className="flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5" />
Your data is encrypted & secure
</span>
<span>&copy; {new Date().getFullYear()} AZ Computer Guru</span>
</div>
</div>
</div>
</footer>
</div>
)
}
export default App

View File

@@ -1,66 +0,0 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronDown, HelpCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface ExpandableInfoProps {
title: string;
children: React.ReactNode;
defaultExpanded?: boolean;
icon?: React.ReactNode;
className?: string;
}
export function ExpandableInfo({
title,
children,
defaultExpanded = false,
icon,
className,
}: ExpandableInfoProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
return (
<div className={cn('border border-gray-200/80 rounded-xl overflow-hidden bg-white shadow-card', className)}>
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between p-4 text-left hover:bg-[#f8f9fb] transition-colors"
aria-expanded={isExpanded}
>
<div className="flex items-center gap-3">
{icon || (
<div className="w-8 h-8 rounded-lg bg-[#fe7400]/8 flex items-center justify-center flex-shrink-0">
<HelpCircle className="w-4 h-4 text-[#fe7400]" />
</div>
)}
<span className="font-semibold text-[#333d49] text-sm"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{title}
</span>
</div>
<motion.div
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<ChevronDown className="w-4 h-4 text-gray-400" />
</motion.div>
</button>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="px-4 pb-4 pt-0 text-sm text-gray-500 border-t border-gray-100">
<div className="pt-4 leading-relaxed">{children}</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -1,86 +0,0 @@
import { motion } from 'framer-motion';
import { Check } from 'lucide-react';
import { Card, Button } from '@/components/ui';
import { cn, formatCurrency } from '@/lib/utils';
import type { PricingTier } from '@/types/quote';
export interface PricingCardProps {
tier: PricingTier;
isSelected: boolean;
deviceCount: number;
onSelect: (tierId: string) => void;
}
export function PricingCard({ tier, isSelected, deviceCount, onSelect }: PricingCardProps) {
const monthlyEstimate = tier.basePrice + tier.perDevicePrice * deviceCount;
return (
<motion.div
whileHover={{ y: -3 }}
transition={{ duration: 0.2 }}
>
<Card
variant={isSelected ? 'highlighted' : 'default'}
padding="none"
className={cn(
'relative overflow-hidden',
tier.recommended && !isSelected && 'ring-2 ring-[#fe7400]/30'
)}
>
{/* Recommended badge */}
{tier.recommended && (
<div className="bg-gradient-accent text-white text-[11px] font-bold px-3 py-1.5 text-center uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Recommended
</div>
)}
<div className="p-6">
{/* Header */}
<div className="mb-4">
<h3 className="text-xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{tier.name}
</h3>
<p className="text-sm text-gray-400 mt-1">{tier.description}</p>
</div>
{/* Pricing */}
<div className="mb-6">
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(monthlyEstimate)}
</span>
<span className="text-gray-400">/month</span>
</div>
<p className="text-xs text-gray-400 mt-1">
{formatCurrency(tier.basePrice)} base + {formatCurrency(tier.perDevicePrice)}/device
</p>
</div>
{/* Features */}
<ul className="space-y-2.5 mb-6">
{tier.features.map((feature, index) => (
<li key={index} className="flex items-start gap-2.5 text-sm">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span className="text-gray-600">{feature}</span>
</li>
))}
</ul>
{/* Select button */}
<Button
variant={isSelected ? 'primary' : 'outline'}
className="w-full"
onClick={() => onSelect(tier.id)}
>
{isSelected ? 'Selected' : 'Select Plan'}
</Button>
</div>
</Card>
</motion.div>
);
}

View File

@@ -1,130 +0,0 @@
import { Check, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { PricingTier } from '@/types/quote';
export interface TierComparisonProps {
tiers: PricingTier[];
selectedTier?: string;
onSelectTier: (tierId: string) => void;
}
interface FeatureRow {
name: string;
essential: boolean | string;
professional: boolean | string;
enterprise: boolean | string;
}
const comparisonFeatures: FeatureRow[] = [
{ name: 'Remote Monitoring', essential: true, professional: true, enterprise: true },
{ name: 'Help Desk Support', essential: '8x5', professional: '24x7', enterprise: '24x7 Priority' },
{ name: 'Patch Management', essential: true, professional: true, enterprise: true },
{ name: 'Antivirus Protection', essential: 'Basic', professional: 'Advanced', enterprise: 'Advanced' },
{ name: 'Backup & Recovery', essential: false, professional: true, enterprise: true },
{ name: 'Network Monitoring', essential: false, professional: true, enterprise: true },
{ name: 'On-Site Support', essential: false, professional: 'Limited', enterprise: 'Unlimited' },
{ name: 'Vendor Management', essential: false, professional: true, enterprise: true },
{ name: 'Dedicated Account Manager', essential: false, professional: false, enterprise: true },
{ name: 'Virtual CIO Services', essential: false, professional: false, enterprise: true },
{ name: 'Compliance Management', essential: false, professional: false, enterprise: true },
{ name: 'Security Training', essential: false, professional: false, enterprise: true },
{ name: 'Business Reviews', essential: 'Annual', professional: 'Quarterly', enterprise: 'Monthly' },
];
export function TierComparison({ tiers, selectedTier, onSelectTier }: TierComparisonProps) {
const renderCell = (value: boolean | string) => {
if (typeof value === 'boolean') {
return value ? (
<div className="w-5 h-5 rounded-full bg-[#ecfdf5] flex items-center justify-center mx-auto">
<Check className="w-3 h-3 text-[#059669]" strokeWidth={3} />
</div>
) : (
<X className="w-4 h-4 text-gray-200 mx-auto" />
);
}
return (
<span className="text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{value}
</span>
);
};
return (
<div className="overflow-x-auto rounded-xl border border-gray-200/80 shadow-card">
<table className="w-full border-collapse">
<thead>
<tr>
<th className="text-left p-4 border-b border-gray-100 bg-[#f8f9fb]">
<span className="font-bold text-[#333d49] text-sm"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Feature
</span>
</th>
{tiers.map((tier) => (
<th
key={tier.id}
className={cn(
'p-4 border-b border-gray-100 text-center cursor-pointer transition-all duration-200',
selectedTier === tier.id
? 'bg-[#fe7400]/5'
: 'bg-[#f8f9fb] hover:bg-gray-100'
)}
onClick={() => onSelectTier(tier.id)}
>
<span
className={cn(
'font-bold text-sm',
selectedTier === tier.id ? 'text-[#fe7400]' : 'text-[#333d49]'
)}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
{tier.name}
</span>
{tier.recommended && (
<span className="block text-[10px] text-[#fe7400] mt-0.5 font-bold uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Recommended
</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{comparisonFeatures.map((feature, index) => (
<tr key={feature.name} className={index % 2 === 0 ? 'bg-white' : 'bg-[#f8f9fb]/50'}>
<td className="p-4 border-b border-gray-50 text-sm text-gray-500">
{feature.name}
</td>
<td
className={cn(
'p-4 border-b border-gray-50 text-center',
selectedTier === 'essential' && 'bg-[#fe7400]/3'
)}
>
{renderCell(feature.essential)}
</td>
<td
className={cn(
'p-4 border-b border-gray-50 text-center',
selectedTier === 'professional' && 'bg-[#fe7400]/3'
)}
>
{renderCell(feature.professional)}
</td>
<td
className={cn(
'p-4 border-b border-gray-50 text-center',
selectedTier === 'enterprise' && 'bg-[#fe7400]/3'
)}
>
{renderCell(feature.enterprise)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -1,3 +0,0 @@
export { PricingCard, type PricingCardProps } from './PricingCard';
export { ExpandableInfo, type ExpandableInfoProps } from './ExpandableInfo';
export { TierComparison, type TierComparisonProps } from './TierComparison';

View File

@@ -1,88 +0,0 @@
import { forwardRef, type ButtonHTMLAttributes } from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
export interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onDrag' | 'onDragStart' | 'onDragEnd' | 'onAnimationStart'> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant = 'primary',
size = 'md',
isLoading = false,
disabled,
children,
...props
},
ref
) => {
const baseStyles =
'inline-flex items-center justify-center font-semibold rounded-xl transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-40 disabled:cursor-not-allowed';
const variants = {
primary:
'bg-gradient-accent text-white hover:brightness-110 focus-visible:ring-[#fe7400] shadow-sm hover:shadow-md active:brightness-95',
secondary:
'bg-[#333d49] text-white hover:bg-[#252d36] focus-visible:ring-[#333d49] shadow-sm hover:shadow-md',
outline:
'border-2 border-gray-200 text-[#333d49] hover:border-[#333d49] hover:bg-gray-50 focus-visible:ring-[#333d49]',
ghost:
'text-[#333d49] hover:bg-gray-100/80 focus-visible:ring-[#333d49]',
};
const sizes = {
sm: 'px-4 py-2 text-sm',
md: 'px-6 py-2.5 text-sm',
lg: 'px-8 py-3.5 text-base',
};
return (
<motion.button
ref={ref}
whileHover={{ scale: disabled || isLoading ? 1 : 1.015 }}
whileTap={{ scale: disabled || isLoading ? 1 : 0.985 }}
className={cn(baseStyles, variants[variant], sizes[size], className)}
disabled={disabled || isLoading}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
{...props}
>
{isLoading ? (
<>
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Processing...
</>
) : (
children
)}
</motion.button>
);
}
);
Button.displayName = 'Button';
export { Button };

View File

@@ -1,146 +0,0 @@
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
export interface CardProps {
variant?: 'default' | 'elevated' | 'outlined' | 'highlighted';
padding?: 'none' | 'sm' | 'md' | 'lg';
hoverable?: boolean;
className?: string;
children?: ReactNode;
onClick?: () => void;
}
const Card = forwardRef<HTMLDivElement, CardProps>(
(
{
className,
variant = 'default',
padding = 'md',
hoverable = false,
children,
onClick,
},
ref
) => {
const baseStyles = 'rounded-2xl transition-all duration-300';
const variants = {
default: 'bg-white border border-gray-200/80',
elevated: 'bg-white border border-gray-200/60',
outlined: 'bg-transparent border-2 border-[#333d49]/20',
highlighted: 'bg-white border-2 border-[#fe7400] ring-1 ring-[#fe7400]/10',
};
const shadowStyles: Record<string, React.CSSProperties> = {
default: { boxShadow: '0 1px 2px rgba(17,53,89,0.04), 0 4px 12px rgba(17,53,89,0.06)' },
elevated: { boxShadow: '0 4px 6px rgba(17,53,89,0.04), 0 12px 32px rgba(17,53,89,0.08)' },
outlined: {},
highlighted: { boxShadow: '0 4px 6px rgba(17,53,89,0.04), 0 12px 32px rgba(17,53,89,0.08)' },
};
const paddings = {
none: '',
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
};
const hoverStyles = hoverable
? 'cursor-pointer hover:-translate-y-0.5'
: '';
if (hoverable) {
return (
<motion.div
ref={ref}
whileHover={{ scale: 1.01, boxShadow: '0 2px 4px rgba(17,53,89,0.06), 0 8px 24px rgba(17,53,89,0.1)' }}
whileTap={{ scale: 0.995 }}
className={cn(
baseStyles,
variants[variant],
paddings[padding],
hoverStyles,
className
)}
style={shadowStyles[variant]}
onClick={onClick}
>
{children}
</motion.div>
);
}
return (
<div
ref={ref}
className={cn(
baseStyles,
variants[variant],
paddings[padding],
className
)}
style={shadowStyles[variant]}
onClick={onClick}
>
{children}
</div>
);
}
);
Card.displayName = 'Card';
const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('mb-4 pb-4 border-b border-gray-100', className)}
{...props}
/>
)
);
CardHeader.displayName = 'CardHeader';
const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-xl font-semibold text-[#333d49]', className)}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
{...props}
/>
)
);
CardTitle.displayName = 'CardTitle';
const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-gray-400 mt-1', className)}
{...props}
/>
)
);
CardDescription.displayName = 'CardDescription';
const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('', className)} {...props} />
)
);
CardContent.displayName = 'CardContent';
const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('mt-4 pt-4 border-t border-gray-100 flex items-center', className)}
{...props}
/>
)
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };

View File

@@ -1,65 +0,0 @@
import { forwardRef, type InputHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
helperText?: string;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, helperText, id, type = 'text', ...props }, ref) => {
const inputId = id || `input-${Math.random().toString(36).slice(2, 9)}`;
return (
<div className="w-full">
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-[#333d49] mb-2"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
{label}
</label>
)}
<input
id={inputId}
type={type}
ref={ref}
className={cn(
'w-full px-4 py-3 rounded-xl border transition-all duration-200',
'text-[#333d49] placeholder-gray-400 bg-white',
'focus:outline-none focus:ring-2 focus:ring-offset-0',
error
? 'border-red-400 focus:border-red-400 focus:ring-red-100'
: 'border-gray-200 hover:border-gray-300 focus:border-[#fe7400] focus:ring-[#fe7400]/15',
'disabled:bg-gray-50 disabled:text-gray-400 disabled:cursor-not-allowed',
className
)}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={
error ? `${inputId}-error` : helperText ? `${inputId}-helper` : undefined
}
{...props}
/>
{error && (
<p id={`${inputId}-error`} className="mt-2 text-sm text-red-500 flex items-center gap-1.5">
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
{error}
</p>
)}
{helperText && !error && (
<p id={`${inputId}-helper`} className="mt-2 text-sm text-gray-400">
{helperText}
</p>
)}
</div>
);
}
);
Input.displayName = 'Input';
export { Input };

View File

@@ -1,56 +0,0 @@
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
export interface ProgressBarProps {
progress: number;
showLabel?: boolean;
size?: 'sm' | 'md' | 'lg';
variant?: 'default' | 'accent';
className?: string;
}
export function ProgressBar({
progress,
showLabel = false,
size = 'md',
variant = 'accent',
className,
}: ProgressBarProps) {
const clampedProgress = Math.min(100, Math.max(0, progress));
const sizes = {
sm: 'h-1',
md: 'h-1.5',
lg: 'h-2.5',
};
const variants = {
default: 'bg-[#333d49]',
accent: 'bg-gradient-accent',
};
return (
<div className={cn('w-full', className)}>
{showLabel && (
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-[#333d49]">Progress</span>
<span className="text-sm font-semibold text-[#fe7400]">{clampedProgress}%</span>
</div>
)}
<div
className={cn('w-full bg-gray-100 rounded-full overflow-hidden', sizes[size])}
role="progressbar"
aria-valuenow={clampedProgress}
aria-valuemin={0}
aria-valuemax={100}
>
<motion.div
className={cn('h-full rounded-full', variants[variant])}
initial={{ width: 0 }}
animate={{ width: `${clampedProgress}%` }}
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
/>
</div>
</div>
);
}

View File

@@ -1,12 +0,0 @@
export { Button, type ButtonProps } from './Button';
export {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
type CardProps,
} from './Card';
export { Input, type InputProps } from './Input';
export { ProgressBar, type ProgressBarProps } from './ProgressBar';

View File

@@ -1,705 +0,0 @@
import { useState, useEffect, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Card, CardContent } from '@/components/ui';
import { WizardProgress } from './WizardProgress';
import { WizardNavigation } from './WizardNavigation';
import { useWizard } from '@/hooks/useWizard';
import type { WizardStepDef } from '@/hooks/useWizard';
import { useQuote } from '@/hooks/useQuote';
import {
StepWelcome,
StepServiceDiscovery,
Step2GPSMonitoring,
Step3SupportPlan,
Step4VoIP,
Step5WebEmail,
Step6Summary,
Step7Contact,
} from './steps';
import {
Sparkles,
LayoutGrid,
Monitor,
Headphones,
Phone,
Globe,
FileCheck,
Send,
TrendingUp,
Hash,
CircleCheck,
} from 'lucide-react';
import { formatCurrency } from '@/lib/utils';
import { createQuote, updateQuote, submitQuote } from '@/lib/api';
import type { QuoteSubmitRequest, QuoteItemCreateRequest } from '@/lib/api';
import {
gpsTiers,
equipmentMonitoring,
supportPlans,
blockTimeOptions,
voipTiers,
voipHardware,
webHostingTiers,
emailTiers,
} from '@/lib/pricing-data';
import type { ServiceInterests } from '@/types/quote';
/**
* WizardContainer - Main container for the MSP Quote Wizard
*
* Dynamic flow:
* 1. Welcome & Intake
* 2. Service Discovery (toggle interests)
* 3-N. Dynamic service configuration steps (based on selections)
* N+1. Review Quote
* N+2. Contact & Submit
*/
/** Map step IDs to icons */
const stepIconMap: Record<string, typeof Monitor> = {
welcome: Sparkles,
discovery: LayoutGrid,
gps: Monitor,
support: Headphones,
voip: Phone,
'web-email': Globe,
review: FileCheck,
submit: Send,
};
/** Fixed step definitions that always appear */
const FIXED_BEFORE: WizardStepDef[] = [
{ id: 'welcome', title: 'Welcome', description: 'Tell us about yourself' },
{ id: 'discovery', title: 'Services', description: 'Choose what interests you' },
];
const FIXED_AFTER: WizardStepDef[] = [
{ id: 'review', title: 'Review', description: 'Review your selections' },
{ id: 'submit', title: 'Submit', description: 'Get your quote' },
];
/** Service step definitions — included only when toggled on */
const SERVICE_STEPS: { key: keyof ServiceInterests; step: WizardStepDef }[] = [
{ key: 'gps', step: { id: 'gps', title: 'Monitoring', description: 'Configure your monitoring tier' } },
{ key: 'support', step: { id: 'support', title: 'Support', description: 'Choose your support level' } },
{ key: 'voip', step: { id: 'voip', title: 'VoIP', description: 'Business phone options' } },
{ key: 'webHosting', step: { id: 'web-email', title: 'Web & Email', description: 'Hosting and email services' } },
];
function buildDynamicSteps(interests: ServiceInterests): WizardStepDef[] {
const dynamicMiddle: WizardStepDef[] = [];
for (const { key, step } of SERVICE_STEPS) {
// Special case: web-email step shows if either webHosting or email is selected
if (key === 'webHosting') {
if (interests.webHosting || interests.email) {
dynamicMiddle.push(step);
}
} else if (interests[key]) {
dynamicMiddle.push(step);
}
}
return [...FIXED_BEFORE, ...dynamicMiddle, ...FIXED_AFTER];
}
export function WizardContainer() {
const quote = useQuote();
// Build dynamic step list based on service interests
const stepDefs = useMemo(
() => buildDynamicSteps(quote.quoteData.serviceInterests),
[quote.quoteData.serviceInterests]
);
const wizard = useWizard(stepDefs);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitSuccess, setSubmitSuccess] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(() => {
try {
const stored = localStorage.getItem('quote-wizard-draft');
if (stored) {
const parsed = JSON.parse(stored);
return parsed.accessToken || null;
}
} catch {
// Ignore parse errors
}
return null;
});
const currentStepId = wizard.currentStepId;
const StepIcon = stepIconMap[currentStepId] || Sparkles;
const currentStepData = wizard.steps[wizard.currentStep];
// Create a draft quote when leaving the discovery step
useEffect(() => {
if (currentStepId !== 'welcome' && currentStepId !== 'discovery' && !accessToken) {
createDraftQuote();
}
}, [currentStepId]); // eslint-disable-line react-hooks/exhaustive-deps
async function createDraftQuote(): Promise<string | null> {
try {
const response = await createQuote({
employee_count: quote.quoteData.company.endpointCount || undefined,
notes: quote.quoteData.company.notes || undefined,
});
setAccessToken(response.access_token);
try {
const existing = localStorage.getItem('quote-wizard-draft');
const draft = existing ? JSON.parse(existing) : {};
draft.accessToken = response.access_token;
localStorage.setItem('quote-wizard-draft', JSON.stringify(draft));
} catch {
// localStorage write failures are non-critical
}
return response.access_token;
} catch (error) {
console.error('Failed to create quote draft:', error);
return null;
}
}
/** Build quote line items from wizard selections */
function buildQuoteItems(): QuoteItemCreateRequest[] {
const items: QuoteItemCreateRequest[] = [];
const data = quote.quoteData;
const interests = data.serviceInterests;
// GPS Monitoring (if interested)
if (interests.gps) {
const gpsTier = gpsTiers.find((t) => t.id === data.gps.tierId);
if (gpsTier) {
items.push({
product_code: `gps-${gpsTier.id}`,
product_name: `GPS ${gpsTier.name} Monitoring`,
description: gpsTier.description,
category: 'gps_monitoring',
billing_frequency: 'monthly',
unit_price: gpsTier.pricePerEndpoint.toFixed(2),
quantity: data.gps.endpointCount,
tier: gpsTier.id,
});
}
if (data.gps.includeEquipment && data.gps.equipmentDeviceCount > 0) {
const additionalDevices = Math.max(0, data.gps.equipmentDeviceCount - equipmentMonitoring.baseDevices);
const eqTotal = equipmentMonitoring.basePrice + (additionalDevices * equipmentMonitoring.additionalDevicePrice);
items.push({
product_code: 'equip-pack',
product_name: 'Equipment Pack Monitoring',
description: `${data.gps.equipmentDeviceCount} devices`,
category: 'gps_monitoring',
billing_frequency: 'monthly',
unit_price: eqTotal.toFixed(2),
quantity: 1,
});
}
}
// Support plan (if interested)
if (interests.support && data.support.planId !== 'none') {
const plan = supportPlans.find((p) => p.id === data.support.planId);
if (plan) {
items.push({
product_code: `support-${plan.id}`,
product_name: `${plan.name} Support Plan`,
description: `${plan.includedHours} hours/month included`,
category: 'support_plan',
billing_frequency: 'monthly',
unit_price: plan.monthlyPrice.toFixed(2),
quantity: 1,
tier: plan.id,
});
}
}
// Block time (one-time)
if (interests.support && data.support.useBlockTime && data.support.blockTimeId) {
const block = blockTimeOptions.find((b) => b.id === data.support.blockTimeId);
if (block) {
items.push({
product_code: `block-${block.id}`,
product_name: `Block Time (${block.hours} hours)`,
description: `Pre-purchased support hours at ${formatCurrency(block.effectiveHourlyRate)}/hr`,
category: 'support_plan',
billing_frequency: 'one_time',
unit_price: block.price.toFixed(2),
quantity: 1,
});
}
}
// VoIP (if interested)
if (interests.voip && data.voip.enabled) {
const vTier = voipTiers.find((t) => t.id === data.voip.tierId);
if (vTier && data.voip.userCount > 0) {
items.push({
product_code: `voip-${vTier.id}`,
product_name: `VoIP ${vTier.name} Plan`,
description: vTier.description,
category: 'voip',
billing_frequency: 'monthly',
unit_price: vTier.pricePerUser.toFixed(2),
quantity: data.voip.userCount,
tier: vTier.id,
});
}
data.voip.hardware.forEach((hw) => {
const hwDef = voipHardware.find((h) => h.id === hw.hardwareId);
if (hwDef && hw.quantity > 0) {
if (hw.isRental) {
items.push({
product_code: `voip-hw-${hwDef.id}-rental`,
product_name: `${hwDef.name} (Rental)`,
description: hwDef.description,
category: 'voip',
billing_frequency: 'monthly',
unit_price: hwDef.monthlyRental.toFixed(2),
quantity: hw.quantity,
});
} else {
items.push({
product_code: `voip-hw-${hwDef.id}-purchase`,
product_name: `${hwDef.name} (Purchase)`,
description: hwDef.description,
category: 'voip',
billing_frequency: 'one_time',
unit_price: hwDef.oneTimePrice.toFixed(2),
quantity: hw.quantity,
});
}
}
});
}
// Web hosting (if interested)
if (interests.webHosting && data.webHosting.enabled) {
const wTier = webHostingTiers.find((t) => t.id === data.webHosting.tierId);
if (wTier) {
items.push({
product_code: `web-${wTier.id}`,
product_name: `${wTier.name} Web Hosting`,
description: `${wTier.storage}, ${wTier.sites === -1 ? 'unlimited' : wTier.sites} sites`,
category: 'web_hosting',
billing_frequency: 'monthly',
unit_price: wTier.monthlyPrice.toFixed(2),
quantity: 1,
tier: wTier.id,
});
}
}
// Email (if interested)
if (interests.email && data.email.enabled && data.email.mailboxCount > 0) {
const eTier = emailTiers.find((t) => t.id === data.email.tierId);
if (eTier) {
items.push({
product_code: `email-${eTier.id}`,
product_name: eTier.name,
description: `${eTier.storage} storage per mailbox`,
category: 'email',
billing_frequency: 'monthly',
unit_price: eTier.pricePerMailbox.toFixed(2),
quantity: data.email.mailboxCount,
tier: eTier.id,
});
}
}
return items;
}
const handleNext = () => {
setSubmitError(null);
// Calculate quote before entering review
if (wizard.steps[wizard.currentStep + 1]?.id === 'review') {
quote.calculateQuote();
}
wizard.nextStep();
};
const handlePrev = () => {
setSubmitError(null);
wizard.prevStep();
};
const handleSubmit = async () => {
setIsSubmitting(true);
setSubmitError(null);
quote.calculateQuote();
try {
let token = accessToken;
const items = buildQuoteItems();
if (!token) {
const response = await createQuote({
employee_count: quote.quoteData.company.endpointCount || undefined,
notes: quote.quoteData.company.notes || undefined,
items,
});
token = response.access_token;
setAccessToken(token);
} else {
const companyData = quote.quoteData.company;
await updateQuote(token, {
company_name: companyData.name || undefined,
employee_count: companyData.endpointCount || undefined,
notes: companyData.notes || undefined,
items,
});
}
const contactData = quote.quoteData.contact;
const companyData = quote.quoteData.company;
const submitData: QuoteSubmitRequest = {
company_name: contactData.companyName || companyData.name || contactData.name,
contact_name: contactData.name,
contact_email: contactData.email,
contact_phone: contactData.phone || undefined,
notes: contactData.currentITSituation || companyData.notes || undefined,
};
await submitQuote(token, submitData);
localStorage.removeItem('quote-wizard-draft');
setSubmitSuccess(true);
} catch (error: unknown) {
console.error('Submission error:', error);
let message = 'An unexpected error occurred. Please try again.';
if (error instanceof Error) {
message = error.message;
}
if (
typeof error === 'object' &&
error !== null &&
'response' in error
) {
const axiosError = error as { response?: { data?: { detail?: string }; status?: number } };
if (axiosError.response?.data?.detail) {
message = axiosError.response.data.detail;
} else if (axiosError.response?.status === 400) {
message = 'Quote cannot be submitted. Please review your selections and try again.';
} else if (axiosError.response?.status === 404) {
message = 'Quote session expired. Please start a new quote.';
}
}
setSubmitError(message);
} finally {
setIsSubmitting(false);
}
};
const handleGoToStep = (step: number) => {
wizard.goToStep(step);
};
const isNextDisabled = (): boolean => {
switch (currentStepId) {
case 'welcome':
return (
!quote.quoteData.contact.name.trim() ||
!quote.quoteData.contact.email.trim() ||
quote.quoteData.company.endpointCount < 1
);
case 'submit':
return !quote.quoteData.contact.agreedToTerms;
default:
return false;
}
};
const renderStepContent = () => {
switch (currentStepId) {
case 'welcome':
return (
<StepWelcome
clientType={quote.quoteData.clientType}
companyInfo={quote.quoteData.company}
contactInfo={quote.quoteData.contact}
onSetClientType={quote.setClientType}
onUpdateCompany={quote.updateCompany}
onUpdateContact={quote.updateContact}
onSetEndpointCount={quote.setEndpointCount}
onSetIndustry={quote.setIndustry}
/>
);
case 'discovery':
return (
<StepServiceDiscovery
serviceInterests={quote.quoteData.serviceInterests}
onSetServiceInterest={quote.setServiceInterest}
/>
);
case 'gps':
return (
<Step2GPSMonitoring
gpsSelection={quote.quoteData.gps}
onSetGPSTier={quote.setGPSTier}
onSetEquipmentEnabled={quote.setEquipmentEnabled}
onSetEquipmentCount={quote.setEquipmentCount}
getGPSMonthly={quote.getGPSMonthly}
/>
);
case 'support':
return (
<Step3SupportPlan
supportSelection={quote.quoteData.support}
endpointCount={quote.quoteData.company.endpointCount}
onSetSupportPlan={quote.setSupportPlan}
onSetBlockTimeEnabled={quote.setBlockTimeEnabled}
onSetBlockTime={quote.setBlockTime}
getSupportMonthly={quote.getSupportMonthly}
getSupportBlockTimeOneTime={quote.getSupportBlockTimeOneTime}
/>
);
case 'voip':
return (
<Step4VoIP
voipSelection={quote.quoteData.voip}
onSetVoIPEnabled={quote.setVoIPEnabled}
onSetVoIPTier={quote.setVoIPTier}
onSetVoIPUserCount={quote.setVoIPUserCount}
onAddHardware={quote.addHardware}
onRemoveHardware={quote.removeHardware}
onUpdateHardwareQuantity={quote.updateHardwareQuantity}
getVoIPMonthly={quote.getVoIPMonthly}
getVoIPOneTime={quote.getVoIPOneTime}
/>
);
case 'web-email':
return (
<Step5WebEmail
webHostingSelection={quote.quoteData.webHosting}
emailSelection={quote.quoteData.email}
onSetWebHostingEnabled={quote.setWebHostingEnabled}
onSetWebHostingTier={quote.setWebHostingTier}
onSetEmailEnabled={quote.setEmailEnabled}
onSetEmailProvider={quote.setEmailProvider}
onSetEmailTier={quote.setEmailTier}
onSetMailboxCount={quote.setMailboxCount}
getWebHostingMonthly={quote.getWebHostingMonthly}
getEmailMonthly={quote.getEmailMonthly}
/>
);
case 'review':
return (
<Step6Summary
quoteData={quote.quoteData}
quoteResult={quote.quoteResult}
onGoToStep={handleGoToStep}
onCalculateQuote={quote.calculateQuote}
/>
);
case 'submit':
return (
<Step7Contact
contactInfo={quote.quoteData.contact}
companyNameFromStep1={quote.quoteData.company.name}
quoteResult={quote.quoteResult}
onUpdateContact={quote.updateContact}
onSetContactPreference={quote.setContactPreference}
onSetAgreedToTerms={quote.setAgreedToTerms}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
);
default:
return null;
}
};
// Running total calculation (only include interested services)
const interests = quote.quoteData.serviceInterests;
const runningMonthly =
(interests.gps ? quote.getGPSMonthly() : 0) +
(interests.support ? quote.getSupportMonthly() : 0) +
(interests.voip ? quote.getVoIPMonthly() : 0) +
(interests.webHosting ? quote.getWebHostingMonthly() : 0) +
(interests.email ? quote.getEmailMonthly() : 0);
// Success state
if (submitSuccess) {
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6">
<Card variant="elevated" padding="lg">
<CardContent>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, ease: [0.25, 0.46, 0.45, 0.94] }}
className="text-center py-12 sm:py-16"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 15, delay: 0.2 }}
className="w-20 h-20 bg-[#ecfdf5] rounded-full flex items-center justify-center mx-auto mb-8"
>
<CircleCheck className="w-10 h-10 text-[#059669]" />
</motion.div>
<h2 className="text-3xl font-bold text-[#333d49] mb-3">
Quote Request Submitted
</h2>
<p className="text-gray-500 mb-10 max-w-md mx-auto leading-relaxed">
Thank you for your interest. Our team will review your custom quote and
contact you within one business day.
</p>
{quote.quoteResult && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="bg-[#f8f9fb] rounded-2xl p-8 max-w-sm mx-auto mb-10"
>
<p className="text-sm text-gray-400 mb-1 uppercase tracking-wide font-medium"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif", fontSize: '11px', letterSpacing: '0.08em' }}>
Your Estimated Monthly Investment
</p>
<p className="text-4xl font-bold text-[#fe7400]" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(quote.quoteResult.monthlyTotal)}
<span className="text-base font-medium text-gray-400 ml-1">/mo</span>
</p>
</motion.div>
)}
<button
onClick={() => {
quote.resetQuote();
wizard.resetWizard();
setSubmitSuccess(false);
setAccessToken(null);
setSubmitError(null);
}}
className="text-[#fe7400] hover:text-[#e56800] font-semibold transition-colors"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
Start a New Quote
</button>
</motion.div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6">
{/* Progress indicator */}
<div className="mb-8 sm:mb-10 print-hide">
<WizardProgress
steps={wizard.steps}
currentStep={wizard.currentStep}
onStepClick={wizard.goToStep}
/>
</div>
{/* Main wizard card */}
<Card variant="elevated" padding="none" className="overflow-hidden">
<CardContent>
{/* Step header */}
<div className="px-4 sm:px-6 md:px-8 pt-5 sm:pt-6 md:pt-8 pb-5 sm:pb-6 border-b border-gray-100 bg-white print-hide">
<div className="flex items-center gap-3 sm:gap-4">
<div className="flex items-center justify-center w-9 h-9 sm:w-11 sm:h-11 rounded-xl bg-[#fe7400]/8 flex-shrink-0">
<StepIcon className="w-4 h-4 sm:w-5 sm:h-5 text-[#fe7400]" />
</div>
<div className="min-w-0">
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#333d49] truncate">
{currentStepData?.title}
</h2>
<p className="text-xs sm:text-sm text-gray-400 mt-0.5 truncate">{currentStepData?.description}</p>
</div>
</div>
</div>
{/* Error banner */}
{submitError && (
<div className="mx-4 sm:mx-6 md:mx-8 mt-4 sm:mt-6 p-3 sm:p-4 bg-red-50 border border-red-100 rounded-xl">
<p className="text-red-600 text-sm font-medium">{submitError}</p>
</div>
)}
{/* Step content with animation */}
<div className="px-4 sm:px-6 md:px-8 py-5 sm:py-6 md:py-8">
<AnimatePresence mode="wait">
<motion.div
key={currentStepId}
initial={{ opacity: 0, x: 16 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -16 }}
transition={{ duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] }}
className="min-h-[400px]"
>
{renderStepContent()}
</motion.div>
</AnimatePresence>
{/* Navigation — hidden on submit step (has its own submit button) */}
{currentStepId !== 'submit' && (
<WizardNavigation
onNext={handleNext}
onPrev={handlePrev}
onSubmit={handleSubmit}
isFirstStep={wizard.isFirstStep}
isLastStep={wizard.isLastStep}
isNextDisabled={isNextDisabled()}
isSubmitting={isSubmitting}
/>
)}
</div>
</CardContent>
</Card>
{/* Running totals bar */}
<div className="mt-5 grid grid-cols-3 gap-2 sm:gap-3">
<div className="bg-white rounded-xl border border-gray-200/80 shadow-sm p-2.5 sm:p-4 text-center">
<div className="flex items-center justify-center gap-1 sm:gap-1.5 mb-1">
<Hash className="w-3 h-3 sm:w-3.5 sm:h-3.5 text-gray-400" />
<p className="text-[10px] sm:text-[11px] font-medium text-gray-400 uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Endpoints
</p>
</div>
<p className="text-lg sm:text-2xl font-bold text-[#333d49]" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{quote.quoteData.company.endpointCount}
</p>
</div>
<div className="bg-white rounded-xl border border-gray-200/80 shadow-sm p-2.5 sm:p-4 text-center">
<div className="flex items-center justify-center gap-1 sm:gap-1.5 mb-1">
<TrendingUp className="w-3 h-3 sm:w-3.5 sm:h-3.5 text-[#fe7400]" />
<p className="text-[10px] sm:text-[11px] font-medium text-gray-400 uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Monthly
</p>
</div>
<p className="text-lg sm:text-2xl font-bold text-[#fe7400]" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(runningMonthly)}
</p>
</div>
<div className="bg-white rounded-xl border border-gray-200/80 shadow-sm p-2.5 sm:p-4 text-center">
<div className="flex items-center justify-center gap-1 sm:gap-1.5 mb-1">
<CircleCheck className="w-3 h-3 sm:w-3.5 sm:h-3.5 text-gray-400" />
<p className="text-[10px] sm:text-[11px] font-medium text-gray-400 uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Progress
</p>
</div>
<p className="text-lg sm:text-2xl font-bold text-[#333d49]" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{wizard.progress}%
</p>
</div>
</div>
</div>
);
}

View File

@@ -1,61 +0,0 @@
import { ChevronLeft, ChevronRight, Send } from 'lucide-react';
import { Button } from '@/components/ui';
export interface WizardNavigationProps {
onNext: () => void;
onPrev: () => void;
onSubmit?: () => void;
isFirstStep: boolean;
isLastStep: boolean;
isNextDisabled?: boolean;
isSubmitting?: boolean;
}
export function WizardNavigation({
onNext,
onPrev,
onSubmit,
isFirstStep,
isLastStep,
isNextDisabled = false,
isSubmitting = false,
}: WizardNavigationProps) {
return (
<div className="flex items-center justify-between pt-8 mt-8 border-t border-gray-100">
<Button
type="button"
variant="ghost"
onClick={onPrev}
disabled={isFirstStep}
className={isFirstStep ? 'invisible' : ''}
>
<ChevronLeft className="w-4 h-4 mr-1.5" />
Back
</Button>
{isLastStep ? (
<Button
type="button"
variant="primary"
size="lg"
onClick={onSubmit}
isLoading={isSubmitting}
disabled={isNextDisabled || isSubmitting}
>
<Send className="w-4 h-4 mr-2" />
Get My Quote
</Button>
) : (
<Button
type="button"
variant="primary"
onClick={onNext}
disabled={isNextDisabled}
>
Continue
<ChevronRight className="w-4 h-4 ml-1.5" />
</Button>
)}
</div>
);
}

View File

@@ -1,130 +0,0 @@
import { motion } from 'framer-motion';
import { Check } from 'lucide-react';
import type { WizardStep } from '@/types/quote';
import { cn } from '@/lib/utils';
export interface WizardProgressProps {
steps: WizardStep[];
currentStep: number;
onStepClick?: (stepIndex: number) => void;
}
export function WizardProgress({ steps, currentStep, onStepClick }: WizardProgressProps) {
const isCompactMode = steps.length > 5;
return (
<nav aria-label="Progress" className="w-full">
{/* Desktop stepper */}
<ol className="flex items-start justify-between">
{steps.map((step, index) => {
const isCompleted = step.isComplete;
const isCurrent = index === currentStep;
const isClickable = isCompleted || index <= currentStep;
const isLast = index === steps.length - 1;
return (
<li
key={step.id}
className={cn(
'relative flex flex-col items-center',
!isLast && 'flex-1'
)}
>
{/* Connector line */}
{!isLast && (
<div
className="absolute top-[18px] left-[calc(50%+18px)] right-[calc(-50%+18px)] h-[2px] bg-gray-200"
aria-hidden="true"
>
<motion.div
className="h-full bg-[#fe7400] origin-left"
initial={{ scaleX: 0 }}
animate={{ scaleX: isCompleted ? 1 : 0 }}
transition={{ duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] }}
/>
</div>
)}
<button
type="button"
onClick={() => isClickable && onStepClick?.(index)}
disabled={!isClickable}
className={cn(
'group relative z-10 flex flex-col items-center gap-2',
isClickable ? 'cursor-pointer' : 'cursor-not-allowed'
)}
aria-current={isCurrent ? 'step' : undefined}
>
{/* Step circle */}
<motion.div
className={cn(
'relative flex items-center justify-center rounded-full transition-all duration-300',
isCompactMode ? 'h-7 w-7' : 'h-9 w-9',
isCompleted
? 'bg-[#fe7400] shadow-[0_0_0_4px_rgba(254,116,0,0.1)]'
: isCurrent
? 'bg-white border-[2.5px] border-[#fe7400] shadow-[0_0_0_4px_rgba(254,116,0,0.1)]'
: 'bg-white border-2 border-gray-200'
)}
whileHover={isClickable ? { scale: 1.08 } : {}}
whileTap={isClickable ? { scale: 0.95 } : {}}
layout
>
{isCompleted ? (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
>
<Check className={cn(isCompactMode ? 'h-3.5 w-3.5' : 'h-4 w-4', 'text-white')} strokeWidth={3} />
</motion.div>
) : (
<span
className={cn(
'font-bold',
isCompactMode ? 'text-xs' : 'text-sm',
isCurrent ? 'text-[#fe7400]' : 'text-gray-400'
)}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
{index + 1}
</span>
)}
</motion.div>
{/* Step label */}
<div className={cn(
'text-center max-w-[80px]',
isCompactMode && 'hidden sm:block'
)}>
<span
className={cn(
'font-medium whitespace-nowrap leading-tight block',
isCompactMode ? 'text-[10px]' : 'text-[11px]',
isCurrent ? 'text-[#fe7400]' : isCompleted ? 'text-[#333d49]' : 'text-gray-400'
)}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
{step.title}
</span>
</div>
</button>
</li>
);
})}
</ol>
{/* Mobile step indicator for compact mode */}
{isCompactMode && (
<div className="sm:hidden mt-4 text-center">
<span className="text-xs text-gray-400">
Step {currentStep + 1} of {steps.length}
</span>
<span className="text-sm font-semibold text-[#333d49] ml-2">
{steps[currentStep]?.title}
</span>
</div>
)}
</nav>
);
}

View File

@@ -1,4 +0,0 @@
export { WizardContainer } from './WizardContainer';
export { WizardProgress, type WizardProgressProps } from './WizardProgress';
export { WizardNavigation, type WizardNavigationProps } from './WizardNavigation';
export * from './steps';

View File

@@ -1,216 +0,0 @@
import { motion } from 'framer-motion';
import { Building2, Users, Briefcase, MessageSquare, Shield, Monitor, Headphones, ArrowRight } from 'lucide-react';
import { Input } from '@/components/ui';
import { industries } from '@/lib/pricing-data';
import type { CompanyInfo, Industry } from '@/types/quote';
export interface Step1CompanyProfileProps {
companyInfo: CompanyInfo;
onUpdateCompany: (data: Partial<CompanyInfo>) => void;
onSetEndpointCount: (count: number) => void;
onSetIndustry: (industry: Industry | '') => void;
}
export function Step1CompanyProfile({
companyInfo,
onUpdateCompany,
onSetEndpointCount,
onSetIndustry,
}: Step1CompanyProfileProps) {
const handleEndpointChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value >= 1) {
onSetEndpointCount(value);
}
};
const handleIndustryChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
onSetIndustry(e.target.value as Industry | '');
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-8"
>
{/* Welcome / Intro Section */}
<div className="space-y-4">
<h3 className="text-xl sm:text-2xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Welcome to Arizona Computer Guru
</h3>
<p className="text-sm sm:text-base text-gray-500 leading-relaxed max-w-3xl">
We're a <strong className="text-[#333d49]">Managed Service Provider (MSP)</strong> serving
businesses across Arizona. An MSP acts as your outsourced IT department &mdash; we proactively
manage, monitor, and secure your technology so you can focus on running your business.
</p>
</div>
{/* What You Get - 3 cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-[#f8f9fb] rounded-xl p-4 border border-gray-200/50"
>
<div className="w-9 h-9 rounded-lg bg-[#fe7400]/8 flex items-center justify-center mb-3">
<Monitor className="w-4.5 h-4.5 text-[#fe7400]" />
</div>
<h4 className="font-semibold text-[#333d49] text-sm mb-1"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
GPS Monitoring
</h4>
<p className="text-xs text-gray-400 leading-relaxed">
Our <strong className="text-gray-500">Guru Protection Suite</strong> provides 24/7
remote monitoring, patch management, antivirus, and help desk support for every endpoint.
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
className="bg-[#f8f9fb] rounded-xl p-4 border border-gray-200/50"
>
<div className="w-9 h-9 rounded-lg bg-[#fe7400]/8 flex items-center justify-center mb-3">
<Headphones className="w-4.5 h-4.5 text-[#fe7400]" />
</div>
<h4 className="font-semibold text-[#333d49] text-sm mb-1"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Support Plans
</h4>
<p className="text-xs text-gray-400 leading-relaxed">
Flexible support tiers from basic help desk to fully managed IT with dedicated
engineers and guaranteed response times.
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-[#f8f9fb] rounded-xl p-4 border border-gray-200/50"
>
<div className="w-9 h-9 rounded-lg bg-[#fe7400]/8 flex items-center justify-center mb-3">
<Shield className="w-4.5 h-4.5 text-[#fe7400]" />
</div>
<h4 className="font-semibold text-[#333d49] text-sm mb-1"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
VoIP, Web & Email
</h4>
<p className="text-xs text-gray-400 leading-relaxed">
Business phone systems, web hosting, and professional email &mdash; all managed
alongside your IT for a single point of contact.
</p>
</motion.div>
</div>
{/* How It Works */}
<div className="flex items-center gap-2 text-xs text-gray-400">
<ArrowRight className="w-3.5 h-3.5 text-[#fe7400]" />
<span>
This wizard builds a custom quote in about 2 minutes. Tell us about your business to get started.
</span>
</div>
{/* Divider */}
<div className="border-t border-gray-100" />
{/* Form Section */}
<div className="space-y-6">
{/* Company Name */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<Building2 className="w-4 h-4 text-[#fe7400]" />
Company Name
<span className="text-gray-300 font-normal text-xs">(optional)</span>
</label>
<Input
type="text"
value={companyInfo.name}
onChange={(e) => onUpdateCompany({ name: e.target.value })}
placeholder="Enter your company name"
className="max-w-lg"
/>
</div>
{/* Number of Endpoints */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<Users className="w-4 h-4 text-[#fe7400]" />
Number of Endpoints / Employees
<span className="text-red-500 text-xs">*</span>
</label>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
<Input
type="number"
min={1}
value={companyInfo.endpointCount}
onChange={handleEndpointChange}
className="w-full sm:w-32"
/>
<span className="text-xs sm:text-sm text-gray-400">
devices requiring monitoring and support
</span>
</div>
<p className="text-xs text-gray-400">
Include workstations, laptops, and servers that need IT support
</p>
</div>
{/* Industry Selection */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<Briefcase className="w-4 h-4 text-[#fe7400]" />
Industry
</label>
<select
value={companyInfo.industry}
onChange={handleIndustryChange}
className="w-full max-w-lg px-4 py-3 rounded-xl border border-gray-200 bg-white text-[#333d49] hover:border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#fe7400]/15 focus:border-[#fe7400] transition-all duration-200 appearance-none"
style={{
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%239aa1ac' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`,
backgroundPosition: 'right 12px center',
backgroundRepeat: 'no-repeat',
backgroundSize: '20px 20px',
paddingRight: '40px'
}}
>
<option value="">Select your industry...</option>
{industries.map((industry) => (
<option key={industry} value={industry}>
{industry}
</option>
))}
</select>
<p className="text-xs text-gray-400">
This helps us understand compliance requirements and best practices for your sector
</p>
</div>
{/* Notes */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<MessageSquare className="w-4 h-4 text-[#fe7400]" />
What brings you here today?
<span className="text-gray-300 font-normal text-xs">(optional)</span>
</label>
<textarea
value={companyInfo.notes}
onChange={(e) => onUpdateCompany({ notes: e.target.value })}
placeholder="Tell us about your current IT challenges or what you're looking for..."
rows={3}
className="w-full px-4 py-3 rounded-xl border border-gray-200 bg-white text-[#333d49] placeholder-gray-400 hover:border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#fe7400]/15 focus:border-[#fe7400] transition-all duration-200 resize-none"
/>
</div>
</div>
</motion.div>
);
}

View File

@@ -1,305 +0,0 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Check, Server, HardDrive, ChevronDown } from 'lucide-react';
import { Card, Button } from '@/components/ui';
import { ExpandableInfo } from '@/components/pricing/ExpandableInfo';
import { gpsTiers, equipmentMonitoring } from '@/lib/pricing-data';
import { formatCurrency } from '@/lib/utils';
import type { GPSSelection, GPSTierId } from '@/types/quote';
export interface Step2GPSMonitoringProps {
gpsSelection: GPSSelection;
onSetGPSTier: (tierId: GPSTierId) => void;
onSetEquipmentEnabled: (enabled: boolean) => void;
onSetEquipmentCount: (count: number) => void;
getGPSMonthly: () => number;
}
export function Step2GPSMonitoring({
gpsSelection,
onSetGPSTier,
onSetEquipmentEnabled,
onSetEquipmentCount,
getGPSMonthly,
}: Step2GPSMonitoringProps) {
const [expandedTiers, setExpandedTiers] = useState<Record<string, boolean>>({});
const toggleTierExpanded = (tierId: string) => {
setExpandedTiers(prev => ({ ...prev, [tierId]: !prev[tierId] }));
};
const calculateEquipmentPrice = () => {
if (!gpsSelection.includeEquipment || gpsSelection.equipmentDeviceCount === 0) {
return 0;
}
const additionalDevices = Math.max(0, gpsSelection.equipmentDeviceCount - equipmentMonitoring.baseDevices);
return equipmentMonitoring.basePrice + (additionalDevices * equipmentMonitoring.additionalDevicePrice);
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
{/* Service Explainer */}
<div className="space-y-2">
<p className="text-sm sm:text-base text-gray-500 leading-relaxed">
<strong className="text-[#333d49]">GPS (Guru Protection Suite)</strong> is our core
managed monitoring service. We install a lightweight agent on each of your endpoints that
runs 24/7 in the background &mdash; watching system health, disk space, CPU/memory usage,
security status, and more.
</p>
<p className="text-sm text-gray-400 leading-relaxed">
When an issue is detected, our team is automatically alerted and can often resolve problems
remotely before you even notice. GPS also includes automated patch management to keep
Windows and third-party apps up to date, enterprise antivirus protection, and access to
our help desk for day-to-day questions. Higher tiers add 24/7 support, advanced endpoint
protection, backup and disaster recovery, and dedicated account management.
</p>
</div>
{/* Endpoint Count Display */}
<div className="flex items-center justify-between bg-[#f8f9fb] rounded-xl p-4">
<div className="flex items-center gap-3">
<Server className="w-5 h-5 text-[#fe7400]" />
<span className="font-medium text-[#333d49]" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Endpoints to Monitor
</span>
</div>
<span className="text-2xl font-bold text-[#fe7400]" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{gpsSelection.endpointCount}
</span>
</div>
{/* Tier Selection Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 sm:gap-4">
{gpsTiers.map((tier, index) => {
const isSelected = gpsSelection.tierId === tier.id;
const monthlyPrice = tier.pricePerEndpoint * gpsSelection.endpointCount;
return (
<motion.div
key={tier.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ y: -3 }}
>
<Card
variant={isSelected ? 'highlighted' : 'default'}
padding="none"
className={`relative overflow-hidden cursor-pointer h-full ${
tier.recommended && !isSelected ? 'ring-2 ring-[#fe7400]/30' : ''
}`}
onClick={() => onSetGPSTier(tier.id)}
>
{/* Recommended Badge */}
{tier.recommended && (
<div className="bg-gradient-accent text-white text-[11px] font-bold px-3 py-1.5 text-center uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Recommended
</div>
)}
<div className="p-5">
{/* Header */}
<div className="mb-4">
<h3 className="text-lg font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{tier.name}
</h3>
<p className="text-sm text-gray-400 mt-0.5">{tier.description}</p>
</div>
{/* Pricing */}
<div className="mb-5">
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(monthlyPrice)}
</span>
<span className="text-gray-400 text-sm">/mo</span>
</div>
<p className="text-xs text-gray-400 mt-1">
{formatCurrency(tier.pricePerEndpoint)}/endpoint/month
</p>
</div>
{/* Features */}
<ul className="space-y-2.5 mb-5">
{tier.features.slice(0, 4).map((feature, idx) => (
<li key={idx} className="flex items-start gap-2.5 text-sm">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span className="text-gray-600">{feature}</span>
</li>
))}
<AnimatePresence>
{expandedTiers[tier.id] && tier.features.slice(4).map((feature, idx) => (
<motion.li
key={`extra-${idx}`}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="flex items-start gap-2.5 text-sm"
>
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span className="text-gray-600">{feature}</span>
</motion.li>
))}
</AnimatePresence>
{tier.features.length > 4 && (
<li>
<button
type="button"
onClick={(e) => { e.stopPropagation(); toggleTierExpanded(tier.id); }}
className="flex items-center gap-1 text-xs text-[#fe7400] font-medium pl-6.5 hover:text-[#e56800] transition-colors"
>
<ChevronDown className={`w-3 h-3 transition-transform duration-200 ${expandedTiers[tier.id] ? 'rotate-180' : ''}`} />
{expandedTiers[tier.id]
? 'Show less'
: `+${tier.features.length - 4} more features`
}
</button>
</li>
)}
</ul>
{/* Select Button */}
<Button
variant={isSelected ? 'primary' : 'outline'}
className="w-full"
size="sm"
>
{isSelected ? 'Selected' : 'Select'}
</Button>
</div>
</Card>
</motion.div>
);
})}
</div>
{/* Equipment Monitoring Section */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="border border-gray-200/80 rounded-xl p-5 bg-white shadow-card"
>
<div className="flex items-start justify-between gap-3 mb-4">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 rounded-lg bg-[#fe7400]/8 flex items-center justify-center flex-shrink-0">
<HardDrive className="w-4.5 h-4.5 text-[#fe7400]" />
</div>
<div className="min-w-0">
<h4 className="font-semibold text-[#333d49] text-sm sm:text-base"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Equipment Pack Monitoring
</h4>
<p className="text-xs sm:text-sm text-gray-400">
Monitor routers, switches, printers, and other network equipment
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer flex-shrink-0">
<input
type="checkbox"
checked={gpsSelection.includeEquipment}
onChange={(e) => onSetEquipmentEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#fe7400]/10 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#fe7400]"></div>
</label>
</div>
{gpsSelection.includeEquipment && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
transition={{ duration: 0.2 }}
className="space-y-4 pt-4 border-t border-gray-100"
>
<div className="flex items-center gap-4">
<label className="text-sm text-gray-500"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Number of devices:
</label>
<input
type="number"
min={1}
value={gpsSelection.equipmentDeviceCount}
onChange={(e) => onSetEquipmentCount(parseInt(e.target.value, 10) || 1)}
className="w-24 px-3 py-2.5 rounded-xl border border-gray-200 bg-white text-[#333d49] hover:border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#fe7400]/15 focus:border-[#fe7400] transition-all"
/>
</div>
<div className="bg-[#f8f9fb] rounded-xl p-4">
<p className="text-sm text-gray-500">
<span className="font-semibold text-[#333d49]">{formatCurrency(equipmentMonitoring.basePrice)}/month</span>
{' '}for up to {equipmentMonitoring.baseDevices} devices
{gpsSelection.equipmentDeviceCount > equipmentMonitoring.baseDevices && (
<span>
{' + '}
<span className="font-semibold text-[#333d49]">
{formatCurrency(equipmentMonitoring.additionalDevicePrice)}/device
</span>
{' for additional devices'}
</span>
)}
</p>
<p className="text-sm font-bold text-[#fe7400] mt-2"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Equipment total: {formatCurrency(calculateEquipmentPrice())}/month
</p>
</div>
</motion.div>
)}
</motion.div>
{/* Expandable Feature Info */}
<ExpandableInfo title="What's included in GPS Monitoring?">
<ul className="space-y-3">
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span><strong>Remote Monitoring:</strong> 24/7 monitoring of system health, performance, and security</span>
</li>
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span><strong>Patch Management:</strong> Automated Windows and third-party application updates</span>
</li>
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span><strong>Antivirus:</strong> Enterprise-grade protection with real-time threat detection</span>
</li>
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span><strong>Help Desk:</strong> Access to our technical support team for issues and questions</span>
</li>
</ul>
</ExpandableInfo>
{/* Monthly Total */}
<div className="bg-gradient-dark text-white rounded-xl p-4 sm:p-5 flex items-center justify-between gap-3">
<span className="text-sm sm:text-base font-medium opacity-90">GPS Monitoring Monthly Total</span>
<span className="text-2xl sm:text-3xl font-bold whitespace-nowrap" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(getGPSMonthly())}
<span className="text-xs sm:text-sm font-medium opacity-60 ml-1">/mo</span>
</span>
</div>
</motion.div>
);
}

View File

@@ -1,377 +0,0 @@
import { motion } from 'framer-motion';
import { Check, Clock, DollarSign, Zap, Ban } from 'lucide-react';
import { Card, Button } from '@/components/ui';
import { ExpandableInfo } from '@/components/pricing/ExpandableInfo';
import { supportPlans, blockTimeOptions } from '@/lib/pricing-data';
import { formatCurrency } from '@/lib/utils';
import type { SupportSelection, SupportPlanId, BlockTimeId } from '@/types/quote';
export interface Step3SupportPlanProps {
supportSelection: SupportSelection;
endpointCount: number;
onSetSupportPlan: (planId: SupportPlanId) => void;
onSetBlockTimeEnabled: (enabled: boolean) => void;
onSetBlockTime: (blockTimeId: BlockTimeId) => void;
getSupportMonthly: () => number;
getSupportBlockTimeOneTime: () => number;
}
export function Step3SupportPlan({
supportSelection,
endpointCount,
onSetSupportPlan,
onSetBlockTimeEnabled,
onSetBlockTime,
getSupportMonthly,
getSupportBlockTimeOneTime,
}: Step3SupportPlanProps) {
const getRecommendedPlan = (): SupportPlanId => {
if (endpointCount <= 10) return 'essential';
if (endpointCount <= 25) return 'standard';
if (endpointCount <= 50) return 'premium';
return 'priority';
};
const recommendedPlanId = getRecommendedPlan();
const isNoPlan = supportSelection.planId === 'none';
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
{/* Service Explainer */}
<div className="space-y-2">
<p className="text-sm sm:text-base text-gray-500 leading-relaxed">
<strong className="text-[#333d49]">Support plans</strong> give your team direct access
to our IT engineers for troubleshooting, questions, and project work. Each plan includes
a set number of monthly support hours covering help desk calls, remote assistance,
and on-site visits (Premium and Priority tiers).
</p>
<p className="text-sm text-gray-400 leading-relaxed">
Hours are used as-needed throughout the month &mdash; whether it's a quick password reset, a
printer issue, or a more involved project like setting up a new workstation.
If you don't need a monthly plan, you can skip it entirely and use block time
for occasional projects, or simply pay as you go at our standard hourly rate.
</p>
</div>
{/* Plan Selection Cards - No Plan + 4 plans = 5 columns on large screens */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 sm:gap-4">
{/* No Plan / Pay-as-you-go Card */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0 }}
whileHover={{ y: -3 }}
>
<Card
variant={isNoPlan ? 'highlighted' : 'default'}
padding="none"
className="relative overflow-hidden cursor-pointer h-full"
onClick={() => onSetSupportPlan('none')}
>
<div className="p-4">
{/* Header */}
<h3 className="text-base font-bold text-[#333d49] mb-1"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
No Plan
</h3>
<p className="text-xs text-gray-400 mb-3">Pay-as-you-go or block time only</p>
{/* Pricing */}
<div className="mb-3">
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
$0
</span>
<span className="text-gray-400 text-xs">/mo</span>
</div>
</div>
{/* No included hours */}
<div className="flex items-center gap-2 mb-3 p-2.5 bg-[#f8f9fb] rounded-lg">
<Ban className="w-4 h-4 text-gray-400" />
<span className="text-sm font-semibold text-gray-400"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
No monthly hours
</span>
</div>
{/* Standard rate note */}
<div className="flex items-center gap-2 mb-4 text-sm text-gray-400">
<DollarSign className="w-4 h-4" />
<span>
$175/hr standard rate
</span>
</div>
{/* Select Button */}
<Button
variant={isNoPlan ? 'primary' : 'outline'}
className="w-full"
size="sm"
>
{isNoPlan ? 'Selected' : 'Select'}
</Button>
</div>
</Card>
</motion.div>
{/* Monthly Plan Cards */}
{supportPlans.map((plan, index) => {
const isSelected = supportSelection.planId === plan.id;
const isRecommended = plan.id === recommendedPlanId;
return (
<motion.div
key={plan.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: (index + 1) * 0.1 }}
whileHover={{ y: -3 }}
>
<Card
variant={isSelected ? 'highlighted' : 'default'}
padding="none"
className={`relative overflow-hidden cursor-pointer h-full ${
isRecommended && !isSelected ? 'ring-2 ring-[#fe7400]/30' : ''
}`}
onClick={() => onSetSupportPlan(plan.id)}
>
{/* Recommended Badge */}
{isRecommended && (
<div className="bg-gradient-accent text-white text-[11px] font-bold px-3 py-1.5 text-center uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Recommended for You
</div>
)}
<div className="p-4">
{/* Header */}
<h3 className="text-base font-bold text-[#333d49] mb-1"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{plan.name}
</h3>
<p className="text-xs text-gray-400 mb-3">{plan.description}</p>
{/* Pricing */}
<div className="mb-3">
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(plan.monthlyPrice)}
</span>
<span className="text-gray-400 text-xs">/mo</span>
</div>
</div>
{/* Hours Included */}
<div className="flex items-center gap-2 mb-3 p-2.5 bg-[#f8f9fb] rounded-lg">
<Clock className="w-4 h-4 text-[#fe7400]" />
<span className="text-sm font-semibold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{plan.includedHours} hrs included
</span>
</div>
{/* Effective Rate */}
<div className="flex items-center gap-2 mb-4 text-sm text-gray-400">
<DollarSign className="w-4 h-4" />
<span>
{formatCurrency(plan.effectiveHourlyRate)}/hr effective
</span>
</div>
{/* Select Button */}
<Button
variant={isSelected ? 'primary' : 'outline'}
className="w-full"
size="sm"
>
{isSelected ? 'Selected' : 'Select'}
</Button>
</div>
</Card>
</motion.div>
);
})}
</div>
{/* Pay-as-you-go info when No Plan is selected */}
{isNoPlan && !supportSelection.useBlockTime && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
transition={{ duration: 0.2 }}
className="bg-[#f8f9fb] rounded-xl p-4 text-sm text-gray-500"
>
<p>
Without a support plan, any support work will be billed at our standard hourly rate of
<strong className="text-[#333d49]"> $175/hr</strong>. You can add block time below
to pre-purchase hours at a discounted rate, or proceed without any support commitment.
</p>
</motion.div>
)}
{/* Block Time Option */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="border border-gray-200/80 rounded-xl p-5 bg-white shadow-card"
>
<div className="flex items-start justify-between gap-3 mb-4">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 rounded-lg bg-[#fe7400]/8 flex items-center justify-center flex-shrink-0">
<Zap className="w-4.5 h-4.5 text-[#fe7400]" />
</div>
<div className="min-w-0">
<h4 className="font-semibold text-[#333d49] text-sm sm:text-base"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{isNoPlan ? 'Add Block Time' : 'Add Extra Block Time'}
</h4>
<p className="text-xs sm:text-sm text-gray-400">
{isNoPlan
? 'Pre-purchase support hours at a discounted rate instead of pay-as-you-go'
: 'Pre-purchase additional support hours at a discounted rate'
}
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer flex-shrink-0">
<input
type="checkbox"
checked={supportSelection.useBlockTime}
onChange={(e) => onSetBlockTimeEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#fe7400]/10 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#fe7400]"></div>
</label>
</div>
{supportSelection.useBlockTime && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
transition={{ duration: 0.2 }}
className="space-y-3 pt-4 border-t border-gray-100"
>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{blockTimeOptions.map((option) => {
const isSelected = supportSelection.blockTimeId === option.id;
return (
<div
key={option.id}
onClick={() => onSetBlockTime(option.id)}
className={`p-4 rounded-xl border-2 cursor-pointer transition-all duration-200 ${
isSelected
? 'border-[#fe7400] bg-[#fe7400]/5 shadow-sm'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="text-base font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{option.hours} Hours
</div>
<div className="text-xl font-bold text-[#fe7400]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(option.price)}
</div>
<div className="text-sm text-gray-400">
{formatCurrency(option.effectiveHourlyRate)}/hr
</div>
{option.hours === 30 && (
<div className="mt-2 text-[11px] font-bold text-[#059669] bg-[#ecfdf5] px-2 py-1 rounded-md inline-block uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Best Value
</div>
)}
</div>
);
})}
</div>
</motion.div>
)}
</motion.div>
{/* Expandable Info */}
<ExpandableInfo title="How does support work?">
<div className="space-y-3">
<p>
Monthly support plans include a set number of hours for help desk assistance,
remote troubleshooting, and project work. Hours roll over for 30 days if unused.
If you prefer not to commit to a monthly plan, you can use block time for planned
projects or pay our standard hourly rate as needed.
</p>
<ul className="space-y-2.5">
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span><strong>Help Desk:</strong> Phone, email, and chat support for daily IT questions</span>
</li>
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span><strong>Remote Support:</strong> Screen sharing and remote control for quick fixes</span>
</li>
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span><strong>On-Site Support:</strong> Available for Premium and Priority plans</span>
</li>
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span><strong>Block Time:</strong> Pre-purchase hours at a discount for planned projects</span>
</li>
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span><strong>Pay-as-you-go:</strong> No commitment &mdash; billed at $175/hr standard rate</span>
</li>
</ul>
</div>
</ExpandableInfo>
{/* Monthly Total */}
<div className="bg-gradient-dark text-white rounded-xl p-4 sm:p-5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<span className="text-sm sm:text-base font-medium opacity-90">Support Monthly Cost</span>
{isNoPlan && (
<p className="text-xs sm:text-sm opacity-50">
Pay-as-you-go at $175/hr
</p>
)}
</div>
<span className="text-2xl sm:text-3xl font-bold whitespace-nowrap" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(getSupportMonthly())}
<span className="text-xs sm:text-sm font-medium opacity-60 ml-1">/mo</span>
</span>
</div>
{supportSelection.useBlockTime && supportSelection.blockTimeId && (
<div className="flex items-center justify-between gap-3 mt-3 pt-3 border-t border-white/15">
<div className="min-w-0">
<span className="text-sm sm:text-base font-medium opacity-90">Block Time</span>
<p className="text-xs sm:text-sm opacity-50">
{blockTimeOptions.find((b) => b.id === supportSelection.blockTimeId)?.hours} hours one-time purchase
</p>
</div>
<span className="text-xl sm:text-2xl font-bold whitespace-nowrap" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(getSupportBlockTimeOneTime())}
</span>
</div>
)}
</div>
</motion.div>
);
}

View File

@@ -1,420 +0,0 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Check, Phone, Headphones, Plus, Minus, X } from 'lucide-react';
import { Card, Button, Input } from '@/components/ui';
import { ExpandableInfo } from '@/components/pricing/ExpandableInfo';
import { voipTiers, voipHardware } from '@/lib/pricing-data';
import { formatCurrency } from '@/lib/utils';
import type { VoIPSelection, VoIPTierId, HardwareSelection } from '@/types/quote';
export interface Step4VoIPProps {
voipSelection: VoIPSelection;
onSetVoIPEnabled: (enabled: boolean) => void;
onSetVoIPTier: (tierId: VoIPTierId) => void;
onSetVoIPUserCount: (count: number) => void;
onAddHardware: (hardwareId: string, quantity: number, isRental: boolean) => void;
onRemoveHardware: (hardwareId: string) => void;
onUpdateHardwareQuantity: (hardwareId: string, quantity: number) => void;
getVoIPMonthly: () => number;
getVoIPOneTime: () => number;
}
export function Step4VoIP({
voipSelection,
onSetVoIPEnabled,
onSetVoIPTier,
onSetVoIPUserCount,
onAddHardware,
onRemoveHardware,
onUpdateHardwareQuantity,
getVoIPMonthly,
getVoIPOneTime,
}: Step4VoIPProps) {
const [showHardware, setShowHardware] = useState(false);
const getHardwareSelection = (hardwareId: string): HardwareSelection | undefined => {
return voipSelection.hardware.find((h) => h.hardwareId === hardwareId);
};
const handleHardwareToggle = (hardwareId: string, isRental: boolean) => {
const existing = getHardwareSelection(hardwareId);
if (existing) {
onRemoveHardware(hardwareId);
} else {
onAddHardware(hardwareId, 1, isRental);
}
};
const handleQuantityChange = (hardwareId: string, delta: number) => {
const existing = getHardwareSelection(hardwareId);
if (existing) {
const newQuantity = Math.max(1, existing.quantity + delta);
onUpdateHardwareQuantity(hardwareId, newQuantity);
}
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
{/* Service Explainer */}
<div className="space-y-2">
<p className="text-sm sm:text-base text-gray-500 leading-relaxed">
<strong className="text-[#333d49]">VoIP (Voice over IP)</strong> replaces traditional
phone lines with a modern cloud-based phone system. Your calls travel over the internet,
which means lower costs, more features, and the flexibility to take calls from your
desk phone, computer, or mobile app &mdash; anywhere with an internet connection.
</p>
<p className="text-sm text-gray-400 leading-relaxed">
Every plan includes unlimited local and long-distance calling, auto-attendant (press 1
for sales, etc.), voicemail-to-email, call forwarding, and the ability to keep your
existing phone numbers. Higher tiers add call recording, analytics, CRM integrations,
and video conferencing. We can also provide desk phones and headsets as a purchase or
monthly rental.
</p>
</div>
{/* VoIP Toggle */}
<div className="bg-[#f8f9fb] rounded-xl p-4 sm:p-5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-xl bg-[#fe7400]/8 flex items-center justify-center flex-shrink-0">
<Phone className="w-4 h-4 sm:w-5 sm:h-5 text-[#fe7400]" />
</div>
<div className="min-w-0">
<h3 className="font-bold text-[#333d49] text-sm sm:text-base"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Do you need business phones?
</h3>
<p className="text-xs sm:text-sm text-gray-400">
Modern VoIP phone system with advanced features
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer flex-shrink-0">
<input
type="checkbox"
checked={voipSelection.enabled}
onChange={(e) => onSetVoIPEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-14 h-7 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#fe7400]/10 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-6 after:w-6 after:transition-all peer-checked:bg-[#fe7400]"></div>
<span className="ml-3 text-sm font-semibold text-gray-500"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{voipSelection.enabled ? 'Yes' : 'No'}
</span>
</label>
</div>
</div>
<AnimatePresence>
{voipSelection.enabled && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
{/* User Count */}
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-4 border border-gray-200/80 rounded-xl bg-white shadow-card">
<label className="text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Number of phone users:
</label>
<Input
type="number"
min={1}
value={voipSelection.userCount}
onChange={(e) => onSetVoIPUserCount(parseInt(e.target.value, 10) || 1)}
className="w-full sm:w-24"
/>
</div>
{/* Tier Selection */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
{voipTiers.map((tier, index) => {
const isSelected = voipSelection.tierId === tier.id;
const monthlyPrice = tier.pricePerUser * voipSelection.userCount;
return (
<motion.div
key={tier.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ y: -3 }}
>
<Card
variant={isSelected ? 'highlighted' : 'default'}
padding="none"
className={`relative overflow-hidden cursor-pointer h-full ${
tier.recommended && !isSelected ? 'ring-2 ring-[#fe7400]/30' : ''
}`}
onClick={() => onSetVoIPTier(tier.id)}
>
{tier.recommended && (
<div className="bg-gradient-accent text-white text-[11px] font-bold px-3 py-1.5 text-center uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Popular
</div>
)}
<div className="p-4">
<h3 className="text-base font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{tier.name}
</h3>
<p className="text-xs text-gray-400 mb-3">{tier.description}</p>
<div className="mb-3">
<span className="text-xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(monthlyPrice)}
</span>
<span className="text-gray-400 text-xs">/mo</span>
<p className="text-xs text-gray-400">
{formatCurrency(tier.pricePerUser)}/user
</p>
</div>
<ul className="space-y-1.5 mb-4">
{tier.features.slice(0, 3).map((feature, idx) => (
<li key={idx} className="flex items-start gap-2 text-xs">
<div className="w-3.5 h-3.5 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2 h-2 text-[#fe7400]" strokeWidth={3} />
</div>
<span className="text-gray-500">{feature}</span>
</li>
))}
</ul>
<Button
variant={isSelected ? 'primary' : 'outline'}
className="w-full"
size="sm"
>
{isSelected ? 'Selected' : 'Select'}
</Button>
</div>
</Card>
</motion.div>
);
})}
</div>
{/* Hardware Section */}
<div className="border border-gray-200/80 rounded-xl overflow-hidden bg-white shadow-card">
<button
type="button"
onClick={() => setShowHardware(!showHardware)}
className="w-full flex items-center justify-between p-4 bg-[#f8f9fb] hover:bg-gray-100 transition-colors"
>
<div className="flex items-center gap-3">
<Headphones className="w-5 h-5 text-[#fe7400]" />
<span className="font-semibold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Phone Hardware (Optional)
</span>
</div>
<span className="text-sm text-gray-400 font-medium"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{showHardware ? 'Hide' : 'Show'} options
</span>
</button>
<AnimatePresence>
{showHardware && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="p-4 space-y-3"
>
{voipHardware.map((hardware) => {
const selection = getHardwareSelection(hardware.id);
const isSelected = !!selection;
return (
<div
key={hardware.id}
className={`p-4 rounded-xl border-2 transition-all duration-200 ${
isSelected
? 'border-[#fe7400] bg-[#fe7400]/5'
: 'border-gray-200'
}`}
>
<div className="space-y-3">
<div>
<h4 className="font-semibold text-[#333d49] text-sm sm:text-base"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{hardware.name}
</h4>
<p className="text-xs sm:text-sm text-gray-400">{hardware.description}</p>
<div className="flex gap-3 sm:gap-4 mt-2 text-xs sm:text-sm">
<span className="text-gray-500">
Buy: <strong className="text-[#333d49]">{formatCurrency(hardware.oneTimePrice)}</strong>
</span>
<span className="text-gray-500">
Rent: <strong className="text-[#333d49]">{formatCurrency(hardware.monthlyRental)}</strong>/mo
</span>
</div>
</div>
{isSelected ? (
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => onAddHardware(hardware.id, selection.quantity, false)}
className={`px-2.5 py-1.5 text-xs rounded-lg font-semibold transition-colors ${
!selection.isRental
? 'bg-[#fe7400] text-white'
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
}`}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
Buy
</button>
<button
type="button"
onClick={() => onAddHardware(hardware.id, selection.quantity, true)}
className={`px-2.5 py-1.5 text-xs rounded-lg font-semibold transition-colors ${
selection.isRental
? 'bg-[#fe7400] text-white'
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
}`}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
Rent
</button>
</div>
<div className="flex items-center gap-1 border border-gray-200 rounded-lg">
<button
type="button"
onClick={() => handleQuantityChange(hardware.id, -1)}
className="p-2 hover:bg-gray-50 rounded-l-lg transition-colors"
disabled={selection.quantity <= 1}
>
<Minus className="w-3.5 h-3.5" />
</button>
<span className="w-8 text-center font-semibold text-sm"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{selection.quantity}
</span>
<button
type="button"
onClick={() => handleQuantityChange(hardware.id, 1)}
className="p-2 hover:bg-gray-50 rounded-r-lg transition-colors"
>
<Plus className="w-3.5 h-3.5" />
</button>
</div>
<button
type="button"
onClick={() => onRemoveHardware(hardware.id)}
className="p-2 text-red-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors ml-auto"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleHardwareToggle(hardware.id, false)}
>
Add (Buy)
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleHardwareToggle(hardware.id, true)}
>
Add (Rent)
</Button>
</div>
)}
</div>
</div>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
{/* Info */}
<ExpandableInfo title="VoIP Features & Benefits">
<ul className="space-y-2.5">
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span>Unlimited local and long-distance calling</span>
</li>
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span>Mobile apps for iOS and Android - take calls anywhere</span>
</li>
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span>Auto-attendant and professional voicemail</span>
</li>
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span>Keep your existing phone numbers</span>
</li>
</ul>
</ExpandableInfo>
{/* Totals */}
<div className="space-y-3">
<div className="bg-gradient-dark text-white rounded-xl p-4 sm:p-5 flex items-center justify-between gap-3">
<span className="text-sm sm:text-base font-medium opacity-90">VoIP Monthly Total</span>
<span className="text-2xl sm:text-3xl font-bold whitespace-nowrap" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(getVoIPMonthly())}
<span className="text-xs sm:text-sm font-medium opacity-60 ml-1">/mo</span>
</span>
</div>
{getVoIPOneTime() > 0 && (
<div className="bg-[#f8f9fb] rounded-xl p-4 flex items-center justify-between border border-gray-200/80">
<span className="text-gray-500 font-medium">Hardware Purchase (One-Time)</span>
<span className="text-xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(getVoIPOneTime())}
</span>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{!voipSelection.enabled && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center py-12 text-gray-400"
>
<Phone className="w-12 h-12 mx-auto mb-3 opacity-20" />
<p className="text-sm">You can always add VoIP services later.</p>
</motion.div>
)}
</motion.div>
);
}

View File

@@ -1,432 +0,0 @@
import { motion, AnimatePresence } from 'framer-motion';
import { Check, Globe, Mail, Cloud, Server } from 'lucide-react';
import { Card, Button, Input } from '@/components/ui';
import { ExpandableInfo } from '@/components/pricing/ExpandableInfo';
import { webHostingTiers, emailTiers } from '@/lib/pricing-data';
import { formatCurrency } from '@/lib/utils';
import type {
WebHostingSelection,
WebHostingTierId,
EmailSelection,
EmailTierId,
EmailProvider,
} from '@/types/quote';
export interface Step5WebEmailProps {
webHostingSelection: WebHostingSelection;
emailSelection: EmailSelection;
onSetWebHostingEnabled: (enabled: boolean) => void;
onSetWebHostingTier: (tierId: WebHostingTierId) => void;
onSetEmailEnabled: (enabled: boolean) => void;
onSetEmailProvider: (provider: EmailProvider) => void;
onSetEmailTier: (tierId: EmailTierId) => void;
onSetMailboxCount: (count: number) => void;
getWebHostingMonthly: () => number;
getEmailMonthly: () => number;
}
export function Step5WebEmail({
webHostingSelection,
emailSelection,
onSetWebHostingEnabled,
onSetWebHostingTier,
onSetEmailEnabled,
onSetEmailProvider,
onSetEmailTier,
onSetMailboxCount,
getWebHostingMonthly,
getEmailMonthly,
}: Step5WebEmailProps) {
const whmTiers = emailTiers.filter((t) => t.provider === 'whm');
const m365Tiers = emailTiers.filter((t) => t.provider === 'm365');
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-8"
>
{/* Service Explainer */}
<div className="space-y-2">
<p className="text-sm sm:text-base text-gray-500 leading-relaxed">
<strong className="text-[#333d49]">Web hosting and email</strong> are often managed
separately from IT, but bundling them with your MSP means one point of contact for
everything. We handle the technical details &mdash; SSL certificates, backups, security
updates, DNS, and spam filtering &mdash; so you don't have to.
</p>
<p className="text-sm text-gray-400 leading-relaxed">
For email, choose between our budget-friendly self-hosted option (great for basic
email needs) or Microsoft 365, which includes Outlook, Teams, OneDrive, and the
full Office suite. Both options include professional yourname@yourcompany.com addresses
and spam protection.
</p>
</div>
{/* Web Hosting Section */}
<div className="space-y-4">
<div className="bg-[#f8f9fb] rounded-xl p-4 sm:p-5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-xl bg-[#fe7400]/8 flex items-center justify-center flex-shrink-0">
<Globe className="w-4 h-4 sm:w-5 sm:h-5 text-[#fe7400]" />
</div>
<div className="min-w-0">
<h3 className="font-bold text-[#333d49] text-sm sm:text-base"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Web Hosting
</h3>
<p className="text-xs sm:text-sm text-gray-400">
Managed WordPress hosting with SSL and backups
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer flex-shrink-0">
<input
type="checkbox"
checked={webHostingSelection.enabled}
onChange={(e) => onSetWebHostingEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-14 h-7 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#fe7400]/10 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-6 after:w-6 after:transition-all peer-checked:bg-[#fe7400]"></div>
<span className="ml-3 text-sm font-semibold text-gray-500"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{webHostingSelection.enabled ? 'Yes' : 'No'}
</span>
</label>
</div>
</div>
<AnimatePresence>
{webHostingSelection.enabled && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 sm:gap-4">
{webHostingTiers.map((tier, index) => {
const isSelected = webHostingSelection.tierId === tier.id;
return (
<motion.div
key={tier.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ y: -3 }}
>
<Card
variant={isSelected ? 'highlighted' : 'default'}
padding="none"
className={`relative overflow-hidden cursor-pointer h-full ${
tier.recommended && !isSelected ? 'ring-2 ring-[#fe7400]/30' : ''
}`}
onClick={() => onSetWebHostingTier(tier.id)}
>
{tier.recommended && (
<div className="bg-gradient-accent text-white text-[11px] font-bold px-3 py-1.5 text-center uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Popular
</div>
)}
<div className="p-4">
<h3 className="text-base font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{tier.name}
</h3>
<p className="text-xs text-gray-400 mb-3">{tier.description}</p>
<div className="mb-3">
<span className="text-2xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(tier.monthlyPrice)}
</span>
<span className="text-gray-400 text-sm">/mo</span>
</div>
<div className="flex gap-3 mb-3 text-xs text-gray-400 font-medium">
<span>{tier.storage}</span>
<span className="text-gray-300">|</span>
<span>{tier.sites === -1 ? 'Unlimited' : tier.sites} site{tier.sites !== 1 && 's'}</span>
</div>
<ul className="space-y-1.5 mb-4">
{tier.features.slice(0, 3).map((feature, idx) => (
<li key={idx} className="flex items-start gap-2 text-xs">
<div className="w-3.5 h-3.5 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2 h-2 text-[#fe7400]" strokeWidth={3} />
</div>
<span className="text-gray-500">{feature}</span>
</li>
))}
</ul>
<Button
variant={isSelected ? 'primary' : 'outline'}
className="w-full"
size="sm"
>
{isSelected ? 'Selected' : 'Select'}
</Button>
</div>
</Card>
</motion.div>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Divider */}
<div className="border-t border-gray-100" />
{/* Email Section */}
<div className="space-y-4">
<div className="bg-[#f8f9fb] rounded-xl p-4 sm:p-5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-xl bg-[#fe7400]/8 flex items-center justify-center flex-shrink-0">
<Mail className="w-4 h-4 sm:w-5 sm:h-5 text-[#fe7400]" />
</div>
<div className="min-w-0">
<h3 className="font-bold text-[#333d49] text-sm sm:text-base"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Email Service
</h3>
<p className="text-xs sm:text-sm text-gray-400">
Professional business email hosting
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer flex-shrink-0">
<input
type="checkbox"
checked={emailSelection.enabled}
onChange={(e) => onSetEmailEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-14 h-7 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#fe7400]/10 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-6 after:w-6 after:transition-all peer-checked:bg-[#fe7400]"></div>
<span className="ml-3 text-sm font-semibold text-gray-500"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{emailSelection.enabled ? 'Yes' : 'No'}
</span>
</label>
</div>
</div>
<AnimatePresence>
{emailSelection.enabled && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
className="space-y-4"
>
{/* Mailbox Count */}
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-4 border border-gray-200/80 rounded-xl bg-white shadow-card">
<label className="text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Number of mailboxes:
</label>
<Input
type="number"
min={1}
value={emailSelection.mailboxCount}
onChange={(e) => onSetMailboxCount(parseInt(e.target.value, 10) || 1)}
className="w-full sm:w-24"
/>
</div>
{/* Provider Selection */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
onClick={() => onSetEmailProvider('whm')}
className={`p-5 rounded-xl border-2 cursor-pointer transition-all duration-200 ${
emailSelection.provider === 'whm'
? 'border-[#fe7400] bg-[#fe7400]/5 shadow-sm'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-center gap-3 mb-2">
<Server className="w-5 h-5 text-[#fe7400]" />
<h4 className="font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Self-Hosted (WHM)
</h4>
</div>
<p className="text-sm text-gray-400">
Budget-friendly email hosting on our servers
</p>
</div>
<div
onClick={() => onSetEmailProvider('m365')}
className={`p-5 rounded-xl border-2 cursor-pointer transition-all duration-200 ${
emailSelection.provider === 'm365'
? 'border-[#fe7400] bg-[#fe7400]/5 shadow-sm'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-center gap-3 mb-2">
<Cloud className="w-5 h-5 text-[#fe7400]" />
<h4 className="font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Microsoft 365
</h4>
<span className="text-[11px] bg-gradient-accent text-white px-2 py-0.5 rounded-md font-bold uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Recommended
</span>
</div>
<p className="text-sm text-gray-400">
Full Microsoft suite with Teams, OneDrive, and Office apps
</p>
</div>
</div>
{/* Tier Selection based on Provider */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 sm:gap-4">
{(emailSelection.provider === 'whm' ? whmTiers : m365Tiers).map((tier, index) => {
const isSelected = emailSelection.tierId === tier.id;
const monthlyPrice = tier.pricePerMailbox * emailSelection.mailboxCount;
return (
<motion.div
key={tier.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ y: -3 }}
>
<Card
variant={isSelected ? 'highlighted' : 'default'}
padding="none"
className={`relative overflow-hidden cursor-pointer h-full ${
tier.recommended && !isSelected ? 'ring-2 ring-[#fe7400]/30' : ''
}`}
onClick={() => onSetEmailTier(tier.id)}
>
{tier.recommended && (
<div className="bg-gradient-accent text-white text-[11px] font-bold px-3 py-1.5 text-center uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Popular
</div>
)}
<div className="p-4">
<h3 className="text-base font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{tier.name}
</h3>
<p className="text-xs text-gray-400 mb-2">{tier.storage}</p>
<div className="mb-3">
<span className="text-xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(monthlyPrice)}
</span>
<span className="text-gray-400 text-xs">/mo</span>
<p className="text-xs text-gray-400">
{formatCurrency(tier.pricePerMailbox)}/mailbox
</p>
</div>
<ul className="space-y-1.5 mb-3">
{tier.features.slice(0, 3).map((feature, idx) => (
<li key={idx} className="flex items-start gap-2 text-xs">
<div className="w-3.5 h-3.5 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2 h-2 text-[#fe7400]" strokeWidth={3} />
</div>
<span className="text-gray-500">{feature}</span>
</li>
))}
</ul>
<Button
variant={isSelected ? 'primary' : 'outline'}
className="w-full"
size="sm"
>
{isSelected ? 'Selected' : 'Select'}
</Button>
</div>
</Card>
</motion.div>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Info */}
<ExpandableInfo title="WHM vs Microsoft 365 - Which should I choose?">
<div className="space-y-4">
<div>
<h5 className="font-semibold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Self-Hosted (WHM)
</h5>
<p className="text-sm text-gray-500 mt-1">
Best for budget-conscious businesses that just need reliable email.
Includes webmail access and standard email features.
</p>
</div>
<div>
<h5 className="font-semibold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Microsoft 365
</h5>
<p className="text-sm text-gray-500 mt-1">
Best for businesses that need collaboration tools. Includes Outlook,
Teams for video calls, OneDrive cloud storage, and the full Office
suite (Word, Excel, PowerPoint).
</p>
</div>
</div>
</ExpandableInfo>
{/* Totals */}
<div className="space-y-3">
{webHostingSelection.enabled && (
<div className="bg-[#f8f9fb] rounded-xl p-4 flex items-center justify-between border border-gray-200/80">
<span className="text-gray-500 font-medium">Web Hosting</span>
<span className="text-xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(getWebHostingMonthly())}
<span className="text-sm font-medium text-gray-400 ml-1">/mo</span>
</span>
</div>
)}
{emailSelection.enabled && (
<div className="bg-[#f8f9fb] rounded-xl p-4 flex items-center justify-between border border-gray-200/80">
<span className="text-gray-500 font-medium">Email Service</span>
<span className="text-xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(getEmailMonthly())}
<span className="text-sm font-medium text-gray-400 ml-1">/mo</span>
</span>
</div>
)}
{(webHostingSelection.enabled || emailSelection.enabled) && (
<div className="bg-gradient-dark text-white rounded-xl p-4 sm:p-5 flex items-center justify-between gap-3">
<span className="text-sm sm:text-base font-medium opacity-90">Web & Email Total</span>
<span className="text-2xl sm:text-3xl font-bold whitespace-nowrap" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(getWebHostingMonthly() + getEmailMonthly())}
<span className="text-xs sm:text-sm font-medium opacity-60 ml-1">/mo</span>
</span>
</div>
)}
</div>
</motion.div>
);
}

View File

@@ -1,373 +0,0 @@
import { motion } from 'framer-motion';
import { Edit2, Monitor, Headphones, Phone, Globe, Mail, Printer, DollarSign, ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui';
import {
gpsTiers,
supportPlans,
blockTimeOptions,
voipTiers,
webHostingTiers,
emailTiers,
} from '@/lib/pricing-data';
import { formatCurrency } from '@/lib/utils';
import type { QuoteData, QuoteResult } from '@/types/quote';
export interface Step6SummaryProps {
quoteData: QuoteData;
quoteResult: QuoteResult | null;
onGoToStep: (step: number) => void;
onCalculateQuote: () => QuoteResult;
}
export function Step6Summary({
quoteData,
quoteResult,
onGoToStep,
onCalculateQuote,
}: Step6SummaryProps) {
const result = quoteResult || onCalculateQuote();
const gpsTier = gpsTiers.find((t) => t.id === quoteData.gps.tierId);
const supportPlan = supportPlans.find((p) => p.id === quoteData.support.planId);
const blockTime = quoteData.support.useBlockTime && quoteData.support.blockTimeId
? blockTimeOptions.find((b) => b.id === quoteData.support.blockTimeId)
: null;
const voipTier = voipTiers.find((t) => t.id === quoteData.voip.tierId);
const webTier = webHostingTiers.find((t) => t.id === quoteData.webHosting.tierId);
const emailTier = emailTiers.find((t) => t.id === quoteData.email.tierId);
const handlePrint = () => {
// Brief delay to ensure print-only elements render
requestAnimationFrame(() => window.print());
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
{/* Print-only branded header */}
<div className="hidden print-show mb-6" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<div className="flex items-center justify-between pb-4 border-b-2 border-[#fe7400]">
<div>
<h1 className="text-2xl font-bold text-[#333d49]">Arizona Computer Guru</h1>
<p className="text-sm text-gray-400">Managed IT Services Quote</p>
</div>
<div className="text-right text-sm text-gray-400">
<p>{new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</p>
<p>Valid for 30 days</p>
</div>
</div>
</div>
{/* Header */}
<div className="text-center mb-8 print-hide">
<h2 className="text-2xl font-bold text-[#333d49] mb-2"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Your Quote Summary
</h2>
<p className="text-gray-400">Review your selections before submitting</p>
</div>
{/* Company Info */}
{quoteData.company.name && (
<div className="bg-[#f8f9fb] rounded-xl p-5 mb-6 border border-gray-200/50">
<p className="text-[11px] text-gray-400 mb-1 uppercase tracking-wider font-medium"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Quote prepared for
</p>
<p className="font-bold text-[#333d49] text-lg"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{quoteData.company.name}
</p>
{quoteData.company.industry && (
<p className="text-sm text-gray-400">{quoteData.company.industry}</p>
)}
</div>
)}
{/* GPS Monitoring Section - only show if enabled */}
{quoteData.serviceInterests.gps && (
<SummarySection
icon={<Monitor className="w-5 h-5" />}
title="GPS Monitoring"
monthlyTotal={result.gpsMonthly}
onEdit={() => onGoToStep(1)}
>
<div className="space-y-2">
<SummaryLine
label={`${gpsTier?.name} Plan (${quoteData.gps.endpointCount} endpoints)`}
value={formatCurrency(result.breakdown.gps.monitoring)}
/>
{quoteData.gps.includeEquipment && quoteData.gps.equipmentDeviceCount > 0 && (
<SummaryLine
label={`Equipment Pack (${quoteData.gps.equipmentDeviceCount} devices)`}
value={formatCurrency(result.breakdown.gps.equipment)}
/>
)}
</div>
</SummarySection>
)}
{/* Support Plan Section - only show if enabled */}
{quoteData.serviceInterests.support && (
<SummarySection
icon={<Headphones className="w-5 h-5" />}
title="Support Plan"
monthlyTotal={result.supportMonthly}
onEdit={() => onGoToStep(2)}
>
<div className="space-y-2">
{quoteData.support.planId === 'none' ? (
<SummaryLine
label="No Monthly Plan (pay-as-you-go)"
value="$0"
/>
) : (
<SummaryLine
label={`${supportPlan?.name} Plan (${supportPlan?.includedHours} hrs/mo)`}
value={formatCurrency(result.breakdown.support.plan)}
/>
)}
{blockTime && (
<SummaryLine
label={`Block Time (${blockTime.hours} hours) — one-time`}
value={formatCurrency(result.breakdown.support.blockTime)}
/>
)}
</div>
</SummarySection>
)}
{/* VoIP Section */}
{quoteData.voip.enabled && (
<SummarySection
icon={<Phone className="w-5 h-5" />}
title="VoIP Phone System"
monthlyTotal={result.voipMonthly}
onEdit={() => onGoToStep(3)}
>
<div className="space-y-2">
<SummaryLine
label={`${voipTier?.name} Plan (${quoteData.voip.userCount} users)`}
value={formatCurrency(result.breakdown.voip.service)}
/>
{result.breakdown.voip.hardware > 0 && (
<SummaryLine
label="Hardware Rental"
value={formatCurrency(result.breakdown.voip.hardware)}
/>
)}
</div>
</SummarySection>
)}
{/* Web Hosting Section */}
{quoteData.webHosting.enabled && (
<SummarySection
icon={<Globe className="w-5 h-5" />}
title="Web Hosting"
monthlyTotal={result.webHostingMonthly}
onEdit={() => onGoToStep(4)}
>
<SummaryLine
label={`${webTier?.name} Plan (${webTier?.storage}, ${webTier?.sites === -1 ? 'unlimited' : webTier?.sites} sites)`}
value={formatCurrency(result.webHostingMonthly)}
/>
</SummarySection>
)}
{/* Email Section */}
{quoteData.email.enabled && (
<SummarySection
icon={<Mail className="w-5 h-5" />}
title="Email Service"
monthlyTotal={result.emailMonthly}
onEdit={() => onGoToStep(4)}
>
<SummaryLine
label={`${emailTier?.name} (${quoteData.email.mailboxCount} mailboxes)`}
value={formatCurrency(result.emailMonthly)}
/>
</SummarySection>
)}
{/* Grand Total */}
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.3 }}
className="bg-gradient-navy text-white rounded-2xl p-6 sm:p-8 mt-8"
>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 mb-5">
<span className="text-base sm:text-lg font-medium text-white/80">Monthly Investment</span>
<span className="text-3xl sm:text-4xl font-bold" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(result.monthlyTotal)}
<span className="text-sm sm:text-base font-medium text-white/50 ml-1">/mo</span>
</span>
</div>
{result.oneTimeTotal > 0 && (
<div className="flex items-center justify-between py-4 border-t border-white/10">
<span className="text-white/60">One-Time Costs</span>
<span className="text-xl font-bold" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(result.oneTimeTotal)}
</span>
</div>
)}
<div className="pt-4 border-t border-white/10">
<div className="flex items-center justify-between text-sm text-white/50">
<span>Annual Investment</span>
<span className="font-medium">{formatCurrency(result.monthlyTotal * 12)}/year</span>
</div>
</div>
</motion.div>
{/* Breakdown Card */}
<div className="bg-white rounded-xl border border-gray-200/80 shadow-card p-5 sm:p-6">
<h4 className="font-bold text-[#333d49] mb-5 flex items-center gap-2"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<DollarSign className="w-5 h-5 text-[#fe7400]" />
Monthly Breakdown
</h4>
<div className="space-y-3">
{quoteData.serviceInterests.gps && (
<BreakdownRow label="GPS Monitoring" value={result.gpsMonthly} />
)}
{quoteData.serviceInterests.support && (
<BreakdownRow label="Support Plan" value={result.supportMonthly} />
)}
{quoteData.serviceInterests.voip && quoteData.voip.enabled && (
<BreakdownRow label="VoIP Phone System" value={result.voipMonthly} />
)}
{quoteData.serviceInterests.webHosting && quoteData.webHosting.enabled && (
<BreakdownRow label="Web Hosting" value={result.webHostingMonthly} />
)}
{quoteData.serviceInterests.email && quoteData.email.enabled && (
<BreakdownRow label="Email Service" value={result.emailMonthly} />
)}
<div className="pt-4 mt-1 border-t-2 border-[#fe7400]/20 flex justify-between items-center">
<span className="font-bold text-[#333d49] text-lg"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Total
</span>
<span className="font-bold text-[#fe7400] text-xl"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(result.monthlyTotal)}/mo
</span>
</div>
</div>
</div>
{/* Print Button */}
<div className="flex justify-center pt-2 print-hide">
<Button
variant="ghost"
onClick={handlePrint}
className="flex items-center gap-2 text-gray-400"
>
<Printer className="w-4 h-4" />
Print Quote
</Button>
</div>
{/* Notes Section */}
<div className="text-center text-xs text-gray-400 pt-2 space-y-1">
<p>This is an estimate. Final pricing may vary based on specific requirements.</p>
<p>Prices are subject to change. Quote valid for 30 days.</p>
</div>
{/* Print-only footer */}
<div className="hidden print-show mt-8 pt-4 border-t border-gray-200 text-center text-xs text-gray-400">
<p>Arizona Computer Guru &middot; azcomputerguru.com &middot; (480) 400-3798</p>
</div>
</motion.div>
);
}
// Helper Components
interface SummarySectionProps {
icon: React.ReactNode;
title: string;
monthlyTotal: number;
onEdit: () => void;
children: React.ReactNode;
}
function SummarySection({ icon, title, monthlyTotal, onEdit, children }: SummarySectionProps) {
return (
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="border border-gray-200/80 rounded-xl overflow-hidden bg-white shadow-card print-section"
>
<div className="bg-[#f8f9fb] px-4 sm:px-5 py-3 sm:py-3.5 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
<span className="text-[#fe7400] flex-shrink-0">{icon}</span>
<span className="font-bold text-[#333d49] text-sm sm:text-base truncate"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{title}
</span>
</div>
<div className="flex items-center gap-2 sm:gap-4 flex-shrink-0">
<span className="font-bold text-[#333d49] text-sm sm:text-base whitespace-nowrap"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(monthlyTotal)}
<span className="text-xs font-medium text-gray-400 ml-0.5">/mo</span>
</span>
<button
type="button"
onClick={onEdit}
className="flex items-center gap-1 sm:gap-1.5 text-xs sm:text-sm text-[#fe7400] hover:text-[#e56800] font-semibold transition-colors print-hide"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
<Edit2 className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
Edit
</button>
</div>
</div>
<div className="p-5">{children}</div>
</motion.div>
);
}
interface SummaryLineProps {
label: string;
value: string;
}
function SummaryLine({ label, value }: SummaryLineProps) {
return (
<div className="flex justify-between items-center text-sm">
<span className="text-gray-500 flex items-center gap-2">
<ArrowRight className="w-3 h-3 text-gray-300" />
{label}
</span>
<span className="font-semibold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{value}
</span>
</div>
);
}
interface BreakdownRowProps {
label: string;
value: number;
}
function BreakdownRow({ label, value }: BreakdownRowProps) {
return (
<div className="flex justify-between items-center py-1">
<span className="text-gray-500">{label}</span>
<span className="font-semibold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(value)}
</span>
</div>
);
}

View File

@@ -1,305 +0,0 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { User, Mail, Phone, MessageSquare, Shield, Clock, Sparkles } from 'lucide-react';
import { Input, Button } from '@/components/ui';
import { contactPreferences } from '@/lib/pricing-data';
import type { ContactInfo, ContactPreference, QuoteResult } from '@/types/quote';
import { formatCurrency } from '@/lib/utils';
export interface Step7ContactProps {
contactInfo: ContactInfo;
companyNameFromStep1: string;
quoteResult: QuoteResult | null;
onUpdateContact: (data: Partial<ContactInfo>) => void;
onSetContactPreference: (preference: ContactPreference) => void;
onSetAgreedToTerms: (agreed: boolean) => void;
onSubmit: () => void;
isSubmitting: boolean;
}
interface FormErrors {
name?: string;
email?: string;
agreedToTerms?: string;
}
export function Step7Contact({
contactInfo,
companyNameFromStep1,
quoteResult,
onUpdateContact,
onSetContactPreference,
onSetAgreedToTerms,
onSubmit,
isSubmitting,
}: Step7ContactProps) {
const [errors, setErrors] = useState<FormErrors>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
if (companyNameFromStep1 && !contactInfo.companyName) {
onUpdateContact({ companyName: companyNameFromStep1 });
}
const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
if (!contactInfo.name.trim()) {
newErrors.name = 'Name is required';
}
if (!contactInfo.email.trim()) {
newErrors.email = 'Email is required';
} else if (!validateEmail(contactInfo.email)) {
newErrors.email = 'Please enter a valid email address';
}
if (!contactInfo.agreedToTerms) {
newErrors.agreedToTerms = 'You must agree to the terms';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleBlur = (field: string) => {
setTouched((prev) => ({ ...prev, [field]: true }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (validateForm()) {
onSubmit();
} else {
setTouched({
name: true,
email: true,
agreedToTerms: true,
});
}
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="max-w-2xl mx-auto"
>
{/* Header */}
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-[#333d49] mb-2"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Get Your Quote
</h2>
<p className="text-gray-400">
We will send your customized quote and contact you to discuss next steps.
</p>
</div>
{/* Quote Preview */}
{quoteResult && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-gradient-navy rounded-xl p-4 sm:p-5 mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2"
>
<span className="text-sm sm:text-base text-white/80 font-medium">Your Estimated Monthly Total</span>
<span className="text-xl sm:text-2xl font-bold text-white"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(quoteResult.monthlyTotal)}
<span className="text-xs sm:text-sm font-medium text-white/50 ml-1">/mo</span>
</span>
</motion.div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Contact Name */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<User className="w-4 h-4 text-[#fe7400]" />
Contact Name
<span className="text-red-500 text-xs">*</span>
</label>
<Input
type="text"
value={contactInfo.name}
onChange={(e) => onUpdateContact({ name: e.target.value })}
onBlur={() => handleBlur('name')}
placeholder="Your full name"
error={touched.name ? errors.name : undefined}
/>
</div>
{/* Email */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<Mail className="w-4 h-4 text-[#fe7400]" />
Email Address
<span className="text-red-500 text-xs">*</span>
</label>
<Input
type="email"
value={contactInfo.email}
onChange={(e) => onUpdateContact({ email: e.target.value })}
onBlur={() => handleBlur('email')}
placeholder="you@company.com"
error={touched.email ? errors.email : undefined}
/>
</div>
{/* Phone */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<Phone className="w-4 h-4 text-[#fe7400]" />
Phone Number
<span className="text-gray-300 font-normal text-xs">(recommended)</span>
</label>
<Input
type="tel"
value={contactInfo.phone}
onChange={(e) => onUpdateContact({ phone: e.target.value })}
placeholder="(555) 123-4567"
/>
</div>
{/* Current IT Situation */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<MessageSquare className="w-4 h-4 text-[#fe7400]" />
Current IT Situation
<span className="text-gray-300 font-normal text-xs">(optional)</span>
</label>
<textarea
value={contactInfo.currentITSituation}
onChange={(e) => onUpdateContact({ currentITSituation: e.target.value })}
placeholder="Tell us about your current IT setup and any challenges you're facing..."
rows={3}
className="w-full px-4 py-3 rounded-xl border border-gray-200 bg-white text-[#333d49] placeholder-gray-400 hover:border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#fe7400]/15 focus:border-[#fe7400] transition-all duration-200 resize-none"
/>
</div>
{/* Contact Preference */}
<div className="space-y-3">
<label className="text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Preferred Contact Method
</label>
<div className="flex flex-wrap gap-4 sm:gap-5">
{contactPreferences.map((pref) => (
<label
key={pref.id}
className="flex items-center gap-2.5 cursor-pointer group"
>
<input
type="radio"
name="contactPreference"
value={pref.id}
checked={contactInfo.contactPreference === pref.id}
onChange={() => onSetContactPreference(pref.id as ContactPreference)}
className="w-4 h-4 text-[#fe7400] border-gray-300 focus:ring-[#fe7400]"
/>
<span className="text-sm text-gray-500 group-hover:text-gray-700 transition-colors">
{pref.label}
</span>
</label>
))}
</div>
</div>
{/* Terms Checkbox */}
<div className="space-y-2 pt-4">
<label className="flex items-start gap-3 cursor-pointer group">
<input
type="checkbox"
checked={contactInfo.agreedToTerms}
onChange={(e) => {
onSetAgreedToTerms(e.target.checked);
handleBlur('agreedToTerms');
}}
className="w-5 h-5 mt-0.5 text-[#fe7400] border-gray-300 rounded focus:ring-[#fe7400]"
/>
<span className="text-sm text-gray-500 leading-relaxed">
I agree to receive communications about my quote and understand that I can
unsubscribe at any time. I have read and agree to the{' '}
<a href="/privacy" className="text-[#fe7400] hover:text-[#e56800] font-medium transition-colors">
Privacy Policy
</a>{' '}
and{' '}
<a href="/terms" className="text-[#fe7400] hover:text-[#e56800] font-medium transition-colors">
Terms of Service
</a>
.
<span className="text-red-500 text-xs ml-0.5">*</span>
</span>
</label>
{touched.agreedToTerms && errors.agreedToTerms && (
<p className="text-sm text-red-500 ml-8">{errors.agreedToTerms}</p>
)}
</div>
{/* Submit Button */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="pt-6"
>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full text-base py-4"
isLoading={isSubmitting}
disabled={isSubmitting || !contactInfo.agreedToTerms}
>
{isSubmitting ? 'Submitting...' : (
<>
<Sparkles className="w-5 h-5 mr-2" />
Submit Quote Request
</>
)}
</Button>
</motion.div>
</form>
{/* Trust Indicators */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="mt-10 pt-6 border-t border-gray-100"
>
<div className="flex flex-col sm:flex-row sm:justify-between gap-4 sm:gap-5">
<div className="flex items-center gap-3 justify-center sm:justify-start">
<div className="w-8 h-8 rounded-lg bg-[#ecfdf5] flex items-center justify-center flex-shrink-0">
<Sparkles className="w-4 h-4 text-[#059669]" />
</div>
<span className="text-sm text-gray-500">No obligation quote</span>
</div>
<div className="flex items-center gap-3 justify-center">
<div className="w-8 h-8 rounded-lg bg-[#ecfdf5] flex items-center justify-center flex-shrink-0">
<Clock className="w-4 h-4 text-[#059669]" />
</div>
<span className="text-sm text-gray-500">Response within 24 hours</span>
</div>
<div className="flex items-center gap-3 justify-center sm:justify-end">
<div className="w-8 h-8 rounded-lg bg-[#ecfdf5] flex items-center justify-center flex-shrink-0">
<Shield className="w-4 h-4 text-[#059669]" />
</div>
<span className="text-sm text-gray-500">Your data is secure</span>
</div>
</div>
</motion.div>
</motion.div>
);
}

View File

@@ -1,286 +0,0 @@
import { motion, AnimatePresence } from 'framer-motion';
import {
Monitor,
Headphones,
Phone,
Globe,
Mail,
ShieldCheck,
ChevronRight,
} from 'lucide-react';
import type { ServiceInterests } from '@/types/quote';
export interface StepServiceDiscoveryProps {
serviceInterests: ServiceInterests;
onSetServiceInterest: (service: keyof ServiceInterests, enabled: boolean) => void;
}
interface ServiceCardDef {
key: keyof ServiceInterests;
icon: typeof Monitor;
title: string;
tagline: string;
description: string;
highlights: string[];
core?: boolean;
}
const serviceCards: ServiceCardDef[] = [
{
key: 'gps',
icon: Monitor,
title: 'Managed IT & Monitoring',
tagline: 'Core Service',
description:
"Our Guru Protection Suite provides 24/7 endpoint monitoring, automated patch management, antivirus, and proactive security — so issues get resolved before they impact your business.",
highlights: [
'Remote monitoring & management',
'Patch management & antivirus',
'Proactive security alerts',
],
core: true,
},
{
key: 'support',
icon: Headphones,
title: 'Help Desk & Support',
tagline: 'Labor Packages',
description:
"From pay-as-you-go to unlimited plans, our help desk gives you access to real technicians who know your environment. Remote support, on-site visits, and pre-purchased block time available.",
highlights: [
'Help desk & remote support',
'On-site technician visits',
'Pre-purchased block time savings',
],
},
{
key: 'voip',
icon: Phone,
title: 'VoIP Phone System',
tagline: 'Business Communications',
description:
"Modern cloud phone system with HD voice, video conferencing, mobile apps, and advanced call management. Hardware options from desk phones to wireless headsets.",
highlights: [
'Cloud-based phone system',
'Video conferencing & mobile app',
'Hardware rental or purchase',
],
},
{
key: 'webHosting',
icon: Globe,
title: 'Web Hosting',
tagline: 'Managed Hosting',
description:
"Secure, fast web hosting with free SSL certificates, automated backups, and staging environments. From a single site to unlimited — we manage the infrastructure so you don't have to.",
highlights: [
'Managed hosting with SSL & backups',
'Staging environments',
'Performance optimization & CDN',
],
},
{
key: 'email',
icon: Mail,
title: 'Email Services',
tagline: 'Business Email & Security',
description:
"Business email powered by Microsoft 365 or our hosted platform. Add advanced spam filtering, phishing simulations, security awareness training, and email archiving.",
highlights: [
'Microsoft 365 or hosted email',
'Advanced spam & phishing protection',
'Security training & compliance',
],
},
];
const stagger = {
hidden: {},
visible: { transition: { staggerChildren: 0.07 } },
};
const cardVariant = {
hidden: { opacity: 0, y: 16 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] as const },
},
};
export function StepServiceDiscovery({
serviceInterests,
onSetServiceInterest,
}: StepServiceDiscoveryProps) {
const selectedCount = Object.values(serviceInterests).filter(Boolean).length;
return (
<motion.div
variants={stagger}
initial="hidden"
animate="visible"
className="space-y-8"
>
{/* Header */}
<motion.div variants={cardVariant} className="text-center max-w-2xl mx-auto">
<h2
className="text-2xl sm:text-3xl font-bold text-[#333d49] mb-2"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
What services interest you?
</h2>
<p className="text-gray-400 text-sm sm:text-base leading-relaxed">
Toggle the services you&rsquo;d like to explore. We&rsquo;ll customize the rest of
your experience based on your selections.
</p>
</motion.div>
{/* Service cards */}
<motion.div variants={stagger} className="space-y-3">
{serviceCards.map((card) => {
const isActive = serviceInterests[card.key];
return (
<motion.div
key={card.key}
variants={cardVariant}
layout
className={`
relative rounded-2xl border-2 transition-all duration-300 overflow-hidden
${isActive
? 'border-[#fe7400]/30 bg-white shadow-[0_2px_12px_rgba(254,116,0,0.08)]'
: 'border-gray-200/60 bg-white/60 hover:border-gray-300'
}
`}
>
{/* Card header — always visible, acts as toggle */}
<button
type="button"
onClick={() => {
if (!card.core) {
onSetServiceInterest(card.key, !isActive);
}
}}
className={`
w-full flex items-center gap-4 px-5 py-4 sm:px-6 sm:py-5 text-left
${card.core ? 'cursor-default' : 'cursor-pointer'}
`}
>
{/* Icon */}
<div
className={`
flex items-center justify-center w-10 h-10 sm:w-11 sm:h-11 rounded-xl flex-shrink-0
transition-colors duration-300
${isActive ? 'bg-[#fe7400]/10' : 'bg-gray-100'}
`}
>
<card.icon
className={`
w-5 h-5 transition-colors duration-300
${isActive ? 'text-[#fe7400]' : 'text-gray-400'}
`}
/>
</div>
{/* Title & tagline */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3
className={`
text-base sm:text-lg font-bold transition-colors duration-300
${isActive ? 'text-[#333d49]' : 'text-gray-400'}
`}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
{card.title}
</h3>
{card.core && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-[#fe7400]/10 text-[#fe7400] text-[10px] font-bold uppercase tracking-wide">
<ShieldCheck className="w-3 h-3" />
Core
</span>
)}
</div>
<p className="text-xs text-gray-400 mt-0.5">{card.tagline}</p>
</div>
{/* Toggle switch */}
<div className="flex-shrink-0">
{card.core ? (
<div className="flex items-center gap-1.5 text-xs font-medium text-[#059669]">
<ShieldCheck className="w-3.5 h-3.5" />
Included
</div>
) : (
<div
className={`
relative w-12 h-7 rounded-full transition-colors duration-300
${isActive ? 'bg-[#fe7400]' : 'bg-gray-200'}
`}
>
<motion.div
className="absolute top-0.5 w-6 h-6 rounded-full bg-white shadow-sm"
animate={{ left: isActive ? '22px' : '2px' }}
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
/>
</div>
)}
</div>
</button>
{/* Expanded detail — shows when active */}
<AnimatePresence>
{isActive && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: [0.25, 0.46, 0.45, 0.94] }}
className="overflow-hidden"
>
<div className="px-5 pb-5 sm:px-6 sm:pb-6 pt-0">
<div className="pl-14 sm:pl-[60px]">
{/* Subtle separator */}
<div className="w-12 h-[2px] bg-[#fe7400]/20 rounded-full mb-3" />
<p className="text-sm text-gray-500 leading-relaxed mb-3">
{card.description}
</p>
<ul className="space-y-1.5">
{card.highlights.map((h) => (
<li key={h} className="flex items-center gap-2 text-sm text-gray-500">
<ChevronRight className="w-3 h-3 text-[#fe7400] flex-shrink-0" />
{h}
</li>
))}
</ul>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
})}
</motion.div>
{/* Selection summary */}
<motion.div
variants={cardVariant}
className="text-center pt-2"
>
<p className="text-sm text-gray-400">
<span className="font-semibold text-[#fe7400]">{selectedCount}</span>
{selectedCount === 1 ? ' service' : ' services'} selected
{selectedCount > 0 && (
<span className="text-gray-300 mx-1.5">&middot;</span>
)}
{selectedCount > 0 && (
<span>Click Continue to configure each one</span>
)}
</p>
</motion.div>
</motion.div>
);
}

View File

@@ -1,350 +0,0 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Building2,
User,
Monitor,
Headphones,
ArrowRight,
Shield,
Clock,
Sparkles,
} from 'lucide-react';
import type {
ClientType,
CompanyInfo,
ContactInfo,
Industry,
} from '@/types/quote';
export interface StepWelcomeProps {
clientType: ClientType;
companyInfo: CompanyInfo;
contactInfo: ContactInfo;
onSetClientType: (type: ClientType) => void;
onUpdateCompany: (data: Partial<CompanyInfo>) => void;
onUpdateContact: (data: Partial<ContactInfo>) => void;
onSetEndpointCount: (count: number) => void;
onSetIndustry: (industry: Industry | '') => void;
}
const industries: Industry[] = [
'Healthcare',
'Legal',
'Finance',
'Manufacturing',
'Retail',
'Professional Services',
'Other',
];
const journeySteps = [
{
icon: Sparkles,
title: 'Tell us about yourself',
desc: 'Basic info so we can personalize your experience',
},
{
icon: Monitor,
title: 'Choose your services',
desc: 'Toggle the IT services that interest you',
},
{
icon: Headphones,
title: 'Configure each service',
desc: "We'll walk through your selections one by one",
},
{
icon: ArrowRight,
title: 'Review & submit',
desc: 'Get your custom quote delivered instantly',
},
];
const stagger = {
hidden: {},
visible: { transition: { staggerChildren: 0.06 } },
};
const fadeUp = {
hidden: { opacity: 0, y: 12 },
visible: { opacity: 1, y: 0, transition: { duration: 0.35, ease: [0.25, 0.46, 0.45, 0.94] as const } },
};
export function StepWelcome({
clientType,
companyInfo,
contactInfo,
onSetClientType,
onUpdateCompany,
onUpdateContact,
onSetEndpointCount,
onSetIndustry,
}: StepWelcomeProps) {
const [endpointInput, setEndpointInput] = useState(String(companyInfo.endpointCount));
const handleEndpointChange = (val: string) => {
setEndpointInput(val);
const num = parseInt(val, 10);
if (!isNaN(num) && num >= 1) {
onSetEndpointCount(num);
}
};
return (
<motion.div
variants={stagger}
initial="hidden"
animate="visible"
className="space-y-10"
>
{/* Hero welcome */}
<motion.div variants={fadeUp} className="text-center max-w-2xl mx-auto">
<h2
className="text-3xl sm:text-4xl font-bold text-[#333d49] mb-3 leading-tight"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
Let&rsquo;s Build Your
<span className="text-[#fe7400]"> IT Solution</span>
</h2>
<p className="text-gray-400 text-base sm:text-lg leading-relaxed max-w-lg mx-auto">
In just a few minutes, we&rsquo;ll create a custom technology package
tailored to your needs. No commitment required.
</p>
</motion.div>
{/* What to expect */}
<motion.div variants={fadeUp}>
<div className="bg-gradient-to-br from-[#f8f9fb] to-[#f1f3f5] rounded-2xl p-5 sm:p-6">
<p
className="text-[11px] uppercase tracking-widest font-semibold text-gray-400 mb-4"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
What to expect
</p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4">
{journeySteps.map((step, i) => (
<div key={i} className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span
className="flex items-center justify-center w-6 h-6 rounded-full bg-[#fe7400]/10 text-[#fe7400] text-[10px] font-bold flex-shrink-0"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
{i + 1}
</span>
<step.icon className="w-3.5 h-3.5 text-gray-400" />
</div>
<p
className="text-sm font-semibold text-[#333d49] leading-snug"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
{step.title}
</p>
<p className="text-xs text-gray-400 leading-relaxed">{step.desc}</p>
</div>
))}
</div>
</div>
</motion.div>
{/* Client type toggle */}
<motion.div variants={fadeUp}>
<label
className="block text-sm font-semibold text-[#333d49] mb-3"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
I&rsquo;m looking for IT services for&hellip;
</label>
<div className="inline-flex bg-[#f1f3f5] rounded-xl p-1 gap-1">
{(['company', 'individual'] as const).map((type) => (
<button
key={type}
type="button"
onClick={() => onSetClientType(type)}
className={`
relative flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-semibold transition-all duration-200
${clientType === type
? 'bg-white text-[#333d49] shadow-sm'
: 'text-gray-400 hover:text-gray-500'
}
`}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
{type === 'company' ? (
<Building2 className="w-4 h-4" />
) : (
<User className="w-4 h-4" />
)}
{type === 'company' ? 'A Business' : 'Myself'}
</button>
))}
</div>
</motion.div>
{/* Contact & company info form */}
<motion.div variants={fadeUp} className="space-y-6">
{/* Contact info */}
<div>
<p
className="text-[11px] uppercase tracking-widest font-semibold text-gray-400 mb-4"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
Your contact information
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[#333d49] mb-1.5">
Your Name <span className="text-[#fe7400]">*</span>
</label>
<input
type="text"
value={contactInfo.name}
onChange={(e) => onUpdateContact({ name: e.target.value })}
placeholder="First and last name"
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 bg-white text-[#333d49] text-sm
placeholder:text-gray-300 focus:border-[#fe7400] focus:ring-2 focus:ring-[#fe7400]/10
transition-all duration-200 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#333d49] mb-1.5">
Email <span className="text-[#fe7400]">*</span>
</label>
<input
type="email"
value={contactInfo.email}
onChange={(e) => onUpdateContact({ email: e.target.value })}
placeholder="you@company.com"
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 bg-white text-[#333d49] text-sm
placeholder:text-gray-300 focus:border-[#fe7400] focus:ring-2 focus:ring-[#fe7400]/10
transition-all duration-200 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#333d49] mb-1.5">
Phone <span className="text-gray-300 text-xs font-normal">(recommended)</span>
</label>
<input
type="tel"
value={contactInfo.phone}
onChange={(e) => onUpdateContact({ phone: e.target.value })}
placeholder="(480) 555-0100"
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 bg-white text-[#333d49] text-sm
placeholder:text-gray-300 focus:border-[#fe7400] focus:ring-2 focus:ring-[#fe7400]/10
transition-all duration-200 outline-none"
/>
</div>
{/* Company name — only for business clients */}
<AnimatePresence mode="wait">
{clientType === 'company' && (
<motion.div
key="company-name"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
>
<label className="block text-sm font-medium text-[#333d49] mb-1.5">
Company Name
</label>
<input
type="text"
value={companyInfo.name}
onChange={(e) => onUpdateCompany({ name: e.target.value })}
placeholder="Acme Corp"
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 bg-white text-[#333d49] text-sm
placeholder:text-gray-300 focus:border-[#fe7400] focus:ring-2 focus:ring-[#fe7400]/10
transition-all duration-200 outline-none"
/>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Business details */}
<div>
<p
className="text-[11px] uppercase tracking-widest font-semibold text-gray-400 mb-4"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
About your environment
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[#333d49] mb-1.5">
Devices / Endpoints <span className="text-[#fe7400]">*</span>
</label>
<div className="flex items-center gap-3">
<input
type="number"
min={1}
value={endpointInput}
onChange={(e) => handleEndpointChange(e.target.value)}
onBlur={() => {
const num = parseInt(endpointInput, 10);
if (isNaN(num) || num < 1) {
setEndpointInput('1');
onSetEndpointCount(1);
}
}}
className="w-24 px-4 py-2.5 rounded-xl border border-gray-200 bg-white text-[#333d49] text-sm text-center
focus:border-[#fe7400] focus:ring-2 focus:ring-[#fe7400]/10
transition-all duration-200 outline-none"
/>
<span className="text-sm text-gray-400">
computers, laptops, & servers
</span>
</div>
</div>
<AnimatePresence mode="wait">
{clientType === 'company' && (
<motion.div
key="industry"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
>
<label className="block text-sm font-medium text-[#333d49] mb-1.5">
Industry
</label>
<select
value={companyInfo.industry}
onChange={(e) => onSetIndustry(e.target.value as Industry | '')}
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 bg-white text-[#333d49] text-sm
focus:border-[#fe7400] focus:ring-2 focus:ring-[#fe7400]/10
transition-all duration-200 outline-none appearance-none cursor-pointer"
>
<option value="">Select an industry</option>
{industries.map((ind) => (
<option key={ind} value={ind}>
{ind}
</option>
))}
</select>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</motion.div>
{/* Trust signals */}
<motion.div variants={fadeUp} className="flex flex-wrap items-center justify-center gap-6 pt-2">
{[
{ icon: Shield, text: 'No obligation' },
{ icon: Clock, text: 'Takes ~2 minutes' },
{ icon: Sparkles, text: 'Instant quote' },
].map(({ icon: Icon, text }) => (
<span key={text} className="flex items-center gap-1.5 text-xs text-gray-400">
<Icon className="w-3.5 h-3.5" />
{text}
</span>
))}
</motion.div>
</motion.div>
);
}

View File

@@ -1,9 +0,0 @@
export { StepWelcome, type StepWelcomeProps } from './StepWelcome';
export { StepServiceDiscovery, type StepServiceDiscoveryProps } from './StepServiceDiscovery';
export { Step1CompanyProfile, type Step1CompanyProfileProps } from './Step1CompanyProfile';
export { Step2GPSMonitoring, type Step2GPSMonitoringProps } from './Step2GPSMonitoring';
export { Step3SupportPlan, type Step3SupportPlanProps } from './Step3SupportPlan';
export { Step4VoIP, type Step4VoIPProps } from './Step4VoIP';
export { Step5WebEmail, type Step5WebEmailProps } from './Step5WebEmail';
export { Step6Summary, type Step6SummaryProps } from './Step6Summary';
export { Step7Contact, type Step7ContactProps } from './Step7Contact';

View File

@@ -1,734 +0,0 @@
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import type {
QuoteData,
QuoteResult,
QuoteBreakdown,
CompanyInfo,
GPSSelection,
SupportSelection,
VoIPSelection,
WebHostingSelection,
EmailSelection,
ContactInfo,
GPSTierId,
SupportPlanId,
BlockTimeId,
VoIPTierId,
WebHostingTierId,
EmailTierId,
EmailProvider,
Industry,
ContactPreference,
ClientType,
ServiceInterests,
} from '@/types/quote';
import {
gpsTiers,
equipmentMonitoring,
supportPlans,
blockTimeOptions,
voipTiers,
voipHardware,
webHostingTiers,
emailTiers,
} from '@/lib/pricing-data';
const DRAFT_STORAGE_KEY = 'quote-wizard-draft';
/**
* Load saved draft from localStorage if available.
* Returns partial state keyed by section, or null if nothing saved.
*/
function loadDraft(): {
clientType?: ClientType;
serviceInterests?: ServiceInterests;
company?: CompanyInfo;
gps?: GPSSelection;
support?: SupportSelection;
voip?: VoIPSelection;
webHosting?: WebHostingSelection;
email?: EmailSelection;
contact?: ContactInfo;
accessToken?: string;
} | null {
try {
const raw = localStorage.getItem(DRAFT_STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
/**
* Initial state values
*/
const initialClientType: ClientType = 'company';
const initialServiceInterests: ServiceInterests = {
gps: true,
support: true,
voip: false,
webHosting: false,
email: false,
};
const initialCompanyInfo: CompanyInfo = {
name: '',
endpointCount: 10,
industry: '',
notes: '',
};
const initialGPSSelection: GPSSelection = {
tierId: 'pro',
endpointCount: 10,
includeEquipment: false,
equipmentDeviceCount: 0,
};
const initialSupportSelection: SupportSelection = {
planId: 'standard',
useBlockTime: false,
blockTimeId: null,
};
const initialVoIPSelection: VoIPSelection = {
enabled: false,
tierId: 'voip-standard',
userCount: 0,
hardware: [],
};
const initialWebHostingSelection: WebHostingSelection = {
enabled: false,
tierId: 'hosting-business',
};
const initialEmailSelection: EmailSelection = {
enabled: false,
provider: 'm365',
tierId: 'm365-standard',
mailboxCount: 0,
};
const initialContactInfo: ContactInfo = {
name: '',
email: '',
phone: '',
companyName: '',
currentITSituation: '',
contactPreference: 'email',
agreedToTerms: false,
};
/**
* Hook return type
*/
export interface UseQuoteReturn {
quoteData: QuoteData;
quoteResult: QuoteResult | null;
// Client type & service interests
setClientType: (type: ClientType) => void;
setServiceInterest: (service: keyof ServiceInterests, enabled: boolean) => void;
// Company updates
updateCompany: (data: Partial<CompanyInfo>) => void;
setEndpointCount: (count: number) => void;
setIndustry: (industry: Industry | '') => void;
// GPS updates
updateGPS: (data: Partial<GPSSelection>) => void;
setGPSTier: (tierId: GPSTierId) => void;
setEquipmentEnabled: (enabled: boolean) => void;
setEquipmentCount: (count: number) => void;
// Support updates
updateSupport: (data: Partial<SupportSelection>) => void;
setSupportPlan: (planId: SupportPlanId) => void;
setBlockTimeEnabled: (enabled: boolean) => void;
setBlockTime: (blockTimeId: BlockTimeId) => void;
// VoIP updates
updateVoIP: (data: Partial<VoIPSelection>) => void;
setVoIPEnabled: (enabled: boolean) => void;
setVoIPTier: (tierId: VoIPTierId) => void;
setVoIPUserCount: (count: number) => void;
addHardware: (hardwareId: string, quantity: number, isRental: boolean) => void;
removeHardware: (hardwareId: string) => void;
updateHardwareQuantity: (hardwareId: string, quantity: number) => void;
// Web Hosting updates
updateWebHosting: (data: Partial<WebHostingSelection>) => void;
setWebHostingEnabled: (enabled: boolean) => void;
setWebHostingTier: (tierId: WebHostingTierId) => void;
// Email updates
updateEmail: (data: Partial<EmailSelection>) => void;
setEmailEnabled: (enabled: boolean) => void;
setEmailProvider: (provider: EmailProvider) => void;
setEmailTier: (tierId: EmailTierId) => void;
setMailboxCount: (count: number) => void;
// Contact updates
updateContact: (data: Partial<ContactInfo>) => void;
setContactPreference: (preference: ContactPreference) => void;
setAgreedToTerms: (agreed: boolean) => void;
// Calculations
calculateQuote: () => QuoteResult;
getGPSMonthly: () => number;
getSupportMonthly: () => number;
getVoIPMonthly: () => number;
getWebHostingMonthly: () => number;
getEmailMonthly: () => number;
getSupportBlockTimeOneTime: () => number;
getVoIPOneTime: () => number;
// Reset
resetQuote: () => void;
}
/**
* Quote calculation and state management hook
*/
export function useQuote(): UseQuoteReturn {
const draft = useRef(loadDraft());
const [clientType, setClientType] = useState<ClientType>(draft.current?.clientType ?? initialClientType);
const [serviceInterests, setServiceInterests] = useState<ServiceInterests>(draft.current?.serviceInterests ?? initialServiceInterests);
const [company, setCompany] = useState<CompanyInfo>(draft.current?.company ?? initialCompanyInfo);
const [gps, setGPS] = useState<GPSSelection>(draft.current?.gps ?? initialGPSSelection);
const [support, setSupport] = useState<SupportSelection>(draft.current?.support ?? initialSupportSelection);
const [voip, setVoIP] = useState<VoIPSelection>(draft.current?.voip ?? initialVoIPSelection);
const [webHosting, setWebHosting] = useState<WebHostingSelection>(draft.current?.webHosting ?? initialWebHostingSelection);
const [email, setEmail] = useState<EmailSelection>(draft.current?.email ?? initialEmailSelection);
const [contact, setContact] = useState<ContactInfo>(draft.current?.contact ?? initialContactInfo);
const [quoteResult, setQuoteResult] = useState<QuoteResult | null>(null);
// Persist draft to localStorage when any section changes (debounced)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
saveTimerRef.current = setTimeout(() => {
try {
// Preserve the accessToken that WizardContainer may have written
const existing = localStorage.getItem(DRAFT_STORAGE_KEY);
let accessToken: string | undefined;
if (existing) {
try {
accessToken = JSON.parse(existing).accessToken;
} catch {
// ignore
}
}
const payload = {
clientType,
serviceInterests,
company,
gps,
support,
voip,
webHosting,
email,
contact,
...(accessToken ? { accessToken } : {}),
};
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(payload));
} catch {
// localStorage write failures are non-critical
}
}, 500);
return () => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
};
}, [clientType, serviceInterests, company, gps, support, voip, webHosting, email, contact]);
// Combined quote data
const quoteData: QuoteData = useMemo(
() => ({
clientType,
serviceInterests,
company,
gps,
support,
voip,
webHosting,
email,
contact,
}),
[clientType, serviceInterests, company, gps, support, voip, webHosting, email, contact]
);
// ============================================================================
// Client Type & Service Interests
// ============================================================================
const setClientTypeValue = useCallback((type: ClientType) => {
setClientType(type);
}, []);
const setServiceInterest = useCallback((service: keyof ServiceInterests, enabled: boolean) => {
setServiceInterests((prev) => ({ ...prev, [service]: enabled }));
// Sync the enabled flags on the corresponding selections
if (service === 'voip') {
setVoIP((prev) => ({ ...prev, enabled, userCount: enabled ? Math.max(prev.userCount, 1) : 0 }));
}
if (service === 'webHosting') {
setWebHosting((prev) => ({ ...prev, enabled }));
}
if (service === 'email') {
setEmail((prev) => ({ ...prev, enabled, mailboxCount: enabled ? Math.max(prev.mailboxCount, 1) : 0 }));
}
}, []);
// ============================================================================
// Company Updates
// ============================================================================
const updateCompany = useCallback((data: Partial<CompanyInfo>) => {
setCompany((prev) => {
const updated = { ...prev, ...data };
// Sync endpoint count with GPS selection
if (data.endpointCount !== undefined) {
setGPS((gpsState) => ({ ...gpsState, endpointCount: data.endpointCount as number }));
}
return updated;
});
}, []);
const setEndpointCount = useCallback((count: number) => {
const validCount = Math.max(1, count);
setCompany((prev) => ({ ...prev, endpointCount: validCount }));
setGPS((prev) => ({ ...prev, endpointCount: validCount }));
}, []);
const setIndustry = useCallback((industry: Industry | '') => {
setCompany((prev) => ({ ...prev, industry }));
}, []);
// ============================================================================
// GPS Updates
// ============================================================================
const updateGPS = useCallback((data: Partial<GPSSelection>) => {
setGPS((prev) => ({ ...prev, ...data }));
}, []);
const setGPSTier = useCallback((tierId: GPSTierId) => {
setGPS((prev) => ({ ...prev, tierId }));
}, []);
const setEquipmentEnabled = useCallback((enabled: boolean) => {
setGPS((prev) => ({
...prev,
includeEquipment: enabled,
equipmentDeviceCount: enabled ? Math.max(prev.equipmentDeviceCount, 1) : 0,
}));
}, []);
const setEquipmentCount = useCallback((count: number) => {
setGPS((prev) => ({ ...prev, equipmentDeviceCount: Math.max(0, count) }));
}, []);
// ============================================================================
// Support Updates
// ============================================================================
const updateSupport = useCallback((data: Partial<SupportSelection>) => {
setSupport((prev) => ({ ...prev, ...data }));
}, []);
const setSupportPlan = useCallback((planId: SupportPlanId) => {
setSupport((prev) => ({ ...prev, planId }));
}, []);
const setBlockTimeEnabled = useCallback((enabled: boolean) => {
setSupport((prev) => ({
...prev,
useBlockTime: enabled,
blockTimeId: enabled ? (prev.blockTimeId || 'block-10') : null,
}));
}, []);
const setBlockTime = useCallback((blockTimeId: BlockTimeId) => {
setSupport((prev) => ({ ...prev, blockTimeId, useBlockTime: true }));
}, []);
// ============================================================================
// VoIP Updates
// ============================================================================
const updateVoIP = useCallback((data: Partial<VoIPSelection>) => {
setVoIP((prev) => ({ ...prev, ...data }));
}, []);
const setVoIPEnabled = useCallback((enabled: boolean) => {
setVoIP((prev) => ({
...prev,
enabled,
userCount: enabled ? Math.max(prev.userCount, 1) : 0,
}));
}, []);
const setVoIPTier = useCallback((tierId: VoIPTierId) => {
setVoIP((prev) => ({ ...prev, tierId }));
}, []);
const setVoIPUserCount = useCallback((count: number) => {
setVoIP((prev) => ({ ...prev, userCount: Math.max(0, count) }));
}, []);
const addHardware = useCallback((hardwareId: string, quantity: number, isRental: boolean) => {
setVoIP((prev) => {
const existing = prev.hardware.find((h) => h.hardwareId === hardwareId);
if (existing) {
return {
...prev,
hardware: prev.hardware.map((h) =>
h.hardwareId === hardwareId ? { ...h, quantity, isRental } : h
),
};
}
return {
...prev,
hardware: [...prev.hardware, { hardwareId, quantity, isRental }],
};
});
}, []);
const removeHardware = useCallback((hardwareId: string) => {
setVoIP((prev) => ({
...prev,
hardware: prev.hardware.filter((h) => h.hardwareId !== hardwareId),
}));
}, []);
const updateHardwareQuantity = useCallback((hardwareId: string, quantity: number) => {
setVoIP((prev) => ({
...prev,
hardware: prev.hardware.map((h) =>
h.hardwareId === hardwareId ? { ...h, quantity: Math.max(0, quantity) } : h
),
}));
}, []);
// ============================================================================
// Web Hosting Updates
// ============================================================================
const updateWebHosting = useCallback((data: Partial<WebHostingSelection>) => {
setWebHosting((prev) => ({ ...prev, ...data }));
}, []);
const setWebHostingEnabled = useCallback((enabled: boolean) => {
setWebHosting((prev) => ({ ...prev, enabled }));
}, []);
const setWebHostingTier = useCallback((tierId: WebHostingTierId) => {
setWebHosting((prev) => ({ ...prev, tierId }));
}, []);
// ============================================================================
// Email Updates
// ============================================================================
const updateEmail = useCallback((data: Partial<EmailSelection>) => {
setEmail((prev) => ({ ...prev, ...data }));
}, []);
const setEmailEnabled = useCallback((enabled: boolean) => {
setEmail((prev) => ({
...prev,
enabled,
mailboxCount: enabled ? Math.max(prev.mailboxCount, 1) : 0,
}));
}, []);
const setEmailProvider = useCallback((provider: EmailProvider) => {
setEmail((prev) => {
// Set default tier for provider
const defaultTier = provider === 'm365' ? 'm365-standard' : 'whm-standard';
return { ...prev, provider, tierId: defaultTier as EmailTierId };
});
}, []);
const setEmailTier = useCallback((tierId: EmailTierId) => {
setEmail((prev) => ({ ...prev, tierId }));
}, []);
const setMailboxCount = useCallback((count: number) => {
setEmail((prev) => ({ ...prev, mailboxCount: Math.max(0, count) }));
}, []);
// ============================================================================
// Contact Updates
// ============================================================================
const updateContact = useCallback((data: Partial<ContactInfo>) => {
setContact((prev) => ({ ...prev, ...data }));
}, []);
const setContactPreference = useCallback((preference: ContactPreference) => {
setContact((prev) => ({ ...prev, contactPreference: preference }));
}, []);
const setAgreedToTerms = useCallback((agreed: boolean) => {
setContact((prev) => ({ ...prev, agreedToTerms: agreed }));
}, []);
// ============================================================================
// Calculation Functions
// ============================================================================
const getGPSMonthly = useCallback((): number => {
const tier = gpsTiers.find((t) => t.id === gps.tierId);
if (!tier) return 0;
let total = tier.pricePerEndpoint * gps.endpointCount;
if (gps.includeEquipment && gps.equipmentDeviceCount > 0) {
const additionalDevices = Math.max(0, gps.equipmentDeviceCount - equipmentMonitoring.baseDevices);
total += equipmentMonitoring.basePrice + (additionalDevices * equipmentMonitoring.additionalDevicePrice);
}
return total;
}, [gps]);
const getSupportMonthly = useCallback((): number => {
if (support.planId === 'none') return 0;
const plan = supportPlans.find((p) => p.id === support.planId);
return plan ? plan.monthlyPrice : 0;
}, [support]);
const getSupportBlockTimeOneTime = useCallback((): number => {
if (!support.useBlockTime || !support.blockTimeId) return 0;
const blockTime = blockTimeOptions.find((b) => b.id === support.blockTimeId);
return blockTime ? blockTime.price : 0;
}, [support]);
const getVoIPMonthly = useCallback((): number => {
if (!voip.enabled) return 0;
const tier = voipTiers.find((t) => t.id === voip.tierId);
if (!tier) return 0;
let total = tier.pricePerUser * voip.userCount;
// Add rental hardware costs
voip.hardware.forEach((hw) => {
if (hw.isRental) {
const hardware = voipHardware.find((h) => h.id === hw.hardwareId);
if (hardware) {
total += hardware.monthlyRental * hw.quantity;
}
}
});
return total;
}, [voip]);
const getVoIPOneTime = useCallback((): number => {
if (!voip.enabled) return 0;
let total = 0;
// Add purchased hardware costs
voip.hardware.forEach((hw) => {
if (!hw.isRental) {
const hardware = voipHardware.find((h) => h.id === hw.hardwareId);
if (hardware) {
total += hardware.oneTimePrice * hw.quantity;
}
}
});
return total;
}, [voip]);
const getWebHostingMonthly = useCallback((): number => {
if (!webHosting.enabled) return 0;
const tier = webHostingTiers.find((t) => t.id === webHosting.tierId);
return tier ? tier.monthlyPrice : 0;
}, [webHosting]);
const getEmailMonthly = useCallback((): number => {
if (!email.enabled) return 0;
const tier = emailTiers.find((t) => t.id === email.tierId);
return tier ? tier.pricePerMailbox * email.mailboxCount : 0;
}, [email]);
const calculateQuote = useCallback((): QuoteResult => {
// Only include services that are enabled in serviceInterests
const gpsMonthly = serviceInterests.gps ? getGPSMonthly() : 0;
const supportMonthly = serviceInterests.support ? getSupportMonthly() : 0;
const supportBlockTimeOneTime = serviceInterests.support ? getSupportBlockTimeOneTime() : 0;
const voipMonthly = serviceInterests.voip ? getVoIPMonthly() : 0;
const voipOneTime = serviceInterests.voip ? getVoIPOneTime() : 0;
const webHostingMonthly = serviceInterests.webHosting ? getWebHostingMonthly() : 0;
const emailMonthly = serviceInterests.email ? getEmailMonthly() : 0;
// Calculate GPS breakdown (only if enabled)
let gpsMonitoring = 0;
let gpsEquipment = 0;
if (serviceInterests.gps) {
const gpsTier = gpsTiers.find((t) => t.id === gps.tierId);
gpsMonitoring = gpsTier ? gpsTier.pricePerEndpoint * gps.endpointCount : 0;
if (gps.includeEquipment && gps.equipmentDeviceCount > 0) {
const additionalDevices = Math.max(0, gps.equipmentDeviceCount - equipmentMonitoring.baseDevices);
gpsEquipment = equipmentMonitoring.basePrice + (additionalDevices * equipmentMonitoring.additionalDevicePrice);
}
}
// Calculate support breakdown (only if enabled)
let supportPlanCost = 0;
if (serviceInterests.support && support.planId !== 'none') {
const supportPlan = supportPlans.find((p) => p.id === support.planId);
supportPlanCost = supportPlan ? supportPlan.monthlyPrice : 0;
}
// Calculate VoIP breakdown (only if enabled)
let voipService = 0;
let voipHardwareMonthly = 0;
if (serviceInterests.voip && voip.enabled) {
const voipTier = voipTiers.find((t) => t.id === voip.tierId);
voipService = voipTier ? voipTier.pricePerUser * voip.userCount : 0;
voip.hardware.forEach((hw) => {
if (hw.isRental) {
const hardware = voipHardware.find((h) => h.id === hw.hardwareId);
if (hardware) {
voipHardwareMonthly += hardware.monthlyRental * hw.quantity;
}
}
});
}
const breakdown: QuoteBreakdown = {
gps: {
monitoring: gpsMonitoring,
equipment: gpsEquipment,
total: gpsMonthly,
},
support: {
plan: supportPlanCost,
blockTime: supportBlockTimeOneTime,
total: supportMonthly,
},
voip: {
service: voipService,
hardware: voipHardwareMonthly,
total: voipMonthly,
},
webHosting: webHostingMonthly,
email: emailMonthly,
};
const monthlyTotal = gpsMonthly + supportMonthly + voipMonthly + webHostingMonthly + emailMonthly;
const result: QuoteResult = {
monthlyTotal,
oneTimeTotal: voipOneTime + supportBlockTimeOneTime,
breakdown,
gpsMonthly,
supportMonthly,
voipMonthly,
webHostingMonthly,
emailMonthly,
};
setQuoteResult(result);
return result;
}, [serviceInterests, gps, support, voip, webHosting, email, getGPSMonthly, getSupportMonthly, getSupportBlockTimeOneTime, getVoIPMonthly, getVoIPOneTime, getWebHostingMonthly, getEmailMonthly]);
// ============================================================================
// Reset
// ============================================================================
const resetQuote = useCallback(() => {
setClientType(initialClientType);
setServiceInterests(initialServiceInterests);
setCompany(initialCompanyInfo);
setGPS(initialGPSSelection);
setSupport(initialSupportSelection);
setVoIP(initialVoIPSelection);
setWebHosting(initialWebHostingSelection);
setEmail(initialEmailSelection);
setContact(initialContactInfo);
setQuoteResult(null);
localStorage.removeItem(DRAFT_STORAGE_KEY);
}, []);
return {
quoteData,
quoteResult,
// Client type & service interests
setClientType: setClientTypeValue,
setServiceInterest,
// Company updates
updateCompany,
setEndpointCount,
setIndustry,
// GPS updates
updateGPS,
setGPSTier,
setEquipmentEnabled,
setEquipmentCount,
// Support updates
updateSupport,
setSupportPlan,
setBlockTimeEnabled,
setBlockTime,
// VoIP updates
updateVoIP,
setVoIPEnabled,
setVoIPTier,
setVoIPUserCount,
addHardware,
removeHardware,
updateHardwareQuantity,
// Web Hosting updates
updateWebHosting,
setWebHostingEnabled,
setWebHostingTier,
// Email updates
updateEmail,
setEmailEnabled,
setEmailProvider,
setEmailTier,
setMailboxCount,
// Contact updates
updateContact,
setContactPreference,
setAgreedToTerms,
// Calculations
calculateQuote,
getGPSMonthly,
getSupportMonthly,
getVoIPMonthly,
getWebHostingMonthly,
getEmailMonthly,
getSupportBlockTimeOneTime,
getVoIPOneTime,
// Reset
resetQuote,
};
}

View File

@@ -1,208 +0,0 @@
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import type { WizardStep } from '@/types/quote';
export interface WizardStepDef {
id: string;
title: string;
description: string;
}
/** Map step id from URL hash to step index */
function stepIndexFromHash(steps: WizardStepDef[]): number {
const hash = window.location.hash.replace('#', '');
if (!hash) return 0;
const idx = steps.findIndex((s) => s.id === hash);
return idx >= 0 ? idx : 0;
}
/** Determine which steps should be marked complete based on a restored index */
function restoredCompletedSteps(upToIndex: number): Set<number> {
const set = new Set<number>();
for (let i = 0; i < upToIndex; i++) {
set.add(i);
}
return set;
}
export interface UseWizardReturn {
currentStep: number;
steps: WizardStep[];
totalSteps: number;
isFirstStep: boolean;
isLastStep: boolean;
goToStep: (step: number) => void;
nextStep: () => void;
prevStep: () => void;
markStepComplete: (stepIndex: number) => void;
markStepIncomplete: (stepIndex: number) => void;
resetWizard: () => void;
progress: number;
canProceed: boolean;
setCanProceed: (canProceed: boolean) => void;
currentStepId: string;
getStepByIndex: (index: number) => WizardStep | undefined;
}
/**
* Dynamic wizard hook — accepts a step definition array that can change
* as the user enables/disables services in the discovery step.
*/
export function useWizard(stepDefs: WizardStepDef[]): UseWizardReturn {
const initialStep = stepIndexFromHash(stepDefs);
const [currentStep, setCurrentStep] = useState(initialStep);
const [completedSteps, setCompletedSteps] = useState<Set<number>>(
() => restoredCompletedSteps(initialStep)
);
const [canProceed, setCanProceed] = useState(true);
const isPopstateRef = useRef(false);
const prevStepDefsRef = useRef(stepDefs);
const totalSteps = stepDefs.length;
const isFirstStep = currentStep === 0;
const isLastStep = currentStep === totalSteps - 1;
// When stepDefs change (services toggled), keep current position valid
useEffect(() => {
const prevDefs = prevStepDefsRef.current;
prevStepDefsRef.current = stepDefs;
if (prevDefs.length === stepDefs.length) return;
// If current step is beyond new length, clamp it
if (currentStep >= stepDefs.length) {
setCurrentStep(Math.max(0, stepDefs.length - 1));
}
// If a step was removed, try to stay on the same step id
const currentId = prevDefs[currentStep]?.id;
if (currentId) {
const newIndex = stepDefs.findIndex((s) => s.id === currentId);
if (newIndex >= 0 && newIndex !== currentStep) {
setCurrentStep(newIndex);
}
}
}, [stepDefs, currentStep]);
// Sync URL hash when currentStep changes
useEffect(() => {
if (isPopstateRef.current) {
isPopstateRef.current = false;
return;
}
const stepId = stepDefs[currentStep]?.id;
if (stepId) {
const newHash = `#${stepId}`;
if (window.location.hash !== newHash) {
window.history.pushState(null, '', newHash);
}
}
}, [currentStep, stepDefs]);
// Listen for browser back/forward
useEffect(() => {
const handlePopState = () => {
const idx = stepIndexFromHash(stepDefs);
isPopstateRef.current = true;
setCurrentStep(idx);
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [stepDefs]);
// Set initial hash if none present
useEffect(() => {
if (!window.location.hash) {
const stepId = stepDefs[0]?.id;
if (stepId) {
window.history.replaceState(null, '', `#${stepId}`);
}
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const steps: WizardStep[] = useMemo(() => {
return stepDefs.map((step, index) => ({
...step,
isComplete: completedSteps.has(index),
isActive: index === currentStep,
}));
}, [stepDefs, currentStep, completedSteps]);
const currentStepId = useMemo(() => {
return stepDefs[currentStep]?.id || '';
}, [currentStep, stepDefs]);
const progress = useMemo(() => {
if (totalSteps <= 1) return 100;
return Math.round((currentStep / (totalSteps - 1)) * 100);
}, [currentStep, totalSteps]);
const goToStep = useCallback(
(step: number) => {
if (step >= 0 && step < totalSteps) {
if (step <= currentStep || completedSteps.has(step - 1) || step === currentStep + 1) {
setCurrentStep(step);
}
}
},
[totalSteps, currentStep, completedSteps]
);
const nextStep = useCallback(() => {
if (!isLastStep && canProceed) {
setCompletedSteps((prev) => new Set(prev).add(currentStep));
setCurrentStep((prev) => prev + 1);
}
}, [currentStep, isLastStep, canProceed]);
const prevStep = useCallback(() => {
if (!isFirstStep) {
setCurrentStep((prev) => prev - 1);
}
}, [isFirstStep]);
const markStepComplete = useCallback((stepIndex: number) => {
setCompletedSteps((prev) => new Set(prev).add(stepIndex));
}, []);
const markStepIncomplete = useCallback((stepIndex: number) => {
setCompletedSteps((prev) => {
const newSet = new Set(prev);
newSet.delete(stepIndex);
return newSet;
});
}, []);
const resetWizard = useCallback(() => {
setCurrentStep(0);
setCompletedSteps(new Set());
setCanProceed(true);
window.history.replaceState(null, '', `#${stepDefs[0]?.id}`);
}, [stepDefs]);
const getStepByIndex = useCallback(
(index: number): WizardStep | undefined => {
return steps[index];
},
[steps]
);
return {
currentStep,
steps,
totalSteps,
isFirstStep,
isLastStep,
goToStep,
nextStep,
prevStep,
markStepComplete,
markStepIncomplete,
resetWizard,
progress,
canProceed,
setCanProceed,
currentStepId,
getStepByIndex,
};
}

View File

@@ -1,199 +0,0 @@
@import "tailwindcss";
@theme {
--color-primary: #333d49;
--color-primary-light: #3d4856;
--color-accent: #fe7400;
--color-accent-hover: #e56800;
--color-accent-light: #fff4e8;
--color-navy: #113559;
--color-navy-light: #1a4370;
--color-gray-50: #f8f9fb;
--color-gray-100: #f1f3f5;
--color-gray-200: #e2e5ea;
--color-gray-300: #cdd2d9;
--color-gray-400: #9aa1ac;
--color-gray-500: #6b7280;
--color-gray-600: #4d5562;
--color-success: #059669;
--color-success-light: #ecfdf5;
--font-family-display: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-family-body: 'DM Sans', system-ui, sans-serif;
}
@layer base {
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-family: 'DM Sans', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-optical-sizing: auto;
}
body {
font-family: 'DM Sans', system-ui, sans-serif;
background-color: #f8f9fb;
color: #333d49;
line-height: 1.65;
}
/* Display headings use Jakarta Sans */
h1, h2, h3, h4, h5, h6 {
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
line-height: 1.3;
letter-spacing: -0.01em;
}
/* Focus styles for accessibility */
:focus-visible {
outline: 2px solid #fe7400;
outline-offset: 2px;
}
/* Selection color */
::selection {
background-color: #fe7400;
color: #ffffff;
}
/* Smooth transitions for interactive elements */
button, a, input, select, textarea {
font-family: inherit;
}
}
/* Typography scale */
.text-display {
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
font-weight: 700;
letter-spacing: -0.02em;
}
.text-heading {
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
font-weight: 600;
letter-spacing: -0.01em;
}
.text-label {
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
font-weight: 500;
font-size: 0.875rem;
letter-spacing: 0.01em;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cdd2d9;
border-radius: 100px;
}
::-webkit-scrollbar-thumb:hover {
background: #9aa1ac;
}
/* Premium card shadow system */
.shadow-card {
box-shadow:
0 1px 2px rgba(17, 53, 89, 0.04),
0 4px 12px rgba(17, 53, 89, 0.06);
}
.shadow-card-hover {
box-shadow:
0 2px 4px rgba(17, 53, 89, 0.06),
0 8px 24px rgba(17, 53, 89, 0.1);
}
.shadow-card-elevated {
box-shadow:
0 4px 6px rgba(17, 53, 89, 0.04),
0 12px 32px rgba(17, 53, 89, 0.08);
}
/* Gradient utilities */
.bg-gradient-navy {
background: linear-gradient(135deg, #113559 0%, #1a4370 100%);
}
.bg-gradient-dark {
background: linear-gradient(135deg, #333d49 0%, #252d36 100%);
}
.bg-gradient-accent {
background: linear-gradient(135deg, #fe7400 0%, #e56800 100%);
}
/* Print styles */
@media print {
/* Reset page */
@page {
margin: 0.6in 0.75in;
size: letter;
}
html, body {
background: white !important;
color: #333d49 !important;
font-size: 11pt !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
/* Hide non-content elements */
.print-hide,
[data-print-hide] {
display: none !important;
}
/* Show print-only elements */
.print-show {
display: block !important;
}
/* Remove decorative styling */
.shadow-card,
.shadow-card-hover,
.shadow-card-elevated {
box-shadow: none !important;
border: 1px solid #d1d5db !important;
}
/* Flatten card padding for print */
.bg-gradient-navy {
background: #113559 !important;
border-radius: 8px !important;
}
/* Ensure no page breaks mid-section */
.print-section {
break-inside: avoid;
}
/* Remove animations */
*, *::before, *::after {
animation: none !important;
transition: none !important;
}
/* Clean link styling */
a {
text-decoration: none !important;
color: inherit !important;
}
}

View File

@@ -1,202 +0,0 @@
import axios from 'axios';
/**
* API client for MSP Quote Wizard
*
* Proxied via /msp-api/ -> backend /api/ on 172.16.3.30:8001
* Endpoints:
* - POST /quotes - Create quote draft
* - GET /quotes/{access_token} - Get quote
* - PUT /quotes/{access_token} - Update quote
* - POST /quotes/{access_token}/items - Add item
* - DELETE /quotes/{access_token}/items/{item_id} - Remove item
* - POST /quotes/{access_token}/submit - Submit quote
* - GET /quotes/{access_token}/pdf - Get PDF (501 placeholder)
*/
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8001';
export const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
timeout: 10000,
});
// Response interceptor for error handling
apiClient.interceptors.response.use(
(response) => response,
(error) => {
return Promise.reject(error);
}
);
// -- Response types matching backend schemas --
export interface QuoteCreatedResponse {
id: string;
access_token: string;
status: string;
message: string;
}
export interface QuoteItemResponse {
id: string;
quote_id: string;
service_name: string;
service_description: string | null;
category: string;
billing_frequency: string;
unit_price: string;
quantity: number;
setup_fee: string | null;
is_required: boolean;
sort_order: number;
line_total: string;
monthly_amount: string;
created_at: string;
updated_at: string;
}
export interface QuoteResponse {
id: string;
access_token: string;
status: string;
company_name: string | null;
contact_name: string | null;
contact_email: string | null;
contact_phone: string | null;
employee_count: number | null;
notes: string | null;
monthly_total: string;
setup_total: string;
annual_total: string;
expires_at: string | null;
submitted_at: string | null;
created_at: string;
updated_at: string;
items: QuoteItemResponse[];
}
// -- Request types matching backend schemas --
export interface QuoteCreateRequest {
employee_count?: number;
notes?: string;
items?: QuoteItemCreateRequest[];
}
export interface QuoteUpdateRequest {
company_name?: string;
contact_name?: string;
contact_email?: string;
contact_phone?: string;
employee_count?: number;
notes?: string;
items?: QuoteItemCreateRequest[];
}
export interface QuoteItemCreateRequest {
category: string;
product_code: string;
product_name: string;
description?: string;
quantity: number;
unit_price: string;
setup_price?: string;
billing_frequency: string;
tier?: string;
is_recommended?: boolean;
}
export interface QuoteSubmitRequest {
company_name: string;
contact_name: string;
contact_email: string;
contact_phone?: string;
notes?: string;
}
// -- API functions --
/**
* Create a new quote draft. Returns access token for future operations.
*/
export async function createQuote(data: QuoteCreateRequest): Promise<QuoteCreatedResponse> {
const response = await apiClient.post<QuoteCreatedResponse>('/quotes', data);
return response.data;
}
/**
* Get a quote by its access token.
*/
export async function getQuote(accessToken: string): Promise<QuoteResponse> {
const response = await apiClient.get<QuoteResponse>(`/quotes/${accessToken}`);
return response.data;
}
/**
* Update a draft quote (wizard progress saves).
*/
export async function updateQuote(
accessToken: string,
data: QuoteUpdateRequest,
): Promise<QuoteResponse> {
const response = await apiClient.put<QuoteResponse>(
`/quotes/${accessToken}`,
data,
);
return response.data;
}
/**
* Add a single item to a quote.
*/
export async function addQuoteItem(
accessToken: string,
item: QuoteItemCreateRequest,
): Promise<QuoteResponse> {
const response = await apiClient.post<QuoteResponse>(
`/quotes/${accessToken}/items`,
item,
);
return response.data;
}
/**
* Remove an item from a quote.
*/
export async function removeQuoteItem(
accessToken: string,
itemId: string,
): Promise<QuoteResponse> {
const response = await apiClient.delete<QuoteResponse>(
`/quotes/${accessToken}/items/${itemId}`,
);
return response.data;
}
/**
* Submit a finalized quote with contact information.
*/
export async function submitQuote(
accessToken: string,
data: QuoteSubmitRequest,
): Promise<QuoteResponse> {
const response = await apiClient.post<QuoteResponse>(
`/quotes/${accessToken}/submit`,
data,
);
return response.data;
}
/**
* Get quote PDF. Currently returns 501 Not Implemented.
*/
export async function getQuotePdf(accessToken: string): Promise<Blob> {
const response = await apiClient.get(`/quotes/${accessToken}/pdf`, {
responseType: 'blob',
});
return response.data;
}

View File

@@ -1,423 +0,0 @@
import type {
GPSTier,
SupportPlan,
BlockTimeOption,
VoIPTier,
WebHostingTier,
EmailTier,
VoIPHardware
} from '@/types/quote';
/**
* GPS Monitoring Tiers
*/
export const gpsTiers: GPSTier[] = [
{
id: 'basic',
name: 'Basic',
description: 'Essential monitoring for small environments',
pricePerEndpoint: 19,
features: [
'Remote monitoring & management',
'8x5 help desk support',
'Patch management',
'Basic antivirus protection',
'Monthly health reports',
],
recommended: false,
},
{
id: 'pro',
name: 'Pro',
description: 'Comprehensive protection for growing businesses',
pricePerEndpoint: 26,
features: [
'Everything in Basic, plus:',
'24x7 help desk support',
'Advanced endpoint protection',
'Backup & disaster recovery',
'Network monitoring',
'Quarterly business reviews',
],
recommended: true,
},
{
id: 'advanced',
name: 'Advanced',
description: 'Enterprise-grade security and compliance',
pricePerEndpoint: 39,
features: [
'Everything in Pro, plus:',
'Dedicated account manager',
'Virtual CIO services',
'Compliance management',
'Security awareness training',
'Advanced threat detection',
'Priority response SLA',
],
recommended: false,
},
];
/**
* Equipment monitoring pricing
*/
export const equipmentMonitoring = {
basePrice: 25, // Up to 10 devices
baseDevices: 10,
additionalDevicePrice: 3, // Per additional device
};
/**
* Support Plans
*/
export const supportPlans: SupportPlan[] = [
{
id: 'essential',
name: 'Essential',
description: 'Basic support for small teams',
monthlyPrice: 200,
includedHours: 2,
effectiveHourlyRate: 100,
recommended: false,
},
{
id: 'standard',
name: 'Standard',
description: 'Balanced support for growing businesses',
monthlyPrice: 380,
includedHours: 4,
effectiveHourlyRate: 95,
recommended: true,
},
{
id: 'premium',
name: 'Premium',
description: 'Enhanced support with faster response',
monthlyPrice: 540,
includedHours: 6,
effectiveHourlyRate: 90,
recommended: false,
},
{
id: 'priority',
name: 'Priority',
description: 'Top-tier support with dedicated resources',
monthlyPrice: 850,
includedHours: 10,
effectiveHourlyRate: 85,
recommended: false,
},
];
/**
* Block Time Options
*/
export const blockTimeOptions: BlockTimeOption[] = [
{
id: 'block-10',
hours: 10,
price: 1500,
effectiveHourlyRate: 150,
},
{
id: 'block-20',
hours: 20,
price: 2600,
effectiveHourlyRate: 130,
},
{
id: 'block-30',
hours: 30,
price: 3000,
effectiveHourlyRate: 100,
},
];
/**
* VoIP Tiers
*/
export const voipTiers: VoIPTier[] = [
{
id: 'voip-basic',
name: 'Basic',
description: 'Essential phone features for small teams',
pricePerUser: 22,
features: [
'Unlimited local & long distance',
'Voicemail to email',
'Basic auto-attendant',
'Mobile app',
],
recommended: false,
},
{
id: 'voip-standard',
name: 'Standard',
description: 'Full-featured business phone system',
pricePerUser: 28,
features: [
'Everything in Basic, plus:',
'Video conferencing',
'Ring groups',
'Call recording',
'CRM integration',
],
recommended: true,
},
{
id: 'voip-pro',
name: 'Pro',
description: 'Advanced features for power users',
pricePerUser: 35,
features: [
'Everything in Standard, plus:',
'Advanced analytics',
'Custom IVR',
'Supervisor dashboard',
'API access',
],
recommended: false,
},
{
id: 'voip-callcenter',
name: 'Call Center',
description: 'Full call center capabilities',
pricePerUser: 55,
features: [
'Everything in Pro, plus:',
'Queue management',
'Wallboards',
'Agent scoring',
'Predictive dialing',
'Real-time monitoring',
],
recommended: false,
},
];
/**
* VoIP Hardware Options
*/
export const voipHardware: VoIPHardware[] = [
{
id: 'yealink-t33g',
name: 'Yealink T33G',
description: 'Entry-level IP phone',
oneTimePrice: 89,
monthlyRental: 5,
},
{
id: 'yealink-t54w',
name: 'Yealink T54W',
description: 'Mid-range color screen phone',
oneTimePrice: 169,
monthlyRental: 8,
},
{
id: 'yealink-t58a',
name: 'Yealink T58A',
description: 'Executive phone with video',
oneTimePrice: 299,
monthlyRental: 12,
},
{
id: 'headset-basic',
name: 'USB Headset',
description: 'Basic USB headset',
oneTimePrice: 45,
monthlyRental: 3,
},
{
id: 'headset-wireless',
name: 'Wireless Headset',
description: 'Premium wireless headset',
oneTimePrice: 149,
monthlyRental: 7,
},
];
/**
* Web Hosting Tiers
*/
export const webHostingTiers: WebHostingTier[] = [
{
id: 'hosting-starter',
name: 'Starter',
description: 'Perfect for simple business sites',
monthlyPrice: 15,
storage: '5GB',
sites: 1,
features: [
'5GB SSD storage',
'1 website',
'Free SSL certificate',
'Daily backups',
'Email support',
],
recommended: false,
},
{
id: 'hosting-business',
name: 'Business',
description: 'Great for multiple sites and more traffic',
monthlyPrice: 35,
storage: '25GB',
sites: 5,
features: [
'25GB SSD storage',
'5 websites',
'Free SSL certificates',
'Daily backups',
'Staging environment',
'Priority support',
],
recommended: true,
},
{
id: 'hosting-commerce',
name: 'Commerce',
description: 'E-commerce ready with unlimited sites',
monthlyPrice: 65,
storage: '50GB',
sites: -1, // Unlimited
features: [
'50GB SSD storage',
'Unlimited websites',
'Free SSL certificates',
'Real-time backups',
'CDN included',
'PCI compliance',
'Dedicated support',
],
recommended: false,
},
];
/**
* Email Tiers
*/
export const emailTiers: EmailTier[] = [
// WHM (Self-hosted) Options
{
id: 'whm-basic',
name: 'WHM Basic',
description: 'Self-hosted email basics',
pricePerMailbox: 2,
provider: 'whm',
storage: '5GB',
features: [
'5GB storage per mailbox',
'Webmail access',
'IMAP/POP3/SMTP',
'Spam filtering',
],
recommended: false,
},
{
id: 'whm-standard',
name: 'WHM Standard',
description: 'Enhanced self-hosted email',
pricePerMailbox: 4,
provider: 'whm',
storage: '10GB',
features: [
'10GB storage per mailbox',
'Webmail access',
'IMAP/POP3/SMTP',
'Advanced spam filtering',
'Email aliases',
],
recommended: false,
},
{
id: 'whm-pro',
name: 'WHM Pro',
description: 'Professional self-hosted email',
pricePerMailbox: 10,
provider: 'whm',
storage: '25GB',
features: [
'25GB storage per mailbox',
'Webmail access',
'IMAP/POP3/SMTP',
'Premium spam filtering',
'Email archiving',
'Shared calendars',
],
recommended: false,
},
// Microsoft 365 Options
{
id: 'm365-basic',
name: 'M365 Basic',
description: 'Microsoft 365 essentials',
pricePerMailbox: 7,
provider: 'm365',
storage: '50GB',
features: [
'50GB mailbox',
'Outlook web access',
'Mobile apps',
'OneDrive 1TB',
'Microsoft Teams',
],
recommended: false,
},
{
id: 'm365-standard',
name: 'M365 Standard',
description: 'Full Microsoft 365 experience',
pricePerMailbox: 14,
provider: 'm365',
storage: '50GB',
features: [
'50GB mailbox',
'Desktop Office apps',
'OneDrive 1TB',
'Microsoft Teams',
'SharePoint',
'Bookings',
],
recommended: true,
},
{
id: 'm365-premium',
name: 'M365 Premium',
description: 'Enterprise security and compliance',
pricePerMailbox: 24,
provider: 'm365',
storage: '100GB',
features: [
'100GB mailbox',
'Everything in Standard',
'Advanced security',
'Device management',
'Azure AD Premium',
'Data loss prevention',
],
recommended: false,
},
];
/**
* Industry options for company info
*/
export const industries = [
'Healthcare',
'Legal',
'Finance',
'Manufacturing',
'Retail',
'Professional Services',
'Other',
] as const;
/**
* Contact preference options
*/
export const contactPreferences = [
{ id: 'email', label: 'Email' },
{ id: 'phone', label: 'Phone' },
{ id: 'either', label: 'Either' },
] as const;

View File

@@ -1,69 +0,0 @@
import { type ClassValue, clsx } from 'clsx';
/**
* Utility function to merge class names
* Combines clsx for conditional classes
*/
export function cn(...inputs: ClassValue[]): string {
return clsx(inputs);
}
/**
* Format currency value
*/
export function formatCurrency(value: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
}
/**
* Format number with commas
*/
export function formatNumber(value: number): string {
return new Intl.NumberFormat('en-US').format(value);
}
/**
* Debounce function
*/
export function debounce<T extends (...args: unknown[]) => unknown>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | null = null;
return function executedFunction(...args: Parameters<T>) {
const later = () => {
timeout = null;
func(...args);
};
if (timeout !== null) {
clearTimeout(timeout);
}
timeout = setTimeout(later, wait);
};
}
/**
* Calculate total device count
*/
export function getTotalDevices(devices: {
workstations: number;
laptops: number;
servers: number;
networkDevices: number;
mobileDevices: number;
}): number {
return (
devices.workstations +
devices.laptops +
devices.servers +
devices.networkDevices +
devices.mobileDevices
);
}

View File

@@ -1,22 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import './index.css'
import App from './App'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1,
},
},
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
)

View File

@@ -1,285 +0,0 @@
/**
* MSP Quote Wizard Types
*/
// ============================================================================
// GPS Monitoring Types
// ============================================================================
export type GPSTierId = 'basic' | 'pro' | 'advanced';
export interface GPSTier {
id: GPSTierId;
name: string;
description: string;
pricePerEndpoint: number;
features: string[];
recommended: boolean;
}
export interface GPSSelection {
tierId: GPSTierId;
endpointCount: number;
includeEquipment: boolean;
equipmentDeviceCount: number;
}
// ============================================================================
// Support Plan Types
// ============================================================================
export type SupportPlanId = 'none' | 'essential' | 'standard' | 'premium' | 'priority';
export type BlockTimeId = 'block-10' | 'block-20' | 'block-30';
export interface SupportPlan {
id: SupportPlanId;
name: string;
description: string;
monthlyPrice: number;
includedHours: number;
effectiveHourlyRate: number;
recommended: boolean;
}
export interface BlockTimeOption {
id: BlockTimeId;
hours: number;
price: number;
effectiveHourlyRate: number;
}
export interface SupportSelection {
planId: SupportPlanId;
useBlockTime: boolean;
blockTimeId: BlockTimeId | null;
}
// ============================================================================
// VoIP Types
// ============================================================================
export type VoIPTierId = 'voip-basic' | 'voip-standard' | 'voip-pro' | 'voip-callcenter';
export interface VoIPTier {
id: VoIPTierId;
name: string;
description: string;
pricePerUser: number;
features: string[];
recommended: boolean;
}
export interface VoIPHardware {
id: string;
name: string;
description: string;
oneTimePrice: number;
monthlyRental: number;
}
export interface HardwareSelection {
hardwareId: string;
quantity: number;
isRental: boolean;
}
export interface VoIPSelection {
enabled: boolean;
tierId: VoIPTierId;
userCount: number;
hardware: HardwareSelection[];
}
// ============================================================================
// Web Hosting Types
// ============================================================================
export type WebHostingTierId = 'hosting-starter' | 'hosting-business' | 'hosting-commerce';
export interface WebHostingTier {
id: WebHostingTierId;
name: string;
description: string;
monthlyPrice: number;
storage: string;
sites: number; // -1 = unlimited
features: string[];
recommended: boolean;
}
export interface WebHostingSelection {
enabled: boolean;
tierId: WebHostingTierId;
}
// ============================================================================
// Email Types
// ============================================================================
export type EmailProvider = 'whm' | 'm365';
export type EmailTierId = 'whm-basic' | 'whm-standard' | 'whm-pro' | 'm365-basic' | 'm365-standard' | 'm365-premium';
export interface EmailTier {
id: EmailTierId;
name: string;
description: string;
pricePerMailbox: number;
provider: EmailProvider;
storage: string;
features: string[];
recommended: boolean;
}
export interface EmailSelection {
enabled: boolean;
provider: EmailProvider;
tierId: EmailTierId;
mailboxCount: number;
}
// ============================================================================
// Client & Contact Types
// ============================================================================
export type ClientType = 'company' | 'individual';
export type Industry =
| 'Healthcare'
| 'Legal'
| 'Finance'
| 'Manufacturing'
| 'Retail'
| 'Professional Services'
| 'Other';
export type ContactPreference = 'email' | 'phone' | 'either';
export interface CompanyInfo {
name: string;
endpointCount: number;
industry: Industry | '';
notes: string;
}
export interface ContactInfo {
name: string;
email: string;
phone: string;
companyName: string;
currentITSituation: string;
contactPreference: ContactPreference;
agreedToTerms: boolean;
}
// ============================================================================
// Service Interest Selection (for discovery step)
// ============================================================================
export interface ServiceInterests {
gps: boolean;
support: boolean;
voip: boolean;
webHosting: boolean;
email: boolean;
}
// ============================================================================
// Quote Data & Result Types
// ============================================================================
export interface QuoteData {
clientType: ClientType;
serviceInterests: ServiceInterests;
company: CompanyInfo;
gps: GPSSelection;
support: SupportSelection;
voip: VoIPSelection;
webHosting: WebHostingSelection;
email: EmailSelection;
contact: ContactInfo;
}
export interface QuoteBreakdown {
gps: {
monitoring: number;
equipment: number;
total: number;
};
support: {
plan: number;
blockTime: number;
total: number;
};
voip: {
service: number;
hardware: number;
total: number;
};
webHosting: number;
email: number;
}
export interface QuoteResult {
monthlyTotal: number;
oneTimeTotal: number;
breakdown: QuoteBreakdown;
gpsMonthly: number;
supportMonthly: number;
voipMonthly: number;
webHostingMonthly: number;
emailMonthly: number;
}
// ============================================================================
// Wizard Types
// ============================================================================
export interface WizardStep {
id: string;
title: string;
description: string;
isComplete: boolean;
isActive: boolean;
}
export interface StepValidation {
isValid: boolean;
errors: string[];
}
// ============================================================================
// Legacy Types (for backward compatibility)
// ============================================================================
export type ServiceTier = 'essential' | 'professional' | 'enterprise';
export interface DeviceCount {
workstations: number;
laptops: number;
servers: number;
networkDevices: number;
mobileDevices: number;
}
export interface ServiceSelection {
tier: ServiceTier;
addOns: string[];
}
export interface PricingTier {
id: ServiceTier;
name: string;
description: string;
basePrice: number;
perDevicePrice: number;
features: string[];
recommended?: boolean;
}
export interface AddOn {
id: string;
name: string;
description: string;
price: number;
priceType: 'flat' | 'per-device' | 'per-user';
}

View File

@@ -1,33 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: '#333d49',
accent: '#fe7400',
navy: '#113559',
gray: {
DEFAULT: '#4d4d4d',
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4d4d4d',
700: '#374151',
800: '#1f2937',
900: '#111827',
},
},
fontFamily: {
lexend: ['Lexend', 'sans-serif'],
},
},
},
plugins: [],
}

View File

@@ -1,34 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -1,26 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,23 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
base: '/quote/',
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: {
outDir: 'dist',
sourcemap: false,
},
server: {
port: 5173,
host: true,
},
})

View File

@@ -1,24 +0,0 @@
RewriteEngine On
# Pass Authorization header through CGI/suPHP
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Handle CORS preflight requests
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteRule ^(.*)$ index.php [QSA,L]
# Route all requests to index.php unless the file or directory exists
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [QSA,L]
# Deny access to PHP files other than index.php
<FilesMatch "^(?!index\.php$).+\.php$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>

View File

@@ -1,54 +0,0 @@
<?php
/**
* Configuration for MSP Quote Wizard PHP API.
*
* All credentials and settings are defined here. On cPanel, this file
* should be outside the web root or protected via .htaccess.
*/
// Deny direct access
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
http_response_code(403);
exit('Direct access denied.');
}
// --------------------------------------------------------------------------
// Database
// --------------------------------------------------------------------------
define('DB_HOST', 'localhost');
define('DB_NAME', 'azcomputerguru_acg2025');
define('DB_USER', 'azcomputerguru_acg2025');
define('DB_PASS', 'Kg-.v?{jFXSH');
define('DB_CHARSET', 'utf8mb4');
define('DB_TABLE_PREFIX', 'acgq_');
// --------------------------------------------------------------------------
// Microsoft Graph API (email sending)
// --------------------------------------------------------------------------
define('GRAPH_TENANT_ID', 'ce61461e-81a0-4c84-bb4a-7b354a9a356d');
define('GRAPH_CLIENT_ID', '15b0fafb-ab51-4cc9-adc7-f6334c805c22');
define('GRAPH_CLIENT_SECRET', 'rRN8Q~FPfSL8O24iZthi_LVJTjGOCZG.DnxGHaSk');
define('GRAPH_SENDER_EMAIL', 'noreply@azcomputerguru.com');
define('GRAPH_SENDER_NAME', 'Arizona Computer Guru');
define('GRAPH_REPLY_TO_EMAIL', 'admin@azcomputerguru.com');
define('GRAPH_REPLY_TO_NAME', 'Arizona Computer Guru');
// --------------------------------------------------------------------------
// Admin / Auth
// --------------------------------------------------------------------------
define('ADMIN_NOTIFICATION_EMAIL', 'mike@azcomputerguru.com');
define('ADMIN_API_KEY', 'RqzhynUHgKxXaQTVFiM9TQyl8C3riuJu4Z_wwt6IGN0');
// --------------------------------------------------------------------------
// Application
// --------------------------------------------------------------------------
define('QUOTE_DRAFT_EXPIRY_DAYS', 30);
define('QUOTE_SUBMITTED_EXPIRY_DAYS', 90);
// CORS allowed origins (comma-separated or '*' for dev)
define('CORS_ALLOWED_ORIGINS', 'https://azcomputerguru.com,https://www.azcomputerguru.com');
// --------------------------------------------------------------------------
// Logging
// --------------------------------------------------------------------------
define('LOG_FILE', __DIR__ . '/../logs/api.log');

View File

@@ -1,55 +0,0 @@
<?php
/**
* PDO database connection singleton.
*
* Provides a lazy-loaded PDO instance configured for the quote wizard
* database with utf8mb4, exception error mode, and associative fetch.
*/
// Deny direct access
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
http_response_code(403);
exit('Direct access denied.');
}
require_once __DIR__ . '/config.php';
/**
* Return a shared PDO connection instance.
*
* The connection is created on first call and reused for the lifetime
* of the request. Uses utf8mb4 charset, ERRMODE_EXCEPTION, and
* FETCH_ASSOC as the default fetch mode.
*
* @return PDO
* @throws RuntimeException If the connection cannot be established.
*/
function get_db(): PDO
{
static $pdo = null;
if ($pdo !== null) {
return $pdo;
}
$dsn = sprintf(
'mysql:host=%s;dbname=%s;charset=%s',
DB_HOST,
DB_NAME,
DB_CHARSET
);
try {
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES 'utf8mb4'",
]);
} catch (PDOException $e) {
app_log('ERROR', 'Database connection failed: ' . $e->getMessage());
throw new RuntimeException('Database connection failed');
}
return $pdo;
}

View File

@@ -1,277 +0,0 @@
<?php
/**
* Shared utility functions for the MSP Quote Wizard API.
*
* Provides UUID generation, token generation, JSON response helpers,
* input validation, CORS headers, and logging.
*/
// Deny direct access
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
http_response_code(403);
exit('Direct access denied.');
}
require_once __DIR__ . '/config.php';
// --------------------------------------------------------------------------
// UUID / Token generation
// --------------------------------------------------------------------------
/**
* Generate a UUID v4 string (lowercase, 36 chars with hyphens).
*
* Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
* where y is one of 8, 9, a, b.
*
* @return string
*/
function generate_uuid(): string
{
$bytes = random_bytes(16);
// Set version to 4 (0100 in binary)
$bytes[6] = chr(ord($bytes[6]) & 0x0f | 0x40);
// Set variant to RFC 4122 (10xx in binary)
$bytes[8] = chr(ord($bytes[8]) & 0x3f | 0x80);
return sprintf(
'%s-%s-%s-%s-%s',
bin2hex(substr($bytes, 0, 4)),
bin2hex(substr($bytes, 4, 2)),
bin2hex(substr($bytes, 6, 2)),
bin2hex(substr($bytes, 8, 2)),
bin2hex(substr($bytes, 10, 6))
);
}
/**
* Generate a URL-safe access token matching Python's secrets.token_urlsafe(32).
*
* Produces a 43-character base64url-encoded string (no padding) from 32
* random bytes, exactly matching the Python implementation.
*
* @return string 43-character URL-safe token
*/
function generate_access_token(): string
{
$bytes = random_bytes(32);
// base64url encode: replace +/ with -_, strip padding =
return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
}
// --------------------------------------------------------------------------
// JSON response helpers
// --------------------------------------------------------------------------
/**
* Send a JSON response with the given data and HTTP status code.
*
* Sets Content-Type header, outputs JSON, and terminates the script.
*
* @param mixed $data Data to encode as JSON.
* @param int $status HTTP status code (default 200).
* @return never
*/
function json_response($data, int $status = 200): void
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION);
exit;
}
/**
* Send a JSON error response.
*
* @param string $message Error message.
* @param int $status HTTP status code (default 400).
* @param mixed|null $details Additional error details.
* @return never
*/
function error_response(string $message, int $status = 400, $details = null): void
{
$body = ['detail' => $message];
if ($details !== null) {
$body['errors'] = $details;
}
json_response($body, $status);
}
// --------------------------------------------------------------------------
// Request parsing
// --------------------------------------------------------------------------
/**
* Parse the JSON request body.
*
* @return array Decoded JSON as an associative array.
*/
function get_json_body(): array
{
$raw = file_get_contents('php://input');
if (empty($raw)) {
return [];
}
$data = json_decode($raw, true);
if (json_last_error() !== JSON_ERROR_NONE) {
error_response('Invalid JSON in request body', 400);
}
return $data;
}
/**
* Get the client IP address, accounting for reverse proxies.
*
* Checks X-Forwarded-For first, then X-Real-IP, then REMOTE_ADDR.
*
* @return string|null
*/
function get_client_ip(): ?string
{
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$parts = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
return trim($parts[0]);
}
if (!empty($_SERVER['HTTP_X_REAL_IP'])) {
return trim($_SERVER['HTTP_X_REAL_IP']);
}
return $_SERVER['REMOTE_ADDR'] ?? null;
}
/**
* Get the User-Agent header value.
*
* @return string|null
*/
function get_user_agent(): ?string
{
return $_SERVER['HTTP_USER_AGENT'] ?? null;
}
// --------------------------------------------------------------------------
// CORS
// --------------------------------------------------------------------------
/**
* Emit CORS headers based on the configured allowed origins.
*
* For preflight (OPTIONS) requests, this also sets the allowed methods
* and headers, then terminates the script with 204.
*/
function cors_headers(): void
{
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowed = array_map('trim', explode(',', CORS_ALLOWED_ORIGINS));
// Allow the origin if it matches our whitelist, or allow all if '*'
if (in_array('*', $allowed, true) || in_array($origin, $allowed, true)) {
$send_origin = in_array('*', $allowed, true) ? '*' : $origin;
header("Access-Control-Allow-Origin: {$send_origin}");
}
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With');
header('Access-Control-Max-Age: 86400');
// Handle preflight
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
}
// --------------------------------------------------------------------------
// Validation
// --------------------------------------------------------------------------
/**
* Validate that all required fields are present and non-empty in the data.
*
* @param array $data Associative array of input data.
* @param string[] $fields List of required field names.
* @return string[] Array of error messages (empty if valid).
*/
function validate_required(array $data, array $fields): array
{
$errors = [];
foreach ($fields as $field) {
if (!isset($data[$field]) || (is_string($data[$field]) && trim($data[$field]) === '')) {
$errors[] = "Field '{$field}' is required.";
}
}
return $errors;
}
/**
* Validate an email address.
*
* @param string $email Email address to validate.
* @return bool True if valid.
*/
function validate_email(string $email): bool
{
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
// --------------------------------------------------------------------------
// Logging
// --------------------------------------------------------------------------
/**
* Append a message to the application log file.
*
* @param string $level Log level (INFO, WARNING, ERROR).
* @param string $message Log message.
*/
function app_log(string $level, string $message): void
{
$dir = dirname(LOG_FILE);
if (!is_dir($dir)) {
@mkdir($dir, 0750, true);
}
$timestamp = gmdate('Y-m-d\TH:i:s\Z');
$line = "[{$timestamp}] [{$level}] {$message}" . PHP_EOL;
@file_put_contents(LOG_FILE, $line, FILE_APPEND | LOCK_EX);
}
// --------------------------------------------------------------------------
// Datetime helpers
// --------------------------------------------------------------------------
/**
* Format a datetime value for JSON output (ISO 8601 format).
*
* Accepts a datetime string from MySQL (Y-m-d H:i:s) and returns
* an ISO 8601 string, or null if input is null/empty.
*
* @param string|null $dt MySQL datetime string.
* @return string|null ISO 8601 formatted string.
*/
function format_datetime(?string $dt): ?string
{
if ($dt === null || $dt === '' || $dt === '0000-00-00 00:00:00') {
return null;
}
// MySQL DATETIME is already in UTC for this application
$ts = strtotime($dt);
if ($ts === false) {
return null;
}
return gmdate('Y-m-d\TH:i:s\Z', $ts);
}
/**
* Get the current UTC datetime in MySQL format.
*
* @return string Y-m-d H:i:s
*/
function utc_now(): string
{
return gmdate('Y-m-d H:i:s');
}

View File

@@ -1,164 +0,0 @@
<?php
/**
* Front controller / router for the MSP Quote Wizard PHP API.
*
* All requests are routed here via .htaccess. Parses the URI and method,
* emits CORS headers, then dispatches to the appropriate route handler.
*
* Route map:
* POST /quotes -> create quote
* GET /quotes/{token} -> get quote by token
* PUT /quotes/{token} -> update quote
* POST /quotes/{token}/items -> add item
* DELETE /quotes/{token}/items/{id} -> remove item
* POST /quotes/{token}/submit -> submit quote
* GET /admin/quotes -> list quotes (auth)
* GET /admin/quotes/stats -> get stats (auth)
* GET /admin/quotes/{id} -> get quote by ID (auth)
* PUT /admin/quotes/{id} -> update quote status (auth)
* POST /admin/quotes/{id}/sync-syncro -> sync to Syncro (auth)
*/
// Error reporting: log only, never display to client
ini_set('display_errors', '0');
error_reporting(E_ALL);
require_once __DIR__ . '/helpers.php';
// Emit CORS headers on every request (handles OPTIONS preflight too)
cors_headers();
// Parse request
$method = $_SERVER['REQUEST_METHOD'];
// Get the path relative to the API directory
// Strip the script directory from REQUEST_URI to get the route path
$request_uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
// Determine the base path (the directory where index.php lives)
$script_dir = dirname($_SERVER['SCRIPT_NAME']);
if ($script_dir !== '/' && $script_dir !== '\\') {
$path = substr($request_uri, strlen($script_dir));
} else {
$path = $request_uri;
}
// Normalize: ensure leading slash, remove trailing slash (except root)
$path = '/' . ltrim($path, '/');
if ($path !== '/' && substr($path, -1) === '/') {
$path = rtrim($path, '/');
}
// Split path into segments for matching
$segments = array_values(array_filter(explode('/', $path), function ($s) {
return $s !== '';
}));
$seg_count = count($segments);
// --------------------------------------------------------------------------
// Route dispatch
// --------------------------------------------------------------------------
// -- Public quote routes: /quotes/... --
if ($seg_count >= 1 && $segments[0] === 'quotes') {
require_once __DIR__ . '/routes/quotes.php';
// POST /quotes -> create
if ($seg_count === 1 && $method === 'POST') {
handle_create_quote();
}
// GET /quotes/{token} -> get
if ($seg_count === 2 && $method === 'GET') {
handle_get_quote($segments[1]);
}
// PUT /quotes/{token} -> update
if ($seg_count === 2 && $method === 'PUT') {
handle_update_quote($segments[1]);
}
// POST /quotes/{token}/items -> add item
if ($seg_count === 3 && $segments[2] === 'items' && $method === 'POST') {
handle_add_item($segments[1]);
}
// DELETE /quotes/{token}/items/{id} -> remove item
if ($seg_count === 4 && $segments[2] === 'items' && $method === 'DELETE') {
handle_remove_item($segments[1], $segments[3]);
}
// POST /quotes/{token}/submit -> submit
if ($seg_count === 3 && $segments[2] === 'submit' && $method === 'POST') {
handle_submit_quote($segments[1]);
}
// If we got here with a quotes path but no match, 404
error_response('Not found', 404);
}
// -- Admin routes: /admin/quotes/... --
if ($seg_count >= 2 && $segments[0] === 'admin' && $segments[1] === 'quotes') {
require_once __DIR__ . '/routes/admin.php';
// GET /admin/quotes -> list
if ($seg_count === 2 && $method === 'GET') {
handle_list_quotes();
}
// GET /admin/quotes/stats -> stats
if ($seg_count === 3 && $segments[2] === 'stats' && $method === 'GET') {
handle_get_stats();
}
// GET /admin/quotes/{id} -> get by ID
if ($seg_count === 3 && $segments[2] !== 'stats' && $method === 'GET') {
handle_admin_get_quote($segments[2]);
}
// PUT /admin/quotes/{id} -> admin update
if ($seg_count === 3 && $method === 'PUT') {
handle_admin_update_quote($segments[2]);
}
// POST /admin/quotes/{id}/sync-syncro -> syncro sync
if ($seg_count === 4 && $segments[3] === 'sync-syncro' && $method === 'POST') {
handle_sync_syncro($segments[2]);
}
// If we got here with an admin path but no match, 404
error_response('Not found', 404);
}
// --------------------------------------------------------------------------
// Health check: GET /health
// --------------------------------------------------------------------------
if ($seg_count === 1 && $segments[0] === 'health' && $method === 'GET') {
// Quick DB connectivity check
try {
require_once __DIR__ . '/db.php';
$db = get_db();
$db->query('SELECT 1');
json_response(['status' => 'ok', 'database' => 'connected']);
} catch (\Throwable $e) {
json_response(['status' => 'error', 'database' => 'disconnected'], 503);
}
}
// --------------------------------------------------------------------------
// Root: GET /
// --------------------------------------------------------------------------
if ($seg_count === 0 && $method === 'GET') {
json_response([
'service' => 'MSP Quote Wizard API',
'version' => '1.0.0',
'status' => 'running',
]);
}
// --------------------------------------------------------------------------
// 404 fallback
// --------------------------------------------------------------------------
error_response('Not found', 404);

View File

@@ -1,145 +0,0 @@
<?php
/**
* Admin route handlers for quote management.
*
* All handlers require a valid API key in the Authorization header.
* Format: Authorization: Bearer {ADMIN_API_KEY}
*/
// Deny direct access
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
http_response_code(403);
exit('Direct access denied.');
}
require_once __DIR__ . '/../helpers.php';
require_once __DIR__ . '/../db.php';
require_once __DIR__ . '/../services/quote_service.php';
require_once __DIR__ . '/../services/syncro_service.php';
/**
* Verify the admin API key from the Authorization header.
*
* Expects: Authorization: Bearer {api_key}
* Terminates with 401 if missing or invalid.
*/
function check_admin_auth(): void
{
// suPHP strips the Authorization header, so accept X-Api-Key as primary
$token = $_SERVER['HTTP_X_API_KEY'] ?? '';
// Fallback: try Authorization: Bearer {key} (works with PHP-FPM)
if (empty($token)) {
$header = $_SERVER['HTTP_AUTHORIZATION']
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
?? '';
if (!empty($header) && strpos($header, 'Bearer ') === 0) {
$token = substr($header, 7);
}
}
if (empty($token)) {
error_response('API key required. Send X-Api-Key header.', 401);
}
if (ADMIN_API_KEY === 'CHANGE_ME_PLACEHOLDER') {
app_log('WARNING', '[WARNING] Admin API key is not configured (still placeholder)');
error_response('Admin API key not configured on server', 500);
}
if (!hash_equals(ADMIN_API_KEY, $token)) {
app_log('WARNING', '[WARNING] Invalid admin API key attempt from ' . (get_client_ip() ?? 'unknown'));
error_response('Invalid API key', 401);
}
}
/**
* GET /admin/quotes
*
* List quotes with pagination and optional filters.
* Query params: skip, limit, status, search
*/
function handle_list_quotes(): void
{
check_admin_auth();
$db = get_db();
$skip = max(0, (int)($_GET['skip'] ?? 0));
$limit = min(1000, max(1, (int)($_GET['limit'] ?? 100)));
$status = $_GET['status'] ?? null;
$search = $_GET['search'] ?? null;
// Validate status if provided
if ($status !== null && $status !== '' && !in_array($status, VALID_STATUSES, true)) {
error_response("Invalid status filter: {$status}", 400);
}
$result = list_quotes($db, $skip, $limit, $status, $search);
json_response([
'total' => $result['total'],
'skip' => $skip,
'limit' => $limit,
'quotes' => $result['quotes'],
]);
}
/**
* GET /admin/quotes/stats
*
* Get dashboard statistics for quotes.
*/
function handle_get_stats(): void
{
check_admin_auth();
$db = get_db();
$stats = get_stats($db);
json_response($stats);
}
/**
* GET /admin/quotes/{id}
*
* Get a single quote by ID with items, activities, and notifications.
*/
function handle_admin_get_quote(string $quote_id): void
{
check_admin_auth();
$db = get_db();
$quote = get_quote_by_id($db, $quote_id);
$response = build_admin_quote_response($db, $quote);
json_response($response);
}
/**
* PUT /admin/quotes/{id}
*
* Update a quote's status and/or expiration (admin only).
*/
function handle_admin_update_quote(string $quote_id): void
{
check_admin_auth();
$data = get_json_body();
$db = get_db();
$quote = admin_update_quote($db, $quote_id, $data, 'admin');
$response = build_admin_quote_response($db, $quote);
json_response($response);
}
/**
* POST /admin/quotes/{id}/sync-syncro
*
* Trigger a SyncroRMM sync for a quote.
*/
function handle_sync_syncro(string $quote_id): void
{
check_admin_auth();
$db = get_db();
$quote = get_quote_by_id($db, $quote_id);
$result = sync_quote_to_syncro($db, $quote);
json_response($result);
}

View File

@@ -1,183 +0,0 @@
<?php
/**
* Public quote route handlers.
*
* These endpoints do not require authentication. They allow prospects
* to create, view, update, and submit quotes using an access token.
*/
// Deny direct access
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
http_response_code(403);
exit('Direct access denied.');
}
require_once __DIR__ . '/../helpers.php';
require_once __DIR__ . '/../db.php';
require_once __DIR__ . '/../services/quote_service.php';
require_once __DIR__ . '/../services/email_service.php';
/**
* POST /quotes
*
* Create a new quote draft. Returns the quote ID, access token, status, and
* a success message. HTTP 201 on success.
*/
function handle_create_quote(): void
{
$data = get_json_body();
$ip = get_client_ip();
$ua = get_user_agent();
$db = get_db();
// Validate employee_count if provided
if (isset($data['employee_count'])) {
$data['employee_count'] = (int)$data['employee_count'];
if ($data['employee_count'] < 1) {
error_response('employee_count must be >= 1', 422);
}
}
$quote = create_quote($db, $data, $ip, $ua);
json_response([
'id' => $quote['id'],
'access_token' => $quote['access_token'],
'status' => $quote['status'],
'message' => 'Quote created successfully. Use the access_token to access your quote.',
], 201);
}
/**
* GET /quotes/{token}
*
* Retrieve a quote by its access token. Returns the full quote with items.
*/
function handle_get_quote(string $token): void
{
$db = get_db();
$quote = get_quote_by_token($db, $token);
$response = build_quote_response($db, $quote);
json_response($response);
}
/**
* PUT /quotes/{token}
*
* Update a draft quote's fields and/or replace all items.
*/
function handle_update_quote(string $token): void
{
$data = get_json_body();
$ip = get_client_ip();
$db = get_db();
$quote = update_quote($db, $token, $data, $ip);
$response = build_quote_response($db, $quote);
json_response($response);
}
/**
* POST /quotes/{token}/items
*
* Add a single item to a draft quote. HTTP 201 on success.
*/
function handle_add_item(string $token): void
{
$data = get_json_body();
$ip = get_client_ip();
$db = get_db();
// Validate required item fields
$errors = validate_required($data, ['category', 'product_code', 'product_name', 'unit_price']);
if (!empty($errors)) {
error_response('Validation error', 422, $errors);
}
$quote = add_item($db, $token, $data, $ip);
$response = build_quote_response($db, $quote);
json_response($response, 201);
}
/**
* DELETE /quotes/{token}/items/{item_id}
*
* Remove an item from a draft quote.
*/
function handle_remove_item(string $token, string $item_id): void
{
$ip = get_client_ip();
$db = get_db();
$quote = remove_item($db, $token, $item_id, $ip);
$response = build_quote_response($db, $quote);
json_response($response);
}
/**
* POST /quotes/{token}/submit
*
* Submit a draft quote with contact information. Sends an email notification
* to the admin (best-effort -- email failure does not fail the submission).
*/
function handle_submit_quote(string $token): void
{
$data = get_json_body();
$ip = get_client_ip();
$db = get_db();
// Validate required submission fields
$errors = validate_required($data, ['company_name', 'contact_name', 'contact_email']);
if (!empty($errors)) {
error_response('Validation error', 422, $errors);
}
if (!validate_email($data['contact_email'])) {
error_response('Invalid email address', 422, ["Field 'contact_email' is not a valid email."]);
}
// Submit the quote (updates DB)
$quote = submit_quote($db, $token, $data, $ip);
// Send email notification (best-effort, do not fail the request)
try {
$items_raw = fetch_items_for_quote($db, $quote['id']);
$items_data = array_map(function ($item) {
return [
'service_name' => $item['product_name'],
'billing_frequency' => $item['billing_frequency'],
'unit_price' => $item['unit_price'],
'quantity' => (int)$item['quantity'],
];
}, $items_raw);
$html = build_quote_notification_html(
$data['company_name'],
$data['contact_name'],
$data['contact_email'],
$data['contact_phone'] ?? null,
number_format((float)$quote['monthly_total'], 2, '.', ''),
number_format((float)$quote['setup_total'], 2, '.', ''),
$items_data,
$data['notes'] ?? null
);
$subject = "New Quote Submission: {$data['company_name']} - \$" .
number_format((float)$quote['monthly_total'], 2, '.', '') . "/mo";
$sent = send_email(ADMIN_NOTIFICATION_EMAIL, $subject, $html);
// Update notification record with result
$notif_status = $sent ? 'sent' : 'failed';
$notif_error = $sent ? null : 'Graph API send failed';
update_notification_status($db, $quote['id'], $notif_status, $notif_error);
} catch (\Throwable $e) {
app_log('ERROR', '[ERROR] Failed to send quote notification email: ' . $e->getMessage());
// Do not fail the submission
}
// Return the full quote response
$response = build_quote_response($db, $quote);
json_response($response);
}

View File

@@ -1,307 +0,0 @@
<?php
/**
* Email service using Microsoft Graph API.
*
* Sends email via M365 Graph API using client credentials flow (OAuth 2.0).
* Used for quote submission notifications and other system emails.
*
* All HTTP calls use curl.
*/
// Deny direct access
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
http_response_code(403);
exit('Direct access denied.');
}
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../helpers.php';
// Token cache: persists across calls within a single request
$_graph_token_cache = [
'access_token' => null,
'expires_at' => 0,
];
/**
* Obtain an access token from Azure AD using client credentials flow.
*
* Caches the token in a static variable and reuses it until 60 seconds
* before expiry.
*
* @return string Bearer access token.
* @throws RuntimeException If credentials are not configured or request fails.
*/
function get_graph_token(): string
{
global $_graph_token_cache;
// Return cached token if still valid (with 60s buffer)
if (
$_graph_token_cache['access_token'] !== null
&& $_graph_token_cache['expires_at'] > time() + 60
) {
return $_graph_token_cache['access_token'];
}
if (empty(GRAPH_TENANT_ID) || empty(GRAPH_CLIENT_ID) || GRAPH_CLIENT_SECRET === 'CHANGE_ME_PLACEHOLDER') {
throw new RuntimeException('Microsoft Graph API credentials not configured');
}
$token_url = "https://login.microsoftonline.com/" . GRAPH_TENANT_ID . "/oauth2/v2.0/token";
$post_fields = http_build_query([
'client_id' => GRAPH_CLIENT_ID,
'client_secret' => GRAPH_CLIENT_SECRET,
'scope' => 'https://graph.microsoft.com/.default',
'grant_type' => 'client_credentials',
]);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $token_url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $post_fields,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
]);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curl_error = curl_error($ch);
curl_close($ch);
if ($response === false) {
app_log('ERROR', "Graph token request failed (curl): {$curl_error}");
throw new RuntimeException("Failed to obtain Graph token: {$curl_error}");
}
if ($http_code !== 200) {
app_log('ERROR', "Graph token request failed (HTTP {$http_code}): {$response}");
throw new RuntimeException("Failed to obtain Graph token: HTTP {$http_code}");
}
$data = json_decode($response, true);
if (empty($data['access_token'])) {
app_log('ERROR', 'Graph token response missing access_token');
throw new RuntimeException('Invalid Graph token response');
}
$_graph_token_cache['access_token'] = $data['access_token'];
$_graph_token_cache['expires_at'] = time() + (int)($data['expires_in'] ?? 3600);
return $data['access_token'];
}
/**
* Send an email via Microsoft Graph API.
*
* @param string $to_email Recipient email address.
* @param string $subject Email subject.
* @param string $body_html HTML body content.
* @param string|null $cc_email Optional CC recipient.
* @return bool True if sent successfully, false otherwise.
*/
function send_email(string $to_email, string $subject, string $body_html, ?string $cc_email = null): bool
{
if (GRAPH_CLIENT_SECRET === 'CHANGE_ME_PLACEHOLDER' || empty(GRAPH_TENANT_ID)) {
app_log('WARNING', 'Graph API not configured - skipping email send');
return false;
}
try {
$token = get_graph_token();
} catch (RuntimeException $e) {
app_log('ERROR', 'Cannot send email - token error: ' . $e->getMessage());
return false;
}
$message = [
'message' => [
'subject' => $subject,
'body' => [
'contentType' => 'HTML',
'content' => $body_html,
],
'from' => [
'emailAddress' => [
'name' => defined('GRAPH_SENDER_NAME') ? GRAPH_SENDER_NAME : 'Arizona Computer Guru',
'address' => GRAPH_SENDER_EMAIL,
],
],
'toRecipients' => [
['emailAddress' => ['address' => $to_email]],
],
'replyTo' => [
[
'emailAddress' => [
'name' => defined('GRAPH_REPLY_TO_NAME') ? GRAPH_REPLY_TO_NAME : 'Arizona Computer Guru',
'address' => defined('GRAPH_REPLY_TO_EMAIL') ? GRAPH_REPLY_TO_EMAIL : GRAPH_SENDER_EMAIL,
],
],
],
],
'saveToSentItems' => 'true',
];
if ($cc_email !== null) {
$message['message']['ccRecipients'] = [
['emailAddress' => ['address' => $cc_email]],
];
}
$url = "https://graph.microsoft.com/v1.0/users/" . GRAPH_SENDER_EMAIL . "/sendMail";
$json_body = json_encode($message, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $json_body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
"Authorization: Bearer {$token}",
],
]);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curl_error = curl_error($ch);
curl_close($ch);
if ($response === false) {
app_log('ERROR', "Graph sendMail curl error: {$curl_error}");
return false;
}
// Graph sendMail returns 202 on success (no body)
if ($http_code >= 200 && $http_code < 300) {
app_log('INFO', "[OK] Email sent to {$to_email}: {$subject}");
return true;
}
app_log('ERROR', "[ERROR] Graph sendMail failed (HTTP {$http_code}): {$response}");
return false;
}
/**
* Build the HTML email body for a quote submission notification.
*
* Matches the exact template from the Python email_service.py implementation.
*
* @param string $company_name Company name.
* @param string $contact_name Contact name.
* @param string $contact_email Contact email address.
* @param string|null $contact_phone Contact phone number.
* @param string $monthly_total Formatted monthly total.
* @param string $setup_total Formatted setup total.
* @param array $items Array of item data (service_name, billing_frequency, unit_price, quantity).
* @param string|null $notes Additional notes from the prospect.
* @return string HTML email body.
*/
function build_quote_notification_html(
string $company_name,
string $contact_name,
string $contact_email,
?string $contact_phone,
string $monthly_total,
string $setup_total,
array $items,
?string $notes = null
): string {
$items_html = '';
foreach ($items as $item) {
$freq = $item['billing_frequency'] ?? 'monthly';
$freq_label = $freq === 'monthly' ? '/mo' : ' (one-time)';
$qty = (int)($item['quantity'] ?? 1);
$price = $item['unit_price'] ?? '0.00';
$line_total = (float)$price * $qty;
$service_name = htmlspecialchars($item['service_name'] ?? '', ENT_QUOTES, 'UTF-8');
$price_formatted = htmlspecialchars($price, ENT_QUOTES, 'UTF-8');
$line_formatted = number_format($line_total, 2, '.', ',');
$items_html .= "
<tr>
<td style=\"padding: 8px 12px; border-bottom: 1px solid #e5e7eb;\">{$service_name}</td>
<td style=\"padding: 8px 12px; border-bottom: 1px solid #e5e7eb; text-align: center;\">{$qty}</td>
<td style=\"padding: 8px 12px; border-bottom: 1px solid #e5e7eb; text-align: right;\">\${$price_formatted}{$freq_label}</td>
<td style=\"padding: 8px 12px; border-bottom: 1px solid #e5e7eb; text-align: right;\">\${$line_formatted}{$freq_label}</td>
</tr>";
}
$notes_section = '';
if ($notes !== null && $notes !== '') {
$notes_escaped = htmlspecialchars($notes, ENT_QUOTES, 'UTF-8');
$notes_section = "
<div style=\"margin-top: 20px; padding: 12px 16px; background: #f8f9fb; border-radius: 8px;\">
<strong style=\"color: #333d49;\">Notes:</strong>
<p style=\"margin: 4px 0 0; color: #555;\">{$notes_escaped}</p>
</div>";
}
$phone_line = $contact_phone ? '<br>Phone: ' . htmlspecialchars($contact_phone, ENT_QUOTES, 'UTF-8') : '';
$contact_name_escaped = htmlspecialchars($contact_name, ENT_QUOTES, 'UTF-8');
$company_escaped = htmlspecialchars($company_name, ENT_QUOTES, 'UTF-8');
$email_escaped = htmlspecialchars($contact_email, ENT_QUOTES, 'UTF-8');
$setup_section = '';
if ((float)($setup_total ?? 0) > 0) {
$setup_section = "<div style='background: #fff7ed; border-radius: 8px; padding: 12px 20px; margin-bottom: 20px;'><span style=\"color: #9a3412; font-size: 14px;\">One-Time Costs: <strong>\${$setup_total}</strong></span></div>";
}
return "
<div style=\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;\">
<div style=\"background: linear-gradient(135deg, #333d49, #113559); padding: 24px 32px; border-radius: 12px 12px 0 0;\">
<h1 style=\"color: white; margin: 0; font-size: 22px;\">New Quote Submission</h1>
<p style=\"color: rgba(255,255,255,0.7); margin: 4px 0 0; font-size: 14px;\">Arizona Computer Guru - MSP Quote Wizard</p>
</div>
<div style=\"padding: 24px 32px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 12px 12px;\">
<div style=\"margin-bottom: 20px;\">
<h2 style=\"color: #333d49; font-size: 18px; margin: 0 0 8px;\">Contact Information</h2>
<p style=\"margin: 0; color: #555; line-height: 1.6;\">
<strong>{$contact_name_escaped}</strong><br>
{$company_escaped}<br>
Email: <a href=\"mailto:{$email_escaped}\">{$email_escaped}</a>
{$phone_line}
</p>
</div>
<div style=\"background: linear-gradient(135deg, #333d49, #113559); border-radius: 8px; padding: 16px 20px; margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;\">
<span style=\"color: rgba(255,255,255,0.8); font-size: 14px;\">Monthly Total</span>
<span style=\"color: white; font-size: 24px; font-weight: bold;\">\${$monthly_total}/mo</span>
</div>
{$setup_section}
<h3 style=\"color: #333d49; font-size: 16px; margin: 20px 0 8px;\">Services</h3>
<table style=\"width: 100%; border-collapse: collapse; font-size: 14px;\">
<thead>
<tr style=\"background: #f8f9fb;\">
<th style=\"padding: 8px 12px; text-align: left; color: #333d49;\">Service</th>
<th style=\"padding: 8px 12px; text-align: center; color: #333d49;\">Qty</th>
<th style=\"padding: 8px 12px; text-align: right; color: #333d49;\">Unit Price</th>
<th style=\"padding: 8px 12px; text-align: right; color: #333d49;\">Total</th>
</tr>
</thead>
<tbody>
{$items_html}
</tbody>
</table>
{$notes_section}
<div style=\"margin-top: 24px; padding-top: 16px; border-top: 2px solid #fe7400; text-align: center;\">
<p style=\"color: #999; font-size: 12px; margin: 0;\">
Submitted via <a href=\"https://azcomputerguru.com/quote\" style=\"color: #fe7400;\">azcomputerguru.com/quote</a>
</p>
</div>
</div>
</div>
";
}

View File

@@ -1,45 +0,0 @@
<?php
/**
* Syncro RMM integration service (stub).
*
* This is a placeholder for the SyncroRMM lead creation and customer
* lookup functionality. The full implementation will be added when
* Syncro API credentials and endpoint details are finalized.
*/
// Deny direct access
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
http_response_code(403);
exit('Direct access denied.');
}
require_once __DIR__ . '/../helpers.php';
/**
* Sync a quote to SyncroRMM as a lead.
*
* Checks for an existing customer by email/business name, then creates
* a lead in Syncro with the quote details.
*
* @param PDO $db Database connection.
* @param array $quote Quote row from database.
* @return array Result with keys: synced, is_existing_customer, syncro_lead_id, error
*/
function sync_quote_to_syncro(PDO $db, array $quote): array
{
$result = [
'synced' => false,
'is_existing_customer' => false,
'syncro_lead_id' => null,
'error' => 'Syncro integration not yet configured',
];
if (empty($quote['contact_email'])) {
$result['error'] = 'Quote has no contact email';
return $result;
}
app_log('INFO', "Syncro sync requested for quote {$quote['id']} - integration not yet configured");
return $result;
}

View File

@@ -1,130 +0,0 @@
-- ==========================================================================
-- MSP Quote Wizard - Database Schema
-- Target: MySQL 5.7+ / MariaDB 10.3+ on cPanel
-- Database: azcomputerguru_acg2025
-- Table prefix: acgq_
-- ==========================================================================
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- --------------------------------------------------------------------------
-- Quotes table - main quote records
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `acgq_quotes` (
`id` CHAR(36) NOT NULL,
`access_token` VARCHAR(64) NOT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'draft',
`company_name` VARCHAR(255) DEFAULT NULL,
`contact_name` VARCHAR(255) DEFAULT NULL,
`contact_email` VARCHAR(255) DEFAULT NULL,
`contact_phone` VARCHAR(50) DEFAULT NULL,
`employee_count` INT DEFAULT NULL,
`industry` VARCHAR(100) DEFAULT NULL,
`current_it_situation` TEXT DEFAULT NULL,
`monthly_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`setup_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`expires_at` DATETIME DEFAULT NULL,
`submitted_at` DATETIME DEFAULT NULL,
`ip_address` VARCHAR(45) DEFAULT NULL,
`user_agent` TEXT DEFAULT NULL,
`source` VARCHAR(50) DEFAULT 'website',
`utm_source` VARCHAR(100) DEFAULT NULL,
`utm_medium` VARCHAR(100) DEFAULT NULL,
`utm_campaign` VARCHAR(100) DEFAULT NULL,
`syncro_lead_id` VARCHAR(100) DEFAULT NULL,
`syncro_synced_at` DATETIME DEFAULT NULL,
`is_existing_customer` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_quotes_access_token` (`access_token`),
INDEX `idx_quotes_access_token` (`access_token`),
INDEX `idx_quotes_status` (`status`),
INDEX `idx_quotes_contact_email` (`contact_email`),
INDEX `idx_quotes_created_at` (`created_at`),
CONSTRAINT `ck_quotes_status` CHECK (
`status` IN ('draft', 'submitted', 'viewed', 'followed_up', 'converted', 'expired', 'archived')
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------------------------
-- Quote items table - line items within a quote
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `acgq_quote_items` (
`id` CHAR(36) NOT NULL,
`quote_id` CHAR(36) NOT NULL,
`category` VARCHAR(50) NOT NULL,
`product_code` VARCHAR(50) NOT NULL,
`product_name` VARCHAR(255) NOT NULL,
`description` TEXT DEFAULT NULL,
`quantity` INT NOT NULL DEFAULT 1,
`unit_price` DECIMAL(10,2) NOT NULL,
`setup_price` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`billing_frequency` VARCHAR(20) NOT NULL DEFAULT 'monthly',
`tier` VARCHAR(50) DEFAULT NULL,
`is_recommended` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_quote_items_quote_id` (`quote_id`),
INDEX `idx_quote_items_category` (`category`),
CONSTRAINT `fk_quote_items_quote` FOREIGN KEY (`quote_id`)
REFERENCES `acgq_quotes` (`id`) ON DELETE CASCADE,
CONSTRAINT `ck_quote_items_category` CHECK (
`category` IN ('gps_monitoring', 'support_plan', 'voip', 'web_hosting', 'email', 'hardware', 'addon', 'backup', 'security', 'other')
),
CONSTRAINT `ck_quote_items_billing_frequency` CHECK (
`billing_frequency` IN ('monthly', 'yearly', 'one_time')
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------------------------
-- Quote activity table - audit log of all actions on a quote
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `acgq_quote_activity` (
`id` CHAR(36) NOT NULL,
`quote_id` CHAR(36) NOT NULL,
`action` VARCHAR(50) NOT NULL,
`step_name` VARCHAR(50) DEFAULT NULL,
`details` TEXT DEFAULT NULL,
`ip_address` VARCHAR(45) DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_quote_activity_quote_id` (`quote_id`),
INDEX `idx_quote_activity_action` (`action`),
INDEX `idx_quote_activity_created_at` (`created_at`),
CONSTRAINT `fk_quote_activity_quote` FOREIGN KEY (`quote_id`)
REFERENCES `acgq_quotes` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------------------------
-- Quote notifications table - tracks emails and webhooks sent
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `acgq_quote_notifications` (
`id` CHAR(36) NOT NULL,
`quote_id` CHAR(36) NOT NULL,
`notification_type` VARCHAR(30) NOT NULL,
`recipient` VARCHAR(255) NOT NULL,
`subject` VARCHAR(255) DEFAULT NULL,
`body` TEXT DEFAULT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'pending',
`attempts` INT NOT NULL DEFAULT 0,
`last_attempt_at` DATETIME DEFAULT NULL,
`sent_at` DATETIME DEFAULT NULL,
`error_message` TEXT DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_quote_notifications_quote_id` (`quote_id`),
INDEX `idx_quote_notifications_type` (`notification_type`),
INDEX `idx_quote_notifications_status` (`status`),
CONSTRAINT `fk_quote_notifications_quote` FOREIGN KEY (`quote_id`)
REFERENCES `acgq_quotes` (`id`) ON DELETE CASCADE,
CONSTRAINT `ck_quote_notifications_type` CHECK (
`notification_type` IN ('email', 'webhook')
),
CONSTRAINT `ck_quote_notifications_status` CHECK (
`status` IN ('pending', 'sent', 'failed')
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SET FOREIGN_KEY_CHECKS = 1;

View File

@@ -1,10 +0,0 @@
{
"status": "complete",
"version": 1,
"timestamp": "2026-03-09T12:00:00.000Z",
"files_written": [
"prompts/app_spec.txt",
"prompts/initializer_prompt.md"
],
"feature_count": 141
}

View File

@@ -1,533 +0,0 @@
<project_specification>
<project_name>MSP Quote Wizard</project_name>
<overview>
An interactive quotation wizard embedded on azcomputerguru.com that guides prospects through MSP service selection, generates proposals with pricing, and syncs leads to SyncroRMM. Features a 7-step linear wizard with expandable educational content, real-time price calculations, and admin dashboard for lead management.
</overview>
<technology_stack>
<frontend>
<framework>React 19 + TypeScript</framework>
<build_tool>Vite</build_tool>
<styling>Tailwind CSS v4 (GuruRMM glassmorphism design system)</styling>
<state_management>React Context + useReducer</state_management>
<api_client>Axios + React Query</api_client>
<animations>Framer Motion</animations>
<icons>Lucide React</icons>
</frontend>
<backend>
<runtime>Python 3.11+ (FastAPI)</runtime>
<database>MariaDB 10.6</database>
<orm>SQLAlchemy</orm>
<api_host>172.16.3.30:8001 (extend existing ClaudeTools API)</api_host>
</backend>
<communication>
<api>RESTful JSON API</api>
<auth>JWT for admin endpoints, token-based for public quote access</auth>
</communication>
<integrations>
<crm>SyncroRMM API (https://computerguru.syncromsp.com/api/v1)</crm>
<email>SMTP or SendGrid for notifications</email>
<pdf>WeasyPrint or Puppeteer for quote generation</pdf>
</integrations>
</technology_stack>
<prerequisites>
<environment_setup>
- Node.js 20+ for frontend development
- Python 3.11+ for backend
- Access to MariaDB at 172.16.3.30:3306
- SyncroRMM API credentials
- SMTP credentials for email notifications
</environment_setup>
</prerequisites>
<feature_count>141</feature_count>
<security_and_access_control>
<user_roles>
<role name="public">
<permissions>
- Can create and view their own quotes via access token
- Can submit quotes with contact information
- Can view/download PDF of their quote
- Cannot access admin endpoints
</permissions>
<protected_routes>
- /api/quotes/* (public with token)
</protected_routes>
</role>
<role name="admin">
<permissions>
- Can view all quotes
- Can update quote status
- Can view analytics/stats
- Can manually sync to SyncroRMM
- Can configure notification settings
</permissions>
<protected_routes>
- /api/admin/* (JWT required)
- /admin/* pages in GuruRMM dashboard
</protected_routes>
</role>
</user_roles>
<authentication>
<method>Token-based for public quotes, JWT for admin</method>
<session_timeout>24 hours for quote tokens, standard JWT for admin</session_timeout>
<quote_expiry>30 days after creation</quote_expiry>
</authentication>
<sensitive_operations>
- Quote submission triggers SyncroRMM sync
- Admin status changes are logged
- Email/phone validation before sync
</sensitive_operations>
<seo>
- noindex, nofollow meta tags on quote wizard
- X-Robots-Tag header on hosting server
</seo>
</security_and_access_control>
<core_features>
<wizard_navigation>
- Progress bar showing current step and completion
- Step indicators with clickable navigation (for completed steps)
- Next/Back buttons with validation
- Step transition animations
- Auto-save draft on step change
- Resume incomplete quote via token
- Mobile-responsive step layout
</wizard_navigation>
<step_1_company_profile>
- Company name input (optional)
- Number of endpoints/employees input
- Industry dropdown selection
- "What brings you here today?" textarea (optional)
- Form validation with helpful messages
- Auto-create quote draft on entry
</step_1_company_profile>
<step_2_gps_monitoring>
- Three tier pricing cards (Basic $19, Pro $26, Advanced $39)
- Expandable feature descriptions for each tier
- Quantity input tied to endpoint count from Step 1
- Equipment monitoring add-on toggle ($25/mo base + $3/device)
- Real-time price calculation display
- Tier comparison table (expandable)
- Recommended tier highlight based on company size
</step_2_gps_monitoring>
<step_3_support_plan>
- Four tier pricing cards (Essential $200, Standard $380, Premium $540, Priority $850)
- Included hours and response time display
- Effective hourly rate calculation
- Prepaid block time option (10hr/$1500, 20hr/$2600, 30hr/$3000)
- Expandable details for each tier
- Recommendation based on endpoint count
</step_3_support_plan>
<step_4_voip>
- Toggle: "Need business phones?"
- Skip step if toggle is off
- Four VoIP tier cards (Basic $22, Standard $28, Pro $35, CallCenter $55)
- User count input
- Hardware options with quantity selectors
- Basic Desk Phone (T53W) $219
- Business Desk Phone (T54W) $279
- Executive Phone (T57W) $359
- Conference Phone (CP920) $599
- Wireless Headset (WH62) $159
- Cordless Phone (W73P) $199
- Add-on services (DID, toll-free, SMS, fax, Teams)
- Real-time total calculation
</step_4_voip>
<step_5_web_email>
- Web hosting toggle with tier selection
- Starter $15 (5GB, 1 site)
- Business $35 (25GB, 5 sites)
- Commerce $65 (50GB, unlimited)
- Email provider choice (expandable comparison)
- WHM Email ($2-20/mailbox based on storage)
- Microsoft 365 Basic $7, Standard $14, Premium $24
- Exchange Online $5
- Email user count input
- Add-ons: email security $3/mailbox, dedicated IP $5, SSL $6.25
</step_5_web_email>
<step_6_summary>
- Itemized breakdown by category
- Monthly recurring total (prominent display)
- One-time/setup costs (separate section)
- Edit buttons to revisit any step
- Collapsible category sections
- Savings highlight if applicable
- Print-friendly view option
</step_6_summary>
<step_7_contact>
- Contact name (required)
- Email address (required, validated)
- Phone number (recommended, formatted)
- Company name (pre-filled from Step 1)
- Current IT situation textarea
- Preferred contact method selection
- Terms acceptance checkbox
- Submit button with loading state
- Duplicate email check against SyncroRMM
- Success confirmation with quote reference
</step_7_contact>
<expandable_info>
- Collapsible info cards throughout wizard
- "Learn more" buttons for each feature
- Smooth expand/collapse animations
- Feature definitions in plain language
- Use case examples
- Comparison tables within expandables
</expandable_info>
<pricing_calculations>
- Real-time total updates as selections change
- Category subtotals
- One-time vs recurring separation
- Quantity-based calculations
- Add-on aggregation
- Discount display (if applicable)
</pricing_calculations>
<quote_api_public>
- POST /api/quotes - Create new quote (returns access_token)
- GET /api/quotes/{token} - Get quote by access token
- PUT /api/quotes/{token} - Update quote (wizard progress)
- POST /api/quotes/{token}/submit - Finalize and submit
- GET /api/quotes/{token}/pdf - Generate PDF
- Rate limiting for public endpoints
</quote_api_public>
<quote_api_admin>
- GET /api/admin/quotes - List all quotes (paginated, filterable)
- GET /api/admin/quotes/{id} - Get quote details
- PUT /api/admin/quotes/{id} - Update status, add notes
- GET /api/admin/quotes/stats - Dashboard analytics
- POST /api/admin/quotes/{id}/sync-syncro - Manual sync
</quote_api_admin>
<syncro_integration>
- Duplicate check via GET /customers?email={email}
- Lead creation via POST /leads
- Quote details in ticket_description
- Sync status tracking
- Error handling for API failures
- Manual retry capability
</syncro_integration>
<notifications>
- Customer confirmation email with quote link
- Admin alert email on new submission
- Email templates with branding
- Quote PDF attachment option
- Webhook support for automation
</notifications>
<admin_dashboard>
- Quote listing with filters (status, date, value)
- Search by company/contact/email
- Quote detail view with full breakdown
- Activity timeline per quote
- Status management (draft, submitted, followed_up, converted)
- SyncroRMM sync status indicator
- Basic analytics (conversion funnel, popular services)
</admin_dashboard>
<pdf_generation>
- Professional quote document
- Company branding (logo, colors)
- Itemized service breakdown
- Terms and conditions
- Validity period display
- Contact information
</pdf_generation>
</core_features>
<database_schema>
<tables>
<quotes>
- id (UUID, PK)
- company_name (VARCHAR 255, nullable)
- contact_name (VARCHAR 255, not null)
- contact_email (VARCHAR 255, not null)
- contact_phone (VARCHAR 50, nullable)
- employee_count (INT, nullable)
- industry (VARCHAR 100, nullable)
- current_it_situation (TEXT, nullable)
- status (ENUM: draft, submitted, viewed, followed_up, converted, expired)
- access_token (VARCHAR 64, unique, not null)
- monthly_total (DECIMAL 10,2)
- setup_total (DECIMAL 10,2)
- syncro_lead_id (VARCHAR 100, nullable)
- syncro_synced_at (DATETIME, nullable)
- is_existing_customer (BOOLEAN, default false)
- source (VARCHAR 50, default 'website')
- utm_source, utm_medium, utm_campaign (VARCHAR 100 each)
- ip_address (VARCHAR 45)
- user_agent (TEXT)
- created_at, updated_at, submitted_at, expires_at (DATETIME)
</quotes>
<quote_items>
- id (UUID, PK)
- quote_id (UUID, FK to quotes, cascade delete)
- category (ENUM: gps_monitoring, support_plan, voip, web_hosting, email, hardware, addon)
- product_code (VARCHAR 50, not null)
- product_name (VARCHAR 255, not null)
- description (TEXT, nullable)
- quantity (INT, default 1)
- unit_price (DECIMAL 10,2, not null)
- setup_price (DECIMAL 10,2, default 0)
- billing_frequency (ENUM: monthly, yearly, one_time)
- tier (VARCHAR 50, nullable)
- is_recommended (BOOLEAN, default false)
- created_at (DATETIME)
</quote_items>
<quote_activity>
- id (UUID, PK)
- quote_id (UUID, FK to quotes, cascade delete)
- action (VARCHAR 50, not null: created, step_completed, submitted, viewed, pdf_generated, synced_syncro, status_changed)
- step_name (VARCHAR 50, nullable)
- details (JSON, nullable)
- ip_address (VARCHAR 45, nullable)
- created_at (DATETIME)
</quote_activity>
<quote_notifications>
- id (UUID, PK)
- quote_id (UUID, FK to quotes, cascade delete)
- notification_type (ENUM: email, webhook)
- recipient (VARCHAR 255, not null)
- subject (VARCHAR 255, nullable)
- body (TEXT, nullable)
- status (ENUM: pending, sent, failed)
- attempts (INT, default 0)
- last_attempt_at, sent_at (DATETIME, nullable)
- error_message (TEXT, nullable)
- created_at (DATETIME)
</quote_notifications>
</tables>
</database_schema>
<api_endpoints_summary>
<public_quotes>
- POST /api/quotes (create quote, returns token)
- GET /api/quotes/{token} (get quote by token)
- PUT /api/quotes/{token} (update quote)
- POST /api/quotes/{token}/submit (finalize)
- GET /api/quotes/{token}/pdf (generate PDF)
</public_quotes>
<admin_quotes>
- GET /api/admin/quotes (list with filters)
- GET /api/admin/quotes/{id} (detail view)
- PUT /api/admin/quotes/{id} (update status/notes)
- GET /api/admin/quotes/stats (analytics)
- POST /api/admin/quotes/{id}/sync-syncro (manual sync)
</admin_quotes>
<syncro_proxy>
- GET /api/syncro/check-customer?email={email} (duplicate check)
</syncro_proxy>
</api_endpoints_summary>
<ui_layout>
<main_structure>
Full-width wizard container with centered content (max-width 1200px).
Progress bar at top showing 7 steps.
Main content area with current step.
Fixed bottom navigation (Back/Next buttons).
Running total display in corner/sidebar on desktop.
</main_structure>
<wizard_step_layout>
Step title with icon.
Optional subtitle/description.
Main content area (cards, forms, selections).
Expandable info sections.
Step-specific help text.
</wizard_step_layout>
<pricing_card_layout>
Card with tier name and price header.
Feature list with checkmarks.
"Most Popular" badge for recommended tier.
Select button at bottom.
Expandable "Learn more" section.
</pricing_card_layout>
<admin_layout>
Integrated into existing GuruRMM dashboard.
Left sidebar navigation (add "Quotes" menu item).
Main content area with quote listing.
Slide-out panel for quick view.
Full page for quote details.
</admin_layout>
</ui_layout>
<design_system>
<color_palette>
Match azcomputerguru.com website theme:
- Primary Dark: #333d49 (dark blue-gray)
- Accent Orange: #fe7400 (call-to-action, highlights)
- Navy: #113559 (headers, dark elements)
- White: #ffffff (backgrounds, text on dark)
- Black: #000000 (text)
- Gray: #4d4d4d (secondary text)
</color_palette>
<typography>
- Font Family: Lexend (Google Fonts) - same as main website
- Headings: Bold weight, navy or dark
- Body: Regular weight, gray/black
- Prices: Bold, larger size, orange accent (#fe7400)
</typography>
<effects>
- Clean, professional cards with subtle shadows
- Smooth transitions (200ms)
- Orange hover effects on buttons
- Progress bar with orange fill
- Step transition slides
- Consistent with main website aesthetic
</effects>
</design_system>
<implementation_steps>
<step number="1">
<title>Foundation - Database and API Setup</title>
<tasks>
- Create database migration for quote tables
- Build SQLAlchemy models (Quote, QuoteItem, QuoteActivity, QuoteNotification)
- Create Pydantic schemas for request/response
- Implement QuoteService with CRUD operations
- Build public quote endpoints (/api/quotes/*)
- Add token generation and validation
</tasks>
</step>
<step number="2">
<title>Frontend Project Setup</title>
<tasks>
- Initialize Vite + React + TypeScript project
- Configure Tailwind CSS v4 with GuruRMM design tokens
- Copy/adapt UI components from GuruRMM (Button, Card, Input)
- Set up React Router for wizard navigation
- Configure Axios + React Query for API calls
- Create pricing data constants from MSP pricing docs
</tasks>
</step>
<step number="3">
<title>Wizard Core Implementation</title>
<tasks>
- Build WizardContainer with progress tracking
- Implement WizardProgress component
- Create each step component (Steps 1-7)
- Build pricing card components
- Implement quantity selectors and toggles
- Wire up quote creation/update API calls
- Add form validation for each step
</tasks>
</step>
<step number="4">
<title>Educational Content and Polish</title>
<tasks>
- Build ExpandableInfo component
- Add feature descriptions and comparisons
- Implement tier comparison tables
- Add Framer Motion animations
- Ensure mobile responsiveness
- Add loading states and error handling
</tasks>
</step>
<step number="5">
<title>Integrations</title>
<tasks>
- Build SyncroService for API integration
- Implement duplicate customer check
- Create lead in SyncroRMM on submit
- Build NotificationService for emails
- Create email templates
- Implement PDF generation
</tasks>
</step>
<step number="6">
<title>Admin Dashboard</title>
<tasks>
- Add admin API endpoints
- Build quote listing page in GuruRMM
- Create quote detail view
- Implement filters and search
- Add status management
- Build basic analytics view
</tasks>
</step>
<step number="7">
<title>Deployment and Website Link</title>
<tasks>
- Build production frontend bundle
- Deploy to quote.azcomputerguru.com or ClaudeTools server
- Add noindex meta tags to quote wizard
- Configure CORS for API access
- Add "Get a Quote" button/link on azcomputerguru.com
- End-to-end testing
</tasks>
</step>
</implementation_steps>
<success_criteria>
<functionality>
- Complete wizard flow from start to submission
- All pricing calculations accurate
- Quote saved to database with all items
- SyncroRMM lead created on submission
- Email notifications sent
- PDF generation works
- Admin can view and manage all quotes
</functionality>
<user_experience>
- Wizard intuitive for non-technical users
- Expandable info provides education without cluttering
- Progress clearly visible at all times
- Mobile-friendly on all devices
- Fast loading and responsive interactions
</user_experience>
<technical_quality>
- No mock data - all real database operations
- Proper error handling throughout
- API validation on both client and server
- Secure token-based quote access
- Rate limiting on public endpoints
</technical_quality>
<design_polish>
- Matches GuruRMM design system
- Consistent glassmorphism styling
- Smooth animations and transitions
- Professional appearance suitable for business
</design_polish>
</success_criteria>
<pricing_data_reference>
<source_files>
- /projects/msp-pricing/docs/gps-pricing-structure.md
- /projects/msp-pricing/docs/voip-pricing-structure.md
- /projects/msp-pricing/docs/web-email-hosting-pricing.md
</source_files>
</pricing_data_reference>
</project_specification>

View File

@@ -1,523 +0,0 @@
## YOUR ROLE - INITIALIZER AGENT (Session 1 of Many)
You are the FIRST agent in a long-running autonomous development process.
Your job is to set up the foundation for all future coding agents.
### FIRST: Read the Project Specification
Start by reading `app_spec.txt` in your working directory. This file contains
the complete specification for what you need to build. Read it carefully
before proceeding.
---
## REQUIRED FEATURE COUNT
**CRITICAL:** You must create exactly **141** features using the `feature_create_bulk` tool.
This number was determined during spec creation and must be followed precisely. Do not create more or fewer features than specified.
---
### CRITICAL FIRST TASK: Create Features
Based on `app_spec.txt`, create features using the feature_create_bulk tool. The features are stored in a SQLite database,
which is the single source of truth for what needs to be built.
**Creating Features:**
Use the feature_create_bulk tool to add all features at once:
```
Use the feature_create_bulk tool with features=[
{
"category": "functional",
"name": "Brief feature name",
"description": "Brief description of the feature and what this test verifies",
"steps": [
"Step 1: Navigate to relevant page",
"Step 2: Perform action",
"Step 3: Verify expected result"
]
},
{
"category": "style",
"name": "Brief feature name",
"description": "Brief description of UI/UX requirement",
"steps": [
"Step 1: Navigate to page",
"Step 2: Take screenshot",
"Step 3: Verify visual requirements"
]
}
]
```
**Notes:**
- IDs and priorities are assigned automatically based on order
- All features start with `passes: false` by default
- You can create features in batches if there are many (e.g., 50 at a time)
**Requirements for features:**
- Feature count must match the `feature_count` specified in app_spec.txt
- Reference tiers for other projects:
- **Simple apps**: ~150 tests
- **Medium apps**: ~250 tests
- **Complex apps**: ~400+ tests
- Both "functional" and "style" categories
- Mix of narrow tests (2-5 steps) and comprehensive tests (10+ steps)
- At least 25 tests MUST have 10+ steps each (more for complex apps)
- Order features by priority: fundamental features first (the API assigns priority based on order)
- All features start with `passes: false` automatically
- Cover every feature in the spec exhaustively
- **MUST include tests from ALL 20 mandatory categories below**
---
## MANDATORY TEST CATEGORIES
The feature_list.json **MUST** include tests from ALL of these categories. The minimum counts scale by complexity tier.
### Category Distribution by Complexity Tier
| Category | Simple | Medium | Complex |
| -------------------------------- | ------- | ------- | -------- |
| A. Security & Access Control | 5 | 20 | 40 |
| B. Navigation Integrity | 15 | 25 | 40 |
| C. Real Data Verification | 20 | 30 | 50 |
| D. Workflow Completeness | 10 | 20 | 40 |
| E. Error Handling | 10 | 15 | 25 |
| F. UI-Backend Integration | 10 | 20 | 35 |
| G. State & Persistence | 8 | 10 | 15 |
| H. URL & Direct Access | 5 | 10 | 20 |
| I. Double-Action & Idempotency | 5 | 8 | 15 |
| J. Data Cleanup & Cascade | 5 | 10 | 20 |
| K. Default & Reset | 5 | 8 | 12 |
| L. Search & Filter Edge Cases | 8 | 12 | 20 |
| M. Form Validation | 10 | 15 | 25 |
| N. Feedback & Notification | 8 | 10 | 15 |
| O. Responsive & Layout | 8 | 10 | 15 |
| P. Accessibility | 8 | 10 | 15 |
| Q. Temporal & Timezone | 5 | 8 | 12 |
| R. Concurrency & Race Conditions | 5 | 8 | 15 |
| S. Export/Import | 5 | 6 | 10 |
| T. Performance | 5 | 5 | 10 |
| **TOTAL** | **150** | **250** | **400+** |
---
### A. Security & Access Control Tests
Test that unauthorized access is blocked and permissions are enforced.
**Required tests (examples):**
- Unauthenticated user cannot access protected routes (redirect to login)
- Regular user cannot access admin-only pages (403 or redirect)
- API endpoints return 401 for unauthenticated requests
- API endpoints return 403 for unauthorized role access
- Session expires after configured inactivity period
- Logout clears all session data and tokens
- Invalid/expired tokens are rejected
- Each role can ONLY see their permitted menu items
- Direct URL access to unauthorized pages is blocked
- Sensitive operations require confirmation or re-authentication
- Cannot access another user's data by manipulating IDs in URL
- Password reset flow works securely
- Failed login attempts are handled (no information leakage)
### B. Navigation Integrity Tests
Test that every button, link, and menu item goes to the correct place.
**Required tests (examples):**
- Every button in sidebar navigates to correct page
- Every menu item links to existing route
- All CRUD action buttons (Edit, Delete, View) go to correct URLs with correct IDs
- Back button works correctly after each navigation
- Deep linking works (direct URL access to any page with auth)
- Breadcrumbs reflect actual navigation path
- 404 page shown for non-existent routes (not crash)
- After login, user redirected to intended destination (or dashboard)
- After logout, user redirected to login page
- Pagination links work and preserve current filters
- Tab navigation within pages works correctly
- Modal close buttons return to previous state
- Cancel buttons on forms return to previous page
### C. Real Data Verification Tests
Test that data is real (not mocked) and persists correctly.
**Required tests (examples):**
- Create a record via UI with unique content → verify it appears in list
- Create a record → refresh page → record still exists
- Create a record → log out → log in → record still exists
- Edit a record → verify changes persist after refresh
- Delete a record → verify it's gone from list AND database
- Delete a record → verify it's gone from related dropdowns
- Filter/search → results match actual data created in test
- Dashboard statistics reflect real record counts (create 3 items, count shows 3)
- Reports show real aggregated data
- Export functionality exports actual data you created
- Related records update when parent changes
- Timestamps are real and accurate (created_at, updated_at)
- Data created by User A is not visible to User B (unless shared)
- Empty state shows correctly when no data exists
### D. Workflow Completeness Tests
Test that every workflow can be completed end-to-end through the UI.
**Required tests (examples):**
- Every entity has working Create operation via UI form
- Every entity has working Read/View operation (detail page loads)
- Every entity has working Update operation (edit form saves)
- Every entity has working Delete operation (with confirmation dialog)
- Every status/state has a UI mechanism to transition to next state
- Multi-step processes (wizards) can be completed end-to-end
- Bulk operations (select all, delete selected) work
- Cancel/Undo operations work where applicable
- Required fields prevent submission when empty
- Form validation shows errors before submission
- Successful submission shows success feedback
- Backend workflow (e.g., user→customer conversion) has UI trigger
### E. Error Handling Tests
Test graceful handling of errors and edge cases.
**Required tests (examples):**
- Network failure shows user-friendly error message, not crash
- Invalid form input shows field-level errors
- API errors display meaningful messages to user
- 404 responses handled gracefully (show not found page)
- 500 responses don't expose stack traces or technical details
- Empty search results show "no results found" message
- Loading states shown during all async operations
- Timeout doesn't hang the UI indefinitely
- Submitting form with server error keeps user data in form
- File upload errors (too large, wrong type) show clear message
- Duplicate entry errors (e.g., email already exists) are clear
### F. UI-Backend Integration Tests
Test that frontend and backend communicate correctly.
**Required tests (examples):**
- Frontend request format matches what backend expects
- Backend response format matches what frontend parses
- All dropdown options come from real database data (not hardcoded)
- Related entity selectors (e.g., "choose category") populated from DB
- Changes in one area reflect in related areas after refresh
- Deleting parent handles children correctly (cascade or block)
- Filters work with actual data attributes from database
- Sort functionality sorts real data correctly
- Pagination returns correct page of real data
- API error responses are parsed and displayed correctly
- Loading spinners appear during API calls
- Optimistic updates (if used) rollback on failure
### G. State & Persistence Tests
Test that state is maintained correctly across sessions and tabs.
**Required tests (examples):**
- Refresh page mid-form - appropriate behavior (data kept or cleared)
- Close browser, reopen - session state handled correctly
- Same user in two browser tabs - changes sync or handled gracefully
- Browser back after form submit - no duplicate submission
- Bookmark a page, return later - works (with auth check)
- LocalStorage/cookies cleared - graceful re-authentication
- Unsaved changes warning when navigating away from dirty form
### H. URL & Direct Access Tests
Test direct URL access and URL manipulation security.
**Required tests (examples):**
- Change entity ID in URL - cannot access others' data
- Access /admin directly as regular user - blocked
- Malformed URL parameters - handled gracefully (no crash)
- Very long URL - handled correctly
- URL with SQL injection attempt - rejected/sanitized
- Deep link to deleted entity - shows "not found", not crash
- Query parameters for filters are reflected in UI
- Sharing a URL with filters preserves those filters
### I. Double-Action & Idempotency Tests
Test that rapid or duplicate actions don't cause issues.
**Required tests (examples):**
- Double-click submit button - only one record created
- Rapid multiple clicks on delete - only one deletion occurs
- Submit form, hit back, submit again - appropriate behavior
- Multiple simultaneous API calls - server handles correctly
- Refresh during save operation - data not corrupted
- Click same navigation link twice quickly - no issues
- Submit button disabled during processing
### J. Data Cleanup & Cascade Tests
Test that deleting data cleans up properly everywhere.
**Required tests (examples):**
- Delete parent entity - children removed from all views
- Delete item - removed from search results immediately
- Delete item - statistics/counts updated immediately
- Delete item - related dropdowns updated
- Delete item - cached views refreshed
- Soft delete (if applicable) - item hidden but recoverable
- Hard delete - item completely removed from database
### K. Default & Reset Tests
Test that defaults and reset functionality work correctly.
**Required tests (examples):**
- New form shows correct default values
- Date pickers default to sensible dates (today, not 1970)
- Dropdowns default to correct option (or placeholder)
- Reset button clears to defaults, not just empty
- Clear filters button resets all filters to default
- Pagination resets to page 1 when filters change
- Sorting resets when changing views
### L. Search & Filter Edge Cases
Test search and filter functionality thoroughly.
**Required tests (examples):**
- Empty search shows all results (or appropriate message)
- Search with only spaces - handled correctly
- Search with special characters (!@#$%^&\*) - no errors
- Search with quotes - handled correctly
- Search with very long string - handled correctly
- Filter combinations that return zero results - shows message
- Filter + search + sort together - all work correctly
- Filter persists after viewing detail and returning to list
- Clear individual filter - works correctly
- Search is case-insensitive (or clearly case-sensitive)
### M. Form Validation Tests
Test all form validation rules exhaustively.
**Required tests (examples):**
- Required field empty - shows error, blocks submit
- Email field with invalid email formats - shows error
- Password field - enforces complexity requirements
- Numeric field with letters - rejected
- Date field with invalid date - rejected
- Min/max length enforced on text fields
- Min/max values enforced on numeric fields
- Duplicate unique values rejected (e.g., duplicate email)
- Error messages are specific (not just "invalid")
- Errors clear when user fixes the issue
- Server-side validation matches client-side
- Whitespace-only input rejected for required fields
### N. Feedback & Notification Tests
Test that users get appropriate feedback for all actions.
**Required tests (examples):**
- Every successful save/create shows success feedback
- Every failed action shows error feedback
- Loading spinner during every async operation
- Disabled state on buttons during form submission
- Progress indicator for long operations (file upload)
- Toast/notification disappears after appropriate time
- Multiple notifications don't overlap incorrectly
- Success messages are specific (not just "Success")
### O. Responsive & Layout Tests
Test that the UI works on different screen sizes.
**Required tests (examples):**
- Desktop layout correct at 1920px width
- Tablet layout correct at 768px width
- Mobile layout correct at 375px width
- No horizontal scroll on any standard viewport
- Touch targets large enough on mobile (44px min)
- Modals fit within viewport on mobile
- Long text truncates or wraps correctly (no overflow)
- Tables scroll horizontally if needed on mobile
- Navigation collapses appropriately on mobile
### P. Accessibility Tests
Test basic accessibility compliance.
**Required tests (examples):**
- Tab navigation works through all interactive elements
- Focus ring visible on all focused elements
- Screen reader can navigate main content areas
- ARIA labels on icon-only buttons
- Color contrast meets WCAG AA (4.5:1 for text)
- No information conveyed by color alone
- Form fields have associated labels
- Error messages announced to screen readers
- Skip link to main content (if applicable)
- Images have alt text
### Q. Temporal & Timezone Tests
Test date/time handling.
**Required tests (examples):**
- Dates display in user's local timezone
- Created/updated timestamps accurate and formatted correctly
- Date picker allows only valid date ranges
- Overdue items identified correctly (timezone-aware)
- "Today", "This Week" filters work correctly for user's timezone
- Recurring items generate at correct times (if applicable)
- Date sorting works correctly across months/years
### R. Concurrency & Race Condition Tests
Test multi-user and race condition scenarios.
**Required tests (examples):**
- Two users edit same record - last save wins or conflict shown
- Record deleted while another user viewing - graceful handling
- List updates while user on page 2 - pagination still works
- Rapid navigation between pages - no stale data displayed
- API response arrives after user navigated away - no crash
- Concurrent form submissions from same user handled
### S. Export/Import Tests (if applicable)
Test data export and import functionality.
**Required tests (examples):**
- Export all data - file contains all records
- Export filtered data - only filtered records included
- Import valid file - all records created correctly
- Import duplicate data - handled correctly (skip/update/error)
- Import malformed file - error message, no partial import
- Export then import - data integrity preserved exactly
### T. Performance Tests
Test basic performance requirements.
**Required tests (examples):**
- Page loads in <3s with 100 records
- Page loads in <5s with 1000 records
- Search responds in <1s
- Infinite scroll doesn't degrade with many items
- Large file upload shows progress
- Memory doesn't leak on long sessions
- No console errors during normal operation
---
## ABSOLUTE PROHIBITION: NO MOCK DATA
The feature_list.json must include tests that **actively verify real data** and **detect mock data patterns**.
**Include these specific tests:**
1. Create unique test data (e.g., "TEST_12345_VERIFY_ME")
2. Verify that EXACT data appears in UI
3. Refresh page - data persists
4. Delete data - verify it's gone
5. If data appears that wasn't created during test - FLAG AS MOCK DATA
**The agent implementing features MUST NOT use:**
- Hardcoded arrays of fake data
- `mockData`, `fakeData`, `sampleData`, `dummyData` variables
- `// TODO: replace with real API`
- `setTimeout` simulating API delays with static data
- Static returns instead of database queries
---
**CRITICAL INSTRUCTION:**
IT IS CATASTROPHIC TO REMOVE OR EDIT FEATURES IN FUTURE SESSIONS.
Features can ONLY be marked as passing (via the `feature_mark_passing` tool with the feature_id).
Never remove features, never edit descriptions, never modify testing steps.
This ensures no functionality is missed.
### SECOND TASK: Create init.sh
Create a script called `init.sh` that future agents can use to quickly
set up and run the development environment. The script should:
1. Install any required dependencies
2. Start any necessary servers or services
3. Print helpful information about how to access the running application
Base the script on the technology stack specified in `app_spec.txt`.
### THIRD TASK: Initialize Git
Create a git repository and make your first commit with:
- init.sh (environment setup script)
- README.md (project overview and setup instructions)
- Any initial project structure files
Note: Features are stored in the SQLite database (features.db), not in a JSON file.
Commit message: "Initial setup: init.sh, project structure, and features created via API"
### FOURTH TASK: Create Project Structure
Set up the basic project structure based on what's specified in `app_spec.txt`.
This typically includes directories for frontend, backend, and any other
components mentioned in the spec.
### OPTIONAL: Start Implementation
If you have time remaining in this session, you may begin implementing
the highest-priority features. Get the next feature with:
```
Use the feature_get_next tool
```
Remember:
- Work on ONE feature at a time
- Test thoroughly before marking as passing
- Commit your progress before session ends
### ENDING THIS SESSION
Before your context fills up:
1. Commit all work with descriptive messages
2. Create `claude-progress.txt` with a summary of what you accomplished
3. Verify features were created using the feature_get_stats tool
4. Leave the environment in a clean, working state
The next agent will continue from here with a fresh context window.
---
**Remember:** You have unlimited time across many sessions. Focus on
quality over speed. Production-ready is the goal.

View File

@@ -1,209 +0,0 @@
# MSP Quote Wizard Session Log - 2026-03-09
## Session Summary
Major deployment session for the MSP Quote Wizard. Started from code pulled from MacBook Air (commit a1a19f8), reviewed the full project, fixed 15+ backend model/schema mismatches, deployed frontend to azcomputerguru.com/quote on IX cPanel, debugged and fixed PHP reverse proxy, and applied comprehensive responsive design fixes to all wizard components.
### Key Accomplishments
1. Full backend model alignment with MariaDB schema (12+ field/table/enum fixes)
2. Frontend deployed to production at https://azcomputerguru.com/quote/
3. PHP reverse proxy debugged and fixed (CURLOPT_FOLLOWLOCATION for FastAPI 307 redirects)
4. Comprehensive responsive design fixes across all 9 wizard components
5. End-to-end API flow verified: create -> get -> add item -> submit
### Key Decisions
- Used PHP curl reverse proxy instead of direct API exposure (API on 172.16.3.30:8001, frontend on IX 172.16.3.10)
- Made contact_name/contact_email nullable in DB to support draft quotes
- Wrapped QuoteActivity details in JSON for MariaDB json_valid() CHECK constraint
- Used `CURLOPT_FOLLOWLOCATION` to handle FastAPI trailing-slash 307 redirects
- SSH to IX requires `-o IdentitiesOnly=yes -i ~/.ssh/id_ed25519` as root (too many keys causes auth failure)
---
## Infrastructure
### Servers
- **API Server:** 172.16.3.30:8001 (FastAPI/Uvicorn, production ClaudeTools API)
- **IX Server (Hosting):** 172.16.3.10 (cPanel/WHM, Apache, PHP 8.1.33)
- SSH: `ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@172.16.3.10`
- Root password: Gptf*77ttb!@#!@#
- Site path: /home/azcomputerguru/public_html/quote/
- cPanel account: azcomputerguru
- **Database:** 172.16.3.30:3306 / MariaDB 10.6.22
- DB: claudetools
- User: claudetools
- Password: CT_e8fcd5a3952030a79ed6debae6c954ed
### Deployment Architecture
```
Browser -> Cloudflare -> IX (172.16.3.10:443)
-> /quote/ -> index.html (SPA)
-> /quote/api/* -> .htaccess rewrite -> api-proxy.php -> curl -> 172.16.3.30:8001/api/*
```
### Files on IX (/home/azcomputerguru/public_html/quote/)
- index.html - SPA entry point
- assets/ - JS/CSS bundles
- api-proxy.php - PHP reverse proxy to API
- .htaccess - Rewrite rules (API proxy + SPA routing)
---
## Backend Fixes Applied
### Model Alignment (api/models/quote.py)
- Status enum: draft/submitted/viewed/followed_up/converted/expired (was reviewing/approved/rejected)
- ServiceCategory enum: gps_monitoring/support_plan/voip/web_hosting/email/hardware/addon
- BillingFrequency enum: monthly/yearly/one_time (was quarterly/annual)
- NotificationType enum: email/webhook (was email_sent/sms_sent/admin_alert/reminder_sent)
- Removed columns: notes, admin_notes, annual_total (don't exist in DB)
- Fixed reserved word: metadata -> details (SQLAlchemy reserves metadata)
- Fixed table name: quote_activities -> quote_activity
- Removed TimestampMixin from QuoteItem/QuoteActivity/QuoteNotification (no updated_at)
- Made contact_name/contact_email Optional for draft support
- QuoteItem fields: service_name->product_name, setup_fee->setup_price, is_required->is_recommended, added product_code/tier, removed sort_order
### Database ALTERs Applied
```sql
ALTER TABLE quotes MODIFY contact_name VARCHAR(255) NULL;
ALTER TABLE quotes MODIFY contact_email VARCHAR(255) NULL;
```
### Service Layer (api/services/quote_service.py)
- calculate_totals() returns (monthly, setup) tuple (removed annual)
- log_activity() wraps details in json.dumps({"message": details}) for json_valid() constraint
- Removed all references to notes/admin_notes/annual_total
- Syncro API key moved to env var SYNCRO_API_KEY
- Admin email from env var ADMIN_NOTIFICATION_EMAIL
### API Routers
- api/routers/quotes.py - 6 public endpoints (create, get, update, add item, remove item, submit)
- api/routers/admin_quotes.py - 5 admin endpoints (list, stats, detail, update status, sync-syncro)
- Both registered in api/main.py
### Dependencies Installed on Production
```bash
pip install email-validator httpx
```
---
## Frontend Changes
### Vite Config
- base: '/quote/' for subdirectory deployment
- build.outDir and sourcemap: false
### API Client (src/lib/api.ts)
- Complete rewrite to match actual backend endpoints
- Exports: createQuote, getQuote, updateQuote, addQuoteItem, removeQuoteItem, submitQuote, getQuotePdf
### Responsive Design Fixes (Applied 2026-03-09)
All wizard components updated for mobile-first responsive design:
**WizardContainer.tsx:**
- Running totals bar: responsive padding (p-2.5 sm:p-4), text sizes (text-lg sm:text-2xl)
- Step header: responsive padding (px-4 sm:px-6 md:px-8), icon sizes, truncation
- Content area: responsive padding
**Step1CompanyProfile.tsx:**
- Endpoint count input: flex-col on mobile, w-full sm:w-32
**Step2GPSMonitoring.tsx:**
- Tier grid: grid-cols-1 sm:grid-cols-2 md:grid-cols-3
- Equipment section: flex-shrink-0 on toggle, min-w-0 on text, responsive text sizes
- Monthly total: responsive text (text-2xl sm:text-3xl), whitespace-nowrap
**Step3SupportPlan.tsx:**
- Plan grid: grid-cols-1 sm:grid-cols-2 lg:grid-cols-4
- Block time grid: grid-cols-1 sm:grid-cols-3
- Toggle headers: flex-shrink-0, min-w-0, responsive text sizes
- Monthly total: responsive sizing
**Step4VoIP.tsx:**
- Toggle header: responsive icon/text sizes, flex-shrink-0
- User count: flex-col sm:flex-row, w-full sm:w-24
- Tier grid: grid-cols-1 sm:grid-cols-2 lg:grid-cols-4
- Hardware items: completely restructured - stacked layout with flex-wrap controls
- Monthly total: responsive sizing
**Step5WebEmail.tsx:**
- All tier grids: sm:grid-cols-2 md:grid-cols-3 (was md:grid-cols-3 only)
- Toggle headers: responsive icon/text/padding, flex-shrink-0
- Mailbox count: flex-col sm:flex-row
- Monthly total: responsive sizing
**Step6Summary.tsx:**
- Grand total: flex-col sm:flex-row for monthly investment header
- Text: text-3xl sm:text-4xl
- SummarySection header: responsive padding, truncation, flex-shrink-0
**Step7Contact.tsx:**
- Quote preview: flex-col sm:flex-row, responsive text
- Contact preferences: flex-wrap
- Trust indicators: flex-col sm:flex-row (was grid-cols-1 md:grid-cols-3)
---
## PHP Reverse Proxy (api-proxy.php)
### Key Fix: CURLOPT_FOLLOWLOCATION
FastAPI returns 307 redirects for trailing-slash URLs. PHP curl doesn't follow redirects by default, causing empty response bodies. Fixed by adding:
```php
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_MAXREDIRS, 5);
```
### Important: Host Header Required
When testing from internal network, must use `Host: azcomputerguru.com` header. Direct IP access (172.16.3.10) hits wrong Apache vhost and PHP doesn't execute. Browser access works fine since it sends correct Host header.
```bash
# WORKS:
curl -s -H "Host: azcomputerguru.com" "http://172.16.3.10/quote/api/quotes" -X POST -H "Content-Type: application/json" -d '{"employee_count":5}'
# FAILS (wrong vhost):
curl -s "http://172.16.3.10/quote/api/quotes" -X POST ...
```
---
## Pending/Next Steps
1. **Frontend polish:** Run through wizard in browser to visually verify responsive fixes
2. **Admin dashboard:** No admin UI yet for viewing submitted quotes (admin API endpoints exist)
3. **Email notifications:** ADMIN_NOTIFICATION_EMAIL env var needs to be set on production
4. **Syncro integration:** SYNCRO_API_KEY env var needs to be set for lead sync
5. **Remove debug endpoint:** Already done (removed _debug path from api-proxy.php)
6. **SSL/CORS:** Currently CORS is wide open (Access-Control-Allow-Origin: *) - consider restricting
7. **Quote PDF generation:** Endpoint exists but likely needs implementation
8. **Production env vars to set:**
- ADMIN_NOTIFICATION_EMAIL
- SYNCRO_API_KEY
- SYNCRO_API_BASE_URL (defaults to computerguru.syncromsp.com)
---
## Commands Reference
### Deploy frontend to IX
```bash
cd D:/ClaudeTools/projects/msp-tools/quote-wizard/frontend
npm run build
scp -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 -r dist/index.html dist/assets/ root@172.16.3.10:/home/azcomputerguru/public_html/quote/
ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@172.16.3.10 'chown -R azcomputerguru:azcomputerguru /home/azcomputerguru/public_html/quote/'
```
### Deploy api-proxy.php
```bash
scp -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 dist/api-proxy.php root@172.16.3.10:/home/azcomputerguru/public_html/quote/api-proxy.php
```
### Test API through proxy
```bash
curl -s -H "Host: azcomputerguru.com" -X POST -H "Content-Type: application/json" -d '{"employee_count":5}' "http://172.16.3.10/quote/api/quotes"
```
### Test API directly
```bash
curl -s -X POST -H "Content-Type: application/json" -d '{"employee_count":5}' "http://172.16.3.30:8001/api/quotes/"
```

View File

@@ -1,91 +0,0 @@
# Quote Wizard Session - 2026-03-13
## Summary
Fixed multiple bugs in the MSP Quote Wizard and deployed updates to IX server.
## Bugs Fixed
### 1. Amount Mismatch Bug ($710 displayed vs $330 saved)
**Root Cause:** `calculateQuote()` in useQuote.ts was including GPS and Support costs unconditionally, without checking `serviceInterests` flags. GPS and Support were enabled by default in `initialServiceInterests`.
**Fix:** Updated `calculateQuote()` to check `serviceInterests.gps` and `serviceInterests.support` before including those costs:
```typescript
const gpsMonthly = serviceInterests.gps ? getGPSMonthly() : 0;
const supportMonthly = serviceInterests.support ? getSupportMonthly() : 0;
```
**Files Changed:**
- `frontend/src/hooks/useQuote.ts` - calculateQuote() now respects serviceInterests
- `frontend/src/components/wizard/steps/Step6Summary.tsx` - Conditionally render GPS/Support sections
### 2. Email Sender Name ("Seafile Noreply")
**Root Cause:** Graph API sendMail was using the mailbox's configured display name instead of specifying one in the API call.
**Fix:** Added `from` and `replyTo` fields to the Graph API request:
```php
'from' => [
'emailAddress' => [
'name' => 'Arizona Computer Guru',
'address' => GRAPH_SENDER_EMAIL,
],
],
'replyTo' => [
[
'emailAddress' => [
'name' => 'Arizona Computer Guru',
'address' => 'admin@azcomputerguru.com',
],
],
],
```
**Files Changed:**
- `php-api/api/config.php` - Added GRAPH_SENDER_NAME, GRAPH_REPLY_TO_EMAIL, GRAPH_REPLY_TO_NAME
- `php-api/api/services/email_service.php` - Added from/replyTo to sendMail payload
### 3. Submit Button Not Disabled
**Issue:** Submit button was clickable before terms checkbox was selected.
**Fix:** Added `!contactInfo.agreedToTerms` to disabled condition:
```typescript
disabled={isSubmitting || !contactInfo.agreedToTerms}
```
**Files Changed:**
- `frontend/src/components/wizard/steps/Step7Contact.tsx`
## Deployments
### Frontend (React)
- Built with Vite: `npm run build`
- Deployed to IX: `/home/azcomputerguru/public_html/quote/`
- Latest bundle: `index-DunPq78q.js`
### Backend (PHP)
- Updated config.php with email settings
- Updated email_service.php with from/replyTo fields
- Files deployed via SCP to IX server
## Database Verification
Confirmed quotes are saving correctly:
- Test Two: $295 (GPS $260 + Web Hosting $35) - NO phantom support charge
- Mike Test: $330 (GPS $260 + M365 $70)
## Configuration
- Reply-to email: admin@azcomputerguru.com
- Sender display name: Arizona Computer Guru
- Admin notification: mike@azcomputerguru.com
## Git Commits
- `c629890` - fix: Quote wizard - correct total calculation and email sender
- `c79c81e` - sync: Submit button disabled until terms agreed
## SSH Access (from Mac)
Working command:
```bash
ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@172.16.3.10
```
## Notes
- Browser caching may show old JS bundle - hard refresh required (Cmd+Shift+R)
- Cloudflare blocks curl requests to API - browser access works fine

View File

@@ -1,76 +0,0 @@
# Syncro API — RMM / Policy Management capabilities (research)
- **Date:** 2026-06-16
- **By:** Mike (GURU-5070) + Claude
- **Question:** Does the Syncro (SyncroMSP) public REST API expose the **RMM** side of the
product — specifically **policy management** (push AV like Bitdefender, manage monitors/
scripts/patch policies, assign policies to assets)?
- **Status:** Research only — NOT yet folded into the `/syncro` skill (per Mike). Verify token
scope before relying on `/policy_folders`.
## TL;DR
**No — RMM policy *management* is NOT exposed by the Syncro API.** The API exposes the policy
**folder hierarchy** (organizational only) plus **read-only RMM inventory** (assets, patch status,
installed apps) and **RMM alerts**. There is **no** endpoint to run scripts, send remote commands,
start a remote session, deploy software, install patches, or edit a policy's content (monitors/
scripts/patch schedule/AV). Those are **UI-/agent-only**. (This is exactly the gap GuruRMM fills,
and why a Bitdefender push goes through the Syncro UI/policy, not the API.)
## Method (authoritative)
Endpoint *probing* alone was misleading (guessed wrong path names → false 404s). The definitive
source is Syncro's own **OpenAPI 3.0.0 spec**:
- Docs UI: `https://api-docs.syncromsp.com/` (Swagger UI)
- **Raw spec:** `https://api-docs.syncromsp.com/swagger.json` (~470 KB) — read this, not the UI.
- Live probes against `https://computerguru.syncromsp.com/api/v1` confirmed behavior + the 401.
(Grok live-web pass returned empty — its known finalization quirk; the published spec is
authoritative anyway, so no second-model opinion was needed.)
## The ENTIRE RMM surface in the spec
Searching all paths in `swagger.json` for polic/script/rmm/run/command/remote/agent/patch/install
returns exactly:
| Path | Methods | Notes |
|---|---|---|
| `/policy_folders`, `/policy_folders/{id}` | GET, POST, PUT, DELETE | **folder hierarchy only** (see below) |
| `/customer_assets`, `/{id}` | GET, POST, PUT | RMM-managed devices (`asset_type: "Syncro Device"`) |
| `/customer_assets/{id}/patches` | GET | Windows patch data (read) — **works (200)** |
| `/customer_assets/{id}/installed_applications` | GET | installed apps (read) — **works (200)** |
| `/rmm_alerts`, `/{id}`, `/{id}/mute` | GET, POST, mute, DELETE | RMM alert read/create/mute/clear |
**That is the whole list.** No `/scripts`, no run-script, no `/remote_sessions`, no agent/command
endpoint exists anywhere in the spec.
## The decisive detail — `/policy_folders` is organization, not policy content
`POST /policy_folders` request body accepts only **three fields**:
```
customer_id # which customer the folder belongs to
name # folder name
parent_id # parent folder (for nesting)
```
So you can create/rename/nest/delete the **policy-folder tree** and tie a folder to a customer —
but there is **no field** for the policy's content (monitors, scripts, patch schedule, AV/
Bitdefender), and **no way to assign a policy to an asset** via the API. RMM policy *definition*
and *assignment* remain UI-only.
## Gotcha — token scope
`GET /policy_folders` returned **HTTP 401** with the same API token that returns 200 for
assets/patches/alerts → the endpoint exists but the token lacks the **policy permission scope**.
Enable it per-token in **Syncro Admin → API Tokens** before even the folder CRUD will work.
## What you CAN automate against Syncro RMM today
- **Read** asset inventory, **Windows patch status**, and **installed applications** per device
(reporting, drift detection, "who's missing patch X").
- **RMM alerts:** list / create / mute / clear.
- **Policy folders:** create/rename/nest/delete (e.g. auto-create a folder per new customer) —
*after* enabling the token's policy scope.
## What you CANNOT (must use the Syncro UI / agent, or GuruRMM)
- Run scripts / send remote commands / open remote sessions on agents.
- Deploy software (e.g. Bitdefender), trigger patch installs, reboot.
- Create/edit policy **content** (monitors, scripts, patch policy, AV) or assign policies to assets.
## Next steps (if we want to act on this)
- Decide whether to enable the policy scope on a dedicated token and script the folder hierarchy.
- For programmatic endpoint actions (scripts/commands/deploy), route through **GuruRMM**, not Syncro.
- Fold the API-capability boundary into the `/syncro` skill once reviewed (Mike: hold for now).

View File

@@ -1,27 +0,0 @@
@echo off
REM Check current status of ClaudeTools API on RMM server
echo ============================================================
echo ClaudeTools API Status Check
echo ============================================================
echo.
echo [1] API Service Status:
plink guru@172.16.3.30 "sudo systemctl status claudetools-api --no-pager | head -15"
echo.
echo [2] Current Code Version (checking for search_term parameter):
plink guru@172.16.3.30 "grep -c 'search_term.*Query' /opt/claudetools/api/routers/conversation_contexts.py"
echo (0 = OLD CODE, 1+ = NEW CODE)
echo.
echo [3] File Last Modified:
plink guru@172.16.3.30 "ls -lh /opt/claudetools/api/routers/conversation_contexts.py"
echo.
echo [4] API Response Format:
python -c "import requests; jwt='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJpbXBvcnQtc2NyaXB0Iiwic2NvcGVzIjpbImFkbWluIiwiaW1wb3J0Il0sImV4cCI6MTc3MTI3NTEyOX0.-DJF50tq0MaNwVQBdO7cGYNuO5pQuXte-tTj5DpHi2U'; r=requests.get('http://172.16.3.30:8001/api/conversation-contexts/recall', headers={'Authorization': f'Bearer {jwt}'}, params={'limit': 1}); print(f'Response keys: {list(r.json().keys())}'); print('Format: NEW' if 'contexts' in r.json() else 'Format: OLD')"
echo.
echo ============================================================
pause

View File

@@ -1,141 +0,0 @@
# CIPP - Add Claude-MSP-Access as Auto-Consent App Template
# This adds Claude's app to CIPP so it gets automatically consented
# when you add new tenants via CIPP.
#
# Uses the CIPP API (ClaudeCipp2 credentials)
$ErrorActionPreference = "Stop"
$cippUrl = "https://cippcanvb.azurewebsites.net"
$cippTenantId = "ce61461e-81a0-4c84-bb4a-7b354a9a356d"
$cippClientId = "420cb849-542d-4374-9cb2-3d8ae0e1835b"
$cippClientSecret = "MOn8Q~otmxJPLvmL~_aCVTV8Va4t4~SrYrukGbJT"
$cippScope = "api://420cb849-542d-4374-9cb2-3d8ae0e1835b/.default"
$claudeAppId = "fabb3421-8b34-484b-bc17-e46de9703418"
Write-Output "========================================="
Write-Output " CIPP - Add Claude-MSP-Access Template"
Write-Output " $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Output "========================================="
# --- STEP 1: Get CIPP API token ---
Write-Output "`n[STEP 1] Getting CIPP API token..."
$tokenBody = @{
client_id = $cippClientId
client_secret = $cippClientSecret
scope = $cippScope
grant_type = "client_credentials"
}
$tokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$cippTenantId/oauth2/v2.0/token" -Method POST -Body $tokenBody
$token = $tokenResponse.access_token
Write-Output "[OK] Got CIPP API token"
$headers = @{
"Authorization" = "Bearer $token"
"Content-Type" = "application/json"
}
# --- STEP 2: Check existing app approval templates ---
Write-Output "`n[STEP 2] Checking existing app approval templates..."
try {
$existing = Invoke-RestMethod -Uri "$cippUrl/api/ExecAppPermissionTemplate" -Headers $headers -Method GET
Write-Output "[INFO] Found $($existing.Count) existing template(s)"
foreach ($tmpl in $existing) {
Write-Output " - $($tmpl.displayName) ($($tmpl.appId))"
}
} catch {
Write-Output "[INFO] No existing templates or endpoint returned error: $($_.Exception.Message)"
}
# --- STEP 3: Add Claude-MSP-Access as app template ---
Write-Output "`n[STEP 3] Adding Claude-MSP-Access app template..."
# Application permissions Claude needs consented in each customer tenant
$appPermissions = @(
"User.ReadWrite.All",
"Directory.ReadWrite.All",
"Mail.ReadWrite",
"MailboxSettings.ReadWrite",
"AuditLog.Read.All",
"Application.ReadWrite.All",
"DelegatedPermissionGrant.ReadWrite.All",
"Group.ReadWrite.All",
"GroupMember.ReadWrite.All",
"SecurityEvents.ReadWrite.All",
"SecurityEvents.Read.All",
"SecurityIncident.ReadWrite.All",
"AppRoleAssignment.ReadWrite.All",
"UserAuthenticationMethod.ReadWrite.All",
"Organization.ReadWrite.All",
"Domain.Read.All",
"Policy.Read.All",
"Policy.ReadWrite.ConditionalAccess",
"Policy.ReadWrite.AuthenticationMethod",
"Policy.ReadWrite.AuthenticationFlows",
"Policy.ReadWrite.ApplicationConfiguration",
"Policy.ReadWrite.ConsentRequest",
"Policy.ReadWrite.CrossTenantAccess",
"Reports.Read.All",
"ReportSettings.ReadWrite.All",
"Device.ReadWrite.All",
"DeviceManagementApps.ReadWrite.All",
"DeviceManagementConfiguration.ReadWrite.All",
"DeviceManagementManagedDevices.ReadWrite.All",
"DeviceManagementManagedDevices.PrivilegedOperations.All",
"DeviceManagementRBAC.ReadWrite.All",
"DeviceManagementServiceConfig.ReadWrite.All",
"CrossTenantInformation.ReadBasic.All",
"Channel.Create",
"Channel.ReadBasic.All",
"ChannelMember.ReadWrite.All",
"Files.ReadWrite.All",
"Group.Create",
"InformationProtectionPolicy.Read.All",
"Place.Read.All",
"PrivilegedAccess.ReadWrite.AzureADGroup",
"SharePointTenantSettings.ReadWrite.All",
"Sites.FullControl.All",
"TeamMember.ReadWrite.All",
"TeamMember.ReadWriteNonOwnerRole.All",
"TeamsTelephoneNumber.ReadWrite.All"
)
$templateBody = @{
AppId = $claudeAppId
displayName = "Claude-MSP-Access (AI Investigation & Remediation)"
Permissions = $appPermissions
} | ConvertTo-Json -Depth 5
try {
$result = Invoke-RestMethod -Uri "$cippUrl/api/ExecAppPermissionTemplate" -Headers $headers -Method POST -Body $templateBody
Write-Output "[OK] Template added: $($result | ConvertTo-Json -Compress)"
} catch {
$errBody = $_.ErrorDetails.Message
Write-Output "[WARNING] API response: $errBody"
Write-Output "[INFO] If the endpoint doesn't support POST, you can add the template manually:"
Write-Output " CIPP > Settings > Application Approval > Add Application"
Write-Output " App ID: $claudeAppId"
Write-Output " Name: Claude-MSP-Access (AI Investigation & Remediation)"
Write-Output ""
Write-Output "Or use the CIPP UI to navigate to:"
Write-Output " Tenant Administration > Application Approval"
Write-Output " Click 'Add App' and enter the App ID above"
}
# --- STEP 4: Summary ---
Write-Output "`n========================================="
Write-Output " TEMPLATE SETUP SUMMARY"
Write-Output "========================================="
Write-Output ""
Write-Output "App ID: $claudeAppId"
Write-Output "Name: Claude-MSP-Access (AI Investigation & Remediation)"
Write-Output "Perms: $($appPermissions.Count) application permissions"
Write-Output ""
Write-Output "What happens now:"
Write-Output " 1. When you add a new tenant in CIPP, Claude's app gets auto-consented"
Write-Output " 2. For existing tenants, run CPV Refresh in CIPP to push the permissions"
Write-Output " 3. The admin consent URL also works as a manual fallback:"
Write-Output ""
Write-Output " https://login.microsoftonline.com/common/adminconsent?client_id=$claudeAppId&redirect_uri=https://login.microsoftonline.com/common/oauth2/nativeclient"
Write-Output ""

View File

@@ -1,640 +0,0 @@
{
"requiredResourceAccess": [
{
"resourceAppId": "c5393580-f805-4401-95e8-94b7a6ef2fc2",
"resourceAccess": [
{
"id": "594c1fb6-4f81-4475-ae41-0c394909246c",
"type": "Scope"
}
]
},
{
"resourceAppId": "aeb86249-8ea3-49e2-900b-54cc8e308f85",
"resourceAccess": [
{
"id": "fc946a4f-bc4d-413b-a090-b2c86113ec4f",
"type": "Scope"
}
]
},
{
"resourceAppId": "00000003-0000-0000-c000-000000000000",
"resourceAccess": [
{
"id": "1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9",
"type": "Role"
},
{
"id": "b0afded3-3588-46d8-8b3d-9842eff778da",
"type": "Role"
},
{
"id": "5e1e9171-754d-478c-812c-f1755a9a4c2d",
"type": "Role"
},
{
"id": "f3a65bd4-b703-46df-8f7e-0174fea562aa",
"type": "Role"
},
{
"id": "59a6b24b-4225-4393-8165-ebaec5f55d7a",
"type": "Role"
},
{
"id": "35930dcf-aceb-4bd1-b99a-8ffed403c974",
"type": "Role"
},
{
"id": "cac88765-0581-4025-9725-5ebc13f729ee",
"type": "Role"
},
{
"id": "1138cb37-bd11-4084-a2b7-9f71582aeddb",
"type": "Role"
},
{
"id": "78145de6-330d-4800-a6ce-494ff2d33d07",
"type": "Role"
},
{
"id": "9241abd9-d0e6-425a-bd4f-47ba86e767a4",
"type": "Role"
},
{
"id": "5b07b0dd-2377-4e44-a38d-703f09a0dc3c",
"type": "Role"
},
{
"id": "243333ab-4d21-40cb-a475-36241daa0842",
"type": "Role"
},
{
"id": "e330c4f0-4170-414e-a55a-2f022ec2b57b",
"type": "Role"
},
{
"id": "9255e99d-faf5-445e-bbf7-cb71482737c4",
"type": "Role"
},
{
"id": "8b9d79d0-ad75-4566-8619-f7500ecfcebe",
"type": "Scope"
},
{
"id": "5ac13192-7ace-4fcf-b828-1a26f28068ee",
"type": "Role"
},
{
"id": "19dbc75e-c2e2-444c-a770-ec69d8559fc7",
"type": "Role"
},
{
"id": "dbb9058a-0e50-45d7-ae91-66909b5d4664",
"type": "Role"
},
{
"id": "75359482-378d-4052-8f01-80520e7db3cd",
"type": "Role"
},
{
"id": "bf7b1a76-6e77-406b-b258-bf5c7720e98f",
"type": "Role"
},
{
"id": "62a82d76-70ea-41e2-9197-370581804d09",
"type": "Role"
},
{
"id": "dbaae8cf-10b5-4b86-a4a1-f871c94c6695",
"type": "Role"
},
{
"id": "19da66cb-0fb0-4390-b071-ebc76a349482",
"type": "Role"
},
{
"id": "6931bccd-447a-43d1-b442-00a195474933",
"type": "Role"
},
{
"id": "292d869f-3427-49a8-9dab-8c70152b74e9",
"type": "Role"
},
{
"id": "2cb92fee-97a3-4034-8702-24a6f5d0d1e9",
"type": "Role"
},
{
"id": "b6890674-9dd5-4e42-bb15-5af07f541ae1",
"type": "Role"
},
{
"id": "913b9306-0ce1-42b8-9137-6a7df690a760",
"type": "Role"
},
{
"id": "246dd0d5-5bd0-4def-940b-0421030a5b68",
"type": "Role"
},
{
"id": "be74164b-cff1-491c-8741-e671cb536e13",
"type": "Role"
},
{
"id": "25f85f3c-f66c-4205-8cd5-de92dd7f0cec",
"type": "Role"
},
{
"id": "29c18626-4985-4dcd-85c0-193eef327366",
"type": "Role"
},
{
"id": "01c0a623-fc9b-48e9-b794-0756f8e8f067",
"type": "Role"
},
{
"id": "999f8c63-0a38-4f1b-91fd-ed1947bdd1a9",
"type": "Role"
},
{
"id": "338163d7-f101-4c92-94ba-ca46fe52447c",
"type": "Role"
},
{
"id": "2f6817f8-7b12-4f0f-bc18-eeaf60705a9e",
"type": "Role"
},
{
"id": "230c1aed-a721-4c5d-9cb4-a90514e508ef",
"type": "Role"
},
{
"id": "2a60023f-3219-47ad-baa4-40e17cd02a1d",
"type": "Role"
},
{
"id": "025d3225-3f02-4882-b4c0-cd5b541a4e80",
"type": "Role"
},
{
"id": "04c55753-2244-4c25-87fc-704ab82a4f69",
"type": "Role"
},
{
"id": "bf394140-e372-4bf9-a898-299cfc7564e5",
"type": "Role"
},
{
"id": "34bf0e97-1971-4929-b999-9e2442d941d7",
"type": "Role"
},
{
"id": "19b94e34-907c-4f43-bde9-38b1909ed408",
"type": "Role"
},
{
"id": "a82116e5-55eb-4c41-a434-62fe8a61c773",
"type": "Role"
},
{
"id": "0121dc95-1b9f-4aed-8bac-58c5ac466691",
"type": "Role"
},
{
"id": "4437522e-9a86-4a41-a7da-e380edd4a97d",
"type": "Role"
},
{
"id": "741f803b-c850-494e-b5df-cde7c675a1ca",
"type": "Role"
},
{
"id": "50483e42-d915-4231-9639-7fdb7fd190e5",
"type": "Role"
},
{
"id": "bdfbf15f-ee85-4955-8675-146e8e5296b5",
"type": "Scope"
},
{
"id": "84bccea3-f856-4a8a-967b-dbe0a3d53a64",
"type": "Scope"
},
{
"id": "e4c9e354-4dc5-45b8-9e7c-e1393b0b1a20",
"type": "Scope"
},
{
"id": "b27a61ec-b99c-4d6a-b126-c4375d08ae30",
"type": "Scope"
},
{
"id": "101147cf-4178-4455-9d58-02b5c164e759",
"type": "Scope"
},
{
"id": "cc83893a-e232-4723-b5af-bd0b01bcfe65",
"type": "Scope"
},
{
"id": "9d8982ae-4365-4f57-95e9-d6032a4c0b87",
"type": "Scope"
},
{
"id": "2eadaff8-0bce-4198-a6b9-2cfc35a30075",
"type": "Scope"
},
{
"id": "0c3e411a-ce45-4cd1-8f30-f99a3efa7b11",
"type": "Scope"
},
{
"id": "2b61aa8a-6d36-4b2f-ac7b-f29867937c53",
"type": "Scope"
},
{
"id": "767156cb-16ae-4d10-8f8b-41b657c8c8c8",
"type": "Scope"
},
{
"id": "ebf0f66e-9fb1-49e4-a278-222f76911cf4",
"type": "Scope"
},
{
"id": "d649fb7c-72b4-4eec-b2b4-b15acf79e378",
"type": "Scope"
},
{
"id": "f3bfad56-966e-4590-a536-82ecf548ac1e",
"type": "Scope"
},
{
"id": "885f682f-a990-4bad-a642-36736a74b0c7",
"type": "Scope"
},
{
"id": "41ce6ca6-6826-4807-84f1-1c82854f7ee5",
"type": "Scope"
},
{
"id": "bac3b9c2-b516-4ef4-bd3b-c2ef73d8d804",
"type": "Scope"
},
{
"id": "11d4cd79-5ba5-460f-803f-e22c8ab85ccd",
"type": "Scope"
},
{
"id": "951183d1-1a61-466f-a6d1-1fde911bfd95",
"type": "Scope"
},
{
"id": "280b3b69-0437-44b1-bc20-3b2fca1ee3e9",
"type": "Scope"
},
{
"id": "7b3f05d5-f68c-4b8d-8c59-a2ecd12f24af",
"type": "Scope"
},
{
"id": "0883f392-0a7a-443d-8c76-16a6d39c7b63",
"type": "Scope"
},
{
"id": "3404d2bf-2b13-457e-a330-c24615765193",
"type": "Scope"
},
{
"id": "44642bfe-8385-4adc-8fc6-fe3cb2c375c3",
"type": "Scope"
},
{
"id": "0c5e8a55-87a6-4556-93ab-adc52c4d862d",
"type": "Scope"
},
{
"id": "662ed50a-ac44-4eef-ad86-62eed9be2a29",
"type": "Scope"
},
{
"id": "0e263e50-5827-48a4-b97c-d940288653c7",
"type": "Scope"
},
{
"id": "c5366453-9fb0-48a5-a156-24f0c49a4b84",
"type": "Scope"
},
{
"id": "2f9ee017-59c1-4f1d-9472-bd5529a7b311",
"type": "Scope"
},
{
"id": "4e46008b-f24c-477d-8fff-7bb4ec7aafe0",
"type": "Scope"
},
{
"id": "f81125ac-d3b7-4573-a3b2-7099cc39df9e",
"type": "Scope"
},
{
"id": "9e4862a5-b68f-479e-848a-4e07e25c9916",
"type": "Scope"
},
{
"id": "bb6f654c-d7fd-4ae3-85c3-fc380934f515",
"type": "Scope"
},
{
"id": "e0a7cdbb-08b0-4697-8264-0069786e9674",
"type": "Scope"
},
{
"id": "e383f46e-2787-4529-855e-0e479a3ffac0",
"type": "Scope"
},
{
"id": "a367ab51-6b49-43bf-a716-a1fb06d2a174",
"type": "Scope"
},
{
"id": "818c620a-27a9-40bd-a6a5-d96f7d610b4b",
"type": "Scope"
},
{
"id": "f6a3db3e-f7e8-4ed2-a414-557c8c9830be",
"type": "Scope"
},
{
"id": "7427e0e9-2fba-42fe-b0c0-848c9e6a8182",
"type": "Scope"
},
{
"id": "37f7f235-527c-4136-accd-4a02d197296e",
"type": "Scope"
},
{
"id": "46ca0847-7e6b-426e-9775-ea810a948356",
"type": "Scope"
},
{
"id": "346c19ff-3fb2-4e81-87a0-bac9e33990c1",
"type": "Scope"
},
{
"id": "e67e6727-c080-415e-b521-e3f35d5248e9",
"type": "Scope"
},
{
"id": "4c06a06a-098a-4063-868e-5dfee3827264",
"type": "Scope"
},
{
"id": "572fea84-0151-49b2-9301-11cb16974376",
"type": "Scope"
},
{
"id": "b27add92-efb2-4f16-84f5-8108ba77985c",
"type": "Scope"
},
{
"id": "edb72de9-4252-4d03-a925-451deef99db7",
"type": "Scope"
},
{
"id": "7e823077-d88e-468f-a337-e18f1f0e6c7c",
"type": "Scope"
},
{
"id": "edd3c878-b384-41fd-95ad-e7407dd775be",
"type": "Scope"
},
{
"id": "ad902697-1014-4ef5-81ef-2b4301988e8c",
"type": "Scope"
},
{
"id": "4d135e65-66b8-41a8-9f8b-081452c91774",
"type": "Scope"
},
{
"id": "40b534c3-9552-4550-901b-23879c90bcf9",
"type": "Scope"
},
{
"id": "a8ead177-1889-4546-9387-f25e658e2a79",
"type": "Scope"
},
{
"id": "a84a9652-ffd3-496e-a991-22ba5529156a",
"type": "Scope"
},
{
"id": "14dad69e-099b-42c9-810b-d002981feec1",
"type": "Scope"
},
{
"id": "02e97553-ed7b-43d0-ab3c-f8bace0d040c",
"type": "Scope"
},
{
"id": "b955410e-7715-4a88-a940-dfd551018df3",
"type": "Scope"
},
{
"id": "d01b97e9-cbc0-49fe-810a-750afd5527a3",
"type": "Scope"
},
{
"id": "dc38509c-b87d-4da0-bd92-6bec988bac4a",
"type": "Scope"
},
{
"id": "6aedf524-7e1c-45a7-bd76-ded8cab8d0fc",
"type": "Scope"
},
{
"id": "128ca929-1a19-45e6-a3b8-435ec44a36ba",
"type": "Scope"
},
{
"id": "55896846-df78-47a7-aa94-8d3d4442ca7f",
"type": "Scope"
},
{
"id": "eda39fa6-f8cf-4c3c-a909-432c683e4c9b",
"type": "Scope"
},
{
"id": "aa07f155-3612-49b8-a147-6c590df35536",
"type": "Scope"
},
{
"id": "89fe6a52-be36-487e-b7d8-d061c450a026",
"type": "Scope"
},
{
"id": "7825d5d6-6049-4ce7-bdf6-3b8d53f4bcd0",
"type": "Scope"
},
{
"id": "485be79e-c497-4b35-9400-0e3fa7f2a5d4",
"type": "Scope"
},
{
"id": "4a06efd2-f825-4e34-813e-82a57b03d1ee",
"type": "Scope"
},
{
"id": "2104a4db-3a2f-4ea0-9dba-143d457dc666",
"type": "Scope"
},
{
"id": "0e755559-83fb-4b44-91d0-4cc721b9323e",
"type": "Scope"
},
{
"id": "39d65650-9d3e-4223-80db-a335590d027e",
"type": "Scope"
},
{
"id": "a9ff19c2-f369-4a95-9a25-ba9d460efc8e",
"type": "Scope"
},
{
"id": "b98bfd41-87c6-45cc-b104-e2de4f0dafb9",
"type": "Scope"
},
{
"id": "cac97e40-6730-457d-ad8d-4852fddab7ad",
"type": "Scope"
},
{
"id": "73e75199-7c3e-41bb-9357-167164dbb415",
"type": "Scope"
},
{
"id": "637d7bec-b31e-4deb-acc9-24275642a2c9",
"type": "Scope"
},
{
"id": "204e0828-b5ca-4ad8-b9f3-f32a958e7cc4",
"type": "Scope"
},
{
"id": "48971fc1-70d7-4245-af77-0beb29b53ee2",
"type": "Scope"
},
{
"id": "b7887744-6746-4312-813d-72daeaee7e2d",
"type": "Scope"
},
{
"id": "424b07a8-1209-4d17-9fe4-9018a93a1024",
"type": "Scope"
},
{
"id": "0a42382f-155c-4eb1-9bdc-21548ccaa387",
"type": "Role"
},
{
"id": "2d9bd318-b883-40be-9df7-63ec4fcdc424",
"type": "Role"
},
{
"id": "c8948c23-e66b-42db-83fd-770b71ab78d2",
"type": "Role"
},
{
"id": "a94a502d-0281-4d15-8cd2-682ac9362c4c",
"type": "Role"
},
{
"id": "e2a3a72e-5f79-4c64-b1b1-878b674786c9",
"type": "Role"
},
{
"id": "06b708a9-e830-4db3-a914-8e69da51d44f",
"type": "Role"
},
{
"id": "d903a879-88e0-4c09-b0c9-82f6a1333f84",
"type": "Role"
},
{
"id": "8e8e4742-1d95-4f68-9d56-6ee75648c72a",
"type": "Role"
}
]
},
{
"resourceAppId": "fa3d9a0c-3fb0-42cc-9193-47c7ecd2edbd",
"resourceAccess": [
{
"id": "1cebfa2a-fb4d-419e-b5f9-839b4383e05a",
"type": "Scope"
}
]
},
{
"resourceAppId": "00000002-0000-0ff1-ce00-000000000000",
"resourceAccess": [
{
"id": "dc50a0fb-09a3-484d-be87-e023b12c6440",
"type": "Role"
},
{
"id": "ef54d2bf-783f-4e0f-bca1-3210c0444d99",
"type": "Role"
},
{
"id": "f9156939-25cd-4ba8-abfe-7fabcf003749",
"type": "Role"
},
{
"id": "ab4f2b77-0b06-4fc1-a9de-02113fc2ab7c",
"type": "Scope"
},
{
"id": "bbd1ca91-75e0-4814-ad94-9c5dbbae3415",
"type": "Scope"
},
{
"id": "2e83d72d-8895-4b66-9eea-abb43449ab8b",
"type": "Scope"
}
]
},
{
"resourceAppId": "00000003-0000-0ff1-ce00-000000000000",
"resourceAccess": [
{
"id": "56680e0d-d2a3-4ae1-80d8-3c4f2100e3d0",
"type": "Scope"
}
]
},
{
"resourceAppId": "48ac35b8-9aa8-4d74-927d-1f4a14a0b239",
"resourceAccess": [
{
"id": "e60370c1-e451-437e-aa6e-d76df38e5f15",
"type": "Scope"
}
]
},
{
"resourceAppId": "fc780465-2017-40d4-a0c5-307022471b92",
"resourceAccess": [
{
"id": "41269fc5-d04d-4bfd-bce7-43a51cea049a",
"type": "Role"
},
{
"id": "63a677ce-818c-4409-9d12-5c6d2e2a6bfe",
"type": "Scope"
}
]
}
]
}

View File

@@ -1,188 +0,0 @@
# Claude-MSP-Access - Automated Tenant Onboarding
# Onboards a customer tenant with full Claude + CIPP permissions
# No manual intervention required after initial admin consent
#
# Usage: .\claude-msp-onboard-tenant.ps1 -TenantDomain "sonorangreenllc.com"
#
# Prerequisites: Admin consent URL must be clicked first by customer/sysadmin:
# https://login.microsoftonline.com/common/adminconsent?client_id=fabb3421-8b34-484b-bc17-e46de9703418&redirect_uri=https://login.microsoftonline.com/common/oauth2/nativeclient
#
# What this script does after consent:
# 1. Finds the Claude-MSP-Access service principal in the customer tenant
# 2. Activates Exchange Administrator directory role (if not active)
# 3. Assigns Exchange Administrator to Claude's SP (via CIPP Graph proxy)
# 4. Verifies all access: Graph, Exchange, Mail, Security, Intune
param(
[Parameter(Mandatory=$true)]
[string]$TenantDomain
)
$ErrorActionPreference = "Stop"
# --- Credentials ---
$cippUrl = "https://cippcanvb.azurewebsites.net"
$cippTenantId = "ce61461e-81a0-4c84-bb4a-7b354a9a356d"
$cippClientId = "420cb849-542d-4374-9cb2-3d8ae0e1835b"
$cippSecret = "MOn8Q~otmxJPLvmL~_aCVTV8Va4t4~SrYrukGbJT"
$claudeAppId = "fabb3421-8b34-484b-bc17-e46de9703418"
$claudeSecret = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
Write-Output "========================================="
Write-Output " Claude-MSP-Access - Tenant Onboarding"
Write-Output " Tenant: $TenantDomain"
Write-Output " $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Output "========================================="
# --- STEP 1: Get CIPP API token ---
Write-Output "`n[STEP 1] Getting CIPP API token..."
$tokenBody = @{
client_id = $cippClientId
client_secret = $cippSecret
scope = "api://$cippClientId/.default"
grant_type = "client_credentials"
}
$cippToken = (Invoke-RestMethod -Uri "https://login.microsoftonline.com/$cippTenantId/oauth2/v2.0/token" -Method POST -Body $tokenBody).access_token
$cippHeaders = @{ "Authorization" = "Bearer $cippToken" }
Write-Output "[OK] CIPP token acquired"
# --- STEP 2: Find Claude SP in customer tenant via CIPP ---
Write-Output "`n[STEP 2] Finding Claude-MSP-Access service principal..."
$spFilter = [uri]::EscapeDataString("appId eq '$claudeAppId'")
$spResult = Invoke-RestMethod -Uri "$cippUrl/api/ListGraphRequest?TenantFilter=$TenantDomain&Endpoint=servicePrincipals&`$filter=$spFilter" -Headers $cippHeaders
$sp = $spResult.Results | Select-Object -First 1
if (-not $sp) {
Write-Output "[ERROR] Claude-MSP-Access SP not found in $TenantDomain"
Write-Output "[INFO] Has admin consent been completed? Use this URL:"
Write-Output " https://login.microsoftonline.com/common/adminconsent?client_id=$claudeAppId&redirect_uri=https://login.microsoftonline.com/common/oauth2/nativeclient"
exit 1
}
$spId = $sp.id
Write-Output "[OK] Found SP: $($sp.displayName) (ID: $spId)"
# --- STEP 3: Get Exchange Administrator role ID ---
Write-Output "`n[STEP 3] Finding Exchange Administrator role..."
$rolesResult = Invoke-RestMethod -Uri "$cippUrl/api/ListGraphRequest?TenantFilter=$TenantDomain&Endpoint=directoryRoles" -Headers $cippHeaders
$exoRole = $rolesResult.Results | Where-Object { $_.displayName -eq "Exchange Administrator" }
if (-not $exoRole) {
Write-Output "[INFO] Exchange Admin role not activated, activating from template..."
# Exchange Administrator role template ID is always 29232cdf-9323-42fd-ade2-1d097af3e4de
$activateBody = [uri]::EscapeDataString((@{ roleTemplateId = "29232cdf-9323-42fd-ade2-1d097af3e4de" } | ConvertTo-Json -Compress))
$activateResult = Invoke-RestMethod -Uri "$cippUrl/api/ListGraphRequest?TenantFilter=$TenantDomain&Endpoint=directoryRoles&type=POST&body=$activateBody" -Headers $cippHeaders
# Re-fetch roles
$rolesResult = Invoke-RestMethod -Uri "$cippUrl/api/ListGraphRequest?TenantFilter=$TenantDomain&Endpoint=directoryRoles" -Headers $cippHeaders
$exoRole = $rolesResult.Results | Where-Object { $_.displayName -eq "Exchange Administrator" }
}
if (-not $exoRole) {
Write-Output "[ERROR] Could not find or activate Exchange Administrator role"
exit 1
}
$exoRoleId = $exoRole.id
Write-Output "[OK] Exchange Admin role: $exoRoleId"
# --- STEP 4: Assign Exchange Administrator to Claude SP ---
Write-Output "`n[STEP 4] Assigning Exchange Administrator role..."
$assignEndpoint = [uri]::EscapeDataString("directoryRoles/$exoRoleId/members/`$ref")
$assignBody = [uri]::EscapeDataString((@{ "@odata.id" = "https://graph.microsoft.com/v1.0/servicePrincipals/$spId" } | ConvertTo-Json -Compress))
try {
$assignResult = Invoke-RestMethod -Uri "$cippUrl/api/ListGraphRequest?TenantFilter=$TenantDomain&Endpoint=$assignEndpoint&type=POST&body=$assignBody" -Headers $cippHeaders
if ($assignResult.Results.CippStatus -eq "Good") {
Write-Output "[OK] Exchange Administrator assigned to Claude-MSP-Access"
} else {
Write-Output "[INFO] Assignment result: $($assignResult.Results | ConvertTo-Json -Compress)"
}
} catch {
$errMsg = $_.Exception.Message
if ($errMsg -match "already exist") {
Write-Output "[OK] Exchange Administrator already assigned"
} else {
Write-Output "[WARNING] Role assignment: $errMsg"
}
}
# --- STEP 5: Verify Claude API access ---
Write-Output "`n[STEP 5] Verifying Claude-MSP-Access API connectivity..."
# Get tenant ID from CIPP
$selectFields = [uri]::EscapeDataString("id,displayName")
$orgResult = Invoke-RestMethod -Uri "$cippUrl/api/ListGraphRequest?TenantFilter=$TenantDomain&Endpoint=organization&`$select=$selectFields" -Headers $cippHeaders
$customerTenantId = $orgResult.Results[0].id
Write-Output "[INFO] Tenant ID: $customerTenantId"
# Get Claude token for this tenant
$claudeTokenBody = @{
client_id = $claudeAppId
client_secret = $claudeSecret
scope = "https://graph.microsoft.com/.default"
grant_type = "client_credentials"
}
try {
$claudeToken = (Invoke-RestMethod -Uri "https://login.microsoftonline.com/$customerTenantId/oauth2/v2.0/token" -Method POST -Body $claudeTokenBody).access_token
Write-Output "[OK] Claude Graph token acquired"
} catch {
Write-Output "[ERROR] Could not get Claude token - admin consent may not be complete"
Write-Output " $($_.Exception.Message)"
exit 1
}
$claudeHeaders = @{ "Authorization" = "Bearer $claudeToken"; "Content-Type" = "application/json" }
# Test endpoints
$tests = @(
@{ Name = "Users"; Uri = "https://graph.microsoft.com/v1.0/users?`$top=1&`$select=displayName" },
@{ Name = "Security"; Uri = "https://graph.microsoft.com/v1.0/security/alerts?`$top=1" },
@{ Name = "AuditLogs"; Uri = "https://graph.microsoft.com/v1.0/auditLogs/signIns?`$top=1" },
@{ Name = "Policies"; Uri = "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies" },
@{ Name = "Devices"; Uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$top=1" }
)
foreach ($test in $tests) {
try {
$r = Invoke-RestMethod -Uri $test.Uri -Headers $claudeHeaders -ErrorAction Stop
Write-Output " [OK] $($test.Name)"
} catch {
$code = $_.Exception.Response.StatusCode.value__
Write-Output " [FAIL] $($test.Name): HTTP $code"
}
}
# Test Exchange Online REST
Write-Output "`n Testing Exchange Online REST API..."
try {
$exoTokenBody = @{
client_id = $claudeAppId
client_secret = $claudeSecret
scope = "https://outlook.office365.com/.default"
grant_type = "client_credentials"
}
$exoToken = (Invoke-RestMethod -Uri "https://login.microsoftonline.com/$customerTenantId/oauth2/v2.0/token" -Method POST -Body $exoTokenBody).access_token
$exoHeaders = @{ "Authorization" = "Bearer $exoToken"; "Content-Type" = "application/json" }
$invokeUrl = "https://outlook.office365.com/adminapi/beta/$customerTenantId/InvokeCommand"
$getMailbox = @{
CmdletInput = @{
CmdletName = "Get-Mailbox"
Parameters = @{ ResultSize = "1" }
}
} | ConvertTo-Json -Depth 5
$r = Invoke-RestMethod -Uri $invokeUrl -Headers $exoHeaders -Method POST -Body $getMailbox -ErrorAction Stop
Write-Output " [OK] Exchange Online (Get-Mailbox)"
} catch {
Write-Output " [FAIL] Exchange Online: $($_.Exception.Message)"
}
# --- DONE ---
Write-Output "`n========================================="
Write-Output " ONBOARDING COMPLETE: $TenantDomain"
Write-Output " $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Output "========================================="
Write-Output ""
Write-Output "Claude-MSP-Access is fully operational for this tenant."
Write-Output "Capabilities: User mgmt, mail access, security alerts,"
Write-Output "audit logs, conditional access, Intune, Exchange admin,"
Write-Output "litigation hold, and all CIPP SAM operations."

View File

@@ -1,93 +0,0 @@
# Claude-MSP-Access - Update App Registration with Combined CIPP + Investigation Permissions
# App ID: fabb3421-8b34-484b-bc17-e46de9703418
# Partner Tenant: ce61461e-81a0-4c84-bb4a-7b354a9a356d
#
# This script updates the app registration to include:
# - All CIPP SAM required permissions (Graph, Exchange, SharePoint, Intune, PowerBI, Partner Center)
# - Claude investigation extras (Mail.ReadWrite, SecurityEvents.ReadWrite.All, etc.)
#
# After running this, the admin consent URL will grant everything in one click.
$ErrorActionPreference = "Stop"
$tenantId = "ce61461e-81a0-4c84-bb4a-7b354a9a356d"
$appId = "fabb3421-8b34-484b-bc17-e46de9703418"
Write-Output "========================================="
Write-Output " Claude-MSP-Access - Permission Update"
Write-Output " $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Output "========================================="
# --- STEP 1: Connect to Graph ---
Write-Output "`n[STEP 1] Connecting to Microsoft Graph..."
Import-Module Microsoft.Graph.Authentication
Import-Module Microsoft.Graph.Applications
Connect-MgGraph -TenantId $tenantId -Scopes 'Application.ReadWrite.All' -NoWelcome
Write-Output "[OK] Connected to Graph"
# --- STEP 2: Get current app registration ---
Write-Output "`n[STEP 2] Reading current app registration..."
$app = Get-MgApplication -Filter "appId eq '$appId'"
if (-not $app) {
Write-Output "[ERROR] App not found: $appId"
exit 1
}
Write-Output "[OK] Found: $($app.DisplayName) (Object ID: $($app.Id))"
$currentPerms = ($app.RequiredResourceAccess | ForEach-Object { $_.ResourceAccess }).Count
Write-Output "[INFO] Current permission count: $currentPerms"
# --- STEP 3: Load combined manifest ---
Write-Output "`n[STEP 3] Loading combined permission manifest..."
$manifestPath = Join-Path $PSScriptRoot "claude-msp-combined-manifest.json"
$manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json
# Build the requiredResourceAccess array
$resourceAccess = @()
foreach ($resource in $manifest.requiredResourceAccess) {
$accessList = @()
foreach ($access in $resource.resourceAccess) {
$accessList += @{
Id = $access.id
Type = $access.type
}
}
$resourceAccess += @{
ResourceAppId = $resource.resourceAppId
ResourceAccess = $accessList
}
}
$newPerms = ($manifest.requiredResourceAccess | ForEach-Object { $_.resourceAccess }).Count
Write-Output "[INFO] New permission count: $newPerms"
# --- STEP 4: Update app registration ---
Write-Output "`n[STEP 4] Updating app registration..."
Update-MgApplication -ApplicationId $app.Id -RequiredResourceAccess $resourceAccess
Write-Output "[OK] App registration updated with combined permissions"
# --- STEP 5: Verify ---
Write-Output "`n[STEP 5] Verifying update..."
$updated = Get-MgApplication -ApplicationId $app.Id
$updatedPerms = ($updated.RequiredResourceAccess | ForEach-Object { $_.ResourceAccess }).Count
Write-Output "[OK] Verified: $updatedPerms permissions across $($updated.RequiredResourceAccess.Count) resource APIs"
# --- STEP 6: Show admin consent URL ---
Write-Output "`n[STEP 6] Admin consent URL (use this to onboard tenants):"
Write-Output ""
Write-Output " https://login.microsoftonline.com/common/adminconsent?client_id=$appId&redirect_uri=https://login.microsoftonline.com/common/oauth2/nativeclient"
Write-Output ""
Write-Output "[INFO] This single URL now grants ALL permissions:"
Write-Output " - Microsoft Graph (application + delegated)"
Write-Output " - Exchange Online (ManageAsApp + Calendars + Mailbox)"
Write-Output " - SharePoint Online (FullControl)"
Write-Output " - Intune (user_impersonation)"
Write-Output " - PowerBI (Vulnerability.Read)"
Write-Output " - Partner Center (user_impersonation)"
Write-Output " - Office Management API (ActivityFeed.Read)"
Write-Output " - Claude investigation extras (Mail.ReadWrite, SecurityEvents.ReadWrite.All)"
Write-Output "`n========================================="
Write-Output " UPDATE COMPLETE"
Write-Output " $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Output "========================================="
Disconnect-MgGraph

View File

@@ -1,68 +0,0 @@
Write-Output "=== HKCU Excel Addins ==="
$path = "HKCU:\Software\Microsoft\Office\Excel\Addins"
if (Test-Path $path) {
Get-ChildItem $path | ForEach-Object {
Write-Output "`n Key: $($_.PSChildName)"
Get-ItemProperty $_.PSPath | Format-List
}
} else {
Write-Output " Path not found"
}
Write-Output "`n=== HKCU Word Addins ==="
$path = "HKCU:\Software\Microsoft\Office\Word\Addins"
if (Test-Path $path) {
Get-ChildItem $path | ForEach-Object {
Write-Output "`n Key: $($_.PSChildName)"
Get-ItemProperty $_.PSPath | Format-List
}
} else {
Write-Output " Path not found"
}
Write-Output "`n=== HKCU PowerPoint Addins ==="
$path = "HKCU:\Software\Microsoft\Office\PowerPoint\Addins"
if (Test-Path $path) {
Get-ChildItem $path | ForEach-Object {
Write-Output "`n Key: $($_.PSChildName)"
Get-ItemProperty $_.PSPath | Format-List
}
} else {
Write-Output " Path not found"
}
Write-Output "`n=== HKLM Excel Addins ==="
$path = "HKLM:\Software\Microsoft\Office\Excel\Addins"
if (Test-Path $path) {
Get-ChildItem $path | ForEach-Object {
Write-Output "`n Key: $($_.PSChildName)"
Get-ItemProperty $_.PSPath | Format-List
}
} else {
Write-Output " Path not found"
}
Write-Output "`n=== HKLM WOW6432 Excel Addins ==="
$path = "HKLM:\Software\WOW6432Node\Microsoft\Office\Excel\Addins"
if (Test-Path $path) {
Get-ChildItem $path | ForEach-Object {
Write-Output "`n Key: $($_.PSChildName)"
Get-ItemProperty $_.PSPath | Format-List
}
} else {
Write-Output " Path not found"
}
Write-Output "`n=== Search for any Datto/SmartBadge registry entries ==="
$results = reg query "HKCU\Software\Microsoft\Office" /s /f "Datto" 2>&1
$results | ForEach-Object { Write-Output $_ }
$results2 = reg query "HKLM\Software\Microsoft\Office" /s /f "Datto" 2>&1
$results2 | ForEach-Object { Write-Output $_ }
$results3 = reg query "HKLM\Software\WOW6432Node\Microsoft\Office" /s /f "SmartBadge" 2>&1
$results3 | ForEach-Object { Write-Output $_ }
Write-Output "`n=== SmartBadge DLL registration (CLSID) ==="
$results4 = reg query "HKLM\Software\Classes\CLSID" /s /f "SmartBadge" 2>&1
$results4 | Select-Object -First 20 | ForEach-Object { Write-Output $_ }
$results5 = reg query "HKCU\Software\Classes\CLSID" /s /f "SmartBadge" 2>&1
$results5 | Select-Object -First 20 | ForEach-Object { Write-Output $_ }

View File

@@ -1,100 +0,0 @@
Windows Registry Editor Version 5.00
; Datto SmartBadge Add-in Registration for 64-bit Office
; Generated from working installation reference
; === Excel Add-ins ===
[HKEY_LOCAL_MACHINE\Software\Microsoft\Office\Excel\Addins\Datto.SmartBadgeShim]
"FriendlyName"="Datto SmartBadge"
"Description"="SmartBadge for Microsoft Office applications."
"LoadBehavior"=dword:00000003
[HKEY_LOCAL_MACHINE\Software\Microsoft\Office\Excel\Addins\Datto.SmartBadgeShim_CC]
"FriendlyName"="Datto SmartBadge"
"Description"="SmartBadge for Microsoft Office applications."
"LoadBehavior"=dword:00000003
; === Word Add-ins ===
[HKEY_LOCAL_MACHINE\Software\Microsoft\Office\Word\Addins\Datto.SmartBadgeShim]
"FriendlyName"="Datto SmartBadge"
"Description"="SmartBadge for Microsoft Office applications."
"LoadBehavior"=dword:00000003
[HKEY_LOCAL_MACHINE\Software\Microsoft\Office\Word\Addins\Datto.SmartBadgeShim_CC]
"FriendlyName"="Datto SmartBadge"
"Description"="SmartBadge for Microsoft Office applications."
"LoadBehavior"=dword:00000003
; === PowerPoint Add-ins ===
[HKEY_LOCAL_MACHINE\Software\Microsoft\Office\PowerPoint\Addins\Datto.SmartBadgeShim]
"FriendlyName"="Datto SmartBadge"
"Description"="SmartBadge for Microsoft Office applications."
"LoadBehavior"=dword:00000003
[HKEY_LOCAL_MACHINE\Software\Microsoft\Office\PowerPoint\Addins\Datto.SmartBadgeShim_CC]
"FriendlyName"="Datto SmartBadge"
"Description"="SmartBadge for Microsoft Office applications."
"LoadBehavior"=dword:00000003
; === WOW6432Node (32-bit compatibility layer) ===
[HKEY_LOCAL_MACHINE\Software\WOW6432Node\Microsoft\Office\Excel\Addins\Datto.SmartBadgeShim]
"FriendlyName"="Datto SmartBadge"
"Description"="SmartBadge for Microsoft Office applications."
"LoadBehavior"=dword:00000003
[HKEY_LOCAL_MACHINE\Software\WOW6432Node\Microsoft\Office\Excel\Addins\Datto.SmartBadgeShim_CC]
"FriendlyName"="Datto SmartBadge"
"Description"="SmartBadge for Microsoft Office applications."
"LoadBehavior"=dword:00000003
[HKEY_LOCAL_MACHINE\Software\WOW6432Node\Microsoft\Office\Word\Addins\Datto.SmartBadgeShim]
"FriendlyName"="Datto SmartBadge"
"Description"="SmartBadge for Microsoft Office applications."
"LoadBehavior"=dword:00000003
[HKEY_LOCAL_MACHINE\Software\WOW6432Node\Microsoft\Office\Word\Addins\Datto.SmartBadgeShim_CC]
"FriendlyName"="Datto SmartBadge"
"Description"="SmartBadge for Microsoft Office applications."
"LoadBehavior"=dword:00000003
[HKEY_LOCAL_MACHINE\Software\WOW6432Node\Microsoft\Office\PowerPoint\Addins\Datto.SmartBadgeShim]
"FriendlyName"="Datto SmartBadge"
"Description"="SmartBadge for Microsoft Office applications."
"LoadBehavior"=dword:00000003
[HKEY_LOCAL_MACHINE\Software\WOW6432Node\Microsoft\Office\PowerPoint\Addins\Datto.SmartBadgeShim_CC]
"FriendlyName"="Datto SmartBadge"
"Description"="SmartBadge for Microsoft Office applications."
"LoadBehavior"=dword:00000003
; === COM CLSID Registration (64-bit shim DLL) ===
[HKEY_LOCAL_MACHINE\Software\Classes\CLSID\{2B96EDC1-FDF3-47E1-B177-F205E7B98DF4}]
@="Datto.SmartBadgeShim"
[HKEY_LOCAL_MACHINE\Software\Classes\CLSID\{2B96EDC1-FDF3-47E1-B177-F205E7B98DF4}\InprocServer32]
@="C:\\Program Files\\Datto\\Workplace Desktop\\SmartBadge\\DattoSmartBadgeShim_x64.dll"
"ThreadingModel"="Both"
[HKEY_LOCAL_MACHINE\Software\Classes\CLSID\{2B96EDC1-FDF3-47E1-B177-F205E7B98DF4}\ProgID]
@="Datto.SmartBadgeShim"
[HKEY_LOCAL_MACHINE\Software\Classes\CLSID\{3C639243-95A2-400D-B4B4-4384DA7F61D3}]
@="Datto.SmartBadgeShim_CC"
[HKEY_LOCAL_MACHINE\Software\Classes\CLSID\{3C639243-95A2-400D-B4B4-4384DA7F61D3}\InprocServer32]
@="C:\\Program Files\\Datto\\Workplace2\\SmartBadge\\DattoSmartBadgeShim_x64.dll"
"ThreadingModel"="Both"
[HKEY_LOCAL_MACHINE\Software\Classes\CLSID\{3C639243-95A2-400D-B4B4-4384DA7F61D3}\ProgID]
@="Datto.SmartBadgeShim_CC"
; === Outlook Plugin (if needed) ===
[HKEY_LOCAL_MACHINE\Software\Microsoft\Office\Outlook\Addins\Datto.OutlookPluginShim]
"FriendlyName"="Datto Outlook Plugin"
"Description"="Datto add-in for Microsoft Outlook."
"LoadBehavior"=dword:00000003
[HKEY_LOCAL_MACHINE\Software\Microsoft\Office\Outlook\Addins\Datto.OutlookPluginShim_CC]
"FriendlyName"="Datto Outlook Plugin"
"Description"="Datto add-in for Microsoft Outlook."
"LoadBehavior"=dword:00000003

File diff suppressed because it is too large Load Diff

View File

@@ -1,639 +0,0 @@
{
"isFallbackPublicClient": true,
"signInAudience": "AzureADMultipleOrgs",
"displayName": "CIPP-SAM",
"web": {
"redirectUris": [
"https://login.microsoftonline.com/common/oauth2/nativeclient",
"https://localhost",
"http://localhost",
"http://localhost:8400"
]
},
"servicePrincipalLockConfiguration": {
"isEnabled": true,
"allProperties": true
},
"requiredResourceAccess": [
{
"resourceAppId": "c5393580-f805-4401-95e8-94b7a6ef2fc2",
"resourceAccess": [
{
"id": "594c1fb6-4f81-4475-ae41-0c394909246c",
"type": "Scope"
}
]
},
{
"resourceAppId": "aeb86249-8ea3-49e2-900b-54cc8e308f85",
"resourceAccess": [
{
"id": "fc946a4f-bc4d-413b-a090-b2c86113ec4f",
"type": "Scope"
}
]
},
{
"resourceAppId": "00000003-0000-0000-c000-000000000000",
"resourceAccess": [
{
"id": "1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9",
"type": "Role"
},
{
"id": "b0afded3-3588-46d8-8b3d-9842eff778da",
"type": "Role"
},
{
"id": "5e1e9171-754d-478c-812c-f1755a9a4c2d",
"type": "Role"
},
{
"id": "f3a65bd4-b703-46df-8f7e-0174fea562aa",
"type": "Role"
},
{
"id": "59a6b24b-4225-4393-8165-ebaec5f55d7a",
"type": "Role"
},
{
"id": "35930dcf-aceb-4bd1-b99a-8ffed403c974",
"type": "Role"
},
{
"id": "cac88765-0581-4025-9725-5ebc13f729ee",
"type": "Role"
},
{
"id": "1138cb37-bd11-4084-a2b7-9f71582aeddb",
"type": "Role"
},
{
"id": "78145de6-330d-4800-a6ce-494ff2d33d07",
"type": "Role"
},
{
"id": "9241abd9-d0e6-425a-bd4f-47ba86e767a4",
"type": "Role"
},
{
"id": "5b07b0dd-2377-4e44-a38d-703f09a0dc3c",
"type": "Role"
},
{
"id": "243333ab-4d21-40cb-a475-36241daa0842",
"type": "Role"
},
{
"id": "e330c4f0-4170-414e-a55a-2f022ec2b57b",
"type": "Role"
},
{
"id": "9255e99d-faf5-445e-bbf7-cb71482737c4",
"type": "Role"
},
{
"id": "8b9d79d0-ad75-4566-8619-f7500ecfcebe",
"type": "Scope"
},
{
"id": "5ac13192-7ace-4fcf-b828-1a26f28068ee",
"type": "Role"
},
{
"id": "19dbc75e-c2e2-444c-a770-ec69d8559fc7",
"type": "Role"
},
{
"id": "dbb9058a-0e50-45d7-ae91-66909b5d4664",
"type": "Role"
},
{
"id": "75359482-378d-4052-8f01-80520e7db3cd",
"type": "Role"
},
{
"id": "bf7b1a76-6e77-406b-b258-bf5c7720e98f",
"type": "Role"
},
{
"id": "62a82d76-70ea-41e2-9197-370581804d09",
"type": "Role"
},
{
"id": "dbaae8cf-10b5-4b86-a4a1-f871c94c6695",
"type": "Role"
},
{
"id": "19da66cb-0fb0-4390-b071-ebc76a349482",
"type": "Role"
},
{
"id": "6931bccd-447a-43d1-b442-00a195474933",
"type": "Role"
},
{
"id": "292d869f-3427-49a8-9dab-8c70152b74e9",
"type": "Role"
},
{
"id": "2cb92fee-97a3-4034-8702-24a6f5d0d1e9",
"type": "Role"
},
{
"id": "b6890674-9dd5-4e42-bb15-5af07f541ae1",
"type": "Role"
},
{
"id": "913b9306-0ce1-42b8-9137-6a7df690a760",
"type": "Role"
},
{
"id": "246dd0d5-5bd0-4def-940b-0421030a5b68",
"type": "Role"
},
{
"id": "be74164b-cff1-491c-8741-e671cb536e13",
"type": "Role"
},
{
"id": "25f85f3c-f66c-4205-8cd5-de92dd7f0cec",
"type": "Role"
},
{
"id": "29c18626-4985-4dcd-85c0-193eef327366",
"type": "Role"
},
{
"id": "01c0a623-fc9b-48e9-b794-0756f8e8f067",
"type": "Role"
},
{
"id": "999f8c63-0a38-4f1b-91fd-ed1947bdd1a9",
"type": "Role"
},
{
"id": "338163d7-f101-4c92-94ba-ca46fe52447c",
"type": "Role"
},
{
"id": "2f6817f8-7b12-4f0f-bc18-eeaf60705a9e",
"type": "Role"
},
{
"id": "230c1aed-a721-4c5d-9cb4-a90514e508ef",
"type": "Role"
},
{
"id": "2a60023f-3219-47ad-baa4-40e17cd02a1d",
"type": "Role"
},
{
"id": "025d3225-3f02-4882-b4c0-cd5b541a4e80",
"type": "Role"
},
{
"id": "04c55753-2244-4c25-87fc-704ab82a4f69",
"type": "Role"
},
{
"id": "bf394140-e372-4bf9-a898-299cfc7564e5",
"type": "Role"
},
{
"id": "34bf0e97-1971-4929-b999-9e2442d941d7",
"type": "Role"
},
{
"id": "19b94e34-907c-4f43-bde9-38b1909ed408",
"type": "Role"
},
{
"id": "a82116e5-55eb-4c41-a434-62fe8a61c773",
"type": "Role"
},
{
"id": "0121dc95-1b9f-4aed-8bac-58c5ac466691",
"type": "Role"
},
{
"id": "4437522e-9a86-4a41-a7da-e380edd4a97d",
"type": "Role"
},
{
"id": "741f803b-c850-494e-b5df-cde7c675a1ca",
"type": "Role"
},
{
"id": "50483e42-d915-4231-9639-7fdb7fd190e5",
"type": "Role"
},
{
"id": "bdfbf15f-ee85-4955-8675-146e8e5296b5",
"type": "Scope"
},
{
"id": "84bccea3-f856-4a8a-967b-dbe0a3d53a64",
"type": "Scope"
},
{
"id": "e4c9e354-4dc5-45b8-9e7c-e1393b0b1a20",
"type": "Scope"
},
{
"id": "b27a61ec-b99c-4d6a-b126-c4375d08ae30",
"type": "Scope"
},
{
"id": "101147cf-4178-4455-9d58-02b5c164e759",
"type": "Scope"
},
{
"id": "cc83893a-e232-4723-b5af-bd0b01bcfe65",
"type": "Scope"
},
{
"id": "9d8982ae-4365-4f57-95e9-d6032a4c0b87",
"type": "Scope"
},
{
"id": "2eadaff8-0bce-4198-a6b9-2cfc35a30075",
"type": "Scope"
},
{
"id": "0c3e411a-ce45-4cd1-8f30-f99a3efa7b11",
"type": "Scope"
},
{
"id": "2b61aa8a-6d36-4b2f-ac7b-f29867937c53",
"type": "Scope"
},
{
"id": "767156cb-16ae-4d10-8f8b-41b657c8c8c8",
"type": "Scope"
},
{
"id": "ebf0f66e-9fb1-49e4-a278-222f76911cf4",
"type": "Scope"
},
{
"id": "d649fb7c-72b4-4eec-b2b4-b15acf79e378",
"type": "Scope"
},
{
"id": "f3bfad56-966e-4590-a536-82ecf548ac1e",
"type": "Scope"
},
{
"id": "885f682f-a990-4bad-a642-36736a74b0c7",
"type": "Scope"
},
{
"id": "41ce6ca6-6826-4807-84f1-1c82854f7ee5",
"type": "Scope"
},
{
"id": "bac3b9c2-b516-4ef4-bd3b-c2ef73d8d804",
"type": "Scope"
},
{
"id": "11d4cd79-5ba5-460f-803f-e22c8ab85ccd",
"type": "Scope"
},
{
"id": "951183d1-1a61-466f-a6d1-1fde911bfd95",
"type": "Scope"
},
{
"id": "280b3b69-0437-44b1-bc20-3b2fca1ee3e9",
"type": "Scope"
},
{
"id": "7b3f05d5-f68c-4b8d-8c59-a2ecd12f24af",
"type": "Scope"
},
{
"id": "0883f392-0a7a-443d-8c76-16a6d39c7b63",
"type": "Scope"
},
{
"id": "3404d2bf-2b13-457e-a330-c24615765193",
"type": "Scope"
},
{
"id": "44642bfe-8385-4adc-8fc6-fe3cb2c375c3",
"type": "Scope"
},
{
"id": "0c5e8a55-87a6-4556-93ab-adc52c4d862d",
"type": "Scope"
},
{
"id": "662ed50a-ac44-4eef-ad86-62eed9be2a29",
"type": "Scope"
},
{
"id": "0e263e50-5827-48a4-b97c-d940288653c7",
"type": "Scope"
},
{
"id": "c5366453-9fb0-48a5-a156-24f0c49a4b84",
"type": "Scope"
},
{
"id": "2f9ee017-59c1-4f1d-9472-bd5529a7b311",
"type": "Scope"
},
{
"id": "4e46008b-f24c-477d-8fff-7bb4ec7aafe0",
"type": "Scope"
},
{
"id": "f81125ac-d3b7-4573-a3b2-7099cc39df9e",
"type": "Scope"
},
{
"id": "9e4862a5-b68f-479e-848a-4e07e25c9916",
"type": "Scope"
},
{
"id": "bb6f654c-d7fd-4ae3-85c3-fc380934f515",
"type": "Scope"
},
{
"id": "e0a7cdbb-08b0-4697-8264-0069786e9674",
"type": "Scope"
},
{
"id": "e383f46e-2787-4529-855e-0e479a3ffac0",
"type": "Scope"
},
{
"id": "a367ab51-6b49-43bf-a716-a1fb06d2a174",
"type": "Scope"
},
{
"id": "818c620a-27a9-40bd-a6a5-d96f7d610b4b",
"type": "Scope"
},
{
"id": "f6a3db3e-f7e8-4ed2-a414-557c8c9830be",
"type": "Scope"
},
{
"id": "7427e0e9-2fba-42fe-b0c0-848c9e6a8182",
"type": "Scope"
},
{
"id": "37f7f235-527c-4136-accd-4a02d197296e",
"type": "Scope"
},
{
"id": "46ca0847-7e6b-426e-9775-ea810a948356",
"type": "Scope"
},
{
"id": "346c19ff-3fb2-4e81-87a0-bac9e33990c1",
"type": "Scope"
},
{
"id": "e67e6727-c080-415e-b521-e3f35d5248e9",
"type": "Scope"
},
{
"id": "4c06a06a-098a-4063-868e-5dfee3827264",
"type": "Scope"
},
{
"id": "572fea84-0151-49b2-9301-11cb16974376",
"type": "Scope"
},
{
"id": "b27add92-efb2-4f16-84f5-8108ba77985c",
"type": "Scope"
},
{
"id": "edb72de9-4252-4d03-a925-451deef99db7",
"type": "Scope"
},
{
"id": "7e823077-d88e-468f-a337-e18f1f0e6c7c",
"type": "Scope"
},
{
"id": "edd3c878-b384-41fd-95ad-e7407dd775be",
"type": "Scope"
},
{
"id": "ad902697-1014-4ef5-81ef-2b4301988e8c",
"type": "Scope"
},
{
"id": "4d135e65-66b8-41a8-9f8b-081452c91774",
"type": "Scope"
},
{
"id": "40b534c3-9552-4550-901b-23879c90bcf9",
"type": "Scope"
},
{
"id": "a8ead177-1889-4546-9387-f25e658e2a79",
"type": "Scope"
},
{
"id": "a84a9652-ffd3-496e-a991-22ba5529156a",
"type": "Scope"
},
{
"id": "14dad69e-099b-42c9-810b-d002981feec1",
"type": "Scope"
},
{
"id": "02e97553-ed7b-43d0-ab3c-f8bace0d040c",
"type": "Scope"
},
{
"id": "b955410e-7715-4a88-a940-dfd551018df3",
"type": "Scope"
},
{
"id": "d01b97e9-cbc0-49fe-810a-750afd5527a3",
"type": "Scope"
},
{
"id": "dc38509c-b87d-4da0-bd92-6bec988bac4a",
"type": "Scope"
},
{
"id": "6aedf524-7e1c-45a7-bd76-ded8cab8d0fc",
"type": "Scope"
},
{
"id": "128ca929-1a19-45e6-a3b8-435ec44a36ba",
"type": "Scope"
},
{
"id": "55896846-df78-47a7-aa94-8d3d4442ca7f",
"type": "Scope"
},
{
"id": "eda39fa6-f8cf-4c3c-a909-432c683e4c9b",
"type": "Scope"
},
{
"id": "aa07f155-3612-49b8-a147-6c590df35536",
"type": "Scope"
},
{
"id": "89fe6a52-be36-487e-b7d8-d061c450a026",
"type": "Scope"
},
{
"id": "7825d5d6-6049-4ce7-bdf6-3b8d53f4bcd0",
"type": "Scope"
},
{
"id": "485be79e-c497-4b35-9400-0e3fa7f2a5d4",
"type": "Scope"
},
{
"id": "4a06efd2-f825-4e34-813e-82a57b03d1ee",
"type": "Scope"
},
{
"id": "2104a4db-3a2f-4ea0-9dba-143d457dc666",
"type": "Scope"
},
{
"id": "0e755559-83fb-4b44-91d0-4cc721b9323e",
"type": "Scope"
},
{
"id": "39d65650-9d3e-4223-80db-a335590d027e",
"type": "Scope"
},
{
"id": "a9ff19c2-f369-4a95-9a25-ba9d460efc8e",
"type": "Scope"
},
{
"id": "b98bfd41-87c6-45cc-b104-e2de4f0dafb9",
"type": "Scope"
},
{
"id": "cac97e40-6730-457d-ad8d-4852fddab7ad",
"type": "Scope"
},
{
"id": "73e75199-7c3e-41bb-9357-167164dbb415",
"type": "Scope"
},
{
"id": "637d7bec-b31e-4deb-acc9-24275642a2c9",
"type": "Scope"
},
{
"id": "204e0828-b5ca-4ad8-b9f3-f32a958e7cc4",
"type": "Scope"
},
{
"id": "48971fc1-70d7-4245-af77-0beb29b53ee2",
"type": "Scope"
},
{
"id": "b7887744-6746-4312-813d-72daeaee7e2d",
"type": "Scope"
},
{
"id": "424b07a8-1209-4d17-9fe4-9018a93a1024",
"type": "Scope"
},
{
"id": "0a42382f-155c-4eb1-9bdc-21548ccaa387",
"type": "Role"
},
{
"id": "2d9bd318-b883-40be-9df7-63ec4fcdc424",
"type": "Role"
},
{
"id": "c8948c23-e66b-42db-83fd-770b71ab78d2",
"type": "Role"
},
{
"id": "a94a502d-0281-4d15-8cd2-682ac9362c4c",
"type": "Role"
}
]
},
{
"resourceAppId": "fa3d9a0c-3fb0-42cc-9193-47c7ecd2edbd",
"resourceAccess": [
{
"id": "1cebfa2a-fb4d-419e-b5f9-839b4383e05a",
"type": "Scope"
}
]
},
{
"resourceAppId": "00000002-0000-0ff1-ce00-000000000000",
"resourceAccess": [
{
"id": "dc50a0fb-09a3-484d-be87-e023b12c6440",
"type": "Role"
},
{
"id": "ef54d2bf-783f-4e0f-bca1-3210c0444d99",
"type": "Role"
},
{
"id": "f9156939-25cd-4ba8-abfe-7fabcf003749",
"type": "Role"
},
{
"id": "ab4f2b77-0b06-4fc1-a9de-02113fc2ab7c",
"type": "Scope"
},
{
"id": "bbd1ca91-75e0-4814-ad94-9c5dbbae3415",
"type": "Scope"
},
{
"id": "2e83d72d-8895-4b66-9eea-abb43449ab8b",
"type": "Scope"
}
]
},
{
"resourceAppId": "00000003-0000-0ff1-ce00-000000000000",
"resourceAccess": [
{
"id": "56680e0d-d2a3-4ae1-80d8-3c4f2100e3d0",
"type": "Scope"
}
]
},
{
"resourceAppId": "48ac35b8-9aa8-4d74-927d-1f4a14a0b239",
"resourceAccess": [
{
"id": "e60370c1-e451-437e-aa6e-d76df38e5f15",
"type": "Scope"
}
]
},
{
"resourceAppId": "fc780465-2017-40d4-a0c5-307022471b92",
"resourceAccess": [
{
"id": "41269fc5-d04d-4bfd-bce7-43a51cea049a",
"type": "Role"
},
{
"id": "63a677ce-818c-4409-9d12-5c6d2e2a6bfe",
"type": "Scope"
}
]
}
]
}

View File

@@ -1,24 +0,0 @@
# Deploy — security.azcomputerguru.com
## 1. cPanel / WHM (IX server, 172.16.3.10)
- Create subdomain `security.azcomputerguru.com` (docroot e.g. `/home/<acct>/security`).
- Create MySQL DB `acgsec_assess` + user `acgsec_app`, grant all on the DB. Vault the password
(`msp-tools/security-assessment-db`). Import `app/schema.sql`.
- Upload `app/*` to the docroot. `cp config.sample.php config.php` and fill secrets (DB pass +
Mike's Syncro key from vault `msp-tools/syncro-mike`).
## 2. Cloudflare DNS + Access
- DNS: `security` A/CNAME -> the IX origin, **proxied** (orange cloud).
- Zero Trust > Access > Applications: add self-hosted app for `security.azcomputerguru.com`,
policy = Allow, include `mike@azcomputerguru.com` (require MFA). Everyone else blocked.
The app reads `Cf-Access-Authenticated-User-Email` as a second check.
## 3. Consent apps
- 365: register redirect URI `https://security.azcomputerguru.com/consent-callback.php` on the
Security Investigator app (bfbc12a4-...). Admin-consent link is generated per-tenant in-app.
- Google: create a Workspace read-only OAuth client, set GOOGLE_CLIENT_ID + redirect; scopes are
in config. (Stubbed until created.)
## Notes
- Read-only by design: the app only *reads* Syncro and *generates* consent links. No tenant writes.
- Post-meeting scan consumes the export + the granted 365/Google consent to run the audit.

View File

@@ -1,12 +0,0 @@
# ACG Security Assessment (security.azcomputerguru.com)
Single-assessor intake tool: identify the client by Syncro phone number, prefill what we can,
walk Mike through a risk-ordered questionnaire during the consult, capture read-only 365/Google
consent on the spot, and export the audit work-list for the post-meeting automated scan.
- `app/questions.json` — the question framework (sections, fields, sources, importance).
- `app/index.php` — the wizard UI (Cloudflare-Access gated).
- `app/api.php` — Syncro lookup, save/load, consent-link generation, export.
- `app/schema.sql` — MySQL schema. `DEPLOY.md` — hosting + Cloudflare Access steps.
Stack: PHP + MySQL on the IX cPanel host, behind Cloudflare Access (only mike@azcomputerguru.com).

View File

@@ -1 +0,0 @@
config.php

View File

@@ -1,126 +0,0 @@
<?php
// Backend for the ACG security assessment intake. JSON API behind Cloudflare Access.
require __DIR__ . '/config.php';
header('Content-Type: application/json');
$email = $_SERVER['HTTP_CF_ACCESS_AUTHENTICATED_USER_EMAIL'] ?? '';
if (ALLOWED_EMAIL !== '' && strcasecmp($email, ALLOWED_EMAIL) !== 0) {
http_response_code(403); echo json_encode(['error' => 'forbidden']); exit;
}
function db() {
static $pdo;
if (!$pdo) $pdo = new PDO('mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8mb4', DB_USER, DB_PASS,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
return $pdo;
}
function body() { return json_decode(file_get_contents('php://input'), true) ?: []; }
function out($x) { echo json_encode($x); exit; }
function syncro($path) {
$url = rtrim(SYNCRO_BASE, '/') . $path . (strpos($path, '?') === false ? '?' : '&') . 'api_key=' . SYNCRO_KEY;
$ch = curl_init($url);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 20, CURLOPT_HTTPHEADER => ['Accept: application/json']]);
$raw = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
$raw = preg_replace('/[\x00-\x1F]/', ' ', $raw); // strip control chars Syncro leaks
return [$code, json_decode($raw, true)];
}
$action = $_GET['action'] ?? '';
// ---- Syncro client lookup by phone ----
if ($action === 'lookup') {
$phone = preg_replace('/\D/', '', body()['phone'] ?? '');
if (strlen($phone) < 7) out(['error' => 'phone too short']);
[$c, $res] = syncro('/customers?phone=' . urlencode($phone));
$cust = $res['customers'][0] ?? null;
if (!$cust) { [$c, $res] = syncro('/customers?query=' . urlencode($phone)); $cust = $res['customers'][0] ?? null; }
if (!$cust) out(['error' => 'no Syncro customer for that phone']);
$domain = '';
if (!empty($cust['email']) && strpos($cust['email'], '@') !== false) $domain = substr(strrchr($cust['email'], '@'), 1);
out(['prefill' => [
'business_name' => $cust['business_name'] ?: trim(($cust['firstname'] ?? '') . ' ' . ($cust['lastname'] ?? '')),
'syncro_customer_id' => $cust['id'] ?? '',
'address' => trim(($cust['address'] ?? '') . ' ' . ($cust['city'] ?? '') . ' ' . ($cust['state'] ?? '') . ' ' . ($cust['zip'] ?? '')),
'primary_contact' => trim(($cust['firstname'] ?? '') . ' ' . ($cust['lastname'] ?? '')),
'contact_email' => $cust['email'] ?? '',
'email_domains' => $domain ? [$domain] : [],
'tenant_domain' => $domain,
'assessment_date' => date('Y-m-d'),
], 'rmm' => null]);
}
// ---- Consent link generation ----
if ($action === 'consent') {
$b = body(); $provider = $b['provider'] ?? ''; $domain = trim($b['domain'] ?? '');
if ($provider === 'm365') {
if (!$domain) out(['error' => 'need tenant domain']);
// Admin-consent prompt for our read-only Security Investigator app (multi-tenant).
out(['url' => 'https://login.microsoftonline.com/' . rawurlencode($domain)
. '/adminconsent?client_id=' . M365_INVESTIGATOR_APP_ID
. '&redirect_uri=' . rawurlencode(CONSENT_REDIRECT)]);
}
if ($provider === 'google') {
if (GOOGLE_CLIENT_ID === '') out(['error' => 'Google client not configured yet']);
out(['url' => 'https://accounts.google.com/o/oauth2/v2/auth?client_id=' . rawurlencode(GOOGLE_CLIENT_ID)
. '&response_type=code&access_type=offline&prompt=consent'
. '&scope=' . rawurlencode(GOOGLE_SCOPES)
. '&redirect_uri=' . rawurlencode(GOOGLE_REDIRECT)
. ($domain ? '&hd=' . rawurlencode($domain) : '')]);
}
out(['error' => 'unknown provider']);
}
// ---- Save / upsert ----
if ($action === 'save') {
$b = body();
$data = json_encode($b['data'] ?? []);
$consent = json_encode($b['consent'] ?? []);
if (!empty($b['id'])) {
db()->prepare('UPDATE assessments SET phone=?, business_name=?, data=?, consent=?, updated=NOW() WHERE id=?')
->execute([$b['phone'] ?? '', $b['business_name'] ?? '', $data, $consent, $b['id']]);
out(['id' => (int)$b['id']]);
}
db()->prepare('INSERT INTO assessments (phone, business_name, data, consent, created, updated) VALUES (?,?,?,?,NOW(),NOW())')
->execute([$b['phone'] ?? '', $b['business_name'] ?? '', $data, $consent]);
out(['id' => (int)db()->lastInsertId()]);
}
// ---- Load ----
if ($action === 'load') {
$row = db()->prepare('SELECT data, consent FROM assessments WHERE id=?');
$row->execute([$_GET['id'] ?? 0]); $r = $row->fetch();
out($r ? ['data' => json_decode($r['data'], true), 'consent' => json_decode($r['consent'], true)] : ['error' => 'not found']);
}
// ---- List ----
if ($action === 'list') {
$rows = db()->query('SELECT id, phone, business_name, DATE_FORMAT(updated, "%Y-%m-%d %H:%i") updated FROM assessments ORDER BY updated DESC LIMIT 100')->fetchAll();
out(['items' => $rows]);
}
// ---- Export (HTML report-ready intake) ----
if ($action === 'export') {
$row = db()->prepare('SELECT * FROM assessments WHERE id=?'); $row->execute([$_GET['id'] ?? 0]); $r = $row->fetch();
if (!$r) { http_response_code(404); exit('not found'); }
$data = json_decode($r['data'], true); $consent = json_decode($r['consent'], true);
$q = json_decode(file_get_contents(__DIR__ . '/questions.json'), true);
header('Content-Type: text/html; charset=utf-8');
echo '<!doctype html><meta charset=utf-8><title>Assessment intake — ' . htmlspecialchars($r['business_name']) . '</title>';
echo '<style>body{font:14px/1.5 system-ui;max-width:820px;margin:30px auto;padding:0 16px;color:#0f172a}h1{margin-bottom:2px}h2{margin-top:26px;border-bottom:2px solid #e3e8ee;padding-bottom:4px}.r{display:grid;grid-template-columns:240px 1fr;gap:8px;padding:4px 0}.k{color:#64748b}@media print{body{margin:0}}</style>';
echo '<h1>' . htmlspecialchars($r['business_name'] ?: 'Client') . '</h1><div class=k>Security assessment intake · ' . $r['updated'] . '</div>';
foreach ($q['sections'] as $s) {
$rows = '';
foreach ($s['fields'] as $f) {
if (($f['type'] ?? '') === 'hidden') continue;
$v = $data[$f['id']] ?? '';
if (($f['type'] ?? '') === 'consent') { $cc = $consent[$f['provider']] ?? []; $v = !empty($cc['granted']) ? 'CONSENTED' : (!empty($cc['url']) ? 'link sent' : ''); }
if (is_array($v)) $v = implode(', ', $v);
if ($v !== '' && $v !== null) $rows .= '<div class=r><div class=k>' . htmlspecialchars($f['label']) . '</div><div>' . htmlspecialchars($v) . '</div></div>';
}
if ($rows) echo '<h2>' . htmlspecialchars($s['title']) . '</h2>' . $rows;
}
exit;
}
out(['error' => 'unknown action']);

View File

@@ -1,15 +0,0 @@
<?php
// Copy to config.php on the server (config.php is gitignored). Secrets live in the SOPS vault.
define('ALLOWED_EMAIL', 'mike@azcomputerguru.com'); // defense-in-depth; Cloudflare Access is the primary gate
define('DB_HOST', 'localhost');
define('DB_NAME', 'acgsec_assess');
define('DB_USER', 'acgsec_app');
define('DB_PASS', 'CHANGE_ME'); // vault: msp-tools/security-assessment-db
define('SYNCRO_BASE', 'https://computerguru.syncromsp.com/api/v1');
define('SYNCRO_KEY', 'CHANGE_ME'); // vault: msp-tools/syncro-mike (Mike's per-user key)
// Read-only "ComputerGuru Security Investigator" multi-tenant app (from the remediation tool):
define('M365_INVESTIGATOR_APP_ID', 'bfbc12a4-f0dd-4e12-b06d-997e7271e10c');
define('CONSENT_REDIRECT', 'https://security.azcomputerguru.com/consent-callback.php');
define('GOOGLE_CLIENT_ID', ''); // set once a Workspace read-only OAuth client exists
define('GOOGLE_SCOPES', 'https://www.googleapis.com/auth/admin.directory.user.readonly https://www.googleapis.com/auth/admin.reports.audit.readonly');
define('GOOGLE_REDIRECT', 'https://security.azcomputerguru.com/google-callback.php');

View File

@@ -1,201 +0,0 @@
<?php
// security.azcomputerguru.com — ACG Client Security Assessment intake.
// Access is gated by Cloudflare Access (only mike@azcomputerguru.com). Defense-in-depth:
// re-check the Cf-Access-Authenticated-User-Email header here too.
require __DIR__ . '/config.php';
$email = $_SERVER['HTTP_CF_ACCESS_AUTHENTICATED_USER_EMAIL'] ?? '';
if (ALLOWED_EMAIL !== '' && strcasecmp($email, ALLOWED_EMAIL) !== 0) {
http_response_code(403);
exit('Forbidden — this tool is restricted.');
}
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ACG Security Assessment</title>
<style>
:root{
--bg:#0f172a; --panel:#111c33; --surface:#ffffff; --ink:#0f172a; --ink-2:#475569; --ink-3:#94a3b8;
--line:#e3e8ee; --accent:#1d4ed8; --accent-soft:#eef4ff; --good:#16a34a; --good-bg:#dcfce7;
--warn:#b45309; --warn-bg:#fef3c7; --crit:#b91c1c; --crit-bg:#fee2e2;
--sans:Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; --r:8px;
}
*{box-sizing:border-box} html,body{height:100%;margin:0}
body{font-family:var(--sans);color:var(--ink);background:#f1f5f9;display:grid;grid-template-columns:260px 1fr;grid-template-rows:56px 1fr;grid-template-areas:"brand top" "rail main";height:100vh;overflow:hidden}
button,input,select,textarea{font-family:inherit}
/* brand + topbar */
.brand{grid-area:brand;background:var(--bg);color:#fff;display:flex;align-items:center;gap:10px;padding:0 18px;font-weight:700;letter-spacing:-.01em}
.brand .sh{width:22px;height:22px;border-radius:5px;background:linear-gradient(135deg,#3b82f6,#1d4ed8);display:inline-block}
.top{grid-area:top;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:14px;padding:0 18px}
.top .client{font-weight:600}.top .muted{color:var(--ink-3)}
.top .sp{flex:1}
.top button{height:34px;padding:0 13px;border:1px solid var(--line);border-radius:var(--r);background:#fff;cursor:pointer;font-size:13px;color:var(--ink)}
.top button:hover{background:#f1f5f9}.top button.pri{background:var(--accent);color:#fff;border-color:var(--accent)}
/* rail */
.rail{grid-area:rail;background:var(--panel);color:#cbd5e1;padding:14px 10px;overflow-y:auto}
.rail .step{display:flex;align-items:center;gap:11px;padding:9px 11px;border-radius:var(--r);cursor:pointer;font-size:13.5px;color:#cbd5e1}
.rail .step:hover{background:#1b2a47}
.rail .step.active{background:#1d4ed8;color:#fff}
.rail .step .n{width:22px;height:22px;border-radius:50%;background:#24344f;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;flex-shrink:0}
.rail .step.active .n{background:#fff;color:#1d4ed8}
.rail .step.done .n{background:var(--good);color:#fff}
.rail .step .pct{margin-left:auto;font-size:10.5px;color:#64748b}
.rail .step.active .pct{color:#bfdbfe}
/* main */
.main{grid-area:main;overflow-y:auto;padding:26px 34px;display:flex;justify-content:center}
.sheet{width:100%;max-width:760px}
.sec-h{display:flex;align-items:baseline;gap:12px;margin:0 0 4px}
.sec-h h1{font-size:21px;margin:0;letter-spacing:-.01em}
.sec-h .ix{font-size:12px;color:var(--ink-3);font-weight:600}
.intro{color:var(--ink-2);font-size:13.5px;margin:0 0 18px}
.field{margin-bottom:16px;background:#fff;border:1px solid var(--line);border-radius:10px;padding:13px 15px}
.field.crit{border-left:3px solid var(--crit)} .field.high{border-left:3px solid var(--warn)}
.field label{display:block;font-size:13.5px;font-weight:600;margin-bottom:6px}
.field .help{font-size:12px;color:var(--ink-3);margin-top:5px}
.field .tag-src{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:1px 6px;border-radius:4px;margin-left:7px;vertical-align:middle}
.src-syncro{background:#e0e7ff;color:#3730a3}.src-rmm{background:#dcfce7;color:#166534}.src-scan{background:#f1f5f9;color:#64748b}.src-ask{background:#fff7ed;color:#9a3412}
.field input[type=text],.field input[type=email],.field input[type=number],.field input[type=tel],.field input[type=date],.field select,.field textarea{
width:100%;height:38px;border:1px solid #cbd5e1;border-radius:7px;padding:0 11px;font-size:14px;background:#fff;color:var(--ink)}
.field textarea{height:74px;padding:9px 11px;resize:vertical}
.field input:focus,.field select:focus,.field textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.opts{display:flex;flex-wrap:wrap;gap:7px}
.opt{font-size:13px;padding:7px 12px;border:1px solid #cbd5e1;border-radius:20px;background:#fff;cursor:pointer;user-select:none}
.opt.on{background:var(--accent);color:#fff;border-color:var(--accent)}
.lookup{display:flex;gap:8px}.lookup input{flex:1}
.lookup button{height:38px;padding:0 16px;border:0;border-radius:7px;background:var(--accent);color:#fff;font-weight:600;cursor:pointer}
.consent{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.consent button{height:36px;padding:0 13px;border:1px solid var(--accent);border-radius:7px;background:var(--accent-soft);color:var(--accent);font-weight:600;cursor:pointer}
.consent .link{font-size:12px;word-break:break-all;background:#f8fafc;border:1px dashed #cbd5e1;border-radius:6px;padding:7px 9px;flex:1;min-width:240px}
.consent .status{font-size:12px;font-weight:700;padding:2px 9px;border-radius:20px}
.consent .status.pending{background:var(--warn-bg);color:var(--warn)} .consent .status.done{background:var(--good-bg);color:var(--good)}
.navbar{display:flex;gap:10px;margin-top:24px;align-items:center}
.navbar .sp{flex:1}
.navbar button{height:40px;padding:0 20px;border-radius:8px;border:1px solid var(--line);background:#fff;cursor:pointer;font-size:14px;font-weight:600}
.navbar button.pri{background:var(--accent);color:#fff;border-color:var(--accent)}
.navbar button:disabled{opacity:.5;cursor:default}
.savedot{font-size:12px;color:var(--ink-3)}
.review .row{display:grid;grid-template-columns:230px 1fr;gap:10px;padding:7px 0;border-bottom:1px solid var(--line);font-size:13.5px}
.review .row .k{color:var(--ink-3)} .review .row .v{font-weight:500}
.review h3{margin:18px 0 4px;font-size:14px}
</style>
</head>
<body>
<div class="brand"><span class="sh"></span> ACG&nbsp;Security</div>
<div class="top">
<span class="client" id="clientName">No client selected</span>
<span class="muted" id="clientSub"></span>
<span class="sp"></span>
<span class="savedot" id="saveDot"></span>
<button id="btnList">Assessments</button>
<button class="pri" id="btnExport">Export</button>
</div>
<nav class="rail" id="rail"></nav>
<main class="main"><div class="sheet" id="sheet"></div></main>
<script>
const ICONS={building:'🏢',target:'🎯',key:'🔑',shield:'🛡️',monitor:'🖥️',globe:'🌐',users:'👥',cloud:'☁️',lock:'🔒'};
const state={q:null,sec:0,data:{},id:null,consent:{}};
const $=s=>document.querySelector(s);
const api=(action,body)=>fetch('api.php?action='+action,{method:body?'POST':'GET',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined}).then(r=>r.json());
async function boot(){
state.q=await fetch('questions.json').then(r=>r.json());
const u=new URLSearchParams(location.search); if(u.get('id')) await load(u.get('id'));
renderRail(); renderSection();
}
function secPct(s){const ask=s.fields.filter(f=>!['hidden','auto'].includes(f.source||'')&&f.type!=='hidden');const done=ask.filter(f=>{const v=state.data[f.id];return Array.isArray(v)?v.length:(v!==undefined&&v!=='')}).length;return ask.length?Math.round(done/ask.length*100):0;}
function renderRail(){
$('#rail').innerHTML=state.q.sections.map((s,i)=>{
const pct=secPct(s);const cls=i===state.sec?'active':(pct===100?'done':'');
return `<div class="step ${cls}" data-i="${i}"><span class="n">${pct===100&&i!==state.sec?'✓':i+1}</span><span>${s.title}</span><span class="pct">${pct}%</span></div>`;
}).join('')+`<div class="step ${state.sec===state.q.sections.length?'active':''}" data-i="${state.q.sections.length}"><span class="n">✓</span><span>Review &amp; finish</span></div>`;
document.querySelectorAll('.rail .step').forEach(el=>el.onclick=()=>{state.sec=+el.dataset.i;renderRail();renderSection();});
}
function field(f){
if(f.type==='hidden')return'';
const v=state.data[f.id];
const src=f.source&&f.source!=='input'&&f.source!=='auto'?`<span class="tag-src src-${f.source}">${f.source}</span>`:'';
const cls='field'+(f.importance==='critical'?' crit':f.importance==='high'?' high':'');
let inner='';
if(f.type==='consent'){inner=consentField(f);}
else if(f.lookup){inner=`<div class="lookup"><input type="tel" id="fld_${f.id}" value="${v||''}" placeholder="(520) 555-1234"><button onclick="doLookup()">Look up</button></div>`;}
else if(f.type==='multiselect'){inner=`<div class="opts">`+f.options.map(o=>`<span class="opt ${(v||[]).includes(o)?'on':''}" data-f="${f.id}" data-o="${o}">${o}</span>`).join('')+`</div>`;}
else if(f.type==='select'){inner=`<select id="fld_${f.id}"><option value="">— select —</option>`+f.options.map(o=>`<option ${v===o?'selected':''}>${o}</option>`).join('')+`</select>`;}
else if(f.type==='boolean'){inner=`<div class="opts">${['Yes','No','Unsure'].map(o=>`<span class="opt ${v===o?'on':''}" data-rf="${f.id}" data-o="${o}">${o}</span>`).join('')}</div>`;}
else if(f.type==='textarea'){inner=`<textarea id="fld_${f.id}">${v||''}</textarea>`;}
else if(f.type==='tags'){inner=`<input type="text" id="fld_${f.id}" value="${Array.isArray(v)?v.join(', '):(v||'')}" placeholder="comma separated">`;}
else {const t=f.type==='number'?'number':f.type==='email'?'email':f.type==='date'?'date':'text';inner=`<input type="${t}" id="fld_${f.id}" value="${v||''}">`;}
return `<div class="${cls}"><label>${f.label}${src}</label>${inner}${f.help?`<div class="help">${f.help}</div>`:''}</div>`;
}
function consentField(f){
const c=state.consent[f.provider]||{};
const status=c.granted?`<span class="status done">✓ consented</span>`:c.url?`<span class="status pending">awaiting click</span>`:'';
return `<div class="consent"><button onclick="genConsent('${f.provider}')">${c.url?'Regenerate':'Generate'} ${f.provider==='m365'?'365':'Google'} link</button>${c.url?`<span class="link">${c.url}</span>`:''}${status}<button onclick="markConsent('${f.provider}')" title="Mark as consented">Mark done</button></div>`;
}
function renderSection(){
const secs=state.q.sections;
if(state.sec>=secs.length){return renderReview();}
const s=secs[state.sec];
$('#sheet').innerHTML=`<div class="sec-h"><span style="font-size:22px">${ICONS[s.icon]||''}</span><h1>${s.title}</h1><span class="ix">${state.sec+1} / ${secs.length}</span></div>${s.intro?`<p class="intro">${s.intro}</p>`:''}<div id="fields">${s.fields.map(field).join('')}</div>${navbar()}`;
wire(s);
}
function navbar(last){
return `<div class="navbar"><button onclick="go(-1)" ${state.sec===0?'disabled':''}>← Back</button><span class="sp"></span><button onclick="saveNow()">Save</button><button class="pri" onclick="go(1)">${last?'Done':'Next →'}</button></div>`;
}
function wire(s){
s.fields.forEach(f=>{
const el=document.getElementById('fld_'+f.id);
if(el)el.onchange=()=>{let v=el.value;if(f.type==='tags')v=v.split(',').map(x=>x.trim()).filter(Boolean);state.data[f.id]=v;debouncedSave();renderRail();};
});
document.querySelectorAll('.opt[data-f]').forEach(el=>el.onclick=()=>{const fid=el.dataset.f,o=el.dataset.o;const a=state.data[fid]||[];const i=a.indexOf(o);i<0?a.push(o):a.splice(i,1);state.data[fid]=a;el.classList.toggle('on');debouncedSave();renderRail();});
document.querySelectorAll('.opt[data-rf]').forEach(el=>el.onclick=()=>{const fid=el.dataset.rf;state.data[fid]=el.dataset.o;document.querySelectorAll(`.opt[data-rf="${fid}"]`).forEach(x=>x.classList.toggle('on',x===el));debouncedSave();renderRail();});
}
function go(d){state.sec=Math.max(0,Math.min(state.q.sections.length,state.sec+d));saveNow();renderRail();renderSection();window.scrollTo(0,0);}
async function doLookup(){
const phone=$('#fld_syncro_phone').value.trim(); if(!phone)return;
$('#clientSub').textContent='looking up…';
const r=await api('lookup',{phone});
if(r.error){$('#clientSub').textContent='not found: '+r.error;return;}
// prefill syncro-sourced fields
Object.assign(state.data,r.prefill||{});
state.data.syncro_phone=phone;
$('#clientName').textContent=r.prefill.business_name||'Client';
$('#clientSub').textContent=(r.prefill.address||'')+(r.rmm?` · ${r.rmm.workstations||0} ws / ${r.rmm.servers||0} srv`:'');
if(r.rmm){state.data.workstation_count=r.rmm.workstations;state.data.server_count=r.rmm.servers;}
saveNow();renderSection();renderRail();
}
async function genConsent(provider){
const r=await api('consent',{provider,domain:state.data.tenant_domain||(state.data.email_domains||[])[0]||'',customer_id:state.data.syncro_customer_id});
if(r.error){alert('Consent link failed: '+r.error);return;}
state.consent[provider]={url:r.url,granted:false};saveNow();renderSection();
}
function markConsent(provider){state.consent[provider]=Object.assign(state.consent[provider]||{},{granted:true});saveNow();renderSection();}
function renderReview(){
let html=`<div class="sec-h"><span style="font-size:22px">✅</span><h1>Review &amp; finish</h1></div><p class="intro">Confirm the captured intake. Export hands the audit work-list to the post-meeting scan.</p><div class="review">`;
for(const s of state.q.sections){
const rows=s.fields.filter(f=>f.type!=='hidden').map(f=>{let v=state.data[f.id];if(f.type==='consent'){const c=state.consent[f.provider]||{};v=c.granted?'✓ consented':(c.url?'link sent':'—');}if(Array.isArray(v))v=v.join(', ');return (v&&v!=='')?`<div class="row"><div class="k">${f.label}</div><div class="v">${v}</div></div>`:'';}).filter(Boolean).join('');
if(rows)html+=`<h3>${ICONS[s.icon]||''} ${s.title}</h3>${rows}`;
}
html+=`</div>${navbar(true)}`;
$('#sheet').innerHTML=html;
}
let saveT=null;
function debouncedSave(){clearTimeout(saveT);saveT=setTimeout(saveNow,800);}
async function saveNow(){
$('#saveDot').textContent='saving…';
const payload={id:state.id,phone:state.data.syncro_phone||'',business_name:state.data.business_name||'',data:state.data,consent:state.consent};
const r=await api('save',payload); if(r.id)state.id=r.id;
history.replaceState(null,'','?id='+state.id);
$('#saveDot').textContent='saved ✓';setTimeout(()=>$('#saveDot').textContent='',1500);
}
async function load(id){const r=await api('load&id='+id);if(r&&r.data){state.id=id;state.data=r.data;state.consent=r.consent||{};$('#clientName').textContent=state.data.business_name||'Client';}}
$('#btnExport').onclick=()=>{window.open('api.php?action=export&id='+(state.id||''),'_blank');};
$('#btnList').onclick=async()=>{const r=await api('list');alert((r.items||[]).map(x=>`#${x.id} ${x.business_name||x.phone} (${x.updated})`).join('\n')||'No assessments yet');};
boot();
</script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More