From 3bf21540e358150e22f1caa70931587007a4df1a Mon Sep 17 00:00:00 2001 From: mrboxs Date: Wed, 4 Nov 2026 03:59:54 +0700 Subject: [PATCH 1/5] fix(react-doctor): stabilize formatters and memoize table columns - Avoid recreating formatters and state initializers on render - Keep submission actions current and admin dates consistent across timezones --- .../src/components/project-list-section.tsx | 6 +++--- .../components/featured-editor.tsx | 2 +- .../components/edit-project-form.tsx | 4 ++-- apps/web/src/features/blogs/date.ts | 10 +++++---- .../src/features/collections/components.tsx | 10 +++++---- .../project/components/github-statistics.tsx | 10 ++++----- apps/web/src/features/project/github-time.ts | 12 ++++++----- .../components/submission-data-table.tsx | 12 +++++++---- apps/web/src/routes/_main/admin.users/$id.tsx | 10 +++++++-- .../tests/member-admin-users-route.test.tsx | 5 +++++ apps/web/tests/submission-data-table.test.tsx | 21 +++++++++++++++++++ 11 files changed, 71 insertions(+), 31 deletions(-) diff --git a/apps/web/src/components/project-list-section.tsx b/apps/web/src/components/project-list-section.tsx index bb8ec823..72e149ad 100644 --- a/apps/web/src/components/project-list-section.tsx +++ b/apps/web/src/components/project-list-section.tsx @@ -112,15 +112,15 @@ function getPaginationItems(currentPage: number, totalPages: number) { ] } +const numberFormatter = new Intl.NumberFormat('en-US', { notation: 'standard' }) + export const ProjectCard: FC = (projectData) => { const Heading = projectData.headingLevel ?? 'h2' const githubStatsItem = [ { icon: IconStar, label: 'Stars', - value: new Intl.NumberFormat('en-US', { notation: 'standard' }).format( - projectData.github.stars - ), + value: numberFormatter.format(projectData.github.stars), }, { icon: IconGitCommit, diff --git a/apps/web/src/features/admin-featured/components/featured-editor.tsx b/apps/web/src/features/admin-featured/components/featured-editor.tsx index 3448e257..765b3921 100644 --- a/apps/web/src/features/admin-featured/components/featured-editor.tsx +++ b/apps/web/src/features/admin-featured/components/featured-editor.tsx @@ -89,7 +89,7 @@ function SelectionEditor({ initial }: { initial: Selection }) { const [changing, startTransition] = useTransition() const queryClient = useQueryClient() const [projects, setProjects] = useState(initial) - const [baseline, setBaseline] = useState(initial.map(({ id }) => id)) + const [baseline, setBaseline] = useState(() => initial.map(({ id }) => id)) const [error, setError] = useState<{ operation: 'save' | 'reload' message: string diff --git a/apps/web/src/features/admin-projects/components/edit-project-form.tsx b/apps/web/src/features/admin-projects/components/edit-project-form.tsx index b2be7ec8..951045ff 100644 --- a/apps/web/src/features/admin-projects/components/edit-project-form.tsx +++ b/apps/web/src/features/admin-projects/components/edit-project-form.tsx @@ -212,12 +212,12 @@ export function EditProjectForm({ project }: { project: AdminProject }) { // Form image fields hold tmp keys only (undefined = keep, // screenshot null = remove). The existing file keys are resolved to // display URLs here so the preview keeps showing them until replaced. - const [logoDisplayUrl, setLogoDisplayUrl] = useState( + const [logoDisplayUrl, setLogoDisplayUrl] = useState(() => resolveFileUrl(project.logo) ) const [screenshotDisplayUrl, setScreenshotDisplayUrl] = useState< string | null - >(resolveFileUrl(project.screenshot)) + >(() => resolveFileUrl(project.screenshot)) // Refetching saved GitHub stats must never replace in-progress form values. const [defaultValues] = useState(() => { diff --git a/apps/web/src/features/blogs/date.ts b/apps/web/src/features/blogs/date.ts index 09255b50..320bcb17 100644 --- a/apps/web/src/features/blogs/date.ts +++ b/apps/web/src/features/blogs/date.ts @@ -1,8 +1,10 @@ +const dateFormatter = new Intl.DateTimeFormat('en-US', { + dateStyle: 'long', + timeZone: 'UTC', +}) + export function blogDate(date: Date) { - return new Intl.DateTimeFormat('en-US', { - dateStyle: 'long', - timeZone: 'UTC', - }).format(date) + return dateFormatter.format(date) } export function blogTimestamp(publishedAt: Date, updatedAt: Date) { diff --git a/apps/web/src/features/collections/components.tsx b/apps/web/src/features/collections/components.tsx index 6f0ddef2..e4624ce9 100644 --- a/apps/web/src/features/collections/components.tsx +++ b/apps/web/src/features/collections/components.tsx @@ -37,6 +37,11 @@ type Collection = ORPCRouterOutputs['collection']['getBySlug'] type CollectionCardData = ORPCRouterOutputs['collection']['list']['collections'][number] +const dateFormatter = new Intl.DateTimeFormat('en', { + dateStyle: 'long', + timeZone: 'UTC', +}) + /** Links the directory and detail page to their public parent pages. */ export function CollectionBreadcrumb({ name }: { name?: string }) { return ( @@ -253,10 +258,7 @@ export function CollectionArticle({ collection }: { collection: Collection }) { Updated{' '} diff --git a/apps/web/src/features/project/components/github-statistics.tsx b/apps/web/src/features/project/components/github-statistics.tsx index dc1d69ea..60583c88 100644 --- a/apps/web/src/features/project/components/github-statistics.tsx +++ b/apps/web/src/features/project/components/github-statistics.tsx @@ -25,6 +25,8 @@ import { GithubStarsHistory } from '#/features/project/components/github-stars-h import { LicenseLabel } from '#/features/project/components/license-label' import { githubElapsed, githubFullDate } from '#/features/project/github-time' +const numberFormatter = new Intl.NumberFormat('en-US', { notation: 'standard' }) + export const GithubStatistics = ({ github, history, @@ -43,9 +45,7 @@ export const GithubStatistics = ({ { icon: IconGitFork, label: 'Forks', - value: new Intl.NumberFormat('en-US', { notation: 'standard' }).format( - github.forks - ), + value: numberFormatter.format(github.forks), }, { icon: IconGitCommit, @@ -85,9 +85,7 @@ export const GithubStatistics = ({ - {new Intl.NumberFormat('en-US', { notation: 'standard' }).format( - github.stars - )} + {numberFormatter.format(github.stars)} Stars diff --git a/apps/web/src/features/project/github-time.ts b/apps/web/src/features/project/github-time.ts index 106fff1a..6d9452ff 100644 --- a/apps/web/src/features/project/github-time.ts +++ b/apps/web/src/features/project/github-time.ts @@ -1,3 +1,9 @@ +const fullDateFormatter = new Intl.DateTimeFormat('en-US', { + dateStyle: 'full', + timeStyle: 'long', + timeZone: 'UTC', +}) + const UNITS = [ ['day', 24 * 60 * 60], ['hour', 60 * 60], @@ -7,11 +13,7 @@ const UNITS = [ // Explicit English and UTC keep SSR and hydration independent of host locale/TZ. export function githubFullDate(date: Date) { - return new Intl.DateTimeFormat('en-US', { - dateStyle: 'full', - timeStyle: 'long', - timeZone: 'UTC', - }).format(date) + return fullDateFormatter.format(date) } export function githubElapsed(date: Date, now: number, relative = false) { diff --git a/apps/web/src/features/submissions/components/submission-data-table.tsx b/apps/web/src/features/submissions/components/submission-data-table.tsx index 9ea88df5..f80dcc7f 100644 --- a/apps/web/src/features/submissions/components/submission-data-table.tsx +++ b/apps/web/src/features/submissions/components/submission-data-table.tsx @@ -8,7 +8,7 @@ import { import { Link } from '@tanstack/react-router' import { createColumnHelper, useTable } from '@tanstack/react-table' import { formatDate } from 'date-fns' -import { useState } from 'react' +import { useMemo, useState } from 'react' import type { ORPCRouterOutputs } from '@altstack/api/routers' @@ -157,9 +157,8 @@ export function SubmissionDataTable({ onResubmit?: (id: string) => void }) { const [rowSelection, setRowSelection] = useState({}) - const table = useTable({ - features, - columns: [ + const tableColumns = useMemo( + () => [ ...columns, column.display({ id: 'actions', @@ -179,6 +178,11 @@ export function SubmissionDataTable({ ) : null, }), ], + [onResubmit] + ) + const table = useTable({ + features, + columns: tableColumns, data: data.submissions, manualPagination: true, manualFiltering: true, diff --git a/apps/web/src/routes/_main/admin.users/$id.tsx b/apps/web/src/routes/_main/admin.users/$id.tsx index c921dd3b..76b5eee1 100644 --- a/apps/web/src/routes/_main/admin.users/$id.tsx +++ b/apps/web/src/routes/_main/admin.users/$id.tsx @@ -67,7 +67,11 @@ function UserPage() {
Active sessions
{activeSessions}
Joined
-
{new Date(user.createdAt).toLocaleDateString('en-GB')}
+
+ {new Date(user.createdAt).toLocaleDateString('en-GB', { + timeZone: 'UTC', + })} +
{user.banned && ( <>
Ban reason
@@ -77,7 +81,9 @@ function UserPage() {
Ban expires
{user.banExpires - ? new Date(user.banExpires).toLocaleString('en-GB') + ? new Date(user.banExpires).toLocaleString('en-GB', { + timeZone: 'UTC', + }) : 'Until an admin unbans this account'}
diff --git a/apps/web/tests/member-admin-users-route.test.tsx b/apps/web/tests/member-admin-users-route.test.tsx index 5ca1493e..92f13a13 100644 --- a/apps/web/tests/member-admin-users-route.test.tsx +++ b/apps/web/tests/member-admin-users-route.test.tsx @@ -256,6 +256,11 @@ async function routerAt(path: string) { return router } describe('admin users and project membership journeys', () => { + it('shows the UTC join date even when the host timezone is behind UTC', async () => { + await routerAt(`/admin/users/${targetId}`) + expect(await screen.findByText('01/10/2026')).toBeTruthy() + }) + it.each(['/admin/users', `/admin/users/${targetId}`])( 'guards %s before reading private users', async (path) => { diff --git a/apps/web/tests/submission-data-table.test.tsx b/apps/web/tests/submission-data-table.test.tsx index b4505bbf..276995c9 100644 --- a/apps/web/tests/submission-data-table.test.tsx +++ b/apps/web/tests/submission-data-table.test.tsx @@ -149,6 +149,27 @@ describe('owner submission table', () => { ).toBeNull() } }) + it('uses the latest resubmit callback after a parent update', () => { + const props = callbacks() + const submissions = data([row('Rejected', 'rejected')]) + const view = render( + + ) + const onResubmit = vi.fn() + view.rerender( + + ) + fireEvent.click( + screen.getByRole('button', { name: 'Edit & Resubmit Rejected' }) + ) + expect(onResubmit).toHaveBeenCalledExactlyOnceWith('Rejected') + expect(props.onResubmit).not.toHaveBeenCalled() + }) it('sends search and page requests to server-controlled navigation', () => { const props = callbacks() render( From 58daa4fe9422e0889ec57fe082ace9d366c13d71 Mon Sep 17 00:00:00 2001 From: mrboxs Date: Wed, 4 Nov 2026 04:33:00 +0700 Subject: [PATCH 2/5] fix(react-doctor): resolve web diagnostics - Extract shared project form previews and stabilize collection keys - Optimize repeated membership checks and simplify derived UI logic - Update auth response header handling and add hydration coverage --- .react-doctor/false-positives.md | 66 ++++++ .../src/components/block-note/code-block.tsx | 1 + apps/web/src/components/block-note/schema.ts | 3 +- .../src/components/category-combobox-view.tsx | 3 +- .../src/components/project-list-section.tsx | 205 +++++++++++------- .../components/create-project-form.tsx | 114 +--------- .../components/edit-project-form.tsx | 111 +--------- .../components/project-form-preview.tsx | 123 +++++++++++ apps/web/src/features/ads/admin-editor.tsx | 5 +- apps/web/src/features/ads/components.tsx | 5 +- apps/web/src/features/ads/selection.ts | 5 +- apps/web/src/features/ads/use-hidden-ads.ts | 10 +- .../components/claim-github-evidence.tsx | 34 +-- .../components/github-stars-history.tsx | 32 ++- .../project/components/github-statistics.tsx | 7 +- .../components/project-filter-panel.tsx | 3 +- .../public-content/server-function.ts | 4 +- apps/web/src/middlewares/auth-middleware.ts | 4 +- apps/web/tests/auth-middleware.test.ts | 5 +- .../tests/browser/skeleton-layout.browser.tsx | 27 ++- .../tests/catalogue-offset-hydration.test.tsx | 41 ++++ 21 files changed, 472 insertions(+), 336 deletions(-) create mode 100644 .react-doctor/false-positives.md create mode 100644 apps/web/src/features/admin-projects/components/project-form-preview.tsx create mode 100644 apps/web/tests/catalogue-offset-hydration.test.tsx diff --git a/.react-doctor/false-positives.md b/.react-doctor/false-positives.md new file mode 100644 index 00000000..ee32cf7f --- /dev/null +++ b/.react-doctor/false-positives.md @@ -0,0 +1,66 @@ +# React Doctor review predicates + +These are source-backed review notes, not scanner suppressions. Recheck every +predicate when the referenced code changes. No rule is disabled by this file. + +## Hydration of catalogue offsets + +`react-doctor/no-hydration-branch-on-browser-global` at `ProjectListSection` +and the collections directory is a false positive only while both use +`useCatalogueOffset`. That hook must keep its `useHydrated()` gate: the initial +server and hydration snapshot use the fixed display-slot offset, and browser +storage is read after hydration. `apps/web/tests/catalogue-offset-hydration.test.tsx` +checks both catalogue identities with a conflicting stored offset and asserts +that hydration reports no recoverable error. Do not exempt direct render-time +storage access elsewhere. + +## Native select names supplied by FieldLabel + +`react-doctor/control-has-associated-label` on `ad-tier`, `ad-status` and +`ad-order-status` is a false positive while each select's ID matches its adjacent +`FieldLabel.htmlFor`. The shared FieldLabel renders a native label. The advertising +admin and order tests exercise these controls using their accessible labels. + +## Member project form drafts + +`react-doctor/no-derived-useState` in `MemberProjectEditor` is intentional only +while its caller in `src/routes/_main/managed/$id.tsx` keys the editor by both +project ID and revision. The editor owns unsaved input; a new project/revision +remounts it. A continuously mounted editor receiving a different project would +need a new review. + +## Mutation cache ownership + +The member editor's `react-doctor/query-mutation-missing-invalidation` finding +does not account for its `save` handler, which awaits `mutateAsync`, catalogue +invalidation, ownership-key invalidation and router invalidation. Those steps +must remain on the successful save path. + +The project report mutation returns a receipt and closes the reporting viewer's +dialog; that viewer has no report-inbox query. Do not add admin-only cache reads +or invalidation solely to silence the detector. Review again if the reporting +viewer starts displaying a cached report list. + +## Authentication return destination + +`react-doctor/clickjacking-redirect-risk` in `src/routes/auth/route.tsx` is a false +positive only while the destination passes through `resolveReturnTo` immediately +before redirecting. That resolver rejects external origins, protocol-relative +paths, backslashes, control characters, malformed encodings and auth loops. The +malicious-destination cases in `apps/web/tests/sign-in-form.test.tsx` protect this +contract. This does not exempt unrelated redirects or framing configuration. + +## JSON serialization contract test + +The `JSON.parse(JSON.stringify(result))` assertion in +`apps/web/tests/public-content-prepare.test.ts` verifies that the public content +payload survives JSON serialization unchanged. It is not an application deep +clone. Replacing it with structuredClone would remove the serialization check. + +## Static text line keys + +Index keys on fitted OG text lines are used by a one-shot static image renderer, +with no browser reconciliation or row-local state. Commit-message lines likewise +belong to an immutable commit keyed by commit identity. These predicates do not +exempt editable, reorderable, filterable or stateful lists. The interactive +catalogue pagination and GitHub statistics now use semantic keys. diff --git a/apps/web/src/components/block-note/code-block.tsx b/apps/web/src/components/block-note/code-block.tsx index 4085662e..b277d3a2 100644 --- a/apps/web/src/components/block-note/code-block.tsx +++ b/apps/web/src/components/block-note/code-block.tsx @@ -297,6 +297,7 @@ export const BNCustomCodeBlock = createReactBlockSpec( return { ...baseSpec.implementation, + renderFrame: undefined, render: CustomCodeBlock, toExternalHTML: CodeBlockExternalHTML, } diff --git a/apps/web/src/components/block-note/schema.ts b/apps/web/src/components/block-note/schema.ts index 9938f647..ab0880af 100644 --- a/apps/web/src/components/block-note/schema.ts +++ b/apps/web/src/components/block-note/schema.ts @@ -1,5 +1,6 @@ -import { BlockNoteSchema, SyntaxHighlightingExtension } from '@blocknote/core' +import { BlockNoteSchema } from '@blocknote/core' import type { BlockNoteEditor, InlineContent } from '@blocknote/core' +import { SyntaxHighlightingExtension } from '@blocknote/core/extensions' import { BNCustomCodeBlock, diff --git a/apps/web/src/components/category-combobox-view.tsx b/apps/web/src/components/category-combobox-view.tsx index f0724b5b..84994253 100644 --- a/apps/web/src/components/category-combobox-view.tsx +++ b/apps/web/src/components/category-combobox-view.tsx @@ -45,7 +45,8 @@ export const CategoryComboboxView = ({ const items = categories .filter((category) => category.isLeaf) .map((category) => category.slug) - const stale = value.filter((slug) => !items.includes(slug)) + const itemSlugs = new Set(items) + const stale = value.filter((slug) => !itemSlugs.has(slug)) return (
diff --git a/apps/web/src/components/project-list-section.tsx b/apps/web/src/components/project-list-section.tsx index 72e149ad..c23b3e76 100644 --- a/apps/web/src/components/project-list-section.tsx +++ b/apps/web/src/components/project-list-section.tsx @@ -87,13 +87,13 @@ function getPaginationItems(currentPage: number, totalPages: number) { } if (currentPage <= 3) { - return [1, 2, 3, 4, 'ellipsis' as const, totalPages] + return [1, 2, 3, 4, 'ellipsis-end' as const, totalPages] } if (currentPage >= totalPages - 2) { return [ 1, - 'ellipsis' as const, + 'ellipsis-start' as const, totalPages - 3, totalPages - 2, totalPages - 1, @@ -103,11 +103,11 @@ function getPaginationItems(currentPage: number, totalPages: number) { return [ 1, - 'ellipsis' as const, + 'ellipsis-start' as const, currentPage - 1, currentPage, currentPage + 1, - 'ellipsis' as const, + 'ellipsis-end' as const, totalPages, ] } @@ -299,7 +299,6 @@ export const ProjectListSection = ({ data.projects.length === 0 && currentPage > 1 && offset >= pagination.totalItems - const paginationItems = getPaginationItems(currentPage, totalPages) const handlePageClick = createPaginationScrollHandler(scrollTargetId, router) if (loaded.query && !query.data && loaded.query.offset !== expectedOffset) { if (query.isError) { @@ -321,28 +320,13 @@ export const ProjectListSection = ({ className="container mx-auto mb-10 w-full max-w-6xl space-y-4 px-4 lg:px-16" > {data.projects.length === 0 ? ( -
-

- {outOfRange ? 'No projects on this page' : 'No projects found'} -

-

- {outOfRange - ? 'This page is beyond the available results. Return to the first page.' - : emptyMessage} -

- {outOfRange ? ( - - First page - - ) : ( - emptyAction - )} -
+ ) : ( )} {totalPages > 1 && !outOfRange && ( - - - - {pagination.hasPreviousPage ? ( - - ) : ( - - )} - - {paginationItems.map((item, idx) => ( - - {item === 'ellipsis' ? ( - - ) : ( - - {item} - - )} - - ))} - - {currentPage < totalPages ? ( - - ) : ( - - )} - - - + )} ) } + +type CatalogueNavigation = { + pageLinkOptions: (page: number) => ProjectPageLinkOptions + onPageClick: ReturnType +} + +function ProjectPagination({ + currentPage, + totalPages, + hasPreviousPage, + pageLinkOptions, + onPageClick, +}: CatalogueNavigation & { + currentPage: number + totalPages: number + hasPreviousPage: boolean +}) { + const paginationItems = getPaginationItems(currentPage, totalPages) + return ( + + + + {hasPreviousPage ? ( + + ) : ( + + )} + + {paginationItems.map((item) => ( + + {typeof item === 'string' ? ( + + ) : ( + + {item} + + )} + + ))} + + {currentPage < totalPages ? ( + + ) : ( + + )} + + + + ) +} + +function ProjectResultsEmpty({ + outOfRange, + emptyMessage, + emptyAction, + pageLinkOptions, + onPageClick, +}: CatalogueNavigation & { + outOfRange: boolean + emptyMessage: string + emptyAction?: ReactNode +}) { + return ( +
+

+ {outOfRange ? 'No projects on this page' : 'No projects found'} +

+

+ {outOfRange + ? 'This page is beyond the available results. Return to the first page.' + : emptyMessage} +

+ {outOfRange ? ( + + First page + + ) : ( + emptyAction + )} +
+ ) +} diff --git a/apps/web/src/features/admin-projects/components/create-project-form.tsx b/apps/web/src/features/admin-projects/components/create-project-form.tsx index d3fbbff5..a44fee5f 100644 --- a/apps/web/src/features/admin-projects/components/create-project-form.tsx +++ b/apps/web/src/features/admin-projects/components/create-project-form.tsx @@ -1,4 +1,4 @@ -import { IconArrowLeft, IconBrandGithub, IconPhoto } from '@tabler/icons-react' +import { IconArrowLeft } from '@tabler/icons-react' import { useForm } from '@tanstack/react-form-start' import { ClientOnly, Link } from '@tanstack/react-router' import { Suspense, useRef, useState } from 'react' @@ -8,7 +8,6 @@ import { slugify } from '@altstack/shared/lib/slug' import { adminCreateProjectBodySchema } from '@altstack/shared/schemas/admin-project' import { Button } from '@altstack/ui/components/button' -import { Card, CardContent } from '@altstack/ui/components/card' import { Field, FieldDescription, @@ -35,10 +34,13 @@ import BlockNoteEditor from '#/components/block-note/editor' import type { BlockNoteEditorHandle } from '#/components/block-note/editor' import { CategoryCombobox } from '#/components/category-combobox' import { LogoUploader, ScreenshotUploader } from '#/components/image-uploader' -import { ProjectCategoryBadges } from '#/components/project-category-badges' import { CategoryComboboxSkeleton } from '#/components/workspace-skeletons' import { GithubMetadataPrefill } from '#/features/admin-projects/components/github-metadata-prefill' import { GithubReadmeImport } from '#/features/admin-projects/components/github-readme-import' +import { + ProjectPreviewCard, + SectionHeading, +} from '#/features/admin-projects/components/project-form-preview' import { useAdminProjectCreate } from '#/features/admin-projects/queries' // Mirrors the max() in adminCreateProjectBodySchema; display-only counters. @@ -62,8 +64,6 @@ const defaultValues: z.input = { status: 'published', } -type CreateProjectValues = z.input - function isUploadExpiredError(error: unknown): boolean { return ( typeof error === 'object' && @@ -91,110 +91,6 @@ function isUploadConsumedError(error: unknown): boolean { ) } -function SectionHeading({ - title, - description, -}: { - title: string - description?: string -}) { - return ( -
-

{title}

- {description ? ( -

{description}

- ) : null} -
- ) -} - -function ProjectPreviewCard({ - values, - logoUrl, - screenshotUrl, -}: { - values: CreateProjectValues - logoUrl: string | null - screenshotUrl: string | null -}) { - const name = values.name.trim() || 'Untitled project' - const slug = values.slug.trim() || 'your-slug' - const tagline = (values.tagline ?? '').trim() - const description = (values.description ?? '').trim() - const repository = values.repositoryUrl.trim() - - return ( - - -
- {logoUrl ? ( - - ) : ( - - )} - -
-

{name}

-

- /{slug} -

-
-
- - {screenshotUrl ? ( - - ) : ( -
- -

16:9 screenshot preview

-
- )} - -
-

- {tagline || 'Tagline will appear here.'} -

-

- {description || 'Short description will appear here.'} -

-
- -
- -
- -
- - - {repository - ? `github.com/${repository}` - : 'github.com/owner/repository'} - -
-
-
- ) -} - export function CreateProjectForm() { const createProject = useAdminProjectCreate() const [submitError, setSubmitError] = useState(null) diff --git a/apps/web/src/features/admin-projects/components/edit-project-form.tsx b/apps/web/src/features/admin-projects/components/edit-project-form.tsx index 951045ff..c606fa3d 100644 --- a/apps/web/src/features/admin-projects/components/edit-project-form.tsx +++ b/apps/web/src/features/admin-projects/components/edit-project-form.tsx @@ -1,4 +1,3 @@ -import { IconBrandGithub, IconPhoto } from '@tabler/icons-react' import { useForm } from '@tanstack/react-form-start' import { ClientOnly } from '@tanstack/react-router' import { Suspense, useEffect, useRef, useState } from 'react' @@ -13,7 +12,6 @@ import { } from '@altstack/shared/schemas/admin-project' import { Button } from '@altstack/ui/components/button' -import { Card, CardContent } from '@altstack/ui/components/card' import { Field, FieldDescription, @@ -34,11 +32,14 @@ import type { BlockNoteEditorHandle } from '#/components/block-note/editor' import BlockNoteEditor from '#/components/block-note/editor' import { CategoryCombobox } from '#/components/category-combobox' import { LogoUploader, ScreenshotUploader } from '#/components/image-uploader' -import { ProjectCategoryBadges } from '#/components/project-category-badges' import { CategoryComboboxSkeleton } from '#/components/workspace-skeletons' import { GithubMetadataPrefill } from '#/features/admin-projects/components/github-metadata-prefill' import { GithubReadmeImport } from '#/features/admin-projects/components/github-readme-import' import { GithubStatisticsRefresh } from '#/features/admin-projects/components/github-statistics-refresh' +import { + ProjectPreviewCard, + SectionHeading, +} from '#/features/admin-projects/components/project-form-preview' import { ProjectReviewActions } from '#/features/admin-projects/components/project-review-actions' import { useAdminProjectUpdate } from '#/features/admin-projects/queries' import { resolveFileUrl } from '#/utils/storage' @@ -102,110 +103,6 @@ function toRepositoryShortForm(canonicalUrl: string): string { return canonicalUrl.replace(/^https?:\/\/(www\.)?github\.com\//i, '') } -function SectionHeading({ - title, - description, -}: { - title: string - description?: string -}) { - return ( -
-

{title}

- {description ? ( -

{description}

- ) : null} -
- ) -} - -function ProjectPreviewCard({ - values, - logoUrl, - screenshotUrl, -}: { - values: EditProjectValues - logoUrl: string | null - screenshotUrl: string | null -}) { - const name = (values.name ?? '').trim() || 'Untitled project' - const slug = (values.slug ?? '').trim() || 'your-slug' - const tagline = (values.tagline ?? '').trim() - const description = (values.description ?? '').trim() - const repository = (values.repositoryUrl ?? '').trim() - - return ( - - -
- {logoUrl ? ( - - ) : ( - - )} - -
-

{name}

-

- /{slug} -

-
-
- - {screenshotUrl ? ( - - ) : ( -
- -

16:9 screenshot preview

-
- )} - -
-

- {tagline || 'Tagline will appear here.'} -

-

- {description || 'Short description will appear here.'} -

-
- -
- -
- -
- - - {repository - ? `github.com/${repository}` - : 'github.com/owner/repository'} - -
-
-
- ) -} - export function EditProjectForm({ project }: { project: AdminProject }) { const updateProject = useAdminProjectUpdate() const [submitError, setSubmitError] = useState(null) diff --git a/apps/web/src/features/admin-projects/components/project-form-preview.tsx b/apps/web/src/features/admin-projects/components/project-form-preview.tsx new file mode 100644 index 00000000..742fbc62 --- /dev/null +++ b/apps/web/src/features/admin-projects/components/project-form-preview.tsx @@ -0,0 +1,123 @@ +import { IconBrandGithub, IconPhoto } from '@tabler/icons-react' + +import { Card, CardContent } from '@altstack/ui/components/card' + +import { ProjectCategoryBadges } from '#/components/project-category-badges' + +type ProjectPreviewValues = { + name?: string + slug?: string + tagline?: string | null + description?: string | null + repositoryUrl?: string + categorySlugs?: Array +} + +function previewText(values: ProjectPreviewValues) { + const name = (values.name ?? '').trim() || 'Untitled project' + const slug = (values.slug ?? '').trim() || 'your-slug' + const tagline = (values.tagline ?? '').trim() + const description = (values.description ?? '').trim() + const repository = (values.repositoryUrl ?? '').trim() + return { name, slug, tagline, description, repository } +} + +export function SectionHeading({ + title, + description, +}: { + title: string + description?: string +}) { + return ( +
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ ) +} + +export function ProjectPreviewCard({ + values, + logoUrl, + screenshotUrl, +}: { + values: ProjectPreviewValues + logoUrl: string | null + screenshotUrl: string | null +}) { + const { name, slug, tagline, description, repository } = previewText(values) + + return ( + + +
+ {logoUrl ? ( + + ) : ( + + )} + +
+

{name}

+

+ /{slug} +

+
+
+ + {screenshotUrl ? ( + + ) : ( +
+ +

16:9 screenshot preview

+
+ )} + +
+

+ {tagline || 'Tagline will appear here.'} +

+

+ {description || 'Short description will appear here.'} +

+
+ +
+ +
+ +
+ + + {repository + ? `github.com/${repository}` + : 'github.com/owner/repository'} + +
+
+
+ ) +} diff --git a/apps/web/src/features/ads/admin-editor.tsx b/apps/web/src/features/ads/admin-editor.tsx index 2f52779b..e000ea85 100644 --- a/apps/web/src/features/ads/admin-editor.tsx +++ b/apps/web/src/features/ads/admin-editor.tsx @@ -56,6 +56,7 @@ export function AdsAdmin({ managedCampaignIds?: Array onManageOrder?: (campaignId: string) => void } = {}) { + const managedIds = new Set(managedCampaignIds) const query = useQuery(orpc.admin.ads.list.queryOptions({ input: {} })) const [editing, setEditing] = useState() const [deleting, setDeleting] = useState() @@ -182,7 +183,7 @@ export function AdsAdmin({

- {managedCampaignIds.includes(ad.id) ? ( + {managedIds.has(ad.id) ? ( )} - {!managedCampaignIds.includes(ad.id) && ( + {!managedIds.has(ad.id) && ( - - Cancel - - - {dirty - ? 'You have unsaved changes' - : initial - ? 'No unsaved changes' - : 'Your collection starts as a draft'} - -
- {initial && ( -
-

- Written by {initial.authorName}. Authorship stays with the - original writer. -

- -
- )} - - -

- Projects are curated manually. Unavailable members are hidden from - readers and must be removed before saving. -

- + ) } + +function CollectionPublication({ + value, + setValue, + busy, + reloading, + readiness, + ready, + dirty, + initial, + reload, +}: { + value: CollectionBody + setValue: React.Dispatch> + busy: boolean + reloading: boolean + readiness: Array<{ label: string; done: boolean }> + ready: boolean + dirty: boolean + initial?: Initial + reload: () => Promise +}) { + return ( + + ) +} + +function getCollectionReadiness( + value: CollectionBody, + target: SelectedProject | null, + members: Array +) { + const readiness = [ + { + label: 'Title, URL, and description added', + done: + value.name.trim().length > 0 && + collectionBodySchema.shape.slug.safeParse(value.slug).success && + value.description.trim().length > 0, + }, + { + label: + value.kind === 'repository' + ? 'Valid source repository linked' + : 'Published main project selected', + done: + value.kind === 'repository' + ? sourceRepositorySchema.safeParse(value.sourceRepositoryUrl).success + : target?.status === 'published', + }, + { + label: 'At least one published member', + done: + members.length > 0 && members.every((p) => p.status === 'published'), + }, + ] + + return readiness +} + +function CollectionSourceField({ + value, + setValue, + fieldErrors, + fieldProps, + fieldError, +}: { + value: CollectionBody + setValue: React.Dispatch> + fieldErrors: Record + fieldProps: (name: string) => { + 'aria-invalid': boolean + 'aria-describedby': string + } + fieldError: (name: string) => ReactNode +}) { + return ( + <> + {value.kind === 'repository' ? ( + + + Source repository URL + + + setValue({ + ...value, + sourceRepositoryUrl: e.target.value, + }) + } + /> + + Link the original GitHub list. You’ll choose its members below; + projects are not imported automatically. + + {fieldError('sourceRepositoryUrl')} + + ) : ( + + Choose a main project below + + {value.kind === 'alternatives' + ? 'Select the project readers are looking for alternatives to.' + : 'Select the project at the center of this ecosystem.'}{' '} + If the main project becomes unavailable, this collection will be + hidden publicly. + + + )} + + ) +} diff --git a/apps/web/src/features/admin-collections/project-selector.tsx b/apps/web/src/features/admin-collections/project-selector.tsx index a1fe4a44..3778e1fa 100644 --- a/apps/web/src/features/admin-collections/project-selector.tsx +++ b/apps/web/src/features/admin-collections/project-selector.tsx @@ -417,54 +417,84 @@ function ProjectSearchResults({ > Submit a missing project - + ) } + +function ProjectSearchPagination({ + busy, + changing, + candidates, + page, + startTransition, + setPage, +}: { + busy: boolean + changing: boolean + candidates: { + isFetching: boolean + data: { pagination: { hasPreviousPage: boolean; hasNextPage: boolean } } + } + page: number + startTransition: React.TransitionStartFunction + setPage: (page: number) => void +}) { + return ( + + ) +} diff --git a/apps/web/src/features/admin-projects/components/github-metadata-prefill.tsx b/apps/web/src/features/admin-projects/components/github-metadata-prefill.tsx index a90a165d..66a74fc4 100644 --- a/apps/web/src/features/admin-projects/components/github-metadata-prefill.tsx +++ b/apps/web/src/features/admin-projects/components/github-metadata-prefill.tsx @@ -52,120 +52,24 @@ export function GithubMetadataPrefill({ getCurrentValues: () => CurrentValues onApply: (values: MetadataFields) => void }) { - const { mutateAsync } = useAdminProjectGithubMetadata() - const id = useId() - const requestId = useRef(0) - const activeRepository = useRef(repositoryUrl) - const [pending, setPending] = useState(false) - const [error, setError] = useState(null) - const [feedback, setFeedback] = useState(null) - const [preview, setPreview] = useState(null) - - useEffect(() => { - if (activeRepository.current === repositoryUrl) return - activeRepository.current = repositoryUrl - if (requestId.current === 0) return - requestId.current += 1 - // Synchronize an external form field with the request/dialog lifecycle. - // oxlint-disable-next-line react-hooks-js/set-state-in-effect - setPending(false) - setError(null) - setPreview(null) - setFeedback(REPOSITORY_CHANGED) - }, [repositoryUrl]) - useEffect( - () => () => { - if (requestId.current > 0) requestId.current += 1 - }, - [] - ) - - async function fetchMetadata() { - if ( - disabled || - pending || - !repositoryUrlSchema.safeParse(repositoryUrl).success - ) { - return - } - const requestedRepositoryUrl = repositoryUrl - const currentRequestId = ++requestId.current - setPending(true) - setError(null) - setFeedback(null) - setPreview(null) - try { - const metadata = await mutateAsync({ - repositoryUrl: requestedRepositoryUrl, - }) - if (currentRequestId !== requestId.current) return - const current = getCurrentValues() - if (current.repositoryUrl !== requestedRepositoryUrl) { - setFeedback(REPOSITORY_CHANGED) - return - } - setPreview({ - repositoryUrl: metadata.repositoryUrl, - requestedRepositoryUrl, - description: metadata.description ?? '', - websiteUrl: metadata.websiteUrl ?? '', - hasDescription: metadata.description !== null, - hasWebsite: metadata.websiteUrl !== null, - selectDescription: - metadata.description !== null && isBlank(current.description), - selectWebsite: - metadata.websiteUrl !== null && isBlank(current.websiteUrl), - }) - } catch (cause) { - if (currentRequestId !== requestId.current) return - if (getCurrentValues().repositoryUrl !== requestedRepositoryUrl) { - setFeedback(REPOSITORY_CHANGED) - return - } - setError( - cause instanceof Error - ? cause.message - : 'Unable to fetch GitHub metadata. Please try again.' - ) - } finally { - if (currentRequestId === requestId.current) setPending(false) - } - } - - const description = adminCreateProjectBodySchema.shape.description.safeParse( - preview?.description - ) - const website = adminCreateProjectBodySchema.shape.websiteUrl.safeParse( - preview?.websiteUrl.trim() - ) - const canApply = Boolean( - preview && - !disabled && - (preview.selectDescription || preview.selectWebsite) && - (!preview.selectDescription || description.success) && - (!preview.selectWebsite || website.success) - ) - - function apply() { - if (!preview || !canApply) return - if (getCurrentValues().repositoryUrl !== preview.requestedRepositoryUrl) { - setPreview(null) - setFeedback(REPOSITORY_CHANGED) - return - } - const values: MetadataFields = {} - if (preview.selectDescription && description.success) { - values.description = description.data ?? null - } - if (preview.selectWebsite && website.success) { - values.websiteUrl = website.data - } - onApply(values) - setPreview(null) - setFeedback( - 'Selected metadata applied to the form. Save the project to keep these changes.' - ) - } + const { + id, + pending, + error, + feedback, + preview, + setPreview, + fetchMetadata, + description, + website, + canApply, + apply, + } = useGithubMetadataPrefill({ + repositoryUrl, + disabled, + getCurrentValues, + onApply, + }) return (
@@ -338,3 +242,139 @@ export function GithubMetadataPrefill({
) } + +function useGithubMetadataPrefill({ + repositoryUrl, + disabled, + getCurrentValues, + onApply, +}: Parameters[0]) { + const { mutateAsync } = useAdminProjectGithubMetadata() + const id = useId() + const requestId = useRef(0) + const activeRepository = useRef(repositoryUrl) + const [pending, setPending] = useState(false) + const [error, setError] = useState(null) + const [feedback, setFeedback] = useState(null) + const [preview, setPreview] = useState(null) + + useEffect(() => { + if (activeRepository.current === repositoryUrl) return + activeRepository.current = repositoryUrl + if (requestId.current === 0) return + requestId.current += 1 + // Synchronize an external form field with the request/dialog lifecycle. + // oxlint-disable-next-line react-hooks-js/set-state-in-effect + setPending(false) + setError(null) + setPreview(null) + setFeedback(REPOSITORY_CHANGED) + }, [repositoryUrl]) + useEffect( + () => () => { + if (requestId.current > 0) requestId.current += 1 + }, + [] + ) + + async function fetchMetadata() { + if ( + disabled || + pending || + !repositoryUrlSchema.safeParse(repositoryUrl).success + ) { + return + } + const requestedRepositoryUrl = repositoryUrl + const currentRequestId = ++requestId.current + setPending(true) + setError(null) + setFeedback(null) + setPreview(null) + try { + const metadata = await mutateAsync({ + repositoryUrl: requestedRepositoryUrl, + }) + if (currentRequestId !== requestId.current) return + const current = getCurrentValues() + if (current.repositoryUrl !== requestedRepositoryUrl) { + setFeedback(REPOSITORY_CHANGED) + return + } + setPreview({ + repositoryUrl: metadata.repositoryUrl, + requestedRepositoryUrl, + description: metadata.description ?? '', + websiteUrl: metadata.websiteUrl ?? '', + hasDescription: metadata.description !== null, + hasWebsite: metadata.websiteUrl !== null, + selectDescription: + metadata.description !== null && isBlank(current.description), + selectWebsite: + metadata.websiteUrl !== null && isBlank(current.websiteUrl), + }) + } catch (cause) { + if (currentRequestId !== requestId.current) return + if (getCurrentValues().repositoryUrl !== requestedRepositoryUrl) { + setFeedback(REPOSITORY_CHANGED) + return + } + setError( + cause instanceof Error + ? cause.message + : 'Unable to fetch GitHub metadata. Please try again.' + ) + } finally { + if (currentRequestId === requestId.current) setPending(false) + } + } + + const description = adminCreateProjectBodySchema.shape.description.safeParse( + preview?.description + ) + const website = adminCreateProjectBodySchema.shape.websiteUrl.safeParse( + preview?.websiteUrl.trim() + ) + const canApply = Boolean( + preview && + !disabled && + (preview.selectDescription || preview.selectWebsite) && + (!preview.selectDescription || description.success) && + (!preview.selectWebsite || website.success) + ) + + function apply() { + if (!preview || !canApply) return + if (getCurrentValues().repositoryUrl !== preview.requestedRepositoryUrl) { + setPreview(null) + setFeedback(REPOSITORY_CHANGED) + return + } + const values: MetadataFields = {} + if (preview.selectDescription && description.success) { + values.description = description.data ?? null + } + if (preview.selectWebsite && website.success) { + values.websiteUrl = website.data + } + onApply(values) + setPreview(null) + setFeedback( + 'Selected metadata applied to the form. Save the project to keep these changes.' + ) + } + + return { + id, + pending, + error, + feedback, + preview, + setPreview, + fetchMetadata, + description, + website, + canApply, + apply, + } +} diff --git a/apps/web/src/features/admin-projects/components/github-readme-import.tsx b/apps/web/src/features/admin-projects/components/github-readme-import.tsx index d413c25a..b52947a0 100644 --- a/apps/web/src/features/admin-projects/components/github-readme-import.tsx +++ b/apps/web/src/features/admin-projects/components/github-readme-import.tsx @@ -202,118 +202,161 @@ export function GithubReadmeImport({ {feedback} )} - { - if (!open && !applying) { - setPreview(null) - setError(null) - setFeedback(null) - } - }} - > - - - Import GitHub README - - GitHub-specific formatting can change during BlockNote conversion. - Review the exact result below before applying. Save the project - afterward to persist it. - - - {preview && ( - <> -
- {preview.readme.repositoryUrl} · {preview.readme.path} ·{' '} - {preview.readme.commitSha.slice(0, 7)} - - {preview.readme.warnings.length > 0 && ( -
-

Conversion warnings

-
    - {preview.readme.warnings.map((warning) => ( -
  • {warning}
  • - ))} -
-
- )} -
- - How should this README be applied? - - {(['replace', 'append'] as const).map((mode) => ( - - ))} -
- {!preview.mode && ( -

- Content already exists. Choose Replace or Append explicitly. - The README conversion is shown until you choose. -

- )} - {feedback && ( - - {feedback} - - )} - {error && ( -

- {error} -

- )} -
- -
- - )} - - - - - - - + + + + )} + + + + + + ) } diff --git a/apps/web/src/features/admin-submissions/components/submission-inbox.tsx b/apps/web/src/features/admin-submissions/components/submission-inbox.tsx index fb5ac8ea..bd6ad912 100644 --- a/apps/web/src/features/admin-submissions/components/submission-inbox.tsx +++ b/apps/web/src/features/admin-submissions/components/submission-inbox.tsx @@ -326,71 +326,109 @@ function SubmissionInboxContent({ ))} ) : ( - - - - - - - {search.name - ? 'No matching submissions' - : page > 1 - ? 'No submissions on this page' - : 'No submissions awaiting review'} - - - {search.name - ? 'Try a different project name or clear your search.' - : page > 1 - ? 'Go to the previous page to continue reviewing.' - : 'New community submissions will appear here when they are ready for review.'} - - - {search.name && ( - - )} - + )} - -

- Page {page} - {` · ${query.data.pagination.totalItems} ${search.name ? 'matching ' : ''}${query.data.pagination.totalItems === 1 ? 'submission' : 'submissions'}`} -

- -
+ ) } + +function SubmissionsEmpty({ + search, + page, + updateSearch, +}: { + search: AdminProjectSearch + page: number + updateSearch: (search: AdminProjectSearch) => void +}) { + return ( + + + + + + + {search.name + ? 'No matching submissions' + : page > 1 + ? 'No submissions on this page' + : 'No submissions awaiting review'} + + + {search.name + ? 'Try a different project name or clear your search.' + : page > 1 + ? 'Go to the previous page to continue reviewing.' + : 'New community submissions will appear here when they are ready for review.'} + + + {search.name && ( + + )} + + ) +} + +function SubmissionsPagination({ + page, + query, + search, + updateSearch, +}: { + page: number + query: { + isFetching: boolean + isError: boolean + data: { pagination: { totalItems: number; hasNextPage: boolean } } + } + search: AdminProjectSearch + updateSearch: (search: AdminProjectSearch) => void +}) { + return ( + +

+ Page {page} + {` · ${query.data.pagination.totalItems} ${search.name ? 'matching ' : ''}${query.data.pagination.totalItems === 1 ? 'submission' : 'submissions'}`} +

+ +
+ ) +} diff --git a/apps/web/src/features/ads/admin-editor.tsx b/apps/web/src/features/ads/admin-editor.tsx index e000ea85..08a47a84 100644 --- a/apps/web/src/features/ads/admin-editor.tsx +++ b/apps/web/src/features/ads/admin-editor.tsx @@ -117,20 +117,7 @@ export function AdsAdmin({ keepMounted className="flex min-w-0 flex-col gap-5" > -
-

- {editing - ? editing === 'new' - ? 'New direct campaign' - : 'Edit campaign' - : 'Campaign inventory'} -

-

- {editing - ? 'Configure artwork, placements, and schedule. Advertiser purchases are managed in Orders.' - : 'Published, paused, and draft campaigns. All dates use UTC.'} -

-
+ {error && (

{error} @@ -224,50 +211,14 @@ export function AdsAdmin({ ))} - {deleting && ( -

-

- Delete campaign “{deleting.name}” and its artwork? This - cannot be undone. -

-
- - -
-
- )} + )} @@ -561,3 +512,92 @@ function CampaignForm({ ) } + +function CampaignInventoryHeading({ + editing, +}: { + editing: AdCampaign | 'new' | undefined +}) { + return ( +
+

+ {editing + ? editing === 'new' + ? 'New direct campaign' + : 'Edit campaign' + : 'Campaign inventory'} +

+

+ {editing + ? 'Configure artwork, placements, and schedule. Advertiser purchases are managed in Orders.' + : 'Published, paused, and draft campaigns. All dates use UTC.'} +

+
+ ) +} + +function CampaignDeletion({ + deleting, + busy, + setBusy, + setError, + setDeleting, + refresh, +}: { + deleting: AdCampaign | undefined + busy: boolean + setBusy: (busy: boolean) => void + setError: (error: string) => void + setDeleting: (campaign: AdCampaign | undefined) => void + refresh: () => Promise +}) { + return ( + <> + {' '} + {deleting && ( +
+

+ Delete campaign “{deleting.name}” and its artwork? This cannot be + undone. +

+
+ + +
+
+ )}{' '} + + ) +} diff --git a/apps/web/src/features/ads/order-manage.tsx b/apps/web/src/features/ads/order-manage.tsx index bb4bb282..8e284208 100644 --- a/apps/web/src/features/ads/order-manage.tsx +++ b/apps/web/src/features/ads/order-manage.tsx @@ -131,172 +131,24 @@ export function AdOrderManage() { )} {order && access && ( <> -
-
-
Package
-
- {AD_PLANS[order.plan].name} · US${order.amount / 100}/month -
-
-
-
Status
-
- {order.status.replaceAll('_', ' ')} - {order.paidThrough && order.paidThrough.getTime() <= loadedAt - ? ' · Period ended' - : ''} -
-
-
-
Order reference
-
{order.id}
-
-
-
Contact
-
{order.email}
-
- {order.startsAt && ( -
-
- First approved -
-
- {order.startsAt.toISOString().replace('T', ' ').slice(0, 19)}{' '} - UTC -
-
- )} - {order.paidThrough && ( -
-
- Paid through (exclusive) -
-
- {order.paidThrough - .toISOString() - .replace('T', ' ') - .slice(0, 19)}{' '} - UTC -
-
- )} -
- {order.status !== 'rejected' && - (!order.paidAt || order.renewalRequestedAt) && ( - - Waiting for payment confirmation - -

- Pay for one month of {AD_PLANS[order.plan].name} on Ko-fi. - Use the contact email above and include your order reference - in the message so we can match your payment. -

-

- Already paid? Wait for our team to confirm receipt by email; - do not pay again. Opening Ko-fi does not update your payment - status.{' '} - {order.paidAt - ? 'Your campaign dates extend after the team confirms this renewal payment.' - : 'Your first advertising month starts after materials are approved.'} -

-
-
- Pay {AD_PLANS[order.plan].name} on Ko-fi - (opens in a new tab) - - - )} - {order.feedback && ( - - Review feedback - {order.feedback} - - )} - {order.status === 'rejected' && ( -

- Your first campaign was declined. Reply to your order email to - arrange a manual refund with the team. -

- )} - {order.status === 'in_review' && ( -

- Your materials are waiting for review. Previously approved artwork - remains live during the review. -

- )} - {order.paidAt && order.status !== 'rejected' && ( - <> - {!editing && ( - - )} - {editing && ( - { - setOrder(value) - setEditing(false) - }} - onCancel={() => setEditing(false)} - /> - )} - - )} + + + {order.campaignId && order.status !== 'rejected' && ( -
-

- Renew your campaign -

-

- No automatic billing. Request another month at US$ - {order.amount / 100}, then pay through Ko-fi using the link that - appears after your request. Dates extend only after the team - confirms the new payment. If your period has ended, a new month - starts at confirmation. -

- -
+ )} )} @@ -483,3 +335,209 @@ function AdMaterialForm({ ) } + +function AdOrderSummary({ + order, + loadedAt, +}: { + order: AdOrderView + loadedAt: number +}) { + return ( +
+
+
Package
+
+ {AD_PLANS[order.plan].name} · US${order.amount / 100}/month +
+
+
+
Status
+
+ {order.status.replaceAll('_', ' ')} + {order.paidThrough && order.paidThrough.getTime() <= loadedAt + ? ' · Period ended' + : ''} +
+
+
+
Order reference
+
{order.id}
+
+
+
Contact
+
{order.email}
+
+ {order.startsAt && ( +
+
First approved
+
+ {order.startsAt.toISOString().replace('T', ' ').slice(0, 19)} UTC +
+
+ )} + {order.paidThrough && ( +
+
+ Paid through (exclusive) +
+
+ {order.paidThrough.toISOString().replace('T', ' ').slice(0, 19)} UTC +
+
+ )} +
+ ) +} + +function AdOrderNotices({ order }: { order: AdOrderView }) { + return ( + <> + {order.status !== 'rejected' && + (!order.paidAt || order.renewalRequestedAt) && ( + + Waiting for payment confirmation + +

+ Pay for one month of {AD_PLANS[order.plan].name} on Ko-fi. Use + the contact email above and include your order reference in the + message so we can match your payment. +

+

+ Already paid? Wait for our team to confirm receipt by email; do + not pay again. Opening Ko-fi does not update your payment + status.{' '} + {order.paidAt + ? 'Your campaign dates extend after the team confirms this renewal payment.' + : 'Your first advertising month starts after materials are approved.'} +

+
+ + Pay {AD_PLANS[order.plan].name} on Ko-fi + (opens in a new tab) + +
+ )} + {order.feedback && ( + + Review feedback + {order.feedback} + + )} + {order.status === 'rejected' && ( +

+ Your first campaign was declined. Reply to your order email to arrange + a manual refund with the team. +

+ )} + {order.status === 'in_review' && ( +

+ Your materials are waiting for review. Previously approved artwork + remains live during the review. +

+ )} + + ) +} + +function AdOrderRenewal({ + order, + access, + busy, + setBusy, + setError, + setOrder, +}: { + order: AdOrderView + access: AdAccess + busy: boolean + setBusy: (busy: boolean) => void + setError: (error: string) => void + setOrder: (order: AdOrderView) => void +}) { + return ( +
+

+ Renew your campaign +

+

+ No automatic billing. Request another month at US$ + {order.amount / 100}, then pay through Ko-fi using the link that appears + after your request. Dates extend only after the team confirms the new + payment. If your period has ended, a new month starts at confirmation. +

+ +
+ ) +} + +function AdOrderMaterials({ + order, + access, + editing, + setEditing, + setOrder, +}: { + order: AdOrderView + access: AdAccess + editing: boolean + setEditing: (editing: boolean) => void + setOrder: (order: AdOrderView) => void +}) { + return ( + <> + {order.paidAt && order.status !== 'rejected' && ( + <> + {!editing && ( + + )} + {editing && ( + { + setOrder(value) + setEditing(false) + }} + onCancel={() => setEditing(false)} + /> + )} + + )} + + ) +} diff --git a/apps/web/src/features/ads/orders-admin.tsx b/apps/web/src/features/ads/orders-admin.tsx index d8163bd1..3ad1a2f9 100644 --- a/apps/web/src/features/ads/orders-admin.tsx +++ b/apps/web/src/features/ads/orders-admin.tsx @@ -252,287 +252,49 @@ function OrderReview({ }) ) } - const preview = order.material - ? { - ...order.material, - id: order.campaignId ?? order.id, - version: 1, - ctaText, - buttonColor, - tier: AD_PLANS[order.plan].tier, - status: 'active' as const, - startMonth: order.startsAt?.toISOString().slice(0, 7) ?? '2026-11', - months: 1, - startsAt: order.startsAt ?? new Date(0), - endsAt: order.paidThrough ?? new Date(0), - createdAt: order.createdAt, - updatedAt: order.createdAt, - } - : null + const preview = getOrderPreview(order, ctaText, buttonColor) return (
-
-
-

- {order.material?.name ?? AD_PLANS[order.plan].name + ' order'} -

-

- {order.email} -

-

- {AD_PLANS[order.plan].name} · US${order.amount / 100} / month -

-
-
- - {order.renewalRequestedAt - ? 'Renewal requested' - : statusLabels[order.status]} - - -
-
+ {expanded && (
-
-
-
Order reference
-
{order.id}
-
-
-
Contact advertiser
-
- - {order.email} - -
-
- {order.paidThrough && ( -
-
- Paid through (UTC, exclusive) -
-
- {order.paidThrough - .toISOString() - .replace('T', ' ') - .slice(0, 16)} -
-
- )} - {order.campaignId && ( -
-
Campaign status
-
{campaign?.status ?? 'Loading…'}
-
- )} -
+ {order.feedback && (

Previous feedback: {order.feedback}

)} {(order.status === 'pending_payment' || order.renewalRequestedAt) && ( -
{ - event.preventDefault() - if (!confirmed) return - void run(() => - client.admin.adBilling.confirmPayment({ - body: { - id: order.id, - expectedVersion: order.version, - kind: order.paidAt ? 'renewal' : 'initial', - reference, - }, - }) - ) - }} - > -
-

- Confirm {order.paidAt ? 'renewal' : 'initial'} payment -

-

- Match the Ko-fi receipt to this order and verify that US$ - {order.amount / 100} arrived. Recording a payment does not - charge the advertiser. -

-
- - - - {order.paidAt ? 'Renewal' : 'Initial'} payment reference - - setReference(event.target.value)} - /> - - Unique transaction reference from the Ko-fi payment receipt. - Amount: US$ - {order.amount / 100}. Record any conversion details in your - payment correspondence. - - - - setConfirmed(value === true)} - /> - - I independently verified receipt of this payment. - - - - -
- )} - {preview && ( -
-
-

- Submitted materials -

-
- -
- {order.plan !== 'core' && ( -
-
- Sponsor grid artwork -
- {`${order.material!.name} -
- )} - - Review advertiser website - -
- {order.status === 'in_review' && ( - -

Review materials

- - - CTA text - - setCtaText(event.target.value)} - /> - - - - CTA button color - - setButtonColor(event.target.value)} - /> - - Choose a brand color; text contrast adjusts automatically. - - - - - Review feedback - -