FRE-5353 Home Title: Dashboard widget + tier gating

- Add hometitle API routes: properties CRUD, changes, alerts, scan
- Implement Premium tier gating with 402 responses for non-Premium users
- Enforce max 5 properties per Premium subscription (0 for Free/Basic, 3 for Plus)
- Build DashboardPage with PropertyCard, AddPropertyForm, AlertsList components
- Add dashboard CSS styles with responsive design
- Register hometitle routes under /hometitle prefix with auth middleware

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-05-14 22:51:35 -04:00
parent 08fedf55e6
commit 7ed1a340b9
5 changed files with 1421 additions and 0 deletions

View File

@@ -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<string, number> = {
free: 0,
basic: 0,
plus: 3,
premium: 5,
};
const HOMETITLE_TIER_ORDER: Record<string, number> = {
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<string | null> {
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<boolean> {
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<string, typeof allRecentAlerts>();
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<string, unknown> = {
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 });
}
});
}

View File

@@ -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' }
);
}

View File

@@ -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;
}
}

View File

@@ -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(() => (
<Route path="/ads" component={AdsLandingPage} />
<Route path="/blog" component={BlogPage} />
<Route path="/blog/:slug" component={BlogPostPage} />
<Route path="/dashboard" component={DashboardPage} />
</Router>
), root);

View File

@@ -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<StatCardProps> = (props) => {
return (
<div class="dashboard-stat-card">
<div class="dashboard-stat-icon" style={{ background: props.color || 'rgba(59, 130, 246, 0.1)', color: props.color || 'var(--accent-primary)' }}>
{props.icon}
</div>
<div class="dashboard-stat-info">
<div class="dashboard-stat-value">{props.value}</div>
<div class="dashboard-stat-label">{props.title}</div>
</div>
</div>
);
};
interface PropertyCardProps {
id: string;
address: string;
status: 'monitored' | 'alert' | 'error';
lastChange: string | null;
alertCount: number;
onRemove: (id: string) => void;
}
const PropertyCard: Component<PropertyCardProps> = (props) => {
const statusBadge = () => {
switch (props.status) {
case 'alert':
return <span class="badge badge-alert">Alert</span>;
case 'error':
return <span class="badge badge-error">Error</span>;
default:
return <span class="badge badge-ok">Monitored</span>;
}
};
const formatDate = (dateStr: string | null) => {
if (!dateStr) return 'Never';
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
};
return (
<div class="property-card">
<div class="property-card-header">
<div class="property-address">{props.address}</div>
{statusBadge()}
</div>
<div class="property-card-body">
<div class="property-meta">
<span>Changes: {props.alertCount}</span>
<span>Last change: {formatDate(props.lastChange)}</span>
</div>
</div>
<div class="property-card-footer">
<button class="btn-icon" onclick={() => props.onRemove(props.id)} title="Remove property">
&times;
</button>
</div>
</div>
);
};
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<AlertsListProps> = (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 (
<div class="empty-state">
<h2>No alerts</h2>
<p>All monitored properties are secure.</p>
</div>
);
}
return (
<div class="alerts-list">
{props.alerts.map((alert) => (
<div class={`alert-item ${alert.isRead ? 'alert-read' : 'alert-unread'}`}>
<div class="alert-severity" style={{ background: severityColor(alert.severity) }}>
{alert.severity}
</div>
<div class="alert-content">
<div class="alert-title">{alert.title}</div>
<div class="alert-property">{alert.propertyAddress}</div>
<div class="alert-message">{alert.message}</div>
<div class="alert-time">{formatDate(alert.createdAt)}</div>
</div>
{!alert.isRead && (
<button class="btn-small" onclick={() => props.onMarkRead(alert.id)}>
Mark read
</button>
)}
</div>
))}
</div>
);
};
interface AddPropertyFormProps {
onAdd: (address: string) => Promise<void>;
canAddMore: boolean;
limit: number;
monitoredCount: number;
}
const AddPropertyForm: Component<AddPropertyFormProps> = (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 (
<div class="add-property-form">
<h3>Add a property to monitor</h3>
<form onSubmit={(e) => handleSubmit(e as unknown as SubmitEvent)}>
<div class="form-row">
<input
type="text"
placeholder="Enter street address"
value={address()}
onInput={(e) => setAddress((e.target as HTMLInputElement).value)}
disabled={loading() || !props.canAddMore}
/>
<button type="submit" disabled={loading() || !props.canAddMore}>
{loading() ? 'Adding...' : 'Add Property'}
</button>
</div>
</form>
{error() && <div class="form-error">{error()}</div>}
{!props.canAddMore && (
<div class="limit-warning">
Property limit reached ({props.monitoredCount}/{props.limit}). Upgrade to add more.
</div>
)}
</div>
);
};
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<Array<{
id: string;
address: string;
status: 'monitored' | 'alert' | 'error';
lastScan: string | null;
lastChange: string | null;
alertCount: number;
addedAt: string;
}>>([]);
const [alerts, setAlerts] = createSignal<AlertItem[]>([]);
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 (
<main class="dashboard-page">
<div class="container">
<div class="dashboard-header">
<h1>Home Title Monitor</h1>
<p class="dashboard-subtitle">Monitor your properties for title changes and alerts</p>
</div>
<div class="loading">Loading dashboard...</div>
</div>
</main>
);
}
if (error()) {
return (
<main class="dashboard-page">
<div class="container">
<div class="dashboard-header">
<h1>Home Title Monitor</h1>
<p class="dashboard-subtitle">Monitor your properties for title changes and alerts</p>
</div>
<div class="error-banner">
<h2>{errorTitle()}</h2>
<p>{error()}</p>
</div>
</div>
</main>
);
}
const s = stats() || { monitoredProperties: 0, tier: 'free', isPremium: false, propertyLimit: 0, canAddMore: false, recentAlertCount: 0, criticalAlertCount: 0 };
return (
<main class="dashboard-page">
<div class="container">
<div class="dashboard-header">
<div>
<h1>Home Title Monitor</h1>
<p class="dashboard-subtitle">Monitor your properties for title changes and alerts</p>
</div>
<div class="dashboard-actions">
<span class="tier-badge tier-{s.tier}">{s.tier} tier</span>
{s.isPremium && (
<button class="btn-primary" onclick={handleScan}>
Scan Now
</button>
)}
</div>
</div>
<div class="dashboard-stats-grid">
<StatCard
title="Monitored Properties"
value={s.monitoredProperties}
icon={<span>&#x1F3E0;</span>}
color="rgba(59, 130, 246, 0.1)"
/>
<StatCard
title="Recent Alerts"
value={s.recentAlertCount}
icon={<span>&#x1F514;</span>}
color="rgba(249, 115, 22, 0.1)"
/>
<StatCard
title="Critical Alerts"
value={s.criticalAlertCount}
icon={<span>&#x26A0;</span>}
color="rgba(239, 68, 68, 0.1)"
/>
<StatCard
title="Properties Remaining"
value={s.canAddMore ? s.propertyLimit - s.monitoredProperties : 0}
icon={<span>&#x1F4CB;</span>}
color="rgba(34, 197, 94, 0.1)"
/>
</div>
<div class="dashboard-grid">
<div class="dashboard-section">
<div class="section-header">
<h2>Monitored Properties</h2>
<button class="btn-link" onclick={() => setShowAlerts(!showAlerts())}>
{showAlerts() ? 'Show Properties' : 'View Alerts'}
</button>
</div>
{showAlerts() ? (
<AlertsList alerts={alerts()} onMarkRead={handleMarkAlertRead} />
) : (
<>
<AddPropertyForm
onAdd={handleAddProperty}
canAddMore={s.canAddMore}
limit={s.propertyLimit}
monitoredCount={s.monitoredProperties}
/>
{properties().length === 0 ? (
<div class="empty-state">
<h2>No properties monitored</h2>
<p>Add your first property above to start monitoring for title changes.</p>
</div>
) : (
<div class="properties-grid">
{properties().map((p) => (
<PropertyCard
id={p.id}
address={p.address}
status={p.status}
lastChange={p.lastChange}
alertCount={p.alertCount}
onRemove={handleRemoveProperty}
/>
))}
</div>
)}
</>
)}
</div>
</div>
</div>
</main>
);
};
export default DashboardPage;