schedule message and qr added

This commit is contained in:
2026-06-16 22:55:10 -06:00
parent 4f10582599
commit 6cbb6771de
16 changed files with 859 additions and 175 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="/assets/index-2aCMahXY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C_-n9NKA.css">
<script type="module" crossorigin src="/assets/index-BB6EjotN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dp8mftuy.css">
</head>
<body>
<div id="root"></div>
+132
View File
@@ -12,11 +12,13 @@
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.0.1",
"@mui/material": "^9.0.1",
"@mui/x-date-pickers": "^9.5.0",
"@supabase/supabase-js": "^2.106.2",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-query": "^5.100.14",
"@tanstack/react-router": "^1.170.10",
"autoprefixer": "^10.5.0",
"dayjs": "^1.11.21",
"postcss": "^8.5.15",
"react": "^19.2.6",
"react-dom": "^19.2.6",
@@ -928,6 +930,124 @@
}
}
},
"node_modules/@mui/x-date-pickers": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-9.5.0.tgz",
"integrity": "sha512-Pzd8gU2qOoIAUMMesjbrq3gFs6akWXcSsEkFIHVA4dtuoh6SeWISu+WR+ymq/sBOcn2tk9IBIjqRQkqLhkm+tg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
"@mui/utils": "9.0.1",
"@mui/x-internals": "^9.1.0",
"@types/react-transition-group": "^4.4.12",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
"@mui/material": "^7.3.0 || ^9.0.0",
"@mui/system": "^7.3.0 || ^9.0.0",
"date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0",
"date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0",
"dayjs": "^1.10.7",
"luxon": "^3.0.2",
"moment": "^2.29.4",
"moment-hijri": "^2.1.2 || ^3.0.0",
"moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
},
"date-fns": {
"optional": true
},
"date-fns-jalali": {
"optional": true
},
"dayjs": {
"optional": true
},
"luxon": {
"optional": true
},
"moment": {
"optional": true
},
"moment-hijri": {
"optional": true
},
"moment-jalaali": {
"optional": true
}
}
},
"node_modules/@mui/x-internals": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-9.1.0.tgz",
"integrity": "sha512-fVezTa1lU+Hb3y9UMI8D/iWXADhs0I8PaZqoh2LOUXjGEUJmKqwsRD19ZXInZsH2yu+YS0dqYMPDvzjYTTyo+Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"@mui/utils": "9.0.0",
"reselect": "^5.1.1",
"use-sync-external-store": "^1.6.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@mui/x-internals/node_modules/@mui/utils": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-9.0.0.tgz",
"integrity": "sha512-bQcqyg/gjULUqTuyUjSAFr6LQGLvtkNtDbJerAtoUn9kGZ0hg5QJiN1PLHMLbeFpe3te1831uq7GFl2ITokGdg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"@mui/types": "^9.0.0",
"@types/prop-types": "^15.7.15",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"react-is": "^19.2.4"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
@@ -2016,6 +2136,12 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/dayjs": {
"version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -3326,6 +3452,12 @@
"react-dom": ">=16.6.0"
}
},
"node_modules/reselect": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
+2
View File
@@ -14,11 +14,13 @@
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.0.1",
"@mui/material": "^9.0.1",
"@mui/x-date-pickers": "^9.5.0",
"@supabase/supabase-js": "^2.106.2",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-query": "^5.100.14",
"@tanstack/react-router": "^1.170.10",
"autoprefixer": "^10.5.0",
"dayjs": "^1.11.21",
"postcss": "^8.5.15",
"react": "^19.2.6",
"react-dom": "^19.2.6",
+3 -1
View File
@@ -47,13 +47,15 @@ export default function AppShell() {
<Tabs
value={location.pathname}
onChange={(_e, path) => navigate({ to: path })}
variant="fullWidth"
variant="scrollable"
scrollButtons="auto"
textColor="primary"
indicatorColor="primary"
>
<Tab label="Campañas" value="/campaigns" />
<Tab label="Sesiones" value="/sessions" />
<Tab label="Archivos" value="/media" />
<Tab label="Instancias" value="/instances" />
</Tabs>
</Paper>
<Outlet />
+2
View File
@@ -10,6 +10,7 @@ import {
import CampaignIcon from '@mui/icons-material/Campaign'
import ChatIcon from '@mui/icons-material/Chat'
import PermMediaIcon from '@mui/icons-material/PermMedia'
import DnsIcon from '@mui/icons-material/Dns'
import { Link, useLocation } from '@tanstack/react-router'
const DRAWER_WIDTH = 240
@@ -19,6 +20,7 @@ const items = [
{ path: '/campaigns', label: 'Campañas', icon: <CampaignIcon /> },
{ path: '/sessions', label: 'Sesiones', icon: <ChatIcon /> },
{ path: '/media', label: 'Archivos', icon: <PermMediaIcon /> },
{ path: '/instances', label: 'Instancias', icon: <DnsIcon /> },
]
export default function Sidebar({ collapsed }) {
@@ -0,0 +1,127 @@
import { useState, useEffect } from 'react'
import {
Card,
CardContent,
CardMedia,
Typography,
Box,
IconButton,
Menu,
MenuItem,
Dialog,
DialogContent,
Skeleton,
} from '@mui/material'
import MoreVertIcon from '@mui/icons-material/MoreVert'
export default function InstanceCard({ instance }) {
const [anchor, setAnchor] = useState(null)
const [showScreenshot, setShowScreenshot] = useState(false)
const [qrUrl, setQrUrl] = useState(null)
const [qrLoading, setQrLoading] = useState(true)
useEffect(() => {
const fetchQr = async () => {
try {
const response = await fetch(instance.qr_url, {
headers: { xtoken: instance.token },
})
if (!response.ok) throw new Error('Failed to fetch QR')
const { mimetype, data } = await response.json()
setQrUrl(`data:${mimetype};base64,${data}`)
} catch (err) {
console.error('QR fetch error:', err)
} finally {
setQrLoading(false)
}
}
fetchQr()
}, [instance.qr_url, instance.token])
return (
<>
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
transition: 'transform 0.15s, box-shadow 0.15s',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: 4,
},
}}
>
<Box sx={{ position: 'relative' }}>
{qrLoading ? (
<Skeleton
variant="rectangular"
sx={{ width: '100%', aspectRatio: '1', bgcolor: 'grey.100' }}
/>
) : (
<CardMedia
component="img"
image={qrUrl}
alt={`QR ${instance.name}`}
sx={{
width: '100%',
height: 'auto',
objectFit: 'contain',
bgcolor: 'grey.50',
p: 2,
}}
/>
)}
<IconButton
onClick={(e) => setAnchor(e.currentTarget)}
size="small"
sx={{
position: 'absolute',
top: 8,
right: 8,
bgcolor: 'rgba(0,0,0,0.55)',
color: 'white',
'&:hover': { bgcolor: 'rgba(0,0,0,0.75)' },
}}
>
<MoreVertIcon fontSize="small" />
</IconButton>
</Box>
<CardContent sx={{ flexGrow: 1, pb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3 }}>
{instance.name}
</Typography>
{instance.description && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, lineHeight: 1.5 }}>
{instance.description}
</Typography>
)}
</CardContent>
</Card>
<Menu anchorEl={anchor} open={Boolean(anchor)} onClose={() => setAnchor(null)}>
<MenuItem
onClick={() => {
setAnchor(null)
setShowScreenshot(true)
}}
>
Ver screenshot
</MenuItem>
</Menu>
<Dialog
open={showScreenshot}
onClose={() => setShowScreenshot(false)}
maxWidth="lg"
fullWidth
>
<DialogContent sx={{ p: 0, lineHeight: 0 }}>
<img
src={instance.schenshoot_url}
alt={`Screenshot ${instance.name}`}
style={{ width: '100%', display: 'block' }}
/>
</DialogContent>
</Dialog>
</>
)
}
@@ -0,0 +1,37 @@
import { Box, Typography, Grid, CircularProgress, Paper } from '@mui/material'
import { useInstances } from './api'
import InstanceCard from './InstanceCard'
export default function InstancesPage() {
const { data: instances, isLoading } = useInstances()
return (
<Box>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 3 }}>
Instancias
</Typography>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
<CircularProgress />
</Box>
) : (
<Grid container spacing={{ xs: 2, sm: 3, md: 4 }} columns={{ xs: 1, sm: 2, md: 2, lg: 3, xl: 4 }}>
{instances?.map((inst) => (
<Grid item xs={1} key={inst.id}>
<InstanceCard instance={inst} />
</Grid>
))}
{instances?.length === 0 && (
<Grid item xs={1}>
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Typography color="text.secondary">
No hay instancias.
</Typography>
</Paper>
</Grid>
)}
</Grid>
)}
</Box>
)
}
@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query'
import { supabase } from '../../lib/supabase'
export function useInstances() {
return useQuery({
queryKey: ['instances'],
queryFn: async () => {
const { data: { session } } = await supabase.auth.getSession()
const { data, error } = await supabase
.from('instances')
.select('*')
.order('created_at', { ascending: false })
.setHeader('xtoken', session?.access_token || '')
if (error) throw new Error(error.message)
return data || []
},
})
}
@@ -0,0 +1,233 @@
import { useState } from 'react'
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Box,
Typography,
Alert,
IconButton,
Divider,
CircularProgress,
} from '@mui/material'
import EditIcon from '@mui/icons-material/Edit'
import DeleteIcon from '@mui/icons-material/Delete'
import { LocalizationProvider, DateTimePicker } from '@mui/x-date-pickers'
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
import dayjs from 'dayjs'
import {
useScheduledMessages,
useCreateScheduledMessage,
useUpdateScheduledMessage,
useDeleteScheduledMessage,
} from './api'
function formatDate(d) {
try {
return new Date(d).toLocaleString()
} catch {
return d
}
}
export default function ScheduleMessageModal({ session, onClose }) {
const [message, setMessage] = useState('')
const [scheduledAt, setScheduledAt] = useState(() => dayjs().add(1, 'hour'))
const [editingId, setEditingId] = useState(null)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const { data: scheduledMessages, isLoading } = useScheduledMessages(session?.id)
const createMutation = useCreateScheduledMessage()
const updateMutation = useUpdateScheduledMessage()
const deleteMutation = useDeleteScheduledMessage()
const handleClose = () => {
setMessage('')
setScheduledAt(dayjs().add(1, 'hour'))
setEditingId(null)
setError('')
setSuccess('')
onClose()
}
const handleEdit = (msg) => {
setMessage(msg.message)
setScheduledAt(dayjs(msg.scheduled_at))
setEditingId(msg.id)
setError('')
setSuccess('')
}
const handleCancelEdit = () => {
setMessage('')
setScheduledAt(dayjs().add(1, 'hour'))
setEditingId(null)
setError('')
setSuccess('')
}
const handleSave = async () => {
setError('')
setSuccess('')
if (!message.trim()) {
setError('El mensaje no puede estar vacío')
return
}
if (!session) return
const isoDate = scheduledAt.toISOString()
try {
if (editingId) {
await updateMutation.mutateAsync({
id: editingId,
sessionId: session.id,
message: message.trim(),
scheduledAt: isoDate,
})
setSuccess('Mensaje programado actualizado')
} else {
await createMutation.mutateAsync({
sessionId: session.id,
message: message.trim(),
scheduledAt: isoDate,
})
setSuccess('Mensaje programado creado')
}
setMessage('')
setScheduledAt(dayjs().add(1, 'hour'))
setEditingId(null)
} catch (err) {
setError(err.message)
}
}
const handleDelete = async (id) => {
if (!session) return
if (!window.confirm('¿Eliminar este mensaje programado?')) return
setError('')
setSuccess('')
try {
await deleteMutation.mutateAsync({ id, sessionId: session.id })
setSuccess('Mensaje programado eliminado')
} catch (err) {
setError(err.message)
}
}
return (
<Dialog open={!!session} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle>
Programar mensaje: {session?.name || session?.phone || session?.id}
</DialogTitle>
<DialogContent>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<DateTimePicker
label="Fecha y hora"
value={scheduledAt}
onChange={(v) => setScheduledAt(v)}
ampm={false}
slotProps={{
textField: {
fullWidth: true,
size: 'small',
readOnly: true,
},
}}
disablePast
/>
<TextField
fullWidth
size="small"
label="Mensaje"
multiline
minRows={3}
maxRows={6}
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Escribe el mensaje a programar..."
/>
{error && <Alert severity="error">{error}</Alert>}
{success && <Alert severity="success">{success}</Alert>}
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
{editingId && (
<Button onClick={handleCancelEdit} color="inherit">
Cancelar edición
</Button>
)}
<Button
variant="contained"
onClick={handleSave}
disabled={!message.trim() || createMutation.isPending || updateMutation.isPending}
>
{createMutation.isPending || updateMutation.isPending ? (
<CircularProgress size={20} color="inherit" />
) : editingId ? (
'Guardar'
) : (
'Programar'
)}
</Button>
</Box>
</Box>
</LocalizationProvider>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" gutterBottom>
Mensajes programados pendientes
</Typography>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress size={24} />
</Box>
) : scheduledMessages?.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No hay mensajes programados pendientes.
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{scheduledMessages?.map((msg) => (
<Box
key={msg.id}
sx={{
p: 1.5,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 1,
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ wordBreak: 'break-word' }}>
{msg.message}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(msg.scheduled_at)}
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton size="small" onClick={() => handleEdit(msg)} disabled={deleteMutation.isPending}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton size="small" onClick={() => handleDelete(msg.id)} disabled={deleteMutation.isPending}>
<DeleteIcon fontSize="small" color="error" />
</IconButton>
</Box>
</Box>
))}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cerrar</Button>
</DialogActions>
</Dialog>
)
}
@@ -20,7 +20,7 @@ function formatDate(d) {
}
}
export default function SessionCard({ session, onBlock, onChat }) {
export default function SessionCard({ session, onBlock, onChat, onSchedule }) {
const [anchor, setAnchor] = useState(null)
const deleteMutation = useDeleteSession()
const unblockMutation = useUnblockSession()
@@ -91,6 +91,9 @@ export default function SessionCard({ session, onBlock, onChat }) {
<MenuItem onClick={handleChat}>
Chat
</MenuItem>
<MenuItem onClick={() => { setAnchor(null); onSchedule(session) }}>
Programar
</MenuItem>
{session.block ? (
<MenuItem onClick={handleUnblock} sx={{ color: 'error.main' }}>
Desbloquear
@@ -17,12 +17,14 @@ import { useSessions } from './api'
import SessionCard from './SessionCard'
import BlockSessionModal from './BlockSessionModal'
import ChatSessionModal from './ChatSessionModal'
import ScheduleMessageModal from './ScheduleMessageModal'
export default function SessionsPage() {
const [campaignFilter, setCampaignFilter] = useState('')
const [search, setSearch] = useState('')
const [blockingSession, setBlockingSession] = useState(null)
const [chatSession, setChatSession] = useState(null)
const [scheduleSession, setScheduleSession] = useState(null)
const { data: campaigns } = useCampaigns()
const { data: sessions, isLoading, refetch } = useSessions(campaignFilter)
@@ -100,6 +102,7 @@ export default function SessionsPage() {
session={s}
onBlock={(session) => setBlockingSession(session)}
onChat={(session) => setChatSession(session)}
onSchedule={(session) => setScheduleSession(session)}
/>
))}
</Box>
@@ -115,6 +118,10 @@ export default function SessionsPage() {
session={chatSession}
onClose={() => setChatSession(null)}
/>
<ScheduleMessageModal
session={scheduleSession}
onClose={() => setScheduleSession(null)}
/>
</Box>
)
}
+77
View File
@@ -130,3 +130,80 @@ export function useSendMessage() {
},
})
}
export function useScheduledMessages(sessionId) {
return useQuery({
queryKey: ['scheduledMessages', sessionId],
queryFn: async () => {
if (!sessionId) return []
const { data, error } = await supabase
.from('scheduled_messages')
.select('*')
.eq('session_id', sessionId)
.eq('status', 'pending')
.order('scheduled_at', { ascending: true })
if (error) throw new Error(error.message)
return data || []
},
enabled: !!sessionId,
})
}
export function useCreateScheduledMessage() {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ sessionId, message, scheduledAt }) => {
const { data, error } = await supabase
.from('scheduled_messages')
.insert({
session_id: sessionId,
message,
scheduled_at: scheduledAt,
status: 'pending',
})
.select()
.single()
if (error) throw new Error(error.message)
return data
},
onSuccess: (_, { sessionId }) => {
qc.invalidateQueries({ queryKey: ['scheduledMessages', sessionId] })
},
})
}
export function useUpdateScheduledMessage() {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ id, message, scheduledAt }) => {
const { error } = await supabase
.from('scheduled_messages')
.update({
message,
scheduled_at: scheduledAt,
updated_at: new Date().toISOString(),
})
.eq('id', id)
if (error) throw new Error(error.message)
},
onSuccess: (_, { sessionId }) => {
qc.invalidateQueries({ queryKey: ['scheduledMessages', sessionId] })
},
})
}
export function useDeleteScheduledMessage() {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ id }) => {
const { error } = await supabase
.from('scheduled_messages')
.delete()
.eq('id', id)
if (error) throw new Error(error.message)
},
onSuccess: (_, { sessionId }) => {
qc.invalidateQueries({ queryKey: ['scheduledMessages', sessionId] })
},
})
}
+8 -1
View File
@@ -5,6 +5,7 @@ import LoginPage from './features/auth/LoginPage'
import CampaignsPage from './features/campaigns/CampaignsPage'
import SessionsPage from './features/sessions/SessionsPage'
import MediaPage from './features/media/MediaPage'
import InstancesPage from './features/instances/InstancesPage'
import { supabase } from './lib/supabase'
const rootRoute = createRootRoute({
@@ -58,10 +59,16 @@ const mediaRoute = createRoute({
component: MediaPage,
})
const instancesRoute = createRoute({
getParentRoute: () => appLayoutRoute,
path: '/instances',
component: InstancesPage,
})
const routeTree = rootRoute.addChildren([
indexRoute,
loginRoute,
appLayoutRoute.addChildren([campaignsRoute, sessionsRoute, mediaRoute]),
appLayoutRoute.addChildren([campaignsRoute, sessionsRoute, mediaRoute, instancesRoute]),
])
export const router = createRouter({ routeTree })