Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
025732a
feat(api): serve the figures of the dashboard, by role
Nascimento95 Oct 13, 2026
4c58c56
feat(admin): show the dashboard with what waits, the catalog and the …
Nascimento95 Oct 13, 2026
780c52b
fix(api): name who decided in the history of a route, even without a …
Nascimento95 Oct 13, 2026
b06f661
chore(api): share the page cursors of the admin lists
Nascimento95 Oct 13, 2026
02db5a7
feat(api): list the routes of the catalog, republish one and hand it …
Nascimento95 Oct 13, 2026
c835545
test(api): cover the catalog routes, their roles and their journal
Nascimento95 Oct 13, 2026
4e548f2
feat(api-client): read the catalog routes and send their actions
Nascimento95 Oct 13, 2026
83bb991
chore(admin): share the route action hook, the status badge and the r…
Nascimento95 Oct 13, 2026
56b6289
feat(admin): list the routes of the catalog with search, filters and …
Nascimento95 Oct 13, 2026
38f9786
feat(admin): unpublish, republish and reassign a route from its sheet…
Nascimento95 Oct 13, 2026
4ab4ab6
feat(api): read the places of the catalog and the sheet of a place
Nascimento95 Oct 13, 2026
eeb105c
feat(api): create and edit a place by hand, with its hours and its jo…
Nascimento95 Oct 13, 2026
fa92dba
test(api): cover the places of the catalog, their roles and their jou…
Nascimento95 Oct 13, 2026
ccd46ab
feat(api-client): read and write the places of the catalog
Nascimento95 Oct 13, 2026
65dec86
feat(admin): read the hours and the form of a place, and map the plac…
Nascimento95 Oct 13, 2026
ee7b6ec
feat(admin): show the sheet of a place in the detail panel (A-04)
Nascimento95 Oct 13, 2026
920fd3b
feat(admin): list the places of the catalog under their own tab (A-04)
Nascimento95 Oct 13, 2026
99d0cea
feat(admin): create and edit a place in a form, for editors and admin…
Nascimento95 Oct 13, 2026
e13b3a8
fix(admin): refuse the pages of a section the role does not open
Nascimento95 Oct 13, 2026
f6162c1
feat(api): import places row by row, with a preview, for admins (A-04)
Nascimento95 Oct 13, 2026
da5e5e3
feat(admin): read the places of a CSV file and join the answer of the…
Nascimento95 Oct 13, 2026
5738853
feat(admin): show the lines of an imported file with their outcome (A…
Nascimento95 Oct 13, 2026
03e5f6b
feat(admin): import places from a CSV file on its own page, for admin…
Nascimento95 Oct 13, 2026
7c14e32
feat(admin): place a venue on a Mapbox map with a pin to move (A-04)
Nascimento95 Oct 14, 2026
52d1d10
feat(admin): open a route in the app from its sheet (A-04)
Nascimento95 Oct 14, 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
10 changes: 10 additions & 0 deletions apps/admin/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,13 @@ 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=

# Public token of Mapbox (`pk.…`) for the map of the form of a place (A-04). Create it in the
# Mapbox account of Widoo, restricted to the addresses of the admin (and http://localhost:5173
# for development). Empty: the position of a place is typed, without a map.
NEXT_PUBLIC_MAPBOX_TOKEN=
1 change: 1 addition & 0 deletions apps/admin/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const nextConfig: NextConfig = {
apiOrigin: env.NEXT_PUBLIC_API_URL,
authDomain: env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
authEmulatorOrigin: env.NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL,
hasMap: Boolean(env.NEXT_PUBLIC_MAPBOX_TOKEN),
}),
},
];
Expand Down
1 change: 1 addition & 0 deletions apps/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@widoo/api-client": "workspace:*",
"@widoo/shared": "workspace:*",
"@widoo/ui": "workspace:*",
"mapbox-gl": "^3.32.0",
"next": "16.3.8",
"qrcode": "^1.5.4",
"react": "19.2.3",
Expand Down
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>
);
}
70 changes: 70 additions & 0 deletions apps/admin/src/components/catalog/CatalogRouteActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { SideCard } from '@/components/moderation/sheet/SideCard';
import { messages } from '@/messages/fr';
import {
ArrowCounterClockwiseIcon,
DeviceMobileIcon,
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;
/** Address of the route in the app. */
appHref: string;
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. And the way to the
* route in the app, for whoever reads the admin on a phone that has it.
*/
export function CatalogRouteActions({
onUnpublish,
onRepublish,
onReassign,
appHref,
isBusy,
}: CatalogRouteActionsProps) {
const hasAction = Boolean(onUnpublish ?? onRepublish ?? onReassign);
return (
<SideCard title={texts.actions}>
<div className="flex flex-col gap-8">
{hasAction ? null : <p className="text-body-s text-muted-foreground">{texts.noAction}</p>}
{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}
<Button asChild variant="ghost">
<a href={appHref} aria-describedby="open-in-app-hint">
<DeviceMobileIcon aria-hidden className="size-18" />
{texts.openInApp}
</a>
</Button>
<p id="open-in-app-hint" className="text-label text-muted-foreground">
{texts.openInAppHint}
</p>
</div>
</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