chore(ops): restore missing root files in newwhats.clube67.com
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* authStore.ts — Estado de autenticação global via Zustand.
|
||||
* O AuthContext (Next.js) permanece como provider de redirect guard.
|
||||
* Este store é usado pelos demais stores (chatStore, etc.) sem hook drilling.
|
||||
*/
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import { authApi } from '../services/chatApiService'
|
||||
import { socketService } from '../services/socketService'
|
||||
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
role: 'USER' | 'ADMIN'
|
||||
planId: string | null
|
||||
daysRemaining: number | null
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: AuthUser | null
|
||||
token: string | null
|
||||
refreshToken: string | null
|
||||
isHydrated: boolean
|
||||
|
||||
// Actions
|
||||
setAuth: (token: string, refreshToken: string, user: AuthUser) => void
|
||||
clearAuth: () => void
|
||||
refreshSession: () => Promise<boolean>
|
||||
fetchMe: () => Promise<void>
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
isHydrated: false,
|
||||
|
||||
setAuth: (token, refreshToken, user) => {
|
||||
localStorage.setItem('token', token)
|
||||
localStorage.setItem('refreshToken', refreshToken)
|
||||
if (typeof window !== 'undefined') {
|
||||
document.cookie = `accessToken=${token}; path=/; SameSite=Lax; Secure`
|
||||
}
|
||||
socketService.connect(token)
|
||||
set({ token, refreshToken, user })
|
||||
},
|
||||
|
||||
clearAuth: () => {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refreshToken')
|
||||
if (typeof window !== 'undefined') {
|
||||
document.cookie = 'accessToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'
|
||||
}
|
||||
socketService.disconnect()
|
||||
set({ token: null, refreshToken: null, user: null })
|
||||
},
|
||||
|
||||
refreshSession: async () => {
|
||||
const { refreshToken } = get()
|
||||
if (!refreshToken) return false
|
||||
try {
|
||||
const { data } = await import('axios').then(({ default: axios }) =>
|
||||
axios.post(
|
||||
`${process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8008'}/api/auth/refresh`,
|
||||
{ refreshToken }
|
||||
)
|
||||
)
|
||||
get().setAuth(data.accessToken, data.refreshToken, {
|
||||
id: data.user.id,
|
||||
email: data.user.email,
|
||||
name: data.user.name ?? null,
|
||||
role: data.user.role as 'USER' | 'ADMIN',
|
||||
planId: data.user.planId ?? null,
|
||||
daysRemaining: data.user.daysRemaining ?? null,
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
get().clearAuth()
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
fetchMe: async () => {
|
||||
try {
|
||||
const me = await authApi.me()
|
||||
if (me?.id) {
|
||||
set({
|
||||
user: {
|
||||
id: me.id,
|
||||
email: me.email,
|
||||
name: me.name ?? null,
|
||||
role: (me.role ?? 'USER') as 'USER' | 'ADMIN',
|
||||
planId: me.planId ?? null,
|
||||
daysRemaining: me.daysRemaining ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// token inválido — clearAuth será chamado pelo interceptor 401
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'newwhats:auth',
|
||||
partialize: (s) => ({ token: s.token, refreshToken: s.refreshToken, user: s.user }),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (state) {
|
||||
state.isHydrated = true
|
||||
// Reconecta socket ao restaurar sessão do localStorage
|
||||
if (state.token) {
|
||||
socketService.connect(state.token)
|
||||
if (typeof window !== 'undefined') {
|
||||
document.cookie = `accessToken=${state.token}; path=/; SameSite=Lax; Secure`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* chatStore.ts — Store de Inbox: chats e mensagens.
|
||||
* Instâncias foram extraídas para instanceStore.ts.
|
||||
* Usa Zustand + Immer para mutações diretas e legíveis.
|
||||
*/
|
||||
import { create } from 'zustand'
|
||||
import { immer } from 'zustand/middleware/immer'
|
||||
import { socketService } from '../services/socketService'
|
||||
import { chatApi, messageApi } from '../services/chatApiService'
|
||||
|
||||
// ─── Mapper: resposta aninhada do backend → Chat plano do store ────────────────
|
||||
|
||||
function mapBackendChat(raw: any): Chat {
|
||||
return {
|
||||
id: raw.id,
|
||||
tenantId: raw.tenantId ?? '',
|
||||
instanceId: raw.instanceId,
|
||||
jid: raw.jid,
|
||||
name: raw.name ?? null,
|
||||
unreadCount: raw.unreadCount ?? 0,
|
||||
lastMessageAt: raw.lastMessageAt ?? null,
|
||||
createdAt: raw.createdAt ?? null,
|
||||
isPinned: raw.isPinned ?? false,
|
||||
isArchived: raw.isArchived ?? false,
|
||||
contactName: raw.contact?.name ?? raw.contactName ?? null,
|
||||
contactAvatarUrl: raw.contact?.avatarUrl ?? raw.contactAvatarUrl ?? null,
|
||||
contactAvatarVersion: raw.contact?.avatarVersion ?? raw.contactAvatarVersion ?? null,
|
||||
contactPhone: raw.contact?.phone ?? raw.contactPhone ?? null,
|
||||
scoreReputacao: raw.contact?.scoreReputacao ?? raw.scoreReputacao ?? 0,
|
||||
flagRestricao: raw.contact?.flagRestricao ?? raw.flagRestricao ?? false,
|
||||
lastMessageBody: raw.lastMessage?.body ?? raw.lastMessageBody ?? null,
|
||||
lastMessageFromMe: raw.lastMessage?.fromMe ?? raw.lastMessageFromMe ?? false,
|
||||
lastMessageStatus: raw.lastMessage?.status ?? raw.lastMessageStatus ?? null,
|
||||
lastMessageType: raw.lastMessage?.type ?? raw.lastMessageType ?? null,
|
||||
lastMessageSender: raw.lastMessage?.pushName ?? raw.lastMessageSender ?? null,
|
||||
protocolNumber: raw.protocol?.number ?? raw.protocolNumber ?? null,
|
||||
botPaused: raw.botPaused ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tipos ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Chat {
|
||||
id: string
|
||||
tenantId: string
|
||||
instanceId: string
|
||||
jid: string
|
||||
name: string | null // subject do grupo; null para individuais
|
||||
unreadCount: number
|
||||
lastMessageAt: string | null
|
||||
createdAt: string | null // quando o chat foi registrado (fallback de ordenação)
|
||||
isPinned: boolean
|
||||
isArchived: boolean
|
||||
contactName: string | null
|
||||
contactAvatarUrl: string | null
|
||||
contactAvatarVersion: string | null
|
||||
contactPhone: string | null
|
||||
scoreReputacao: number
|
||||
flagRestricao: boolean
|
||||
lastMessageBody: string | null
|
||||
lastMessageFromMe: boolean
|
||||
lastMessageStatus: string | null
|
||||
lastMessageType: string | null
|
||||
lastMessageSender: string | null // pushName do remetente (grupos: quem enviou a última msg)
|
||||
protocolNumber: string | null
|
||||
botPaused: boolean
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
chatId: string
|
||||
messageId: string
|
||||
fromMe: boolean
|
||||
type: string
|
||||
body: string | null
|
||||
mediaUrl: string | null
|
||||
mimeType: string | null
|
||||
fileName: string | null
|
||||
replyToId: string | null
|
||||
replyTo?: {
|
||||
id: string
|
||||
messageId: string
|
||||
body: string | null
|
||||
fromMe: boolean
|
||||
type: string
|
||||
mediaUrl: string | null
|
||||
} | null
|
||||
status: 'PENDING' | 'SENT' | 'DELIVERED' | 'READ' | 'FAILED'
|
||||
timestamp: string
|
||||
pushName?: string | null
|
||||
senderJid?: string | null
|
||||
}
|
||||
|
||||
// ─── Estado ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ChatState {
|
||||
chats: Chat[]
|
||||
chatsLoading: boolean
|
||||
activeChatId: string | null
|
||||
messages: Record<string, Message[]>
|
||||
messagesLoading: boolean
|
||||
|
||||
// Sync
|
||||
setChats: (chats: Chat[]) => void
|
||||
setChatsLoading: (v: boolean) => void
|
||||
upsertChat: (chat: Chat) => void
|
||||
patchChat: (chatId: string, patch: Partial<Chat>) => void
|
||||
setActiveChat: (chatId: string | null) => void
|
||||
clearUnread: (chatId: string) => void
|
||||
setMessages: (chatId: string, msgs: Message[]) => void
|
||||
setMessagesLoading: (v: boolean) => void
|
||||
appendMessage: (msg: Message) => void
|
||||
patchMessage: (msgId: string, patch: Partial<Message>) => void
|
||||
updateMediaReady: (msgId: string, mediaUrl: string) => void
|
||||
|
||||
// Async
|
||||
loadChats: (instanceId: string, opts?: { search?: string; archived?: boolean }) => Promise<void>
|
||||
loadMessages: (chatId: string, opts?: { before?: string }) => Promise<{ hasMore: boolean }>
|
||||
selectChat: (chatId: string) => Promise<void>
|
||||
sendMessage: (instanceId: string, jid: string, text: string, replyToMessageId?: string, mentions?: string[]) => Promise<void>
|
||||
|
||||
// Socket
|
||||
initSocketListeners: () => void
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function chatSortKey(c: Chat): number {
|
||||
// WhatsApp ordena por: timestamp da última msg; se não houver msg, usa hora de criação do chat
|
||||
if (c.lastMessageAt) return new Date(c.lastMessageAt).getTime()
|
||||
if (c.createdAt) return new Date(c.createdAt).getTime()
|
||||
return 0
|
||||
}
|
||||
|
||||
function sortChats(chats: Chat[]): Chat[] {
|
||||
return [...chats].sort((a, b) => {
|
||||
// Fixados sempre primeiro (mesma lógica do WhatsApp oficial)
|
||||
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1
|
||||
return chatSortKey(b) - chatSortKey(a)
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Store ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ─── Cache local (localStorage) — UX instantâneo no reload ─────────────────────
|
||||
// Persiste os primeiros N chats por instância para hidratar a UI antes do fetch.
|
||||
// Chave: `inboxCache:v1:${instanceId}:${archived ? '1' : '0'}`
|
||||
// TTL implícito: validamos pela data salva e descartamos se > 7 dias.
|
||||
const INBOX_CACHE_PREFIX = 'inboxCache:v1'
|
||||
const INBOX_CACHE_LIMIT = 10
|
||||
const INBOX_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
interface InboxCacheEntry {
|
||||
savedAt: number
|
||||
chats: Chat[]
|
||||
}
|
||||
|
||||
function inboxCacheKey(instanceId: string, archived: boolean): string {
|
||||
return `${INBOX_CACHE_PREFIX}:${instanceId}:${archived ? '1' : '0'}`
|
||||
}
|
||||
|
||||
function readInboxCache(instanceId: string, archived: boolean): Chat[] | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
const raw = localStorage.getItem(inboxCacheKey(instanceId, archived))
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as InboxCacheEntry
|
||||
if (!parsed || !Array.isArray(parsed.chats)) return null
|
||||
if (Date.now() - parsed.savedAt > INBOX_CACHE_MAX_AGE_MS) return null
|
||||
return parsed.chats
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function writeInboxCache(instanceId: string, archived: boolean, chats: Chat[]): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
const slim = chats.slice(0, INBOX_CACHE_LIMIT)
|
||||
const entry: InboxCacheEntry = { savedAt: Date.now(), chats: slim }
|
||||
localStorage.setItem(inboxCacheKey(instanceId, archived), JSON.stringify(entry))
|
||||
} catch { /* quota exceeded — ignora */ }
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>()(
|
||||
immer((set, get) => ({
|
||||
chats: [],
|
||||
chatsLoading: false,
|
||||
activeChatId: null,
|
||||
messages: {},
|
||||
messagesLoading: false,
|
||||
|
||||
// ── Chats ─────────────────────────────────────────────────────────────────
|
||||
|
||||
setChats: (chats) =>
|
||||
set((s) => { s.chats = sortChats(chats) }),
|
||||
|
||||
setChatsLoading: (v) =>
|
||||
set((s) => { s.chatsLoading = v }),
|
||||
|
||||
upsertChat: (chat) =>
|
||||
set((s) => {
|
||||
const idx = s.chats.findIndex((c) => c.id === chat.id)
|
||||
if (idx !== -1) s.chats[idx] = chat
|
||||
else s.chats.unshift(chat)
|
||||
s.chats = sortChats(s.chats as Chat[])
|
||||
}),
|
||||
|
||||
patchChat: (chatId, patch) =>
|
||||
set((s) => {
|
||||
const idx = s.chats.findIndex((c) => c.id === chatId)
|
||||
if (idx !== -1) {
|
||||
Object.assign(s.chats[idx], patch)
|
||||
s.chats = sortChats(s.chats as Chat[])
|
||||
}
|
||||
}),
|
||||
|
||||
setActiveChat: (chatId) =>
|
||||
set((s) => { s.activeChatId = chatId }),
|
||||
|
||||
clearUnread: (chatId) =>
|
||||
set((s) => {
|
||||
const c = s.chats.find((c) => c.id === chatId)
|
||||
if (c) c.unreadCount = 0
|
||||
}),
|
||||
|
||||
// ── Mensagens ─────────────────────────────────────────────────────────────
|
||||
|
||||
setMessages: (chatId, msgs) =>
|
||||
set((s) => { s.messages[chatId] = msgs }),
|
||||
|
||||
setMessagesLoading: (v) =>
|
||||
set((s) => { s.messagesLoading = v }),
|
||||
|
||||
appendMessage: (msg) =>
|
||||
set((s) => {
|
||||
const list = s.messages[msg.chatId]
|
||||
if (!list) { s.messages[msg.chatId] = [msg]; return }
|
||||
const exists = list.some((m) => m.id === msg.id || m.messageId === msg.messageId)
|
||||
if (!exists) list.push(msg)
|
||||
}),
|
||||
|
||||
patchMessage: (msgId, patch) =>
|
||||
set((s) => {
|
||||
for (const list of Object.values(s.messages)) {
|
||||
const idx = list.findIndex((m) => m.id === msgId || m.messageId === msgId)
|
||||
if (idx !== -1) { Object.assign(list[idx], patch); break }
|
||||
}
|
||||
}),
|
||||
|
||||
updateMediaReady: (msgId, mediaUrl) =>
|
||||
set((s) => {
|
||||
for (const list of Object.values(s.messages)) {
|
||||
const msg = list.find((m) => m.id === msgId)
|
||||
if (msg) { msg.mediaUrl = mediaUrl; break }
|
||||
}
|
||||
}),
|
||||
|
||||
// ── Async ─────────────────────────────────────────────────────────────────
|
||||
|
||||
loadChats: async (instanceId, opts = {}) => {
|
||||
// Hidratação instantânea: se temos cache local válido para esta instância,
|
||||
// mostra os 10 chats salvos imediatamente enquanto a request real roda.
|
||||
// Evita tela em branco ou skeleton de 15s+ em redes lentas.
|
||||
const archivedFlag = opts.archived ?? false
|
||||
const hasSearch = !!opts.search
|
||||
const current = get().chats
|
||||
if (!hasSearch) {
|
||||
const sameInstance = current.length > 0 && current[0]?.instanceId === instanceId
|
||||
if (!sameInstance) {
|
||||
const cached = readInboxCache(instanceId, archivedFlag)
|
||||
if (cached && cached.length > 0) {
|
||||
set((s) => { s.chats = sortChats(cached); s.chatsLoading = true })
|
||||
} else {
|
||||
set((s) => { s.chatsLoading = true })
|
||||
}
|
||||
} else {
|
||||
set((s) => { s.chatsLoading = true })
|
||||
}
|
||||
} else {
|
||||
set((s) => { s.chatsLoading = true })
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await chatApi.list(instanceId, opts)
|
||||
const data: Chat[] = (Array.isArray(raw) ? raw : []).map(mapBackendChat)
|
||||
const sorted = sortChats(data)
|
||||
set((s) => { s.chats = sorted; s.chatsLoading = false })
|
||||
if (!hasSearch) writeInboxCache(instanceId, archivedFlag, sorted)
|
||||
} catch {
|
||||
set((s) => { s.chatsLoading = false })
|
||||
}
|
||||
},
|
||||
|
||||
loadMessages: async (chatId, opts = {}) => {
|
||||
console.log('[chatStore] loadMessages chamado:', chatId, opts)
|
||||
set((s) => { s.messagesLoading = true })
|
||||
try {
|
||||
const res = await messageApi.list(chatId, opts)
|
||||
console.log('[chatStore] API retornou:', { hasMore: res.hasMore, count: res.messages?.length, raw: typeof res })
|
||||
const { messages, hasMore } = res
|
||||
if (!Array.isArray(messages)) {
|
||||
console.error('[chatStore] messages NÃO é array!', messages)
|
||||
set((s) => { s.messagesLoading = false })
|
||||
return { hasMore: false }
|
||||
}
|
||||
set((s) => {
|
||||
if (opts.before) {
|
||||
const existing = s.messages[chatId] ?? []
|
||||
const ids = new Set(existing.map((m) => m.id))
|
||||
s.messages[chatId] = [...messages.filter((m: Message) => !ids.has(m.id)), ...existing]
|
||||
} else {
|
||||
s.messages[chatId] = messages
|
||||
}
|
||||
s.messagesLoading = false
|
||||
})
|
||||
console.log('[chatStore] messages armazenadas:', get().messages[chatId]?.length)
|
||||
return { hasMore }
|
||||
} catch (err) {
|
||||
console.error('[chatStore] loadMessages ERRO:', err)
|
||||
set((s) => { s.messagesLoading = false })
|
||||
return { hasMore: false }
|
||||
}
|
||||
},
|
||||
|
||||
selectChat: async (chatId) => {
|
||||
console.log('[chatStore] selectChat:', chatId)
|
||||
const prev = get().activeChatId
|
||||
if (prev && prev !== chatId) socketService.leaveChat(prev)
|
||||
set((s) => { s.activeChatId = chatId })
|
||||
socketService.joinChat(chatId)
|
||||
get().clearUnread(chatId)
|
||||
chatApi.markRead(chatId).catch(() => {})
|
||||
await get().loadMessages(chatId)
|
||||
console.log('[chatStore] selectChat completo. Messages:', get().messages[chatId]?.length)
|
||||
},
|
||||
|
||||
sendMessage: async (instanceId, jid, text, replyToMessageId, mentions) => {
|
||||
const chatId = get().chats.find((c) => c.jid === jid && c.instanceId === instanceId)?.id
|
||||
if (!chatId) return
|
||||
|
||||
// Resolve replyTo a partir das mensagens já no store
|
||||
const replyToMsg = replyToMessageId
|
||||
? get().messages[chatId]?.find((m) => m.messageId === replyToMessageId) ?? null
|
||||
: null
|
||||
|
||||
const optimistic: Message = {
|
||||
id: `opt-${Date.now()}`,
|
||||
chatId,
|
||||
messageId: `opt-${Date.now()}`,
|
||||
fromMe: true,
|
||||
type: 'TEXT',
|
||||
body: text,
|
||||
mediaUrl: null,
|
||||
mimeType: null,
|
||||
fileName: null,
|
||||
replyToId: replyToMsg?.id ?? null,
|
||||
replyTo: replyToMsg
|
||||
? { id: replyToMsg.id, messageId: replyToMsg.messageId, body: replyToMsg.body, fromMe: replyToMsg.fromMe, type: replyToMsg.type, mediaUrl: replyToMsg.mediaUrl }
|
||||
: null,
|
||||
status: 'PENDING',
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
get().appendMessage(optimistic)
|
||||
|
||||
try {
|
||||
const saved = await messageApi.send(instanceId, jid, text, replyToMessageId, mentions)
|
||||
get().patchMessage(optimistic.id, { id: saved.id, messageId: saved.messageId, status: 'SENT' })
|
||||
get().patchChat(chatId, {
|
||||
lastMessageBody: text,
|
||||
lastMessageFromMe: true,
|
||||
lastMessageAt: saved.timestamp,
|
||||
lastMessageStatus: 'SENT',
|
||||
lastMessageType: 'TEXT',
|
||||
})
|
||||
} catch {
|
||||
get().patchMessage(optimistic.id, { status: 'FAILED' })
|
||||
}
|
||||
},
|
||||
|
||||
// ── Socket ────────────────────────────────────────────────────────────────
|
||||
|
||||
initSocketListeners: () => {
|
||||
// IMPORTANTE: sempre usar get() dentro dos callbacks para ler estado atual.
|
||||
// Capturar `const store = get()` fora criaria uma closure stale.
|
||||
|
||||
socketService.on('message:new', (msg: Message) => {
|
||||
console.log('[socket] message:new recebida:', { chatId: msg.chatId, body: msg.body?.slice(0, 40), fromMe: msg.fromMe })
|
||||
get().appendMessage(msg)
|
||||
const current = get()
|
||||
const isActive = current.activeChatId === msg.chatId
|
||||
current.patchChat(msg.chatId, {
|
||||
lastMessageBody: msg.body,
|
||||
lastMessageFromMe: msg.fromMe,
|
||||
lastMessageAt: msg.timestamp,
|
||||
lastMessageStatus: msg.status,
|
||||
lastMessageType: msg.type,
|
||||
lastMessageSender: msg.fromMe ? null : (msg.pushName ?? null),
|
||||
unreadCount: msg.fromMe || isActive
|
||||
? 0
|
||||
: (current.chats.find((c) => c.id === msg.chatId)?.unreadCount ?? 0) + 1,
|
||||
})
|
||||
})
|
||||
|
||||
socketService.on('message:media_ready', ({ messageId, mediaUrl }: { messageId: string; mediaUrl: string }) => {
|
||||
get().updateMediaReady(messageId, mediaUrl)
|
||||
})
|
||||
|
||||
socketService.on('message:status', ({ messageId, status }: { messageId: string; status: Message['status'] }) => {
|
||||
get().patchMessage(messageId, { status })
|
||||
})
|
||||
|
||||
socketService.on('chat:upsert', (raw: any) => {
|
||||
get().upsertChat(mapBackendChat(raw))
|
||||
})
|
||||
|
||||
socketService.on('bot:escalated', ({ chatId }: { chatId: string }) => {
|
||||
get().patchChat(chatId, { botPaused: true })
|
||||
})
|
||||
|
||||
socketService.on('contact:avatar', ({ instanceId, jid, avatarUrl }: { instanceId: string; jid: string; avatarUrl: string }) => {
|
||||
set((s) => {
|
||||
const chat = s.chats.find((c) => c.instanceId === instanceId && c.jid === jid)
|
||||
if (chat) chat.contactAvatarUrl = avatarUrl
|
||||
})
|
||||
})
|
||||
|
||||
// Reconciliação de nomes concluída — re-busca lista de chats
|
||||
socketService.on('chats:refresh', ({ instanceId }: { instanceId: string }) => {
|
||||
const chats = get().chats
|
||||
if (chats.length > 0 && chats[0]?.instanceId === instanceId) {
|
||||
get().loadChats(instanceId)
|
||||
}
|
||||
})
|
||||
|
||||
// Reconciliação via ContactHandler (pós-sync)
|
||||
socketService.on('contacts:reconciled', ({ instanceId }: { instanceId: string }) => {
|
||||
const chats = get().chats
|
||||
if (chats.length > 0 && chats[0]?.instanceId === instanceId) {
|
||||
get().loadChats(instanceId)
|
||||
}
|
||||
})
|
||||
},
|
||||
}))
|
||||
)
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* instanceStore.ts — Gerencia instâncias WhatsApp e QR Codes.
|
||||
* Separado do chatStore para responsabilidade única.
|
||||
*/
|
||||
import { create } from 'zustand'
|
||||
import { immer } from 'zustand/middleware/immer'
|
||||
import { instanceApi } from '../services/chatApiService'
|
||||
import { socketService } from '../services/socketService'
|
||||
import { notify } from './notificationStore'
|
||||
|
||||
export type InstanceStatus = 'DISCONNECTED' | 'CONNECTING' | 'QR_PENDING' | 'CONNECTED' | 'BANNED'
|
||||
|
||||
export interface Instance {
|
||||
id: string
|
||||
name: string
|
||||
status: InstanceStatus
|
||||
phone: string | null
|
||||
avatar: string | null
|
||||
}
|
||||
|
||||
interface InstanceState {
|
||||
instances: Instance[]
|
||||
activeInstanceId: string | null
|
||||
qrByInstance: Record<string, string>
|
||||
loading: boolean
|
||||
|
||||
// Sync actions
|
||||
setInstances: (instances: Instance[]) => void
|
||||
setActiveInstance: (id: string | null) => void
|
||||
patchInstance: (id: string, patch: Partial<Instance>) => void
|
||||
setQr: (instanceId: string, qrBase64: string) => void
|
||||
clearQr: (instanceId: string) => void
|
||||
removeInstance: (id: string) => void
|
||||
|
||||
// Async actions
|
||||
loadInstances: () => Promise<void>
|
||||
connectInstance: (id: string) => Promise<void>
|
||||
disconnectInstance: (id: string) => Promise<void>
|
||||
createInstance: (name: string) => Promise<Instance>
|
||||
deleteInstance: (id: string) => Promise<void>
|
||||
|
||||
// Socket
|
||||
initSocketListeners: () => void
|
||||
}
|
||||
|
||||
export const useInstanceStore = create<InstanceState>()(
|
||||
immer((set, get) => ({
|
||||
instances: [],
|
||||
// Persiste a seleção ativa no localStorage para sobreviver a reloads
|
||||
activeInstanceId: typeof window !== 'undefined' ? localStorage.getItem('activeInstanceId') : null,
|
||||
qrByInstance: {},
|
||||
loading: false,
|
||||
|
||||
// ── Sync ─────────────────────────────────────────────────────────────────
|
||||
|
||||
setInstances: (instances) =>
|
||||
set((s) => { s.instances = instances }),
|
||||
|
||||
setActiveInstance: (id) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (id) localStorage.setItem('activeInstanceId', id)
|
||||
else localStorage.removeItem('activeInstanceId')
|
||||
}
|
||||
set((s) => { s.activeInstanceId = id })
|
||||
},
|
||||
|
||||
patchInstance: (id, patch) =>
|
||||
set((s) => {
|
||||
const idx = s.instances.findIndex((i) => i.id === id)
|
||||
if (idx !== -1) Object.assign(s.instances[idx], patch)
|
||||
else s.instances.push({ id, name: '', phone: null, status: 'CONNECTING', ...patch } as Instance)
|
||||
}),
|
||||
|
||||
setQr: (instanceId, qrBase64) =>
|
||||
set((s) => { s.qrByInstance[instanceId] = qrBase64 }),
|
||||
|
||||
clearQr: (instanceId) =>
|
||||
set((s) => { delete s.qrByInstance[instanceId] }),
|
||||
|
||||
removeInstance: (id) =>
|
||||
set((s) => { s.instances = s.instances.filter((i) => i.id !== id) }),
|
||||
|
||||
// ── Async ─────────────────────────────────────────────────────────────────
|
||||
|
||||
loadInstances: async () => {
|
||||
set((s) => { s.loading = true })
|
||||
try {
|
||||
const data = await instanceApi.list()
|
||||
const list: Instance[] = (Array.isArray(data) ? data : (data.instances ?? [])).map((i: any) => ({
|
||||
...i,
|
||||
avatar: i.avatar ?? null,
|
||||
}))
|
||||
set((s) => {
|
||||
s.loading = false
|
||||
// Mescla em vez de substituir: preserva status CONNECTED que veio via socket
|
||||
// (socket events são mais recentes que leituras REST — o banco pode estar
|
||||
// desatualizado se a resposta da API chegou antes do DB ser gravado pelo backend)
|
||||
const memMap = new Map(s.instances.map((i) => [i.id, i]))
|
||||
s.instances = list.map((fromDb) => {
|
||||
const inMem = memMap.get(fromDb.id)
|
||||
if (inMem?.status === 'CONNECTED' && fromDb.status !== 'CONNECTED') {
|
||||
// Socket já confirmou CONNECTED — mantém, não deixa REST stale sobrescrever
|
||||
return { ...fromDb, status: 'CONNECTED' as InstanceStatus }
|
||||
}
|
||||
return fromDb
|
||||
})
|
||||
})
|
||||
} catch {
|
||||
set((s) => { s.loading = false })
|
||||
}
|
||||
},
|
||||
|
||||
connectInstance: async (id) => {
|
||||
get().patchInstance(id, { status: 'CONNECTING' })
|
||||
socketService.joinInstance(id)
|
||||
await instanceApi.connect(id)
|
||||
},
|
||||
|
||||
disconnectInstance: async (id) => {
|
||||
await instanceApi.disconnect(id)
|
||||
get().patchInstance(id, { status: 'DISCONNECTED' })
|
||||
get().clearQr(id)
|
||||
},
|
||||
|
||||
createInstance: async (name) => {
|
||||
const created: Instance = await instanceApi.create(name)
|
||||
set((s) => { s.instances.push(created) })
|
||||
return created
|
||||
},
|
||||
|
||||
deleteInstance: async (id) => {
|
||||
await instanceApi.delete(id)
|
||||
get().removeInstance(id)
|
||||
if (get().activeInstanceId === id) {
|
||||
set((s) => { s.activeInstanceId = null })
|
||||
}
|
||||
},
|
||||
|
||||
// ── Socket ────────────────────────────────────────────────────────────────
|
||||
|
||||
initSocketListeners: () => {
|
||||
socketService.on('instance:status', ({
|
||||
instanceId,
|
||||
status,
|
||||
qrBase64,
|
||||
}: {
|
||||
instanceId: string
|
||||
status: InstanceStatus
|
||||
qrBase64?: string
|
||||
}) => {
|
||||
get().patchInstance(instanceId, { status })
|
||||
if (qrBase64) get().setQr(instanceId, qrBase64)
|
||||
if (status === 'CONNECTED') get().clearQr(instanceId)
|
||||
})
|
||||
|
||||
socketService.on('qr', ({ instanceId, qrBase64 }: { instanceId: string; qrBase64: string }) => {
|
||||
get().setQr(instanceId, qrBase64)
|
||||
get().patchInstance(instanceId, { status: 'QR_PENDING' })
|
||||
})
|
||||
|
||||
socketService.on('instance:avatar', ({ instanceId, avatar }: { instanceId: string; avatar: string }) => {
|
||||
get().patchInstance(instanceId, { avatar })
|
||||
})
|
||||
|
||||
socketService.on('instance:syncing', ({ instanceId, gapMinutes }: { instanceId: string; gapMinutes: number }) => {
|
||||
const inst = get().instances.find((i) => i.id === instanceId)
|
||||
const label = inst?.name ?? instanceId.slice(0, 8)
|
||||
if (gapMinutes >= 60) {
|
||||
notify(
|
||||
`📥 ${label}: sincronizando mensagens dos últimos ${Math.round(gapMinutes / 60)}h`,
|
||||
'warning',
|
||||
8000,
|
||||
'syncing',
|
||||
)
|
||||
} else if (gapMinutes > 0) {
|
||||
notify(
|
||||
`📥 ${label}: buscando mensagens perdidas (${gapMinutes} min offline)`,
|
||||
'info',
|
||||
6000,
|
||||
'syncing',
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
}))
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type NotificationType = 'info' | 'warning' | 'success' | 'error'
|
||||
|
||||
export interface Notification {
|
||||
id: string
|
||||
tag?: string
|
||||
message: string
|
||||
type: NotificationType
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
interface NotificationState {
|
||||
notifications: Notification[]
|
||||
timers: Record<string, ReturnType<typeof setTimeout>>
|
||||
add: (msg: string, type?: NotificationType, durationMs?: number, tag?: string) => void
|
||||
remove: (id: string) => void
|
||||
}
|
||||
|
||||
export const useNotificationStore = create<NotificationState>((set, get) => ({
|
||||
notifications: [],
|
||||
timers: {},
|
||||
|
||||
add(msg, type = 'info', durationMs = 5000, tag) {
|
||||
const { timers } = get()
|
||||
|
||||
// Se tem tag, remove a notificação anterior com a mesma tag (sem animação de saída)
|
||||
// e cancela o timer dela, para não empilhar
|
||||
if (tag) {
|
||||
const existing = get().notifications.find((n) => n.tag === tag)
|
||||
if (existing) {
|
||||
if (timers[existing.id]) {
|
||||
clearTimeout(timers[existing.id])
|
||||
set((s) => {
|
||||
const { [existing.id]: _, ...rest } = s.timers
|
||||
return { timers: rest }
|
||||
})
|
||||
}
|
||||
set((s) => ({ notifications: s.notifications.filter((n) => n.tag !== tag) }))
|
||||
}
|
||||
}
|
||||
|
||||
const id = tag ?? `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||
set((s) => ({ notifications: [...s.notifications, { id, tag, message: msg, type, durationMs }] }))
|
||||
|
||||
if (durationMs > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
set((s) => ({
|
||||
notifications: s.notifications.filter((n) => n.id !== id),
|
||||
timers: Object.fromEntries(Object.entries(s.timers).filter(([k]) => k !== id)),
|
||||
}))
|
||||
}, durationMs)
|
||||
set((s) => ({ timers: { ...s.timers, [id]: timer } }))
|
||||
}
|
||||
},
|
||||
|
||||
remove(id) {
|
||||
const { timers } = get()
|
||||
if (timers[id]) {
|
||||
clearTimeout(timers[id])
|
||||
set((s) => {
|
||||
const { [id]: _, ...rest } = s.timers
|
||||
return { timers: rest }
|
||||
})
|
||||
}
|
||||
set((s) => ({ notifications: s.notifications.filter((n) => n.id !== id) }))
|
||||
},
|
||||
}))
|
||||
|
||||
/** Atalho para usar fora de componentes React (stores, services, etc.) */
|
||||
export const notify = (msg: string, type?: NotificationType, durationMs?: number, tag?: string) =>
|
||||
useNotificationStore.getState().add(msg, type, durationMs, tag)
|
||||
@@ -0,0 +1,279 @@
|
||||
import { create } from 'zustand'
|
||||
import {
|
||||
secAgentApi, secNodeApi, secConvApi, secCalApi, secNumberApi,
|
||||
type SecAgent, type BrainNode, type SecConversation, type SecMessage, type CalendarSlot, type SecNumber,
|
||||
} from '../services/secretariaApi'
|
||||
|
||||
interface SecretariaState {
|
||||
// Data
|
||||
agents: SecAgent[]
|
||||
selectedAgent: SecAgent | null
|
||||
nodes: BrainNode[]
|
||||
conversations: SecConversation[]
|
||||
selectedConversation: SecConversation | null
|
||||
messages: SecMessage[]
|
||||
calendarSlots: CalendarSlot[]
|
||||
|
||||
// Loading flags
|
||||
loadingAgents: boolean
|
||||
loadingNodes: boolean
|
||||
loadingConversations: boolean
|
||||
loadingMessages: boolean
|
||||
sendingMessage: boolean
|
||||
loadingCalendar: boolean
|
||||
|
||||
// ── Agents ────────────────────────────────────────────────────────────────
|
||||
loadAgents: () => Promise<void>
|
||||
selectAgent: (a: SecAgent | null) => void
|
||||
createAgent: (d: Partial<SecAgent>) => Promise<SecAgent>
|
||||
updateAgent: (id: string, d: Partial<SecAgent>) => Promise<void>
|
||||
deleteAgent: (id: string) => Promise<void>
|
||||
|
||||
// ── Brain Nodes ───────────────────────────────────────────────────────────
|
||||
loadNodes: (agentId: string) => Promise<void>
|
||||
createNode: (agentId: string, d: Partial<BrainNode>) => Promise<void>
|
||||
updateNode: (id: string, d: Partial<BrainNode>) => Promise<void>
|
||||
deleteNode: (id: string) => Promise<void>
|
||||
|
||||
// ── Conversations ─────────────────────────────────────────────────────────
|
||||
loadConversations: (agentId?: string) => Promise<void>
|
||||
selectConversation: (c: SecConversation | null) => void
|
||||
createConversation: (agentId: string, contactName: string) => Promise<SecConversation>
|
||||
deleteConversation: (id: string) => Promise<void>
|
||||
updateConversationStatus: (id: string, status: SecConversation['status']) => Promise<void>
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────
|
||||
loadMessages: (convId: string) => Promise<void>
|
||||
sendMessage: (convId: string, text: string) => Promise<void>
|
||||
|
||||
// ── Calendar ──────────────────────────────────────────────────────────────
|
||||
loadCalendar: (params?: { from?: string; to?: string; status?: string }) => Promise<void>
|
||||
createCalendarSlot: (d: Partial<CalendarSlot>) => Promise<void>
|
||||
updateCalendarSlot: (id: string, d: Partial<CalendarSlot>) => Promise<void>
|
||||
deleteCalendarSlot: (id: string) => Promise<void>
|
||||
|
||||
// ── Numbers ───────────────────────────────────────────────────────────────
|
||||
numbers: SecNumber[]
|
||||
loadingNumbers: boolean
|
||||
loadNumbers: () => Promise<void>
|
||||
createNumber: (d: Partial<SecNumber>) => Promise<void>
|
||||
updateNumber: (id: string, d: Partial<SecNumber>) => Promise<void>
|
||||
deleteNumber: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
export const useSecretariaStore = create<SecretariaState>((set, get) => ({
|
||||
agents: [], selectedAgent: null, nodes: [],
|
||||
conversations: [], selectedConversation: null, messages: [],
|
||||
calendarSlots: [], numbers: [],
|
||||
loadingAgents: false, loadingNodes: false, loadingConversations: false,
|
||||
loadingMessages: false, sendingMessage: false, loadingCalendar: false, loadingNumbers: false,
|
||||
|
||||
// ── Agents ────────────────────────────────────────────────────────────────
|
||||
|
||||
loadAgents: async () => {
|
||||
set({ loadingAgents: true })
|
||||
try {
|
||||
const agents = await secAgentApi.list()
|
||||
set({ agents })
|
||||
// Auto-seleciona o primeiro agente se nenhum selecionado
|
||||
if (!get().selectedAgent && agents.length > 0) {
|
||||
get().selectAgent(agents[0])
|
||||
}
|
||||
} finally {
|
||||
set({ loadingAgents: false })
|
||||
}
|
||||
},
|
||||
|
||||
selectAgent: (a) => {
|
||||
set({ selectedAgent: a, nodes: [], conversations: [], selectedConversation: null, messages: [] })
|
||||
if (a) {
|
||||
get().loadNodes(a.id)
|
||||
get().loadConversations(a.id)
|
||||
}
|
||||
},
|
||||
|
||||
createAgent: async (d) => {
|
||||
const agent = await secAgentApi.create(d)
|
||||
set((s) => ({ agents: [...s.agents, agent] }))
|
||||
return agent
|
||||
},
|
||||
|
||||
updateAgent: async (id, d) => {
|
||||
const updated = await secAgentApi.update(id, d)
|
||||
set((s) => ({
|
||||
agents: s.agents.map((a) => (a.id === id ? updated : a)),
|
||||
selectedAgent: s.selectedAgent?.id === id ? updated : s.selectedAgent,
|
||||
}))
|
||||
},
|
||||
|
||||
deleteAgent: async (id) => {
|
||||
await secAgentApi.delete(id)
|
||||
set((s) => ({
|
||||
agents: s.agents.filter((a) => a.id !== id),
|
||||
selectedAgent: s.selectedAgent?.id === id ? null : s.selectedAgent,
|
||||
}))
|
||||
},
|
||||
|
||||
// ── Brain Nodes ───────────────────────────────────────────────────────────
|
||||
|
||||
loadNodes: async (agentId) => {
|
||||
set({ loadingNodes: true })
|
||||
try {
|
||||
const nodes = await secNodeApi.list(agentId)
|
||||
set({ nodes })
|
||||
} finally {
|
||||
set({ loadingNodes: false })
|
||||
}
|
||||
},
|
||||
|
||||
createNode: async (agentId, d) => {
|
||||
const node = await secNodeApi.create(agentId, d)
|
||||
set((s) => ({ nodes: [...s.nodes, node] }))
|
||||
},
|
||||
|
||||
updateNode: async (id, d) => {
|
||||
const updated = await secNodeApi.update(id, d)
|
||||
set((s) => ({ nodes: s.nodes.map((n) => (n.id === id ? updated : n)) }))
|
||||
},
|
||||
|
||||
deleteNode: async (id) => {
|
||||
await secNodeApi.delete(id)
|
||||
set((s) => ({ nodes: s.nodes.filter((n) => n.id !== id) }))
|
||||
},
|
||||
|
||||
// ── Conversations ─────────────────────────────────────────────────────────
|
||||
|
||||
loadConversations: async (agentId) => {
|
||||
set({ loadingConversations: true })
|
||||
try {
|
||||
const conversations = await secConvApi.list(agentId)
|
||||
set({ conversations })
|
||||
} finally {
|
||||
set({ loadingConversations: false })
|
||||
}
|
||||
},
|
||||
|
||||
selectConversation: (c) => {
|
||||
set({ selectedConversation: c, messages: [] })
|
||||
if (c) get().loadMessages(c.id)
|
||||
},
|
||||
|
||||
createConversation: async (agentId, contactName) => {
|
||||
const conv = await secConvApi.create(agentId, contactName)
|
||||
set((s) => ({ conversations: [conv, ...s.conversations] }))
|
||||
return conv
|
||||
},
|
||||
|
||||
deleteConversation: async (id) => {
|
||||
await secConvApi.delete(id)
|
||||
set((s) => ({
|
||||
conversations: s.conversations.filter((c) => c.id !== id),
|
||||
selectedConversation: s.selectedConversation?.id === id ? null : s.selectedConversation,
|
||||
}))
|
||||
},
|
||||
|
||||
updateConversationStatus: async (id, status) => {
|
||||
const updated = await secConvApi.patch(id, { status })
|
||||
set((s) => ({
|
||||
conversations: s.conversations.map((c) => (c.id === id ? updated : c)),
|
||||
selectedConversation: s.selectedConversation?.id === id ? updated : s.selectedConversation,
|
||||
}))
|
||||
},
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────
|
||||
|
||||
loadMessages: async (convId) => {
|
||||
set({ loadingMessages: true })
|
||||
try {
|
||||
const messages = await secConvApi.messages(convId)
|
||||
set({ messages })
|
||||
} finally {
|
||||
set({ loadingMessages: false })
|
||||
}
|
||||
},
|
||||
|
||||
sendMessage: async (convId, text) => {
|
||||
// Adiciona mensagem do usuário imediatamente (optimistic)
|
||||
const tempId = `temp-${Date.now()}`
|
||||
const userMsg: SecMessage = {
|
||||
id: tempId, conversation_id: convId, role: 'user', content: text, created_at: new Date().toISOString(),
|
||||
}
|
||||
set((s) => ({ messages: [...s.messages, userMsg], sendingMessage: true }))
|
||||
|
||||
try {
|
||||
const { reply } = await secConvApi.chat(convId, text)
|
||||
const aiMsg: SecMessage = {
|
||||
id: `ai-${Date.now()}`, conversation_id: convId, role: 'assistant', content: reply,
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
set((s) => ({ messages: [...s.messages, aiMsg] }))
|
||||
|
||||
// Atualiza updated_at da conversa na lista
|
||||
set((s) => ({
|
||||
conversations: s.conversations.map((c) =>
|
||||
c.id === convId ? { ...c, updated_at: new Date().toISOString() } : c,
|
||||
),
|
||||
}))
|
||||
} finally {
|
||||
set({ sendingMessage: false })
|
||||
}
|
||||
},
|
||||
|
||||
// ── Calendar ──────────────────────────────────────────────────────────────
|
||||
|
||||
loadCalendar: async (params) => {
|
||||
set({ loadingCalendar: true })
|
||||
try {
|
||||
const calendarSlots = await secCalApi.list(params)
|
||||
set({ calendarSlots })
|
||||
} finally {
|
||||
set({ loadingCalendar: false })
|
||||
}
|
||||
},
|
||||
|
||||
createCalendarSlot: async (d) => {
|
||||
const slot = await secCalApi.create(d)
|
||||
set((s) => ({ calendarSlots: [...s.calendarSlots, slot].sort((a, b) => a.date.localeCompare(b.date)) }))
|
||||
},
|
||||
|
||||
updateCalendarSlot: async (id, d) => {
|
||||
const updated = await secCalApi.update(id, d)
|
||||
set((s) => ({ calendarSlots: s.calendarSlots.map((sl) => (sl.id === id ? updated : sl)) }))
|
||||
},
|
||||
|
||||
deleteCalendarSlot: async (id) => {
|
||||
await secCalApi.delete(id)
|
||||
set((s) => ({ calendarSlots: s.calendarSlots.filter((sl) => sl.id !== id) }))
|
||||
},
|
||||
|
||||
// ── Numbers ───────────────────────────────────────────────────────────────
|
||||
|
||||
loadNumbers: async () => {
|
||||
set({ loadingNumbers: true })
|
||||
try {
|
||||
const numbers = await secNumberApi.list()
|
||||
set({ numbers })
|
||||
} finally {
|
||||
set({ loadingNumbers: false })
|
||||
}
|
||||
},
|
||||
|
||||
createNumber: async (d) => {
|
||||
const num = await secNumberApi.assign(d)
|
||||
set((s) => ({
|
||||
// upsert: substitui se já existia o mesmo instance_id
|
||||
numbers: [...s.numbers.filter((n) => n.instance_id !== num.instance_id), num]
|
||||
.sort((a, b) => a.priority - b.priority),
|
||||
}))
|
||||
},
|
||||
|
||||
updateNumber: async (id, d) => {
|
||||
const updated = await secNumberApi.update(id, d)
|
||||
set((s) => ({ numbers: s.numbers.map((n) => (n.id === id ? updated : n)).sort((a, b) => a.priority - b.priority) }))
|
||||
},
|
||||
|
||||
deleteNumber: async (id) => {
|
||||
await secNumberApi.delete(id)
|
||||
set((s) => ({ numbers: s.numbers.filter((n) => n.id !== id) }))
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,22 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
|
||||
interface UIState {
|
||||
isSidebarCollapsed: boolean;
|
||||
toggleSidebar: () => void;
|
||||
setSidebarCollapsed: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
isSidebarCollapsed: false,
|
||||
toggleSidebar: () => set((state) => ({ isSidebarCollapsed: !state.isSidebarCollapsed })),
|
||||
setSidebarCollapsed: (value: boolean) => set({ isSidebarCollapsed: value }),
|
||||
}),
|
||||
{
|
||||
name: 'ui-storage',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user