458 lines
24 KiB
HTML
458 lines
24 KiB
HTML
<!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;
|
|
|
|
// Render SVG using simple string replacement or creating an element
|
|
// Since we don't have the React components directly from the CDN easily without a factory
|
|
// We will just use the svg string approach or a simple placeholder if complexity is high.
|
|
// Actually, lucide.createIcons() works on DOM elements.
|
|
// For React in browser, we can implement a simple SVG map for the icons we need to avoid dependency issues.
|
|
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 } },
|
|
{ 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_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
|
|
const handleCategoryToggle = (catId, newValue) => {
|
|
const updatedData = data.map(cat => {
|
|
if (cat.id !== catId) return cat;
|
|
|
|
// Logic: If category is enabled/disabled, prompt says:
|
|
// "If it's turned on, all the Event Types within the Category are also enabled."
|
|
const updatedEvents = cat.events.map(evt => ({ ...evt, enabled: newValue }));
|
|
|
|
return { ...cat, enabled: newValue, events: updatedEvents };
|
|
});
|
|
onUpdate(updatedData);
|
|
};
|
|
|
|
// Level 4: Toggle Category Channel (App/Email)
|
|
const handleCategoryChannelToggle = (catId, channelKey, newValue) => {
|
|
const updatedData = data.map(cat => {
|
|
if (cat.id !== catId) return cat;
|
|
|
|
// Update parent channel
|
|
const updatedChannels = { ...cat.channels, [channelKey]: newValue };
|
|
|
|
// Logic: "If a channel is applied, the channel is also applied to every Event Type"
|
|
const updatedEvents = cat.events.map(evt => ({
|
|
...evt,
|
|
channels: { ...evt.channels, [channelKey]: newValue }
|
|
}));
|
|
|
|
return { ...cat, channels: updatedChannels, events: updatedEvents };
|
|
});
|
|
onUpdate(updatedData);
|
|
};
|
|
|
|
// Level 3: Toggle Event Enable/Disable
|
|
const handleEventToggle = (catId, evtId, newValue) => {
|
|
const updatedData = data.map(cat => {
|
|
if (cat.id !== catId) return cat;
|
|
const updatedEvents = cat.events.map(evt =>
|
|
evt.id === evtId ? { ...evt, enabled: newValue } : evt
|
|
);
|
|
// Note: We don't necessarily turn off the parent if one child is off,
|
|
// but we could if we wanted strict "all or nothing" logic.
|
|
// Usually parents stay active if at least one child is active, or stay active as a container.
|
|
// We will leave parent state as is.
|
|
return { ...cat, events: updatedEvents };
|
|
});
|
|
onUpdate(updatedData);
|
|
};
|
|
|
|
// Level 5: Toggle Event Channel (Deviation)
|
|
const handleEventChannelToggle = (catId, evtId, channelKey, newValue) => {
|
|
const updatedData = data.map(cat => {
|
|
if (cat.id !== catId) return 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>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
root.render(<App />);
|
|
</script>
|
|
</body>
|
|
</html> |