@aglyn/plugins-email 1.0.0-beta.179 → 1.0.0-beta.180
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.
- package/README.md +2 -1
- package/package.json +17 -17
- package/src/lib/components/email-org-mount.d.ts +116 -0
- package/src/lib/components/email-org-mount.js +211 -0
- package/src/lib/components/email-org-mount.js.map +1 -0
- package/src/lib/components/email-topic-detail.d.ts +2 -1
- package/src/lib/components/email-topic-detail.js +7 -1
- package/src/lib/components/email-topic-detail.js.map +1 -1
- package/src/lib/components/email-topics-card.d.ts +2 -1
- package/src/lib/components/email-topics-card.js +7 -1
- package/src/lib/components/email-topics-card.js.map +1 -1
- package/src/lib/components/email-zones.d.ts +28 -1
- package/src/lib/components/email-zones.js.map +1 -1
- package/src/lib/components/emails-console-page.d.ts +4 -0
- package/src/lib/components/emails-console-page.js +107 -5
- package/src/lib/components/emails-console-page.js.map +1 -1
- package/src/lib/components/list-detail-card.d.ts +19 -3
- package/src/lib/components/list-detail-card.js +52 -13
- package/src/lib/components/list-detail-card.js.map +1 -1
- package/src/lib/components/list-edit-card.d.ts +2 -1
- package/src/lib/components/list-edit-card.js +56 -17
- package/src/lib/components/list-edit-card.js.map +1 -1
- package/src/lib/components/lists-card.d.ts +2 -1
- package/src/lib/components/lists-card.js +5 -5
- package/src/lib/components/lists-card.js.map +1 -1
- package/src/lib/components/org-email-templates-card.d.ts +34 -0
- package/src/lib/components/org-email-templates-card.js +470 -0
- package/src/lib/components/org-email-templates-card.js.map +1 -0
- package/src/lib/components/org-sending-card.d.ts +30 -0
- package/src/lib/components/org-sending-card.js +680 -0
- package/src/lib/components/org-sending-card.js.map +1 -0
- package/src/lib/components/org-sending-domain-detail.d.ts +28 -0
- package/src/lib/components/org-sending-domain-detail.js +497 -0
- package/src/lib/components/org-sending-domain-detail.js.map +1 -0
- package/src/lib/components/org-site-suppressions.d.ts +20 -0
- package/src/lib/components/org-site-suppressions.js +99 -0
- package/src/lib/components/org-site-suppressions.js.map +1 -0
- package/src/lib/components/org-suppressions-card.d.ts +22 -0
- package/src/lib/components/org-suppressions-card.js +196 -0
- package/src/lib/components/org-suppressions-card.js.map +1 -0
- package/src/lib/components/sending-domain-detail.d.ts +12 -0
- package/src/lib/components/sending-domain-detail.js +4 -1
- package/src/lib/components/sending-domain-detail.js.map +1 -1
- package/src/lib/components/suppression-totals.d.ts +63 -0
- package/src/lib/components/suppression-totals.js +105 -0
- package/src/lib/components/suppression-totals.js.map +1 -0
- package/src/lib/components/suppressions-card.js +7 -80
- package/src/lib/components/suppressions-card.js.map +1 -1
- package/src/lib/components/use-org-email-topics.d.ts +9 -1
- package/src/lib/components/use-org-email-topics.js +4 -3
- package/src/lib/components/use-org-email-topics.js.map +1 -1
- package/src/lib/components/use-org-sending-views.d.ts +39 -0
- package/src/lib/components/use-org-sending-views.js +124 -0
- package/src/lib/components/use-org-sending-views.js.map +1 -0
- package/src/lib/model/sending-domain-status.d.ts +36 -0
- package/src/lib/model/sending-domain-status.js +46 -0
- package/src/lib/model/sending-domain-status.js.map +1 -1
- package/src/lib/plugin.js +29 -1
- package/src/lib/plugin.js.map +1 -1
package/README.md
CHANGED
|
@@ -34,7 +34,8 @@ This plugin owns email designs, the topic catalog, lists, sending identities and
|
|
|
34
34
|
`registerEmailConsole()` registers:
|
|
35
35
|
|
|
36
36
|
- An **Emails** nav item and page at `/emails`, with the sections Messages, Templates, Audiences, Topics, Sending and Suppressions as routes. It requires the `data.manage` permission and is code-split.
|
|
37
|
-
-
|
|
37
|
+
- The same page at the organization level, `/[orgSlug]/emails` (an `orgNavItems` entry, served by the shell's generic org route to org-wide members). Audiences and topics are the organization's; messages, templates, sending identities and suppression lists are read site by site over a capped batch of sites, and every write that belongs to a site asks which one.
|
|
38
|
+
- Two zones this plugin hosts for the plugin that owns campaigns: the whole body of the Messages section (under a site, and on the organization's page with `hostId: null` and the org mount), and the recipients table under a template's report. This package draws neither.
|
|
38
39
|
- Five widgets drawn in zones the campaign owner's pages host: `campaignTopicSelect`, `campaignTopicOptions`, `campaignSenderEditor`, `campaignDesignCreate` and `campaignDesignPreview`. Each reports back through a callback; none writes a campaign.
|
|
39
40
|
|
|
40
41
|
### On the server
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/plugins-email",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.180",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,22 +25,22 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-data-types": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-ui-email-campaigns": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
34
|
-
"@aglyn/shared-ui-jsx-forms": "1.0.0-beta.
|
|
35
|
-
"@aglyn/shared-ui-next": "1.0.0-beta.
|
|
36
|
-
"@aglyn/shared-ui-snackstack": "1.0.0-beta.
|
|
37
|
-
"@aglyn/shared-ui-theme": "1.0.0-beta.
|
|
38
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
39
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
40
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
41
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
42
|
-
"@aglyn/tenant-data-admin": "1.0.0-beta.
|
|
43
|
-
"@aglyn/tenant-feature-instance": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.180",
|
|
29
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.180",
|
|
30
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.180",
|
|
31
|
+
"@aglyn/shared-data-types": "1.0.0-beta.180",
|
|
32
|
+
"@aglyn/shared-ui-email-campaigns": "1.0.0-beta.180",
|
|
33
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.180",
|
|
34
|
+
"@aglyn/shared-ui-jsx-forms": "1.0.0-beta.180",
|
|
35
|
+
"@aglyn/shared-ui-next": "1.0.0-beta.180",
|
|
36
|
+
"@aglyn/shared-ui-snackstack": "1.0.0-beta.180",
|
|
37
|
+
"@aglyn/shared-ui-theme": "1.0.0-beta.180",
|
|
38
|
+
"@aglyn/shared-util-email": "1.0.0-beta.180",
|
|
39
|
+
"@aglyn/shared-util-http": "1.0.0-beta.180",
|
|
40
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.180",
|
|
41
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.180",
|
|
42
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.180",
|
|
43
|
+
"@aglyn/tenant-feature-instance": "1.0.0-beta.180",
|
|
44
44
|
"@swc/helpers": "0.5.23"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { ConsolePluginOrgHost, ConsolePluginOrgMount } from '@aglyn/aglyn';
|
|
2
|
+
import { type OrgDataScope } from '@aglyn/tenant-feature-instance';
|
|
3
|
+
import { type ReactNode } from 'react';
|
|
4
|
+
/**
|
|
5
|
+
* The organization the Emails page is mounted over, when it is mounted at the
|
|
6
|
+
* ORGANIZATION level — `/[orgSlug]/emails` — rather than under one site.
|
|
7
|
+
*
|
|
8
|
+
* The audiences and the topics are the organization's, so those sections
|
|
9
|
+
* render the same cards over the org. Everything else here is a SITE fact: a
|
|
10
|
+
* template is a screen on one site, the sending identity and the suppression
|
|
11
|
+
* list are one site's, and consent is recorded by one site. So the org page
|
|
12
|
+
* reads those per site, a page of sites at a time, and asks which site
|
|
13
|
+
* whenever it is about to write one.
|
|
14
|
+
*
|
|
15
|
+
* `null` under a site. A card branches on that and nothing else: a `hostId`
|
|
16
|
+
* of `null` without a mount is a bug, not an org page.
|
|
17
|
+
*/
|
|
18
|
+
export interface EmailOrgMount extends ConsolePluginOrgMount {
|
|
19
|
+
/** The org Emails page's own path, `/[orgSlug]/emails`. */
|
|
20
|
+
basePath: string;
|
|
21
|
+
/**
|
|
22
|
+
* The site a site-scoped action defaults to: the reader's last pick this
|
|
23
|
+
* session, or the org's only site when it has exactly one, else `null`
|
|
24
|
+
* until somebody picks. Nothing else is chosen silently — enrolling
|
|
25
|
+
* somebody as the wrong site records consent under a brand that never met
|
|
26
|
+
* them.
|
|
27
|
+
*/
|
|
28
|
+
pickedHostId: string | null;
|
|
29
|
+
/** Records a pick for the rest of the session. */
|
|
30
|
+
setPickedHostId: (hostId: string) => void;
|
|
31
|
+
}
|
|
32
|
+
/** Publishes the org-level mount to every card beneath the Emails page. */
|
|
33
|
+
export declare function EmailOrgMountProvider(props: {
|
|
34
|
+
mount: ConsolePluginOrgMount;
|
|
35
|
+
/** The org Emails page's own path. */
|
|
36
|
+
basePath: string;
|
|
37
|
+
children: ReactNode;
|
|
38
|
+
}): import("react").JSX.Element;
|
|
39
|
+
export declare namespace EmailOrgMountProvider {
|
|
40
|
+
var displayName: string;
|
|
41
|
+
}
|
|
42
|
+
/** The org mount, or `null` when the Emails page is mounted under a site. */
|
|
43
|
+
export declare function useEmailOrgMount(): EmailOrgMount | null;
|
|
44
|
+
/**
|
|
45
|
+
* The `['orgs', orgId]` parent an org-shared card reads — the audiences and
|
|
46
|
+
* the topics — at either level.
|
|
47
|
+
*
|
|
48
|
+
* Under a site it is the site's owning org, resolved through `hostIndex`, and
|
|
49
|
+
* `scope` is null while that lookup is in flight. On the org page the mount
|
|
50
|
+
* names the org and there is nothing to wait for.
|
|
51
|
+
*/
|
|
52
|
+
export declare function useEmailDataScope(hostId: string | null | undefined): OrgDataScope;
|
|
53
|
+
/** A site's display name from the mount, or its id when the mount lacks it. */
|
|
54
|
+
export declare function orgSiteName(mount: Pick<ConsolePluginOrgMount, 'hosts'> | null, hostId: string | null | undefined): string;
|
|
55
|
+
/**
|
|
56
|
+
* One site's own Emails page — `/[orgSlug]/hosts/{subdomain}/emails` — or a
|
|
57
|
+
* section beneath it, or `null` when the site is not in the mount or has no
|
|
58
|
+
* subdomain yet (such a site is named, never linked).
|
|
59
|
+
*
|
|
60
|
+
* What an org row links to for anything that stays a site fact: a template,
|
|
61
|
+
* a site's sending identity, a site's suppression list.
|
|
62
|
+
*/
|
|
63
|
+
export declare function orgSiteEmailsPath(mount: Pick<ConsolePluginOrgMount, 'hosts' | 'hostsPath'> | null, hostId: string | null | undefined, section?: string): string | null;
|
|
64
|
+
/**
|
|
65
|
+
* How many sites an org section reads at once, when what it shows is read
|
|
66
|
+
* one site at a time.
|
|
67
|
+
*
|
|
68
|
+
* The same figure as the console's cross-site search fan-out: enough for the
|
|
69
|
+
* organizations that actually run several sites to see all of them on one
|
|
70
|
+
* page, and a ceiling on what one page costs for the agency that runs fifty.
|
|
71
|
+
* It is a fixed page rather than a reader's choice of page size, because
|
|
72
|
+
* every site on a page is a read of its own.
|
|
73
|
+
*/
|
|
74
|
+
export declare const ORG_SITES_PER_PAGE = 10;
|
|
75
|
+
/**
|
|
76
|
+
* The org's sites, one page of {@link ORG_SITES_PER_PAGE} at a time.
|
|
77
|
+
*
|
|
78
|
+
* Ordered by name so a page boundary falls somewhere a reader can predict.
|
|
79
|
+
* Paged on the console's shared footer rather than grown by a button: the
|
|
80
|
+
* site list grows with the organization like any other list, and a section
|
|
81
|
+
* that read each new batch on top of the last would read without end.
|
|
82
|
+
*/
|
|
83
|
+
export declare function useOrgSitePage(mount: Pick<ConsolePluginOrgMount, 'hosts'>): {
|
|
84
|
+
/** The sites on the current page. */
|
|
85
|
+
sites: readonly ConsolePluginOrgHost[];
|
|
86
|
+
page: number;
|
|
87
|
+
setPage: (page: number) => void;
|
|
88
|
+
pageSize: number;
|
|
89
|
+
/** Every site the org has. */
|
|
90
|
+
count: number;
|
|
91
|
+
};
|
|
92
|
+
/** The footer's count line for a page of sites: `Sites 1–10 of 12`. */
|
|
93
|
+
export declare const orgSitePageLabel: (range: {
|
|
94
|
+
from: number;
|
|
95
|
+
to: number;
|
|
96
|
+
count: number;
|
|
97
|
+
}) => string;
|
|
98
|
+
/**
|
|
99
|
+
* The picker every site-scoped action on the org page asks with.
|
|
100
|
+
*
|
|
101
|
+
* A pick is remembered for the session, so a reader enrolling people on three
|
|
102
|
+
* audiences as the same site answers the question once.
|
|
103
|
+
*/
|
|
104
|
+
export declare function OrgSiteSelect(props: {
|
|
105
|
+
mount: EmailOrgMount;
|
|
106
|
+
label: string;
|
|
107
|
+
value: string;
|
|
108
|
+
onChange: (hostId: string) => void;
|
|
109
|
+
helperText?: string;
|
|
110
|
+
/** Narrower than the org's whole list, when only some sites apply. */
|
|
111
|
+
sites?: readonly ConsolePluginOrgHost[];
|
|
112
|
+
disabled?: boolean;
|
|
113
|
+
}): import("react").JSX.Element;
|
|
114
|
+
export declare namespace OrgSiteSelect {
|
|
115
|
+
var displayName: string;
|
|
116
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
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 { jsx as _jsx } from "react/jsx-runtime";
|
|
18
|
+
import { useOrgDataScope } from "@aglyn/tenant-feature-instance";
|
|
19
|
+
import { MenuItem, TextField } from "@mui/material";
|
|
20
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
21
|
+
const EmailOrgMountContext = /*#__PURE__*/ createContext(null);
|
|
22
|
+
/**
|
|
23
|
+
* The session's memory of the picked site, per org. Session storage rather
|
|
24
|
+
* than local, for the reason the CRM gives for its own: the pick is a working
|
|
25
|
+
* convenience for one sitting, and a site remembered for weeks would default
|
|
26
|
+
* an enrollment to a site the reader had forgotten choosing.
|
|
27
|
+
*/ const pickStorageKey = (orgId)=>`aglyn.emails.site.${orgId}`;
|
|
28
|
+
function readRememberedPick(orgId) {
|
|
29
|
+
try {
|
|
30
|
+
return window.sessionStorage.getItem(pickStorageKey(orgId));
|
|
31
|
+
} catch (unused) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function writeRememberedPick(orgId, hostId) {
|
|
36
|
+
try {
|
|
37
|
+
window.sessionStorage.setItem(pickStorageKey(orgId), hostId);
|
|
38
|
+
} catch (unused) {
|
|
39
|
+
// A browser that refuses storage still keeps the pick for this page.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Publishes the org-level mount to every card beneath the Emails page. */ export function EmailOrgMountProvider(props) {
|
|
43
|
+
const { mount, basePath, children } = props;
|
|
44
|
+
const { orgId, orgSlug, hosts, hostsReady, hostsPath, billingPath } = mount;
|
|
45
|
+
const [picked, setPicked] = useState(null);
|
|
46
|
+
// Read after mount rather than in the initializer, so the server and the
|
|
47
|
+
// first client paint agree.
|
|
48
|
+
useEffect(()=>{
|
|
49
|
+
setPicked(readRememberedPick(orgId));
|
|
50
|
+
}, [
|
|
51
|
+
orgId
|
|
52
|
+
]);
|
|
53
|
+
/*
|
|
54
|
+
* A remembered site the list no longer carries is forgotten rather than
|
|
55
|
+
* used: the reader may have lost the site, or the site may be gone.
|
|
56
|
+
*/ const pickedHostId = useMemo(()=>{
|
|
57
|
+
if (picked && hosts.some((host)=>host.id === picked)) return picked;
|
|
58
|
+
if (hostsReady && hosts.length === 1) return hosts[0].id;
|
|
59
|
+
return null;
|
|
60
|
+
}, [
|
|
61
|
+
picked,
|
|
62
|
+
hosts,
|
|
63
|
+
hostsReady
|
|
64
|
+
]);
|
|
65
|
+
const setPickedHostId = useCallback((hostId)=>{
|
|
66
|
+
setPicked(hostId);
|
|
67
|
+
writeRememberedPick(orgId, hostId);
|
|
68
|
+
}, [
|
|
69
|
+
orgId
|
|
70
|
+
]);
|
|
71
|
+
/*
|
|
72
|
+
* Memoized because every card beneath reads it through context, and a fresh
|
|
73
|
+
* object each render would re-render all of them for nothing.
|
|
74
|
+
*/ const value = useMemo(()=>({
|
|
75
|
+
orgId,
|
|
76
|
+
orgSlug,
|
|
77
|
+
hosts,
|
|
78
|
+
hostsReady,
|
|
79
|
+
hostsPath,
|
|
80
|
+
billingPath,
|
|
81
|
+
basePath,
|
|
82
|
+
pickedHostId,
|
|
83
|
+
setPickedHostId
|
|
84
|
+
}), [
|
|
85
|
+
orgId,
|
|
86
|
+
orgSlug,
|
|
87
|
+
hosts,
|
|
88
|
+
hostsReady,
|
|
89
|
+
hostsPath,
|
|
90
|
+
billingPath,
|
|
91
|
+
basePath,
|
|
92
|
+
pickedHostId,
|
|
93
|
+
setPickedHostId
|
|
94
|
+
]);
|
|
95
|
+
return /*#__PURE__*/ _jsx(EmailOrgMountContext.Provider, {
|
|
96
|
+
value: value,
|
|
97
|
+
children: children
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
EmailOrgMountProvider.displayName = 'EmailOrgMountProvider';
|
|
101
|
+
/** The org mount, or `null` when the Emails page is mounted under a site. */ export function useEmailOrgMount() {
|
|
102
|
+
return useContext(EmailOrgMountContext);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The `['orgs', orgId]` parent an org-shared card reads — the audiences and
|
|
106
|
+
* the topics — at either level.
|
|
107
|
+
*
|
|
108
|
+
* Under a site it is the site's owning org, resolved through `hostIndex`, and
|
|
109
|
+
* `scope` is null while that lookup is in flight. On the org page the mount
|
|
110
|
+
* names the org and there is nothing to wait for.
|
|
111
|
+
*/ export function useEmailDataScope(hostId) {
|
|
112
|
+
const mount = useEmailOrgMount();
|
|
113
|
+
return useOrgDataScope({
|
|
114
|
+
hostId: hostId || undefined,
|
|
115
|
+
orgId: hostId ? undefined : (mount == null ? void 0 : mount.orgId) || undefined
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/** A site's display name from the mount, or its id when the mount lacks it. */ export function orgSiteName(mount, hostId) {
|
|
119
|
+
if (!hostId) return '';
|
|
120
|
+
const host = mount == null ? void 0 : mount.hosts.find((entry)=>entry.id === hostId);
|
|
121
|
+
return (host == null ? void 0 : host.name) || (host == null ? void 0 : host.subdomain) || hostId;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* One site's own Emails page — `/[orgSlug]/hosts/{subdomain}/emails` — or a
|
|
125
|
+
* section beneath it, or `null` when the site is not in the mount or has no
|
|
126
|
+
* subdomain yet (such a site is named, never linked).
|
|
127
|
+
*
|
|
128
|
+
* What an org row links to for anything that stays a site fact: a template,
|
|
129
|
+
* a site's sending identity, a site's suppression list.
|
|
130
|
+
*/ export function orgSiteEmailsPath(mount, hostId, section) {
|
|
131
|
+
var _mount_hosts_find;
|
|
132
|
+
if (!mount || !hostId) return null;
|
|
133
|
+
const subdomain = (_mount_hosts_find = mount.hosts.find((host)=>host.id === hostId)) == null ? void 0 : _mount_hosts_find.subdomain;
|
|
134
|
+
if (!subdomain) return null;
|
|
135
|
+
const hub = `${mount.hostsPath}/${encodeURIComponent(subdomain)}/emails`;
|
|
136
|
+
return section ? `${hub}/${section}` : hub;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* How many sites an org section reads at once, when what it shows is read
|
|
140
|
+
* one site at a time.
|
|
141
|
+
*
|
|
142
|
+
* The same figure as the console's cross-site search fan-out: enough for the
|
|
143
|
+
* organizations that actually run several sites to see all of them on one
|
|
144
|
+
* page, and a ceiling on what one page costs for the agency that runs fifty.
|
|
145
|
+
* It is a fixed page rather than a reader's choice of page size, because
|
|
146
|
+
* every site on a page is a read of its own.
|
|
147
|
+
*/ export const ORG_SITES_PER_PAGE = 10;
|
|
148
|
+
/**
|
|
149
|
+
* The org's sites, one page of {@link ORG_SITES_PER_PAGE} at a time.
|
|
150
|
+
*
|
|
151
|
+
* Ordered by name so a page boundary falls somewhere a reader can predict.
|
|
152
|
+
* Paged on the console's shared footer rather than grown by a button: the
|
|
153
|
+
* site list grows with the organization like any other list, and a section
|
|
154
|
+
* that read each new batch on top of the last would read without end.
|
|
155
|
+
*/ export function useOrgSitePage(mount) {
|
|
156
|
+
const [page, setPage] = useState(0);
|
|
157
|
+
const ordered = useMemo(()=>[
|
|
158
|
+
...mount.hosts
|
|
159
|
+
].sort((a, b)=>(a.name || a.subdomain || a.id).localeCompare(b.name || b.subdomain || b.id)), [
|
|
160
|
+
mount.hosts
|
|
161
|
+
]);
|
|
162
|
+
// A page past the end — the org lost sites while it was open — falls back
|
|
163
|
+
// to the last page that has any.
|
|
164
|
+
const lastPage = Math.max(0, Math.ceil(ordered.length / ORG_SITES_PER_PAGE) - 1);
|
|
165
|
+
const current = Math.min(page, lastPage);
|
|
166
|
+
const sites = useMemo(()=>ordered.slice(current * ORG_SITES_PER_PAGE, (current + 1) * ORG_SITES_PER_PAGE), [
|
|
167
|
+
ordered,
|
|
168
|
+
current
|
|
169
|
+
]);
|
|
170
|
+
return {
|
|
171
|
+
sites,
|
|
172
|
+
page: current,
|
|
173
|
+
setPage,
|
|
174
|
+
pageSize: ORG_SITES_PER_PAGE,
|
|
175
|
+
count: ordered.length
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/** The footer's count line for a page of sites: `Sites 1–10 of 12`. */ export const orgSitePageLabel = (range)=>`Sites ${range.from}–${range.to} of ${range.count}`;
|
|
179
|
+
/**
|
|
180
|
+
* The picker every site-scoped action on the org page asks with.
|
|
181
|
+
*
|
|
182
|
+
* A pick is remembered for the session, so a reader enrolling people on three
|
|
183
|
+
* audiences as the same site answers the question once.
|
|
184
|
+
*/ export function OrgSiteSelect(props) {
|
|
185
|
+
const { mount, label, value, onChange, helperText, sites, disabled } = props;
|
|
186
|
+
const options = sites != null ? sites : mount.hosts;
|
|
187
|
+
return /*#__PURE__*/ _jsx(TextField, {
|
|
188
|
+
select: true,
|
|
189
|
+
size: "small",
|
|
190
|
+
label: label,
|
|
191
|
+
value: options.some((site)=>site.id === value) ? value : '',
|
|
192
|
+
onChange: (event)=>{
|
|
193
|
+
const hostId = String(event.target.value);
|
|
194
|
+
mount.setPickedHostId(hostId);
|
|
195
|
+
onChange(hostId);
|
|
196
|
+
},
|
|
197
|
+
helperText: helperText,
|
|
198
|
+
disabled: disabled,
|
|
199
|
+
sx: {
|
|
200
|
+
minWidth: 240,
|
|
201
|
+
maxWidth: 420
|
|
202
|
+
},
|
|
203
|
+
children: options.map((site)=>/*#__PURE__*/ _jsx(MenuItem, {
|
|
204
|
+
value: site.id,
|
|
205
|
+
children: site.name || site.subdomain || site.id
|
|
206
|
+
}, site.id))
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
OrgSiteSelect.displayName = 'OrgSiteSelect';
|
|
210
|
+
|
|
211
|
+
//# sourceMappingURL=email-org-mount.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/email-org-mount.tsx"],"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 type { ConsolePluginOrgHost, ConsolePluginOrgMount } from '@aglyn/aglyn'\nimport {\n useOrgDataScope,\n type OrgDataScope,\n} from '@aglyn/tenant-feature-instance'\nimport { MenuItem, TextField } from '@mui/material'\nimport {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useState,\n type ReactNode,\n} from 'react'\n\n/**\n * The organization the Emails page is mounted over, when it is mounted at the\n * ORGANIZATION level — `/[orgSlug]/emails` — rather than under one site.\n *\n * The audiences and the topics are the organization's, so those sections\n * render the same cards over the org. Everything else here is a SITE fact: a\n * template is a screen on one site, the sending identity and the suppression\n * list are one site's, and consent is recorded by one site. So the org page\n * reads those per site, a page of sites at a time, and asks which site\n * whenever it is about to write one.\n *\n * `null` under a site. A card branches on that and nothing else: a `hostId`\n * of `null` without a mount is a bug, not an org page.\n */\nexport interface EmailOrgMount extends ConsolePluginOrgMount {\n /** The org Emails page's own path, `/[orgSlug]/emails`. */\n basePath: string\n /**\n * The site a site-scoped action defaults to: the reader's last pick this\n * session, or the org's only site when it has exactly one, else `null`\n * until somebody picks. Nothing else is chosen silently — enrolling\n * somebody as the wrong site records consent under a brand that never met\n * them.\n */\n pickedHostId: string | null\n /** Records a pick for the rest of the session. */\n setPickedHostId: (hostId: string) => void\n}\n\nconst EmailOrgMountContext = createContext<EmailOrgMount | null>(null)\n\n/**\n * The session's memory of the picked site, per org. Session storage rather\n * than local, for the reason the CRM gives for its own: the pick is a working\n * convenience for one sitting, and a site remembered for weeks would default\n * an enrollment to a site the reader had forgotten choosing.\n */\nconst pickStorageKey = (orgId: string) => `aglyn.emails.site.${orgId}`\n\nfunction readRememberedPick(orgId: string): string | null {\n try {\n return window.sessionStorage.getItem(pickStorageKey(orgId))\n } catch {\n return null\n }\n}\n\nfunction writeRememberedPick(orgId: string, hostId: string): void {\n try {\n window.sessionStorage.setItem(pickStorageKey(orgId), hostId)\n } catch {\n // A browser that refuses storage still keeps the pick for this page.\n }\n}\n\n/** Publishes the org-level mount to every card beneath the Emails page. */\nexport function EmailOrgMountProvider(props: {\n mount: ConsolePluginOrgMount\n /** The org Emails page's own path. */\n basePath: string\n children: ReactNode\n}) {\n const { mount, basePath, children } = props\n const { orgId, orgSlug, hosts, hostsReady, hostsPath, billingPath } = mount\n const [picked, setPicked] = useState<string | null>(null)\n // Read after mount rather than in the initializer, so the server and the\n // first client paint agree.\n useEffect(() => {\n setPicked(readRememberedPick(orgId))\n }, [orgId])\n\n /*\n * A remembered site the list no longer carries is forgotten rather than\n * used: the reader may have lost the site, or the site may be gone.\n */\n const pickedHostId = useMemo(() => {\n if (picked && hosts.some((host) => host.id === picked)) return picked\n if (hostsReady && hosts.length === 1) return hosts[0].id\n return null\n }, [picked, hosts, hostsReady])\n\n const setPickedHostId = useCallback(\n (hostId: string) => {\n setPicked(hostId)\n writeRememberedPick(orgId, hostId)\n },\n [orgId],\n )\n\n /*\n * Memoized because every card beneath reads it through context, and a fresh\n * object each render would re-render all of them for nothing.\n */\n const value = useMemo<EmailOrgMount>(\n () => ({\n orgId,\n orgSlug,\n hosts,\n hostsReady,\n hostsPath,\n billingPath,\n basePath,\n pickedHostId,\n setPickedHostId,\n }),\n [\n orgId,\n orgSlug,\n hosts,\n hostsReady,\n hostsPath,\n billingPath,\n basePath,\n pickedHostId,\n setPickedHostId,\n ],\n )\n return (\n <EmailOrgMountContext.Provider value={value}>\n {children}\n </EmailOrgMountContext.Provider>\n )\n}\nEmailOrgMountProvider.displayName = 'EmailOrgMountProvider'\n\n/** The org mount, or `null` when the Emails page is mounted under a site. */\nexport function useEmailOrgMount(): EmailOrgMount | null {\n return useContext(EmailOrgMountContext)\n}\n\n/**\n * The `['orgs', orgId]` parent an org-shared card reads — the audiences and\n * the topics — at either level.\n *\n * Under a site it is the site's owning org, resolved through `hostIndex`, and\n * `scope` is null while that lookup is in flight. On the org page the mount\n * names the org and there is nothing to wait for.\n */\nexport function useEmailDataScope(\n hostId: string | null | undefined,\n): OrgDataScope {\n const mount = useEmailOrgMount()\n return useOrgDataScope({\n hostId: hostId || undefined,\n orgId: hostId ? undefined : mount?.orgId || undefined,\n })\n}\n\n/** A site's display name from the mount, or its id when the mount lacks it. */\nexport function orgSiteName(\n mount: Pick<ConsolePluginOrgMount, 'hosts'> | null,\n hostId: string | null | undefined,\n): string {\n if (!hostId) return ''\n const host = mount?.hosts.find((entry) => entry.id === hostId)\n return host?.name || host?.subdomain || hostId\n}\n\n/**\n * One site's own Emails page — `/[orgSlug]/hosts/{subdomain}/emails` — or a\n * section beneath it, or `null` when the site is not in the mount or has no\n * subdomain yet (such a site is named, never linked).\n *\n * What an org row links to for anything that stays a site fact: a template,\n * a site's sending identity, a site's suppression list.\n */\nexport function orgSiteEmailsPath(\n mount: Pick<ConsolePluginOrgMount, 'hosts' | 'hostsPath'> | null,\n hostId: string | null | undefined,\n section?: string,\n): string | null {\n if (!mount || !hostId) return null\n const subdomain = mount.hosts.find((host) => host.id === hostId)?.subdomain\n if (!subdomain) return null\n const hub = `${mount.hostsPath}/${encodeURIComponent(subdomain)}/emails`\n return section ? `${hub}/${section}` : hub\n}\n\n/**\n * How many sites an org section reads at once, when what it shows is read\n * one site at a time.\n *\n * The same figure as the console's cross-site search fan-out: enough for the\n * organizations that actually run several sites to see all of them on one\n * page, and a ceiling on what one page costs for the agency that runs fifty.\n * It is a fixed page rather than a reader's choice of page size, because\n * every site on a page is a read of its own.\n */\nexport const ORG_SITES_PER_PAGE = 10\n\n/**\n * The org's sites, one page of {@link ORG_SITES_PER_PAGE} at a time.\n *\n * Ordered by name so a page boundary falls somewhere a reader can predict.\n * Paged on the console's shared footer rather than grown by a button: the\n * site list grows with the organization like any other list, and a section\n * that read each new batch on top of the last would read without end.\n */\nexport function useOrgSitePage(mount: Pick<ConsolePluginOrgMount, 'hosts'>): {\n /** The sites on the current page. */\n sites: readonly ConsolePluginOrgHost[]\n page: number\n setPage: (page: number) => void\n pageSize: number\n /** Every site the org has. */\n count: number\n} {\n const [page, setPage] = useState(0)\n const ordered = useMemo(\n () =>\n [...mount.hosts].sort((a, b) =>\n (a.name || a.subdomain || a.id).localeCompare(\n b.name || b.subdomain || b.id,\n ),\n ),\n [mount.hosts],\n )\n // A page past the end — the org lost sites while it was open — falls back\n // to the last page that has any.\n const lastPage = Math.max(\n 0,\n Math.ceil(ordered.length / ORG_SITES_PER_PAGE) - 1,\n )\n const current = Math.min(page, lastPage)\n const sites = useMemo(\n () =>\n ordered.slice(\n current * ORG_SITES_PER_PAGE,\n (current + 1) * ORG_SITES_PER_PAGE,\n ),\n [ordered, current],\n )\n return {\n sites,\n page: current,\n setPage,\n pageSize: ORG_SITES_PER_PAGE,\n count: ordered.length,\n }\n}\n\n/** The footer's count line for a page of sites: `Sites 1–10 of 12`. */\nexport const orgSitePageLabel = (range: {\n from: number\n to: number\n count: number\n}) => `Sites ${range.from}–${range.to} of ${range.count}`\n\n/**\n * The picker every site-scoped action on the org page asks with.\n *\n * A pick is remembered for the session, so a reader enrolling people on three\n * audiences as the same site answers the question once.\n */\nexport function OrgSiteSelect(props: {\n mount: EmailOrgMount\n label: string\n value: string\n onChange: (hostId: string) => void\n helperText?: string\n /** Narrower than the org's whole list, when only some sites apply. */\n sites?: readonly ConsolePluginOrgHost[]\n disabled?: boolean\n}) {\n const { mount, label, value, onChange, helperText, sites, disabled } = props\n const options = sites ?? mount.hosts\n return (\n <TextField\n select\n size=\"small\"\n label={label}\n value={options.some((site) => site.id === value) ? value : ''}\n onChange={(event) => {\n const hostId = String(event.target.value)\n mount.setPickedHostId(hostId)\n onChange(hostId)\n }}\n helperText={helperText}\n disabled={disabled}\n sx={{ minWidth: 240, maxWidth: 420 }}\n >\n {options.map((site) => (\n <MenuItem key={site.id} value={site.id}>\n {site.name || site.subdomain || site.id}\n </MenuItem>\n ))}\n </TextField>\n )\n}\nOrgSiteSelect.displayName = 'OrgSiteSelect'\n"],"names":["useOrgDataScope","MenuItem","TextField","createContext","useCallback","useContext","useEffect","useMemo","useState","EmailOrgMountContext","pickStorageKey","orgId","readRememberedPick","window","sessionStorage","getItem","writeRememberedPick","hostId","setItem","EmailOrgMountProvider","props","mount","basePath","children","orgSlug","hosts","hostsReady","hostsPath","billingPath","picked","setPicked","pickedHostId","some","host","id","length","setPickedHostId","value","Provider","displayName","useEmailOrgMount","useEmailDataScope","undefined","orgSiteName","find","entry","name","subdomain","orgSiteEmailsPath","section","hub","encodeURIComponent","ORG_SITES_PER_PAGE","useOrgSitePage","page","setPage","ordered","sort","a","b","localeCompare","lastPage","Math","max","ceil","current","min","sites","slice","pageSize","count","orgSitePageLabel","range","from","to","OrgSiteSelect","label","onChange","helperText","disabled","options","select","size","site","event","String","target","sx","minWidth","maxWidth","map"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAGA,SACEA,eAAe,QAEV,iCAAgC;AACvC,SAASC,QAAQ,EAAEC,SAAS,QAAQ,gBAAe;AACnD,SACEC,aAAa,EACbC,WAAW,EACXC,UAAU,EACVC,SAAS,EACTC,OAAO,EACPC,QAAQ,QAEH,QAAO;AA+Bd,MAAMC,qCAAuBN,cAAoC;AAEjE;;;;;CAKC,GACD,MAAMO,iBAAiB,CAACC,QAAkB,CAAC,kBAAkB,EAAEA,OAAO;AAEtE,SAASC,mBAAmBD,KAAa;IACvC,IAAI;QACF,OAAOE,OAAOC,cAAc,CAACC,OAAO,CAACL,eAAeC;IACtD,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA,SAASK,oBAAoBL,KAAa,EAAEM,MAAc;IACxD,IAAI;QACFJ,OAAOC,cAAc,CAACI,OAAO,CAACR,eAAeC,QAAQM;IACvD,EAAE,eAAM;IACN,qEAAqE;IACvE;AACF;AAEA,yEAAyE,GACzE,OAAO,SAASE,sBAAsBC,KAKrC;IACC,MAAM,EAAEC,KAAK,EAAEC,QAAQ,EAAEC,QAAQ,EAAE,GAAGH;IACtC,MAAM,EAAET,KAAK,EAAEa,OAAO,EAAEC,KAAK,EAAEC,UAAU,EAAEC,SAAS,EAAEC,WAAW,EAAE,GAAGP;IACtE,MAAM,CAACQ,QAAQC,UAAU,GAAGtB,SAAwB;IACpD,yEAAyE;IACzE,4BAA4B;IAC5BF,UAAU;QACRwB,UAAUlB,mBAAmBD;IAC/B,GAAG;QAACA;KAAM;IAEV;;;GAGC,GACD,MAAMoB,eAAexB,QAAQ;QAC3B,IAAIsB,UAAUJ,MAAMO,IAAI,CAAC,CAACC,OAASA,KAAKC,EAAE,KAAKL,SAAS,OAAOA;QAC/D,IAAIH,cAAcD,MAAMU,MAAM,KAAK,GAAG,OAAOV,KAAK,CAAC,EAAE,CAACS,EAAE;QACxD,OAAO;IACT,GAAG;QAACL;QAAQJ;QAAOC;KAAW;IAE9B,MAAMU,kBAAkBhC,YACtB,CAACa;QACCa,UAAUb;QACVD,oBAAoBL,OAAOM;IAC7B,GACA;QAACN;KAAM;IAGT;;;GAGC,GACD,MAAM0B,QAAQ9B,QACZ,IAAO,CAAA;YACLI;YACAa;YACAC;YACAC;YACAC;YACAC;YACAN;YACAS;YACAK;QACF,CAAA,GACA;QACEzB;QACAa;QACAC;QACAC;QACAC;QACAC;QACAN;QACAS;QACAK;KACD;IAEH,qBACE,KAAC3B,qBAAqB6B,QAAQ;QAACD,OAAOA;kBACnCd;;AAGP;AACAJ,sBAAsBoB,WAAW,GAAG;AAEpC,2EAA2E,GAC3E,OAAO,SAASC;IACd,OAAOnC,WAAWI;AACpB;AAEA;;;;;;;CAOC,GACD,OAAO,SAASgC,kBACdxB,MAAiC;IAEjC,MAAMI,QAAQmB;IACd,OAAOxC,gBAAgB;QACrBiB,QAAQA,UAAUyB;QAClB/B,OAAOM,SAASyB,YAAYrB,CAAAA,yBAAAA,MAAOV,KAAK,KAAI+B;IAC9C;AACF;AAEA,6EAA6E,GAC7E,OAAO,SAASC,YACdtB,KAAkD,EAClDJ,MAAiC;IAEjC,IAAI,CAACA,QAAQ,OAAO;IACpB,MAAMgB,OAAOZ,yBAAAA,MAAOI,KAAK,CAACmB,IAAI,CAAC,CAACC,QAAUA,MAAMX,EAAE,KAAKjB;IACvD,OAAOgB,CAAAA,wBAAAA,KAAMa,IAAI,MAAIb,wBAAAA,KAAMc,SAAS,KAAI9B;AAC1C;AAEA;;;;;;;CAOC,GACD,OAAO,SAAS+B,kBACd3B,KAAgE,EAChEJ,MAAiC,EACjCgC,OAAgB;QAGE5B;IADlB,IAAI,CAACA,SAAS,CAACJ,QAAQ,OAAO;IAC9B,MAAM8B,aAAY1B,oBAAAA,MAAMI,KAAK,CAACmB,IAAI,CAAC,CAACX,OAASA,KAAKC,EAAE,KAAKjB,4BAAvCI,kBAAgD0B,SAAS;IAC3E,IAAI,CAACA,WAAW,OAAO;IACvB,MAAMG,MAAM,GAAG7B,MAAMM,SAAS,CAAC,CAAC,EAAEwB,mBAAmBJ,WAAW,OAAO,CAAC;IACxE,OAAOE,UAAU,GAAGC,IAAI,CAAC,EAAED,SAAS,GAAGC;AACzC;AAEA;;;;;;;;;CASC,GACD,OAAO,MAAME,qBAAqB,GAAE;AAEpC;;;;;;;CAOC,GACD,OAAO,SAASC,eAAehC,KAA2C;IASxE,MAAM,CAACiC,MAAMC,QAAQ,GAAG/C,SAAS;IACjC,MAAMgD,UAAUjD,QACd,IACE;eAAIc,MAAMI,KAAK;SAAC,CAACgC,IAAI,CAAC,CAACC,GAAGC,IACxB,AAACD,CAAAA,EAAEZ,IAAI,IAAIY,EAAEX,SAAS,IAAIW,EAAExB,EAAE,AAAD,EAAG0B,aAAa,CAC3CD,EAAEb,IAAI,IAAIa,EAAEZ,SAAS,IAAIY,EAAEzB,EAAE,IAGnC;QAACb,MAAMI,KAAK;KAAC;IAEf,0EAA0E;IAC1E,iCAAiC;IACjC,MAAMoC,WAAWC,KAAKC,GAAG,CACvB,GACAD,KAAKE,IAAI,CAACR,QAAQrB,MAAM,GAAGiB,sBAAsB;IAEnD,MAAMa,UAAUH,KAAKI,GAAG,CAACZ,MAAMO;IAC/B,MAAMM,QAAQ5D,QACZ,IACEiD,QAAQY,KAAK,CACXH,UAAUb,oBACV,AAACa,CAAAA,UAAU,CAAA,IAAKb,qBAEpB;QAACI;QAASS;KAAQ;IAEpB,OAAO;QACLE;QACAb,MAAMW;QACNV;QACAc,UAAUjB;QACVkB,OAAOd,QAAQrB,MAAM;IACvB;AACF;AAEA,qEAAqE,GACrE,OAAO,MAAMoC,mBAAmB,CAACC,QAI3B,CAAC,MAAM,EAAEA,MAAMC,IAAI,CAAC,CAAC,EAAED,MAAME,EAAE,CAAC,IAAI,EAAEF,MAAMF,KAAK,EAAE,CAAA;AAEzD;;;;;CAKC,GACD,OAAO,SAASK,cAAcvD,KAS7B;IACC,MAAM,EAAEC,KAAK,EAAEuD,KAAK,EAAEvC,KAAK,EAAEwC,QAAQ,EAAEC,UAAU,EAAEX,KAAK,EAAEY,QAAQ,EAAE,GAAG3D;IACvE,MAAM4D,UAAUb,gBAAAA,QAAS9C,MAAMI,KAAK;IACpC,qBACE,KAACvB;QACC+E,MAAM;QACNC,MAAK;QACLN,OAAOA;QACPvC,OAAO2C,QAAQhD,IAAI,CAAC,CAACmD,OAASA,KAAKjD,EAAE,KAAKG,SAASA,QAAQ;QAC3DwC,UAAU,CAACO;YACT,MAAMnE,SAASoE,OAAOD,MAAME,MAAM,CAACjD,KAAK;YACxChB,MAAMe,eAAe,CAACnB;YACtB4D,SAAS5D;QACX;QACA6D,YAAYA;QACZC,UAAUA;QACVQ,IAAI;YAAEC,UAAU;YAAKC,UAAU;QAAI;kBAElCT,QAAQU,GAAG,CAAC,CAACP,qBACZ,KAAClF;gBAAuBoC,OAAO8C,KAAKjD,EAAE;0BACnCiD,KAAKrC,IAAI,IAAIqC,KAAKpC,SAAS,IAAIoC,KAAKjD,EAAE;eAD1BiD,KAAKjD,EAAE;;AAM9B;AACAyC,cAAcpC,WAAW,GAAG"}
|
|
@@ -22,6 +22,7 @@ import { Button, Chip, Stack, TextField, Typography } from "@mui/material";
|
|
|
22
22
|
import Link from "next/link";
|
|
23
23
|
import { useCallback, useEffect, useState } from "react";
|
|
24
24
|
import { useFirestore } from "@aglyn/tenant-feature-instance";
|
|
25
|
+
import { useEmailOrgMount } from "./email-org-mount.js";
|
|
25
26
|
import { useOrgEmailTopics, writeEmailTopic } from "./use-org-email-topics.js";
|
|
26
27
|
/**
|
|
27
28
|
* One topic's own page — where every change to an existing topic is made.
|
|
@@ -51,7 +52,12 @@ import { useOrgEmailTopics, writeEmailTopic } from "./use-org-email-topics.js";
|
|
|
51
52
|
const firestore = useFirestore();
|
|
52
53
|
const { enqueueSnackbar } = useSnackbar();
|
|
53
54
|
const { confirm } = useConfirmationContext();
|
|
54
|
-
|
|
55
|
+
// The catalog is the org's: read through the site under one, and straight
|
|
56
|
+
// from the organization on its own page.
|
|
57
|
+
const orgMount = useEmailOrgMount();
|
|
58
|
+
const { topics, scope } = useOrgEmailTopics(hostId, {
|
|
59
|
+
orgId: orgMount == null ? void 0 : orgMount.orgId
|
|
60
|
+
});
|
|
55
61
|
/*
|
|
56
62
|
* `resolveCampaignTopic` rather than a bare `find`, so an id that no longer
|
|
57
63
|
* names anything lands on a real topic instead of an empty form. A stale
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/email-topic-detail.tsx"],"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 PageHeaderRecord,\n pluginDocsHelp,\n resolveCampaignTopic,\n DEFAULT_EMAIL_TOPICS,\n} from '@aglyn/aglyn'\nimport { CardDisplay, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Button,\n Chip,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport Link from 'next/link'\nimport { useCallback, useEffect, useState } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useOrgEmailTopics, writeEmailTopic } from './use-org-email-topics'\n\nexport interface EmailTopicDetailProps {\n hostId: string\n topicId: string\n /** `/…/emails`, so the page can route back to the list. */\n basePath: string\n}\n\n/**\n * One topic's own page — where every change to an existing topic is made.\n *\n * The console's shape for a list surface is create-in-a-drawer, edit-on-the\n * record's page, and this is the second half of it for topics. The list card\n * lists and routes; nothing about a saved topic is editable from a form\n * stacked above that table.\n *\n * ## Saving a built-in\n *\n * The four built-ins have no stored document until somebody changes one, so\n * this page's save is the same `setDoc` for a built-in and a custom topic:\n * the write CREATES the override document at the built-in's id. That is the\n * whole overlay design — see `email-topics.ts`.\n *\n * ## Retire, not delete\n *\n * A retired topic leaves the composer's picker and the recipient's preference\n * page, and stays resolvable everywhere else. It has to: campaigns already\n * sent under it minted unsubscribe links carrying its id, those links are in\n * inboxes, and an id that stops resolving is a preference page that cannot\n * name the message the recipient is holding. There is no delete on this page,\n * and the Firestore rules refuse one too.\n */\nexport function EmailTopicDetail(props: EmailTopicDetailProps) {\n const { hostId, topicId, basePath } = props\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n const { topics, scope } = useOrgEmailTopics(hostId)\n\n /*\n * `resolveCampaignTopic` rather than a bare `find`, so an id that no longer\n * names anything lands on a real topic instead of an empty form. A stale\n * bookmark is the common way to arrive here with one.\n */\n const topic = resolveCampaignTopic(topicId, topics)\n const known = topics.some((it) => it.id === topicId)\n\n const [name, setName] = useState('')\n const [description, setDescription] = useState('')\n /**\n * Whether joining this stream needs a confirmation click.\n *\n * Three states, held as a string because a `Select` carries one and a\n * boolean cannot: `''` defers to the site, `'on'` and `'off'` are decisions\n * about this stream. Collapsing the first into a boolean would make \"not\n * chosen\" indistinguishable from \"off\", and the site default would then be\n * unreachable once anybody saved a topic.\n */\n const [doubleOptIn, setDoubleOptIn] = useState('')\n const [loaded, setLoaded] = useState(false)\n const [busy, setBusy] = useState(false)\n\n /*\n * Seed the fields ONCE, when the catalog first arrives.\n *\n * Re-seeding on every change of `topic` would overwrite what the operator is\n * typing the moment the collection listener fires — and it fires on this\n * page's own save.\n */\n useEffect(() => {\n if (loaded || !topics.length) return\n setName(topic.name)\n setDescription(topic.description)\n setDoubleOptIn(\n typeof topic.doubleOptIn === 'boolean'\n ? topic.doubleOptIn\n ? 'on'\n : 'off'\n : '',\n )\n setLoaded(true)\n }, [loaded, topics, topic])\n\n const storedDoubleOptIn =\n typeof topic.doubleOptIn === 'boolean'\n ? topic.doubleOptIn\n ? 'on'\n : 'off'\n : ''\n const dirty =\n loaded &&\n (name !== topic.name ||\n description !== topic.description ||\n doubleOptIn !== storedDoubleOptIn)\n\n /**\n * Save what is in the form, optionally changing the retired flag with it.\n *\n * The write itself is `writeEmailTopic`, which the list card's row menu also\n * goes through — a topic retired from the list and one retired here have to\n * leave the same document behind.\n */\n const save = useCallback(\n async (patch?: { archived?: boolean }) => {\n if (!scope || busy) return\n const nextName = name.trim()\n if (!nextName) {\n return void enqueueSnackbar('Give the topic a name', {\n variant: 'warning',\n persist: false,\n })\n }\n setBusy(true)\n try {\n await writeEmailTopic(firestore, scope, {\n id: topic.id,\n name: nextName,\n description: description.trim(),\n archived: patch?.archived ?? !!topic.archived,\n // `null` clears the field, which is how \"whatever the site says\"\n // is expressed. A stored `false` is a different answer and has to\n // survive as one.\n doubleOptIn:\n doubleOptIn === 'on' ? true : doubleOptIn === 'off' ? false : null,\n })\n enqueueSnackbar('Topic saved', { variant: 'success', persist: false })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('An error has occurred', { variant: 'error' })\n } finally {\n setBusy(false)\n }\n },\n [\n scope,\n busy,\n name,\n description,\n doubleOptIn,\n firestore,\n topic,\n enqueueSnackbar,\n ],\n )\n\n const toggleArchived = useCallback(async () => {\n if (topic.archived) return void save({ archived: false })\n const accepted = await confirm({\n title: `Retire “${topic.name}”?`,\n description:\n 'It leaves the composer and the preference page, so nothing new can ' +\n 'be sent under it and recipients stop seeing it as a choice. ' +\n 'Campaigns already sent under it keep working — their unsubscribe ' +\n 'links still name this topic. You can bring it back at any time.',\n confirmationText: 'Retire',\n })\n // `confirm` resolves with NO VALUE and REJECTS on cancel, so gating on\n // the resolved value alone makes this always return (AGL-950).\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n await save({ archived: true })\n }, [topic, confirm, save])\n\n /* The card, named so the page chrome above it is a plain list of\n what this surface publishes upward. */\n const card = (\n <CardDisplay\n header={'Topic'}\n subheader={\n <Stack direction=\"row\" spacing={1} sx={{ mt: 1, alignItems: 'center' }}>\n {topic.archived ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n color=\"warning\"\n label=\"Retired\"\n />\n ) : null}\n {DEFAULT_EMAIL_TOPICS.some((it) => it.id === topic.id) ? (\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Built in'}\n </Typography>\n ) : null}\n </Stack>\n }\n help={pluginDocsHelp('emailCampaigns', { anchor: '#topics' })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{\n action: (\n <Button component={Link} href={`${basePath}/topics`} size=\"small\">\n {'All topics'}\n </Button>\n ),\n }}\n >\n <Stack spacing={2}>\n {!scope ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'This site has no organization, so it has no topic list.'}\n </Typography>\n ) : (\n <>\n {!known && loaded ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'That topic no longer exists, so this is the one campaigns ' +\n 'fall back to.'}\n </Typography>\n ) : null}\n <TextField\n label=\"Name\"\n size=\"small\"\n value={name}\n onChange={(event) => setName(event.target.value)}\n disabled={busy || !loaded}\n helperText=\"What recipients see on the preference page.\"\n />\n <TextField\n label=\"Description\"\n size=\"small\"\n multiline\n minRows={2}\n value={description}\n onChange={(event) => setDescription(event.target.value)}\n disabled={busy || !loaded}\n helperText=\"One sentence telling them what this stream is.\"\n />\n {/*\n CONFIRMATION — off unless somebody chooses it.\n\n No jurisdiction verified requires a double opt-in by statute.\n They require prior express consent plus a burden of proof on\n the sender, and a confirmation click is the proof the courts\n have so far accepted — so this is an option a merchant reaches\n for, never a default the product imposes.\n\n A three-way select rather than a switch, because the third\n state is real: this topic can defer to the site's own setting,\n and a switch has nowhere to put that.\n */}\n <TextField\n select\n label=\"Confirmation\"\n size=\"small\"\n value={doubleOptIn}\n onChange={(event) => setDoubleOptIn(event.target.value)}\n disabled={busy || !loaded}\n helperText={\n 'Ask new subscribers to confirm by email before anything is ' +\n 'sent to them. Until they do, nothing on this stream reaches ' +\n 'them and they stay on your list.'\n }\n slotProps={{ select: { native: true } }}\n >\n <option value=\"\">{'Use the site setting'}</option>\n <option value=\"on\">{'Require a confirmation click'}</option>\n <option value=\"off\">{'No confirmation for this stream'}</option>\n </TextField>\n <Stack direction=\"row\" spacing={1}>\n <Button\n variant=\"contained\"\n disabled={!dirty || busy}\n onClick={() => void save()}\n >\n {'Save'}\n </Button>\n <Button\n color={topic.archived ? 'primary' : 'error'}\n disabled={busy || !loaded}\n onClick={() => void toggleArchived()}\n >\n {topic.archived ? 'Restore' : 'Retire'}\n </Button>\n </Stack>\n </>\n )}\n </Stack>\n </CardDisplay>\n )\n\n return (\n <>\n {/* The page heading and the trail name the topic; this card is\n then free to say what it holds rather than repeating the title. */}\n <PageHeaderRecord title={topic.name || topic.id} />\n {card}\n </>\n )\n}\n\nexport default EmailTopicDetail\n"],"names":["PageHeaderRecord","pluginDocsHelp","resolveCampaignTopic","DEFAULT_EMAIL_TOPICS","CardDisplay","useConfirmationContext","useSnackbar","Button","Chip","Stack","TextField","Typography","Link","useCallback","useEffect","useState","useFirestore","useOrgEmailTopics","writeEmailTopic","EmailTopicDetail","props","hostId","topicId","basePath","firestore","enqueueSnackbar","confirm","topics","scope","topic","known","some","it","id","name","setName","description","setDescription","doubleOptIn","setDoubleOptIn","loaded","setLoaded","busy","setBusy","length","storedDoubleOptIn","dirty","save","patch","nextName","trim","variant","persist","archived","error","console","toggleArchived","accepted","title","confirmationText","then","catch","card","header","subheader","direction","spacing","sx","mt","alignItems","size","color","label","help","anchor","contentGutterX","contentGutterY","contentBordered","HeaderProps","action","component","href","value","onChange","event","target","disabled","helperText","multiline","minRows","select","slotProps","native","option","onClick"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/email-topic-detail.tsx"],"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 PageHeaderRecord,\n pluginDocsHelp,\n resolveCampaignTopic,\n DEFAULT_EMAIL_TOPICS,\n} from '@aglyn/aglyn'\nimport { CardDisplay, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Button,\n Chip,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport Link from 'next/link'\nimport { useCallback, useEffect, useState } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useEmailOrgMount } from './email-org-mount'\nimport { useOrgEmailTopics, writeEmailTopic } from './use-org-email-topics'\n\nexport interface EmailTopicDetailProps {\n /** The site, or `null` on the organization's Emails page. */\n hostId: string | null\n topicId: string\n /** `/…/emails`, so the page can route back to the list. */\n basePath: string\n}\n\n/**\n * One topic's own page — where every change to an existing topic is made.\n *\n * The console's shape for a list surface is create-in-a-drawer, edit-on-the\n * record's page, and this is the second half of it for topics. The list card\n * lists and routes; nothing about a saved topic is editable from a form\n * stacked above that table.\n *\n * ## Saving a built-in\n *\n * The four built-ins have no stored document until somebody changes one, so\n * this page's save is the same `setDoc` for a built-in and a custom topic:\n * the write CREATES the override document at the built-in's id. That is the\n * whole overlay design — see `email-topics.ts`.\n *\n * ## Retire, not delete\n *\n * A retired topic leaves the composer's picker and the recipient's preference\n * page, and stays resolvable everywhere else. It has to: campaigns already\n * sent under it minted unsubscribe links carrying its id, those links are in\n * inboxes, and an id that stops resolving is a preference page that cannot\n * name the message the recipient is holding. There is no delete on this page,\n * and the Firestore rules refuse one too.\n */\nexport function EmailTopicDetail(props: EmailTopicDetailProps) {\n const { hostId, topicId, basePath } = props\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n // The catalog is the org's: read through the site under one, and straight\n // from the organization on its own page.\n const orgMount = useEmailOrgMount()\n const { topics, scope } = useOrgEmailTopics(hostId, {\n orgId: orgMount?.orgId,\n })\n\n /*\n * `resolveCampaignTopic` rather than a bare `find`, so an id that no longer\n * names anything lands on a real topic instead of an empty form. A stale\n * bookmark is the common way to arrive here with one.\n */\n const topic = resolveCampaignTopic(topicId, topics)\n const known = topics.some((it) => it.id === topicId)\n\n const [name, setName] = useState('')\n const [description, setDescription] = useState('')\n /**\n * Whether joining this stream needs a confirmation click.\n *\n * Three states, held as a string because a `Select` carries one and a\n * boolean cannot: `''` defers to the site, `'on'` and `'off'` are decisions\n * about this stream. Collapsing the first into a boolean would make \"not\n * chosen\" indistinguishable from \"off\", and the site default would then be\n * unreachable once anybody saved a topic.\n */\n const [doubleOptIn, setDoubleOptIn] = useState('')\n const [loaded, setLoaded] = useState(false)\n const [busy, setBusy] = useState(false)\n\n /*\n * Seed the fields ONCE, when the catalog first arrives.\n *\n * Re-seeding on every change of `topic` would overwrite what the operator is\n * typing the moment the collection listener fires — and it fires on this\n * page's own save.\n */\n useEffect(() => {\n if (loaded || !topics.length) return\n setName(topic.name)\n setDescription(topic.description)\n setDoubleOptIn(\n typeof topic.doubleOptIn === 'boolean'\n ? topic.doubleOptIn\n ? 'on'\n : 'off'\n : '',\n )\n setLoaded(true)\n }, [loaded, topics, topic])\n\n const storedDoubleOptIn =\n typeof topic.doubleOptIn === 'boolean'\n ? topic.doubleOptIn\n ? 'on'\n : 'off'\n : ''\n const dirty =\n loaded &&\n (name !== topic.name ||\n description !== topic.description ||\n doubleOptIn !== storedDoubleOptIn)\n\n /**\n * Save what is in the form, optionally changing the retired flag with it.\n *\n * The write itself is `writeEmailTopic`, which the list card's row menu also\n * goes through — a topic retired from the list and one retired here have to\n * leave the same document behind.\n */\n const save = useCallback(\n async (patch?: { archived?: boolean }) => {\n if (!scope || busy) return\n const nextName = name.trim()\n if (!nextName) {\n return void enqueueSnackbar('Give the topic a name', {\n variant: 'warning',\n persist: false,\n })\n }\n setBusy(true)\n try {\n await writeEmailTopic(firestore, scope, {\n id: topic.id,\n name: nextName,\n description: description.trim(),\n archived: patch?.archived ?? !!topic.archived,\n // `null` clears the field, which is how \"whatever the site says\"\n // is expressed. A stored `false` is a different answer and has to\n // survive as one.\n doubleOptIn:\n doubleOptIn === 'on' ? true : doubleOptIn === 'off' ? false : null,\n })\n enqueueSnackbar('Topic saved', { variant: 'success', persist: false })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('An error has occurred', { variant: 'error' })\n } finally {\n setBusy(false)\n }\n },\n [\n scope,\n busy,\n name,\n description,\n doubleOptIn,\n firestore,\n topic,\n enqueueSnackbar,\n ],\n )\n\n const toggleArchived = useCallback(async () => {\n if (topic.archived) return void save({ archived: false })\n const accepted = await confirm({\n title: `Retire “${topic.name}”?`,\n description:\n 'It leaves the composer and the preference page, so nothing new can ' +\n 'be sent under it and recipients stop seeing it as a choice. ' +\n 'Campaigns already sent under it keep working — their unsubscribe ' +\n 'links still name this topic. You can bring it back at any time.',\n confirmationText: 'Retire',\n })\n // `confirm` resolves with NO VALUE and REJECTS on cancel, so gating on\n // the resolved value alone makes this always return (AGL-950).\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n await save({ archived: true })\n }, [topic, confirm, save])\n\n /* The card, named so the page chrome above it is a plain list of\n what this surface publishes upward. */\n const card = (\n <CardDisplay\n header={'Topic'}\n subheader={\n <Stack direction=\"row\" spacing={1} sx={{ mt: 1, alignItems: 'center' }}>\n {topic.archived ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n color=\"warning\"\n label=\"Retired\"\n />\n ) : null}\n {DEFAULT_EMAIL_TOPICS.some((it) => it.id === topic.id) ? (\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Built in'}\n </Typography>\n ) : null}\n </Stack>\n }\n help={pluginDocsHelp('emailCampaigns', { anchor: '#topics' })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{\n action: (\n <Button component={Link} href={`${basePath}/topics`} size=\"small\">\n {'All topics'}\n </Button>\n ),\n }}\n >\n <Stack spacing={2}>\n {!scope ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'This site has no organization, so it has no topic list.'}\n </Typography>\n ) : (\n <>\n {!known && loaded ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'That topic no longer exists, so this is the one campaigns ' +\n 'fall back to.'}\n </Typography>\n ) : null}\n <TextField\n label=\"Name\"\n size=\"small\"\n value={name}\n onChange={(event) => setName(event.target.value)}\n disabled={busy || !loaded}\n helperText=\"What recipients see on the preference page.\"\n />\n <TextField\n label=\"Description\"\n size=\"small\"\n multiline\n minRows={2}\n value={description}\n onChange={(event) => setDescription(event.target.value)}\n disabled={busy || !loaded}\n helperText=\"One sentence telling them what this stream is.\"\n />\n {/*\n CONFIRMATION — off unless somebody chooses it.\n\n No jurisdiction verified requires a double opt-in by statute.\n They require prior express consent plus a burden of proof on\n the sender, and a confirmation click is the proof the courts\n have so far accepted — so this is an option a merchant reaches\n for, never a default the product imposes.\n\n A three-way select rather than a switch, because the third\n state is real: this topic can defer to the site's own setting,\n and a switch has nowhere to put that.\n */}\n <TextField\n select\n label=\"Confirmation\"\n size=\"small\"\n value={doubleOptIn}\n onChange={(event) => setDoubleOptIn(event.target.value)}\n disabled={busy || !loaded}\n helperText={\n 'Ask new subscribers to confirm by email before anything is ' +\n 'sent to them. Until they do, nothing on this stream reaches ' +\n 'them and they stay on your list.'\n }\n slotProps={{ select: { native: true } }}\n >\n <option value=\"\">{'Use the site setting'}</option>\n <option value=\"on\">{'Require a confirmation click'}</option>\n <option value=\"off\">{'No confirmation for this stream'}</option>\n </TextField>\n <Stack direction=\"row\" spacing={1}>\n <Button\n variant=\"contained\"\n disabled={!dirty || busy}\n onClick={() => void save()}\n >\n {'Save'}\n </Button>\n <Button\n color={topic.archived ? 'primary' : 'error'}\n disabled={busy || !loaded}\n onClick={() => void toggleArchived()}\n >\n {topic.archived ? 'Restore' : 'Retire'}\n </Button>\n </Stack>\n </>\n )}\n </Stack>\n </CardDisplay>\n )\n\n return (\n <>\n {/* The page heading and the trail name the topic; this card is\n then free to say what it holds rather than repeating the title. */}\n <PageHeaderRecord title={topic.name || topic.id} />\n {card}\n </>\n )\n}\n\nexport default EmailTopicDetail\n"],"names":["PageHeaderRecord","pluginDocsHelp","resolveCampaignTopic","DEFAULT_EMAIL_TOPICS","CardDisplay","useConfirmationContext","useSnackbar","Button","Chip","Stack","TextField","Typography","Link","useCallback","useEffect","useState","useFirestore","useEmailOrgMount","useOrgEmailTopics","writeEmailTopic","EmailTopicDetail","props","hostId","topicId","basePath","firestore","enqueueSnackbar","confirm","orgMount","topics","scope","orgId","topic","known","some","it","id","name","setName","description","setDescription","doubleOptIn","setDoubleOptIn","loaded","setLoaded","busy","setBusy","length","storedDoubleOptIn","dirty","save","patch","nextName","trim","variant","persist","archived","error","console","toggleArchived","accepted","title","confirmationText","then","catch","card","header","subheader","direction","spacing","sx","mt","alignItems","size","color","label","help","anchor","contentGutterX","contentGutterY","contentBordered","HeaderProps","action","component","href","value","onChange","event","target","disabled","helperText","multiline","minRows","select","slotProps","native","option","onClick"],"mappings":"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"}
|
|
@@ -27,6 +27,7 @@ import { doc, setDoc } from "firebase/firestore";
|
|
|
27
27
|
import { useRouter } from "next/navigation";
|
|
28
28
|
import { useCallback, useState } from "react";
|
|
29
29
|
import { useFirestore } from "@aglyn/tenant-feature-instance";
|
|
30
|
+
import { useEmailOrgMount } from "./email-org-mount.js";
|
|
30
31
|
import { useOrgEmailTopics, writeEmailTopic } from "./use-org-email-topics.js";
|
|
31
32
|
/**
|
|
32
33
|
* Email topics: the streams a recipient can leave one at a time.
|
|
@@ -72,7 +73,12 @@ import { useOrgEmailTopics, writeEmailTopic } from "./use-org-email-topics.js";
|
|
|
72
73
|
const router = useRouter();
|
|
73
74
|
const { enqueueSnackbar } = useSnackbar();
|
|
74
75
|
const { confirm } = useConfirmationContext();
|
|
75
|
-
|
|
76
|
+
// The catalog is the org's: read through the site under one, and straight
|
|
77
|
+
// from the organization on its own page.
|
|
78
|
+
const orgMount = useEmailOrgMount();
|
|
79
|
+
const { topics, scope } = useOrgEmailTopics(hostId, {
|
|
80
|
+
orgId: orgMount == null ? void 0 : orgMount.orgId
|
|
81
|
+
});
|
|
76
82
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
77
83
|
const [error, setError] = useState(null);
|
|
78
84
|
const [busyTopicId, setBusyTopicId] = useState('');
|