Merge branch 'dev' into test

This commit is contained in:
Marek Maslowski
2026-04-05 10:26:09 +02:00
63 changed files with 24436 additions and 18674 deletions
@@ -2,7 +2,11 @@ import React, { useState, useEffect } from 'react'
import { adminApi, tripsApi } from '../../api/client'
import { useAuthStore } from '../../store/authStore'
import { useToast } from '../shared/Toast'
import { Bell, Send, Zap, ArrowRight, CheckCircle, XCircle, Navigation, User } from 'lucide-react'
import {
Bell, Zap, ArrowRight, CheckCircle, Navigation, User,
Calendar, Clock, Image, MessageSquare, Tag, UserPlus,
Download, MapPin,
} from 'lucide-react'
interface Trip {
id: number
@@ -37,7 +41,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
}).catch(() => {})
}, [])
const send = async (label: string, payload: Record<string, unknown>) => {
const fire = async (label: string, payload: Record<string, unknown>) => {
setSending(label)
try {
await adminApi.sendTestNotification(payload)
@@ -49,74 +53,69 @@ export default function DevNotificationsPanel(): React.ReactElement {
}
}
const buttons = [
{
label: 'Simple → Me',
icon: Bell,
color: '#6366f1',
payload: {
type: 'simple',
scope: 'user',
target: user?.id,
title_key: 'notifications.test.title',
title_params: { actor: user?.username || 'Admin' },
text_key: 'notifications.test.text',
text_params: {},
},
},
{
label: 'Boolean → Me',
icon: CheckCircle,
color: '#10b981',
payload: {
type: 'boolean',
scope: 'user',
target: user?.id,
title_key: 'notifications.test.booleanTitle',
title_params: { actor: user?.username || 'Admin' },
text_key: 'notifications.test.booleanText',
text_params: {},
positive_text_key: 'notifications.test.accept',
negative_text_key: 'notifications.test.decline',
positive_callback: { action: 'test_approve', payload: {} },
negative_callback: { action: 'test_deny', payload: {} },
},
},
{
label: 'Navigate → Me',
icon: Navigation,
color: '#f59e0b',
payload: {
type: 'navigate',
scope: 'user',
target: user?.id,
title_key: 'notifications.test.navigateTitle',
title_params: {},
text_key: 'notifications.test.navigateText',
text_params: {},
navigate_text_key: 'notifications.test.goThere',
navigate_target: '/dashboard',
},
},
{
label: 'Simple → Admins',
icon: Zap,
color: '#ef4444',
payload: {
type: 'simple',
scope: 'admin',
target: 0,
title_key: 'notifications.test.adminTitle',
title_params: {},
text_key: 'notifications.test.adminText',
text_params: { actor: user?.username || 'Admin' },
},
},
]
const selectedTrip = trips.find(t => t.id === selectedTripId)
const selectedUser = users.find(u => u.id === selectedUserId)
const username = user?.username || 'Admin'
const tripTitle = selectedTrip?.title || 'Test Trip'
// ── Helpers ──────────────────────────────────────────────────────────────
const Btn = ({
id, label, sub, icon: Icon, color, onClick,
}: {
id: string; label: string; sub: string; icon: React.ElementType; color: string; onClick: () => void
}) => (
<button
onClick={onClick}
disabled={sending !== null}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)' }}
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: `${color}20`, color }}>
<Icon className="w-4 h-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
<p className="text-xs truncate" style={{ color: 'var(--text-faint)' }}>{sub}</p>
</div>
{sending === id && (
<div className="w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin flex-shrink-0" />
)}
</button>
)
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>{children}</h3>
)
const TripSelector = () => (
<select
value={selectedTripId ?? ''}
onChange={e => setSelectedTripId(Number(e.target.value))}
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
>
{trips.map(trip => <option key={trip.id} value={trip.id}>{trip.title}</option>)}
</select>
)
const UserSelector = () => (
<select
value={selectedUserId ?? ''}
onChange={e => setSelectedUserId(Number(e.target.value))}
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
>
{users.map(u => <option key={u.id} value={u.id}>{u.username} ({u.email})</option>)}
</select>
)
return (
<div className="space-y-6">
<div className="flex items-center gap-2 mb-2">
<div className="space-y-8">
<div className="flex items-center gap-2">
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold" style={{ background: '#fbbf24', color: '#000' }}>
DEV ONLY
</div>
@@ -125,219 +124,162 @@ export default function DevNotificationsPanel(): React.ReactElement {
</span>
</div>
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>
Send test notifications to yourself, all admins, or trip members. These use test i18n keys.
</p>
{/* Quick-fire buttons */}
{/* ── Type Testing ─────────────────────────────────────────────────── */}
<div>
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>Quick Send</h3>
<SectionTitle>Type Testing</SectionTitle>
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
Test how each in-app notification type renders, sent to yourself.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{buttons.map(btn => {
const Icon = btn.icon
return (
<button
key={btn.label}
onClick={() => send(btn.label, btn.payload)}
disabled={sending !== null}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: `${btn.color}20`, color: btn.color }}>
<Icon className="w-4 h-4" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{btn.label}</p>
<p className="text-xs truncate" style={{ color: 'var(--text-faint)' }}>
{btn.payload.type} · {btn.payload.scope}
</p>
</div>
{sending === btn.label && (
<div className="ml-auto w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin" />
)}
</button>
)
})}
<Btn id="simple-me" label="Simple → Me" sub="test_simple · user" icon={Bell} color="#6366f1"
onClick={() => fire('simple-me', {
event: 'test_simple',
scope: 'user',
targetId: user?.id,
params: {},
})}
/>
<Btn id="boolean-me" label="Boolean → Me" sub="test_boolean · user" icon={CheckCircle} color="#10b981"
onClick={() => fire('boolean-me', {
event: 'test_boolean',
scope: 'user',
targetId: user?.id,
params: {},
inApp: {
type: 'boolean',
positiveCallback: { action: 'test_approve', payload: {} },
negativeCallback: { action: 'test_deny', payload: {} },
},
})}
/>
<Btn id="navigate-me" label="Navigate → Me" sub="test_navigate · user" icon={Navigation} color="#f59e0b"
onClick={() => fire('navigate-me', {
event: 'test_navigate',
scope: 'user',
targetId: user?.id,
params: {},
})}
/>
<Btn id="simple-admins" label="Simple → All Admins" sub="test_simple · admin" icon={Zap} color="#ef4444"
onClick={() => fire('simple-admins', {
event: 'test_simple',
scope: 'admin',
targetId: 0,
params: {},
})}
/>
</div>
</div>
{/* Trip-scoped notifications */}
{/* ── Trip-Scoped Events ───────────────────────────────────────────── */}
{trips.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>Trip-Scoped</h3>
<div className="flex gap-2 mb-2">
<select
value={selectedTripId ?? ''}
onChange={e => setSelectedTripId(Number(e.target.value))}
className="flex-1 px-3 py-2 rounded-lg border text-sm"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
>
{trips.map(trip => (
<option key={trip.id} value={trip.id}>{trip.title}</option>
))}
</select>
</div>
<SectionTitle>Trip-Scoped Events</SectionTitle>
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
Fires each trip event to all members of the selected trip (excluding yourself).
</p>
<TripSelector />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
onClick={() => selectedTripId && send('Simple → Trip', {
type: 'simple',
<Btn id="booking_change" label="booking_change" sub="navigate · trip" icon={Calendar} color="#6366f1"
onClick={() => selectedTripId && fire('booking_change', {
event: 'booking_change',
scope: 'trip',
target: selectedTripId,
title_key: 'notifications.test.tripTitle',
title_params: { actor: user?.username || 'Admin' },
text_key: 'notifications.test.tripText',
text_params: { trip: trips.find(t => t.id === selectedTripId)?.title || 'Trip' },
targetId: selectedTripId,
params: { actor: username, trip: tripTitle, booking: 'Test Hotel', type: 'hotel', tripId: String(selectedTripId) },
})}
disabled={sending !== null || !selectedTripId}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: '#8b5cf620', color: '#8b5cf6' }}>
<Send className="w-4 h-4" />
</div>
<div>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Simple Trip Members</p>
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>simple · trip</p>
</div>
</button>
<button
onClick={() => selectedTripId && send('Navigate → Trip', {
type: 'navigate',
/>
<Btn id="trip_reminder" label="trip_reminder" sub="navigate · trip" icon={Clock} color="#10b981"
onClick={() => selectedTripId && fire('trip_reminder', {
event: 'trip_reminder',
scope: 'trip',
target: selectedTripId,
title_key: 'notifications.test.tripTitle',
title_params: { actor: user?.username || 'Admin' },
text_key: 'notifications.test.tripText',
text_params: { trip: trips.find(t => t.id === selectedTripId)?.title || 'Trip' },
navigate_text_key: 'notifications.test.goThere',
navigate_target: `/trips/${selectedTripId}`,
targetId: selectedTripId,
params: { trip: tripTitle, tripId: String(selectedTripId) },
})}
disabled={sending !== null || !selectedTripId}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: '#f59e0b20', color: '#f59e0b' }}>
<ArrowRight className="w-4 h-4" />
</div>
<div>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Navigate Trip Members</p>
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>navigate · trip</p>
</div>
</button>
/>
<Btn id="photos_shared" label="photos_shared" sub="navigate · trip" icon={Image} color="#f59e0b"
onClick={() => selectedTripId && fire('photos_shared', {
event: 'photos_shared',
scope: 'trip',
targetId: selectedTripId,
params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) },
})}
/>
<Btn id="collab_message" label="collab_message" sub="navigate · trip" icon={MessageSquare} color="#8b5cf6"
onClick={() => selectedTripId && fire('collab_message', {
event: 'collab_message',
scope: 'trip',
targetId: selectedTripId,
params: { actor: username, trip: tripTitle, preview: 'This is a test message preview.', tripId: String(selectedTripId) },
})}
/>
<Btn id="packing_tagged" label="packing_tagged" sub="navigate · trip" icon={Tag} color="#ec4899"
onClick={() => selectedTripId && fire('packing_tagged', {
event: 'packing_tagged',
scope: 'trip',
targetId: selectedTripId,
params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) },
})}
/>
</div>
</div>
)}
{/* User-scoped notifications */}
{/* ── User-Scoped Events ───────────────────────────────────────────── */}
{users.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>User-Scoped</h3>
<div className="flex gap-2 mb-2">
<select
value={selectedUserId ?? ''}
onChange={e => setSelectedUserId(Number(e.target.value))}
className="flex-1 px-3 py-2 rounded-lg border text-sm"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
>
{users.map(u => (
<option key={u.id} value={u.id}>{u.username} ({u.email})</option>
))}
</select>
</div>
<SectionTitle>User-Scoped Events</SectionTitle>
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
Fires each user event to the selected recipient.
</p>
<UserSelector />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
onClick={() => selectedUserId && send(`Simple → ${users.find(u => u.id === selectedUserId)?.username}`, {
type: 'simple',
<Btn
id={`trip_invite-${selectedUserId}`}
label="trip_invite"
sub="navigate · user"
icon={UserPlus}
color="#06b6d4"
onClick={() => selectedUserId && fire(`trip_invite-${selectedUserId}`, {
event: 'trip_invite',
scope: 'user',
target: selectedUserId,
title_key: 'notifications.test.title',
title_params: { actor: user?.username || 'Admin' },
text_key: 'notifications.test.text',
text_params: {},
targetId: selectedUserId,
params: { actor: username, trip: tripTitle, invitee: selectedUser?.email || '', tripId: String(selectedTripId ?? 0) },
})}
disabled={sending !== null || !selectedUserId}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: '#06b6d420', color: '#06b6d4' }}>
<User className="w-4 h-4" />
</div>
<div>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Simple User</p>
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>simple · user</p>
</div>
</button>
<button
onClick={() => selectedUserId && send(`Boolean → ${users.find(u => u.id === selectedUserId)?.username}`, {
type: 'boolean',
/>
<Btn
id={`vacay_invite-${selectedUserId}`}
label="vacay_invite"
sub="navigate · user"
icon={MapPin}
color="#f97316"
onClick={() => selectedUserId && fire(`vacay_invite-${selectedUserId}`, {
event: 'vacay_invite',
scope: 'user',
target: selectedUserId,
title_key: 'notifications.test.booleanTitle',
title_params: { actor: user?.username || 'Admin' },
text_key: 'notifications.test.booleanText',
text_params: {},
positive_text_key: 'notifications.test.accept',
negative_text_key: 'notifications.test.decline',
positive_callback: { action: 'test_approve', payload: {} },
negative_callback: { action: 'test_deny', payload: {} },
targetId: selectedUserId,
params: { actor: username, planId: '1' },
})}
disabled={sending !== null || !selectedUserId}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: '#10b98120', color: '#10b981' }}>
<CheckCircle className="w-4 h-4" />
</div>
<div>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Boolean User</p>
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>boolean · user</p>
</div>
</button>
<button
onClick={() => selectedUserId && send(`Navigate → ${users.find(u => u.id === selectedUserId)?.username}`, {
type: 'navigate',
scope: 'user',
target: selectedUserId,
title_key: 'notifications.test.navigateTitle',
title_params: {},
text_key: 'notifications.test.navigateText',
text_params: {},
navigate_text_key: 'notifications.test.goThere',
navigate_target: '/dashboard',
})}
disabled={sending !== null || !selectedUserId}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: '#f59e0b20', color: '#f59e0b' }}>
<ArrowRight className="w-4 h-4" />
</div>
<div>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Navigate User</p>
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>navigate · user</p>
</div>
</button>
/>
</div>
</div>
)}
{/* ── Admin-Scoped Events ──────────────────────────────────────────── */}
<div>
<SectionTitle>Admin-Scoped Events</SectionTitle>
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
Fires to all admin users.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Btn id="version_available" label="version_available" sub="navigate · admin" icon={Download} color="#64748b"
onClick={() => fire('version_available', {
event: 'version_available',
scope: 'admin',
targetId: 0,
params: { version: '9.9.9-test' },
})}
/>
</div>
</div>
</div>
)
}