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 bb8ec823..10ec544c 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,24 +103,24 @@ 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, ] } +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, @@ -282,24 +282,8 @@ export const ProjectListSection = ({ }) const data = query.data ?? loaded const [consumed, setConsumed] = useState(45) - const { pagination } = data - const currentPage = pagination.page || 1 - const offset = data.query?.offset ?? (currentPage - 1) * 45 - const totalPages = Math.max( - currentPage, - currentPage + - Math.ceil( - (pagination.totalItems - - offset - - Math.min(consumed, data.projects.length)) / - 45 - ) - ) - const outOfRange = - data.projects.length === 0 && - currentPage > 1 && - offset >= pagination.totalItems - const paginationItems = getPaginationItems(currentPage, totalPages) + const { pagination, currentPage, offset, totalPages, outOfRange } = + getCataloguePagination(data, consumed) const handlePageClick = createPaginationScrollHandler(scrollTargetId, router) if (loaded.query && !query.data && loaded.query.offset !== expectedOffset) { if (query.isError) { @@ -321,28 +305,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 + )} +
+ ) +} + +function getCataloguePagination( + data: ORPCRouterOutputs['project']['search'], + consumed: number +) { + const { pagination } = data + const currentPage = pagination.page || 1 + const offset = data.query?.offset ?? (currentPage - 1) * 45 + const totalPages = Math.max( + currentPage, + currentPage + + Math.ceil( + (pagination.totalItems - + offset - + Math.min(consumed, data.projects.length)) / + 45 + ) + ) + const outOfRange = + data.projects.length === 0 && + currentPage > 1 && + offset >= pagination.totalItems + return { pagination, currentPage, offset, totalPages, outOfRange } +} diff --git a/apps/web/src/components/public-page-state.tsx b/apps/web/src/components/public-page-state.tsx index 0126bfb3..11561833 100644 --- a/apps/web/src/components/public-page-state.tsx +++ b/apps/web/src/components/public-page-state.tsx @@ -17,16 +17,16 @@ export function PublicError({ const [retrying, setRetrying] = useState(false) const retry = async () => { setRetrying(true) - try { + await (async () => { // Mark external cache stale before Router re-runs loaders. Do not refetch // active observers early, which could throw back into the same boundary. await router.options.context.queryClient.invalidateQueries({ refetchType: 'none', }) await router.invalidate({ sync: true }) - } finally { + })().finally(() => { setRetrying(false) - } + }) } return (
{ - if (response.status === 429) { - const seconds = Number( - response.headers.get('X-Retry-After') ?? - response.headers.get('Retry-After') - ) - setRetryIn( - Number.isFinite(seconds) && seconds > 0 - ? Math.ceil(seconds) - : 60 - ) - } + await (async () => { + try { + const { error } = await authClient.signIn.magicLink({ + email: parsed.data, + callbackURL, + newUserCallbackURL: callbackURL, + errorCallbackURL, + fetchOptions: { + onError: ({ response }) => { + if (response.status === 429) { + const seconds = Number( + response.headers.get('X-Retry-After') ?? + response.headers.get('Retry-After') + ) + setRetryIn( + Number.isFinite(seconds) && seconds > 0 + ? Math.ceil(seconds) + : 60 + ) + } + }, }, - }, - }) - if (error) { + }) + if (error) { + setRequestError( + error.status === 429 + ? 'Too many requests. Wait before requesting another link.' + : 'Unable to send a sign-in email. Try again later or continue with GitHub.' + ) + return + } + setEmail(parsed.data) + setSentTo(parsed.data) + setRetryIn(60) + } catch { setRequestError( - error.status === 429 - ? 'Too many requests. Wait before requesting another link.' - : 'Unable to send a sign-in email. Try again later or continue with GitHub.' + 'Unable to connect. Check your connection and try again.' ) - return } - setEmail(parsed.data) - setSentTo(parsed.data) - setRetryIn(60) - } catch { - setRequestError('Unable to connect. Check your connection and try again.') - } finally { + })().finally(() => { busy.current = false setPending(null) - } + }) } const signInWithGithub = async () => { @@ -124,21 +128,25 @@ export function SignInForm({ busy.current = true setPending('github') setRequestError(null) - try { - const { error } = await authClient.signIn.social({ - provider: 'github', - callbackURL, - errorCallbackURL, - }) - if (error) { - setRequestError('Unable to continue with GitHub. Please try again.') + await (async () => { + try { + const { error } = await authClient.signIn.social({ + provider: 'github', + callbackURL, + errorCallbackURL, + }) + if (error) { + setRequestError('Unable to continue with GitHub. Please try again.') + } + } catch { + setRequestError( + 'Unable to connect. Check your connection and try again.' + ) } - } catch { - setRequestError('Unable to connect. Check your connection and try again.') - } finally { + })().finally(() => { busy.current = false setPending(null) - } + }) } const error = requestError ?? (sentTo ? null : verificationError(authError)) diff --git a/apps/web/src/components/use-image-upload.ts b/apps/web/src/components/use-image-upload.ts index 854dc283..db4b45cd 100644 --- a/apps/web/src/components/use-image-upload.ts +++ b/apps/web/src/components/use-image-upload.ts @@ -406,44 +406,46 @@ export const useImageUpload = ({ return } - try { - setIsDeleting(true) - setErrorMessage(null) - - if (managedFile.fileKey) { - await deleteManagedFile(managedFile.fileKey) - } + await (async () => { + try { + setIsDeleting(true) + setErrorMessage(null) - onChange?.('') - onDisplayUrlChange?.(null) - setManagedFile(null) - setPendingFile(null) - setProgress(0) - setErrorMessage(null) + if (managedFile.fileKey) { + await deleteManagedFile(managedFile.fileKey) + } - toast.add({ - type: 'success', - title: `${copy.noun} image removed successfully`, - description: `${copy.noun} image removed successfully`, - }) - } catch (error) { - setErrorMessage( - error instanceof Error - ? error.message - : 'Error while deleting file, please try again' - ) + onChange?.('') + onDisplayUrlChange?.(null) + setManagedFile(null) + setPendingFile(null) + setProgress(0) + setErrorMessage(null) - toast.add({ - type: 'error', - title: `Delete ${copy.noun.toLowerCase()} image failed`, - description: + toast.add({ + type: 'success', + title: `${copy.noun} image removed successfully`, + description: `${copy.noun} image removed successfully`, + }) + } catch (error) { + setErrorMessage( error instanceof Error ? error.message - : 'Error while deleting file, please try again', - }) - } finally { + : 'Error while deleting file, please try again' + ) + + toast.add({ + type: 'error', + title: `Delete ${copy.noun.toLowerCase()} image failed`, + description: + error instanceof Error + ? error.message + : 'Error while deleting file, please try again', + }) + } + })().finally(() => { setIsDeleting(false) - } + }) } const rejectedFile = (fileRejection: Array) => { diff --git a/apps/web/src/features/admin-blogs/editor.tsx b/apps/web/src/features/admin-blogs/editor.tsx index b063c169..00f242df 100644 --- a/apps/web/src/features/admin-blogs/editor.tsx +++ b/apps/web/src/features/admin-blogs/editor.tsx @@ -131,7 +131,8 @@ export function BlogEditor({ initial }: { initial?: Initial }) { if (!parsed.success) { const fields: Record = {} for (const issue of parsed.error.issues) { - fields[String(issue.path[0])] ??= issue.message + const field = String(issue.path[0]) + fields[field] = fields[field] ?? issue.message } setFieldErrors(fields) document.getElementById(`blog-${Object.keys(fields)[0]}`)?.focus() @@ -352,35 +353,7 @@ export function BlogEditor({ initial }: { initial?: Initial }) { - { - if (open) editor.current?.flush() - }} - > - } - > - Preview content - - -
- {value.content.trim() ? ( - - ) : ( -

- Write some content to preview it here. -

- )} -
-
-
+
{ setReloading(true) - try { + await (async () => { await queryClient.invalidateQueries({ queryKey: orpc.admin.blog.key(), }) await router.invalidate({ sync: true }) - } finally { + })().finally(() => { setReloading(false) setReload(false) - } + }) }} > Reload saved version @@ -451,3 +424,41 @@ export function BlogEditor({ initial }: { initial?: Initial }) {
) } + +function BlogContentPreview({ + editor, + value, +}: { + editor: React.RefObject + value: BlogBody +}) { + return ( + { + if (open) editor.current?.flush() + }} + > + }> + Preview content + + +
+ {value.content.trim() ? ( + + ) : ( +

+ Write some content to preview it here. +

+ )} +
+
+
+ ) +} diff --git a/apps/web/src/features/admin-collections/editor.tsx b/apps/web/src/features/admin-collections/editor.tsx index c34f996c..c7642643 100644 --- a/apps/web/src/features/admin-collections/editor.tsx +++ b/apps/web/src/features/admin-collections/editor.tsx @@ -178,30 +178,7 @@ export function CollectionEditor({ initial }: { initial?: Initial }) { enableBeforeUnload: () => !saved.current && dirty, withResolver: true, }) - 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'), - }, - ] + const readiness = getCollectionReadiness(value, target, members) const ready = readiness.every((item) => item.done) function fieldProps(name: string) { return { @@ -269,20 +246,22 @@ export function CollectionEditor({ initial }: { initial?: Initial }) { return } setReloading(true) - try { - await router.invalidate() - setValue(initialValue) - setMembers(initial?.projects ?? []) - setTarget(initial?.mainProject ?? null) - setEditorRevision((revision) => revision + 1) - setError('') - setFieldErrors({}) - setPreview(false) - } catch { - showError('Unable to reload. Your edits have been kept.') - } finally { + await (async () => { + try { + await router.invalidate() + setValue(initialValue) + setMembers(initial?.projects ?? []) + setTarget(initial?.mainProject ?? null) + setEditorRevision((revision) => revision + 1) + setError('') + setFieldErrors({}) + setPreview(false) + } catch { + showError('Unable to reload. Your edits have been kept.') + } + })().finally(() => { setReloading(false) - } + }) } function togglePreview() { editor.current?.flush() @@ -465,43 +444,13 @@ export function CollectionEditor({ initial }: { initial?: Initial }) { {kindDescriptions[value.kind]} - {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. - - - )} + - + ) } + +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-featured/components/featured-editor.tsx b/apps/web/src/features/admin-featured/components/featured-editor.tsx index 3448e257..d38b6913 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 @@ -125,24 +125,26 @@ function SelectionEditor({ initial }: { initial: Selection }) { async function reload() { setConfirmReload(false) setReloading(true) - try { - await queryClient.invalidateQueries({ - queryKey: adminORPC.featured.list.key(), - refetchType: 'none', - }) - const data = await queryClient.query(adminFeaturedQueries.list()) - setProjects(data.projects) - setBaseline(data.projects.map(({ id }) => id)) - setError(null) - } catch { - setError({ - operation: 'reload', - message: - 'Unable to reload the selection. Your unsaved changes have been kept.', - }) - } finally { + await (async () => { + try { + await queryClient.invalidateQueries({ + queryKey: adminORPC.featured.list.key(), + refetchType: 'none', + }) + const data = await queryClient.query(adminFeaturedQueries.list()) + setProjects(data.projects) + setBaseline(data.projects.map(({ id }) => id)) + setError(null) + } catch { + setError({ + operation: 'reload', + message: + 'Unable to reload the selection. Your unsaved changes have been kept.', + }) + } + })().finally(() => { setReloading(false) - } + }) } function move(index: number, delta: number) { setProjects((previous) => { 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 b2be7ec8..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,122 +103,18 @@ 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) // 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/admin-projects/components/github-metadata-prefill.tsx b/apps/web/src/features/admin-projects/components/github-metadata-prefill.tsx index a90a165d..f4348c80 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,141 @@ 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) + await (async () => { + 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..c45a1057 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 @@ -96,33 +96,35 @@ export function GithubReadmeImport({ setError(null) setFeedback(null) setPreview(null) - try { - const readme = await mutateAsync({ - repositoryUrl: requestedRepositoryUrl, - }) - if (currentRequest !== requestId.current) return - if (getRepositoryUrl() !== requestedRepositoryUrl) { - setFeedback(REPOSITORY_CHANGED) - return - } - const current = editor() - const mode = current.readMarkdown().trim() ? null : 'replace' - const conversion = current.previewImport( - readme.markdown, - mode ?? 'replace' - ) - setPreview({ readme, requestedRepositoryUrl, mode, conversion }) - } catch (cause) { - if (currentRequest === requestId.current) { - setError( - cause instanceof Error - ? cause.message - : 'Unable to import README. Please try again.' + await (async () => { + try { + const readme = await mutateAsync({ + repositoryUrl: requestedRepositoryUrl, + }) + if (currentRequest !== requestId.current) return + if (getRepositoryUrl() !== requestedRepositoryUrl) { + setFeedback(REPOSITORY_CHANGED) + return + } + const current = editor() + const mode = current.readMarkdown().trim() ? null : 'replace' + const conversion = current.previewImport( + readme.markdown, + mode ?? 'replace' ) + setPreview({ readme, requestedRepositoryUrl, mode, conversion }) + } catch (cause) { + if (currentRequest === requestId.current) { + setError( + cause instanceof Error + ? cause.message + : 'Unable to import README. Please try again.' + ) + } } - } finally { + })().finally(() => { if (currentRequest === requestId.current) setPending(false) - } + }) } function chooseMode(mode: ReadmeImportMode) { if (!preview || disabled || applying) return @@ -156,19 +158,20 @@ export function GithubReadmeImport({ setFeedback( 'Content changed. Review the updated preview, then Apply again.' ) - return + } else { + setPreview(null) + setFeedback( + 'README applied to Content. Save the project to persist it.' + ) } - setPreview(null) - setFeedback('README applied to Content. Save the project to persist it.') } catch (cause) { setError( cause instanceof Error ? cause.message : 'Unable to apply README. Existing content was kept.' ) - } finally { - setApplying(false) } + setApplying(false) } return ( @@ -202,118 +205,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} -

- )} -
- -
- - )} - -
+ ) +} + +function ReadmeImportDialog({ + preview, + setPreview, + applying, + disabled, + pending, + error, + setError, + feedback, + setFeedback, + id, + chooseMode, + apply, +}: { + preview: Preview | null + setPreview: (preview: Preview | null) => void + applying: boolean + disabled: boolean + pending: boolean + error: string | null + setError: (error: string | null) => void + feedback: string | null + setFeedback: (feedback: string | null) => void + id: string + chooseMode: (mode: ReadmeImportMode) => void + apply: () => void +}) { + return ( + { + 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 && ( + <> + - Cancel - - - - - - + + + + )} + + + + + + ) } 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/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 2f52779b..5f90755c 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() @@ -116,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} @@ -182,7 +170,7 @@ export function AdsAdmin({

- {managedCampaignIds.includes(ad.id) ? ( + {managedIds.has(ad.id) ? ( )} - {!managedCampaignIds.includes(ad.id) && ( + {!managedIds.has(ad.id) && (
- {deleting && ( -
-

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

-
- - -
-
- )} + )} @@ -319,21 +271,23 @@ function CampaignForm({ return } setSaving(true) - try { - if (campaign) { - await client.admin.ads.update({ - params: { id: campaign.id }, - body: { ...parsed.data, expectedVersion: campaign.version }, - }) - } else await client.admin.ads.create({ body: parsed.data }) - await onSaved() - } catch { - setError( - 'Unable to save. Check uploads and reload if another admin edited this campaign. Your input is retained.' - ) - } finally { + await (async () => { + try { + if (campaign) { + await client.admin.ads.update({ + params: { id: campaign.id }, + body: { ...parsed.data, expectedVersion: campaign.version }, + }) + } else await client.admin.ads.create({ body: parsed.data }) + await onSaved() + } catch { + setError( + 'Unable to save. Check uploads and reload if another admin edited this campaign. Your input is retained.' + ) + } + })().finally(() => { setSaving(false) - } + }) }} >
@@ -560,3 +514,94 @@ 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/components.tsx b/apps/web/src/features/ads/components.tsx index cc9d0f1a..4e4e336f 100644 --- a/apps/web/src/features/ads/components.tsx +++ b/apps/web/src/features/ads/components.tsx @@ -109,7 +109,7 @@ export function AdBanner() {
@@ -262,7 +262,8 @@ export function AdListingGrid({ media.addEventListener('change', update) return () => media.removeEventListener('change', update) }, []) - const ads = selected.filter((ad) => !hidden.includes(ad.id)) + const hiddenIds = new Set(hidden) + const ads = selected.filter((ad) => !hiddenIds.has(ad.id)) const slots = listingSlots(items, ads, mobile) const consumed = slots.filter((slot) => 'item' in slot).length useEffect(() => { diff --git a/apps/web/src/features/ads/order-access.tsx b/apps/web/src/features/ads/order-access.tsx index 2b74df31..b8bdbb70 100644 --- a/apps/web/src/features/ads/order-access.tsx +++ b/apps/web/src/features/ads/order-access.tsx @@ -25,18 +25,20 @@ export function AdAccessRequest() { setBusy(true) setError('') setMessage('') - try { - await client.adBilling.requestAccess({ body: { email } }) - setMessage( - 'If this email has advertising orders, a private link will arrive shortly. Check your spam folder too.' - ) - } catch { - setError( - 'Unable to request a link. Please wait a few minutes and try again.' - ) - } finally { + await (async () => { + try { + await client.adBilling.requestAccess({ body: { email } }) + setMessage( + 'If this email has advertising orders, a private link will arrive shortly. Check your spam folder too.' + ) + } catch { + setError( + 'Unable to request a link. Please wait a few minutes and try again.' + ) + } + })().finally(() => { setBusy(false) - } + }) }} > diff --git a/apps/web/src/features/ads/order-dialog.tsx b/apps/web/src/features/ads/order-dialog.tsx index 32d7d494..6ff49b76 100644 --- a/apps/web/src/features/ads/order-dialog.tsx +++ b/apps/web/src/features/ads/order-dialog.tsx @@ -75,18 +75,20 @@ function OrderForm({ plan }: { plan: AdPlan }) { if (!accepted || busy) return setBusy(true) setError('') - try { - await client.adBilling.create({ - body: { plan, email, termsAccepted: true }, - }) - setSent(true) - } catch { - setError( - 'Unable to request an order. Your input is retained; wait a few minutes and try again.' - ) - } finally { + await (async () => { + try { + await client.adBilling.create({ + body: { plan, email, termsAccepted: true }, + }) + setSent(true) + } catch { + setError( + 'Unable to request an order. Your input is retained; wait a few minutes and try again.' + ) + } + })().finally(() => { setBusy(false) - } + }) }} > diff --git a/apps/web/src/features/ads/order-manage.tsx b/apps/web/src/features/ads/order-manage.tsx index bb4bb282..247e3dfe 100644 --- a/apps/web/src/features/ads/order-manage.tsx +++ b/apps/web/src/features/ads/order-manage.tsx @@ -80,16 +80,18 @@ export function AdOrderManage() { if (!access) return setLoading(true) setError('') - try { - setOrder(await client.adBilling.get({ body: access })) - setLoadedAt(Date.now()) - } catch { - setError( - 'Unable to load this order. Request a fresh link if yours has expired.' - ) - } finally { + await (async () => { + try { + setOrder(await client.adBilling.get({ body: access })) + setLoadedAt(Date.now()) + } catch { + setError( + 'Unable to load this order. Request a fresh link if yours has expired.' + ) + } + })().finally(() => { setLoading(false) - } + }) } return (
@@ -131,172 +133,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. -

- -
+ )} )} @@ -367,23 +221,25 @@ function AdMaterialForm({ return } setSaving(true) - try { - onSaved( - await client.adBilling.submit({ - body: { - ...access, - material: parsed.data, - expectedVersion: order.version, - }, - }) - ) - } catch { - setError( - 'Unable to submit. Check uploads, or refresh the order if another change was made. Your input is retained.' - ) - } finally { + await (async () => { + try { + onSaved( + await client.adBilling.submit({ + body: { + ...access, + material: parsed.data, + expectedVersion: order.version, + }, + }) + ) + } catch { + setError( + 'Unable to submit. Check uploads, or refresh the order if another change was made. Your input is retained.' + ) + } + })().finally(() => { setSaving(false) - } + }) }} > @@ -483,3 +339,213 @@ 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..7951a74e 100644 --- a/apps/web/src/features/ads/orders-admin.tsx +++ b/apps/web/src/features/ads/orders-admin.tsx @@ -223,16 +223,18 @@ function OrderReview({ if (busy) return setBusy(true) setError('') - try { - await action() - await onSaved() - } catch { - setError( - 'Unable to save. The order may have changed or this payment reference may already be recorded. Refresh before retrying.' - ) - } finally { + await (async () => { + try { + await action() + await onSaved() + } catch { + setError( + 'Unable to save. The order may have changed or this payment reference may already be recorded. Refresh before retrying.' + ) + } + })().finally(() => { setBusy(false) - } + }) } const review = (decision: 'approve' | 'revise' | 'reject') => { if (decision !== 'approve' && !reason.trim()) { @@ -252,287 +254,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 - -