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)