Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a0411d4
feat(api): serve the figures of the dashboard, by role
Nascimento95 Oct 13, 2026
0c416e3
feat(admin): show the dashboard with what waits, the catalog and the …
Nascimento95 Oct 13, 2026
0da8fd9
fix(api): name who decided in the history of a route, even without a …
Nascimento95 Oct 13, 2026
cab7cd8
chore(api): share the page cursors of the admin lists
Nascimento95 Oct 13, 2026
15c9dce
feat(api): list the routes of the catalog, republish one and hand it …
Nascimento95 Oct 13, 2026
4d8e24d
test(api): cover the catalog routes, their roles and their journal
Nascimento95 Oct 13, 2026
a8227a2
feat(api-client): read the catalog routes and send their actions
Nascimento95 Oct 13, 2026
b2b901b
chore(admin): share the route action hook, the status badge and the r…
Nascimento95 Oct 13, 2026
680fa2f
feat(admin): list the routes of the catalog with search, filters and …
Nascimento95 Oct 13, 2026
12d69e4
feat(admin): unpublish, republish and reassign a route from its sheet…
Nascimento95 Oct 13, 2026
e92b418
feat(api): read the places of the catalog and the sheet of a place
Nascimento95 Oct 13, 2026
a61046c
feat(api): create and edit a place by hand, with its hours and its jo…
Nascimento95 Oct 13, 2026
53b211e
test(api): cover the places of the catalog, their roles and their jou…
Nascimento95 Oct 13, 2026
63a942c
feat(api-client): read and write the places of the catalog
Nascimento95 Oct 13, 2026
7b4a5d5
feat(admin): read the hours and the form of a place, and map the plac…
Nascimento95 Oct 13, 2026
48989b7
feat(admin): show the sheet of a place in the detail panel (A-04)
Nascimento95 Oct 13, 2026
98b588d
feat(admin): list the places of the catalog under their own tab (A-04)
Nascimento95 Oct 13, 2026
9411fb6
feat(admin): create and edit a place in a form, for editors and admin…
Nascimento95 Oct 13, 2026
6107218
fix(admin): refuse the pages of a section the role does not open
Nascimento95 Oct 13, 2026
efa0e3d
feat(api): import places row by row, with a preview, for admins (A-04)
Nascimento95 Oct 13, 2026
bfe4d09
feat(admin): read the places of a CSV file and join the answer of the…
Nascimento95 Oct 13, 2026
8a51d21
feat(admin): show the lines of an imported file with their outcome (A…
Nascimento95 Oct 13, 2026
b99fa34
feat(admin): import places from a CSV file on its own page, for admin…
Nascimento95 Oct 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/admin/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
12 changes: 12 additions & 0 deletions apps/admin/src/app/(shell)/catalogue/lieux/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<PlaceWriteGate>
<PlaceEditContainer key={id} placeId={id} />
</PlaceWriteGate>
);
}
13 changes: 13 additions & 0 deletions apps/admin/src/app/(shell)/catalogue/lieux/import/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AdminOnlyGate backHref={catalogPaths.places} backLabel={messages.catalog.places.form.back}>
<PlaceImportContainer />
</AdminOnlyGate>
);
}
11 changes: 11 additions & 0 deletions apps/admin/src/app/(shell)/catalogue/lieux/nouveau/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<PlaceWriteGate>
<PlaceFormContainer place={null} />
</PlaceWriteGate>
);
}
15 changes: 15 additions & 0 deletions apps/admin/src/app/(shell)/catalogue/lieux/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-16">
<CatalogTabs current="places" />
<Suspense>
<CatalogPlacesContainer />
</Suspense>
</div>
);
}
7 changes: 7 additions & 0 deletions apps/admin/src/app/(shell)/catalogue/page.tsx
Original file line number Diff line number Diff line change
@@ -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);
}
11 changes: 11 additions & 0 deletions apps/admin/src/app/(shell)/catalogue/parcours/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <CatalogRouteSheetContainer key={id} routeId={id} />;
}
15 changes: 15 additions & 0 deletions apps/admin/src/app/(shell)/catalogue/parcours/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-16">
<CatalogTabs current="routes" />
<Suspense>
<CatalogRoutesContainer />
</Suspense>
</div>
);
}
9 changes: 4 additions & 5 deletions apps/admin/src/app/(shell)/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <DashboardContainer />;
}
39 changes: 39 additions & 0 deletions apps/admin/src/components/catalog/CatalogPlacesFilters.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div role="group" aria-label={texts.label} className="flex flex-wrap gap-8">
<FilterMenu
label={texts.category}
options={categories}
value={category}
onChange={onCategoryChange}
/>
<FilterMenu
label={texts.status}
options={statuses}
value={status}
onChange={onStatusChange}
/>
</div>
);
}
89 changes: 89 additions & 0 deletions apps/admin/src/components/catalog/CatalogPlacesTable.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<TableHeaderCell aria-sort={ariaSort} className={className}>
<SortableColumnHeader direction={direction} onSort={() => onSort(column)}>
{label}
</SortableColumnHeader>
</TableHeaderCell>
);
};

return (
<Table>
<thead>
<tr>
{renderSortableHeader('lieu', texts.columns.place)}
{renderSortableHeader('categorie', texts.columns.category)}
{renderSortableHeader('ville', texts.columns.city)}
<TableHeaderCell>{texts.columns.status}</TableHeaderCell>
{renderSortableHeader('verifie', texts.columns.verified)}
{renderSortableHeader('parcours', texts.columns.routes, 'text-right')}
</tr>
</thead>
<tbody>
{isLoading ? (
<TableSkeleton rows={8} columns={columnCount} label={messages.list.loading} />
) : (
items.map((place) => (
<TableRow key={place.id} onOpen={() => onOpen(place)} isOpen={place.id === openId}>
<TableCell isStrong>{place.name}</TableCell>
<TableCell>{labels.fr.placeCategories[place.category]}</TableCell>
<TableCell>{place.cityName}</TableCell>
<TableCell>
<VerificationBadge status={place.verificationStatus} />
</TableCell>
<TableCell>
{place.verifiedAt ? (
formatRelativeDate(new Date(place.verifiedAt))
) : (
<span className="text-muted-foreground">{texts.neverVerified}</span>
)}
</TableCell>
<TableCell className="text-right">{place.routeCount}</TableCell>
</TableRow>
))
)}
</tbody>
</Table>
);
}
59 changes: 59 additions & 0 deletions apps/admin/src/components/catalog/CatalogRouteActions.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SideCard title={texts.actions}>
{hasAction ? (
<div className="flex flex-col gap-8">
{onRepublish ? (
<Button onClick={onRepublish} disabled={isBusy}>
<ArrowCounterClockwiseIcon aria-hidden className="size-18" />
{texts.republish}
</Button>
) : null}
{onReassign ? (
<Button variant="outline" onClick={onReassign} disabled={isBusy}>
<UsersThreeIcon aria-hidden className="size-18" />
{texts.reassign}
</Button>
) : null}
{onUnpublish ? (
<Button variant="destructiveOutline" onClick={onUnpublish} disabled={isBusy}>
<ProhibitIcon aria-hidden className="size-18" />
{texts.unpublish}
</Button>
) : null}
</div>
) : (
<p className="text-body-s text-muted-foreground">{texts.noAction}</p>
)}
</SideCard>
);
}
39 changes: 39 additions & 0 deletions apps/admin/src/components/catalog/CatalogRoutesFilters.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div role="group" aria-label={texts.label} className="flex flex-wrap gap-8">
<FilterMenu
label={texts.status}
options={statuses}
value={status}
onChange={onStatusChange}
/>
<FilterMenu
label={texts.access}
options={accesses}
value={access}
onChange={onAccessChange}
/>
</div>
);
}
Loading
Loading