diff --git a/src/parties/FunZonePartySection.tsx b/src/parties/FunZonePartySection.tsx index dae39c1..829a6fb 100644 --- a/src/parties/FunZonePartySection.tsx +++ b/src/parties/FunZonePartySection.tsx @@ -233,7 +233,17 @@ export function FunZonePartySection({ config }: { config: FunZonePartyConfig })

{config.subtitle}

-
diff --git a/src/parties/GlobalPartySync.tsx b/src/parties/GlobalPartySync.tsx index 38386c7..b62cdef 100644 --- a/src/parties/GlobalPartySync.tsx +++ b/src/parties/GlobalPartySync.tsx @@ -9,7 +9,7 @@ import { findExistingParty, mergeFunZoneParties, partyNeedsSync } from './funzon /** Syncs FunZone owners/customers into accounting parties on app load. */ export function GlobalPartySync() { - const { ready, upsertPartyAsync, reload } = useStore() + const { ready, upsertPartyAsync } = useStore() const synced = useRef(false) useEffect(() => { @@ -36,16 +36,21 @@ export function GlobalPartySync() { for (const incoming of batches) { const existing = findExistingParty(currentParties, incoming.externalId!, incoming.externalSource) if (existing && !partyNeedsSync(existing, incoming)) continue - await upsertPartyAsync({ ...incoming, id: existing?.id ?? createId('party-') }) + const next = { ...incoming, id: existing?.id ?? createId('party-') } + await upsertPartyAsync(next) + // Keep the local snapshot in sync so later comparisons in this pass don't re-PATCH. + currentParties = existing + ? currentParties.map((p) => (p.id === existing.id ? { ...p, ...next } : p)) + : [...currentParties, next] } - reload() + // upsertPartyAsync already updates AppStore; avoid a full reload that re-fetches every resource. } catch { synced.current = false } } void run() - }, [ready, upsertPartyAsync, reload]) + }, [ready, upsertPartyAsync]) return null } diff --git a/src/parties/funzonePartySync.ts b/src/parties/funzonePartySync.ts index 81dd7b6..02b480e 100644 --- a/src/parties/funzonePartySync.ts +++ b/src/parties/funzonePartySync.ts @@ -84,23 +84,44 @@ export function mergeFunZoneParties( }) } +function normalizeText(value: unknown): string { + return value == null ? '' : String(value).trim() +} + +function normalizeBirthday(value: unknown): string { + if (value == null || value === '') return '' + return String(value).slice(0, 10) +} + +function sameNumber(a: unknown, b: unknown): boolean { + const left = Number(a ?? 0) + const right = Number(b ?? 0) + if (Number.isNaN(left) && Number.isNaN(right)) return true + return left === right +} + +function sameVenues(a: unknown, b: unknown): boolean { + const left = Array.isArray(a) ? a.map(String) : [] + const right = Array.isArray(b) ? b.map(String) : [] + if (left.length !== right.length) return false + return left.every((value, index) => value === right[index]) +} + /** Returns true when party data from FunZone differs from the stored accounting record. */ export function partyNeedsSync(stored: Party, incoming: Party): boolean { - const keys: Array = [ - 'name', - 'phone', - 'address', - 'firstName', - 'lastName', - 'username', - 'email', - 'nationalCode', - 'iban', - 'walletBalance', - 'isActive', - 'isVerified', - 'birthday', - ] - if (JSON.stringify(stored.venues ?? []) !== JSON.stringify(incoming.venues ?? [])) return true - return keys.some((key) => stored[key] !== incoming[key]) + if (normalizeText(stored.name) !== normalizeText(incoming.name)) return true + if (normalizeText(stored.phone) !== normalizeText(incoming.phone)) return true + if (normalizeText(stored.address) !== normalizeText(incoming.address)) return true + if (normalizeText(stored.firstName) !== normalizeText(incoming.firstName)) return true + if (normalizeText(stored.lastName) !== normalizeText(incoming.lastName)) return true + if (normalizeText(stored.username) !== normalizeText(incoming.username)) return true + if (normalizeText(stored.email) !== normalizeText(incoming.email)) return true + if (normalizeText(stored.nationalCode) !== normalizeText(incoming.nationalCode)) return true + if (normalizeText(stored.iban) !== normalizeText(incoming.iban)) return true + if (!sameNumber(stored.walletBalance, incoming.walletBalance)) return true + if (Boolean(stored.isActive) !== Boolean(incoming.isActive)) return true + if (Boolean(stored.isVerified) !== Boolean(incoming.isVerified)) return true + if (normalizeBirthday(stored.birthday) !== normalizeBirthday(incoming.birthday)) return true + if (!sameVenues(stored.venues, incoming.venues)) return true + return false } diff --git a/src/parties/useFunZoneParties.ts b/src/parties/useFunZoneParties.ts index e2a0359..c636173 100644 --- a/src/parties/useFunZoneParties.ts +++ b/src/parties/useFunZoneParties.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { apiGet, asList } from '../api/client' import { ENDPOINTS } from '../api/config' import type { ApiCustomer, ApiOwner } from '../api/types' @@ -37,6 +37,13 @@ export function useFunZoneParties(config: FunZonePartyConfig) { const [syncing, setSyncing] = useState(false) const [error, setError] = useState(null) + // Keep the latest parties without putting them in callback deps (avoids PATCH → state → sync loop). + const partiesRef = useRef(data.parties) + partiesRef.current = data.parties + + const autoSyncKeyRef = useRef(null) + const syncInFlightRef = useRef(false) + const load = useCallback(async () => { setLoading(true) setError(null) @@ -50,27 +57,45 @@ export function useFunZoneParties(config: FunZonePartyConfig) { }, [config.kind]) const syncToAccounting = useCallback(async () => { - if (apiRecords.length === 0) return + if (apiRecords.length === 0 || syncInFlightRef.current) return + syncInFlightRef.current = true setSyncing(true) try { - const merged = mergeFunZoneParties(apiRecords, data.parties, config.kind) + // Snapshot parties for this pass; do not re-read reactive store mid-loop. + let currentParties = partiesRef.current + const merged = mergeFunZoneParties(apiRecords, currentParties, config.kind) for (const incoming of merged) { - const existing = findExistingParty(data.parties, incoming.externalId!, incoming.externalSource) + const existing = findExistingParty( + currentParties, + incoming.externalId!, + incoming.externalSource, + ) if (existing && !partyNeedsSync(existing, incoming)) continue - await upsertPartyAsync({ ...incoming, id: existing?.id ?? createId('party-') }) + const next = { ...incoming, id: existing?.id ?? createId('party-') } + await upsertPartyAsync(next) + currentParties = existing + ? currentParties.map((p) => (p.id === existing.id ? { ...p, ...next } : p)) + : [...currentParties, next] + partiesRef.current = currentParties } } finally { + syncInFlightRef.current = false setSyncing(false) } - }, [apiRecords, config.kind, data.parties, upsertPartyAsync]) + }, [apiRecords, config.kind, upsertPartyAsync]) useEffect(() => { if (ready) void load() }, [ready, load]) + // Auto-sync once per loaded FunZone snapshot — never on every parties store update. useEffect(() => { - if (ready && apiRecords.length > 0) void syncToAccounting() - }, [ready, apiRecords, syncToAccounting]) + if (!ready || apiRecords.length === 0) return + const key = `${config.kind}:${apiRecords.map((r) => r.id).join(',')}` + if (autoSyncKeyRef.current === key) return + autoSyncKeyRef.current = key + void syncToAccounting() + }, [ready, apiRecords, config.kind, syncToAccounting]) const parties = useMemo(() => { const merged = mergeFunZoneParties(apiRecords, data.parties, config.kind)