@aglyn/tenant-feature-instance 1.0.0-beta.277 → 1.0.0-beta.283

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The billing plan/tier for each of the user's orgs, read on demand (AGL-631).
3
+ * The org doc is the plan source of truth (webhook-mirrored); the membership
4
+ * mirror the org switcher lists carries only role/name/slug, so the switcher
5
+ * reads each org's plan directly. Per-doc `getDoc` keeps it rules-safe — a
6
+ * member can read their own org doc, whereas an `in` query over `orgs` cannot
7
+ * be proven allowed and is denied. `enabled` gates the reads to when the menu
8
+ * opens, so nothing is fetched until the user actually looks.
9
+ *
10
+ * AGL-2336 — this fan-out is what capped memberships at 50, not the size of
11
+ * the membership docs. Two rules now bound it:
12
+ *
13
+ * - **Ask only for what is not already answered.** Resolved tiers accumulate
14
+ * for the life of the hook, so re-opening the switcher costs ZERO reads.
15
+ * Previously every open re-read every id, so a 50-workspace agency paid 50
16
+ * reads per glance at the menu and a 500-workspace one would pay 500.
17
+ * - **The caller passes the rows it is actually showing**, not the whole
18
+ * membership list — see `org-switcher-nav`'s reveal window. Cost is then
19
+ * proportional to what a human looked at and is independent of how many
20
+ * workspaces they belong to, which is the precondition for raising the cap.
21
+ *
22
+ * A tier resolved once is not re-read while the hook is mounted, so a plan
23
+ * change elsewhere shows on the next load. That is fine for a row badge: the
24
+ * workspace you are actually IN takes its badge from `useCurrentOrg`, which
25
+ * is a live subscription.
26
+ *
27
+ * A FAILED read is deliberately not memoised — it is not an answer, and the
28
+ * next open should ask again.
29
+ */
30
+ export declare function useOrgPlans(orgIds: string[], enabled?: boolean): Record<string, string>;
31
+ /** What one read of an org doc tells a row about that workspace. */
32
+ export interface OrgRow {
33
+ /** The plan the workspace gets, as `useOrgPlans` reports it. */
34
+ plan: string;
35
+ /** The org's logo (AGL-363), when it set one. */
36
+ logoUrl?: string;
37
+ /** The org's dark-mode logo, when it set one. */
38
+ logoDarkUrl?: string;
39
+ }
40
+ /**
41
+ * Each org's plan and logos from the SAME per-doc read `useOrgPlans` makes,
42
+ * so a row that shows a workspace's logo costs nothing more than its badge.
43
+ * The membership mirror carries no logo, so without this only the workspace
44
+ * the reader is in (whose doc `useCurrentOrg` holds) could show its own.
45
+ */
46
+ export declare function useOrgRows(orgIds: string[], enabled?: boolean): Record<string, OrgRow>;
47
+ export default useOrgPlans;
@@ -0,0 +1,164 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ 'use client';
17
+ import { _ as _extends } from "@swc/helpers/_/_extends";
18
+ import { isEnterpriseOrg, resolveEffectivePlan } from "@aglyn/aglyn";
19
+ import { useFirestore } from "./firebase/firebase-services.js";
20
+ import { doc, getDoc } from "firebase/firestore";
21
+ import { useEffect, useMemo, useRef, useState } from "react";
22
+ /**
23
+ * The billing plan/tier for each of the user's orgs, read on demand (AGL-631).
24
+ * The org doc is the plan source of truth (webhook-mirrored); the membership
25
+ * mirror the org switcher lists carries only role/name/slug, so the switcher
26
+ * reads each org's plan directly. Per-doc `getDoc` keeps it rules-safe — a
27
+ * member can read their own org doc, whereas an `in` query over `orgs` cannot
28
+ * be proven allowed and is denied. `enabled` gates the reads to when the menu
29
+ * opens, so nothing is fetched until the user actually looks.
30
+ *
31
+ * AGL-2336 — this fan-out is what capped memberships at 50, not the size of
32
+ * the membership docs. Two rules now bound it:
33
+ *
34
+ * - **Ask only for what is not already answered.** Resolved tiers accumulate
35
+ * for the life of the hook, so re-opening the switcher costs ZERO reads.
36
+ * Previously every open re-read every id, so a 50-workspace agency paid 50
37
+ * reads per glance at the menu and a 500-workspace one would pay 500.
38
+ * - **The caller passes the rows it is actually showing**, not the whole
39
+ * membership list — see `org-switcher-nav`'s reveal window. Cost is then
40
+ * proportional to what a human looked at and is independent of how many
41
+ * workspaces they belong to, which is the precondition for raising the cap.
42
+ *
43
+ * A tier resolved once is not re-read while the hook is mounted, so a plan
44
+ * change elsewhere shows on the next load. That is fine for a row badge: the
45
+ * workspace you are actually IN takes its badge from `useCurrentOrg`, which
46
+ * is a live subscription.
47
+ *
48
+ * A FAILED read is deliberately not memoised — it is not an answer, and the
49
+ * next open should ask again.
50
+ */ export function useOrgPlans(orgIds, enabled = true) {
51
+ const rows = useOrgRows(orgIds, enabled);
52
+ return useMemo(()=>Object.fromEntries(Object.entries(rows).map(([id, row])=>[
53
+ id,
54
+ row.plan
55
+ ])), [
56
+ rows
57
+ ]);
58
+ }
59
+ /**
60
+ * Each org's plan and logos from the SAME per-doc read `useOrgPlans` makes,
61
+ * so a row that shows a workspace's logo costs nothing more than its badge.
62
+ * The membership mirror carries no logo, so without this only the workspace
63
+ * the reader is in (whose doc `useCurrentOrg` holds) could show its own.
64
+ */ export function useOrgRows(orgIds, enabled = true) {
65
+ const firestore = useFirestore();
66
+ const [plans, setPlans] = useState({});
67
+ // What has been asked for already — answered or in flight. A ref, not
68
+ // derived from `plans`, so an id whose read is still in flight is not
69
+ // re-requested by the next render.
70
+ const requested = useRef(new Set());
71
+ const mounted = useRef(true);
72
+ const key = orgIds.join(',');
73
+ useEffect(()=>{
74
+ mounted.current = true;
75
+ return ()=>{
76
+ mounted.current = false;
77
+ };
78
+ }, []);
79
+ // A different Firestore instance is a different world; drop the memo with
80
+ // it rather than serve another instance's answers. Declared BEFORE the
81
+ // fetch effect so it runs first on the render that swaps the instance, and
82
+ // skipped on the first run so mounting does not cost a wasted re-render.
83
+ const instance = useRef(firestore);
84
+ useEffect(()=>{
85
+ if (instance.current === firestore) return;
86
+ instance.current = firestore;
87
+ requested.current = new Set();
88
+ setPlans({});
89
+ }, [
90
+ firestore
91
+ ]);
92
+ useEffect(()=>{
93
+ if (!enabled) return undefined;
94
+ const wanted = orgIds.filter((id)=>id && !requested.current.has(id));
95
+ if (wanted.length === 0) return undefined;
96
+ wanted.forEach((id)=>requested.current.add(id));
97
+ void Promise.all(wanted.map(async (id)=>{
98
+ try {
99
+ var _snap_data;
100
+ const snap = await getDoc(doc(firestore, 'orgs', id));
101
+ const data = (_snap_data = snap.data()) != null ? _snap_data : {};
102
+ const logos = _extends({}, typeof data.logoUrl === 'string' && data.logoUrl ? {
103
+ logoUrl: data.logoUrl
104
+ } : {}, typeof data.logoDarkUrl === 'string' && data.logoDarkUrl ? {
105
+ logoDarkUrl: data.logoDarkUrl
106
+ } : {});
107
+ // A custom-priced enterprise deal reads as "enterprise" regardless of
108
+ // its base plan (AGL-1110).
109
+ if (isEnterpriseOrg(data)) {
110
+ return [
111
+ id,
112
+ _extends({
113
+ plan: 'enterprise'
114
+ }, logos)
115
+ ];
116
+ }
117
+ // An org on the free tier has no `plan` field at all — the webhook
118
+ // only writes one for a paid subscription. A successful read with
119
+ // no plan therefore MEANS free, and must be reported as such;
120
+ // leaving it absent made "free" look identical to "not loaded"
121
+ // (AGL-646). Only a failed read stays unknown.
122
+ //
123
+ // RESOLVED, not read (AGL-3034): a staff comp grants a plan the
124
+ // field does not name, and a dead subscription's stored plan is
125
+ // Free. The resolver answers `free` for the missing field too.
126
+ return [
127
+ id,
128
+ _extends({
129
+ plan: resolveEffectivePlan(data)
130
+ }, logos)
131
+ ];
132
+ } catch (unused) {
133
+ // Not an answer — let the next open ask again.
134
+ requested.current.delete(id);
135
+ return [
136
+ id,
137
+ undefined
138
+ ];
139
+ }
140
+ })).then((entries)=>{
141
+ // Deliberately NOT gated on an effect-scoped `cancelled` flag: closing
142
+ // the switcher flips `enabled` and tears this effect down, and dropping
143
+ // an in-flight answer there would leave the id marked requested and
144
+ // permanently unanswerable. Only an unmounted hook stops caring.
145
+ if (!mounted.current) return;
146
+ const resolved = entries.filter((entry)=>Boolean(entry[1]));
147
+ if (resolved.length === 0) return;
148
+ // MERGE, never replace: the map spans every row revealed so far, and
149
+ // replacing it would blank the badges on rows already on screen.
150
+ setPlans((current)=>_extends({}, current, Object.fromEntries(resolved)));
151
+ });
152
+ return undefined;
153
+ // `key` captures the org-id list; enabled toggles the fetch.
154
+ // eslint-disable-next-line react-hooks/exhaustive-deps
155
+ }, [
156
+ firestore,
157
+ key,
158
+ enabled
159
+ ]);
160
+ return plans;
161
+ }
162
+ export default useOrgPlans;
163
+
164
+ //# sourceMappingURL=use-org-plans.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-org-plans.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { isEnterpriseOrg, resolveEffectivePlan } from '@aglyn/aglyn'\nimport { useFirestore } from './firebase/firebase-services'\nimport { doc, getDoc } from 'firebase/firestore'\nimport { useEffect, useMemo, useRef, useState } from 'react'\n\n/**\n * The billing plan/tier for each of the user's orgs, read on demand (AGL-631).\n * The org doc is the plan source of truth (webhook-mirrored); the membership\n * mirror the org switcher lists carries only role/name/slug, so the switcher\n * reads each org's plan directly. Per-doc `getDoc` keeps it rules-safe — a\n * member can read their own org doc, whereas an `in` query over `orgs` cannot\n * be proven allowed and is denied. `enabled` gates the reads to when the menu\n * opens, so nothing is fetched until the user actually looks.\n *\n * AGL-2336 — this fan-out is what capped memberships at 50, not the size of\n * the membership docs. Two rules now bound it:\n *\n * - **Ask only for what is not already answered.** Resolved tiers accumulate\n * for the life of the hook, so re-opening the switcher costs ZERO reads.\n * Previously every open re-read every id, so a 50-workspace agency paid 50\n * reads per glance at the menu and a 500-workspace one would pay 500.\n * - **The caller passes the rows it is actually showing**, not the whole\n * membership list — see `org-switcher-nav`'s reveal window. Cost is then\n * proportional to what a human looked at and is independent of how many\n * workspaces they belong to, which is the precondition for raising the cap.\n *\n * A tier resolved once is not re-read while the hook is mounted, so a plan\n * change elsewhere shows on the next load. That is fine for a row badge: the\n * workspace you are actually IN takes its badge from `useCurrentOrg`, which\n * is a live subscription.\n *\n * A FAILED read is deliberately not memoised — it is not an answer, and the\n * next open should ask again.\n */\nexport function useOrgPlans(\n orgIds: string[],\n enabled = true,\n): Record<string, string> {\n const rows = useOrgRows(orgIds, enabled)\n return useMemo(\n () =>\n Object.fromEntries(Object.entries(rows).map(([id, row]) => [id, row.plan])),\n [rows],\n )\n}\n\n/** What one read of an org doc tells a row about that workspace. */\nexport interface OrgRow {\n /** The plan the workspace gets, as `useOrgPlans` reports it. */\n plan: string\n /** The org's logo (AGL-363), when it set one. */\n logoUrl?: string\n /** The org's dark-mode logo, when it set one. */\n logoDarkUrl?: string\n}\n\n/**\n * Each org's plan and logos from the SAME per-doc read `useOrgPlans` makes,\n * so a row that shows a workspace's logo costs nothing more than its badge.\n * The membership mirror carries no logo, so without this only the workspace\n * the reader is in (whose doc `useCurrentOrg` holds) could show its own.\n */\nexport function useOrgRows(\n orgIds: string[],\n enabled = true,\n): Record<string, OrgRow> {\n const firestore = useFirestore()\n const [plans, setPlans] = useState<Record<string, OrgRow>>({})\n // What has been asked for already — answered or in flight. A ref, not\n // derived from `plans`, so an id whose read is still in flight is not\n // re-requested by the next render.\n const requested = useRef(new Set<string>())\n const mounted = useRef(true)\n const key = orgIds.join(',')\n\n useEffect(() => {\n mounted.current = true\n return () => {\n mounted.current = false\n }\n }, [])\n\n // A different Firestore instance is a different world; drop the memo with\n // it rather than serve another instance's answers. Declared BEFORE the\n // fetch effect so it runs first on the render that swaps the instance, and\n // skipped on the first run so mounting does not cost a wasted re-render.\n const instance = useRef(firestore)\n useEffect(() => {\n if (instance.current === firestore) return\n instance.current = firestore\n requested.current = new Set<string>()\n setPlans({})\n }, [firestore])\n\n useEffect(() => {\n if (!enabled) return undefined\n const wanted = orgIds.filter((id) => id && !requested.current.has(id))\n if (wanted.length === 0) return undefined\n wanted.forEach((id) => requested.current.add(id))\n void Promise.all(\n wanted.map(async (id) => {\n try {\n const snap = await getDoc(doc(firestore, 'orgs', id))\n const data = (snap.data() ?? {}) as Record<string, unknown>\n const logos = {\n ...(typeof data.logoUrl === 'string' && data.logoUrl\n ? { logoUrl: data.logoUrl }\n : {}),\n ...(typeof data.logoDarkUrl === 'string' && data.logoDarkUrl\n ? { logoDarkUrl: data.logoDarkUrl }\n : {}),\n }\n // A custom-priced enterprise deal reads as \"enterprise\" regardless of\n // its base plan (AGL-1110).\n if (isEnterpriseOrg(data as never)) {\n return [id, { plan: 'enterprise', ...logos }] as const\n }\n // An org on the free tier has no `plan` field at all — the webhook\n // only writes one for a paid subscription. A successful read with\n // no plan therefore MEANS free, and must be reported as such;\n // leaving it absent made \"free\" look identical to \"not loaded\"\n // (AGL-646). Only a failed read stays unknown.\n //\n // RESOLVED, not read (AGL-3034): a staff comp grants a plan the\n // field does not name, and a dead subscription's stored plan is\n // Free. The resolver answers `free` for the missing field too.\n return [\n id,\n { plan: resolveEffectivePlan(data as never) as string, ...logos },\n ] as const\n } catch {\n // Not an answer — let the next open ask again.\n requested.current.delete(id)\n return [id, undefined] as const\n }\n }),\n ).then((entries) => {\n // Deliberately NOT gated on an effect-scoped `cancelled` flag: closing\n // the switcher flips `enabled` and tears this effect down, and dropping\n // an in-flight answer there would leave the id marked requested and\n // permanently unanswerable. Only an unmounted hook stops caring.\n if (!mounted.current) return\n const resolved = entries.filter((entry): entry is [string, OrgRow] =>\n Boolean(entry[1]),\n )\n if (resolved.length === 0) return\n // MERGE, never replace: the map spans every row revealed so far, and\n // replacing it would blank the badges on rows already on screen.\n setPlans((current) => ({ ...current, ...Object.fromEntries(resolved) }))\n })\n return undefined\n // `key` captures the org-id list; enabled toggles the fetch.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [firestore, key, enabled])\n\n return plans\n}\n\nexport default useOrgPlans\n"],"names":["isEnterpriseOrg","resolveEffectivePlan","useFirestore","doc","getDoc","useEffect","useMemo","useRef","useState","useOrgPlans","orgIds","enabled","rows","useOrgRows","Object","fromEntries","entries","map","id","row","plan","firestore","plans","setPlans","requested","Set","mounted","key","join","current","instance","undefined","wanted","filter","has","length","forEach","add","Promise","all","snap","data","logos","logoUrl","logoDarkUrl","delete","then","resolved","entry","Boolean"],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Registers the host's installed plugins as named besigner drawer entries
3
+ * (AGL-190): one preset per install under the Marketplace category, dropping
4
+ * a `marketplacePlugin` node with the listing id pre-pinned. Presets are
5
+ * re-synced whenever installs change and removed on host change/unmount so
6
+ * switching hosts never leaks another host's plugins into the drawer.
7
+ */
8
+ export declare function usePluginDrawerRegistration(hostId: string): void;
9
+ export default usePluginDrawerRegistration;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ 'use client';
17
+ import { _ as _extends } from "@swc/helpers/_/_extends";
18
+ import { components } from "@aglyn/aglyn";
19
+ import { pluginInstallToPresets, setKnownPluginInstalls } from "@aglyn/aglyn";
20
+ import { collection, limit, query } from "firebase/firestore";
21
+ import { runInAction } from "mobx";
22
+ import { useEffect, useMemo, useRef } from "react";
23
+ import { useFirestore } from "./firebase/firebase-services.js";
24
+ import { useHostOrgId } from "./use-host-org-id.js";
25
+ import useFirestoreCollection from "./use-firestore-collection.js";
26
+ /**
27
+ * Registers the host's installed plugins as named besigner drawer entries
28
+ * (AGL-190): one preset per install under the Marketplace category, dropping
29
+ * a `marketplacePlugin` node with the listing id pre-pinned. Presets are
30
+ * re-synced whenever installs change and removed on host change/unmount so
31
+ * switching hosts never leaks another host's plugins into the drawer.
32
+ */ export function usePluginDrawerRegistration(hostId) {
33
+ const firestore = useFirestore();
34
+ const { data: installDocs } = useFirestoreCollection(()=>query(collection(firestore, 'hosts', hostId, 'installs'), limit(50)), [
35
+ firestore,
36
+ hostId
37
+ ], {
38
+ idField: '$id'
39
+ });
40
+ // Org-wide pins too (AGL-1029). One org pin covers every site, so a plugin
41
+ // installed that way is genuinely available here — but the drawer only ever
42
+ // read the host's own installs, so those plugins had no entry, and the
43
+ // element could not be told they were installed either.
44
+ const orgId = useHostOrgId(hostId);
45
+ // Held at null while `useHostOrgId` is in flight, never `orgs/-pending-`
46
+ // (AGL-1440): installs are member-gated, so the sentinel was a
47
+ // guaranteed-denied listen on every besigner editor session.
48
+ const { data: orgInstallDocs } = useFirestoreCollection(()=>orgId ? query(collection(firestore, 'orgs', orgId, 'installs'), limit(50)) : null, [
49
+ firestore,
50
+ orgId
51
+ ], {
52
+ idField: '$id'
53
+ });
54
+ /**
55
+ * Host pins shadow org pins for the same listing (AGL-656), so the host
56
+ * entry wins — the drawer must not offer the same plugin twice, and the
57
+ * scope reported for it has to be the one actually in force.
58
+ */ const installs = useMemo(()=>{
59
+ const byListing = new Map();
60
+ for (const install of orgInstallDocs != null ? orgInstallDocs : []){
61
+ var _ref;
62
+ const id = (_ref = install == null ? void 0 : install.listingId) != null ? _ref : install == null ? void 0 : install.$id;
63
+ if (id) byListing.set(id, _extends({}, install, {
64
+ scope: 'org'
65
+ }));
66
+ }
67
+ for (const install of installDocs != null ? installDocs : []){
68
+ var _ref1;
69
+ const id = (_ref1 = install == null ? void 0 : install.listingId) != null ? _ref1 : install == null ? void 0 : install.$id;
70
+ if (id) byListing.set(id, _extends({}, install, {
71
+ scope: 'host'
72
+ }));
73
+ }
74
+ return [
75
+ ...byListing.values()
76
+ ];
77
+ }, [
78
+ installDocs,
79
+ orgInstallDocs
80
+ ]);
81
+ // Track the ids we registered so we can unregister exactly those.
82
+ const registeredIds = useRef([]);
83
+ // What the canvas may claim about installation (AGL-1029). Published
84
+ // separately from the presets because the element asks by listing id, and
85
+ // an unknown id is only "not installed" if there is a list to be missing
86
+ // from — on the tenant nothing publishes one.
87
+ useEffect(()=>{
88
+ setKnownPluginInstalls(installs.map((install)=>{
89
+ var _install_listingId, _install_displayName;
90
+ var _install_manifest, _install_manifest1;
91
+ return {
92
+ listingId: (_install_listingId = install.listingId) != null ? _install_listingId : install.$id,
93
+ displayName: (_install_displayName = install.displayName) != null ? _install_displayName : (_install_manifest = install.manifest) == null ? void 0 : _install_manifest.name,
94
+ scope: install.scope,
95
+ // The declared props (and, where a publisher supplied one, how to edit
96
+ // them) so the attributes panel can render real fields — AGL-1049.
97
+ capabilities: (_install_manifest1 = install.manifest) == null ? void 0 : _install_manifest1.capabilities
98
+ };
99
+ }));
100
+ return ()=>setKnownPluginInstalls(undefined);
101
+ }, [
102
+ installs
103
+ ]);
104
+ useEffect(()=>{
105
+ // Single mobx transaction (AGL-371): the drawer re-renders once per
106
+ // install sync, not once for the unregister and again per preset.
107
+ runInAction(()=>{
108
+ if (registeredIds.current.length) {
109
+ components.unregisterPreset(registeredIds.current);
110
+ registeredIds.current = [];
111
+ }
112
+ // One preset per install PLUS one per element the pinned version
113
+ // declares (AGL-1031). Built from the pin, so a declared element appears
114
+ // only where the plugin is installed and leaves with a revoked version.
115
+ const presets = installs.flatMap((install)=>pluginInstallToPresets(install)).filter((preset)=>Boolean(preset));
116
+ if (presets.length) {
117
+ components.registerPreset(presets);
118
+ registeredIds.current = presets.map((preset)=>preset.$id);
119
+ }
120
+ });
121
+ return ()=>{
122
+ if (registeredIds.current.length) {
123
+ components.unregisterPreset(registeredIds.current);
124
+ registeredIds.current = [];
125
+ }
126
+ };
127
+ }, [
128
+ installs
129
+ ]);
130
+ }
131
+ export default usePluginDrawerRegistration;
132
+
133
+ //# sourceMappingURL=use-plugin-drawer-registration.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-plugin-drawer-registration.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { components } from '@aglyn/aglyn'\nimport { pluginInstallToPresets, setKnownPluginInstalls } from '@aglyn/aglyn'\nimport { collection, limit, query } from 'firebase/firestore'\nimport { runInAction } from 'mobx'\nimport { useEffect, useMemo, useRef } from 'react'\nimport { useFirestore } from './firebase/firebase-services'\nimport { useHostOrgId } from './use-host-org-id'\nimport useFirestoreCollection from './use-firestore-collection'\n\n/**\n * Registers the host's installed plugins as named besigner drawer entries\n * (AGL-190): one preset per install under the Marketplace category, dropping\n * a `marketplacePlugin` node with the listing id pre-pinned. Presets are\n * re-synced whenever installs change and removed on host change/unmount so\n * switching hosts never leaks another host's plugins into the drawer.\n */\nexport function usePluginDrawerRegistration(hostId: string): void {\n const firestore = useFirestore()\n const { data: installDocs } = useFirestoreCollection<any>(\n () => query(collection(firestore, 'hosts', hostId, 'installs'), limit(50)),\n [firestore, hostId],\n { idField: '$id' },\n )\n // Org-wide pins too (AGL-1029). One org pin covers every site, so a plugin\n // installed that way is genuinely available here — but the drawer only ever\n // read the host's own installs, so those plugins had no entry, and the\n // element could not be told they were installed either.\n const orgId = useHostOrgId(hostId)\n // Held at null while `useHostOrgId` is in flight, never `orgs/-pending-`\n // (AGL-1440): installs are member-gated, so the sentinel was a\n // guaranteed-denied listen on every besigner editor session.\n const { data: orgInstallDocs } = useFirestoreCollection<any>(\n () =>\n orgId\n ? query(collection(firestore, 'orgs', orgId, 'installs'), limit(50))\n : null,\n [firestore, orgId],\n { idField: '$id' },\n )\n /**\n * Host pins shadow org pins for the same listing (AGL-656), so the host\n * entry wins — the drawer must not offer the same plugin twice, and the\n * scope reported for it has to be the one actually in force.\n */\n const installs = useMemo(() => {\n const byListing = new Map<string, any>()\n for (const install of (orgInstallDocs as any[]) ?? []) {\n const id = install?.listingId ?? install?.$id\n if (id) byListing.set(id, { ...install, scope: 'org' })\n }\n for (const install of (installDocs as any[]) ?? []) {\n const id = install?.listingId ?? install?.$id\n if (id) byListing.set(id, { ...install, scope: 'host' })\n }\n return [...byListing.values()]\n }, [installDocs, orgInstallDocs])\n // Track the ids we registered so we can unregister exactly those.\n const registeredIds = useRef<string[]>([])\n\n // What the canvas may claim about installation (AGL-1029). Published\n // separately from the presets because the element asks by listing id, and\n // an unknown id is only \"not installed\" if there is a list to be missing\n // from — on the tenant nothing publishes one.\n useEffect(() => {\n setKnownPluginInstalls(\n installs.map((install) => ({\n listingId: install.listingId ?? install.$id,\n displayName: install.displayName ?? install.manifest?.name,\n scope: install.scope,\n // The declared props (and, where a publisher supplied one, how to edit\n // them) so the attributes panel can render real fields — AGL-1049.\n capabilities: install.manifest?.capabilities,\n })),\n )\n return () => setKnownPluginInstalls(undefined)\n }, [installs])\n\n useEffect(() => {\n // Single mobx transaction (AGL-371): the drawer re-renders once per\n // install sync, not once for the unregister and again per preset.\n runInAction(() => {\n if (registeredIds.current.length) {\n components.unregisterPreset(registeredIds.current)\n registeredIds.current = []\n }\n // One preset per install PLUS one per element the pinned version\n // declares (AGL-1031). Built from the pin, so a declared element appears\n // only where the plugin is installed and leaves with a revoked version.\n const presets = installs\n .flatMap((install) => pluginInstallToPresets(install))\n .filter(\n (preset): preset is NonNullable<typeof preset> => Boolean(preset),\n )\n if (presets.length) {\n components.registerPreset(presets)\n registeredIds.current = presets.map((preset) => preset.$id)\n }\n })\n return () => {\n if (registeredIds.current.length) {\n components.unregisterPreset(registeredIds.current)\n registeredIds.current = []\n }\n }\n }, [installs])\n}\n\nexport default usePluginDrawerRegistration\n"],"names":["components","pluginInstallToPresets","setKnownPluginInstalls","collection","limit","query","runInAction","useEffect","useMemo","useRef","useFirestore","useHostOrgId","useFirestoreCollection","usePluginDrawerRegistration","hostId","firestore","data","installDocs","idField","orgId","orgInstallDocs","installs","byListing","Map","install","id","listingId","$id","set","scope","values","registeredIds","map","displayName","manifest","name","capabilities","undefined","current","length","unregisterPreset","presets","flatMap","filter","preset","Boolean","registerPreset"],"mappings":"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"}
@@ -0,0 +1,35 @@
1
+ /** One marketplace plugin as a site sees it. */
2
+ export interface SiteMarketplacePlugin {
3
+ /** The listing document id — the id `enabledPlugins` and the pins key by. */
4
+ listingId: string;
5
+ /** The publisher's name for it, denormalized onto the pin. */
6
+ displayName: string;
7
+ version?: string;
8
+ /**
9
+ * `org` — pinned for every site in the workspace; `site` — pinned for this
10
+ * one. The distinction changes what switching it off here means, so it is
11
+ * carried rather than flattened.
12
+ */
13
+ scope: 'org' | 'site';
14
+ }
15
+ /**
16
+ * Every marketplace plugin that can run on one site (AGL-1014).
17
+ *
18
+ * The site's plugin switchboard used to derive this from `org.enabledPlugins`
19
+ * alone, and that field is EMPTY for most workspaces: `syncEnabledPlugins`
20
+ * appends a listing id only for an org that has already configured the
21
+ * switchboard, because an absent field means default-open and adding the first
22
+ * id would switch off every plugin not in it. So a workspace that never
23
+ * touched the built-in switches had its marketplace installs running on every
24
+ * site with nothing in the console listing them, and no way to switch one off
25
+ * for one site.
26
+ *
27
+ * The pins are the durable record, so they are the source here: org-tier pins
28
+ * apply to every site, host-tier pins to this one, and the ids already on
29
+ * `enabledPlugins` are unioned in so a listing whose pin this reader cannot
30
+ * see still gets a row rather than silently vanishing.
31
+ */
32
+ export declare function useSiteMarketplacePlugins(orgId: string, hostId: string,
33
+ /** Non-first-party ids already on the org's switchboard. */
34
+ switchboardIds?: readonly string[]): SiteMarketplacePlugin[];
35
+ export default useSiteMarketplacePlugins;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ 'use client';
17
+ import { collection, limit, query } from "firebase/firestore";
18
+ import { useMemo } from "react";
19
+ import { useFirestore } from "./firebase/firebase-services.js";
20
+ import { useFirestoreCollection } from "./use-firestore-collection.js";
21
+ /**
22
+ * Every marketplace plugin that can run on one site (AGL-1014).
23
+ *
24
+ * The site's plugin switchboard used to derive this from `org.enabledPlugins`
25
+ * alone, and that field is EMPTY for most workspaces: `syncEnabledPlugins`
26
+ * appends a listing id only for an org that has already configured the
27
+ * switchboard, because an absent field means default-open and adding the first
28
+ * id would switch off every plugin not in it. So a workspace that never
29
+ * touched the built-in switches had its marketplace installs running on every
30
+ * site with nothing in the console listing them, and no way to switch one off
31
+ * for one site.
32
+ *
33
+ * The pins are the durable record, so they are the source here: org-tier pins
34
+ * apply to every site, host-tier pins to this one, and the ids already on
35
+ * `enabledPlugins` are unioned in so a listing whose pin this reader cannot
36
+ * see still gets a row rather than silently vanishing.
37
+ */ export function useSiteMarketplacePlugins(orgId, hostId, /** Non-first-party ids already on the org's switchboard. */ switchboardIds = []) {
38
+ const firestore = useFirestore();
39
+ // Held at null while the scope is unknown, never `orgs/-pending-`
40
+ // (AGL-1440): installs are member-gated, so a sentinel id is a
41
+ // guaranteed-denied listen on every mount.
42
+ const { data: orgInstalls } = useFirestoreCollection(()=>orgId ? query(collection(firestore, 'orgs', orgId, 'installs'), limit(100)) : null, [
43
+ firestore,
44
+ orgId
45
+ ], {
46
+ idField: '$id'
47
+ });
48
+ const { data: hostInstalls } = useFirestoreCollection(()=>hostId ? query(collection(firestore, 'hosts', hostId, 'installs'), limit(100)) : null, [
49
+ firestore,
50
+ hostId
51
+ ], {
52
+ idField: '$id'
53
+ });
54
+ const switchboardKey = [
55
+ ...switchboardIds
56
+ ].sort().join('|');
57
+ return useMemo(()=>{
58
+ const byListing = new Map();
59
+ const add = (pin, scope)=>{
60
+ var _ref, _ref1, _ref2, _ref3;
61
+ var _pin_manifest;
62
+ const listingId = String((_ref = pin == null ? void 0 : pin.$id) != null ? _ref : '');
63
+ if (!listingId) return;
64
+ // A host pin is the more specific fact about THIS site, so it wins the
65
+ // scope; the display name and version are the same either way.
66
+ const existing = byListing.get(listingId);
67
+ if (existing && scope === 'org') return;
68
+ byListing.set(listingId, {
69
+ listingId,
70
+ displayName: String((_ref1 = (_ref2 = (_ref3 = pin == null ? void 0 : pin.displayName) != null ? _ref3 : pin == null ? void 0 : (_pin_manifest = pin.manifest) == null ? void 0 : _pin_manifest.name) != null ? _ref2 : pin == null ? void 0 : pin.pluginId) != null ? _ref1 : listingId),
71
+ version: (pin == null ? void 0 : pin.version) ? String(pin.version) : undefined,
72
+ scope
73
+ });
74
+ };
75
+ for (const pin of orgInstalls != null ? orgInstalls : [])add(pin, 'org');
76
+ for (const pin of hostInstalls != null ? hostInstalls : [])add(pin, 'site');
77
+ // An id the switchboard carries with no readable pin still gets a row,
78
+ // named by its id. A row that says less is better than a plugin running on
79
+ // the site with no row at all — which is the state this hook exists to end.
80
+ for (const listingId of switchboardKey ? switchboardKey.split('|') : []){
81
+ if (byListing.has(listingId)) continue;
82
+ byListing.set(listingId, {
83
+ listingId,
84
+ displayName: listingId,
85
+ scope: 'org'
86
+ });
87
+ }
88
+ return [
89
+ ...byListing.values()
90
+ ].sort((left, right)=>left.displayName.localeCompare(right.displayName));
91
+ }, [
92
+ orgInstalls,
93
+ hostInstalls,
94
+ switchboardKey
95
+ ]);
96
+ }
97
+ export default useSiteMarketplacePlugins;
98
+
99
+ //# sourceMappingURL=use-site-marketplace-plugins.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-site-marketplace-plugins.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { collection, limit, query } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from './firebase/firebase-services'\nimport { useFirestoreCollection } from './use-firestore-collection'\n\n/** One marketplace plugin as a site sees it. */\nexport interface SiteMarketplacePlugin {\n /** The listing document id — the id `enabledPlugins` and the pins key by. */\n listingId: string\n /** The publisher's name for it, denormalized onto the pin. */\n displayName: string\n version?: string\n /**\n * `org` — pinned for every site in the workspace; `site` — pinned for this\n * one. The distinction changes what switching it off here means, so it is\n * carried rather than flattened.\n */\n scope: 'org' | 'site'\n}\n\n/**\n * Every marketplace plugin that can run on one site (AGL-1014).\n *\n * The site's plugin switchboard used to derive this from `org.enabledPlugins`\n * alone, and that field is EMPTY for most workspaces: `syncEnabledPlugins`\n * appends a listing id only for an org that has already configured the\n * switchboard, because an absent field means default-open and adding the first\n * id would switch off every plugin not in it. So a workspace that never\n * touched the built-in switches had its marketplace installs running on every\n * site with nothing in the console listing them, and no way to switch one off\n * for one site.\n *\n * The pins are the durable record, so they are the source here: org-tier pins\n * apply to every site, host-tier pins to this one, and the ids already on\n * `enabledPlugins` are unioned in so a listing whose pin this reader cannot\n * see still gets a row rather than silently vanishing.\n */\nexport function useSiteMarketplacePlugins(\n orgId: string,\n hostId: string,\n /** Non-first-party ids already on the org's switchboard. */\n switchboardIds: readonly string[] = [],\n): SiteMarketplacePlugin[] {\n const firestore = useFirestore()\n\n // Held at null while the scope is unknown, never `orgs/-pending-`\n // (AGL-1440): installs are member-gated, so a sentinel id is a\n // guaranteed-denied listen on every mount.\n const { data: orgInstalls } = useFirestoreCollection<any>(\n () =>\n orgId\n ? query(collection(firestore, 'orgs', orgId, 'installs'), limit(100))\n : null,\n [firestore, orgId],\n { idField: '$id' },\n )\n const { data: hostInstalls } = useFirestoreCollection<any>(\n () =>\n hostId\n ? query(collection(firestore, 'hosts', hostId, 'installs'), limit(100))\n : null,\n [firestore, hostId],\n { idField: '$id' },\n )\n\n const switchboardKey = [...switchboardIds].sort().join('|')\n\n return useMemo(() => {\n const byListing = new Map<string, SiteMarketplacePlugin>()\n const add = (pin: any, scope: 'org' | 'site') => {\n const listingId = String(pin?.$id ?? '')\n if (!listingId) return\n // A host pin is the more specific fact about THIS site, so it wins the\n // scope; the display name and version are the same either way.\n const existing = byListing.get(listingId)\n if (existing && scope === 'org') return\n byListing.set(listingId, {\n listingId,\n displayName: String(\n pin?.displayName ?? pin?.manifest?.name ?? pin?.pluginId ?? listingId,\n ),\n version: pin?.version ? String(pin.version) : undefined,\n scope,\n })\n }\n for (const pin of orgInstalls ?? []) add(pin, 'org')\n for (const pin of hostInstalls ?? []) add(pin, 'site')\n // An id the switchboard carries with no readable pin still gets a row,\n // named by its id. A row that says less is better than a plugin running on\n // the site with no row at all — which is the state this hook exists to end.\n for (const listingId of switchboardKey ? switchboardKey.split('|') : []) {\n if (byListing.has(listingId)) continue\n byListing.set(listingId, { listingId, displayName: listingId, scope: 'org' })\n }\n return [...byListing.values()].sort((left, right) =>\n left.displayName.localeCompare(right.displayName),\n )\n }, [orgInstalls, hostInstalls, switchboardKey])\n}\n\nexport default useSiteMarketplacePlugins\n"],"names":["collection","limit","query","useMemo","useFirestore","useFirestoreCollection","useSiteMarketplacePlugins","orgId","hostId","switchboardIds","firestore","data","orgInstalls","idField","hostInstalls","switchboardKey","sort","join","byListing","Map","add","pin","scope","listingId","String","$id","existing","get","set","displayName","manifest","name","pluginId","version","undefined","split","has","values","left","right","localeCompare"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;AAEA,SAASA,UAAU,EAAEC,KAAK,EAAEC,KAAK,QAAQ,qBAAoB;AAC7D,SAASC,OAAO,QAAQ,QAAO;AAC/B,SAASC,YAAY,QAAQ,kCAA8B;AAC3D,SAASC,sBAAsB,QAAQ,gCAA4B;AAiBnE;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASC,0BACdC,KAAa,EACbC,MAAc,EACd,0DAA0D,GAC1DC,iBAAoC,EAAE;IAEtC,MAAMC,YAAYN;IAElB,kEAAkE;IAClE,+DAA+D;IAC/D,2CAA2C;IAC3C,MAAM,EAAEO,MAAMC,WAAW,EAAE,GAAGP,uBAC5B,IACEE,QACIL,MAAMF,WAAWU,WAAW,QAAQH,OAAO,aAAaN,MAAM,QAC9D,MACN;QAACS;QAAWH;KAAM,EAClB;QAAEM,SAAS;IAAM;IAEnB,MAAM,EAAEF,MAAMG,YAAY,EAAE,GAAGT,uBAC7B,IACEG,SACIN,MAAMF,WAAWU,WAAW,SAASF,QAAQ,aAAaP,MAAM,QAChE,MACN;QAACS;QAAWF;KAAO,EACnB;QAAEK,SAAS;IAAM;IAGnB,MAAME,iBAAiB;WAAIN;KAAe,CAACO,IAAI,GAAGC,IAAI,CAAC;IAEvD,OAAOd,QAAQ;QACb,MAAMe,YAAY,IAAIC;QACtB,MAAMC,MAAM,CAACC,KAAUC;sBAUjBD,OAAAA;gBAAoBA;YATxB,MAAME,YAAYC,eAAOH,uBAAAA,IAAKI,GAAG,mBAAI;YACrC,IAAI,CAACF,WAAW;YAChB,uEAAuE;YACvE,+DAA+D;YAC/D,MAAMG,WAAWR,UAAUS,GAAG,CAACJ;YAC/B,IAAIG,YAAYJ,UAAU,OAAO;YACjCJ,UAAUU,GAAG,CAACL,WAAW;gBACvBA;gBACAM,aAAaL,QACXH,SAAAA,iBAAAA,uBAAAA,IAAKQ,WAAW,oBAAIR,wBAAAA,gBAAAA,IAAKS,QAAQ,qBAAbT,cAAeU,IAAI,YAAvCV,QAA2CA,uBAAAA,IAAKW,QAAQ,YAAxDX,QAA4DE;gBAE9DU,SAASZ,CAAAA,uBAAAA,IAAKY,OAAO,IAAGT,OAAOH,IAAIY,OAAO,IAAIC;gBAC9CZ;YACF;QACF;QACA,KAAK,MAAMD,OAAOT,sBAAAA,cAAe,EAAE,CAAEQ,IAAIC,KAAK;QAC9C,KAAK,MAAMA,OAAOP,uBAAAA,eAAgB,EAAE,CAAEM,IAAIC,KAAK;QAC/C,uEAAuE;QACvE,2EAA2E;QAC3E,4EAA4E;QAC5E,KAAK,MAAME,aAAaR,iBAAiBA,eAAeoB,KAAK,CAAC,OAAO,EAAE,CAAE;YACvE,IAAIjB,UAAUkB,GAAG,CAACb,YAAY;YAC9BL,UAAUU,GAAG,CAACL,WAAW;gBAAEA;gBAAWM,aAAaN;gBAAWD,OAAO;YAAM;QAC7E;QACA,OAAO;eAAIJ,UAAUmB,MAAM;SAAG,CAACrB,IAAI,CAAC,CAACsB,MAAMC,QACzCD,KAAKT,WAAW,CAACW,aAAa,CAACD,MAAMV,WAAW;IAEpD,GAAG;QAACjB;QAAaE;QAAcC;KAAe;AAChD;AAEA,eAAeT,0BAAyB"}
@@ -0,0 +1,7 @@
1
+ import { type ThemeLibraryEntry } from '@aglyn/aglyn/app-utils/theme-library';
2
+ /**
3
+ * A site's theme library (AGL-3404), live, by entry id. `undefined` until the
4
+ * first snapshot, so the picker does not list a library it has not read.
5
+ */
6
+ export declare function useThemeLibrary(hostId: string | undefined): Record<string, ThemeLibraryEntry> | undefined;
7
+ export default useThemeLibrary;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ 'use client';
17
+ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
18
+ import { THEME_LIBRARY_COLLECTION } from "@aglyn/aglyn/app-utils/theme-library";
19
+ import { useFirestore } from "./firebase/firebase-services.js";
20
+ import { collection, limit, query } from "firebase/firestore";
21
+ import { useMemo } from "react";
22
+ import useFirestoreCollection from "./use-firestore-collection.js";
23
+ /**
24
+ * Well above what a site holds — `THEME_LIBRARY_MAX_CUSTOM` custom themes, a
25
+ * stash per built-in theme and one entry per marketplace theme — and a bound
26
+ * on the read however the collection grows.
27
+ */ const LIBRARY_READ_LIMIT = 100;
28
+ /**
29
+ * A site's theme library (AGL-3404), live, by entry id. `undefined` until the
30
+ * first snapshot, so the picker does not list a library it has not read.
31
+ */ export function useThemeLibrary(hostId) {
32
+ const firestore = useFirestore();
33
+ const { data, status } = useFirestoreCollection(()=>hostId ? query(collection(firestore, 'hosts', hostId, THEME_LIBRARY_COLLECTION), limit(LIBRARY_READ_LIMIT)) : null, [
34
+ firestore,
35
+ hostId
36
+ ], {
37
+ idField: '$id'
38
+ });
39
+ return useMemo(()=>{
40
+ // A refused read lists nothing rather than holding the picker on a
41
+ // spinner: the built-in themes and the current one still render.
42
+ if (status === 'loading') return undefined;
43
+ const entries = {};
44
+ for (const _ref of data != null ? data : []){
45
+ const { $id } = _ref, entry = _object_without_properties_loose(_ref, [
46
+ "$id"
47
+ ]);
48
+ entries[$id] = entry;
49
+ }
50
+ return entries;
51
+ }, [
52
+ data,
53
+ status
54
+ ]);
55
+ }
56
+ export default useThemeLibrary;
57
+
58
+ //# sourceMappingURL=use-theme-library.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-theme-library.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n THEME_LIBRARY_COLLECTION,\n type ThemeLibraryEntry,\n} from '@aglyn/aglyn/app-utils/theme-library'\nimport { useFirestore } from './firebase/firebase-services'\nimport { collection, limit, query } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport useFirestoreCollection from './use-firestore-collection'\n\n/**\n * Well above what a site holds — `THEME_LIBRARY_MAX_CUSTOM` custom themes, a\n * stash per built-in theme and one entry per marketplace theme — and a bound\n * on the read however the collection grows.\n */\nconst LIBRARY_READ_LIMIT = 100\n\n/**\n * A site's theme library (AGL-3404), live, by entry id. `undefined` until the\n * first snapshot, so the picker does not list a library it has not read.\n */\nexport function useThemeLibrary(\n hostId: string | undefined,\n): Record<string, ThemeLibraryEntry> | undefined {\n const firestore = useFirestore()\n const { data, status } = useFirestoreCollection<\n ThemeLibraryEntry & { $id: string }\n >(\n () =>\n hostId\n ? query(\n collection(firestore, 'hosts', hostId, THEME_LIBRARY_COLLECTION),\n limit(LIBRARY_READ_LIMIT),\n )\n : null,\n [firestore, hostId],\n { idField: '$id' },\n )\n return useMemo(() => {\n // A refused read lists nothing rather than holding the picker on a\n // spinner: the built-in themes and the current one still render.\n if (status === 'loading') return undefined\n const entries: Record<string, ThemeLibraryEntry> = {}\n for (const { $id, ...entry } of data ?? []) entries[$id] = entry\n return entries\n }, [data, status])\n}\n\nexport default useThemeLibrary\n"],"names":["THEME_LIBRARY_COLLECTION","useFirestore","collection","limit","query","useMemo","useFirestoreCollection","LIBRARY_READ_LIMIT","useThemeLibrary","hostId","firestore","data","status","idField","undefined","entries","$id","entry"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SACEA,wBAAwB,QAEnB,uCAAsC;AAC7C,SAASC,YAAY,QAAQ,kCAA8B;AAC3D,SAASC,UAAU,EAAEC,KAAK,EAAEC,KAAK,QAAQ,qBAAoB;AAC7D,SAASC,OAAO,QAAQ,QAAO;AAC/B,OAAOC,4BAA4B,gCAA4B;AAE/D;;;;CAIC,GACD,MAAMC,qBAAqB;AAE3B;;;CAGC,GACD,OAAO,SAASC,gBACdC,MAA0B;IAE1B,MAAMC,YAAYT;IAClB,MAAM,EAAEU,IAAI,EAAEC,MAAM,EAAE,GAAGN,uBAGvB,IACEG,SACIL,MACEF,WAAWQ,WAAW,SAASD,QAAQT,2BACvCG,MAAMI,uBAER,MACN;QAACG;QAAWD;KAAO,EACnB;QAAEI,SAAS;IAAM;IAEnB,OAAOR,QAAQ;QACb,mEAAmE;QACnE,iEAAiE;QACjE,IAAIO,WAAW,WAAW,OAAOE;QACjC,MAAMC,UAA6C,CAAC;QACpD,KAAK,MAAM,QAAqBJ,eAAAA,OAAQ,EAAE;kBAA/B,EAAEK,GAAG,EAAY,SAAPC;;;YAAuBF,OAAO,CAACC,IAAI,GAAGC;;QAC3D,OAAOF;IACT,GAAG;QAACJ;QAAMC;KAAO;AACnB;AAEA,eAAeJ,gBAAe"}