NotificationManager/v4

604 lines
31 KiB
Plaintext

<!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 */
::-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;
}
.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;
opacity: 1;
}
/* Animation for section entry */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in {
animation: fadeIn 0.5s ease-out forwards;
}
/* Popover animation */
.popover-enter {
opacity: 0;
transform: scale(0.95) translateY(-5px);
}
.popover-enter-active {
opacity: 1;
transform: scale(1) translateY(0);
transition: opacity 150ms ease-out, transform 150ms ease-out;
}
</style>
</head>
<body class="bg-slate-50 text-slate-800 font-sans antialiased">
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useRef, useMemo } = React;
// --- Data Definitions ---
// 0. Define User Devices and Emails (The new dimension)
const USER_DEVICES = [
{ id: 'dev_1', name: 'iPhone 15 Pro' },
{ id: 'dev_2', name: 'iPad Air' }
];
const USER_EMAILS = [
{ id: 'em_1', name: 'j.doe@company.com' },
{ id: 'em_2', name: 'johnny@gmail.com' }
];
// 1. Define the Categories and their Events
// UPDATED: Channels now store arrays of IDs instead of booleans
const CATEGORY_DEFINITIONS = {
cat_trips: {
id: 'cat_trips',
name: 'My Trips',
description: 'Notifications related to your travel history and active journeys.',
enabled: true,
channels: { app: ['dev_1', 'dev_2'], email: [] },
events: [
{ id: 'evt_in', name: 'Tap In', description: 'When you start a journey', enabled: true, channels: { app: ['dev_1', 'dev_2'], email: [] } },
{ id: 'evt_out', name: 'Tap Out', description: 'When you end a journey', enabled: true, channels: { app: ['dev_1', 'dev_2'], email: [] } },
{ id: 'evt_miss_out', name: 'Missing Tap Out', description: 'Alert when you forget to check out', enabled: true, channels: { app: ['dev_1', 'dev_2'], email: ['em_1'] } },
{ id: 'evt_recalc', name: 'Trip Recalculated', description: 'Price corrections', enabled: true, channels: { app: ['dev_1'], email: ['em_1', 'em_2'] } },
]
},
cat_service: {
id: 'cat_service',
name: 'My Service Messages',
description: 'Bundled events regarding service and account status.',
enabled: true,
channels: { app: ['dev_1', 'dev_2'], email: ['em_1'] },
events: [
{ id: 'evt_miss_out', name: 'Missing Tap Out', description: 'Alert when you forget to check out', enabled: true, channels: { app: ['dev_1', 'dev_2'], email: ['em_1'] } },
{ id: 'evt_refund', name: 'Refund Processed', description: 'Updates on your refund requests', enabled: true, channels: { app: ['dev_1'], email: ['em_1'] } },
]
},
cat_products: {
id: 'cat_products',
name: 'My Products',
description: 'Updates about your subscriptions and card validity.',
enabled: false,
channels: { app: ['dev_1', 'dev_2'], email: ['em_1', 'em_2'] },
events: [
{ id: 'evt_sub_renew', name: 'Subscription Renewal', description: 'Monthly renewal alerts', enabled: true, channels: { app: ['dev_1', 'dev_2'], email: ['em_1'] } },
{ id: 'evt_card_exp', name: 'Card Expiry', description: 'Warning before card expires', enabled: true, channels: { app: [], email: ['em_1'] } },
]
},
cat_disruptions: {
id: 'cat_disruptions',
name: 'Disruptions',
description: 'Real-time alerts about delays on your favorite routes.',
enabled: true,
channels: { app: ['dev_1', 'dev_2'], email: [] },
events: [
{ id: 'evt_delay', name: 'Delays > 15min', description: 'Significant delays', enabled: true, channels: { app: ['dev_1', 'dev_2'], email: [] } },
{ id: 'evt_cancelled', name: 'Train Cancelled', description: 'Cancellations on route', enabled: true, channels: { app: ['dev_1', 'dev_2'], email: [] } },
{ id: 'evt_maintenance', name: 'Planned Maintenance', description: 'Future work alerts', enabled: false, channels: { app: [], email: ['em_1'] } },
]
},
cat_newsletter: {
id: 'cat_newsletter',
name: 'Newsletter',
description: 'Stay updated with our latest news and offers.',
enabled: true,
channels: { app: [], email: ['em_2'] },
events: [
{ id: 'evt_weekly', name: 'Weekly Digest', description: 'Summary of your travels', enabled: true, channels: { app: [], email: ['em_2'] } },
{ id: 'evt_offers', name: 'Partner Offers', description: 'Exclusive deals', enabled: true, channels: { app: [], email: ['em_2'] } }
]
}
};
// 2. Define Resource Groups
const RESOURCE_GROUPS_CONFIG = [
{
id: 'rg_cards',
title: 'Travel Cards',
icon: 'credit-card',
type: 'selector', // Requires a dropdown to select specific resource
resources: [
{ 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' }
],
categoryIds: ['cat_trips', 'cat_service', 'cat_products']
},
{
id: 'rg_updates',
title: 'Trip Updates',
icon: 'train',
type: 'static', // Single block, no selection
resources: [{ id: 'global_updates', name: 'Trip Updates', type: 'System' }],
categoryIds: ['cat_disruptions']
},
{
id: 'rg_marketing',
title: 'Marketing',
icon: 'megaphone',
type: 'static',
resources: [{ id: 'global_marketing', name: 'Marketing', type: 'System' }],
categoryIds: ['cat_newsletter']
}
];
// 3. Generator for Initial State
const generateInitialState = () => {
const state = {};
RESOURCE_GROUPS_CONFIG.forEach(group => {
group.resources.forEach(resource => {
// Create deep copy of categories for this specific resource
const resourceCategories = group.categoryIds.map(catId =>
JSON.parse(JSON.stringify(CATEGORY_DEFINITIONS[catId]))
);
state[resource.id] = resourceCategories;
});
});
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"
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>
);
// New Component: MultiSelect Dropdown for Channels
const ChannelMultiSelect = ({ options, selectedIds = [], onChange, disabled }) => {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef(null);
// Handle click outside to close
useEffect(() => {
const handleClickOutside = (event) => {
if (containerRef.current && !containerRef.current.contains(event.target)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleToggleOption = (id) => {
let newSelected;
if (selectedIds.includes(id)) {
newSelected = selectedIds.filter(existingId => existingId !== id);
} else {
newSelected = [...selectedIds, id];
}
onChange(newSelected);
};
const handleSelectAll = () => {
if (selectedIds.length === options.length) {
onChange([]);
} else {
onChange(options.map(o => o.id));
}
};
const selectionLabel = useMemo(() => {
if (selectedIds.length === 0) return "Off";
if (selectedIds.length === options.length) return "All";
return `${selectedIds.length} / ${options.length}`;
}, [selectedIds, options]);
const isActive = selectedIds.length > 0;
return (
<div className="relative inline-block" ref={containerRef}>
<button
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
className={`
h-8 px-3 rounded-md text-xs font-semibold flex items-center gap-2 border transition-all
${disabled ? 'opacity-40 cursor-not-allowed bg-slate-100 border-slate-200 text-slate-400' :
isActive ? 'bg-blue-50 border-blue-200 text-blue-700 hover:bg-blue-100' : 'bg-white border-slate-300 text-slate-600 hover:bg-slate-50'}
`}
>
<span>{selectionLabel}</span>
<i data-lucide="chevron-down" className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`}></i>
</button>
{isOpen && !disabled && (
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-2 w-48 bg-white border border-slate-200 rounded-lg shadow-xl z-50 p-2 animate-fade-in origin-top">
<div
onClick={handleSelectAll}
className="px-2 py-1.5 text-xs font-bold text-slate-500 uppercase cursor-pointer hover:bg-slate-50 rounded flex justify-between items-center mb-1"
>
<span>Select All</span>
<div className={`w-3 h-3 border rounded ${selectedIds.length === options.length ? 'bg-slate-500 border-slate-500' : 'border-slate-300'}`}></div>
</div>
<div className="space-y-1">
{options.map(option => {
const isSelected = selectedIds.includes(option.id);
return (
<div
key={option.id}
onClick={() => handleToggleOption(option.id)}
className={`
flex items-center gap-3 px-2 py-2 rounded cursor-pointer text-sm
${isSelected ? 'bg-blue-50 text-blue-700' : 'text-slate-700 hover:bg-slate-50'}
`}
>
<div className={`
w-4 h-4 rounded border flex items-center justify-center transition-colors
${isSelected ? 'bg-blue-500 border-blue-500' : 'bg-white border-slate-300'}
`}>
{isSelected && <svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={4}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>}
</div>
<span className="truncate">{option.name}</span>
</div>
);
})}
</div>
</div>
)}
</div>
);
};
const ResourceSelector = ({ resources, selectedId, onSelect, label, icon }) => {
const selectedResource = resources.find(r => r.id === selectedId);
const [isOpen, setIsOpen] = useState(false);
return (
<div className="relative mb-6 z-20">
<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={icon || "layers"}></i>
</div>
<div>
<div className="font-bold text-slate-800">{selectedResource?.name}</div>
<div className="text-xs text-slate-500">{selectedResource?.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 z-50">
{resources.map(res => (
<div
key={res.id}
onClick={() => { onSelect(res.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 ${res.id === selectedId ? 'bg-blue-50/50' : ''}`}
>
<div className={`w-2 h-2 rounded-full ${res.id === selectedId ? 'bg-blue-500' : 'bg-slate-300'}`}></div>
<div className="text-sm font-medium text-slate-700">{res.name}</div>
</div>
))}
</div>
)}
</div>
);
};
const NotificationMatrix = ({ data, onUpdate }) => {
const [expandedCategories, setExpandedCategories] = useState({});
const toggleExpand = (catId) => {
setExpandedCategories(prev => ({
...prev,
[catId]: !prev[catId]
}));
};
// Shared logic: Updates all matching events in all categories
const updateWithSync = (newData, targetCatId, targetEventId, updateFn) => {
// 1. Identify what we are targeting
// If targetEventId is null, we are targeting a Category change that might affect multiple events
// If targetEventId is present, we are targeting that specific event type
let targetEventIds = new Set();
if (targetEventId) {
targetEventIds.add(targetEventId);
} else if (targetCatId) {
const cat = data.find(c => c.id === targetCatId);
if (cat) cat.events.forEach(e => targetEventIds.add(e.id));
}
return data.map(cat => {
// Apply Category Level changes
let newCat = { ...cat };
if (targetCatId && cat.id === targetCatId) {
newCat = updateFn(cat, 'category');
}
// Apply Event Level changes (propagate to all events matching IDs)
const newEvents = newCat.events.map(evt => {
if (targetEventIds.has(evt.id)) {
return updateFn(evt, 'event');
}
return evt;
});
newCat.events = newEvents;
return newCat;
});
};
const handleCategoryToggle = (catId, val) => {
const updated = updateWithSync(data, catId, null, (item, type) => {
if (type === 'category') return { ...item, enabled: val };
if (type === 'event') return { ...item, enabled: val };
return item;
});
onUpdate(updated);
};
const handleCategoryChannel = (catId, channel, newSelectedIds) => {
const updated = updateWithSync(data, catId, null, (item, type) => {
// Update the channel with the new array of selected IDs
if (type === 'category') return { ...item, channels: { ...item.channels, [channel]: newSelectedIds } };
if (type === 'event') return { ...item, channels: { ...item.channels, [channel]: newSelectedIds } };
return item;
});
onUpdate(updated);
};
const handleEventToggle = (catId, evtId, val) => {
const updated = updateWithSync(data, null, evtId, (item, type) => {
if (type === 'event') return { ...item, enabled: val };
return item;
});
onUpdate(updated);
};
const handleEventChannel = (catId, evtId, channel, newSelectedIds) => {
const updated = updateWithSync(data, null, evtId, (item, type) => {
if (type === 'event') return { ...item, channels: { ...item.channels, [channel]: newSelectedIds } };
return item;
});
onUpdate(updated);
};
return (
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden mb-8">
<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>
<div className="divide-y divide-slate-100">
{data.map(cat => {
const isExpanded = !!expandedCategories[cat.id];
return (
<div key={cat.id} className="group">
<div className={`grid grid-cols-12 gap-4 p-4 items-center transition-colors hover:bg-slate-50 ${isExpanded ? 'bg-slate-50/80' : ''}`}>
<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>
<div className="col-span-2 flex justify-center">
<ToggleSwitch checked={cat.enabled} onChange={(v) => handleCategoryToggle(cat.id, v)} />
</div>
<div className="col-span-4 md:col-span-5 flex justify-around items-center">
<ChannelMultiSelect
options={USER_DEVICES}
selectedIds={cat.channels.app}
onChange={(ids) => handleCategoryChannel(cat.id, 'app', ids)}
disabled={!cat.enabled}
/>
<ChannelMultiSelect
options={USER_EMAILS}
selectedIds={cat.channels.email}
onChange={(ids) => handleCategoryChannel(cat.id, 'email', ids)}
disabled={!cat.enabled}
/>
</div>
</div>
<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) => (
<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' : ''}`}>
<div className="col-span-6 md:col-span-5 pl-12 flex flex-col justify-center relative">
<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>
<div className="col-span-2 flex justify-center">
<ToggleSwitch checked={evt.enabled} onChange={(v) => handleEventToggle(cat.id, evt.id, v)} disabled={!cat.enabled} />
</div>
<div className="col-span-4 md:col-span-5 flex justify-around items-center">
<ChannelMultiSelect
options={USER_DEVICES}
selectedIds={evt.channels.app}
onChange={(ids) => handleEventChannel(cat.id, evt.id, 'app', ids)}
disabled={!cat.enabled || !evt.enabled}
/>
<ChannelMultiSelect
options={USER_EMAILS}
selectedIds={evt.channels.email}
onChange={(ids) => handleEventChannel(cat.id, evt.id, 'email', ids)}
disabled={!cat.enabled || !evt.enabled}
/>
</div>
</div>
))}
</div>
</div>
</div>
);
})}
</div>
</div>
);
};
const ResourceGroupSection = ({ config, settings, onUpdateSettings }) => {
const [selectedResourceId, setSelectedResourceId] = useState(config.resources[0].id);
const currentData = settings[selectedResourceId];
const handleMatrixUpdate = (newData) => {
onUpdateSettings(selectedResourceId, newData);
};
return (
<div className="mb-12 animate-fade-in">
<div className="flex items-center gap-3 mb-6 border-b border-slate-200 pb-2">
<i data-lucide={config.icon} className="text-blue-500 w-6 h-6"></i>
<h2 className="text-xl font-bold text-slate-800 tracking-tight">{config.title}</h2>
</div>
{config.type === 'selector' && (
<ResourceSelector
resources={config.resources}
selectedId={selectedResourceId}
onSelect={setSelectedResourceId}
label={`Select ${config.title}`}
icon={config.icon}
/>
)}
<NotificationMatrix
data={currentData}
onUpdate={handleMatrixUpdate}
/>
</div>
);
};
const App = () => {
const [settings, setSettings] = useState(generateInitialState());
// Initialize icons
useEffect(() => {
if (window.lucide) window.lucide.createIcons();
});
const handleSettingsUpdate = (resourceId, newData) => {
setSettings(prev => ({
...prev,
[resourceId]: newData
}));
};
return (
<div className="min-h-screen p-4 md:p-12 max-w-5xl mx-auto">
{/* Main Header */}
<div className="mb-12 text-center md:text-left">
<h1 className="text-4xl font-extrabold text-slate-900 mb-3 tracking-tight">Notification Center</h1>
<p className="text-lg text-slate-500">Manage all your communication preferences in one place.</p>
</div>
{/* Iterate over Resource Groups */}
{RESOURCE_GROUPS_CONFIG.map(group => (
<ResourceGroupSection
key={group.id}
config={group}
settings={settings}
onUpdateSettings={handleSettingsUpdate}
/>
))}
<div className="mt-12 pt-8 border-t border-slate-200 text-center text-xs text-slate-400">
<p>Preferences are auto-saved. Shared event types are synchronized within their resource group.</p>
</div>
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>