v2 Notification Manager

Added support for Changing Event-Types across Notification Categories
This commit is contained in:
ovpay 2025-12-23 12:55:09 +00:00
parent c460f20e46
commit 01c5eb3680

485
v2.html Normal file
View File

@ -0,0 +1,485 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Notification Manager</title>
<!-- React & ReactDOM -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<!-- Babel for JSX -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<style>
/* Custom Scrollbar for cleaner look */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.toggle-checkbox:checked {
right: 0;
border-color: #68D391;
}
.toggle-checkbox:checked + .toggle-label {
background-color: #68D391;
}
/* Smooth height transition for accordion */
.accordion-content {
transition: max-height 0.3s ease-in-out, opacity 0.3s ease-in-out;
max-height: 0;
opacity: 0;
overflow: hidden;
}
.accordion-content.expanded {
max-height: 1000px; /* Arbitrary large height */
opacity: 1;
}
</style>
</head>
<body class="bg-slate-50 text-slate-800 font-sans antialiased">
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useMemo } = React;
// --- Icons ---
const Icon = ({ name, size = 18, className }) => {
const LucideIcon = window.lucide && window.lucide.icons ? window.lucide.icons[name] : null;
if (!LucideIcon) return null;
return (
<i data-lucide={name} className={className} style={{ width: size, height: size, display: 'inline-block' }}></i>
);
};
// --- Mock Data ---
const INITIAL_TOKENS = [
{ id: 't1', name: 'Personal OV-chipkaart (**** 9021)', type: 'Personal' },
{ id: 't2', name: 'NS Business Card (**** 3321)', type: 'Business' },
{ id: 't3', name: 'Anonymous Travel Card (**** 1100)', type: 'Anonymous' }
];
const INITIAL_DATA_TEMPLATE = [
{
id: 'cat_trips',
name: 'My Trips',
description: 'Notifications related to your travel history and active journeys.',
enabled: true,
channels: { app: true, email: false },
events: [
{ id: 'evt_in', name: 'Tap In', description: 'When you start a journey', enabled: true, channels: { app: true, email: false } },
{ id: 'evt_out', name: 'Tap Out', description: 'When you end a journey', enabled: true, channels: { app: true, email: false } },
// SHARED EVENT TYPE: evt_miss_out
{ id: 'evt_miss_out', name: 'Missing Tap Out', description: 'Alert when you forget to check out', enabled: true, channels: { app: true, email: true } },
{ id: 'evt_recalc', name: 'Trip Recalculated', description: 'Price corrections', enabled: true, channels: { app: true, email: true } },
]
},
{
id: 'cat_service',
name: 'My Service Messages',
description: 'Bundled events regarding service and account status.',
enabled: true,
channels: { app: true, email: true },
events: [
// SHARED EVENT TYPE: evt_miss_out (Same ID as in My Trips)
{ id: 'evt_miss_out', name: 'Missing Tap Out', description: 'Alert when you forget to check out', enabled: true, channels: { app: true, email: true } },
{ id: 'evt_refund', name: 'Refund Processed', description: 'Updates on your refund requests', enabled: true, channels: { app: true, email: true } },
]
},
{
id: 'cat_products',
name: 'My Products',
description: 'Updates about your subscriptions and card validity.',
enabled: false,
channels: { app: true, email: true },
events: [
{ id: 'evt_sub_renew', name: 'Subscription Renewal', description: 'Monthly renewal alerts', enabled: true, channels: { app: true, email: true } },
{ id: 'evt_card_exp', name: 'Card Expiry', description: 'Warning before card expires', enabled: true, channels: { app: false, email: true } },
]
},
{
id: 'cat_disruptions',
name: 'Disruptions',
description: 'Real-time alerts about delays on your favorite routes.',
enabled: true,
channels: { app: true, email: false },
events: [
{ id: 'evt_delay', name: 'Delays > 15min', description: 'Significant delays', enabled: true, channels: { app: true, email: false } },
{ id: 'evt_cancelled', name: 'Train Cancelled', description: 'Cancellations on route', enabled: true, channels: { app: true, email: false } },
{ id: 'evt_maintenance', name: 'Planned Maintenance', description: 'Future work alerts', enabled: false, channels: { app: false, email: true } },
]
}
];
// Helper to deep copy mock data so state is independent per token
const generateMockState = () => {
const state = {};
INITIAL_TOKENS.forEach(t => {
state[t.id] = JSON.parse(JSON.stringify(INITIAL_DATA_TEMPLATE));
});
return state;
};
// --- Components ---
const ToggleSwitch = ({ checked, onChange, disabled }) => (
<div className={`relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}>
<input
type="checkbox"
name="toggle"
id={Math.random().toString(36).substring(7)}
className={`toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer transition-all duration-300 ${checked ? 'right-0 border-green-500' : 'left-0 border-gray-300'}`}
checked={checked}
onChange={(e) => !disabled && onChange(e.target.checked)}
disabled={disabled}
/>
<label
className={`toggle-label block overflow-hidden h-5 rounded-full cursor-pointer transition-colors duration-300 ${checked ? 'bg-green-500' : 'bg-gray-300'}`}
></label>
</div>
);
const Checkbox = ({ checked, onChange, label, disabled }) => (
<label className={`inline-flex items-center space-x-2 cursor-pointer ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}>
<div className={`w-5 h-5 flex items-center justify-center border rounded transition-colors ${checked ? 'bg-blue-600 border-blue-600' : 'bg-white border-slate-300 hover:border-blue-400'}`}>
{checked && (
<svg className="w-3 h-3 text-white fill-current" viewBox="0 0 20 20">
<path d="M0 11l2-2 5 5L18 3l2 2L7 18z"/>
</svg>
)}
</div>
<input
type="checkbox"
className="hidden"
checked={checked}
onChange={(e) => !disabled && onChange(e.target.checked)}
disabled={disabled}
/>
<span className="text-sm font-medium text-slate-600">{label}</span>
</label>
);
const TokenSelector = ({ tokens, selectedId, onSelect }) => {
const selectedToken = tokens.find(t => t.id === selectedId);
const [isOpen, setIsOpen] = useState(false);
return (
<div className="relative mb-8 z-20">
<label className="block text-sm font-semibold text-slate-500 mb-2 uppercase tracking-wide">Select Travel Card</label>
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full md:w-96 flex items-center justify-between bg-white border border-slate-200 hover:border-blue-400 px-4 py-3 rounded-xl shadow-sm transition-all text-left"
>
<div className="flex items-center gap-3">
<div className="bg-blue-100 p-2 rounded-full text-blue-600">
<i data-lucide="credit-card"></i>
</div>
<div>
<div className="font-bold text-slate-800">{selectedToken?.name}</div>
<div className="text-xs text-slate-500">{selectedToken?.type}</div>
</div>
</div>
<i data-lucide="chevron-down" className={`text-slate-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}></i>
</button>
{isOpen && (
<div className="absolute top-full mt-2 w-full md:w-96 bg-white border border-slate-100 rounded-xl shadow-xl overflow-hidden animate-fade-in-down">
{tokens.map(token => (
<div
key={token.id}
onClick={() => { onSelect(token.id); setIsOpen(false); }}
className={`px-4 py-3 cursor-pointer hover:bg-slate-50 flex items-center gap-3 border-b border-slate-50 last:border-0 ${token.id === selectedId ? 'bg-blue-50/50' : ''}`}
>
<div className={`w-2 h-2 rounded-full ${token.id === selectedId ? 'bg-blue-500' : 'bg-slate-300'}`}></div>
<div className="text-sm font-medium text-slate-700">{token.name}</div>
</div>
))}
</div>
)}
</div>
);
};
const NotificationMatrix = ({ data, onUpdate }) => {
const [expandedCategories, setExpandedCategories] = useState({});
const toggleExpand = (catId) => {
setExpandedCategories(prev => ({
...prev,
[catId]: !prev[catId]
}));
};
// --- Handlers ---
// Level 2: Toggle Category Enable/Disable
// COMPLEXITY UPDATE: If category is toggled, it gathers the Event IDs inside it.
// It then updates ALL categories, syncing the enabled state for those shared Event IDs globally.
const handleCategoryToggle = (catId, newValue) => {
// 1. Identify which events are being affected by this category change
const targetCat = data.find(c => c.id === catId);
const targetEventIds = new Set(targetCat.events.map(e => e.id));
// 2. Map over all data to apply changes everywhere those events exist
const updatedData = data.map(cat => {
// Update the category itself if it is the target
const newCatEnabled = cat.id === catId ? newValue : cat.enabled;
// Update events if they match the IDs from the toggled category
const updatedEvents = cat.events.map(evt => {
if (targetEventIds.has(evt.id)) {
return { ...evt, enabled: newValue };
}
return evt;
});
return { ...cat, enabled: newCatEnabled, events: updatedEvents };
});
onUpdate(updatedData);
};
// Level 4: Toggle Category Channel (App/Email)
// COMPLEXITY UPDATE: Propagates channel settings to shared events globally.
const handleCategoryChannelToggle = (catId, channelKey, newValue) => {
const targetCat = data.find(c => c.id === catId);
const targetEventIds = new Set(targetCat.events.map(e => e.id));
const updatedData = data.map(cat => {
// Update parent channel state only for the clicked category
const updatedChannels = cat.id === catId
? { ...cat.channels, [channelKey]: newValue }
: cat.channels;
// Update events globally if they match the IDs
const updatedEvents = cat.events.map(evt => {
if (targetEventIds.has(evt.id)) {
return {
...evt,
channels: { ...evt.channels, [channelKey]: newValue }
};
}
return evt;
});
return { ...cat, channels: updatedChannels, events: updatedEvents };
});
onUpdate(updatedData);
};
// Level 3: Toggle Event Enable/Disable
// COMPLEXITY UPDATE: Scans ALL categories for the matching evtId and updates them.
const handleEventToggle = (catId, evtId, newValue) => {
const updatedData = data.map(cat => {
// We remove the `if (cat.id !== catId)` check to allow global updates
const updatedEvents = cat.events.map(evt =>
evt.id === evtId ? { ...evt, enabled: newValue } : evt
);
return { ...cat, events: updatedEvents };
});
onUpdate(updatedData);
};
// Level 5: Toggle Event Channel (Deviation)
// COMPLEXITY UPDATE: Scans ALL categories for the matching evtId and updates channels.
const handleEventChannelToggle = (catId, evtId, channelKey, newValue) => {
const updatedData = data.map(cat => {
const updatedEvents = cat.events.map(evt =>
evt.id === evtId ? {
...evt,
channels: { ...evt.channels, [channelKey]: newValue }
} : evt
);
return { ...cat, events: updatedEvents };
});
onUpdate(updatedData);
};
return (
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden">
{/* Header */}
<div className="grid grid-cols-12 gap-4 p-4 bg-slate-100 border-b border-slate-200 text-xs font-bold text-slate-500 uppercase tracking-wider">
<div className="col-span-6 md:col-span-5 pl-2">Category / Event</div>
<div className="col-span-2 text-center">Status</div>
<div className="col-span-4 md:col-span-5 flex justify-around">
<span>App Push</span>
<span>Email</span>
</div>
</div>
{/* Body */}
<div className="divide-y divide-slate-100">
{data.map(cat => {
const isExpanded = !!expandedCategories[cat.id];
return (
<div key={cat.id} className="group">
{/* Category Row (Level 2 & 4) */}
<div className={`grid grid-cols-12 gap-4 p-4 items-center transition-colors hover:bg-slate-50 ${isExpanded ? 'bg-slate-50/80' : ''}`}>
{/* Name & Expander */}
<div className="col-span-6 md:col-span-5 flex items-center gap-3">
<button
onClick={() => toggleExpand(cat.id)}
className="p-1 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition-colors"
>
<i data-lucide={isExpanded ? "chevron-down" : "chevron-right"} className="w-5 h-5"></i>
</button>
<div className="flex flex-col">
<span className="font-bold text-slate-800 text-base">{cat.name}</span>
<span className="text-xs text-slate-500 hidden md:block">{cat.description}</span>
</div>
</div>
{/* Master Toggle (Level 2) */}
<div className="col-span-2 flex justify-center">
<ToggleSwitch
checked={cat.enabled}
onChange={(val) => handleCategoryToggle(cat.id, val)}
/>
</div>
{/* Channels (Level 4) */}
<div className="col-span-4 md:col-span-5 flex justify-around items-center">
<Checkbox
checked={cat.channels.app}
onChange={(val) => handleCategoryChannelToggle(cat.id, 'app', val)}
disabled={!cat.enabled}
label=""
/>
<Checkbox
checked={cat.channels.email}
onChange={(val) => handleCategoryChannelToggle(cat.id, 'email', val)}
disabled={!cat.enabled}
label=""
/>
</div>
</div>
{/* Event Rows (Level 3 & 5) - Accordion Body */}
<div className={`accordion-content bg-slate-50/50 ${isExpanded ? 'expanded' : ''}`}>
<div className="pl-0 md:pl-0 border-t border-slate-100/50">
{cat.events.map((evt, idx) => (
<div key={evt.id} className={`grid grid-cols-12 gap-4 py-3 px-4 items-center border-b border-slate-100 last:border-0 ${!cat.enabled ? 'opacity-40 pointer-events-none' : ''}`}>
{/* Event Name */}
<div className="col-span-6 md:col-span-5 pl-12 flex flex-col justify-center relative">
{/* Connector Line Visualization */}
<div className="absolute left-6 top-0 bottom-1/2 w-px bg-slate-200 -z-10 h-full"></div>
<div className="absolute left-6 top-1/2 w-4 h-px bg-slate-200 -z-10"></div>
<span className="text-sm font-medium text-slate-700">{evt.name}</span>
<span className="text-[10px] text-slate-400 md:hidden">{evt.description}</span>
</div>
{/* Event Toggle (Level 3) */}
<div className="col-span-2 flex justify-center">
<ToggleSwitch
checked={evt.enabled}
onChange={(val) => handleEventToggle(cat.id, evt.id, val)}
disabled={!cat.enabled}
/>
</div>
{/* Event Channels (Level 5) */}
<div className="col-span-4 md:col-span-5 flex justify-around items-center">
<Checkbox
checked={evt.channels.app}
onChange={(val) => handleEventChannelToggle(cat.id, evt.id, 'app', val)}
disabled={!cat.enabled || !evt.enabled}
label=""
/>
<Checkbox
checked={evt.channels.email}
onChange={(val) => handleEventChannelToggle(cat.id, evt.id, 'email', val)}
disabled={!cat.enabled || !evt.enabled}
label=""
/>
</div>
</div>
))}
</div>
</div>
</div>
);
})}
</div>
</div>
);
};
const App = () => {
const [selectedTokenId, setSelectedTokenId] = useState('t1');
const [settings, setSettings] = useState(generateMockState());
// Initialize icons on mount and update
useEffect(() => {
if (window.lucide) {
window.lucide.createIcons();
}
});
const currentData = settings[selectedTokenId];
const handleUpdate = (newData) => {
setSettings(prev => ({
...prev,
[selectedTokenId]: newData
}));
};
return (
<div className="min-h-screen p-4 md:p-8 max-w-5xl mx-auto">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-slate-800 mb-2">Manage Notifications</h1>
<p className="text-slate-500">Fine-tune your communication preferences per travel card.</p>
</div>
{/* Level 1: Token Selection */}
<TokenSelector
tokens={INITIAL_TOKENS}
selectedId={selectedTokenId}
onSelect={setSelectedTokenId}
/>
{/* Notification Matrix */}
<NotificationMatrix
data={currentData}
onUpdate={handleUpdate}
/>
{/* Footer / Hint */}
<div className="mt-8 text-center text-xs text-slate-400">
<p>Changes are saved automatically.</p>
<p>Disabling a category will suppress all notifications within it.</p>
<p className="mt-1 font-semibold text-blue-500">Note: Identical events (e.g. "Missing Tap Out") are synchronized across categories.</p>
</div>
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>