// Vanday DAM — app shell + route switcher

// Signup plan selection. Shown once to a brand-new workspace owner when billing
// is live: pick a plan to start a 14-day free trial (no card), or skip onto the
// free plan. On success we mark the user onboarded and reload so the app comes
// up with the trial's entitlements. Mounted via an early return in App, so its
// hooks always run unconditionally here.
function ChoosePlanTrial({ onDone }) {
  const [plans, setPlans] = React.useState(null);   // null = loading
  const [cycle, setCycle] = React.useState(() => {
    try { return window.localStorage.getItem("vanday_billing_cycle") || "annual"; }
    catch { return "annual"; }
  });
  const [busy, setBusy] = React.useState(null);      // planId | "skip"
  const [err, setErr] = React.useState(null);

  React.useEffect(() => {
    let alive = true;
    window.VandayAPI?.getBilling?.()
      .then((d) => { if (alive) setPlans(Array.isArray(d?.plans) ? d.plans : []); })
      .catch(() => { if (alive) setPlans([]); });
    return () => { alive = false; };
  }, []);

  const startTrial = async (planId) => {
    setErr(null); setBusy(planId);
    try {
      await window.VandayAPI.startTrial(planId);
      try { if (window.VandayMe) await window.VandayMe.markOnboarded(); } catch {}
      // Stash the plan so the trial welcome modal shows after the reload.
      try { window.localStorage.setItem("vanday_trial_welcome", planId); } catch {}
      window.location.reload();
    } catch {
      setErr("Couldn't start your trial. Please try again."); setBusy(null);
    }
  };
  // No free plan — the only escape from the picker is to sign out. (Most
  // people never see this: a plan picked on the sign-in screen auto-starts
  // the trial and skips this entirely.)
  const signOut = async () => {
    setBusy("signout");
    try { await window.Clerk?.signOut?.(); } catch {}
    location.reload();
  };

  const fmtPrice = (n) => (n == null ? "—" : `$${Number(n).toLocaleString("en-US", { maximumFractionDigits: 0 })}`);

  return (
    <div style={{
      position: "fixed", inset: 0, overflow: "auto",
      display: "grid", placeItems: "center", padding: 24,
      background: "var(--bg, #f9f8f6)", color: "var(--text, #1f1f1c)", fontFamily: "inherit",
    }}>
      <div style={{
        width: "100%", maxWidth: 560,
        background: "var(--surface, #fff)", color: "var(--text, #111)",
        borderRadius: 16, boxShadow: "0 20px 60px rgba(0,0,0,0.12)",
        border: "1px solid var(--border, #e7e6e0)", padding: "40px 34px",
      }}>
        <h1 style={{ fontSize: 23, fontWeight: 700, margin: "0 0 8px", textAlign: "center" }}>
          Start your free trial
        </h1>
        <p style={{ fontSize: 14, lineHeight: 1.6, color: "var(--text-muted)", margin: "0 0 22px", textAlign: "center" }}>
          Pick a plan to try free for 14 days. <strong>No credit card required</strong> — you won't
          be charged unless you add a payment method before your trial ends.
        </p>

        {plans === null ? (
          <p style={{ fontSize: 13, color: "var(--text-muted)", textAlign: "center" }}>Loading plans…</p>
        ) : plans.length === 0 ? (
          <p style={{ fontSize: 13, color: "var(--text-muted)", textAlign: "center" }}>
            Plans are unavailable right now. Please try again shortly.
          </p>
        ) : (
          <>
            <div role="tablist" style={{
              display: "inline-flex", gap: 4, padding: 4, margin: "0 auto 18px",
              background: "var(--surface-sunken, #f1efe9)", borderRadius: 10, width: "fit-content",
              position: "relative", left: "50%", transform: "translateX(-50%)",
            }}>
              {[["monthly", "Monthly"], ["annual", "Annual · save 20%"]].map(([id, label]) => (
                <button key={id} role="tab" aria-selected={cycle === id}
                  onClick={() => { setCycle(id); try { window.localStorage.setItem("vanday_billing_cycle", id); } catch {} }}
                  style={{
                    border: "none", cursor: "pointer", padding: "7px 14px", borderRadius: 7, fontSize: 13,
                    background: cycle === id ? "var(--surface, #fff)" : "transparent",
                    color: cycle === id ? "var(--text)" : "var(--text-muted)",
                    fontWeight: cycle === id ? 600 : 500,
                    boxShadow: cycle === id ? "0 1px 2px rgba(0,0,0,0.06)" : "none",
                  }}>{label}</button>
              ))}
            </div>

            <div style={{ display: "grid", gap: 10 }}>
              {plans.map((p) => {
                const price = p.pricing?.[cycle] ?? null;
                const perMonth = cycle === "annual" && price != null ? price / 12 : price;
                return (
                  <div key={p.id} style={{
                    display: "flex", alignItems: "center", justifyContent: "space-between", gap: 14,
                    border: "1px solid var(--border, #e7e6e0)", borderRadius: 12, padding: "14px 16px",
                  }}>
                    <div>
                      <div style={{ fontWeight: 600, fontSize: 15 }}>{p.label}</div>
                      <div style={{ fontSize: 12.5, color: "var(--text-muted)" }}>
                        {fmtPrice(perMonth)}/mo{cycle === "annual" && price != null ? ` · ${fmtPrice(price)} billed yearly after trial` : " after trial"}
                      </div>
                    </div>
                    <button className="btn primary" disabled={!!busy} onClick={() => startTrial(p.id)}
                      style={{ whiteSpace: "nowrap", fontSize: 13, fontWeight: 600 }}>
                      {busy === p.id ? "Starting…" : "Start free trial"}
                    </button>
                  </div>
                );
              })}
            </div>
            {err && <p style={{ color: "var(--accent, #c8553d)", fontSize: 13, margin: "14px 0 0", textAlign: "center" }}>{err}</p>}
          </>
        )}

        <div style={{ marginTop: 22, textAlign: "center" }}>
          <button onClick={signOut} disabled={!!busy}
            style={{ background: "none", border: "none", cursor: "pointer", fontSize: 13, color: "var(--text-muted)", textDecoration: "underline" }}>
            {busy === "signout" ? "Signing out…" : "Sign out"}
          </button>
        </div>
      </div>
    </div>
  );
}

// Full-screen lockout shown when a workspace's 14-day trial expired without a
// payment method. Data is preserved until `locked.purgeAfter`, then deleted.
// Admins (and personal-workspace owners) get a plan picker that starts a Stripe
// checkout; everyone else is told to contact their admin. Mounted via an early
// return in App, so its hooks always run unconditionally here.
function TrialEndedLockout({ locked }) {
  const canPay = !!locked?.canManageBilling;
  const [plans, setPlans] = React.useState(null);   // null = loading
  const [cycle, setCycle] = React.useState(() => {
    try { return window.localStorage.getItem("vanday_billing_cycle") || "annual"; }
    catch { return "annual"; }
  });
  const [busy, setBusy] = React.useState(null);      // planId mid-checkout
  const [err, setErr] = React.useState(null);
  const [notify, setNotify] = React.useState(null);  // null | sending | sent | error (non-admin "notify my admin")
  const notifyAdmin = async () => {
    setNotify("sending");
    try { await window.VandayAPI.notifyLockoutAdmin(); setNotify("sent"); }
    catch { setNotify("error"); }
  };

  React.useEffect(() => {
    if (!canPay) return;
    let alive = true;
    window.VandayAPI?.getBilling?.()
      .then((d) => { if (alive) setPlans(Array.isArray(d?.plans) ? d.plans : []); })
      .catch(() => { if (alive) setPlans([]); });
    return () => { alive = false; };
  }, [canPay]);

  const startCheckout = async (planId) => {
    setErr(null); setBusy(planId);
    try {
      const { url } = await window.VandayAPI.billingCheckout(planId, cycle);
      if (url) { window.location.href = url; return; }
      throw new Error("no url");
    } catch {
      setErr("Couldn't start checkout. Please try again."); setBusy(null);
    }
  };

  const purgeDate = locked?.purgeAfter
    ? new Date(locked.purgeAfter).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })
    : null;
  const days = locked?.daysUntilPurge;
  const fmtPrice = (n) => (n == null ? "—" : `$${Number(n).toLocaleString("en-US", { maximumFractionDigits: 0 })}`);

  return (
    <div style={{
      position: "fixed", inset: 0, overflow: "auto",
      display: "grid", placeItems: "center", padding: 24,
      background: "var(--bg, #f9f8f6)", color: "var(--text, #1f1f1c)", fontFamily: "inherit",
    }}>
      <div style={{
        width: "100%", maxWidth: 520,
        background: "var(--surface, #fff)", color: "var(--text, #111)",
        borderRadius: 16, boxShadow: "0 20px 60px rgba(0,0,0,0.12)",
        border: "1px solid var(--border, #e7e6e0)", padding: "40px 34px",
      }}>
        <div style={{
          width: 52, height: 52, borderRadius: 13, margin: "0 auto 18px",
          background: "var(--accent-soft, #fdf0ee)", color: "var(--accent, #c8553d)",
          display: "grid", placeItems: "center", fontSize: 24,
        }}>🔒</div>
        <h1 style={{ fontSize: 22, fontWeight: 700, margin: "0 0 10px", textAlign: "center" }}>
          Your access has ended
        </h1>
        <p style={{ fontSize: 14, lineHeight: 1.6, color: "var(--text-muted)", margin: "0 0 6px", textAlign: "center" }}>
          Start a paid subscription to restore access and keep everything in your workspace.
        </p>
        <p style={{ fontSize: 13, lineHeight: 1.6, color: "var(--text-muted)", margin: "0 0 14px", textAlign: "center" }}>
          Your data is safe for now. It will be permanently deleted{" "}
          <strong>30 days after it ends</strong>
          {purgeDate ? <> — on <strong>{purgeDate}</strong>{typeof days === "number" ? <> ({days} {days === 1 ? "day" : "days"} left)</> : null}</> : null}.
        </p>
        {canPay && (
          <p style={{ fontSize: 13, lineHeight: 1.6, color: "var(--text-muted)", margin: "0 0 22px", textAlign: "center" }}>
            Just want a copy of your files? Email <a href="mailto:crew@vanday.ai" style={{ color: "var(--accent, #c8553d)" }}>crew@vanday.ai</a> within 30 days and we'll send them over — no subscription needed.
          </p>
        )}

        {!canPay ? (
          <div style={{ textAlign: "center", marginTop: 16 }}>
            <p style={{ fontSize: 14, lineHeight: 1.6, margin: "0 0 14px", color: "var(--text-muted)" }}>
              Only a workspace admin can add a payment method.
            </p>
            {notify === "sent" ? (
              <p style={{ fontSize: 13.5, fontWeight: 600, color: "var(--accent, #c8553d)", margin: 0 }}>
                ✓ We've let your admin know.
              </p>
            ) : (
              <button
                type="button"
                onClick={notifyAdmin}
                disabled={notify === "sending"}
                style={{
                  padding: "10px 20px", borderRadius: 8, border: "none",
                  background: "var(--accent, #c8553d)", color: "white",
                  fontWeight: 600, fontSize: 14,
                  cursor: notify === "sending" ? "default" : "pointer",
                  opacity: notify === "sending" ? 0.7 : 1,
                }}
              >
                {notify === "sending" ? "Notifying…" : "Notify my admin"}
              </button>
            )}
            {notify === "error" && (
              <p style={{ fontSize: 12.5, color: "var(--status-danger, #b3261e)", margin: "10px 0 0" }}>
                Couldn't notify right now — please try again.
              </p>
            )}
          </div>
        ) : plans === null ? (
          <p style={{ fontSize: 13, color: "var(--text-muted)", textAlign: "center" }}>Loading plans…</p>
        ) : plans.length === 0 ? (
          <p style={{ fontSize: 13, color: "var(--text-muted)", textAlign: "center" }}>
            Plans are unavailable right now. Please try again shortly.
          </p>
        ) : (
          <>
            {/* Monthly / annual toggle (annual ≈ 20% off, baked into pricing). */}
            <div role="tablist" style={{
              display: "inline-flex", gap: 4, padding: 4, margin: "0 auto 18px",
              background: "var(--surface-sunken, #f1efe9)", borderRadius: 10, width: "fit-content",
              position: "relative", left: "50%", transform: "translateX(-50%)",
            }}>
              {[["monthly", "Monthly"], ["annual", "Annual · save 20%"]].map(([id, label]) => (
                <button key={id} role="tab" aria-selected={cycle === id}
                  onClick={() => { setCycle(id); try { window.localStorage.setItem("vanday_billing_cycle", id); } catch {} }}
                  style={{
                    border: "none", cursor: "pointer", padding: "7px 14px", borderRadius: 7, fontSize: 13,
                    background: cycle === id ? "var(--surface, #fff)" : "transparent",
                    color: cycle === id ? "var(--text)" : "var(--text-muted)",
                    fontWeight: cycle === id ? 600 : 500,
                    boxShadow: cycle === id ? "0 1px 2px rgba(0,0,0,0.06)" : "none",
                  }}>{label}</button>
              ))}
            </div>

            <div style={{ display: "grid", gap: 10 }}>
              {plans.map((p) => {
                const price = p.pricing?.[cycle] ?? null;
                const perMonth = cycle === "annual" && price != null ? price / 12 : price;
                return (
                  <div key={p.id} style={{
                    display: "flex", alignItems: "center", justifyContent: "space-between", gap: 14,
                    border: "1px solid var(--border, #e7e6e0)", borderRadius: 12, padding: "14px 16px",
                  }}>
                    <div>
                      <div style={{ fontWeight: 600, fontSize: 15 }}>{p.label}</div>
                      <div style={{ fontSize: 12.5, color: "var(--text-muted)" }}>
                        {fmtPrice(perMonth)}/mo{cycle === "annual" && price != null ? ` · ${fmtPrice(price)} billed yearly` : ""}
                      </div>
                    </div>
                    <button className="btn" disabled={!!busy} onClick={() => startCheckout(p.id)}
                      style={{ whiteSpace: "nowrap", fontSize: 13, fontWeight: 600 }}>
                      {busy === p.id ? "Starting…" : "Start Paid Subscription"}
                    </button>
                  </div>
                );
              })}
            </div>
            {err && <p style={{ color: "var(--accent, #c8553d)", fontSize: 13, margin: "14px 0 0", textAlign: "center" }}>{err}</p>}
          </>
        )}

        <div style={{ marginTop: 22, textAlign: "center" }}>
          <button onClick={async () => { try { await window.Clerk?.signOut?.(); } catch {} location.reload(); }}
            style={{ background: "none", border: "none", cursor: "pointer", fontSize: 13, color: "var(--text-muted)", textDecoration: "underline" }}>
            Sign out
          </button>
        </div>
      </div>
    </div>
  );
}

function App() {
  // Check session on mount. If not logged in, force the login view; if we
  // are, skip past it to the library.
  const [authChecked, setAuthChecked] = React.useState(false);
  const [view, setView] = React.useState("login"); // login | upload | library | preview | settings | portal
  // Set when /api/me reports the beta is full and this brand-new, uninvited
  // signup was turned away. We show a dedicated "beta is full" screen instead
  // of the app, and sign them out so they don't sit in a half-provisioned state.
  const [betaFull, setBetaFull] = React.useState(false);
  // Set when a new user tried to auto-join via the company-domain rule but the
  // workspace is at its seat cap. Show a "contact your admin" screen.
  const [seatLimitReached, setSeatLimitReached] = React.useState(false);
  const [domainMatchFound, setDomainMatchFound] = React.useState(null);
  // Set when /api/me reports the signed-in person used an email that doesn't
  // match the invite they clicked. We show a dedicated screen letting them
  // either retry with the invited email or create a brand-new workspace.
  // Shape: { workspaceName, invitedEmail (masked), yourEmail }
  const [inviteMismatch, setInviteMismatch] = React.useState(null);
  // Set when /api/me reports the workspace is LOCKED — its 14-day trial ended
  // without a payment method. The account is fully provisioned but access is
  // blocked until someone pays; data is retained until locked.purgeAfter, then
  // permanently deleted. Shape: { lockedAt, purgeAfter, daysUntilPurge,
  // canManageBilling }. We show a dedicated lockout screen instead of the app.
  const [locked, setLocked] = React.useState(null);

  // Set for a brand-new workspace owner when billing is live (usageBilling on):
  // show the plan-selection / start-your-14-day-trial screen at signup instead
  // of the free-beta welcome modal. Cleared once they pick a plan or skip.
  const [choosePlan, setChoosePlan] = React.useState(false);

  // Welcome modal for first-time beta sign-up. Triggers when /api/me reports
  // a user with no onboardedAt timestamp on the free_beta plan.
  const [showWelcome, setShowWelcome] = React.useState(false);
  // When the welcome modal is for a just-started trial, this holds the plan id
  // so the modal shows trial copy instead of the beta copy.
  const [welcomeTrialPlan, setWelcomeTrialPlan] = React.useState(null);
  // Set when a super-admin is impersonating this account. Shape: { userId, email }.
  const [impersonating, setImpersonating] = React.useState(null);
  // Mobile search overlay state — declared here (not near its usage below) so
  // it's unconditionally called before any early returns (Rules of Hooks).
  const [mobileSearchOpen, setMobileSearchOpen] = React.useState(false);
  const [mobileSearchQuery, setMobileSearchQuery] = React.useState("");
  const dismissWelcome = React.useCallback(async () => {
    setShowWelcome(false);
    try { if (window.VandayMe) await window.VandayMe.markOnboarded(); } catch {}
  }, []);

  React.useEffect(() => {
    let stop = false;

    // Wait for Clerk to finish loading before firing /api/me, so the very
    // first request carries the Clerk session token. Without this, an old
    // legacy session cookie can sneak the user through as "legacy" and the
    // server never sees them as a real Clerk user (no demos, no user record).
    const waitForClerk = () => new Promise((resolve) => {
      if (window.__clerkReady) return resolve();
      const onReady = () => { window.removeEventListener("clerk-ready", onReady); resolve(); };
      window.addEventListener("clerk-ready", onReady);
      // Safety net: give up waiting after 4s and proceed without Clerk.
      setTimeout(() => { window.removeEventListener("clerk-ready", onReady); resolve(); }, 4000);
    });

    // Wait until window.Clerk.session is established AND getToken() returns
    // a non-null token. Without this, our very first /api/me call can race
    // ahead of Clerk's session setup, arrive at the server without an Auth
    // header, fail to provision the user, and break demo seeding + the
    // welcome modal for brand-new sign-ups.
    const waitForClerkSession = async () => {
      const deadline = Date.now() + 5000;
      while (Date.now() < deadline) {
        try {
          if (window.Clerk?.session) {
            const t = await window.Clerk.session.getToken();
            if (t) { return t; }
          }
        } catch {}
        // If there's no Clerk user at all, we're an anonymous visitor — bail.
        if (window.Clerk && !window.Clerk.user && !window.Clerk.session) {
          return null;
        }
        await new Promise((r) => setTimeout(r, 100));
      }
      return null;
    };

    const fetchMe = async () => {
      const token = await waitForClerkSession();
      const headers = token ? { Authorization: `Bearer ${token}` } : {};
      // Pass the signed-in person's name so the server can store it for the
      // team/members list. Clerk's JWT doesn't always carry name claims, so
      // sourcing it from the client SDK here makes names reliable. Encoded to
      // stay header-safe for non-ASCII names.
      try {
        const cu = window.Clerk?.user;
        const clientName = cu
          ? ([cu.firstName, cu.lastName].filter(Boolean).join(" ") || cu.fullName || "")
          : "";
        if (clientName) headers["X-Vanday-Name"] = encodeURIComponent(clientName);
      } catch {}
      // Forward the invite id (if they arrived via an invite link) so the
      // server can check the signed-in email matches the invite. This /api/me
      // call doesn't go through jfetch, so attach the header explicitly here.
      try {
        const inv = window.sessionStorage.getItem("vanday_invite_id");
        if (inv) headers["X-Vanday-Invite"] = inv;
      } catch {}
      try {
        const dmj = window.sessionStorage.getItem("vanday_domain_join");
        if (dmj) headers["X-Vanday-Domain-Join"] = dmj;
      } catch {}
      return fetch("/api/me", { headers });
    };

    // Flags for the Clerk auth-state retry (see comment below loadMe).
    let clerkListenerRegistered = false;
    let clerkRetried = false;

    const loadMe = async () => {
      try {
        await waitForClerk();

        // Register the Clerk auth-state listener here — after Clerk.load()
        // has completed — so window.Clerk is guaranteed to exist.
        // The Clerk script is loaded with async=true, so window.Clerk is null
        // when the useEffect body runs synchronously and any addListener call
        // there silently no-ops via optional chaining.
        if (!clerkListenerRegistered) {
          clerkListenerRegistered = true;
          window.Clerk?.addListener?.(({ user } = {}) => {
            if (stop || !user || clerkRetried) return;
            clerkRetried = true;
            loadMe();
          });
        }

        // Populate window.USERS with a single entry for the signed-in user
        // so member chips, the bottom-left avatar, the comment author label,
        // etc. all show "you" rather than blank or mock identities.
        try {
          const cu = window.Clerk?.user;
          if (cu) {
            const name = [cu.firstName, cu.lastName].filter(Boolean).join(" ") || cu.primaryEmailAddress?.emailAddress || "You";
            const initials = (name.split(/\s+/).map((s) => s[0]).filter(Boolean).slice(0, 2).join("") || "Y").toUpperCase();
            window.USERS.length = 0;
            window.USERS.push({
              id: cu.id,
              name,
              email: cu.primaryEmailAddress?.emailAddress || null,
              role: "Owner",
              status: "Active",
              lastActive: "Just now",
              joined: new Date(cu.createdAt || Date.now()).toLocaleDateString(),
              color: "oklch(0.55 0.14 32)",
              initials,
            });
            try { localStorage.setItem("vanday_active_user_id", cu.id); } catch {}
          }
        } catch {}

        let r = await fetchMe();
        if (stop) return;
        setAuthChecked(true);
        if (!r.ok) return;
        let d = await r.json();

        // Resilience: if Clerk reports a signed-in user but /api/me didn't
        // see one (race during sign-up before the session token attaches),
        // retry up to three times so the server-side provisioning + demo
        // seeding actually runs for brand-new accounts.
        let retries = 0;
        while (window.Clerk?.user && !d.user && retries < 3) {
          await new Promise((res) => setTimeout(res, 400));
          r = await fetchMe();
          if (stop) return;
          if (!r.ok) break;
          d = await r.json();
          retries += 1;
        }

        // Beta is full: this is a brand-new, uninvited signup we can't admit.
        // Show the "beta is full" screen and sign them out (their account was
        // never created server-side, but Clerk still has a session).
        if (d.betaFull) {
          setBetaFull(true);
          // Always call Clerk.signOut() if the Clerk SDK is loaded — don't gate
    // on window.Clerk.user, which can be transiently null even when the
    // user IS signed in (race between Clerk's client-side hydration and
    // the click handler). Gating caused the sign-out button to silently
    // skip the Clerk session clear on the first click; the second click
    // worked because Clerk had hydrated by then.
    try { await window.Clerk?.signOut?.(); } catch {}
          return;
        }

        // Seat limit reached via company-domain auto-join: the workspace is
        // full and the server didn't add this person as a member. Show a
        // "contact your administrator" screen. No sign-out needed — they have a
        // valid Clerk session; they just can't get into THIS workspace yet.
        if (d.seatLimitReached) {
          setSeatLimitReached(true);
          return;
        }

        // Domain match: user's email matches a workspace with domain auto-join
        // but they haven't chosen yet. Show the "join or start your own" screen.
        if (d.domainMatchFound) {
          setDomainMatchFound(d.domainMatchFound);
          return;
        }

        // Invite email mismatch: the person signed in/up with an email that
        // doesn't match the workspace invite they clicked. The server didn't
        // provision them (no user/org footprint). Show the choice screen.
        if (d.inviteMismatch) {
          setInviteMismatch(d.inviteMismatch);
          return;
        }

        // Workspace locked: the 14-day trial expired without payment. The
        // account is fully provisioned but access is blocked until someone
        // pays; data is retained until d.locked.purgeAfter, then deleted. Show
        // the lockout screen. Only workspace admins (or a personal-workspace
        // owner) can pay, so capture that so the screen shows the right CTA.
        if (d.locked) {
          setLocked({
            ...d.locked,
            canManageBilling: !!(d.org?.isPersonal || d.org?.role === "admin" || d.isAdmin),
          });
          return;
        }

        // Successfully provisioned (no mismatch, no beta-full) — drop the
        // stashed invite id so we stop sending the X-Vanday-Invite header.
        if (d.user) {
          try { window.sessionStorage.removeItem("vanday_invite_id"); } catch {}
          try { window.sessionStorage.removeItem("vanday_domain_join"); } catch {}
        }

        // Correct the placeholder role stamped above (which defaulted to
        // "Owner"). Use the real workspace role from /api/me so the Profile
        // page and member chips don't mislabel editors/viewers as Owner.
        // Personal-workspace admins are shown as "Owner"; everyone else gets
        // their actual role, capitalized.
        try {
          if (window.USERS[0] && d.org) {
            const r = d.org.role || null;
            window.USERS[0].role = d.org.isPersonal
              ? "Owner"
              : (r ? r.charAt(0).toUpperCase() + r.slice(1) : "Member");
          }
        } catch {}

        setImpersonating(d.impersonating || null);
        if (d.loggedIn) setView((v) => (v === "login" ? "library" : v));
        // First-time free-beta user — show the welcome modal. But ONLY for the
        // person who owns/created the workspace (their own personal workspace).
        // Someone who joined via invite lands in an existing team workspace they
        // don't administer: the workspace is already named, demos already
        // seeded, and social accounts are the admin's to set up — so the
        // onboarding modal (name your workspace, connect socials) doesn't apply.
        const isInvitedMember = d.org && d.org.isPersonal === false;
        const isNewOwner = d.user && !d.user.onboardedAt
          && (d.plan?.name || "free_beta") === "free_beta" && !isInvitedMember;
        if (isNewOwner && d.features?.usageBilling === true && !window.__vandaySignupHandled) {
          // Billing is live. If they picked a plan on the sign-in screen before
          // signing up, auto-start that 14-day trial and go straight into the
          // app. Otherwise fall back to the post-signup plan picker.
          //
          // ONE-SHOT guard: /api/me can be processed more than once on load (the
          // provisioning retry path). Without this, the first pass consumes the
          // stashed plan + starts the trial, and a later pass — seeing the stash
          // gone — would pop the picker even though the trial already started.
          window.__vandaySignupHandled = true;
          let preselected = null;
          try { preselected = window.localStorage.getItem("vanday_signup_plan"); } catch {}
          // eslint-disable-next-line no-console
          console.log("[signup] post-verify plan decision · preselected =", preselected);
          if (preselected) {
            try {
              await window.VandayAPI.startTrial(preselected);
              // Clear the stash only AFTER the trial actually starts, so a
              // transient failure doesn't strand the user without their plan.
              try { window.localStorage.removeItem("vanday_signup_plan"); } catch {}
              try { window.localStorage.removeItem("vanday_signup_cycle"); } catch {}
              try { if (window.VandayMe) await window.VandayMe.markOnboarded(); } catch {}
              // eslint-disable-next-line no-console
              console.log("[signup] auto-started trial on", preselected);
              // Trial started → welcome them with the trial-flavored modal.
              setWelcomeTrialPlan(preselected);
              setShowWelcome(true);
            } catch (e) {
              // Couldn't auto-start → show the in-app picker, and re-arm the
              // guard + keep the stash so a reload can retry.
              window.__vandaySignupHandled = false;
              // eslint-disable-next-line no-console
              console.warn("[signup] auto-start failed → picker:", e && e.message);
              setChoosePlan(true);
            }
          } else {
            setChoosePlan(true);
          }
        } else if (isNewOwner && !(d.features?.usageBilling === true)) {
          // Billing not rolled out → the original free-beta welcome modal.
          setShowWelcome(true);
        }
        // Make the response available to other components via the global cache.
        if (window.VandayMe) {
          try { await window.VandayMe.fetchMe(); } catch {}
        }
      } catch { if (!stop) setAuthChecked(true); }
    };
    loadMe();

    return () => { stop = true; };
  }, []);

  // Server bootstrap: load real assets from the local Node service, then
  // re-render whenever the server-side list changes (uploads, AI completion).
  // If the server isn't reachable, the mocked sample data still shows.
  VandayServer.useServerRev();
  VandayServer.useServerAssets({ pollMs: 2500 });

  const handleLogout = React.useCallback(async () => {
    try { await fetch("/api/logout", { method: "POST" }); } catch {}
    // Also sign out of Clerk so the next visit doesn't auto-log in.
    // Always call Clerk.signOut() if the Clerk SDK is loaded — don't gate
    // on window.Clerk.user, which can be transiently null even when the
    // user IS signed in (race between Clerk's client-side hydration and
    // the click handler). Gating caused the sign-out button to silently
    // skip the Clerk session clear on the first click; the second click
    // worked because Clerk had hydrated by then.
    try { await window.Clerk?.signOut?.(); } catch {}
    setView("login");
  }, []);
  // Expose globally so each page's Rail "Logout" button hits the real API
  // without prop-drilling through every screen.
  React.useEffect(() => { window.__vandayLogout = handleLogout; }, [handleLogout]);
  // Tiny global nav helper — lets the welcome modal (and other floating UI)
  // send the user to a different view without prop-drilling.
  React.useEffect(() => {
    window.__vandayGoto = (v) => setView(v);
    // Real upload straight into the Inbox — used by the welcome CTA. Selects
    // the Inbox so the destination picker defaults to it, then opens the
    // quick-upload picker (the working flow, NOT the old UploadPage).
    window.__vandayUploadToInbox = () => {
      setView("library");
      setActiveFolder("raw");
      setShowUploadPicker(true);
    };
  }, []);

  // Google Drive connect returns to /?google=connected (a #hash route can't be
  // restored on a cold load). Land the user back on Settings → Integrations —
  // where the new "Import from Drive" button now is — by seeding the settings
  // section hint SettingsPage reads on mount, then strip the param so a refresh
  // doesn't repeat it.
  React.useEffect(() => {
    const p = new URLSearchParams(window.location.search);
    if (p.get("google") !== "connected") return;
    try { localStorage.setItem("vanday_settings_section", "integrations"); } catch {}
    setView("settings");
    p.delete("google");
    const qs = p.toString();
    window.history.replaceState({}, "", window.location.pathname + (qs ? "?" + qs : "") + window.location.hash);
  }, []);
  // Deep-link from transactional emails (trial reminders, etc.):
  // /?settings=<section> opens Settings on that section. "billing" maps to the
  // merged "Plan & billing" tab (id "plan"). Mirror the google-connected flow:
  // seed the section hint SettingsPage reads on mount, switch to settings, then
  // strip the param so a refresh doesn't repeat it.
  React.useEffect(() => {
    const p = new URLSearchParams(window.location.search);
    const s = p.get("settings");
    if (!s) return;
    const section = s === "billing" ? "plan" : s;
    try { localStorage.setItem("vanday_settings_section", section); } catch {}
    setView("settings");
    p.delete("settings");
    const qs = p.toString();
    window.history.replaceState({}, "", window.location.pathname + (qs ? "?" + qs : "") + window.location.hash);
  }, []);
  // After the in-app plan picker (ChoosePlanTrial) reloads the page, surface the
  // trial welcome modal for the plan they just started (the auto-start path sets
  // this directly without a reload; this covers the reload path).
  React.useEffect(() => {
    let p = null;
    try { p = window.localStorage.getItem("vanday_trial_welcome"); } catch {}
    if (!p) return;
    try { window.localStorage.removeItem("vanday_trial_welcome"); } catch {}
    setWelcomeTrialPlan(p);
    setShowWelcome(true);
  }, []);
  const [activeFolder, setActiveFolder] = React.useState("all");
  const [previewAsset, setPreviewAsset] = React.useState(null);
  const [publishAsset, setPublishAsset] = React.useState(null);
  const [permissionsFolder, setPermissionsFolder] = React.useState(null);
  const [shareFolder, setShareFolder] = React.useState(null);
  const [activePortal, setActivePortal] = React.useState(null);
  const [similarSource, setSimilarSource] = React.useState(null);
  // Preview navigation stack: when user jumps from one asset's Related tab
  // to another, we push the previous asset here so they can Back out of it.
  const [previewStack, setPreviewStack] = React.useState([]);

  // Quick-upload flow: destination picker + persistent progress dock.
  // Hooked into Rail / topbar buttons everywhere. If user wants the full
  // batch experience they can still open the dedicated UploadPage.
  const [showUploadPicker, setShowUploadPicker] = React.useState(false);
  const [uploadQueue, setUploadQueue] = React.useState([]);
  const queueIdRef = React.useRef(1);
  const openQuickUpload = () => setShowUploadPicker(true);

  // ----- Mobile chrome (Wave 2, from Claude Design "DAM Refresh") -----------
  // Tracks the off-canvas folder drawer. Synced to a `drawer-open` class on
  // <body> so the CSS at the bottom of styles.css can slide the sidebar in
  // without each individual screen needing to know about it. Auto-closes
  // whenever the top-level view changes (e.g. tapping the Upload tab in the
  // bottom nav navigates away from Library AND closes the drawer).
  const [drawerOpen, setDrawerOpen] = React.useState(false);
  React.useEffect(() => {
    if (drawerOpen) document.body.classList.add("drawer-open");
    else document.body.classList.remove("drawer-open");
    return () => document.body.classList.remove("drawer-open");
  }, [drawerOpen]);
  React.useEffect(() => { setDrawerOpen(false); }, [view]);
  // Sidebar items inside the drawer call setActiveFolder, which doesn't change
  // the top-level `view`, so the close-on-view-change effect above never fires
  // for in-library navigation. Listen for a vanday-drawer-close event that
  // library.jsx's Sidebar dispatches whenever the user picks a folder / quick-
  // access view, so the drawer slides shut and they see what they just selected.
  React.useEffect(() => {
    const handler = () => setDrawerOpen(false);
    window.addEventListener("vanday-drawer-close", handler);
    return () => window.removeEventListener("vanday-drawer-close", handler);
  }, []);

  // Real upload: hand the user's selected files to the server API and let
  // the upload dock track real progress + the real server response.
  const queueFiles = async ({ folderId, files }) => {
    setShowUploadPicker(false);
    if (!files || files.length === 0) return;

    // ----- Pre-flight upload-count cap check -----
    // Read the live limits + usage from /api/me. If this batch would
    // push the org over the free-beta upload cap, stop right here and
    // tell the user — don't show progress for files we know we're
    // going to reject. window.alert is intentional: this is a hard
    // wall, not a soft warning, and the user should acknowledge it
    // before continuing.
    try {
      const limits = (typeof window.useVandayLimitsSnapshot === "function")
        ? window.useVandayLimitsSnapshot()
        : null;
      if (limits && Number.isFinite(limits.maxUploads)) {
        const remaining = Math.max(0, limits.maxUploads - (limits.uploadsUsed || 0));
        if (remaining <= 0) {
          window.alert(
            `You've reached the ${limits.maxUploads}-upload limit on the ${({ free_beta: "Free Beta", starter: "Starter", growth: "Growth", pro: "Pro" }[limits.planName] || "current")} plan. ` +
            `Delete some assets in your library to make room before uploading more.`
          );
          return;
        }
        if (files.length > remaining) {
          const msg =
            `You're trying to upload ${files.length} file${files.length === 1 ? "" : "s"} ` +
            `but only have ${remaining} slot${remaining === 1 ? "" : "s"} left ` +
            `(${limits.uploadsUsed} / ${limits.maxUploads} used).\n\n` +
            `Free up some room by deleting assets, then try again.`;
          window.alert(msg);
          return;
        }
      }
    } catch { /* if /api/me hasn't loaded, fall through and let server enforce */ }

    // Stage each file as an "uploading" row in the dock right away, so the
    // user sees activity while the network call is in flight.
    const rows = files.map((f) => ({
      id: `u-${queueIdRef.current++}`,
      name: f.name,
      size: f.size,
      thumb: f.thumb,
      folder: folderId,
      progress: 30,           // arbitrary — we don't get true progress events from fetch
      status: "uploading",
      asset: null,
      _file: f._file,         // underlying browser File
    }));
    setUploadQueue((q) => [...q, ...rows]);

    // Fire one POST /api/upload with every file in this batch.
    const realFiles = files.map((f) => f._file).filter(Boolean);
    try {
      const resp = await window.VandayAPI.uploadFiles(realFiles, { folder: folderId });
      const created = Array.isArray(resp?.assets) ? resp.assets : [];
      // Refresh /api/me so the limits snapshot reflects the new count;
      // the next upload attempt's pre-flight check needs to be accurate.
      try { window.VandayMe?.fetchMe?.({ force: true }); } catch {}
      // Files the server couldn't accept (unsupported type inside a zip, or the
      // upload-count cap) come back in resp.skipped. Surface each as its own
      // failed row in the upload dock — a clear, non-disruptive failure flow —
      // instead of a modal alert that vanishes and leaves no trace.
      const skippedRows = (resp?.skipped || []).map((s) => ({
        id: `u-${queueIdRef.current++}`,
        name: s.name,
        size: 0,
        thumb: null,
        progress: 100,
        status: "failed",
        noRetry: true, // retrying an unsupported type / hit cap won't help
        error: s.reason === "unsupported_type" ? "Skipped — unsupported file type"
          : s.reason === "upload_limit_reached" ? "Skipped — upload limit reached"
          : "Skipped",
      }));

      // Reconcile staged rows with what the server actually created.
      //
      // Three cases:
      //   1. created.length === rows.length — normal images, 1-to-1 by index
      //      (handles HEIC where the asset's name changes to .jpg)
      //   2. created.length > rows.length — at least one file was a zip the
      //      server expanded into many assets, or HEIC at the zip root added
      //      to the count. Replace our staged rows with one per server asset.
      //   3. created.length < rows.length — some files were rejected silently
      //      (rare). Show the unmatched ones as failed.
      setUploadQueue((qs) => {
        const others = qs.filter((q) => !rows.some((r) => r.id === q.id));
        if (created.length === rows.length) {
          return [
            ...others,
            ...rows.map((q, i) => {
              const a = created[i];
              return { ...q, status: "analyzing", progress: 100, asset: a, name: a.name, thumb: a.url, _file: undefined };
            }),
            ...skippedRows,
          ];
        }
        if (created.length > 0) {
          // Replace staged rows with one row per actually-created asset.
          return [
            ...others,
            ...created.map((a) => ({
              id: `u-${queueIdRef.current++}`,
              name: a.name,
              size: (a.sizeBytes || 0) / (1024 * 1024),
              thumb: a.url,
              folder: a.folder,
              progress: 100,
              status: "analyzing",
              asset: a,
            })),
            ...skippedRows,
          ];
        }
        // Nothing created — show all staged rows as failed.
        return [
          ...others,
          ...rows.map((q) => ({ ...q, status: "failed", error: "Server did not accept these files", _file: undefined })),
          ...skippedRows,
        ];
      });

      // Poll each uploaded asset until AI tagging finishes (or we time out).
      for (const a of created) {
        const start = Date.now();
        const tick = async () => {
          if (Date.now() - start > 120_000) return;
          try {
            const fresh = await window.VandayAPI.getAsset(a.id);
            if (fresh.aiState === "done" || fresh.aiState === "failed") {
              setUploadQueue((qs) => qs.map((q) =>
                q.asset?.id === a.id
                  ? { ...q, status: "ready", asset: fresh }
                  : q
              ));
              return;
            }
          } catch {/* keep polling */}
          setTimeout(tick, 700);
        };
        setTimeout(tick, 700);
      }
    } catch (err) {
      // Parse server error message if it's a structured 4xx.
      let msg = String(err?.message || err || "Upload failed");
      try {
        const m = msg.match(/: (\{.+\})$/);
        if (m) {
          const body = JSON.parse(m[1]);
          if (body.message) msg = body.message;
          else if (body.error) msg = body.error.replace(/_/g, " ");
        }
      } catch {}
      setUploadQueue((qs) => qs.map((q) => (
        rows.some((r) => r.id === q.id)
          ? { ...q, status: "failed", error: msg, _file: undefined }
          : q
      )));
    }
  };

  const retryUpload = (id) => setUploadQueue((qs) => qs.map((q) => (
    q.id === id ? { ...q, status: "uploading", progress: 0, willFail: false, error: null } : q
  )));
  // useCallback so the function identity is stable across App
  // re-renders. UploadDock's auto-dismiss effect depends on this
  // reference — without the memo, every App render handed it a new
  // function, the effect re-ran, and its 4.5s setTimeout was cancelled
  // and re-scheduled before it could fire. Net effect: the dock would
  // sit at "All N files processed" indefinitely.
  const clearCompleted = React.useCallback(() => {
    setUploadQueue((qs) => qs.filter((q) => (
      q.status === "uploading" || q.status === "analyzing"
    )));
  }, []);

  const openSimilar = (a) => {
    setSimilarSource(a);
    setView("library");
  };
  const clearSimilar = () => setSimilarSource(null);

  const isPublished = activeFolder === "__published";
  // Preview navigation. The pager steps through ORIGINAL assets so its count
  // matches the library grid (crops are reachable via the Crops tab). When a
  // crop itself is open, prev/next steps through that asset's sibling crops so
  // crop navigation still works.
  const baseList = ALL_ASSETS;
  const folderList =
    activeFolder === "all" || isPublished
      ? baseList
      : baseList.filter((a) => a.folder === activeFolder);
  const visibleAssets =
    previewAsset && previewAsset.kind === "crop"
      ? baseList.filter((a) => a.kind === "crop" && a.parentId === previewAsset.parentId)
      : folderList.filter((a) => a.kind !== "crop");
  const previewIndex = previewAsset
    ? visibleAssets.findIndex((a) => a.id === previewAsset.id)
    : -1;

  const openAsset = (a) => {
    // If we're already viewing a different asset and the user is opening
    // another from inside preview (e.g. via Related / Find similar / crops),
    // push the current asset so they can navigate back.
    if (view === "preview" && previewAsset && previewAsset.id !== a.id) {
      setPreviewStack((s) => [...s, previewAsset]);
    }
    setPreviewAsset(a);
    setView("preview");
  };
  const goBackInPreview = () => {
    setPreviewStack((s) => {
      if (s.length === 0) return s;
      const next = s.slice(0, -1);
      setPreviewAsset(s[s.length - 1]);
      return next;
    });
  };
  const closePreview = () => {
    setPreviewStack([]);
    setView("library");
  };
  const goPrev = () => {
    if (previewIndex > 0) setPreviewAsset(visibleAssets[previewIndex - 1]);
  };
  const goNext = () => {
    if (previewIndex < visibleAssets.length - 1) setPreviewAsset(visibleAssets[previewIndex + 1]);
  };
  const openPublish = (a) => setPublishAsset(a);
  const closePublish = () => setPublishAsset(null);

  // Shopify OAuth return — Shopify redirects the merchant back to
  // /?shopify=connected|error. Deep-link into Settings → Integrations once
  // logged in, where IntegrationsHub reads the param and shows the result.
  const shopifyReturnPending = React.useRef(
    new URLSearchParams(window.location.search).has("shopify"),
  );
  React.useEffect(() => {
    if (!shopifyReturnPending.current) return;
    if (view === "login") return; // wait until authenticated
    shopifyReturnPending.current = false;
    try { localStorage.setItem("vanday_settings_section", "integrations"); } catch {}
    setView("settings");
  }, [view]);

  // Reopen the Publish modal after the social-connect flow. The modal
  // navigates the whole tab to Upload-Post and back to /?back_from=connect;
  // here we restore it for the asset the user was publishing. We retry until
  // the server asset list has loaded (assets stream in asynchronously).
  React.useEffect(() => {
    const p = new URLSearchParams(window.location.search);
    if (p.get("back_from") !== "connect") return;
    let pendingId = null;
    try { pendingId = sessionStorage.getItem("vanday_publish_return"); } catch {}
    if (!pendingId) return; // came from Settings, not the publish modal

    const cleanUrl = () => {
      const q = new URLSearchParams(window.location.search);
      q.delete("back_from");
      const qs = q.toString();
      window.history.replaceState({}, "", window.location.pathname + (qs ? "?" + qs : "") + window.location.hash);
    };
    const tryOpen = () => {
      const a = getAsset(pendingId);
      if (!a) return false;
      setPublishAsset(a);
      try { sessionStorage.removeItem("vanday_publish_return"); } catch {}
      cleanUrl();
      return true;
    };
    if (tryOpen()) return;
    let tries = 0;
    const iv = setInterval(() => {
      tries += 1;
      if (tryOpen() || tries > 40) { // ~10s ceiling
        clearInterval(iv);
        if (tries > 40) { try { sessionStorage.removeItem("vanday_publish_return"); } catch {} cleanUrl(); }
      }
    }, 250);
    return () => clearInterval(iv);
  }, []);

  // Keyboard navigation on preview
  React.useEffect(() => {
    if (view !== "preview" || publishAsset) return;
    const onKey = (e) => {
      if (e.key === "Escape") closePreview();
      if (e.key === "ArrowLeft") goPrev();
      if (e.key === "ArrowRight") goNext();
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [view, previewIndex, publishAsset]);

  // Beta is full — brand-new, uninvited signups can't get in. Show a friendly
  // dead-end instead of the app. (All hooks above run unconditionally; this
  // early return is safe.)
  if (betaFull) {
    return (
      <div style={{
        position: "fixed", inset: 0,
        display: "grid", placeItems: "center", padding: 24,
        background: "var(--bg, #0b0f1a)", color: "var(--text, #f3f5f9)",
        fontFamily: "inherit",
      }}>
        <div style={{
          width: "100%", maxWidth: 460, textAlign: "center",
          background: "var(--surface, #fff)", color: "var(--text, #111)",
          borderRadius: 16, boxShadow: "0 20px 60px rgba(0,0,0,0.25)",
          padding: "40px 34px",
        }}>
          <div style={{
            width: 52, height: 52, borderRadius: 13, margin: "0 auto 18px",
            background: "var(--brand, #5b6cff)", color: "white",
            display: "grid", placeItems: "center", fontWeight: 700, fontSize: 22,
          }}>v.</div>
          <h1 style={{ fontSize: 20, fontWeight: 700, margin: "0 0 10px" }}>
            The Vanday beta is full
          </h1>
          <p style={{ fontSize: 14, lineHeight: 1.6, color: "var(--text-muted)", margin: 0 }}>
            Thanks so much for your interest! We've reached the limit for this
            round of the free beta. We'll reach out by email as soon as we
            re-open sign-ups — no need to do anything in the meantime.
          </p>
        </div>
      </div>
    );
  }

  // Workspace locked — its trial ended without payment. Block the app and show
  // the lockout screen (with a path to pay, for admins). Its own component so
  // its data-fetching hooks aren't called conditionally (Rules of Hooks).
  if (locked) {
    return <TrialEndedLockout locked={locked} />;
  }

  // Brand-new owner + billing live — pick a plan and start the 14-day trial
  // before entering the app. Its own component so its hooks run unconditionally.
  if (choosePlan) {
    return <ChoosePlanTrial onDone={() => setChoosePlan(false)} />;
  }

  // Seat limit reached — this person tried to auto-join via the company-domain
  // rule but the workspace is at capacity. Tell them to contact an admin.
  if (seatLimitReached) {
    return (
      <div style={{
        position: "fixed", inset: 0,
        display: "grid", placeItems: "center", padding: 24,
        background: "var(--bg, #f9f8f6)", color: "var(--text, #1f1f1c)",
        fontFamily: "inherit",
      }}>
        <div style={{
          width: "100%", maxWidth: 460, textAlign: "center",
          background: "var(--surface, #fff)", color: "var(--text, #111)",
          borderRadius: 16, boxShadow: "0 20px 60px rgba(0,0,0,0.12)",
          border: "1px solid var(--border, #e7e6e0)",
          padding: "40px 34px",
        }}>
          <div style={{
            width: 52, height: 52, borderRadius: 13, margin: "0 auto 18px",
            background: "var(--accent-soft, #fdf0ee)", color: "var(--accent, #c8553d)",
            display: "grid", placeItems: "center", fontSize: 24,
          }}>🔒</div>
          <h1 style={{ fontSize: 20, fontWeight: 700, margin: "0 0 10px" }}>
            This workspace is full
          </h1>
          <p style={{ fontSize: 14, lineHeight: 1.6, color: "var(--text-muted)", margin: "0 0 20px" }}>
            Your organization's Vanday workspace has used all of its available seats.
            Contact your workspace administrator — they can either upgrade the plan
            or remove a member to make room for you.
          </p>
          <button
            className="btn"
            onClick={async () => { try { await window.Clerk?.signOut?.(); } catch {} location.reload(); }}
            style={{ fontSize: 13 }}
          >
            Sign out
          </button>
        </div>
      </div>
    );
  }

  // Domain match choice — user's email matches a workspace with domain auto-join
  // but we don't silently capture them. Let them decide: join the team or start
  // their own separate workspace.
  if (domainMatchFound) {
    const joinWorkspace = () => {
      try { window.sessionStorage.setItem("vanday_domain_join", "confirm"); } catch {}
      location.reload();
    };
    const createOwnWorkspace = () => {
      try { window.sessionStorage.setItem("vanday_domain_join", "skip"); } catch {}
      location.reload();
    };
    return (
      <div style={{
        position: "fixed", inset: 0,
        display: "grid", placeItems: "center", padding: 24,
        background: "var(--bg, #f9f8f6)", color: "var(--text, #1f1f1c)",
        fontFamily: "inherit",
      }}>
        <div style={{
          width: "100%", maxWidth: 460, textAlign: "center",
          background: "var(--surface, #fff)", color: "var(--text, #111)",
          borderRadius: 16, boxShadow: "0 20px 60px rgba(0,0,0,0.12)",
          border: "1px solid var(--border, #e7e6e0)",
          padding: "40px 34px",
        }}>
          <div style={{
            width: 52, height: 52, borderRadius: 13, margin: "0 auto 18px",
            background: "var(--brand-soft, #eef0ff)", color: "var(--brand, #5b6cff)",
            display: "grid", placeItems: "center", fontSize: 24,
          }}>🏢</div>
          <h1 style={{ fontSize: 20, fontWeight: 700, margin: "0 0 10px" }}>
            {domainMatchFound.orgName} is on Vanday
          </h1>
          <p style={{ fontSize: 14, lineHeight: 1.6, color: "var(--text-muted)", margin: "0 0 28px" }}>
            Your email matches your company&rsquo;s existing Vanday workspace.
            Would you like to join your team, or start your own separate account?
          </p>
          <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
            <button className="btn" onClick={joinWorkspace} style={{ fontSize: 14 }}>
              Join {domainMatchFound.orgName}
            </button>
            <button
              onClick={createOwnWorkspace}
              style={{
                background: "none", border: "none", cursor: "pointer",
                fontSize: 13, color: "var(--text-muted)", padding: "6px 0",
                textDecoration: "underline",
              }}
            >
              Start my own workspace instead
            </button>
          </div>
        </div>
      </div>
    );
  }

  // Invite email mismatch — the person authenticated with an email that
  // doesn't match the workspace invite. Offer a clear choice: retry with the
  // invited email, or abandon the invite and start their own workspace.
  if (inviteMismatch) {
    const useInvitedEmail = async () => {
      // Keep the invite id in sessionStorage and bounce them back through
      // sign-up. Signing out of Clerk + reloading re-opens the sign-up popup
      // (the URL still carries ?join=invite), where they can use the right email.
      // Always call Clerk.signOut() if the Clerk SDK is loaded — don't gate
    // on window.Clerk.user, which can be transiently null even when the
    // user IS signed in (race between Clerk's client-side hydration and
    // the click handler). Gating caused the sign-out button to silently
    // skip the Clerk session clear on the first click; the second click
    // worked because Clerk had hydrated by then.
    try { await window.Clerk?.signOut?.(); } catch {}
      location.reload();
    };
    const createNewInstead = () => {
      // Drop the invite link so jfetch stops sending X-Vanday-Invite; the
      // server then provisions a normal personal workspace for this email.
      try { window.sessionStorage.removeItem("vanday_invite_id"); } catch {}
      // Also strip the invite params from the URL. Without this, login.jsx
      // re-reads ?join=invite&i=… on the next load and immediately re-stashes
      // the invite id, so /api/me sends X-Vanday-Invite again and the user
      // lands right back on this mismatch screen — an infinite loop.
      try {
        const u = new URL(window.location.href);
        u.searchParams.delete("join");
        u.searchParams.delete("i");
        u.searchParams.delete("w");
        window.history.replaceState({}, "", u.pathname + u.search + u.hash);
      } catch {}
      location.reload();
    };
    return (
      <div style={{
        position: "fixed", inset: 0,
        display: "grid", placeItems: "center", padding: 24,
        background: "var(--bg, #0b0f1a)", color: "var(--text, #f3f5f9)",
        fontFamily: "inherit",
      }}>
        <div style={{
          width: "100%", maxWidth: 480, textAlign: "center",
          background: "var(--surface, #fff)", color: "var(--text, #111)",
          borderRadius: 16, boxShadow: "0 20px 60px rgba(0,0,0,0.25)",
          padding: "40px 34px",
        }}>
          <div style={{
            width: 52, height: 52, borderRadius: 13, margin: "0 auto 18px",
            background: "var(--brand, #5b6cff)", color: "white",
            display: "grid", placeItems: "center", fontWeight: 700, fontSize: 22,
          }}>v.</div>
          <h1 style={{ fontSize: 20, fontWeight: 700, margin: "0 0 10px" }}>
            Your email doesn't match the invite
          </h1>
          <p style={{ fontSize: 14, lineHeight: 1.6, color: "var(--text-muted)", margin: "0 0 8px" }}>
            To join <strong>{inviteMismatch.workspaceName}</strong>, sign in with the
            email the invite was sent to{inviteMismatch.invitedEmail ? <> (<strong>{inviteMismatch.invitedEmail}</strong>)</> : null}.
          </p>
          {inviteMismatch.yourEmail && (
            <p style={{ fontSize: 13, lineHeight: 1.6, color: "var(--text-faint)", margin: "0 0 22px" }}>
              You signed in as <strong>{inviteMismatch.yourEmail}</strong>.
            </p>
          )}
          <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
            <button type="button" className="btn primary btn-block" onClick={useInvitedEmail}>
              Use the invited email
            </button>
            <button type="button" className="btn btn-block" onClick={createNewInstead}>
              Create a new account instead
            </button>
          </div>
        </div>
      </div>
    );
  }

  let screen;
  if (view === "login") screen = <LoginPage onLogin={() => setView("library")} />;
  else if (view === "upload") screen = <UploadPage onNav={setView} onOpenAsset={openAsset} onQuickUpload={openQuickUpload} />;
  else if (view === "admin") screen = <AdminDashboard onClose={() => setView("library")} />;
  else if (view === "settings") screen = <SettingsPage onNav={setView} onQuickUpload={openQuickUpload} />;
  else if (view === "portal") screen = <SharePortalPage portal={activePortal} onClose={() => { setActivePortal(null); setView("library"); }} />;
  else if (view === "preview")
    screen = (
      <PreviewPage
        asset={previewAsset}
        onClose={closePreview}
        onNav={setView}
        onPrev={goPrev}
        onNext={goNext}
        index={previewIndex >= 0 ? previewIndex : 0}
        total={visibleAssets.length}
        onPublish={openPublish}
        onOpenAsset={openAsset}
        onFindSimilar={openSimilar}
        onShare={(ctx) => setShareFolder(ctx)}
        backStack={previewStack}
        onBack={goBackInPreview}
        onQuickUpload={openQuickUpload}
      />
    );
  else
    screen = (
      <LibraryPage
        onOpenAsset={openAsset}
        onNav={setView}
        activeFolder={activeFolder}
        setActiveFolder={setActiveFolder}
        onPublish={openPublish}
        onFindSimilar={openSimilar}
        similarSource={similarSource}
        onClearSimilar={clearSimilar}
        onPermissions={setPermissionsFolder}
        onShare={setShareFolder}
        onQuickUpload={openQuickUpload}
      />
    );

  // Trial countdown banner. Shows above every authenticated view when a
  // 14-day in-app trial is active and entering its last week (daysLeft <= 7).
  // Clicking the upgrade CTA navigates to Settings → Billing where the user
  // can convert to a paid subscription. Hidden on login + share portal so
  // those public/unauthenticated layouts aren't disrupted.
  const showTrialBanner = view !== "login" && view !== "portal";

  // Mobile chrome only hides on login + public share-portal pages, which have
  // their own full-page layouts. Preview now keeps the chrome so the user can
  // navigate away (earlier we excluded it; that left users stranded on phone).
  const showMobileChrome = view !== "login" && view !== "portal";
  const MOBILE_TITLES = {
    library: "Library",
    upload: "Upload",
    settings: "Settings",
    admin: "Admin",
    preview: "Preview",
  };
  const mobileTitle = MOBILE_TITLES[view] || "Vanday";

  // Mobile search overlay — when the topbar's search icon is tapped, the title
  // and right-side actions are hidden and the row turns into an inline search
  // input. Typing dispatches a `vanday-search` window event that library.jsx
  // listens for; closing clears the query.
  // NOTE: useState declarations for this are hoisted to the top of App to
  // satisfy Rules of Hooks (they must not come after any early return).
  const emitSearch = (q) => {
    try { window.dispatchEvent(new CustomEvent("vanday-search", { detail: { q } })); } catch {}
  };
  const openMobileSearch = () => {
    if (view !== "library") setView("library");
    setMobileSearchOpen(true);
  };
  const closeMobileSearch = () => {
    setMobileSearchOpen(false);
    setMobileSearchQuery("");
    emitSearch("");
  };

  return (
    <>
      {impersonating && (
        <div style={{
          // sticky (not fixed) so the banner reserves its own space and pushes
          // the app down instead of overlaying the top action bar / tabs —
          // matching the TrialBanner pattern. Stays pinned while scrolling.
          position: "sticky", top: 0, left: 0, right: 0, zIndex: 9999,
          background: "#f5a623", color: "#000", padding: "8px 16px",
          display: "flex", alignItems: "center", justifyContent: "center", gap: 12,
          fontSize: 13, fontWeight: 500, boxShadow: "0 2px 6px rgba(0,0,0,0.15)",
        }}>
          <span>⚠ You are impersonating <strong>{impersonating.email || impersonating.userId}</strong></span>
          <button
            onClick={async () => {
              try {
                const t = window.Clerk?.session ? await window.Clerk.session.getToken() : null;
                await fetch("/api/admin/impersonate/stop", {
                  method: "POST",
                  headers: t ? { Authorization: `Bearer ${t}` } : {},
                });
              } catch {}
              window.location.reload();
            }}
            style={{
              background: "rgba(0,0,0,0.15)", border: "none", borderRadius: 4,
              padding: "3px 10px", cursor: "pointer", fontWeight: 600, fontSize: 12,
            }}
          >Stop impersonating</button>
        </div>
      )}
      {showTrialBanner && <TrialBanner onGoToBilling={() => setView("settings")} />}
      {showMobileChrome && (
        <header className={"m-topbar" + (mobileSearchOpen ? " searching" : "")}>
          {mobileSearchOpen ? (
            <>
              <input
                className="m-search-input"
                autoFocus
                value={mobileSearchQuery}
                onChange={(e) => {
                  const q = e.target.value;
                  setMobileSearchQuery(q);
                  emitSearch(q);
                }}
                placeholder="Search assets, tags, crops…"
                type="search"
              />
              <button
                className="m-search-close"
                onClick={closeMobileSearch}
                aria-label="Close search"
                type="button"
              >
                ×
              </button>
            </>
          ) : (
            <>
              {/* On Preview, the library sidebar isn't rendered (different view),
                  so opening the drawer would slide in nothing — tap appeared
                  dead. Repurpose the left slot as "back to library" while on
                  preview. Everywhere else, it's the menu burger as before. */}
              {view === "preview" ? (
                <button
                  className="m-burger"
                  onClick={() => setView("library")}
                  aria-label="Back to library"
                  type="button"
                >
                  <IcChevL size={20} />
                </button>
              ) : (
                <button
                  className="m-burger"
                  onClick={() => setDrawerOpen(true)}
                  aria-label="Open menu"
                  type="button"
                >
                  <IcMenu size={20} />
                </button>
              )}
              <span className="m-title">{mobileTitle}</span>
              <div className="m-actions">
                <button
                  className="m-icon"
                  onClick={openMobileSearch}
                  aria-label="Search"
                  type="button"
                >
                  <IcSearch size={17} />
                </button>
                <button
                  className="m-icon accent"
                  onClick={openQuickUpload}
                  aria-label="Upload"
                  type="button"
                >
                  <IcUpload size={17} />
                </button>
              </div>
            </>
          )}
        </header>
      )}
      {screen}
      {showMobileChrome && (
        <>
          {/* Scrim tap-anywhere to dismiss the drawer. */}
          <div className="drawer-scrim" onClick={() => setDrawerOpen(false)} />
          <nav className="bottom-nav">
            <button
              className={`bn-item ${view === "library" ? "on" : ""}`}
              onClick={() => setView("library")}
              type="button"
            >
              <IcHome size={20} />
              Library
            </button>
            <button
              className="bn-item"
              onClick={() => setDrawerOpen(true)}
              type="button"
            >
              <IcFolder size={20} />
              Browse
            </button>
            <button
              className="bn-fab"
              onClick={openQuickUpload}
              aria-label="Upload"
              type="button"
            >
              <IcUpload size={22} />
            </button>
            <button
              className={`bn-item ${view === "settings" ? "on" : ""}`}
              onClick={() => setView("settings")}
              type="button"
            >
              <IcSettings size={20} />
              Settings
            </button>
            {/* Profile tab — for now routes to Settings (which contains the
                profile section). Wave 2b can wire a dedicated profile view. */}
            <button
              className="bn-item"
              onClick={() => setView("settings")}
              type="button"
            >
              <IcUsers size={20} />
              Profile
            </button>
          </nav>
        </>
      )}
      <WelcomeModal open={showWelcome} trialPlan={welcomeTrialPlan} onClose={dismissWelcome} />
      {publishAsset && (
        <PublishModal asset={publishAsset} onClose={closePublish} />
      )}
      {permissionsFolder && (
        <PermissionsModal
          folder={permissionsFolder}
          onClose={() => setPermissionsFolder(null)}
        />
      )}
      {shareFolder && (
        <ShareModal
          folder={shareFolder.asset || shareFolder.presetAssets ? null : shareFolder}
          asset={shareFolder.asset || null}
          presetAssets={shareFolder.presetAssets || null}
          onClose={() => setShareFolder(null)}
          onOpenPortal={(p) => {
            setActivePortal({
              ...p,
              folder: (shareFolder.asset || shareFolder.presetAssets) ? null : shareFolder.id,
              assetIds: p.assetIds,
            });
            setShareFolder(null);
            setView("portal");
          }}
        />
      )}

      {showUploadPicker && (
        <UploadDestinationModal
          contextFolder={activeFolder}
          onCancel={() => setShowUploadPicker(false)}
          onConfirm={queueFiles}
        />
      )}

      {view !== "login" && view !== "portal" && (
        <UploadDock
          queue={uploadQueue}
          onDismiss={clearCompleted}
          onRetry={retryUpload}
          onOpenAsset={openAsset}
        />
      )}
    </>
  );
}

// ---------------------------------------------------------------------------
// TrialBanner — top-of-app reminder for orgs in the last 7 days of a 14-day
// in-app trial. Polls /api/billing on mount + every 5 minutes after that.
// Dismissable per session (the X is a no-op past the immediate session); we
// don't persist dismissal across reloads because a customer who's missed
// adding a card needs to be re-poked aggressively as the deadline approaches.
// ---------------------------------------------------------------------------
function TrialBanner({ onGoToBilling }) {
  const [trial, setTrial] = React.useState(null);
  const [dismissed, setDismissed] = React.useState(false);
  const [busy, setBusy] = React.useState(false);

  const load = React.useCallback(() => {
    // No billing rolled out for this workspace → no trial is possible, and
    // /api/billing is admin-only. Skip the poll so non-admin/beta users don't
    // fire a pointless 403 every 5 minutes.
    if (window.VandayMe?.usageBillingEnabled?.() !== true) { setTrial(null); return; }
    window.VandayAPI?.getBilling?.()
      .then((d) => { if (d?.trial) setTrial(d.trial); else setTrial(null); })
      .catch(() => {});
  }, []);

  React.useEffect(() => {
    load();
    const iv = window.setInterval(load, 5 * 60 * 1000);
    return () => window.clearInterval(iv);
  }, [load]);

  if (!trial || dismissed) return null;
  // Only nag in the last week. Earlier in the trial we don't want to
  // pressure the user — let them explore.
  if (trial.daysLeft > 7) return null;

  // Tone scales with urgency. 7-4 days = soft accent. 3-2 days = stronger.
  // 1 day / today = red, no dismiss button.
  const urgent = trial.daysLeft <= 1;
  const tightening = trial.daysLeft <= 3;
  const bg = urgent
    ? "oklch(0.62 0.18 28)"
    : tightening
      ? "var(--accent)"
      : "var(--accent-soft, #fbe6df)";
  const fg = urgent || tightening ? "#fff" : "var(--text)";
  const message =
    trial.daysLeft <= 0 ? "Your trial ended."
    : trial.daysLeft === 1 ? "Last day of your trial."
    : `${trial.daysLeft} days left on your free trial.`;
  const cta = urgent ? "Add payment to keep your plan" : "Add payment method";

  const goUpgrade = async () => {
    setBusy(true);
    try {
      const { url } = await window.VandayAPI.billingCheckout(trial.plan, "monthly");
      window.location.href = url;
    } catch (e) {
      setBusy(false);
      // Fall back to opening the Billing tab so the user can recover.
      onGoToBilling?.();
    }
  };

  return (
    <div
      role="status"
      style={{
        background: bg,
        color: fg,
        padding: "9px 16px",
        display: "flex",
        alignItems: "center",
        gap: 12,
        fontSize: 13,
        fontWeight: 500,
        borderBottom: "1px solid var(--border)",
        position: "sticky",
        top: 0,
        zIndex: 30,
      }}
    >
      <span style={{ flex: 1, lineHeight: 1.4 }}>{message}</span>
      <button
        onClick={goUpgrade}
        disabled={busy}
        style={{
          background: "rgba(255,255,255,0.18)",
          color: fg,
          border: "1px solid rgba(255,255,255,0.3)",
          padding: "5px 10px",
          borderRadius: 6,
          fontSize: 12.5,
          fontWeight: 600,
          cursor: "pointer",
        }}
      >
        {busy ? "Opening…" : cta}
      </button>
      {!urgent && (
        <button
          onClick={() => setDismissed(true)}
          title="Dismiss for now"
          aria-label="Dismiss"
          style={{
            background: "transparent",
            color: fg,
            border: 0,
            fontSize: 18,
            lineHeight: 1,
            padding: "0 4px",
            cursor: "pointer",
            opacity: 0.7,
          }}
        >
          ×
        </button>
      )}
    </div>
  );
}

// ---------------------------------------------------------------------------
// UpgradeModal — the in-context paywall for blocked actions. Catches a 403
// from the server (with shape { error, plan, upgradeTo, message }) and shows
// a one-screen upgrade prompt. Two CTAs:
//   - "Start free trial" (no card) - skips Stripe entirely
//   - "Upgrade now" - goes to Stripe Checkout for the upgradeTo plan
// Mount once at the app root and trigger via window.dispatchEvent below.
// ---------------------------------------------------------------------------
function UpgradeModal() {
  const [payload, setPayload] = React.useState(null); // { plan, upgradeTo, message } | null
  const [busy, setBusy] = React.useState(null);

  React.useEffect(() => {
    const handler = (e) => setPayload(e.detail || null);
    window.addEventListener("vanday-paywall", handler);
    return () => window.removeEventListener("vanday-paywall", handler);
  }, []);

  if (!payload) return null;
  const close = () => setPayload(null);
  const startTrial = async () => {
    setBusy("trial");
    try {
      await window.VandayAPI.startTrial(payload.upgradeTo);
      // Close the modal first, then refresh /api/me so the page the user
      // is currently on (e.g. Integrations) re-renders with the new limits
      // without a full navigation away.
      setPayload(null);
      await window.VandayMe.fetchMe({ force: true });
    } catch (e) {
      window.alert(e.message || "Couldn't start the trial");
      setBusy(null);
    }
  };
  const checkout = async () => {
    setBusy("checkout");
    try {
      const { url } = await window.VandayAPI.billingCheckout(payload.upgradeTo, "monthly");
      window.location.href = url;
    } catch (e) {
      setBusy(null);
      window.alert(e.message || "Couldn't open checkout");
    }
  };

  return (
    <div
      onClick={close}
      style={{
        position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)",
        display: "flex", alignItems: "center", justifyContent: "center",
        zIndex: 1000, padding: 24,
      }}
    >
      <div
        onClick={(e) => e.stopPropagation()}
        style={{
          background: "var(--surface)", borderRadius: 14, padding: 28,
          maxWidth: 440, width: "100%",
          border: "1px solid var(--border)",
          boxShadow: "0 24px 64px rgba(0,0,0,0.16)",
        }}
      >
        <h2 style={{ fontSize: 19, fontWeight: 600, margin: "0 0 8px" }}>
          {payload.title || "Upgrade to unlock this"}
        </h2>
        <p style={{ fontSize: 13.5, color: "var(--text-muted)", margin: "0 0 18px", lineHeight: 1.55 }}>
          {payload.message}
          {payload.comingSoon && " Paid plans are coming soon — we'll email you the moment they launch."}
        </p>
        <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
          {payload.comingSoon ? (
            <button className="btn primary" disabled={!!busy} onClick={close} style={{ width: "100%" }}>
              Got it
            </button>
          ) : (
            <>
              <button
                className="btn primary"
                disabled={!!busy}
                onClick={startTrial}
                style={{ width: "100%" }}
              >
                {busy === "trial" ? "Starting…" : "Start 14-day free trial — no card"}
              </button>
              <button
                className="btn"
                disabled={!!busy}
                onClick={checkout}
                style={{ width: "100%" }}
              >
                {busy === "checkout" ? "Opening…" : "Upgrade now"}
              </button>
              <button
                disabled={!!busy}
                onClick={close}
                style={{
                  background: "transparent",
                  color: "var(--text-muted)",
                  border: 0,
                  fontSize: 12.5,
                  padding: "8px 0",
                  cursor: "pointer",
                }}
              >
                Maybe later
              </button>
            </>
          )}
        </div>
      </div>
    </div>
  );
}

// Helper used by feature gates around the app: call this with the 403 body
// the server returned for the blocked action; the modal hooks itself up via
// the window event. Title is optional and lets the caller phrase the prompt
// for the specific action ('Publish to Instagram', 'Invite teammates', etc.).
window.openVandayPaywall = function openVandayPaywall({ title, plan, upgradeTo, message }) {
  if (!upgradeTo) return; // server didn't suggest a plan; no-op
  // Billing isn't rolled out for this workspace (usageBilling off) — never
  // expose trial/checkout. Show a "paid plans coming soon" teaser instead, so
  // a blocked paid feature still explains itself without a live upgrade path.
  const comingSoon = window.VandayMe?.usageBillingEnabled?.() !== true;
  window.dispatchEvent(new CustomEvent("vanday-paywall", {
    detail: { title, plan, upgradeTo, message, comingSoon },
  }));
};

// Catches any render/runtime crash in the React tree so the user sees a
// recoverable message instead of a blank white page, and reports it to Sentry
// (loaded via Vanday.html on prod). window.Sentry is optional — on dev/local
// it's undefined and we just log to the console.
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }
  static getDerivedStateFromError() {
    return { hasError: true };
  }
  componentDidCatch(error, info) {
    console.error("[app] crashed:", error);
    if (window.Sentry && window.Sentry.captureException) {
      window.Sentry.captureException(error, {
        extra: { componentStack: info && info.componentStack },
      });
    }
  }
  render() {
    if (!this.state.hasError) return this.props.children;
    return (
      <div style={{
        minHeight: "100vh", display: "grid", placeItems: "center",
        padding: 24, fontFamily: "inherit",
        background: "var(--bg, #fff)", color: "var(--text, #111)",
      }}>
        <div style={{ maxWidth: 420, textAlign: "center" }}>
          <div style={{ fontSize: 40, marginBottom: 12 }}>⚠️</div>
          <h1 style={{ fontSize: 20, fontWeight: 600, margin: "0 0 8px" }}>
            Something went wrong
          </h1>
          <p style={{ fontSize: 14, lineHeight: 1.55, opacity: 0.75, margin: "0 0 20px" }}>
            The page hit an unexpected error. We've been notified automatically.
            Reloading usually fixes it — your files are safe.
          </p>
          <button
            type="button"
            onClick={() => window.location.reload()}
            style={{
              padding: "10px 20px", borderRadius: 8, border: "none",
              background: "var(--accent, #c8553d)", color: "white",
              fontWeight: 500, fontSize: 14, cursor: "pointer",
            }}
          >
            Reload the page
          </button>
        </div>
      </div>
    );
  }
}

ReactDOM.createRoot(document.getElementById("root")).render(
  <ErrorBoundary>
    <App />
    <UpgradeModal />
  </ErrorBoundary>
);
