← Files WixARCHIVED FILE

skills/wix-headless-templates/members/project/src/wix/members/member-store.ts

4.53 KB · Oct 8, 2026 · 12:02 UTC

↓ Download file

See the change to this file →

// Member session state — a module-scope store, deliberately NOT a React context.
// A context can't span Astro islands (each island is its own React root); a module
// singleton is shared by every island in the page bundle, and works identically in a
// single-root SPA. Consume it through useMember() (hooks/), or subscribe directly.
import { fetchCurrentMember } from "./members";
import {
  loggedInHint,
  loginMember,
  logoutMember,
  registerMember,
  verifyMemberEmail,
  type LoginOptions,
  type LoginResult,
} from "./auth";
import type { CurrentMember } from "./types";

export interface MemberState {
  member: CurrentMember | null;
  /** True for a logged-in member — can be true with `member` null when the Members Area app is absent. */
  loggedIn: boolean;
  /** True until the first session read settles — render skeletons, not the logged-out state. */
  loading: boolean;
  /** Last failed operation's message — render it; a new operation clears it. */
  error: string | null;
}

const EMPTY: MemberState = {
  member: null,
  loggedIn: false,
  loading: true,
  error: null,
};

let state: MemberState = EMPTY;
const listeners = new Set<() => void>();
let started = false;

function setState(patch: Partial<MemberState>): void {
  state = { ...state, ...patch };
  for (const l of listeners) l();
}

export function getMemberState(): MemberState {
  return state;
}

/** Adopt an SSR-resolved member (Astro island props) so no client re-fetch happens. */
export function hydrateMember(member: CurrentMember | null): void {
  if (started) return;
  started = true;
  state = { member, loggedIn: member !== null, loading: false, error: null };
}

export function subscribeMember(listener: () => void): () => void {
  listeners.add(listener);
  // First subscriber triggers the initial session read (browser only — SSR renders `loading`).
  if (!started && typeof window !== "undefined") {
    started = true;
    void refreshMember();
  }
  return () => listeners.delete(listener);
}

/** Re-read the session — call after anything that may have changed it. */
export async function refreshMember(): Promise<void> {
  setState({ loading: true, error: null });
  const hint = loggedInHint();
  if (hint === false) {
    setState({ member: null, loggedIn: false, loading: false });
    return;
  }
  // hint true → read the profile (null = profile data missing, still logged in).
  // hint null (ambient) → the member read IS the session check: null = anonymous.
  const member = await fetchCurrentMember();
  setState({ member, loggedIn: hint ?? member !== null, loading: false });
}

async function runLogin(
  action: () => Promise<LoginResult>,
): Promise<LoginResult> {
  try {
    const result = await action();
    // Managed Astro: the session is written by the callback the browser is about to visit; navigate
    // and do nothing else here (the page unloads). Manual: the tokens are already in place — re-read.
    if (result.state === "SUCCESS" && result.redirectUrl) {
      window.location.assign(result.redirectUrl);
      return result;
    }
    if (result.state === "SUCCESS") await refreshMember();
    if (result.state === "FAILURE")
      setState({
        error: result.error ?? result.errorCode ?? "Could not log in.",
      });
    return result;
  } catch (e) {
    setState({ error: e instanceof Error ? e.message : String(e) });
    throw e;
  }
}

/**
 * Submit the branded in-app sign-in form. `returnTo` is where a SUCCESS lands (managed Astro: the
 * callback's destination; the caller navigates itself otherwise).
 */
export function login(email: string, password: string, options?: LoginOptions): Promise<LoginResult> {
  return runLogin(() => loginMember(email, password, options));
}

/** Submit the branded in-app sign-up form (names only where PROFILE_ON_SIGNUP is true). */
export function register(
  email: string,
  password: string,
  profile?: { firstName?: string; lastName?: string },
  options?: LoginOptions,
): Promise<LoginResult> {
  return runLogin(() => registerMember(email, password, profile, options));
}

/** Finish an email-verification branch from the same in-app form. */
export function verifyEmail(code: string): Promise<LoginResult> {
  return runLogin(() => verifyMemberEmail(code));
}

/** Log out (navigates away); local state resets for the instant before the redirect lands. */
export async function logout(returnTo?: string): Promise<void> {
  setState({ member: null, loggedIn: false });
  try {
    await logoutMember(returnTo);
  } catch (e) {
    setState({ error: e instanceof Error ? e.message : String(e) });
    throw e;
  }
}

SHA-256: 59f597e28a574f2813fefc2e233bb81d8fa10cbf559466e4ebeb861fa608e7c8