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:
@@ -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