Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions apps/admin/src/app/(shell)/affiliation/etapes/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { AffiliationTabs } from '@/components/affiliation/AffiliationTabs';
import { BookableStepsContainer } from '@/containers/BookableStepsContainer';
import { Suspense } from 'react';

/** A-09, the steps that can be booked. The filter and the page are read from the address. */
export default function BookableStepsPage() {
return (
<div className="flex flex-col gap-16">
<AffiliationTabs current="steps" />
<Suspense>
<BookableStepsContainer />
</Suspense>
</div>
);
}
7 changes: 7 additions & 0 deletions apps/admin/src/app/(shell)/affiliation/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { affiliationPaths } from '@/components/affiliation/AffiliationTabs';
import { redirect } from 'next/navigation';

/** A-09 opens on the programs. */
export default function AffiliationPage() {
redirect(affiliationPaths.programs);
}
15 changes: 15 additions & 0 deletions apps/admin/src/app/(shell)/affiliation/programmes/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { AffiliationTabs } from '@/components/affiliation/AffiliationTabs';
import { ProgramsContainer } from '@/containers/ProgramsContainer';
import { Suspense } from 'react';

/** A-09, the affiliate programs. The open program is read from the address, in the browser. */
export default function ProgramsPage() {
return (
<div className="flex flex-col gap-16">
<AffiliationTabs current="programs" />
<Suspense>
<ProgramsContainer />
</Suspense>
</div>
);
}
25 changes: 25 additions & 0 deletions apps/admin/src/components/affiliation/AffiliationTabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { SectionTabs } from '@/components/navigation/SectionTabs';
import { sectionPaths } from '@/config/navigation';
import { messages } from '@/messages/fr';

const texts = messages.affiliation.tabs;

/** Addresses of the two lists of the affiliation (A-09). */
export const affiliationPaths = {
programs: `${sectionPaths.affiliation}/programmes`,
steps: `${sectionPaths.affiliation}/etapes`,
} as const;

/** The two lists of A-09 as tabs: the programs, then the bookable steps. */
export function AffiliationTabs({ current }: { current: keyof typeof affiliationPaths }) {
return (
<SectionTabs
label={texts.label}
current={current}
tabs={[
{ id: 'programs', label: texts.programs, href: affiliationPaths.programs },
{ id: 'steps', label: texts.steps, href: affiliationPaths.steps },
]}
/>
);
}
79 changes: 79 additions & 0 deletions apps/admin/src/components/affiliation/BookableStepsTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { catalogPaths } from '@/components/catalog/CatalogTabs';
import { messages } from '@/messages/fr';
import type { BookableStep } from '@widoo/shared';
import { StatusBadge, Table, TableCell, TableHeaderCell, TableRow, TableSkeleton } from '@widoo/ui';
import Link from 'next/link';

const texts = messages.affiliation.steps;
const columnCount = 4;

type BookableStepsTableProps = {
/** Rows of the page; ignored while `isLoading`. */
items: BookableStep[];
isLoading: boolean;
};

/** The host of a link, to show where it leads without the whole address. */
const hostOf = (url: string) => new URL(url).host;

/** The steps that can be booked as a table (A-09): the link shown, its origin and its clicks. */
export function BookableStepsTable({ items, isLoading }: BookableStepsTableProps) {
return (
<Table>
<thead>
<tr>
<TableHeaderCell>{texts.columns.route}</TableHeaderCell>
<TableHeaderCell>{texts.columns.place}</TableHeaderCell>
<TableHeaderCell>{texts.columns.link}</TableHeaderCell>
<TableHeaderCell className="text-right">{texts.columns.clicks}</TableHeaderCell>
</tr>
</thead>
<tbody>
{isLoading ? (
<TableSkeleton rows={8} columns={columnCount} label={messages.list.loading} />
) : (
items.map((step) => (
<TableRow key={step.stepId}>
<TableCell className="py-8">
<span className="flex flex-col">
<span className="text-control">{step.route.title}</span>
<span className="text-label text-muted-foreground">
{texts.stepOf(step.position)}
</span>
</span>
</TableCell>
<TableCell className="py-8">
<span className="flex flex-col items-start gap-4">
<Link
href={`${catalogPaths.places}/${step.place.id}`}
className="text-blue-ink underline"
>
{step.place.name}
</Link>
<span className="text-label text-muted-foreground">
{step.place.hasPartnerLink ? texts.hasPartner : texts.addPartner}
</span>
</span>
</TableCell>
<TableCell className="py-8">
{step.link ? (
<span className="flex flex-col items-start gap-4">
<StatusBadge tone={step.link.origin === 'partner' ? 'info' : 'neutral'}>
{texts.origins[step.link.origin]}
</StatusBadge>
<span className="max-w-dialog truncate text-label text-muted-foreground">
{hostOf(step.link.url)}
</span>
</span>
) : (
<span className="text-muted-foreground">{texts.noLink}</span>
)}
</TableCell>
<TableCell className="text-right">{step.clicks30Days}</TableCell>
</TableRow>
))
)}
</tbody>
</Table>
);
}
30 changes: 30 additions & 0 deletions apps/admin/src/components/affiliation/ProgramActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { messages } from '@/messages/fr';
import { Button } from '@widoo/ui';

const texts = messages.affiliation.programs.panel;

type ProgramActionsProps = {
isActive: boolean;
onEdit: () => void;
/** Turns the program off, or back on. */
onToggleActive: () => void;
isBusy: boolean;
};

/** The actions of the card of a program (A-09): change it, turn it off or on. */
export function ProgramActions({ isActive, onEdit, onToggleActive, isBusy }: ProgramActionsProps) {
return (
<>
<Button onClick={onEdit} disabled={isBusy}>
{texts.edit}
</Button>
<Button
variant={isActive ? 'destructiveOutline' : 'secondary'}
onClick={onToggleActive}
disabled={isBusy}
>
{isActive ? texts.deactivate : texts.activate}
</Button>
</>
);
}
86 changes: 86 additions & 0 deletions apps/admin/src/components/affiliation/ProgramFormDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { ProgramFormValues } from '@/lib/program-form';
import { messages } from '@/messages/fr';
import { Checkbox, ConfirmDialog, Field, Input } from '@widoo/ui';

const texts = messages.affiliation.programs.form;

type ProgramFormDialogProps = {
isOpen: boolean;
/** True when it changes a program; false when it adds one. */
isEdit: boolean;
values: ProgramFormValues;
onChange: <Key extends keyof ProgramFormValues>(key: Key, value: ProgramFormValues[Key]) => void;
onCancel: () => void;
onConfirm: () => void;
isBusy: boolean;
};

/** « Ajouter un programme » or « Modifier le programme »: its name, network, commission and cookie. */
export function ProgramFormDialog({
isOpen,
isEdit,
values,
onChange,
onCancel,
onConfirm,
isBusy,
}: ProgramFormDialogProps) {
return (
<ConfirmDialog
isOpen={isOpen}
onCancel={onCancel}
onConfirm={onConfirm}
title={isEdit ? texts.editTitle : texts.createTitle}
description={texts.description}
cancelLabel={messages.confirm.cancel}
closeLabel={messages.confirm.close}
confirmLabel={isEdit ? texts.save : texts.create}
confirmVariant="primary"
isBusy={isBusy}
>
<Field label={texts.name} htmlFor="program-name" isRequired>
<Input
id="program-name"
required
maxLength={100}
value={values.name}
onChange={(event) => onChange('name', event.target.value)}
/>
</Field>
<Field label={texts.network} htmlFor="program-network" isRequired hint={texts.networkHint}>
<Input
id="program-network"
required
maxLength={100}
aria-describedby="program-network-hint"
value={values.network}
onChange={(event) => onChange('network', event.target.value)}
/>
</Field>
<Field label={texts.commission} htmlFor="program-commission" hint={texts.commissionHint}>
<Input
id="program-commission"
maxLength={200}
aria-describedby="program-commission-hint"
value={values.commissionNote}
onChange={(event) => onChange('commissionNote', event.target.value)}
/>
</Field>
<Field label={texts.cookieDays} htmlFor="program-cookie" isRequired hint={texts.cookieHint}>
<Input
id="program-cookie"
required
inputMode="numeric"
aria-describedby="program-cookie-hint"
value={values.cookieDays}
onChange={(event) => onChange('cookieDays', event.target.value)}
/>
</Field>
<Checkbox
label={texts.isActive}
checked={values.isActive}
onChange={(event) => onChange('isActive', event.target.checked)}
/>
</ConfirmDialog>
);
}
44 changes: 44 additions & 0 deletions apps/admin/src/components/affiliation/ProgramPanelContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { formatFullDate } from '@/lib/dates';
import { messages } from '@/messages/fr';
import type { AffiliateProgram } from '@widoo/shared';
import type { ReactNode } from 'react';

const texts = messages.affiliation.programs;

function Line({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex gap-12 text-body-s">
<dt className="shrink-0 text-muted-foreground">{label}</dt>
<dd className="min-w-0 flex-1 break-words text-right">{children}</dd>
</div>
);
}

/** A program in the detail panel (A-09): its network, commission, cookie and the places it serves. */
export function ProgramPanelContent({ program }: { program: AffiliateProgram }) {
return (
<div className="flex flex-col gap-16">
{program.isActive ? null : (
<p className="rounded-photo bg-alert-soft p-12 text-body-s text-alert">
{texts.panel.inactiveHint}
</p>
)}
<dl className="flex flex-col gap-8">
<Line label={texts.panel.network}>{program.network}</Line>
<Line label={texts.panel.commission}>
{program.commissionNote ?? (
<span className="text-muted-foreground">{texts.noCommission}</span>
)}
</Line>
<Line label={texts.panel.cookie}>{texts.cookieDays(program.cookieDays)}</Line>
<Line label={texts.panel.places}>{program.placeCount}</Line>
<Line label={texts.panel.status}>
{program.isActive ? texts.status.active : texts.status.inactive}
</Line>
<Line label={messages.users.panel.joined}>
{formatFullDate(new Date(program.createdAt))}
</Line>
</dl>
</div>
);
}
61 changes: 61 additions & 0 deletions apps/admin/src/components/affiliation/ProgramsTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { messages } from '@/messages/fr';
import type { AffiliateProgram } from '@widoo/shared';
import { StatusBadge, Table, TableCell, TableHeaderCell, TableRow, TableSkeleton } from '@widoo/ui';

const texts = messages.affiliation.programs;
const columnCount = 6;

type ProgramsTableProps = {
/** The programs; ignored while `isLoading`. */
items: AffiliateProgram[];
isLoading: boolean;
/** The program whose detail is open in the panel. */
openId: string | null;
onOpen: (program: AffiliateProgram) => void;
};

/** The affiliate programs as a table (A-09); a row opens its detail. */
export function ProgramsTable({ items, isLoading, openId, onOpen }: ProgramsTableProps) {
return (
<Table>
<thead>
<tr>
<TableHeaderCell>{texts.columns.program}</TableHeaderCell>
<TableHeaderCell>{texts.columns.network}</TableHeaderCell>
<TableHeaderCell>{texts.columns.commission}</TableHeaderCell>
<TableHeaderCell className="text-right">{texts.columns.cookie}</TableHeaderCell>
<TableHeaderCell className="text-right">{texts.columns.places}</TableHeaderCell>
<TableHeaderCell>{texts.columns.status}</TableHeaderCell>
</tr>
</thead>
<tbody>
{isLoading ? (
<TableSkeleton rows={6} columns={columnCount} label={messages.list.loading} />
) : (
items.map((program) => (
<TableRow
key={program.id}
onOpen={() => onOpen(program)}
isOpen={program.id === openId}
>
<TableCell className="text-control">{program.name}</TableCell>
<TableCell>{program.network}</TableCell>
<TableCell>
{program.commissionNote ?? (
<span className="text-muted-foreground">{texts.noCommission}</span>
)}
</TableCell>
<TableCell className="text-right">{texts.cookieDays(program.cookieDays)}</TableCell>
<TableCell className="text-right">{program.placeCount}</TableCell>
<TableCell>
<StatusBadge tone={program.isActive ? 'success' : 'neutral'}>
{program.isActive ? texts.status.active : texts.status.inactive}
</StatusBadge>
</TableCell>
</TableRow>
))
)}
</tbody>
</Table>
);
}
Loading
Loading