Added Resource Groups

This commit is contained in:
ovpay 2026-01-05 13:07:48 +00:00
parent 01c5eb3680
commit 0c4ff14b8d

491
v3.html Normal file
View File

@ -0,0 +1,491 @@
<!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;
}
</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;
// --- Data Definitions ---
// 1. Define the Categories and their Events
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: 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 } },
]
},
cat_service: {
id: 'cat_service',
name: 'My Service Messages',
description: 'Bundled events regarding service and account status.',
enabled: true,
channels: { app: true, email: true },
events: [
{ 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 } },
]
},
cat_products: {
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 } },
]
},
cat_disruptions: {
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 } },
]
},
cat_newsletter: {
id: 'cat_newsletter',
name: 'Newsletter',
description: 'Stay updated with our latest news and offers.',
enabled: true,
channels: { app: false, email: true },
events: [
{ id: 'evt_weekly', name: 'Weekly Digest', description: 'Summary of your travels', enabled: true, channels: { app: false, email: true } },
{ id: 'evt_offers', name: 'Partner Offers', description: 'Exclusive deals', enabled: true, channels: { app: false, email: true } }
]
}
};
// 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>
);
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 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">
{/* <label className="block text-sm font-semibold text-slate-500 mb-2 uppercase tracking-wide">{label}</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={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, val) => {
const updated = updateWithSync(data, catId, null, (item, type) => {
if (type === 'category') return { ...item, channels: { ...item.channels, [channel]: val } };
if (type === 'event') return { ...item, channels: { ...item.channels, [channel]: val } };
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, val) => {
const updated = updateWithSync(data, null, evtId, (item, type) => {
if (type === 'event') return { ...item, channels: { ...item.channels, [channel]: val } };
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">
<Checkbox checked={cat.channels.app} onChange={(v) => handleCategoryChannel(cat.id, 'app', v)} disabled={!cat.enabled} label="" />
<Checkbox checked={cat.channels.email} onChange={(v) => handleCategoryChannel(cat.id, 'email', v)} disabled={!cat.enabled} label="" />
</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">
<Checkbox checked={evt.channels.app} onChange={(v) => handleEventChannel(cat.id, evt.id, 'app', v)} disabled={!cat.enabled || !evt.enabled} label="" />
<Checkbox checked={evt.channels.email} onChange={(v) => handleEventChannel(cat.id, evt.id, 'email', v)} disabled={!cat.enabled || !evt.enabled} label="" />
</div>
</div>
))}
</div>
</div>
</div>
);
})}
</div>
</div>
);
};
const ResourceGroupSection = ({ config, settings, onUpdateSettings }) => {
// State for the local selector (e.g. which Card is selected)
// Default to first resource in the group
const [selectedResourceId, setSelectedResourceId] = useState(config.resources[0].id);
// Get the data for the currently selected resource in this group
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>
{/* Show Selector if the group allows selection (e.g. Multiple Cards) */}
{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>