diff --git a/packages/api/src/routes/hometitle.routes.ts b/packages/api/src/routes/hometitle.routes.ts new file mode 100644 index 0000000..10da044 --- /dev/null +++ b/packages/api/src/routes/hometitle.routes.ts @@ -0,0 +1,463 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { prisma, SubscriptionTier } from '@shieldai/db'; +import { detectChanges, shouldTriggerAlert } from '@shieldai/hometitle'; +import { homeTitleAlertPipeline } from '@shieldai/hometitle'; +import { AuthRequest } from '../auth.middleware'; + +const HOMETITLE_PROPERTY_LIMITS: Record = { + free: 0, + basic: 0, + plus: 3, + premium: 5, +}; + +const HOMETITLE_TIER_ORDER: Record = { + free: 0, + basic: 1, + plus: 2, + premium: 3, +}; + +/** + * Middleware: require Premium tier for home title features. + * Returns subscriptionId if user is Premium, otherwise replies with 402. + */ +async function requirePremiumTier( + request: FastifyRequest, + reply: FastifyReply, +): Promise { + const authReq = request as AuthRequest; + const userId = authReq.user?.id; + + if (!userId) { + await reply.code(401).send({ error: 'User not authenticated' }); + return null; + } + + const subscription = await prisma.subscription.findFirst({ + where: { userId, status: 'active' }, + select: { id: true, tier: true }, + }); + + if (!subscription) { + await reply.code(402).send({ + error: 'Subscription required', + message: 'A home title monitoring subscription is required', + }); + return null; + } + + if (subscription.tier !== 'premium') { + const currentTier = HOMETITLE_TIER_ORDER[subscription.tier] ?? 0; + const nextTier = 'plus'; + + await reply.code(402).send({ + error: 'Premium tier required', + message: `Home title monitoring requires a Premium subscription. You are currently on ${subscription.tier}.`, + currentTier: subscription.tier, + upgradeTo: nextTier, + }); + return null; + } + + return subscription.id; +} + +/** + * Check property limit for the user's tier. + * Returns true if under limit, replies 400 if exceeded. + */ +async function checkPropertyLimit( + reply: FastifyReply, + subscriptionId: string, +): Promise { + const subscription = await prisma.subscription.findUnique({ + where: { id: subscriptionId }, + select: { tier: true }, + }); + + if (!subscription) { + return false; + } + + const limit = HOMETITLE_PROPERTY_LIMITS[subscription.tier] ?? 0; + const count = await prisma.watchlistItem.count({ + where: { subscriptionId, type: 'address' }, + }); + + if (count >= limit) { + await reply.code(400).send({ + error: 'Property limit reached', + message: `You have reached the maximum of ${limit} properties for your ${subscription.tier} tier.`, + currentCount: count, + limit, + upgradeTo: 'premium', + }); + return false; + } + + return true; +} + +export async function hometitleRoutes(fastify: FastifyInstance) { + // GET /hometitle/properties - List monitored properties with status + fastify.get('/properties', async (request: FastifyRequest, reply: FastifyReply) => { + const subscriptionId = await requirePremiumTier(request, reply); + if (!subscriptionId) return; + + try { + const watchlistItems = await prisma.watchlistItem.findMany({ + where: { subscriptionId, type: 'address', isActive: true }, + orderBy: { createdAt: 'desc' }, + }); + + const itemIds = watchlistItems.map((item) => item.id); + + // Batch query: fetch all recent alerts in one query + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + const allRecentAlerts = await prisma.alert.findMany({ + where: { + subscriptionId, + watchlistItemId: { in: itemIds }, + createdAt: { gte: sevenDaysAgo }, + }, + orderBy: { createdAt: 'desc' }, + }); + + // Group alerts by watchlistItemId + const alertsByItem = new Map(); + for (const alert of allRecentAlerts) { + const arr = alertsByItem.get(alert.watchlistItemId) || []; + arr.push(alert); + alertsByItem.set(alert.watchlistItemId, arr); + } + + const properties = watchlistItems.map((item) => { + const recentAlerts = (alertsByItem.get(item.id) || []).slice(0, 3); + const hasRecentAlerts = recentAlerts.length > 0; + const latestAlert = recentAlerts[0]; + + let status: 'monitored' | 'alert' | 'error' = 'monitored'; + if (hasRecentAlerts && latestAlert) { + status = latestAlert.severity === 'CRITICAL' ? 'alert' : 'monitored'; + } + + return { + id: item.id, + address: item.value, + status, + lastScan: null, + lastChange: latestAlert ? latestAlert.createdAt : null, + alertCount: recentAlerts.length, + addedAt: item.createdAt, + }; + }); + + return reply.send({ properties }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to list properties'; + return reply.code(500).send({ error: message }); + } + }); + + // GET /hometitle/properties/stats - Dashboard widget stats (available to all active subscriptions) + fastify.get('/properties/stats', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as AuthRequest; + const userId = authReq.user?.id; + + if (!userId) { + return reply.code(401).send({ error: 'User not authenticated' }); + } + + const subscription = await prisma.subscription.findFirst({ + where: { userId, status: 'active' }, + select: { id: true, tier: true }, + }); + + if (!subscription) { + return reply.code(404).send({ error: 'Active subscription not found' }); + } + + try { + const propertyCount = await prisma.watchlistItem.count({ + where: { subscriptionId: subscription.id, type: 'address', isActive: true }, + }); + + const limit = HOMETITLE_PROPERTY_LIMITS[subscription.tier] ?? 0; + const isPremium = subscription.tier === 'premium'; + + // Count recent alerts (last 7 days) + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + const recentAlertCount = await prisma.alert.count({ + where: { + subscriptionId: subscription.id, + createdAt: { gte: sevenDaysAgo }, + }, + }); + + // Count critical alerts + const criticalAlertCount = await prisma.alert.count({ + where: { + subscriptionId: subscription.id, + severity: 'CRITICAL', + createdAt: { gte: sevenDaysAgo }, + }, + }); + + return reply.send({ + monitoredProperties: propertyCount, + tier: subscription.tier, + isPremium, + propertyLimit: limit, + canAddMore: propertyCount < limit, + recentAlertCount, + criticalAlertCount, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to fetch stats'; + return reply.code(500).send({ error: message }); + } + }); + + // POST /hometitle/properties - Add property to monitor + fastify.post('/properties', async (request: FastifyRequest, reply: FastifyReply) => { + const subscriptionId = await requirePremiumTier(request, reply); + if (!subscriptionId) return; + + const body = request.body as { address: string }; + + if (!body.address || typeof body.address !== 'string') { + return reply.code(400).send({ + error: 'Invalid request', + message: 'Address is required', + }); + } + + // Validate address format (permissive: requires number + space + text) + const addressPattern = /^\d+[\s,].+$/i; + if (!addressPattern.test(body.address.trim())) { + return reply.code(400).send({ + error: 'Invalid address', + message: 'Please enter a valid street address (e.g., 123 Main Street)', + }); + } + + const limitReached = await checkPropertyLimit(reply, subscriptionId); + if (!limitReached) return; + + try { + // Check for duplicate + const existing = await prisma.watchlistItem.findFirst({ + where: { subscriptionId, type: 'address', value: body.address.trim() }, + }); + + if (existing) { + return reply.code(409).send({ + error: 'Duplicate property', + message: 'This property is already being monitored', + }); + } + + const property = await prisma.watchlistItem.create({ + data: { + subscriptionId, + type: 'address', + value: body.address.trim(), + }, + }); + + // Trigger initial scan + try { + const { homeTitleScheduler } = await import('@shieldai/hometitle'); + if (homeTitleScheduler.isRunning()) { + await homeTitleScheduler.runScan(); + } + } catch (scanError) { + console.error('[HomeTitle] Initial scan error:', scanError); + } + + return reply.code(201).send({ + property: { + id: property.id, + address: property.value, + status: 'monitored', + addedAt: property.createdAt, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to add property'; + return reply.code(422).send({ error: message }); + } + }); + + // DELETE /hometitle/properties/:id - Remove property from monitoring + fastify.delete('/properties/:id', async (request: FastifyRequest, reply: FastifyReply) => { + const subscriptionId = await requirePremiumTier(request, reply); + if (!subscriptionId) return; + + const id = (request.params as { id: string }).id; + + try { + const result = await prisma.watchlistItem.update({ + where: { id, subscriptionId }, + data: { isActive: false }, + }); + + return reply.send({ + property: { + id: result.id, + address: result.value, + status: 'removed', + }, + }); + } catch { + return reply.code(404).send({ + error: 'Property not found', + message: 'The monitored property does not exist or is not owned by this user', + }); + } + }); + + // GET /hometitle/changes - Recent property changes + fastify.get('/changes', async (request: FastifyRequest, reply: FastifyReply) => { + const subscriptionId = await requirePremiumTier(request, reply); + if (!subscriptionId) return; + + const query = request.query as { limit?: number }; + const limit = Math.min(query.limit ?? 20, 50); + + try { + const watchlistItemIds = ( + await prisma.watchlistItem.findMany({ + where: { subscriptionId, type: 'address', isActive: true }, + select: { id: true }, + }) + ).map((item) => item.id); + + const changes = await prisma.alert.findMany({ + where: { + subscriptionId, + watchlistItemId: { in: watchlistItemIds }, + }, + orderBy: { createdAt: 'desc' }, + take: limit, + include: { + watchlistItem: true, + }, + }); + + const formattedChanges = changes.map((alert) => ({ + id: alert.id, + propertyAddress: alert.watchlistItem?.value ?? 'Unknown', + type: 'property_change', + severity: alert.severity, + title: alert.title, + message: alert.message, + isRead: alert.isRead, + createdAt: alert.createdAt, + })); + + return reply.send({ changes: formattedChanges }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to fetch changes'; + return reply.code(500).send({ error: message }); + } + }); + + // GET /hometitle/alerts - Recent property alerts + fastify.get('/alerts', async (request: FastifyRequest, reply: FastifyReply) => { + const subscriptionId = await requirePremiumTier(request, reply); + if (!subscriptionId) return; + + const query = request.query as { limit?: number; severity?: string }; + const limit = Math.min(query.limit ?? 20, 50); + + try { + const watchlistItemIds = ( + await prisma.watchlistItem.findMany({ + where: { subscriptionId, type: 'address', isActive: true }, + select: { id: true }, + }) + ).map((item) => item.id); + + const whereClause: Record = { + subscriptionId, + watchlistItemId: { in: watchlistItemIds }, + }; + + if (query.severity) { + whereClause.severity = query.severity; + } + + const alerts = await prisma.alert.findMany({ + where: whereClause, + orderBy: { createdAt: 'desc' }, + take: limit, + include: { + watchlistItem: true, + }, + }); + + const formattedAlerts = alerts.map((alert) => ({ + id: alert.id, + propertyAddress: alert.watchlistItem?.value ?? 'Unknown', + severity: alert.severity, + title: alert.title, + message: alert.message, + isRead: alert.isRead, + channel: alert.channel, + createdAt: alert.createdAt, + })); + + return reply.send({ alerts: formattedAlerts }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to fetch alerts'; + return reply.code(500).send({ error: message }); + } + }); + + // PATCH /hometitle/alerts/:id/read - Mark alert as read + fastify.patch('/alerts/:id/read', async (request: FastifyRequest, reply: FastifyReply) => { + const subscriptionId = await requirePremiumTier(request, reply); + if (!subscriptionId) return; + + const id = (request.params as { id: string }).id; + + try { + const alert = await prisma.alert.update({ + where: { id, subscriptionId }, + data: { isRead: true, readAt: new Date() }, + }); + + return reply.send({ alert: { id: alert.id, isRead: alert.isRead } }); + } catch { + return reply.code(404).send({ error: 'Alert not found' }); + } + }); + + // POST /hometitle/scan - Trigger on-demand scan for all monitored properties + fastify.post('/scan', async (request: FastifyRequest, reply: FastifyReply) => { + const subscriptionId = await requirePremiumTier(request, reply); + if (!subscriptionId) return; + + try { + const { homeTitleScheduler } = await import('@shieldai/hometitle'); + const result = await homeTitleScheduler.runScan(); + + return reply.send({ + scan: { + scanId: result.scanId, + propertiesScanned: result.propertiesScanned, + changesDetected: result.changesDetected, + alertsCreated: result.alertsCreated, + notificationsSent: result.notificationsSent, + startedAt: result.startedAt, + completedAt: result.completedAt, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Scan failed'; + return reply.code(500).send({ error: message }); + } + }); +} diff --git a/packages/api/src/routes/index.ts b/packages/api/src/routes/index.ts index b1d1317..382b927 100644 --- a/packages/api/src/routes/index.ts +++ b/packages/api/src/routes/index.ts @@ -4,6 +4,10 @@ import { voiceprintRoutes } from './voiceprint.routes'; import { spamshieldRoutes } from './spamshield.routes'; import { darkwatchRoutes } from './darkwatch.routes'; import { reportRoutes } from './report.routes'; +import { subscriptionRoutes } from './subscription.routes'; +import { deviceRoutes } from './device.routes'; +import { notificationRoutes } from './notifications.routes'; +import { hometitleRoutes } from './hometitle.routes'; export async function routes(fastify: FastifyInstance) { // Authenticated routes group @@ -148,4 +152,31 @@ export async function routes(fastify: FastifyInstance) { }, { prefix: '/reports' } ); + +// Subscription routes + fastify.register( + async (subscriptionRouter) => { + await subscriptionRoutes(subscriptionRouter); + }, + { prefix: '/billing' } + ); + + // Device routes + fastify.register( + async (deviceRouter) => { + await deviceRoutes(deviceRouter); + }, + { prefix: '/api/v1' } + ); + + // Home Title service routes + fastify.register( + async (hometitleRouter) => { + hometitleRouter.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => { + await fastify.requireAuth(request as AuthRequest); + }); + await hometitleRoutes(hometitleRouter); + }, + { prefix: '/hometitle' } + ); } diff --git a/packages/web/src/index.css b/packages/web/src/index.css index 8c21cec..53a91af 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -959,3 +959,470 @@ img { font-size: 2rem; } } + +/* Dashboard Page */ +.dashboard-page { + min-height: 100vh; + padding: 80px 0; + background: var(--bg-primary); +} + +.dashboard-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 48px; + gap: 24px; + flex-wrap: wrap; +} + +.dashboard-header h1 { + font-size: 2.5rem; + font-weight: 800; + margin-bottom: 8px; +} + +.dashboard-subtitle { + color: var(--text-secondary); + font-size: 1.063rem; +} + +.dashboard-actions { + display: flex; + align-items: center; + gap: 16px; +} + +.dashboard-stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 20px; + margin-bottom: 48px; +} + +.dashboard-stat-card { + display: flex; + align-items: center; + gap: 16px; + padding: 24px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius); +} + +.dashboard-stat-icon { + width: 48px; + height: 48px; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + flex-shrink: 0; +} + +.dashboard-stat-info { + display: flex; + flex-direction: column; +} + +.dashboard-stat-value { + font-size: 1.75rem; + font-weight: 800; + color: var(--text-primary); +} + +.dashboard-stat-label { + font-size: 0.813rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 500; +} + +.dashboard-grid { + display: flex; + flex-direction: column; + gap: 32px; +} + +.dashboard-section { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 32px; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +.section-header h2 { + font-size: 1.25rem; + font-weight: 700; +} + +.btn-link { + background: none; + border: none; + color: var(--accent-primary); + font-size: 0.938rem; + font-weight: 500; + cursor: pointer; + padding: 4px 8px; + border-radius: var(--radius-sm); + transition: background 0.2s; +} + +.btn-link:hover { + background: rgba(59, 130, 246, 0.1); +} + +.btn-primary { + padding: 10px 20px; + border-radius: var(--radius-sm); + border: none; + background: var(--accent-gradient); + color: white; + font-size: 0.938rem; + font-weight: 600; + font-family: var(--font-sans); + cursor: pointer; + transition: opacity 0.2s; +} + +.btn-primary:hover { + opacity: 0.9; +} + +.tier-badge { + display: inline-block; + padding: 4px 12px; + border-radius: 999px; + font-size: 0.813rem; + font-weight: 600; +} + +.tier-free { + background: rgba(148, 163, 184, 0.1); + color: var(--text-muted); +} + +.tier-basic { + background: rgba(59, 130, 246, 0.1); + color: var(--accent-primary); +} + +.tier-plus { + background: rgba(168, 85, 247, 0.1); + color: #a855f7; +} + +.tier-premium { + background: rgba(245, 158, 11, 0.1); + color: #f59e0b; +} + +.add-property-form { + margin-bottom: 24px; +} + +.add-property-form h3 { + font-size: 1rem; + font-weight: 600; + margin-bottom: 12px; + color: var(--text-secondary); +} + +.add-property-form .form-row { + display: flex; + gap: 12px; +} + +.add-property-form input[type="text"] { + flex: 1; + padding: 12px 16px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-light); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 0.938rem; + font-family: var(--font-sans); + outline: none; + transition: border-color 0.2s; +} + +.add-property-form input[type="text"]:focus { + border-color: var(--accent-primary); +} + +.add-property-form button { + padding: 12px 20px; + border-radius: var(--radius-sm); + border: none; + background: var(--accent-gradient); + color: white; + font-size: 0.938rem; + font-weight: 600; + font-family: var(--font-sans); + cursor: pointer; + transition: opacity 0.2s; + white-space: nowrap; +} + +.add-property-form button:hover:not(:disabled) { + opacity: 0.9; +} + +.add-property-form button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.limit-warning { + margin-top: 8px; + font-size: 0.813rem; + color: var(--text-muted); +} + +.properties-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 16px; + margin-top: 24px; +} + +.property-card { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 20px; + transition: border-color 0.2s; +} + +.property-card:hover { + border-color: var(--border-light); +} + +.property-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 12px; +} + +.property-address { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + word-break: break-word; +} + +.badge { + display: inline-block; + padding: 3px 10px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + flex-shrink: 0; +} + +.badge-ok { + background: rgba(34, 197, 94, 0.1); + color: var(--success); +} + +.badge-alert { + background: rgba(249, 115, 22, 0.1); + color: #f97316; +} + +.badge-error { + background: rgba(239, 68, 68, 0.1); + color: var(--error); +} + +.property-card-body { + margin-bottom: 12px; +} + +.property-meta { + display: flex; + gap: 16px; + font-size: 0.813rem; + color: var(--text-muted); +} + +.property-card-footer { + display: flex; + justify-content: flex-end; +} + +.btn-icon { + background: none; + border: 1px solid var(--border-light); + color: var(--text-muted); + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + font-size: 1.25rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: border-color 0.2s, color 0.2s; +} + +.btn-icon:hover { + border-color: var(--error); + color: var(--error); +} + +.alerts-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 24px; +} + +.alert-item { + display: flex; + gap: 12px; + padding: 16px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + transition: border-color 0.2s; +} + +.alert-unread { + border-color: var(--accent-primary); + background: rgba(59, 130, 246, 0.03); +} + +.alert-read { + opacity: 0.6; +} + +.alert-severity { + padding: 4px 10px; + border-radius: 6px; + font-size: 0.688rem; + font-weight: 700; + color: white; + text-transform: uppercase; + letter-spacing: 0.05em; + flex-shrink: 0; + width: 64px; + text-align: center; +} + +.alert-content { + flex: 1; + min-width: 0; +} + +.alert-title { + font-size: 0.938rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; +} + +.alert-property { + font-size: 0.813rem; + color: var(--text-muted); + margin-bottom: 4px; + word-break: break-word; +} + +.alert-message { + font-size: 0.813rem; + color: var(--text-secondary); + margin-bottom: 8px; + line-height: 1.5; +} + +.alert-time { + font-size: 0.75rem; + color: var(--text-muted); +} + +.btn-small { + padding: 6px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-light); + background: none; + color: var(--text-secondary); + font-size: 0.813rem; + font-weight: 500; + font-family: var(--font-sans); + cursor: pointer; + flex-shrink: 0; + transition: border-color 0.2s, color 0.2s; +} + +.btn-small:hover { + border-color: var(--accent-primary); + color: var(--accent-primary); +} + +.error-banner { + text-align: center; + padding: 64px 24px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius); +} + +.error-banner h2 { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 12px; + color: var(--text-primary); +} + +.error-banner p { + color: var(--text-secondary); + font-size: 1rem; + max-width: 400px; + margin: 0 auto; +} + +/* Dashboard responsive */ +@media (max-width: 1024px) { + .dashboard-stats-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + .dashboard-header { + flex-direction: column; + } + + .dashboard-header h1 { + font-size: 2rem; + } + + .dashboard-stats-grid { + grid-template-columns: 1fr; + } + + .properties-grid { + grid-template-columns: 1fr; + } + + .add-property-form .form-row { + flex-direction: column; + } + + .dashboard-actions { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index 7c44664..a3ad2df 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -5,6 +5,7 @@ import LandingPage from './pages/LandingPage'; import AdsLandingPage from './pages/AdsLandingPage'; import BlogPage from './pages/BlogPage'; import BlogPostPage from './pages/BlogPostPage'; +import DashboardPage from './pages/DashboardPage'; import './index.css'; const root = document.getElementById('root'); @@ -16,5 +17,6 @@ render(() => ( + ), root); diff --git a/packages/web/src/pages/DashboardPage.tsx b/packages/web/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..d17a73f --- /dev/null +++ b/packages/web/src/pages/DashboardPage.tsx @@ -0,0 +1,458 @@ +import { Component, JSX, onMount, createSignal } from 'solid-js'; +import { ComponentProps } from 'solid-js'; + +interface StatCardProps { + title: string; + value: string | number; + icon: JSX.Element; + color?: string; +} + +const StatCard: Component = (props) => { + return ( +
+
+ {props.icon} +
+
+
{props.value}
+
{props.title}
+
+
+ ); +}; + +interface PropertyCardProps { + id: string; + address: string; + status: 'monitored' | 'alert' | 'error'; + lastChange: string | null; + alertCount: number; + onRemove: (id: string) => void; +} + +const PropertyCard: Component = (props) => { + const statusBadge = () => { + switch (props.status) { + case 'alert': + return Alert; + case 'error': + return Error; + default: + return Monitored; + } + }; + + const formatDate = (dateStr: string | null) => { + if (!dateStr) return 'Never'; + return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + }; + + return ( +
+
+
{props.address}
+ {statusBadge()} +
+
+
+ Changes: {props.alertCount} + Last change: {formatDate(props.lastChange)} +
+
+ +
+ ); +}; + +interface AlertItem { + id: string; + propertyAddress: string; + severity: string; + title: string; + message: string; + isRead: boolean; + createdAt: string; +} + +interface AlertsListProps { + alerts: AlertItem[]; + onMarkRead: (id: string) => void; +} + +const AlertsList: Component = (props) => { + const severityColor = (severity: string) => { + switch (severity) { + case 'CRITICAL': + return 'var(--error)'; + case 'HIGH': + return '#f97316'; + case 'MEDIUM': + return '#eab308'; + default: + return 'var(--accent-primary)'; + } + }; + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + }; + + if (props.alerts.length === 0) { + return ( +
+

No alerts

+

All monitored properties are secure.

+
+ ); + } + + return ( +
+ {props.alerts.map((alert) => ( +
+
+ {alert.severity} +
+
+
{alert.title}
+
{alert.propertyAddress}
+
{alert.message}
+
{formatDate(alert.createdAt)}
+
+ {!alert.isRead && ( + + )} +
+ ))} +
+ ); +}; + +interface AddPropertyFormProps { + onAdd: (address: string) => Promise; + canAddMore: boolean; + limit: number; + monitoredCount: number; +} + +const AddPropertyForm: Component = (props) => { + const [address, setAddress] = createSignal(''); + const [loading, setLoading] = createSignal(false); + const [error, setError] = createSignal(''); + + const handleSubmit = async (e: SubmitEvent) => { + e.preventDefault(); + const addr = address().trim(); + if (!addr) { + setError('Address is required'); + return; + } + const addressPattern = /^\d+[\s,].+$/i; + if (!addressPattern.test(addr)) { + setError('Please enter a valid street address (e.g., 123 Main Street)'); + return; + } + setLoading(true); + setError(''); + try { + await props.onAdd(addr); + setAddress(''); + } catch { + setError('Failed to add property. Please try again.'); + } finally { + setLoading(false); + } + }; + + return ( +
+

Add a property to monitor

+
handleSubmit(e as unknown as SubmitEvent)}> +
+ setAddress((e.target as HTMLInputElement).value)} + disabled={loading() || !props.canAddMore} + /> + +
+
+ {error() &&
{error()}
} + {!props.canAddMore && ( +
+ Property limit reached ({props.monitoredCount}/{props.limit}). Upgrade to add more. +
+ )} +
+ ); +}; + +const DashboardPage: Component = () => { + const [stats, setStats] = createSignal<{ + monitoredProperties: number; + tier: string; + isPremium: boolean; + propertyLimit: number; + canAddMore: boolean; + recentAlertCount: number; + criticalAlertCount: number; + } | null>(null); + + const [properties, setProperties] = createSignal>([]); + + const [alerts, setAlerts] = createSignal([]); + const [loading, setLoading] = createSignal(true); + const [error, setError] = createSignal(''); + const [showAlerts, setShowAlerts] = createSignal(false); + + const fetchStats = async () => { + try { + const res = await fetch('/hometitle/properties/stats'); + if (!res.ok) { + if (res.status === 401) { + setError('Please log in to view your dashboard'); + } else if (res.status === 404) { + setError('No active subscription found'); + } else if (res.status === 402) { + const data = await res.json(); + setError(data.message || 'Premium tier required for home title monitoring'); + } + return; + } + const data = await res.json(); + setStats(data); + } catch { + setError('Failed to load dashboard stats'); + } + }; + + const fetchProperties = async () => { + try { + const res = await fetch('/hometitle/properties'); + if (!res.ok) return; + const data = await res.json(); + setProperties(data.properties || []); + } catch { + // Silently fail - properties may not be available + } + }; + + const fetchAlerts = async () => { + try { + const res = await fetch('/hometitle/alerts?limit=20'); + if (!res.ok) return; + const data = await res.json(); + setAlerts(data.alerts || []); + } catch { + // Silently fail + } + }; + + const loadData = async () => { + setLoading(true); + await Promise.all([fetchStats(), fetchProperties(), fetchAlerts()]); + setLoading(false); + }; + + onMount(() => { + loadData(); + }); + + const handleAddProperty = async (address: string) => { + try { + const res = await fetch('/hometitle/properties', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.message || 'Failed to add property'); + } + await loadData(); + } catch (err) { + throw err; + } + }; + + const handleRemoveProperty = async (id: string) => { + try { + const res = await fetch(`/hometitle/properties/${id}`, { method: 'DELETE' }); + if (!res.ok) return; + await loadData(); + } catch { + // Silently fail + } + }; + + const handleMarkAlertRead = async (id: string) => { + try { + const res = await fetch(`/hometitle/alerts/${id}/read`, { method: 'PATCH' }); + if (!res.ok) return; + setAlerts((prev) => prev.map((a) => (a.id === id ? { ...a, isRead: true } : a))); + } catch { + // Silently fail + } + }; + + const handleScan = async () => { + try { + const res = await fetch('/hometitle/scan', { method: 'POST' }); + if (!res.ok) return; + await loadData(); + } catch { + // Silently fail + } + }; + + const errorTitle = () => { + if (error().includes('Premium')) return 'Upgrade Required'; + if (error().includes('subscription')) return 'Subscription Needed'; + return 'Dashboard Unavailable'; + }; + + if (loading()) { + return ( +
+
+
+

Home Title Monitor

+

Monitor your properties for title changes and alerts

+
+
Loading dashboard...
+
+
+ ); + } + + if (error()) { + return ( +
+
+
+

Home Title Monitor

+

Monitor your properties for title changes and alerts

+
+
+

{errorTitle()}

+

{error()}

+
+
+
+ ); + } + + const s = stats() || { monitoredProperties: 0, tier: 'free', isPremium: false, propertyLimit: 0, canAddMore: false, recentAlertCount: 0, criticalAlertCount: 0 }; + + return ( +
+
+
+
+

Home Title Monitor

+

Monitor your properties for title changes and alerts

+
+
+ {s.tier} tier + {s.isPremium && ( + + )} +
+
+ +
+ 🏠} + color="rgba(59, 130, 246, 0.1)" + /> + 🔔} + color="rgba(249, 115, 22, 0.1)" + /> + ⚠} + color="rgba(239, 68, 68, 0.1)" + /> + 📋} + color="rgba(34, 197, 94, 0.1)" + /> +
+ +
+
+
+

Monitored Properties

+ +
+ + {showAlerts() ? ( + + ) : ( + <> + + {properties().length === 0 ? ( +
+

No properties monitored

+

Add your first property above to start monitoring for title changes.

+
+ ) : ( +
+ {properties().map((p) => ( + + ))} +
+ )} + + )} +
+
+
+
+ ); +}; + +export default DashboardPage;