sync: Auto-sync from ACG-M-L5090 at 2026-03-10 19:11:00

Synced files:
- Quote wizard frontend (all components, hooks, types, config)
- API updates (config, models, routers, schemas, services)
- Client work (bg-builders, gurushow)
- Scripts (BGB Lesley termination, CIPP, Datto, migration)
- Temp files (Bardach contacts, VWP investigation, misc)
- Credentials and session logs
- Email service, PHP API, session logs

Machine: ACG-M-L5090
Timestamp: 2026-03-10 19:11:00

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 19:59:08 -07:00
parent a1a19f8c00
commit fa15b03180
169 changed files with 879909 additions and 1243 deletions

View File

@@ -46,6 +46,13 @@ class Settings(BaseSettings):
# API configuration
ALLOWED_ORIGINS: str = "*"
# Microsoft Graph API (Email via M365)
GRAPH_TENANT_ID: str = ""
GRAPH_CLIENT_ID: str = ""
GRAPH_CLIENT_SECRET: str = ""
GRAPH_SENDER_EMAIL: str = "noreply@azcomputerguru.com"
ADMIN_NOTIFICATION_EMAIL: str = "mike@azcomputerguru.com"
class Config:
"""Pydantic configuration."""

View File

@@ -22,6 +22,7 @@ from sqlalchemy import (
Numeric,
String,
Text,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -32,38 +33,34 @@ class QuoteStatus(str, PyEnum):
"""Status options for quotes."""
DRAFT = "draft"
SUBMITTED = "submitted"
REVIEWING = "reviewing"
APPROVED = "approved"
REJECTED = "rejected"
VIEWED = "viewed"
FOLLOWED_UP = "followed_up"
CONVERTED = "converted"
EXPIRED = "expired"
class ServiceCategory(str, PyEnum):
"""Service category options for quote items."""
MANAGED_SERVICES = "managed_services"
SECURITY = "security"
BACKUP = "backup"
CLOUD = "cloud"
GPS_MONITORING = "gps_monitoring"
SUPPORT_PLAN = "support_plan"
VOIP = "voip"
WEB_HOSTING = "web_hosting"
EMAIL = "email"
HARDWARE = "hardware"
SOFTWARE = "software"
CONSULTING = "consulting"
SUPPORT = "support"
ADDON = "addon"
class BillingFrequency(str, PyEnum):
"""Billing frequency options for quote items."""
MONTHLY = "monthly"
QUARTERLY = "quarterly"
ANNUAL = "annual"
YEARLY = "yearly"
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"
EMAIL = "email"
WEBHOOK = "webhook"
class Quote(Base, UUIDMixin, TimestampMixin):
@@ -75,17 +72,14 @@ class Quote(Base, UUIDMixin, TimestampMixin):
Attributes:
access_token: Unique token for public access (URL-safe, 43 chars)
status: Current quote status (draft, submitted, reviewing, etc.)
status: Current quote status (draft, submitted, viewed, 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
@@ -109,10 +103,10 @@ class Quote(Base, UUIDMixin, TimestampMixin):
nullable=False,
default=QuoteStatus.DRAFT.value,
server_default=QuoteStatus.DRAFT.value,
doc="Quote status: draft, submitted, reviewing, approved, rejected, expired"
doc="Quote status: draft, submitted, viewed, followed_up, converted, expired"
)
# Contact information (optional until submission)
# Contact information
company_name: Mapped[Optional[str]] = mapped_column(
String(255),
doc="Prospect company name"
@@ -139,15 +133,14 @@ class Quote(Base, UUIDMixin, TimestampMixin):
doc="Number of employees/users"
)
# Notes
notes: Mapped[Optional[str]] = mapped_column(
Text,
doc="Customer notes or special requirements"
industry: Mapped[Optional[str]] = mapped_column(
String(100),
doc="Industry/vertical of the prospect"
)
admin_notes: Mapped[Optional[str]] = mapped_column(
current_it_situation: Mapped[Optional[str]] = mapped_column(
Text,
doc="Internal admin notes (not visible to customer)"
doc="Description of the prospect's current IT setup"
)
# Calculated totals
@@ -167,14 +160,6 @@ class Quote(Base, UUIDMixin, TimestampMixin):
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,
@@ -193,10 +178,32 @@ class Quote(Base, UUIDMixin, TimestampMixin):
)
user_agent: Mapped[Optional[str]] = mapped_column(
String(500),
Text,
doc="Browser user agent string"
)
# Marketing attribution
source: Mapped[Optional[str]] = mapped_column(
String(50),
server_default="website",
doc="Lead source (e.g., website, referral)"
)
utm_source: Mapped[Optional[str]] = mapped_column(
String(100),
doc="UTM source parameter"
)
utm_medium: Mapped[Optional[str]] = mapped_column(
String(100),
doc="UTM medium parameter"
)
utm_campaign: Mapped[Optional[str]] = mapped_column(
String(100),
doc="UTM campaign parameter"
)
# Syncro RMM Integration
syncro_lead_id: Mapped[Optional[str]] = mapped_column(
String(100),
@@ -242,7 +249,7 @@ class Quote(Base, UUIDMixin, TimestampMixin):
# Constraints and indexes
__table_args__ = (
CheckConstraint(
"status IN ('draft', 'submitted', 'reviewing', 'approved', 'rejected', 'expired')",
"status IN ('draft', 'submitted', 'viewed', 'followed_up', 'converted', 'expired')",
name="ck_quotes_status"
),
Index("idx_quotes_access_token", "access_token"),
@@ -256,23 +263,25 @@ class Quote(Base, UUIDMixin, TimestampMixin):
return f"<Quote(id='{self.id}', status='{self.status}', company='{self.company_name}')>"
class QuoteItem(Base, UUIDMixin, TimestampMixin):
class QuoteItem(Base, UUIDMixin):
"""
Quote item model representing a single line item in a quote.
Stores service details, pricing, and quantity information.
Stores product 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
category: Service category (gps_monitoring, support_plan, etc.)
product_code: Product code identifier
product_name: Name of the product/service
description: Detailed description of the product/service
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
unit_price: Price per unit
setup_price: One-time setup price
billing_frequency: Billing frequency (monthly, yearly, one_time)
tier: Pricing tier
is_recommended: Whether this item is recommended
created_at: Timestamp when item was created
"""
__tablename__ = "quote_items"
@@ -285,42 +294,32 @@ class QuoteItem(Base, UUIDMixin, TimestampMixin):
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."
doc="Service category: gps_monitoring, support_plan, voip, web_hosting, email, hardware, addon"
)
# Billing
billing_frequency: Mapped[str] = mapped_column(
String(20),
# Product identification
product_code: Mapped[str] = mapped_column(
String(50),
nullable=False,
default=BillingFrequency.MONTHLY.value,
doc="Billing frequency: monthly, quarterly, annual, one_time"
doc="Product code identifier"
)
# Pricing
unit_price: Mapped[Decimal] = mapped_column(
Numeric(10, 2),
product_name: Mapped[str] = mapped_column(
String(255),
nullable=False,
default=Decimal("0.00"),
doc="Price per unit"
doc="Name of the product/service"
)
description: Mapped[Optional[str]] = mapped_column(
Text,
doc="Detailed description of the product/service"
)
# Quantity and pricing
quantity: Mapped[int] = mapped_column(
Integer,
nullable=False,
@@ -328,29 +327,49 @@ class QuoteItem(Base, UUIDMixin, TimestampMixin):
doc="Number of units"
)
setup_fee: Mapped[Decimal] = mapped_column(
unit_price: Mapped[Decimal] = mapped_column(
Numeric(10, 2),
nullable=False,
doc="Price per unit"
)
setup_price: Mapped[Decimal] = mapped_column(
Numeric(10, 2),
nullable=False,
default=Decimal("0.00"),
server_default="0.00",
doc="One-time setup fee"
doc="One-time setup price"
)
# Billing
billing_frequency: Mapped[str] = mapped_column(
String(20),
nullable=False,
default=BillingFrequency.MONTHLY.value,
server_default="monthly",
doc="Billing frequency: monthly, yearly, one_time"
)
# Configuration
is_required: Mapped[bool] = mapped_column(
tier: Mapped[Optional[str]] = mapped_column(
String(50),
doc="Pricing tier"
)
is_recommended: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
server_default="0",
doc="Whether this item is required (cannot be removed)"
doc="Whether this item is recommended"
)
sort_order: Mapped[int] = mapped_column(
Integer,
# Timestamp (no updated_at in DB)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=0,
server_default="0",
doc="Display order within the quote"
server_default=func.now(),
doc="Timestamp when the item was created"
)
# Relationships
@@ -362,24 +381,20 @@ class QuoteItem(Base, UUIDMixin, TimestampMixin):
# Constraints and indexes
__table_args__ = (
CheckConstraint(
"category IN ('managed_services', 'security', 'backup', 'cloud', 'hardware', 'software', 'consulting', 'support')",
"category IN ('gps_monitoring', 'support_plan', 'voip', 'web_hosting', 'email', 'hardware', 'addon')",
name="ck_quote_items_category"
),
CheckConstraint(
"billing_frequency IN ('monthly', 'quarterly', 'annual', 'one_time')",
"billing_frequency IN ('monthly', 'yearly', '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})>"
return f"<QuoteItem(product='{self.product_name}', qty={self.quantity}, price={self.unit_price})>"
@property
def line_total(self) -> Decimal:
@@ -391,15 +406,13 @@ class QuoteItem(Base, UUIDMixin, TimestampMixin):
"""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:
elif self.billing_frequency == BillingFrequency.YEARLY.value:
return self.line_total / Decimal("12")
else: # one_time
return Decimal("0.00")
class QuoteActivity(Base, UUIDMixin, TimestampMixin):
class QuoteActivity(Base, UUIDMixin):
"""
Quote activity model for tracking quote history and changes.
@@ -408,13 +421,13 @@ class QuoteActivity(Base, UUIDMixin, TimestampMixin):
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')
step_name: Name of the wizard step associated with the action
details: Additional details about the action (longtext)
ip_address: IP address of the actor
metadata: JSON metadata about the action
created_at: Timestamp when the activity was recorded
"""
__tablename__ = "quote_activities"
__tablename__ = "quote_activity"
# Foreign keys
quote_id: Mapped[str] = mapped_column(
@@ -431,14 +444,14 @@ class QuoteActivity(Base, UUIDMixin, TimestampMixin):
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"
step_name: Mapped[Optional[str]] = mapped_column(
String(50),
doc="Name of the wizard step associated with the action"
)
actor: Mapped[Optional[str]] = mapped_column(
String(255),
doc="Who performed the action (email, 'system', 'admin')"
details: Mapped[Optional[str]] = mapped_column(
Text,
doc="Additional details about the action"
)
ip_address: Mapped[Optional[str]] = mapped_column(
@@ -446,9 +459,12 @@ class QuoteActivity(Base, UUIDMixin, TimestampMixin):
doc="IP address of the actor"
)
metadata: Mapped[Optional[str]] = mapped_column(
Text,
doc="JSON metadata about the action"
# Timestamp (no updated_at in DB)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
doc="Timestamp when the activity was recorded"
)
# Relationships
@@ -469,21 +485,24 @@ class QuoteActivity(Base, UUIDMixin, TimestampMixin):
return f"<QuoteActivity(quote_id='{self.quote_id}', action='{self.action}')>"
class QuoteNotification(Base, UUIDMixin, TimestampMixin):
class QuoteNotification(Base, UUIDMixin):
"""
Quote notification model for tracking notifications sent.
Records all notifications (emails, SMS, alerts) sent for a quote.
Records all notifications (emails, webhooks) 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.)
notification_type: Type of notification (email, webhook)
recipient: Notification recipient (email address, webhook URL, etc.)
subject: Notification subject
content: Notification content/body
status: Delivery status (pending, sent, delivered, failed)
sent_at: Timestamp when notification was sent
body: Notification body content
status: Delivery status (pending, sent, failed)
attempts: Number of delivery attempts
last_attempt_at: Timestamp of last delivery attempt
sent_at: Timestamp when notification was successfully sent
error_message: Error message if delivery failed
created_at: Timestamp when notification was created
"""
__tablename__ = "quote_notifications"
@@ -500,23 +519,23 @@ class QuoteNotification(Base, UUIDMixin, TimestampMixin):
notification_type: Mapped[str] = mapped_column(
String(30),
nullable=False,
doc="Type of notification: email_sent, sms_sent, admin_alert, reminder_sent"
doc="Type of notification: email, webhook"
)
recipient: Mapped[str] = mapped_column(
String(255),
nullable=False,
doc="Notification recipient (email, phone, etc.)"
doc="Notification recipient (email address, webhook URL, etc.)"
)
subject: Mapped[Optional[str]] = mapped_column(
String(500),
String(255),
doc="Notification subject"
)
content: Mapped[Optional[str]] = mapped_column(
body: Mapped[Optional[str]] = mapped_column(
Text,
doc="Notification content/body"
doc="Notification body content"
)
# Status tracking
@@ -525,12 +544,25 @@ class QuoteNotification(Base, UUIDMixin, TimestampMixin):
nullable=False,
default="pending",
server_default="pending",
doc="Delivery status: pending, sent, delivered, failed"
doc="Delivery status: pending, sent, failed"
)
attempts: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
server_default="0",
doc="Number of delivery attempts"
)
last_attempt_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
doc="Timestamp of last delivery attempt"
)
sent_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
doc="Timestamp when notification was sent"
doc="Timestamp when notification was successfully sent"
)
error_message: Mapped[Optional[str]] = mapped_column(
@@ -538,6 +570,14 @@ class QuoteNotification(Base, UUIDMixin, TimestampMixin):
doc="Error message if delivery failed"
)
# Timestamp (no updated_at in DB)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
doc="Timestamp when the notification was created"
)
# Relationships
quote: Mapped["Quote"] = relationship(
"Quote",
@@ -547,11 +587,11 @@ class QuoteNotification(Base, UUIDMixin, TimestampMixin):
# Constraints and indexes
__table_args__ = (
CheckConstraint(
"notification_type IN ('email_sent', 'sms_sent', 'admin_alert', 'reminder_sent')",
"notification_type IN ('email', 'webhook')",
name="ck_quote_notifications_type"
),
CheckConstraint(
"status IN ('pending', 'sent', 'delivered', 'failed')",
"status IN ('pending', 'sent', 'failed')",
name="ck_quote_notifications_status"
),
Index("idx_quote_notifications_quote_id", "quote_id"),

View File

@@ -52,7 +52,7 @@ def list_quotes(
status_filter: Optional[str] = Query(
default=None,
alias="status",
description="Filter by status (draft, submitted, reviewing, approved, rejected, expired)"
description="Filter by status (draft, submitted, viewed, followed_up, converted, expired)"
),
search: Optional[str] = Query(
default=None,
@@ -166,9 +166,9 @@ def get_stats(
"quotes_by_status": {
"draft": 45,
"submitted": 60,
"reviewing": 15,
"approved": 25,
"rejected": 3,
"viewed": 15,
"followed_up": 10,
"converted": 25,
"expired": 2
},
"total_monthly_value": "12500.00",
@@ -229,7 +229,6 @@ def get_quote(
"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": [...],
@@ -254,19 +253,19 @@ def get_quote(
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,
product_code=item.product_code,
product_name=item.product_name,
description=item.description,
quantity=item.quantity,
setup_fee=item.setup_fee,
is_required=item.is_required,
sort_order=item.sort_order,
unit_price=item.unit_price,
setup_price=item.setup_price,
billing_frequency=item.billing_frequency,
tier=item.tier,
is_recommended=item.is_recommended,
line_total=item.line_total,
monthly_amount=item.monthly_amount,
created_at=item.created_at,
updated_at=item.updated_at
))
activities_response = []
@@ -275,8 +274,8 @@ def get_quote(
id=activity.id,
quote_id=activity.quote_id,
action=activity.action,
description=activity.description,
actor=activity.actor,
step_name=activity.step_name,
details=activity.details,
ip_address=activity.ip_address,
created_at=activity.created_at
))
@@ -290,6 +289,8 @@ def get_quote(
recipient=notification.recipient,
subject=notification.subject,
status=notification.status,
attempts=notification.attempts,
last_attempt_at=notification.last_attempt_at,
sent_at=notification.sent_at,
error_message=notification.error_message,
created_at=notification.created_at
@@ -304,11 +305,8 @@ def get_quote(
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,
@@ -346,8 +344,8 @@ def update_quote(
"""
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.
Admins can change the quote status (e.g., from submitted to viewed
or converted) and update expiration.
**Example Request:**
```json
@@ -356,8 +354,7 @@ def update_quote(
Content-Type: application/json
{
"status": "reviewing",
"admin_notes": "Assigned to sales team. Follow up scheduled for Monday."
"status": "viewed"
}
```
@@ -365,8 +362,7 @@ def update_quote(
```json
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"status": "reviewing",
"admin_notes": "Assigned to sales team. Follow up scheduled for Monday.",
"status": "viewed",
...
}
```
@@ -382,3 +378,47 @@ def update_quote(
)
return get_quote(quote_id, db, current_user)
@router.post(
"/{quote_id}/sync-syncro",
summary="Sync quote to SyncroRMM",
description="Create or update a lead in SyncroRMM from a submitted quote",
status_code=status.HTTP_200_OK,
responses={
200: {
"description": "Sync result",
"content": {
"application/json": {
"example": {
"synced": True,
"is_existing_customer": False,
"syncro_lead_id": "12345",
"error": None,
}
}
},
},
404: {"description": "Quote not found"},
},
)
async def sync_quote_to_syncro(
quote_id: UUID,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
Manually trigger a SyncroRMM sync for a quote.
Checks for an existing customer in Syncro and creates a lead with
the quote details. The quote must have a contact email to sync.
**Example Request:**
```
POST /api/admin/quotes/123e4567-e89b-12d3-a456-426614174000/sync-syncro
Authorization: Bearer <token>
```
"""
quote = quote_service.get_quote_by_id(db, quote_id)
result = await quote_service.sync_quote_to_syncro(db, quote)
return result

View File

@@ -78,8 +78,7 @@ def create_quote(
Content-Type: application/json
{
"employee_count": 25,
"notes": "Looking for complete managed services package"
"employee_count": 25
}
```
@@ -159,7 +158,6 @@ def get_quote(
"employee_count": 25,
"monthly_total": "450.00",
"setup_total": "500.00",
"annual_total": "5900.00",
"items": [
{
"id": "456e7890-e89b-12d3-a456-426614174001",
@@ -185,19 +183,19 @@ def get_quote(
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,
product_code=item.product_code,
product_name=item.product_name,
description=item.description,
quantity=item.quantity,
setup_fee=item.setup_fee,
is_required=item.is_required,
sort_order=item.sort_order,
unit_price=item.unit_price,
setup_price=item.setup_price,
billing_frequency=item.billing_frequency,
tier=item.tier,
is_recommended=item.is_recommended,
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)
@@ -210,10 +208,8 @@ def get_quote(
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,
@@ -432,7 +428,7 @@ def remove_item(
},
},
)
def submit_quote(
async def submit_quote_endpoint(
access_token: str,
submit_data: QuoteSubmit,
request: Request,
@@ -442,7 +438,7 @@ def submit_quote(
Submit a quote with contact information.
This finalizes the quote and sends it for review. Contact information
is required at this stage.
is required at this stage. An email notification is sent to the admin.
**Example Request:**
```json
@@ -453,8 +449,7 @@ def submit_quote(
"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."
"contact_phone": "555-123-4567"
}
```
@@ -472,15 +467,62 @@ def submit_quote(
}
```
"""
import logging
from api.config import get_settings
from api.services.email_service import send_email, build_quote_notification_html
logger = logging.getLogger(__name__)
ip_address = get_client_ip(request)
quote_service.submit_quote(
quote = quote_service.submit_quote(
db=db,
access_token=access_token,
submit_data=submit_data,
ip_address=ip_address
)
# Send email notification (non-blocking, don't fail the request if email fails)
try:
settings = get_settings()
items_data = [
{
"service_name": item.product_name,
"billing_frequency": item.billing_frequency,
"unit_price": str(item.unit_price),
"quantity": item.quantity,
}
for item in quote.items
]
html = build_quote_notification_html(
company_name=submit_data.company_name,
contact_name=submit_data.contact_name,
contact_email=submit_data.contact_email,
contact_phone=submit_data.contact_phone,
monthly_total=str(quote.monthly_total),
setup_total=str(quote.setup_total),
items=items_data,
notes=submit_data.notes,
)
sent = await send_email(
to_email=settings.ADMIN_NOTIFICATION_EMAIL,
subject=f"New Quote Submission: {submit_data.company_name} - ${quote.monthly_total}/mo",
body_html=html,
)
# Update notification record status
if quote.notifications:
notification = quote.notifications[-1]
notification.status = "sent" if sent else "failed"
if not sent:
notification.error_message = "Graph API send failed"
db.commit()
except Exception as e:
logger.error(f"Failed to send quote notification email: {e}")
# Don't fail the submission - email is best-effort
return get_quote(access_token, db)

View File

@@ -7,49 +7,17 @@ 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"
from api.models.quote import (
QuoteStatus,
ServiceCategory,
BillingFrequency,
NotificationType,
)
# ============================================================================
@@ -59,21 +27,19 @@ class NotificationType(str, Enum):
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"
)
category: ServiceCategory = Field(..., description="Service category")
product_code: str = Field(..., description="Product code identifier", min_length=1, max_length=50)
product_name: str = Field(..., description="Name of the product/service", min_length=1, max_length=255)
description: Optional[str] = Field(None, description="Detailed description of the product/service")
quantity: int = Field(1, description="Number of units", ge=1)
unit_price: Decimal = Field(..., description="Price per unit", ge=0)
setup_price: Decimal = Field(Decimal("0.00"), description="One-time setup price", ge=0)
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")
tier: Optional[str] = Field(None, description="Pricing tier", max_length=50)
is_recommended: bool = Field(False, description="Whether this item is recommended")
class QuoteItemCreate(QuoteItemBase):
@@ -84,15 +50,16 @@ class QuoteItemCreate(QuoteItemBase):
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)
product_code: Optional[str] = Field(None, min_length=1, max_length=50)
product_name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
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
unit_price: Optional[Decimal] = Field(None, ge=0)
setup_price: Optional[Decimal] = Field(None, ge=0)
billing_frequency: Optional[BillingFrequency] = None
tier: Optional[str] = Field(None, max_length=50)
is_recommended: Optional[bool] = None
class QuoteItemResponse(QuoteItemBase):
@@ -103,7 +70,6 @@ class QuoteItemResponse(QuoteItemBase):
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}
@@ -120,14 +86,12 @@ class QuoteBase(BaseModel):
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")
@@ -140,7 +104,6 @@ class QuoteUpdate(BaseModel):
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)")
@@ -152,7 +115,7 @@ class QuoteSubmit(BaseModel):
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")
notes: Optional[str] = Field(None, description="Additional notes from the prospect", max_length=2000)
@field_validator("company_name", "contact_name")
@classmethod
@@ -169,7 +132,6 @@ class QuoteResponse(QuoteBase):
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")
@@ -200,8 +162,8 @@ class QuoteActivityResponse(BaseModel):
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")
step_name: Optional[str] = Field(None, description="Wizard step name")
details: Optional[str] = Field(None, description="Additional details")
ip_address: Optional[str] = Field(None, description="IP address of the actor")
created_at: datetime = Field(..., description="Timestamp of the action")
@@ -221,6 +183,8 @@ class QuoteNotificationResponse(BaseModel):
recipient: str = Field(..., description="Notification recipient")
subject: Optional[str] = Field(None, description="Notification subject")
status: str = Field(..., description="Delivery status")
attempts: int = Field(0, description="Number of delivery attempts")
last_attempt_at: Optional[datetime] = Field(None, description="Last delivery attempt timestamp")
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")
@@ -236,14 +200,12 @@ 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(

View File

@@ -0,0 +1,204 @@
"""
Email service using Microsoft Graph API.
Sends email via M365 Graph API using client credentials flow.
Used for quote submission notifications and other system emails.
"""
import logging
from typing import Optional
import httpx
from api.config import get_settings
logger = logging.getLogger(__name__)
# Cache the access token to avoid requesting a new one for every email
_token_cache: dict = {"access_token": None, "expires_at": 0}
async def _get_graph_token() -> str:
"""Obtain an access token from Azure AD using client credentials."""
import time
if _token_cache["access_token"] and _token_cache["expires_at"] > time.time() + 60:
return _token_cache["access_token"]
settings = get_settings()
if not settings.GRAPH_TENANT_ID or not settings.GRAPH_CLIENT_ID:
raise RuntimeError("Microsoft Graph API credentials not configured")
token_url = f"https://login.microsoftonline.com/{settings.GRAPH_TENANT_ID}/oauth2/v2.0/token"
async with httpx.AsyncClient(timeout=15) as client:
response = await client.post(
token_url,
data={
"client_id": settings.GRAPH_CLIENT_ID,
"client_secret": settings.GRAPH_CLIENT_SECRET,
"scope": "https://graph.microsoft.com/.default",
"grant_type": "client_credentials",
},
)
response.raise_for_status()
data = response.json()
_token_cache["access_token"] = data["access_token"]
_token_cache["expires_at"] = time.time() + data.get("expires_in", 3600)
return data["access_token"]
async def send_email(
to_email: str,
subject: str,
body_html: str,
cc_email: Optional[str] = None,
) -> bool:
"""
Send an email via Microsoft Graph API.
Args:
to_email: Recipient email address
subject: Email subject
body_html: HTML body content
cc_email: Optional CC recipient
Returns:
True if sent successfully, False otherwise
"""
settings = get_settings()
if not settings.GRAPH_TENANT_ID:
logger.warning("Graph API not configured - skipping email send")
return False
try:
token = await _get_graph_token()
message: dict = {
"message": {
"subject": subject,
"body": {
"contentType": "HTML",
"content": body_html,
},
"toRecipients": [
{"emailAddress": {"address": to_email}}
],
},
"saveToSentItems": "true",
}
if cc_email:
message["message"]["ccRecipients"] = [
{"emailAddress": {"address": cc_email}}
]
sender = settings.GRAPH_SENDER_EMAIL
url = f"https://graph.microsoft.com/v1.0/users/{sender}/sendMail"
async with httpx.AsyncClient(timeout=15) as client:
response = await client.post(
url,
json=message,
headers={"Authorization": f"Bearer {token}"},
)
response.raise_for_status()
logger.info(f"Email sent to {to_email}: {subject}")
return True
except Exception as e:
logger.error(f"Failed to send email to {to_email}: {e}")
return False
def build_quote_notification_html(
company_name: str,
contact_name: str,
contact_email: str,
contact_phone: Optional[str],
monthly_total: str,
setup_total: str,
items: list,
notes: Optional[str] = None,
) -> str:
"""Build HTML email body for quote submission notification."""
items_html = ""
for item in items:
freq = item.get("billing_frequency", "monthly")
freq_label = "/mo" if freq == "monthly" else " (one-time)"
qty = item.get("quantity", 1)
price = item.get("unit_price", "0.00")
line_total = float(price) * qty
items_html += f"""
<tr>
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb;">{item.get('service_name', '')}</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; text-align: center;">{qty}</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; text-align: right;">${price}{freq_label}</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; text-align: right;">${line_total:,.2f}{freq_label}</td>
</tr>"""
notes_section = ""
if notes:
notes_section = f"""
<div style="margin-top: 20px; padding: 12px 16px; background: #f8f9fb; border-radius: 8px;">
<strong style="color: #333d49;">Notes:</strong>
<p style="margin: 4px 0 0; color: #555;">{notes}</p>
</div>"""
phone_line = f"<br>Phone: {contact_phone}" if contact_phone else ""
return f"""
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #333d49, #113559); padding: 24px 32px; border-radius: 12px 12px 0 0;">
<h1 style="color: white; margin: 0; font-size: 22px;">New Quote Submission</h1>
<p style="color: rgba(255,255,255,0.7); margin: 4px 0 0; font-size: 14px;">Arizona Computer Guru - MSP Quote Wizard</p>
</div>
<div style="padding: 24px 32px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 12px 12px;">
<div style="margin-bottom: 20px;">
<h2 style="color: #333d49; font-size: 18px; margin: 0 0 8px;">Contact Information</h2>
<p style="margin: 0; color: #555; line-height: 1.6;">
<strong>{contact_name}</strong><br>
{company_name}<br>
Email: <a href="mailto:{contact_email}">{contact_email}</a>
{phone_line}
</p>
</div>
<div style="background: linear-gradient(135deg, #333d49, #113559); border-radius: 8px; padding: 16px 20px; margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;">
<span style="color: rgba(255,255,255,0.8); font-size: 14px;">Monthly Total</span>
<span style="color: white; font-size: 24px; font-weight: bold;">${monthly_total}/mo</span>
</div>
{"<div style='background: #fff7ed; border-radius: 8px; padding: 12px 20px; margin-bottom: 20px;'><span style=\"color: #9a3412; font-size: 14px;\">One-Time Costs: <strong>$" + setup_total + "</strong></span></div>" if float(setup_total or 0) > 0 else ""}
<h3 style="color: #333d49; font-size: 16px; margin: 20px 0 8px;">Services</h3>
<table style="width: 100%; border-collapse: collapse; font-size: 14px;">
<thead>
<tr style="background: #f8f9fb;">
<th style="padding: 8px 12px; text-align: left; color: #333d49;">Service</th>
<th style="padding: 8px 12px; text-align: center; color: #333d49;">Qty</th>
<th style="padding: 8px 12px; text-align: right; color: #333d49;">Unit Price</th>
<th style="padding: 8px 12px; text-align: right; color: #333d49;">Total</th>
</tr>
</thead>
<tbody>
{items_html}
</tbody>
</table>
{notes_section}
<div style="margin-top: 24px; padding-top: 16px; border-top: 2px solid #fe7400; text-align: center;">
<p style="color: #999; font-size: 12px; margin: 0;">
Submitted via <a href="https://azcomputerguru.com/quote" style="color: #fe7400;">azcomputerguru.com/quote</a>
</p>
</div>
</div>
</div>
"""

View File

@@ -5,8 +5,8 @@ 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 os
import secrets
from datetime import datetime, timedelta
from decimal import Decimal
@@ -50,15 +50,15 @@ def generate_access_token() -> str:
return secrets.token_urlsafe(32)
def calculate_totals(items: list[QuoteItem]) -> tuple[Decimal, Decimal, Decimal]:
def calculate_totals(items: list[QuoteItem]) -> tuple[Decimal, Decimal]:
"""
Calculate monthly, setup, and annual totals from quote items.
Calculate monthly and setup totals from quote items.
Args:
items: List of QuoteItem objects
Returns:
tuple: (monthly_total, setup_total, annual_total)
tuple: (monthly_total, setup_total)
"""
monthly_total = Decimal("0.00")
setup_total = Decimal("0.00")
@@ -70,29 +70,23 @@ def calculate_totals(items: list[QuoteItem]) -> tuple[Decimal, Decimal, Decimal]
# 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:
elif item.billing_frequency == BillingFrequency.YEARLY.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
# Setup prices are always one-time
setup_total += item.setup_price
# Annual total is monthly * 12 + setup
annual_total = (monthly_total * Decimal("12")) + setup_total
return monthly_total, setup_total, annual_total
return monthly_total, setup_total
def log_activity(
db: Session,
quote_id: str,
action: str,
description: Optional[str] = None,
actor: Optional[str] = None,
details: Optional[str] = None,
step_name: Optional[str] = None,
ip_address: Optional[str] = None,
metadata: Optional[dict] = None
) -> QuoteActivity:
"""
Log an activity for a quote.
@@ -101,21 +95,23 @@ def log_activity(
db: Database session
quote_id: UUID of the quote
action: Action being performed
description: Detailed description
actor: Who performed the action
details: Additional details about the action (stored as JSON)
step_name: Wizard step name associated with the action
ip_address: IP address of the actor
metadata: Additional metadata as dict
Returns:
QuoteActivity: The created activity record
"""
import json
# DB column has CHECK (json_valid(details)), so wrap in JSON
details_json = json.dumps({"message": details}) if details else None
activity = QuoteActivity(
quote_id=quote_id,
action=action,
description=description,
actor=actor,
step_name=step_name,
details=details_json,
ip_address=ip_address,
metadata=json.dumps(metadata) if metadata else None
)
db.add(activity)
@@ -155,7 +151,6 @@ def create_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
@@ -170,34 +165,33 @@ def create_quote(
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,
product_code=item_data.product_code,
product_name=item_data.product_name,
description=item_data.description,
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
unit_price=item_data.unit_price,
setup_price=item_data.setup_price,
billing_frequency=item_data.billing_frequency.value,
tier=item_data.tier,
is_recommended=item_data.is_recommended,
)
db.add(item)
db.flush()
# Calculate and update totals
monthly, setup, annual = calculate_totals(quote.items)
monthly, setup = 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",
details=f"Quote draft created, employee_count={quote_data.employee_count}",
ip_address=ip_address,
metadata={"employee_count": quote_data.employee_count}
)
db.commit()
@@ -344,15 +338,16 @@ def update_quote(
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,
product_code=item_data.product_code,
product_name=item_data.product_name,
description=item_data.description,
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
unit_price=item_data.unit_price,
setup_price=item_data.setup_price,
billing_frequency=item_data.billing_frequency.value,
tier=item_data.tier,
is_recommended=item_data.is_recommended,
)
db.add(item)
@@ -362,10 +357,9 @@ def update_quote(
# Recalculate totals
db.refresh(quote)
monthly, setup, annual = calculate_totals(quote.items)
monthly, setup = calculate_totals(quote.items)
quote.monthly_total = monthly
quote.setup_total = setup
quote.annual_total = annual
# Log activity
if changes:
@@ -373,7 +367,7 @@ def update_quote(
db=db,
quote_id=quote.id,
action="updated",
description=f"Quote updated: {', '.join(changes)}",
details=f"Quote updated: {', '.join(changes)}",
ip_address=ip_address
)
@@ -438,8 +432,6 @@ def submit_quote(
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
@@ -453,24 +445,17 @@ def submit_quote(
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,
details=f"Quote submitted by {submit_data.contact_name} ({submit_data.contact_email}), company={submit_data.company_name}, monthly=${quote.monthly_total}, setup=${quote.setup_total}",
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
notification_type="email",
recipient=os.environ.get("ADMIN_NOTIFICATION_EMAIL", "mike@azcomputerguru.com"),
subject=f"New Quote Submission: {submit_data.company_name}",
content=f"Quote submitted by {submit_data.contact_name}. Monthly: ${quote.monthly_total}",
body=f"Quote submitted by {submit_data.contact_name}. Monthly: ${quote.monthly_total}",
status="pending"
)
db.add(notification)
@@ -478,6 +463,10 @@ def submit_quote(
db.commit()
db.refresh(quote)
# Syncro sync is handled via the admin endpoint POST /{quote_id}/sync-syncro
# or can be triggered manually after submission. Not run inline to avoid
# async/sync mixing and DB session lifecycle issues.
return quote
except HTTPException:
@@ -556,11 +545,7 @@ async def sync_quote_to_syncro(db: Session, quote: Quote) -> dict:
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
}
details=f"Existing Syncro customer found: {customer_check.customer_name} (ID: {customer_check.customer_id}, match: {customer_check.match_type})",
)
# Create lead in Syncro
@@ -577,11 +562,7 @@ async def sync_quote_to_syncro(db: Session, quote: Quote) -> dict:
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
}
details=f"Lead created in Syncro: {lead_result.lead_id}, is_existing_customer={customer_check.exists}",
)
else:
result["error"] = lead_result.error
@@ -594,8 +575,7 @@ async def sync_quote_to_syncro(db: Session, quote: Quote) -> dict:
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}
details=f"Failed to sync to Syncro: {lead_result.error}",
)
# Commit the updates to quote
@@ -616,8 +596,7 @@ async def sync_quote_to_syncro(db: Session, quote: Quote) -> dict:
db=db,
quote_id=quote.id,
action="syncro_sync_error",
description=f"Syncro sync error: {error_msg}",
metadata={"error": error_msg}
details=f"Syncro sync error: {error_msg}",
)
db.commit()
except Exception:
@@ -682,7 +661,7 @@ def update_quote_status(
admin_user: str
) -> Quote:
"""
Update quote status and admin notes (admin).
Update quote status and expiration (admin).
Args:
db: Database session
@@ -703,10 +682,6 @@ def update_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}")
@@ -717,8 +692,7 @@ def update_quote_status(
db=db,
quote_id=quote.id,
action="admin_update",
description=f"Admin update: {', '.join(changes)}",
actor=admin_user
details=f"Admin update by {admin_user}: {', '.join(changes)}",
)
db.commit()
@@ -758,8 +732,9 @@ def get_quote_stats(db: Session) -> QuoteStatsResponse:
# Total values for submitted quotes
submitted_statuses = [
QuoteStatus.SUBMITTED.value,
QuoteStatus.REVIEWING.value,
QuoteStatus.APPROVED.value
QuoteStatus.VIEWED.value,
QuoteStatus.FOLLOWED_UP.value,
QuoteStatus.CONVERTED.value,
]
value_query = (
db.query(
@@ -849,41 +824,34 @@ def add_item_to_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,
product_code=item_data.product_code,
product_name=item_data.product_name,
description=item_data.description,
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
unit_price=item_data.unit_price,
setup_price=item_data.setup_price,
billing_frequency=item_data.billing_frequency.value,
tier=item_data.tier,
is_recommended=item_data.is_recommended,
)
db.add(item)
db.flush()
# Recalculate totals
db.refresh(quote)
monthly, setup, annual = calculate_totals(quote.items)
monthly, setup = 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}",
details=f"Added item: {item_data.product_name}",
ip_address=ip_address
)
@@ -942,30 +910,23 @@ def remove_item_from_quote(
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
item_name = item.product_name
db.delete(item)
db.flush()
# Recalculate totals
db.refresh(quote)
monthly, setup, annual = calculate_totals(quote.items)
monthly, setup = 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}",
details=f"Removed item: {item_name}",
ip_address=ip_address
)

View File

@@ -8,6 +8,7 @@ API Documentation: https://api-docs.syncromsp.com/
"""
import logging
import os
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
@@ -20,9 +21,10 @@ if TYPE_CHECKING:
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"
SYNCRO_API_BASE_URL = os.environ.get(
"SYNCRO_API_BASE_URL", "https://computerguru.syncromsp.com/api/v1"
)
SYNCRO_API_KEY = os.environ.get("SYNCRO_API_KEY", "")
# HTTP client configuration
SYNCRO_TIMEOUT_SECONDS = 30.0

Binary file not shown.

View File

@@ -0,0 +1,29 @@
Hi Shelly,
Lesley Roth's account has been disabled. Here's a summary of what was done:
**Account Access**
- Sign-in has been blocked -- Lesley can no longer log in to any Microsoft 365 services
- All active sessions have been revoked (any currently logged-in session was terminated immediately)
- Password has been reset
- The account itself is preserved and mailbox is intact
**Device Email Wipe**
- An account-only wipe has been sent to both of Lesley's devices:
- iPhone 16 Pro (active) -- wipe is pending and will complete the next time the phone connects
- iPhone 14 Pro (older device, not actively syncing)
- This removes only the BG Builders email account and company data from the devices. Personal data on the phones is not affected.
**Email Activity Review**
- We reviewed all sent, received, and deleted email for the last 72 hours
- Nothing unusual or concerning was found
- Litigation hold is enabled on the mailbox, so no emails can be permanently deleted
**Mailbox Access**
- You and Barry both have full access to Lesley's mailbox. It should appear automatically in your Outlook.
- You can also send email on behalf of Lesley's address if needed.
Let us know if you need anything else or if you'd like us to proceed with converting the mailbox to shared and removing the license once you've had a chance to review the contents.
Thanks,
Mike

View File

@@ -0,0 +1,74 @@
# BG Builders - Session Log 2026-03-09
## Session Summary
Lesley Roth (lesley@bgbuildersllc.com) employee disable and device wipe. Account disabled (sign-in blocked, sessions revoked), email data wipe initiated on both mobile devices, and 72-hour mail activity report generated. Account preserved (not deleted/converted to shared) per client request.
## Actions Completed
### 1. Account Disable
- **Sign-in blocked** - AccountEnabled set to False (was already False from previous termination on 2026-02-27)
- **All sessions revoked** - Confirmed via Revoke-MgUserSignInSession
- **Password reset** - Script failed with 403 (sysadmin lacks privilege), manually reset via M365 Admin Center to: `bgb-pass-reset-2026!!`
### 2. Device Email Wipe
- **iPhone 16 Pro** (iOS 26.3.1) - AccountOnlyDeviceWipePending. Active device, last synced 2026-03-09 16:23:30. Should complete on next sync.
- **iPhone 14 Pro** (iOS 18.5) - AccountOnlyDeviceWipePending. Stale device, last synced 2025-06-27. May never acknowledge.
- No Intune-managed devices found (BGB has no Intune/Business Premium)
- Wipe type: AccountOnly (removes M365 email account only, preserves personal data)
### 3. 72-Hour Mail Activity Report
- Report generated covering 2026-03-06 09:25 to 2026-03-09 09:25
- **Nothing of consequence found** - no suspicious sent/deleted mail activity
- Report saved to: `D:\ClaudeTools\scripts\bgb-lesley-mail-report-20260309.txt`
- Checked: sent messages, received messages, deleted items, inbox rules, forwarding config
### 4. Pre-existing Security Measures
- **Litigation hold** already enabled (from previous re-enable script on 2026-02-27)
- **Barry** (barry@bgbuildersllc.com) has FullAccess + SendAs on mailbox (from original termination)
- **Shelly** (Shelly@bgbuildersllc.com) has FullAccess + SendAs (from re-enable script)
## Credentials Used
### Microsoft 365 Tenant - BG Builders LLC
- **Tenant:** bgbuildersllc.com
- **Tenant ID:** ededa4fb-f6eb-4398-851d-5eb3e11fab27
- **CIPP Name:** sonorangreenllc.com
- **Admin User:** sysadmin@bgbuildersllc.com
- **Password:** Window123!@#-bgb
### Target User
- **User:** Lesley Roth
- **UPN:** lesley@bgbuildersllc.com
## Scripts Created/Modified
### New Scripts
- `scripts/bgb-lesley-disable-wipe.ps1` - Disable account + device email wipe
- `scripts/bgb-lesley-mail-report.ps1` - 72-hour mail activity report (sent/received/deleted)
- `scripts/bgb-lesley-verify-wipe.ps1` - Verify device wipe status
### Technical Notes
- `Get-MessageTrace` deprecated Sep 2025 - use `Get-MessageTraceV2` (no `-PageSize` parameter)
- `Search-MailboxAuditLog` deprecated Jan 2026 - use `Search-UnifiedAuditLog`
- Exchange Online `-Device` auth switch only works in PowerShell 7 (pwsh), not Windows PowerShell 5.1
- WAM broker auth requires a visible PowerShell window (can't run from bash/non-interactive shell)
## Current Account State
| Property | Value |
|----------|-------|
| AccountEnabled | False |
| Mailbox Type | UserMailbox |
| Litigation Hold | True |
| Licenses | Still assigned |
| Barry Access | FullAccess + SendAs |
| Shelly Access | FullAccess + SendAs |
| iPhone 16 Pro | AccountOnlyDeviceWipePending |
| iPhone 14 Pro | AccountOnlyDeviceWipePending |
## Pending/Follow-up
- Password reset needs Global Admin or check sysadmin role assignments
- iPhone 16 Pro wipe should complete soon (active device)
- iPhone 14 Pro wipe may never complete (stale since June 2025)
- Account NOT converted to shared, licenses NOT removed (per request to keep account)
- OneDrive access not addressed this session

File diff suppressed because it is too large Load Diff

View File

@@ -1117,6 +1117,51 @@ users = requests.get("https://graph.microsoft.com/v1.0/users", headers=headers)
---
### ACG-MSP-Access (Google Workspace - Multi-Tenant)
- **Service:** Google Workspace API access for investigations and remediation
- **Google Cloud Project:** acg-msp-access
- **Service Account Email:** acg-msp-access@acg-msp-access.iam.gserviceaccount.com
- **Client ID:** 102231607889615995452
- **Key File:** `temp/acg-msp-access-8f72339997e5.json`
- **Private Key ID:** 8f72339997e510cb3bf3c01aa658a09a4bce97ba
- **Created:** 2026-03-10
- **Purpose:** Domain-wide delegation for Google Workspace client investigations
- **Scopes:**
- `admin.directory.user` (user management)
- `admin.directory.user.security` (password reset, 2FA, revoke sessions)
- `admin.reports.audit.readonly` (audit/sign-in logs)
- `gmail.readonly` (mailbox investigation)
- `gmail.settings.basic` (forwarding rules)
- `drive.readonly` (drive audit)
- `admin.directory.domain.readonly` (domain info)
- **Onboarded Tenants:**
- lonestarelectrical.net (sysadmin@lonestarelectrical.net) - added 2026-03-10
#### Usage (Python)
```python
from google.oauth2 import service_account
from googleapiclient.discovery import build
SCOPES = [
'https://www.googleapis.com/auth/admin.directory.user',
'https://www.googleapis.com/auth/admin.directory.user.security',
'https://www.googleapis.com/auth/admin.reports.audit.readonly',
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.settings.basic',
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/admin.directory.domain.readonly',
]
creds = service_account.Credentials.from_service_account_file(
'temp/acg-msp-access-8f72339997e5.json', scopes=SCOPES
)
# Impersonate the admin user in the target tenant
delegated = creds.with_subject('sysadmin@lonestarelectrical.net')
service = build('admin', 'reports_v1', credentials=delegated)
```
---
## Tailscale Network
| Tailscale IP | Hostname | Owner | OS | Notes |

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,22 +21,29 @@ export function ExpandableInfo({
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
return (
<div className={cn('border border-gray-200 rounded-lg overflow-hidden', className)}>
<div className={cn('border border-gray-200/80 rounded-xl overflow-hidden bg-white shadow-card', className)}>
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 transition-colors"
className="w-full flex items-center justify-between p-4 text-left hover:bg-[#f8f9fb] transition-colors"
aria-expanded={isExpanded}
>
<div className="flex items-center gap-3">
{icon || <HelpCircle className="w-5 h-5 text-[#fe7400]" />}
<span className="font-medium text-[#333d49]">{title}</span>
{icon || (
<div className="w-8 h-8 rounded-lg bg-[#fe7400]/8 flex items-center justify-center flex-shrink-0">
<HelpCircle className="w-4 h-4 text-[#fe7400]" />
</div>
)}
<span className="font-semibold text-[#333d49] text-sm"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{title}
</span>
</div>
<motion.div
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<ChevronDown className="w-5 h-5 text-gray-400" />
<ChevronDown className="w-4 h-4 text-gray-400" />
</motion.div>
</button>
@@ -48,8 +55,8 @@ export function ExpandableInfo({
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 className="px-4 pb-4 pt-0 text-sm text-gray-500 border-t border-gray-100">
<div className="pt-4 leading-relaxed">{children}</div>
</div>
</motion.div>
)}

View File

@@ -16,40 +16,43 @@ export function PricingCard({ tier, isSelected, deviceCount, onSelect }: Pricing
return (
<motion.div
whileHover={{ y: -4 }}
whileHover={{ y: -3 }}
transition={{ duration: 0.2 }}
>
<Card
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
variant={isSelected ? 'highlighted' : 'default'}
padding="none"
className={cn(
'relative overflow-hidden',
tier.recommended && !isSelected && 'ring-2 ring-[#333d49]'
tier.recommended && !isSelected && 'ring-2 ring-[#fe7400]/30'
)}
>
{/* 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">
<div className="bg-gradient-accent text-white text-[11px] font-bold px-3 py-1.5 text-center uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Recommended
</div>
</div>
)}
<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>
<h3 className="text-xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{tier.name}
</h3>
<p className="text-sm text-gray-400 mt-1">{tier.description}</p>
</div>
{/* Pricing */}
<div className="mb-6">
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold text-[#333d49]">
<span className="text-3xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(monthlyEstimate)}
</span>
<span className="text-gray-500">/month</span>
<span className="text-gray-400">/month</span>
</div>
<p className="text-xs text-gray-400 mt-1">
{formatCurrency(tier.basePrice)} base + {formatCurrency(tier.perDevicePrice)}/device
@@ -57,10 +60,12 @@ export function PricingCard({ tier, isSelected, deviceCount, onSelect }: Pricing
</div>
{/* Features */}
<ul className="space-y-2 mb-6">
<ul className="space-y-2.5 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" />
<li key={index} className="flex items-start gap-2.5 text-sm">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span className="text-gray-600">{feature}</span>
</li>
))}

View File

@@ -35,43 +35,57 @@ export function TierComparison({ tiers, selectedTier, onSelectTier }: TierCompar
const renderCell = (value: boolean | string) => {
if (typeof value === 'boolean') {
return value ? (
<Check className="w-5 h-5 text-green-500 mx-auto" />
<div className="w-5 h-5 rounded-full bg-[#ecfdf5] flex items-center justify-center mx-auto">
<Check className="w-3 h-3 text-[#059669]" strokeWidth={3} />
</div>
) : (
<X className="w-5 h-5 text-gray-300 mx-auto" />
<X className="w-4 h-4 text-gray-200 mx-auto" />
);
}
return <span className="text-sm text-[#333d49]">{value}</span>;
return (
<span className="text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{value}
</span>
);
};
return (
<div className="overflow-x-auto">
<div className="overflow-x-auto rounded-xl border border-gray-200/80 shadow-card">
<table className="w-full border-collapse">
<thead>
<tr>
<th className="text-left p-4 border-b border-gray-200 bg-gray-50">
<span className="font-semibold text-[#333d49]">Feature</span>
<th className="text-left p-4 border-b border-gray-100 bg-[#f8f9fb]">
<span className="font-bold text-[#333d49] text-sm"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Feature
</span>
</th>
{tiers.map((tier) => (
<th
key={tier.id}
className={cn(
'p-4 border-b border-gray-200 text-center cursor-pointer transition-colors',
'p-4 border-b border-gray-100 text-center cursor-pointer transition-all duration-200',
selectedTier === tier.id
? 'bg-[#fe7400]/10'
: 'bg-gray-50 hover:bg-gray-100'
? 'bg-[#fe7400]/5'
: 'bg-[#f8f9fb] hover:bg-gray-100'
)}
onClick={() => onSelectTier(tier.id)}
>
<span
className={cn(
'font-semibold',
'font-bold text-sm',
selectedTier === tier.id ? 'text-[#fe7400]' : 'text-[#333d49]'
)}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
{tier.name}
</span>
{tier.recommended && (
<span className="block text-xs text-[#fe7400] mt-1">Recommended</span>
<span className="block text-[10px] text-[#fe7400] mt-0.5 font-bold uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Recommended
</span>
)}
</th>
))}
@@ -79,30 +93,30 @@ export function TierComparison({ tiers, selectedTier, onSelectTier }: TierCompar
</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">
<tr key={feature.name} className={index % 2 === 0 ? 'bg-white' : 'bg-[#f8f9fb]/50'}>
<td className="p-4 border-b border-gray-50 text-sm text-gray-500">
{feature.name}
</td>
<td
className={cn(
'p-4 border-b border-gray-100 text-center',
selectedTier === 'essential' && 'bg-[#fe7400]/5'
'p-4 border-b border-gray-50 text-center',
selectedTier === 'essential' && 'bg-[#fe7400]/3'
)}
>
{renderCell(feature.essential)}
</td>
<td
className={cn(
'p-4 border-b border-gray-100 text-center',
selectedTier === 'professional' && 'bg-[#fe7400]/5'
'p-4 border-b border-gray-50 text-center',
selectedTier === 'professional' && 'bg-[#fe7400]/3'
)}
>
{renderCell(feature.professional)}
</td>
<td
className={cn(
'p-4 border-b border-gray-100 text-center',
selectedTier === 'enterprise' && 'bg-[#fe7400]/5'
'p-4 border-b border-gray-50 text-center',
selectedTier === 'enterprise' && 'bg-[#fe7400]/3'
)}
>
{renderCell(feature.enterprise)}

View File

@@ -22,32 +22,33 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
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';
'inline-flex items-center justify-center font-semibold rounded-xl transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-40 disabled:cursor-not-allowed';
const variants = {
primary:
'bg-[#fe7400] text-white hover:bg-[#e56800] focus-visible:ring-[#fe7400] shadow-sm hover:shadow-md',
'bg-gradient-accent text-white hover:brightness-110 focus-visible:ring-[#fe7400] shadow-sm hover:shadow-md active:brightness-95',
secondary:
'bg-[#333d49] text-white hover:bg-[#252d36] focus-visible:ring-[#333d49] shadow-sm hover:shadow-md',
outline:
'border-2 border-[#333d49] text-[#333d49] hover:bg-[#333d49] hover:text-white focus-visible:ring-[#333d49]',
'border-2 border-gray-200 text-[#333d49] hover:border-[#333d49] hover:bg-gray-50 focus-visible:ring-[#333d49]',
ghost:
'text-[#333d49] hover:bg-gray-100 focus-visible:ring-[#333d49]',
'text-[#333d49] hover:bg-gray-100/80 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',
sm: 'px-4 py-2 text-sm',
md: 'px-6 py-2.5 text-sm',
lg: 'px-8 py-3.5 text-base',
};
return (
<motion.button
ref={ref}
whileHover={{ scale: disabled || isLoading ? 1 : 1.02 }}
whileTap={{ scale: disabled || isLoading ? 1 : 0.98 }}
whileHover={{ scale: disabled || isLoading ? 1 : 1.015 }}
whileTap={{ scale: disabled || isLoading ? 1 : 0.985 }}
className={cn(baseStyles, variants[variant], sizes[size], className)}
disabled={disabled || isLoading}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
{...props}
>
{isLoading ? (
@@ -72,7 +73,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
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...
Processing...
</>
) : (
children

View File

@@ -23,13 +23,20 @@ const Card = forwardRef<HTMLDivElement, CardProps>(
},
ref
) => {
const baseStyles = 'rounded-xl transition-all duration-200';
const baseStyles = 'rounded-2xl transition-all duration-300';
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',
default: 'bg-white border border-gray-200/80',
elevated: 'bg-white border border-gray-200/60',
outlined: 'bg-transparent border-2 border-[#333d49]/20',
highlighted: 'bg-white border-2 border-[#fe7400] ring-1 ring-[#fe7400]/10',
};
const shadowStyles: Record<string, React.CSSProperties> = {
default: { boxShadow: '0 1px 2px rgba(17,53,89,0.04), 0 4px 12px rgba(17,53,89,0.06)' },
elevated: { boxShadow: '0 4px 6px rgba(17,53,89,0.04), 0 12px 32px rgba(17,53,89,0.08)' },
outlined: {},
highlighted: { boxShadow: '0 4px 6px rgba(17,53,89,0.04), 0 12px 32px rgba(17,53,89,0.08)' },
};
const paddings = {
@@ -40,15 +47,15 @@ const Card = forwardRef<HTMLDivElement, CardProps>(
};
const hoverStyles = hoverable
? 'cursor-pointer hover:shadow-xl hover:-translate-y-1'
? 'cursor-pointer hover:-translate-y-0.5'
: '';
if (hoverable) {
return (
<motion.div
ref={ref}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
whileHover={{ scale: 1.01, boxShadow: '0 2px 4px rgba(17,53,89,0.06), 0 8px 24px rgba(17,53,89,0.1)' }}
whileTap={{ scale: 0.995 }}
className={cn(
baseStyles,
variants[variant],
@@ -56,6 +63,7 @@ const Card = forwardRef<HTMLDivElement, CardProps>(
hoverStyles,
className
)}
style={shadowStyles[variant]}
onClick={onClick}
>
{children}
@@ -72,6 +80,7 @@ const Card = forwardRef<HTMLDivElement, CardProps>(
paddings[padding],
className
)}
style={shadowStyles[variant]}
onClick={onClick}
>
{children}
@@ -82,7 +91,6 @@ const Card = forwardRef<HTMLDivElement, CardProps>(
Card.displayName = 'Card';
// Card subcomponents
const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
@@ -99,6 +107,7 @@ const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingEleme
<h3
ref={ref}
className={cn('text-xl font-semibold text-[#333d49]', className)}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
{...props}
/>
)
@@ -109,7 +118,7 @@ const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLPara
({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-gray-500 mt-1', className)}
className={cn('text-sm text-gray-400 mt-1', className)}
{...props}
/>
)

View File

@@ -16,7 +16,8 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-[#333d49] mb-1.5"
className="block text-sm font-medium text-[#333d49] mb-2"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
{label}
</label>
@@ -26,13 +27,13 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
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',
'w-full px-4 py-3 rounded-xl border transition-all duration-200',
'text-[#333d49] placeholder-gray-400 bg-white',
'focus:outline-none focus:ring-2 focus:ring-offset-0',
error
? 'border-red-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',
? 'border-red-400 focus:border-red-400 focus:ring-red-100'
: 'border-gray-200 hover:border-gray-300 focus:border-[#fe7400] focus:ring-[#fe7400]/15',
'disabled:bg-gray-50 disabled:text-gray-400 disabled:cursor-not-allowed',
className
)}
aria-invalid={error ? 'true' : 'false'}
@@ -42,12 +43,15 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
{...props}
/>
{error && (
<p id={`${inputId}-error`} className="mt-1.5 text-sm text-red-500">
<p id={`${inputId}-error`} className="mt-2 text-sm text-red-500 flex items-center gap-1.5">
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
{error}
</p>
)}
{helperText && !error && (
<p id={`${inputId}-helper`} className="mt-1.5 text-sm text-gray-500">
<p id={`${inputId}-helper`} className="mt-2 text-sm text-gray-400">
{helperText}
</p>
)}

View File

@@ -19,26 +19,26 @@ export function ProgressBar({
const clampedProgress = Math.min(100, Math.max(0, progress));
const sizes = {
sm: 'h-1.5',
md: 'h-2.5',
lg: 'h-4',
sm: 'h-1',
md: 'h-1.5',
lg: 'h-2.5',
};
const variants = {
default: 'bg-[#333d49]',
accent: 'bg-[#fe7400]',
accent: 'bg-gradient-accent',
};
return (
<div className={cn('w-full', className)}>
{showLabel && (
<div className="flex justify-between items-center mb-1.5">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-[#333d49]">Progress</span>
<span className="text-sm font-medium text-[#333d49]">{clampedProgress}%</span>
<span className="text-sm font-semibold text-[#fe7400]">{clampedProgress}%</span>
</div>
)}
<div
className={cn('w-full bg-gray-200 rounded-full overflow-hidden', sizes[size])}
className={cn('w-full bg-gray-100 rounded-full overflow-hidden', sizes[size])}
role="progressbar"
aria-valuenow={clampedProgress}
aria-valuemin={0}
@@ -48,7 +48,7 @@ export function ProgressBar({
className={cn('h-full rounded-full', variants[variant])}
initial={{ width: 0 }}
animate={{ width: `${clampedProgress}%` }}
transition={{ duration: 0.5, ease: 'easeOut' }}
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
/>
</div>
</div>

View File

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

View File

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

View File

@@ -14,34 +14,33 @@ export function WizardProgress({ steps, currentStep, onStepClick }: WizardProgre
return (
<nav aria-label="Progress" className="w-full">
<ol className="flex items-center justify-between">
{/* Desktop stepper */}
<ol className="flex items-start justify-between">
{steps.map((step, index) => {
const isCompleted = step.isComplete;
const isCurrent = index === currentStep;
const isClickable = isCompleted || index <= currentStep;
const isLast = index === steps.length - 1;
return (
<li
key={step.id}
className={cn(
'relative flex-1',
index !== steps.length - 1 && (isCompactMode ? 'pr-4 sm:pr-8' : 'pr-8 sm:pr-20')
'relative flex flex-col items-center',
!isLast && 'flex-1'
)}
>
{/* Connector line */}
{index !== steps.length - 1 && (
{!isLast && (
<div
className={cn(
'absolute top-4 right-0 h-0.5 bg-gray-200',
isCompactMode ? 'left-6' : 'left-8'
)}
className="absolute top-[18px] left-[calc(50%+18px)] right-[calc(-50%+18px)] h-[2px] bg-gray-200"
aria-hidden="true"
>
<motion.div
className="h-full bg-[#fe7400]"
initial={{ width: '0%' }}
animate={{ width: isCompleted ? '100%' : '0%' }}
transition={{ duration: 0.3 }}
className="h-full bg-[#fe7400] origin-left"
initial={{ scaleX: 0 }}
animate={{ scaleX: isCompleted ? 1 : 0 }}
transition={{ duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] }}
/>
</div>
)}
@@ -51,7 +50,7 @@ export function WizardProgress({ steps, currentStep, onStepClick }: WizardProgre
onClick={() => isClickable && onStepClick?.(index)}
disabled={!isClickable}
className={cn(
'group flex flex-col items-center',
'group relative z-10 flex flex-col items-center gap-2',
isClickable ? 'cursor-pointer' : 'cursor-not-allowed'
)}
aria-current={isCurrent ? 'step' : undefined}
@@ -59,42 +58,54 @@ export function WizardProgress({ steps, currentStep, onStepClick }: WizardProgre
{/* 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',
'relative flex items-center justify-center rounded-full transition-all duration-300',
isCompactMode ? 'h-7 w-7' : 'h-9 w-9',
isCompleted
? 'bg-[#fe7400] border-[#fe7400]'
? 'bg-[#fe7400] shadow-[0_0_0_4px_rgba(254,116,0,0.1)]'
: isCurrent
? 'border-[#fe7400] bg-white'
: 'border-gray-300 bg-white'
? 'bg-white border-[2.5px] border-[#fe7400] shadow-[0_0_0_4px_rgba(254,116,0,0.1)]'
: 'bg-white border-2 border-gray-200'
)}
whileHover={isClickable ? { scale: 1.1 } : {}}
whileHover={isClickable ? { scale: 1.08 } : {}}
whileTap={isClickable ? { scale: 0.95 } : {}}
layout
>
{isCompleted ? (
<Check className={cn(isCompactMode ? 'h-3 w-3' : 'h-4 w-4', 'text-white')} />
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
>
<Check className={cn(isCompactMode ? 'h-3.5 w-3.5' : 'h-4 w-4', 'text-white')} strokeWidth={3} />
</motion.div>
) : (
<span
className={cn(
'font-semibold',
'font-bold',
isCompactMode ? 'text-xs' : 'text-sm',
isCurrent ? 'text-[#fe7400]' : 'text-gray-500'
isCurrent ? 'text-[#fe7400]' : 'text-gray-400'
)}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
{index + 1}
</span>
)}
</motion.div>
{/* Step label - hidden on mobile for compact mode */}
<div className={cn('mt-2 text-center', isCompactMode && 'hidden sm:block')}>
{/* Step label */}
<div className={cn(
'text-center max-w-[80px]',
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'
'font-medium whitespace-nowrap leading-tight block',
isCompactMode ? 'text-[10px]' : 'text-[11px]',
isCurrent ? 'text-[#fe7400]' : isCompleted ? 'text-[#333d49]' : 'text-gray-400'
)}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
{isCompactMode ? step.title.split(' ')[0] : step.title}
{step.title}
</span>
</div>
</button>
@@ -106,10 +117,10 @@ export function WizardProgress({ steps, currentStep, onStepClick }: WizardProgre
{/* 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 className="text-xs text-gray-400">
Step {currentStep + 1} of {steps.length}
</span>
<span className="text-sm font-medium text-[#333d49] ml-1">
<span className="text-sm font-semibold text-[#333d49] ml-2">
{steps[currentStep]?.title}
</span>
</div>

View File

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

View File

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

View File

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

View File

@@ -60,27 +60,50 @@ export function Step4VoIP({
transition={{ duration: 0.3 }}
className="space-y-6"
>
{/* Service Explainer */}
<div className="space-y-2">
<p className="text-sm sm:text-base text-gray-500 leading-relaxed">
<strong className="text-[#333d49]">VoIP (Voice over IP)</strong> replaces traditional
phone lines with a modern cloud-based phone system. Your calls travel over the internet,
which means lower costs, more features, and the flexibility to take calls from your
desk phone, computer, or mobile app &mdash; anywhere with an internet connection.
</p>
<p className="text-sm text-gray-400 leading-relaxed">
Every plan includes unlimited local and long-distance calling, auto-attendant (press 1
for sales, etc.), voicemail-to-email, call forwarding, and the ability to keep your
existing phone numbers. Higher tiers add call recording, analytics, CRM integrations,
and video conferencing. We can also provide desk phones and headsets as a purchase or
monthly rental.
</p>
</div>
{/* VoIP Toggle */}
<div className="bg-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">
<div className="bg-[#f8f9fb] rounded-xl p-4 sm:p-5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-xl bg-[#fe7400]/8 flex items-center justify-center flex-shrink-0">
<Phone className="w-4 h-4 sm:w-5 sm:h-5 text-[#fe7400]" />
</div>
<div className="min-w-0">
<h3 className="font-bold text-[#333d49] text-sm sm:text-base"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Do you need business phones?
</h3>
<p className="text-xs sm:text-sm text-gray-400">
Modern VoIP phone system with advanced features
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<label className="relative inline-flex items-center cursor-pointer flex-shrink-0">
<input
type="checkbox"
checked={voipSelection.enabled}
onChange={(e) => onSetVoIPEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-14 h-7 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#fe7400]/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">
<div className="w-14 h-7 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#fe7400]/10 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-6 after:w-6 after:transition-all peer-checked:bg-[#fe7400]"></div>
<span className="ml-3 text-sm font-semibold text-gray-500"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{voipSelection.enabled ? 'Yes' : 'No'}
</span>
</label>
@@ -97,19 +120,22 @@ export function Step4VoIP({
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>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-4 border border-gray-200/80 rounded-xl bg-white shadow-card">
<label className="text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Number of phone users:
</label>
<Input
type="number"
min={1}
value={voipSelection.userCount}
onChange={(e) => onSetVoIPUserCount(parseInt(e.target.value, 10) || 1)}
className="w-24"
className="w-full sm:w-24"
/>
</div>
{/* Tier Selection */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
{voipTiers.map((tier, index) => {
const isSelected = voipSelection.tierId === tier.id;
const monthlyPrice = tier.pricePerUser * voipSelection.userCount;
@@ -120,43 +146,48 @@ export function Step4VoIP({
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ y: -4 }}
whileHover={{ y: -3 }}
>
<Card
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
variant={isSelected ? 'highlighted' : 'default'}
padding="none"
className={`relative overflow-hidden cursor-pointer h-full ${
tier.recommended && !isSelected ? 'ring-2 ring-[#333d49]' : ''
tier.recommended && !isSelected ? 'ring-2 ring-[#fe7400]/30' : ''
}`}
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">
<div className="bg-gradient-accent text-white text-[11px] font-bold px-3 py-1.5 text-center uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Popular
</div>
</div>
)}
<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>
<h3 className="text-base font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{tier.name}
</h3>
<p className="text-xs text-gray-400 mb-3">{tier.description}</p>
<div className="mb-3">
<span className="text-xl font-bold text-[#333d49]">
<span className="text-xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(monthlyPrice)}
</span>
<span className="text-gray-500 text-xs">/mo</span>
<span className="text-gray-400 text-xs">/mo</span>
<p className="text-xs text-gray-400">
{formatCurrency(tier.pricePerUser)}/user
</p>
</div>
<ul className="space-y-1 mb-4">
<ul className="space-y-1.5 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 key={idx} className="flex items-start gap-2 text-xs">
<div className="w-3.5 h-3.5 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2 h-2 text-[#fe7400]" strokeWidth={3} />
</div>
<span className="text-gray-500">{feature}</span>
</li>
))}
</ul>
@@ -176,19 +207,21 @@ export function Step4VoIP({
</div>
{/* Hardware Section */}
<div className="border border-gray-200 rounded-lg overflow-hidden">
<div className="border border-gray-200/80 rounded-xl overflow-hidden bg-white shadow-card">
<button
type="button"
onClick={() => setShowHardware(!showHardware)}
className="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 transition-colors"
className="w-full flex items-center justify-between p-4 bg-[#f8f9fb] hover:bg-gray-100 transition-colors"
>
<div className="flex items-center gap-3">
<Headphones className="w-5 h-5 text-[#fe7400]" />
<span className="font-medium text-[#333d49]">
<span className="font-semibold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Phone Hardware (Optional)
</span>
</div>
<span className="text-sm text-gray-500">
<span className="text-sm text-gray-400 font-medium"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{showHardware ? 'Hide' : 'Show'} options
</span>
</button>
@@ -209,81 +242,84 @@ export function Step4VoIP({
return (
<div
key={hardware.id}
className={`p-4 rounded-lg border-2 transition-all ${
className={`p-4 rounded-xl border-2 transition-all duration-200 ${
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>
<div className="space-y-3">
<div>
<h4 className="font-semibold text-[#333d49] text-sm sm:text-base"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{hardware.name}
</h4>
<p className="text-xs sm:text-sm text-gray-400">{hardware.description}</p>
<div className="flex gap-3 sm:gap-4 mt-2 text-xs sm:text-sm">
<span className="text-gray-500">
Buy: <strong className="text-[#333d49]">{formatCurrency(hardware.oneTimePrice)}</strong>
</span>
<span className="text-[#333d49]">
Rent: <strong>{formatCurrency(hardware.monthlyRental)}</strong>/mo
<span className="text-gray-500">
Rent: <strong className="text-[#333d49]">{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">
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => onAddHardware(hardware.id, selection.quantity, false)}
className={`px-2 py-1 text-xs rounded ${
className={`px-2.5 py-1.5 text-xs rounded-lg font-semibold transition-colors ${
!selection.isRental
? 'bg-[#fe7400] text-white'
: 'bg-gray-200 text-gray-600'
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
}`}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
Buy
</button>
<button
type="button"
onClick={() => onAddHardware(hardware.id, selection.quantity, true)}
className={`px-2 py-1 text-xs rounded ${
className={`px-2.5 py-1.5 text-xs rounded-lg font-semibold transition-colors ${
selection.isRental
? 'bg-[#fe7400] text-white'
: 'bg-gray-200 text-gray-600'
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
}`}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
Rent
</button>
</div>
{/* Quantity */}
<div className="flex items-center gap-2 border border-gray-300 rounded-lg">
<div className="flex items-center gap-1 border border-gray-200 rounded-lg">
<button
type="button"
onClick={() => handleQuantityChange(hardware.id, -1)}
className="p-2 hover:bg-gray-100 rounded-l-lg"
className="p-2 hover:bg-gray-50 rounded-l-lg transition-colors"
disabled={selection.quantity <= 1}
>
<Minus className="w-4 h-4" />
<Minus className="w-3.5 h-3.5" />
</button>
<span className="w-8 text-center font-medium">
<span className="w-8 text-center font-semibold text-sm"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{selection.quantity}
</span>
<button
type="button"
onClick={() => handleQuantityChange(hardware.id, 1)}
className="p-2 hover:bg-gray-100 rounded-r-lg"
className="p-2 hover:bg-gray-50 rounded-r-lg transition-colors"
>
<Plus className="w-4 h-4" />
<Plus className="w-3.5 h-3.5" />
</button>
</div>
{/* Remove */}
<button
type="button"
onClick={() => onRemoveHardware(hardware.id)}
className="p-2 text-red-500 hover:bg-red-50 rounded-lg"
className="p-2 text-red-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors ml-auto"
>
<X className="w-4 h-4" />
</button>
@@ -317,21 +353,29 @@ export function Step4VoIP({
{/* 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" />
<ul className="space-y-2.5">
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span>Unlimited local and long-distance calling</span>
</li>
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span>Mobile apps for iOS and Android - take calls anywhere</span>
</li>
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span>Auto-attendant and professional voicemail</span>
</li>
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span>Keep your existing phone numbers</span>
</li>
</ul>
@@ -339,18 +383,19 @@ export function Step4VoIP({
{/* 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">
<div className="bg-gradient-dark text-white rounded-xl p-4 sm:p-5 flex items-center justify-between gap-3">
<span className="text-sm sm:text-base font-medium opacity-90">VoIP Monthly Total</span>
<span className="text-2xl sm:text-3xl font-bold whitespace-nowrap" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(getVoIPMonthly())}
<span className="text-lg font-normal opacity-75">/month</span>
<span className="text-xs sm:text-sm font-medium opacity-60 ml-1">/mo</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]">
<div className="bg-[#f8f9fb] rounded-xl p-4 flex items-center justify-between border border-gray-200/80">
<span className="text-gray-500 font-medium">Hardware Purchase (One-Time)</span>
<span className="text-xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(getVoIPOneTime())}
</span>
</div>
@@ -364,10 +409,10 @@ export function Step4VoIP({
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center py-8 text-gray-500"
className="text-center py-12 text-gray-400"
>
<Phone className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p>You can always add VoIP services later.</p>
<Phone className="w-12 h-12 mx-auto mb-3 opacity-20" />
<p className="text-sm">You can always add VoIP services later.</p>
</motion.div>
)}
</motion.div>

View File

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

View File

@@ -1,5 +1,5 @@
import { motion } from 'framer-motion';
import { Edit2, Monitor, Headphones, Phone, Globe, Mail, Printer, DollarSign } from 'lucide-react';
import { Edit2, Monitor, Headphones, Phone, Globe, Mail, Printer, DollarSign, ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui';
import {
gpsTiers,
@@ -25,7 +25,6 @@ export function Step6Summary({
onGoToStep,
onCalculateQuote,
}: Step6SummaryProps) {
// Calculate fresh quote if not available
const result = quoteResult || onCalculateQuote();
const gpsTier = gpsTiers.find((t) => t.id === quoteData.gps.tierId);
@@ -38,7 +37,8 @@ export function Step6Summary({
const emailTier = emailTiers.find((t) => t.id === quoteData.email.tierId);
const handlePrint = () => {
window.print();
// Brief delay to ensure print-only elements render
requestAnimationFrame(() => window.print());
};
return (
@@ -48,19 +48,42 @@ export function Step6Summary({
transition={{ duration: 0.3 }}
className="space-y-6"
>
{/* Print-only branded header */}
<div className="hidden print-show mb-6" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<div className="flex items-center justify-between pb-4 border-b-2 border-[#fe7400]">
<div>
<h1 className="text-2xl font-bold text-[#333d49]">Arizona Computer Guru</h1>
<p className="text-sm text-gray-400">Managed IT Services Quote</p>
</div>
<div className="text-right text-sm text-gray-400">
<p>{new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</p>
<p>Valid for 30 days</p>
</div>
</div>
</div>
{/* Header */}
<div className="text-center mb-8">
<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 className="text-center mb-8 print-hide">
<h2 className="text-2xl font-bold text-[#333d49] mb-2"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Your Quote Summary
</h2>
<p className="text-gray-400">Review your selections before submitting</p>
</div>
{/* Company Info */}
{quoteData.company.name && (
<div className="bg-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>
<div className="bg-[#f8f9fb] rounded-xl p-5 mb-6 border border-gray-200/50">
<p className="text-[11px] text-gray-400 mb-1 uppercase tracking-wider font-medium"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Quote prepared for
</p>
<p className="font-bold text-[#333d49] text-lg"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{quoteData.company.name}
</p>
{quoteData.company.industry && (
<p className="text-sm text-gray-600">{quoteData.company.industry}</p>
<p className="text-sm text-gray-400">{quoteData.company.industry}</p>
)}
</div>
)}
@@ -94,13 +117,20 @@ export function Step6Summary({
onEdit={() => onGoToStep(2)}
>
<div className="space-y-2">
{quoteData.support.planId === 'none' ? (
<SummaryLine
label="No Monthly Plan (pay-as-you-go)"
value="$0"
/>
) : (
<SummaryLine
label={`${supportPlan?.name} Plan (${supportPlan?.includedHours} hrs/mo)`}
value={formatCurrency(result.breakdown.support.plan)}
/>
)}
{blockTime && (
<SummaryLine
label={`Block Time (${blockTime.hours} hours)`}
label={`Block Time (${blockTime.hours} hours) — one-time`}
value={formatCurrency(result.breakdown.support.blockTime)}
/>
)}
@@ -160,41 +190,42 @@ export function Step6Summary({
</SummarySection>
)}
{/* Totals */}
{/* Grand Total */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.3 }}
className="bg-[#333d49] text-white rounded-xl p-6 mt-8"
className="bg-gradient-navy text-white rounded-2xl p-6 sm:p-8 mt-8"
>
<div className="flex items-center justify-between mb-4">
<span className="text-lg">Monthly Total</span>
<span className="text-4xl font-bold">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 mb-5">
<span className="text-base sm:text-lg font-medium text-white/80">Monthly Investment</span>
<span className="text-3xl sm:text-4xl font-bold" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(result.monthlyTotal)}
<span className="text-lg font-normal opacity-75">/mo</span>
<span className="text-sm sm:text-base font-medium text-white/50 ml-1">/mo</span>
</span>
</div>
{result.oneTimeTotal > 0 && (
<div className="flex items-center justify-between pt-4 border-t border-white/20">
<span className="opacity-75">One-Time Costs (Hardware)</span>
<span className="text-xl font-semibold">
<div className="flex items-center justify-between py-4 border-t border-white/10">
<span className="text-white/60">One-Time Costs</span>
<span className="text-xl font-bold" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(result.oneTimeTotal)}
</span>
</div>
)}
<div className="mt-6 pt-4 border-t border-white/20">
<div className="flex items-center justify-between text-sm opacity-75">
<div className="pt-4 border-t border-white/10">
<div className="flex items-center justify-between text-sm text-white/50">
<span>Annual Investment</span>
<span>{formatCurrency(result.monthlyTotal * 12)}/year</span>
<span className="font-medium">{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">
<div className="bg-white rounded-xl border border-gray-200/80 shadow-card p-5 sm:p-6">
<h4 className="font-bold text-[#333d49] mb-5 flex items-center gap-2"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<DollarSign className="w-5 h-5 text-[#fe7400]" />
Monthly Breakdown
</h4>
@@ -210,19 +241,25 @@ export function Step6Summary({
{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 className="pt-4 mt-1 border-t-2 border-[#fe7400]/20 flex justify-between items-center">
<span className="font-bold text-[#333d49] text-lg"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Total
</span>
<span className="font-bold text-[#fe7400] text-xl"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(result.monthlyTotal)}/mo
</span>
</div>
</div>
</div>
{/* Print Button */}
<div className="flex justify-center pt-4 print:hidden">
<div className="flex justify-center pt-2 print-hide">
<Button
variant="outline"
variant="ghost"
onClick={handlePrint}
className="flex items-center gap-2"
className="flex items-center gap-2 text-gray-400"
>
<Printer className="w-4 h-4" />
Print Quote
@@ -230,10 +267,15 @@ export function Step6Summary({
</div>
{/* Notes Section */}
<div className="text-center text-sm text-gray-500 pt-4">
<div className="text-center text-xs text-gray-400 pt-2 space-y-1">
<p>This is an estimate. Final pricing may vary based on specific requirements.</p>
<p>Prices are subject to change. Quote valid for 30 days.</p>
</div>
{/* Print-only footer */}
<div className="hidden print-show mt-8 pt-4 border-t border-gray-200 text-center text-xs text-gray-400">
<p>Arizona Computer Guru &middot; azcomputerguru.com &middot; (480) 400-3798</p>
</div>
</motion.div>
);
}
@@ -251,30 +293,36 @@ interface SummarySectionProps {
function SummarySection({ icon, title, monthlyTotal, onEdit, children }: SummarySectionProps) {
return (
<motion.div
initial={{ opacity: 0, x: -20 }}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="border border-gray-200 rounded-lg overflow-hidden"
className="border border-gray-200/80 rounded-xl overflow-hidden bg-white shadow-card print-section"
>
<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 className="bg-[#f8f9fb] px-4 sm:px-5 py-3 sm:py-3.5 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
<span className="text-[#fe7400] flex-shrink-0">{icon}</span>
<span className="font-bold text-[#333d49] text-sm sm:text-base truncate"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{title}
</span>
</div>
<div className="flex items-center gap-4">
<span className="font-bold text-[#333d49]">
{formatCurrency(monthlyTotal)}/mo
<div className="flex items-center gap-2 sm:gap-4 flex-shrink-0">
<span className="font-bold text-[#333d49] text-sm sm:text-base whitespace-nowrap"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(monthlyTotal)}
<span className="text-xs font-medium text-gray-400 ml-0.5">/mo</span>
</span>
<button
type="button"
onClick={onEdit}
className="flex items-center gap-1 text-sm text-[#fe7400] hover:text-[#e56800] transition-colors print:hidden"
className="flex items-center gap-1 sm:gap-1.5 text-xs sm:text-sm text-[#fe7400] hover:text-[#e56800] font-semibold transition-colors print-hide"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
<Edit2 className="w-3 h-3" />
<Edit2 className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
Edit
</button>
</div>
</div>
<div className="p-4">{children}</div>
<div className="p-5">{children}</div>
</motion.div>
);
}
@@ -286,9 +334,15 @@ interface SummaryLineProps {
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 className="flex justify-between items-center text-sm">
<span className="text-gray-500 flex items-center gap-2">
<ArrowRight className="w-3 h-3 text-gray-300" />
{label}
</span>
<span className="font-semibold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{value}
</span>
</div>
);
}
@@ -300,9 +354,12 @@ interface BreakdownRowProps {
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 className="flex justify-between items-center py-1">
<span className="text-gray-500">{label}</span>
<span className="font-semibold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(value)}
</span>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { User, Mail, Phone, Building2, MessageSquare, CheckCircle } from 'lucide-react';
import { User, Mail, Phone, MessageSquare, Shield, Clock, Sparkles } from 'lucide-react';
import { Input, Button } from '@/components/ui';
import { contactPreferences } from '@/lib/pricing-data';
import type { ContactInfo, ContactPreference, QuoteResult } from '@/types/quote';
@@ -36,7 +36,6 @@ export function Step7Contact({
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 });
}
@@ -77,7 +76,6 @@ export function Step7Contact({
if (validateForm()) {
onSubmit();
} else {
// Mark all fields as touched to show errors
setTouched({
name: true,
email: true,
@@ -95,29 +93,39 @@ export function Step7Contact({
>
{/* 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">
<h2 className="text-2xl font-bold text-[#333d49] mb-2"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Get Your Quote
</h2>
<p className="text-gray-400">
We will send your customized quote and contact you to discuss next steps.
</p>
</div>
{/* Quote Preview */}
{quoteResult && (
<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
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-gradient-navy rounded-xl p-4 sm:p-5 mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2"
>
<span className="text-sm sm:text-base text-white/80 font-medium">Your Estimated Monthly Total</span>
<span className="text-xl sm:text-2xl font-bold text-white"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(quoteResult.monthlyTotal)}
<span className="text-xs sm:text-sm font-medium text-white/50 ml-1">/mo</span>
</span>
</div>
</motion.div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Contact Name */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<User className="w-4 h-4 text-[#fe7400]" />
Contact Name
<span className="text-red-500">*</span>
<span className="text-red-500 text-xs">*</span>
</label>
<Input
type="text"
@@ -131,10 +139,11 @@ export function Step7Contact({
{/* Email */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<Mail className="w-4 h-4 text-[#fe7400]" />
Email Address
<span className="text-red-500">*</span>
<span className="text-red-500 text-xs">*</span>
</label>
<Input
type="email"
@@ -148,10 +157,11 @@ export function Step7Contact({
{/* Phone */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<Phone className="w-4 h-4 text-[#fe7400]" />
Phone Number
<span className="text-gray-400 font-normal">(recommended)</span>
<span className="text-gray-300 font-normal text-xs">(recommended)</span>
</label>
<Input
type="tel"
@@ -161,46 +171,34 @@ export function Step7Contact({
/>
</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]">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<MessageSquare className="w-4 h-4 text-[#fe7400]" />
Current IT Situation
<span className="text-gray-400 font-normal">(optional)</span>
<span className="text-gray-300 font-normal text-xs">(optional)</span>
</label>
<textarea
value={contactInfo.currentITSituation}
onChange={(e) => onUpdateContact({ currentITSituation: e.target.value })}
placeholder="Tell us about your current IT setup and any challenges you're facing..."
rows={3}
className="w-full px-4 py-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"
className="w-full px-4 py-3 rounded-xl border border-gray-200 bg-white text-[#333d49] placeholder-gray-400 hover:border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#fe7400]/15 focus:border-[#fe7400] transition-all duration-200 resize-none"
/>
</div>
{/* Contact Preference */}
<div className="space-y-3">
<label className="text-sm font-medium text-[#333d49]">
<label className="text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Preferred Contact Method
</label>
<div className="flex gap-4">
<div className="flex flex-wrap gap-4 sm:gap-5">
{contactPreferences.map((pref) => (
<label
key={pref.id}
className="flex items-center gap-2 cursor-pointer"
className="flex items-center gap-2.5 cursor-pointer group"
>
<input
type="radio"
@@ -210,7 +208,9 @@ export function Step7Contact({
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>
<span className="text-sm text-gray-500 group-hover:text-gray-700 transition-colors">
{pref.label}
</span>
</label>
))}
</div>
@@ -218,7 +218,7 @@ export function Step7Contact({
{/* Terms Checkbox */}
<div className="space-y-2 pt-4">
<label className="flex items-start gap-3 cursor-pointer">
<label className="flex items-start gap-3 cursor-pointer group">
<input
type="checkbox"
checked={contactInfo.agreedToTerms}
@@ -228,18 +228,18 @@ export function Step7Contact({
}}
className="w-5 h-5 mt-0.5 text-[#fe7400] border-gray-300 rounded focus:ring-[#fe7400]"
/>
<span className="text-sm text-gray-600">
<span className="text-sm text-gray-500 leading-relaxed">
I agree to receive communications about my quote and understand that I can
unsubscribe at any time. I have read and agree to the{' '}
<a href="/privacy" className="text-[#fe7400] hover:underline">
<a href="/privacy" className="text-[#fe7400] hover:text-[#e56800] font-medium transition-colors">
Privacy Policy
</a>{' '}
and{' '}
<a href="/terms" className="text-[#fe7400] hover:underline">
<a href="/terms" className="text-[#fe7400] hover:text-[#e56800] font-medium transition-colors">
Terms of Service
</a>
.
<span className="text-red-500">*</span>
<span className="text-red-500 text-xs ml-0.5">*</span>
</span>
</label>
{touched.agreedToTerms && errors.agreedToTerms && (
@@ -258,11 +258,16 @@ export function Step7Contact({
type="submit"
variant="primary"
size="lg"
className="w-full text-lg py-4"
className="w-full text-base py-4"
isLoading={isSubmitting}
disabled={isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Submit Quote Request'}
{isSubmitting ? 'Submitting...' : (
<>
<Sparkles className="w-5 h-5 mr-2" />
Submit Quote Request
</>
)}
</Button>
</motion.div>
</form>
@@ -272,20 +277,26 @@ export function Step7Contact({
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="mt-8 pt-6 border-t border-gray-200"
className="mt-10 pt-6 border-t border-gray-100"
>
<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 className="flex flex-col sm:flex-row sm:justify-between gap-4 sm:gap-5">
<div className="flex items-center gap-3 justify-center sm:justify-start">
<div className="w-8 h-8 rounded-lg bg-[#ecfdf5] flex items-center justify-center flex-shrink-0">
<Sparkles className="w-4 h-4 text-[#059669]" />
</div>
<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>
<span className="text-sm text-gray-500">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">Your data is secure</span>
<div className="flex items-center gap-3 justify-center">
<div className="w-8 h-8 rounded-lg bg-[#ecfdf5] flex items-center justify-center flex-shrink-0">
<Clock className="w-4 h-4 text-[#059669]" />
</div>
<span className="text-sm text-gray-500">Response within 24 hours</span>
</div>
<div className="flex items-center gap-3 justify-center sm:justify-end">
<div className="w-8 h-8 rounded-lg bg-[#ecfdf5] flex items-center justify-center flex-shrink-0">
<Shield className="w-4 h-4 text-[#059669]" />
</div>
<span className="text-sm text-gray-500">Your data is secure</span>
</div>
</div>
</motion.div>

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
export { StepWelcome, type StepWelcomeProps } from './StepWelcome';
export { StepServiceDiscovery, type StepServiceDiscoveryProps } from './StepServiceDiscovery';
export { Step1CompanyProfile, type Step1CompanyProfileProps } from './Step1CompanyProfile';
export { Step2GPSMonitoring, type Step2GPSMonitoringProps } from './Step2GPSMonitoring';
export { Step3SupportPlan, type Step3SupportPlanProps } from './Step3SupportPlan';

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useMemo } from 'react';
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import type {
QuoteData,
QuoteResult,
@@ -19,6 +19,8 @@ import type {
EmailProvider,
Industry,
ContactPreference,
ClientType,
ServiceInterests,
} from '@/types/quote';
import {
gpsTiers,
@@ -31,9 +33,46 @@ import {
emailTiers,
} from '@/lib/pricing-data';
const DRAFT_STORAGE_KEY = 'quote-wizard-draft';
/**
* Load saved draft from localStorage if available.
* Returns partial state keyed by section, or null if nothing saved.
*/
function loadDraft(): {
clientType?: ClientType;
serviceInterests?: ServiceInterests;
company?: CompanyInfo;
gps?: GPSSelection;
support?: SupportSelection;
voip?: VoIPSelection;
webHosting?: WebHostingSelection;
email?: EmailSelection;
contact?: ContactInfo;
accessToken?: string;
} | null {
try {
const raw = localStorage.getItem(DRAFT_STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
/**
* Initial state values
*/
const initialClientType: ClientType = 'company';
const initialServiceInterests: ServiceInterests = {
gps: true,
support: true,
voip: false,
webHosting: false,
email: false,
};
const initialCompanyInfo: CompanyInfo = {
name: '',
endpointCount: 10,
@@ -90,6 +129,10 @@ export interface UseQuoteReturn {
quoteData: QuoteData;
quoteResult: QuoteResult | null;
// Client type & service interests
setClientType: (type: ClientType) => void;
setServiceInterest: (service: keyof ServiceInterests, enabled: boolean) => void;
// Company updates
updateCompany: (data: Partial<CompanyInfo>) => void;
setEndpointCount: (count: number) => void;
@@ -140,6 +183,7 @@ export interface UseQuoteReturn {
getVoIPMonthly: () => number;
getWebHostingMonthly: () => number;
getEmailMonthly: () => number;
getSupportBlockTimeOneTime: () => number;
getVoIPOneTime: () => number;
// Reset
@@ -150,18 +194,67 @@ export interface UseQuoteReturn {
* 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 draft = useRef(loadDraft());
const [clientType, setClientType] = useState<ClientType>(draft.current?.clientType ?? initialClientType);
const [serviceInterests, setServiceInterests] = useState<ServiceInterests>(draft.current?.serviceInterests ?? initialServiceInterests);
const [company, setCompany] = useState<CompanyInfo>(draft.current?.company ?? initialCompanyInfo);
const [gps, setGPS] = useState<GPSSelection>(draft.current?.gps ?? initialGPSSelection);
const [support, setSupport] = useState<SupportSelection>(draft.current?.support ?? initialSupportSelection);
const [voip, setVoIP] = useState<VoIPSelection>(draft.current?.voip ?? initialVoIPSelection);
const [webHosting, setWebHosting] = useState<WebHostingSelection>(draft.current?.webHosting ?? initialWebHostingSelection);
const [email, setEmail] = useState<EmailSelection>(draft.current?.email ?? initialEmailSelection);
const [contact, setContact] = useState<ContactInfo>(draft.current?.contact ?? initialContactInfo);
const [quoteResult, setQuoteResult] = useState<QuoteResult | null>(null);
// Persist draft to localStorage when any section changes (debounced)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
saveTimerRef.current = setTimeout(() => {
try {
// Preserve the accessToken that WizardContainer may have written
const existing = localStorage.getItem(DRAFT_STORAGE_KEY);
let accessToken: string | undefined;
if (existing) {
try {
accessToken = JSON.parse(existing).accessToken;
} catch {
// ignore
}
}
const payload = {
clientType,
serviceInterests,
company,
gps,
support,
voip,
webHosting,
email,
contact,
...(accessToken ? { accessToken } : {}),
};
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(payload));
} catch {
// localStorage write failures are non-critical
}
}, 500);
return () => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
};
}, [clientType, serviceInterests, company, gps, support, voip, webHosting, email, contact]);
// Combined quote data
const quoteData: QuoteData = useMemo(
() => ({
clientType,
serviceInterests,
company,
gps,
support,
@@ -170,9 +263,31 @@ export function useQuote(): UseQuoteReturn {
email,
contact,
}),
[company, gps, support, voip, webHosting, email, contact]
[clientType, serviceInterests, company, gps, support, voip, webHosting, email, contact]
);
// ============================================================================
// Client Type & Service Interests
// ============================================================================
const setClientTypeValue = useCallback((type: ClientType) => {
setClientType(type);
}, []);
const setServiceInterest = useCallback((service: keyof ServiceInterests, enabled: boolean) => {
setServiceInterests((prev) => ({ ...prev, [service]: enabled }));
// Sync the enabled flags on the corresponding selections
if (service === 'voip') {
setVoIP((prev) => ({ ...prev, enabled, userCount: enabled ? Math.max(prev.userCount, 1) : 0 }));
}
if (service === 'webHosting') {
setWebHosting((prev) => ({ ...prev, enabled }));
}
if (service === 'email') {
setEmail((prev) => ({ ...prev, enabled, mailboxCount: enabled ? Math.max(prev.mailboxCount, 1) : 0 }));
}
}, []);
// ============================================================================
// Company Updates
// ============================================================================
@@ -387,19 +502,16 @@ export function useQuote(): UseQuoteReturn {
}, [gps]);
const getSupportMonthly = useCallback((): number => {
if (support.planId === 'none') return 0;
const plan = supportPlans.find((p) => p.id === support.planId);
if (!plan) return 0;
return plan ? plan.monthlyPrice : 0;
}, [support]);
let total = plan.monthlyPrice;
if (support.useBlockTime && support.blockTimeId) {
const getSupportBlockTimeOneTime = useCallback((): number => {
if (!support.useBlockTime || !support.blockTimeId) return 0;
const blockTime = blockTimeOptions.find((b) => b.id === support.blockTimeId);
if (blockTime) {
total += blockTime.price;
}
}
return total;
return blockTime ? blockTime.price : 0;
}, [support]);
const getVoIPMonthly = useCallback((): number => {
@@ -460,6 +572,7 @@ export function useQuote(): UseQuoteReturn {
const supportMonthly = getSupportMonthly();
const voipMonthly = getVoIPMonthly();
const voipOneTime = getVoIPOneTime();
const supportBlockTimeOneTime = getSupportBlockTimeOneTime();
const webHostingMonthly = getWebHostingMonthly();
const emailMonthly = getEmailMonthly();
@@ -473,15 +586,8 @@ export function useQuote(): UseQuoteReturn {
}
// Calculate support breakdown
const supportPlan = supportPlans.find((p) => p.id === support.planId);
const supportPlan = support.planId !== 'none' ? supportPlans.find((p) => p.id === support.planId) : null;
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);
@@ -506,7 +612,7 @@ export function useQuote(): UseQuoteReturn {
},
support: {
plan: supportPlanCost,
blockTime: supportBlockTime,
blockTime: supportBlockTimeOneTime,
total: supportMonthly,
},
voip: {
@@ -522,7 +628,7 @@ export function useQuote(): UseQuoteReturn {
const result: QuoteResult = {
monthlyTotal,
oneTimeTotal: voipOneTime,
oneTimeTotal: voipOneTime + supportBlockTimeOneTime,
breakdown,
gpsMonthly,
supportMonthly,
@@ -533,13 +639,15 @@ export function useQuote(): UseQuoteReturn {
setQuoteResult(result);
return result;
}, [gps, support, voip, webHosting, email, getGPSMonthly, getSupportMonthly, getVoIPMonthly, getVoIPOneTime, getWebHostingMonthly, getEmailMonthly]);
}, [gps, support, voip, webHosting, email, getGPSMonthly, getSupportMonthly, getSupportBlockTimeOneTime, getVoIPMonthly, getVoIPOneTime, getWebHostingMonthly, getEmailMonthly]);
// ============================================================================
// Reset
// ============================================================================
const resetQuote = useCallback(() => {
setClientType(initialClientType);
setServiceInterests(initialServiceInterests);
setCompany(initialCompanyInfo);
setGPS(initialGPSSelection);
setSupport(initialSupportSelection);
@@ -548,12 +656,17 @@ export function useQuote(): UseQuoteReturn {
setEmail(initialEmailSelection);
setContact(initialContactInfo);
setQuoteResult(null);
localStorage.removeItem(DRAFT_STORAGE_KEY);
}, []);
return {
quoteData,
quoteResult,
// Client type & service interests
setClientType: setClientTypeValue,
setServiceInterest,
// Company updates
updateCompany,
setEndpointCount,
@@ -604,6 +717,7 @@ export function useQuote(): UseQuoteReturn {
getVoIPMonthly,
getWebHostingMonthly,
getEmailMonthly,
getSupportBlockTimeOneTime,
getVoIPOneTime,
// Reset

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ export interface GPSSelection {
// Support Plan Types
// ============================================================================
export type SupportPlanId = 'essential' | 'standard' | 'premium' | 'priority';
export type SupportPlanId = 'none' | 'essential' | 'standard' | 'premium' | 'priority';
export type BlockTimeId = 'block-10' | 'block-20' | 'block-30';
export interface SupportPlan {
@@ -138,9 +138,11 @@ export interface EmailSelection {
}
// ============================================================================
// Company & Contact Types
// Client & Contact Types
// ============================================================================
export type ClientType = 'company' | 'individual';
export type Industry =
| 'Healthcare'
| 'Legal'
@@ -169,11 +171,25 @@ export interface ContactInfo {
agreedToTerms: boolean;
}
// ============================================================================
// Service Interest Selection (for discovery step)
// ============================================================================
export interface ServiceInterests {
gps: boolean;
support: boolean;
voip: boolean;
webHosting: boolean;
email: boolean;
}
// ============================================================================
// Quote Data & Result Types
// ============================================================================
export interface QuoteData {
clientType: ClientType;
serviceInterests: ServiceInterests;
company: CompanyInfo;
gps: GPSSelection;
support: SupportSelection;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,37 @@
# BG Builders - Assign Exchange Administrator role to Claude-MSP-Access service principal
# Required for Exchange Online app-only auth (Set-Mailbox, litigation hold, etc.)
# Run from interactive PowerShell as sysadmin@bgbuildersllc.com
$tenantId = "ededa4fb-f6eb-4398-851d-5eb3e11fab27"
$spId = "9c04bb74-c2d0-4d83-ab54-9c43a9daaa23" # Claude-MSP-Access SP in BG Builders
$exoRoleId = "87706939-e519-4028-a73e-a6a7f04b4a20" # Exchange Administrator
Write-Output "Connecting to Graph..."
Import-Module Microsoft.Graph.Authentication
Import-Module Microsoft.Graph.Identity.DirectoryManagement
Connect-MgGraph -TenantId $tenantId -Scopes 'RoleManagement.ReadWrite.Directory' -NoWelcome
Write-Output "[OK] Connected"
Write-Output "Assigning Exchange Administrator to Claude-MSP-Access..."
$body = @{
"@odata.id" = "https://graph.microsoft.com/v1.0/servicePrincipals/$spId"
}
New-MgDirectoryRoleMemberByRef -DirectoryRoleId $exoRoleId -BodyParameter $body
Write-Output "[OK] Exchange Administrator role assigned"
# Now set litigation hold on Lesley
Write-Output "`nConnecting to Exchange Online..."
Import-Module ExchangeOnlineManagement
Connect-ExchangeOnline -UserPrincipalName "sysadmin@bgbuildersllc.com" -ShowBanner:$false
Write-Output "[OK] Connected"
Write-Output "Setting litigation hold on Lesley's mailbox..."
Set-Mailbox -Identity "lesley@bgbuildersllc.com" -LitigationHoldEnabled $true -LitigationHoldDuration Unlimited
Write-Output "[OK] Litigation hold enabled"
Write-Output "`nVerifying..."
Get-Mailbox -Identity "lesley@bgbuildersllc.com" | Format-List DisplayName,LitigationHoldEnabled,LitigationHoldDuration
Disconnect-ExchangeOnline -Confirm:$false
Disconnect-MgGraph
Write-Output "[OK] Done"

View File

@@ -0,0 +1,81 @@
Import-Module Microsoft.Graph.Authentication
Import-Module Microsoft.Graph.Users
Import-Module Microsoft.Graph.Groups
Import-Module Microsoft.Graph.Sites
$tenantId = "ededa4fb-f6eb-4398-851d-5eb3e11fab27"
$lesleyUPN = "lesley@bgbuildersllc.com"
Write-Output "========================================="
Write-Output " BG Builders - Lesley Roth Ownership Audit"
Write-Output " $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Output "========================================="
Connect-MgGraph -TenantId $tenantId -Scopes 'User.Read.All','Group.Read.All','Sites.Read.All','TeamSettings.Read.All' -NoWelcome
$lesley = Get-MgUser -UserId $lesleyUPN -Property Id,DisplayName
Write-Output "[OK] Lesley ID: $($lesley.Id)"
# --- Check Teams/M365 Group ownership ---
Write-Output "`n--- Teams / M365 Group Ownership ---"
$ownedGroups = Get-MgUserOwnedObject -UserId $lesley.Id -All
if ($ownedGroups) {
foreach ($obj in $ownedGroups) {
$group = Get-MgGroup -GroupId $obj.Id -Property DisplayName,GroupTypes,Mail -ErrorAction SilentlyContinue
if ($group) {
$isTeam = $group.GroupTypes -contains "Unified"
$type = if ($isTeam) { "M365 Group/Team" } else { "Group" }
Write-Output " [OWNER] $type : $($group.DisplayName) ($($group.Mail))"
# Check if sole owner
$owners = Get-MgGroupOwner -GroupId $obj.Id -All
if ($owners.Count -le 1) {
Write-Output " [WARNING] SOLE OWNER - needs transfer before termination"
} else {
Write-Output " [OK] Has $($owners.Count) owners total"
}
}
}
} else {
Write-Output " [INFO] Lesley does not own any groups or teams"
}
# --- Check group memberships ---
Write-Output "`n--- Group / Team Memberships ---"
$memberships = Get-MgUserMemberOf -UserId $lesley.Id -All
foreach ($mem in $memberships) {
$group = Get-MgGroup -GroupId $mem.Id -Property DisplayName,GroupTypes,Mail -ErrorAction SilentlyContinue
if ($group) {
$isTeam = $group.GroupTypes -contains "Unified"
$type = if ($isTeam) { "M365 Group/Team" } else { "Security/DL Group" }
Write-Output " [MEMBER] $type : $($group.DisplayName) ($($group.Mail))"
}
}
# --- Check SharePoint site ownership ---
Write-Output "`n--- SharePoint Sites ---"
try {
$sites = Get-MgSite -Search "*" -All -Property DisplayName,WebUrl 2>$null
if ($sites) {
foreach ($site in $sites) {
try {
$sitePermissions = Get-MgSitePermission -SiteId $site.Id -ErrorAction SilentlyContinue 2>$null
} catch {
# Fall through - permissions API may not be available on all sites
}
Write-Output " [SITE] $($site.DisplayName) - $($site.WebUrl)"
}
}
} catch {
Write-Output " [INFO] Could not enumerate SharePoint sites (may need SharePoint admin role)"
}
# --- Check distribution group membership via Exchange ---
Write-Output "`n--- Distribution List Memberships (requires Exchange connection) ---"
Write-Output " [INFO] Run separately via Exchange Online to check DL memberships"
Write-Output "`n========================================="
Write-Output " Audit Complete"
Write-Output "========================================="
Disconnect-MgGraph

View File

@@ -0,0 +1,11 @@
Import-Module Microsoft.Graph.Authentication
Import-Module Microsoft.Graph.Users
Connect-MgGraph -TenantId 'ededa4fb-f6eb-4398-851d-5eb3e11fab27' -Scopes 'User.Read.All' -NoWelcome
# List all users to find Leslie
$allUsers = Get-MgUser -All -Property DisplayName,Mail,UserPrincipalName,AccountEnabled,Id
Write-Output "--- All Users in Tenant ---"
$allUsers | Format-Table DisplayName,Mail,UserPrincipalName,AccountEnabled -AutoSize
Disconnect-MgGraph

View File

@@ -0,0 +1,102 @@
# BG Builders - Disable Lesley Roth + Wipe Email from Device
# Employee: Lesley Roth (lesley@bgbuildersllc.com)
# Date: 2026-03-09
# Actions:
# 1. Block sign-in
# 2. Revoke all sessions
# 3. Reset password
# 4. Wipe email data from mobile devices (selective wipe + EAS wipe)
$ErrorActionPreference = "Stop"
$tenantId = "ededa4fb-f6eb-4398-851d-5eb3e11fab27"
$lesleyUPN = "lesley@bgbuildersllc.com"
Write-Output "========================================="
Write-Output " BG Builders - Disable Lesley Roth"
Write-Output " $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Output "========================================="
# --- STEP 1: Connect to Microsoft Graph ---
Write-Output "`n[STEP 1] Connecting to Microsoft Graph..."
Import-Module Microsoft.Graph.Authentication
Import-Module Microsoft.Graph.Users
Import-Module Microsoft.Graph.Users.Actions
Connect-MgGraph -TenantId $tenantId -Scopes 'User.ReadWrite.All','Directory.ReadWrite.All','DeviceManagementManagedDevices.ReadWrite.All','DeviceManagementManagedDevices.PrivilegedOperations.All' -NoWelcome
Write-Output "[OK] Connected to Graph"
$lesley = Get-MgUser -UserId $lesleyUPN -Property Id,DisplayName,AccountEnabled,AssignedLicenses
Write-Output "[INFO] Current state: AccountEnabled=$($lesley.AccountEnabled)"
# --- STEP 2: Block sign-in ---
Write-Output "`n[STEP 2] Blocking sign-in..."
Update-MgUser -UserId $lesley.Id -AccountEnabled:$false
Write-Output "[OK] Sign-in blocked"
# --- STEP 3: Revoke all sessions ---
Write-Output "`n[STEP 3] Revoking all active sessions..."
Revoke-MgUserSignInSession -UserId $lesley.Id
Write-Output "[OK] All sessions revoked"
# --- STEP 4: Reset password ---
Write-Output "`n[STEP 4] Resetting password..."
$newPassword = -join ((65..90) + (97..122) + (48..57) + (33,35,36,37,38) | Get-Random -Count 24 | ForEach-Object {[char]$_})
$params = @{
passwordProfile = @{
forceChangePasswordNextSignIn = $true
password = $newPassword
}
}
Update-MgUser -UserId $lesley.Id -BodyParameter $params
Write-Output "[OK] Password reset to random value"
# --- STEP 5: Wipe email from devices (Intune managed) ---
Write-Output "`n[STEP 5] Checking for Intune-managed devices..."
Import-Module Microsoft.Graph.DeviceManagement
$devices = Get-MgDeviceManagementManagedDevice -Filter "userPrincipalName eq '$lesleyUPN'" 2>$null
if ($devices) {
foreach ($device in $devices) {
Write-Output " Found: $($device.DeviceName) ($($device.OperatingSystem)) - ID: $($device.Id)"
Write-Output " Initiating selective wipe (company data only)..."
Invoke-MgRetireDeviceManagementManagedDevice -ManagedDeviceId $device.Id
Write-Output " [OK] Selective wipe queued for $($device.DeviceName)"
}
} else {
Write-Output "[INFO] No Intune-managed devices found"
}
# --- STEP 6: Wipe email from devices (Exchange ActiveSync) ---
Write-Output "`n[STEP 6] Connecting to Exchange Online..."
Import-Module ExchangeOnlineManagement
Connect-ExchangeOnline -UserPrincipalName "sysadmin@bgbuildersllc.com" -ShowBanner:$false
Write-Output "[OK] Connected to Exchange Online"
Write-Output "Checking for ActiveSync devices..."
$easDevices = Get-MobileDevice -Mailbox $lesleyUPN 2>$null
if ($easDevices) {
foreach ($eas in $easDevices) {
Write-Output " Found EAS device: $($eas.FriendlyName) ($($eas.DeviceOS))"
Clear-MobileDevice -Identity $eas.Identity -AccountOnly -Confirm:$false
Write-Output " [OK] Account-only wipe initiated for $($eas.FriendlyName)"
}
Write-Output "[OK] All EAS devices queued for account wipe"
} else {
Write-Output "[INFO] No EAS mobile devices found"
}
# --- DONE ---
Write-Output "`n========================================="
Write-Output " DISABLE + DEVICE WIPE COMPLETE"
Write-Output " $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Output "========================================="
Write-Output ""
Write-Output "Summary:"
Write-Output " [OK] Sign-in blocked"
Write-Output " [OK] Sessions revoked"
Write-Output " [OK] Password reset"
Write-Output " [OK] Device email wipe initiated (Intune + EAS)"
Write-Output ""
Write-Output "[INFO] Mailbox is still accessible - run full termination script"
Write-Output " when ready to convert to shared, remove license, etc."
Disconnect-ExchangeOnline -Confirm:$false
Disconnect-MgGraph

View File

@@ -0,0 +1,33 @@
# BG Builders - Lesley Exchange steps (run from interactive PowerShell)
# Adds Shelly as delegate + enables litigation hold
$lesleyUPN = "lesley@bgbuildersllc.com"
$shellyUPN = "Shelly@bgbuildersllc.com"
Write-Output "Connecting to Exchange Online..."
Import-Module ExchangeOnlineManagement
Connect-ExchangeOnline -UserPrincipalName "sysadmin@bgbuildersllc.com" -ShowBanner:$false
Write-Output "[OK] Connected"
# Add Shelly as delegate
Write-Output "`nAdding Shelly as delegate..."
Add-MailboxPermission -Identity $lesleyUPN -User $shellyUPN -AccessRights FullAccess -AutoMapping $true
Write-Output "[OK] Shelly granted FullAccess"
Add-RecipientPermission -Identity $lesleyUPN -Trustee $shellyUPN -AccessRights SendAs -Confirm:$false
Write-Output "[OK] Shelly granted SendAs"
# Enable litigation hold
Write-Output "`nEnabling litigation hold..."
Set-Mailbox -Identity $lesleyUPN -LitigationHoldEnabled $true -LitigationHoldDuration Unlimited
Write-Output "[OK] Litigation hold enabled"
# Verify
Write-Output "`nVerifying permissions..."
Get-MailboxPermission -Identity $lesleyUPN | Where-Object { $_.User -notlike "NT AUTHORITY*" -and $_.User -notlike "S-1-*" } | Format-Table User,AccessRights -AutoSize
Write-Output "`nVerifying litigation hold..."
Get-Mailbox -Identity $lesleyUPN | Format-List LitigationHoldEnabled,LitigationHoldDuration
Disconnect-ExchangeOnline -Confirm:$false
Write-Output "[OK] Done"

View File

@@ -0,0 +1,83 @@
# BG Builders - Check and fix inbox rules on lesley shared mailbox
# Run from interactive PowerShell
$lesleyUPN = "lesley@bgbuildersllc.com"
Write-Output "Connecting to Exchange Online..."
Import-Module ExchangeOnlineManagement
Connect-ExchangeOnline -UserPrincipalName "sysadmin@bgbuildersllc.com" -ShowBanner:$false
Write-Output "[OK] Connected"
# Check inbox rules
Write-Output "`n=== INBOX RULES ==="
$rules = Get-InboxRule -Mailbox $lesleyUPN -IncludeHidden
if ($rules) {
foreach ($rule in $rules) {
Write-Output " Rule: $($rule.Name) | Enabled: $($rule.Enabled) | Priority: $($rule.Priority)"
Write-Output " Description: $($rule.Description)"
Write-Output " MoveToFolder: $($rule.MoveToFolder)"
Write-Output " DeleteMessage: $($rule.DeleteMessage)"
Write-Output " SoftDeleteMessage: $($rule.SoftDeleteMessage)"
Write-Output ""
}
# Disable any rules that delete messages
foreach ($rule in $rules) {
if ($rule.DeleteMessage -or $rule.SoftDeleteMessage -or $rule.MoveToFolder -match "Deleted") {
Write-Output "[ALERT] Removing problematic rule: $($rule.Name)"
Remove-InboxRule -Mailbox $lesleyUPN -Identity $rule.Identity -Confirm:$false
Write-Output "[OK] Removed"
}
}
} else {
Write-Output " [OK] No inbox rules found"
}
# Check sweep rules
Write-Output "`n=== SWEEP RULES ==="
try {
$sweep = Get-SweepRule -Mailbox $lesleyUPN
if ($sweep) {
foreach ($s in $sweep) {
Write-Output " Rule: $($s.Name) | Enabled: $($s.Enabled)"
Write-Output " SourceFolder: $($s.SourceFolder)"
Write-Output " DestFolder: $($s.DestFolder)"
Write-Output " KeepLatest: $($s.KeepLatest)"
Write-Output ""
}
# Remove sweep rules
foreach ($s in $sweep) {
Write-Output "[ALERT] Removing sweep rule: $($s.Name)"
Remove-SweepRule -Identity $s.Identity -Mailbox $lesleyUPN -Confirm:$false
Write-Output "[OK] Removed"
}
} else {
Write-Output " [OK] No sweep rules found"
}
} catch {
Write-Output " [INFO] Sweep rules not available: $_"
}
# Check mailbox type and forwarding
Write-Output "`n=== MAILBOX STATUS ==="
$mb = Get-Mailbox -Identity $lesleyUPN
Write-Output " Type: $($mb.RecipientTypeDetails)"
Write-Output " Forwarding: $($mb.ForwardingAddress)"
Write-Output " ForwardingSMTP: $($mb.ForwardingSmtpAddress)"
Write-Output " DeliverToMailboxAndForward: $($mb.DeliverToMailboxAndForward)"
Write-Output " HiddenFromGAL: $($mb.HiddenFromAddressListsEnabled)"
Write-Output " LitigationHold: $($mb.LitigationHoldEnabled)"
# Check transport rules affecting this mailbox
Write-Output "`n=== TRANSPORT RULES ==="
$transport = Get-TransportRule | Where-Object { $_.State -eq "Enabled" }
if ($transport) {
foreach ($t in $transport) {
Write-Output " Rule: $($t.Name) | Priority: $($t.Priority)"
}
} else {
Write-Output " [OK] No transport rules"
}
Disconnect-ExchangeOnline -Confirm:$false
Write-Output "`n[OK] Done"

View File

@@ -0,0 +1,62 @@
=========================================
LESLEY ROTH - 72-HOUR MAIL ACTIVITY REPORT
Generated: 2026-03-09 09:30:46
Window: 2026-03-06 09:30 to 2026-03-09 09:30
=========================================
=========================================
SENT MESSAGES (0 total)
=========================================
[NONE] No sent messages in the last 72 hours
=========================================
RECEIVED MESSAGES (5 total)
=========================================
Date: 2026-03-09 09:53:49
From: Gallagher.NoReply@Vertafore.com
Subject: Coyote Landing - 23-09001.Coyote - Enrollment Status Report From AJG - 03/09/2026 - By Contractor Name (All Tier)
Status: Delivered
---
Date: 2026-03-09 09:22:52
From: Gallagher.NoReply@Vertafore.com
Subject: Coyote Landing - 23-09001.Coyote - Enrollment Status Report From AJG - 03/09/2026 - By Contractor Name (First Tier)
Status: Delivered
---
Date: 2026-03-09 08:32:29
From: Gallagher.NoReply@Vertafore.com
Subject: Coyote Landing / EmpirePaving-BGBuild-23-09001.Coyote / Missing/Incomplete Insurance Cost Worksheet
Status: Delivered
---
Date: 2026-03-09 08:17:05
From: Gallagher.NoReply@Vertafore.com
Subject: Coyote Landing / EmpirePaving-BGBuild-23-09001.Coyote / Enrollment Incomplete
Status: Delivered
---
Date: 2026-03-06 22:09:29
From: notifications@s.usa.experian.com
Subject: Lesley, your Experian account info recently changed.
Status: Delivered
---
=========================================
DELETED ITEMS (0 total)
=========================================
[NONE] No deleted items in the last 72 hours
=========================================
INBOX RULES
=========================================
[NONE] No inbox rules configured
=========================================
FORWARDING CONFIGURATION
=========================================
ForwardingAddress:
ForwardingSmtpAddress:
DeliverToMailboxAndForward: False
[OK] No forwarding configured

View File

@@ -0,0 +1,150 @@
# BG Builders - Lesley Roth 72-Hour Mail Activity Report
# Pulls sent mail (message trace) and deleted items (mailbox audit log)
# Date: 2026-03-09
$ErrorActionPreference = "Stop"
$lesleyUPN = "lesley@bgbuildersllc.com"
$startDate = (Get-Date).AddHours(-72)
$endDate = Get-Date
$reportPath = "D:\ClaudeTools\scripts\bgb-lesley-mail-report-$(Get-Date -Format 'yyyyMMdd').txt"
Write-Output "========================================="
Write-Output " BG Builders - Lesley Roth Mail Report"
Write-Output " 72-Hour Window: $($startDate.ToString('yyyy-MM-dd HH:mm')) to $($endDate.ToString('yyyy-MM-dd HH:mm'))"
Write-Output "========================================="
# --- Connect to Exchange Online ---
Write-Output "`n[STEP 1] Connecting to Exchange Online..."
Import-Module ExchangeOnlineManagement
Connect-ExchangeOnline -UserPrincipalName "sysadmin@bgbuildersllc.com" -ShowBanner:$false
Write-Output "[OK] Connected"
# Start building report
$report = @()
$report += "========================================="
$report += " LESLEY ROTH - 72-HOUR MAIL ACTIVITY REPORT"
$report += " Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
$report += " Window: $($startDate.ToString('yyyy-MM-dd HH:mm')) to $($endDate.ToString('yyyy-MM-dd HH:mm'))"
$report += "========================================="
# --- SENT MAIL (Message Trace) ---
Write-Output "`n[STEP 2] Pulling sent mail via message trace..."
$sentMessages = Get-MessageTraceV2 -SenderAddress $lesleyUPN -StartDate $startDate -EndDate $endDate
$report += ""
$report += "=========================================`n SENT MESSAGES ($($sentMessages.Count) total)`n========================================="
if ($sentMessages.Count -gt 0) {
$sentMessages | Sort-Object Received -Descending | ForEach-Object {
$report += ""
$report += " Date: $($_.Received.ToString('yyyy-MM-dd HH:mm:ss'))"
$report += " To: $($_.RecipientAddress)"
$report += " Subject: $($_.Subject)"
$report += " Status: $($_.Status)"
$report += " Size: $([math]::Round($_.Size / 1KB, 1)) KB"
$report += " MsgID: $($_.MessageId)"
$report += " ---"
}
} else {
$report += " [NONE] No sent messages in the last 72 hours"
}
Write-Output "[OK] Found $($sentMessages.Count) sent messages"
# --- RECEIVED MAIL (Message Trace) ---
Write-Output "`n[STEP 3] Pulling received mail via message trace..."
$receivedMessages = Get-MessageTraceV2 -RecipientAddress $lesleyUPN -StartDate $startDate -EndDate $endDate
$report += ""
$report += "=========================================`n RECEIVED MESSAGES ($($receivedMessages.Count) total)`n========================================="
if ($receivedMessages.Count -gt 0) {
$receivedMessages | Sort-Object Received -Descending | ForEach-Object {
$report += ""
$report += " Date: $($_.Received.ToString('yyyy-MM-dd HH:mm:ss'))"
$report += " From: $($_.SenderAddress)"
$report += " Subject: $($_.Subject)"
$report += " Status: $($_.Status)"
$report += " ---"
}
} else {
$report += " [NONE] No received messages in the last 72 hours"
}
Write-Output "[OK] Found $($receivedMessages.Count) received messages"
# --- DELETED ITEMS (Mailbox Audit Log) ---
Write-Output "`n[STEP 4] Pulling deleted items via mailbox audit log..."
# Use Search-UnifiedAuditLog (Search-MailboxAuditLog deprecated Jan 2026)
$deleteOps = "SoftDelete","HardDelete","MoveToDeletedItems"
$deletedItems = Search-UnifiedAuditLog -UserIds $lesleyUPN -Operations ($deleteOps -join ",") -StartDate $startDate -EndDate $endDate -ResultSize 5000
$report += ""
$report += "=========================================`n DELETED ITEMS ($($deletedItems.Count) total)`n========================================="
if ($deletedItems.Count -gt 0) {
$deletedItems | Sort-Object CreationDate -Descending | ForEach-Object {
$auditData = $_.AuditData | ConvertFrom-Json
$report += ""
$report += " Date: $($_.CreationDate)"
$report += " Operation: $($_.Operations)"
$report += " User: $($_.UserIds)"
$report += " Subject: $($auditData.AffectedItems.Subject -join '; ')"
$report += " Folder: $($auditData.Folder.Path)"
$report += " Client: $($auditData.ClientInfoString)"
$report += " ---"
}
} else {
$report += " [NONE] No deleted items in the last 72 hours"
}
Write-Output "[OK] Found $($deletedItems.Count) deleted items"
# --- INBOX RULES (check for forwarding/auto-delete) ---
Write-Output "`n[STEP 5] Checking inbox rules..."
$rules = Get-InboxRule -Mailbox $lesleyUPN 2>$null
$report += ""
$report += "=========================================`n INBOX RULES`n========================================="
if ($rules) {
foreach ($rule in $rules) {
$report += ""
$report += " Name: $($rule.Name)"
$report += " Enabled: $($rule.Enabled)"
$report += " Priority: $($rule.Priority)"
if ($rule.ForwardTo) { $report += " ForwardTo: $($rule.ForwardTo -join '; ')" }
if ($rule.RedirectTo) { $report += " RedirectTo: $($rule.RedirectTo -join '; ')" }
if ($rule.DeleteMessage) { $report += " [WARNING] Auto-delete enabled" }
$report += " ---"
}
} else {
$report += " [NONE] No inbox rules configured"
}
Write-Output "[OK] Rules checked"
# --- FORWARDING CONFIG ---
Write-Output "`n[STEP 6] Checking forwarding configuration..."
$mbx = Get-Mailbox -Identity $lesleyUPN | Select-Object ForwardingAddress,ForwardingSmtpAddress,DeliverToMailboxAndForward
$report += ""
$report += "=========================================`n FORWARDING CONFIGURATION`n========================================="
$report += " ForwardingAddress: $($mbx.ForwardingAddress)"
$report += " ForwardingSmtpAddress: $($mbx.ForwardingSmtpAddress)"
$report += " DeliverToMailboxAndForward: $($mbx.DeliverToMailboxAndForward)"
if ($mbx.ForwardingAddress -or $mbx.ForwardingSmtpAddress) {
$report += " [WARNING] Active forwarding detected!"
} else {
$report += " [OK] No forwarding configured"
}
# --- Write report to file ---
$report | Out-File -FilePath $reportPath -Encoding UTF8
Write-Output "`n========================================="
Write-Output " REPORT SAVED"
Write-Output " $reportPath"
Write-Output "========================================="
# Also output to console
Write-Output "`n--- REPORT CONTENTS ---"
$report | ForEach-Object { Write-Output $_ }
Disconnect-ExchangeOnline -Confirm:$false
Write-Output "`n[OK] Done"

View File

@@ -0,0 +1,193 @@
#Requires -Modules ExchangeOnlineManagement
<#
.SYNOPSIS
BG Builders - Lesley Roth: Recover deleted items (last 10 days) and review inbox rules
.DESCRIPTION
1. Connects to Exchange Online as sysadmin@bgbuildersllc.com
2. Recovers all soft-deleted items from Lesley's mailbox (last 10 days)
3. Lists all inbox rules on the account
.NOTES
Run in PowerShell 7 (pwsh) for best compatibility
Tenant: bgbuildersllc.com / sonorangreenllc.onmicrosoft.com
Target: lesley@bgbuildersllc.com
#>
$ErrorActionPreference = 'Stop'
$targetUser = 'lesley@bgbuildersllc.com'
# ── Connect to Exchange Online ──────────────────────────────────────
Write-Host "`n=== Connecting to Exchange Online ===" -ForegroundColor Cyan
try {
$session = Get-ConnectionInformation -ErrorAction SilentlyContinue
if (-not $session -or $session.State -ne 'Connected') {
Connect-ExchangeOnline -UserPrincipalName sysadmin@bgbuildersllc.com -ShowBanner:$false
} else {
Write-Host "Already connected to Exchange Online" -ForegroundColor Green
}
} catch {
Write-Host "Connecting fresh..." -ForegroundColor Yellow
Connect-ExchangeOnline -UserPrincipalName sysadmin@bgbuildersllc.com -ShowBanner:$false
}
# ── Part 1: Review Inbox Rules ──────────────────────────────────────
Write-Host "`n=== INBOX RULES for $targetUser ===" -ForegroundColor Cyan
try {
$rules = Get-InboxRule -Mailbox $targetUser -IncludeHidden
if ($rules) {
Write-Host "`nFound $($rules.Count) rule(s):" -ForegroundColor Yellow
foreach ($rule in $rules) {
Write-Host "`n--- Rule: $($rule.Name) ---" -ForegroundColor White
Write-Host " Enabled: $($rule.Enabled)"
Write-Host " Priority: $($rule.Priority)"
Write-Host " Description: $($rule.Description)"
if ($rule.ForwardTo) {
Write-Host " ** FORWARD TO: $($rule.ForwardTo)" -ForegroundColor Red
}
if ($rule.ForwardAsAttachmentTo) {
Write-Host " ** FWD ATTACH: $($rule.ForwardAsAttachmentTo)" -ForegroundColor Red
}
if ($rule.RedirectTo) {
Write-Host " ** REDIRECT TO: $($rule.RedirectTo)" -ForegroundColor Red
}
if ($rule.DeleteMessage) {
Write-Host " ** DELETE MSG: True" -ForegroundColor Red
}
if ($rule.MoveToFolder) {
Write-Host " Move To: $($rule.MoveToFolder)"
}
if ($rule.From) {
Write-Host " From: $($rule.From)"
}
if ($rule.SubjectContainsWords) {
Write-Host " Subject Words: $($rule.SubjectContainsWords -join ', ')"
}
if ($rule.BodyContainsWords) {
Write-Host " Body Words: $($rule.BodyContainsWords -join ', ')"
}
}
} else {
Write-Host "No inbox rules found." -ForegroundColor Green
}
} catch {
Write-Host "Error getting inbox rules: $_" -ForegroundColor Red
}
# ── Check forwarding configuration ──────────────────────────────────
Write-Host "`n=== FORWARDING CONFIG for $targetUser ===" -ForegroundColor Cyan
try {
$mbx = Get-Mailbox -Identity $targetUser
if ($mbx.ForwardingAddress) {
Write-Host " ForwardingAddress: $($mbx.ForwardingAddress)" -ForegroundColor Red
} else {
Write-Host " ForwardingAddress: (none)" -ForegroundColor Green
}
if ($mbx.ForwardingSmtpAddress) {
Write-Host " ForwardingSmtpAddress: $($mbx.ForwardingSmtpAddress)" -ForegroundColor Red
} else {
Write-Host " ForwardingSmtpAddress: (none)" -ForegroundColor Green
}
Write-Host " DeliverToMailboxAndForward: $($mbx.DeliverToMailboxAndForward)"
} catch {
Write-Host "Error getting forwarding config: $_" -ForegroundColor Red
}
# ── Part 2: Recover Deleted Items (last 10 days) ───────────────────
Write-Host "`n=== RECOVERING DELETED ITEMS (last 10 days) ===" -ForegroundColor Cyan
Write-Host "Target: $targetUser" -ForegroundColor White
$startDate = (Get-Date).AddDays(-10)
$endDate = Get-Date
$dateRange = "$($startDate.ToString('yyyy-MM-dd'))..$($endDate.ToString('yyyy-MM-dd'))"
# Step 1: Try Get-RecoverableItems (requires Mailbox Import Export role)
Write-Host "`n--- Method 1: Get-RecoverableItems ---" -ForegroundColor White
try {
Write-Host "Scanning recoverable items from $dateRange..."
$preview = Get-RecoverableItems -Identity $targetUser -FilterStartTime $startDate -FilterEndTime $endDate -FilterItemType All
if ($preview) {
Write-Host "Found $($preview.Count) recoverable item(s):" -ForegroundColor Yellow
$preview | Group-Object ItemClass | ForEach-Object { Write-Host " $($_.Name): $($_.Count) items" }
$preview | Select-Object -First 20 | ForEach-Object {
$subj = if ($_.Subject) { $_.Subject } else { "(no subject)" }
Write-Host " [$($_.LastModifiedTime.ToString('MM/dd HH:mm'))] $subj"
}
Write-Host "`nRestoring all $($preview.Count) items..." -ForegroundColor Yellow
Restore-RecoverableItems -Identity $targetUser -FilterStartTime $startDate -FilterEndTime $endDate -FilterItemType All -Confirm:$false
Write-Host "Recovery complete!" -ForegroundColor Green
} else {
Write-Host "No recoverable items found." -ForegroundColor Green
}
} catch {
Write-Host "Get-RecoverableItems not available (needs Mailbox Import Export role)." -ForegroundColor Yellow
Write-Host "Falling back to Compliance Search..." -ForegroundColor Yellow
# Step 2: Connect to Security & Compliance and run a content search
Write-Host "`n--- Method 2: Compliance Search (eDiscovery) ---" -ForegroundColor White
try {
Connect-IPPSSession -UserPrincipalName sysadmin@bgbuildersllc.com -ShowBanner:$false
Write-Host "Connected to Security & Compliance Center." -ForegroundColor Green
$searchName = "LesleyRecovery_$(Get-Date -Format 'yyyyMMdd_HHmmss')"
$kql = "received>=$($startDate.ToString('yyyy-MM-dd')) AND received<=$($endDate.ToString('yyyy-MM-dd'))"
Write-Host "Creating compliance search: $searchName"
Write-Host " KQL: $kql"
Write-Host " Mailbox: $targetUser"
New-ComplianceSearch -Name $searchName `
-ExchangeLocation $targetUser `
-ContentMatchQuery $kql `
-Description "Recover deleted items for Lesley Roth - last 10 days" |
Out-Null
Write-Host "Starting search..." -ForegroundColor Yellow
Start-ComplianceSearch -Identity $searchName
# Poll for completion (max 5 minutes)
$maxWait = 300
$elapsed = 0
do {
Start-Sleep -Seconds 10
$elapsed += 10
$status = (Get-ComplianceSearch -Identity $searchName).Status
Write-Host " Status: $status ($elapsed sec)"
} while ($status -ne 'Completed' -and $elapsed -lt $maxWait)
$result = Get-ComplianceSearch -Identity $searchName
Write-Host "`nSearch Results:" -ForegroundColor Cyan
Write-Host " Status: $($result.Status)"
Write-Host " Items Found: $($result.Items)"
Write-Host " Size: $($result.Size)"
Write-Host " Success Results: $($result.SuccessResults)"
if ($result.Items -gt 0) {
Write-Host "`nItems found! To restore them:" -ForegroundColor Yellow
Write-Host " Option A: Use the Microsoft Purview portal > Content Search > '$searchName' > Export/Restore"
Write-Host " Option B: Run New-ComplianceSearchAction -SearchName '$searchName' -Purge -PurgeType SoftDelete"
Write-Host " (This moves items - for restore, use the Purview portal export instead)"
Write-Host "`n Purview URL: https://compliance.microsoft.com/contentsearchv2" -ForegroundColor Cyan
} else {
Write-Host "`nNo deleted items found in date range." -ForegroundColor Green
Write-Host "(Litigation hold preserves items in-place - they may still be in the mailbox)"
}
} catch {
Write-Host "Compliance search also failed: $_" -ForegroundColor Red
Write-Host "`nManual recovery options:" -ForegroundColor Yellow
Write-Host " 1. Outlook > Deleted Items > 'Recover items recently removed from this folder'"
Write-Host " (Log in as Barry/Shelly who have FullAccess)"
Write-Host " 2. CIPP > Mailbox Restore"
Write-Host " 3. Microsoft Purview portal > eDiscovery > Content Search"
Write-Host " URL: https://compliance.microsoft.com/contentsearchv2"
}
}
Write-Host "`n=== DONE ===" -ForegroundColor Cyan
Write-Host "Summary:"
Write-Host " - Inbox rules reviewed"
Write-Host " - Forwarding config checked"
Write-Host " - Deleted item recovery attempted"
Write-Host ""

View File

@@ -0,0 +1,71 @@
# BG Builders - Verify Lesley Device Wipe Status
$ErrorActionPreference = "Stop"
$tenantId = "ededa4fb-f6eb-4398-851d-5eb3e11fab27"
$lesleyUPN = "lesley@bgbuildersllc.com"
Write-Output "========================================="
Write-Output " Verify Device Wipe - Lesley Roth"
Write-Output " $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Output "========================================="
# --- Check Intune Managed Devices ---
Write-Output "`n[CHECK 1] Intune Managed Devices..."
Import-Module Microsoft.Graph.Authentication
Import-Module Microsoft.Graph.DeviceManagement
Connect-MgGraph -TenantId $tenantId -Scopes 'DeviceManagementManagedDevices.Read.All' -NoWelcome
Write-Output "[OK] Connected to Graph"
$devices = Get-MgDeviceManagementManagedDevice -Filter "userPrincipalName eq '$lesleyUPN'" 2>$null
if ($devices) {
foreach ($d in $devices) {
Write-Output ""
Write-Output " Device: $($d.DeviceName)"
Write-Output " OS: $($d.OperatingSystem) $($d.OsVersion)"
Write-Output " Compliance: $($d.ComplianceState)"
Write-Output " Management State: $($d.ManagementState)"
Write-Output " Last Sync: $($d.LastSyncDateTime)"
Write-Output " Device Action: $($d.DeviceActionResults | ForEach-Object { "$($_.ActionName): $($_.ActionState)" })"
}
} else {
Write-Output " [INFO] No Intune-managed devices found for $lesleyUPN"
}
Disconnect-MgGraph
# --- Check EAS Devices ---
Write-Output "`n[CHECK 2] Exchange ActiveSync Devices..."
Import-Module ExchangeOnlineManagement
Connect-ExchangeOnline -UserPrincipalName "sysadmin@bgbuildersllc.com" -ShowBanner:$false
Write-Output "[OK] Connected to Exchange Online"
$easDevices = Get-MobileDevice -Mailbox $lesleyUPN 2>$null
if ($easDevices) {
foreach ($eas in $easDevices) {
$stats = Get-MobileDeviceStatistics -Identity $eas.Identity 2>$null
Write-Output ""
Write-Output " Device: $($eas.FriendlyName)"
Write-Output " Type: $($eas.DeviceType)"
Write-Output " OS: $($eas.DeviceOS)"
Write-Output " Access State: $($eas.DeviceAccessState)"
Write-Output " First Sync: $($eas.FirstSyncTime)"
if ($stats) {
Write-Output " Last Sync: $($stats.LastSuccessSync)"
Write-Output " Wipe Status: $($stats.DeviceWipeSentTime)"
Write-Output " Wipe Ack: $($stats.DeviceWipeAckTime)"
Write-Output " Status: $($stats.Status)"
}
}
} else {
Write-Output " [INFO] No EAS devices found for $lesleyUPN"
}
# --- Check account status ---
Write-Output "`n[CHECK 3] Account Status..."
$mbx = Get-Mailbox -Identity $lesleyUPN -ErrorAction SilentlyContinue
if ($mbx) {
Write-Output " Mailbox Type: $($mbx.RecipientTypeDetails)"
Write-Output " Litigation Hold: $($mbx.LitigationHoldEnabled)"
}
Disconnect-ExchangeOnline -Confirm:$false
Write-Output "`n[OK] Verification complete"

View File

@@ -0,0 +1,119 @@
# BG Builders - Re-enable Lesley Roth + Add Shelly Delegate
# lesley@bgbuildersllc.com - was terminated 2026-02-27
# Actions:
# 1. Unblock sign-in
# 2. Reassign license
# 3. Add Shelly@bgbuildersllc.com as delegate (FullAccess + SendAs)
# 4. Enable litigation hold (prevent email deletion)
$ErrorActionPreference = "Stop"
$tenantId = "ededa4fb-f6eb-4398-851d-5eb3e11fab27"
$lesleyUPN = "lesley@bgbuildersllc.com"
$shellyUPN = "Shelly@bgbuildersllc.com"
Write-Output "========================================="
Write-Output " BG Builders - Re-enable Lesley Roth"
Write-Output " $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Output "========================================="
# --- STEP 1: Connect to Microsoft Graph ---
Write-Output "`n[STEP 1] Connecting to Microsoft Graph..."
Import-Module Microsoft.Graph.Authentication
Import-Module Microsoft.Graph.Users
Connect-MgGraph -TenantId $tenantId -Scopes 'User.ReadWrite.All','Organization.Read.All' -NoWelcome
Write-Output "[OK] Connected to Graph"
$lesley = Get-MgUser -UserId $lesleyUPN -Property Id,DisplayName,AccountEnabled,AssignedLicenses
Write-Output "[INFO] Lesley current state: AccountEnabled=$($lesley.AccountEnabled)"
# --- STEP 2: Unblock sign-in ---
Write-Output "`n[STEP 2] Unblocking sign-in..."
Update-MgUser -UserId $lesley.Id -AccountEnabled:$true
Write-Output "[OK] Sign-in unblocked for Lesley Roth"
# --- STEP 3: Reassign license ---
Write-Output "`n[STEP 3] Reassigning license..."
# List available SKUs to find the right one
$skus = Get-MgSubscribedSku -All
Write-Output "Available licenses:"
foreach ($sku in $skus) {
$available = $sku.PrepaidUnits.Enabled - $sku.ConsumedUnits
Write-Output " $($sku.SkuPartNumber) - $available available of $($sku.PrepaidUnits.Enabled) total"
}
# Assign Exchange Online Plan 1 (EXCHANGESTANDARD) - cheapest option for mailbox access
$exoPlan = $skus | Where-Object { $_.SkuPartNumber -eq "EXCHANGESTANDARD" }
if ($exoPlan) {
$availableCount = $exoPlan.PrepaidUnits.Enabled - $exoPlan.ConsumedUnits
if ($availableCount -gt 0) {
Set-MgUserLicense -UserId $lesley.Id -AddLicenses @(@{SkuId = $exoPlan.SkuId}) -RemoveLicenses @()
Write-Output "[OK] Assigned Exchange Online Plan 1 ($availableCount were available)"
} else {
Write-Output "[WARNING] No Exchange Online Plan 1 licenses available, trying Business Standard..."
$bizStd = $skus | Where-Object { $_.SkuPartNumber -eq "O365_BUSINESS_PREMIUM" }
if ($bizStd) {
$availableCount = $bizStd.PrepaidUnits.Enabled - $bizStd.ConsumedUnits
if ($availableCount -gt 0) {
Set-MgUserLicense -UserId $lesley.Id -AddLicenses @(@{SkuId = $bizStd.SkuId}) -RemoveLicenses @()
Write-Output "[OK] Assigned M365 Business Standard ($availableCount were available)"
} else {
Write-Output "[ERROR] No available licenses of either type - assign manually"
}
}
}
} else {
Write-Output "[WARNING] EXCHANGESTANDARD SKU not found, trying Business Standard..."
$bizStd = $skus | Where-Object { $_.SkuPartNumber -eq "O365_BUSINESS_PREMIUM" }
if ($bizStd) {
$availableCount = $bizStd.PrepaidUnits.Enabled - $bizStd.ConsumedUnits
if ($availableCount -gt 0) {
Set-MgUserLicense -UserId $lesley.Id -AddLicenses @(@{SkuId = $bizStd.SkuId}) -RemoveLicenses @()
Write-Output "[OK] Assigned M365 Business Standard ($availableCount were available)"
} else {
Write-Output "[ERROR] No available licenses - assign manually"
}
}
}
# --- STEP 4: Connect to Exchange Online ---
Write-Output "`n[STEP 4] Connecting to Exchange Online..."
Import-Module ExchangeOnlineManagement
Connect-ExchangeOnline -UserPrincipalName "sysadmin@bgbuildersllc.com" -ShowBanner:$false
Write-Output "[OK] Connected to Exchange Online"
# --- STEP 5: Add Shelly as delegate ---
Write-Output "`n[STEP 5] Adding Shelly as delegate on Lesley's mailbox..."
Add-MailboxPermission -Identity $lesleyUPN -User $shellyUPN -AccessRights FullAccess -AutoMapping $true
Write-Output "[OK] Shelly granted FullAccess (auto-mapped)"
Add-RecipientPermission -Identity $lesleyUPN -Trustee $shellyUPN -AccessRights SendAs -Confirm:$false
Write-Output "[OK] Shelly granted SendAs"
# --- STEP 6: Enable litigation hold ---
Write-Output "`n[STEP 6] Enabling litigation hold (prevent email deletion)..."
Set-Mailbox -Identity $lesleyUPN -LitigationHoldEnabled $true -LitigationHoldDuration Unlimited
Write-Output "[OK] Litigation hold enabled - emails cannot be permanently deleted"
# --- STEP 7: Verify ---
Write-Output "`n[STEP 7] Verifying permissions..."
$perms = Get-MailboxPermission -Identity $lesleyUPN | Where-Object { $_.User -notlike "NT AUTHORITY*" -and $_.User -notlike "S-1-*" }
Write-Output "Current mailbox permissions:"
foreach ($p in $perms) {
Write-Output " $($p.User) - $($p.AccessRights -join ', ')"
}
# --- DONE ---
Write-Output "`n========================================="
Write-Output " COMPLETE"
Write-Output " $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Output "========================================="
Write-Output ""
Write-Output "Summary:"
Write-Output " [OK] Lesley sign-in re-enabled"
Write-Output " [OK] License reassigned"
Write-Output " [OK] Shelly has FullAccess + SendAs on Lesley's mailbox"
Write-Output " [OK] Litigation hold enabled - no email can be permanently deleted"
Write-Output " [INFO] Barry still has access from termination script"
Disconnect-ExchangeOnline -Confirm:$false
Disconnect-MgGraph

View File

@@ -0,0 +1,166 @@
# BG Builders - Employee Termination Script
# Employee: Lesley Roth (lesley@bgbuildersllc.com)
# Scheduled: 2026-02-27 12:00 PM MST
# Actions:
# 1. Block sign-in
# 2. Revoke all sessions
# 3. Reset password
# 4. Selective wipe company data from mobile devices
# 5. Convert mailbox to shared
# 6. Grant Barry full access + send-as on shared mailbox
# 7. Remove from Employees group
# 8. Hide from GAL
# 9. Grant Barry OneDrive access
# 10. Remove license
$ErrorActionPreference = "Stop"
$tenantId = "ededa4fb-f6eb-4398-851d-5eb3e11fab27"
$lesleyUPN = "lesley@bgbuildersllc.com"
$barryUPN = "barry@bgbuildersllc.com"
Write-Output "========================================="
Write-Output " BG Builders - Lesley Roth Termination"
Write-Output " $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Output "========================================="
# --- STEP 1: Connect to Microsoft Graph ---
Write-Output "`n[STEP 1] Connecting to Microsoft Graph..."
Import-Module Microsoft.Graph.Authentication
Import-Module Microsoft.Graph.Users
Import-Module Microsoft.Graph.Users.Actions
Import-Module Microsoft.Graph.Identity.DirectoryManagement
Connect-MgGraph -TenantId $tenantId -Scopes 'User.ReadWrite.All','Directory.ReadWrite.All','Group.ReadWrite.All','DeviceManagementManagedDevices.ReadWrite.All','DeviceManagementManagedDevices.PrivilegedOperations.All' -NoWelcome
Write-Output "[OK] Connected to Graph"
# Get user IDs
$lesley = Get-MgUser -UserId $lesleyUPN -Property Id,DisplayName,AccountEnabled,AssignedLicenses
$barry = Get-MgUser -UserId $barryUPN -Property Id,DisplayName
Write-Output "[OK] Lesley ID: $($lesley.Id)"
Write-Output "[OK] Barry ID: $($barry.Id)"
# --- STEP 2: Block sign-in ---
Write-Output "`n[STEP 2] Blocking sign-in..."
Update-MgUser -UserId $lesley.Id -AccountEnabled:$false
Write-Output "[OK] Sign-in blocked"
# --- STEP 3: Revoke all sessions ---
Write-Output "`n[STEP 3] Revoking all active sessions..."
Revoke-MgUserSignInSession -UserId $lesley.Id
Write-Output "[OK] All sessions revoked"
# --- STEP 4: Reset password ---
Write-Output "`n[STEP 4] Resetting password..."
$newPassword = -join ((65..90) + (97..122) + (48..57) + (33,35,36,37,38) | Get-Random -Count 24 | ForEach-Object {[char]$_})
$params = @{
passwordProfile = @{
forceChangePasswordNextSignIn = $true
password = $newPassword
}
}
Update-MgUser -UserId $lesley.Id -BodyParameter $params
Write-Output "[OK] Password reset (stored securely - not displayed)"
# --- STEP 5: Selective wipe company data from mobile devices ---
Write-Output "`n[STEP 5] Checking for managed mobile devices..."
Import-Module Microsoft.Graph.DeviceManagement
$devices = Get-MgDeviceManagementManagedDevice -Filter "userPrincipalName eq '$lesleyUPN'" 2>$null
if ($devices) {
foreach ($device in $devices) {
Write-Output " Found device: $($device.DeviceName) ($($device.OperatingSystem)) - ID: $($device.Id)"
Write-Output " Initiating selective wipe (company data only)..."
# Retire = selective wipe (removes company data, leaves personal data)
Invoke-MgRetireDeviceManagementManagedDevice -ManagedDeviceId $device.Id
Write-Output " [OK] Selective wipe initiated for $($device.DeviceName)"
}
Write-Output "[OK] All managed devices queued for selective wipe"
} else {
Write-Output "[INFO] No Intune-managed devices found"
Write-Output "[INFO] Checking for EAS (Exchange ActiveSync) devices..."
}
# --- STEP 6: Connect to Exchange Online and convert mailbox ---
Write-Output "`n[STEP 6] Connecting to Exchange Online..."
Import-Module ExchangeOnlineManagement
Connect-ExchangeOnline -UserPrincipalName "sysadmin@bgbuildersllc.com" -ShowBanner:$false
Write-Output "[OK] Connected to Exchange Online"
# Check for ActiveSync devices and wipe company data
$easDevices = Get-MobileDevice -Mailbox $lesleyUPN 2>$null
if ($easDevices) {
foreach ($eas in $easDevices) {
Write-Output " Found EAS device: $($eas.FriendlyName) ($($eas.DeviceOS))"
# AccountOnly wipe - removes only the M365 account, not personal data
Clear-MobileDevice -Identity $eas.Identity -AccountOnly -Confirm:$false
Write-Output " [OK] Account-only wipe initiated for $($eas.FriendlyName)"
}
Write-Output "[OK] All EAS devices queued for account wipe"
} else {
Write-Output "[INFO] No EAS mobile devices found"
}
Write-Output "`n[STEP 6a] Converting mailbox to shared..."
Set-Mailbox -Identity $lesleyUPN -Type Shared
Write-Output "[OK] Mailbox converted to shared"
# --- STEP 7: Grant Barry full access and send-as ---
Write-Output "`n[STEP 7] Granting Barry full access to shared mailbox..."
Add-MailboxPermission -Identity $lesleyUPN -User $barryUPN -AccessRights FullAccess -AutoMapping $true
Write-Output "[OK] Full access granted"
Write-Output "Granting Barry send-as permission..."
Add-RecipientPermission -Identity $lesleyUPN -Trustee $barryUPN -AccessRights SendAs -Confirm:$false
Write-Output "[OK] Send-as granted"
# --- STEP 8: Remove from Employees group ---
Write-Output "`n[STEP 8] Removing from Employees group..."
$employeesGroup = Get-MgGroup -Filter "displayName eq 'Employees'" | Select-Object -First 1
if ($employeesGroup) {
Remove-MgGroupMemberByRef -GroupId $employeesGroup.Id -DirectoryObjectId $lesley.Id -ErrorAction SilentlyContinue
Write-Output "[OK] Removed from Employees group ($($employeesGroup.Id))"
} else {
Write-Output "[WARNING] Employees group not found"
}
# --- STEP 9: Hide from GAL ---
Write-Output "`n[STEP 9] Hiding shared mailbox from Global Address List..."
Set-Mailbox -Identity $lesleyUPN -HiddenFromAddressListsEnabled $true
Write-Output "[OK] Hidden from GAL"
# --- STEP 10: Remove license ---
Write-Output "`n[STEP 10] Removing licenses..."
$licenses = $lesley.AssignedLicenses
if ($licenses.Count -gt 0) {
$licenseIds = $licenses | ForEach-Object { $_.SkuId }
Set-MgUserLicense -UserId $lesley.Id -AddLicenses @() -RemoveLicenses $licenseIds
Write-Output "[OK] Removed $($licenseIds.Count) license(s)"
} else {
Write-Output "[INFO] No licenses assigned"
}
# --- STEP 11: Grant Barry OneDrive access ---
Write-Output "`n[STEP 11] Granting Barry access to Lesley's OneDrive..."
# Note: OneDrive access delegation requires SharePoint admin or may need manual step
Write-Output "[WARNING] OneDrive access must be granted via M365 Admin Center:"
Write-Output " Admin Center > Users > Lesley Roth > OneDrive tab > Create link to files"
Write-Output " Or: SharePoint Admin > User Profiles > Manage User Profiles > Lesley Roth > Manage site collection owners > Add Barry"
# --- DONE ---
Write-Output "`n========================================="
Write-Output " TERMINATION COMPLETE"
Write-Output " $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Output "========================================="
Write-Output ""
Write-Output "Summary:"
Write-Output " [OK] Sign-in blocked"
Write-Output " [OK] Sessions revoked"
Write-Output " [OK] Password reset"
Write-Output " [OK] Mobile devices - selective wipe initiated"
Write-Output " [OK] Mailbox converted to shared"
Write-Output " [OK] Barry has full access + send-as"
Write-Output " [OK] Removed from Employees group"
Write-Output " [OK] Hidden from GAL"
Write-Output " [OK] Licenses removed"
Write-Output " [WARNING] OneDrive access - manual step required"
Disconnect-ExchangeOnline -Confirm:$false
Disconnect-MgGraph

View File

@@ -0,0 +1,2 @@
@echo off
powershell.exe -ExecutionPolicy Bypass -File "D:\ClaudeTools\scripts\bgb-terminate-lesley.ps1" > "D:\ClaudeTools\scripts\bgb-terminate-lesley.log" 2>&1

View File

@@ -0,0 +1,16 @@
Import-Module Microsoft.Graph.Authentication
Import-Module Microsoft.Graph.Users
# Connect with interactive browser auth
Connect-MgGraph -TenantId 'ededa4fb-f6eb-4398-851d-5eb3e11fab27' -Scopes 'User.Read.All','User.ReadWrite.All','Directory.ReadWrite.All' -NoWelcome
# Find both users
$leslie = Get-MgUser -Filter "startsWith(displayName,'Leslie') or startsWith(mail,'leslie')" -Property DisplayName,Mail,UserPrincipalName,AccountEnabled,Id
$barry = Get-MgUser -Filter "startsWith(displayName,'Barry') or startsWith(mail,'barry')" -Property DisplayName,Mail,UserPrincipalName,AccountEnabled,Id
Write-Output '--- Leslie ---'
$leslie | Format-List DisplayName,Mail,UserPrincipalName,AccountEnabled,Id
Write-Output '--- Barry ---'
$barry | Format-List DisplayName,Mail,UserPrincipalName,AccountEnabled,Id
Disconnect-MgGraph

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
Import-Module Posh-SSH
$secPassword = ConvertTo-SecureString 'Paper123!@#' -AsPlainText -Force
$cred = New-Object System.Management.Automation.PSCredential('INTRANET\sysadmin', $secPassword)
$session = New-SSHSession -ComputerName 192.168.0.6 -Credential $cred -AcceptKey -Force -ConnectionTimeout 30
Write-Output "[OK] Connected to AD2"
$portCheck = @'
powershell -Command "foreach ($p in @(22,445,3389,5985)) { $t = New-Object System.Net.Sockets.TcpClient; $r = $t.BeginConnect('192.168.0.149', $p, $null, $null); $w = $r.AsyncWaitHandle.WaitOne(2000, $false); if ($w -and $t.Connected) { Write-Output \"$p : Open\"; $t.Close() } else { Write-Output \"$p : Closed\"; $t.Close() } }"
'@
Write-Output "`n=== Port Check 192.168.0.149 ==="
$result = Invoke-SSHCommand -SessionId $session.SessionId -Command $portCheck -TimeOut 30
Write-Output $result.Output
# If 445 is open, try PsExec-style via SMB to check creds
# If 5985 not open, try enabling WinRM via scheduled task
$cmd = @'
powershell -Command "Invoke-Command -ComputerName DESKTOP-Q33I5H1 -Credential (New-Object PSCredential('INTRANET\sysadmin',(ConvertTo-SecureString 'Paper123!@#' -AsPlainText -Force))) -ScriptBlock { cmdkey /list } -ErrorAction SilentlyContinue 2>&1"
'@
Write-Output "`n=== WinRM attempt ==="
$r2 = Invoke-SSHCommand -SessionId $session.SessionId -Command $cmd -TimeOut 30
Write-Output $r2.Output
if ($r2.Error) { Write-Output $r2.Error }
Remove-SSHSession -SessionId $session.SessionId | Out-Null

View File

@@ -0,0 +1,62 @@
$secPassword = ConvertTo-SecureString 'Paper123!@#' -AsPlainText -Force
$cred = New-Object System.Management.Automation.PSCredential('INTRANET\sysadmin', $secPassword)
# Query lockout events from AD1 via AD2 (same subnet hop)
Invoke-Command -ComputerName 192.168.0.6 -Credential $cred -Authentication Negotiate -ScriptBlock {
# Query AD1's event log from AD2 (both on same subnet)
Write-Output "=== Lockout Events (4740) from AD1 ==="
try {
$lockouts = Get-WinEvent -ComputerName AD1 -FilterHashtable @{LogName='Security'; Id=4740; StartTime=(Get-Date).AddDays(-7)} -ErrorAction Stop |
Where-Object { $_.Properties[0].Value -eq 'jlohr' } |
Select-Object -First 30
foreach ($e in $lockouts) {
Write-Output "$($e.TimeCreated) | Caller: $($e.Properties[1].Value)"
}
if (-not $lockouts) { Write-Output " None found" }
} catch { Write-Output " ERROR: $_" }
Write-Output "`n=== Kerberos Failures (4771) from AD1 ==="
try {
$k = Get-WinEvent -ComputerName AD1 -FilterHashtable @{LogName='Security'; Id=4771; StartTime=(Get-Date).AddDays(-3)} -ErrorAction Stop |
Where-Object { $_.Properties[0].Value -eq 'jlohr' } |
Select-Object -First 30
foreach ($e in $k) {
Write-Output "$($e.TimeCreated) | IP: $($e.Properties[6].Value) | Status: $($e.Properties[4].Value)"
}
if (-not $k) { Write-Output " None found" }
} catch { Write-Output " ERROR: $_" }
Write-Output "`n=== NTLM Failures (4776) from AD1 ==="
try {
$n = Get-WinEvent -ComputerName AD1 -FilterHashtable @{LogName='Security'; Id=4776; StartTime=(Get-Date).AddDays(-3)} -ErrorAction Stop |
Where-Object { $_.Properties[1].Value -eq 'jlohr' -and $_.Properties[2].Value -ne 0 } |
Select-Object -First 30
foreach ($e in $n) {
Write-Output "$($e.TimeCreated) | Workstation: $($e.Properties[0].Value) | Error: $($e.Properties[2].Value)"
}
if (-not $n) { Write-Output " None found" }
} catch { Write-Output " ERROR: $_" }
Write-Output "`n=== Logon Failures (4625) from AD1 ==="
try {
$f = Get-WinEvent -ComputerName AD1 -FilterHashtable @{LogName='Security'; Id=4625; StartTime=(Get-Date).AddDays(-3)} -ErrorAction Stop |
Where-Object { $_.Properties[5].Value -eq 'jlohr' } |
Select-Object -First 30
foreach ($e in $f) {
Write-Output "$($e.TimeCreated) | Source: $($e.Properties[13].Value) ($($e.Properties[19].Value)) | Type: $($e.Properties[10].Value) | Reason: $($e.Properties[8].Value)"
}
if (-not $f) { Write-Output " None found" }
} catch { Write-Output " ERROR: $_" }
# Also check AD2's own logs
Write-Output "`n=== Lockout Events (4740) from AD2 ==="
try {
$l2 = Get-WinEvent -FilterHashtable @{LogName='Security'; Id=4740; StartTime=(Get-Date).AddDays(-7)} -ErrorAction Stop |
Where-Object { $_.Properties[0].Value -eq 'jlohr' } |
Select-Object -First 30
foreach ($e in $l2) {
Write-Output "$($e.TimeCreated) | Caller: $($e.Properties[1].Value)"
}
if (-not $l2) { Write-Output " None found" }
} catch { Write-Output " ERROR: $_" }
} -ErrorAction Stop

27
scripts/df-test-winrm.ps1 Normal file
View File

@@ -0,0 +1,27 @@
$secPassword = ConvertTo-SecureString 'Paper123!@#' -AsPlainText -Force
$cred = New-Object System.Management.Automation.PSCredential('INTRANET\sysadmin', $secPassword)
Write-Output "Testing Negotiate auth..."
try {
$result = Invoke-Command -ComputerName 192.168.0.27 -Credential $cred -Authentication Negotiate -ScriptBlock { hostname } -ErrorAction Stop
Write-Output "[OK] Negotiate: $result"
} catch {
Write-Output "[FAIL] Negotiate: $_"
}
Write-Output "`nTesting Default auth..."
try {
$result = Invoke-Command -ComputerName 192.168.0.27 -Credential $cred -ScriptBlock { hostname } -ErrorAction Stop
Write-Output "[OK] Default: $result"
} catch {
Write-Output "[FAIL] Default: $_"
}
Write-Output "`nTesting with SessionOption..."
try {
$so = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
$result = Invoke-Command -ComputerName 192.168.0.27 -Credential $cred -Authentication Negotiate -SessionOption $so -ScriptBlock { hostname } -ErrorAction Stop
Write-Output "[OK] SessionOption: $result"
} catch {
Write-Output "[FAIL] SessionOption: $_"
}

290
scripts/migration-pack.sh Normal file
View File

@@ -0,0 +1,290 @@
#!/bin/bash
###############################################################################
# migration-pack.sh
#
# Creates an encrypted migration archive of all non-git ClaudeTools data.
# Works in Git Bash on Windows AND native Linux bash.
#
# Usage: ./migration-pack.sh [source_dir]
# source_dir Path to ClaudeTools repo (default: script's parent directory)
#
# Output: claudetools-migration-YYYYMMDD.tar.gpg in current working directory
###############################################################################
set -euo pipefail
# ---------------------------------------------------------------------------
# Globals
# ---------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SOURCE_DIR="${1:-"$(cd "$SCRIPT_DIR/.." && pwd)"}"
DATE_STAMP="$(date +%Y%m%d)"
ARCHIVE_NAME="claudetools-migration-${DATE_STAMP}.tar.gpg"
STAGING_DIR=""
MANIFEST_FILE="MIGRATION_MANIFEST.txt"
WARN_COUNT=0
COPY_COUNT=0
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
log_info() { echo "[INFO] $*"; }
log_ok() { echo "[OK] $*"; }
log_warn() { echo "[WARNING] $*"; WARN_COUNT=$((WARN_COUNT + 1)); }
log_error() { echo "[ERROR] $*"; }
cleanup() {
if [[ -n "$STAGING_DIR" && -d "$STAGING_DIR" ]]; then
log_info "Cleaning up staging directory..."
rm -rf "$STAGING_DIR"
fi
}
trap cleanup EXIT
check_tool() {
if ! command -v "$1" &>/dev/null; then
log_error "Required tool not found: $1"
exit 1
fi
}
# Copy a single file into the staging area, preserving relative path.
# Warns and continues if the source does not exist.
stage_file() {
local rel_path="$1"
local src="${SOURCE_DIR}/${rel_path}"
local dst="${STAGING_DIR}/${rel_path}"
if [[ ! -e "$src" ]]; then
log_warn "File not found, skipping: ${rel_path}"
return
fi
mkdir -p "$(dirname "$dst")"
cp -a "$src" "$dst"
COPY_COUNT=$((COPY_COUNT + 1))
log_ok "Staged: ${rel_path}"
}
# Copy an entire directory into the staging area.
stage_dir() {
local rel_path="$1"
local src="${SOURCE_DIR}/${rel_path}"
local dst="${STAGING_DIR}/${rel_path}"
if [[ ! -d "$src" ]]; then
log_warn "Directory not found, skipping: ${rel_path}"
return
fi
mkdir -p "$(dirname "$dst")"
cp -a "$src" "$dst"
COPY_COUNT=$((COPY_COUNT + 1))
log_ok "Staged directory: ${rel_path}"
}
# Detect the Claude AI context directory based on platform conventions.
# On Windows (Git Bash), the repo path D:\ClaudeTools becomes D--ClaudeTools.
# On Linux/macOS, /home/user/ClaudeTools becomes -home-user-ClaudeTools.
detect_claude_context_dir() {
local claude_projects_base="${HOME}/.claude/projects"
if [[ ! -d "$claude_projects_base" ]]; then
echo ""
return
fi
# Try Windows-style mapping first: D:\ClaudeTools -> D--ClaudeTools
# Convert SOURCE_DIR from /d/path or D:/path to D--path
local win_name=""
if [[ "$SOURCE_DIR" =~ ^/([a-zA-Z])/(.*) ]]; then
# Git Bash path like /d/ClaudeTools
local drive="${BASH_REMATCH[1]^^}"
local rest="${BASH_REMATCH[2]}"
win_name="${drive}--${rest//\//-}"
elif [[ "$SOURCE_DIR" =~ ^([a-zA-Z]):(.*) ]]; then
# Windows path like D:\ClaudeTools or D:/ClaudeTools
local drive="${BASH_REMATCH[1]^^}"
local rest="${BASH_REMATCH[2]}"
rest="${rest//\\/-}"
rest="${rest//\//-}"
rest="${rest#-}"
win_name="${drive}--${rest}"
fi
if [[ -n "$win_name" && -d "${claude_projects_base}/${win_name}" ]]; then
echo "${claude_projects_base}/${win_name}"
return
fi
# Try Linux-style mapping: absolute path with slashes replaced by dashes
local linux_name="${SOURCE_DIR//\//-}"
linux_name="${linux_name#-}"
if [[ -d "${claude_projects_base}/${linux_name}" ]]; then
echo "${claude_projects_base}/${linux_name}"
return
fi
echo ""
}
# Write a manifest of everything in the staging directory.
write_manifest() {
local manifest="${STAGING_DIR}/${MANIFEST_FILE}"
{
echo "============================================================"
echo " ClaudeTools Migration Manifest"
echo " Created: $(date '+%Y-%m-%d %H:%M:%S')"
echo " Source: ${SOURCE_DIR}"
echo " Host: $(hostname)"
echo "============================================================"
echo ""
echo "Contents:"
echo "------------------------------------------------------------"
# Use find to list all files with sizes.
# On Git Bash, stat flags differ from GNU coreutils; use portable approach.
find "$STAGING_DIR" -type f ! -name "$MANIFEST_FILE" -print0 | while IFS= read -r -d '' file; do
local rel="${file#"${STAGING_DIR}/"}"
local size
size="$(wc -c < "$file" 2>/dev/null || echo "?")"
printf " %-60s %s bytes\n" "$rel" "$size"
done | sort
echo "------------------------------------------------------------"
echo ""
# Directory count and file count
local dir_count file_count total_size
dir_count="$(find "$STAGING_DIR" -mindepth 1 -type d | wc -l)"
file_count="$(find "$STAGING_DIR" -type f ! -name "$MANIFEST_FILE" | wc -l)"
total_size="$(find "$STAGING_DIR" -type f ! -name "$MANIFEST_FILE" -print0 | xargs -0 wc -c 2>/dev/null | tail -n1 | awk '{print $1}')"
echo "Directories: ${dir_count}"
echo "Files: ${file_count}"
echo "Total size: ${total_size:-0} bytes"
} > "$manifest"
log_ok "Manifest written: ${MANIFEST_FILE}"
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
echo "============================================================"
echo " ClaudeTools Migration Packer"
echo " $(date '+%Y-%m-%d %H:%M:%S')"
echo "============================================================"
echo ""
# Validate source directory
if [[ ! -d "$SOURCE_DIR" ]]; then
log_error "Source directory does not exist: ${SOURCE_DIR}"
exit 1
fi
log_info "Source directory: ${SOURCE_DIR}"
# Check required tools
check_tool tar
check_tool gpg
log_ok "Required tools available (tar, gpg)"
# Create staging directory
STAGING_DIR="$(mktemp -d 2>/dev/null || mktemp -d -t 'migration')"
log_info "Staging directory: ${STAGING_DIR}"
echo ""
# ------------------------------------------------------------------
# Stage individual files
# ------------------------------------------------------------------
log_info "--- Staging individual files ---"
stage_file "credentials.md"
stage_file ".env"
stage_file ".mcp.json"
stage_file "dataforth-notifications-creds.txt"
stage_file ".claude/settings.local.json"
stage_file "projects/solverbot/.env"
stage_file "session-logs/2026-02-25-session.md"
echo ""
# ------------------------------------------------------------------
# Stage directories
# ------------------------------------------------------------------
log_info "--- Staging directories ---"
stage_dir "imported-conversations"
stage_dir "backups"
stage_dir "clients/gurushow"
echo ""
# ------------------------------------------------------------------
# Stage Claude AI context
# ------------------------------------------------------------------
log_info "--- Staging Claude AI context ---"
local claude_ctx
claude_ctx="$(detect_claude_context_dir)"
if [[ -n "$claude_ctx" && -d "$claude_ctx" ]]; then
local ctx_dst="${STAGING_DIR}/claude-context"
mkdir -p "$ctx_dst"
cp -a "$claude_ctx"/. "$ctx_dst/"
COPY_COUNT=$((COPY_COUNT + 1))
log_ok "Staged Claude context from: ${claude_ctx}"
else
log_warn "Claude AI context directory not found. Looked under \$HOME/.claude/projects/"
log_warn "You may need to manually copy this after migration."
fi
echo ""
# ------------------------------------------------------------------
# Write manifest
# ------------------------------------------------------------------
log_info "--- Writing manifest ---"
write_manifest
echo ""
# ------------------------------------------------------------------
# Create encrypted archive
# ------------------------------------------------------------------
log_info "--- Creating encrypted archive ---"
log_info "You will be prompted for a passphrase to encrypt the archive."
echo ""
# Create tar from staging contents, then encrypt with GPG symmetric.
# Use --batch only if GPG_PASSPHRASE env var is set (for automation).
local tar_tmp="${STAGING_DIR}.tar"
tar -cf "$tar_tmp" -C "$STAGING_DIR" .
if [[ -n "${GPG_PASSPHRASE:-}" ]]; then
echo "$GPG_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \
--symmetric --cipher-algo AES256 \
--output "$ARCHIVE_NAME" "$tar_tmp"
else
gpg --symmetric --cipher-algo AES256 \
--output "$ARCHIVE_NAME" "$tar_tmp"
fi
rm -f "$tar_tmp"
if [[ ! -f "$ARCHIVE_NAME" ]]; then
log_error "Archive creation failed."
exit 1
fi
local archive_size
archive_size="$(wc -c < "$ARCHIVE_NAME")"
echo ""
echo "============================================================"
echo " Migration Pack Complete"
echo "============================================================"
echo ""
echo " Archive: $(pwd)/${ARCHIVE_NAME}"
echo " Size: ${archive_size} bytes"
echo " Items: ${COPY_COUNT} files/directories staged"
echo " Warnings: ${WARN_COUNT}"
echo " Encrypted: AES-256 (GPG symmetric)"
echo ""
echo " To restore, run:"
echo " ./migration-restore.sh ${ARCHIVE_NAME}"
echo ""
log_ok "Done."
}
main "$@"

View File

@@ -0,0 +1,296 @@
#!/bin/bash
###############################################################################
# migration-restore.sh
#
# Restores a ClaudeTools environment from an encrypted migration archive.
# Works in Git Bash on Windows AND native Linux bash.
#
# Usage: ./migration-restore.sh <archive.tar.gpg> [target_dir]
# archive.tar.gpg Path to the encrypted migration archive
# target_dir Where to clone/restore (default: $HOME/ClaudeTools)
###############################################################################
set -euo pipefail
# ---------------------------------------------------------------------------
# Globals
# ---------------------------------------------------------------------------
ARCHIVE_PATH="${1:-}"
TARGET_DIR="${2:-"${HOME}/ClaudeTools"}"
GITEA_REPO="ssh://git@172.16.3.20:2222/azcomputerguru/claudetools.git"
TEMP_EXTRACT=""
WARN_COUNT=0
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
log_info() { echo "[INFO] $*"; }
log_ok() { echo "[OK] $*"; }
log_warn() { echo "[WARNING] $*"; WARN_COUNT=$((WARN_COUNT + 1)); }
log_error() { echo "[ERROR] $*"; }
cleanup() {
if [[ -n "$TEMP_EXTRACT" && -d "$TEMP_EXTRACT" ]]; then
log_info "Cleaning up temporary extraction directory..."
rm -rf "$TEMP_EXTRACT"
fi
}
trap cleanup EXIT
check_tool() {
local tool="$1"
if ! command -v "$tool" &>/dev/null; then
log_error "Required tool not found: ${tool}"
log_error "Please install ${tool} before running this script."
exit 1
fi
log_ok "Found: ${tool}"
}
# Derive the Claude projects directory name from the target path.
# On Windows (Git Bash): /d/ClaudeTools -> D--ClaudeTools
# On Linux: /home/user/ClaudeTools -> -home-user-ClaudeTools
derive_claude_project_name() {
local abs_target
abs_target="$(cd "$TARGET_DIR" && pwd)"
# Check if we are on Windows (Git Bash) by looking at path format
if [[ "$abs_target" =~ ^/([a-zA-Z])/(.*) ]]; then
# Git Bash path: /d/ClaudeTools -> D--ClaudeTools
local drive="${BASH_REMATCH[1]^^}"
local rest="${BASH_REMATCH[2]}"
echo "${drive}--${rest//\//-}"
elif [[ "$abs_target" =~ ^([a-zA-Z]):(.*) ]]; then
# Raw Windows path: D:\ClaudeTools -> D--ClaudeTools
local drive="${BASH_REMATCH[1]^^}"
local rest="${BASH_REMATCH[2]}"
rest="${rest//\\/-}"
rest="${rest//\//-}"
rest="${rest#-}"
echo "${drive}--${rest}"
else
# Linux/macOS: /home/user/ClaudeTools -> -home-user-ClaudeTools
local name="${abs_target//\//-}"
name="${name#-}"
echo "${name}"
fi
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
echo "============================================================"
echo " ClaudeTools Migration Restore"
echo " $(date '+%Y-%m-%d %H:%M:%S')"
echo "============================================================"
echo ""
# ------------------------------------------------------------------
# Validate arguments
# ------------------------------------------------------------------
if [[ -z "$ARCHIVE_PATH" ]]; then
log_error "Usage: $0 <archive.tar.gpg> [target_dir]"
log_error " archive.tar.gpg Encrypted migration archive"
log_error " target_dir Restore location (default: \$HOME/ClaudeTools)"
exit 1
fi
if [[ ! -f "$ARCHIVE_PATH" ]]; then
log_error "Archive not found: ${ARCHIVE_PATH}"
exit 1
fi
log_info "Archive: ${ARCHIVE_PATH}"
log_info "Target dir: ${TARGET_DIR}"
echo ""
# ------------------------------------------------------------------
# Check required tools
# ------------------------------------------------------------------
log_info "--- Checking required tools ---"
check_tool git
check_tool gpg
check_tool tar
echo ""
# ------------------------------------------------------------------
# Decrypt archive
# ------------------------------------------------------------------
log_info "--- Decrypting archive ---"
log_info "You will be prompted for the passphrase."
echo ""
TEMP_EXTRACT="$(mktemp -d 2>/dev/null || mktemp -d -t 'migration-restore')"
local tar_tmp="${TEMP_EXTRACT}/archive.tar"
if [[ -n "${GPG_PASSPHRASE:-}" ]]; then
echo "$GPG_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \
--decrypt --output "$tar_tmp" "$ARCHIVE_PATH"
else
gpg --decrypt --output "$tar_tmp" "$ARCHIVE_PATH"
fi
if [[ ! -f "$tar_tmp" ]]; then
log_error "Decryption failed. Check your passphrase and try again."
exit 1
fi
log_ok "Archive decrypted."
echo ""
# ------------------------------------------------------------------
# Extract archive to temp location
# ------------------------------------------------------------------
log_info "--- Extracting archive ---"
local extract_dir="${TEMP_EXTRACT}/contents"
mkdir -p "$extract_dir"
tar -xf "$tar_tmp" -C "$extract_dir"
rm -f "$tar_tmp"
log_ok "Archive extracted."
# Show manifest if present
if [[ -f "${extract_dir}/MIGRATION_MANIFEST.txt" ]]; then
echo ""
log_info "--- Migration Manifest ---"
cat "${extract_dir}/MIGRATION_MANIFEST.txt"
echo ""
fi
# ------------------------------------------------------------------
# Clone repository
# ------------------------------------------------------------------
log_info "--- Cloning repository ---"
if [[ -d "$TARGET_DIR/.git" ]]; then
log_warn "Target directory already contains a git repo: ${TARGET_DIR}"
log_info "Skipping clone; will overlay migration files into existing repo."
elif [[ -d "$TARGET_DIR" ]]; then
# Directory exists but is not a git repo
log_warn "Target directory exists but is not a git repo: ${TARGET_DIR}"
log_info "Attempting clone into existing directory..."
git clone "$GITEA_REPO" "${TARGET_DIR}.tmp"
# Move .git and tracked files into existing directory
mv "${TARGET_DIR}.tmp/.git" "${TARGET_DIR}/"
# Checkout working tree into existing directory
(cd "$TARGET_DIR" && git checkout -- .)
rm -rf "${TARGET_DIR}.tmp"
log_ok "Cloned repository into existing directory."
else
git clone "$GITEA_REPO" "$TARGET_DIR"
log_ok "Cloned repository to: ${TARGET_DIR}"
fi
echo ""
# ------------------------------------------------------------------
# Overlay non-git files from migration archive
# ------------------------------------------------------------------
log_info "--- Restoring non-git files ---"
# Copy everything except claude-context (handled separately) and manifest
local item
for item in "$extract_dir"/*; do
local basename
basename="$(basename "$item")"
# Skip claude-context dir and manifest
if [[ "$basename" == "claude-context" || "$basename" == "MIGRATION_MANIFEST.txt" ]]; then
continue
fi
if [[ -d "$item" ]]; then
cp -a "$item" "$TARGET_DIR/"
log_ok "Restored directory: ${basename}"
elif [[ -f "$item" ]]; then
cp -a "$item" "$TARGET_DIR/"
log_ok "Restored file: ${basename}"
fi
done
# Handle dotfiles (hidden files/dirs from archive root)
for item in "$extract_dir"/.*; do
local basename
basename="$(basename "$item")"
[[ "$basename" == "." || "$basename" == ".." ]] && continue
if [[ -d "$item" ]]; then
# Merge directory contents (e.g., .claude/)
cp -a "$item"/. "$TARGET_DIR/${basename}/" 2>/dev/null || cp -a "$item" "$TARGET_DIR/"
log_ok "Restored directory: ${basename}"
elif [[ -f "$item" ]]; then
cp -a "$item" "$TARGET_DIR/"
log_ok "Restored file: ${basename}"
fi
done
echo ""
# ------------------------------------------------------------------
# Restore Claude AI context
# ------------------------------------------------------------------
log_info "--- Restoring Claude AI context ---"
local claude_ctx_src="${extract_dir}/claude-context"
if [[ -d "$claude_ctx_src" ]]; then
local project_name
project_name="$(derive_claude_project_name)"
local claude_ctx_dst="${HOME}/.claude/projects/${project_name}"
mkdir -p "$claude_ctx_dst"
cp -a "$claude_ctx_src"/. "$claude_ctx_dst/"
log_ok "Restored Claude context to: ${claude_ctx_dst}"
else
log_warn "No claude-context directory found in archive. Skipping."
fi
echo ""
# ------------------------------------------------------------------
# Initialize submodules
# ------------------------------------------------------------------
log_info "--- Initializing git submodules ---"
(cd "$TARGET_DIR" && git submodule update --init --recursive) && \
log_ok "Submodules initialized." || \
log_warn "Submodule initialization had issues. You may need to run it manually."
echo ""
# ------------------------------------------------------------------
# Summary and post-restore checklist
# ------------------------------------------------------------------
echo "============================================================"
echo " Restore Complete"
echo "============================================================"
echo ""
echo " Target: ${TARGET_DIR}"
echo " Warnings: ${WARN_COUNT}"
echo ""
echo "============================================================"
echo " Post-Restore Checklist"
echo "============================================================"
echo ""
echo " [ ] Verify credentials.md contains correct, unredacted values"
echo " File: ${TARGET_DIR}/credentials.md"
echo ""
echo " [ ] Set up Python virtual environment for MCP servers"
echo " cd ${TARGET_DIR} && python -m venv .venv"
echo " source .venv/bin/activate (or .venv\\Scripts\\activate on Windows)"
echo " pip install -r requirements.txt"
echo ""
echo " [ ] Configure Claude Code CLI"
echo " Run: claude (first launch will prompt for authentication)"
echo " Verify .claude/ context was restored correctly"
echo ""
echo " [ ] Test Gitea SSH access"
echo " Run: ssh -p 2222 git@172.16.3.20"
echo " If it fails, copy your SSH keys and update ~/.ssh/config"
echo ""
echo " [ ] Rebuild grepai index"
echo " The semantic search index is machine-specific."
echo " Run grepai indexing from within Claude Code."
echo ""
echo " [ ] Verify .env and .mcp.json values are correct for new machine"
echo ""
echo " [ ] Test database connectivity"
echo " Ensure 172.16.3.30:3306 is reachable from this machine"
echo ""
log_ok "Done."
}
main "$@"

5375
scripts/perms.json Normal file

File diff suppressed because it is too large Load Diff

639
scripts/sam.json Normal file
View File

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

View File

@@ -0,0 +1,158 @@
# Session Log: 2026-02-25
## Session Summary
Continued diagnostics on Peaceful Spirit Country Club UCG Ultra speed issues. Performed SSH-based monitoring, identified ECM crash-loop patterns, rebooted gateway, and ran 15-minute stability monitoring. Gateway fully exonerated -- issue confirmed as Cox plant-side.
---
## Peaceful Spirit Country Club - UCG Ultra Continued Diagnostics
### Pre-Reboot Findings (via SSH)
Connected via VPN to 192.168.0.10 after fixing SSH key (had to add to `/root/.ssh/authorized_keys` directly -- GUI-added key required password).
**ECM crash-loop confirmed ongoing:**
- ECM was NOT loaded (`lsmod | grep ecm` = empty)
- Cycle pattern from dmesg: runs 2-6 minutes, crashes, stays down 15-39 minutes
- Last cycle before reboot: init at 89499s, exit at 89638s (~2 min run), then never reloaded
**Other findings:**
- Load average: 1.26 (elevated, CPU handling all forwarding without ECM)
- Memory: 1169 MB / 2947 MB (40%), 65 MB swap used
- IDS/IPS: confirmed OFF (no suricata process)
- eth4 RX: 4 errors, 4 CRC errors (physical layer corruption from modem)
- WAN link flap: eth4 went down for 6 seconds at 76591s (modem sync loss)
- QUIC reassembly failures: multiple bursts, including triple failure at 96270s
- WireGuard tunnel: down (VPN was hung, had to be restarted on our side)
### Reboot and Hardware Acceleration
User rebooted UCG Ultra. Initial post-reboot check (7 min uptime):
- ECM was NOT loaded -- initially suspected PCIe probe failure (`qcom-pcie: probe of 20000000.pcie failed with error -110`)
- Actual cause: **Hardware Acceleration was disabled in UI settings**
- User re-enabled Hardware Acceleration
- ECM loaded immediately: `ECM init` at 669s, `ECM init complete` at 669s
### 15-Minute Stability Monitoring
Ran automated check every 60 seconds for 15 minutes (08:24 - 08:39).
**Results:**
- ECM: STABLE for entire 15 minutes -- zero crashes, zero restarts
- RX errors: 0 across all 15 checks
- CRC errors: 0 across all 15 checks
- Drops: 0 both directions
- QUIC failures: 0
- Link flaps: 0
- dmesg: clean -- only the initial ECM init message
**Load trend:**
| Time | Load (1m) | Load (5m) | Load (15m) |
|------|-----------|-----------|------------|
| 08:24 | 1.53 | 1.43 | 0.92 |
| 08:28 | 1.33 | 1.43 | 1.04 |
| 08:32 | 1.74 | 1.57 | 1.19 |
| 08:36 | 2.12 | 1.72 | 1.33 |
| 08:38 | 2.32 | 1.80 | 1.38 |
| 08:39 | 1.74 | 1.73 | 1.38 |
Load persistently above 1.0 -- likely WireGuard VPN crypto (can't be offloaded to ECM).
### Configuration Changes Made
1. **IDS/IPS:** Disabled (was on High) -- done 2026-02-25 earlier
2. **Hardware Acceleration:** Re-enabled after reboot
3. **MSS Clamping:** Changed from Custom 1452 to Auto
- iptables now shows `clamp to PMTU` on tun1 only (correct behavior)
- No MSS rules on eth4/WAN (confirmed -- MSS setting never affected WAN traffic)
### Speed Test Results
- Post-reboot with ECM running: **29/28 Mbps** (300/30 provisioned)
- Upload hitting near-provisioned speed (28 of 30)
- Download at ~10% of provisioned (29 of 300)
- Occasionally achieves full provisioned speeds (200-278 Mbps seen previously)
### Final Status Check (08:41, 33 min uptime)
- ECM: loaded, stable
- Load: 1.25 (trending down)
- Memory: 981 MB / 2947 MB (33%), 2 MB swap
- eth4: 0 errors, 0 CRC, 0 drops
- dmesg: clean since boot
- MSS: Auto, clamp to PMTU on tun1 only
### Sequential Thinking Re-Evaluation
Performed full sequential thinking analysis (8 steps) re-evaluating all evidence:
**Two overlapping problems identified:**
**Problem 1 - Cox Plant (Primary):**
- Speed decays from 200+ to 70 Mbps under sustained load = marginal DOCSIS channels de-bonding
- 50% packet loss at all packet sizes = not MTU or gateway related
- Download degraded, upload stable = downstream RF path
- New modem, same symptoms = rules out CPE
- Persists with all gateway configurations tested
- Occasionally hits provisioned speed = CMTS config is correct, channels are marginal
**Problem 2 - Gateway ECM (Secondary, resolved):**
- ECM crash-loop amplified plant symptoms (caused <1 Mbps drops)
- Resolved by: disabling IDS/IPS, rebooting, re-enabling HW acceleration
- 15-minute monitoring confirms stable operation
### Summary Prepared for Cox Tech
> **Site:** Peaceful Spirit Country Club
> **Circuit:** 300/30 Mbps | **IP:** 98.190.129.150
> **Modem:** New (replaced prior day) - same symptoms
>
> Download speeds start at 200+ then decay to 29-70 Mbps under load. Intermittent drops to <1 Mbps. 50% packet loss at all sizes. Upload stable at 28-29 Mbps. Modem intermittently achieves full provisioned speed, proving CMTS config is correct.
>
> Customer gateway fully eliminated: 15 min monitoring shows zero errors at every layer, hardware offload stable, zero CRC errors.
>
> Pattern consistent with marginal downstream DOCSIS channels bonding/de-bonding as signal conditions fluctuate.
>
> Tech should check: downstream signal levels/SNR, uncorrectable codewords, T3/T4 timeouts, tap/drop/connectors for corrosion, amplifier health, node health.
---
## SSH Access Reference
- **Host:** 192.168.0.10 (via VPN) or 98.190.129.150 (WAN)
- **User:** root
- **Key:** `~/.ssh/ucg_peaceful_spirit` (ed25519)
- **Public key:** `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKBw+BK25MXpm91XBtDsSp7K0nTcKwFDLFZDx7tAO/N8 claude@claudetools`
- **Auth method:** Key added to `/root/.ssh/authorized_keys` (NOT via UniFi GUI)
- **Note:** GUI-added keys require password; direct authorized_keys works with key-only
### Current UCG Config (post-changes)
- Hardware Acceleration: ON
- IDS/IPS: Disabled
- MSS Clamping: Auto (clamp to PMTU on VPN tunnels)
- Jumbo Frames: OFF
- SNMP: OFF
- ARP Cache: Min DHCP lease
- Auto Firewall State Timeouts: ON
- Global NAT: Auto
- Connection Tracking: FTP, H.323, GRE, PPTP, TFTP
---
## Pending Tasks
### Peaceful Spirit
- [ ] Cox tech visit -- confirm plant-side fix resolves speed issues
- [ ] After Cox fix: re-test speeds to verify 300 Mbps sustained
- [ ] Consider re-enabling IDS/IPS at Medium/Low after Cox plant is fixed
- [ ] Monitor ECM stability over coming days
- [ ] Investigate persistent high load (1.2-2.3) -- likely WireGuard related
### From Previous Session (2026-02-24)
- [ ] Yealink: Get IP Discovery Tool from distributor for serial extraction
- [ ] Yealink: Test browser-based scanner (tools/yealink-serial-scanner.html)
- [ ] Yealink: Onboard remaining phones into YMCS
- [ ] Yealink: Build OIT VoIP templates when ready for migration
- [ ] Clean up tools/test-yealink.ps1

50
temp/_debug_graph.py Normal file
View File

@@ -0,0 +1,50 @@
import subprocess, json
TENANT = 'dd4a82e8-85a3-44ac-8800-07945ab4d95f'
CLIENT_ID = 'fabb3421-8b34-484b-bc17-e46de9703418'
CLIENT_SECRET = '~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO'
USER = 'barbara@bardach.net'
# Get token
r = subprocess.run(['curl', '-s', '-X', 'POST',
f'https://login.microsoftonline.com/{TENANT}/oauth2/v2.0/token',
'-d', f'client_id={CLIENT_ID}', '-d', f'client_secret={CLIENT_SECRET}',
'-d', 'scope=https://graph.microsoft.com/.default', '-d', 'grant_type=client_credentials'],
capture_output=True, text=True)
token = json.loads(r.stdout)['access_token']
print("Got token")
# Try searching ALL messages (not just inbox) from a known sender
email = 'liz@hightailhikes.com'
url = (f"https://graph.microsoft.com/v1.0/users/{USER}/messages"
f"?$filter=from/emailAddress/address eq '{email}'"
f"&$select=subject,from,body"
f"&$top=1"
f"&$orderby=receivedDateTime desc")
print(f"URL: {url[:120]}...")
r2 = subprocess.run(['curl', '-s', '-X', 'GET', url,
'-H', f'Authorization: Bearer {token}', '-H', 'Content-Type: application/json'],
capture_output=True, text=True)
print(f"Stdout length: {len(r2.stdout)}")
print(f"Stderr: {r2.stderr[:200] if r2.stderr else 'none'}")
if r2.stdout:
data = json.loads(r2.stdout)
if 'value' in data:
print(f"Results: {len(data['value'])}")
if data['value']:
msg = data['value'][0]
print(f"Subject: {msg.get('subject','')[:80]}")
body = msg.get('body',{}).get('content','')
print(f"Body length: {len(body)}")
# Show last 800 chars of body (signature area)
if body:
print(f"Body tail:\n{body[-800:]}")
elif 'error' in data:
print(f"Error: {json.dumps(data['error'], indent=2)}")
else:
print(f"Unexpected: {r2.stdout[:500]}")
else:
print("Empty response")

84
temp/_debug_graph2.py Normal file
View File

@@ -0,0 +1,84 @@
import subprocess, json, urllib.parse
TENANT = 'dd4a82e8-85a3-44ac-8800-07945ab4d95f'
CLIENT_ID = 'fabb3421-8b34-484b-bc17-e46de9703418'
CLIENT_SECRET = '~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO'
USER = 'barbara@bardach.net'
# Get token
r = subprocess.run(['curl', '-s', '-X', 'POST',
f'https://login.microsoftonline.com/{TENANT}/oauth2/v2.0/token',
'-d', f'client_id={CLIENT_ID}', '-d', f'client_secret={CLIENT_SECRET}',
'-d', 'scope=https://graph.microsoft.com/.default', '-d', 'grant_type=client_credentials'],
capture_output=True, text=True)
token = json.loads(r.stdout)['access_token']
print("Got token")
# Try with --url flag and proper encoding
email = 'liz@hightailhikes.com'
# Build URL with proper encoding
params = {
'$filter': f"from/emailAddress/address eq '{email}'",
'$select': 'subject,from,body',
'$top': '1',
'$orderby': 'receivedDateTime desc'
}
qs = urllib.parse.urlencode(params, quote_via=urllib.parse.quote)
url = f"https://graph.microsoft.com/v1.0/users/{USER}/messages?{qs}"
print(f"URL: {url[:150]}...")
r2 = subprocess.run(['curl', '-s', '--url', url,
'-H', f'Authorization: Bearer {token}',
'-H', 'Content-Type: application/json'],
capture_output=True, text=True)
print(f"Stdout length: {len(r2.stdout)}")
print(f"Stderr length: {len(r2.stderr)}")
if r2.stdout:
try:
data = json.loads(r2.stdout)
except:
print(f"Raw: {r2.stdout[:500]}")
raise
if 'value' in data:
print(f"Results: {len(data['value'])}")
if data['value']:
msg = data['value'][0]
print(f"Subject: {msg.get('subject','')[:80]}")
body = msg.get('body',{}).get('content','')
print(f"Body length: {len(body)}")
if body:
print(f"Body tail (last 800 chars):\n{body[-800:]}")
elif 'error' in data:
print(f"Error: {json.dumps(data['error'], indent=2)}")
else:
print(f"Keys: {list(data.keys())}")
print(f"Raw: {r2.stdout[:500]}")
else:
print("Empty response")
# Try with -G and -d params instead
print("\nRetrying with -G approach...")
r3 = subprocess.run(['curl', '-s', '-G',
f'https://graph.microsoft.com/v1.0/users/{USER}/messages',
'--data-urlencode', f"$filter=from/emailAddress/address eq '{email}'",
'--data-urlencode', '$select=subject,from,body',
'--data-urlencode', '$top=1',
'--data-urlencode', '$orderby=receivedDateTime desc',
'-H', f'Authorization: Bearer {token}',
'-H', 'Content-Type: application/json'],
capture_output=True, text=True)
print(f"Stdout length: {len(r3.stdout)}")
if r3.stdout:
data = json.loads(r3.stdout)
if 'value' in data:
print(f"Results: {len(data['value'])}")
if data['value']:
msg = data['value'][0]
print(f"Subject: {msg.get('subject','')[:80]}")
body = msg.get('body',{}).get('content','')
print(f"Body length: {len(body)}")
if body:
print(f"Body tail:\n{body[-800:]}")
elif 'error' in data:
print(f"Error: {json.dumps(data['error'], indent=2)}")

47
temp/_debug_graph3.py Normal file
View File

@@ -0,0 +1,47 @@
import subprocess, json, urllib.parse
TENANT = 'dd4a82e8-85a3-44ac-8800-07945ab4d95f'
CLIENT_ID = 'fabb3421-8b34-484b-bc17-e46de9703418'
CLIENT_SECRET = '~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO'
USER = 'barbara@bardach.net'
r = subprocess.run(['curl', '-s', '-X', 'POST',
f'https://login.microsoftonline.com/{TENANT}/oauth2/v2.0/token',
'-d', f'client_id={CLIENT_ID}', '-d', f'client_secret={CLIENT_SECRET}',
'-d', 'scope=https://graph.microsoft.com/.default', '-d', 'grant_type=client_credentials'],
capture_output=True, text=True)
token = json.loads(r.stdout)['access_token']
print("Got token")
email = 'kellyy@cpa-cm.com'
# Approach: use $search with from: keyword
# $search requires ConsistencyLevel: eventual header
r2 = subprocess.run(['curl', '-s', '-G',
f'https://graph.microsoft.com/v1.0/users/{USER}/messages',
'--data-urlencode', f'$search="from:{email}"',
'--data-urlencode', '$select=subject,from,body',
'--data-urlencode', '$top=3',
'-H', f'Authorization: Bearer {token}',
'-H', 'Content-Type: application/json',
'-H', 'ConsistencyLevel: eventual'],
capture_output=True, text=True)
print(f"Stdout length: {len(r2.stdout)}")
if r2.stdout:
data = json.loads(r2.stdout)
if 'value' in data:
print(f"Results: {len(data['value'])}")
for i, msg in enumerate(data['value'][:3]):
subj = msg.get('subject','')[:60]
frm = msg.get('from',{}).get('emailAddress',{}).get('address','')
body = msg.get('body',{}).get('content','')
print(f"\n--- Message {i+1}: {subj} from {frm} ---")
print(f"Body length: {len(body)}")
if body:
# Show last 600 chars
print(f"Body tail:\n{body[-600:]}")
elif 'error' in data:
print(f"Error: {json.dumps(data['error'], indent=2)}")
else:
print(f"Raw: {r2.stdout[:500]}")

47
temp/_debug_graph4.py Normal file
View File

@@ -0,0 +1,47 @@
import subprocess, json, re, html as htmlmod
TENANT = 'dd4a82e8-85a3-44ac-8800-07945ab4d95f'
CLIENT_ID = 'fabb3421-8b34-484b-bc17-e46de9703418'
CLIENT_SECRET = '~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO'
USER = 'barbara@bardach.net'
r = subprocess.run(['curl', '-s', '-X', 'POST',
f'https://login.microsoftonline.com/{TENANT}/oauth2/v2.0/token',
'-d', f'client_id={CLIENT_ID}', '-d', f'client_secret={CLIENT_SECRET}',
'-d', 'scope=https://graph.microsoft.com/.default', '-d', 'grant_type=client_credentials'],
capture_output=True, text=True)
token = json.loads(r.stdout)['access_token']
# Test with a real estate agent who likely has phone in signature
email = 'brandonlopez@longrealty.com'
r2 = subprocess.run(['curl', '-s', '-G',
f'https://graph.microsoft.com/v1.0/users/{USER}/messages',
'--data-urlencode', f'$search="from:{email}"',
'--data-urlencode', '$select=subject,from,body',
'--data-urlencode', '$top=1',
'-H', f'Authorization: Bearer {token}',
'-H', 'Content-Type: application/json',
'-H', 'ConsistencyLevel: eventual'],
capture_output=True, text=True)
data = json.loads(r2.stdout)
if 'value' in data and data['value']:
body = data['value'][0].get('body',{}).get('content','')
# Strip HTML
text = re.sub(r'<br\s*/?>', '\n', body, flags=re.IGNORECASE)
text = re.sub(r'</?(?:p|div|tr|td|li|blockquote)[^>]*>', '\n', text, flags=re.IGNORECASE)
text = re.sub(r'<[^>]+>', '', text)
text = htmlmod.unescape(text)
# Show last 1500 chars
print(f"=== Stripped text tail (last 1500 chars) ===")
print(text[-1500:])
# Search for phone patterns
phone_re = re.compile(r'[\(]?\d{3}[\)\s.\-]?\s?\d{3}[\s.\-]?\d{4}')
phones = phone_re.findall(text)
print(f"\n=== Phone numbers found: {phones} ===")
labeled_re = re.compile(r'(?:Tel|Phone|Cell|Mobile|Office|Direct|Fax)[:\s]*\(?\d{3}\)?[\s.\-]?\d{3}[\s.\-]?\d{4}', re.IGNORECASE)
labeled = labeled_re.findall(text)
print(f"=== Labeled phones: {labeled} ===")

View File

@@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "acg-msp-access",
"private_key_id": "8f72339997e510cb3bf3c01aa658a09a4bce97ba",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDEGZPOw8DG7PxR\ns7d0i+J4jOgrr8k/imtSBn5inhkU6HH2q+eeKOK2dCpwv77/5fU0sxpH5bE4xlqy\nxo+/L/BFE2iSyJ8yu8wp2tepJfh0Mg2VjoI1/rJrk8g8Zye75P9hT8yCv7wkLXu4\n46sTg7Us1oS6GhFTqag4Q2gdfDfM5OxvsEvwbW9iUx/XJbVNd3YQM4+0d3b+F/0P\n1DtYk/mTNq082OC9yDreyXuEV/N9LqhAgCGm5I12ViBJWWT2P6pzbQRxcPM8lyCo\n3R76has3qQ+allOO+zBE8R1FudIz2KVWERUVJykymijQJpB9GOX0FW6s53EgzSBr\naTpTJM3ZAgMBAAECggEAJye7Q2MDREUQCYlCpYcD2JG8DvMJ0kHjdWyeAjdypyHV\nlY0UEZi00f0Gd15V9xcVu6jSY845cW5rsDwk+iYKieRa8koUPYdRd/7+JkRSZHMV\nEspydfEN85x9tA/d127dTjMmkOnTWX7qcAunfl1DSPlpZZZsZMHguKE+8fo6UxL+\nGbW5zPDXlVJVrNtAQhp9bHgRDszGjG22ikE1oYSUaQr2BlmpDsF7slFLe0Dv4zb0\nlvVdpQBuWIGmgzsWE2WEUVqMEef6gew8rOkh3Pi9m+x6dmbHk04Y2y/Winu89TvJ\nZmR19MdUC0Ktt6ZwMBGsuVJ53BmSgRAUELNCOnogHQKBgQDoELhQFbvykYak0yZs\nayMMCpEyAaNSai2DHpBOTCgBefFNPPCwI0xMJWO9Rowiwb+Wwa+iwjM6cQNS1+Ix\nOUckBsBo2norj856o/WO8f5g9Du3JBEarH9S1AmC1wueWRhbl2Efme0byDCmuP1o\niHTTLKlUbhi6tcx/6clA9DUNJQKBgQDYU0Dx3m1WpP0JX66Qfk7FBXaXuc5mLeDr\nmIqB8KmJQDgV2AiPIACqUfx2Y7OaYefYkqXG+05rmS7EQDVSWuI4AfgUVwkBPeT4\nJJJKcJpfWrDnldThH0r6Z15jDp/QntG03+xUR77P6/SqwE09IfoBEIQ5sRovhE+R\nMBBvV65xpQKBgQCgC5fxs2uRmQew+OaQ8zqSfV8xi6ullRCaUyPWu/MDQaRHTnX4\nI//krAyjZtoSxmhpgl6s8x39eh9+rOCUbhpAIF/mcHa9QEp4jkc2NHLpTsc4QSmC\nqeCNsSp2D/U1WeDQmhAjiTbbaC8VbJNn2mQnl6+YSO3JJsRIm2Vu5H0J+QKBgGfK\nahqiMauktZNNyR+iuoBlQqVBjPoRgR0Ir0vxACbOHRq98D1biXYuqAbVh1LHLsoG\ncmuqH9IYSQv4Ep1U5b0hlLmNmNBztewo/9efdzHQ/Zffl6f7r6m89thoJ92cldlG\npsk5Mx/nghh685QlPSJNnmNfycSKovJyMTB6zUPRAoGBAMLD7Q764s4Rbqw61FYQ\nDz4kLhnra/237AtnP2lRCNkITpXxTou2uDIYdUajR9eZ5r1k3PTytvjtOttjNCV9\n6IKUpNqTDXmYOprRw0f1ZVtNZyIx+x4aUCOxTmQ8NVW7pTDi48ZKzp9EcjPP2oeR\nFJKtbMauYofgPMNA7QZwpEQb\n-----END PRIVATE KEY-----\n",
"client_email": "acg-msp-access@acg-msp-access.iam.gserviceaccount.com",
"client_id": "102231607889615995452",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/acg-msp-access%40acg-msp-access.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

View File

@@ -0,0 +1,396 @@
#!/usr/bin/env python3
"""
Compare Bardach Temp contacts folder against main Contacts folder in Microsoft 365.
Uses subprocess + curl for all HTTP requests.
"""
import subprocess
import json
import sys
import time
from collections import defaultdict
# --- Configuration ---
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
SCOPE = "https://graph.microsoft.com/.default"
USER = "barbara@bardach.net"
GRAPH_BASE = f"https://graph.microsoft.com/v1.0/users/{USER}"
SELECT_FIELDS = "id,displayName,givenName,surname,emailAddresses,homePhones,businessPhones,companyName,jobTitle,personalNotes,homeAddress,businessAddress,otherAddress,birthday,nickName,categories,lastModifiedDateTime"
OUTPUT_FILE = "D:/ClaudeTools/temp/bardach_temp_vs_main.json"
api_call_count = 0
access_token = None
def get_token():
"""Acquire OAuth2 token via client credentials."""
global access_token
print("[INFO] Acquiring access token...")
cmd = [
"curl", "-s", "-X", "POST",
f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token",
"-H", "Content-Type: application/x-www-form-urlencoded",
"-d", f"client_id={CLIENT_ID}&scope={SCOPE}&client_secret={CLIENT_SECRET}&grant_type=client_credentials"
]
result = subprocess.run(cmd, capture_output=True, text=True)
data = json.loads(result.stdout)
if "access_token" not in data:
print(f"[ERROR] Failed to get token: {data}")
sys.exit(1)
access_token = data["access_token"]
print("[OK] Token acquired.")
def api_get(url):
"""Make a GET request to Graph API, re-acquiring token every 500 calls."""
global api_call_count, access_token
api_call_count += 1
if api_call_count % 500 == 0:
print(f"[INFO] Re-acquiring token after {api_call_count} API calls...")
get_token()
cmd = [
"curl", "-s", "-X", "GET", url,
"-H", f"Authorization: Bearer {access_token}",
"-H", "Content-Type: application/json"
]
result = subprocess.run(cmd, capture_output=True, text=True)
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
print(f"[ERROR] Non-JSON response from: {url}")
print(result.stdout[:500])
return None
if "error" in data:
err = data["error"]
# Handle throttling
if err.get("code") == "TooManyRequests" or err.get("code") == "429":
retry_after = 30
print(f"[WARNING] Throttled. Waiting {retry_after}s...")
time.sleep(retry_after)
return api_get(url)
print(f"[ERROR] API error: {err.get('code')}: {err.get('message')}")
return None
return data
def get_contact_folders():
"""Find the Temp folder ID and the default Contacts folder ID."""
print("[INFO] Fetching contact folders...")
url = f"{GRAPH_BASE}/contactFolders?$top=100"
data = api_get(url)
if not data:
print("[ERROR] Could not fetch contact folders.")
sys.exit(1)
temp_folder_id = None
default_folder_id = None
for folder in data.get("value", []):
name = folder.get("displayName", "")
fid = folder.get("id", "")
parent = folder.get("parentFolderId", "")
print(f" Folder: {name} (id: {fid[:20]}...)")
if name.lower() == "temp":
temp_folder_id = fid
# The default contacts folder usually has displayName = "Contacts" at top level
# but we can also just use the /contacts endpoint for default
# For the main contacts folder, we use the default /contacts endpoint
# which returns contacts in the default Contacts folder
print(f"[INFO] Temp folder ID: {temp_folder_id[:20] if temp_folder_id else 'NOT FOUND'}...")
if not temp_folder_id:
print("[ERROR] Temp folder not found!")
sys.exit(1)
return temp_folder_id
def fetch_all_contacts(url_base, label):
"""Fetch all contacts from a folder with pagination."""
contacts = []
url = f"{url_base}?$top=100&$select={SELECT_FIELDS}"
page = 1
while url:
print(f" Fetching {label} page {page}...")
data = api_get(url)
if not data:
break
batch = data.get("value", [])
contacts.extend(batch)
url = data.get("@odata.nextLink", None)
page += 1
print(f"[OK] Fetched {len(contacts)} contacts from {label}.")
return contacts
def normalize(s):
"""Lowercase and strip whitespace."""
if not s:
return ""
return s.strip().lower()
def get_emails(contact):
"""Extract lowercase email set from a contact."""
emails = set()
for e in (contact.get("emailAddresses") or []):
addr = (e.get("address") or "").strip().lower()
if addr:
emails.add(addr)
return emails
def is_blank(contact):
"""Check if a contact is essentially empty."""
dn = normalize(contact.get("displayName", ""))
emails = get_emails(contact)
gn = normalize(contact.get("givenName", ""))
sn = normalize(contact.get("surname", ""))
company = normalize(contact.get("companyName", ""))
return not dn and not emails and not gn and not sn and not company
def has_address(addr):
"""Check if an address dict has any content."""
if not addr:
return False
for key in ["street", "city", "state", "postalCode", "countryOrRegion"]:
if (addr.get(key) or "").strip():
return True
return False
def find_extras(temp_contact, main_contact):
"""Find fields that Temp has but Main is missing."""
extras = {}
# Check emails - find emails in temp not in main
temp_emails = get_emails(temp_contact)
main_emails = get_emails(main_contact)
extra_emails = temp_emails - main_emails
if extra_emails:
extras["emailAddresses"] = list(extra_emails)
# Check phones
for phone_field in ["homePhones", "businessPhones"]:
temp_phones = set(p.strip() for p in (temp_contact.get(phone_field) or []) if p.strip())
main_phones = set(p.strip() for p in (main_contact.get(phone_field) or []) if p.strip())
extra_phones = temp_phones - main_phones
if extra_phones:
extras[phone_field] = list(extra_phones)
# Check simple string fields
for field in ["companyName", "jobTitle", "nickName", "birthday"]:
temp_val = (temp_contact.get(field) or "").strip()
main_val = (main_contact.get(field) or "").strip()
if temp_val and not main_val:
extras[field] = temp_val
# personalNotes - temp has content, main doesn't
temp_notes = (temp_contact.get("personalNotes") or "").strip()
main_notes = (main_contact.get("personalNotes") or "").strip()
if temp_notes and not main_notes:
extras["personalNotes"] = temp_notes[:200] + ("..." if len(temp_notes) > 200 else "")
# Addresses
for addr_field in ["homeAddress", "businessAddress", "otherAddress"]:
if has_address(temp_contact.get(addr_field)) and not has_address(main_contact.get(addr_field)):
extras[addr_field] = temp_contact.get(addr_field)
# Categories
temp_cats = set(temp_contact.get("categories") or [])
main_cats = set(main_contact.get("categories") or [])
extra_cats = temp_cats - main_cats
if extra_cats:
extras["categories"] = list(extra_cats)
return extras
def main():
get_token()
# Step 1: Find folder IDs
temp_folder_id = get_contact_folders()
# Step 2: Fetch all contacts from both folders
print("\n[INFO] Fetching Temp folder contacts...")
temp_contacts = fetch_all_contacts(
f"{GRAPH_BASE}/contactFolders/{temp_folder_id}/contacts",
"Temp"
)
print("\n[INFO] Fetching Main (default) contacts...")
main_contacts = fetch_all_contacts(
f"{GRAPH_BASE}/contacts",
"Main/Default"
)
# Step 3: Build indexes for main contacts
print("\n[INFO] Building main contact indexes...")
main_by_displayname = defaultdict(list)
main_by_email = defaultdict(list)
main_by_name_combo = defaultdict(list)
for mc in main_contacts:
dn = normalize(mc.get("displayName", ""))
if dn:
main_by_displayname[dn].append(mc)
for email in get_emails(mc):
main_by_email[email].append(mc)
gn = normalize(mc.get("givenName", ""))
sn = normalize(mc.get("surname", ""))
if gn and sn:
main_by_name_combo[f"{gn}|{sn}"].append(mc)
# Step 4: Compare each Temp contact
print("[INFO] Comparing contacts...")
exact_matches = []
matches_with_extras = []
unique_to_temp = []
blank_contacts = []
for tc in temp_contacts:
# Check blank first
if is_blank(tc):
blank_contacts.append({"temp_id": tc["id"]})
continue
# Try matching
matched_main = None
# Match by displayName
dn = normalize(tc.get("displayName", ""))
if dn and dn in main_by_displayname:
matched_main = main_by_displayname[dn][0]
# Match by email
if not matched_main:
temp_emails = get_emails(tc)
for email in temp_emails:
if email in main_by_email:
matched_main = main_by_email[email][0]
break
# Match by givenName+surname
if not matched_main:
gn = normalize(tc.get("givenName", ""))
sn = normalize(tc.get("surname", ""))
if gn and sn:
combo = f"{gn}|{sn}"
if combo in main_by_name_combo:
matched_main = main_by_name_combo[combo][0]
if matched_main:
extras = find_extras(tc, matched_main)
if extras:
matches_with_extras.append({
"temp_id": tc["id"],
"main_id": matched_main["id"],
"displayName": tc.get("displayName", ""),
"extra_fields": extras
})
else:
exact_matches.append({
"temp_id": tc["id"],
"main_id": matched_main["id"],
"displayName": tc.get("displayName", "")
})
else:
emails_list = [e.get("address", "") for e in (tc.get("emailAddresses") or [])]
unique_to_temp.append({
"temp_id": tc["id"],
"displayName": tc.get("displayName", ""),
"emails": emails_list,
"company": tc.get("companyName", "")
})
# Step 5: Check for duplicates within Main contacts
print("[INFO] Checking for duplicates within Main contacts...")
main_name_counts = defaultdict(list)
for mc in main_contacts:
dn = normalize(mc.get("displayName", ""))
if dn:
main_name_counts[dn].append(mc["id"])
main_internal_dupes = []
for name, ids in main_name_counts.items():
if len(ids) > 1:
main_internal_dupes.append({
"name": name,
"count": len(ids),
"ids": ids
})
# Step 6: Print report
print("\n" + "=" * 70)
print("BARDACH TEMP vs MAIN CONTACTS - COMPARISON REPORT")
print("=" * 70)
print(f"\nTotal Temp contacts: {len(temp_contacts)}")
print(f"Total Main contacts: {len(main_contacts)}")
print()
print(f"EXACT MATCH (no extra data): {len(exact_matches)}")
print(f"MATCH WITH EXTRAS: {len(matches_with_extras)}")
print(f"UNIQUE TO TEMP: {len(unique_to_temp)}")
print(f"BLANK/EMPTY: {len(blank_contacts)}")
# Extras breakdown
if matches_with_extras:
print(f"\n--- MATCH WITH EXTRAS Breakdown ---")
field_counts = defaultdict(int)
for m in matches_with_extras:
for field in m["extra_fields"]:
field_counts[field] += 1
for field, count in sorted(field_counts.items(), key=lambda x: -x[1]):
print(f" {count:>5} contacts have '{field}' that Main lacks")
# Unique to Temp - first 50
if unique_to_temp:
print(f"\n--- UNIQUE TO TEMP (first 50 of {len(unique_to_temp)}) ---")
for i, u in enumerate(unique_to_temp[:50]):
emails_str = ", ".join(u["emails"][:2]) if u["emails"] else "(no email)"
company_str = u.get("company") or ""
dn = u.get("displayName") or "(no name)"
print(f" {i+1:>3}. {dn:<35} {emails_str:<40} {company_str}")
# Main internal dupes
print(f"\n--- MAIN FOLDER INTERNAL DUPLICATES ---")
print(f" {len(main_internal_dupes)} names appear more than once in Main contacts")
if main_internal_dupes:
dupes_sorted = sorted(main_internal_dupes, key=lambda x: -x["count"])
for d in dupes_sorted[:30]:
print(f" {d['name']:<40} appears {d['count']}x")
# Step 7: Save JSON
print(f"\n[INFO] Saving full analysis to {OUTPUT_FILE}...")
output = {
"summary": {
"total_temp": len(temp_contacts),
"total_main": len(main_contacts),
"exact_matches": len(exact_matches),
"matches_with_extras": len(matches_with_extras),
"unique_to_temp": len(unique_to_temp),
"blank": len(blank_contacts),
"main_internal_dupes": len(main_internal_dupes)
},
"exact_matches": exact_matches,
"matches_with_extras": matches_with_extras,
"unique_to_temp": unique_to_temp,
"blank": blank_contacts,
"main_internal_dupes": main_internal_dupes
}
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
json.dump(output, f, indent=2, ensure_ascii=False, default=str)
print(f"[OK] Saved to {OUTPUT_FILE}")
print(f"\n[INFO] Total API calls made: {api_call_count}")
print("[SUCCESS] Comparison complete.")
if __name__ == "__main__":
main()

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,134 @@
import urllib.request, urllib.parse, json, sys
CIPP_URL = "https://cippcanvb.azurewebsites.net"
CIPP_TENANT = "ce61461e-81a0-4c84-bb4a-7b354a9a356d"
CIPP_CLIENT = "420cb849-542d-4374-9cb2-3d8ae0e1835b"
CIPP_SECRET = "MOn8Q~otmxJPLvmL~_aCVTV8Va4t4~SrYrukGbJT"
CLAUDE_APP = "fabb3421-8b34-484b-bc17-e46de9703418"
CLAUDE_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
TENANT = "bardach.net"
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
def get_token(tenant_id, client_id, client_secret, scope):
data = urllib.parse.urlencode({
'client_id': client_id,
'client_secret': client_secret,
'scope': scope,
'grant_type': 'client_credentials'
}).encode()
req = urllib.request.Request(f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token", data=data, method='POST')
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())['access_token']
# Get Exchange token for bardach.net
print("[STEP 1] Getting Exchange token...")
try:
exo_token = get_token(TENANT_ID, CLAUDE_APP, CLAUDE_SECRET, "https://outlook.office365.com/.default")
print("[OK] Exchange token acquired")
except Exception as e:
print(f"[ERROR] {e}")
sys.exit(1)
# Run Get-MailboxFolderStatistics to find contact folders including deleted
print("\n[STEP 2] Getting mailbox folder statistics (contacts scope)...")
invoke_url = f"https://outlook.office365.com/adminapi/beta/{TENANT_ID}/InvokeCommand"
headers = {'Authorization': f'Bearer {exo_token}', 'Content-Type': 'application/json'}
cmd = json.dumps({
"CmdletInput": {
"CmdletName": "Get-MailboxFolderStatistics",
"Parameters": {
"Identity": "barbara@bardach.net",
"FolderScope": "Contacts"
}
}
}).encode()
try:
req = urllib.request.Request(invoke_url, data=cmd, headers=headers, method='POST')
with urllib.request.urlopen(req) as resp:
result = json.loads(resp.read())
for item in result.get('value', []):
name = item.get('Name', '?')
count = item.get('ItemsInFolder', '?')
folder_type = item.get('FolderType', '?')
size = item.get('FolderSize', '?')
print(f" {name}: {count} items ({folder_type})")
except urllib.error.HTTPError as e:
body = e.read().decode()
print(f"[ERROR] HTTP {e.code}: {body[:500]}")
except Exception as e:
print(f"[ERROR] {e}")
# Also check RecoverableItems scope
print("\n[STEP 3] Getting mailbox folder statistics (RecoverableItems scope)...")
cmd2 = json.dumps({
"CmdletInput": {
"CmdletName": "Get-MailboxFolderStatistics",
"Parameters": {
"Identity": "barbara@bardach.net",
"FolderScope": "RecoverableItems"
}
}
}).encode()
try:
req2 = urllib.request.Request(invoke_url, data=cmd2, headers=headers, method='POST')
with urllib.request.urlopen(req2) as resp2:
result2 = json.loads(resp2.read())
for item in result2.get('value', []):
name = item.get('Name', '?')
count = item.get('ItemsInFolder', '?')
size = item.get('FolderSize', '?')
folder_type = item.get('FolderType', '?')
print(f" {name}: {count} items ({folder_type}) - {size}")
except urllib.error.HTTPError as e:
body = e.read().decode()
print(f"[ERROR] HTTP {e.code}: {body[:500]}")
except Exception as e:
print(f"[ERROR] {e}")
# Also get ALL folder stats to see everything
print("\n[STEP 4] Getting ALL mailbox folder statistics...")
cmd3 = json.dumps({
"CmdletInput": {
"CmdletName": "Get-MailboxFolderStatistics",
"Parameters": {
"Identity": "barbara@bardach.net"
}
}
}).encode()
try:
req3 = urllib.request.Request(invoke_url, data=cmd3, headers=headers, method='POST')
with urllib.request.urlopen(req3) as resp3:
result3 = json.loads(resp3.read())
# Filter for anything contact-related
contact_folders = []
for item in result3.get('value', []):
name = item.get('Name', '?')
folder_type = item.get('FolderType', '?')
count = item.get('ItemsInFolder', 0)
container_class = item.get('ContainerClass', '?')
if 'contact' in name.lower() or 'contact' in str(folder_type).lower() or 'contact' in str(container_class).lower() or count > 100:
contact_folders.append(item)
print(f" {name}: {count} items (type={folder_type}, class={container_class})")
if not contact_folders:
print(" No contact-related folders found in full stats")
# Show all folders with items
print("\n All folders with >0 items:")
for item in result3.get('value', []):
count = item.get('ItemsInFolder', 0)
if count > 0:
name = item.get('Name', '?')
folder_type = item.get('FolderType', '?')
print(f" {name}: {count} items ({folder_type})")
except urllib.error.HTTPError as e:
body = e.read().decode()
print(f"[ERROR] HTTP {e.code}: {body[:500]}")
except Exception as e:
print(f"[ERROR] {e}")

View File

@@ -0,0 +1,36 @@
Subject: Contact Cleanup Complete - Summary of Changes
Hi Barbara,
We've finished cleaning up your Outlook contacts. Here's a summary of what was done:
WHAT WE STARTED WITH
Your contacts were split across two folders: your main Contacts folder (~5,800 contacts) and a "Temp" folder (~10,400 contacts) that had synced over from iCloud. The Temp folder had thousands of duplicates and outdated entries.
WHAT WE DID
1. Cleaned up the Temp (iCloud) folder
- Removed 4,431 duplicate entries within the Temp folder
- That brought it down from 10,400 to about 5,970 contacts
2. Compared Temp contacts to your main Contacts
- 1,792 were exact copies of what you already had - deleted from Temp
- 278 were contacts not in your main folder - moved them over
- For contacts that existed in both places, we kept the version in your main Contacts folder (since those are more current) and merged in any extra info from the Temp copy that was missing
3. Removed the Temp folder
- Once everything was merged, the empty Temp folder was deleted
4. Cleaned up junk data
- Removed iCloud system messages that had been inserted into contact notes ("This contact is read-only..." messages on 223 contacts)
- Removed 216 broken website URLs that iCloud/Outlook had inserted (ms-outlook:// links that don't work)
5. Removed duplicates in main Contacts
- Found and merged 18 duplicate pairs, keeping the most complete version of each
WHERE THINGS STAND NOW
Your main Contacts folder has about 6,054 contacts - one clean, consolidated set with no duplicates and no junk data. Everything from the old iCloud Temp folder has been preserved where it was useful.
Let me know if you have any questions or if anything looks off.
Mike

View File

@@ -0,0 +1,284 @@
#!/usr/bin/env python3
"""Create real person contacts in Barbara's M365 Contacts folder from missing contacts list."""
import json
import subprocess
import re
import time
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
USER = "barbara@bardach.net"
GRAPH_BASE = "https://graph.microsoft.com/v1.0"
MIN_MESSAGES = 4
BARBARAS_PHONE = "(520) 275-3867"
# Commercial domains to exclude
COMMERCIAL_DOMAINS = {
"monos.com", "zestypaws.com", "augustinusbader.com", "ella-bella.com",
"thefarmersdog.com", "nordprotect.zendesk.com", "hilton.com", "orhp.com",
"havenlifestyles.com", "4unature.com", "skyslope.com", "arcisgolf.com",
"tucsonrealtors.org"
}
# Commercial keywords in display_name (case-insensitive)
COMMERCIAL_NAME_KEYWORDS = [
"team", "support", "reception", "frontdesk", "nordprotect", "zesty", "monos"
]
# Commercial email prefixes
COMMERCIAL_EMAIL_PREFIXES = [
"care@", "hello@", "connect@", "contact@", "bark@", "support+",
"justchecking", "ticketing@"
]
# Title suffixes to drop when parsing names
TITLE_SUFFIXES = [
"office manager", "broker", "agent", "realtor", "manager", "director",
"assistant", "coordinator", "specialist", "advisor", "consultant"
]
def get_token():
"""Get OAuth token via client credentials."""
url = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token"
cmd = [
"curl", "-s", "-X", "POST", url,
"-H", "Content-Type: application/x-www-form-urlencoded",
"-d", f"client_id={CLIENT_ID}&scope=https%3A%2F%2Fgraph.microsoft.com%2F.default&client_secret={CLIENT_SECRET}&grant_type=client_credentials"
]
result = subprocess.run(cmd, capture_output=True, text=True)
data = json.loads(result.stdout)
if "access_token" not in data:
print(f"[ERROR] Token failed: {data}")
return None
return data["access_token"]
def is_email_like(name):
"""Check if display_name is just an email address."""
return "@" in name and "." in name
def is_commercial(contact):
"""Check if a contact is commercial/automated."""
email = contact["email"].lower()
name = contact["display_name"].lower()
domain = email.split("@")[-1] if "@" in email else ""
# Own email
if email == "bardach@bardach.net":
return True
# No-reply patterns
if any(x in email for x in ["noreply", "no-reply", "donotreply"]):
return True
# Commercial domains
if domain in COMMERCIAL_DOMAINS:
return True
# Commercial name keywords
for kw in COMMERCIAL_NAME_KEYWORDS:
if kw in name:
return True
# Commercial email prefixes
for prefix in COMMERCIAL_EMAIL_PREFIXES:
if email.startswith(prefix) or prefix in email:
return True
return False
def parse_name(display_name):
"""Parse display_name into (givenName, surname)."""
name = display_name.strip()
# Handle "Last, First" format
if "," in name:
parts = [p.strip() for p in name.split(",", 1)]
if len(parts) == 2 and parts[0] and parts[1]:
first = parts[1].split()[0] # Take first word after comma
return first, parts[0]
# Split into words
words = name.split()
if len(words) == 0:
return "", ""
if len(words) == 1:
return words[0], ""
if len(words) == 2:
return words[0], words[1]
# 3+ words: check for title suffixes
# Try to find where a title suffix starts
lower_name = name.lower()
for suffix in TITLE_SUFFIXES:
idx = lower_name.find(suffix)
if idx > 0:
# Take everything before the suffix
name_part = name[:idx].strip()
name_words = name_part.split()
if len(name_words) >= 2:
return name_words[0], " ".join(name_words[1:])
elif len(name_words) == 1:
return name_words[0], ""
# Default: first word = given, second word = surname, ignore rest
return words[0], words[1]
def build_contact_payload(contact):
"""Build the JSON payload for creating a contact."""
given, surname = parse_name(contact["display_name"])
payload = {
"givenName": given,
"surname": surname,
"displayName": contact["display_name"],
"emailAddresses": [
{"address": contact["email"], "name": contact["display_name"]}
]
}
phone = contact.get("phone")
label = (contact.get("phone_label") or "").strip()
if phone and phone != BARBARAS_PHONE:
label_lower = label.lower()
if label_lower == "fax":
pass # Skip fax
elif label_lower in ("cell", "mobile"):
payload["mobilePhone"] = phone
elif label_lower in ("home",):
payload["homePhones"] = [phone]
else:
# Office, Direct, Phone, empty -> businessPhones
payload["businessPhones"] = [phone]
return payload
def create_contact(token, payload):
"""Create a contact via Graph API."""
url = f"{GRAPH_BASE}/users/{USER}/contacts"
json_str = json.dumps(payload)
cmd = [
"curl", "-s", "-X", "POST", url,
"-H", f"Authorization: Bearer {token}",
"-H", "Content-Type: application/json",
"-d", json_str
]
result = subprocess.run(cmd, capture_output=True, text=True)
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
return None, result.stdout
return data, None
def main():
# Load data
with open(r"D:\ClaudeTools\temp\bardach_missing_real_contacts.json", encoding="utf-8") as f:
data = json.load(f)
contacts = data["contacts"]
print(f"[INFO] Loaded {len(contacts)} total missing contacts")
# Filter: minimum messages
contacts = [c for c in contacts if c["total"] >= MIN_MESSAGES]
print(f"[INFO] After >= {MIN_MESSAGES} messages filter: {len(contacts)}")
# Filter: remove empty/email-only display names
filtered = []
removed_reasons = []
for c in contacts:
name = (c["display_name"] or "").strip()
email = c["email"].lower()
if not name:
removed_reasons.append(f" REMOVED (empty name): {email}")
continue
if is_email_like(name):
removed_reasons.append(f" REMOVED (email-as-name): {name} <{email}>")
continue
if is_commercial(c):
removed_reasons.append(f" REMOVED (commercial): {name} <{email}>")
continue
filtered.append(c)
print(f"[INFO] Removed {len(contacts) - len(filtered)} non-person entries:")
for r in removed_reasons:
print(r)
print(f"\n[INFO] Final filtered list: {len(filtered)} real person contacts\n")
# Print the filtered list for review
print(f"{'#':<4} {'Name':<35} {'Email':<45} {'Phone':<18} {'Msgs':>5}")
print("-" * 110)
has_phone_count = 0
for i, c in enumerate(filtered, 1):
phone = c.get("phone") or ""
if phone == BARBARAS_PHONE:
phone = "(skipped-own)"
if phone and phone != "(skipped-own)":
has_phone_count += 1
label = c.get("phone_label") or ""
phone_display = f"{phone} [{label}]" if label else phone
print(f"{i:<4} {c['display_name']:<35} {c['email']:<45} {phone_display:<18} {c['total']:>5}")
print(f"\n[INFO] {has_phone_count} contacts have phone numbers")
print(f"[INFO] Starting contact creation...\n")
# Get token
token = get_token()
if not token:
print("[ERROR] Could not get token. Aborting.")
return
created = 0
errors = 0
with_phone = 0
for i, c in enumerate(filtered):
# Refresh token every 30 creates
if i > 0 and i % 30 == 0:
print(f"[INFO] Refreshing token after {i} contacts...")
token = get_token()
if not token:
print("[ERROR] Token refresh failed. Aborting.")
return
payload = build_contact_payload(c)
has_phone = "businessPhones" in payload or "mobilePhone" in payload or "homePhones" in payload
resp, err = create_contact(token, payload)
if err:
print(f"[ERROR] {c['display_name']} <{c['email']}>: curl error: {err}")
errors += 1
continue
if "id" in resp:
phone_note = " (with phone)" if has_phone else ""
print(f"[CREATED] {c['display_name']} <{c['email']}>{phone_note}")
created += 1
if has_phone:
with_phone += 1
else:
err_code = resp.get("error", {}).get("code", "unknown")
err_msg = resp.get("error", {}).get("message", str(resp))
print(f"[ERROR] {c['display_name']} <{c['email']}>: {err_code} - {err_msg}")
errors += 1
print(f"\n{'=' * 60}")
print(f"[SUMMARY]")
print(f" Total filtered contacts: {len(filtered)}")
print(f" Created: {created}")
print(f" With phone: {with_phone}")
print(f" Errors: {errors}")
print(f"{'=' * 60}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,5 @@
{
"completed_index": 4431,
"successes": 4431,
"failures": []
}

View File

@@ -0,0 +1,6 @@
{
"total_attempted": 4431,
"successes": 4431,
"failures": 0,
"failure_details": []
}

View File

@@ -0,0 +1,840 @@
{
"total_attempted": 156,
"successes": 105,
"failures": 51,
"success_details": [
{
"display_name": "alaska airlines",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUkFAAA=",
"status": 200
},
{
"display_name": "alyson campbell",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUo_AAA=",
"status": 200
},
{
"display_name": "andria duckworth",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUqYAAA=",
"status": 200
},
{
"display_name": "ann clark",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUrbAAA=",
"status": 200
},
{
"display_name": "ann danna",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUr5AAA=",
"status": 200
},
{
"display_name": "apple support",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUtbAAA=",
"status": 200
},
{
"display_name": "barbara bardach",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUv0AAA=",
"status": 200
},
{
"display_name": "becca heeter",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUwrAAA=",
"status": 200
},
{
"display_name": "bill marquardt",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUyyAAA=",
"status": 200
},
{
"display_name": "billy rosenfeld",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUz7AAA=",
"status": 200
},
{
"display_name": "brenda o'brien",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqU3BAAA=",
"status": 200
},
{
"display_name": "brooke dray",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqU4bAAA=",
"status": 200
},
{
"display_name": "carol macnally",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqU60AAA=",
"status": 200
},
{
"display_name": "carrie lorensen",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqU7aAAA=",
"status": 200
},
{
"display_name": "carrisa martinez",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqU7gAAA=",
"status": 200
},
{
"display_name": "charlie lose-frahn",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqU9IAAA=",
"status": 200
},
{
"display_name": "chris colhane",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqU_ZAAA=",
"status": 200
},
{
"display_name": "concierge",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVA0AAA=",
"status": 200
},
{
"display_name": "costco",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVBUAAA=",
"status": 200
},
{
"display_name": "dave kuefler",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVE9AAA=",
"status": 200
},
{
"display_name": "dawn duncan",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVG1AAA=",
"status": 200
},
{
"display_name": "debbie duncan",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVHoAAA=",
"status": 200
},
{
"display_name": "debbie vinson",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVHvAAA=",
"status": 200
},
{
"display_name": "dennia chromzak",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVJJAAA=",
"status": 200
},
{
"display_name": "dick steiner",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVKhAAA=",
"status": 200
},
{
"display_name": "dr richard lewis",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVNJAAA=",
"status": 200
},
{
"display_name": "ellen steiner",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVPjAAA=",
"status": 200
},
{
"display_name": "facebook",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVRKAAA=",
"status": 200
},
{
"display_name": "fran bull",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVRtAAA=",
"status": 200
},
{
"display_name": "frys pharmacy",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVSmAAA=",
"status": 200
},
{
"display_name": "homewise hoa info",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVYwAAA=",
"status": 200
},
{
"display_name": "inside tucson business",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUhWAAA=",
"status": 200
},
{
"display_name": "isabel hendricks",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVZdAAA=",
"status": 200
},
{
"display_name": "j r ferman",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVZpAAA=",
"status": 200
},
{
"display_name": "james rafiner",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVbIAAA=",
"status": 200
},
{
"display_name": "jan lyeth sharp",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVcAAAA=",
"status": 200
},
{
"display_name": "jay thorpe",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVd2AAA=",
"status": 200
},
{
"display_name": "jeni jankowski",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVgaAAA=",
"status": 200
},
{
"display_name": "jerry",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqViUAAA=",
"status": 200
},
{
"display_name": "jessica phillips",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVirAAA=",
"status": 200
},
{
"display_name": "jillian koenig",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVjIAAA=",
"status": 200
},
{
"display_name": "joe brusky",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVmVAAA=",
"status": 200
},
{
"display_name": "john pasalis",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVoJAAA=",
"status": 200
},
{
"display_name": "julie sparkman",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVrQAAA=",
"status": 200
},
{
"display_name": "karin radzewicz",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVtUAAA=",
"status": 200
},
{
"display_name": "kat covey",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVtqAAA=",
"status": 200
},
{
"display_name": "kathi heeter",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVuBAAA=",
"status": 200
},
{
"display_name": "kc woods",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVvWAAA=",
"status": 200
},
{
"display_name": "kelly",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVv3AAA=",
"status": 200
},
{
"display_name": "kelly ann cornell",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVwBAAA=",
"status": 200
},
{
"display_name": "laura gallagher",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV0qAAA=",
"status": 200
},
{
"display_name": "lauren duffy",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV07AAA=",
"status": 200
},
{
"display_name": "lindsay liffengrin",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV32AAA=",
"status": 200
},
{
"display_name": "lori balsino",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV6TAAA=",
"status": 200
},
{
"display_name": "marcia manzo",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV9qAAA=",
"status": 200
},
{
"display_name": "mark alan mehalic",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWAVAAA=",
"status": 200
},
{
"display_name": "mark crager",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWAEAAA=",
"status": 200
},
{
"display_name": "mark kerrigan",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWAeAAA=",
"status": 200
},
{
"display_name": "mark mowat",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV-1AAA=",
"status": 200
},
{
"display_name": "mark seitz",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWAKAAA=",
"status": 200
},
{
"display_name": "mary cotter",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWDlAAA=",
"status": 200
},
{
"display_name": "matt carlson",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWEsAAA=",
"status": 200
},
{
"display_name": "mel goldberg",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWF7AAA=",
"status": 200
},
{
"display_name": "metro title",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWHAAAA=",
"status": 200
},
{
"display_name": "mike davis",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWLOAAA=",
"status": 200
},
{
"display_name": "monica lopez",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWNnAAA=",
"status": 200
},
{
"display_name": "mr an 's teppan restaurant",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUptAAA=",
"status": 200
},
{
"display_name": "nick",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWQpAAA=",
"status": 200
},
{
"display_name": "nick danna",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWQbAAA=",
"status": 200
},
{
"display_name": "northwest exterminating",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWSDAAA=",
"status": 200
},
{
"display_name": "old pueblo septic",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWSMAAA=",
"status": 200
},
{
"display_name": "pat leahy",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWUPAAA=",
"status": 200
},
{
"display_name": "paul lehman",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWWQAAA=",
"status": 200
},
{
"display_name": "paula jacobi",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWXNAAA=",
"status": 200
},
{
"display_name": "rick carr",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWe6AAA=",
"status": 200
},
{
"display_name": "ron dames",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWiWAAA=",
"status": 200
},
{
"display_name": "ron scharf",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWh1AAA=",
"status": 200
},
{
"display_name": "russell long",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWj7AAA=",
"status": 200
},
{
"display_name": "sahuaro vista vet clinic",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWkxAAA=",
"status": 200
},
{
"display_name": "sally goldberg",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWlKAAA=",
"status": 200
},
{
"display_name": "sally schrempf",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWlNAAA=",
"status": 200
},
{
"display_name": "scott anderson",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWnCAAA=",
"status": 200
},
{
"display_name": "serenita",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWn9AAA=",
"status": 200
},
{
"display_name": "shoe repair",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWqeAAA=",
"status": 200
},
{
"display_name": "sotheby's tucson",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWrhAAA=",
"status": 200
},
{
"display_name": "splendido dining reservations",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWrwAAA=",
"status": 200
},
{
"display_name": "steve drehobl",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWu0AAA=",
"status": 200
},
{
"display_name": "strategic marketing",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWvxAAA=",
"status": 200
},
{
"display_name": "sue feakes",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWwrAAA=",
"status": 200
},
{
"display_name": "sue steen",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWwDAAA=",
"status": 200
},
{
"display_name": "supra ekey",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWxDAAA=",
"status": 200
},
{
"display_name": "susan barry",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWxuAAA=",
"status": 200
},
{
"display_name": "taylor boyd",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW09AAA=",
"status": 200
},
{
"display_name": "ted haworth",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW1NAAA=",
"status": 200
},
{
"display_name": "terry ellis",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW2KAAA=",
"status": 200
},
{
"display_name": "terry hutchison",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW2AAAA=",
"status": 200
},
{
"display_name": "tom barker",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW5NAAA=",
"status": 200
},
{
"display_name": "valerie martin",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW8rAAA=",
"status": 200
},
{
"display_name": "verizon",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW9JAAA=",
"status": 200
},
{
"display_name": "vicki parrott",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW9zAAA=",
"status": 200
},
{
"display_name": "vickie pierce",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW96AAA=",
"status": 200
},
{
"display_name": "wayne wilkins",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW-RAAA=",
"status": 200
},
{
"display_name": "wells fargo",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW-bAAA=",
"status": 200
},
{
"display_name": "xfinity",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqXAYAAA=",
"status": 200
},
{
"display_name": "zain khalpey",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqXA3AAA=",
"status": 200
}
],
"failure_details": [
{
"display_name": "alma guimarin",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUo6AAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "angie rupp",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUrMAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "ann garland",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUreAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "b m w",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUuyAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "brad king",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqU2iAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "bryan durkin",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqU5MAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "dave allen",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVFAAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "dawnell juergensen",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUkRAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "deborah van de putte",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVIRAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "diane ritchey",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVKQAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "dr ajay tuli",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVM6AAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "dr. robert hohenstein",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVNeAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "eric sheffield",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVQMAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "gary mertens",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVTlAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "jeff jones",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVfvAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property emailAddresses has 4 entries, that exceeds the max allowed value of 3.\"}}"
},
{
"display_name": "joanie zimmermann",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVlTAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "julie enfield",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVreAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "k c woods",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVsTAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "karen macphail",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVs-AAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "kathryn welch",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVuUAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "larry miramontez",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV0CAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "linda dewilde",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV3mAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "long - dove mtn",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUhEAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "manny herrera",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV9AAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "marc hendricks",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV9RAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "marcella ann puentes",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV9hAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "maria anemone",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV_hAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property emailAddresses has 4 entries, that exceeds the max allowed value of 3.\"}}"
},
{
"display_name": "maria bardach",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV_eAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "mary lou gerbi",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWEDAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 4 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "mina dillards",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWM0AAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "nichole stivers",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWQTAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "pam mccurry",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWTGAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "pam woods",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWTCAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "paula brown",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWXVAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "peter muhlbach",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWYyAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "poochini's",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWZ2AAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "rachel bradley",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWaSAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "ray rivas",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWbxAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "rayma",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWb-AAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "robin hodge",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWgnAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "robyn anderson",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWg9AAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 4 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "ross elmore",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWjcAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "sign up signs",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWqoAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "sonia",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWrNAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "steve",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqUj5AAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "steven williams",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWvVAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property emailAddresses has 4 entries, that exceeds the max allowed value of 3.\"}}"
},
{
"display_name": "susan harnedy",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWxcAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property emailAddresses has 4 entries, that exceeds the max allowed value of 3.\"}}"
},
{
"display_name": "suzie terry",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWzNAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "tar",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW0dAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "tucson rolling shutters",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW8RAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property businessPhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
},
{
"display_name": "vistoso",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqW_wAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property homePhones has 3 entries, that exceeds the max allowed value of 2.\"}}"
}
]
}

View File

@@ -0,0 +1,31 @@
{
"total_retried": 51,
"successes": 47,
"failures": 4,
"failure_details": [
{
"display_name": "jeff jones",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqVfvAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property emailAddresses has 4 entries, that exceeds the max allowed value of 3.\"}}"
},
{
"display_name": "maria anemone",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqV_hAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property emailAddresses has 4 entries, that exceeds the max allowed value of 3.\"}}"
},
{
"display_name": "steven williams",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWvVAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property emailAddresses has 4 entries, that exceeds the max allowed value of 3.\"}}"
},
{
"display_name": "susan harnedy",
"contact_id": "AAMkADNiYWE4ZDYxLWE4M2EtNGY1MS05YWQwLWY2OWYzMWI3YjZjNABGAAAAAADrk4YN-mpcR5zROC2646l9BwCo_dM7bg-DQY5RuVpcPz_JAAQU2EZxAACo_dM7bg-DQY5RuVpcPz_JAAVkqWxcAAA=",
"status": 400,
"error": "{\"error\":{\"code\":\"ErrorInvalidProperty\",\"message\":\"The multi value property emailAddresses has 4 entries, that exceeds the max allowed value of 3.\"}}"
}
]
}

45925
temp/bardach_dedup_plan.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""Step 1: Pull all Bardach Temp contacts and save as backup."""
import json
import subprocess
import sys
import os
TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f"
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
SCOPE = "https://graph.microsoft.com/.default"
USER = "barbara@bardach.net"
BACKUP_FILE = "D:/ClaudeTools/temp/bardach_temp_backup_prededup.json"
SELECT_FIELDS = "id,displayName,givenName,surname,emailAddresses,homePhones,businessPhones,companyName,jobTitle,personalNotes,homeAddress,businessAddress,otherAddress,birthday,nickName,categories,lastModifiedDateTime"
def get_token():
"""Get OAuth2 token via client credentials."""
url = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token"
result = subprocess.run(
[
"curl", "-s", "-X", "POST", url,
"-H", "Content-Type: application/x-www-form-urlencoded",
"-d", f"client_id={CLIENT_ID}&scope={SCOPE}&client_secret={CLIENT_SECRET}&grant_type=client_credentials"
],
capture_output=True, text=True
)
data = json.loads(result.stdout)
if "access_token" not in data:
print(f"[ERROR] Failed to get token: {data}")
sys.exit(1)
print("[OK] Token acquired")
return data["access_token"]
def graph_get(token, url):
"""Make a GET request to Graph API."""
result = subprocess.run(
[
"curl", "-s", "-X", "GET", url,
"-H", f"Authorization: Bearer {token}",
"-H", "Content-Type: application/json"
],
capture_output=True, text=True
)
return json.loads(result.stdout)
def find_temp_folder(token):
"""Find the Temp contact folder ID."""
url = f"https://graph.microsoft.com/v1.0/users/{USER}/contactFolders"
data = graph_get(token, url)
if "value" not in data:
print(f"[ERROR] Failed to get contact folders: {data}")
sys.exit(1)
for folder in data["value"]:
print(f" Found folder: {folder['displayName']} (id: {folder['id']})")
if folder["displayName"].lower() == "temp":
return folder["id"]
# Check for child folders
for folder in data["value"]:
child_url = f"https://graph.microsoft.com/v1.0/users/{USER}/contactFolders/{folder['id']}/childFolders"
child_data = graph_get(token, child_url)
if "value" in child_data:
for child in child_data["value"]:
print(f" Found subfolder: {folder['displayName']}/{child['displayName']} (id: {child['id']})")
if child["displayName"].lower() == "temp":
return child["id"]
print("[ERROR] Temp folder not found")
sys.exit(1)
def pull_all_contacts(token, folder_id):
"""Pull all contacts from the Temp folder with pagination."""
contacts = []
url = f"https://graph.microsoft.com/v1.0/users/{USER}/contactFolders/{folder_id}/contacts?$top=100&$select={SELECT_FIELDS}"
page = 1
while url:
print(f" Fetching page {page}...")
data = graph_get(token, url)
if "value" not in data:
print(f"[ERROR] Failed to get contacts: {data}")
break
contacts.extend(data["value"])
print(f" Got {len(data['value'])} contacts (total: {len(contacts)})")
url = data.get("@odata.nextLink")
page += 1
# Re-acquire token every 50 pages to be safe
if page % 50 == 0:
print(" Re-acquiring token...")
token = get_token()
return contacts, token
def main():
print("=" * 60)
print("STEP 1: Pull all Temp contacts and save backup")
print("=" * 60)
token = get_token()
print("\n[INFO] Finding Temp folder...")
folder_id = find_temp_folder(token)
print(f"[OK] Temp folder ID: {folder_id}")
print("\n[INFO] Pulling all contacts...")
contacts, token = pull_all_contacts(token, folder_id)
print(f"\n[OK] Total contacts pulled: {len(contacts)}")
# Save backup
os.makedirs(os.path.dirname(BACKUP_FILE), exist_ok=True)
with open(BACKUP_FILE, "w", encoding="utf-8") as f:
json.dump({"total": len(contacts), "contacts": contacts}, f, indent=2, ensure_ascii=False)
print(f"[OK] Backup saved to {BACKUP_FILE}")
print(f"[OK] File size: {os.path.getsize(BACKUP_FILE) / 1024 / 1024:.1f} MB")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""Step 2: Build dedup plan from backup contacts."""
import json
import os
from collections import defaultdict
from datetime import datetime
BACKUP_FILE = "D:/ClaudeTools/temp/bardach_temp_backup_prededup.json"
PLAN_FILE = "D:/ClaudeTools/temp/bardach_dedup_plan.json"
def load_backup():
with open(BACKUP_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
return data["contacts"]
def normalize_name(name):
"""Normalize display name for grouping."""
if not name:
return ""
return name.strip().lower()
def get_emails(contact):
"""Extract email addresses as lowercase set."""
emails = set()
for e in (contact.get("emailAddresses") or []):
addr = (e.get("address") or "").strip().lower()
if addr:
emails.add(addr)
return emails
def get_phones(contact, field):
"""Extract phone numbers as set."""
phones = set()
for p in (contact.get(field) or []):
cleaned = p.strip()
if cleaned:
phones.add(cleaned)
return phones
def is_address_empty(addr):
"""Check if an address object is empty."""
if not addr:
return True
for key in ["street", "city", "state", "postalCode", "countryOrRegion"]:
if (addr.get(key) or "").strip():
return False
return True
def score_contact(contact):
"""Score a contact by richness of data."""
score = 0
# Email addresses (2 pts each)
emails = get_emails(contact)
score += len(emails) * 2
# Phone numbers (2 pts each)
for field in ["homePhones", "businessPhones"]:
score += len(get_phones(contact, field)) * 2
# Text fields (1 pt each if non-empty)
for field in ["companyName", "jobTitle", "nickName", "birthday"]:
if (contact.get(field) or "").strip():
score += 1
# Personal notes (2 pts if non-empty, more for longer)
notes = (contact.get("personalNotes") or "").strip()
if notes:
score += 2
if len(notes) > 50:
score += 1
# Addresses (2 pts each if non-empty)
for field in ["homeAddress", "businessAddress", "otherAddress"]:
if not is_address_empty(contact.get(field)):
score += 2
# Categories (1 pt if has any)
if contact.get("categories"):
score += 1
# Given/surname (1 pt each)
if (contact.get("givenName") or "").strip():
score += 1
if (contact.get("surname") or "").strip():
score += 1
# Recency bonus: slight preference for more recently modified
lm = contact.get("lastModifiedDateTime")
if lm:
try:
dt = datetime.fromisoformat(lm.replace("Z", "+00:00"))
# Give up to 2 bonus points for recency (within last year = 2, older = less)
days_ago = (datetime.now(dt.tzinfo) - dt).days
if days_ago < 365:
score += 2
elif days_ago < 730:
score += 1
except Exception:
pass
return score
def build_merge_updates(keeper, duplicates):
"""Determine what unique data from duplicates should be merged into keeper."""
updates = {}
# Merge emails
keeper_emails = get_emails(keeper)
new_emails = set()
for dup in duplicates:
new_emails |= get_emails(dup)
new_emails -= keeper_emails
if new_emails:
# Build new emailAddresses list: keeper's existing + new ones
existing = list(keeper.get("emailAddresses") or [])
for addr in new_emails:
existing.append({"address": addr, "name": ""})
updates["emailAddresses"] = existing
# Merge phones
for field in ["homePhones", "businessPhones"]:
keeper_phones = get_phones(keeper, field)
new_phones = set()
for dup in duplicates:
new_phones |= get_phones(dup, field)
new_phones -= keeper_phones
if new_phones:
existing = list(keeper.get(field) or [])
existing.extend(list(new_phones))
updates[field] = existing
# Merge notes (append unique notes)
keeper_notes = (keeper.get("personalNotes") or "").strip()
for dup in duplicates:
dup_notes = (dup.get("personalNotes") or "").strip()
if dup_notes and dup_notes != keeper_notes and dup_notes not in keeper_notes:
if keeper_notes:
keeper_notes += "\n---\n" + dup_notes
else:
keeper_notes = dup_notes
if keeper_notes != (keeper.get("personalNotes") or "").strip():
updates["personalNotes"] = keeper_notes
# Fill blank fields from duplicates
for field in ["companyName", "jobTitle", "nickName", "birthday"]:
if not (keeper.get(field) or "").strip():
for dup in duplicates:
val = (dup.get(field) or "").strip()
if val:
updates[field] = val
break
# Fill blank addresses
for field in ["homeAddress", "businessAddress", "otherAddress"]:
if is_address_empty(keeper.get(field)):
for dup in duplicates:
if not is_address_empty(dup.get(field)):
updates[field] = dup[field]
break
# Fill given/surname if blank
for field in ["givenName", "surname"]:
if not (keeper.get(field) or "").strip():
for dup in duplicates:
val = (dup.get(field) or "").strip()
if val:
updates[field] = val
break
# Merge categories
keeper_cats = set(keeper.get("categories") or [])
new_cats = set()
for dup in duplicates:
new_cats |= set(dup.get("categories") or [])
new_cats -= keeper_cats
if new_cats:
updates["categories"] = list(keeper_cats | new_cats)
return updates
def main():
print("=" * 60)
print("STEP 2: Build dedup plan")
print("=" * 60)
contacts = load_backup()
print(f"[OK] Loaded {len(contacts)} contacts from backup")
# Group by normalized displayName
groups = defaultdict(list)
no_name_count = 0
for c in contacts:
name = normalize_name(c.get("displayName"))
if not name:
no_name_count += 1
continue
groups[name].append(c)
print(f"[INFO] Unique names: {len(groups)}")
print(f"[INFO] Contacts without displayName: {no_name_count}")
# Find duplicate groups (2+ contacts with same name)
dup_groups = {name: clist for name, clist in groups.items() if len(clist) >= 2}
print(f"[INFO] Duplicate groups (2+ contacts with same name): {len(dup_groups)}")
total_dupes = sum(len(v) for v in dup_groups.values())
total_to_delete = total_dupes - len(dup_groups) # keep one per group
print(f"[INFO] Total contacts in duplicate groups: {total_dupes}")
print(f"[INFO] Contacts to delete (extras): {total_to_delete}")
# Build merge plan
plan = []
keepers_needing_updates = 0
for name, clist in sorted(dup_groups.items()):
# Score each contact
scored = [(score_contact(c), c) for c in clist]
scored.sort(key=lambda x: x[0], reverse=True)
keeper = scored[0][1]
duplicates = [s[1] for s in scored[1:]]
# Build updates
updates = build_merge_updates(keeper, duplicates)
entry = {
"display_name": name,
"group_size": len(clist),
"keeper_id": keeper["id"],
"keeper_score": scored[0][0],
"updates_to_apply": updates,
"delete_ids": [d["id"] for d in duplicates],
"delete_count": len(duplicates)
}
plan.append(entry)
if updates:
keepers_needing_updates += 1
# Save plan
with open(PLAN_FILE, "w", encoding="utf-8") as f:
json.dump({"total_groups": len(plan), "plan": plan}, f, indent=2, ensure_ascii=False)
# Summary
total_deletes = sum(e["delete_count"] for e in plan)
print(f"\n{'=' * 60}")
print(f"DEDUP PLAN SUMMARY")
print(f"{'=' * 60}")
print(f" Duplicate groups: {len(plan)}")
print(f" Keepers needing updates: {keepers_needing_updates}")
print(f" Contacts to delete: {total_deletes}")
print(f" Contacts to keep (dupes): {len(plan)}")
print(f" Unique contacts (no dup): {len(groups) - len(dup_groups)}")
print(f" Final expected count: {len(groups) - len(dup_groups) + len(plan) + no_name_count}")
print(f"[OK] Plan saved to {PLAN_FILE}")
# Show top 10 largest duplicate groups
by_size = sorted(plan, key=lambda x: x["group_size"], reverse=True)[:10]
print(f"\nTop 10 largest duplicate groups:")
for e in by_size:
print(f" {e['display_name']}: {e['group_size']} copies (delete {e['delete_count']})")
if __name__ == "__main__":
main()

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