import { useMutation, useQueryClient } from "@tanstack/react-query"; import { api, authHeaders } from "./api.js"; import { optimisticBoard, removeKinkFromItemsPayload } from "./board-utils.js"; const EMPTY_BOARD = { to_me: [], by_me: [], together: [], hidden: [], no_go: [] }; export function useLoginMutation({ setAuth, setCreateResult, setAuthOpen, setStatus }) { const qc = useQueryClient(); return useMutation({ mutationFn: ({ user_id, private_token }) => api("/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ user_id, private_token }), }), onMutate: () => setStatus("Opening profile…"), onSuccess: (payload, vars) => { setAuth({ userId: vars.user_id, token: vars.private_token }); setCreateResult(null); setAuthOpen(false); qc.setQueryData(["user", vars.user_id], payload.user); setStatus("Profile open. Loading your saved plays…"); }, onError: (error) => setStatus(error.message), }); } export function useCreateMutation({ setAuth, setCreateResult, setLoginForm, setStatus }) { return useMutation({ mutationFn: () => api("/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}", }), onMutate: () => { setStatus( "Creating profile… If this sits here a while, the Space is still loading the catalog (first boot can take several minutes).", ); }, onSuccess: (payload) => { setAuth({ userId: payload.id, token: payload.private_token }); setCreateResult(payload); setLoginForm({ user_id: payload.id, private_token: payload.private_token }); setStatus("Profile created. Choose roles, then open your Play List."); }, onError: (error) => setStatus(error.message), }); } export function useSavePlayMutation({ auth, activeGroupId, selectedKinkId, setSelectedKinkId, setStatus, pendingPlayCommitRef, playCommitKey, syncPostSaveCaches, onSaveStart = () => {}, onSaveSettled = () => {}, }) { const qc = useQueryClient(); return useMutation({ mutationKey: ["savePlay"], mutationFn: ({ kinkId, interestState, directions }) => api(`/users/${auth.userId}/plays`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders(auth.token) }, body: JSON.stringify({ kink_id: kinkId, interest_state: interestState, directions }), }), onMutate: async (vars) => { onSaveStart(); const directions = vars.directions || []; /** Do not cancel/refetch the user query here: refetchInterval can still deliver a stale GET that * replaces the cache after this onMutate runs but before onSuccess, wiping the optimistic play * and leaving the same top card so the next tap dedupes to “no new save” while the counter stays flat. */ const recsKey = ["recs", auth.userId, activeGroupId]; const previousUser = qc.getQueryData(["user", auth.userId]); const previousBoard = qc.getQueryData(["board", auth.userId]); const previousRecs = qc.getQueryData(recsKey); const playKey = String(vars.kinkId); qc.setQueryData(["user", auth.userId], (old) => { const base = old && typeof old === "object" ? old : { id: auth.userId, plays: {} }; return { ...base, plays: { ...(base.plays || {}), [playKey]: { interest_state: vars.interestState, directions }, }, }; }); if (previousRecs?.items) { qc.setQueryData(recsKey, (old) => removeKinkFromItemsPayload(old, playKey)); } qc.setQueryData(["board", auth.userId], (old) => optimisticBoard(old || previousBoard || EMPTY_BOARD, playKey, vars.interestState, directions, qc, auth), ); if (!selectedKinkId) setSelectedKinkId(playKey); return { previousUser, previousBoard, previousRecs, recsKey, commitKey: playCommitKey(playKey, vars.interestState, directions), }; }, onSuccess: (payload, vars, context) => { syncPostSaveCaches(payload, String(vars.kinkId), vars.interestState, vars.directions || []); /** Refetch play-board from server so My Plays never stays on a partial optimistic board (e.g. board cache was empty when save ran). */ qc.invalidateQueries({ queryKey: ["board", auth.userId] }); /** Avoid immediate recs/prompts refetch: it re-randomized the deck and made the next card “flash” away. Server still updates user/board via payload + optimistic cache. */ // No "Saved." status update — the deck advancing IS the feedback; the persistent text was friction. }, onError: (error, _vars, context) => { if (context?.previousUser) qc.setQueryData(["user", auth.userId], context.previousUser); if (context?.previousBoard) qc.setQueryData(["board", auth.userId], context.previousBoard); if (context?.recsKey) qc.setQueryData(context.recsKey, context.previousRecs); setStatus(error.message); }, onSettled: (_payload, _error, _vars, context) => { onSaveSettled(); if (context?.commitKey && pendingPlayCommitRef.current === context.commitKey) { pendingPlayCommitRef.current = ""; } }, }); } export function useDeletePlayMutation({ auth, setStatus }) { const qc = useQueryClient(); return useMutation({ mutationFn: (kinkId) => api(`/users/${auth.userId}/plays/${encodeURIComponent(kinkId)}`, { method: "DELETE", headers: authHeaders(auth.token), }), onMutate: async (kinkId) => { const playKey = String(kinkId); const previousUser = qc.getQueryData(["user", auth.userId]); const previousBoard = qc.getQueryData(["board", auth.userId]); qc.setQueryData(["user", auth.userId], (old) => { if (!old?.plays) return old; const { [playKey]: _drop, ...rest } = old.plays; return { ...old, plays: rest }; }); qc.setQueryData(["board", auth.userId], (old) => { if (!old) return old; const next = { ...old }; for (const col of ["to_me", "by_me", "together", "hidden", "no_go"]) { if (Array.isArray(next[col])) next[col] = next[col].filter((item) => String(item?.id) !== playKey); } return next; }); return { previousUser, previousBoard }; }, onSuccess: () => { qc.invalidateQueries({ queryKey: ["board", auth.userId] }); qc.invalidateQueries({ queryKey: ["user", auth.userId] }); setStatus?.("Removed."); }, onError: (error, _kinkId, context) => { if (context?.previousUser) qc.setQueryData(["user", auth.userId], context.previousUser); if (context?.previousBoard) qc.setQueryData(["board", auth.userId], context.previousBoard); setStatus?.(error.message); }, }); } export function useSaveRoleMutation({ auth, setStatus }) { const qc = useQueryClient(); return useMutation({ mutationFn: ({ kinkId, selected }) => api(`/users/${auth.userId}/roles`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders(auth.token) }, body: JSON.stringify({ kink_id: kinkId, selected }), }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["user", auth.userId] }); qc.invalidateQueries({ queryKey: ["roles", auth.userId] }); setStatus("Role saved."); }, onError: (error) => setStatus(error.message), }); } export function usePrefMutation({ auth, setStatus }) { const qc = useQueryClient(); return useMutation({ mutationFn: (show_images) => api(`/users/${auth.userId}/preferences`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders(auth.token) }, body: JSON.stringify({ show_images }), }), onSuccess: (payload) => qc.setQueryData(["user", auth.userId], payload), onError: (error) => setStatus(error.message), }); } export function useScenarioPreferenceMutation({ auth, activeGroupId, setStatus }) { const qc = useQueryClient(); return useMutation({ mutationFn: ({ parentKinkId, scenarioKinkId, interestState, directions = [], remove = false }) => { if (remove) { return api(`/users/${auth.userId}/scenario-preferences/${encodeURIComponent(scenarioKinkId)}`, { method: "DELETE", headers: authHeaders(auth.token), }); } return api(`/users/${auth.userId}/scenario-preferences`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders(auth.token) }, body: JSON.stringify({ parent_kink_id: parentKinkId, scenario_kink_id: scenarioKinkId, interest_state: interestState, directions, }), }); }, onSuccess: (payload) => { qc.setQueryData(["user", auth.userId], payload); qc.invalidateQueries({ queryKey: ["scenario-parents", auth.userId] }); if (activeGroupId) qc.invalidateQueries({ queryKey: ["group-overlap", auth.userId, activeGroupId] }); setStatus("Scenario saved."); }, onError: (error) => setStatus(error.message), }); } function syncPartnerGroupsFromUserPayload(qc, userId, payload) { if (payload?.partner_groups != null) { qc.setQueryData(["partner-groups", userId], { items: payload.partner_groups }); } } export function useLinkMutation({ auth, setGroupDraftMembers, setGroupNameInput, setStatus }) { const qc = useQueryClient(); return useMutation({ mutationFn: (partner_id) => api(`/users/${auth.userId}/partners`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders(auth.token) }, body: JSON.stringify({ partner_id }), }), onSuccess: (payload, partnerId) => { qc.setQueryData(["user", auth.userId], payload); syncPartnerGroupsFromUserPayload(qc, auth.userId, payload); qc.invalidateQueries({ queryKey: ["partner-groups", auth.userId] }); if (payload.partners?.includes(partnerId)) { setGroupDraftMembers([partnerId]); setGroupNameInput(`Shared with ${partnerId}`); setStatus("You're linked. A shared list \"Together\" was created for both of you."); } else { setStatus("Link request sent. When they accept, you will both get a shared \"Together\" list."); } }, onError: (error) => setStatus(error.message), }); } export function usePartnerRequestMutations({ auth, setStatus }) { const qc = useQueryClient(); const patchUser = (payload) => { qc.setQueryData(["user", auth.userId], payload); syncPartnerGroupsFromUserPayload(qc, auth.userId, payload); qc.invalidateQueries({ queryKey: ["partner-groups", auth.userId] }); }; const accept = useMutation({ mutationFn: (from_user_id) => api(`/users/${auth.userId}/partner-requests/accept`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders(auth.token) }, body: JSON.stringify({ from_user_id }), }), onSuccess: (payload) => { patchUser(payload); setStatus("Linked. A shared \"Together\" list is ready — you can add more lists anytime."); }, onError: (error) => setStatus(error.message), }); const decline = useMutation({ mutationFn: (from_user_id) => api(`/users/${auth.userId}/partner-requests/decline`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders(auth.token) }, body: JSON.stringify({ from_user_id }), }), onSuccess: (payload) => { patchUser(payload); setStatus("Request declined."); }, onError: (error) => setStatus(error.message), }); const cancel = useMutation({ mutationFn: (to_user_id) => api(`/users/${auth.userId}/partner-requests/cancel`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders(auth.token) }, body: JSON.stringify({ to_user_id }), }), onSuccess: (payload) => { patchUser(payload); setStatus("Request withdrawn."); }, onError: (error) => setStatus(error.message), }); return { accept, decline, cancel }; } export function useCreateGroupMutation({ auth, setActiveGroupId, setGroupNameInput, setGroupDraftMembers, setStatus }) { const qc = useQueryClient(); return useMutation({ mutationFn: ({ name, memberIds }) => api(`/users/${auth.userId}/partner-groups`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders(auth.token) }, body: JSON.stringify({ name, member_ids: memberIds }), }), onSuccess: (payload) => { qc.invalidateQueries({ queryKey: ["user", auth.userId] }); qc.invalidateQueries({ queryKey: ["partner-groups", auth.userId] }); setActiveGroupId(payload.id); setGroupNameInput(""); setGroupDraftMembers([]); setStatus("Group created."); }, onError: (error) => setStatus(error.message), }); } export function useRemoveGroupMemberMutation({ auth, removeMemberFromGroupCache, setStatus }) { const qc = useQueryClient(); return useMutation({ mutationFn: ({ groupId, memberId }) => api(`/users/${auth.userId}/partner-groups/${encodeURIComponent(groupId)}/members/${encodeURIComponent(memberId)}`, { method: "DELETE", headers: authHeaders(auth.token), }), onSuccess: (_payload, vars) => { removeMemberFromGroupCache(vars.groupId, vars.memberId); qc.invalidateQueries({ queryKey: ["group-overlap", auth.userId, vars.groupId] }); qc.invalidateQueries({ queryKey: ["recs", auth.userId, vars.groupId] }); setStatus("Group updated."); }, onError: (error) => setStatus(error.message), }); } export function useShareToggleMutation({ auth, replacePartnerGroup, setStatus }) { const qc = useQueryClient(); return useMutation({ mutationFn: ({ groupId, share }) => api(`/users/${auth.userId}/partner-groups/${encodeURIComponent(groupId)}/share-toggle`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders(auth.token) }, body: JSON.stringify({ share }), }), onSuccess: (_payload, vars) => { replacePartnerGroup({ id: vars.groupId, my_share_full_list: vars.share }); setStatus("Sharing preference updated."); qc.invalidateQueries({ queryKey: ["group-overlap", auth.userId, vars.groupId] }); }, onError: (error) => setStatus(error.message), }); }