Added Devices and Emails as new dimension
This commit is contained in:
parent
0c4ff14b8d
commit
512f6e199b
604
v4
Normal file
604
v4
Normal file
@ -0,0 +1,604 @@
|
|||||||
|
<!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>
|
||||||
Loading…
Reference in New Issue
Block a user