Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
7d0d36e
feat(membership): add memberships and billing_webhook_events schema, …
Innei Jul 18, 2026
d580d23
feat(post): add pure Lexical truncation helpers for paywall teasers
Innei Jul 18, 2026
49e147a
feat(membership): add membership services, Dodo provider adapter, con…
Innei Jul 18, 2026
f582f9b
fix(membership): close webhook idempotency race and narrow module exp…
Innei Jul 18, 2026
8d4bf99
feat(membership): add HTTP API controller for checkout, status, webho…
Innei Jul 18, 2026
e22b05a
feat(post): enforce paywall gate on premium post detail responses
Innei Jul 18, 2026
38eb917
fix(post): close paywall gate gaps in list route and previewBlocks clamp
Innei Jul 18, 2026
e097f99
feat(post): route public surfaces through getPublicText/getPublicCont…
Innei Jul 18, 2026
4d07d68
fix(render): gate premium content behind teaser on public render routes
Innei Jul 18, 2026
692ae7c
feat(reader): add membership summary and status filter to reader list
Innei Jul 18, 2026
e5d4d99
feat(api-client): add membership controller and paywall types
Innei Jul 18, 2026
2ab74b5
feat(admin): add post editor paywall controls (isPremium, previewBlocks)
Innei Jul 18, 2026
3c021e4
fix(admin): strip stale paywall.previewBlocks when post is not premium
Innei Jul 18, 2026
781a3bd
feat(admin): membership UI on readers page
Innei Jul 18, 2026
6d12a26
fix(reader): join membership into single-reader detail query
Innei Jul 18, 2026
2f6c331
fix(membership): entitlement-keyed cache, retryable webhook apply, bo…
Innei Jul 18, 2026
5c5b25e
docs: add paid-articles membership design spec and implementation plan
Innei Jul 18, 2026
bbc8be4
refactor(membership): move Dodo settings into configs module
Innei Jul 18, 2026
0508c9f
fix(admin): improve webhook editor feedback
Innei Jul 18, 2026
7bf2800
feat(configs): surface membership section in admin settings groups
Innei Jul 18, 2026
6bca55e
fix(aggregate): gate premium post body behind paywall in /aggregate/l…
Innei Jul 18, 2026
c89878f
fix(activity): stop leaking draft and premium post bodies from /activity
Innei Jul 18, 2026
2f4f369
fix(ai-translation): block public translation endpoints for premium p…
Innei Jul 18, 2026
a4bd082
fix(ai-insights): block public insights endpoints for premium posts
Innei Jul 18, 2026
becaebb
fix(search): enforce paywall on public search result bodies
Innei Jul 18, 2026
5e22a9e
fix(membership): report effective expired status once currentPeriodEn…
Innei Jul 18, 2026
1da3166
feat(membership): thread checkout return_url back to the frontend art…
Innei Jul 18, 2026
322acd5
chore(release): api-client 5.6.0-preview.0
Innei Jul 18, 2026
d4087c5
feat(api-client): membership.plans() availability + preview.1
Innei Jul 18, 2026
6af4a35
feat(membership): guard premium posts and expose plan availability
Innei Jul 18, 2026
76d548b
feat(membership): expose plan pricing from provider on plans endpoint
Innei Jul 18, 2026
d832e87
refactor(membership): provider registry so pricing/checkout/webhook a…
Innei Jul 18, 2026
0f9bf74
feat(membership): expose isMember on comment authors via batch entitl…
Innei Jul 18, 2026
f3d5b6e
refactor(membership): declare webhook raw-body capture via route opti…
Innei Jul 19, 2026
c46c0d6
fix: address codex review findings on paywall and membership
Innei Jul 19, 2026
3922afc
fix: address membership review findings
Innei Jul 19, 2026
1691a64
test: provide comment entitlement dependency
Innei Jul 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/admin/src/api/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface CreatePostData {
copyright?: boolean
draftId?: string
images?: Image[]
isPremium?: boolean
isPublished?: boolean
meta?: Record<string, unknown>
pin?: null | string
Expand Down
32 changes: 31 additions & 1 deletion apps/admin/src/api/readers.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
import type { PaginateResult } from '~/models/base'

import { getJson, patchJson } from './http'
import { deleteJson, getJson, patchJson, putJson } from './http'

export type ReaderRole = 'reader' | 'owner'
export type ReaderRoleFilter = 'all' | 'owner' | 'reader'

export type MembershipProvider =
'creem' | 'dodo' | 'lemonsqueezy' | 'manual' | 'stripe'
export type MembershipPlan = 'monthly' | 'yearly'
export type MembershipStatus = 'active' | 'cancelled' | 'expired' | 'on_hold'
export type ReaderMembershipStatusFilter = 'none' | MembershipStatus

export interface ReaderMembership {
status: MembershipStatus
plan: MembershipPlan
provider: MembershipProvider
currentPeriodEnd: string
}

export interface ReaderModel {
id: string
email: string | null
Expand All @@ -20,6 +33,7 @@ export interface ReaderModel {
createdAt: string
updatedAt: string | null
lastLoginAt: string | null
membership: ReaderMembership | null
}

export interface ReaderStats {
Expand All @@ -34,10 +48,12 @@ export interface ReaderListParams {
size: number
search?: string
role?: ReaderRoleFilter
membershipStatus?: ReaderMembershipStatusFilter
}

export function getReaders(params: ReaderListParams) {
return getJson<PaginateResult<ReaderModel>>('/readers', {
membershipStatus: params.membershipStatus,
page: params.page,
role: params.role,
search: params.search,
Expand Down Expand Up @@ -73,3 +89,17 @@ export function unbanReader(id: string) {
{},
)
}

export function grantMembership(
readerId: string,
data: { plan: MembershipPlan; expiresAt: string },
) {
return putJson<ReaderMembership, typeof data>(
`/membership/members/${readerId}`,
data,
)
}

export function revokeMembership(readerId: string) {
return deleteJson<ReaderMembership>(`/membership/members/${readerId}`)
}
1 change: 1 addition & 0 deletions apps/admin/src/data/resources/post.mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ function toOptimisticPostPatch(data: CreatePostData): Partial<PostModel> {
categoryId: data.categoryId,
contentFormat: data.contentFormat,
copyright: data.copyright,
isPremium: data.isPremium,
isPublished: data.isPublished,
meta: data.meta,
pinAt: data.pin,
Expand Down
28 changes: 28 additions & 0 deletions apps/admin/src/features/readers/components/ReaderListRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ import { ListRow } from '~/ui/list-actions'
import { cn } from '~/utils/cn'
import { relativeTimeFromNow } from '~/utils/time'

import {
effectiveMembershipStatus,
MEMBERSHIP_PLAN_LABEL_KEY,
MEMBERSHIP_STATUS_LABEL_KEY,
MEMBERSHIP_STATUS_TONE,
providerLabel,
} from '../utils/membership-status'

interface ReaderListRowProps {
reader: ReaderModel
selected: boolean
Expand All @@ -25,6 +33,7 @@ export function ReaderListRow(props: ReaderListRowProps) {
const banned = Boolean(reader.bannedAt)
const displayName =
reader.name || reader.username || reader.handle || reader.id
const membershipStatus = effectiveMembershipStatus(reader.membership)

return (
<ListRow
Expand Down Expand Up @@ -74,8 +83,27 @@ export function ReaderListRow(props: ReaderListRowProps) {
{t('readers.row.banned')}
</StatusPill>
) : null}
{membershipStatus !== 'none' ? (
<StatusPill
className="shrink-0"
tone={MEMBERSHIP_STATUS_TONE[membershipStatus]}
>
{t(MEMBERSHIP_STATUS_LABEL_KEY[membershipStatus])}
</StatusPill>
) : null}
</div>
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-fg-muted">
{reader.membership ? (
<>
<span className="shrink-0 truncate">
{t(MEMBERSHIP_PLAN_LABEL_KEY[reader.membership.plan])} ·{' '}
{providerLabel(reader.membership.provider)}
</span>
<span aria-hidden="true" className="text-fg-subtle">
·
</span>
</>
) : null}
{reader.handle ? (
<span className="shrink-0 truncate">@{reader.handle}</span>
) : null}
Expand Down
112 changes: 112 additions & 0 deletions apps/admin/src/features/readers/components/ReaderMembershipBlock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { ReaderModel } from '~/api/readers'
import { useI18n } from '~/i18n'
import { StatusPill } from '~/ui/data/StatusPill'
import { confirmDialog } from '~/ui/feedback/confirm'
import { Button } from '~/ui/primitives/button'
import { parseDate } from '~/utils/time'

import type { useReaderMutations } from '../hooks/useReaderMutations'
import {
effectiveMembershipStatus,
hasLiveProviderManagedMembership,
MEMBERSHIP_PLAN_LABEL_KEY,
MEMBERSHIP_STATUS_LABEL_KEY,
MEMBERSHIP_STATUS_TONE,
providerLabel,
} from '../utils/membership-status'
import { presentMembershipGrantModal } from './modals/MembershipGrantModal'

export function ReaderMembershipBlock(props: {
reader: ReaderModel
mutations: ReturnType<typeof useReaderMutations>
}) {
const { t } = useI18n()
const { reader, mutations } = props
const membership = reader.membership ?? null
const status = effectiveMembershipStatus(membership)
const liveProviderManaged = hasLiveProviderManagedMembership(membership)
const isManual = membership?.provider === 'manual'
const name = reader.name ?? reader.handle ?? reader.id

const handleGrant = async () => {
const result = await presentMembershipGrantModal(reader)
if (!result) return
mutations.grantMembership.mutate({ readerId: reader.id, ...result })
}

const handleRevoke = async () => {
const ok = await confirmDialog({
description: t('readers.membership.revoke.desc', { name }),
destructive: true,
title: t('readers.membership.revoke.title'),
})
if (ok) mutations.revokeMembership.mutate(reader.id)
}

return (
<section className="flex flex-col gap-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
{t('readers.membership.section.title')}
</h3>

<div className="rounded-md border border-neutral-200 p-3 dark:border-neutral-800">
<div className="flex items-center justify-between gap-2">
<StatusPill tone={MEMBERSHIP_STATUS_TONE[status]}>
{t(MEMBERSHIP_STATUS_LABEL_KEY[status])}
</StatusPill>
{membership ? (
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{t(MEMBERSHIP_PLAN_LABEL_KEY[membership.plan])} ·{' '}
{providerLabel(membership.provider)}
</span>
) : null}
</div>

{membership ? (
<div className="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
{t('readers.membership.periodEnd', {
date: parseDate(
membership.currentPeriodEnd,
'yyyy 年 M 月 d 日 HH:mm:ss',
),
})}
</div>
) : null}

<div className="mt-3 flex flex-wrap items-center gap-2">
{!liveProviderManaged ? (
<Button
className="h-8 px-3"
disabled={mutations.grantMembership.isPending}
onClick={handleGrant}
type="button"
variant="subtle"
>
{t(
membership
? 'readers.membership.action.extend'
: 'readers.membership.action.grant',
)}
</Button>
) : null}
{isManual ? (
<Button
className="h-8 border-red-200 px-3 text-red-600 hover:bg-red-50 disabled:hover:bg-transparent dark:border-red-950 dark:text-red-400 dark:hover:bg-red-950/30"
disabled={mutations.revokeMembership.isPending}
onClick={handleRevoke}
type="button"
variant="subtle"
>
{t('readers.membership.action.revoke')}
</Button>
) : null}
{liveProviderManaged ? (
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('readers.membership.providerManagedHint')}
</p>
) : null}
</div>
</div>
</section>
)
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { Loader2 } from 'lucide-react'
import type { ReaderModel } from '~/api/readers'
import type { useReaderMutations } from '../hooks/useReaderMutations'

import type { ReaderModel } from '~/api/readers'
import { Scroll } from '~/ui/primitives/scroll'

import type { useReaderMutations } from '../hooks/useReaderMutations'
import { ReaderActionsFooter } from './ReaderActionsFooter'
import { ReaderActivityBlock } from './ReaderActivityBlock'
import { ReaderDetailHeader } from './ReaderDetailHeader'
import { ReaderIdentityBlock } from './ReaderIdentityBlock'
import { ReaderMembershipBlock } from './ReaderMembershipBlock'
import { ReaderStatusBanner } from './ReaderStatusBanner'

export function ReadersDetailPane(props: {
Expand Down Expand Up @@ -39,6 +40,7 @@ export function ReadersDetailPane(props: {
unbanPending={mutations.unbanReader.isPending}
/>
<ReaderIdentityBlock reader={reader} />
<ReaderMembershipBlock mutations={mutations} reader={reader} />
<ReaderActivityBlock reader={reader} />
</Scroll>
</div>
Expand Down
13 changes: 12 additions & 1 deletion apps/admin/src/features/readers/components/ReadersListPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { ReaderModel, ReaderRoleFilter, ReaderStats } from '~/api/readers'
import type {
ReaderMembershipStatusFilter,
ReaderModel,
ReaderRoleFilter,
ReaderStats,
} from '~/api/readers'
import { APP_SHELL_HEADER_HEIGHT_CLASS } from '~/constants/layout'
import { useI18n } from '~/i18n'
import type { Pager } from '~/models/base'
Expand Down Expand Up @@ -29,6 +34,10 @@ interface ReadersListPanelProps {
onSearchChange: (value: string) => void
role: ReaderRoleFilter
onRoleChange: (role: ReaderRoleFilter) => void
membershipStatus: 'all' | ReaderMembershipStatusFilter
onMembershipStatusChange: (
value: 'all' | ReaderMembershipStatusFilter,
) => void
page: number
onPageChange: (page: number) => void
onRefresh: () => void
Expand Down Expand Up @@ -94,6 +103,8 @@ export function ReadersListPanel(props: ReadersListPanelProps) {

<ReadersToolbar
isFetching={props.isFetching}
membershipStatus={props.membershipStatus}
onMembershipStatusChange={props.onMembershipStatusChange}
onRefresh={props.onRefresh}
onSearchChange={props.onSearchChange}
search={props.search}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export function ReadersRouteViewContent() {
detailId={detailId}
isFetching={list.isFetching}
isLoading={list.isLoading}
membershipStatus={list.membershipStatus}
onMembershipStatusChange={list.setMembershipStatus}
onPageChange={list.setPage}
onRefresh={list.refetch}
onRoleChange={list.setRole}
Expand Down
18 changes: 18 additions & 0 deletions apps/admin/src/features/readers/components/ReadersToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import { RefreshCw, Search } from 'lucide-react'

import type { ReaderMembershipStatusFilter } from '~/api/readers'
import { useI18n } from '~/i18n'
import { Button } from '~/ui/primitives/button'
import { SelectField } from '~/ui/primitives/select'
import { TextInput } from '~/ui/primitives/text-field'
import { cn } from '~/utils/cn'

import { MEMBERSHIP_STATUS_FILTERS } from '../constants'

interface ReadersToolbarProps {
search: string
onSearchChange: (value: string) => void
membershipStatus: 'all' | ReaderMembershipStatusFilter
onMembershipStatusChange: (
value: 'all' | ReaderMembershipStatusFilter,
) => void
onRefresh: () => void
isFetching: boolean
}
Expand All @@ -29,6 +37,16 @@ export function ReadersToolbar(props: ReadersToolbarProps) {
value={props.search}
/>
</div>
<SelectField
aria-label={t('readers.membership.filter.label')}
onValueChange={props.onMembershipStatusChange}
options={MEMBERSHIP_STATUS_FILTERS.map((filter) => ({
label: t(filter.labelKey),
value: filter.value,
}))}
triggerClassName="w-36 shrink-0"
value={props.membershipStatus}
/>
<Button
aria-label={t('readers.refresh')}
disabled={props.isFetching}
Expand Down
Loading