sync: Auto-sync from Mikes-MacBook-Air.local at 2026-03-09 08:14:13

Synced files:
- Session logs updated
- Latest context and credentials
- Command/directive updates

Machine: Mikes-MacBook-Air.local
Timestamp: 2026-03-09 08:14:13

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 08:14:13 -07:00
parent f81872784b
commit a1a19f8c00
59 changed files with 14435 additions and 1 deletions

View File

@@ -60,7 +60,7 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = mysql+pymysql://claudetools:CT_e8fcd5a3952030a79ed6debae6c954ed@172.16.3.20:3306/claudetools
sqlalchemy.url = mysql+pymysql://claudetools:CT_e8fcd5a3952030a79ed6debae6c954ed@172.16.3.30:3306/claudetools
[post_write_hooks]

View File

@@ -33,6 +33,8 @@ from api.routers import (
security_incidents,
bulk_import,
version,
quotes,
admin_quotes,
)
# Import middleware
@@ -124,6 +126,10 @@ app.include_router(credential_audit_logs.router, prefix="/api/credential-audit-l
app.include_router(security_incidents.router, prefix="/api/security-incidents", tags=["Security Incidents"])
app.include_router(bulk_import.router, prefix="/api/bulk-import", tags=["Bulk Import"])
# Quote Wizard endpoints (public and admin)
app.include_router(quotes.router, prefix="/api/quotes", tags=["Quotes"])
app.include_router(admin_quotes.router, prefix="/api/admin/quotes", tags=["Admin Quotes"])
if __name__ == "__main__":
import uvicorn

View File

@@ -31,6 +31,7 @@ from api.models.operation_failure import OperationFailure
from api.models.pending_task import PendingTask
from api.models.problem_solution import ProblemSolution
from api.models.project import Project
from api.models.quote import Quote, QuoteActivity, QuoteItem, QuoteNotification
from api.models.schema_migration import SchemaMigration
from api.models.security_incident import SecurityIncident
from api.models.service import Service
@@ -72,6 +73,10 @@ __all__ = [
"PendingTask",
"ProblemSolution",
"Project",
"Quote",
"QuoteActivity",
"QuoteItem",
"QuoteNotification",
"SchemaMigration",
"SecurityIncident",
"Service",

564
api/models/quote.py Normal file
View File

@@ -0,0 +1,564 @@
"""
Quote models for MSP Quote Wizard.
Models for managing quotes, quote items, activity tracking, and notifications
for the public-facing MSP service quote wizard.
"""
import secrets
from datetime import datetime
from decimal import Decimal
from enum import Enum as PyEnum
from typing import TYPE_CHECKING, Optional
from sqlalchemy import (
CHAR,
Boolean,
CheckConstraint,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base, TimestampMixin, UUIDMixin
class QuoteStatus(str, PyEnum):
"""Status options for quotes."""
DRAFT = "draft"
SUBMITTED = "submitted"
REVIEWING = "reviewing"
APPROVED = "approved"
REJECTED = "rejected"
EXPIRED = "expired"
class ServiceCategory(str, PyEnum):
"""Service category options for quote items."""
MANAGED_SERVICES = "managed_services"
SECURITY = "security"
BACKUP = "backup"
CLOUD = "cloud"
HARDWARE = "hardware"
SOFTWARE = "software"
CONSULTING = "consulting"
SUPPORT = "support"
class BillingFrequency(str, PyEnum):
"""Billing frequency options for quote items."""
MONTHLY = "monthly"
QUARTERLY = "quarterly"
ANNUAL = "annual"
ONE_TIME = "one_time"
class NotificationType(str, PyEnum):
"""Notification types for quote events."""
EMAIL_SENT = "email_sent"
SMS_SENT = "sms_sent"
ADMIN_ALERT = "admin_alert"
REMINDER_SENT = "reminder_sent"
class Quote(Base, UUIDMixin, TimestampMixin):
"""
Quote model representing a service quote request.
Stores quote details including contact information, selections,
and calculated totals. Uses an access token for public URL access.
Attributes:
access_token: Unique token for public access (URL-safe, 43 chars)
status: Current quote status (draft, submitted, reviewing, etc.)
company_name: Prospect company name
contact_name: Primary contact name
contact_email: Contact email address
contact_phone: Contact phone number
employee_count: Number of employees/users
notes: Customer notes or special requirements
admin_notes: Internal admin notes (not visible to customer)
monthly_total: Calculated monthly recurring total
setup_total: Calculated one-time setup total
annual_total: Calculated annual total
expires_at: Quote expiration date
submitted_at: Timestamp when quote was submitted
ip_address: IP address of the requester
user_agent: Browser user agent string
"""
__tablename__ = "quotes"
# Access control
access_token: Mapped[str] = mapped_column(
String(64),
unique=True,
nullable=False,
default=lambda: secrets.token_urlsafe(32),
doc="Unique access token for public URL (URL-safe, 43 chars)"
)
# Status
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default=QuoteStatus.DRAFT.value,
server_default=QuoteStatus.DRAFT.value,
doc="Quote status: draft, submitted, reviewing, approved, rejected, expired"
)
# Contact information (optional until submission)
company_name: Mapped[Optional[str]] = mapped_column(
String(255),
doc="Prospect company name"
)
contact_name: Mapped[Optional[str]] = mapped_column(
String(255),
doc="Primary contact name"
)
contact_email: Mapped[Optional[str]] = mapped_column(
String(255),
doc="Contact email address"
)
contact_phone: Mapped[Optional[str]] = mapped_column(
String(50),
doc="Contact phone number"
)
# Business information
employee_count: Mapped[Optional[int]] = mapped_column(
Integer,
doc="Number of employees/users"
)
# Notes
notes: Mapped[Optional[str]] = mapped_column(
Text,
doc="Customer notes or special requirements"
)
admin_notes: Mapped[Optional[str]] = mapped_column(
Text,
doc="Internal admin notes (not visible to customer)"
)
# Calculated totals
monthly_total: Mapped[Decimal] = mapped_column(
Numeric(10, 2),
nullable=False,
default=Decimal("0.00"),
server_default="0.00",
doc="Calculated monthly recurring total"
)
setup_total: Mapped[Decimal] = mapped_column(
Numeric(10, 2),
nullable=False,
default=Decimal("0.00"),
server_default="0.00",
doc="Calculated one-time setup total"
)
annual_total: Mapped[Decimal] = mapped_column(
Numeric(10, 2),
nullable=False,
default=Decimal("0.00"),
server_default="0.00",
doc="Calculated annual total"
)
# Timestamps
expires_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
doc="Quote expiration date"
)
submitted_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
doc="Timestamp when quote was submitted"
)
# Tracking
ip_address: Mapped[Optional[str]] = mapped_column(
String(45),
doc="IP address of the requester (IPv4 or IPv6)"
)
user_agent: Mapped[Optional[str]] = mapped_column(
String(500),
doc="Browser user agent string"
)
# Syncro RMM Integration
syncro_lead_id: Mapped[Optional[str]] = mapped_column(
String(100),
doc="Lead ID in SyncroRMM if synced"
)
syncro_synced_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
doc="Timestamp when quote was synced to Syncro"
)
is_existing_customer: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
server_default="0",
doc="Whether this is an existing Syncro customer"
)
# Relationships
items: Mapped[list["QuoteItem"]] = relationship(
"QuoteItem",
back_populates="quote",
cascade="all, delete-orphan",
doc="Line items in this quote"
)
activities: Mapped[list["QuoteActivity"]] = relationship(
"QuoteActivity",
back_populates="quote",
cascade="all, delete-orphan",
order_by="QuoteActivity.created_at.desc()",
doc="Activity log for this quote"
)
notifications: Mapped[list["QuoteNotification"]] = relationship(
"QuoteNotification",
back_populates="quote",
cascade="all, delete-orphan",
doc="Notifications sent for this quote"
)
# Constraints and indexes
__table_args__ = (
CheckConstraint(
"status IN ('draft', 'submitted', 'reviewing', 'approved', 'rejected', 'expired')",
name="ck_quotes_status"
),
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"),
)
def __repr__(self) -> str:
"""String representation of the quote."""
return f"<Quote(id='{self.id}', status='{self.status}', company='{self.company_name}')>"
class QuoteItem(Base, UUIDMixin, TimestampMixin):
"""
Quote item model representing a single line item in a quote.
Stores service details, pricing, and quantity information.
Attributes:
quote_id: Reference to the parent quote
service_name: Name of the service
service_description: Detailed description of the service
category: Service category (managed_services, security, etc.)
billing_frequency: Billing frequency (monthly, annual, one_time)
unit_price: Price per unit
quantity: Number of units
setup_fee: One-time setup fee
is_required: Whether this item is required (cannot be removed)
sort_order: Display order within the quote
"""
__tablename__ = "quote_items"
# Foreign keys
quote_id: Mapped[str] = mapped_column(
CHAR(36),
ForeignKey("quotes.id", ondelete="CASCADE"),
nullable=False,
doc="Reference to the parent quote"
)
# Service identification
service_name: Mapped[str] = mapped_column(
String(255),
nullable=False,
doc="Name of the service"
)
service_description: Mapped[Optional[str]] = mapped_column(
Text,
doc="Detailed description of the service"
)
# Category
category: Mapped[str] = mapped_column(
String(50),
nullable=False,
default=ServiceCategory.MANAGED_SERVICES.value,
doc="Service category: managed_services, security, backup, cloud, etc."
)
# Billing
billing_frequency: Mapped[str] = mapped_column(
String(20),
nullable=False,
default=BillingFrequency.MONTHLY.value,
doc="Billing frequency: monthly, quarterly, annual, one_time"
)
# Pricing
unit_price: Mapped[Decimal] = mapped_column(
Numeric(10, 2),
nullable=False,
default=Decimal("0.00"),
doc="Price per unit"
)
quantity: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=1,
doc="Number of units"
)
setup_fee: Mapped[Decimal] = mapped_column(
Numeric(10, 2),
nullable=False,
default=Decimal("0.00"),
server_default="0.00",
doc="One-time setup fee"
)
# Configuration
is_required: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
server_default="0",
doc="Whether this item is required (cannot be removed)"
)
sort_order: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
server_default="0",
doc="Display order within the quote"
)
# Relationships
quote: Mapped["Quote"] = relationship(
"Quote",
back_populates="items"
)
# Constraints and indexes
__table_args__ = (
CheckConstraint(
"category IN ('managed_services', 'security', 'backup', 'cloud', 'hardware', 'software', 'consulting', 'support')",
name="ck_quote_items_category"
),
CheckConstraint(
"billing_frequency IN ('monthly', 'quarterly', 'annual', 'one_time')",
name="ck_quote_items_billing_frequency"
),
CheckConstraint(
"quantity >= 1",
name="ck_quote_items_quantity_positive"
),
Index("idx_quote_items_quote_id", "quote_id"),
Index("idx_quote_items_category", "category"),
)
def __repr__(self) -> str:
"""String representation of the quote item."""
return f"<QuoteItem(service='{self.service_name}', qty={self.quantity}, price={self.unit_price})>"
@property
def line_total(self) -> Decimal:
"""Calculate the line total (unit_price * quantity)."""
return self.unit_price * self.quantity
@property
def monthly_amount(self) -> Decimal:
"""Calculate the monthly amount based on billing frequency."""
if self.billing_frequency == BillingFrequency.MONTHLY.value:
return self.line_total
elif self.billing_frequency == BillingFrequency.QUARTERLY.value:
return self.line_total / Decimal("3")
elif self.billing_frequency == BillingFrequency.ANNUAL.value:
return self.line_total / Decimal("12")
else: # one_time
return Decimal("0.00")
class QuoteActivity(Base, UUIDMixin, TimestampMixin):
"""
Quote activity model for tracking quote history and changes.
Logs all actions taken on a quote for audit and tracking purposes.
Attributes:
quote_id: Reference to the parent quote
action: Action performed (created, updated, submitted, etc.)
description: Detailed description of the action
actor: Who performed the action (email, 'system', 'admin')
ip_address: IP address of the actor
metadata: JSON metadata about the action
"""
__tablename__ = "quote_activities"
# Foreign keys
quote_id: Mapped[str] = mapped_column(
CHAR(36),
ForeignKey("quotes.id", ondelete="CASCADE"),
nullable=False,
doc="Reference to the parent quote"
)
# Activity details
action: Mapped[str] = mapped_column(
String(50),
nullable=False,
doc="Action performed: created, updated, item_added, item_removed, submitted, status_changed, etc."
)
description: Mapped[Optional[str]] = mapped_column(
Text,
doc="Detailed description of the action"
)
actor: Mapped[Optional[str]] = mapped_column(
String(255),
doc="Who performed the action (email, 'system', 'admin')"
)
ip_address: Mapped[Optional[str]] = mapped_column(
String(45),
doc="IP address of the actor"
)
metadata: Mapped[Optional[str]] = mapped_column(
Text,
doc="JSON metadata about the action"
)
# Relationships
quote: Mapped["Quote"] = relationship(
"Quote",
back_populates="activities"
)
# Indexes
__table_args__ = (
Index("idx_quote_activities_quote_id", "quote_id"),
Index("idx_quote_activities_action", "action"),
Index("idx_quote_activities_created_at", "created_at"),
)
def __repr__(self) -> str:
"""String representation of the quote activity."""
return f"<QuoteActivity(quote_id='{self.quote_id}', action='{self.action}')>"
class QuoteNotification(Base, UUIDMixin, TimestampMixin):
"""
Quote notification model for tracking notifications sent.
Records all notifications (emails, SMS, alerts) sent for a quote.
Attributes:
quote_id: Reference to the parent quote
notification_type: Type of notification (email_sent, sms_sent, etc.)
recipient: Notification recipient (email, phone, etc.)
subject: Notification subject
content: Notification content/body
status: Delivery status (pending, sent, delivered, failed)
sent_at: Timestamp when notification was sent
error_message: Error message if delivery failed
"""
__tablename__ = "quote_notifications"
# Foreign keys
quote_id: Mapped[str] = mapped_column(
CHAR(36),
ForeignKey("quotes.id", ondelete="CASCADE"),
nullable=False,
doc="Reference to the parent quote"
)
# Notification details
notification_type: Mapped[str] = mapped_column(
String(30),
nullable=False,
doc="Type of notification: email_sent, sms_sent, admin_alert, reminder_sent"
)
recipient: Mapped[str] = mapped_column(
String(255),
nullable=False,
doc="Notification recipient (email, phone, etc.)"
)
subject: Mapped[Optional[str]] = mapped_column(
String(500),
doc="Notification subject"
)
content: Mapped[Optional[str]] = mapped_column(
Text,
doc="Notification content/body"
)
# Status tracking
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="pending",
server_default="pending",
doc="Delivery status: pending, sent, delivered, failed"
)
sent_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
doc="Timestamp when notification was sent"
)
error_message: Mapped[Optional[str]] = mapped_column(
Text,
doc="Error message if delivery failed"
)
# Relationships
quote: Mapped["Quote"] = relationship(
"Quote",
back_populates="notifications"
)
# Constraints and indexes
__table_args__ = (
CheckConstraint(
"notification_type IN ('email_sent', 'sms_sent', 'admin_alert', 'reminder_sent')",
name="ck_quote_notifications_type"
),
CheckConstraint(
"status IN ('pending', 'sent', 'delivered', 'failed')",
name="ck_quote_notifications_status"
),
Index("idx_quote_notifications_quote_id", "quote_id"),
Index("idx_quote_notifications_type", "notification_type"),
Index("idx_quote_notifications_status", "status"),
)
def __repr__(self) -> str:
"""String representation of the quote notification."""
return f"<QuoteNotification(type='{self.notification_type}', recipient='{self.recipient}', status='{self.status}')>"

384
api/routers/admin_quotes.py Normal file
View File

@@ -0,0 +1,384 @@
"""
Admin Quote API router for ClaudeTools.
This module defines all admin REST API endpoints for managing quotes,
requiring JWT authentication for access.
"""
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.orm import Session
from api.database import get_db
from api.middleware.auth import get_current_user
from api.schemas.quote import (
QuoteAdminResponse,
QuoteAdminUpdate,
QuoteActivityResponse,
QuoteItemResponse,
QuoteListItem,
QuoteListResponse,
QuoteNotificationResponse,
QuoteStatsResponse,
QuoteStatus,
)
from api.services import quote_service
# Create router with authentication required
router = APIRouter()
@router.get(
"",
response_model=QuoteListResponse,
summary="List all quotes",
description="Retrieve a paginated list of all quotes with optional filtering",
status_code=status.HTTP_200_OK,
)
def list_quotes(
skip: int = Query(
default=0,
ge=0,
description="Number of records to skip for pagination"
),
limit: int = Query(
default=100,
ge=1,
le=1000,
description="Maximum number of records to return (max 1000)"
),
status_filter: Optional[str] = Query(
default=None,
alias="status",
description="Filter by status (draft, submitted, reviewing, approved, rejected, expired)"
),
search: Optional[str] = Query(
default=None,
description="Search in company_name, contact_name, contact_email"
),
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
List all quotes with pagination and filtering.
- **skip**: Number of quotes to skip (default: 0)
- **limit**: Maximum number of quotes to return (default: 100, max: 1000)
- **status**: Filter by quote status
- **search**: Search in company name, contact name, or email
Returns a list of quotes with pagination metadata.
**Example Request:**
```
GET /api/admin/quotes?skip=0&limit=50&status=submitted
Authorization: Bearer <token>
```
**Example Response:**
```json
{
"total": 25,
"skip": 0,
"limit": 50,
"quotes": [
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"access_token": "xYz123...",
"status": "submitted",
"company_name": "Acme Corporation",
"contact_name": "John Doe",
"contact_email": "john@acme.com",
"employee_count": 25,
"monthly_total": "450.00",
"setup_total": "500.00",
"item_count": 3,
"submitted_at": "2024-01-15T14:30:00Z",
"created_at": "2024-01-15T10:30:00Z"
}
]
}
```
"""
quotes, total = quote_service.list_quotes(
db=db,
skip=skip,
limit=limit,
status_filter=status_filter,
search=search
)
# Build list items with item counts
quote_items = []
for quote in quotes:
quote_items.append(QuoteListItem(
id=quote.id,
access_token=quote.access_token,
status=quote.status,
company_name=quote.company_name,
contact_name=quote.contact_name,
contact_email=quote.contact_email,
employee_count=quote.employee_count,
monthly_total=quote.monthly_total,
setup_total=quote.setup_total,
item_count=len(quote.items),
submitted_at=quote.submitted_at,
created_at=quote.created_at
))
return QuoteListResponse(
total=total,
skip=skip,
limit=limit,
quotes=quote_items
)
@router.get(
"/stats",
response_model=QuoteStatsResponse,
summary="Get quote statistics",
description="Get dashboard statistics for quotes",
status_code=status.HTTP_200_OK,
)
def get_stats(
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
Get quote statistics for the admin dashboard.
Returns aggregate statistics including totals, counts by status,
and conversion rates.
**Example Request:**
```
GET /api/admin/quotes/stats
Authorization: Bearer <token>
```
**Example Response:**
```json
{
"total_quotes": 150,
"quotes_by_status": {
"draft": 45,
"submitted": 60,
"reviewing": 15,
"approved": 25,
"rejected": 3,
"expired": 2
},
"total_monthly_value": "12500.00",
"total_setup_value": "8500.00",
"quotes_this_month": 28,
"quotes_submitted_this_month": 18,
"average_monthly_value": "125.00",
"conversion_rate": "66.67"
}
```
"""
return quote_service.get_quote_stats(db)
@router.get(
"/{quote_id}",
response_model=QuoteAdminResponse,
summary="Get quote by ID",
description="Retrieve a single quote by its ID with full details",
status_code=status.HTTP_200_OK,
responses={
200: {
"description": "Quote found and returned",
"model": QuoteAdminResponse,
},
404: {
"description": "Quote not found",
"content": {
"application/json": {
"example": {"detail": "Quote with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
}
},
},
},
)
def get_quote(
quote_id: UUID,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
Get a specific quote by ID with full admin details.
Returns the quote with all items, activities, and notifications.
**Example Request:**
```
GET /api/admin/quotes/123e4567-e89b-12d3-a456-426614174000
Authorization: Bearer <token>
```
**Example Response:**
```json
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"access_token": "xYz123...",
"status": "submitted",
"company_name": "Acme Corporation",
"contact_name": "John Doe",
"contact_email": "john@acme.com",
"admin_notes": "Follow up scheduled for next week",
"ip_address": "192.168.1.100",
"user_agent": "Mozilla/5.0...",
"items": [...],
"activities": [
{
"id": "789...",
"action": "submitted",
"description": "Quote submitted by John Doe (john@acme.com)",
"actor": "john@acme.com",
"created_at": "2024-01-15T14:30:00Z"
}
],
"notifications": [...]
}
```
"""
quote = quote_service.get_quote_by_id(db, quote_id)
# Build response with all related data
items_response = []
for item in quote.items:
items_response.append(QuoteItemResponse(
id=item.id,
quote_id=item.quote_id,
service_name=item.service_name,
service_description=item.service_description,
category=item.category,
billing_frequency=item.billing_frequency,
unit_price=item.unit_price,
quantity=item.quantity,
setup_fee=item.setup_fee,
is_required=item.is_required,
sort_order=item.sort_order,
line_total=item.line_total,
monthly_amount=item.monthly_amount,
created_at=item.created_at,
updated_at=item.updated_at
))
activities_response = []
for activity in quote.activities:
activities_response.append(QuoteActivityResponse(
id=activity.id,
quote_id=activity.quote_id,
action=activity.action,
description=activity.description,
actor=activity.actor,
ip_address=activity.ip_address,
created_at=activity.created_at
))
notifications_response = []
for notification in quote.notifications:
notifications_response.append(QuoteNotificationResponse(
id=notification.id,
quote_id=notification.quote_id,
notification_type=notification.notification_type,
recipient=notification.recipient,
subject=notification.subject,
status=notification.status,
sent_at=notification.sent_at,
error_message=notification.error_message,
created_at=notification.created_at
))
return QuoteAdminResponse(
id=quote.id,
access_token=quote.access_token,
status=quote.status,
company_name=quote.company_name,
contact_name=quote.contact_name,
contact_email=quote.contact_email,
contact_phone=quote.contact_phone,
employee_count=quote.employee_count,
notes=quote.notes,
admin_notes=quote.admin_notes,
monthly_total=quote.monthly_total,
setup_total=quote.setup_total,
annual_total=quote.annual_total,
expires_at=quote.expires_at,
submitted_at=quote.submitted_at,
ip_address=quote.ip_address,
user_agent=quote.user_agent,
created_at=quote.created_at,
updated_at=quote.updated_at,
items=items_response,
activities=activities_response,
notifications=notifications_response
)
@router.put(
"/{quote_id}",
response_model=QuoteAdminResponse,
summary="Update quote status/notes",
description="Update a quote's status or admin notes",
status_code=status.HTTP_200_OK,
responses={
200: {
"description": "Quote updated successfully",
"model": QuoteAdminResponse,
},
404: {
"description": "Quote not found",
},
},
)
def update_quote(
quote_id: UUID,
update_data: QuoteAdminUpdate,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
Update a quote's status or admin notes.
Admins can change the quote status (e.g., from submitted to reviewing
or approved) and add internal notes.
**Example Request:**
```json
PUT /api/admin/quotes/123e4567-e89b-12d3-a456-426614174000
Authorization: Bearer <token>
Content-Type: application/json
{
"status": "reviewing",
"admin_notes": "Assigned to sales team. Follow up scheduled for Monday."
}
```
**Example Response:**
```json
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"status": "reviewing",
"admin_notes": "Assigned to sales team. Follow up scheduled for Monday.",
...
}
```
"""
# Get admin username from token
admin_user = current_user.get("sub", "admin")
quote_service.update_quote_status(
db=db,
quote_id=quote_id,
update_data=update_data,
admin_user=admin_user
)
return get_quote(quote_id, db, current_user)

519
api/routers/quotes.py Normal file
View File

@@ -0,0 +1,519 @@
"""
Public Quote API router for ClaudeTools.
This module defines all public REST API endpoints for the MSP Quote Wizard,
allowing prospects to create, view, and submit quotes without authentication.
"""
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy.orm import Session
from api.database import get_db
from api.schemas.quote import (
QuoteCreate,
QuoteCreatedResponse,
QuoteItemCreate,
QuoteResponse,
QuoteItemResponse,
QuoteSubmit,
QuoteUpdate,
)
from api.services import quote_service
# Create router (no authentication required for public endpoints)
router = APIRouter()
def get_client_ip(request: Request) -> Optional[str]:
"""Extract client IP from request, handling proxies."""
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else None
def get_user_agent(request: Request) -> Optional[str]:
"""Extract user agent from request."""
return request.headers.get("User-Agent")
@router.post(
"",
response_model=QuoteCreatedResponse,
summary="Create new quote draft",
description="Create a new quote draft. Returns an access token for future access.",
status_code=status.HTTP_201_CREATED,
responses={
201: {
"description": "Quote created successfully",
"model": QuoteCreatedResponse,
},
500: {
"description": "Server error",
"content": {
"application/json": {
"example": {"detail": "Failed to create quote"}
}
},
},
},
)
def create_quote(
quote_data: QuoteCreate,
request: Request,
db: Session = Depends(get_db),
):
"""
Create a new quote draft.
This endpoint does not require authentication. A unique access token is
generated for the quote which can be used to access it later.
**Example Request:**
```json
POST /api/quotes
Content-Type: application/json
{
"employee_count": 25,
"notes": "Looking for complete managed services package"
}
```
**Example Response:**
```json
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"access_token": "xYz123abc456def789ghi012jkl345mno678pqr901stu",
"status": "draft",
"message": "Quote created successfully. Use the access_token to access your quote."
}
```
"""
ip_address = get_client_ip(request)
user_agent = get_user_agent(request)
quote = quote_service.create_quote(
db=db,
quote_data=quote_data,
ip_address=ip_address,
user_agent=user_agent
)
return QuoteCreatedResponse(
id=quote.id,
access_token=quote.access_token,
status=quote.status,
message="Quote created successfully. Use the access_token to access your quote."
)
@router.get(
"/{access_token}",
response_model=QuoteResponse,
summary="Get quote by access token",
description="Retrieve a quote by its access token",
status_code=status.HTTP_200_OK,
responses={
200: {
"description": "Quote found and returned",
"model": QuoteResponse,
},
404: {
"description": "Quote not found",
"content": {
"application/json": {
"example": {"detail": "Quote not found"}
}
},
},
},
)
def get_quote(
access_token: str,
db: Session = Depends(get_db),
):
"""
Get a quote by its access token.
Returns the quote with all its items. This is the public endpoint
for viewing a quote.
**Example Request:**
```
GET /api/quotes/xYz123abc456def789ghi012jkl345mno678pqr901stu
```
**Example Response:**
```json
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"access_token": "xYz123abc456def789ghi012jkl345mno678pqr901stu",
"status": "draft",
"company_name": null,
"contact_name": null,
"contact_email": null,
"employee_count": 25,
"monthly_total": "450.00",
"setup_total": "500.00",
"annual_total": "5900.00",
"items": [
{
"id": "456e7890-e89b-12d3-a456-426614174001",
"service_name": "Managed Endpoint Protection",
"category": "security",
"unit_price": "15.00",
"quantity": 25,
"billing_frequency": "monthly",
"line_total": "375.00",
"monthly_amount": "375.00"
}
],
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
```
"""
quote = quote_service.get_quote_by_token(db, access_token)
# Build response with calculated fields for items
items_response = []
for item in quote.items:
item_dict = QuoteItemResponse(
id=item.id,
quote_id=item.quote_id,
service_name=item.service_name,
service_description=item.service_description,
category=item.category,
billing_frequency=item.billing_frequency,
unit_price=item.unit_price,
quantity=item.quantity,
setup_fee=item.setup_fee,
is_required=item.is_required,
sort_order=item.sort_order,
line_total=item.line_total,
monthly_amount=item.monthly_amount,
created_at=item.created_at,
updated_at=item.updated_at
)
items_response.append(item_dict)
return QuoteResponse(
id=quote.id,
access_token=quote.access_token,
status=quote.status,
company_name=quote.company_name,
contact_name=quote.contact_name,
contact_email=quote.contact_email,
contact_phone=quote.contact_phone,
employee_count=quote.employee_count,
notes=quote.notes,
monthly_total=quote.monthly_total,
setup_total=quote.setup_total,
annual_total=quote.annual_total,
expires_at=quote.expires_at,
submitted_at=quote.submitted_at,
created_at=quote.created_at,
updated_at=quote.updated_at,
items=items_response
)
@router.put(
"/{access_token}",
response_model=QuoteResponse,
summary="Update quote",
description="Update a quote's details and/or items",
status_code=status.HTTP_200_OK,
responses={
200: {
"description": "Quote updated successfully",
"model": QuoteResponse,
},
400: {
"description": "Quote cannot be modified (not a draft)",
"content": {
"application/json": {
"example": {"detail": "Cannot update quote with status 'submitted'. Only drafts can be modified."}
}
},
},
404: {
"description": "Quote not found",
},
},
)
def update_quote(
access_token: str,
quote_data: QuoteUpdate,
request: Request,
db: Session = Depends(get_db),
):
"""
Update a quote.
Updates quote details and/or replaces all items. Only draft quotes
can be modified.
**Example Request:**
```json
PUT /api/quotes/xYz123abc456def789ghi012jkl345mno678pqr901stu
Content-Type: application/json
{
"employee_count": 30,
"items": [
{
"service_name": "Managed Endpoint Protection",
"category": "security",
"unit_price": "15.00",
"quantity": 30,
"billing_frequency": "monthly"
},
{
"service_name": "Cloud Backup",
"category": "backup",
"unit_price": "5.00",
"quantity": 30,
"billing_frequency": "monthly",
"setup_fee": "250.00"
}
]
}
```
"""
ip_address = get_client_ip(request)
quote = quote_service.update_quote(
db=db,
access_token=access_token,
quote_data=quote_data,
ip_address=ip_address
)
return get_quote(access_token, db)
@router.post(
"/{access_token}/items",
response_model=QuoteResponse,
summary="Add item to quote",
description="Add a single item to the quote",
status_code=status.HTTP_201_CREATED,
responses={
201: {
"description": "Item added successfully",
"model": QuoteResponse,
},
400: {
"description": "Quote cannot be modified",
},
404: {
"description": "Quote not found",
},
},
)
def add_item(
access_token: str,
item_data: QuoteItemCreate,
request: Request,
db: Session = Depends(get_db),
):
"""
Add a single item to a quote.
**Example Request:**
```json
POST /api/quotes/xYz123abc456def789ghi012jkl345mno678pqr901stu/items
Content-Type: application/json
{
"service_name": "24/7 Help Desk Support",
"category": "support",
"unit_price": "50.00",
"quantity": 1,
"billing_frequency": "monthly"
}
```
"""
ip_address = get_client_ip(request)
quote_service.add_item_to_quote(
db=db,
access_token=access_token,
item_data=item_data,
ip_address=ip_address
)
return get_quote(access_token, db)
@router.delete(
"/{access_token}/items/{item_id}",
response_model=QuoteResponse,
summary="Remove item from quote",
description="Remove an item from the quote",
status_code=status.HTTP_200_OK,
responses={
200: {
"description": "Item removed successfully",
"model": QuoteResponse,
},
400: {
"description": "Quote cannot be modified or item is required",
},
404: {
"description": "Quote or item not found",
},
},
)
def remove_item(
access_token: str,
item_id: UUID,
request: Request,
db: Session = Depends(get_db),
):
"""
Remove an item from a quote.
Required items cannot be removed.
**Example Request:**
```
DELETE /api/quotes/xYz123abc456def789ghi012jkl345mno678pqr901stu/items/456e7890-e89b-12d3-a456-426614174001
```
"""
ip_address = get_client_ip(request)
quote_service.remove_item_from_quote(
db=db,
access_token=access_token,
item_id=item_id,
ip_address=ip_address
)
return get_quote(access_token, db)
@router.post(
"/{access_token}/submit",
response_model=QuoteResponse,
summary="Submit quote",
description="Submit the quote with contact information",
status_code=status.HTTP_200_OK,
responses={
200: {
"description": "Quote submitted successfully",
"model": QuoteResponse,
},
400: {
"description": "Quote cannot be submitted (not a draft or no items)",
"content": {
"application/json": {
"examples": {
"not_draft": {
"value": {"detail": "Cannot submit quote with status 'submitted'. Only drafts can be submitted."}
},
"no_items": {
"value": {"detail": "Cannot submit quote without any items. Please add at least one service."}
}
}
}
},
},
404: {
"description": "Quote not found",
},
422: {
"description": "Validation error - missing required fields",
},
},
)
def submit_quote(
access_token: str,
submit_data: QuoteSubmit,
request: Request,
db: Session = Depends(get_db),
):
"""
Submit a quote with contact information.
This finalizes the quote and sends it for review. Contact information
is required at this stage.
**Example Request:**
```json
POST /api/quotes/xYz123abc456def789ghi012jkl345mno678pqr901stu/submit
Content-Type: application/json
{
"company_name": "Acme Corporation",
"contact_name": "John Doe",
"contact_email": "john.doe@acme.com",
"contact_phone": "555-123-4567",
"notes": "Please contact me to discuss implementation timeline."
}
```
**Example Response:**
```json
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"access_token": "xYz123abc456def789ghi012jkl345mno678pqr901stu",
"status": "submitted",
"company_name": "Acme Corporation",
"contact_name": "John Doe",
"contact_email": "john.doe@acme.com",
"submitted_at": "2024-01-15T14:30:00Z",
...
}
```
"""
ip_address = get_client_ip(request)
quote_service.submit_quote(
db=db,
access_token=access_token,
submit_data=submit_data,
ip_address=ip_address
)
return get_quote(access_token, db)
@router.get(
"/{access_token}/pdf",
summary="Get quote PDF (placeholder)",
description="Generate and return a PDF version of the quote",
status_code=status.HTTP_501_NOT_IMPLEMENTED,
responses={
501: {
"description": "PDF generation not yet implemented",
"content": {
"application/json": {
"example": {"detail": "PDF generation is not yet implemented"}
}
},
},
},
)
def get_quote_pdf(
access_token: str,
db: Session = Depends(get_db),
):
"""
Generate a PDF version of the quote.
**Note:** This endpoint is a placeholder. PDF generation will be
implemented in a future update.
"""
# Verify quote exists
quote_service.get_quote_by_token(db, access_token)
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="PDF generation is not yet implemented"
)

View File

@@ -15,6 +15,20 @@ from .m365_tenant import M365TenantBase, M365TenantCreate, M365TenantResponse, M
from .machine import MachineBase, MachineCreate, MachineResponse, MachineUpdate
from .network import NetworkBase, NetworkCreate, NetworkResponse, NetworkUpdate
from .project import ProjectBase, ProjectCreate, ProjectResponse, ProjectUpdate
from .quote import (
QuoteCreate,
QuoteCreatedResponse,
QuoteItemCreate,
QuoteItemResponse,
QuoteItemUpdate,
QuoteListResponse,
QuoteResponse,
QuoteAdminResponse,
QuoteAdminUpdate,
QuoteStatsResponse,
QuoteSubmit,
QuoteUpdate,
)
from .security_incident import SecurityIncidentBase, SecurityIncidentCreate, SecurityIncidentResponse, SecurityIncidentUpdate
from .service import ServiceBase, ServiceCreate, ServiceResponse, ServiceUpdate
from .session import SessionBase, SessionCreate, SessionResponse, SessionUpdate
@@ -109,4 +123,17 @@ __all__ = [
"SecurityIncidentCreate",
"SecurityIncidentUpdate",
"SecurityIncidentResponse",
# Quote schemas
"QuoteCreate",
"QuoteCreatedResponse",
"QuoteItemCreate",
"QuoteItemResponse",
"QuoteItemUpdate",
"QuoteListResponse",
"QuoteResponse",
"QuoteAdminResponse",
"QuoteAdminUpdate",
"QuoteStatsResponse",
"QuoteSubmit",
"QuoteUpdate",
]

303
api/schemas/quote.py Normal file
View File

@@ -0,0 +1,303 @@
"""
Pydantic schemas for Quote models.
Request and response schemas for the MSP Quote Wizard including
public and admin-facing operations.
"""
from datetime import datetime
from decimal import Decimal
from enum import Enum
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field, EmailStr, field_validator
class QuoteStatus(str, Enum):
"""Status options for quotes."""
DRAFT = "draft"
SUBMITTED = "submitted"
REVIEWING = "reviewing"
APPROVED = "approved"
REJECTED = "rejected"
EXPIRED = "expired"
class ServiceCategory(str, Enum):
"""Service category options for quote items."""
MANAGED_SERVICES = "managed_services"
SECURITY = "security"
BACKUP = "backup"
CLOUD = "cloud"
HARDWARE = "hardware"
SOFTWARE = "software"
CONSULTING = "consulting"
SUPPORT = "support"
class BillingFrequency(str, Enum):
"""Billing frequency options for quote items."""
MONTHLY = "monthly"
QUARTERLY = "quarterly"
ANNUAL = "annual"
ONE_TIME = "one_time"
class NotificationType(str, Enum):
"""Notification types for quote events."""
EMAIL_SENT = "email_sent"
SMS_SENT = "sms_sent"
ADMIN_ALERT = "admin_alert"
REMINDER_SENT = "reminder_sent"
# ============================================================================
# Quote Item Schemas
# ============================================================================
class QuoteItemBase(BaseModel):
"""Base schema with shared QuoteItem fields."""
service_name: str = Field(..., description="Name of the service", min_length=1, max_length=255)
service_description: Optional[str] = Field(None, description="Detailed description of the service")
category: ServiceCategory = Field(
ServiceCategory.MANAGED_SERVICES,
description="Service category"
)
billing_frequency: BillingFrequency = Field(
BillingFrequency.MONTHLY,
description="Billing frequency"
)
unit_price: Decimal = Field(..., description="Price per unit", ge=0)
quantity: int = Field(1, description="Number of units", ge=1)
setup_fee: Decimal = Field(Decimal("0.00"), description="One-time setup fee", ge=0)
is_required: bool = Field(False, description="Whether this item is required")
sort_order: int = Field(0, description="Display order within the quote")
class QuoteItemCreate(QuoteItemBase):
"""Schema for creating a new QuoteItem."""
pass
class QuoteItemUpdate(BaseModel):
"""Schema for updating an existing QuoteItem. All fields optional."""
service_name: Optional[str] = Field(None, min_length=1, max_length=255)
service_description: Optional[str] = None
category: Optional[ServiceCategory] = None
billing_frequency: Optional[BillingFrequency] = None
unit_price: Optional[Decimal] = Field(None, ge=0)
quantity: Optional[int] = Field(None, ge=1)
setup_fee: Optional[Decimal] = Field(None, ge=0)
is_required: Optional[bool] = None
sort_order: Optional[int] = None
class QuoteItemResponse(QuoteItemBase):
"""Schema for QuoteItem responses with ID and computed fields."""
id: UUID = Field(..., description="Unique identifier for the quote item")
quote_id: UUID = Field(..., description="Reference to the parent quote")
line_total: Decimal = Field(..., description="Calculated line total (unit_price * quantity)")
monthly_amount: Decimal = Field(..., description="Calculated monthly amount")
created_at: datetime = Field(..., description="Timestamp when item was created")
updated_at: datetime = Field(..., description="Timestamp when item was last updated")
model_config = {"from_attributes": True}
# ============================================================================
# Quote Schemas
# ============================================================================
class QuoteBase(BaseModel):
"""Base schema with shared Quote fields."""
company_name: Optional[str] = Field(None, description="Prospect company name", max_length=255)
contact_name: Optional[str] = Field(None, description="Primary contact name", max_length=255)
contact_email: Optional[EmailStr] = Field(None, description="Contact email address")
contact_phone: Optional[str] = Field(None, description="Contact phone number", max_length=50)
employee_count: Optional[int] = Field(None, description="Number of employees/users", ge=1)
notes: Optional[str] = Field(None, description="Customer notes or special requirements")
class QuoteCreate(BaseModel):
"""Schema for creating a new Quote draft."""
employee_count: Optional[int] = Field(None, description="Number of employees/users", ge=1)
notes: Optional[str] = Field(None, description="Initial notes")
# Items can optionally be provided at creation
items: Optional[list[QuoteItemCreate]] = Field(None, description="Initial quote items")
class QuoteUpdate(BaseModel):
"""Schema for updating a Quote during wizard flow."""
company_name: Optional[str] = Field(None, max_length=255)
contact_name: Optional[str] = Field(None, max_length=255)
contact_email: Optional[EmailStr] = None
contact_phone: Optional[str] = Field(None, max_length=50)
employee_count: Optional[int] = Field(None, ge=1)
notes: Optional[str] = None
# Items to add/update
items: Optional[list[QuoteItemCreate]] = Field(None, description="Items to set (replaces existing)")
class QuoteSubmit(BaseModel):
"""Schema for final quote submission with required contact info."""
company_name: str = Field(..., description="Company name (required for submission)", min_length=1, max_length=255)
contact_name: str = Field(..., description="Contact name (required for submission)", min_length=1, max_length=255)
contact_email: EmailStr = Field(..., description="Email address (required for submission)")
contact_phone: Optional[str] = Field(None, description="Phone number", max_length=50)
notes: Optional[str] = Field(None, description="Additional notes")
@field_validator("company_name", "contact_name")
@classmethod
def strip_whitespace(cls, v: str) -> str:
"""Strip whitespace from string fields."""
return v.strip() if v else v
class QuoteResponse(QuoteBase):
"""Schema for public Quote responses with items."""
id: UUID = Field(..., description="Unique identifier for the quote")
access_token: str = Field(..., description="Access token for public URL")
status: QuoteStatus = Field(..., description="Current quote status")
monthly_total: Decimal = Field(..., description="Calculated monthly recurring total")
setup_total: Decimal = Field(..., description="Calculated one-time setup total")
annual_total: Decimal = Field(..., description="Calculated annual total")
expires_at: Optional[datetime] = Field(None, description="Quote expiration date")
submitted_at: Optional[datetime] = Field(None, description="When quote was submitted")
created_at: datetime = Field(..., description="Timestamp when quote was created")
updated_at: datetime = Field(..., description="Timestamp when quote was last updated")
items: list[QuoteItemResponse] = Field(default_factory=list, description="Quote line items")
model_config = {"from_attributes": True}
class QuoteCreatedResponse(BaseModel):
"""Schema for quote creation response with access URL info."""
id: UUID = Field(..., description="Unique identifier for the quote")
access_token: str = Field(..., description="Access token for public URL")
status: QuoteStatus = Field(..., description="Current quote status")
message: str = Field(..., description="Success message")
model_config = {"from_attributes": True}
# ============================================================================
# Quote Activity Schemas
# ============================================================================
class QuoteActivityResponse(BaseModel):
"""Schema for QuoteActivity responses."""
id: UUID = Field(..., description="Unique identifier for the activity")
quote_id: UUID = Field(..., description="Reference to the parent quote")
action: str = Field(..., description="Action performed")
description: Optional[str] = Field(None, description="Detailed description")
actor: Optional[str] = Field(None, description="Who performed the action")
ip_address: Optional[str] = Field(None, description="IP address of the actor")
created_at: datetime = Field(..., description="Timestamp of the action")
model_config = {"from_attributes": True}
# ============================================================================
# Quote Notification Schemas
# ============================================================================
class QuoteNotificationResponse(BaseModel):
"""Schema for QuoteNotification responses."""
id: UUID = Field(..., description="Unique identifier for the notification")
quote_id: UUID = Field(..., description="Reference to the parent quote")
notification_type: NotificationType = Field(..., description="Type of notification")
recipient: str = Field(..., description="Notification recipient")
subject: Optional[str] = Field(None, description="Notification subject")
status: str = Field(..., description="Delivery status")
sent_at: Optional[datetime] = Field(None, description="When notification was sent")
error_message: Optional[str] = Field(None, description="Error message if failed")
created_at: datetime = Field(..., description="Timestamp when created")
model_config = {"from_attributes": True}
# ============================================================================
# Admin Schemas
# ============================================================================
class QuoteAdminUpdate(BaseModel):
"""Schema for admin updates to a quote."""
status: Optional[QuoteStatus] = Field(None, description="New status")
admin_notes: Optional[str] = Field(None, description="Internal admin notes")
expires_at: Optional[datetime] = Field(None, description="Quote expiration date")
class QuoteAdminResponse(QuoteResponse):
"""Schema for admin Quote responses with additional fields."""
admin_notes: Optional[str] = Field(None, description="Internal admin notes")
ip_address: Optional[str] = Field(None, description="IP address of the requester")
user_agent: Optional[str] = Field(None, description="Browser user agent")
activities: list[QuoteActivityResponse] = Field(
default_factory=list,
description="Activity log for this quote"
)
notifications: list[QuoteNotificationResponse] = Field(
default_factory=list,
description="Notifications sent for this quote"
)
model_config = {"from_attributes": True}
# ============================================================================
# List and Stats Schemas
# ============================================================================
class QuoteListItem(BaseModel):
"""Schema for quote list items (summary view)."""
id: UUID = Field(..., description="Unique identifier")
access_token: str = Field(..., description="Access token")
status: QuoteStatus = Field(..., description="Current status")
company_name: Optional[str] = Field(None, description="Company name")
contact_name: Optional[str] = Field(None, description="Contact name")
contact_email: Optional[str] = Field(None, description="Contact email")
employee_count: Optional[int] = Field(None, description="Employee count")
monthly_total: Decimal = Field(..., description="Monthly total")
setup_total: Decimal = Field(..., description="Setup total")
item_count: int = Field(..., description="Number of line items")
submitted_at: Optional[datetime] = Field(None, description="Submission timestamp")
created_at: datetime = Field(..., description="Creation timestamp")
model_config = {"from_attributes": True}
class QuoteListResponse(BaseModel):
"""Schema for paginated quote list responses."""
total: int = Field(..., description="Total number of quotes matching filters")
skip: int = Field(..., description="Number of records skipped")
limit: int = Field(..., description="Maximum number of records returned")
quotes: list[QuoteListItem] = Field(..., description="List of quotes")
class QuoteStatsResponse(BaseModel):
"""Schema for admin dashboard statistics."""
total_quotes: int = Field(..., description="Total number of quotes")
quotes_by_status: dict[str, int] = Field(..., description="Quote count by status")
total_monthly_value: Decimal = Field(..., description="Total monthly value of all submitted quotes")
total_setup_value: Decimal = Field(..., description="Total setup value of all submitted quotes")
quotes_this_month: int = Field(..., description="Quotes created this month")
quotes_submitted_this_month: int = Field(..., description="Quotes submitted this month")
average_monthly_value: Decimal = Field(..., description="Average monthly value per submitted quote")
conversion_rate: Decimal = Field(..., description="Percentage of drafts that get submitted")

View File

@@ -11,6 +11,8 @@ from . import (
credential_service,
credential_audit_log_service,
security_incident_service,
quote_service,
syncro_service,
)
__all__ = [
@@ -24,4 +26,6 @@ __all__ = [
"credential_service",
"credential_audit_log_service",
"security_incident_service",
"quote_service",
"syncro_service",
]

View File

@@ -0,0 +1,985 @@
"""
Quote service layer for business logic and database operations.
This module handles all database operations for quotes, providing a clean
separation between the API routes and data access layer.
"""
import json
import logging
import secrets
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Optional
from uuid import UUID
from fastapi import HTTPException, status
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload
from api.models.quote import (
Quote,
QuoteActivity,
QuoteItem,
QuoteNotification,
QuoteStatus,
BillingFrequency,
)
from api.schemas.quote import (
QuoteCreate,
QuoteUpdate,
QuoteSubmit,
QuoteItemCreate,
QuoteAdminUpdate,
QuoteListItem,
QuoteStatsResponse,
)
from api.services.syncro_service import get_syncro_service
logger = logging.getLogger(__name__)
def generate_access_token() -> str:
"""
Generate a secure, URL-safe access token for quote access.
Returns:
str: A 43-character URL-safe token
"""
return secrets.token_urlsafe(32)
def calculate_totals(items: list[QuoteItem]) -> tuple[Decimal, Decimal, Decimal]:
"""
Calculate monthly, setup, and annual totals from quote items.
Args:
items: List of QuoteItem objects
Returns:
tuple: (monthly_total, setup_total, annual_total)
"""
monthly_total = Decimal("0.00")
setup_total = Decimal("0.00")
for item in items:
# Calculate line total
line_total = item.unit_price * item.quantity
# Add to appropriate total based on billing frequency
if item.billing_frequency == BillingFrequency.MONTHLY.value:
monthly_total += line_total
elif item.billing_frequency == BillingFrequency.QUARTERLY.value:
monthly_total += line_total / Decimal("3")
elif item.billing_frequency == BillingFrequency.ANNUAL.value:
monthly_total += line_total / Decimal("12")
# one_time items don't add to monthly
# Setup fees are always one-time
setup_total += item.setup_fee
# Annual total is monthly * 12 + setup
annual_total = (monthly_total * Decimal("12")) + setup_total
return monthly_total, setup_total, annual_total
def log_activity(
db: Session,
quote_id: str,
action: str,
description: Optional[str] = None,
actor: Optional[str] = None,
ip_address: Optional[str] = None,
metadata: Optional[dict] = None
) -> QuoteActivity:
"""
Log an activity for a quote.
Args:
db: Database session
quote_id: UUID of the quote
action: Action being performed
description: Detailed description
actor: Who performed the action
ip_address: IP address of the actor
metadata: Additional metadata as dict
Returns:
QuoteActivity: The created activity record
"""
activity = QuoteActivity(
quote_id=quote_id,
action=action,
description=description,
actor=actor,
ip_address=ip_address,
metadata=json.dumps(metadata) if metadata else None
)
db.add(activity)
db.flush()
return activity
def create_quote(
db: Session,
quote_data: QuoteCreate,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None
) -> Quote:
"""
Create a new quote draft with access token.
Args:
db: Database session
quote_data: Quote creation data
ip_address: IP address of the requester
user_agent: Browser user agent
Returns:
Quote: The created quote object
Example:
```python
quote_data = QuoteCreate(employee_count=25)
quote = create_quote(db, quote_data, ip_address="192.168.1.1")
print(f"Quote created: {quote.access_token}")
```
"""
try:
# Create quote with unique access token
quote = Quote(
access_token=generate_access_token(),
status=QuoteStatus.DRAFT.value,
employee_count=quote_data.employee_count,
notes=quote_data.notes,
ip_address=ip_address,
user_agent=user_agent,
# Set expiration to 30 days from now
expires_at=datetime.utcnow() + timedelta(days=30)
)
db.add(quote)
db.flush() # Get the quote ID
# Add initial items if provided
if quote_data.items:
for idx, item_data in enumerate(quote_data.items):
item = QuoteItem(
quote_id=quote.id,
service_name=item_data.service_name,
service_description=item_data.service_description,
category=item_data.category.value,
billing_frequency=item_data.billing_frequency.value,
unit_price=item_data.unit_price,
quantity=item_data.quantity,
setup_fee=item_data.setup_fee,
is_required=item_data.is_required,
sort_order=item_data.sort_order if item_data.sort_order else idx
)
db.add(item)
db.flush()
# Calculate and update totals
monthly, setup, annual = calculate_totals(quote.items)
quote.monthly_total = monthly
quote.setup_total = setup
quote.annual_total = annual
# Log activity
log_activity(
db=db,
quote_id=quote.id,
action="created",
description="Quote draft created",
ip_address=ip_address,
metadata={"employee_count": quote_data.employee_count}
)
db.commit()
db.refresh(quote)
return quote
except IntegrityError as e:
db.rollback()
# If token collision (extremely rare), retry once
if "access_token" in str(e.orig):
return create_quote(db, quote_data, ip_address, user_agent)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Database error: {str(e)}"
)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create quote: {str(e)}"
)
def get_quote_by_token(db: Session, access_token: str) -> Quote:
"""
Retrieve a quote by its access token (public access).
Args:
db: Database session
access_token: The quote's access token
Returns:
Quote: The quote object with items loaded
Raises:
HTTPException: 404 if quote not found
"""
quote = (
db.query(Quote)
.options(joinedload(Quote.items))
.filter(Quote.access_token == access_token)
.first()
)
if not quote:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Quote not found"
)
# Check if expired
if quote.expires_at and quote.expires_at < datetime.utcnow():
if quote.status == QuoteStatus.DRAFT.value:
quote.status = QuoteStatus.EXPIRED.value
db.commit()
return quote
def get_quote_by_id(db: Session, quote_id: UUID) -> Quote:
"""
Retrieve a quote by its ID (admin access).
Args:
db: Database session
quote_id: UUID of the quote
Returns:
Quote: The quote object with all related data loaded
Raises:
HTTPException: 404 if quote not found
"""
quote = (
db.query(Quote)
.options(
joinedload(Quote.items),
joinedload(Quote.activities),
joinedload(Quote.notifications)
)
.filter(Quote.id == str(quote_id))
.first()
)
if not quote:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Quote with ID {quote_id} not found"
)
return quote
def update_quote(
db: Session,
access_token: str,
quote_data: QuoteUpdate,
ip_address: Optional[str] = None
) -> Quote:
"""
Update a quote (add/remove items, update details).
Only drafts can be updated. Replaces all items if items are provided.
Args:
db: Database session
access_token: The quote's access token
quote_data: Update data
ip_address: IP address of the requester
Returns:
Quote: The updated quote object
Raises:
HTTPException: 404 if not found, 400 if not a draft
"""
quote = get_quote_by_token(db, access_token)
# Only drafts can be updated
if quote.status != QuoteStatus.DRAFT.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot update quote with status '{quote.status}'. Only drafts can be modified."
)
try:
# Update basic fields if provided
update_data = quote_data.model_dump(exclude_unset=True, exclude={"items"})
changes = []
for field, value in update_data.items():
old_value = getattr(quote, field)
if old_value != value:
setattr(quote, field, value)
changes.append(f"{field}: {old_value} -> {value}")
# Replace items if provided
if quote_data.items is not None:
# Remove existing items
db.query(QuoteItem).filter(QuoteItem.quote_id == quote.id).delete()
# Add new items
for idx, item_data in enumerate(quote_data.items):
item = QuoteItem(
quote_id=quote.id,
service_name=item_data.service_name,
service_description=item_data.service_description,
category=item_data.category.value,
billing_frequency=item_data.billing_frequency.value,
unit_price=item_data.unit_price,
quantity=item_data.quantity,
setup_fee=item_data.setup_fee,
is_required=item_data.is_required,
sort_order=item_data.sort_order if item_data.sort_order else idx
)
db.add(item)
changes.append(f"items: replaced with {len(quote_data.items)} items")
db.flush()
# Recalculate totals
db.refresh(quote)
monthly, setup, annual = calculate_totals(quote.items)
quote.monthly_total = monthly
quote.setup_total = setup
quote.annual_total = annual
# Log activity
if changes:
log_activity(
db=db,
quote_id=quote.id,
action="updated",
description=f"Quote updated: {', '.join(changes)}",
ip_address=ip_address
)
db.commit()
db.refresh(quote)
return quote
except HTTPException:
db.rollback()
raise
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update quote: {str(e)}"
)
def submit_quote(
db: Session,
access_token: str,
submit_data: QuoteSubmit,
ip_address: Optional[str] = None
) -> Quote:
"""
Submit a quote with contact information.
Transitions quote from draft to submitted status.
Args:
db: Database session
access_token: The quote's access token
submit_data: Submission data with required contact info
ip_address: IP address of the requester
Returns:
Quote: The submitted quote object
Raises:
HTTPException: 404 if not found, 400 if not a draft or no items
"""
quote = get_quote_by_token(db, access_token)
# Only drafts can be submitted
if quote.status != QuoteStatus.DRAFT.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot submit quote with status '{quote.status}'. Only drafts can be submitted."
)
# Must have at least one item
if not quote.items:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot submit quote without any items. Please add at least one service."
)
try:
# Update contact information
quote.company_name = submit_data.company_name
quote.contact_name = submit_data.contact_name
quote.contact_email = submit_data.contact_email
quote.contact_phone = submit_data.contact_phone
if submit_data.notes:
quote.notes = submit_data.notes
# Update status and timestamp
quote.status = QuoteStatus.SUBMITTED.value
quote.submitted_at = datetime.utcnow()
# Extend expiration to 90 days from submission
quote.expires_at = datetime.utcnow() + timedelta(days=90)
# Log activity
log_activity(
db=db,
quote_id=quote.id,
action="submitted",
description=f"Quote submitted by {submit_data.contact_name} ({submit_data.contact_email})",
actor=submit_data.contact_email,
ip_address=ip_address,
metadata={
"company_name": submit_data.company_name,
"contact_email": submit_data.contact_email,
"monthly_total": str(quote.monthly_total),
"setup_total": str(quote.setup_total)
}
)
# Create admin notification record (actual sending would be handled elsewhere)
notification = QuoteNotification(
quote_id=quote.id,
notification_type="admin_alert",
recipient="admin@example.com", # Would come from config in production
subject=f"New Quote Submission: {submit_data.company_name}",
content=f"Quote submitted by {submit_data.contact_name}. Monthly: ${quote.monthly_total}",
status="pending"
)
db.add(notification)
db.commit()
db.refresh(quote)
return quote
except HTTPException:
db.rollback()
raise
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to submit quote: {str(e)}"
)
async def sync_quote_to_syncro(db: Session, quote: Quote) -> dict:
"""
Sync a submitted quote to SyncroRMM.
Checks for existing customer and creates a lead in Syncro. Updates the
quote with sync status and existing customer flag.
This function is designed to be called after submit_quote() completes,
typically as a background task or in the API endpoint. It handles all
Syncro API errors gracefully to avoid blocking the quote submission.
Args:
db: Database session
quote: The submitted quote object (must have contact_email)
Returns:
dict: Sync result with keys:
- synced: bool - Whether lead was created successfully
- is_existing_customer: bool - Whether customer already exists
- syncro_lead_id: str|None - Lead ID if created
- error: str|None - Error message if sync failed
Example:
```python
quote = submit_quote(db, access_token, submit_data, ip_address)
sync_result = await sync_quote_to_syncro(db, quote)
if sync_result["synced"]:
print(f"Lead created: {sync_result['syncro_lead_id']}")
```
"""
result = {
"synced": False,
"is_existing_customer": False,
"syncro_lead_id": None,
"error": None
}
if not quote.contact_email:
result["error"] = "Quote has no contact email"
return result
try:
syncro = get_syncro_service()
# Check for existing customer
customer_check = await syncro.check_existing_customer(
email=quote.contact_email,
business_name=quote.company_name
)
if customer_check.exists:
quote.is_existing_customer = True
result["is_existing_customer"] = True
logger.info(
f"Quote {quote.id} is from existing customer: "
f"{customer_check.customer_name} (ID: {customer_check.customer_id}, "
f"match: {customer_check.match_type})"
)
# Log activity for existing customer
log_activity(
db=db,
quote_id=quote.id,
action="syncro_customer_found",
description=f"Existing Syncro customer found: {customer_check.customer_name}",
metadata={
"syncro_customer_id": customer_check.customer_id,
"match_type": customer_check.match_type
}
)
# Create lead in Syncro
lead_result = await syncro.create_lead(quote)
if lead_result.success:
quote.syncro_lead_id = lead_result.lead_id
quote.syncro_synced_at = datetime.utcnow()
result["synced"] = True
result["syncro_lead_id"] = lead_result.lead_id
# Log activity for successful sync
log_activity(
db=db,
quote_id=quote.id,
action="syncro_lead_created",
description=f"Lead created in Syncro: {lead_result.lead_id}",
metadata={
"syncro_lead_id": lead_result.lead_id,
"is_existing_customer": customer_check.exists
}
)
else:
result["error"] = lead_result.error
logger.warning(
f"Failed to create Syncro lead for quote {quote.id}: {lead_result.error}"
)
# Log activity for failed sync
log_activity(
db=db,
quote_id=quote.id,
action="syncro_sync_failed",
description=f"Failed to sync to Syncro: {lead_result.error}",
metadata={"error": lead_result.error}
)
# Commit the updates to quote
db.commit()
db.refresh(quote)
except Exception as e:
# Log error but don't fail the overall operation
error_msg = str(e)
result["error"] = error_msg
logger.error(
f"Unexpected error syncing quote {quote.id} to Syncro: {error_msg}",
exc_info=True
)
try:
log_activity(
db=db,
quote_id=quote.id,
action="syncro_sync_error",
description=f"Syncro sync error: {error_msg}",
metadata={"error": error_msg}
)
db.commit()
except Exception:
db.rollback()
return result
def list_quotes(
db: Session,
skip: int = 0,
limit: int = 100,
status_filter: Optional[str] = None,
search: Optional[str] = None
) -> tuple[list[Quote], int]:
"""
List quotes with pagination and optional filters (admin).
Args:
db: Database session
skip: Number of records to skip
limit: Maximum number of records to return
status_filter: Filter by status
search: Search in company_name, contact_name, contact_email
Returns:
tuple: (list of quotes, total count)
"""
query = db.query(Quote).options(joinedload(Quote.items))
# Apply filters
if status_filter:
query = query.filter(Quote.status == status_filter)
if search:
search_term = f"%{search}%"
query = query.filter(
(Quote.company_name.ilike(search_term)) |
(Quote.contact_name.ilike(search_term)) |
(Quote.contact_email.ilike(search_term))
)
# Get total count before pagination
total = query.count()
# Apply pagination and ordering
quotes = (
query
.order_by(Quote.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return quotes, total
def update_quote_status(
db: Session,
quote_id: UUID,
update_data: QuoteAdminUpdate,
admin_user: str
) -> Quote:
"""
Update quote status and admin notes (admin).
Args:
db: Database session
quote_id: UUID of the quote
update_data: Admin update data
admin_user: Username of the admin making the change
Returns:
Quote: The updated quote object
"""
quote = get_quote_by_id(db, quote_id)
try:
changes = []
if update_data.status is not None and update_data.status.value != quote.status:
old_status = quote.status
quote.status = update_data.status.value
changes.append(f"status: {old_status} -> {update_data.status.value}")
if update_data.admin_notes is not None:
quote.admin_notes = update_data.admin_notes
changes.append("admin_notes updated")
if update_data.expires_at is not None:
quote.expires_at = update_data.expires_at
changes.append(f"expires_at: {update_data.expires_at}")
# Log activity
if changes:
log_activity(
db=db,
quote_id=quote.id,
action="admin_update",
description=f"Admin update: {', '.join(changes)}",
actor=admin_user
)
db.commit()
db.refresh(quote)
return quote
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update quote status: {str(e)}"
)
def get_quote_stats(db: Session) -> QuoteStatsResponse:
"""
Get dashboard statistics for quotes (admin).
Args:
db: Database session
Returns:
QuoteStatsResponse: Statistics about quotes
"""
# Total quotes
total_quotes = db.query(Quote).count()
# Quotes by status
status_counts = (
db.query(Quote.status, func.count(Quote.id))
.group_by(Quote.status)
.all()
)
quotes_by_status = {status: count for status, count in status_counts}
# Total values for submitted quotes
submitted_statuses = [
QuoteStatus.SUBMITTED.value,
QuoteStatus.REVIEWING.value,
QuoteStatus.APPROVED.value
]
value_query = (
db.query(
func.sum(Quote.monthly_total),
func.sum(Quote.setup_total)
)
.filter(Quote.status.in_(submitted_statuses))
.first()
)
total_monthly_value = value_query[0] or Decimal("0.00")
total_setup_value = value_query[1] or Decimal("0.00")
# Quotes this month
month_start = datetime.utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
quotes_this_month = (
db.query(Quote)
.filter(Quote.created_at >= month_start)
.count()
)
# Quotes submitted this month
quotes_submitted_this_month = (
db.query(Quote)
.filter(
Quote.submitted_at >= month_start,
Quote.submitted_at.isnot(None)
)
.count()
)
# Calculate averages and conversion rate
submitted_count = sum(
quotes_by_status.get(s, 0)
for s in submitted_statuses
)
average_monthly_value = (
total_monthly_value / submitted_count
if submitted_count > 0
else Decimal("0.00")
)
draft_count = quotes_by_status.get(QuoteStatus.DRAFT.value, 0)
total_started = draft_count + submitted_count
conversion_rate = (
(Decimal(submitted_count) / Decimal(total_started) * Decimal("100"))
if total_started > 0
else Decimal("0.00")
)
return QuoteStatsResponse(
total_quotes=total_quotes,
quotes_by_status=quotes_by_status,
total_monthly_value=total_monthly_value,
total_setup_value=total_setup_value,
quotes_this_month=quotes_this_month,
quotes_submitted_this_month=quotes_submitted_this_month,
average_monthly_value=round(average_monthly_value, 2),
conversion_rate=round(conversion_rate, 2)
)
def add_item_to_quote(
db: Session,
access_token: str,
item_data: QuoteItemCreate,
ip_address: Optional[str] = None
) -> Quote:
"""
Add a single item to a quote.
Args:
db: Database session
access_token: The quote's access token
item_data: Item data to add
ip_address: IP address of the requester
Returns:
Quote: The updated quote object
"""
quote = get_quote_by_token(db, access_token)
if quote.status != QuoteStatus.DRAFT.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot add items to a non-draft quote"
)
try:
# Get next sort order
max_order = (
db.query(func.max(QuoteItem.sort_order))
.filter(QuoteItem.quote_id == quote.id)
.scalar()
) or 0
item = QuoteItem(
quote_id=quote.id,
service_name=item_data.service_name,
service_description=item_data.service_description,
category=item_data.category.value,
billing_frequency=item_data.billing_frequency.value,
unit_price=item_data.unit_price,
quantity=item_data.quantity,
setup_fee=item_data.setup_fee,
is_required=item_data.is_required,
sort_order=item_data.sort_order if item_data.sort_order else max_order + 1
)
db.add(item)
db.flush()
# Recalculate totals
db.refresh(quote)
monthly, setup, annual = calculate_totals(quote.items)
quote.monthly_total = monthly
quote.setup_total = setup
quote.annual_total = annual
# Log activity
log_activity(
db=db,
quote_id=quote.id,
action="item_added",
description=f"Added item: {item_data.service_name}",
ip_address=ip_address
)
db.commit()
db.refresh(quote)
return quote
except HTTPException:
db.rollback()
raise
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to add item: {str(e)}"
)
def remove_item_from_quote(
db: Session,
access_token: str,
item_id: UUID,
ip_address: Optional[str] = None
) -> Quote:
"""
Remove an item from a quote.
Args:
db: Database session
access_token: The quote's access token
item_id: UUID of the item to remove
ip_address: IP address of the requester
Returns:
Quote: The updated quote object
"""
quote = get_quote_by_token(db, access_token)
if quote.status != QuoteStatus.DRAFT.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot remove items from a non-draft quote"
)
# Find the item
item = (
db.query(QuoteItem)
.filter(QuoteItem.id == str(item_id), QuoteItem.quote_id == quote.id)
.first()
)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Item with ID {item_id} not found in this quote"
)
if item.is_required:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot remove required items from the quote"
)
try:
item_name = item.service_name
db.delete(item)
db.flush()
# Recalculate totals
db.refresh(quote)
monthly, setup, annual = calculate_totals(quote.items)
quote.monthly_total = monthly
quote.setup_total = setup
quote.annual_total = annual
# Log activity
log_activity(
db=db,
quote_id=quote.id,
action="item_removed",
description=f"Removed item: {item_name}",
ip_address=ip_address
)
db.commit()
db.refresh(quote)
return quote
except HTTPException:
db.rollback()
raise
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to remove item: {str(e)}"
)

View File

@@ -0,0 +1,445 @@
"""
SyncroRMM integration service for Quote Wizard.
This module handles all interactions with the SyncroRMM API for lead creation
and customer duplicate detection when quotes are submitted.
API Documentation: https://api-docs.syncromsp.com/
"""
import logging
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from typing import Optional, TYPE_CHECKING
import httpx
if TYPE_CHECKING:
from api.models.quote import Quote
logger = logging.getLogger(__name__)
# TODO: Move to environment variables or secure configuration for production
SYNCRO_API_BASE_URL = "https://computerguru.syncromsp.com/api/v1"
SYNCRO_API_KEY = "T259810e5c9917386b-52c2aeea7cdb5ff41c6685a73cebbeb3"
# HTTP client configuration
SYNCRO_TIMEOUT_SECONDS = 30.0
SYNCRO_CONNECT_TIMEOUT_SECONDS = 10.0
@dataclass
class CustomerCheckResult:
"""Result of checking for an existing customer in Syncro."""
exists: bool
customer_id: Optional[str] = None
customer_name: Optional[str] = None
match_type: Optional[str] = None # 'email' or 'business_name'
@dataclass
class LeadCreationResult:
"""Result of creating a lead in Syncro."""
success: bool
lead_id: Optional[str] = None
error: Optional[str] = None
class SyncroService:
"""
Service for interacting with the SyncroRMM API.
Handles customer duplicate checking and lead creation for the Quote Wizard.
All API calls are made asynchronously to avoid blocking quote submission.
Example:
```python
syncro = SyncroService()
# Check for existing customer
result = await syncro.check_existing_customer(
email="contact@company.com",
business_name="Company Inc"
)
if result.exists:
print(f"Customer exists: {result.customer_name}")
# Create lead from quote
lead_result = await syncro.create_lead(quote)
if lead_result.success:
print(f"Lead created: {lead_result.lead_id}")
```
"""
def __init__(
self,
api_base_url: str = SYNCRO_API_BASE_URL,
api_key: str = SYNCRO_API_KEY,
timeout: float = SYNCRO_TIMEOUT_SECONDS,
connect_timeout: float = SYNCRO_CONNECT_TIMEOUT_SECONDS
):
"""
Initialize the SyncroService.
Args:
api_base_url: Base URL for the Syncro API
api_key: API key for authentication
timeout: Total request timeout in seconds
connect_timeout: Connection timeout in seconds
"""
self.api_base_url = api_base_url.rstrip('/')
self.api_key = api_key
self.timeout = httpx.Timeout(timeout, connect=connect_timeout)
def _get_client(self) -> httpx.AsyncClient:
"""
Create an async HTTP client with configured settings.
Returns:
httpx.AsyncClient: Configured HTTP client
"""
return httpx.AsyncClient(
timeout=self.timeout,
headers={
"Content-Type": "application/json",
"Accept": "application/json"
}
)
def _build_url(self, endpoint: str, **params) -> str:
"""
Build a full API URL with the api_key parameter.
Args:
endpoint: API endpoint path (e.g., '/customers')
**params: Additional query parameters
Returns:
str: Full URL with query parameters
"""
url = f"{self.api_base_url}{endpoint}"
query_params = {"api_key": self.api_key, **params}
# Build query string
query_string = "&".join(
f"{key}={httpx.URL('').copy_with(params={key: str(value)}).params[key]}"
for key, value in query_params.items()
if value is not None
)
return f"{url}?{query_string}"
async def check_existing_customer(
self,
email: str,
business_name: Optional[str] = None
) -> CustomerCheckResult:
"""
Check if a customer already exists in Syncro.
Performs a two-stage check:
1. Search by email address (primary)
2. Search by business name if no email match (secondary)
Args:
email: Contact email address to search for
business_name: Optional business name for secondary search
Returns:
CustomerCheckResult: Object containing match status and details
Note:
This method handles errors gracefully and returns a non-match
result if the API is unavailable, to avoid blocking quote submission.
"""
async with self._get_client() as client:
# First, check by email
try:
email_result = await self._search_customers_by_email(client, email)
if email_result.exists:
return email_result
except Exception as e:
logger.warning(
f"Syncro email search failed for {email}: {e}",
exc_info=True
)
# Continue to business name search if email search fails
# If no email match, try business name
if business_name:
try:
name_result = await self._search_customers_by_business_name(
client, business_name
)
if name_result.exists:
return name_result
except Exception as e:
logger.warning(
f"Syncro business name search failed for {business_name}: {e}",
exc_info=True
)
# No matches found
return CustomerCheckResult(exists=False)
async def _search_customers_by_email(
self,
client: httpx.AsyncClient,
email: str
) -> CustomerCheckResult:
"""
Search for customers by email address.
Args:
client: HTTP client instance
email: Email address to search for
Returns:
CustomerCheckResult: Match result
Raises:
httpx.HTTPError: If the API request fails
"""
url = self._build_url("/customers", email=email)
response = await client.get(url)
response.raise_for_status()
data = response.json()
customers = data.get("customers", [])
if customers:
customer = customers[0]
return CustomerCheckResult(
exists=True,
customer_id=str(customer.get("id")),
customer_name=customer.get("business_name") or customer.get("fullname"),
match_type="email"
)
return CustomerCheckResult(exists=False)
async def _search_customers_by_business_name(
self,
client: httpx.AsyncClient,
business_name: str
) -> CustomerCheckResult:
"""
Search for customers by business name.
Args:
client: HTTP client instance
business_name: Business name to search for
Returns:
CustomerCheckResult: Match result
Raises:
httpx.HTTPError: If the API request fails
"""
url = self._build_url("/customers", business_name=business_name)
response = await client.get(url)
response.raise_for_status()
data = response.json()
customers = data.get("customers", [])
if customers:
# Look for exact match or very close match
normalized_search = business_name.lower().strip()
for customer in customers:
customer_name = customer.get("business_name", "").lower().strip()
if customer_name == normalized_search:
return CustomerCheckResult(
exists=True,
customer_id=str(customer.get("id")),
customer_name=customer.get("business_name"),
match_type="business_name"
)
return CustomerCheckResult(exists=False)
async def create_lead(self, quote: "Quote") -> LeadCreationResult:
"""
Create a lead in Syncro from a submitted quote.
Builds a formatted lead with quote details in the notes field.
Args:
quote: Quote object with contact info and items
Returns:
LeadCreationResult: Object containing success status and lead ID
Note:
This method handles errors gracefully to avoid blocking quote
submission. Errors are logged but not raised.
"""
if not quote.contact_email:
return LeadCreationResult(
success=False,
error="Quote has no contact email"
)
# Parse contact name into first/last
first_name, last_name = self._parse_contact_name(quote.contact_name or "")
# Build formatted notes with quote summary
notes = self._build_quote_summary(quote)
lead_data = {
"business_name": quote.company_name or "",
"first_name": first_name,
"last_name": last_name,
"email": quote.contact_email,
"phone": quote.contact_phone or "",
"address": "",
"referred_by": "Website Quote Tool",
"status": "New",
"notes": notes
}
try:
async with self._get_client() as client:
url = self._build_url("/leads")
response = await client.post(url, json=lead_data)
response.raise_for_status()
data = response.json()
lead_id = str(data.get("lead", {}).get("id", ""))
if lead_id:
logger.info(
f"Created Syncro lead {lead_id} for quote {quote.id}"
)
return LeadCreationResult(success=True, lead_id=lead_id)
else:
logger.warning(
f"Syncro lead creation returned no ID for quote {quote.id}"
)
return LeadCreationResult(
success=False,
error="No lead ID returned from Syncro"
)
except httpx.TimeoutException as e:
error_msg = f"Syncro API timeout: {e}"
logger.error(f"{error_msg} for quote {quote.id}")
return LeadCreationResult(success=False, error=error_msg)
except httpx.HTTPStatusError as e:
error_msg = f"Syncro API error {e.response.status_code}: {e.response.text}"
logger.error(f"{error_msg} for quote {quote.id}")
return LeadCreationResult(success=False, error=error_msg)
except Exception as e:
error_msg = f"Unexpected error: {str(e)}"
logger.error(f"{error_msg} for quote {quote.id}", exc_info=True)
return LeadCreationResult(success=False, error=error_msg)
def _parse_contact_name(self, full_name: str) -> tuple[str, str]:
"""
Parse a full name into first and last name components.
Args:
full_name: Full contact name
Returns:
tuple: (first_name, last_name)
"""
parts = full_name.strip().split(maxsplit=1)
if len(parts) == 0:
return ("", "")
elif len(parts) == 1:
return (parts[0], "")
else:
return (parts[0], parts[1])
def _build_quote_summary(self, quote: "Quote") -> str:
"""
Build formatted notes from quote items for Syncro lead.
Creates a human-readable summary of the quote including:
- Quote reference number
- Monthly and setup totals
- List of selected services with pricing
- Customer notes if provided
Args:
quote: Quote object with items
Returns:
str: Formatted notes string for Syncro lead
"""
lines = []
# Quote reference
access_token_short = quote.access_token[:8] if quote.access_token else "N/A"
lines.append(f"Quote #{access_token_short}")
lines.append("")
# Totals
monthly_total = quote.monthly_total or Decimal("0.00")
setup_total = quote.setup_total or Decimal("0.00")
lines.append(f"Monthly: ${monthly_total:,.2f}")
if setup_total > 0:
lines.append(f"Setup: ${setup_total:,.2f}")
lines.append("")
# Services
lines.append("Services:")
if hasattr(quote, 'items') and quote.items:
for item in sorted(quote.items, key=lambda x: x.sort_order or 0):
quantity = item.quantity or 1
unit_price = item.unit_price or Decimal("0.00")
line_total = quantity * unit_price
if quantity > 1:
lines.append(
f"- {item.service_name} ({quantity} x ${unit_price:,.2f}): "
f"${line_total:,.2f}/mo"
)
else:
lines.append(f"- {item.service_name}: ${unit_price:,.2f}/mo")
# Add setup fee if present
if item.setup_fee and item.setup_fee > 0:
lines.append(f" Setup: ${item.setup_fee:,.2f}")
else:
lines.append("- No items")
# Employee count
if quote.employee_count:
lines.append("")
lines.append(f"Employees/Users: {quote.employee_count}")
# Customer notes
if quote.notes:
lines.append("")
lines.append("Customer Notes:")
lines.append(quote.notes)
return "\n".join(lines)
# Singleton instance for convenience
_syncro_service: Optional[SyncroService] = None
def get_syncro_service() -> SyncroService:
"""
Get or create the singleton SyncroService instance.
Returns:
SyncroService: The service instance
"""
global _syncro_service
if _syncro_service is None:
_syncro_service = SyncroService()
return _syncro_service

View File

@@ -0,0 +1,156 @@
"""MSP Quote Wizard Tables
Revision ID: 20260309_074038
Revises: a0dfb0b4373c
Create Date: 2026-03-09 07:40:38
Creates the MSP Quote Wizard tables:
- quotes: Main quote records with contact info, pricing, and tracking
- quote_items: Line items for each quote (services, products, addons)
- quote_activity: Activity log for quote interactions
- quote_notifications: Email/webhook notification queue
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '20260309_074038'
down_revision: Union[str, None] = 'a0dfb0b4373c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create MSP Quote Wizard tables."""
# 1. Create quotes table - main quote records
op.create_table(
'quotes',
sa.Column('id', sa.CHAR(36), primary_key=True),
sa.Column('company_name', sa.String(255), nullable=True),
sa.Column('contact_name', sa.String(255), nullable=False),
sa.Column('contact_email', sa.String(255), nullable=False),
sa.Column('contact_phone', sa.String(50), nullable=True),
sa.Column('employee_count', sa.Integer(), nullable=True),
sa.Column('industry', sa.String(100), nullable=True),
sa.Column('current_it_situation', sa.Text(), nullable=True),
sa.Column('status', sa.Enum('draft', 'submitted', 'viewed', 'followed_up', 'converted', 'expired', name='quote_status'), server_default='draft'),
sa.Column('access_token', sa.String(64), unique=True, nullable=False),
sa.Column('monthly_total', sa.DECIMAL(10, 2), server_default='0'),
sa.Column('setup_total', sa.DECIMAL(10, 2), server_default='0'),
sa.Column('syncro_lead_id', sa.String(100), nullable=True),
sa.Column('syncro_synced_at', sa.DateTime(), nullable=True),
sa.Column('is_existing_customer', sa.Boolean(), server_default='0'),
sa.Column('source', sa.String(50), server_default='website'),
sa.Column('utm_source', sa.String(100), nullable=True),
sa.Column('utm_medium', sa.String(100), nullable=True),
sa.Column('utm_campaign', sa.String(100), nullable=True),
sa.Column('ip_address', sa.String(45), nullable=True),
sa.Column('user_agent', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')),
sa.Column('submitted_at', sa.DateTime(), nullable=True),
sa.Column('expires_at', sa.DateTime(), nullable=True),
)
# Indexes for quotes table
op.create_index('idx_quotes_status', 'quotes', ['status'])
op.create_index('idx_quotes_email', 'quotes', ['contact_email'])
op.create_index('idx_quotes_created', 'quotes', ['created_at'])
op.create_index('idx_quotes_token', 'quotes', ['access_token'])
# 2. Create quote_items table - line items for each quote
op.create_table(
'quote_items',
sa.Column('id', sa.CHAR(36), primary_key=True),
sa.Column('quote_id', sa.CHAR(36), nullable=False),
sa.Column('category', sa.Enum('gps_monitoring', 'support_plan', 'voip', 'web_hosting', 'email', 'hardware', 'addon', name='quote_item_category'), nullable=False),
sa.Column('product_code', sa.String(50), nullable=False),
sa.Column('product_name', sa.String(255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('quantity', sa.Integer(), server_default='1'),
sa.Column('unit_price', sa.DECIMAL(10, 2), nullable=False),
sa.Column('setup_price', sa.DECIMAL(10, 2), server_default='0'),
sa.Column('billing_frequency', sa.Enum('monthly', 'yearly', 'one_time', name='billing_frequency'), server_default='monthly'),
sa.Column('tier', sa.String(50), nullable=True),
sa.Column('is_recommended', sa.Boolean(), server_default='0'),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.ForeignKeyConstraint(['quote_id'], ['quotes.id'], ondelete='CASCADE'),
)
# Indexes for quote_items table
op.create_index('idx_quote_items_quote', 'quote_items', ['quote_id'])
op.create_index('idx_quote_items_category', 'quote_items', ['category'])
# 3. Create quote_activity table - activity log for quotes
op.create_table(
'quote_activity',
sa.Column('id', sa.CHAR(36), primary_key=True),
sa.Column('quote_id', sa.CHAR(36), nullable=False),
sa.Column('action', sa.String(50), nullable=False),
sa.Column('step_name', sa.String(50), nullable=True),
sa.Column('details', sa.JSON(), nullable=True),
sa.Column('ip_address', sa.String(45), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.ForeignKeyConstraint(['quote_id'], ['quotes.id'], ondelete='CASCADE'),
)
# Index for quote_activity table
op.create_index('idx_quote_activity_quote', 'quote_activity', ['quote_id'])
# 4. Create quote_notifications table - notification queue
op.create_table(
'quote_notifications',
sa.Column('id', sa.CHAR(36), primary_key=True),
sa.Column('quote_id', sa.CHAR(36), nullable=False),
sa.Column('notification_type', sa.Enum('email', 'webhook', name='notification_type'), nullable=False),
sa.Column('recipient', sa.String(255), nullable=False),
sa.Column('subject', sa.String(255), nullable=True),
sa.Column('body', sa.Text(), nullable=True),
sa.Column('status', sa.Enum('pending', 'sent', 'failed', name='notification_status'), server_default='pending'),
sa.Column('attempts', sa.Integer(), server_default='0'),
sa.Column('last_attempt_at', sa.DateTime(), nullable=True),
sa.Column('sent_at', sa.DateTime(), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.ForeignKeyConstraint(['quote_id'], ['quotes.id'], ondelete='CASCADE'),
)
# Indexes for quote_notifications table
op.create_index('idx_notifications_status', 'quote_notifications', ['status'])
op.create_index('idx_notifications_quote', 'quote_notifications', ['quote_id'])
def downgrade() -> None:
"""Drop MSP Quote Wizard tables in reverse order."""
# Drop quote_notifications and its indexes
op.drop_index('idx_notifications_quote', table_name='quote_notifications')
op.drop_index('idx_notifications_status', table_name='quote_notifications')
op.drop_table('quote_notifications')
# Drop quote_activity and its index
op.drop_index('idx_quote_activity_quote', table_name='quote_activity')
op.drop_table('quote_activity')
# Drop quote_items and its indexes
op.drop_index('idx_quote_items_category', table_name='quote_items')
op.drop_index('idx_quote_items_quote', table_name='quote_items')
op.drop_table('quote_items')
# Drop quotes and its indexes
op.drop_index('idx_quotes_token', table_name='quotes')
op.drop_index('idx_quotes_created', table_name='quotes')
op.drop_index('idx_quotes_email', table_name='quotes')
op.drop_index('idx_quotes_status', table_name='quotes')
op.drop_table('quotes')
# Drop the enum types
op.execute("DROP TYPE IF EXISTS notification_status")
op.execute("DROP TYPE IF EXISTS notification_type")
op.execute("DROP TYPE IF EXISTS billing_frequency")
op.execute("DROP TYPE IF EXISTS quote_item_category")
op.execute("DROP TYPE IF EXISTS quote_status")

View File

@@ -0,0 +1,24 @@
# 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

@@ -0,0 +1,73 @@
# 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

@@ -0,0 +1,23 @@
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

@@ -0,0 +1,14 @@
<!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'><text y='.9em' font-size='90'>📊</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="MSP Quote Wizard - Get a custom IT services quote for your business" />
<title>MSP Quote Wizard | 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

@@ -0,0 +1,37 @@
{
"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

@@ -0,0 +1,26 @@
import { WizardContainer } from '@/components/wizard/WizardContainer'
function App() {
return (
<div className="min-h-screen bg-white">
<header className="bg-[#333d49] text-white py-4 px-6">
<div className="max-w-6xl mx-auto flex items-center justify-between">
<h1 className="text-xl font-semibold">MSP Quote Wizard</h1>
<span className="text-sm text-gray-300">Powered by AZ Computer Guru</span>
</div>
</header>
<main className="py-8">
<WizardContainer />
</main>
<footer className="bg-[#113559] text-white py-6 px-6 mt-auto">
<div className="max-w-6xl mx-auto text-center text-sm">
<p>&copy; {new Date().getFullYear()} AZ Computer Guru. All rights reserved.</p>
</div>
</footer>
</div>
)
}
export default App

View File

@@ -0,0 +1,59 @@
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 rounded-lg overflow-hidden', className)}>
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 transition-colors"
aria-expanded={isExpanded}
>
<div className="flex items-center gap-3">
{icon || <HelpCircle className="w-5 h-5 text-[#fe7400]" />}
<span className="font-medium text-[#333d49]">{title}</span>
</div>
<motion.div
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<ChevronDown className="w-5 h-5 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-600 border-t border-gray-100">
<div className="pt-4">{children}</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,81 @@
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: -4 }}
transition={{ duration: 0.2 }}
>
<Card
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
padding="none"
className={cn(
'relative overflow-hidden',
tier.recommended && !isSelected && 'ring-2 ring-[#333d49]'
)}
>
{/* Recommended badge */}
{tier.recommended && (
<div className="absolute top-0 right-0">
<div className="bg-[#fe7400] text-white text-xs font-semibold px-3 py-1 rounded-bl-lg">
Recommended
</div>
</div>
)}
<div className="p-6">
{/* Header */}
<div className="mb-4">
<h3 className="text-xl font-semibold text-[#333d49]">{tier.name}</h3>
<p className="text-sm text-gray-500 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]">
{formatCurrency(monthlyEstimate)}
</span>
<span className="text-gray-500">/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 mb-6">
{tier.features.map((feature, index) => (
<li key={index} className="flex items-start gap-2 text-sm">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<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

@@ -0,0 +1,116 @@
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 ? (
<Check className="w-5 h-5 text-green-500 mx-auto" />
) : (
<X className="w-5 h-5 text-gray-300 mx-auto" />
);
}
return <span className="text-sm text-[#333d49]">{value}</span>;
};
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr>
<th className="text-left p-4 border-b border-gray-200 bg-gray-50">
<span className="font-semibold text-[#333d49]">Feature</span>
</th>
{tiers.map((tier) => (
<th
key={tier.id}
className={cn(
'p-4 border-b border-gray-200 text-center cursor-pointer transition-colors',
selectedTier === tier.id
? 'bg-[#fe7400]/10'
: 'bg-gray-50 hover:bg-gray-100'
)}
onClick={() => onSelectTier(tier.id)}
>
<span
className={cn(
'font-semibold',
selectedTier === tier.id ? 'text-[#fe7400]' : 'text-[#333d49]'
)}
>
{tier.name}
</span>
{tier.recommended && (
<span className="block text-xs text-[#fe7400] mt-1">Recommended</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{comparisonFeatures.map((feature, index) => (
<tr key={feature.name} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50/50'}>
<td className="p-4 border-b border-gray-100 text-sm text-gray-600">
{feature.name}
</td>
<td
className={cn(
'p-4 border-b border-gray-100 text-center',
selectedTier === 'essential' && 'bg-[#fe7400]/5'
)}
>
{renderCell(feature.essential)}
</td>
<td
className={cn(
'p-4 border-b border-gray-100 text-center',
selectedTier === 'professional' && 'bg-[#fe7400]/5'
)}
>
{renderCell(feature.professional)}
</td>
<td
className={cn(
'p-4 border-b border-gray-100 text-center',
selectedTier === 'enterprise' && 'bg-[#fe7400]/5'
)}
>
{renderCell(feature.enterprise)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

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

View File

@@ -0,0 +1,87 @@
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-medium rounded-lg transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
const variants = {
primary:
'bg-[#fe7400] text-white hover:bg-[#e56800] focus-visible:ring-[#fe7400] shadow-sm hover:shadow-md',
secondary:
'bg-[#333d49] text-white hover:bg-[#252d36] focus-visible:ring-[#333d49] shadow-sm hover:shadow-md',
outline:
'border-2 border-[#333d49] text-[#333d49] hover:bg-[#333d49] hover:text-white focus-visible:ring-[#333d49]',
ghost:
'text-[#333d49] hover:bg-gray-100 focus-visible:ring-[#333d49]',
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-5 py-2.5 text-base',
lg: 'px-7 py-3.5 text-lg',
};
return (
<motion.button
ref={ref}
whileHover={{ scale: disabled || isLoading ? 1 : 1.02 }}
whileTap={{ scale: disabled || isLoading ? 1 : 0.98 }}
className={cn(baseStyles, variants[variant], sizes[size], className)}
disabled={disabled || isLoading}
{...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>
Loading...
</>
) : (
children
)}
</motion.button>
);
}
);
Button.displayName = 'Button';
export { Button };

View File

@@ -0,0 +1,137 @@
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-xl transition-all duration-200';
const variants = {
default: 'bg-white border border-gray-200',
elevated: 'bg-white shadow-lg',
outlined: 'bg-transparent border-2 border-[#333d49]',
highlighted: 'bg-white border-2 border-[#fe7400] shadow-lg',
};
const paddings = {
none: '',
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
};
const hoverStyles = hoverable
? 'cursor-pointer hover:shadow-xl hover:-translate-y-1'
: '';
if (hoverable) {
return (
<motion.div
ref={ref}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className={cn(
baseStyles,
variants[variant],
paddings[padding],
hoverStyles,
className
)}
onClick={onClick}
>
{children}
</motion.div>
);
}
return (
<div
ref={ref}
className={cn(
baseStyles,
variants[variant],
paddings[padding],
className
)}
onClick={onClick}
>
{children}
</div>
);
}
);
Card.displayName = 'Card';
// Card subcomponents
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)}
{...props}
/>
)
);
CardTitle.displayName = 'CardTitle';
const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-gray-500 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

@@ -0,0 +1,61 @@
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-1.5"
>
{label}
</label>
)}
<input
id={inputId}
type={type}
ref={ref}
className={cn(
'w-full px-4 py-2.5 rounded-lg border transition-all duration-200',
'text-[#333d49] placeholder-gray-400',
'focus:outline-none focus:ring-2 focus:ring-offset-0',
error
? 'border-red-500 focus:border-red-500 focus:ring-red-200'
: 'border-gray-300 focus:border-[#fe7400] focus:ring-[#fe7400]/20',
'disabled:bg-gray-50 disabled:text-gray-500 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-1.5 text-sm text-red-500">
{error}
</p>
)}
{helperText && !error && (
<p id={`${inputId}-helper`} className="mt-1.5 text-sm text-gray-500">
{helperText}
</p>
)}
</div>
);
}
);
Input.displayName = 'Input';
export { Input };

View File

@@ -0,0 +1,56 @@
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.5',
md: 'h-2.5',
lg: 'h-4',
};
const variants = {
default: 'bg-[#333d49]',
accent: 'bg-[#fe7400]',
};
return (
<div className={cn('w-full', className)}>
{showLabel && (
<div className="flex justify-between items-center mb-1.5">
<span className="text-sm font-medium text-[#333d49]">Progress</span>
<span className="text-sm font-medium text-[#333d49]">{clampedProgress}%</span>
</div>
)}
<div
className={cn('w-full bg-gray-200 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.5, ease: 'easeOut' }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
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

@@ -0,0 +1,341 @@
import { useState } 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 { useQuote } from '@/hooks/useQuote';
import {
Step1CompanyProfile,
Step2GPSMonitoring,
Step3SupportPlan,
Step4VoIP,
Step5WebEmail,
Step6Summary,
Step7Contact,
} from './steps';
import {
Building2,
Monitor,
Headphones,
Phone,
Globe,
FileCheck,
Send,
} from 'lucide-react';
import { formatCurrency } from '@/lib/utils';
/**
* WizardContainer - Main container for the MSP Quote Wizard
*
* Orchestrates the 7-step wizard flow:
* 1. Company Profile
* 2. GPS Monitoring
* 3. Support Plan
* 4. VoIP Phone System
* 5. Web & Email
* 6. Review Quote
* 7. Contact & Submit
*/
const stepIcons = [Building2, Monitor, Headphones, Phone, Globe, FileCheck, Send];
export function WizardContainer() {
const wizard = useWizard();
const quote = useQuote();
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitSuccess, setSubmitSuccess] = useState(false);
const StepIcon = stepIcons[wizard.currentStep] || Building2;
const currentStepData = wizard.steps[wizard.currentStep];
const handleNext = () => {
// Calculate quote before moving to summary
if (wizard.currentStep === 4) {
quote.calculateQuote();
}
wizard.nextStep();
};
const handlePrev = () => {
wizard.prevStep();
};
const handleSubmit = async () => {
setIsSubmitting(true);
// Calculate final quote
const result = quote.calculateQuote();
try {
// Simulate API submission
await new Promise((resolve) => setTimeout(resolve, 2000));
// Log submission (in production, this would send to an API)
console.log('Quote submitted:', {
quoteData: quote.quoteData,
quoteResult: result,
timestamp: new Date().toISOString(),
});
setSubmitSuccess(true);
} catch (error) {
console.error('Submission error:', error);
// Handle error state here
} finally {
setIsSubmitting(false);
}
};
const handleGoToStep = (step: number) => {
wizard.goToStep(step);
};
// Validate current step for "Next" button
const isNextDisabled = (): boolean => {
switch (wizard.currentStep) {
case 0: // Company Profile
return quote.quoteData.company.endpointCount < 1;
case 6: // Contact
return (
!quote.quoteData.contact.name.trim() ||
!quote.quoteData.contact.email.trim() ||
!quote.quoteData.contact.agreedToTerms
);
default:
return false;
}
};
// Render current step content
const renderStepContent = () => {
switch (wizard.currentStep) {
case 0:
return (
<Step1CompanyProfile
companyInfo={quote.quoteData.company}
onUpdateCompany={quote.updateCompany}
onSetEndpointCount={quote.setEndpointCount}
onSetIndustry={quote.setIndustry}
/>
);
case 1:
return (
<Step2GPSMonitoring
gpsSelection={quote.quoteData.gps}
onSetGPSTier={quote.setGPSTier}
onSetEquipmentEnabled={quote.setEquipmentEnabled}
onSetEquipmentCount={quote.setEquipmentCount}
getGPSMonthly={quote.getGPSMonthly}
/>
);
case 2:
return (
<Step3SupportPlan
supportSelection={quote.quoteData.support}
endpointCount={quote.quoteData.company.endpointCount}
onSetSupportPlan={quote.setSupportPlan}
onSetBlockTimeEnabled={quote.setBlockTimeEnabled}
onSetBlockTime={quote.setBlockTime}
getSupportMonthly={quote.getSupportMonthly}
/>
);
case 3:
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 4:
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 5:
return (
<Step6Summary
quoteData={quote.quoteData}
quoteResult={quote.quoteResult}
onGoToStep={handleGoToStep}
onCalculateQuote={quote.calculateQuote}
/>
);
case 6:
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;
}
};
// Success state
if (submitSuccess) {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6">
<Card variant="elevated" padding="lg">
<CardContent>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center py-12"
>
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg
className="w-10 h-10 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h2 className="text-3xl font-bold text-[#333d49] mb-4">
Quote Request Submitted!
</h2>
<p className="text-gray-600 mb-8 max-w-md mx-auto">
Thank you for your interest. Our team will review your quote and
contact you within 24 hours.
</p>
{quote.quoteResult && (
<div className="bg-gray-50 rounded-lg p-6 max-w-sm mx-auto mb-8">
<p className="text-sm text-gray-500 mb-2">Your Estimated Monthly Total</p>
<p className="text-4xl font-bold text-[#fe7400]">
{formatCurrency(quote.quoteResult.monthlyTotal)}
<span className="text-lg font-normal text-gray-500">/mo</span>
</p>
</div>
)}
<button
onClick={() => {
quote.resetQuote();
wizard.resetWizard();
setSubmitSuccess(false);
}}
className="text-[#fe7400] hover:text-[#e56800] font-medium"
>
Start a New Quote
</button>
</motion.div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6">
{/* Progress indicator */}
<div className="mb-8">
<WizardProgress
steps={wizard.steps}
currentStep={wizard.currentStep}
onStepClick={wizard.goToStep}
/>
</div>
{/* Main wizard card */}
<Card variant="elevated" padding="lg">
<CardContent>
{/* Step header */}
<div className="flex items-center gap-4 mb-6 pb-6 border-b border-gray-100">
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-[#fe7400]/10">
<StepIcon className="w-6 h-6 text-[#fe7400]" />
</div>
<div>
<h2 className="text-2xl font-semibold text-[#333d49]">
{currentStepData?.title}
</h2>
<p className="text-gray-500">{currentStepData?.description}</p>
</div>
</div>
{/* Step content with animation */}
<AnimatePresence mode="wait">
<motion.div
key={wizard.currentStep}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
className="min-h-[400px]"
>
{renderStepContent()}
</motion.div>
</AnimatePresence>
{/* Navigation - hidden on contact step (has its own submit) */}
{wizard.currentStep !== 6 && (
<WizardNavigation
onNext={handleNext}
onPrev={handlePrev}
onSubmit={handleSubmit}
isFirstStep={wizard.isFirstStep}
isLastStep={wizard.isLastStep}
isNextDisabled={isNextDisabled()}
isSubmitting={isSubmitting}
/>
)}
</CardContent>
</Card>
{/* Quick stats - show running total */}
<div className="mt-6 grid grid-cols-3 gap-4">
<Card variant="default" padding="sm" className="text-center">
<p className="text-sm text-gray-500">Endpoints</p>
<p className="text-2xl font-bold text-[#333d49]">
{quote.quoteData.company.endpointCount}
</p>
</Card>
<Card variant="default" padding="sm" className="text-center">
<p className="text-sm text-gray-500">Est. Monthly</p>
<p className="text-2xl font-bold text-[#fe7400]">
{formatCurrency(
quote.getGPSMonthly() +
quote.getSupportMonthly() +
quote.getVoIPMonthly() +
quote.getWebHostingMonthly() +
quote.getEmailMonthly()
)}
</p>
</Card>
<Card variant="default" padding="sm" className="text-center">
<p className="text-sm text-gray-500">Progress</p>
<p className="text-2xl font-bold text-[#333d49]">{wizard.progress}%</p>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { ChevronLeft, ChevronRight } 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-6 mt-6 border-t border-gray-200">
<Button
type="button"
variant="outline"
onClick={onPrev}
disabled={isFirstStep}
className={isFirstStep ? 'invisible' : ''}
>
<ChevronLeft className="w-4 h-4 mr-1" />
Previous
</Button>
{isLastStep ? (
<Button
type="button"
variant="primary"
onClick={onSubmit}
isLoading={isSubmitting}
disabled={isNextDisabled || isSubmitting}
>
Get My Quote
</Button>
) : (
<Button
type="button"
variant="primary"
onClick={onNext}
disabled={isNextDisabled}
>
Next
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,119 @@
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">
<ol className="flex items-center justify-between">
{steps.map((step, index) => {
const isCompleted = step.isComplete;
const isCurrent = index === currentStep;
const isClickable = isCompleted || index <= currentStep;
return (
<li
key={step.id}
className={cn(
'relative flex-1',
index !== steps.length - 1 && (isCompactMode ? 'pr-4 sm:pr-8' : 'pr-8 sm:pr-20')
)}
>
{/* Connector line */}
{index !== steps.length - 1 && (
<div
className={cn(
'absolute top-4 right-0 h-0.5 bg-gray-200',
isCompactMode ? 'left-6' : 'left-8'
)}
aria-hidden="true"
>
<motion.div
className="h-full bg-[#fe7400]"
initial={{ width: '0%' }}
animate={{ width: isCompleted ? '100%' : '0%' }}
transition={{ duration: 0.3 }}
/>
</div>
)}
<button
type="button"
onClick={() => isClickable && onStepClick?.(index)}
disabled={!isClickable}
className={cn(
'group flex flex-col items-center',
isClickable ? 'cursor-pointer' : 'cursor-not-allowed'
)}
aria-current={isCurrent ? 'step' : undefined}
>
{/* Step circle */}
<motion.div
className={cn(
'relative z-10 flex items-center justify-center rounded-full border-2 transition-colors duration-200',
isCompactMode ? 'h-6 w-6' : 'h-8 w-8',
isCompleted
? 'bg-[#fe7400] border-[#fe7400]'
: isCurrent
? 'border-[#fe7400] bg-white'
: 'border-gray-300 bg-white'
)}
whileHover={isClickable ? { scale: 1.1 } : {}}
whileTap={isClickable ? { scale: 0.95 } : {}}
>
{isCompleted ? (
<Check className={cn(isCompactMode ? 'h-3 w-3' : 'h-4 w-4', 'text-white')} />
) : (
<span
className={cn(
'font-semibold',
isCompactMode ? 'text-xs' : 'text-sm',
isCurrent ? 'text-[#fe7400]' : 'text-gray-500'
)}
>
{index + 1}
</span>
)}
</motion.div>
{/* Step label - hidden on mobile for compact mode */}
<div className={cn('mt-2 text-center', isCompactMode && 'hidden sm:block')}>
<span
className={cn(
'font-medium whitespace-nowrap',
isCompactMode ? 'text-[10px]' : 'text-xs',
isCurrent ? 'text-[#fe7400]' : isCompleted ? 'text-[#333d49]' : 'text-gray-500'
)}
>
{isCompactMode ? step.title.split(' ')[0] : 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-sm text-gray-500">
Step {currentStep + 1} of {steps.length}:
</span>
<span className="text-sm font-medium text-[#333d49] ml-1">
{steps[currentStep]?.title}
</span>
</div>
)}
</nav>
);
}

View File

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

View File

@@ -0,0 +1,133 @@
import { motion } from 'framer-motion';
import { Building2, Users, Briefcase, MessageSquare } 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-6"
>
{/* Company Name (Optional) */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
<Building2 className="w-4 h-4 text-[#fe7400]" />
Company Name
<span className="text-gray-400 font-normal">(optional)</span>
</label>
<Input
type="text"
value={companyInfo.name}
onChange={(e) => onUpdateCompany({ name: e.target.value })}
placeholder="Enter your company name"
className="max-w-md"
/>
</div>
{/* Number of Endpoints (Required) */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
<Users className="w-4 h-4 text-[#fe7400]" />
Number of Endpoints / Employees
<span className="text-red-500">*</span>
</label>
<div className="flex items-center gap-4">
<Input
type="number"
min={1}
value={companyInfo.endpointCount}
onChange={handleEndpointChange}
className="w-32"
/>
<span className="text-sm text-gray-500">
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]">
<Briefcase className="w-4 h-4 text-[#fe7400]" />
Industry
</label>
<select
value={companyInfo.industry}
onChange={handleIndustryChange}
className="w-full max-w-md px-4 py-2.5 rounded-lg border border-gray-300 text-[#333d49] focus:outline-none focus:ring-2 focus:ring-[#fe7400]/20 focus:border-[#fe7400] transition-all duration-200"
>
<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 (Optional) */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
<MessageSquare className="w-4 h-4 text-[#fe7400]" />
What brings you here today?
<span className="text-gray-400 font-normal">(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-2.5 rounded-lg border border-gray-300 text-[#333d49] placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#fe7400]/20 focus:border-[#fe7400] transition-all duration-200 resize-none"
/>
</div>
{/* Info Card */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="bg-[#fe7400]/5 border border-[#fe7400]/20 rounded-lg p-4 mt-6"
>
<h4 className="font-medium text-[#333d49] mb-2">Why we ask this</h4>
<p className="text-sm text-gray-600">
Understanding your business size and industry helps us recommend the right
service tier and identify any compliance requirements (like HIPAA for healthcare
or PCI-DSS for retail) that may affect your IT needs.
</p>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,230 @@
import { motion } from 'framer-motion';
import { Check, Server, HardDrive } 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 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"
>
{/* Endpoint Count Display */}
<div className="flex items-center justify-between bg-gray-50 rounded-lg p-4">
<div className="flex items-center gap-3">
<Server className="w-5 h-5 text-[#fe7400]" />
<span className="font-medium text-[#333d49]">Endpoints to Monitor</span>
</div>
<span className="text-2xl font-bold text-[#fe7400]">
{gpsSelection.endpointCount}
</span>
</div>
{/* Tier Selection Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 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: -4 }}
>
<Card
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
padding="none"
className={`relative overflow-hidden cursor-pointer ${
tier.recommended && !isSelected ? 'ring-2 ring-[#333d49]' : ''
}`}
onClick={() => onSetGPSTier(tier.id)}
>
{/* Recommended Badge */}
{tier.recommended && (
<div className="absolute top-0 right-0">
<div className="bg-[#fe7400] text-white text-xs font-semibold px-3 py-1 rounded-bl-lg">
Recommended
</div>
</div>
)}
<div className="p-5">
{/* Header */}
<div className="mb-3">
<h3 className="text-lg font-semibold text-[#333d49]">{tier.name}</h3>
<p className="text-sm text-gray-500">{tier.description}</p>
</div>
{/* Pricing */}
<div className="mb-4">
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-[#333d49]">
{formatCurrency(monthlyPrice)}
</span>
<span className="text-gray-500 text-sm">/month</span>
</div>
<p className="text-xs text-gray-400 mt-1">
{formatCurrency(tier.pricePerEndpoint)}/endpoint/month
</p>
</div>
{/* Features */}
<ul className="space-y-2 mb-4">
{tier.features.slice(0, 4).map((feature, idx) => (
<li key={idx} className="flex items-start gap-2 text-sm">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span className="text-gray-600">{feature}</span>
</li>
))}
{tier.features.length > 4 && (
<li className="text-xs text-[#fe7400]">
+{tier.features.length - 4} more features
</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 rounded-lg p-5"
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<HardDrive className="w-5 h-5 text-[#fe7400]" />
<div>
<h4 className="font-medium text-[#333d49]">Equipment Pack Monitoring</h4>
<p className="text-sm text-gray-500">
Monitor routers, switches, printers, and other network equipment
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<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]/20 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-600">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 rounded-lg border border-gray-300 text-[#333d49] focus:outline-none focus:ring-2 focus:ring-[#fe7400]/20 focus:border-[#fe7400]"
/>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-sm text-gray-600">
<span className="font-medium">{formatCurrency(equipmentMonitoring.basePrice)}/month</span>
{' '}for up to {equipmentMonitoring.baseDevices} devices
{gpsSelection.equipmentDeviceCount > equipmentMonitoring.baseDevices && (
<span>
{' + '}
<span className="font-medium">
{formatCurrency(equipmentMonitoring.additionalDevicePrice)}/device
</span>
{' for additional devices'}
</span>
)}
</p>
<p className="text-sm font-medium text-[#fe7400] mt-1">
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-2">
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span><strong>Remote Monitoring:</strong> 24/7 monitoring of system health, performance, and security</span>
</li>
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span><strong>Patch Management:</strong> Automated Windows and third-party application updates</span>
</li>
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span><strong>Antivirus:</strong> Enterprise-grade protection with real-time threat detection</span>
</li>
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span><strong>Help Desk:</strong> Access to our technical support team for issues and questions</span>
</li>
</ul>
</ExpandableInfo>
{/* Monthly Total */}
<div className="bg-[#333d49] text-white rounded-lg p-5 flex items-center justify-between">
<span className="text-lg">GPS Monitoring Monthly Total</span>
<span className="text-3xl font-bold">
{formatCurrency(getGPSMonthly())}
<span className="text-lg font-normal opacity-75">/month</span>
</span>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,235 @@
import { motion } from 'framer-motion';
import { Check, Clock, DollarSign, Zap } 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;
}
export function Step3SupportPlan({
supportSelection,
endpointCount,
onSetSupportPlan,
onSetBlockTimeEnabled,
onSetBlockTime,
getSupportMonthly,
}: Step3SupportPlanProps) {
// Recommend plan based on endpoint count
const getRecommendedPlan = (): SupportPlanId => {
if (endpointCount <= 10) return 'essential';
if (endpointCount <= 25) return 'standard';
if (endpointCount <= 50) return 'premium';
return 'priority';
};
const recommendedPlanId = getRecommendedPlan();
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
{/* Plan Selection Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{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 * 0.1 }}
whileHover={{ y: -4 }}
>
<Card
variant={isSelected ? 'highlighted' : isRecommended ? 'elevated' : 'default'}
padding="none"
className={`relative overflow-hidden cursor-pointer h-full ${
isRecommended && !isSelected ? 'ring-2 ring-[#333d49]' : ''
}`}
onClick={() => onSetSupportPlan(plan.id)}
>
{/* Recommended Badge */}
{isRecommended && (
<div className="absolute top-0 right-0">
<div className="bg-[#fe7400] text-white text-xs font-semibold px-2 py-1 rounded-bl-lg">
For You
</div>
</div>
)}
<div className="p-4">
{/* Header */}
<h3 className="text-lg font-semibold text-[#333d49] mb-1">{plan.name}</h3>
<p className="text-xs text-gray-500 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]">
{formatCurrency(plan.monthlyPrice)}
</span>
<span className="text-gray-500 text-xs">/mo</span>
</div>
</div>
{/* Hours Included */}
<div className="flex items-center gap-2 mb-3 p-2 bg-gray-50 rounded-lg">
<Clock className="w-4 h-4 text-[#fe7400]" />
<span className="text-sm font-medium text-[#333d49]">
{plan.includedHours} hrs included
</span>
</div>
{/* Effective Rate */}
<div className="flex items-center gap-2 mb-4 text-sm text-gray-600">
<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>
{/* Block Time Option */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="border border-gray-200 rounded-lg p-5"
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<Zap className="w-5 h-5 text-[#fe7400]" />
<div>
<h4 className="font-medium text-[#333d49]">Add Block Time</h4>
<p className="text-sm text-gray-500">
Pre-purchase additional support hours at a discounted rate
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<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]/20 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 md: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-lg border-2 cursor-pointer transition-all ${
isSelected
? 'border-[#fe7400] bg-[#fe7400]/5'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="text-lg font-bold text-[#333d49]">
{option.hours} Hours
</div>
<div className="text-xl font-bold text-[#fe7400]">
{formatCurrency(option.price)}
</div>
<div className="text-sm text-gray-500">
{formatCurrency(option.effectiveHourlyRate)}/hr
</div>
{option.hours === 30 && (
<div className="mt-2 text-xs font-medium text-green-600">
Best Value
</div>
)}
</div>
);
})}
</div>
</motion.div>
)}
</motion.div>
{/* Expandable Info */}
<ExpandableInfo title="How does support work?">
<div className="space-y-3">
<p>
Your monthly support plan includes a set number of hours for help desk assistance,
remote troubleshooting, and project work. Hours roll over for 30 days if unused.
</p>
<ul className="space-y-2">
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span><strong>Help Desk:</strong> Phone, email, and chat support for daily IT questions</span>
</li>
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span><strong>Remote Support:</strong> Screen sharing and remote control for quick fixes</span>
</li>
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span><strong>On-Site Support:</strong> Available for Premium and Priority plans</span>
</li>
</ul>
<p className="text-sm text-gray-500">
Block time is great for planned projects, office moves, or seasonal busy periods.
</p>
</div>
</ExpandableInfo>
{/* Monthly Total */}
<div className="bg-[#333d49] text-white rounded-lg p-5 flex items-center justify-between">
<div>
<span className="text-lg">Support Monthly Total</span>
{supportSelection.useBlockTime && supportSelection.blockTimeId && (
<p className="text-sm opacity-75">
Includes{' '}
{blockTimeOptions.find((b) => b.id === supportSelection.blockTimeId)?.hours} hr block
</p>
)}
</div>
<span className="text-3xl font-bold">
{formatCurrency(getSupportMonthly())}
<span className="text-lg font-normal opacity-75">/month</span>
</span>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,375 @@
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"
>
{/* VoIP Toggle */}
<div className="bg-gray-50 rounded-lg p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Phone className="w-6 h-6 text-[#fe7400]" />
<div>
<h3 className="font-semibold text-[#333d49]">Do you need business phones?</h3>
<p className="text-sm text-gray-500">
Modern VoIP phone system with advanced features
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<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]/20 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-medium text-gray-700">
{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 items-center gap-4 p-4 border border-gray-200 rounded-lg">
<label className="text-sm font-medium text-[#333d49]">Number of phone users:</label>
<Input
type="number"
min={1}
value={voipSelection.userCount}
onChange={(e) => onSetVoIPUserCount(parseInt(e.target.value, 10) || 1)}
className="w-24"
/>
</div>
{/* Tier Selection */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 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: -4 }}
>
<Card
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
padding="none"
className={`relative overflow-hidden cursor-pointer h-full ${
tier.recommended && !isSelected ? 'ring-2 ring-[#333d49]' : ''
}`}
onClick={() => onSetVoIPTier(tier.id)}
>
{tier.recommended && (
<div className="absolute top-0 right-0">
<div className="bg-[#fe7400] text-white text-xs font-semibold px-2 py-1 rounded-bl-lg">
Popular
</div>
</div>
)}
<div className="p-4">
<h3 className="text-lg font-semibold text-[#333d49]">{tier.name}</h3>
<p className="text-xs text-gray-500 mb-3">{tier.description}</p>
<div className="mb-3">
<span className="text-xl font-bold text-[#333d49]">
{formatCurrency(monthlyPrice)}
</span>
<span className="text-gray-500 text-xs">/mo</span>
<p className="text-xs text-gray-400">
{formatCurrency(tier.pricePerUser)}/user
</p>
</div>
<ul className="space-y-1 mb-4">
{tier.features.slice(0, 3).map((feature, idx) => (
<li key={idx} className="flex items-start gap-1.5 text-xs">
<Check className="w-3 h-3 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span className="text-gray-600">{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 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setShowHardware(!showHardware)}
className="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 transition-colors"
>
<div className="flex items-center gap-3">
<Headphones className="w-5 h-5 text-[#fe7400]" />
<span className="font-medium text-[#333d49]">
Phone Hardware (Optional)
</span>
</div>
<span className="text-sm text-gray-500">
{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-lg border-2 transition-all ${
isSelected
? 'border-[#fe7400] bg-[#fe7400]/5'
: 'border-gray-200'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium text-[#333d49]">{hardware.name}</h4>
<p className="text-sm text-gray-500">{hardware.description}</p>
<div className="flex gap-4 mt-2 text-sm">
<span className="text-[#333d49]">
Buy: <strong>{formatCurrency(hardware.oneTimePrice)}</strong>
</span>
<span className="text-[#333d49]">
Rent: <strong>{formatCurrency(hardware.monthlyRental)}</strong>/mo
</span>
</div>
</div>
{isSelected ? (
<div className="flex items-center gap-3">
{/* Rental Toggle */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onAddHardware(hardware.id, selection.quantity, false)}
className={`px-2 py-1 text-xs rounded ${
!selection.isRental
? 'bg-[#fe7400] text-white'
: 'bg-gray-200 text-gray-600'
}`}
>
Buy
</button>
<button
type="button"
onClick={() => onAddHardware(hardware.id, selection.quantity, true)}
className={`px-2 py-1 text-xs rounded ${
selection.isRental
? 'bg-[#fe7400] text-white'
: 'bg-gray-200 text-gray-600'
}`}
>
Rent
</button>
</div>
{/* Quantity */}
<div className="flex items-center gap-2 border border-gray-300 rounded-lg">
<button
type="button"
onClick={() => handleQuantityChange(hardware.id, -1)}
className="p-2 hover:bg-gray-100 rounded-l-lg"
disabled={selection.quantity <= 1}
>
<Minus className="w-4 h-4" />
</button>
<span className="w-8 text-center font-medium">
{selection.quantity}
</span>
<button
type="button"
onClick={() => handleQuantityChange(hardware.id, 1)}
className="p-2 hover:bg-gray-100 rounded-r-lg"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Remove */}
<button
type="button"
onClick={() => onRemoveHardware(hardware.id)}
className="p-2 text-red-500 hover:bg-red-50 rounded-lg"
>
<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">
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span>Unlimited local and long-distance calling</span>
</li>
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span>Mobile apps for iOS and Android - take calls anywhere</span>
</li>
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span>Auto-attendant and professional voicemail</span>
</li>
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span>Keep your existing phone numbers</span>
</li>
</ul>
</ExpandableInfo>
{/* Totals */}
<div className="space-y-3">
<div className="bg-[#333d49] text-white rounded-lg p-5 flex items-center justify-between">
<span className="text-lg">VoIP Monthly Total</span>
<span className="text-3xl font-bold">
{formatCurrency(getVoIPMonthly())}
<span className="text-lg font-normal opacity-75">/month</span>
</span>
</div>
{getVoIPOneTime() > 0 && (
<div className="bg-gray-100 rounded-lg p-4 flex items-center justify-between">
<span className="text-gray-700">Hardware Purchase (One-Time)</span>
<span className="text-xl font-bold text-[#333d49]">
{formatCurrency(getVoIPOneTime())}
</span>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{!voipSelection.enabled && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center py-8 text-gray-500"
>
<Phone className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p>You can always add VoIP services later.</p>
</motion.div>
)}
</motion.div>
);
}

View File

@@ -0,0 +1,378 @@
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"
>
{/* Web Hosting Section */}
<div className="space-y-4">
<div className="bg-gray-50 rounded-lg p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Globe className="w-6 h-6 text-[#fe7400]" />
<div>
<h3 className="font-semibold text-[#333d49]">Web Hosting</h3>
<p className="text-sm text-gray-500">
Managed WordPress hosting with SSL and backups
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<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]/20 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-medium text-gray-700">
{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 md:grid-cols-3 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: -4 }}
>
<Card
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
padding="none"
className={`relative overflow-hidden cursor-pointer h-full ${
tier.recommended && !isSelected ? 'ring-2 ring-[#333d49]' : ''
}`}
onClick={() => onSetWebHostingTier(tier.id)}
>
{tier.recommended && (
<div className="absolute top-0 right-0">
<div className="bg-[#fe7400] text-white text-xs font-semibold px-2 py-1 rounded-bl-lg">
Popular
</div>
</div>
)}
<div className="p-4">
<h3 className="text-lg font-semibold text-[#333d49]">{tier.name}</h3>
<p className="text-xs text-gray-500 mb-3">{tier.description}</p>
<div className="mb-3">
<span className="text-2xl font-bold text-[#333d49]">
{formatCurrency(tier.monthlyPrice)}
</span>
<span className="text-gray-500 text-sm">/mo</span>
</div>
<div className="flex gap-3 mb-3 text-xs text-gray-600">
<span>{tier.storage}</span>
<span>|</span>
<span>{tier.sites === -1 ? 'Unlimited' : tier.sites} site{tier.sites !== 1 && 's'}</span>
</div>
<ul className="space-y-1 mb-4">
{tier.features.slice(0, 3).map((feature, idx) => (
<li key={idx} className="flex items-start gap-1.5 text-xs">
<Check className="w-3 h-3 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span className="text-gray-600">{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-200" />
{/* Email Section */}
<div className="space-y-4">
<div className="bg-gray-50 rounded-lg p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Mail className="w-6 h-6 text-[#fe7400]" />
<div>
<h3 className="font-semibold text-[#333d49]">Email Service</h3>
<p className="text-sm text-gray-500">
Professional business email hosting
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<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]/20 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-medium text-gray-700">
{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 items-center gap-4 p-4 border border-gray-200 rounded-lg">
<label className="text-sm font-medium text-[#333d49]">
Number of mailboxes:
</label>
<Input
type="number"
min={1}
value={emailSelection.mailboxCount}
onChange={(e) => onSetMailboxCount(parseInt(e.target.value, 10) || 1)}
className="w-24"
/>
</div>
{/* Provider Selection */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
onClick={() => onSetEmailProvider('whm')}
className={`p-4 rounded-lg border-2 cursor-pointer transition-all ${
emailSelection.provider === 'whm'
? 'border-[#fe7400] bg-[#fe7400]/5'
: '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-semibold text-[#333d49]">Self-Hosted (WHM)</h4>
</div>
<p className="text-sm text-gray-500">
Budget-friendly email hosting on our servers
</p>
</div>
<div
onClick={() => onSetEmailProvider('m365')}
className={`p-4 rounded-lg border-2 cursor-pointer transition-all ${
emailSelection.provider === 'm365'
? 'border-[#fe7400] bg-[#fe7400]/5'
: '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-semibold text-[#333d49]">Microsoft 365</h4>
<span className="text-xs bg-[#fe7400] text-white px-2 py-0.5 rounded">
Recommended
</span>
</div>
<p className="text-sm text-gray-500">
Full Microsoft suite with Teams, OneDrive, and Office apps
</p>
</div>
</div>
{/* Tier Selection based on Provider */}
<div className="grid grid-cols-1 md:grid-cols-3 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: -4 }}
>
<Card
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
padding="none"
className={`relative overflow-hidden cursor-pointer h-full ${
tier.recommended && !isSelected ? 'ring-2 ring-[#333d49]' : ''
}`}
onClick={() => onSetEmailTier(tier.id)}
>
{tier.recommended && (
<div className="absolute top-0 right-0">
<div className="bg-[#fe7400] text-white text-xs font-semibold px-2 py-1 rounded-bl-lg">
Popular
</div>
</div>
)}
<div className="p-4">
<h3 className="text-base font-semibold text-[#333d49]">{tier.name}</h3>
<p className="text-xs text-gray-500 mb-2">{tier.storage}</p>
<div className="mb-3">
<span className="text-xl font-bold text-[#333d49]">
{formatCurrency(monthlyPrice)}
</span>
<span className="text-gray-500 text-xs">/mo</span>
<p className="text-xs text-gray-400">
{formatCurrency(tier.pricePerMailbox)}/mailbox
</p>
</div>
<ul className="space-y-1 mb-3">
{tier.features.slice(0, 3).map((feature, idx) => (
<li key={idx} className="flex items-start gap-1.5 text-xs">
<Check className="w-3 h-3 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span className="text-gray-600">{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-3">
<div>
<h5 className="font-medium text-[#333d49]">Self-Hosted (WHM)</h5>
<p className="text-sm text-gray-600">
Best for budget-conscious businesses that just need reliable email.
Includes webmail access and standard email features.
</p>
</div>
<div>
<h5 className="font-medium text-[#333d49]">Microsoft 365</h5>
<p className="text-sm text-gray-600">
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-gray-100 rounded-lg p-4 flex items-center justify-between">
<span className="text-gray-700">Web Hosting</span>
<span className="text-xl font-bold text-[#333d49]">
{formatCurrency(getWebHostingMonthly())}
<span className="text-sm font-normal">/mo</span>
</span>
</div>
)}
{emailSelection.enabled && (
<div className="bg-gray-100 rounded-lg p-4 flex items-center justify-between">
<span className="text-gray-700">Email Service</span>
<span className="text-xl font-bold text-[#333d49]">
{formatCurrency(getEmailMonthly())}
<span className="text-sm font-normal">/mo</span>
</span>
</div>
)}
{(webHostingSelection.enabled || emailSelection.enabled) && (
<div className="bg-[#333d49] text-white rounded-lg p-5 flex items-center justify-between">
<span className="text-lg">Web & Email Total</span>
<span className="text-3xl font-bold">
{formatCurrency(getWebHostingMonthly() + getEmailMonthly())}
<span className="text-lg font-normal opacity-75">/month</span>
</span>
</div>
)}
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,308 @@
import { motion } from 'framer-motion';
import { Edit2, Monitor, Headphones, Phone, Globe, Mail, Printer, DollarSign } 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) {
// Calculate fresh quote if not available
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 = () => {
window.print();
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
{/* Header */}
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-[#333d49] mb-2">Your Quote Summary</h2>
<p className="text-gray-500">Review your selections before submitting</p>
</div>
{/* Company Info */}
{quoteData.company.name && (
<div className="bg-gray-50 rounded-lg p-4 mb-6">
<p className="text-sm text-gray-500">Quote prepared for:</p>
<p className="font-semibold text-[#333d49] text-lg">{quoteData.company.name}</p>
{quoteData.company.industry && (
<p className="text-sm text-gray-600">{quoteData.company.industry}</p>
)}
</div>
)}
{/* GPS Monitoring Section */}
<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 */}
<SummarySection
icon={<Headphones className="w-5 h-5" />}
title="Support Plan"
monthlyTotal={result.supportMonthly}
onEdit={() => onGoToStep(2)}
>
<div className="space-y-2">
<SummaryLine
label={`${supportPlan?.name} Plan (${supportPlan?.includedHours} hrs/mo)`}
value={formatCurrency(result.breakdown.support.plan)}
/>
{blockTime && (
<SummaryLine
label={`Block Time (${blockTime.hours} hours)`}
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>
)}
{/* Totals */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.3 }}
className="bg-[#333d49] text-white rounded-xl p-6 mt-8"
>
<div className="flex items-center justify-between mb-4">
<span className="text-lg">Monthly Total</span>
<span className="text-4xl font-bold">
{formatCurrency(result.monthlyTotal)}
<span className="text-lg font-normal opacity-75">/mo</span>
</span>
</div>
{result.oneTimeTotal > 0 && (
<div className="flex items-center justify-between pt-4 border-t border-white/20">
<span className="opacity-75">One-Time Costs (Hardware)</span>
<span className="text-xl font-semibold">
{formatCurrency(result.oneTimeTotal)}
</span>
</div>
)}
<div className="mt-6 pt-4 border-t border-white/20">
<div className="flex items-center justify-between text-sm opacity-75">
<span>Annual Investment</span>
<span>{formatCurrency(result.monthlyTotal * 12)}/year</span>
</div>
</div>
</motion.div>
{/* Breakdown Card */}
<div className="bg-gray-50 rounded-lg p-5">
<h4 className="font-semibold text-[#333d49] mb-4 flex items-center gap-2">
<DollarSign className="w-5 h-5 text-[#fe7400]" />
Monthly Breakdown
</h4>
<div className="space-y-3">
<BreakdownRow label="GPS Monitoring" value={result.gpsMonthly} />
<BreakdownRow label="Support Plan" value={result.supportMonthly} />
{quoteData.voip.enabled && (
<BreakdownRow label="VoIP Phone System" value={result.voipMonthly} />
)}
{quoteData.webHosting.enabled && (
<BreakdownRow label="Web Hosting" value={result.webHostingMonthly} />
)}
{quoteData.email.enabled && (
<BreakdownRow label="Email Service" value={result.emailMonthly} />
)}
<div className="pt-3 border-t border-gray-200 flex justify-between font-bold text-lg">
<span className="text-[#333d49]">Total</span>
<span className="text-[#fe7400]">{formatCurrency(result.monthlyTotal)}/mo</span>
</div>
</div>
</div>
{/* Print Button */}
<div className="flex justify-center pt-4 print:hidden">
<Button
variant="outline"
onClick={handlePrint}
className="flex items-center gap-2"
>
<Printer className="w-4 h-4" />
Print Quote
</Button>
</div>
{/* Notes Section */}
<div className="text-center text-sm text-gray-500 pt-4">
<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>
</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: -20 }}
animate={{ opacity: 1, x: 0 }}
className="border border-gray-200 rounded-lg overflow-hidden"
>
<div className="bg-gray-50 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-[#fe7400]">{icon}</span>
<span className="font-semibold text-[#333d49]">{title}</span>
</div>
<div className="flex items-center gap-4">
<span className="font-bold text-[#333d49]">
{formatCurrency(monthlyTotal)}/mo
</span>
<button
type="button"
onClick={onEdit}
className="flex items-center gap-1 text-sm text-[#fe7400] hover:text-[#e56800] transition-colors print:hidden"
>
<Edit2 className="w-3 h-3" />
Edit
</button>
</div>
</div>
<div className="p-4">{children}</div>
</motion.div>
);
}
interface SummaryLineProps {
label: string;
value: string;
}
function SummaryLine({ label, value }: SummaryLineProps) {
return (
<div className="flex justify-between text-sm">
<span className="text-gray-600">{label}</span>
<span className="font-medium text-[#333d49]">{value}</span>
</div>
);
}
interface BreakdownRowProps {
label: string;
value: number;
}
function BreakdownRow({ label, value }: BreakdownRowProps) {
return (
<div className="flex justify-between">
<span className="text-gray-600">{label}</span>
<span className="font-medium text-[#333d49]">{formatCurrency(value)}</span>
</div>
);
}

View File

@@ -0,0 +1,294 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { User, Mail, Phone, Building2, MessageSquare, CheckCircle } 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>>({});
// Pre-fill company name if available
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 {
// Mark all fields as touched to show errors
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">Get Your Quote</h2>
<p className="text-gray-500">
We will send your customized quote and contact you to discuss next steps.
</p>
</div>
{/* Quote Preview */}
{quoteResult && (
<div className="bg-[#fe7400]/10 border border-[#fe7400]/30 rounded-lg p-4 mb-6 flex items-center justify-between">
<span className="text-[#333d49] font-medium">Your Estimated Monthly Total:</span>
<span className="text-2xl font-bold text-[#fe7400]">
{formatCurrency(quoteResult.monthlyTotal)}/mo
</span>
</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]">
<User className="w-4 h-4 text-[#fe7400]" />
Contact Name
<span className="text-red-500">*</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]">
<Mail className="w-4 h-4 text-[#fe7400]" />
Email Address
<span className="text-red-500">*</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]">
<Phone className="w-4 h-4 text-[#fe7400]" />
Phone Number
<span className="text-gray-400 font-normal">(recommended)</span>
</label>
<Input
type="tel"
value={contactInfo.phone}
onChange={(e) => onUpdateContact({ phone: e.target.value })}
placeholder="(555) 123-4567"
/>
</div>
{/* Company Name */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
<Building2 className="w-4 h-4 text-[#fe7400]" />
Company Name
</label>
<Input
type="text"
value={contactInfo.companyName}
onChange={(e) => onUpdateContact({ companyName: e.target.value })}
placeholder="Your company name"
/>
</div>
{/* Current IT Situation */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
<MessageSquare className="w-4 h-4 text-[#fe7400]" />
Current IT Situation
<span className="text-gray-400 font-normal">(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-2.5 rounded-lg border border-gray-300 text-[#333d49] placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#fe7400]/20 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]">
Preferred Contact Method
</label>
<div className="flex gap-4">
{contactPreferences.map((pref) => (
<label
key={pref.id}
className="flex items-center gap-2 cursor-pointer"
>
<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-700">{pref.label}</span>
</label>
))}
</div>
</div>
{/* Terms Checkbox */}
<div className="space-y-2 pt-4">
<label className="flex items-start gap-3 cursor-pointer">
<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-600">
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:underline">
Privacy Policy
</a>{' '}
and{' '}
<a href="/terms" className="text-[#fe7400] hover:underline">
Terms of Service
</a>
.
<span className="text-red-500">*</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-lg py-4"
isLoading={isSubmitting}
disabled={isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Submit Quote Request'}
</Button>
</motion.div>
</form>
{/* Trust Indicators */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="mt-8 pt-6 border-t border-gray-200"
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-center">
<div className="flex flex-col items-center gap-2">
<CheckCircle className="w-6 h-6 text-green-500" />
<span className="text-sm text-gray-600">No obligation quote</span>
</div>
<div className="flex flex-col items-center gap-2">
<CheckCircle className="w-6 h-6 text-green-500" />
<span className="text-sm text-gray-600">Response within 24 hours</span>
</div>
<div className="flex flex-col items-center gap-2">
<CheckCircle className="w-6 h-6 text-green-500" />
<span className="text-sm text-gray-600">Your data is secure</span>
</div>
</div>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,7 @@
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

@@ -0,0 +1,612 @@
import { useState, useCallback, useMemo } from 'react';
import type {
QuoteData,
QuoteResult,
QuoteBreakdown,
CompanyInfo,
GPSSelection,
SupportSelection,
VoIPSelection,
WebHostingSelection,
EmailSelection,
ContactInfo,
GPSTierId,
SupportPlanId,
BlockTimeId,
VoIPTierId,
WebHostingTierId,
EmailTierId,
EmailProvider,
Industry,
ContactPreference,
} from '@/types/quote';
import {
gpsTiers,
equipmentMonitoring,
supportPlans,
blockTimeOptions,
voipTiers,
voipHardware,
webHostingTiers,
emailTiers,
} from '@/lib/pricing-data';
/**
* Initial state values
*/
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;
// 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;
getVoIPOneTime: () => number;
// Reset
resetQuote: () => void;
}
/**
* Quote calculation and state management hook
*/
export function useQuote(): UseQuoteReturn {
const [company, setCompany] = useState<CompanyInfo>(initialCompanyInfo);
const [gps, setGPS] = useState<GPSSelection>(initialGPSSelection);
const [support, setSupport] = useState<SupportSelection>(initialSupportSelection);
const [voip, setVoIP] = useState<VoIPSelection>(initialVoIPSelection);
const [webHosting, setWebHosting] = useState<WebHostingSelection>(initialWebHostingSelection);
const [email, setEmail] = useState<EmailSelection>(initialEmailSelection);
const [contact, setContact] = useState<ContactInfo>(initialContactInfo);
const [quoteResult, setQuoteResult] = useState<QuoteResult | null>(null);
// Combined quote data
const quoteData: QuoteData = useMemo(
() => ({
company,
gps,
support,
voip,
webHosting,
email,
contact,
}),
[company, gps, support, voip, webHosting, email, contact]
);
// ============================================================================
// 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 => {
const plan = supportPlans.find((p) => p.id === support.planId);
if (!plan) return 0;
let total = plan.monthlyPrice;
if (support.useBlockTime && support.blockTimeId) {
const blockTime = blockTimeOptions.find((b) => b.id === support.blockTimeId);
if (blockTime) {
total += blockTime.price;
}
}
return total;
}, [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 => {
const gpsMonthly = getGPSMonthly();
const supportMonthly = getSupportMonthly();
const voipMonthly = getVoIPMonthly();
const voipOneTime = getVoIPOneTime();
const webHostingMonthly = getWebHostingMonthly();
const emailMonthly = getEmailMonthly();
// Calculate GPS breakdown
const gpsTier = gpsTiers.find((t) => t.id === gps.tierId);
const gpsMonitoring = gpsTier ? gpsTier.pricePerEndpoint * gps.endpointCount : 0;
let gpsEquipment = 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
const supportPlan = supportPlans.find((p) => p.id === support.planId);
const supportPlanCost = supportPlan ? supportPlan.monthlyPrice : 0;
let supportBlockTime = 0;
if (support.useBlockTime && support.blockTimeId) {
const blockTime = blockTimeOptions.find((b) => b.id === support.blockTimeId);
if (blockTime) {
supportBlockTime = blockTime.price;
}
}
// Calculate VoIP breakdown
const voipTier = voipTiers.find((t) => t.id === voip.tierId);
const voipService = voip.enabled && voipTier ? voipTier.pricePerUser * voip.userCount : 0;
let voipHardwareMonthly = 0;
if (voip.enabled) {
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: supportBlockTime,
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,
breakdown,
gpsMonthly,
supportMonthly,
voipMonthly,
webHostingMonthly,
emailMonthly,
};
setQuoteResult(result);
return result;
}, [gps, support, voip, webHosting, email, getGPSMonthly, getSupportMonthly, getVoIPMonthly, getVoIPOneTime, getWebHostingMonthly, getEmailMonthly]);
// ============================================================================
// Reset
// ============================================================================
const resetQuote = useCallback(() => {
setCompany(initialCompanyInfo);
setGPS(initialGPSSelection);
setSupport(initialSupportSelection);
setVoIP(initialVoIPSelection);
setWebHosting(initialWebHostingSelection);
setEmail(initialEmailSelection);
setContact(initialContactInfo);
setQuoteResult(null);
}, []);
return {
quoteData,
quoteResult,
// 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,
getVoIPOneTime,
// Reset
resetQuote,
};
}

View File

@@ -0,0 +1,160 @@
import { useState, useCallback, useMemo } from 'react';
import type { WizardStep } from '@/types/quote';
/**
* Wizard steps configuration for the 7-step MSP Quote Wizard
*/
const WIZARD_STEPS: Omit<WizardStep, 'isComplete' | 'isActive'>[] = [
{
id: 'company',
title: 'Company Profile',
description: 'Tell us about your business',
},
{
id: 'gps',
title: 'GPS Monitoring',
description: 'Select your monitoring tier',
},
{
id: 'support',
title: 'Support Plan',
description: 'Choose your support level',
},
{
id: 'voip',
title: 'VoIP Phone System',
description: 'Business phone options',
},
{
id: 'web-email',
title: 'Web & Email',
description: 'Hosting and email services',
},
{
id: 'summary',
title: 'Review Quote',
description: 'Review your selections',
},
{
id: 'contact',
title: 'Get Your Quote',
description: 'Submit your information',
},
];
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;
}
export function useWizard(): UseWizardReturn {
const [currentStep, setCurrentStep] = useState(0);
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
const [canProceed, setCanProceed] = useState(true);
const totalSteps = WIZARD_STEPS.length;
const isFirstStep = currentStep === 0;
const isLastStep = currentStep === totalSteps - 1;
const steps: WizardStep[] = useMemo(() => {
return WIZARD_STEPS.map((step, index) => ({
...step,
isComplete: completedSteps.has(index),
isActive: index === currentStep,
}));
}, [currentStep, completedSteps]);
const currentStepId = useMemo(() => {
return WIZARD_STEPS[currentStep]?.id || '';
}, [currentStep]);
const progress = useMemo(() => {
// Progress based on current step position (0 to 100)
return Math.round((currentStep / (totalSteps - 1)) * 100);
}, [currentStep, totalSteps]);
const goToStep = useCallback(
(step: number) => {
if (step >= 0 && step < totalSteps) {
// Allow going back to any previous step
// Only allow going forward to completed steps or the next step
if (step <= currentStep || completedSteps.has(step - 1) || step === currentStep + 1) {
setCurrentStep(step);
}
}
},
[totalSteps, currentStep, completedSteps]
);
const nextStep = useCallback(() => {
if (!isLastStep && canProceed) {
// Mark current step as complete when moving forward
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);
}, []);
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

@@ -0,0 +1,62 @@
@import url('https://fonts.googleapis.com/css2?family=Lexend:wght@300;400;500;600;700&display=swap');
@import "tailwindcss";
@theme {
--color-primary: #333d49;
--color-accent: #fe7400;
--color-navy: #113559;
--color-gray-600: #4d4d4d;
--font-family-lexend: 'Lexend', sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-family: 'Lexend', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: 'Lexend', sans-serif;
background-color: #ffffff;
color: #333d49;
line-height: 1.6;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #333d49;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #113559;
}
/* Focus styles for accessibility */
:focus-visible {
outline: 2px solid #fe7400;
outline-offset: 2px;
}
/* Selection color */
::selection {
background-color: #fe7400;
color: #ffffff;
}

View File

@@ -0,0 +1,84 @@
import axios from 'axios';
import type { QuoteData, QuoteResult } from '@/types/quote';
/**
* API client for MSP Quote Wizard
*/
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,
});
// Request interceptor for adding auth token
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('quote_wizard_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor for error handling
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('quote_wizard_token');
}
return Promise.reject(error);
}
);
/**
* API endpoints
*/
export const quoteApi = {
/**
* Calculate quote based on provided data
*/
calculateQuote: async (data: QuoteData): Promise<QuoteResult> => {
const response = await apiClient.post<QuoteResult>('/api/quotes/calculate', data);
return response.data;
},
/**
* Save quote for later retrieval
*/
saveQuote: async (data: QuoteData & { email: string }): Promise<{ quoteId: string }> => {
const response = await apiClient.post<{ quoteId: string }>('/api/quotes/save', data);
return response.data;
},
/**
* Retrieve saved quote by ID
*/
getQuote: async (quoteId: string): Promise<QuoteData & QuoteResult> => {
const response = await apiClient.get<QuoteData & QuoteResult>(`/api/quotes/${quoteId}`);
return response.data;
},
/**
* Submit quote request for sales follow-up
*/
submitQuoteRequest: async (data: QuoteData & {
contactInfo: {
name: string;
email: string;
phone?: string;
}
}): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post('/api/quotes/submit', data);
return response.data;
},
};

View File

@@ -0,0 +1,423 @@
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

@@ -0,0 +1,69 @@
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

@@ -0,0 +1,22 @@
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

@@ -0,0 +1,269 @@
/**
* 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 = '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;
}
// ============================================================================
// Company & Contact Types
// ============================================================================
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;
}
// ============================================================================
// Quote Data & Result Types
// ============================================================================
export interface QuoteData {
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

@@ -0,0 +1,33 @@
/** @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

@@ -0,0 +1,34 @@
{
"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

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

View File

@@ -0,0 +1,26 @@
{
"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

@@ -0,0 +1,18 @@
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({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
host: true,
},
})

View File

@@ -0,0 +1,10 @@
{
"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

@@ -0,0 +1,533 @@
<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

@@ -0,0 +1,523 @@
## 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

@@ -11,3 +11,4 @@ python-multipart==0.0.6
pydantic==2.10.6
pydantic-settings==2.8.0
python-dotenv==1.0.0
httpx==0.27.0

404
temp/m365_security_scan.py Normal file
View File

@@ -0,0 +1,404 @@
#!/usr/bin/env python3
"""
M365 Security Scan - Check all accounts for compromise indicators
Scans: Sign-in logs, inbox rules, OAuth grants, MFA methods, forwarding
"""
import requests
import json
from datetime import datetime, timedelta
# Claude-MSP-Access Multi-Tenant App
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
TENANTS = {
"Valley Wide Plastering": {
"tenant_id": "5c53ae9f-7071-4248-b834-8685b646450f",
"domain": "valleywideplastering.com"
},
"BG Builders LLC": {
"tenant_id": "ededa4fb-f6eb-4398-851d-5eb3e11fab27",
"domain": "bgbuildersllc.com"
}
}
# Known suspicious patterns
SUSPICIOUS_RULE_PATTERNS = [".", "..", "...", "spam", "junk", "filter"]
SUSPICIOUS_OAUTH_APPS = ["gmail", "yahoo", "p2p", "autoforward"]
US_COUNTRY_CODES = ["US", "United States"]
def get_token(tenant_id):
"""Get Graph API access token for tenant"""
url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
data = {
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"scope": "https://graph.microsoft.com/.default",
"grant_type": "client_credentials"
}
resp = requests.post(url, data=data)
if resp.status_code == 200:
return resp.json().get("access_token")
else:
print(f" [ERROR] Token failed: {resp.status_code} - {resp.text[:200]}")
return None
def graph_get(token, endpoint, params=None):
"""Make Graph API GET request"""
headers = {"Authorization": f"Bearer {token}"}
url = f"https://graph.microsoft.com/v1.0{endpoint}"
resp = requests.get(url, headers=headers, params=params)
if resp.status_code == 200:
return resp.json()
elif resp.status_code == 404:
return None
else:
return {"error": resp.status_code, "message": resp.text[:200]}
def graph_get_beta(token, endpoint, params=None):
"""Make Graph API beta GET request"""
headers = {"Authorization": f"Bearer {token}"}
url = f"https://graph.microsoft.com/beta{endpoint}"
resp = requests.get(url, headers=headers, params=params)
if resp.status_code == 200:
return resp.json()
elif resp.status_code == 404:
return None
else:
return {"error": resp.status_code, "message": resp.text[:200]}
def check_signin_logs(token, user_id, user_email, days=30):
"""Check sign-in logs for foreign/suspicious IPs"""
issues = []
cutoff = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
# Get sign-in logs
params = {
"$filter": f"userId eq '{user_id}' and createdDateTime ge {cutoff}",
"$top": 100,
"$orderby": "createdDateTime desc"
}
result = graph_get_beta(token, "/auditLogs/signIns", params)
if result and "value" in result:
foreign_logins = []
failed_foreign = []
for signin in result["value"]:
location = signin.get("location", {})
country = location.get("countryOrRegion", "Unknown")
status = signin.get("status", {})
error_code = status.get("errorCode", 0)
ip = signin.get("ipAddress", "Unknown")
if country not in US_COUNTRY_CODES and country != "Unknown":
entry = {
"ip": ip,
"country": country,
"city": location.get("city", "Unknown"),
"time": signin.get("createdDateTime"),
"success": error_code == 0,
"error": error_code
}
if error_code == 0:
foreign_logins.append(entry)
else:
failed_foreign.append(entry)
if foreign_logins:
issues.append({
"type": "FOREIGN_SUCCESS_LOGIN",
"severity": "CRITICAL",
"count": len(foreign_logins),
"details": foreign_logins[:5] # Top 5
})
if failed_foreign:
# Group by country
countries = list(set([f["country"] for f in failed_foreign]))
issues.append({
"type": "FOREIGN_FAILED_ATTEMPTS",
"severity": "INFO",
"count": len(failed_foreign),
"countries": countries
})
elif result and "error" in result:
if result["error"] != 404:
issues.append({"type": "SIGNIN_LOG_ERROR", "severity": "WARNING", "details": result})
return issues
def check_inbox_rules(token, user_id, user_email):
"""Check for malicious inbox rules"""
issues = []
result = graph_get(token, f"/users/{user_id}/mailFolders/inbox/messageRules")
if result and "value" in result:
for rule in result["value"]:
name = rule.get("displayName", "")
is_enabled = rule.get("isEnabled", False)
# Check for suspicious patterns
suspicious = False
reasons = []
# Short/dot names
if name in SUSPICIOUS_RULE_PATTERNS or len(name) <= 2:
suspicious = True
reasons.append(f"Suspicious name: '{name}'")
# Rules that delete/move and mark read
actions = rule.get("actions", {})
if actions.get("markAsRead") and (actions.get("delete") or actions.get("moveToFolder")):
suspicious = True
reasons.append("Marks read + moves/deletes")
# Stop processing
if actions.get("stopProcessingRules") and (actions.get("moveToFolder") or actions.get("delete")):
suspicious = True
reasons.append("Stops processing + hides mail")
# Forwarding rules
if actions.get("forwardTo") or actions.get("forwardAsAttachmentTo") or actions.get("redirectTo"):
forward_targets = actions.get("forwardTo", []) + actions.get("forwardAsAttachmentTo", []) + actions.get("redirectTo", [])
suspicious = True
reasons.append(f"Forwards to external: {forward_targets}")
if suspicious and is_enabled:
issues.append({
"type": "SUSPICIOUS_INBOX_RULE",
"severity": "CRITICAL",
"rule_name": name,
"rule_id": rule.get("id"),
"reasons": reasons
})
elif result and "error" in result:
if result["error"] != 404:
issues.append({"type": "INBOX_RULE_ERROR", "severity": "WARNING", "details": result})
return issues
def check_oauth_grants(token, user_id, user_email):
"""Check for suspicious OAuth app grants"""
issues = []
result = graph_get(token, f"/users/{user_id}/oauth2PermissionGrants")
if result and "value" in result:
for grant in result["value"]:
client_id = grant.get("clientId", "")
scope = grant.get("scope", "")
# Get app details
app_result = graph_get(token, f"/servicePrincipals/{client_id}")
app_name = app_result.get("displayName", "Unknown") if app_result else "Unknown"
# Check for suspicious apps
suspicious = False
for pattern in SUSPICIOUS_OAUTH_APPS:
if pattern.lower() in app_name.lower():
suspicious = True
break
# Check for sensitive scopes
sensitive_scopes = ["Mail.ReadWrite", "Mail.Send", "MailboxSettings", "full_access"]
has_sensitive = any(s.lower() in scope.lower() for s in sensitive_scopes)
if suspicious or (has_sensitive and "Microsoft" not in app_name):
issues.append({
"type": "SUSPICIOUS_OAUTH_APP",
"severity": "HIGH" if suspicious else "MEDIUM",
"app_name": app_name,
"client_id": client_id,
"scope": scope
})
return issues
def check_mfa_methods(token, user_id, user_email):
"""Check MFA methods for suspicious devices"""
issues = []
result = graph_get(token, f"/users/{user_id}/authentication/methods")
if result and "value" in result:
methods = []
for method in result["value"]:
method_type = method.get("@odata.type", "")
if "phone" in method_type.lower():
phone = method.get("phoneNumber", "Unknown")
methods.append({"type": "phone", "value": phone})
elif "microsoftAuthenticator" in method_type:
device = method.get("displayName", method.get("deviceTag", "Unknown"))
methods.append({"type": "authenticator", "device": device})
elif "fido2" in method_type.lower():
methods.append({"type": "fido2", "model": method.get("model", "Unknown")})
# Flag if multiple authenticator devices (potential attacker device)
auth_devices = [m for m in methods if m.get("type") == "authenticator"]
if len(auth_devices) > 2:
issues.append({
"type": "MULTIPLE_AUTH_DEVICES",
"severity": "MEDIUM",
"count": len(auth_devices),
"devices": auth_devices
})
return issues
def check_mailbox_settings(token, user_id, user_email):
"""Check mailbox for forwarding/auto-replies"""
issues = []
result = graph_get(token, f"/users/{user_id}/mailboxSettings")
if result and "error" not in result:
# Check auto-forwarding
# Note: Graph API doesn't expose SMTP forwarding directly, need Exchange
# Check automatic replies
auto_reply = result.get("automaticRepliesSetting", {})
if auto_reply.get("status") == "alwaysEnabled":
issues.append({
"type": "AUTO_REPLY_ALWAYS_ON",
"severity": "LOW",
"message": auto_reply.get("internalReplyMessage", "")[:100]
})
return issues
def scan_tenant(tenant_name, tenant_info):
"""Scan all users in a tenant"""
print(f"\n{'='*60}")
print(f"Scanning: {tenant_name}")
print(f"Tenant ID: {tenant_info['tenant_id']}")
print(f"{'='*60}")
token = get_token(tenant_info["tenant_id"])
if not token:
return {"error": "Failed to get token - admin consent may be needed"}
# Get all users
users_result = graph_get(token, "/users", {"$select": "id,displayName,mail,userPrincipalName,accountEnabled"})
if not users_result or "value" not in users_result:
return {"error": f"Failed to get users: {users_result}"}
users = users_result["value"]
print(f"Found {len(users)} users")
results = {
"tenant": tenant_name,
"scan_time": datetime.utcnow().isoformat(),
"total_users": len(users),
"clean_users": [],
"flagged_users": [],
"disabled_users": [],
"errors": []
}
for user in users:
user_id = user.get("id")
email = user.get("mail") or user.get("userPrincipalName", "Unknown")
name = user.get("displayName", "Unknown")
enabled = user.get("accountEnabled", True)
if not enabled:
results["disabled_users"].append({"name": name, "email": email})
print(f" [SKIP] {name} - disabled")
continue
print(f" Checking: {name} ({email})...", end=" ")
all_issues = []
# Run all checks
try:
all_issues.extend(check_signin_logs(token, user_id, email))
except Exception as e:
results["errors"].append({"user": email, "check": "signin_logs", "error": str(e)})
try:
all_issues.extend(check_inbox_rules(token, user_id, email))
except Exception as e:
results["errors"].append({"user": email, "check": "inbox_rules", "error": str(e)})
try:
all_issues.extend(check_oauth_grants(token, user_id, email))
except Exception as e:
results["errors"].append({"user": email, "check": "oauth_grants", "error": str(e)})
try:
all_issues.extend(check_mfa_methods(token, user_id, email))
except Exception as e:
results["errors"].append({"user": email, "check": "mfa_methods", "error": str(e)})
try:
all_issues.extend(check_mailbox_settings(token, user_id, email))
except Exception as e:
results["errors"].append({"user": email, "check": "mailbox_settings", "error": str(e)})
# Categorize by severity
critical = [i for i in all_issues if i.get("severity") == "CRITICAL"]
high = [i for i in all_issues if i.get("severity") == "HIGH"]
if critical or high:
results["flagged_users"].append({
"name": name,
"email": email,
"user_id": user_id,
"issues": all_issues
})
print(f"[FLAGGED] {len(critical)} critical, {len(high)} high")
else:
results["clean_users"].append({"name": name, "email": email})
info_issues = [i for i in all_issues if i.get("severity") == "INFO"]
if info_issues:
print(f"[OK] ({len(info_issues)} info)")
else:
print("[OK]")
return results
def main():
print("M365 Security Scan")
print(f"Started: {datetime.utcnow().isoformat()}")
all_results = {}
for tenant_name, tenant_info in TENANTS.items():
try:
results = scan_tenant(tenant_name, tenant_info)
all_results[tenant_name] = results
except Exception as e:
all_results[tenant_name] = {"error": str(e)}
print(f" [ERROR] Tenant scan failed: {e}")
# Save results
output_file = "/Users/azcomputerguru/ClaudeTools/temp/m365_security_scan_results.json"
with open(output_file, "w") as f:
json.dump(all_results, f, indent=2)
# Print summary
print("\n" + "="*60)
print("SCAN SUMMARY")
print("="*60)
for tenant_name, results in all_results.items():
print(f"\n{tenant_name}:")
if "error" in results:
print(f" [ERROR] {results['error']}")
else:
print(f" Total users: {results['total_users']}")
print(f" Clean: {len(results['clean_users'])}")
print(f" Flagged: {len(results['flagged_users'])}")
print(f" Disabled: {len(results['disabled_users'])}")
if results["flagged_users"]:
print("\n FLAGGED ACCOUNTS:")
for user in results["flagged_users"]:
print(f" - {user['name']} ({user['email']})")
for issue in user["issues"]:
print(f" [{issue['severity']}] {issue['type']}")
print(f"\nResults saved to: {output_file}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,274 @@
{
"Valley Wide Plastering": {
"tenant": "Valley Wide Plastering",
"scan_time": "2026-03-06T01:21:31.514321",
"total_users": 33,
"clean_users": [
{
"name": "Adolfo Suarez",
"email": "adolfos@valleywideplastering.com"
},
{
"name": "Toni",
"email": "billing@valleywideplastering.onmicrosoft.com"
},
{
"name": "Brian",
"email": "Brian@valleywideplastering.com"
},
{
"name": "Carlos Reyes",
"email": "carlos@valleywideplastering.com"
},
{
"name": "Charlie Jones",
"email": "charlie@valleywideplastering.com"
},
{
"name": "Chris Guerrero",
"email": "chris@valleywideplastering.com"
},
{
"name": "Customer Service",
"email": "customerservice@valleywideplastering.com"
},
{
"name": "Customer Service",
"email": "customerservice@valleywideplastering.onmicrosoft.com"
},
{
"name": "Bart Graffin",
"email": "estimating@valleywideplastering.com"
},
{
"name": "Fax Inbox",
"email": "faxinbox@valleywideplastering.com"
},
{
"name": "Fermin Matta",
"email": "fermin@valleywideplastering.com"
},
{
"name": "Francisco Arias",
"email": "franciscoa@valleywideplastering.com"
},
{
"name": "VWP Insurance",
"email": "insurance@valleywideplastering.com"
},
{
"name": "Issac Chavez",
"email": "isaacc@valleywideplastering.com"
},
{
"name": "JR Guerrero",
"email": "j-r@valleywideplastering.com"
},
{
"name": "Jaime Hernandez",
"email": "jaimebh@valleywideplastering.com"
},
{
"name": "Jesse Guerrero",
"email": "jesse@valleywideplastering.com"
},
{
"name": "JR Guerrero",
"email": "jr@CASARICA.NET"
},
{
"name": "Juan Leal",
"email": "juan@valleywideplastering.com"
},
{
"name": "Kayla Guerrero",
"email": "kayla@valleywideplastering.com"
},
{
"name": "Orders VWP",
"email": "orders@valleywideplastering.com"
},
{
"name": "Payroll VWP",
"email": "payroll@valleywideplastering.com"
},
{
"name": "Ron Winger",
"email": "ron@valleywideplastering.com"
},
{
"name": "Rose Guerrero",
"email": "rose@valleywideplastering.com"
},
{
"name": "Ryan Guerrero",
"email": "ryan@valleywideplastering.com"
},
{
"name": "Sammy Montijo",
"email": "sammy@valleywideplastering.com"
},
{
"name": "Shelly Dooley",
"email": "shelly@valleywideplastering.com"
},
{
"name": "Spro VWP",
"email": "spro@valleywideplastering.com"
},
{
"name": "Computer Guru",
"email": "sysadmin@valleywideplastering.com"
},
{
"name": "Teresa Carpio",
"email": "teresa@valleywideplastering.com"
},
{
"name": "Ty Fetters",
"email": "Ty@CASARICA.NET"
}
],
"flagged_users": [
{
"name": "Accounts Payable",
"email": "acctpay@valleywideplastering.com",
"user_id": "e70d7ec5-72f3-4b80-9614-e6bd5380b773",
"issues": [
{
"type": "SUSPICIOUS_INBOX_RULE",
"severity": "CRITICAL",
"rule_name": "Order Acknowledgment ",
"rule_id": "AQAAANfcAXQ=",
"reasons": [
"Stops processing + hides mail"
]
}
]
},
{
"name": "Billing Clerk",
"email": "billing@valleywideplastering.com",
"user_id": "4f708b80-e537-4f63-92d3-5feedfa28244",
"issues": [
{
"type": "FOREIGN_FAILED_ATTEMPTS",
"severity": "INFO",
"count": 15,
"countries": [
"GN",
"SG",
"ID",
"CZ",
"CN",
"BR",
"IT",
"ZA",
"VN",
"PH",
"CA",
"AR",
"AL"
]
},
{
"type": "SUSPICIOUS_INBOX_RULE",
"severity": "CRITICAL",
"rule_name": "Tim Wolf",
"rule_id": "AQAAAFDUDZY=",
"reasons": [
"Stops processing + hides mail"
]
},
{
"type": "SUSPICIOUS_INBOX_RULE",
"severity": "CRITICAL",
"rule_name": "donotreply@pulte.com",
"rule_id": "AQAAADPeesE=",
"reasons": [
"Stops processing + hides mail"
]
},
{
"type": "SUSPICIOUS_INBOX_RULE",
"severity": "CRITICAL",
"rule_name": "ssrs-donotreply@pulte.com",
"rule_id": "AQAAADJQZww=",
"reasons": [
"Stops processing + hides mail"
]
}
]
}
],
"disabled_users": [],
"errors": []
},
"BG Builders LLC": {
"tenant": "BG Builders LLC",
"scan_time": "2026-03-06T01:54:05.702139",
"total_users": 14,
"clean_users": [
{
"name": "Accounting",
"email": "Accounting@sonorangreenllc.com"
},
{
"name": "Accounts Payable",
"email": "accountspayable@sonorangreenllc.com"
},
{
"name": "admin",
"email": "admin@bgbuildersllc.com"
},
{
"name": "Balynda Western",
"email": "balynda@bgbuildersllc.com"
},
{
"name": "Barry Walling",
"email": "barry@bgbuildersllc.com"
},
{
"name": "Barry Walling",
"email": "Barry@sonorangreenllc.com"
},
{
"name": "Chad Bradford",
"email": "chad@bgbuildersllc.com"
},
{
"name": "Lesley Roth",
"email": "lesley@bgbuildersllc.com"
},
{
"name": "Projects",
"email": "projects@bgbuildersllc.com"
},
{
"name": "Raul Flores",
"email": "raul@bgbuildersllc.com"
},
{
"name": "Shelly Dooley",
"email": "Shelly@bgbuildersllc.com"
},
{
"name": "Site Operations",
"email": "siteoperations@bgbuildersllc.com"
},
{
"name": "Computer Guru",
"email": "sysadmin@bgbuildersllc.com"
}
],
"flagged_users": [],
"disabled_users": [
{
"name": "Shaun Smith",
"email": "Shaun@bgbuildersllc.com"
}
],
"errors": []
}
}