live auction views, live updating bids

This commit is contained in:
Brett Woodward
2025-03-08 16:41:38 -05:00
parent 583a9d4f92
commit db048062b5
12 changed files with 210 additions and 35 deletions
+52
View File
@@ -45,6 +45,7 @@
"@next-auth/prisma-adapter": "^1.0.7",
"@next/bundle-analyzer": "^15.0.3",
"@next/third-parties": "^15.0.3",
"@number-flow/react": "^0.5.7",
"@okikio/sharedworker": "^1.0.4",
"@paddle/paddle-js": "^1.2.1",
"@paddle/paddle-node-sdk": "^1.4.1",
@@ -3158,6 +3159,20 @@
"node": ">= 8"
}
},
"node_modules/@number-flow/react": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/@number-flow/react/-/react-0.5.7.tgz",
"integrity": "sha512-Fm1ZTUx5SYFZcJ3NjNKzC513Sq0XbU//X1yIEJ33MLBNyff+S16akGvez1zD1EjBHbgRGUyyNKQP6U8u0cszvA==",
"license": "MIT",
"dependencies": {
"esm-env": "^1.1.4",
"number-flow": "0.5.5"
},
"peerDependencies": {
"react": "^18 || ^19",
"react-dom": "^18 || ^19"
}
},
"node_modules/@okikio/sharedworker": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@okikio/sharedworker/-/sharedworker-1.0.4.tgz",
@@ -10101,6 +10116,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/esm-env": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"license": "MIT"
},
"node_modules/espree": {
"version": "9.5.1",
"resolved": "https://registry.npmjs.org/espree/-/espree-9.5.1.tgz",
@@ -15714,6 +15735,15 @@
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/number-flow": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/number-flow/-/number-flow-0.5.5.tgz",
"integrity": "sha512-oE+gyA3S0ar8un2dg80TlEi3hjvi/UnTewHl2bu9dGKMxU7nT8VTUdIf1X7NbRLslqyyTyxdSmIAv/QJhaq1pw==",
"license": "MIT",
"dependencies": {
"esm-env": "^1.1.4"
}
},
"node_modules/oauth": {
"version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
@@ -23564,6 +23594,15 @@
"fastq": "^1.6.0"
}
},
"@number-flow/react": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/@number-flow/react/-/react-0.5.7.tgz",
"integrity": "sha512-Fm1ZTUx5SYFZcJ3NjNKzC513Sq0XbU//X1yIEJ33MLBNyff+S16akGvez1zD1EjBHbgRGUyyNKQP6U8u0cszvA==",
"requires": {
"esm-env": "^1.1.4",
"number-flow": "0.5.5"
}
},
"@okikio/sharedworker": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@okikio/sharedworker/-/sharedworker-1.0.4.tgz",
@@ -28645,6 +28684,11 @@
"integrity": "sha512-HPpKPUBQcAsZOsHAFwTtIKcYlCje62XB7SEAcxjtmW6TD1WVpkS6i6/hOVtTZIl4zGj/mBqpFVGvaDneik+VoQ==",
"dev": true
},
"esm-env": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="
},
"espree": {
"version": "9.5.1",
"resolved": "https://registry.npmjs.org/espree/-/espree-9.5.1.tgz",
@@ -32307,6 +32351,14 @@
"boolbase": "^1.0.0"
}
},
"number-flow": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/number-flow/-/number-flow-0.5.5.tgz",
"integrity": "sha512-oE+gyA3S0ar8un2dg80TlEi3hjvi/UnTewHl2bu9dGKMxU7nT8VTUdIf1X7NbRLslqyyTyxdSmIAv/QJhaq1pw==",
"requires": {
"esm-env": "^1.1.4"
}
},
"oauth": {
"version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
+1
View File
@@ -89,6 +89,7 @@
"@next-auth/prisma-adapter": "^1.0.7",
"@next/bundle-analyzer": "^15.0.3",
"@next/third-parties": "^15.0.3",
"@number-flow/react": "^0.5.7",
"@okikio/sharedworker": "^1.0.4",
"@paddle/paddle-js": "^1.2.1",
"@paddle/paddle-node-sdk": "^1.4.1",
+16 -2
View File
@@ -21,6 +21,7 @@ import {
IconCalendar,
IconLayoutSidebarLeftExpand,
IconMoodSmile,
IconPlugConnected,
} from '@tabler/icons-react';
import { clsx } from 'clsx';
import dayjs from 'dayjs';
@@ -30,7 +31,7 @@ import { AlertWithIcon } from '~/components/AlertWithIcon/AlertWithIcon';
import { getModelTypesForAuction } from '~/components/Auction/auction.utils';
import { ModelPlacementCard } from '~/components/Auction/AuctionPlacementCard';
import { useAuctionContext } from '~/components/Auction/AuctionProvider';
import { usePurchaseBid } from '~/components/Auction/AuctionUtils';
import { AuctionViews, usePurchaseBid } from '~/components/Auction/AuctionUtils';
import { BuzzTransactionButton } from '~/components/Buzz/BuzzTransactionButton';
import { CosmeticCard } from '~/components/CardTemplates/CosmeticCard';
import { Countdown } from '~/components/Countdown/Countdown';
@@ -38,6 +39,7 @@ import { CurrencyIcon } from '~/components/Currency/CurrencyIcon';
import { HelpButton } from '~/components/HelpButton/HelpButton';
import { ResourceSelect } from '~/components/ImageGeneration/GenerationForm/ResourceSelect';
import { featureInfo } from '~/components/Model/ModelVersions/ModelVersionPopularity';
import { useSignalContext } from '~/components/Signals/SignalsProvider';
import { useTourContext } from '~/components/Tours/ToursProvider';
import { useIsMobile } from '~/hooks/useIsMobile';
import { NumberInputWrapper } from '~/libs/form/components/NumberInputWrapper';
@@ -79,11 +81,17 @@ export const AuctionTopSection = ({ refreshFunc }: { refreshFunc?: () => any })
const features = useFeatureFlags();
const { runTour } = useTourContext();
const { drawerToggle } = useAuctionContext();
const { connected } = useSignalContext();
// {/*<Group className="sticky top-0 right-0">*/}
return (
<Group position="right">
{!connected && (
<Tooltip label="Not connected. May not receive live updates.">
<IconPlugConnected color="orangered" />
</Tooltip>
)}
{features.appTour && (
<HelpButton
data-tour="auction:reset"
@@ -127,6 +135,7 @@ export const AuctionTopSection = ({ refreshFunc }: { refreshFunc?: () => any })
</Stack>
</HoverCard.Dropdown>
</HoverCard>
<AuctionViews />
<Button
className="md:hidden"
onClick={drawerToggle}
@@ -138,7 +147,11 @@ export const AuctionTopSection = ({ refreshFunc }: { refreshFunc?: () => any })
<Text>Auctions</Text>
</Group>
</Button>
{!!refreshFunc && <Button onClick={() => refreshFunc()}>Refresh</Button>}
{!!refreshFunc && (
<Button variant="light" onClick={() => refreshFunc()}>
Refresh
</Button>
)}
</Group>
);
};
@@ -320,6 +333,7 @@ export const AuctionInfo = () => {
sx={{
flexGrow: 1,
justifyItems: 'center',
display: 'grid', // for firefox
}}
buttonProps={{
fullWidth: mobile,
@@ -312,6 +312,7 @@ const SectionBidInfo = ({
sx={{
fontSize: '1.25rem',
}}
asCounter={true}
/>
</Tooltip>
{right}
@@ -16,16 +16,19 @@ type AuctionState = {
selectedModel: GenerationResource | undefined;
validAuction: boolean;
justBid: { auctionId: number; entityId: number } | undefined;
viewing: { [p: number]: number };
};
type AuctionContextState = {
selectedAuction: AuctionState['selectedAuction'];
selectedModel: AuctionState['selectedModel'];
validAuction: AuctionState['validAuction'];
justBid: AuctionState['justBid'];
viewing: AuctionState['viewing'];
setSelectedAuction: Dispatch<SetStateAction<AuctionState['selectedAuction']>>;
setSelectedModel: Dispatch<SetStateAction<AuctionState['selectedModel']>>;
setValidAuction: Dispatch<SetStateAction<AuctionState['validAuction']>>;
setJustBid: Dispatch<SetStateAction<AuctionState['justBid']>>;
setViewing: Dispatch<SetStateAction<AuctionState['viewing']>>;
drawerIsOpen: boolean;
drawerToggle: () => void;
drawerClose: () => void;
@@ -48,6 +51,7 @@ export const AuctionContextProvider = ({ children }: { children: ReactNode }) =>
const [selectedModel, setSelectedModel] = useState<AuctionState['selectedModel']>(undefined);
const [validAuction, setValidAuction] = useState<AuctionState['validAuction']>(true);
const [justBid, setJustBid] = useState<AuctionState['justBid']>(undefined);
const [viewing, setViewing] = useState<AuctionState['viewing']>({});
const [drawerIsOpen, { close: drawerClose, toggle: drawerToggle }] = useDisclosure();
const router = useRouter();
@@ -76,10 +80,12 @@ export const AuctionContextProvider = ({ children }: { children: ReactNode }) =>
selectedModel,
validAuction,
justBid,
viewing,
setSelectedAuction,
setSelectedModel,
setValidAuction,
setJustBid,
setViewing,
drawerIsOpen,
drawerToggle,
drawerClose,
+60 -14
View File
@@ -1,13 +1,17 @@
import { ActionIcon, ActionIconProps, Stack, Text, Tooltip } from '@mantine/core';
import { ActionIcon, ActionIconProps, Group, Paper, Stack, Text, Tooltip } from '@mantine/core';
import { showNotification, updateNotification } from '@mantine/notifications';
import { IconCheck, IconGavel, IconX } from '@tabler/icons-react';
import NumberFlow from '@number-flow/react';
import { IconCheck, IconGavel, IconUsers, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import React, { useMemo, useState } from 'react';
import { useAuctionContext } from '~/components/Auction/AuctionProvider';
import { useBuzzTransaction } from '~/components/Buzz/buzz.utils';
import { CurrencyBadge } from '~/components/Currency/CurrencyBadge';
import { useSignalConnection, useSignalTopic } from '~/components/Signals/SignalsProvider';
import { useCurrentUser } from '~/hooks/useCurrentUser';
import {
useSignalConnection,
useSignalContext,
useSignalTopic,
} from '~/components/Signals/SignalsProvider';
import { useFeatureFlags } from '~/providers/FeatureFlagsProvider';
import { SignalMessages, SignalTopic } from '~/server/common/enums';
import type { GetAuctionBySlugReturn } from '~/server/services/auction.service';
@@ -21,6 +25,7 @@ export function usePurchaseBid() {
const queryUtils = trpc.useUtils();
const [createLoading, setCreateLoading] = useState(false);
const { setJustBid } = useAuctionContext();
const { connected } = useSignalContext();
const { conditionalPerformTransaction } = useBuzzTransaction({
message: (requiredBalance: number) =>
@@ -68,7 +73,9 @@ export function usePurchaseBid() {
});
// TODO updates instead for MyBids
await queryUtils.auction.getBySlug.invalidate({ slug: res.slug });
if (!connected) {
await queryUtils.auction.getBySlug.invalidate({ slug: res.slug });
}
await queryUtils.auction.getMyBids.invalidate();
await queryUtils.auction.getMyRecurringBids.invalidate();
await queryUtils.model.getRecentlyBid.invalidate();
@@ -204,20 +211,59 @@ export const BidModelButton = ({
);
};
export const useAuctionTopicListener = (auctionId?: number) => {
const utils = trpc.useUtils();
const currentUser = useCurrentUser();
export const AuctionViews = () => {
const { viewing, selectedAuction } = useAuctionContext();
// TODO
useSignalConnection(SignalMessages.AuctionUpdate, (data: any) => {
// console.log('auction update', data);
});
useSignalConnection(SignalMessages.AuctionBidChange, (data: any) => {
const views = selectedAuction?.id ? viewing[selectedAuction.id] ?? 0 : 0;
if (!views) return <></>;
return (
<Tooltip label="Currently viewing">
<Paper radius="sm" shadow="xs" px={8} py={4} withBorder className="bg-gray-0 dark:bg-dark-6">
<Group spacing={4} className="min-w-[55px] cursor-default justify-center">
<IconUsers size={14} />
<NumberFlow
format={{ notation: 'compact' }}
respectMotionPreference={false}
value={views}
className="text-sm"
/>
{/*<button*/}
{/* onClick={() => {*/}
{/* const delta = Math.floor(Math.random() * 100000);*/}
{/* if (selectedAuction)*/}
{/* setViewing((prev) => ({ ...prev, [selectedAuction?.id]: delta }));*/}
{/* }}*/}
{/*>*/}
{/* change*/}
{/*</button>*/}
</Group>
</Paper>
</Tooltip>
);
};
export const useAuctionTopicListener = (auctionId?: number) => {
const queryUtils = trpc.useUtils();
const { setViewing } = useAuctionContext();
// console.log('listening to auction', auctionId);
useSignalConnection(SignalMessages.AuctionBidChange, (data: GetAuctionBySlugReturn) => {
// console.log('auction bid change', data);
queryUtils.auction.getBySlug.setData({ slug: data.auctionBase.slug }, data);
});
useSignalConnection(SignalMessages.TopicUpdate, (data: { topic: string; count: number }) => {
// if topic === .... then get count
// console.log('auction count', data);
setViewing((prev) => {
const auctionId = parseInt(data.topic.split(':')[1]);
if (isNaN(auctionId)) return prev;
return {
...prev,
[auctionId]: Math.max(0, data.count),
};
});
});
// TODO is there a race condition? missing the first topic message
+23 -6
View File
@@ -1,10 +1,19 @@
import { Badge, BadgeProps, Loader, MantineSize, Tooltip, useMantineTheme } from '@mantine/core';
import { Currency } from '~/shared/utils/prisma/enums';
import {
Badge,
BadgeProps,
Loader,
MantineSize,
Text,
Tooltip,
useMantineTheme,
} from '@mantine/core';
import NumberFlow from '@number-flow/react';
import { IconProps } from '@tabler/icons-react';
import React, { forwardRef } from 'react';
import { BuzzTypeDistribution } from '~/components/Buzz/buzz.utils';
import { CurrencyConfig } from '~/server/common/constants';
import { Currency } from '~/shared/utils/prisma/enums';
import { formatCurrencyForDisplay } from '~/utils/number-helpers';
import { forwardRef } from 'react';
type Props = BadgeProps & {
currency: Currency;
@@ -16,6 +25,7 @@ type Props = BadgeProps & {
textColor?: string;
type?: string;
typeDistrib?: BuzzTypeDistribution;
asCounter?: boolean;
};
const iconSize: Record<MantineSize, number> = {
@@ -40,6 +50,7 @@ export const CurrencyBadge = forwardRef<HTMLDivElement, Props>(
textColor,
type,
typeDistrib,
asCounter,
...badgeProps
},
ref
@@ -109,9 +120,15 @@ export const CurrencyBadge = forwardRef<HTMLDivElement, Props>(
<Loader size="xs" variant="dots" color={colorString} />
) : (
<div className="flex items-center gap-1">
{formatter
? formatter(unitAmount)
: `${value || 0} ${displayCurrency ? currency : ''}`}
{asCounter ? (
<NumberFlow respectMotionPreference={false} value={unitAmount} />
) : (
<Text>
{formatter
? formatter(unitAmount)
: `${value || 0} ${displayCurrency ? currency : ''}`}
</Text>
)}
{children}
</div>
)}
-3
View File
@@ -133,11 +133,8 @@ export enum SignalMessages {
NotificationNew = 'notification:new',
ModelVersionPopularityUpdate = 'model-version:popularity:update',
Pong = 'pong',
// Creator Program:
CompensationPoolUpdate = 'creators-program:compensation-pool-update',
CashInvalidator = 'creators-program:cash-invalidator',
// Auction
AuctionUpdate = 'auction:update',
AuctionBidChange = 'auction:bid-change',
TopicUpdate = 'topic:connections',
}
+2
View File
@@ -71,6 +71,8 @@ export const handleAuctions = createJob(jobName, '1 0 * * *', async () => {
log('-----------');
}
// TODO possibly send signals
log('end', dayjs().toDate());
await dbKV.set(kvKey, 0);
+47 -8
View File
@@ -1,7 +1,7 @@
import { Prisma } from '@prisma/client';
import { uniq } from 'lodash-es';
import { getModelTypesForAuction } from '~/components/Auction/auction.utils';
import { NotificationCategory } from '~/server/common/enums';
import { NotificationCategory, SignalMessages, SignalTopic } from '~/server/common/enums';
import { dbRead, dbWrite } from '~/server/db/client';
import { logToAxiom } from '~/server/logging/client';
import type { DetailsCanceledBid } from '~/server/notifications/auction.notifications';
@@ -30,6 +30,7 @@ import {
import { AuctionType, Availability, BuzzAccountType } from '~/shared/utils/prisma/enums';
import { formatDate } from '~/utils/date-helpers';
import { withRetries } from '~/utils/errorHandling';
import { signalClient } from '~/utils/signal-client';
export const auctionBaseSelect = Prisma.validator<Prisma.AuctionBaseSelect>()({
id: true,
@@ -496,6 +497,16 @@ export const createBid = async ({
});
}
// TODO there is probably a better way to do this that avoids refetching everything, but we need to update all positions and numbers
const signalData = await getAuctionBySlug({ slug: auctionData.auctionBase.slug });
signalClient
.topicSend({
topic: `${SignalTopic.Auction}:${auctionId}`,
target: SignalMessages.AuctionBidChange,
data: signalData,
})
.catch();
// TODO fetch the entity that was knocked out (if any)
// get all the bids userIds
@@ -525,8 +536,12 @@ export const deleteBid = async ({ userId, bidId }: DeleteBidInput & { userId: nu
transactionIds: true,
auction: {
select: {
id: true,
startAt: true,
endAt: true,
auctionBase: {
select: { slug: true },
},
},
},
},
@@ -546,6 +561,15 @@ export const deleteBid = async ({ userId, bidId }: DeleteBidInput & { userId: nu
deleted: true,
},
});
const signalData = await getAuctionBySlug({ slug: bid.auction.auctionBase.slug });
signalClient
.topicSend({
topic: `${SignalTopic.Auction}:${bid.auction.id}`,
target: SignalMessages.AuctionBidChange,
data: signalData,
})
.catch();
};
export const deleteBidsForModel = async ({ modelId }: { modelId: number }) => {
@@ -560,12 +584,18 @@ export const deleteBidsForModel = async ({ modelId }: { modelId: number }) => {
const versionIds = model.modelVersions.map((mv) => mv.id);
if (!versionIds.length) throw throwBadRequestError('Model has no versions.');
const aIds = (
await dbWrite.auction.findMany({
where: { startAt: { lte: now }, endAt: { gt: now } },
select: { id: true },
})
).map((a) => a.id);
const aData = await dbWrite.auction.findMany({
where: { startAt: { lte: now }, endAt: { gt: now } },
select: {
id: true,
auctionBase: {
select: {
slug: true,
},
},
},
});
const aIds = aData.map((a) => a.id);
let deletedIds: number[] = [];
let deletedRecurringIds: number[] = [];
@@ -647,7 +677,16 @@ export const deleteBidsForModel = async ({ modelId }: { modelId: number }) => {
deletedRecurringIds = recToDelete.map((d) => d.id);
}
// TODO send signal
for (const a of aData) {
const signalData = await getAuctionBySlug({ slug: a.auctionBase.slug });
signalClient
.topicSend({
topic: `${SignalTopic.Auction}:${a.id}`,
target: SignalMessages.AuctionBidChange,
data: signalData,
})
.catch();
}
return {
bidsDeleted: deletedIds,
+1 -1
View File
@@ -111,7 +111,7 @@ const featureFlags = createFeatureFlags({
generationOnlyModels: ['mod', 'granted', 'gold'],
appTour: ['public'],
privateModels: ['mod', 'granted'],
auctions: ['mod'],
auctions: ['public'],
});
export const featureFlagKeys = Object.keys(featureFlags) as FeatureFlagKey[];
+1 -1
View File
@@ -39,7 +39,7 @@ class SignalClient {
target,
data,
}: {
topic: SignalTopic;
topic: `${SignalTopic}${'' | `:${number}`}`;
target: string;
data: Record<string, unknown>;
}) => {