rider partner page

This commit is contained in:
2026-09-09 15:43:23 +05:30
parent 32c612a10d
commit b760c1a078
18 changed files with 1802 additions and 63 deletions

View File

@@ -0,0 +1,209 @@
import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { Bike, Check, Truck } from 'lucide-react';
import { errorMessage } from '@/api/client';
import { tenantsApi } from '@/api/tenants';
import { useAllPartners, useAppRegions } from '@/queries/hooks';
import { queryKeys } from '@/queries/keys';
import { Drawer } from '@/features/store-admin/Drawer';
import { DrawerButton, DrawerCard, Note, Row, Section } from '@/features/store-admin/drawerKit';
/**
* Which delivery partner supplies a merchant's riders.
*
* ── Why this is the platform's decision ─────────────────────────────────────
*
* `partnerid` is kept out of the merchant-editable allowlist on the server, so
* this cannot be done from the shop's own profile — a merchant who could set it
* would move themselves under another partner's riders and billing. It is
* changed here, by whoever is looking at that merchant's record.
*
* ── Why "own riders" is an option and not an absence ────────────────────────
*
* Sending `partnerid: 0` is a real instruction: it means the shop delivers with
* riders it hired itself. The server reads a zero as sent rather than as a
* missing field, which is exactly why the endpoint is separate — everywhere
* else in the tenant API a zero means "not supplied", and there it would
* silently unassign somebody.
*
* One partner per merchant, which is what `tenants.partnerid` allows and what
* the assign picker later branches on. A partner serving several merchants is
* the ordinary case in the other direction: 44 supplies 48 shops.
*/
export function PartnerAssignDrawer({
tenantid,
tenantname,
currentPartnerId,
onClose,
}: {
tenantid: number;
tenantname: string;
currentPartnerId: number;
onClose: () => void;
}) {
const client = useQueryClient();
const partners = useAllPartners();
const regions = useAppRegions();
const [chosen, setChosen] = useState<number>(currentPartnerId);
const [error, setError] = useState<string | null>(null);
const regionName = useMemo(() => {
const map = new Map<number, string>();
for (const region of regions.data ?? []) {
map.set(region.applocationid, region.locationname ?? `Region ${region.applocationid}`);
}
return map;
}, [regions.data]);
const save = useMutation({
mutationFn: () => tenantsApi.assignPartner(tenantid, chosen),
onSuccess: async () => {
await client.invalidateQueries({ queryKey: queryKeys.tenants.all });
onClose();
},
onError: (cause) => setError(errorMessage(cause)),
});
const current = partners.data.find((p) => p.partnerid === currentPartnerId);
return (
<Drawer
title="Delivery partner"
subtitle={tenantname}
width={460}
onClose={onClose}
isFooterSpread
footer={
<>
<DrawerButton label="Cancel" variant="ghost" onClick={onClose} />
<DrawerButton
label={save.isPending ? 'Saving…' : 'Save'}
variant="primary"
icon={<Truck size={15} />}
isDisabled={save.isPending || chosen === currentPartnerId}
onClick={() => save.mutate()}
/>
</>
}
>
<VStack gap={2}>
{error ? (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
{error}
</Text>
) : null}
<Note icon={<Bike size={15} />}>
A partner supplies riders to this shop. With one set, the assign screen offers the
partner’s riders alongside any the shop hired itself; without one, only its own.
</Note>
<Section title="Currently">
<DrawerCard>
<Row
label="Partner"
value={current?.partnername ?? (currentPartnerId > 0 ? `Partner ${currentPartnerId}` : 'Own riders only')}
/>
</DrawerCard>
</Section>
<Section title="Change to">
<DrawerCard>
{/* "Own riders" first and always present. It is not the empty state
— a shop that hires its own riders is a real arrangement, and
the option has to be as reachable as any partner. */}
<Choice
label="Own riders only"
detail="This shop delivers with riders it hired itself."
isChosen={chosen === 0}
onChoose={() => setChosen(0)}
/>
{partners.isLoading ? (
<Row label="Partners" value="Reading…" />
) : (
partners.data.map((partner) => (
<Choice
key={partner.partnerid}
label={partner.partnername ?? `Partner ${partner.partnerid}`}
detail={
[
regionName.get(partner.applocationid ?? 0),
partner.companyname,
]
.filter(Boolean)
.join(' · ') || 'No region recorded'
}
isChosen={chosen === partner.partnerid}
onChoose={() => setChosen(partner.partnerid)}
/>
))
)}
</DrawerCard>
</Section>
</VStack>
</Drawer>
);
}
function Choice({
label,
detail,
isChosen,
onChoose,
}: {
label: string;
detail: string;
isChosen: boolean;
onChoose: () => void;
}) {
return (
<button
type="button"
onClick={onChoose}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
width: '100%',
padding: '12px 14px',
border: 0,
borderTop: '1px solid var(--color-line)',
background: isChosen ? 'var(--color-brand-tint)' : 'transparent',
cursor: 'pointer',
textAlign: 'left',
}}
>
<span
aria-hidden
style={{
width: 18,
height: 18,
flex: 'none',
borderRadius: 999,
display: 'grid',
placeItems: 'center',
border: isChosen ? 0 : '1.5px solid var(--color-line)',
background: isChosen ? 'var(--color-brand)' : 'transparent',
color: '#fff',
}}
>
{isChosen ? <Check size={11} strokeWidth={3} /> : null}
</span>
<span style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
<span
style={{
font: '500 13.5px/1.4 var(--font-sans)',
color: isChosen ? 'var(--color-brand)' : 'var(--color-ink-1)',
}}
>
{label}
</span>
<span style={{ font: '400 12px/1.4 var(--font-sans)', color: 'var(--color-ink-3)' }}>
{detail}
</span>
</span>
</button>
);
}