180 lines
4.6 KiB
TypeScript
180 lines
4.6 KiB
TypeScript
import { useState, type ReactNode } from 'react';
|
|
import { ImageOff } from 'lucide-react';
|
|
|
|
/**
|
|
* The pieces every Store user page is built from.
|
|
*
|
|
* Copied — deliberately, and once — from the Store Admin pages rather than
|
|
* invented, so the two workspaces are the same product seen from two heights.
|
|
* The values are not arbitrary: 32px controls, radius 12, 13px labels, centred
|
|
* table cells and a soft chip in the row's own colour are the corrections the
|
|
* merchant pages already went through.
|
|
*/
|
|
|
|
/* The sixth and last copy of `TabButton` lived here, exported for the Store
|
|
user pages. Every one of them now imports `components/TabBar`. */
|
|
|
|
/** A header action drawn like the tabs, so the two rows read as one bar. */
|
|
export function BarAction({
|
|
label,
|
|
icon,
|
|
onClick,
|
|
isDisabled,
|
|
}: {
|
|
label: string;
|
|
icon: ReactNode;
|
|
onClick: () => void;
|
|
isDisabled?: boolean;
|
|
}) {
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
disabled={isDisabled}
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
onMouseLeave={() => setIsHovered(false)}
|
|
style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
height: 32,
|
|
padding: '0 12px',
|
|
borderRadius: 12,
|
|
border: 0,
|
|
background: isHovered && !isDisabled ? 'var(--color-surface-sunken)' : 'transparent',
|
|
color: isDisabled ? 'var(--color-ink-4)' : 'var(--color-ink-3)',
|
|
fontSize: 13,
|
|
fontWeight: 500,
|
|
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
|
transition: 'background .2s, color .2s',
|
|
}}
|
|
>
|
|
{icon}
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A column heading.
|
|
*
|
|
* `align` defaults to centre because every store-user table was built that
|
|
* way and changing them all is not this prop's job. Pass `"end"` for a column
|
|
* of numbers: a figure is read against the ones above and below it, and that
|
|
* only works when the digits line up on the right.
|
|
*/
|
|
export function Th({
|
|
children,
|
|
align = 'center',
|
|
}: {
|
|
children?: ReactNode;
|
|
align?: 'start' | 'center' | 'end';
|
|
}) {
|
|
return (
|
|
<th
|
|
style={{
|
|
textAlign: align,
|
|
padding: '10px',
|
|
borderBottom: '1px solid var(--color-line)',
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
color: 'var(--color-ink-3)',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{children}
|
|
</th>
|
|
);
|
|
}
|
|
|
|
export function Td({
|
|
children,
|
|
isMuted,
|
|
isStrong,
|
|
align = 'center',
|
|
}: {
|
|
children: ReactNode;
|
|
isMuted?: boolean;
|
|
isStrong?: boolean;
|
|
align?: 'start' | 'center' | 'end';
|
|
}) {
|
|
return (
|
|
<td
|
|
style={{
|
|
textAlign: align,
|
|
padding: '10px',
|
|
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
|
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
|
|
fontWeight: isStrong ? 600 : 400,
|
|
verticalAlign: 'middle',
|
|
}}
|
|
>
|
|
{children}
|
|
</td>
|
|
);
|
|
}
|
|
|
|
/** Soft fill, 26% border, in its own colour — the console's one status chip. */
|
|
export function Chip({ label, color }: { label: string; color: string }) {
|
|
return (
|
|
<span
|
|
style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
borderRadius: 999,
|
|
padding: '2px 9px',
|
|
fontSize: 11.5,
|
|
fontWeight: 600,
|
|
whiteSpace: 'nowrap',
|
|
color,
|
|
background: `color-mix(in oklab, ${color} 12%, transparent)`,
|
|
border: `1px solid color-mix(in oklab, ${color} 26%, transparent)`,
|
|
}}
|
|
>
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A 40px pack shot.
|
|
*
|
|
* `mix-blend-multiply` because catalogue photography is shot on white — without
|
|
* it every thumbnail carries a grey square where the photo's ground meets the
|
|
* row.
|
|
*/
|
|
export function Thumb({ images, alt }: { images: readonly string[]; alt: string }) {
|
|
const [failed, setFailed] = useState(false);
|
|
const src = images[0];
|
|
|
|
return (
|
|
<span
|
|
style={{
|
|
width: 40,
|
|
height: 40,
|
|
flex: 'none',
|
|
display: 'grid',
|
|
placeItems: 'center',
|
|
borderRadius: 10,
|
|
border: '1px solid var(--color-line)',
|
|
background: 'var(--color-surface)',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{src && !failed ? (
|
|
<img
|
|
src={src}
|
|
alt={alt}
|
|
loading="lazy"
|
|
referrerPolicy="no-referrer"
|
|
onError={() => setFailed(true)}
|
|
style={{ width: '100%', height: '100%', objectFit: 'contain', mixBlendMode: 'multiply' }}
|
|
/>
|
|
) : (
|
|
<ImageOff size={15} style={{ color: 'var(--color-line)' }} />
|
|
)}
|
|
</span>
|
|
);
|
|
}
|