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.handle || props.did) && ( -
{props.handle ?? props.did}
+
+ {props.handle ?? props.did} +
)} {(props.handle || props.did) && props.email && ( @@ -180,16 +192,20 @@ const SubscriberListItem = (props: { {" "} -
{props.email}
+
+ {props.email} +
)}
-
+
{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} );