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 { spamshieldRoutes } from './spamshield.routes';
|
||||||
import { darkwatchRoutes } from './darkwatch.routes';
|
import { darkwatchRoutes } from './darkwatch.routes';
|
||||||
import { reportRoutes } from './report.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) {
|
export async function routes(fastify: FastifyInstance) {
|
||||||
// Authenticated routes group
|
// Authenticated routes group
|
||||||
@@ -148,4 +152,31 @@ export async function routes(fastify: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
{ prefix: '/reports' }
|
{ 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;
|
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 AdsLandingPage from './pages/AdsLandingPage';
|
||||||
import BlogPage from './pages/BlogPage';
|
import BlogPage from './pages/BlogPage';
|
||||||
import BlogPostPage from './pages/BlogPostPage';
|
import BlogPostPage from './pages/BlogPostPage';
|
||||||
|
import DashboardPage from './pages/DashboardPage';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
const root = document.getElementById('root');
|
const root = document.getElementById('root');
|
||||||
@@ -16,5 +17,6 @@ render(() => (
|
|||||||
<Route path="/ads" component={AdsLandingPage} />
|
<Route path="/ads" component={AdsLandingPage} />
|
||||||
<Route path="/blog" component={BlogPage} />
|
<Route path="/blog" component={BlogPage} />
|
||||||
<Route path="/blog/:slug" component={BlogPostPage} />
|
<Route path="/blog/:slug" component={BlogPostPage} />
|
||||||
|
<Route path="/dashboard" component={DashboardPage} />
|
||||||
</Router>
|
</Router>
|
||||||
), root);
|
), 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