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:
@@ -46,6 +46,13 @@ class Settings(BaseSettings):
|
|||||||
# API configuration
|
# API configuration
|
||||||
ALLOWED_ORIGINS: str = "*"
|
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:
|
class Config:
|
||||||
"""Pydantic configuration."""
|
"""Pydantic configuration."""
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from sqlalchemy import (
|
|||||||
Numeric,
|
Numeric,
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
|
func,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -32,38 +33,34 @@ class QuoteStatus(str, PyEnum):
|
|||||||
"""Status options for quotes."""
|
"""Status options for quotes."""
|
||||||
DRAFT = "draft"
|
DRAFT = "draft"
|
||||||
SUBMITTED = "submitted"
|
SUBMITTED = "submitted"
|
||||||
REVIEWING = "reviewing"
|
VIEWED = "viewed"
|
||||||
APPROVED = "approved"
|
FOLLOWED_UP = "followed_up"
|
||||||
REJECTED = "rejected"
|
CONVERTED = "converted"
|
||||||
EXPIRED = "expired"
|
EXPIRED = "expired"
|
||||||
|
|
||||||
|
|
||||||
class ServiceCategory(str, PyEnum):
|
class ServiceCategory(str, PyEnum):
|
||||||
"""Service category options for quote items."""
|
"""Service category options for quote items."""
|
||||||
MANAGED_SERVICES = "managed_services"
|
GPS_MONITORING = "gps_monitoring"
|
||||||
SECURITY = "security"
|
SUPPORT_PLAN = "support_plan"
|
||||||
BACKUP = "backup"
|
VOIP = "voip"
|
||||||
CLOUD = "cloud"
|
WEB_HOSTING = "web_hosting"
|
||||||
|
EMAIL = "email"
|
||||||
HARDWARE = "hardware"
|
HARDWARE = "hardware"
|
||||||
SOFTWARE = "software"
|
ADDON = "addon"
|
||||||
CONSULTING = "consulting"
|
|
||||||
SUPPORT = "support"
|
|
||||||
|
|
||||||
|
|
||||||
class BillingFrequency(str, PyEnum):
|
class BillingFrequency(str, PyEnum):
|
||||||
"""Billing frequency options for quote items."""
|
"""Billing frequency options for quote items."""
|
||||||
MONTHLY = "monthly"
|
MONTHLY = "monthly"
|
||||||
QUARTERLY = "quarterly"
|
YEARLY = "yearly"
|
||||||
ANNUAL = "annual"
|
|
||||||
ONE_TIME = "one_time"
|
ONE_TIME = "one_time"
|
||||||
|
|
||||||
|
|
||||||
class NotificationType(str, PyEnum):
|
class NotificationType(str, PyEnum):
|
||||||
"""Notification types for quote events."""
|
"""Notification types for quote events."""
|
||||||
EMAIL_SENT = "email_sent"
|
EMAIL = "email"
|
||||||
SMS_SENT = "sms_sent"
|
WEBHOOK = "webhook"
|
||||||
ADMIN_ALERT = "admin_alert"
|
|
||||||
REMINDER_SENT = "reminder_sent"
|
|
||||||
|
|
||||||
|
|
||||||
class Quote(Base, UUIDMixin, TimestampMixin):
|
class Quote(Base, UUIDMixin, TimestampMixin):
|
||||||
@@ -75,17 +72,14 @@ class Quote(Base, UUIDMixin, TimestampMixin):
|
|||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
access_token: Unique token for public access (URL-safe, 43 chars)
|
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
|
company_name: Prospect company name
|
||||||
contact_name: Primary contact name
|
contact_name: Primary contact name
|
||||||
contact_email: Contact email address
|
contact_email: Contact email address
|
||||||
contact_phone: Contact phone number
|
contact_phone: Contact phone number
|
||||||
employee_count: Number of employees/users
|
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
|
monthly_total: Calculated monthly recurring total
|
||||||
setup_total: Calculated one-time setup total
|
setup_total: Calculated one-time setup total
|
||||||
annual_total: Calculated annual total
|
|
||||||
expires_at: Quote expiration date
|
expires_at: Quote expiration date
|
||||||
submitted_at: Timestamp when quote was submitted
|
submitted_at: Timestamp when quote was submitted
|
||||||
ip_address: IP address of the requester
|
ip_address: IP address of the requester
|
||||||
@@ -109,10 +103,10 @@ class Quote(Base, UUIDMixin, TimestampMixin):
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
default=QuoteStatus.DRAFT.value,
|
default=QuoteStatus.DRAFT.value,
|
||||||
server_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(
|
company_name: Mapped[Optional[str]] = mapped_column(
|
||||||
String(255),
|
String(255),
|
||||||
doc="Prospect company name"
|
doc="Prospect company name"
|
||||||
@@ -139,15 +133,14 @@ class Quote(Base, UUIDMixin, TimestampMixin):
|
|||||||
doc="Number of employees/users"
|
doc="Number of employees/users"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Notes
|
industry: Mapped[Optional[str]] = mapped_column(
|
||||||
notes: Mapped[Optional[str]] = mapped_column(
|
String(100),
|
||||||
Text,
|
doc="Industry/vertical of the prospect"
|
||||||
doc="Customer notes or special requirements"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
admin_notes: Mapped[Optional[str]] = mapped_column(
|
current_it_situation: Mapped[Optional[str]] = mapped_column(
|
||||||
Text,
|
Text,
|
||||||
doc="Internal admin notes (not visible to customer)"
|
doc="Description of the prospect's current IT setup"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Calculated totals
|
# Calculated totals
|
||||||
@@ -167,14 +160,6 @@ class Quote(Base, UUIDMixin, TimestampMixin):
|
|||||||
doc="Calculated one-time setup total"
|
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
|
# Timestamps
|
||||||
expires_at: Mapped[Optional[datetime]] = mapped_column(
|
expires_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
@@ -193,10 +178,32 @@ class Quote(Base, UUIDMixin, TimestampMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
user_agent: Mapped[Optional[str]] = mapped_column(
|
user_agent: Mapped[Optional[str]] = mapped_column(
|
||||||
String(500),
|
Text,
|
||||||
doc="Browser user agent string"
|
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 RMM Integration
|
||||||
syncro_lead_id: Mapped[Optional[str]] = mapped_column(
|
syncro_lead_id: Mapped[Optional[str]] = mapped_column(
|
||||||
String(100),
|
String(100),
|
||||||
@@ -242,7 +249,7 @@ class Quote(Base, UUIDMixin, TimestampMixin):
|
|||||||
# Constraints and indexes
|
# Constraints and indexes
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
CheckConstraint(
|
CheckConstraint(
|
||||||
"status IN ('draft', 'submitted', 'reviewing', 'approved', 'rejected', 'expired')",
|
"status IN ('draft', 'submitted', 'viewed', 'followed_up', 'converted', 'expired')",
|
||||||
name="ck_quotes_status"
|
name="ck_quotes_status"
|
||||||
),
|
),
|
||||||
Index("idx_quotes_access_token", "access_token"),
|
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}')>"
|
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.
|
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:
|
Attributes:
|
||||||
quote_id: Reference to the parent quote
|
quote_id: Reference to the parent quote
|
||||||
service_name: Name of the service
|
category: Service category (gps_monitoring, support_plan, etc.)
|
||||||
service_description: Detailed description of the service
|
product_code: Product code identifier
|
||||||
category: Service category (managed_services, security, etc.)
|
product_name: Name of the product/service
|
||||||
billing_frequency: Billing frequency (monthly, annual, one_time)
|
description: Detailed description of the product/service
|
||||||
unit_price: Price per unit
|
|
||||||
quantity: Number of units
|
quantity: Number of units
|
||||||
setup_fee: One-time setup fee
|
unit_price: Price per unit
|
||||||
is_required: Whether this item is required (cannot be removed)
|
setup_price: One-time setup price
|
||||||
sort_order: Display order within the quote
|
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"
|
__tablename__ = "quote_items"
|
||||||
@@ -285,42 +294,32 @@ class QuoteItem(Base, UUIDMixin, TimestampMixin):
|
|||||||
doc="Reference to the parent quote"
|
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
|
||||||
category: Mapped[str] = mapped_column(
|
category: Mapped[str] = mapped_column(
|
||||||
String(50),
|
String(50),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default=ServiceCategory.MANAGED_SERVICES.value,
|
doc="Service category: gps_monitoring, support_plan, voip, web_hosting, email, hardware, addon"
|
||||||
doc="Service category: managed_services, security, backup, cloud, etc."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Billing
|
# Product identification
|
||||||
billing_frequency: Mapped[str] = mapped_column(
|
product_code: Mapped[str] = mapped_column(
|
||||||
String(20),
|
String(50),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default=BillingFrequency.MONTHLY.value,
|
doc="Product code identifier"
|
||||||
doc="Billing frequency: monthly, quarterly, annual, one_time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pricing
|
product_name: Mapped[str] = mapped_column(
|
||||||
unit_price: Mapped[Decimal] = mapped_column(
|
String(255),
|
||||||
Numeric(10, 2),
|
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default=Decimal("0.00"),
|
doc="Name of the product/service"
|
||||||
doc="Price per unit"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(
|
||||||
|
Text,
|
||||||
|
doc="Detailed description of the product/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Quantity and pricing
|
||||||
quantity: Mapped[int] = mapped_column(
|
quantity: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
@@ -328,29 +327,49 @@ class QuoteItem(Base, UUIDMixin, TimestampMixin):
|
|||||||
doc="Number of units"
|
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),
|
Numeric(10, 2),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default=Decimal("0.00"),
|
default=Decimal("0.00"),
|
||||||
server_default="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
|
# 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,
|
Boolean,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default=False,
|
default=False,
|
||||||
server_default="0",
|
server_default="0",
|
||||||
doc="Whether this item is required (cannot be removed)"
|
doc="Whether this item is recommended"
|
||||||
)
|
)
|
||||||
|
|
||||||
sort_order: Mapped[int] = mapped_column(
|
# Timestamp (no updated_at in DB)
|
||||||
Integer,
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default=0,
|
server_default=func.now(),
|
||||||
server_default="0",
|
doc="Timestamp when the item was created"
|
||||||
doc="Display order within the quote"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
@@ -362,24 +381,20 @@ class QuoteItem(Base, UUIDMixin, TimestampMixin):
|
|||||||
# Constraints and indexes
|
# Constraints and indexes
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
CheckConstraint(
|
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"
|
name="ck_quote_items_category"
|
||||||
),
|
),
|
||||||
CheckConstraint(
|
CheckConstraint(
|
||||||
"billing_frequency IN ('monthly', 'quarterly', 'annual', 'one_time')",
|
"billing_frequency IN ('monthly', 'yearly', 'one_time')",
|
||||||
name="ck_quote_items_billing_frequency"
|
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_quote_id", "quote_id"),
|
||||||
Index("idx_quote_items_category", "category"),
|
Index("idx_quote_items_category", "category"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
"""String representation of the quote item."""
|
"""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
|
@property
|
||||||
def line_total(self) -> Decimal:
|
def line_total(self) -> Decimal:
|
||||||
@@ -391,15 +406,13 @@ class QuoteItem(Base, UUIDMixin, TimestampMixin):
|
|||||||
"""Calculate the monthly amount based on billing frequency."""
|
"""Calculate the monthly amount based on billing frequency."""
|
||||||
if self.billing_frequency == BillingFrequency.MONTHLY.value:
|
if self.billing_frequency == BillingFrequency.MONTHLY.value:
|
||||||
return self.line_total
|
return self.line_total
|
||||||
elif self.billing_frequency == BillingFrequency.QUARTERLY.value:
|
elif self.billing_frequency == BillingFrequency.YEARLY.value:
|
||||||
return self.line_total / Decimal("3")
|
|
||||||
elif self.billing_frequency == BillingFrequency.ANNUAL.value:
|
|
||||||
return self.line_total / Decimal("12")
|
return self.line_total / Decimal("12")
|
||||||
else: # one_time
|
else: # one_time
|
||||||
return Decimal("0.00")
|
return Decimal("0.00")
|
||||||
|
|
||||||
|
|
||||||
class QuoteActivity(Base, UUIDMixin, TimestampMixin):
|
class QuoteActivity(Base, UUIDMixin):
|
||||||
"""
|
"""
|
||||||
Quote activity model for tracking quote history and changes.
|
Quote activity model for tracking quote history and changes.
|
||||||
|
|
||||||
@@ -408,13 +421,13 @@ class QuoteActivity(Base, UUIDMixin, TimestampMixin):
|
|||||||
Attributes:
|
Attributes:
|
||||||
quote_id: Reference to the parent quote
|
quote_id: Reference to the parent quote
|
||||||
action: Action performed (created, updated, submitted, etc.)
|
action: Action performed (created, updated, submitted, etc.)
|
||||||
description: Detailed description of the action
|
step_name: Name of the wizard step associated with the action
|
||||||
actor: Who performed the action (email, 'system', 'admin')
|
details: Additional details about the action (longtext)
|
||||||
ip_address: IP address of the actor
|
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
|
# Foreign keys
|
||||||
quote_id: Mapped[str] = mapped_column(
|
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."
|
doc="Action performed: created, updated, item_added, item_removed, submitted, status_changed, etc."
|
||||||
)
|
)
|
||||||
|
|
||||||
description: Mapped[Optional[str]] = mapped_column(
|
step_name: Mapped[Optional[str]] = mapped_column(
|
||||||
Text,
|
String(50),
|
||||||
doc="Detailed description of the action"
|
doc="Name of the wizard step associated with the action"
|
||||||
)
|
)
|
||||||
|
|
||||||
actor: Mapped[Optional[str]] = mapped_column(
|
details: Mapped[Optional[str]] = mapped_column(
|
||||||
String(255),
|
Text,
|
||||||
doc="Who performed the action (email, 'system', 'admin')"
|
doc="Additional details about the action"
|
||||||
)
|
)
|
||||||
|
|
||||||
ip_address: Mapped[Optional[str]] = mapped_column(
|
ip_address: Mapped[Optional[str]] = mapped_column(
|
||||||
@@ -446,9 +459,12 @@ class QuoteActivity(Base, UUIDMixin, TimestampMixin):
|
|||||||
doc="IP address of the actor"
|
doc="IP address of the actor"
|
||||||
)
|
)
|
||||||
|
|
||||||
metadata: Mapped[Optional[str]] = mapped_column(
|
# Timestamp (no updated_at in DB)
|
||||||
Text,
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
doc="JSON metadata about the action"
|
DateTime,
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
doc="Timestamp when the activity was recorded"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
@@ -469,21 +485,24 @@ class QuoteActivity(Base, UUIDMixin, TimestampMixin):
|
|||||||
return f"<QuoteActivity(quote_id='{self.quote_id}', action='{self.action}')>"
|
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.
|
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:
|
Attributes:
|
||||||
quote_id: Reference to the parent quote
|
quote_id: Reference to the parent quote
|
||||||
notification_type: Type of notification (email_sent, sms_sent, etc.)
|
notification_type: Type of notification (email, webhook)
|
||||||
recipient: Notification recipient (email, phone, etc.)
|
recipient: Notification recipient (email address, webhook URL, etc.)
|
||||||
subject: Notification subject
|
subject: Notification subject
|
||||||
content: Notification content/body
|
body: Notification body content
|
||||||
status: Delivery status (pending, sent, delivered, failed)
|
status: Delivery status (pending, sent, failed)
|
||||||
sent_at: Timestamp when notification was sent
|
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
|
error_message: Error message if delivery failed
|
||||||
|
created_at: Timestamp when notification was created
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "quote_notifications"
|
__tablename__ = "quote_notifications"
|
||||||
@@ -500,23 +519,23 @@ class QuoteNotification(Base, UUIDMixin, TimestampMixin):
|
|||||||
notification_type: Mapped[str] = mapped_column(
|
notification_type: Mapped[str] = mapped_column(
|
||||||
String(30),
|
String(30),
|
||||||
nullable=False,
|
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(
|
recipient: Mapped[str] = mapped_column(
|
||||||
String(255),
|
String(255),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
doc="Notification recipient (email, phone, etc.)"
|
doc="Notification recipient (email address, webhook URL, etc.)"
|
||||||
)
|
)
|
||||||
|
|
||||||
subject: Mapped[Optional[str]] = mapped_column(
|
subject: Mapped[Optional[str]] = mapped_column(
|
||||||
String(500),
|
String(255),
|
||||||
doc="Notification subject"
|
doc="Notification subject"
|
||||||
)
|
)
|
||||||
|
|
||||||
content: Mapped[Optional[str]] = mapped_column(
|
body: Mapped[Optional[str]] = mapped_column(
|
||||||
Text,
|
Text,
|
||||||
doc="Notification content/body"
|
doc="Notification body content"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Status tracking
|
# Status tracking
|
||||||
@@ -525,12 +544,25 @@ class QuoteNotification(Base, UUIDMixin, TimestampMixin):
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
default="pending",
|
default="pending",
|
||||||
server_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(
|
sent_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
doc="Timestamp when notification was sent"
|
doc="Timestamp when notification was successfully sent"
|
||||||
)
|
)
|
||||||
|
|
||||||
error_message: Mapped[Optional[str]] = mapped_column(
|
error_message: Mapped[Optional[str]] = mapped_column(
|
||||||
@@ -538,6 +570,14 @@ class QuoteNotification(Base, UUIDMixin, TimestampMixin):
|
|||||||
doc="Error message if delivery failed"
|
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
|
# Relationships
|
||||||
quote: Mapped["Quote"] = relationship(
|
quote: Mapped["Quote"] = relationship(
|
||||||
"Quote",
|
"Quote",
|
||||||
@@ -547,11 +587,11 @@ class QuoteNotification(Base, UUIDMixin, TimestampMixin):
|
|||||||
# Constraints and indexes
|
# Constraints and indexes
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
CheckConstraint(
|
CheckConstraint(
|
||||||
"notification_type IN ('email_sent', 'sms_sent', 'admin_alert', 'reminder_sent')",
|
"notification_type IN ('email', 'webhook')",
|
||||||
name="ck_quote_notifications_type"
|
name="ck_quote_notifications_type"
|
||||||
),
|
),
|
||||||
CheckConstraint(
|
CheckConstraint(
|
||||||
"status IN ('pending', 'sent', 'delivered', 'failed')",
|
"status IN ('pending', 'sent', 'failed')",
|
||||||
name="ck_quote_notifications_status"
|
name="ck_quote_notifications_status"
|
||||||
),
|
),
|
||||||
Index("idx_quote_notifications_quote_id", "quote_id"),
|
Index("idx_quote_notifications_quote_id", "quote_id"),
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ def list_quotes(
|
|||||||
status_filter: Optional[str] = Query(
|
status_filter: Optional[str] = Query(
|
||||||
default=None,
|
default=None,
|
||||||
alias="status",
|
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(
|
search: Optional[str] = Query(
|
||||||
default=None,
|
default=None,
|
||||||
@@ -166,9 +166,9 @@ def get_stats(
|
|||||||
"quotes_by_status": {
|
"quotes_by_status": {
|
||||||
"draft": 45,
|
"draft": 45,
|
||||||
"submitted": 60,
|
"submitted": 60,
|
||||||
"reviewing": 15,
|
"viewed": 15,
|
||||||
"approved": 25,
|
"followed_up": 10,
|
||||||
"rejected": 3,
|
"converted": 25,
|
||||||
"expired": 2
|
"expired": 2
|
||||||
},
|
},
|
||||||
"total_monthly_value": "12500.00",
|
"total_monthly_value": "12500.00",
|
||||||
@@ -229,7 +229,6 @@ def get_quote(
|
|||||||
"company_name": "Acme Corporation",
|
"company_name": "Acme Corporation",
|
||||||
"contact_name": "John Doe",
|
"contact_name": "John Doe",
|
||||||
"contact_email": "john@acme.com",
|
"contact_email": "john@acme.com",
|
||||||
"admin_notes": "Follow up scheduled for next week",
|
|
||||||
"ip_address": "192.168.1.100",
|
"ip_address": "192.168.1.100",
|
||||||
"user_agent": "Mozilla/5.0...",
|
"user_agent": "Mozilla/5.0...",
|
||||||
"items": [...],
|
"items": [...],
|
||||||
@@ -254,19 +253,19 @@ def get_quote(
|
|||||||
items_response.append(QuoteItemResponse(
|
items_response.append(QuoteItemResponse(
|
||||||
id=item.id,
|
id=item.id,
|
||||||
quote_id=item.quote_id,
|
quote_id=item.quote_id,
|
||||||
service_name=item.service_name,
|
|
||||||
service_description=item.service_description,
|
|
||||||
category=item.category,
|
category=item.category,
|
||||||
billing_frequency=item.billing_frequency,
|
product_code=item.product_code,
|
||||||
unit_price=item.unit_price,
|
product_name=item.product_name,
|
||||||
|
description=item.description,
|
||||||
quantity=item.quantity,
|
quantity=item.quantity,
|
||||||
setup_fee=item.setup_fee,
|
unit_price=item.unit_price,
|
||||||
is_required=item.is_required,
|
setup_price=item.setup_price,
|
||||||
sort_order=item.sort_order,
|
billing_frequency=item.billing_frequency,
|
||||||
|
tier=item.tier,
|
||||||
|
is_recommended=item.is_recommended,
|
||||||
line_total=item.line_total,
|
line_total=item.line_total,
|
||||||
monthly_amount=item.monthly_amount,
|
monthly_amount=item.monthly_amount,
|
||||||
created_at=item.created_at,
|
created_at=item.created_at,
|
||||||
updated_at=item.updated_at
|
|
||||||
))
|
))
|
||||||
|
|
||||||
activities_response = []
|
activities_response = []
|
||||||
@@ -275,8 +274,8 @@ def get_quote(
|
|||||||
id=activity.id,
|
id=activity.id,
|
||||||
quote_id=activity.quote_id,
|
quote_id=activity.quote_id,
|
||||||
action=activity.action,
|
action=activity.action,
|
||||||
description=activity.description,
|
step_name=activity.step_name,
|
||||||
actor=activity.actor,
|
details=activity.details,
|
||||||
ip_address=activity.ip_address,
|
ip_address=activity.ip_address,
|
||||||
created_at=activity.created_at
|
created_at=activity.created_at
|
||||||
))
|
))
|
||||||
@@ -290,6 +289,8 @@ def get_quote(
|
|||||||
recipient=notification.recipient,
|
recipient=notification.recipient,
|
||||||
subject=notification.subject,
|
subject=notification.subject,
|
||||||
status=notification.status,
|
status=notification.status,
|
||||||
|
attempts=notification.attempts,
|
||||||
|
last_attempt_at=notification.last_attempt_at,
|
||||||
sent_at=notification.sent_at,
|
sent_at=notification.sent_at,
|
||||||
error_message=notification.error_message,
|
error_message=notification.error_message,
|
||||||
created_at=notification.created_at
|
created_at=notification.created_at
|
||||||
@@ -304,11 +305,8 @@ def get_quote(
|
|||||||
contact_email=quote.contact_email,
|
contact_email=quote.contact_email,
|
||||||
contact_phone=quote.contact_phone,
|
contact_phone=quote.contact_phone,
|
||||||
employee_count=quote.employee_count,
|
employee_count=quote.employee_count,
|
||||||
notes=quote.notes,
|
|
||||||
admin_notes=quote.admin_notes,
|
|
||||||
monthly_total=quote.monthly_total,
|
monthly_total=quote.monthly_total,
|
||||||
setup_total=quote.setup_total,
|
setup_total=quote.setup_total,
|
||||||
annual_total=quote.annual_total,
|
|
||||||
expires_at=quote.expires_at,
|
expires_at=quote.expires_at,
|
||||||
submitted_at=quote.submitted_at,
|
submitted_at=quote.submitted_at,
|
||||||
ip_address=quote.ip_address,
|
ip_address=quote.ip_address,
|
||||||
@@ -346,8 +344,8 @@ def update_quote(
|
|||||||
"""
|
"""
|
||||||
Update a quote's status or admin notes.
|
Update a quote's status or admin notes.
|
||||||
|
|
||||||
Admins can change the quote status (e.g., from submitted to reviewing
|
Admins can change the quote status (e.g., from submitted to viewed
|
||||||
or approved) and add internal notes.
|
or converted) and update expiration.
|
||||||
|
|
||||||
**Example Request:**
|
**Example Request:**
|
||||||
```json
|
```json
|
||||||
@@ -356,8 +354,7 @@ def update_quote(
|
|||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
"status": "reviewing",
|
"status": "viewed"
|
||||||
"admin_notes": "Assigned to sales team. Follow up scheduled for Monday."
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -365,8 +362,7 @@ def update_quote(
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||||
"status": "reviewing",
|
"status": "viewed",
|
||||||
"admin_notes": "Assigned to sales team. Follow up scheduled for Monday.",
|
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -382,3 +378,47 @@ def update_quote(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return get_quote(quote_id, db, current_user)
|
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
|
||||||
|
|||||||
@@ -78,8 +78,7 @@ def create_quote(
|
|||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
"employee_count": 25,
|
"employee_count": 25
|
||||||
"notes": "Looking for complete managed services package"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -159,7 +158,6 @@ def get_quote(
|
|||||||
"employee_count": 25,
|
"employee_count": 25,
|
||||||
"monthly_total": "450.00",
|
"monthly_total": "450.00",
|
||||||
"setup_total": "500.00",
|
"setup_total": "500.00",
|
||||||
"annual_total": "5900.00",
|
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "456e7890-e89b-12d3-a456-426614174001",
|
"id": "456e7890-e89b-12d3-a456-426614174001",
|
||||||
@@ -185,19 +183,19 @@ def get_quote(
|
|||||||
item_dict = QuoteItemResponse(
|
item_dict = QuoteItemResponse(
|
||||||
id=item.id,
|
id=item.id,
|
||||||
quote_id=item.quote_id,
|
quote_id=item.quote_id,
|
||||||
service_name=item.service_name,
|
|
||||||
service_description=item.service_description,
|
|
||||||
category=item.category,
|
category=item.category,
|
||||||
billing_frequency=item.billing_frequency,
|
product_code=item.product_code,
|
||||||
unit_price=item.unit_price,
|
product_name=item.product_name,
|
||||||
|
description=item.description,
|
||||||
quantity=item.quantity,
|
quantity=item.quantity,
|
||||||
setup_fee=item.setup_fee,
|
unit_price=item.unit_price,
|
||||||
is_required=item.is_required,
|
setup_price=item.setup_price,
|
||||||
sort_order=item.sort_order,
|
billing_frequency=item.billing_frequency,
|
||||||
|
tier=item.tier,
|
||||||
|
is_recommended=item.is_recommended,
|
||||||
line_total=item.line_total,
|
line_total=item.line_total,
|
||||||
monthly_amount=item.monthly_amount,
|
monthly_amount=item.monthly_amount,
|
||||||
created_at=item.created_at,
|
created_at=item.created_at,
|
||||||
updated_at=item.updated_at
|
|
||||||
)
|
)
|
||||||
items_response.append(item_dict)
|
items_response.append(item_dict)
|
||||||
|
|
||||||
@@ -210,10 +208,8 @@ def get_quote(
|
|||||||
contact_email=quote.contact_email,
|
contact_email=quote.contact_email,
|
||||||
contact_phone=quote.contact_phone,
|
contact_phone=quote.contact_phone,
|
||||||
employee_count=quote.employee_count,
|
employee_count=quote.employee_count,
|
||||||
notes=quote.notes,
|
|
||||||
monthly_total=quote.monthly_total,
|
monthly_total=quote.monthly_total,
|
||||||
setup_total=quote.setup_total,
|
setup_total=quote.setup_total,
|
||||||
annual_total=quote.annual_total,
|
|
||||||
expires_at=quote.expires_at,
|
expires_at=quote.expires_at,
|
||||||
submitted_at=quote.submitted_at,
|
submitted_at=quote.submitted_at,
|
||||||
created_at=quote.created_at,
|
created_at=quote.created_at,
|
||||||
@@ -432,7 +428,7 @@ def remove_item(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
def submit_quote(
|
async def submit_quote_endpoint(
|
||||||
access_token: str,
|
access_token: str,
|
||||||
submit_data: QuoteSubmit,
|
submit_data: QuoteSubmit,
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -442,7 +438,7 @@ def submit_quote(
|
|||||||
Submit a quote with contact information.
|
Submit a quote with contact information.
|
||||||
|
|
||||||
This finalizes the quote and sends it for review. 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:**
|
**Example Request:**
|
||||||
```json
|
```json
|
||||||
@@ -453,8 +449,7 @@ def submit_quote(
|
|||||||
"company_name": "Acme Corporation",
|
"company_name": "Acme Corporation",
|
||||||
"contact_name": "John Doe",
|
"contact_name": "John Doe",
|
||||||
"contact_email": "john.doe@acme.com",
|
"contact_email": "john.doe@acme.com",
|
||||||
"contact_phone": "555-123-4567",
|
"contact_phone": "555-123-4567"
|
||||||
"notes": "Please contact me to discuss implementation timeline."
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -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)
|
ip_address = get_client_ip(request)
|
||||||
|
|
||||||
quote_service.submit_quote(
|
quote = quote_service.submit_quote(
|
||||||
db=db,
|
db=db,
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
submit_data=submit_data,
|
submit_data=submit_data,
|
||||||
ip_address=ip_address
|
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)
|
return get_quote(access_token, db)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,49 +7,17 @@ public and admin-facing operations.
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from enum import Enum
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, EmailStr, field_validator
|
from pydantic import BaseModel, Field, EmailStr, field_validator
|
||||||
|
|
||||||
|
from api.models.quote import (
|
||||||
class QuoteStatus(str, Enum):
|
QuoteStatus,
|
||||||
"""Status options for quotes."""
|
ServiceCategory,
|
||||||
DRAFT = "draft"
|
BillingFrequency,
|
||||||
SUBMITTED = "submitted"
|
NotificationType,
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -59,21 +27,19 @@ class NotificationType(str, Enum):
|
|||||||
class QuoteItemBase(BaseModel):
|
class QuoteItemBase(BaseModel):
|
||||||
"""Base schema with shared QuoteItem fields."""
|
"""Base schema with shared QuoteItem fields."""
|
||||||
|
|
||||||
service_name: str = Field(..., description="Name of the service", min_length=1, max_length=255)
|
category: ServiceCategory = Field(..., description="Service category")
|
||||||
service_description: Optional[str] = Field(None, description="Detailed description of the service")
|
product_code: str = Field(..., description="Product code identifier", min_length=1, max_length=50)
|
||||||
category: ServiceCategory = Field(
|
product_name: str = Field(..., description="Name of the product/service", min_length=1, max_length=255)
|
||||||
ServiceCategory.MANAGED_SERVICES,
|
description: Optional[str] = Field(None, description="Detailed description of the product/service")
|
||||||
description="Service category"
|
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(
|
billing_frequency: BillingFrequency = Field(
|
||||||
BillingFrequency.MONTHLY,
|
BillingFrequency.MONTHLY,
|
||||||
description="Billing frequency"
|
description="Billing frequency"
|
||||||
)
|
)
|
||||||
unit_price: Decimal = Field(..., description="Price per unit", ge=0)
|
tier: Optional[str] = Field(None, description="Pricing tier", max_length=50)
|
||||||
quantity: int = Field(1, description="Number of units", ge=1)
|
is_recommended: bool = Field(False, description="Whether this item is recommended")
|
||||||
setup_fee: Decimal = Field(Decimal("0.00"), description="One-time setup fee", ge=0)
|
|
||||||
is_required: bool = Field(False, description="Whether this item is required")
|
|
||||||
sort_order: int = Field(0, description="Display order within the quote")
|
|
||||||
|
|
||||||
|
|
||||||
class QuoteItemCreate(QuoteItemBase):
|
class QuoteItemCreate(QuoteItemBase):
|
||||||
@@ -84,15 +50,16 @@ class QuoteItemCreate(QuoteItemBase):
|
|||||||
class QuoteItemUpdate(BaseModel):
|
class QuoteItemUpdate(BaseModel):
|
||||||
"""Schema for updating an existing QuoteItem. All fields optional."""
|
"""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
|
category: Optional[ServiceCategory] = None
|
||||||
billing_frequency: Optional[BillingFrequency] = None
|
product_code: Optional[str] = Field(None, min_length=1, max_length=50)
|
||||||
unit_price: Optional[Decimal] = Field(None, ge=0)
|
product_name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
description: Optional[str] = None
|
||||||
quantity: Optional[int] = Field(None, ge=1)
|
quantity: Optional[int] = Field(None, ge=1)
|
||||||
setup_fee: Optional[Decimal] = Field(None, ge=0)
|
unit_price: Optional[Decimal] = Field(None, ge=0)
|
||||||
is_required: Optional[bool] = None
|
setup_price: Optional[Decimal] = Field(None, ge=0)
|
||||||
sort_order: Optional[int] = None
|
billing_frequency: Optional[BillingFrequency] = None
|
||||||
|
tier: Optional[str] = Field(None, max_length=50)
|
||||||
|
is_recommended: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class QuoteItemResponse(QuoteItemBase):
|
class QuoteItemResponse(QuoteItemBase):
|
||||||
@@ -103,7 +70,6 @@ class QuoteItemResponse(QuoteItemBase):
|
|||||||
line_total: Decimal = Field(..., description="Calculated line total (unit_price * quantity)")
|
line_total: Decimal = Field(..., description="Calculated line total (unit_price * quantity)")
|
||||||
monthly_amount: Decimal = Field(..., description="Calculated monthly amount")
|
monthly_amount: Decimal = Field(..., description="Calculated monthly amount")
|
||||||
created_at: datetime = Field(..., description="Timestamp when item was created")
|
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}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -120,14 +86,12 @@ class QuoteBase(BaseModel):
|
|||||||
contact_email: Optional[EmailStr] = Field(None, description="Contact email address")
|
contact_email: Optional[EmailStr] = Field(None, description="Contact email address")
|
||||||
contact_phone: Optional[str] = Field(None, description="Contact phone number", max_length=50)
|
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)
|
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):
|
class QuoteCreate(BaseModel):
|
||||||
"""Schema for creating a new Quote draft."""
|
"""Schema for creating a new Quote draft."""
|
||||||
|
|
||||||
employee_count: Optional[int] = Field(None, description="Number of employees/users", ge=1)
|
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 can optionally be provided at creation
|
||||||
items: Optional[list[QuoteItemCreate]] = Field(None, description="Initial quote items")
|
items: Optional[list[QuoteItemCreate]] = Field(None, description="Initial quote items")
|
||||||
|
|
||||||
@@ -140,7 +104,6 @@ class QuoteUpdate(BaseModel):
|
|||||||
contact_email: Optional[EmailStr] = None
|
contact_email: Optional[EmailStr] = None
|
||||||
contact_phone: Optional[str] = Field(None, max_length=50)
|
contact_phone: Optional[str] = Field(None, max_length=50)
|
||||||
employee_count: Optional[int] = Field(None, ge=1)
|
employee_count: Optional[int] = Field(None, ge=1)
|
||||||
notes: Optional[str] = None
|
|
||||||
# Items to add/update
|
# Items to add/update
|
||||||
items: Optional[list[QuoteItemCreate]] = Field(None, description="Items to set (replaces existing)")
|
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_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_email: EmailStr = Field(..., description="Email address (required for submission)")
|
||||||
contact_phone: Optional[str] = Field(None, description="Phone number", max_length=50)
|
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")
|
@field_validator("company_name", "contact_name")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -169,7 +132,6 @@ class QuoteResponse(QuoteBase):
|
|||||||
status: QuoteStatus = Field(..., description="Current quote status")
|
status: QuoteStatus = Field(..., description="Current quote status")
|
||||||
monthly_total: Decimal = Field(..., description="Calculated monthly recurring total")
|
monthly_total: Decimal = Field(..., description="Calculated monthly recurring total")
|
||||||
setup_total: Decimal = Field(..., description="Calculated one-time setup 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")
|
expires_at: Optional[datetime] = Field(None, description="Quote expiration date")
|
||||||
submitted_at: Optional[datetime] = Field(None, description="When quote was submitted")
|
submitted_at: Optional[datetime] = Field(None, description="When quote was submitted")
|
||||||
created_at: datetime = Field(..., description="Timestamp when quote was created")
|
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")
|
id: UUID = Field(..., description="Unique identifier for the activity")
|
||||||
quote_id: UUID = Field(..., description="Reference to the parent quote")
|
quote_id: UUID = Field(..., description="Reference to the parent quote")
|
||||||
action: str = Field(..., description="Action performed")
|
action: str = Field(..., description="Action performed")
|
||||||
description: Optional[str] = Field(None, description="Detailed description")
|
step_name: Optional[str] = Field(None, description="Wizard step name")
|
||||||
actor: Optional[str] = Field(None, description="Who performed the action")
|
details: Optional[str] = Field(None, description="Additional details")
|
||||||
ip_address: Optional[str] = Field(None, description="IP address of the actor")
|
ip_address: Optional[str] = Field(None, description="IP address of the actor")
|
||||||
created_at: datetime = Field(..., description="Timestamp of the action")
|
created_at: datetime = Field(..., description="Timestamp of the action")
|
||||||
|
|
||||||
@@ -221,6 +183,8 @@ class QuoteNotificationResponse(BaseModel):
|
|||||||
recipient: str = Field(..., description="Notification recipient")
|
recipient: str = Field(..., description="Notification recipient")
|
||||||
subject: Optional[str] = Field(None, description="Notification subject")
|
subject: Optional[str] = Field(None, description="Notification subject")
|
||||||
status: str = Field(..., description="Delivery status")
|
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")
|
sent_at: Optional[datetime] = Field(None, description="When notification was sent")
|
||||||
error_message: Optional[str] = Field(None, description="Error message if failed")
|
error_message: Optional[str] = Field(None, description="Error message if failed")
|
||||||
created_at: datetime = Field(..., description="Timestamp when created")
|
created_at: datetime = Field(..., description="Timestamp when created")
|
||||||
@@ -236,14 +200,12 @@ class QuoteAdminUpdate(BaseModel):
|
|||||||
"""Schema for admin updates to a quote."""
|
"""Schema for admin updates to a quote."""
|
||||||
|
|
||||||
status: Optional[QuoteStatus] = Field(None, description="New status")
|
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")
|
expires_at: Optional[datetime] = Field(None, description="Quote expiration date")
|
||||||
|
|
||||||
|
|
||||||
class QuoteAdminResponse(QuoteResponse):
|
class QuoteAdminResponse(QuoteResponse):
|
||||||
"""Schema for admin Quote responses with additional fields."""
|
"""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")
|
ip_address: Optional[str] = Field(None, description="IP address of the requester")
|
||||||
user_agent: Optional[str] = Field(None, description="Browser user agent")
|
user_agent: Optional[str] = Field(None, description="Browser user agent")
|
||||||
activities: list[QuoteActivityResponse] = Field(
|
activities: list[QuoteActivityResponse] = Field(
|
||||||
|
|||||||
204
api/services/email_service.py
Normal file
204
api/services/email_service.py
Normal 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>
|
||||||
|
"""
|
||||||
@@ -5,8 +5,8 @@ This module handles all database operations for quotes, providing a clean
|
|||||||
separation between the API routes and data access layer.
|
separation between the API routes and data access layer.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@@ -50,15 +50,15 @@ def generate_access_token() -> str:
|
|||||||
return secrets.token_urlsafe(32)
|
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:
|
Args:
|
||||||
items: List of QuoteItem objects
|
items: List of QuoteItem objects
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple: (monthly_total, setup_total, annual_total)
|
tuple: (monthly_total, setup_total)
|
||||||
"""
|
"""
|
||||||
monthly_total = Decimal("0.00")
|
monthly_total = Decimal("0.00")
|
||||||
setup_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
|
# Add to appropriate total based on billing frequency
|
||||||
if item.billing_frequency == BillingFrequency.MONTHLY.value:
|
if item.billing_frequency == BillingFrequency.MONTHLY.value:
|
||||||
monthly_total += line_total
|
monthly_total += line_total
|
||||||
elif item.billing_frequency == BillingFrequency.QUARTERLY.value:
|
elif item.billing_frequency == BillingFrequency.YEARLY.value:
|
||||||
monthly_total += line_total / Decimal("3")
|
|
||||||
elif item.billing_frequency == BillingFrequency.ANNUAL.value:
|
|
||||||
monthly_total += line_total / Decimal("12")
|
monthly_total += line_total / Decimal("12")
|
||||||
# one_time items don't add to monthly
|
# one_time items don't add to monthly
|
||||||
|
|
||||||
# Setup fees are always one-time
|
# Setup prices are always one-time
|
||||||
setup_total += item.setup_fee
|
setup_total += item.setup_price
|
||||||
|
|
||||||
# Annual total is monthly * 12 + setup
|
return monthly_total, setup_total
|
||||||
annual_total = (monthly_total * Decimal("12")) + setup_total
|
|
||||||
|
|
||||||
return monthly_total, setup_total, annual_total
|
|
||||||
|
|
||||||
|
|
||||||
def log_activity(
|
def log_activity(
|
||||||
db: Session,
|
db: Session,
|
||||||
quote_id: str,
|
quote_id: str,
|
||||||
action: str,
|
action: str,
|
||||||
description: Optional[str] = None,
|
details: Optional[str] = None,
|
||||||
actor: Optional[str] = None,
|
step_name: Optional[str] = None,
|
||||||
ip_address: Optional[str] = None,
|
ip_address: Optional[str] = None,
|
||||||
metadata: Optional[dict] = None
|
|
||||||
) -> QuoteActivity:
|
) -> QuoteActivity:
|
||||||
"""
|
"""
|
||||||
Log an activity for a quote.
|
Log an activity for a quote.
|
||||||
@@ -101,21 +95,23 @@ def log_activity(
|
|||||||
db: Database session
|
db: Database session
|
||||||
quote_id: UUID of the quote
|
quote_id: UUID of the quote
|
||||||
action: Action being performed
|
action: Action being performed
|
||||||
description: Detailed description
|
details: Additional details about the action (stored as JSON)
|
||||||
actor: Who performed the action
|
step_name: Wizard step name associated with the action
|
||||||
ip_address: IP address of the actor
|
ip_address: IP address of the actor
|
||||||
metadata: Additional metadata as dict
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
QuoteActivity: The created activity record
|
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(
|
activity = QuoteActivity(
|
||||||
quote_id=quote_id,
|
quote_id=quote_id,
|
||||||
action=action,
|
action=action,
|
||||||
description=description,
|
step_name=step_name,
|
||||||
actor=actor,
|
details=details_json,
|
||||||
ip_address=ip_address,
|
ip_address=ip_address,
|
||||||
metadata=json.dumps(metadata) if metadata else None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(activity)
|
db.add(activity)
|
||||||
@@ -155,7 +151,6 @@ def create_quote(
|
|||||||
access_token=generate_access_token(),
|
access_token=generate_access_token(),
|
||||||
status=QuoteStatus.DRAFT.value,
|
status=QuoteStatus.DRAFT.value,
|
||||||
employee_count=quote_data.employee_count,
|
employee_count=quote_data.employee_count,
|
||||||
notes=quote_data.notes,
|
|
||||||
ip_address=ip_address,
|
ip_address=ip_address,
|
||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
# Set expiration to 30 days from now
|
# Set expiration to 30 days from now
|
||||||
@@ -170,34 +165,33 @@ def create_quote(
|
|||||||
for idx, item_data in enumerate(quote_data.items):
|
for idx, item_data in enumerate(quote_data.items):
|
||||||
item = QuoteItem(
|
item = QuoteItem(
|
||||||
quote_id=quote.id,
|
quote_id=quote.id,
|
||||||
service_name=item_data.service_name,
|
|
||||||
service_description=item_data.service_description,
|
|
||||||
category=item_data.category.value,
|
category=item_data.category.value,
|
||||||
billing_frequency=item_data.billing_frequency.value,
|
product_code=item_data.product_code,
|
||||||
unit_price=item_data.unit_price,
|
product_name=item_data.product_name,
|
||||||
|
description=item_data.description,
|
||||||
quantity=item_data.quantity,
|
quantity=item_data.quantity,
|
||||||
setup_fee=item_data.setup_fee,
|
unit_price=item_data.unit_price,
|
||||||
is_required=item_data.is_required,
|
setup_price=item_data.setup_price,
|
||||||
sort_order=item_data.sort_order if item_data.sort_order else idx
|
billing_frequency=item_data.billing_frequency.value,
|
||||||
|
tier=item_data.tier,
|
||||||
|
is_recommended=item_data.is_recommended,
|
||||||
)
|
)
|
||||||
db.add(item)
|
db.add(item)
|
||||||
|
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
# Calculate and update totals
|
# Calculate and update totals
|
||||||
monthly, setup, annual = calculate_totals(quote.items)
|
monthly, setup = calculate_totals(quote.items)
|
||||||
quote.monthly_total = monthly
|
quote.monthly_total = monthly
|
||||||
quote.setup_total = setup
|
quote.setup_total = setup
|
||||||
quote.annual_total = annual
|
|
||||||
|
|
||||||
# Log activity
|
# Log activity
|
||||||
log_activity(
|
log_activity(
|
||||||
db=db,
|
db=db,
|
||||||
quote_id=quote.id,
|
quote_id=quote.id,
|
||||||
action="created",
|
action="created",
|
||||||
description="Quote draft created",
|
details=f"Quote draft created, employee_count={quote_data.employee_count}",
|
||||||
ip_address=ip_address,
|
ip_address=ip_address,
|
||||||
metadata={"employee_count": quote_data.employee_count}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -344,15 +338,16 @@ def update_quote(
|
|||||||
for idx, item_data in enumerate(quote_data.items):
|
for idx, item_data in enumerate(quote_data.items):
|
||||||
item = QuoteItem(
|
item = QuoteItem(
|
||||||
quote_id=quote.id,
|
quote_id=quote.id,
|
||||||
service_name=item_data.service_name,
|
|
||||||
service_description=item_data.service_description,
|
|
||||||
category=item_data.category.value,
|
category=item_data.category.value,
|
||||||
billing_frequency=item_data.billing_frequency.value,
|
product_code=item_data.product_code,
|
||||||
unit_price=item_data.unit_price,
|
product_name=item_data.product_name,
|
||||||
|
description=item_data.description,
|
||||||
quantity=item_data.quantity,
|
quantity=item_data.quantity,
|
||||||
setup_fee=item_data.setup_fee,
|
unit_price=item_data.unit_price,
|
||||||
is_required=item_data.is_required,
|
setup_price=item_data.setup_price,
|
||||||
sort_order=item_data.sort_order if item_data.sort_order else idx
|
billing_frequency=item_data.billing_frequency.value,
|
||||||
|
tier=item_data.tier,
|
||||||
|
is_recommended=item_data.is_recommended,
|
||||||
)
|
)
|
||||||
db.add(item)
|
db.add(item)
|
||||||
|
|
||||||
@@ -362,10 +357,9 @@ def update_quote(
|
|||||||
|
|
||||||
# Recalculate totals
|
# Recalculate totals
|
||||||
db.refresh(quote)
|
db.refresh(quote)
|
||||||
monthly, setup, annual = calculate_totals(quote.items)
|
monthly, setup = calculate_totals(quote.items)
|
||||||
quote.monthly_total = monthly
|
quote.monthly_total = monthly
|
||||||
quote.setup_total = setup
|
quote.setup_total = setup
|
||||||
quote.annual_total = annual
|
|
||||||
|
|
||||||
# Log activity
|
# Log activity
|
||||||
if changes:
|
if changes:
|
||||||
@@ -373,7 +367,7 @@ def update_quote(
|
|||||||
db=db,
|
db=db,
|
||||||
quote_id=quote.id,
|
quote_id=quote.id,
|
||||||
action="updated",
|
action="updated",
|
||||||
description=f"Quote updated: {', '.join(changes)}",
|
details=f"Quote updated: {', '.join(changes)}",
|
||||||
ip_address=ip_address
|
ip_address=ip_address
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -438,8 +432,6 @@ def submit_quote(
|
|||||||
quote.contact_name = submit_data.contact_name
|
quote.contact_name = submit_data.contact_name
|
||||||
quote.contact_email = submit_data.contact_email
|
quote.contact_email = submit_data.contact_email
|
||||||
quote.contact_phone = submit_data.contact_phone
|
quote.contact_phone = submit_data.contact_phone
|
||||||
if submit_data.notes:
|
|
||||||
quote.notes = submit_data.notes
|
|
||||||
|
|
||||||
# Update status and timestamp
|
# Update status and timestamp
|
||||||
quote.status = QuoteStatus.SUBMITTED.value
|
quote.status = QuoteStatus.SUBMITTED.value
|
||||||
@@ -453,24 +445,17 @@ def submit_quote(
|
|||||||
db=db,
|
db=db,
|
||||||
quote_id=quote.id,
|
quote_id=quote.id,
|
||||||
action="submitted",
|
action="submitted",
|
||||||
description=f"Quote submitted by {submit_data.contact_name} ({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}",
|
||||||
actor=submit_data.contact_email,
|
|
||||||
ip_address=ip_address,
|
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)
|
# Create admin notification record (actual sending would be handled elsewhere)
|
||||||
notification = QuoteNotification(
|
notification = QuoteNotification(
|
||||||
quote_id=quote.id,
|
quote_id=quote.id,
|
||||||
notification_type="admin_alert",
|
notification_type="email",
|
||||||
recipient="admin@example.com", # Would come from config in production
|
recipient=os.environ.get("ADMIN_NOTIFICATION_EMAIL", "mike@azcomputerguru.com"),
|
||||||
subject=f"New Quote Submission: {submit_data.company_name}",
|
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"
|
status="pending"
|
||||||
)
|
)
|
||||||
db.add(notification)
|
db.add(notification)
|
||||||
@@ -478,6 +463,10 @@ def submit_quote(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(quote)
|
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
|
return quote
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
@@ -556,11 +545,7 @@ async def sync_quote_to_syncro(db: Session, quote: Quote) -> dict:
|
|||||||
db=db,
|
db=db,
|
||||||
quote_id=quote.id,
|
quote_id=quote.id,
|
||||||
action="syncro_customer_found",
|
action="syncro_customer_found",
|
||||||
description=f"Existing Syncro customer found: {customer_check.customer_name}",
|
details=f"Existing Syncro customer found: {customer_check.customer_name} (ID: {customer_check.customer_id}, match: {customer_check.match_type})",
|
||||||
metadata={
|
|
||||||
"syncro_customer_id": customer_check.customer_id,
|
|
||||||
"match_type": customer_check.match_type
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create lead in Syncro
|
# Create lead in Syncro
|
||||||
@@ -577,11 +562,7 @@ async def sync_quote_to_syncro(db: Session, quote: Quote) -> dict:
|
|||||||
db=db,
|
db=db,
|
||||||
quote_id=quote.id,
|
quote_id=quote.id,
|
||||||
action="syncro_lead_created",
|
action="syncro_lead_created",
|
||||||
description=f"Lead created in Syncro: {lead_result.lead_id}",
|
details=f"Lead created in Syncro: {lead_result.lead_id}, is_existing_customer={customer_check.exists}",
|
||||||
metadata={
|
|
||||||
"syncro_lead_id": lead_result.lead_id,
|
|
||||||
"is_existing_customer": customer_check.exists
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
result["error"] = lead_result.error
|
result["error"] = lead_result.error
|
||||||
@@ -594,8 +575,7 @@ async def sync_quote_to_syncro(db: Session, quote: Quote) -> dict:
|
|||||||
db=db,
|
db=db,
|
||||||
quote_id=quote.id,
|
quote_id=quote.id,
|
||||||
action="syncro_sync_failed",
|
action="syncro_sync_failed",
|
||||||
description=f"Failed to sync to Syncro: {lead_result.error}",
|
details=f"Failed to sync to Syncro: {lead_result.error}",
|
||||||
metadata={"error": lead_result.error}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Commit the updates to quote
|
# Commit the updates to quote
|
||||||
@@ -616,8 +596,7 @@ async def sync_quote_to_syncro(db: Session, quote: Quote) -> dict:
|
|||||||
db=db,
|
db=db,
|
||||||
quote_id=quote.id,
|
quote_id=quote.id,
|
||||||
action="syncro_sync_error",
|
action="syncro_sync_error",
|
||||||
description=f"Syncro sync error: {error_msg}",
|
details=f"Syncro sync error: {error_msg}",
|
||||||
metadata={"error": error_msg}
|
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -682,7 +661,7 @@ def update_quote_status(
|
|||||||
admin_user: str
|
admin_user: str
|
||||||
) -> Quote:
|
) -> Quote:
|
||||||
"""
|
"""
|
||||||
Update quote status and admin notes (admin).
|
Update quote status and expiration (admin).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
@@ -703,10 +682,6 @@ def update_quote_status(
|
|||||||
quote.status = update_data.status.value
|
quote.status = update_data.status.value
|
||||||
changes.append(f"status: {old_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:
|
if update_data.expires_at is not None:
|
||||||
quote.expires_at = update_data.expires_at
|
quote.expires_at = update_data.expires_at
|
||||||
changes.append(f"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,
|
db=db,
|
||||||
quote_id=quote.id,
|
quote_id=quote.id,
|
||||||
action="admin_update",
|
action="admin_update",
|
||||||
description=f"Admin update: {', '.join(changes)}",
|
details=f"Admin update by {admin_user}: {', '.join(changes)}",
|
||||||
actor=admin_user
|
|
||||||
)
|
)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -758,8 +732,9 @@ def get_quote_stats(db: Session) -> QuoteStatsResponse:
|
|||||||
# Total values for submitted quotes
|
# Total values for submitted quotes
|
||||||
submitted_statuses = [
|
submitted_statuses = [
|
||||||
QuoteStatus.SUBMITTED.value,
|
QuoteStatus.SUBMITTED.value,
|
||||||
QuoteStatus.REVIEWING.value,
|
QuoteStatus.VIEWED.value,
|
||||||
QuoteStatus.APPROVED.value
|
QuoteStatus.FOLLOWED_UP.value,
|
||||||
|
QuoteStatus.CONVERTED.value,
|
||||||
]
|
]
|
||||||
value_query = (
|
value_query = (
|
||||||
db.query(
|
db.query(
|
||||||
@@ -849,41 +824,34 @@ def add_item_to_quote(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
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(
|
item = QuoteItem(
|
||||||
quote_id=quote.id,
|
quote_id=quote.id,
|
||||||
service_name=item_data.service_name,
|
|
||||||
service_description=item_data.service_description,
|
|
||||||
category=item_data.category.value,
|
category=item_data.category.value,
|
||||||
billing_frequency=item_data.billing_frequency.value,
|
product_code=item_data.product_code,
|
||||||
unit_price=item_data.unit_price,
|
product_name=item_data.product_name,
|
||||||
|
description=item_data.description,
|
||||||
quantity=item_data.quantity,
|
quantity=item_data.quantity,
|
||||||
setup_fee=item_data.setup_fee,
|
unit_price=item_data.unit_price,
|
||||||
is_required=item_data.is_required,
|
setup_price=item_data.setup_price,
|
||||||
sort_order=item_data.sort_order if item_data.sort_order else max_order + 1
|
billing_frequency=item_data.billing_frequency.value,
|
||||||
|
tier=item_data.tier,
|
||||||
|
is_recommended=item_data.is_recommended,
|
||||||
)
|
)
|
||||||
db.add(item)
|
db.add(item)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
# Recalculate totals
|
# Recalculate totals
|
||||||
db.refresh(quote)
|
db.refresh(quote)
|
||||||
monthly, setup, annual = calculate_totals(quote.items)
|
monthly, setup = calculate_totals(quote.items)
|
||||||
quote.monthly_total = monthly
|
quote.monthly_total = monthly
|
||||||
quote.setup_total = setup
|
quote.setup_total = setup
|
||||||
quote.annual_total = annual
|
|
||||||
|
|
||||||
# Log activity
|
# Log activity
|
||||||
log_activity(
|
log_activity(
|
||||||
db=db,
|
db=db,
|
||||||
quote_id=quote.id,
|
quote_id=quote.id,
|
||||||
action="item_added",
|
action="item_added",
|
||||||
description=f"Added item: {item_data.service_name}",
|
details=f"Added item: {item_data.product_name}",
|
||||||
ip_address=ip_address
|
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"
|
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:
|
try:
|
||||||
item_name = item.service_name
|
item_name = item.product_name
|
||||||
db.delete(item)
|
db.delete(item)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
# Recalculate totals
|
# Recalculate totals
|
||||||
db.refresh(quote)
|
db.refresh(quote)
|
||||||
monthly, setup, annual = calculate_totals(quote.items)
|
monthly, setup = calculate_totals(quote.items)
|
||||||
quote.monthly_total = monthly
|
quote.monthly_total = monthly
|
||||||
quote.setup_total = setup
|
quote.setup_total = setup
|
||||||
quote.annual_total = annual
|
|
||||||
|
|
||||||
# Log activity
|
# Log activity
|
||||||
log_activity(
|
log_activity(
|
||||||
db=db,
|
db=db,
|
||||||
quote_id=quote.id,
|
quote_id=quote.id,
|
||||||
action="item_removed",
|
action="item_removed",
|
||||||
description=f"Removed item: {item_name}",
|
details=f"Removed item: {item_name}",
|
||||||
ip_address=ip_address
|
ip_address=ip_address
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ API Documentation: https://api-docs.syncromsp.com/
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@@ -20,9 +21,10 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# TODO: Move to environment variables or secure configuration for production
|
SYNCRO_API_BASE_URL = os.environ.get(
|
||||||
SYNCRO_API_BASE_URL = "https://computerguru.syncromsp.com/api/v1"
|
"SYNCRO_API_BASE_URL", "https://computerguru.syncromsp.com/api/v1"
|
||||||
SYNCRO_API_KEY = "T259810e5c9917386b-52c2aeea7cdb5ff41c6685a73cebbeb3"
|
)
|
||||||
|
SYNCRO_API_KEY = os.environ.get("SYNCRO_API_KEY", "")
|
||||||
|
|
||||||
# HTTP client configuration
|
# HTTP client configuration
|
||||||
SYNCRO_TIMEOUT_SECONDS = 30.0
|
SYNCRO_TIMEOUT_SECONDS = 30.0
|
||||||
|
|||||||
BIN
claudetools-migration-20260225.tar.gpg
Normal file
BIN
claudetools-migration-20260225.tar.gpg
Normal file
Binary file not shown.
29
clients/bg-builders/lesley-disable-summary.md
Normal file
29
clients/bg-builders/lesley-disable-summary.md
Normal 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
|
||||||
74
clients/bg-builders/session-logs/2026-03-09-session.md
Normal file
74
clients/bg-builders/session-logs/2026-03-09-session.md
Normal 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
|
||||||
1565
clients/gurushow/archive-player/index.html
Normal file
1565
clients/gurushow/archive-player/index.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 Network
|
||||||
|
|
||||||
| Tailscale IP | Hostname | Owner | OS | Notes |
|
| Tailscale IP | Hostname | Owner | OS | Notes |
|
||||||
|
|||||||
69
projects/msp-tools/quote-wizard/fix_api.py
Normal file
69
projects/msp-tools/quote-wizard/fix_api.py
Normal 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}')
|
||||||
1
projects/msp-tools/quote-wizard/frontend/.env.production
Normal file
1
projects/msp-tools/quote-wizard/frontend/.env.production
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL=/msp-api
|
||||||
@@ -2,10 +2,13 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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="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" />
|
<meta name="description" content="Get a custom IT services quote for your business from AZ Computer Guru - Arizona's trusted managed service provider." />
|
||||||
<title>MSP Quote Wizard | AZ Computer Guru</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,22 +1,66 @@
|
|||||||
import { WizardContainer } from '@/components/wizard/WizardContainer'
|
import { WizardContainer } from '@/components/wizard/WizardContainer'
|
||||||
|
import { Shield, Phone, MapPin } from 'lucide-react'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-[#f8f9fb] flex flex-col">
|
||||||
<header className="bg-[#333d49] text-white py-4 px-6">
|
{/* Header */}
|
||||||
<div className="max-w-6xl mx-auto flex items-center justify-between">
|
<header className="bg-white border-b border-gray-200">
|
||||||
<h1 className="text-xl font-semibold">MSP Quote Wizard</h1>
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between">
|
||||||
<span className="text-sm text-gray-300">Powered by AZ Computer Guru</span>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="py-8">
|
{/* Main content */}
|
||||||
|
<main className="flex-1 py-8 sm:py-10">
|
||||||
<WizardContainer />
|
<WizardContainer />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="bg-[#113559] text-white py-6 px-6 mt-auto">
|
{/* Footer */}
|
||||||
<div className="max-w-6xl mx-auto text-center text-sm">
|
<footer className="bg-gradient-navy text-white py-8 px-4 sm:px-6">
|
||||||
<p>© {new Date().getFullYear()} AZ Computer Guru. All rights reserved.</p>
|
<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>© {new Date().getFullYear()} AZ Computer Guru</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,22 +21,29 @@ export function ExpandableInfo({
|
|||||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||||
|
|
||||||
return (
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
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}
|
aria-expanded={isExpanded}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{icon || <HelpCircle className="w-5 h-5 text-[#fe7400]" />}
|
{icon || (
|
||||||
<span className="font-medium text-[#333d49]">{title}</span>
|
<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>
|
</div>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ rotate: isExpanded ? 180 : 0 }}
|
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
>
|
>
|
||||||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -48,8 +55,8 @@ export function ExpandableInfo({
|
|||||||
exit={{ height: 0, opacity: 0 }}
|
exit={{ height: 0, opacity: 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
>
|
>
|
||||||
<div className="px-4 pb-4 pt-0 text-sm text-gray-600 border-t border-gray-100">
|
<div className="px-4 pb-4 pt-0 text-sm text-gray-500 border-t border-gray-100">
|
||||||
<div className="pt-4">{children}</div>
|
<div className="pt-4 leading-relaxed">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,40 +16,43 @@ export function PricingCard({ tier, isSelected, deviceCount, onSelect }: Pricing
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
whileHover={{ y: -4 }}
|
whileHover={{ y: -3 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
|
variant={isSelected ? 'highlighted' : 'default'}
|
||||||
padding="none"
|
padding="none"
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative overflow-hidden',
|
'relative overflow-hidden',
|
||||||
tier.recommended && !isSelected && 'ring-2 ring-[#333d49]'
|
tier.recommended && !isSelected && 'ring-2 ring-[#fe7400]/30'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Recommended badge */}
|
{/* Recommended badge */}
|
||||||
{tier.recommended && (
|
{tier.recommended && (
|
||||||
<div className="absolute top-0 right-0">
|
<div className="bg-gradient-accent text-white text-[11px] font-bold px-3 py-1.5 text-center uppercase tracking-wider"
|
||||||
<div className="bg-[#fe7400] text-white text-xs font-semibold px-3 py-1 rounded-bl-lg">
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
Recommended
|
Recommended
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-xl font-semibold text-[#333d49]">{tier.name}</h3>
|
<h3 className="text-xl font-bold text-[#333d49]"
|
||||||
<p className="text-sm text-gray-500 mt-1">{tier.description}</p>
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
|
{tier.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">{tier.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pricing */}
|
{/* Pricing */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-baseline gap-1">
|
<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)}
|
{formatCurrency(monthlyEstimate)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-500">/month</span>
|
<span className="text-gray-400">/month</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400 mt-1">
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
{formatCurrency(tier.basePrice)} base + {formatCurrency(tier.perDevicePrice)}/device
|
{formatCurrency(tier.basePrice)} base + {formatCurrency(tier.perDevicePrice)}/device
|
||||||
@@ -57,10 +60,12 @@ export function PricingCard({ tier, isSelected, deviceCount, onSelect }: Pricing
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Features */}
|
{/* Features */}
|
||||||
<ul className="space-y-2 mb-6">
|
<ul className="space-y-2.5 mb-6">
|
||||||
{tier.features.map((feature, index) => (
|
{tier.features.map((feature, index) => (
|
||||||
<li key={index} className="flex items-start gap-2 text-sm">
|
<li key={index} className="flex items-start gap-2.5 text-sm">
|
||||||
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
<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>
|
<span className="text-gray-600">{feature}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -35,43 +35,57 @@ export function TierComparison({ tiers, selectedTier, onSelectTier }: TierCompar
|
|||||||
const renderCell = (value: boolean | string) => {
|
const renderCell = (value: boolean | string) => {
|
||||||
if (typeof value === 'boolean') {
|
if (typeof value === 'boolean') {
|
||||||
return value ? (
|
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 (
|
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">
|
<table className="w-full border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left p-4 border-b border-gray-200 bg-gray-50">
|
<th className="text-left p-4 border-b border-gray-100 bg-[#f8f9fb]">
|
||||||
<span className="font-semibold text-[#333d49]">Feature</span>
|
<span className="font-bold text-[#333d49] text-sm"
|
||||||
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
|
Feature
|
||||||
|
</span>
|
||||||
</th>
|
</th>
|
||||||
{tiers.map((tier) => (
|
{tiers.map((tier) => (
|
||||||
<th
|
<th
|
||||||
key={tier.id}
|
key={tier.id}
|
||||||
className={cn(
|
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
|
selectedTier === tier.id
|
||||||
? 'bg-[#fe7400]/10'
|
? 'bg-[#fe7400]/5'
|
||||||
: 'bg-gray-50 hover:bg-gray-100'
|
: 'bg-[#f8f9fb] hover:bg-gray-100'
|
||||||
)}
|
)}
|
||||||
onClick={() => onSelectTier(tier.id)}
|
onClick={() => onSelectTier(tier.id)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-semibold',
|
'font-bold text-sm',
|
||||||
selectedTier === tier.id ? 'text-[#fe7400]' : 'text-[#333d49]'
|
selectedTier === tier.id ? 'text-[#fe7400]' : 'text-[#333d49]'
|
||||||
)}
|
)}
|
||||||
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
|
||||||
>
|
>
|
||||||
{tier.name}
|
{tier.name}
|
||||||
</span>
|
</span>
|
||||||
{tier.recommended && (
|
{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>
|
</th>
|
||||||
))}
|
))}
|
||||||
@@ -79,30 +93,30 @@ export function TierComparison({ tiers, selectedTier, onSelectTier }: TierCompar
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{comparisonFeatures.map((feature, index) => (
|
{comparisonFeatures.map((feature, index) => (
|
||||||
<tr key={feature.name} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50/50'}>
|
<tr key={feature.name} className={index % 2 === 0 ? 'bg-white' : 'bg-[#f8f9fb]/50'}>
|
||||||
<td className="p-4 border-b border-gray-100 text-sm text-gray-600">
|
<td className="p-4 border-b border-gray-50 text-sm text-gray-500">
|
||||||
{feature.name}
|
{feature.name}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className={cn(
|
className={cn(
|
||||||
'p-4 border-b border-gray-100 text-center',
|
'p-4 border-b border-gray-50 text-center',
|
||||||
selectedTier === 'essential' && 'bg-[#fe7400]/5'
|
selectedTier === 'essential' && 'bg-[#fe7400]/3'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{renderCell(feature.essential)}
|
{renderCell(feature.essential)}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className={cn(
|
className={cn(
|
||||||
'p-4 border-b border-gray-100 text-center',
|
'p-4 border-b border-gray-50 text-center',
|
||||||
selectedTier === 'professional' && 'bg-[#fe7400]/5'
|
selectedTier === 'professional' && 'bg-[#fe7400]/3'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{renderCell(feature.professional)}
|
{renderCell(feature.professional)}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className={cn(
|
className={cn(
|
||||||
'p-4 border-b border-gray-100 text-center',
|
'p-4 border-b border-gray-50 text-center',
|
||||||
selectedTier === 'enterprise' && 'bg-[#fe7400]/5'
|
selectedTier === 'enterprise' && 'bg-[#fe7400]/3'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{renderCell(feature.enterprise)}
|
{renderCell(feature.enterprise)}
|
||||||
|
|||||||
@@ -22,32 +22,33 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
const baseStyles =
|
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 = {
|
const variants = {
|
||||||
primary:
|
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:
|
secondary:
|
||||||
'bg-[#333d49] text-white hover:bg-[#252d36] focus-visible:ring-[#333d49] shadow-sm hover:shadow-md',
|
'bg-[#333d49] text-white hover:bg-[#252d36] focus-visible:ring-[#333d49] shadow-sm hover:shadow-md',
|
||||||
outline:
|
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:
|
ghost:
|
||||||
'text-[#333d49] hover:bg-gray-100 focus-visible:ring-[#333d49]',
|
'text-[#333d49] hover:bg-gray-100/80 focus-visible:ring-[#333d49]',
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
sm: 'px-3 py-1.5 text-sm',
|
sm: 'px-4 py-2 text-sm',
|
||||||
md: 'px-5 py-2.5 text-base',
|
md: 'px-6 py-2.5 text-sm',
|
||||||
lg: 'px-7 py-3.5 text-lg',
|
lg: 'px-8 py-3.5 text-base',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.button
|
<motion.button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
whileHover={{ scale: disabled || isLoading ? 1 : 1.02 }}
|
whileHover={{ scale: disabled || isLoading ? 1 : 1.015 }}
|
||||||
whileTap={{ scale: disabled || isLoading ? 1 : 0.98 }}
|
whileTap={{ scale: disabled || isLoading ? 1 : 0.985 }}
|
||||||
className={cn(baseStyles, variants[variant], sizes[size], className)}
|
className={cn(baseStyles, variants[variant], sizes[size], className)}
|
||||||
disabled={disabled || isLoading}
|
disabled={disabled || isLoading}
|
||||||
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{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"
|
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>
|
</svg>
|
||||||
Loading...
|
Processing...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
children
|
children
|
||||||
|
|||||||
@@ -23,13 +23,20 @@ const Card = forwardRef<HTMLDivElement, CardProps>(
|
|||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
const baseStyles = 'rounded-xl transition-all duration-200';
|
const baseStyles = 'rounded-2xl transition-all duration-300';
|
||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
default: 'bg-white border border-gray-200',
|
default: 'bg-white border border-gray-200/80',
|
||||||
elevated: 'bg-white shadow-lg',
|
elevated: 'bg-white border border-gray-200/60',
|
||||||
outlined: 'bg-transparent border-2 border-[#333d49]',
|
outlined: 'bg-transparent border-2 border-[#333d49]/20',
|
||||||
highlighted: 'bg-white border-2 border-[#fe7400] shadow-lg',
|
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 = {
|
const paddings = {
|
||||||
@@ -40,15 +47,15 @@ const Card = forwardRef<HTMLDivElement, CardProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hoverStyles = hoverable
|
const hoverStyles = hoverable
|
||||||
? 'cursor-pointer hover:shadow-xl hover:-translate-y-1'
|
? 'cursor-pointer hover:-translate-y-0.5'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
if (hoverable) {
|
if (hoverable) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
whileHover={{ scale: 1.01 }}
|
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.99 }}
|
whileTap={{ scale: 0.995 }}
|
||||||
className={cn(
|
className={cn(
|
||||||
baseStyles,
|
baseStyles,
|
||||||
variants[variant],
|
variants[variant],
|
||||||
@@ -56,6 +63,7 @@ const Card = forwardRef<HTMLDivElement, CardProps>(
|
|||||||
hoverStyles,
|
hoverStyles,
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
style={shadowStyles[variant]}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -72,6 +80,7 @@ const Card = forwardRef<HTMLDivElement, CardProps>(
|
|||||||
paddings[padding],
|
paddings[padding],
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
style={shadowStyles[variant]}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -82,7 +91,6 @@ const Card = forwardRef<HTMLDivElement, CardProps>(
|
|||||||
|
|
||||||
Card.displayName = 'Card';
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
// Card subcomponents
|
|
||||||
const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
@@ -99,6 +107,7 @@ const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingEleme
|
|||||||
<h3
|
<h3
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('text-xl font-semibold text-[#333d49]', className)}
|
className={cn('text-xl font-semibold text-[#333d49]', className)}
|
||||||
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -109,7 +118,7 @@ const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLPara
|
|||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<p
|
<p
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('text-sm text-gray-500 mt-1', className)}
|
className={cn('text-sm text-gray-400 mt-1', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
{label && (
|
{label && (
|
||||||
<label
|
<label
|
||||||
htmlFor={inputId}
|
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}
|
||||||
</label>
|
</label>
|
||||||
@@ -26,13 +27,13 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
type={type}
|
type={type}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full px-4 py-2.5 rounded-lg border transition-all duration-200',
|
'w-full px-4 py-3 rounded-xl border transition-all duration-200',
|
||||||
'text-[#333d49] placeholder-gray-400',
|
'text-[#333d49] placeholder-gray-400 bg-white',
|
||||||
'focus:outline-none focus:ring-2 focus:ring-offset-0',
|
'focus:outline-none focus:ring-2 focus:ring-offset-0',
|
||||||
error
|
error
|
||||||
? 'border-red-500 focus:border-red-500 focus:ring-red-200'
|
? 'border-red-400 focus:border-red-400 focus:ring-red-100'
|
||||||
: 'border-gray-300 focus:border-[#fe7400] focus:ring-[#fe7400]/20',
|
: 'border-gray-200 hover:border-gray-300 focus:border-[#fe7400] focus:ring-[#fe7400]/15',
|
||||||
'disabled:bg-gray-50 disabled:text-gray-500 disabled:cursor-not-allowed',
|
'disabled:bg-gray-50 disabled:text-gray-400 disabled:cursor-not-allowed',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
aria-invalid={error ? 'true' : 'false'}
|
aria-invalid={error ? 'true' : 'false'}
|
||||||
@@ -42,12 +43,15 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{helperText && !error && (
|
{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}
|
{helperText}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,26 +19,26 @@ export function ProgressBar({
|
|||||||
const clampedProgress = Math.min(100, Math.max(0, progress));
|
const clampedProgress = Math.min(100, Math.max(0, progress));
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
sm: 'h-1.5',
|
sm: 'h-1',
|
||||||
md: 'h-2.5',
|
md: 'h-1.5',
|
||||||
lg: 'h-4',
|
lg: 'h-2.5',
|
||||||
};
|
};
|
||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
default: 'bg-[#333d49]',
|
default: 'bg-[#333d49]',
|
||||||
accent: 'bg-[#fe7400]',
|
accent: 'bg-gradient-accent',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('w-full', className)}>
|
<div className={cn('w-full', className)}>
|
||||||
{showLabel && (
|
{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]">Progress</span>
|
||||||
<span className="text-sm font-medium text-[#333d49]">{clampedProgress}%</span>
|
<span className="text-sm font-semibold text-[#fe7400]">{clampedProgress}%</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<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"
|
role="progressbar"
|
||||||
aria-valuenow={clampedProgress}
|
aria-valuenow={clampedProgress}
|
||||||
aria-valuemin={0}
|
aria-valuemin={0}
|
||||||
@@ -48,7 +48,7 @@ export function ProgressBar({
|
|||||||
className={cn('h-full rounded-full', variants[variant])}
|
className={cn('h-full rounded-full', variants[variant])}
|
||||||
initial={{ width: 0 }}
|
initial={{ width: 0 }}
|
||||||
animate={{ width: `${clampedProgress}%` }}
|
animate={{ width: `${clampedProgress}%` }}
|
||||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Card, CardContent } from '@/components/ui';
|
import { Card, CardContent } from '@/components/ui';
|
||||||
import { WizardProgress } from './WizardProgress';
|
import { WizardProgress } from './WizardProgress';
|
||||||
import { WizardNavigation } from './WizardNavigation';
|
import { WizardNavigation } from './WizardNavigation';
|
||||||
import { useWizard } from '@/hooks/useWizard';
|
import { useWizard } from '@/hooks/useWizard';
|
||||||
|
import type { WizardStepDef } from '@/hooks/useWizard';
|
||||||
import { useQuote } from '@/hooks/useQuote';
|
import { useQuote } from '@/hooks/useQuote';
|
||||||
import {
|
import {
|
||||||
Step1CompanyProfile,
|
StepWelcome,
|
||||||
|
StepServiceDiscovery,
|
||||||
Step2GPSMonitoring,
|
Step2GPSMonitoring,
|
||||||
Step3SupportPlan,
|
Step3SupportPlan,
|
||||||
Step4VoIP,
|
Step4VoIP,
|
||||||
@@ -15,73 +17,383 @@ import {
|
|||||||
Step7Contact,
|
Step7Contact,
|
||||||
} from './steps';
|
} from './steps';
|
||||||
import {
|
import {
|
||||||
Building2,
|
Sparkles,
|
||||||
|
LayoutGrid,
|
||||||
Monitor,
|
Monitor,
|
||||||
Headphones,
|
Headphones,
|
||||||
Phone,
|
Phone,
|
||||||
Globe,
|
Globe,
|
||||||
FileCheck,
|
FileCheck,
|
||||||
Send,
|
Send,
|
||||||
|
TrendingUp,
|
||||||
|
Hash,
|
||||||
|
CircleCheck,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
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
|
* WizardContainer - Main container for the MSP Quote Wizard
|
||||||
*
|
*
|
||||||
* Orchestrates the 7-step wizard flow:
|
* Dynamic flow:
|
||||||
* 1. Company Profile
|
* 1. Welcome & Intake
|
||||||
* 2. GPS Monitoring
|
* 2. Service Discovery (toggle interests)
|
||||||
* 3. Support Plan
|
* 3-N. Dynamic service configuration steps (based on selections)
|
||||||
* 4. VoIP Phone System
|
* N+1. Review Quote
|
||||||
* 5. Web & Email
|
* N+2. Contact & Submit
|
||||||
* 6. Review Quote
|
|
||||||
* 7. 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() {
|
export function WizardContainer() {
|
||||||
const wizard = useWizard();
|
|
||||||
const quote = useQuote();
|
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 [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [submitSuccess, setSubmitSuccess] = 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];
|
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 = () => {
|
const handleNext = () => {
|
||||||
// Calculate quote before moving to summary
|
setSubmitError(null);
|
||||||
if (wizard.currentStep === 4) {
|
// Calculate quote before entering review
|
||||||
|
if (wizard.steps[wizard.currentStep + 1]?.id === 'review') {
|
||||||
quote.calculateQuote();
|
quote.calculateQuote();
|
||||||
}
|
}
|
||||||
wizard.nextStep();
|
wizard.nextStep();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrev = () => {
|
const handlePrev = () => {
|
||||||
|
setSubmitError(null);
|
||||||
wizard.prevStep();
|
wizard.prevStep();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
setSubmitError(null);
|
||||||
|
|
||||||
// Calculate final quote
|
quote.calculateQuote();
|
||||||
const result = quote.calculateQuote();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Simulate API submission
|
let token = accessToken;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
const items = buildQuoteItems();
|
||||||
|
|
||||||
// Log submission (in production, this would send to an API)
|
if (!token) {
|
||||||
console.log('Quote submitted:', {
|
const response = await createQuote({
|
||||||
quoteData: quote.quoteData,
|
employee_count: quote.quoteData.company.endpointCount || undefined,
|
||||||
quoteResult: result,
|
notes: quote.quoteData.company.notes || undefined,
|
||||||
timestamp: new Date().toISOString(),
|
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);
|
setSubmitSuccess(true);
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
console.error('Submission error:', error);
|
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 {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -91,35 +403,44 @@ export function WizardContainer() {
|
|||||||
wizard.goToStep(step);
|
wizard.goToStep(step);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate current step for "Next" button
|
|
||||||
const isNextDisabled = (): boolean => {
|
const isNextDisabled = (): boolean => {
|
||||||
switch (wizard.currentStep) {
|
switch (currentStepId) {
|
||||||
case 0: // Company Profile
|
case 'welcome':
|
||||||
return quote.quoteData.company.endpointCount < 1;
|
|
||||||
case 6: // Contact
|
|
||||||
return (
|
return (
|
||||||
!quote.quoteData.contact.name.trim() ||
|
!quote.quoteData.contact.name.trim() ||
|
||||||
!quote.quoteData.contact.email.trim() ||
|
!quote.quoteData.contact.email.trim() ||
|
||||||
!quote.quoteData.contact.agreedToTerms
|
quote.quoteData.company.endpointCount < 1
|
||||||
);
|
);
|
||||||
|
case 'submit':
|
||||||
|
return !quote.quoteData.contact.agreedToTerms;
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render current step content
|
|
||||||
const renderStepContent = () => {
|
const renderStepContent = () => {
|
||||||
switch (wizard.currentStep) {
|
switch (currentStepId) {
|
||||||
case 0:
|
case 'welcome':
|
||||||
return (
|
return (
|
||||||
<Step1CompanyProfile
|
<StepWelcome
|
||||||
|
clientType={quote.quoteData.clientType}
|
||||||
companyInfo={quote.quoteData.company}
|
companyInfo={quote.quoteData.company}
|
||||||
|
contactInfo={quote.quoteData.contact}
|
||||||
|
onSetClientType={quote.setClientType}
|
||||||
onUpdateCompany={quote.updateCompany}
|
onUpdateCompany={quote.updateCompany}
|
||||||
|
onUpdateContact={quote.updateContact}
|
||||||
onSetEndpointCount={quote.setEndpointCount}
|
onSetEndpointCount={quote.setEndpointCount}
|
||||||
onSetIndustry={quote.setIndustry}
|
onSetIndustry={quote.setIndustry}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 1:
|
case 'discovery':
|
||||||
|
return (
|
||||||
|
<StepServiceDiscovery
|
||||||
|
serviceInterests={quote.quoteData.serviceInterests}
|
||||||
|
onSetServiceInterest={quote.setServiceInterest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'gps':
|
||||||
return (
|
return (
|
||||||
<Step2GPSMonitoring
|
<Step2GPSMonitoring
|
||||||
gpsSelection={quote.quoteData.gps}
|
gpsSelection={quote.quoteData.gps}
|
||||||
@@ -129,7 +450,7 @@ export function WizardContainer() {
|
|||||||
getGPSMonthly={quote.getGPSMonthly}
|
getGPSMonthly={quote.getGPSMonthly}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 2:
|
case 'support':
|
||||||
return (
|
return (
|
||||||
<Step3SupportPlan
|
<Step3SupportPlan
|
||||||
supportSelection={quote.quoteData.support}
|
supportSelection={quote.quoteData.support}
|
||||||
@@ -138,9 +459,10 @@ export function WizardContainer() {
|
|||||||
onSetBlockTimeEnabled={quote.setBlockTimeEnabled}
|
onSetBlockTimeEnabled={quote.setBlockTimeEnabled}
|
||||||
onSetBlockTime={quote.setBlockTime}
|
onSetBlockTime={quote.setBlockTime}
|
||||||
getSupportMonthly={quote.getSupportMonthly}
|
getSupportMonthly={quote.getSupportMonthly}
|
||||||
|
getSupportBlockTimeOneTime={quote.getSupportBlockTimeOneTime}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 3:
|
case 'voip':
|
||||||
return (
|
return (
|
||||||
<Step4VoIP
|
<Step4VoIP
|
||||||
voipSelection={quote.quoteData.voip}
|
voipSelection={quote.quoteData.voip}
|
||||||
@@ -154,7 +476,7 @@ export function WizardContainer() {
|
|||||||
getVoIPOneTime={quote.getVoIPOneTime}
|
getVoIPOneTime={quote.getVoIPOneTime}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 4:
|
case 'web-email':
|
||||||
return (
|
return (
|
||||||
<Step5WebEmail
|
<Step5WebEmail
|
||||||
webHostingSelection={quote.quoteData.webHosting}
|
webHostingSelection={quote.quoteData.webHosting}
|
||||||
@@ -169,7 +491,7 @@ export function WizardContainer() {
|
|||||||
getEmailMonthly={quote.getEmailMonthly}
|
getEmailMonthly={quote.getEmailMonthly}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 5:
|
case 'review':
|
||||||
return (
|
return (
|
||||||
<Step6Summary
|
<Step6Summary
|
||||||
quoteData={quote.quoteData}
|
quoteData={quote.quoteData}
|
||||||
@@ -178,7 +500,7 @@ export function WizardContainer() {
|
|||||||
onCalculateQuote={quote.calculateQuote}
|
onCalculateQuote={quote.calculateQuote}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 6:
|
case 'submit':
|
||||||
return (
|
return (
|
||||||
<Step7Contact
|
<Step7Contact
|
||||||
contactInfo={quote.quoteData.contact}
|
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
|
// Success state
|
||||||
if (submitSuccess) {
|
if (submitSuccess) {
|
||||||
return (
|
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">
|
<Card variant="elevated" padding="lg">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
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">
|
<motion.div
|
||||||
<svg
|
initial={{ scale: 0 }}
|
||||||
className="w-10 h-10 text-green-600"
|
animate={{ scale: 1 }}
|
||||||
fill="none"
|
transition={{ type: 'spring', stiffness: 200, damping: 15, delay: 0.2 }}
|
||||||
stroke="currentColor"
|
className="w-20 h-20 bg-[#ecfdf5] rounded-full flex items-center justify-center mx-auto mb-8"
|
||||||
viewBox="0 0 24 24"
|
>
|
||||||
>
|
<CircleCheck className="w-10 h-10 text-[#059669]" />
|
||||||
<path
|
</motion.div>
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
<h2 className="text-3xl font-bold text-[#333d49] mb-3">
|
||||||
strokeWidth={2}
|
Quote Request Submitted
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-3xl font-bold text-[#333d49] mb-4">
|
|
||||||
Quote Request Submitted!
|
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 mb-8 max-w-md mx-auto">
|
<p className="text-gray-500 mb-10 max-w-md mx-auto leading-relaxed">
|
||||||
Thank you for your interest. Our team will review your quote and
|
Thank you for your interest. Our team will review your custom quote and
|
||||||
contact you within 24 hours.
|
contact you within one business day.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{quote.quoteResult && (
|
{quote.quoteResult && (
|
||||||
<div className="bg-gray-50 rounded-lg p-6 max-w-sm mx-auto mb-8">
|
<motion.div
|
||||||
<p className="text-sm text-gray-500 mb-2">Your Estimated Monthly Total</p>
|
initial={{ opacity: 0, y: 10 }}
|
||||||
<p className="text-4xl font-bold text-[#fe7400]">
|
animate={{ opacity: 1, y: 0 }}
|
||||||
{formatCurrency(quote.quoteResult.monthlyTotal)}
|
transition={{ delay: 0.4 }}
|
||||||
<span className="text-lg font-normal text-gray-500">/mo</span>
|
className="bg-[#f8f9fb] rounded-2xl p-8 max-w-sm mx-auto mb-10"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-gray-400 mb-1 uppercase tracking-wide font-medium"
|
||||||
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif", fontSize: '11px', letterSpacing: '0.08em' }}>
|
||||||
|
Your Estimated Monthly Investment
|
||||||
</p>
|
</p>
|
||||||
</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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
quote.resetQuote();
|
quote.resetQuote();
|
||||||
wizard.resetWizard();
|
wizard.resetWizard();
|
||||||
setSubmitSuccess(false);
|
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
|
Start a New Quote
|
||||||
</button>
|
</button>
|
||||||
@@ -256,9 +595,9 @@ export function WizardContainer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Progress indicator */}
|
||||||
<div className="mb-8">
|
<div className="mb-8 sm:mb-10 print-hide">
|
||||||
<WizardProgress
|
<WizardProgress
|
||||||
steps={wizard.steps}
|
steps={wizard.steps}
|
||||||
currentStep={wizard.currentStep}
|
currentStep={wizard.currentStep}
|
||||||
@@ -267,74 +606,99 @@ export function WizardContainer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main wizard card */}
|
{/* Main wizard card */}
|
||||||
<Card variant="elevated" padding="lg">
|
<Card variant="elevated" padding="none" className="overflow-hidden">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{/* Step header */}
|
{/* Step header */}
|
||||||
<div className="flex items-center gap-4 mb-6 pb-6 border-b border-gray-100">
|
<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 justify-center w-12 h-12 rounded-full bg-[#fe7400]/10">
|
<div className="flex items-center gap-3 sm:gap-4">
|
||||||
<StepIcon className="w-6 h-6 text-[#fe7400]" />
|
<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">
|
||||||
</div>
|
<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">
|
||||||
{currentStepData?.title}
|
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#333d49] truncate">
|
||||||
</h2>
|
{currentStepData?.title}
|
||||||
<p className="text-gray-500">{currentStepData?.description}</p>
|
</h2>
|
||||||
|
<p className="text-xs sm:text-sm text-gray-400 mt-0.5 truncate">{currentStepData?.description}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Step content with animation */}
|
{/* Error banner */}
|
||||||
<AnimatePresence mode="wait">
|
{submitError && (
|
||||||
<motion.div
|
<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">
|
||||||
key={wizard.currentStep}
|
<p className="text-red-600 text-sm font-medium">{submitError}</p>
|
||||||
initial={{ opacity: 0, x: 20 }}
|
</div>
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
exit={{ opacity: 0, x: -20 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
className="min-h-[400px]"
|
|
||||||
>
|
|
||||||
{renderStepContent()}
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Navigation - hidden on contact step (has its own submit) */}
|
|
||||||
{wizard.currentStep !== 6 && (
|
|
||||||
<WizardNavigation
|
|
||||||
onNext={handleNext}
|
|
||||||
onPrev={handlePrev}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
isFirstStep={wizard.isFirstStep}
|
|
||||||
isLastStep={wizard.isLastStep}
|
|
||||||
isNextDisabled={isNextDisabled()}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Step content with animation */}
|
||||||
|
<div className="px-4 sm:px-6 md:px-8 py-5 sm:py-6 md:py-8">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={currentStepId}
|
||||||
|
initial={{ opacity: 0, x: 16 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -16 }}
|
||||||
|
transition={{ duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||||
|
className="min-h-[400px]"
|
||||||
|
>
|
||||||
|
{renderStepContent()}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Navigation — hidden on submit step (has its own submit button) */}
|
||||||
|
{currentStepId !== 'submit' && (
|
||||||
|
<WizardNavigation
|
||||||
|
onNext={handleNext}
|
||||||
|
onPrev={handlePrev}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
isFirstStep={wizard.isFirstStep}
|
||||||
|
isLastStep={wizard.isLastStep}
|
||||||
|
isNextDisabled={isNextDisabled()}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Quick stats - show running total */}
|
{/* Running totals bar */}
|
||||||
<div className="mt-6 grid grid-cols-3 gap-4">
|
<div className="mt-5 grid grid-cols-3 gap-2 sm:gap-3">
|
||||||
<Card variant="default" padding="sm" className="text-center">
|
<div className="bg-white rounded-xl border border-gray-200/80 shadow-sm p-2.5 sm:p-4 text-center">
|
||||||
<p className="text-sm text-gray-500">Endpoints</p>
|
<div className="flex items-center justify-center gap-1 sm:gap-1.5 mb-1">
|
||||||
<p className="text-2xl font-bold text-[#333d49]">
|
<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}
|
{quote.quoteData.company.endpointCount}
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</div>
|
||||||
<Card variant="default" padding="sm" className="text-center">
|
<div className="bg-white rounded-xl border border-gray-200/80 shadow-sm p-2.5 sm:p-4 text-center">
|
||||||
<p className="text-sm text-gray-500">Est. Monthly</p>
|
<div className="flex items-center justify-center gap-1 sm:gap-1.5 mb-1">
|
||||||
<p className="text-2xl font-bold text-[#fe7400]">
|
<TrendingUp className="w-3 h-3 sm:w-3.5 sm:h-3.5 text-[#fe7400]" />
|
||||||
{formatCurrency(
|
<p className="text-[10px] sm:text-[11px] font-medium text-gray-400 uppercase tracking-wider"
|
||||||
quote.getGPSMonthly() +
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
quote.getSupportMonthly() +
|
Monthly
|
||||||
quote.getVoIPMonthly() +
|
</p>
|
||||||
quote.getWebHostingMonthly() +
|
</div>
|
||||||
quote.getEmailMonthly()
|
<p className="text-lg sm:text-2xl font-bold text-[#fe7400]" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
)}
|
{formatCurrency(runningMonthly)}
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</div>
|
||||||
<Card variant="default" padding="sm" className="text-center">
|
<div className="bg-white rounded-xl border border-gray-200/80 shadow-sm p-2.5 sm:p-4 text-center">
|
||||||
<p className="text-sm text-gray-500">Progress</p>
|
<div className="flex items-center justify-center gap-1 sm:gap-1.5 mb-1">
|
||||||
<p className="text-2xl font-bold text-[#333d49]">{wizard.progress}%</p>
|
<CircleCheck className="w-3 h-3 sm:w-3.5 sm:h-3.5 text-gray-400" />
|
||||||
</Card>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Send } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui';
|
import { Button } from '@/components/ui';
|
||||||
|
|
||||||
export interface WizardNavigationProps {
|
export interface WizardNavigationProps {
|
||||||
@@ -21,26 +21,28 @@ export function WizardNavigation({
|
|||||||
isSubmitting = false,
|
isSubmitting = false,
|
||||||
}: WizardNavigationProps) {
|
}: WizardNavigationProps) {
|
||||||
return (
|
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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
onClick={onPrev}
|
onClick={onPrev}
|
||||||
disabled={isFirstStep}
|
disabled={isFirstStep}
|
||||||
className={isFirstStep ? 'invisible' : ''}
|
className={isFirstStep ? 'invisible' : ''}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
<ChevronLeft className="w-4 h-4 mr-1.5" />
|
||||||
Previous
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{isLastStep ? (
|
{isLastStep ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
isLoading={isSubmitting}
|
isLoading={isSubmitting}
|
||||||
disabled={isNextDisabled || isSubmitting}
|
disabled={isNextDisabled || isSubmitting}
|
||||||
>
|
>
|
||||||
|
<Send className="w-4 h-4 mr-2" />
|
||||||
Get My Quote
|
Get My Quote
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
@@ -50,8 +52,8 @@ export function WizardNavigation({
|
|||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
disabled={isNextDisabled}
|
disabled={isNextDisabled}
|
||||||
>
|
>
|
||||||
Next
|
Continue
|
||||||
<ChevronRight className="w-4 h-4 ml-1" />
|
<ChevronRight className="w-4 h-4 ml-1.5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,34 +14,33 @@ export function WizardProgress({ steps, currentStep, onStepClick }: WizardProgre
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav aria-label="Progress" className="w-full">
|
<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) => {
|
{steps.map((step, index) => {
|
||||||
const isCompleted = step.isComplete;
|
const isCompleted = step.isComplete;
|
||||||
const isCurrent = index === currentStep;
|
const isCurrent = index === currentStep;
|
||||||
const isClickable = isCompleted || index <= currentStep;
|
const isClickable = isCompleted || index <= currentStep;
|
||||||
|
const isLast = index === steps.length - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={step.id}
|
key={step.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex-1',
|
'relative flex flex-col items-center',
|
||||||
index !== steps.length - 1 && (isCompactMode ? 'pr-4 sm:pr-8' : 'pr-8 sm:pr-20')
|
!isLast && 'flex-1'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Connector line */}
|
{/* Connector line */}
|
||||||
{index !== steps.length - 1 && (
|
{!isLast && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className="absolute top-[18px] left-[calc(50%+18px)] right-[calc(-50%+18px)] h-[2px] bg-gray-200"
|
||||||
'absolute top-4 right-0 h-0.5 bg-gray-200',
|
|
||||||
isCompactMode ? 'left-6' : 'left-8'
|
|
||||||
)}
|
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="h-full bg-[#fe7400]"
|
className="h-full bg-[#fe7400] origin-left"
|
||||||
initial={{ width: '0%' }}
|
initial={{ scaleX: 0 }}
|
||||||
animate={{ width: isCompleted ? '100%' : '0%' }}
|
animate={{ scaleX: isCompleted ? 1 : 0 }}
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -51,7 +50,7 @@ export function WizardProgress({ steps, currentStep, onStepClick }: WizardProgre
|
|||||||
onClick={() => isClickable && onStepClick?.(index)}
|
onClick={() => isClickable && onStepClick?.(index)}
|
||||||
disabled={!isClickable}
|
disabled={!isClickable}
|
||||||
className={cn(
|
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'
|
isClickable ? 'cursor-pointer' : 'cursor-not-allowed'
|
||||||
)}
|
)}
|
||||||
aria-current={isCurrent ? 'step' : undefined}
|
aria-current={isCurrent ? 'step' : undefined}
|
||||||
@@ -59,42 +58,54 @@ export function WizardProgress({ steps, currentStep, onStepClick }: WizardProgre
|
|||||||
{/* Step circle */}
|
{/* Step circle */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative z-10 flex items-center justify-center rounded-full border-2 transition-colors duration-200',
|
'relative flex items-center justify-center rounded-full transition-all duration-300',
|
||||||
isCompactMode ? 'h-6 w-6' : 'h-8 w-8',
|
isCompactMode ? 'h-7 w-7' : 'h-9 w-9',
|
||||||
isCompleted
|
isCompleted
|
||||||
? 'bg-[#fe7400] border-[#fe7400]'
|
? 'bg-[#fe7400] shadow-[0_0_0_4px_rgba(254,116,0,0.1)]'
|
||||||
: isCurrent
|
: isCurrent
|
||||||
? 'border-[#fe7400] bg-white'
|
? 'bg-white border-[2.5px] border-[#fe7400] shadow-[0_0_0_4px_rgba(254,116,0,0.1)]'
|
||||||
: 'border-gray-300 bg-white'
|
: 'bg-white border-2 border-gray-200'
|
||||||
)}
|
)}
|
||||||
whileHover={isClickable ? { scale: 1.1 } : {}}
|
whileHover={isClickable ? { scale: 1.08 } : {}}
|
||||||
whileTap={isClickable ? { scale: 0.95 } : {}}
|
whileTap={isClickable ? { scale: 0.95 } : {}}
|
||||||
|
layout
|
||||||
>
|
>
|
||||||
{isCompleted ? (
|
{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
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-semibold',
|
'font-bold',
|
||||||
isCompactMode ? 'text-xs' : 'text-sm',
|
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}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Step label - hidden on mobile for compact mode */}
|
{/* Step label */}
|
||||||
<div className={cn('mt-2 text-center', isCompactMode && 'hidden sm:block')}>
|
<div className={cn(
|
||||||
|
'text-center max-w-[80px]',
|
||||||
|
isCompactMode && 'hidden sm:block'
|
||||||
|
)}>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-medium whitespace-nowrap',
|
'font-medium whitespace-nowrap leading-tight block',
|
||||||
isCompactMode ? 'text-[10px]' : 'text-xs',
|
isCompactMode ? 'text-[10px]' : 'text-[11px]',
|
||||||
isCurrent ? 'text-[#fe7400]' : isCompleted ? 'text-[#333d49]' : 'text-gray-500'
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -106,10 +117,10 @@ export function WizardProgress({ steps, currentStep, onStepClick }: WizardProgre
|
|||||||
{/* Mobile step indicator for compact mode */}
|
{/* Mobile step indicator for compact mode */}
|
||||||
{isCompactMode && (
|
{isCompactMode && (
|
||||||
<div className="sm:hidden mt-4 text-center">
|
<div className="sm:hidden mt-4 text-center">
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-xs text-gray-400">
|
||||||
Step {currentStep + 1} of {steps.length}:
|
Step {currentStep + 1} of {steps.length}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-medium text-[#333d49] ml-1">
|
<span className="text-sm font-semibold text-[#333d49] ml-2">
|
||||||
{steps[currentStep]?.title}
|
{steps[currentStep]?.title}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { motion } from 'framer-motion';
|
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 { Input } from '@/components/ui';
|
||||||
import { industries } from '@/lib/pricing-data';
|
import { industries } from '@/lib/pricing-data';
|
||||||
import type { CompanyInfo, Industry } from '@/types/quote';
|
import type { CompanyInfo, Industry } from '@/types/quote';
|
||||||
@@ -33,101 +33,184 @@ export function Step1CompanyProfile({
|
|||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
className="space-y-6"
|
className="space-y-8"
|
||||||
>
|
>
|
||||||
{/* Company Name (Optional) */}
|
{/* Welcome / Intro Section */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-4">
|
||||||
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
|
<h3 className="text-xl sm:text-2xl font-bold text-[#333d49]"
|
||||||
<Building2 className="w-4 h-4 text-[#fe7400]" />
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
Company Name
|
Welcome to Arizona Computer Guru
|
||||||
<span className="text-gray-400 font-normal">(optional)</span>
|
</h3>
|
||||||
</label>
|
<p className="text-sm sm:text-base text-gray-500 leading-relaxed max-w-3xl">
|
||||||
<Input
|
We're a <strong className="text-[#333d49]">Managed Service Provider (MSP)</strong> serving
|
||||||
type="text"
|
businesses across Arizona. An MSP acts as your outsourced IT department — we proactively
|
||||||
value={companyInfo.name}
|
manage, monitor, and secure your technology so you can focus on running your business.
|
||||||
onChange={(e) => onUpdateCompany({ name: e.target.value })}
|
|
||||||
placeholder="Enter your company name"
|
|
||||||
className="max-w-md"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Number of Endpoints (Required) */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
|
|
||||||
<Users className="w-4 h-4 text-[#fe7400]" />
|
|
||||||
Number of Endpoints / Employees
|
|
||||||
<span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={companyInfo.endpointCount}
|
|
||||||
onChange={handleEndpointChange}
|
|
||||||
className="w-32"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-gray-500">
|
|
||||||
devices requiring monitoring and support
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-400">
|
|
||||||
Include workstations, laptops, and servers that need IT support
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Industry Selection */}
|
{/* What You Get - 3 cards */}
|
||||||
<div className="space-y-2">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4">
|
||||||
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
|
<motion.div
|
||||||
<Briefcase className="w-4 h-4 text-[#fe7400]" />
|
initial={{ opacity: 0, y: 10 }}
|
||||||
Industry
|
animate={{ opacity: 1, y: 0 }}
|
||||||
</label>
|
transition={{ delay: 0.1 }}
|
||||||
<select
|
className="bg-[#f8f9fb] rounded-xl p-4 border border-gray-200/50"
|
||||||
value={companyInfo.industry}
|
|
||||||
onChange={handleIndustryChange}
|
|
||||||
className="w-full max-w-md px-4 py-2.5 rounded-lg border border-gray-300 text-[#333d49] focus:outline-none focus:ring-2 focus:ring-[#fe7400]/20 focus:border-[#fe7400] transition-all duration-200"
|
|
||||||
>
|
>
|
||||||
<option value="">Select your industry...</option>
|
<div className="w-9 h-9 rounded-lg bg-[#fe7400]/8 flex items-center justify-center mb-3">
|
||||||
{industries.map((industry) => (
|
<Monitor className="w-4.5 h-4.5 text-[#fe7400]" />
|
||||||
<option key={industry} value={industry}>
|
</div>
|
||||||
{industry}
|
<h4 className="font-semibold text-[#333d49] text-sm mb-1"
|
||||||
</option>
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
))}
|
GPS Monitoring
|
||||||
</select>
|
</h4>
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-gray-400 leading-relaxed">
|
||||||
This helps us understand compliance requirements and best practices for your sector
|
Our <strong className="text-gray-500">Guru Protection Suite</strong> provides 24/7
|
||||||
</p>
|
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 — all managed
|
||||||
|
alongside your IT for a single point of contact.
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notes (Optional) */}
|
{/* How It Works */}
|
||||||
<div className="space-y-2">
|
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||||
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
|
<ArrowRight className="w-3.5 h-3.5 text-[#fe7400]" />
|
||||||
<MessageSquare className="w-4 h-4 text-[#fe7400]" />
|
<span>
|
||||||
What brings you here today?
|
This wizard builds a custom quote in about 2 minutes. Tell us about your business to get started.
|
||||||
<span className="text-gray-400 font-normal">(optional)</span>
|
</span>
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={companyInfo.notes}
|
|
||||||
onChange={(e) => onUpdateCompany({ notes: e.target.value })}
|
|
||||||
placeholder="Tell us about your current IT challenges or what you're looking for..."
|
|
||||||
rows={3}
|
|
||||||
className="w-full px-4 py-2.5 rounded-lg border border-gray-300 text-[#333d49] placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#fe7400]/20 focus:border-[#fe7400] transition-all duration-200 resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Card */}
|
{/* Divider */}
|
||||||
<motion.div
|
<div className="border-t border-gray-100" />
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
{/* Form Section */}
|
||||||
transition={{ delay: 0.2 }}
|
<div className="space-y-6">
|
||||||
className="bg-[#fe7400]/5 border border-[#fe7400]/20 rounded-lg p-4 mt-6"
|
{/* Company Name */}
|
||||||
>
|
<div className="space-y-2">
|
||||||
<h4 className="font-medium text-[#333d49] mb-2">Why we ask this</h4>
|
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
|
||||||
<p className="text-sm text-gray-600">
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
Understanding your business size and industry helps us recommend the right
|
<Building2 className="w-4 h-4 text-[#fe7400]" />
|
||||||
service tier and identify any compliance requirements (like HIPAA for healthcare
|
Company Name
|
||||||
or PCI-DSS for retail) that may affect your IT needs.
|
<span className="text-gray-300 font-normal text-xs">(optional)</span>
|
||||||
</p>
|
</label>
|
||||||
</motion.div>
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={companyInfo.name}
|
||||||
|
onChange={(e) => onUpdateCompany({ name: e.target.value })}
|
||||||
|
placeholder="Enter your company name"
|
||||||
|
className="max-w-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Number of Endpoints */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
|
||||||
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
|
<Users className="w-4 h-4 text-[#fe7400]" />
|
||||||
|
Number of Endpoints / Employees
|
||||||
|
<span className="text-red-500 text-xs">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={companyInfo.endpointCount}
|
||||||
|
onChange={handleEndpointChange}
|
||||||
|
className="w-full sm:w-32"
|
||||||
|
/>
|
||||||
|
<span className="text-xs sm:text-sm text-gray-400">
|
||||||
|
devices requiring monitoring and support
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Include workstations, laptops, and servers that need IT support
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Industry Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
|
||||||
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
|
<Briefcase className="w-4 h-4 text-[#fe7400]" />
|
||||||
|
Industry
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={companyInfo.industry}
|
||||||
|
onChange={handleIndustryChange}
|
||||||
|
className="w-full max-w-lg px-4 py-3 rounded-xl border border-gray-200 bg-white text-[#333d49] hover:border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#fe7400]/15 focus:border-[#fe7400] transition-all duration-200 appearance-none"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%239aa1ac' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`,
|
||||||
|
backgroundPosition: 'right 12px center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: '20px 20px',
|
||||||
|
paddingRight: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Select your industry...</option>
|
||||||
|
{industries.map((industry) => (
|
||||||
|
<option key={industry} value={industry}>
|
||||||
|
{industry}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
This helps us understand compliance requirements and best practices for your sector
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
|
||||||
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
|
<MessageSquare className="w-4 h-4 text-[#fe7400]" />
|
||||||
|
What brings you here today?
|
||||||
|
<span className="text-gray-300 font-normal text-xs">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={companyInfo.notes}
|
||||||
|
onChange={(e) => onUpdateCompany({ notes: e.target.value })}
|
||||||
|
placeholder="Tell us about your current IT challenges or what you're looking for..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-3 rounded-xl border border-gray-200 bg-white text-[#333d49] placeholder-gray-400 hover:border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#fe7400]/15 focus:border-[#fe7400] transition-all duration-200 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { motion } from 'framer-motion';
|
import { useState } from 'react';
|
||||||
import { Check, Server, HardDrive } from 'lucide-react';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Check, Server, HardDrive, ChevronDown } from 'lucide-react';
|
||||||
import { Card, Button } from '@/components/ui';
|
import { Card, Button } from '@/components/ui';
|
||||||
import { ExpandableInfo } from '@/components/pricing/ExpandableInfo';
|
import { ExpandableInfo } from '@/components/pricing/ExpandableInfo';
|
||||||
import { gpsTiers, equipmentMonitoring } from '@/lib/pricing-data';
|
import { gpsTiers, equipmentMonitoring } from '@/lib/pricing-data';
|
||||||
@@ -21,6 +22,12 @@ export function Step2GPSMonitoring({
|
|||||||
onSetEquipmentCount,
|
onSetEquipmentCount,
|
||||||
getGPSMonthly,
|
getGPSMonthly,
|
||||||
}: Step2GPSMonitoringProps) {
|
}: Step2GPSMonitoringProps) {
|
||||||
|
const [expandedTiers, setExpandedTiers] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const toggleTierExpanded = (tierId: string) => {
|
||||||
|
setExpandedTiers(prev => ({ ...prev, [tierId]: !prev[tierId] }));
|
||||||
|
};
|
||||||
|
|
||||||
const calculateEquipmentPrice = () => {
|
const calculateEquipmentPrice = () => {
|
||||||
if (!gpsSelection.includeEquipment || gpsSelection.equipmentDeviceCount === 0) {
|
if (!gpsSelection.includeEquipment || gpsSelection.equipmentDeviceCount === 0) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -36,19 +43,38 @@ export function Step2GPSMonitoring({
|
|||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
className="space-y-6"
|
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 — 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 */}
|
{/* 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">
|
<div className="flex items-center gap-3">
|
||||||
<Server className="w-5 h-5 text-[#fe7400]" />
|
<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>
|
</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}
|
{gpsSelection.endpointCount}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tier Selection Cards */}
|
{/* 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) => {
|
{gpsTiers.map((tier, index) => {
|
||||||
const isSelected = gpsSelection.tierId === tier.id;
|
const isSelected = gpsSelection.tierId === tier.id;
|
||||||
const monthlyPrice = tier.pricePerEndpoint * gpsSelection.endpointCount;
|
const monthlyPrice = tier.pricePerEndpoint * gpsSelection.endpointCount;
|
||||||
@@ -59,39 +85,42 @@ export function Step2GPSMonitoring({
|
|||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: index * 0.1 }}
|
transition={{ delay: index * 0.1 }}
|
||||||
whileHover={{ y: -4 }}
|
whileHover={{ y: -3 }}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
|
variant={isSelected ? 'highlighted' : 'default'}
|
||||||
padding="none"
|
padding="none"
|
||||||
className={`relative overflow-hidden cursor-pointer ${
|
className={`relative overflow-hidden cursor-pointer h-full ${
|
||||||
tier.recommended && !isSelected ? 'ring-2 ring-[#333d49]' : ''
|
tier.recommended && !isSelected ? 'ring-2 ring-[#fe7400]/30' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => onSetGPSTier(tier.id)}
|
onClick={() => onSetGPSTier(tier.id)}
|
||||||
>
|
>
|
||||||
{/* Recommended Badge */}
|
{/* Recommended Badge */}
|
||||||
{tier.recommended && (
|
{tier.recommended && (
|
||||||
<div className="absolute top-0 right-0">
|
<div className="bg-gradient-accent text-white text-[11px] font-bold px-3 py-1.5 text-center uppercase tracking-wider"
|
||||||
<div className="bg-[#fe7400] text-white text-xs font-semibold px-3 py-1 rounded-bl-lg">
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
Recommended
|
Recommended
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-3">
|
<div className="mb-4">
|
||||||
<h3 className="text-lg font-semibold text-[#333d49]">{tier.name}</h3>
|
<h3 className="text-lg font-bold text-[#333d49]"
|
||||||
<p className="text-sm text-gray-500">{tier.description}</p>
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
|
{tier.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-400 mt-0.5">{tier.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pricing */}
|
{/* Pricing */}
|
||||||
<div className="mb-4">
|
<div className="mb-5">
|
||||||
<div className="flex items-baseline gap-1">
|
<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)}
|
{formatCurrency(monthlyPrice)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-500 text-sm">/month</span>
|
<span className="text-gray-400 text-sm">/mo</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400 mt-1">
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
{formatCurrency(tier.pricePerEndpoint)}/endpoint/month
|
{formatCurrency(tier.pricePerEndpoint)}/endpoint/month
|
||||||
@@ -99,16 +128,45 @@ export function Step2GPSMonitoring({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Features */}
|
{/* Features */}
|
||||||
<ul className="space-y-2 mb-4">
|
<ul className="space-y-2.5 mb-5">
|
||||||
{tier.features.slice(0, 4).map((feature, idx) => (
|
{tier.features.slice(0, 4).map((feature, idx) => (
|
||||||
<li key={idx} className="flex items-start gap-2 text-sm">
|
<li key={idx} className="flex items-start gap-2.5 text-sm">
|
||||||
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
<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>
|
<span className="text-gray-600">{feature}</span>
|
||||||
</li>
|
</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 && (
|
{tier.features.length > 4 && (
|
||||||
<li className="text-xs text-[#fe7400]">
|
<li>
|
||||||
+{tier.features.length - 4} more features
|
<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>
|
</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -133,26 +191,31 @@ export function Step2GPSMonitoring({
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ delay: 0.3 }}
|
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-start justify-between gap-3 mb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<HardDrive className="w-5 h-5 text-[#fe7400]" />
|
<div className="w-9 h-9 rounded-lg bg-[#fe7400]/8 flex items-center justify-center flex-shrink-0">
|
||||||
<div>
|
<HardDrive className="w-4.5 h-4.5 text-[#fe7400]" />
|
||||||
<h4 className="font-medium text-[#333d49]">Equipment Pack Monitoring</h4>
|
</div>
|
||||||
<p className="text-sm text-gray-500">
|
<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
|
Monitor routers, switches, printers, and other network equipment
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<label className="relative inline-flex items-center cursor-pointer flex-shrink-0">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={gpsSelection.includeEquipment}
|
checked={gpsSelection.includeEquipment}
|
||||||
onChange={(e) => onSetEquipmentEnabled(e.target.checked)}
|
onChange={(e) => onSetEquipmentEnabled(e.target.checked)}
|
||||||
className="sr-only peer"
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -164,30 +227,34 @@ export function Step2GPSMonitoring({
|
|||||||
className="space-y-4 pt-4 border-t border-gray-100"
|
className="space-y-4 pt-4 border-t border-gray-100"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
<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
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
value={gpsSelection.equipmentDeviceCount}
|
value={gpsSelection.equipmentDeviceCount}
|
||||||
onChange={(e) => onSetEquipmentCount(parseInt(e.target.value, 10) || 1)}
|
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>
|
||||||
<div className="bg-gray-50 rounded-lg p-3">
|
<div className="bg-[#f8f9fb] rounded-xl p-4">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-500">
|
||||||
<span className="font-medium">{formatCurrency(equipmentMonitoring.basePrice)}/month</span>
|
<span className="font-semibold text-[#333d49]">{formatCurrency(equipmentMonitoring.basePrice)}/month</span>
|
||||||
{' '}for up to {equipmentMonitoring.baseDevices} devices
|
{' '}for up to {equipmentMonitoring.baseDevices} devices
|
||||||
{gpsSelection.equipmentDeviceCount > equipmentMonitoring.baseDevices && (
|
{gpsSelection.equipmentDeviceCount > equipmentMonitoring.baseDevices && (
|
||||||
<span>
|
<span>
|
||||||
{' + '}
|
{' + '}
|
||||||
<span className="font-medium">
|
<span className="font-semibold text-[#333d49]">
|
||||||
{formatCurrency(equipmentMonitoring.additionalDevicePrice)}/device
|
{formatCurrency(equipmentMonitoring.additionalDevicePrice)}/device
|
||||||
</span>
|
</span>
|
||||||
{' for additional devices'}
|
{' for additional devices'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</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
|
Equipment total: {formatCurrency(calculateEquipmentPrice())}/month
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,32 +264,40 @@ export function Step2GPSMonitoring({
|
|||||||
|
|
||||||
{/* Expandable Feature Info */}
|
{/* Expandable Feature Info */}
|
||||||
<ExpandableInfo title="What's included in GPS Monitoring?">
|
<ExpandableInfo title="What's included in GPS Monitoring?">
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-3">
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2.5">
|
||||||
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
<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>
|
<span><strong>Remote Monitoring:</strong> 24/7 monitoring of system health, performance, and security</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2.5">
|
||||||
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
<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>
|
<span><strong>Patch Management:</strong> Automated Windows and third-party application updates</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2.5">
|
||||||
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
<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>
|
<span><strong>Antivirus:</strong> Enterprise-grade protection with real-time threat detection</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2.5">
|
||||||
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
<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>
|
<span><strong>Help Desk:</strong> Access to our technical support team for issues and questions</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</ExpandableInfo>
|
</ExpandableInfo>
|
||||||
|
|
||||||
{/* Monthly Total */}
|
{/* Monthly Total */}
|
||||||
<div className="bg-[#333d49] text-white rounded-lg p-5 flex items-center justify-between">
|
<div className="bg-gradient-dark text-white rounded-xl p-4 sm:p-5 flex items-center justify-between gap-3">
|
||||||
<span className="text-lg">GPS Monitoring Monthly Total</span>
|
<span className="text-sm sm:text-base font-medium opacity-90">GPS Monitoring Monthly Total</span>
|
||||||
<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(getGPSMonthly())}
|
{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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { motion } from 'framer-motion';
|
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 { Card, Button } from '@/components/ui';
|
||||||
import { ExpandableInfo } from '@/components/pricing/ExpandableInfo';
|
import { ExpandableInfo } from '@/components/pricing/ExpandableInfo';
|
||||||
import { supportPlans, blockTimeOptions } from '@/lib/pricing-data';
|
import { supportPlans, blockTimeOptions } from '@/lib/pricing-data';
|
||||||
@@ -13,6 +13,7 @@ export interface Step3SupportPlanProps {
|
|||||||
onSetBlockTimeEnabled: (enabled: boolean) => void;
|
onSetBlockTimeEnabled: (enabled: boolean) => void;
|
||||||
onSetBlockTime: (blockTimeId: BlockTimeId) => void;
|
onSetBlockTime: (blockTimeId: BlockTimeId) => void;
|
||||||
getSupportMonthly: () => number;
|
getSupportMonthly: () => number;
|
||||||
|
getSupportBlockTimeOneTime: () => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Step3SupportPlan({
|
export function Step3SupportPlan({
|
||||||
@@ -22,8 +23,8 @@ export function Step3SupportPlan({
|
|||||||
onSetBlockTimeEnabled,
|
onSetBlockTimeEnabled,
|
||||||
onSetBlockTime,
|
onSetBlockTime,
|
||||||
getSupportMonthly,
|
getSupportMonthly,
|
||||||
|
getSupportBlockTimeOneTime,
|
||||||
}: Step3SupportPlanProps) {
|
}: Step3SupportPlanProps) {
|
||||||
// Recommend plan based on endpoint count
|
|
||||||
const getRecommendedPlan = (): SupportPlanId => {
|
const getRecommendedPlan = (): SupportPlanId => {
|
||||||
if (endpointCount <= 10) return 'essential';
|
if (endpointCount <= 10) return 'essential';
|
||||||
if (endpointCount <= 25) return 'standard';
|
if (endpointCount <= 25) return 'standard';
|
||||||
@@ -32,6 +33,7 @@ export function Step3SupportPlan({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const recommendedPlanId = getRecommendedPlan();
|
const recommendedPlanId = getRecommendedPlan();
|
||||||
|
const isNoPlan = supportSelection.planId === 'none';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -40,8 +42,86 @@ export function Step3SupportPlan({
|
|||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
className="space-y-6"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
{/* Plan Selection Cards */}
|
{/* Service Explainer */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<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 — 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) => {
|
{supportPlans.map((plan, index) => {
|
||||||
const isSelected = supportSelection.planId === plan.id;
|
const isSelected = supportSelection.planId === plan.id;
|
||||||
const isRecommended = plan.id === recommendedPlanId;
|
const isRecommended = plan.id === recommendedPlanId;
|
||||||
@@ -51,51 +131,55 @@ export function Step3SupportPlan({
|
|||||||
key={plan.id}
|
key={plan.id}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: index * 0.1 }}
|
transition={{ delay: (index + 1) * 0.1 }}
|
||||||
whileHover={{ y: -4 }}
|
whileHover={{ y: -3 }}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
variant={isSelected ? 'highlighted' : isRecommended ? 'elevated' : 'default'}
|
variant={isSelected ? 'highlighted' : 'default'}
|
||||||
padding="none"
|
padding="none"
|
||||||
className={`relative overflow-hidden cursor-pointer h-full ${
|
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)}
|
onClick={() => onSetSupportPlan(plan.id)}
|
||||||
>
|
>
|
||||||
{/* Recommended Badge */}
|
{/* Recommended Badge */}
|
||||||
{isRecommended && (
|
{isRecommended && (
|
||||||
<div className="absolute top-0 right-0">
|
<div className="bg-gradient-accent text-white text-[11px] font-bold px-3 py-1.5 text-center uppercase tracking-wider"
|
||||||
<div className="bg-[#fe7400] text-white text-xs font-semibold px-2 py-1 rounded-bl-lg">
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
For You
|
Recommended for You
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<h3 className="text-lg font-semibold text-[#333d49] mb-1">{plan.name}</h3>
|
<h3 className="text-base font-bold text-[#333d49] mb-1"
|
||||||
<p className="text-xs text-gray-500 mb-3">{plan.description}</p>
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
|
{plan.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-400 mb-3">{plan.description}</p>
|
||||||
|
|
||||||
{/* Pricing */}
|
{/* Pricing */}
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div className="flex items-baseline gap-1">
|
<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)}
|
{formatCurrency(plan.monthlyPrice)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-500 text-xs">/mo</span>
|
<span className="text-gray-400 text-xs">/mo</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hours Included */}
|
{/* 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]" />
|
<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
|
{plan.includedHours} hrs included
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Effective Rate */}
|
{/* 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" />
|
<DollarSign className="w-4 h-4" />
|
||||||
<span>
|
<span>
|
||||||
{formatCurrency(plan.effectiveHourlyRate)}/hr effective
|
{formatCurrency(plan.effectiveHourlyRate)}/hr effective
|
||||||
@@ -117,31 +201,55 @@ export function Step3SupportPlan({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pay-as-you-go info when No Plan is selected */}
|
||||||
|
{isNoPlan && !supportSelection.useBlockTime && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="bg-[#f8f9fb] rounded-xl p-4 text-sm text-gray-500"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Without a support plan, any support work will be billed at our standard hourly rate of
|
||||||
|
<strong className="text-[#333d49]"> $175/hr</strong>. You can add block time below
|
||||||
|
to pre-purchase hours at a discounted rate, or proceed without any support commitment.
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Block Time Option */}
|
{/* Block Time Option */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ delay: 0.3 }}
|
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-start justify-between gap-3 mb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<Zap className="w-5 h-5 text-[#fe7400]" />
|
<div className="w-9 h-9 rounded-lg bg-[#fe7400]/8 flex items-center justify-center flex-shrink-0">
|
||||||
<div>
|
<Zap className="w-4.5 h-4.5 text-[#fe7400]" />
|
||||||
<h4 className="font-medium text-[#333d49]">Add Block Time</h4>
|
</div>
|
||||||
<p className="text-sm text-gray-500">
|
<div className="min-w-0">
|
||||||
Pre-purchase additional support hours at a discounted rate
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<label className="relative inline-flex items-center cursor-pointer flex-shrink-0">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={supportSelection.useBlockTime}
|
checked={supportSelection.useBlockTime}
|
||||||
onChange={(e) => onSetBlockTimeEnabled(e.target.checked)}
|
onChange={(e) => onSetBlockTimeEnabled(e.target.checked)}
|
||||||
className="sr-only peer"
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -152,30 +260,33 @@ export function Step3SupportPlan({
|
|||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="space-y-3 pt-4 border-t border-gray-100"
|
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) => {
|
{blockTimeOptions.map((option) => {
|
||||||
const isSelected = supportSelection.blockTimeId === option.id;
|
const isSelected = supportSelection.blockTimeId === option.id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={option.id}
|
key={option.id}
|
||||||
onClick={() => onSetBlockTime(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
|
isSelected
|
||||||
? 'border-[#fe7400] bg-[#fe7400]/5'
|
? 'border-[#fe7400] bg-[#fe7400]/5 shadow-sm'
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
: '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
|
{option.hours} Hours
|
||||||
</div>
|
</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)}
|
{formatCurrency(option.price)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-400">
|
||||||
{formatCurrency(option.effectiveHourlyRate)}/hr
|
{formatCurrency(option.effectiveHourlyRate)}/hr
|
||||||
</div>
|
</div>
|
||||||
{option.hours === 30 && (
|
{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
|
Best Value
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -191,44 +302,75 @@ export function Step3SupportPlan({
|
|||||||
<ExpandableInfo title="How does support work?">
|
<ExpandableInfo title="How does support work?">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p>
|
<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.
|
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>
|
</p>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2.5">
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2.5">
|
||||||
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
<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>
|
<span><strong>Help Desk:</strong> Phone, email, and chat support for daily IT questions</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2.5">
|
||||||
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
<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>
|
<span><strong>Remote Support:</strong> Screen sharing and remote control for quick fixes</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2.5">
|
||||||
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
<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>
|
<span><strong>On-Site Support:</strong> Available for Premium and Priority plans</span>
|
||||||
</li>
|
</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 — billed at $175/hr standard rate</span>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Block time is great for planned projects, office moves, or seasonal busy periods.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</ExpandableInfo>
|
</ExpandableInfo>
|
||||||
|
|
||||||
{/* Monthly Total */}
|
{/* Monthly Total */}
|
||||||
<div className="bg-[#333d49] text-white rounded-lg p-5 flex items-center justify-between">
|
<div className="bg-gradient-dark text-white rounded-xl p-4 sm:p-5">
|
||||||
<div>
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="text-lg">Support Monthly Total</span>
|
<div className="min-w-0">
|
||||||
{supportSelection.useBlockTime && supportSelection.blockTimeId && (
|
<span className="text-sm sm:text-base font-medium opacity-90">Support Monthly Cost</span>
|
||||||
<p className="text-sm opacity-75">
|
{isNoPlan && (
|
||||||
Includes{' '}
|
<p className="text-xs sm:text-sm opacity-50">
|
||||||
{blockTimeOptions.find((b) => b.id === supportSelection.blockTimeId)?.hours} hr block
|
Pay-as-you-go at $175/hr
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl sm:text-3xl font-bold whitespace-nowrap" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
|
{formatCurrency(getSupportMonthly())}
|
||||||
|
<span className="text-xs sm:text-sm font-medium opacity-60 ml-1">/mo</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-3xl font-bold">
|
{supportSelection.useBlockTime && supportSelection.blockTimeId && (
|
||||||
{formatCurrency(getSupportMonthly())}
|
<div className="flex items-center justify-between gap-3 mt-3 pt-3 border-t border-white/15">
|
||||||
<span className="text-lg font-normal opacity-75">/month</span>
|
<div className="min-w-0">
|
||||||
</span>
|
<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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,27 +60,50 @@ export function Step4VoIP({
|
|||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
className="space-y-6"
|
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 — 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 */}
|
{/* VoIP Toggle */}
|
||||||
<div className="bg-gray-50 rounded-lg p-5">
|
<div className="bg-[#f8f9fb] rounded-xl p-4 sm:p-5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<Phone className="w-6 h-6 text-[#fe7400]" />
|
<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">
|
||||||
<div>
|
<Phone className="w-4 h-4 sm:w-5 sm:h-5 text-[#fe7400]" />
|
||||||
<h3 className="font-semibold text-[#333d49]">Do you need business phones?</h3>
|
</div>
|
||||||
<p className="text-sm text-gray-500">
|
<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
|
Modern VoIP phone system with advanced features
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<label className="relative inline-flex items-center cursor-pointer flex-shrink-0">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={voipSelection.enabled}
|
checked={voipSelection.enabled}
|
||||||
onChange={(e) => onSetVoIPEnabled(e.target.checked)}
|
onChange={(e) => onSetVoIPEnabled(e.target.checked)}
|
||||||
className="sr-only peer"
|
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>
|
<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-medium text-gray-700">
|
<span className="ml-3 text-sm font-semibold text-gray-500"
|
||||||
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
{voipSelection.enabled ? 'Yes' : 'No'}
|
{voipSelection.enabled ? 'Yes' : 'No'}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -97,19 +120,22 @@ export function Step4VoIP({
|
|||||||
className="space-y-6"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
{/* User Count */}
|
{/* User Count */}
|
||||||
<div className="flex items-center gap-4 p-4 border border-gray-200 rounded-lg">
|
<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]">Number of phone users:</label>
|
<label className="text-sm font-medium text-[#333d49]"
|
||||||
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
|
Number of phone users:
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
value={voipSelection.userCount}
|
value={voipSelection.userCount}
|
||||||
onChange={(e) => onSetVoIPUserCount(parseInt(e.target.value, 10) || 1)}
|
onChange={(e) => onSetVoIPUserCount(parseInt(e.target.value, 10) || 1)}
|
||||||
className="w-24"
|
className="w-full sm:w-24"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tier Selection */}
|
{/* 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) => {
|
{voipTiers.map((tier, index) => {
|
||||||
const isSelected = voipSelection.tierId === tier.id;
|
const isSelected = voipSelection.tierId === tier.id;
|
||||||
const monthlyPrice = tier.pricePerUser * voipSelection.userCount;
|
const monthlyPrice = tier.pricePerUser * voipSelection.userCount;
|
||||||
@@ -120,43 +146,48 @@ export function Step4VoIP({
|
|||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: index * 0.1 }}
|
transition={{ delay: index * 0.1 }}
|
||||||
whileHover={{ y: -4 }}
|
whileHover={{ y: -3 }}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
|
variant={isSelected ? 'highlighted' : 'default'}
|
||||||
padding="none"
|
padding="none"
|
||||||
className={`relative overflow-hidden cursor-pointer h-full ${
|
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)}
|
onClick={() => onSetVoIPTier(tier.id)}
|
||||||
>
|
>
|
||||||
{tier.recommended && (
|
{tier.recommended && (
|
||||||
<div className="absolute top-0 right-0">
|
<div className="bg-gradient-accent text-white text-[11px] font-bold px-3 py-1.5 text-center uppercase tracking-wider"
|
||||||
<div className="bg-[#fe7400] text-white text-xs font-semibold px-2 py-1 rounded-bl-lg">
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
Popular
|
Popular
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<h3 className="text-lg font-semibold text-[#333d49]">{tier.name}</h3>
|
<h3 className="text-base font-bold text-[#333d49]"
|
||||||
<p className="text-xs text-gray-500 mb-3">{tier.description}</p>
|
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">
|
<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)}
|
{formatCurrency(monthlyPrice)}
|
||||||
</span>
|
</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">
|
<p className="text-xs text-gray-400">
|
||||||
{formatCurrency(tier.pricePerUser)}/user
|
{formatCurrency(tier.pricePerUser)}/user
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="space-y-1 mb-4">
|
<ul className="space-y-1.5 mb-4">
|
||||||
{tier.features.slice(0, 3).map((feature, idx) => (
|
{tier.features.slice(0, 3).map((feature, idx) => (
|
||||||
<li key={idx} className="flex items-start gap-1.5 text-xs">
|
<li key={idx} className="flex items-start gap-2 text-xs">
|
||||||
<Check className="w-3 h-3 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
<div className="w-3.5 h-3.5 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
<span className="text-gray-600">{feature}</span>
|
<Check className="w-2 h-2 text-[#fe7400]" strokeWidth={3} />
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-500">{feature}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -176,19 +207,21 @@ export function Step4VoIP({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hardware Section */}
|
{/* 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowHardware(!showHardware)}
|
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">
|
<div className="flex items-center gap-3">
|
||||||
<Headphones className="w-5 h-5 text-[#fe7400]" />
|
<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)
|
Phone Hardware (Optional)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
{showHardware ? 'Hide' : 'Show'} options
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -209,81 +242,84 @@ export function Step4VoIP({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={hardware.id}
|
key={hardware.id}
|
||||||
className={`p-4 rounded-lg border-2 transition-all ${
|
className={`p-4 rounded-xl border-2 transition-all duration-200 ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'border-[#fe7400] bg-[#fe7400]/5'
|
? 'border-[#fe7400] bg-[#fe7400]/5'
|
||||||
: 'border-gray-200'
|
: 'border-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="space-y-3">
|
||||||
<div className="flex-1">
|
<div>
|
||||||
<h4 className="font-medium text-[#333d49]">{hardware.name}</h4>
|
<h4 className="font-semibold text-[#333d49] text-sm sm:text-base"
|
||||||
<p className="text-sm text-gray-500">{hardware.description}</p>
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
<div className="flex gap-4 mt-2 text-sm">
|
{hardware.name}
|
||||||
<span className="text-[#333d49]">
|
</h4>
|
||||||
Buy: <strong>{formatCurrency(hardware.oneTimePrice)}</strong>
|
<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>
|
||||||
<span className="text-[#333d49]">
|
<span className="text-gray-500">
|
||||||
Rent: <strong>{formatCurrency(hardware.monthlyRental)}</strong>/mo
|
Rent: <strong className="text-[#333d49]">{formatCurrency(hardware.monthlyRental)}</strong>/mo
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSelected ? (
|
{isSelected ? (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||||
{/* Rental Toggle */}
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onAddHardware(hardware.id, selection.quantity, false)}
|
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
|
!selection.isRental
|
||||||
? 'bg-[#fe7400] text-white'
|
? '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
|
Buy
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onAddHardware(hardware.id, selection.quantity, true)}
|
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
|
selection.isRental
|
||||||
? 'bg-[#fe7400] text-white'
|
? '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
|
Rent
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quantity */}
|
<div className="flex items-center gap-1 border border-gray-200 rounded-lg">
|
||||||
<div className="flex items-center gap-2 border border-gray-300 rounded-lg">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleQuantityChange(hardware.id, -1)}
|
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}
|
disabled={selection.quantity <= 1}
|
||||||
>
|
>
|
||||||
<Minus className="w-4 h-4" />
|
<Minus className="w-3.5 h-3.5" />
|
||||||
</button>
|
</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}
|
{selection.quantity}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleQuantityChange(hardware.id, 1)}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Remove */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onRemoveHardware(hardware.id)}
|
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" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -317,21 +353,29 @@ export function Step4VoIP({
|
|||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<ExpandableInfo title="VoIP Features & Benefits">
|
<ExpandableInfo title="VoIP Features & Benefits">
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2.5">
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2.5">
|
||||||
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
<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>
|
<span>Unlimited local and long-distance calling</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2.5">
|
||||||
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
<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>
|
<span>Mobile apps for iOS and Android - take calls anywhere</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2.5">
|
||||||
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
<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>
|
<span>Auto-attendant and professional voicemail</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2.5">
|
||||||
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
<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>
|
<span>Keep your existing phone numbers</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -339,18 +383,19 @@ export function Step4VoIP({
|
|||||||
|
|
||||||
{/* Totals */}
|
{/* Totals */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="bg-[#333d49] text-white rounded-lg p-5 flex items-center justify-between">
|
<div className="bg-gradient-dark text-white rounded-xl p-4 sm:p-5 flex items-center justify-between gap-3">
|
||||||
<span className="text-lg">VoIP Monthly Total</span>
|
<span className="text-sm sm:text-base font-medium opacity-90">VoIP Monthly Total</span>
|
||||||
<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(getVoIPMonthly())}
|
{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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{getVoIPOneTime() > 0 && (
|
{getVoIPOneTime() > 0 && (
|
||||||
<div className="bg-gray-100 rounded-lg p-4 flex items-center justify-between">
|
<div className="bg-[#f8f9fb] rounded-xl p-4 flex items-center justify-between border border-gray-200/80">
|
||||||
<span className="text-gray-700">Hardware Purchase (One-Time)</span>
|
<span className="text-gray-500 font-medium">Hardware Purchase (One-Time)</span>
|
||||||
<span className="text-xl font-bold text-[#333d49]">
|
<span className="text-xl font-bold text-[#333d49]"
|
||||||
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
{formatCurrency(getVoIPOneTime())}
|
{formatCurrency(getVoIPOneTime())}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -364,10 +409,10 @@ export function Step4VoIP({
|
|||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
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" />
|
<Phone className="w-12 h-12 mx-auto mb-3 opacity-20" />
|
||||||
<p>You can always add VoIP services later.</p>
|
<p className="text-sm">You can always add VoIP services later.</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -47,28 +47,50 @@ export function Step5WebEmail({
|
|||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
className="space-y-8"
|
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 — SSL certificates, backups, security
|
||||||
|
updates, DNS, and spam filtering — 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 */}
|
{/* Web Hosting Section */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="bg-gray-50 rounded-lg p-5">
|
<div className="bg-[#f8f9fb] rounded-xl p-4 sm:p-5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<Globe className="w-6 h-6 text-[#fe7400]" />
|
<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">
|
||||||
<div>
|
<Globe className="w-4 h-4 sm:w-5 sm:h-5 text-[#fe7400]" />
|
||||||
<h3 className="font-semibold text-[#333d49]">Web Hosting</h3>
|
</div>
|
||||||
<p className="text-sm text-gray-500">
|
<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
|
Managed WordPress hosting with SSL and backups
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<label className="relative inline-flex items-center cursor-pointer flex-shrink-0">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={webHostingSelection.enabled}
|
checked={webHostingSelection.enabled}
|
||||||
onChange={(e) => onSetWebHostingEnabled(e.target.checked)}
|
onChange={(e) => onSetWebHostingEnabled(e.target.checked)}
|
||||||
className="sr-only peer"
|
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>
|
<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-medium text-gray-700">
|
<span className="ml-3 text-sm font-semibold text-gray-500"
|
||||||
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
{webHostingSelection.enabled ? 'Yes' : 'No'}
|
{webHostingSelection.enabled ? 'Yes' : 'No'}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -83,7 +105,7 @@ export function Step5WebEmail({
|
|||||||
exit={{ opacity: 0, height: 0 }}
|
exit={{ opacity: 0, height: 0 }}
|
||||||
transition={{ duration: 0.3 }}
|
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) => {
|
{webHostingTiers.map((tier, index) => {
|
||||||
const isSelected = webHostingSelection.tierId === tier.id;
|
const isSelected = webHostingSelection.tierId === tier.id;
|
||||||
|
|
||||||
@@ -93,46 +115,51 @@ export function Step5WebEmail({
|
|||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: index * 0.1 }}
|
transition={{ delay: index * 0.1 }}
|
||||||
whileHover={{ y: -4 }}
|
whileHover={{ y: -3 }}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
|
variant={isSelected ? 'highlighted' : 'default'}
|
||||||
padding="none"
|
padding="none"
|
||||||
className={`relative overflow-hidden cursor-pointer h-full ${
|
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)}
|
onClick={() => onSetWebHostingTier(tier.id)}
|
||||||
>
|
>
|
||||||
{tier.recommended && (
|
{tier.recommended && (
|
||||||
<div className="absolute top-0 right-0">
|
<div className="bg-gradient-accent text-white text-[11px] font-bold px-3 py-1.5 text-center uppercase tracking-wider"
|
||||||
<div className="bg-[#fe7400] text-white text-xs font-semibold px-2 py-1 rounded-bl-lg">
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
Popular
|
Popular
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<h3 className="text-lg font-semibold text-[#333d49]">{tier.name}</h3>
|
<h3 className="text-base font-bold text-[#333d49]"
|
||||||
<p className="text-xs text-gray-500 mb-3">{tier.description}</p>
|
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">
|
<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)}
|
{formatCurrency(tier.monthlyPrice)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-500 text-sm">/mo</span>
|
<span className="text-gray-400 text-sm">/mo</span>
|
||||||
</div>
|
</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>{tier.storage}</span>
|
||||||
<span>|</span>
|
<span className="text-gray-300">|</span>
|
||||||
<span>{tier.sites === -1 ? 'Unlimited' : tier.sites} site{tier.sites !== 1 && 's'}</span>
|
<span>{tier.sites === -1 ? 'Unlimited' : tier.sites} site{tier.sites !== 1 && 's'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="space-y-1 mb-4">
|
<ul className="space-y-1.5 mb-4">
|
||||||
{tier.features.slice(0, 3).map((feature, idx) => (
|
{tier.features.slice(0, 3).map((feature, idx) => (
|
||||||
<li key={idx} className="flex items-start gap-1.5 text-xs">
|
<li key={idx} className="flex items-start gap-2 text-xs">
|
||||||
<Check className="w-3 h-3 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
<div className="w-3.5 h-3.5 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
<span className="text-gray-600">{feature}</span>
|
<Check className="w-2 h-2 text-[#fe7400]" strokeWidth={3} />
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-500">{feature}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -156,30 +183,36 @@ export function Step5WebEmail({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="border-t border-gray-200" />
|
<div className="border-t border-gray-100" />
|
||||||
|
|
||||||
{/* Email Section */}
|
{/* Email Section */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="bg-gray-50 rounded-lg p-5">
|
<div className="bg-[#f8f9fb] rounded-xl p-4 sm:p-5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<Mail className="w-6 h-6 text-[#fe7400]" />
|
<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">
|
||||||
<div>
|
<Mail className="w-4 h-4 sm:w-5 sm:h-5 text-[#fe7400]" />
|
||||||
<h3 className="font-semibold text-[#333d49]">Email Service</h3>
|
</div>
|
||||||
<p className="text-sm text-gray-500">
|
<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
|
Professional business email hosting
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<label className="relative inline-flex items-center cursor-pointer flex-shrink-0">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={emailSelection.enabled}
|
checked={emailSelection.enabled}
|
||||||
onChange={(e) => onSetEmailEnabled(e.target.checked)}
|
onChange={(e) => onSetEmailEnabled(e.target.checked)}
|
||||||
className="sr-only peer"
|
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>
|
<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-medium text-gray-700">
|
<span className="ml-3 text-sm font-semibold text-gray-500"
|
||||||
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
{emailSelection.enabled ? 'Yes' : 'No'}
|
{emailSelection.enabled ? 'Yes' : 'No'}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -196,8 +229,9 @@ export function Step5WebEmail({
|
|||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
{/* Mailbox Count */}
|
{/* Mailbox Count */}
|
||||||
<div className="flex items-center gap-4 p-4 border border-gray-200 rounded-lg">
|
<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]">
|
<label className="text-sm font-medium text-[#333d49]"
|
||||||
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
Number of mailboxes:
|
Number of mailboxes:
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -205,7 +239,7 @@ export function Step5WebEmail({
|
|||||||
min={1}
|
min={1}
|
||||||
value={emailSelection.mailboxCount}
|
value={emailSelection.mailboxCount}
|
||||||
onChange={(e) => onSetMailboxCount(parseInt(e.target.value, 10) || 1)}
|
onChange={(e) => onSetMailboxCount(parseInt(e.target.value, 10) || 1)}
|
||||||
className="w-24"
|
className="w-full sm:w-24"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -213,44 +247,51 @@ export function Step5WebEmail({
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={() => onSetEmailProvider('whm')}
|
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'
|
emailSelection.provider === 'whm'
|
||||||
? 'border-[#fe7400] bg-[#fe7400]/5'
|
? 'border-[#fe7400] bg-[#fe7400]/5 shadow-sm'
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<Server className="w-5 h-5 text-[#fe7400]" />
|
<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>
|
</div>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-400">
|
||||||
Budget-friendly email hosting on our servers
|
Budget-friendly email hosting on our servers
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
onClick={() => onSetEmailProvider('m365')}
|
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'
|
emailSelection.provider === 'm365'
|
||||||
? 'border-[#fe7400] bg-[#fe7400]/5'
|
? 'border-[#fe7400] bg-[#fe7400]/5 shadow-sm'
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<Cloud className="w-5 h-5 text-[#fe7400]" />
|
<Cloud className="w-5 h-5 text-[#fe7400]" />
|
||||||
<h4 className="font-semibold text-[#333d49]">Microsoft 365</h4>
|
<h4 className="font-bold text-[#333d49]"
|
||||||
<span className="text-xs bg-[#fe7400] text-white px-2 py-0.5 rounded">
|
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
|
Recommended
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-400">
|
||||||
Full Microsoft suite with Teams, OneDrive, and Office apps
|
Full Microsoft suite with Teams, OneDrive, and Office apps
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tier Selection based on Provider */}
|
{/* 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) => {
|
{(emailSelection.provider === 'whm' ? whmTiers : m365Tiers).map((tier, index) => {
|
||||||
const isSelected = emailSelection.tierId === tier.id;
|
const isSelected = emailSelection.tierId === tier.id;
|
||||||
const monthlyPrice = tier.pricePerMailbox * emailSelection.mailboxCount;
|
const monthlyPrice = tier.pricePerMailbox * emailSelection.mailboxCount;
|
||||||
@@ -261,43 +302,48 @@ export function Step5WebEmail({
|
|||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: index * 0.1 }}
|
transition={{ delay: index * 0.1 }}
|
||||||
whileHover={{ y: -4 }}
|
whileHover={{ y: -3 }}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
|
variant={isSelected ? 'highlighted' : 'default'}
|
||||||
padding="none"
|
padding="none"
|
||||||
className={`relative overflow-hidden cursor-pointer h-full ${
|
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)}
|
onClick={() => onSetEmailTier(tier.id)}
|
||||||
>
|
>
|
||||||
{tier.recommended && (
|
{tier.recommended && (
|
||||||
<div className="absolute top-0 right-0">
|
<div className="bg-gradient-accent text-white text-[11px] font-bold px-3 py-1.5 text-center uppercase tracking-wider"
|
||||||
<div className="bg-[#fe7400] text-white text-xs font-semibold px-2 py-1 rounded-bl-lg">
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
Popular
|
Popular
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<h3 className="text-base font-semibold text-[#333d49]">{tier.name}</h3>
|
<h3 className="text-base font-bold text-[#333d49]"
|
||||||
<p className="text-xs text-gray-500 mb-2">{tier.storage}</p>
|
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">
|
<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)}
|
{formatCurrency(monthlyPrice)}
|
||||||
</span>
|
</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">
|
<p className="text-xs text-gray-400">
|
||||||
{formatCurrency(tier.pricePerMailbox)}/mailbox
|
{formatCurrency(tier.pricePerMailbox)}/mailbox
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="space-y-1 mb-3">
|
<ul className="space-y-1.5 mb-3">
|
||||||
{tier.features.slice(0, 3).map((feature, idx) => (
|
{tier.features.slice(0, 3).map((feature, idx) => (
|
||||||
<li key={idx} className="flex items-start gap-1.5 text-xs">
|
<li key={idx} className="flex items-start gap-2 text-xs">
|
||||||
<Check className="w-3 h-3 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
<div className="w-3.5 h-3.5 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
<span className="text-gray-600">{feature}</span>
|
<Check className="w-2 h-2 text-[#fe7400]" strokeWidth={3} />
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-500">{feature}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -322,17 +368,23 @@ export function Step5WebEmail({
|
|||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<ExpandableInfo title="WHM vs Microsoft 365 - Which should I choose?">
|
<ExpandableInfo title="WHM vs Microsoft 365 - Which should I choose?">
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h5 className="font-medium text-[#333d49]">Self-Hosted (WHM)</h5>
|
<h5 className="font-semibold text-[#333d49]"
|
||||||
<p className="text-sm text-gray-600">
|
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.
|
Best for budget-conscious businesses that just need reliable email.
|
||||||
Includes webmail access and standard email features.
|
Includes webmail access and standard email features.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h5 className="font-medium text-[#333d49]">Microsoft 365</h5>
|
<h5 className="font-semibold text-[#333d49]"
|
||||||
<p className="text-sm text-gray-600">
|
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,
|
Best for businesses that need collaboration tools. Includes Outlook,
|
||||||
Teams for video calls, OneDrive cloud storage, and the full Office
|
Teams for video calls, OneDrive cloud storage, and the full Office
|
||||||
suite (Word, Excel, PowerPoint).
|
suite (Word, Excel, PowerPoint).
|
||||||
@@ -344,31 +396,33 @@ export function Step5WebEmail({
|
|||||||
{/* Totals */}
|
{/* Totals */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{webHostingSelection.enabled && (
|
{webHostingSelection.enabled && (
|
||||||
<div className="bg-gray-100 rounded-lg p-4 flex items-center justify-between">
|
<div className="bg-[#f8f9fb] rounded-xl p-4 flex items-center justify-between border border-gray-200/80">
|
||||||
<span className="text-gray-700">Web Hosting</span>
|
<span className="text-gray-500 font-medium">Web Hosting</span>
|
||||||
<span className="text-xl font-bold text-[#333d49]">
|
<span className="text-xl font-bold text-[#333d49]"
|
||||||
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
{formatCurrency(getWebHostingMonthly())}
|
{formatCurrency(getWebHostingMonthly())}
|
||||||
<span className="text-sm font-normal">/mo</span>
|
<span className="text-sm font-medium text-gray-400 ml-1">/mo</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{emailSelection.enabled && (
|
{emailSelection.enabled && (
|
||||||
<div className="bg-gray-100 rounded-lg p-4 flex items-center justify-between">
|
<div className="bg-[#f8f9fb] rounded-xl p-4 flex items-center justify-between border border-gray-200/80">
|
||||||
<span className="text-gray-700">Email Service</span>
|
<span className="text-gray-500 font-medium">Email Service</span>
|
||||||
<span className="text-xl font-bold text-[#333d49]">
|
<span className="text-xl font-bold text-[#333d49]"
|
||||||
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
{formatCurrency(getEmailMonthly())}
|
{formatCurrency(getEmailMonthly())}
|
||||||
<span className="text-sm font-normal">/mo</span>
|
<span className="text-sm font-medium text-gray-400 ml-1">/mo</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(webHostingSelection.enabled || emailSelection.enabled) && (
|
{(webHostingSelection.enabled || emailSelection.enabled) && (
|
||||||
<div className="bg-[#333d49] text-white rounded-lg p-5 flex items-center justify-between">
|
<div className="bg-gradient-dark text-white rounded-xl p-4 sm:p-5 flex items-center justify-between gap-3">
|
||||||
<span className="text-lg">Web & Email Total</span>
|
<span className="text-sm sm:text-base font-medium opacity-90">Web & Email Total</span>
|
||||||
<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(getWebHostingMonthly() + getEmailMonthly())}
|
{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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { motion } from 'framer-motion';
|
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 { Button } from '@/components/ui';
|
||||||
import {
|
import {
|
||||||
gpsTiers,
|
gpsTiers,
|
||||||
@@ -25,7 +25,6 @@ export function Step6Summary({
|
|||||||
onGoToStep,
|
onGoToStep,
|
||||||
onCalculateQuote,
|
onCalculateQuote,
|
||||||
}: Step6SummaryProps) {
|
}: Step6SummaryProps) {
|
||||||
// Calculate fresh quote if not available
|
|
||||||
const result = quoteResult || onCalculateQuote();
|
const result = quoteResult || onCalculateQuote();
|
||||||
|
|
||||||
const gpsTier = gpsTiers.find((t) => t.id === quoteData.gps.tierId);
|
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 emailTier = emailTiers.find((t) => t.id === quoteData.email.tierId);
|
||||||
|
|
||||||
const handlePrint = () => {
|
const handlePrint = () => {
|
||||||
window.print();
|
// Brief delay to ensure print-only elements render
|
||||||
|
requestAnimationFrame(() => window.print());
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -48,19 +48,42 @@ export function Step6Summary({
|
|||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
className="space-y-6"
|
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 */}
|
{/* Header */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8 print-hide">
|
||||||
<h2 className="text-2xl font-bold text-[#333d49] mb-2">Your Quote Summary</h2>
|
<h2 className="text-2xl font-bold text-[#333d49] mb-2"
|
||||||
<p className="text-gray-500">Review your selections before submitting</p>
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
|
Your Quote Summary
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-400">Review your selections before submitting</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Company Info */}
|
{/* Company Info */}
|
||||||
{quoteData.company.name && (
|
{quoteData.company.name && (
|
||||||
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
<div className="bg-[#f8f9fb] rounded-xl p-5 mb-6 border border-gray-200/50">
|
||||||
<p className="text-sm text-gray-500">Quote prepared for:</p>
|
<p className="text-[11px] text-gray-400 mb-1 uppercase tracking-wider font-medium"
|
||||||
<p className="font-semibold text-[#333d49] text-lg">{quoteData.company.name}</p>
|
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 && (
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -94,13 +117,20 @@ export function Step6Summary({
|
|||||||
onEdit={() => onGoToStep(2)}
|
onEdit={() => onGoToStep(2)}
|
||||||
>
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<SummaryLine
|
{quoteData.support.planId === 'none' ? (
|
||||||
label={`${supportPlan?.name} Plan (${supportPlan?.includedHours} hrs/mo)`}
|
<SummaryLine
|
||||||
value={formatCurrency(result.breakdown.support.plan)}
|
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 && (
|
{blockTime && (
|
||||||
<SummaryLine
|
<SummaryLine
|
||||||
label={`Block Time (${blockTime.hours} hours)`}
|
label={`Block Time (${blockTime.hours} hours) — one-time`}
|
||||||
value={formatCurrency(result.breakdown.support.blockTime)}
|
value={formatCurrency(result.breakdown.support.blockTime)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -160,41 +190,42 @@ export function Step6Summary({
|
|||||||
</SummarySection>
|
</SummarySection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Totals */}
|
{/* Grand Total */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
transition={{ delay: 0.3 }}
|
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">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 mb-5">
|
||||||
<span className="text-lg">Monthly Total</span>
|
<span className="text-base sm:text-lg font-medium text-white/80">Monthly Investment</span>
|
||||||
<span className="text-4xl font-bold">
|
<span className="text-3xl sm:text-4xl font-bold" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
{formatCurrency(result.monthlyTotal)}
|
{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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{result.oneTimeTotal > 0 && (
|
{result.oneTimeTotal > 0 && (
|
||||||
<div className="flex items-center justify-between pt-4 border-t border-white/20">
|
<div className="flex items-center justify-between py-4 border-t border-white/10">
|
||||||
<span className="opacity-75">One-Time Costs (Hardware)</span>
|
<span className="text-white/60">One-Time Costs</span>
|
||||||
<span className="text-xl font-semibold">
|
<span className="text-xl font-bold" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
{formatCurrency(result.oneTimeTotal)}
|
{formatCurrency(result.oneTimeTotal)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-6 pt-4 border-t border-white/20">
|
<div className="pt-4 border-t border-white/10">
|
||||||
<div className="flex items-center justify-between text-sm opacity-75">
|
<div className="flex items-center justify-between text-sm text-white/50">
|
||||||
<span>Annual Investment</span>
|
<span>Annual Investment</span>
|
||||||
<span>{formatCurrency(result.monthlyTotal * 12)}/year</span>
|
<span className="font-medium">{formatCurrency(result.monthlyTotal * 12)}/year</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Breakdown Card */}
|
{/* Breakdown Card */}
|
||||||
<div className="bg-gray-50 rounded-lg p-5">
|
<div className="bg-white rounded-xl border border-gray-200/80 shadow-card p-5 sm:p-6">
|
||||||
<h4 className="font-semibold text-[#333d49] mb-4 flex items-center gap-2">
|
<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]" />
|
<DollarSign className="w-5 h-5 text-[#fe7400]" />
|
||||||
Monthly Breakdown
|
Monthly Breakdown
|
||||||
</h4>
|
</h4>
|
||||||
@@ -210,19 +241,25 @@ export function Step6Summary({
|
|||||||
{quoteData.email.enabled && (
|
{quoteData.email.enabled && (
|
||||||
<BreakdownRow label="Email Service" value={result.emailMonthly} />
|
<BreakdownRow label="Email Service" value={result.emailMonthly} />
|
||||||
)}
|
)}
|
||||||
<div className="pt-3 border-t border-gray-200 flex justify-between font-bold text-lg">
|
<div className="pt-4 mt-1 border-t-2 border-[#fe7400]/20 flex justify-between items-center">
|
||||||
<span className="text-[#333d49]">Total</span>
|
<span className="font-bold text-[#333d49] text-lg"
|
||||||
<span className="text-[#fe7400]">{formatCurrency(result.monthlyTotal)}/mo</span>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Print Button */}
|
{/* Print Button */}
|
||||||
<div className="flex justify-center pt-4 print:hidden">
|
<div className="flex justify-center pt-2 print-hide">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
onClick={handlePrint}
|
onClick={handlePrint}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2 text-gray-400"
|
||||||
>
|
>
|
||||||
<Printer className="w-4 h-4" />
|
<Printer className="w-4 h-4" />
|
||||||
Print Quote
|
Print Quote
|
||||||
@@ -230,10 +267,15 @@ export function Step6Summary({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notes Section */}
|
{/* 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>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>
|
<p>Prices are subject to change. Quote valid for 30 days.</p>
|
||||||
</div>
|
</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 · azcomputerguru.com · (480) 400-3798</p>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -251,30 +293,36 @@ interface SummarySectionProps {
|
|||||||
function SummarySection({ icon, title, monthlyTotal, onEdit, children }: SummarySectionProps) {
|
function SummarySection({ icon, title, monthlyTotal, onEdit, children }: SummarySectionProps) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: -20 }}
|
initial={{ opacity: 0, x: -10 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
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="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-3">
|
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
|
||||||
<span className="text-[#fe7400]">{icon}</span>
|
<span className="text-[#fe7400] flex-shrink-0">{icon}</span>
|
||||||
<span className="font-semibold text-[#333d49]">{title}</span>
|
<span className="font-bold text-[#333d49] text-sm sm:text-base truncate"
|
||||||
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-2 sm:gap-4 flex-shrink-0">
|
||||||
<span className="font-bold text-[#333d49]">
|
<span className="font-bold text-[#333d49] text-sm sm:text-base whitespace-nowrap"
|
||||||
{formatCurrency(monthlyTotal)}/mo
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
|
{formatCurrency(monthlyTotal)}
|
||||||
|
<span className="text-xs font-medium text-gray-400 ml-0.5">/mo</span>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onEdit}
|
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
|
Edit
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">{children}</div>
|
<div className="p-5">{children}</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -286,9 +334,15 @@ interface SummaryLineProps {
|
|||||||
|
|
||||||
function SummaryLine({ label, value }: SummaryLineProps) {
|
function SummaryLine({ label, value }: SummaryLineProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between items-center text-sm">
|
||||||
<span className="text-gray-600">{label}</span>
|
<span className="text-gray-500 flex items-center gap-2">
|
||||||
<span className="font-medium text-[#333d49]">{value}</span>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -300,9 +354,12 @@ interface BreakdownRowProps {
|
|||||||
|
|
||||||
function BreakdownRow({ label, value }: BreakdownRowProps) {
|
function BreakdownRow({ label, value }: BreakdownRowProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between items-center py-1">
|
||||||
<span className="text-gray-600">{label}</span>
|
<span className="text-gray-500">{label}</span>
|
||||||
<span className="font-medium text-[#333d49]">{formatCurrency(value)}</span>
|
<span className="font-semibold text-[#333d49]"
|
||||||
|
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
|
||||||
|
{formatCurrency(value)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
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 { Input, Button } from '@/components/ui';
|
||||||
import { contactPreferences } from '@/lib/pricing-data';
|
import { contactPreferences } from '@/lib/pricing-data';
|
||||||
import type { ContactInfo, ContactPreference, QuoteResult } from '@/types/quote';
|
import type { ContactInfo, ContactPreference, QuoteResult } from '@/types/quote';
|
||||||
@@ -36,7 +36,6 @@ export function Step7Contact({
|
|||||||
const [errors, setErrors] = useState<FormErrors>({});
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
const [touched, setTouched] = useState<Record<string, boolean>>({});
|
const [touched, setTouched] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
// Pre-fill company name if available
|
|
||||||
if (companyNameFromStep1 && !contactInfo.companyName) {
|
if (companyNameFromStep1 && !contactInfo.companyName) {
|
||||||
onUpdateContact({ companyName: companyNameFromStep1 });
|
onUpdateContact({ companyName: companyNameFromStep1 });
|
||||||
}
|
}
|
||||||
@@ -77,7 +76,6 @@ export function Step7Contact({
|
|||||||
if (validateForm()) {
|
if (validateForm()) {
|
||||||
onSubmit();
|
onSubmit();
|
||||||
} else {
|
} else {
|
||||||
// Mark all fields as touched to show errors
|
|
||||||
setTouched({
|
setTouched({
|
||||||
name: true,
|
name: true,
|
||||||
email: true,
|
email: true,
|
||||||
@@ -95,29 +93,39 @@ export function Step7Contact({
|
|||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h2 className="text-2xl font-bold text-[#333d49] mb-2">Get Your Quote</h2>
|
<h2 className="text-2xl font-bold text-[#333d49] mb-2"
|
||||||
<p className="text-gray-500">
|
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.
|
We will send your customized quote and contact you to discuss next steps.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quote Preview */}
|
{/* Quote Preview */}
|
||||||
{quoteResult && (
|
{quoteResult && (
|
||||||
<div className="bg-[#fe7400]/10 border border-[#fe7400]/30 rounded-lg p-4 mb-6 flex items-center justify-between">
|
<motion.div
|
||||||
<span className="text-[#333d49] font-medium">Your Estimated Monthly Total:</span>
|
initial={{ opacity: 0, y: 10 }}
|
||||||
<span className="text-2xl font-bold text-[#fe7400]">
|
animate={{ opacity: 1, y: 0 }}
|
||||||
{formatCurrency(quoteResult.monthlyTotal)}/mo
|
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>
|
</span>
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
{/* Contact Name */}
|
{/* Contact Name */}
|
||||||
<div className="space-y-2">
|
<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]" />
|
<User className="w-4 h-4 text-[#fe7400]" />
|
||||||
Contact Name
|
Contact Name
|
||||||
<span className="text-red-500">*</span>
|
<span className="text-red-500 text-xs">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -131,10 +139,11 @@ export function Step7Contact({
|
|||||||
|
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
<div className="space-y-2">
|
<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]" />
|
<Mail className="w-4 h-4 text-[#fe7400]" />
|
||||||
Email Address
|
Email Address
|
||||||
<span className="text-red-500">*</span>
|
<span className="text-red-500 text-xs">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
@@ -148,10 +157,11 @@ export function Step7Contact({
|
|||||||
|
|
||||||
{/* Phone */}
|
{/* Phone */}
|
||||||
<div className="space-y-2">
|
<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 className="w-4 h-4 text-[#fe7400]" />
|
||||||
Phone Number
|
Phone Number
|
||||||
<span className="text-gray-400 font-normal">(recommended)</span>
|
<span className="text-gray-300 font-normal text-xs">(recommended)</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="tel"
|
type="tel"
|
||||||
@@ -161,46 +171,34 @@ export function Step7Contact({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Current IT Situation */}
|
||||||
<div className="space-y-2">
|
<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]" />
|
<MessageSquare className="w-4 h-4 text-[#fe7400]" />
|
||||||
Current IT Situation
|
Current IT Situation
|
||||||
<span className="text-gray-400 font-normal">(optional)</span>
|
<span className="text-gray-300 font-normal text-xs">(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={contactInfo.currentITSituation}
|
value={contactInfo.currentITSituation}
|
||||||
onChange={(e) => onUpdateContact({ currentITSituation: e.target.value })}
|
onChange={(e) => onUpdateContact({ currentITSituation: e.target.value })}
|
||||||
placeholder="Tell us about your current IT setup and any challenges you're facing..."
|
placeholder="Tell us about your current IT setup and any challenges you're facing..."
|
||||||
rows={3}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Contact Preference */}
|
{/* Contact Preference */}
|
||||||
<div className="space-y-3">
|
<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
|
Preferred Contact Method
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-4">
|
<div className="flex flex-wrap gap-4 sm:gap-5">
|
||||||
{contactPreferences.map((pref) => (
|
{contactPreferences.map((pref) => (
|
||||||
<label
|
<label
|
||||||
key={pref.id}
|
key={pref.id}
|
||||||
className="flex items-center gap-2 cursor-pointer"
|
className="flex items-center gap-2.5 cursor-pointer group"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -210,7 +208,9 @@ export function Step7Contact({
|
|||||||
onChange={() => onSetContactPreference(pref.id as ContactPreference)}
|
onChange={() => onSetContactPreference(pref.id as ContactPreference)}
|
||||||
className="w-4 h-4 text-[#fe7400] border-gray-300 focus:ring-[#fe7400]"
|
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>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -218,7 +218,7 @@ export function Step7Contact({
|
|||||||
|
|
||||||
{/* Terms Checkbox */}
|
{/* Terms Checkbox */}
|
||||||
<div className="space-y-2 pt-4">
|
<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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={contactInfo.agreedToTerms}
|
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]"
|
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
|
I agree to receive communications about my quote and understand that I can
|
||||||
unsubscribe at any time. I have read and agree to the{' '}
|
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
|
Privacy Policy
|
||||||
</a>{' '}
|
</a>{' '}
|
||||||
and{' '}
|
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
|
Terms of Service
|
||||||
</a>
|
</a>
|
||||||
.
|
.
|
||||||
<span className="text-red-500">*</span>
|
<span className="text-red-500 text-xs ml-0.5">*</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{touched.agreedToTerms && errors.agreedToTerms && (
|
{touched.agreedToTerms && errors.agreedToTerms && (
|
||||||
@@ -258,11 +258,16 @@ export function Step7Contact({
|
|||||||
type="submit"
|
type="submit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="w-full text-lg py-4"
|
className="w-full text-base py-4"
|
||||||
isLoading={isSubmitting}
|
isLoading={isSubmitting}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
{isSubmitting ? 'Submitting...' : 'Submit Quote Request'}
|
{isSubmitting ? 'Submitting...' : (
|
||||||
|
<>
|
||||||
|
<Sparkles className="w-5 h-5 mr-2" />
|
||||||
|
Submit Quote Request
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</form>
|
</form>
|
||||||
@@ -272,20 +277,26 @@ export function Step7Contact({
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ delay: 0.3 }}
|
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 sm:flex-row sm:justify-between gap-4 sm:gap-5">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex items-center gap-3 justify-center sm:justify-start">
|
||||||
<CheckCircle className="w-6 h-6 text-green-500" />
|
<div className="w-8 h-8 rounded-lg bg-[#ecfdf5] flex items-center justify-center flex-shrink-0">
|
||||||
<span className="text-sm text-gray-600">No obligation quote</span>
|
<Sparkles className="w-4 h-4 text-[#059669]" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500">No obligation quote</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex items-center gap-3 justify-center">
|
||||||
<CheckCircle className="w-6 h-6 text-green-500" />
|
<div className="w-8 h-8 rounded-lg bg-[#ecfdf5] flex items-center justify-center flex-shrink-0">
|
||||||
<span className="text-sm text-gray-600">Response within 24 hours</span>
|
<Clock className="w-4 h-4 text-[#059669]" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500">Response within 24 hours</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex items-center gap-3 justify-center sm:justify-end">
|
||||||
<CheckCircle className="w-6 h-6 text-green-500" />
|
<div className="w-8 h-8 rounded-lg bg-[#ecfdf5] flex items-center justify-center flex-shrink-0">
|
||||||
<span className="text-sm text-gray-600">Your data is secure</span>
|
<Shield className="w-4 h-4 text-[#059669]" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500">Your data is secure</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -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’d like to explore. We’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">·</span>
|
||||||
|
)}
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<span>Click Continue to configure each one</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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’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’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’m looking for IT services for…
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export { StepWelcome, type StepWelcomeProps } from './StepWelcome';
|
||||||
|
export { StepServiceDiscovery, type StepServiceDiscoveryProps } from './StepServiceDiscovery';
|
||||||
export { Step1CompanyProfile, type Step1CompanyProfileProps } from './Step1CompanyProfile';
|
export { Step1CompanyProfile, type Step1CompanyProfileProps } from './Step1CompanyProfile';
|
||||||
export { Step2GPSMonitoring, type Step2GPSMonitoringProps } from './Step2GPSMonitoring';
|
export { Step2GPSMonitoring, type Step2GPSMonitoringProps } from './Step2GPSMonitoring';
|
||||||
export { Step3SupportPlan, type Step3SupportPlanProps } from './Step3SupportPlan';
|
export { Step3SupportPlan, type Step3SupportPlanProps } from './Step3SupportPlan';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useMemo } from 'react';
|
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||||
import type {
|
import type {
|
||||||
QuoteData,
|
QuoteData,
|
||||||
QuoteResult,
|
QuoteResult,
|
||||||
@@ -19,6 +19,8 @@ import type {
|
|||||||
EmailProvider,
|
EmailProvider,
|
||||||
Industry,
|
Industry,
|
||||||
ContactPreference,
|
ContactPreference,
|
||||||
|
ClientType,
|
||||||
|
ServiceInterests,
|
||||||
} from '@/types/quote';
|
} from '@/types/quote';
|
||||||
import {
|
import {
|
||||||
gpsTiers,
|
gpsTiers,
|
||||||
@@ -31,9 +33,46 @@ import {
|
|||||||
emailTiers,
|
emailTiers,
|
||||||
} from '@/lib/pricing-data';
|
} 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
|
* Initial state values
|
||||||
*/
|
*/
|
||||||
|
const initialClientType: ClientType = 'company';
|
||||||
|
|
||||||
|
const initialServiceInterests: ServiceInterests = {
|
||||||
|
gps: true,
|
||||||
|
support: true,
|
||||||
|
voip: false,
|
||||||
|
webHosting: false,
|
||||||
|
email: false,
|
||||||
|
};
|
||||||
|
|
||||||
const initialCompanyInfo: CompanyInfo = {
|
const initialCompanyInfo: CompanyInfo = {
|
||||||
name: '',
|
name: '',
|
||||||
endpointCount: 10,
|
endpointCount: 10,
|
||||||
@@ -90,6 +129,10 @@ export interface UseQuoteReturn {
|
|||||||
quoteData: QuoteData;
|
quoteData: QuoteData;
|
||||||
quoteResult: QuoteResult | null;
|
quoteResult: QuoteResult | null;
|
||||||
|
|
||||||
|
// Client type & service interests
|
||||||
|
setClientType: (type: ClientType) => void;
|
||||||
|
setServiceInterest: (service: keyof ServiceInterests, enabled: boolean) => void;
|
||||||
|
|
||||||
// Company updates
|
// Company updates
|
||||||
updateCompany: (data: Partial<CompanyInfo>) => void;
|
updateCompany: (data: Partial<CompanyInfo>) => void;
|
||||||
setEndpointCount: (count: number) => void;
|
setEndpointCount: (count: number) => void;
|
||||||
@@ -140,6 +183,7 @@ export interface UseQuoteReturn {
|
|||||||
getVoIPMonthly: () => number;
|
getVoIPMonthly: () => number;
|
||||||
getWebHostingMonthly: () => number;
|
getWebHostingMonthly: () => number;
|
||||||
getEmailMonthly: () => number;
|
getEmailMonthly: () => number;
|
||||||
|
getSupportBlockTimeOneTime: () => number;
|
||||||
getVoIPOneTime: () => number;
|
getVoIPOneTime: () => number;
|
||||||
|
|
||||||
// Reset
|
// Reset
|
||||||
@@ -150,18 +194,67 @@ export interface UseQuoteReturn {
|
|||||||
* Quote calculation and state management hook
|
* Quote calculation and state management hook
|
||||||
*/
|
*/
|
||||||
export function useQuote(): UseQuoteReturn {
|
export function useQuote(): UseQuoteReturn {
|
||||||
const [company, setCompany] = useState<CompanyInfo>(initialCompanyInfo);
|
const draft = useRef(loadDraft());
|
||||||
const [gps, setGPS] = useState<GPSSelection>(initialGPSSelection);
|
|
||||||
const [support, setSupport] = useState<SupportSelection>(initialSupportSelection);
|
const [clientType, setClientType] = useState<ClientType>(draft.current?.clientType ?? initialClientType);
|
||||||
const [voip, setVoIP] = useState<VoIPSelection>(initialVoIPSelection);
|
const [serviceInterests, setServiceInterests] = useState<ServiceInterests>(draft.current?.serviceInterests ?? initialServiceInterests);
|
||||||
const [webHosting, setWebHosting] = useState<WebHostingSelection>(initialWebHostingSelection);
|
const [company, setCompany] = useState<CompanyInfo>(draft.current?.company ?? initialCompanyInfo);
|
||||||
const [email, setEmail] = useState<EmailSelection>(initialEmailSelection);
|
const [gps, setGPS] = useState<GPSSelection>(draft.current?.gps ?? initialGPSSelection);
|
||||||
const [contact, setContact] = useState<ContactInfo>(initialContactInfo);
|
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);
|
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
|
// Combined quote data
|
||||||
const quoteData: QuoteData = useMemo(
|
const quoteData: QuoteData = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
clientType,
|
||||||
|
serviceInterests,
|
||||||
company,
|
company,
|
||||||
gps,
|
gps,
|
||||||
support,
|
support,
|
||||||
@@ -170,9 +263,31 @@ export function useQuote(): UseQuoteReturn {
|
|||||||
email,
|
email,
|
||||||
contact,
|
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
|
// Company Updates
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -387,19 +502,16 @@ export function useQuote(): UseQuoteReturn {
|
|||||||
}, [gps]);
|
}, [gps]);
|
||||||
|
|
||||||
const getSupportMonthly = useCallback((): number => {
|
const getSupportMonthly = useCallback((): number => {
|
||||||
|
if (support.planId === 'none') return 0;
|
||||||
|
|
||||||
const plan = supportPlans.find((p) => p.id === support.planId);
|
const plan = supportPlans.find((p) => p.id === support.planId);
|
||||||
if (!plan) return 0;
|
return plan ? plan.monthlyPrice : 0;
|
||||||
|
}, [support]);
|
||||||
|
|
||||||
let total = plan.monthlyPrice;
|
const getSupportBlockTimeOneTime = useCallback((): number => {
|
||||||
|
if (!support.useBlockTime || !support.blockTimeId) return 0;
|
||||||
if (support.useBlockTime && support.blockTimeId) {
|
const blockTime = blockTimeOptions.find((b) => b.id === support.blockTimeId);
|
||||||
const blockTime = blockTimeOptions.find((b) => b.id === support.blockTimeId);
|
return blockTime ? blockTime.price : 0;
|
||||||
if (blockTime) {
|
|
||||||
total += blockTime.price;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return total;
|
|
||||||
}, [support]);
|
}, [support]);
|
||||||
|
|
||||||
const getVoIPMonthly = useCallback((): number => {
|
const getVoIPMonthly = useCallback((): number => {
|
||||||
@@ -460,6 +572,7 @@ export function useQuote(): UseQuoteReturn {
|
|||||||
const supportMonthly = getSupportMonthly();
|
const supportMonthly = getSupportMonthly();
|
||||||
const voipMonthly = getVoIPMonthly();
|
const voipMonthly = getVoIPMonthly();
|
||||||
const voipOneTime = getVoIPOneTime();
|
const voipOneTime = getVoIPOneTime();
|
||||||
|
const supportBlockTimeOneTime = getSupportBlockTimeOneTime();
|
||||||
const webHostingMonthly = getWebHostingMonthly();
|
const webHostingMonthly = getWebHostingMonthly();
|
||||||
const emailMonthly = getEmailMonthly();
|
const emailMonthly = getEmailMonthly();
|
||||||
|
|
||||||
@@ -473,15 +586,8 @@ export function useQuote(): UseQuoteReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate support breakdown
|
// 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;
|
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
|
// Calculate VoIP breakdown
|
||||||
const voipTier = voipTiers.find((t) => t.id === voip.tierId);
|
const voipTier = voipTiers.find((t) => t.id === voip.tierId);
|
||||||
@@ -506,7 +612,7 @@ export function useQuote(): UseQuoteReturn {
|
|||||||
},
|
},
|
||||||
support: {
|
support: {
|
||||||
plan: supportPlanCost,
|
plan: supportPlanCost,
|
||||||
blockTime: supportBlockTime,
|
blockTime: supportBlockTimeOneTime,
|
||||||
total: supportMonthly,
|
total: supportMonthly,
|
||||||
},
|
},
|
||||||
voip: {
|
voip: {
|
||||||
@@ -522,7 +628,7 @@ export function useQuote(): UseQuoteReturn {
|
|||||||
|
|
||||||
const result: QuoteResult = {
|
const result: QuoteResult = {
|
||||||
monthlyTotal,
|
monthlyTotal,
|
||||||
oneTimeTotal: voipOneTime,
|
oneTimeTotal: voipOneTime + supportBlockTimeOneTime,
|
||||||
breakdown,
|
breakdown,
|
||||||
gpsMonthly,
|
gpsMonthly,
|
||||||
supportMonthly,
|
supportMonthly,
|
||||||
@@ -533,13 +639,15 @@ export function useQuote(): UseQuoteReturn {
|
|||||||
|
|
||||||
setQuoteResult(result);
|
setQuoteResult(result);
|
||||||
return 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
|
// Reset
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const resetQuote = useCallback(() => {
|
const resetQuote = useCallback(() => {
|
||||||
|
setClientType(initialClientType);
|
||||||
|
setServiceInterests(initialServiceInterests);
|
||||||
setCompany(initialCompanyInfo);
|
setCompany(initialCompanyInfo);
|
||||||
setGPS(initialGPSSelection);
|
setGPS(initialGPSSelection);
|
||||||
setSupport(initialSupportSelection);
|
setSupport(initialSupportSelection);
|
||||||
@@ -548,12 +656,17 @@ export function useQuote(): UseQuoteReturn {
|
|||||||
setEmail(initialEmailSelection);
|
setEmail(initialEmailSelection);
|
||||||
setContact(initialContactInfo);
|
setContact(initialContactInfo);
|
||||||
setQuoteResult(null);
|
setQuoteResult(null);
|
||||||
|
localStorage.removeItem(DRAFT_STORAGE_KEY);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
quoteData,
|
quoteData,
|
||||||
quoteResult,
|
quoteResult,
|
||||||
|
|
||||||
|
// Client type & service interests
|
||||||
|
setClientType: setClientTypeValue,
|
||||||
|
setServiceInterest,
|
||||||
|
|
||||||
// Company updates
|
// Company updates
|
||||||
updateCompany,
|
updateCompany,
|
||||||
setEndpointCount,
|
setEndpointCount,
|
||||||
@@ -604,6 +717,7 @@ export function useQuote(): UseQuoteReturn {
|
|||||||
getVoIPMonthly,
|
getVoIPMonthly,
|
||||||
getWebHostingMonthly,
|
getWebHostingMonthly,
|
||||||
getEmailMonthly,
|
getEmailMonthly,
|
||||||
|
getSupportBlockTimeOneTime,
|
||||||
getVoIPOneTime,
|
getVoIPOneTime,
|
||||||
|
|
||||||
// Reset
|
// Reset
|
||||||
|
|||||||
@@ -1,46 +1,28 @@
|
|||||||
import { useState, useCallback, useMemo } from 'react';
|
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||||
import type { WizardStep } from '@/types/quote';
|
import type { WizardStep } from '@/types/quote';
|
||||||
|
|
||||||
/**
|
export interface WizardStepDef {
|
||||||
* Wizard steps configuration for the 7-step MSP Quote Wizard
|
id: string;
|
||||||
*/
|
title: string;
|
||||||
const WIZARD_STEPS: Omit<WizardStep, 'isComplete' | 'isActive'>[] = [
|
description: string;
|
||||||
{
|
}
|
||||||
id: 'company',
|
|
||||||
title: 'Company Profile',
|
/** Map step id from URL hash to step index */
|
||||||
description: 'Tell us about your business',
|
function stepIndexFromHash(steps: WizardStepDef[]): number {
|
||||||
},
|
const hash = window.location.hash.replace('#', '');
|
||||||
{
|
if (!hash) return 0;
|
||||||
id: 'gps',
|
const idx = steps.findIndex((s) => s.id === hash);
|
||||||
title: 'GPS Monitoring',
|
return idx >= 0 ? idx : 0;
|
||||||
description: 'Select your monitoring tier',
|
}
|
||||||
},
|
|
||||||
{
|
/** Determine which steps should be marked complete based on a restored index */
|
||||||
id: 'support',
|
function restoredCompletedSteps(upToIndex: number): Set<number> {
|
||||||
title: 'Support Plan',
|
const set = new Set<number>();
|
||||||
description: 'Choose your support level',
|
for (let i = 0; i < upToIndex; i++) {
|
||||||
},
|
set.add(i);
|
||||||
{
|
}
|
||||||
id: 'voip',
|
return set;
|
||||||
title: 'VoIP Phone System',
|
}
|
||||||
description: 'Business phone options',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'web-email',
|
|
||||||
title: 'Web & Email',
|
|
||||||
description: 'Hosting and email services',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'summary',
|
|
||||||
title: 'Review Quote',
|
|
||||||
description: 'Review your selections',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'contact',
|
|
||||||
title: 'Get Your Quote',
|
|
||||||
description: 'Submit your information',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface UseWizardReturn {
|
export interface UseWizardReturn {
|
||||||
currentStep: number;
|
currentStep: number;
|
||||||
@@ -61,37 +43,103 @@ export interface UseWizardReturn {
|
|||||||
getStepByIndex: (index: number) => WizardStep | undefined;
|
getStepByIndex: (index: number) => WizardStep | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWizard(): UseWizardReturn {
|
/**
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
* Dynamic wizard hook — accepts a step definition array that can change
|
||||||
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
|
* 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 [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 isFirstStep = currentStep === 0;
|
||||||
const isLastStep = currentStep === totalSteps - 1;
|
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(() => {
|
const steps: WizardStep[] = useMemo(() => {
|
||||||
return WIZARD_STEPS.map((step, index) => ({
|
return stepDefs.map((step, index) => ({
|
||||||
...step,
|
...step,
|
||||||
isComplete: completedSteps.has(index),
|
isComplete: completedSteps.has(index),
|
||||||
isActive: index === currentStep,
|
isActive: index === currentStep,
|
||||||
}));
|
}));
|
||||||
}, [currentStep, completedSteps]);
|
}, [stepDefs, currentStep, completedSteps]);
|
||||||
|
|
||||||
const currentStepId = useMemo(() => {
|
const currentStepId = useMemo(() => {
|
||||||
return WIZARD_STEPS[currentStep]?.id || '';
|
return stepDefs[currentStep]?.id || '';
|
||||||
}, [currentStep]);
|
}, [currentStep, stepDefs]);
|
||||||
|
|
||||||
const progress = useMemo(() => {
|
const progress = useMemo(() => {
|
||||||
// Progress based on current step position (0 to 100)
|
if (totalSteps <= 1) return 100;
|
||||||
return Math.round((currentStep / (totalSteps - 1)) * 100);
|
return Math.round((currentStep / (totalSteps - 1)) * 100);
|
||||||
}, [currentStep, totalSteps]);
|
}, [currentStep, totalSteps]);
|
||||||
|
|
||||||
const goToStep = useCallback(
|
const goToStep = useCallback(
|
||||||
(step: number) => {
|
(step: number) => {
|
||||||
if (step >= 0 && step < totalSteps) {
|
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) {
|
if (step <= currentStep || completedSteps.has(step - 1) || step === currentStep + 1) {
|
||||||
setCurrentStep(step);
|
setCurrentStep(step);
|
||||||
}
|
}
|
||||||
@@ -102,7 +150,6 @@ export function useWizard(): UseWizardReturn {
|
|||||||
|
|
||||||
const nextStep = useCallback(() => {
|
const nextStep = useCallback(() => {
|
||||||
if (!isLastStep && canProceed) {
|
if (!isLastStep && canProceed) {
|
||||||
// Mark current step as complete when moving forward
|
|
||||||
setCompletedSteps((prev) => new Set(prev).add(currentStep));
|
setCompletedSteps((prev) => new Set(prev).add(currentStep));
|
||||||
setCurrentStep((prev) => prev + 1);
|
setCurrentStep((prev) => prev + 1);
|
||||||
}
|
}
|
||||||
@@ -130,7 +177,8 @@ export function useWizard(): UseWizardReturn {
|
|||||||
setCurrentStep(0);
|
setCurrentStep(0);
|
||||||
setCompletedSteps(new Set());
|
setCompletedSteps(new Set());
|
||||||
setCanProceed(true);
|
setCanProceed(true);
|
||||||
}, []);
|
window.history.replaceState(null, '', `#${stepDefs[0]?.id}`);
|
||||||
|
}, [stepDefs]);
|
||||||
|
|
||||||
const getStepByIndex = useCallback(
|
const getStepByIndex = useCallback(
|
||||||
(index: number): WizardStep | undefined => {
|
(index: number): WizardStep | undefined => {
|
||||||
|
|||||||
@@ -1,62 +1,199 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Lexend:wght@300;400;500;600;700&display=swap');
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--color-primary: #333d49;
|
--color-primary: #333d49;
|
||||||
|
--color-primary-light: #3d4856;
|
||||||
--color-accent: #fe7400;
|
--color-accent: #fe7400;
|
||||||
|
--color-accent-hover: #e56800;
|
||||||
|
--color-accent-light: #fff4e8;
|
||||||
--color-navy: #113559;
|
--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;
|
margin: 0;
|
||||||
box-sizing: border-box;
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: 'DM Sans', system-ui, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
font-optical-sizing: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'DM Sans', system-ui, sans-serif;
|
||||||
|
background-color: #f8f9fb;
|
||||||
|
color: #333d49;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Display headings use Jakarta Sans */
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||||
|
line-height: 1.3;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus styles for accessibility */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid #fe7400;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection color */
|
||||||
|
::selection {
|
||||||
|
background-color: #fe7400;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transitions for interactive elements */
|
||||||
|
button, a, input, select, textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
/* Typography scale */
|
||||||
font-family: 'Lexend', sans-serif;
|
.text-display {
|
||||||
-webkit-font-smoothing: antialiased;
|
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
.text-heading {
|
||||||
font-family: 'Lexend', sans-serif;
|
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||||
background-color: #ffffff;
|
font-weight: 600;
|
||||||
color: #333d49;
|
letter-spacing: -0.01em;
|
||||||
line-height: 1.6;
|
}
|
||||||
|
|
||||||
|
.text-label {
|
||||||
|
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom scrollbar */
|
/* Custom scrollbar */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 6px;
|
||||||
height: 8px;
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: #f1f1f1;
|
background: transparent;
|
||||||
border-radius: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #333d49;
|
background: #cdd2d9;
|
||||||
border-radius: 4px;
|
border-radius: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #113559;
|
background: #9aa1ac;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Focus styles for accessibility */
|
/* Premium card shadow system */
|
||||||
:focus-visible {
|
.shadow-card {
|
||||||
outline: 2px solid #fe7400;
|
box-shadow:
|
||||||
outline-offset: 2px;
|
0 1px 2px rgba(17, 53, 89, 0.04),
|
||||||
|
0 4px 12px rgba(17, 53, 89, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Selection color */
|
.shadow-card-hover {
|
||||||
::selection {
|
box-shadow:
|
||||||
background-color: #fe7400;
|
0 2px 4px rgba(17, 53, 89, 0.06),
|
||||||
color: #ffffff;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { QuoteData, QuoteResult } from '@/types/quote';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API client for MSP Quote Wizard
|
* 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';
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8001';
|
||||||
@@ -15,70 +24,179 @@ export const apiClient = axios.create({
|
|||||||
timeout: 10000,
|
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
|
// Response interceptor for error handling
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response?.status === 401) {
|
|
||||||
localStorage.removeItem('quote_wizard_token');
|
|
||||||
}
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// -- Response types matching backend schemas --
|
||||||
|
|
||||||
|
export interface QuoteCreatedResponse {
|
||||||
|
id: string;
|
||||||
|
access_token: string;
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuoteItemResponse {
|
||||||
|
id: string;
|
||||||
|
quote_id: string;
|
||||||
|
service_name: string;
|
||||||
|
service_description: string | null;
|
||||||
|
category: string;
|
||||||
|
billing_frequency: string;
|
||||||
|
unit_price: string;
|
||||||
|
quantity: number;
|
||||||
|
setup_fee: string | null;
|
||||||
|
is_required: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
line_total: string;
|
||||||
|
monthly_amount: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuoteResponse {
|
||||||
|
id: string;
|
||||||
|
access_token: string;
|
||||||
|
status: string;
|
||||||
|
company_name: string | null;
|
||||||
|
contact_name: string | null;
|
||||||
|
contact_email: string | null;
|
||||||
|
contact_phone: string | null;
|
||||||
|
employee_count: number | null;
|
||||||
|
notes: string | null;
|
||||||
|
monthly_total: string;
|
||||||
|
setup_total: string;
|
||||||
|
annual_total: string;
|
||||||
|
expires_at: string | null;
|
||||||
|
submitted_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
items: QuoteItemResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Request types matching backend schemas --
|
||||||
|
|
||||||
|
export interface QuoteCreateRequest {
|
||||||
|
employee_count?: number;
|
||||||
|
notes?: string;
|
||||||
|
items?: QuoteItemCreateRequest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuoteUpdateRequest {
|
||||||
|
company_name?: string;
|
||||||
|
contact_name?: string;
|
||||||
|
contact_email?: string;
|
||||||
|
contact_phone?: string;
|
||||||
|
employee_count?: number;
|
||||||
|
notes?: string;
|
||||||
|
items?: QuoteItemCreateRequest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuoteItemCreateRequest {
|
||||||
|
category: string;
|
||||||
|
product_code: string;
|
||||||
|
product_name: string;
|
||||||
|
description?: string;
|
||||||
|
quantity: number;
|
||||||
|
unit_price: string;
|
||||||
|
setup_price?: string;
|
||||||
|
billing_frequency: string;
|
||||||
|
tier?: string;
|
||||||
|
is_recommended?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuoteSubmitRequest {
|
||||||
|
company_name: string;
|
||||||
|
contact_name: string;
|
||||||
|
contact_email: string;
|
||||||
|
contact_phone?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- API functions --
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API endpoints
|
* Create a new quote draft. Returns access token for future operations.
|
||||||
*/
|
*/
|
||||||
export const quoteApi = {
|
export async function createQuote(data: QuoteCreateRequest): Promise<QuoteCreatedResponse> {
|
||||||
/**
|
const response = await apiClient.post<QuoteCreatedResponse>('/quotes', data);
|
||||||
* Calculate quote based on provided data
|
return response.data;
|
||||||
*/
|
}
|
||||||
calculateQuote: async (data: QuoteData): Promise<QuoteResult> => {
|
|
||||||
const response = await apiClient.post<QuoteResult>('/api/quotes/calculate', data);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save quote for later retrieval
|
* Get a quote by its access token.
|
||||||
*/
|
*/
|
||||||
saveQuote: async (data: QuoteData & { email: string }): Promise<{ quoteId: string }> => {
|
export async function getQuote(accessToken: string): Promise<QuoteResponse> {
|
||||||
const response = await apiClient.post<{ quoteId: string }>('/api/quotes/save', data);
|
const response = await apiClient.get<QuoteResponse>(`/quotes/${accessToken}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve saved quote by ID
|
* Update a draft quote (wizard progress saves).
|
||||||
*/
|
*/
|
||||||
getQuote: async (quoteId: string): Promise<QuoteData & QuoteResult> => {
|
export async function updateQuote(
|
||||||
const response = await apiClient.get<QuoteData & QuoteResult>(`/api/quotes/${quoteId}`);
|
accessToken: string,
|
||||||
return response.data;
|
data: QuoteUpdateRequest,
|
||||||
},
|
): Promise<QuoteResponse> {
|
||||||
|
const response = await apiClient.put<QuoteResponse>(
|
||||||
|
`/quotes/${accessToken}`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit quote request for sales follow-up
|
* Add a single item to a quote.
|
||||||
*/
|
*/
|
||||||
submitQuoteRequest: async (data: QuoteData & {
|
export async function addQuoteItem(
|
||||||
contactInfo: {
|
accessToken: string,
|
||||||
name: string;
|
item: QuoteItemCreateRequest,
|
||||||
email: string;
|
): Promise<QuoteResponse> {
|
||||||
phone?: string;
|
const response = await apiClient.post<QuoteResponse>(
|
||||||
}
|
`/quotes/${accessToken}/items`,
|
||||||
}): Promise<{ success: boolean; message: string }> => {
|
item,
|
||||||
const response = await apiClient.post('/api/quotes/submit', data);
|
);
|
||||||
return response.data;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export interface GPSSelection {
|
|||||||
// Support Plan Types
|
// 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 type BlockTimeId = 'block-10' | 'block-20' | 'block-30';
|
||||||
|
|
||||||
export interface SupportPlan {
|
export interface SupportPlan {
|
||||||
@@ -138,9 +138,11 @@ export interface EmailSelection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Company & Contact Types
|
// Client & Contact Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ClientType = 'company' | 'individual';
|
||||||
|
|
||||||
export type Industry =
|
export type Industry =
|
||||||
| 'Healthcare'
|
| 'Healthcare'
|
||||||
| 'Legal'
|
| 'Legal'
|
||||||
@@ -169,11 +171,25 @@ export interface ContactInfo {
|
|||||||
agreedToTerms: boolean;
|
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
|
// Quote Data & Result Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface QuoteData {
|
export interface QuoteData {
|
||||||
|
clientType: ClientType;
|
||||||
|
serviceInterests: ServiceInterests;
|
||||||
company: CompanyInfo;
|
company: CompanyInfo;
|
||||||
gps: GPSSelection;
|
gps: GPSSelection;
|
||||||
support: SupportSelection;
|
support: SupportSelection;
|
||||||
|
|||||||
@@ -5,12 +5,17 @@ import path from 'path'
|
|||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
base: '/quote/',
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: false,
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
host: true,
|
host: true,
|
||||||
|
|||||||
24
projects/msp-tools/quote-wizard/php-api/api/.htaccess
Normal file
24
projects/msp-tools/quote-wizard/php-api/api/.htaccess
Normal 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>
|
||||||
51
projects/msp-tools/quote-wizard/php-api/api/config.php
Normal file
51
projects/msp-tools/quote-wizard/php-api/api/config.php
Normal 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');
|
||||||
55
projects/msp-tools/quote-wizard/php-api/api/db.php
Normal file
55
projects/msp-tools/quote-wizard/php-api/api/db.php
Normal 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;
|
||||||
|
}
|
||||||
277
projects/msp-tools/quote-wizard/php-api/api/helpers.php
Normal file
277
projects/msp-tools/quote-wizard/php-api/api/helpers.php
Normal 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');
|
||||||
|
}
|
||||||
164
projects/msp-tools/quote-wizard/php-api/api/index.php
Normal file
164
projects/msp-tools/quote-wizard/php-api/api/index.php
Normal 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);
|
||||||
148
projects/msp-tools/quote-wizard/php-api/api/routes/admin.php
Normal file
148
projects/msp-tools/quote-wizard/php-api/api/routes/admin.php
Normal 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);
|
||||||
|
}
|
||||||
183
projects/msp-tools/quote-wizard/php-api/api/routes/quotes.php
Normal file
183
projects/msp-tools/quote-wizard/php-api/api/routes/quotes.php
Normal 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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
130
projects/msp-tools/quote-wizard/php-api/schema.sql
Normal file
130
projects/msp-tools/quote-wizard/php-api/schema.sql
Normal 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;
|
||||||
@@ -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/"
|
||||||
|
```
|
||||||
37
scripts/bgb-assign-exo-role.ps1
Normal file
37
scripts/bgb-assign-exo-role.ps1
Normal 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"
|
||||||
81
scripts/bgb-check-lesley-ownership.ps1
Normal file
81
scripts/bgb-check-lesley-ownership.ps1
Normal 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
|
||||||
11
scripts/bgb-find-leslie.ps1
Normal file
11
scripts/bgb-find-leslie.ps1
Normal 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
|
||||||
102
scripts/bgb-lesley-disable-wipe.ps1
Normal file
102
scripts/bgb-lesley-disable-wipe.ps1
Normal 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
|
||||||
33
scripts/bgb-lesley-exchange.ps1
Normal file
33
scripts/bgb-lesley-exchange.ps1
Normal 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"
|
||||||
83
scripts/bgb-lesley-fix-rules.ps1
Normal file
83
scripts/bgb-lesley-fix-rules.ps1
Normal 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"
|
||||||
62
scripts/bgb-lesley-mail-report-20260309.txt
Normal file
62
scripts/bgb-lesley-mail-report-20260309.txt
Normal 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
|
||||||
150
scripts/bgb-lesley-mail-report.ps1
Normal file
150
scripts/bgb-lesley-mail-report.ps1
Normal 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"
|
||||||
193
scripts/bgb-lesley-recover-review.ps1
Normal file
193
scripts/bgb-lesley-recover-review.ps1
Normal 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 ""
|
||||||
71
scripts/bgb-lesley-verify-wipe.ps1
Normal file
71
scripts/bgb-lesley-verify-wipe.ps1
Normal 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"
|
||||||
119
scripts/bgb-reenable-lesley.ps1
Normal file
119
scripts/bgb-reenable-lesley.ps1
Normal 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
|
||||||
166
scripts/bgb-terminate-lesley.ps1
Normal file
166
scripts/bgb-terminate-lesley.ps1
Normal 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
|
||||||
2
scripts/bgb-terminate-wrapper.cmd
Normal file
2
scripts/bgb-terminate-wrapper.cmd
Normal 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
|
||||||
16
scripts/bgb-verify-users.ps1
Normal file
16
scripts/bgb-verify-users.ps1
Normal 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
|
||||||
141
scripts/cipp-add-claude-app-template.ps1
Normal file
141
scripts/cipp-add-claude-app-template.ps1
Normal 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 ""
|
||||||
640
scripts/claude-msp-combined-manifest.json
Normal file
640
scripts/claude-msp-combined-manifest.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
188
scripts/claude-msp-onboard-tenant.ps1
Normal file
188
scripts/claude-msp-onboard-tenant.ps1
Normal 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."
|
||||||
93
scripts/claude-msp-update-permissions.ps1
Normal file
93
scripts/claude-msp-update-permissions.ps1
Normal 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
|
||||||
68
scripts/datto-smartbadge-check.ps1
Normal file
68
scripts/datto-smartbadge-check.ps1
Normal 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 $_ }
|
||||||
100
scripts/datto-smartbadge-fix.reg
Normal file
100
scripts/datto-smartbadge-fix.reg
Normal 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
|
||||||
27
scripts/df-check-desktop-creds.ps1
Normal file
27
scripts/df-check-desktop-creds.ps1
Normal 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
|
||||||
62
scripts/df-check-jlohr-lockout.ps1
Normal file
62
scripts/df-check-jlohr-lockout.ps1
Normal 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
27
scripts/df-test-winrm.ps1
Normal 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
290
scripts/migration-pack.sh
Normal 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 "$@"
|
||||||
296
scripts/migration-restore.sh
Normal file
296
scripts/migration-restore.sh
Normal 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
5375
scripts/perms.json
Normal file
File diff suppressed because it is too large
Load Diff
639
scripts/sam.json
Normal file
639
scripts/sam.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
158
session-logs/2026-02-25-session.md
Normal file
158
session-logs/2026-02-25-session.md
Normal 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
50
temp/_debug_graph.py
Normal 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
84
temp/_debug_graph2.py
Normal 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
47
temp/_debug_graph3.py
Normal 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
47
temp/_debug_graph4.py
Normal 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} ===")
|
||||||
13
temp/acg-msp-access-8f72339997e5.json
Normal file
13
temp/acg-msp-access-8f72339997e5.json
Normal 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"
|
||||||
|
}
|
||||||
396
temp/bardach_compare_temp_main.py
Normal file
396
temp/bardach_compare_temp_main.py
Normal 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()
|
||||||
1
temp/bardach_contacts.json
Normal file
1
temp/bardach_contacts.json
Normal file
File diff suppressed because one or more lines are too long
134
temp/bardach_contacts_check.py
Normal file
134
temp/bardach_contacts_check.py
Normal 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}")
|
||||||
36
temp/bardach_contacts_summary_email.md
Normal file
36
temp/bardach_contacts_summary_email.md
Normal 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
|
||||||
284
temp/bardach_create_missing_contacts.py
Normal file
284
temp/bardach_create_missing_contacts.py
Normal 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()
|
||||||
5
temp/bardach_dedup_delete_progress.json
Normal file
5
temp/bardach_dedup_delete_progress.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"completed_index": 4431,
|
||||||
|
"successes": 4431,
|
||||||
|
"failures": []
|
||||||
|
}
|
||||||
6
temp/bardach_dedup_delete_results.json
Normal file
6
temp/bardach_dedup_delete_results.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"total_attempted": 4431,
|
||||||
|
"successes": 4431,
|
||||||
|
"failures": 0,
|
||||||
|
"failure_details": []
|
||||||
|
}
|
||||||
840
temp/bardach_dedup_merge_results.json
Normal file
840
temp/bardach_dedup_merge_results.json
Normal 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": "fry’s 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.\"}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
31
temp/bardach_dedup_merge_results_retry.json
Normal file
31
temp/bardach_dedup_merge_results_retry.json
Normal 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
45925
temp/bardach_dedup_plan.json
Normal file
File diff suppressed because it is too large
Load Diff
122
temp/bardach_dedup_step1_backup.py
Normal file
122
temp/bardach_dedup_step1_backup.py
Normal 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()
|
||||||
275
temp/bardach_dedup_step2_plan.py
Normal file
275
temp/bardach_dedup_step2_plan.py
Normal 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
Reference in New Issue
Block a user