diff --git a/apps/admin/.env.example b/apps/admin/.env.example index 52615e47..dee2a02a 100644 --- a/apps/admin/.env.example +++ b/apps/admin/.env.example @@ -17,3 +17,8 @@ NEXT_PUBLIC_FIREBASE_PROJECT_ID=demo-widoo # FIREBASE_AUTH_EMULATOR_HOST of the API. Accepted with a demo-* project only; empty on staging # and production. NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL=http://127.0.0.1:9099 + +# Dashboards the admin links to from its home page (A-05), https: the exact page of the Widoo +# project. Empty: the home page of PostHog Cloud EU and of RevenueCat. +NEXT_PUBLIC_POSTHOG_URL= +NEXT_PUBLIC_REVENUECAT_URL= diff --git a/apps/admin/src/app/(shell)/catalogue/lieux/[id]/page.tsx b/apps/admin/src/app/(shell)/catalogue/lieux/[id]/page.tsx new file mode 100644 index 00000000..ab60277d --- /dev/null +++ b/apps/admin/src/app/(shell)/catalogue/lieux/[id]/page.tsx @@ -0,0 +1,12 @@ +import { PlaceEditContainer } from '@/containers/PlaceEditContainer'; +import { PlaceWriteGate } from '@/containers/PlaceWriteGate'; + +/** A-04, the sheet of a place as a form. Editors and admins only. */ +export default async function EditPlacePage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ( + + + + ); +} diff --git a/apps/admin/src/app/(shell)/catalogue/lieux/import/page.tsx b/apps/admin/src/app/(shell)/catalogue/lieux/import/page.tsx new file mode 100644 index 00000000..26e8edaa --- /dev/null +++ b/apps/admin/src/app/(shell)/catalogue/lieux/import/page.tsx @@ -0,0 +1,13 @@ +import { catalogPaths } from '@/components/catalog/CatalogTabs'; +import { AdminOnlyGate } from '@/containers/AdminOnlyGate'; +import { PlaceImportContainer } from '@/containers/PlaceImportContainer'; +import { messages } from '@/messages/fr'; + +/** A-04, the import of places from a CSV file. Admins only. */ +export default function PlaceImportPage() { + return ( + + + + ); +} diff --git a/apps/admin/src/app/(shell)/catalogue/lieux/nouveau/page.tsx b/apps/admin/src/app/(shell)/catalogue/lieux/nouveau/page.tsx new file mode 100644 index 00000000..a3a289bd --- /dev/null +++ b/apps/admin/src/app/(shell)/catalogue/lieux/nouveau/page.tsx @@ -0,0 +1,11 @@ +import { PlaceFormContainer } from '@/containers/PlaceFormContainer'; +import { PlaceWriteGate } from '@/containers/PlaceWriteGate'; + +/** A-04, a new place created by hand. Editors and admins only. */ +export default function NewPlacePage() { + return ( + + + + ); +} diff --git a/apps/admin/src/app/(shell)/catalogue/lieux/page.tsx b/apps/admin/src/app/(shell)/catalogue/lieux/page.tsx new file mode 100644 index 00000000..45da305b --- /dev/null +++ b/apps/admin/src/app/(shell)/catalogue/lieux/page.tsx @@ -0,0 +1,15 @@ +import { CatalogTabs } from '@/components/catalog/CatalogTabs'; +import { CatalogPlacesContainer } from '@/containers/CatalogPlacesContainer'; +import { Suspense } from 'react'; + +/** A-04, the places of the catalog. The state of the list is read from the address. */ +export default function CatalogPlacesPage() { + return ( +
+ + + + +
+ ); +} diff --git a/apps/admin/src/app/(shell)/catalogue/page.tsx b/apps/admin/src/app/(shell)/catalogue/page.tsx new file mode 100644 index 00000000..8c2bee5d --- /dev/null +++ b/apps/admin/src/app/(shell)/catalogue/page.tsx @@ -0,0 +1,7 @@ +import { catalogPaths } from '@/components/catalog/CatalogTabs'; +import { redirect } from 'next/navigation'; + +/** A-04 opens on its places; its routes are the second tab. */ +export default function CatalogPage() { + redirect(catalogPaths.places); +} diff --git a/apps/admin/src/app/(shell)/catalogue/parcours/[id]/page.tsx b/apps/admin/src/app/(shell)/catalogue/parcours/[id]/page.tsx new file mode 100644 index 00000000..94478a76 --- /dev/null +++ b/apps/admin/src/app/(shell)/catalogue/parcours/[id]/page.tsx @@ -0,0 +1,11 @@ +import { CatalogRouteSheetContainer } from '@/containers/CatalogRouteSheetContainer'; + +/** A-04, the sheet of a route of the catalog, on its own page. */ +export default async function CatalogRouteSheetPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + return ; +} diff --git a/apps/admin/src/app/(shell)/catalogue/parcours/page.tsx b/apps/admin/src/app/(shell)/catalogue/parcours/page.tsx new file mode 100644 index 00000000..779b144d --- /dev/null +++ b/apps/admin/src/app/(shell)/catalogue/parcours/page.tsx @@ -0,0 +1,15 @@ +import { CatalogTabs } from '@/components/catalog/CatalogTabs'; +import { CatalogRoutesContainer } from '@/containers/CatalogRoutesContainer'; +import { Suspense } from 'react'; + +/** A-04, the routes of the catalog. The state of the list is read from the address. */ +export default function CatalogRoutesPage() { + return ( +
+ + + + +
+ ); +} diff --git a/apps/admin/src/app/(shell)/page.tsx b/apps/admin/src/app/(shell)/page.tsx index 890f7b5a..2bf9f7e4 100644 --- a/apps/admin/src/app/(shell)/page.tsx +++ b/apps/admin/src/app/(shell)/page.tsx @@ -1,7 +1,6 @@ -/** - * A-05, the dashboard: its tiles and figures come with the ticket of the dashboard (#55). The - * shell around it already shows its title. - */ +import { DashboardContainer } from '@/containers/DashboardContainer'; + +/** A-05, the dashboard: the home page of the admin. */ export default function DashboardPage() { - return null; + return ; } diff --git a/apps/admin/src/components/catalog/CatalogPlacesFilters.tsx b/apps/admin/src/components/catalog/CatalogPlacesFilters.tsx new file mode 100644 index 00000000..55208c75 --- /dev/null +++ b/apps/admin/src/components/catalog/CatalogPlacesFilters.tsx @@ -0,0 +1,39 @@ +import { messages } from '@/messages/fr'; +import { FilterMenu, type FilterMenuOption } from '@widoo/ui'; + +type CatalogPlacesFiltersProps = { + categories: readonly FilterMenuOption[]; + category: string | null; + onCategoryChange: (category: string | null) => void; + statuses: readonly FilterMenuOption[]; + status: string | null; + onStatusChange: (status: string | null) => void; +}; + +/** Filters of the places of the catalog: their category, and how far they are verified. */ +export function CatalogPlacesFilters({ + categories, + category, + onCategoryChange, + statuses, + status, + onStatusChange, +}: CatalogPlacesFiltersProps) { + const texts = messages.catalog.places.filters; + return ( +
+ + +
+ ); +} diff --git a/apps/admin/src/components/catalog/CatalogPlacesTable.tsx b/apps/admin/src/components/catalog/CatalogPlacesTable.tsx new file mode 100644 index 00000000..890fec36 --- /dev/null +++ b/apps/admin/src/components/catalog/CatalogPlacesTable.tsx @@ -0,0 +1,89 @@ +import type { CatalogPlaceColumn } from '@/config/catalog-places-list'; +import { formatRelativeDate } from '@/lib/dates'; +import { messages } from '@/messages/fr'; +import { labels, type CatalogPlace } from '@widoo/shared'; +import { + SortableColumnHeader, + Table, + TableCell, + TableHeaderCell, + TableRow, + TableSkeleton, + type SortDirection, +} from '@widoo/ui'; +import { VerificationBadge } from './VerificationBadge'; + +type CatalogPlacesTableProps = { + /** Rows of the page; ignored while `isLoading`. */ + items: CatalogPlace[]; + isLoading: boolean; + sort: { column: CatalogPlaceColumn; direction: SortDirection }; + onSort: (column: CatalogPlaceColumn) => void; + /** The place whose sheet is open in the detail panel. */ + openId: string | null; + onOpen: (place: CatalogPlace) => void; +}; + +const texts = messages.catalog.places; +const columnCount = 6; + +/** The places of the catalog as a table (A-04); a row opens its sheet next to the list. */ +export function CatalogPlacesTable({ + items, + isLoading, + sort, + onSort, + openId, + onOpen, +}: CatalogPlacesTableProps) { + const renderSortableHeader = (column: CatalogPlaceColumn, label: string, className?: string) => { + const direction = sort.column === column ? sort.direction : null; + const ariaSort = direction === null ? 'none' : direction === 'asc' ? 'ascending' : 'descending'; + return ( + + onSort(column)}> + {label} + + + ); + }; + + return ( + + + + {renderSortableHeader('lieu', texts.columns.place)} + {renderSortableHeader('categorie', texts.columns.category)} + {renderSortableHeader('ville', texts.columns.city)} + {texts.columns.status} + {renderSortableHeader('verifie', texts.columns.verified)} + {renderSortableHeader('parcours', texts.columns.routes, 'text-right')} + + + + {isLoading ? ( + + ) : ( + items.map((place) => ( + onOpen(place)} isOpen={place.id === openId}> + {place.name} + {labels.fr.placeCategories[place.category]} + {place.cityName} + + + + + {place.verifiedAt ? ( + formatRelativeDate(new Date(place.verifiedAt)) + ) : ( + {texts.neverVerified} + )} + + {place.routeCount} + + )) + )} + +
+ ); +} diff --git a/apps/admin/src/components/catalog/CatalogRouteActions.tsx b/apps/admin/src/components/catalog/CatalogRouteActions.tsx new file mode 100644 index 00000000..2f64e8fc --- /dev/null +++ b/apps/admin/src/components/catalog/CatalogRouteActions.tsx @@ -0,0 +1,59 @@ +import { SideCard } from '@/components/moderation/sheet/SideCard'; +import { messages } from '@/messages/fr'; +import { + ArrowCounterClockwiseIcon, + ProhibitIcon, + UsersThreeIcon, +} from '@phosphor-icons/react/dist/ssr'; +import { Button } from '@widoo/ui'; + +const texts = messages.catalog.routes.sheet; + +type CatalogRouteActionsProps = { + /** Absent when the action does not apply to the route, or is not for the role. */ + onUnpublish?: () => void; + onRepublish?: () => void; + onReassign?: () => void; + isBusy: boolean; +}; + +/** + * What the team may do to a route of the catalog (A-04): take it down, put it back online, hand + * it over to the Widoo team. Each one opens a window that names its effect. + */ +export function CatalogRouteActions({ + onUnpublish, + onRepublish, + onReassign, + isBusy, +}: CatalogRouteActionsProps) { + const hasAction = Boolean(onUnpublish ?? onRepublish ?? onReassign); + return ( + + {hasAction ? ( +
+ {onRepublish ? ( + + ) : null} + {onReassign ? ( + + ) : null} + {onUnpublish ? ( + + ) : null} +
+ ) : ( +

{texts.noAction}

+ )} +
+ ); +} diff --git a/apps/admin/src/components/catalog/CatalogRoutesFilters.tsx b/apps/admin/src/components/catalog/CatalogRoutesFilters.tsx new file mode 100644 index 00000000..d49aa32a --- /dev/null +++ b/apps/admin/src/components/catalog/CatalogRoutesFilters.tsx @@ -0,0 +1,39 @@ +import { messages } from '@/messages/fr'; +import { FilterMenu, type FilterMenuOption } from '@widoo/ui'; + +type CatalogRoutesFiltersProps = { + statuses: readonly FilterMenuOption[]; + status: string | null; + onStatusChange: (status: string | null) => void; + accesses: readonly FilterMenuOption[]; + access: string | null; + onAccessChange: (access: string | null) => void; +}; + +/** Filters of the routes of the catalog: their status, and free or Premium. */ +export function CatalogRoutesFilters({ + statuses, + status, + onStatusChange, + accesses, + access, + onAccessChange, +}: CatalogRoutesFiltersProps) { + const texts = messages.catalog.routes.filters; + return ( +
+ + +
+ ); +} diff --git a/apps/admin/src/components/catalog/CatalogRoutesTable.tsx b/apps/admin/src/components/catalog/CatalogRoutesTable.tsx new file mode 100644 index 00000000..6a0c9523 --- /dev/null +++ b/apps/admin/src/components/catalog/CatalogRoutesTable.tsx @@ -0,0 +1,94 @@ +import { RouteStatusBadge } from '@/components/routes/RouteStatusBadge'; +import type { CatalogRouteColumn } from '@/config/catalog-routes-list'; +import { formatRelativeDate } from '@/lib/dates'; +import { messages } from '@/messages/fr'; +import type { CatalogRoute } from '@widoo/shared'; +import { + SortableColumnHeader, + Table, + TableCell, + TableHeaderCell, + TableRow, + TableSkeleton, + type SortDirection, +} from '@widoo/ui'; + +type CatalogRoutesTableProps = { + /** Rows of the page; ignored while `isLoading`. */ + items: CatalogRoute[]; + isLoading: boolean; + sort: { column: CatalogRouteColumn; direction: SortDirection }; + onSort: (column: CatalogRouteColumn) => void; + /** Opens the sheet of a route; absent for a role that only reads the list. */ + onOpen?: (route: CatalogRoute) => void; +}; + +const texts = messages.catalog.routes; +const columnCount = 7; + +/** The routes of the catalog as a table (A-04): one route per row, whatever its status. */ +export function CatalogRoutesTable({ + items, + isLoading, + sort, + onSort, + onOpen, +}: CatalogRoutesTableProps) { + const renderSortableHeader = (column: CatalogRouteColumn, label: string, className?: string) => { + const direction = sort.column === column ? sort.direction : null; + const ariaSort = direction === null ? 'none' : direction === 'asc' ? 'ascending' : 'descending'; + return ( + + onSort(column)}> + {label} + + + ); + }; + + return ( + + + + {renderSortableHeader('parcours', texts.columns.route)} + {renderSortableHeader('auteur', texts.columns.author)} + {renderSortableHeader('ville', texts.columns.city)} + {texts.columns.status} + {texts.columns.access} + {renderSortableHeader('note', texts.columns.rating)} + {renderSortableHeader('modifie', texts.columns.updated, 'text-right')} + + + + {isLoading ? ( + + ) : ( + items.map((route) => ( + onOpen(route) : undefined}> + {route.title} + + {route.author?.firstName ?? + (route.isOfficial ? texts.team : messages.moderation.noAuthor)} + + {route.cityName} + + + + {texts.access[route.access]} + + {route.rating.average === null ? ( + {texts.noRating} + ) : ( + texts.rating(route.rating.average, route.rating.count) + )} + + + {formatRelativeDate(new Date(route.updatedAt))} + + + )) + )} + +
+ ); +} diff --git a/apps/admin/src/components/catalog/CatalogTabs.tsx b/apps/admin/src/components/catalog/CatalogTabs.tsx new file mode 100644 index 00000000..79491940 --- /dev/null +++ b/apps/admin/src/components/catalog/CatalogTabs.tsx @@ -0,0 +1,35 @@ +import { sectionPaths } from '@/config/navigation'; +import { messages } from '@/messages/fr'; +import { cn } from '@widoo/ui'; +import Link from 'next/link'; + +const texts = messages.catalog.tabs; + +/** Addresses of the two lists of the catalog. */ +export const catalogPaths = { + places: `${sectionPaths.catalog}/lieux`, + routes: `${sectionPaths.catalog}/parcours`, +} as const; + +/** The two lists of the catalog (A-04), as tabs above them: places, then routes. */ +export function CatalogTabs({ current }: { current: keyof typeof catalogPaths }) { + return ( + + ); +} diff --git a/apps/admin/src/components/catalog/PlaceDetailContent.tsx b/apps/admin/src/components/catalog/PlaceDetailContent.tsx new file mode 100644 index 00000000..86c841dc --- /dev/null +++ b/apps/admin/src/components/catalog/PlaceDetailContent.tsx @@ -0,0 +1,72 @@ +import { RouteStatusBadge } from '@/components/routes/RouteStatusBadge'; +import { hoursToDayTexts } from '@/lib/place-hours'; +import { messages } from '@/messages/fr'; +import { labels, type CatalogPlaceDetail } from '@widoo/shared'; +import type { ReactNode } from 'react'; + +const texts = messages.catalog.places.panel; + +/** A line of the sheet: what it is on the left, its value on the right. */ +function Line({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +/** + * The sheet of a place inside the detail panel: its address and practical details, its hours day + * by day, and the routes that go through it. + */ +export function PlaceDetailContent({ place }: { place: CatalogPlaceDetail }) { + const dayTexts = hoursToDayTexts(place.hours); + const hasHours = place.hours.length > 0; + return ( + <> +
+ + {labels.fr.placeCategories[place.category]} + + {place.address} + + {place.location.lat.toFixed(5)}, {place.location.lng.toFixed(5)} + + {place.isIndoor ? texts.yes : texts.no} + + {place.priceRange ? labels.fr.priceRanges[place.priceRange] : texts.none} + + {place.websiteUrl ?? texts.none} + {place.phone ?? texts.none} +
+
+

{texts.hours}

+ {hasHours ? ( +
+ {messages.catalog.places.weekdays.map((day, index) => ( + + {dayTexts[index] || texts.closed} + + ))} +
+ ) : ( +

{texts.hoursUnknown}

+ )} +
+
+

+ {place.routes.length > 0 ? texts.routes(place.routes.length) : texts.noRoute} +

+
    + {place.routes.map((route) => ( +
  • + {route.title} + +
  • + ))} +
+
+ + ); +} diff --git a/apps/admin/src/components/catalog/PlaceForm.tsx b/apps/admin/src/components/catalog/PlaceForm.tsx new file mode 100644 index 00000000..ad69a66d --- /dev/null +++ b/apps/admin/src/components/catalog/PlaceForm.tsx @@ -0,0 +1,167 @@ +import type { PlaceFormValues } from '@/lib/place-form'; +import { messages } from '@/messages/fr'; +import { labels, taxonomies } from '@widoo/shared'; +import { Button, Checkbox, Field, Input, Select } from '@widoo/ui'; +import type { FormEvent, ReactNode } from 'react'; + +const texts = messages.catalog.places.form; + +type PlaceFormProps = { + /** An error of the form or of the API, above the fields. */ + notice: ReactNode; + values: PlaceFormValues; + /** Changes one field of the form. */ + onChange: (key: Key, value: PlaceFormValues[Key]) => void; + onSubmit: () => void; + onCancel: () => void; + isBusy: boolean; +}; + +/** + * The sheet of a place as a form (A-04), for a new place and for an existing one: name, kind, + * address and position, practical details, then its opening hours day by day. + */ +export function PlaceForm({ + notice, + values, + onChange, + onSubmit, + onCancel, + isBusy, +}: PlaceFormProps) { + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + onSubmit(); + }; + const handleDayChange = (day: number, text: string) => + onChange( + 'dayHours', + values.dayHours.map((current, index) => (index === day ? text : current)), + ); + + return ( +
+ {notice} + + onChange('name', event.target.value)} + /> + + + + + + onChange('address', event.target.value)} + /> + +
+ + onChange('latitude', event.target.value)} + /> + + + onChange('longitude', event.target.value)} + /> + +
+

+ {texts.positionHint} +

+ onChange('isIndoor', event.target.checked)} + /> + + + + + onChange('websiteUrl', event.target.value)} + /> + + + onChange('phone', event.target.value)} + /> + +
+ {texts.hours} +

+ {texts.hoursHint} +

+ {messages.catalog.places.weekdays.map((day, index) => ( + + ))} +
+
+ + +
+ + ); +} diff --git a/apps/admin/src/components/catalog/PlaceImportFilePicker.tsx b/apps/admin/src/components/catalog/PlaceImportFilePicker.tsx new file mode 100644 index 00000000..d366a2c1 --- /dev/null +++ b/apps/admin/src/components/catalog/PlaceImportFilePicker.tsx @@ -0,0 +1,54 @@ +import { messages } from '@/messages/fr'; +import { DownloadSimpleIcon, UploadSimpleIcon } from '@phosphor-icons/react/dist/ssr'; +import { Button } from '@widoo/ui'; +import { useRef } from 'react'; + +const texts = messages.catalog.places.import; + +type PlaceImportFilePickerProps = { + onFile: (file: File) => void; + onDownloadTemplate: () => void; + isBusy: boolean; +}; + +/** First step of an import: what the file must hold, the template, and the file to choose. */ +export function PlaceImportFilePicker({ + onFile, + onDownloadTemplate, + isBusy, +}: PlaceImportFilePickerProps) { + const input = useRef(null); + return ( +
+
    + {texts.rules.map((rule) => ( +
  • {rule}
  • + ))} +
+
+ + +
+ { + const file = event.target.files?.[0]; + // Emptied so that choosing the same file again, once corrected, is read again. + event.target.value = ''; + if (file) { + onFile(file); + } + }} + /> +
+ ); +} diff --git a/apps/admin/src/components/catalog/PlaceImportOutcomeBadge.tsx b/apps/admin/src/components/catalog/PlaceImportOutcomeBadge.tsx new file mode 100644 index 00000000..6592982d --- /dev/null +++ b/apps/admin/src/components/catalog/PlaceImportOutcomeBadge.tsx @@ -0,0 +1,21 @@ +import { messages } from '@/messages/fr'; +import type { PlaceImportOutcome } from '@widoo/shared'; +import { StatusBadge } from '@widoo/ui'; +import type { ComponentProps } from 'react'; + +const tones: Record['tone']> = { + ready: 'info', + created: 'success', + invalid: 'error', + duplicate: 'alert', + outside: 'alert', +}; + +/** What became of a line of an import: « Prêt », « Créé », « Invalide », « Doublon »… */ +export function PlaceImportOutcomeBadge({ outcome }: { outcome: PlaceImportOutcome }) { + return ( + + {messages.catalog.places.import.outcomes[outcome]} + + ); +} diff --git a/apps/admin/src/components/catalog/PlaceImportTable.tsx b/apps/admin/src/components/catalog/PlaceImportTable.tsx new file mode 100644 index 00000000..a8216f35 --- /dev/null +++ b/apps/admin/src/components/catalog/PlaceImportTable.tsx @@ -0,0 +1,51 @@ +import type { PlaceImportLine } from '@/lib/place-import'; +import { messages } from '@/messages/fr'; +import { Table, TableCell, TableHeaderCell, TableRow } from '@widoo/ui'; +import { PlaceImportOutcomeBadge } from './PlaceImportOutcomeBadge'; + +const texts = messages.catalog.places.import; +const fieldLabels: Record = texts.fields; + +/** Why a line is set aside, or what happens to it, in a sentence. */ +function explanationOf(line: PlaceImportLine): string { + if (line.outcome === 'invalid') { + const fields = line.fields.map((field) => fieldLabels[field] ?? texts.otherField); + return texts.explanations.invalid([...new Set(fields)].join(', ')); + } + if (line.outcome === 'duplicate') { + return line.duplicateOf?.line + ? texts.explanations.duplicateInFile(line.duplicateOf.line) + : texts.explanations.duplicateInCatalog(line.duplicateOf?.name ?? line.name); + } + return texts.explanations[line.outcome]; +} + +/** The lines of an imported file, one per row: its number, its place, its outcome and why. */ +export function PlaceImportTable({ lines }: { lines: PlaceImportLine[] }) { + return ( + + + + {texts.columns.line} + {texts.columns.place} + {texts.columns.outcome} + {texts.columns.explanation} + + + + {lines.map((line) => ( + + {line.line} + + {line.name || {texts.noName}} + + + + + {explanationOf(line)} + + ))} + +
+ ); +} diff --git a/apps/admin/src/components/catalog/RepublishDialog.tsx b/apps/admin/src/components/catalog/RepublishDialog.tsx new file mode 100644 index 00000000..29aaa328 --- /dev/null +++ b/apps/admin/src/components/catalog/RepublishDialog.tsx @@ -0,0 +1,49 @@ +import { messages } from '@/messages/fr'; +import { ConfirmDialog, Field, Textarea } from '@widoo/ui'; + +const texts = messages.catalog.routes.republishDialog; + +type RepublishDialogProps = { + isOpen: boolean; + message: string; + onMessageChange: (message: string) => void; + onCancel: () => void; + onConfirm: () => void; + isBusy: boolean; +}; + +/** « Republier »: why the route goes back online, required, kept in its history. */ +export function RepublishDialog({ + isOpen, + message, + onMessageChange, + onCancel, + onConfirm, + isBusy, +}: RepublishDialogProps) { + return ( + + +