diff --git a/app/(app)/(identity)/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx b/app/(app)/(identity)/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx
index 196012ad..c0b99ab9 100644
--- a/app/(app)/(identity)/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx
+++ b/app/(app)/(identity)/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx
@@ -1,4 +1,5 @@
"use client";
+import { Fragment } from "react";
import { ButtonPrimary } from "components/Buttons";
import { useSmoker } from "components/Toast";
import { Separator } from "components/Layout";
@@ -10,8 +11,7 @@ import {
import { AtmosphereAccount } from "components/Icons/AtmosphereAccount";
import { EmailTiny } from "components/Icons/EmailTiny";
import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout";
-import { Popover } from "components/Popover";
-import { Checkbox } from "components/Checkbox";
+import { CheckboxMenuItem, Menu, MenuSeparator } from "components/Menu";
export type SubscriberStatus = "subscribed" | "unconfirmed" | "unsubscribed";
@@ -19,6 +19,8 @@ export type SubscriberStatus = "subscribed" | "unconfirmed" | "unsubscribed";
// tier FK ON DELETE SET NULL); they still count as a member, just untierable.
export type MemberTier = { id: string | null; name: string };
+export type Tier = { id: string; name: string; is_free: boolean };
+
export type MergedSubscriber = {
key: string;
did: string | undefined;
@@ -35,18 +37,24 @@ export function PublicationSubscribers(props: {
publicationUri: string;
showPageBackground: boolean;
membershipsEnabled: boolean;
- tiers: { id: string; name: string }[];
+ tiers: Tier[];
}) {
let smoker = useSmoker();
- let { subscriberStatus, membersOnly } = useDashboardState();
- let selectedTiers = useSelectedTiers(props.tiers);
- let filtered = props.subscribers.filter(
- (s) =>
- subscriberStatus[s.status] &&
- (!membersOnly || !!s.memberTier) &&
- (selectedTiers.length === 0 ||
- (!!s.memberTier?.id && selectedTiers.includes(s.memberTier.id))),
- );
+ let { subscriberStatus } = useDashboardState();
+ let { membersOnly, selected, freeSelected, selectedPaidTiers, tierNarrowed } =
+ useTierFilter(props.tiers);
+ let filtered = props.subscribers.filter((s) => {
+ if (!subscriberStatus[s.status]) return false;
+ if (!membersOnly && !freeSelected) return true;
+ // Joining the free tier is a plain subscription with no membership row, so
+ // the free tier is everyone without a paid one.
+ if (!s.memberTier) return freeSelected;
+ if (!membersOnly) return false;
+ return (
+ !tierNarrowed ||
+ (!!s.memberTier.id && selectedPaidTiers.includes(s.memberTier.id))
+ );
+ });
let activeStatuses = (
Object.keys(subscriberStatus) as SubscriberStatus[]
@@ -55,7 +63,7 @@ export function PublicationSubscribers(props: {
activeStatuses.length === 1 &&
activeStatuses[0] === "subscribed" &&
!membersOnly &&
- selectedTiers.length === 0;
+ selected.length === 0;
let bgStyle = props.showPageBackground
? { backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha)) " }
@@ -127,7 +135,7 @@ export function PublicationSubscribers(props: {
{filtered
.sort((a, b) => b.created_at.localeCompare(a.created_at))
.map((subscriber) => (
-
+
-
-
+
+
))}
@@ -155,22 +163,26 @@ const SubscriberListItem = (props: {
memberTier?: MemberTier;
}) => {
let contactClassName =
- "flex flex-row gap-2 items-center border rounded-md px-1 text-sm w-full max-w-fit no-underline! hover:bg-[var(--accent-light)] hover:border-accent-contrast ";
- let subscribedClassName = " border-transparent font-bold text-secondary";
- let mutedClassName = "border-border bg-border-light text-tertiary";
- let unconfirmedClassName = "border-border-light animate-pulse text-tertiary";
+ "subscriber flex flex-row gap-2 items-center px-1 text-sm w-full max-w-fit no-underline! hover:text-accent-contrast ";
+ let subscribedClassName = " text-secondary";
+ let mutedClassName = "text-tertiary line-through";
+ let unconfirmedClassName = "animate-pulse text-tertiary";
return (
-
-
+
+
-
+
{props.memberTier && (
-
+
{props.memberTier.name}
)}
@@ -217,103 +233,140 @@ function SubscriberDate(props: { createdAt: string }) {
);
}
-// Persisted tier selections can reference tiers that were since deleted or
-// deactivated; only the ones still offered should filter (and count as active).
-const useSelectedTiers = (tiers: { id: string }[]) => {
- let { memberTiers } = useDashboardState();
- return memberTiers.filter((id) => tiers.some((t) => t.id === id));
+const useTierFilter = (tiers: Tier[]) => {
+ let { membersOnly, memberTiers } = useDashboardState();
+ let selected = memberTiers.filter((id) => tiers.some((t) => t.id === id));
+ let paidTiers = tiers.filter((t) => !t.is_free);
+ let selectedPaidTiers = selected.filter((id) =>
+ paidTiers.some((t) => t.id === id),
+ );
+ return {
+ membersOnly,
+ selected,
+ paidTiers,
+ selectedPaidTiers,
+ freeSelected: tiers.some((t) => t.is_free && selected.includes(t.id)),
+ tierNarrowed:
+ selectedPaidTiers.length > 0 &&
+ selectedPaidTiers.length < paidTiers.length,
+ };
};
const SubscriberStatusFilter = (props: {
membershipsEnabled: boolean;
- tiers: { id: string; name: string }[];
+ tiers: Tier[];
}) => {
- let { subscriberStatus, membersOnly, memberTiers } = useDashboardState();
+ let { subscriberStatus, memberTiers } = useDashboardState();
let setState = useSetDashboardState();
- let selectedTiers = useSelectedTiers(props.tiers);
+ let {
+ membersOnly,
+ selected,
+ paidTiers,
+ selectedPaidTiers,
+ freeSelected,
+ tierNarrowed,
+ } = useTierFilter(props.tiers);
+
let count =
Object.values(subscriberStatus).filter(Boolean).length +
- (membersOnly ? 1 : 0) +
- selectedTiers.length;
+ (freeSelected ? 1 : 0) +
+ (membersOnly ? (tierNarrowed ? selectedPaidTiers.length : 1) : 0);
+
+ const setMemberTiers = (ids: string[]) =>
+ setState({
+ memberTiers: ids,
+ // Paid stays on as long as any tier under it is, so unchecking the last
+ // child unchecks the parent.
+ membersOnly: paidTiers.some((t) => ids.includes(t.id)),
+ });
+
+ const statusCheckbox = (status: SubscriberStatus, label: string) => (
+ {
+ e.preventDefault();
+ setState({
+ subscriberStatus: {
+ ...subscriberStatus,
+ [status]: !subscriberStatus[status],
+ },
+ });
+ }}
+ >
+ {label}
+
+ );
+
+ const tierCheckbox = (tier: Tier, className?: string) => (
+ {
+ e.preventDefault();
+ let ids = selected.includes(tier.id)
+ ? memberTiers.filter((id) => id !== tier.id)
+ : [...memberTiers.filter((id) => id !== tier.id), tier.id];
+ if (tier.is_free) return setState({ memberTiers: ids });
+ setMemberTiers(ids);
+ }}
+ >
+ {tier.name}
+
+ );
return (
-
+
+
}
>
-
- setState({
- subscriberStatus: {
- ...subscriberStatus,
- subscribed: !!e.target.checked,
- },
- })
- }
- >
- Subscribed
-
+ {statusCheckbox("subscribed", "Subscribed")}
+ {statusCheckbox("unconfirmed", "Unconfirmed")}
+ {statusCheckbox("unsubscribed", "Unsubscribed")}
+
+ {props.membershipsEnabled && (
+ <>
+
-
- setState({
- subscriberStatus: {
- ...subscriberStatus,
- unconfirmed: !!e.target.checked,
- },
- })
- }
- >
- Unconfirmed
-
-
- setState({
- subscriberStatus: {
- ...subscriberStatus,
- unsubscribed: !!e.target.checked,
- },
- })
- }
- >
- Unsubscribed
-
-
-
setState({ membersOnly: !!e.target.checked })}
- >
- Members
-
- {props.membershipsEnabled &&
- props.tiers.map((tier) => (
-
+ {props.tiers
+ .filter((t) => t.is_free)
+ .map((tier) => tierCheckbox(tier))}
+
+ {
+ e.preventDefault();
+ let checked = !(membersOnly && !tierNarrowed);
+ let withoutPaid = memberTiers.filter(
+ (id) => !paidTiers.some((t) => t.id === id),
+ );
setState({
- memberTiers: e.target.checked
- ? [...memberTiers.filter((id) => id !== tier.id), tier.id]
- : memberTiers.filter((id) => id !== tier.id),
- })
- }
+ // Set directly rather than derived from the tiers below: a
+ // publication can have members with no paid tier left to check.
+ membersOnly: checked,
+ memberTiers: checked
+ ? [...withoutPaid, ...paidTiers.map((t) => t.id)]
+ : withoutPaid,
+ });
+ }}
>
- {tier.name}
-
- ))}
-
+ Paid Members
+
+ {props.tiers
+ .filter((t) => !t.is_free)
+ .map((tier) => tierCheckbox(tier, "pl-6!"))}
+ >
+ )}
+
);
};
diff --git a/app/(app)/(identity)/lish/[did]/[publication]/dashboard/subs/page.tsx b/app/(app)/(identity)/lish/[did]/[publication]/dashboard/subs/page.tsx
index 8631023a..b22c55fc 100644
--- a/app/(app)/(identity)/lish/[did]/[publication]/dashboard/subs/page.tsx
+++ b/app/(app)/(identity)/lish/[did]/[publication]/dashboard/subs/page.tsx
@@ -140,7 +140,7 @@ export default async function SubsPage(props: {
a.monthly_price_cents - b.monthly_price_cents ||
a.sort_order - b.sort_order,
)
- .map((t) => ({ id: t.id, name: t.name }));
+ .map((t) => ({ id: t.id, name: t.name, is_free: t.is_free }));
return (
) => void;
children: React.ReactNode;
className?: string;
small?: boolean;
}) {
+ let ref = useRef(null);
+ let indeterminate = !props.checked && !!props.indeterminate;
+ // Only settable on the DOM node, and it's what exposes the mixed state to
+ // assistive tech.
+ useEffect(() => {
+ if (ref.current) ref.current.indeterminate = indeterminate;
+ }, [indeterminate]);
+ let marked = props.checked || indeterminate;
return (
-
>
)}
diff --git a/components/Menu.tsx b/components/Menu.tsx
index 76a93602..fa6c2ca9 100644
--- a/components/Menu.tsx
+++ b/components/Menu.tsx
@@ -3,10 +3,12 @@ import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { theme } from "tailwind.config";
import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider";
import { PopoverArrow } from "./Icons/PopoverArrow";
+import { CheckboxChecked } from "./Icons/CheckboxChecked";
+import { CheckboxEmpty } from "./Icons/CheckboxEmpty";
+import { CheckboxIndeterminate } from "./Icons/CheckboxIndeterminate";
import { PopoverOpenContext } from "./Popover/PopoverContext";
import { useState } from "react";
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
-import { Drop } from "node_modules/@tiptap/core/dist/extensions";
export const Menu = (props: {
open?: boolean;
@@ -121,15 +123,38 @@ export const CheckboxMenuItem = (props: {
className?: string;
onSelect?: (e: Event) => void;
checked?: boolean;
+ // Renders as a Checkbox — a box in the margin and plain type — rather than
+ // the filled pill, for dense multi-select lists like filters.
+ compact?: boolean;
+ // A parent whose children are only partly checked. Selecting it reports
+ // unchecked, so callers select the whole group.
+ indeterminate?: boolean;
}) => {
+ let indeterminate = !props.checked && !!props.indeterminate;
+ let marked = props.checked || indeterminate;
+ let iconClassName = `shrink-0 mt-1 ${marked ? "text-accent-contrast" : "text-tertiary"}`;
return (
+ {props.compact &&
+ (indeterminate ? (
+
+ ) : props.checked ? (
+
+ ) : (
+
+ ))}
{props.children}
);