Files
clube67_newwhats.local/newwhats.clube67.com/newwhats.local/frontend/store/instanceStore.ts
T
VPS 4 Deploy Agent 2f8c04a0a7
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
chore(ops): restore all source files in newwhats.clube67.com
2026-05-18 03:28:29 +02:00

187 lines
6.5 KiB
TypeScript

/**
* 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',
)
}
})
},
}))
)