Merge remote-tracking branch 'gitea/main' into feat/revenue-intelligence
CI / verify (push) Successful in 2m54s
CI / verify (push) Successful in 2m54s
This commit is contained in:
@@ -12,6 +12,7 @@ import { CreateProfile } from '@/pages/CreateProfile';
|
||||
import { Register } from '@/pages/Register';
|
||||
import { PiggyMark } from '@/components/PiggyMark';
|
||||
import { EmptyState } from '@/components/ui';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
|
||||
const Overview = lazy(() => import('@/pages/Overview').then(({ Overview }) => ({ default: Overview })));
|
||||
@@ -79,6 +80,8 @@ export function App() {
|
||||
<BrowserRouter>
|
||||
<AuthGate config={config} />
|
||||
</BrowserRouter>
|
||||
{/* Inside ThemeProvider: the host reads the resolved light/dark value. */}
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -67,7 +67,7 @@ export function AdminSettings() {
|
||||
<div className="relative overflow-hidden border-b border-border bg-surface-2 px-4 py-5 sm:px-6">
|
||||
<div className="absolute -right-12 -top-20 size-48 rounded-full bg-accent-subtle blur-3xl" aria-hidden />
|
||||
<div className="relative flex items-start gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-accent text-accent-on">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary text-accent-on">
|
||||
<ShieldCheck aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from '@/components/ui/sheet';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ApiError, compactNumber, get, money, percent, post, shortDate } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export interface AvailabilityRow {
|
||||
commitmentId: string;
|
||||
@@ -295,10 +296,24 @@ export function AllocationSheet({
|
||||
})
|
||||
: post<AllocationRecord>('/api/allocations', { ...body, status: values.status });
|
||||
},
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (allocation, values) => {
|
||||
await refresh();
|
||||
onOpenChange(false);
|
||||
// The sheet closes on success, so without this the only evidence the
|
||||
// write landed is a number moving somewhere off-screen. Report what
|
||||
// actually happened, in the units the seller was thinking in.
|
||||
const hours = Number(allocation.gpuHours ?? values.gpuHours).toLocaleString();
|
||||
toast.success(
|
||||
values.kind === 'hold' ? 'Capacity held' : 'Capacity allocated',
|
||||
{
|
||||
description:
|
||||
values.kind === 'hold'
|
||||
? `${hours} GPU-hours reserved. The hold releases automatically when it expires.`
|
||||
: `${hours} GPU-hours committed. Margin and utilisation have been updated.`,
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: (error) => toast.error('Could not save', { description: errorMessage(error) }),
|
||||
});
|
||||
const release = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
@@ -306,8 +321,16 @@ export function AllocationSheet({
|
||||
reason: releaseReason.trim() || undefined,
|
||||
}),
|
||||
onMutate: () => setReleaseError(null),
|
||||
onSuccess: refresh,
|
||||
onError: (error) => setReleaseError(errorMessage(error)),
|
||||
onSuccess: async () => {
|
||||
await refresh();
|
||||
toast.success('Hold released', {
|
||||
description: 'The capacity is available to sell again.',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setReleaseError(errorMessage(error));
|
||||
toast.error('Could not release the hold', { description: errorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const chooseCommitment = (id: string) => {
|
||||
@@ -526,8 +549,8 @@ function CommitmentContext({ row, detail, match, quotedPrice }: { row: Availabil
|
||||
{match ? <Badge tone={match.score > 0.7 ? 'positive' : 'neutral'}>{percent(match.score)} fit</Badge> : null}
|
||||
</div>
|
||||
<div className="mt-4 flex h-2 overflow-hidden rounded-full bg-surface">
|
||||
<div className="bg-accent" style={{ width: `${Math.min(100, soldPct * 100)}%` }} />
|
||||
<div className="bg-accent/35" style={{ width: `${Math.min(100 - soldPct * 100, heldPct * 100)}%` }} />
|
||||
<div className="bg-primary" style={{ width: `${Math.min(100, soldPct * 100)}%` }} />
|
||||
<div className="bg-primary/35" style={{ width: `${Math.min(100 - soldPct * 100, heldPct * 100)}%` }} />
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-3 gap-2 text-xs">
|
||||
<div><p className="text-muted">Sold</p><p className="nums mt-0.5 font-medium">{compactNumber(row.soldGpuHours)} hrs</p></div>
|
||||
|
||||
@@ -137,7 +137,7 @@ export function GoogleSheetsSource({ onLoaded }: { onLoaded(table: GoogleParsedT
|
||||
{files.isLoading ? <Skeleton className="h-40" /> : files.isError ? <ErrorText error={files.error} /> : files.data?.files.length === 0 ? <EmptyState title="No spreadsheets found" description="Try another name or confirm this Google account can see the spreadsheet." /> : (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{files.data?.files.map((file) => (
|
||||
<button key={file.id} type="button" onClick={() => { setSpreadsheetId(file.id); setSheetId(''); }} className={spreadsheetId === file.id ? 'tap min-w-0 rounded-lg border border-accent bg-accent-subtle p-3 text-left' : 'tap min-w-0 rounded-lg border border-border p-3 text-left hover:bg-surface-2'}>
|
||||
<button key={file.id} type="button" onClick={() => { setSpreadsheetId(file.id); setSheetId(''); }} className={spreadsheetId === file.id ? 'tap min-w-0 rounded-lg border border-primary bg-accent-subtle p-3 text-left' : 'tap min-w-0 rounded-lg border border-border p-3 text-left hover:bg-surface-2'}>
|
||||
<p className="truncate text-sm font-medium">{file.name}</p>
|
||||
<p className="mt-1 text-xs text-muted">{file.modifiedTime ? `Modified ${relativeTime(file.modifiedTime)}` : 'Modified time unavailable'}</p>
|
||||
</button>
|
||||
|
||||
@@ -264,7 +264,7 @@ function PiggyChatPanel({
|
||||
|
||||
function ChatMessage({ message }: { message: TranscriptMessage }) {
|
||||
if (message.role === 'user') {
|
||||
return <div className="ml-auto max-w-[88%] rounded-2xl rounded-br-md bg-accent px-4 py-3 text-sm text-accent-on"><p className="whitespace-pre-wrap">{message.content}</p></div>;
|
||||
return <div className="ml-auto max-w-[88%] rounded-2xl rounded-br-md bg-primary px-4 py-3 text-sm text-accent-on"><p className="whitespace-pre-wrap">{message.content}</p></div>;
|
||||
}
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
|
||||
@@ -83,8 +83,22 @@ Input.displayName = 'Input';
|
||||
|
||||
// --------------------------------------------------------------------- card
|
||||
|
||||
/**
|
||||
* `min-w-0` is on the base class deliberately, not left to each call site.
|
||||
*
|
||||
* A grid or flex child defaults to `min-width: auto`, meaning it refuses to
|
||||
* shrink below its content — and cards routinely contain something
|
||||
* unshrinkable (a `tabular-nums` figure, a `whitespace-nowrap` badge, a long
|
||||
* unbroken title). The result is a card wider than its column, which drags the
|
||||
* whole page into horizontal scrolling on a phone.
|
||||
*
|
||||
* This has now been fixed three separate times at individual call sites, which
|
||||
* is the signal that it belongs here instead. `min-width: 0` is inert for a
|
||||
* block-level card outside a flex or grid container, so applying it always
|
||||
* costs nothing and removes the entire class of bug.
|
||||
*/
|
||||
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('card', className)} {...props} />;
|
||||
return <div className={cn('card min-w-0', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
|
||||
@@ -1,29 +1,43 @@
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner } from "sonner"
|
||||
/**
|
||||
* Toast host.
|
||||
*
|
||||
* The shadcn original reads the theme from `next-themes`, which PIG does not
|
||||
* use — it has its own provider so a user's choice can be persisted server-side
|
||||
* and follow them between devices. Importing next-themes here would have thrown
|
||||
* at module load, which is why the Toaster was never mounted and every mutation
|
||||
* in the app completed in silence.
|
||||
*
|
||||
* Rewired to PIG's `useTheme`, which already resolves `system` to a concrete
|
||||
* light or dark value.
|
||||
*/
|
||||
import { Toaster as Sonner, type ToasterProps } from 'sonner';
|
||||
import { useTheme } from '@/lib/theme';
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
export function Toaster(props: ToasterProps) {
|
||||
const { resolved } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
theme={resolved}
|
||||
className="toaster group"
|
||||
// Above the sheets and dialogs it confirms, and clear of the phone tab
|
||||
// bar and the home indicator beneath it.
|
||||
position="bottom-right"
|
||||
offset="1rem"
|
||||
style={{ marginBottom: 'calc(var(--safe-bottom) + 4rem)' }}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
'group toast group-[.toaster]:bg-surface group-[.toaster]:text-fg ' +
|
||||
'group-[.toaster]:border-border group-[.toaster]:shadow-lg',
|
||||
description: 'group-[.toast]:text-muted',
|
||||
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||
cancelButton: 'group-[.toast]:bg-surface-2 group-[.toast]:text-muted',
|
||||
error: 'group-[.toaster]:text-danger',
|
||||
success: 'group-[.toaster]:text-positive',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
|
||||
@@ -161,9 +161,9 @@ function CapacityCard({ row, writable, onAllocate }: { row: AvailabilityRow; wri
|
||||
bar made mostly of unconverted holds is a lie a seller would act on. */}
|
||||
<div>
|
||||
<div className="flex h-2 overflow-hidden rounded-full bg-surface-2">
|
||||
<div className="bg-accent" style={{ width: `${Math.min(100, soldPct * 100)}%` }} />
|
||||
<div className="bg-primary" style={{ width: `${Math.min(100, soldPct * 100)}%` }} />
|
||||
<div
|
||||
className="bg-accent/35"
|
||||
className="bg-primary/35"
|
||||
style={{ width: `${Math.min(100 - soldPct * 100, heldPct * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -95,7 +95,7 @@ export function CreateProfile({
|
||||
className={[
|
||||
'flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors',
|
||||
team === value
|
||||
? 'border-accent bg-accent-subtle'
|
||||
? 'border-primary bg-accent-subtle'
|
||||
: 'border-border hover:bg-surface-2',
|
||||
].join(' ')}
|
||||
>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { get, patch, relativeTime } from '@/lib/api';
|
||||
import { can } from '@/lib/permissions';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ReviewItem {
|
||||
fact: SourcedFact;
|
||||
@@ -74,7 +75,16 @@ export function FactReview() {
|
||||
const decision = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: 'approved' | 'dismissed' }) =>
|
||||
patch<DecisionResponse>(`/api/facts/${id}/decision`, { status }),
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (_result, variables) => {
|
||||
toast.success(
|
||||
variables.status === 'approved' ? 'Evidence accepted' : 'Claim dismissed',
|
||||
{
|
||||
description:
|
||||
variables.status === 'approved'
|
||||
? 'Recorded as reviewed. Applying it to the record is a separate, field-aware step.'
|
||||
: 'It will not be proposed again from the same evidence.',
|
||||
},
|
||||
);
|
||||
await queryClient.invalidateQueries({ queryKey: ['facts', 'proposed'] });
|
||||
},
|
||||
});
|
||||
@@ -99,7 +109,7 @@ export function FactReview() {
|
||||
</Badge>
|
||||
</header>
|
||||
|
||||
<Card className="overflow-hidden border-accent/30 bg-accent-subtle/40">
|
||||
<Card className="overflow-hidden border-primary/30 bg-accent-subtle/40">
|
||||
<CardContent className="flex gap-3 p-4 sm:p-5">
|
||||
<FileCheck2 className="mt-0.5 size-5 shrink-0 text-accent-fg" aria-hidden />
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -145,7 +145,7 @@ export function Imports() {
|
||||
type="button"
|
||||
aria-pressed={entity === candidate}
|
||||
onClick={() => resetForEntity(candidate)}
|
||||
className={entity === candidate ? 'tap card min-w-0 border-accent p-4 text-left ring-1 ring-accent' : 'tap card min-w-0 p-4 text-left'}
|
||||
className={entity === candidate ? 'tap card min-w-0 border-primary p-4 text-left ring-1 ring-primary' : 'tap card min-w-0 p-4 text-left'}
|
||||
>
|
||||
<p className="font-semibold">{candidateDefinition.label}</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted">{candidateDefinition.description}</p>
|
||||
|
||||
@@ -184,7 +184,7 @@ function PipelineBoard<T extends { id: string; stage: string; updatedAt: string
|
||||
className={[
|
||||
'tap shrink-0 rounded-full px-3.5 text-sm font-medium transition-colors',
|
||||
stage === currentStage
|
||||
? 'bg-accent text-accent-on'
|
||||
? 'bg-primary text-accent-on'
|
||||
: 'bg-surface-2 text-muted',
|
||||
].join(' ')}
|
||||
>
|
||||
|
||||
@@ -172,7 +172,7 @@ export function Register({
|
||||
className={[
|
||||
'flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors',
|
||||
team === value
|
||||
? 'border-accent bg-accent-subtle'
|
||||
? 'border-primary bg-accent-subtle'
|
||||
: 'border-border hover:bg-surface-2',
|
||||
].join(' ')}
|
||||
>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input } from '
|
||||
import { useState } from 'react';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
import { AdminSettings } from '@/components/AdminSettings';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Me {
|
||||
id: string;
|
||||
@@ -93,7 +94,7 @@ function Appearance() {
|
||||
title={option.label}
|
||||
className={[
|
||||
'tap relative flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors',
|
||||
selected ? 'border-accent bg-accent-subtle' : 'border-border hover:bg-surface-2',
|
||||
selected ? 'border-primary bg-accent-subtle' : 'border-border hover:bg-surface-2',
|
||||
].join(' ')}
|
||||
>
|
||||
{/*
|
||||
@@ -144,7 +145,11 @@ function Profile({ me }: { me: Me | undefined }) {
|
||||
void queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||
setName('');
|
||||
setTitle('');
|
||||
// The form clears itself on success, which without confirmation reads as
|
||||
// though the input was discarded rather than saved.
|
||||
toast.success('Profile saved');
|
||||
},
|
||||
onError: () => toast.error('Could not save your profile'),
|
||||
});
|
||||
|
||||
if (!me) return null;
|
||||
|
||||
Reference in New Issue
Block a user