Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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 @@ -18,6 +18,11 @@ NEXT_PUBLIC_FIREBASE_PROJECT_ID=demo-widoo
# and production.
NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL=http://127.0.0.1:9099

# Origin of the public site (https://host, without path): where the pages of the B2B partners and
# the link of the stores live, written in the links and QR codes of A-10. Locally,
# http://localhost:3000 if the site runs there. Empty: A-10 says it is not set, and gives no link.
NEXT_PUBLIC_SITE_URL=

# 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=
Expand Down
11 changes: 11 additions & 0 deletions apps/admin/src/app/(shell)/partenaires/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { PartnersContainer } from '@/containers/PartnersContainer';
import { Suspense } from 'react';

/** A-10, the B2B partners. The open partner is read from the address, in the browser. */
export default function PartnersPage() {
return (
<Suspense>
<PartnersContainer />
</Suspense>
);
}
21 changes: 21 additions & 0 deletions apps/admin/src/components/b2b/PartnerActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { messages } from '@/messages/fr';
import { Button } from '@widoo/ui';

const texts = messages.b2b.panel;

type PartnerActionsProps = {
onEdit: () => void;
onChooseRoutes: () => void;
};

/** The actions of the card of a partner (A-10): change it, choose the routes of its page. */
export function PartnerActions({ onEdit, onChooseRoutes }: PartnerActionsProps) {
return (
<>
<Button onClick={onEdit}>{texts.edit}</Button>
<Button variant="secondary" onClick={onChooseRoutes}>
{texts.chooseRoutes}
</Button>
</>
);
}
162 changes: 162 additions & 0 deletions apps/admin/src/components/b2b/PartnerFormDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { monthlyPricePattern, type PartnerFormValues } from '@/lib/partner-form';
import { messages } from '@/messages/fr';
import { b2bClientStatuses, b2bClientTypes } from '@widoo/shared';
import { ConfirmDialog, Field, Input, Select, Textarea } from '@widoo/ui';

const texts = messages.b2b.form;

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

/** « Ajouter un partenaire » or « Modifier le partenaire »: its contact, contract and notes. */
export function PartnerFormDialog({
isOpen,
isEdit,
values,
onChange,
onCancel,
onConfirm,
isBusy,
}: PartnerFormDialogProps) {
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="partner-name" isRequired>
<Input
id="partner-name"
required
maxLength={100}
value={values.name}
onChange={(event) => onChange('name', event.target.value)}
/>
</Field>
<Field label={texts.slug} htmlFor="partner-slug" isRequired hint={texts.slugHint}>
<Input
id="partner-slug"
required
readOnly={isEdit}
minLength={3}
maxLength={60}
pattern="[a-z0-9]+(-[a-z0-9]+)*"
aria-describedby="partner-slug-hint"
value={values.slug}
onChange={(event) => onChange('slug', event.target.value)}
/>
</Field>
<Field label={texts.type} htmlFor="partner-type" isRequired>
<Select
id="partner-type"
required
value={values.type}
onChange={(event) =>
onChange('type', b2bClientTypes.find((type) => type === event.target.value) ?? 'other')
}
>
{b2bClientTypes.map((type) => (
<option key={type} value={type}>
{messages.b2b.types[type]}
</option>
))}
</Select>
</Field>
<Field label={texts.status} htmlFor="partner-status" isRequired hint={texts.statusHint}>
<Select
id="partner-status"
required
aria-describedby="partner-status-hint"
value={values.status}
onChange={(event) =>
onChange(
'status',
b2bClientStatuses.find((status) => status === event.target.value) ?? 'prospect',
)
}
>
{b2bClientStatuses.map((status) => (
<option key={status} value={status}>
{messages.b2b.statuses[status]}
</option>
))}
</Select>
</Field>
<Field label={texts.contactName} htmlFor="partner-contact-name">
<Input
id="partner-contact-name"
maxLength={100}
value={values.contactName}
onChange={(event) => onChange('contactName', event.target.value)}
/>
</Field>
<Field label={texts.contactEmail} htmlFor="partner-contact-email">
<Input
id="partner-contact-email"
type="email"
maxLength={254}
value={values.contactEmail}
onChange={(event) => onChange('contactEmail', event.target.value)}
/>
</Field>
<Field label={texts.contactPhone} htmlFor="partner-contact-phone">
<Input
id="partner-contact-phone"
type="tel"
maxLength={30}
value={values.contactPhone}
onChange={(event) => onChange('contactPhone', event.target.value)}
/>
</Field>
<Field label={texts.address} htmlFor="partner-address">
<Input
id="partner-address"
maxLength={300}
value={values.address}
onChange={(event) => onChange('address', event.target.value)}
/>
</Field>
<Field label={texts.price} htmlFor="partner-price" hint={texts.priceHint}>
<Input
id="partner-price"
inputMode="decimal"
pattern={monthlyPricePattern}
aria-describedby="partner-price-hint"
value={values.monthlyPrice}
onChange={(event) => onChange('monthlyPrice', event.target.value)}
/>
</Field>
<Field label={texts.started} htmlFor="partner-started">
<Input
id="partner-started"
type="date"
value={values.startedAt}
onChange={(event) => onChange('startedAt', event.target.value)}
/>
</Field>
<Field label={texts.notes} htmlFor="partner-notes">
<Textarea
id="partner-notes"
maxLength={2000}
value={values.notes}
onChange={(event) => onChange('notes', event.target.value)}
/>
</Field>
</ConfirmDialog>
);
}
75 changes: 75 additions & 0 deletions apps/admin/src/components/b2b/PartnerLinks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { messages } from '@/messages/fr';
import type { B2bClientStatus } from '@widoo/shared';

const texts = messages.b2b.links;

export type PartnerLink = {
/** What the link opens: « Page du partenaire », « Application ». */
label: string;
url: string;
/** The QR code of the link as an image; null while it is drawn. */
qrCodeImage: string | null;
fileName: string;
};

type PartnerLinksProps = {
/** Null when the origin of the site is not set: there is nothing to link to. */
links: PartnerLink[] | null;
status: B2bClientStatus;
};

/** The links and QR codes of a partner (A-10): its page, and the stores with its source of measure. */
export function PartnerLinks({ links, status }: PartnerLinksProps) {
return (
<section className="flex flex-col gap-8">
<h3 className="text-label-strong">{texts.title}</h3>
{links === null ? (
<p className="text-body-s text-muted-foreground">{texts.siteUrlMissing}</p>
) : (
<>
<p className="text-label text-muted-foreground">{texts.pageHint[status]}</p>
<ul className="flex flex-col gap-16">
{links.map((link) => (
<li key={link.url} className="flex items-start gap-12">
{link.qrCodeImage ? (
// The QR code is a `data:` image of our own: nothing to optimise or to fetch.
// eslint-disable-next-line @next/next/no-img-element
<img
src={link.qrCodeImage}
alt={texts.qrCodeOf(link.label)}
className="h-qr w-qr shrink-0 rounded-photo"
/>
) : (
<span
aria-hidden
className="h-qr w-qr shrink-0 animate-pulse rounded-photo bg-skeleton"
/>
)}
<span className="flex min-w-0 flex-1 flex-col gap-4 text-body-s">
<span className="text-label-strong">{link.label}</span>
<a
href={link.url}
target="_blank"
rel="noreferrer"
className="break-all text-blue-ink underline"
>
{link.url}
</a>
{link.qrCodeImage ? (
<a
href={link.qrCodeImage}
download={link.fileName}
className="text-blue-ink underline"
>
{texts.download}
</a>
) : null}
</span>
</li>
))}
</ul>
</>
)}
</section>
);
}
74 changes: 74 additions & 0 deletions apps/admin/src/components/b2b/PartnerPanelContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { dayLabelOf, monthlyPriceLabelOf } from '@/lib/partner-display';
import { messages } from '@/messages/fr';
import type { B2bClient } from '@widoo/shared';
import { StatusBadge } from '@widoo/ui';
import Link from 'next/link';
import type { ReactNode } from 'react';
import { PartnerStatusBadge } from './PartnerStatusBadge';

const texts = messages.b2b.panel;

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>
);
}

const noValue = <span className="text-muted-foreground">{messages.b2b.noValue}</span>;

type PartnerPanelContentProps = {
partner: B2bClient;
/** Address of the sheet of a route, to open it from the card. */
routePathOf: (routeId: string) => string;
};

/** The card of a partner in the detail panel (A-10): contact, contract, notes and routes. */
export function PartnerPanelContent({ partner, routePathOf }: PartnerPanelContentProps) {
return (
<div className="flex flex-col gap-16">
<dl className="flex flex-col gap-8">
<Line label={texts.type}>{messages.b2b.types[partner.type]}</Line>
<Line label={texts.status}>
<PartnerStatusBadge status={partner.status} />
</Line>
<Line label={texts.contactName}>{partner.contactName ?? noValue}</Line>
<Line label={texts.contactEmail}>{partner.contactEmail ?? noValue}</Line>
<Line label={texts.contactPhone}>{partner.contactPhone ?? noValue}</Line>
<Line label={texts.address}>{partner.address ?? noValue}</Line>
<Line label={texts.price}>{monthlyPriceLabelOf(partner.monthlyPriceCents) ?? noValue}</Line>
<Line label={texts.started}>{dayLabelOf(partner.startedAt) ?? noValue}</Line>
<Line label={texts.source}>
<span className="text-label">{partner.source}</span>
</Line>
</dl>
{partner.notes ? (
<section className="flex flex-col gap-8">
<h3 className="text-label-strong">{texts.notes}</h3>
<p className="whitespace-pre-line break-words text-body-s">{partner.notes}</p>
</section>
) : null}
<section className="flex flex-col gap-8">
<h3 className="text-label-strong">{texts.routes}</h3>
{partner.routes.length === 0 ? (
<p className="text-body-s text-muted-foreground">{texts.noRoute}</p>
) : (
<ol className="flex flex-col gap-8">
{partner.routes.map((route) => (
<li key={route.id} className="flex items-center justify-between gap-8 text-body-s">
<Link href={routePathOf(route.id)} className="text-blue-ink underline">
{texts.routePosition(route.position + 1)} {route.title}
</Link>
{route.isPublished ? null : (
<StatusBadge tone="neutral">{texts.notPublished}</StatusBadge>
)}
</li>
))}
</ol>
)}
</section>
</div>
);
}
Loading
Loading