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:
463
packages/api/src/routes/hometitle.routes.ts
Normal file
463
packages/api/src/routes/hometitle.routes.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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' }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
458
packages/web/src/pages/DashboardPage.tsx
Normal file
458
packages/web/src/pages/DashboardPage.tsx
Normal 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">
|
||||
×
|
||||
</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>🏠</span>}
|
||||
color="rgba(59, 130, 246, 0.1)"
|
||||
/>
|
||||
<StatCard
|
||||
title="Recent Alerts"
|
||||
value={s.recentAlertCount}
|
||||
icon={<span>🔔</span>}
|
||||
color="rgba(249, 115, 22, 0.1)"
|
||||
/>
|
||||
<StatCard
|
||||
title="Critical Alerts"
|
||||
value={s.criticalAlertCount}
|
||||
icon={<span>⚠</span>}
|
||||
color="rgba(239, 68, 68, 0.1)"
|
||||
/>
|
||||
<StatCard
|
||||
title="Properties Remaining"
|
||||
value={s.canAddMore ? s.propertyLimit - s.monitoredProperties : 0}
|
||||
icon={<span>📋</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;
|
||||
Reference in New Issue
Block a user