diff --git a/app/(home-pages)/(writer)/home/HomeLayout.tsx b/app/(home-pages)/(writer)/home/HomeLayout.tsx index db630f02..ad008f19 100644 --- a/app/(home-pages)/(writer)/home/HomeLayout.tsx +++ b/app/(home-pages)/(writer)/home/HomeLayout.tsx @@ -77,7 +77,7 @@ export const HomeContent = (props: { scrollKey="dashboard-home" pageTitle="Home" mobileActions={} - search={ + controls={ } + hasSearch showHeader={true} > setDebouncedSearchValue(searchValue), - 200, - [searchValue], - ); + useDebouncedEffect(() => setDebouncedSearchValue(searchValue), 200, [ + searchValue, + ]); const showPageBackground = !!record?.theme?.showPageBackground; @@ -31,7 +29,7 @@ export function DraftsTab() { scrollKey={`dashboard-${pubUri}-Drafts`} pageTitle="Drafts" mobileActions={} - search={ + controls={ } + hasSearch publication={pubUri} showHeader={true} > diff --git a/app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx b/app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx index 7e84ed17..a70155f3 100644 --- a/app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx +++ b/app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx @@ -24,7 +24,56 @@ import { type ReferrerType = { referrer_host: string; pageviews: number }; type TrafficMetric = "pageviews" | "visitors"; type DatePreset = "Last Week" | "Last Month" | "All Time"; -type DateSelection = { range: DateRange; preset: DatePreset | null }; +export type DateSelection = { range: DateRange; preset: DatePreset | null }; + +// URL params: +// ?date=all → All Time (no from/to bounds) +// ?date=month → Last Month preset +// ?from=YYYY-MM-DD&to=YYYY-MM-DD → custom range +// (none) → default: Last Week +export function useAnalyticsDateState() { + return useQueryState({ + fromParams: (get) => { + let preset = get("date"); + if (preset === "all") + return { + range: { from: undefined, to: undefined }, + preset: "All Time", + }; + if (preset === "month") { + let from = new Date(); + from.setMonth(from.getMonth() - 1); + return { range: { from, to: new Date() }, preset: "Last Month" }; + } + let fromParam = get("from"); + let toParam = get("to"); + if (fromParam || toParam) { + return { + range: { + from: fromParam ? new Date(fromParam) : undefined, + to: toParam ? new Date(toParam) : new Date(), + }, + preset: null, + }; + } + let from = new Date(); + from.setDate(from.getDate() - 7); + return { range: { from, to: new Date() }, preset: "Last Week" }; + }, + toParams: ({ preset, range }) => { + if (preset === "All Time") + return { date: "all", from: null, to: null }; + if (preset === "Last Month") + return { date: "month", from: null, to: null }; + if (preset === "Last Week") return { date: null, from: null, to: null }; + return { + date: null, + from: range.from?.toISOString().slice(0, 10) ?? null, + to: range.to?.toISOString().slice(0, 10) ?? null, + }; + }, + }); +} const dayTickFormatter = new Intl.DateTimeFormat(undefined, { month: "short", @@ -87,59 +136,15 @@ function fillDailyGaps( export const PublicationAnalytics = (props: { showPageBackground: boolean; + dateState: DateSelection; }) => { let isPro = useIsPro(); let canSeePro = useCanSeePro(); let { data: publication } = usePublicationData(); - // URL params: - // ?date=all → All Time (no from/to bounds) - // ?date=month → Last Month preset - // ?from=YYYY-MM-DD&to=YYYY-MM-DD → custom range - // (none) → default: Last Week - let [dateState, setDateState] = useQueryState({ - fromParams: (get) => { - let preset = get("date"); - if (preset === "all") - return { - range: { from: undefined, to: undefined }, - preset: "All Time", - }; - if (preset === "month") { - let from = new Date(); - from.setMonth(from.getMonth() - 1); - return { range: { from, to: new Date() }, preset: "Last Month" }; - } - let fromParam = get("from"); - let toParam = get("to"); - if (fromParam || toParam) { - return { - range: { - from: fromParam ? new Date(fromParam) : undefined, - to: toParam ? new Date(toParam) : new Date(), - }, - preset: null, - }; - } - let from = new Date(); - from.setDate(from.getDate() - 7); - return { range: { from, to: new Date() }, preset: "Last Week" }; - }, - toParams: ({ preset, range }) => { - if (preset === "All Time") - return { date: "all", from: null, to: null }; - if (preset === "Last Month") - return { date: "month", from: null, to: null }; - if (preset === "Last Week") return { date: null, from: null, to: null }; - return { - date: null, - from: range.from?.toISOString().slice(0, 10) ?? null, - to: range.to?.toISOString().slice(0, 10) ?? null, - }; - }, - }); - let { range: dateRange, preset: datePreset } = dateState; + let { dateState } = props; + let { range: dateRange } = dateState; // ?post= — filter traffic to a single published post (path is the stable ID) let [selectedPostPath, setSelectedPostPath] = useQueryState< @@ -258,14 +263,6 @@ export const PublicationAnalytics = (props: { return (
-
- -
void; diff --git a/app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx b/app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx index f7072058..809a9808 100644 --- a/app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx +++ b/app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx @@ -8,6 +8,8 @@ import { Separator } from "components/Layout"; import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; import { useLocalizedDate } from "src/hooks/useLocalizedDate"; import { useDashboardState } from "components/PageLayouts/dashboardState"; +import { AtmosphereAccount } from "components/Icons/AtmosphereAccount"; +import { EmailTiny } from "components/Icons/EmailTiny"; type subscriber = { email: string | undefined; did: string | undefined }; @@ -22,14 +24,11 @@ type MergedSubscriber = { status: SubscriberStatus; }; -export function PublicationSubscribers(props: { - showPageBackground?: boolean; -}) { - let smoker = useSmoker(); +export function useMergedSubscribers(): MergedSubscriber[] | null { let { data: publication } = usePublicationData(); let { subscriberStatus } = useDashboardState(); - if (!publication) return
null
; + if (!publication) return null; // ATProto subscribers have no email lifecycle state — they're just present // or absent, so they only count under the "subscribed" status filter. let atprotoSubs = subscriberStatus.subscribed @@ -84,7 +83,18 @@ export function PublicationSubscribers(props: { status, }); } - let subscribers: MergedSubscriber[] = [...byDid.values(), ...emailOnly]; + return [...byDid.values(), ...emailOnly]; +} + +export function PublicationSubscribers(props: { + showPageBackground?: boolean; +}) { + let smoker = useSmoker(); + let { data: publication } = usePublicationData(); + let { subscriberStatus } = useDashboardState(); + let subscribers = useMergedSubscribers(); + + if (!publication || !subscribers) return
null
; // useEffect(() => { // const allSubscribersSelected = subscribers.every((subscriber) => @@ -118,7 +128,8 @@ export function PublicationSubscribers(props: { .join(", "); return (
-

No subscribers match this filter

-

Showing: {label}

+

No subscribers match your filters!

); } return (
- {/*
- { - if (checkAll === false) { - const allSubscribers = subscribers.map((subscriber) => ({ - email: "dummyemail@email.com", - did: subscriber.identities?.bsky_profiles?.did, - })); - setCheckedSubscribers(allSubscribers); - } else { - setCheckedSubscribers([]); - } - }} - className="!font-bold text-secondary mb-1" - > - {subscribers.length} Subscriber{subscribers.length !== 1 && "s"} - - {checkedSubscribers.length !== 0 && ( - - )} -
*/} -
- {subscribers.length} Subscriber{subscribers.length !== 1 && "s"} -
-
-
+
{subscribers .sort((a, b) => b.created_at.localeCompare(a.created_at)) .map((subscriber) => ( - + <> + +
+ ))}
@@ -234,35 +218,47 @@ const SubscriberListItem = (props: { createdAt: string; status: SubscriberStatus; }) => { + 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"; + return ( -
- {props.handle && ( - - @{props.handle} - - )} - {props.handle && props.email && ( - - )} - {props.email && ( - - {props.email} - - )} - {props.status !== "subscribed" && ( - - {props.status === "unconfirmed" ? "unconfirmed" : "unsubscribed"} - - )} - +
+
+ {props.handle && ( + + + {props.handle} + + )} + {props.handle && props.email && ( + + )} + {props.email && ( + + {" "} +
{props.email}
+
+ )} +
+
+ {props.status !== "subscribed" && ( + + {props.status === "unconfirmed" ? "unconfirmed" : "unsubscribed"} + + )} + +
); }; diff --git a/app/lish/[did]/[publication]/dashboard/analytics/page.tsx b/app/lish/[did]/[publication]/dashboard/analytics/page.tsx index 491c56ea..6c85cfef 100644 --- a/app/lish/[did]/[publication]/dashboard/analytics/page.tsx +++ b/app/lish/[did]/[publication]/dashboard/analytics/page.tsx @@ -3,7 +3,11 @@ import { useRouter } from "next/navigation"; import { useEffect } from "react"; import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; -import { PublicationAnalytics } from "../PublicationAnalytics"; +import { + PublicationAnalytics, + DateRangeSelector, + useAnalyticsDateState, +} from "../PublicationAnalytics"; import { NewDraftActionButton } from "../NewDraftButton"; import { usePublicationData, @@ -19,6 +23,8 @@ export default function AnalyticsPage() { let pubUri = data?.publication?.uri || ""; const showPageBackground = !!record?.theme?.showPageBackground; + let [dateState, setDateState] = useAnalyticsDateState(); + useEffect(() => { if (canSeePro === false) router.replace("../"); }, [canSeePro, router]); @@ -31,9 +37,22 @@ export default function AnalyticsPage() { pageTitle="Analytics" mobileActions={} publication={pubUri} - showHeader={false} + showHeader={true} + controls={ +
+ +
+ } > - + ); } diff --git a/app/lish/[did]/[publication]/dashboard/subs/page.tsx b/app/lish/[did]/[publication]/dashboard/subs/page.tsx index 5d6377c4..fd88c02c 100644 --- a/app/lish/[did]/[publication]/dashboard/subs/page.tsx +++ b/app/lish/[did]/[publication]/dashboard/subs/page.tsx @@ -1,32 +1,106 @@ "use client"; import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; -import { SubscriberStatusFilter } from "components/PageLayouts/PageSearch"; -import { PublicationSubscribers } from "../PublicationSubscribers"; -import { NewDraftActionButton } from "../NewDraftButton"; +import { + PublicationSubscribers, + useMergedSubscribers, +} from "../PublicationSubscribers"; import { usePublicationData, useNormalizedPublicationRecord, } from "../PublicationSWRProvider"; +import { Popover } from "components/Popover"; +import { Checkbox } from "components/Checkbox"; +import { + useDashboardState, + useSetDashboardState, +} from "components/PageLayouts/dashboardState"; export default function SubsPage() { let { data } = usePublicationData(); let record = useNormalizedPublicationRecord(); let pubUri = data?.publication?.uri || ""; const showPageBackground = !!record?.theme?.showPageBackground; + let subscribers = useMergedSubscribers(); + let count = subscribers?.length ?? 0; return ( } + mobileActions={} publication={pubUri} - showHeader={false} + showHeader={true} + controls={ +
+
+ {count} Subscriber{count !== 1 && "s"} +
+ +
+ } > -
- -
); } + +const SubscriberStatusFilter = () => { + let { subscriberStatus } = useDashboardState(); + let setState = useSetDashboardState(); + let count = Object.values(subscriberStatus).filter(Boolean).length; + + return ( + + Filters {count > 0 && `(${count})`} +
+ } + > + + setState({ + subscriberStatus: { + ...subscriberStatus, + subscribed: !!e.target.checked, + }, + }) + } + > + Subscribed + + + setState({ + subscriberStatus: { + ...subscriberStatus, + unconfirmed: !!e.target.checked, + }, + }) + } + > + Unconfirmed + + + setState({ + subscriberStatus: { + ...subscriberStatus, + unsubscribed: !!e.target.checked, + }, + }) + } + > + Unsubscribed + + + ); +}; diff --git a/app/lish/[did]/[publication]/theme-settings/page.tsx b/app/lish/[did]/[publication]/theme-settings/page.tsx index caffe8fd..80040b0d 100644 --- a/app/lish/[did]/[publication]/theme-settings/page.tsx +++ b/app/lish/[did]/[publication]/theme-settings/page.tsx @@ -6,6 +6,7 @@ import { AtUri } from "@atproto/syntax"; import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; import { normalizePublicationRecord } from "src/utils/normalizeRecords"; import { ThemeSettingsContent } from "./ThemeSettingsContent"; +import { LoginModal } from "components/LoginButton"; export default async function ThemeSettingsPage(props: { params: Promise<{ publication: string; did: string }>; @@ -15,13 +16,23 @@ export default async function ThemeSettingsPage(props: { if (!identity || !identity.atp_did) return ( -

Looks like you're not logged in.

+

+ Looks like you're not logged in.{" "} + Log in here

+ } + /> + ! +

If the issue persists please{" "} send us a note.

); + let did = decodeURIComponent(params.did); if (!did) return ; let { result: publication_data } = await get_publication_data.handler( diff --git a/components/ActionBar/MobileNavigation.tsx b/components/ActionBar/MobileNavigation.tsx index 8c7093b3..bb31ef2a 100644 --- a/components/ActionBar/MobileNavigation.tsx +++ b/components/ActionBar/MobileNavigation.tsx @@ -11,7 +11,8 @@ import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; import { useIdentityData } from "components/IdentityProvider"; export const MobileNavigation = (props: { - search?: React.ReactNode; + controls?: React.ReactNode; + hasSearch?: boolean; mobileActions?: React.ReactNode; pageTitle: string; hiddenOnScroll?: boolean; @@ -113,7 +114,7 @@ export const MobileNavigation = (props: {
- {props.search && ( + {props.controls && props.hasSearch && (
); diff --git a/components/PageLayouts/PageSearch.tsx b/components/PageLayouts/PageSearch.tsx index 0aa751ab..8289b580 100644 --- a/components/PageLayouts/PageSearch.tsx +++ b/components/PageLayouts/PageSearch.tsx @@ -167,62 +167,6 @@ const FilterCheckboxes = (props: { ); }; -export const SubscriberStatusFilter = () => { - let { subscriberStatus } = useDashboardState(); - let setState = useSetDashboardState(); - let count = Object.values(subscriberStatus).filter(Boolean).length; - - return ( - Status {count > 0 && `(${count})`}
} - > - - setState({ - subscriberStatus: { - ...subscriberStatus, - subscribed: !!e.target.checked, - }, - }) - } - > - Subscribed - - - setState({ - subscriberStatus: { - ...subscriberStatus, - unconfirmed: !!e.target.checked, - }, - }) - } - > - Unconfirmed - - - setState({ - subscriberStatus: { - ...subscriberStatus, - unsubscribed: !!e.target.checked, - }, - }) - } - > - Unsubscribed - - - ); -}; - const FilterOptions = (props: { hasPubs: boolean; hasArchived: boolean }) => { let { filter } = useDashboardState(); let filterCount = Object.values(filter).filter(Boolean).length;