From 047f606e836107f14f6b5d1e93641ef8ee6af1cb Mon Sep 17 00:00:00 2001 From: ntr Date: Tue, 23 Aug 2022 23:35:25 +1000 Subject: [PATCH] acp stuff --- api/src/api.test.ts | 4 +- api/src/api.ts | 42 +++++++++++++++--- api/src/offers.ts | 25 ++++++++--- api/src/pg.ts | 49 +++++++++++++++++++-- client/package-lock.json | 7 ++- client/package.json | 1 + client/src/actions.ts | 20 ++++++++- client/src/app.tsx | 6 ++- client/src/components/acp.offer.tsx | 35 +++++++++++++++ client/src/components/bundle.tsx | 68 ++++++++++++++++++++++++----- client/src/components/header.tsx | 10 ++++- client/src/components/offer.tsx | 30 ++++++++++--- client/src/routes/acp.tsx | 66 ++++++++++++++++++++++++++++ client/src/style/index.less | 49 +++++++++++++++++++-- 14 files changed, 368 insertions(+), 44 deletions(-) create mode 100644 client/src/components/acp.offer.tsx create mode 100644 client/src/routes/acp.tsx diff --git a/api/src/api.test.ts b/api/src/api.test.ts index 2ad32d0..65f10cf 100644 --- a/api/src/api.test.ts +++ b/api/src/api.test.ts @@ -51,8 +51,8 @@ describe('api', function apiTests() { describe('offers', function offerTests() { it('create offers', async function createOffersTest() { const { data: bundles } = await api.get('/bundles'); - const { seller } = bundles[0]; - const { data } = await api.post('/offers', { seller, shipping: 50.0, baseShipping: 15.0 }); + const listingIds = bundles[0].items.map(i => i.id); + const { data } = await api.post('/offers', { listingIds, shipping: 50.0, baseShipping: 15.0 }); }); }); }); diff --git a/api/src/api.ts b/api/src/api.ts index e6bdda8..12f1ed5 100644 --- a/api/src/api.ts +++ b/api/src/api.ts @@ -6,7 +6,7 @@ import session from 'koa-session'; import koaRouter from '@koa/router' import cors from '@koa/cors'; -import { groupBy } from 'lodash'; +import { groupBy, sortBy } from 'lodash'; import Bluebird from 'bluebird'; import type Log from 'bunyan'; @@ -104,14 +104,46 @@ export default function createApi(log: Log, pg: Pg, discogs, offers, events) { ctx.status = 200; }); - router.get('/bundles', async (ctx, next) => { + router.get('/acp/bundles', async function bundlesGet(ctx, next) { const bundles = await getBundles(); ctx.response.body = bundles; }) - router.post('/offers', async (ctx, next) => { - const { seller, shipping, baseShipping } = ctx.request.body; - ctx.response.body = await offers.generate(seller, shipping, baseShipping); + router.get('/acp/offers', async function acpOffers(ctx, next) { + const offers = await pg.offersAdmin(); + + const grouped = groupBy(offers, o => o.listing.data.seller.id); + + const bundles = sortBy(Object.values(grouped).map(offers => { + const shipping = offers[0]?.listing.data.seller.shipping; + const bundle = { + username: offers[0]?.listing.data.seller.username, + seller: offers[0]?.listing.data.seller.id, + shipsFrom: offers[0]?.listing.data.ships_from, + shipping, + offers, + mentionsAustralia: shipping.toLowerCase().includes('australia'), + mentionsWorldwide: shipping.toLowerCase().includes('world'), + } + + return bundle; + }), bundle => -bundle.offers.length); + + ctx.response.body = bundles; + }) + + router.post('/offers', async function postOffers(ctx, next) { + const { listingIds, shipping, baseShipping } = ctx.request.body; + + ctx.assert(listingIds.length, 400, 'listingIds required'); + ctx.assert([listingIds, shipping, baseShipping].every(i => i), 400, 'listingIds, shipping, baseShipping required'); + + ctx.response.body = await offers.generate(listingIds, shipping, baseShipping); + }) + + router.post('/offer/accept', async function postOffers(ctx, next) { + ctx.assert(ctx.isAuthenticated(), 401, 'log in required'); + ctx.response.body = await offers.accept(ctx); }) router.get('/user/offers', async function userOffersGet(ctx, next) { diff --git a/api/src/offers.ts b/api/src/offers.ts index c7cd2dc..d4a01e0 100644 --- a/api/src/offers.ts +++ b/api/src/offers.ts @@ -2,6 +2,7 @@ import type Log from 'bunyan'; import type { PrismaClient, Want, User, Listing, Offer, } from '@prisma/client'; import groupBy from 'lodash/groupBy'; import sortBy from 'lodash/sortBy'; +import { Context } from 'koa'; import { logAxiosError } from './utils'; import { Pg } from './pg'; @@ -14,18 +15,22 @@ export enum Status { } export default function createOffers(log: Log, pg: Pg) { - async function generate(seller: number, bulkShipping: number, baseShipping: number): Offer[] { - log.info('generateOffers', { seller, bulkShipping, baseShipping }); - const listings = await pg.listingFindSeller(seller); + async function generate(listingIds: DiscogsListing[], bulkShipping: number, baseShipping: number) { + log.info('generateOffers', { listingIds, bulkShipping, baseShipping }); + const listings = await pg.listingByIds(listingIds); - log.info('creating offers for listings', { listings }) + log.info({ listings }, 'creating offers for listings') const shipping = bulkShipping / listings.length; + // how to deal with users who don't want all items in a bundle... + const offers = listings.map(l => ({ userId: l.want.user.id, listingId: l.id, wantId: l.want.id, + // sellerId: l.seller, + status: Status.Pending, item: l.data.price.value, @@ -42,11 +47,21 @@ export default function createOffers(log: Log, pg: Pg) { // and ensure the cheapest one goes in first const sorted = sortBy(offers, ['item']); const inserts = await pg.offersCreate(sorted); - console.log(inserts); + return inserts; } + async function accept(ctx: Context) { + const { id } = ctx.request.body; + ctx.assert(id, 401, 'no id supplied'); + + // check the user actually has this offer + const offer = await pg.userOfferFind(ctx.state.user, id); + return pg.offerSetStatus(offer.id, Status.Accepted); + } + return { generate, + accept, }; } diff --git a/api/src/pg.ts b/api/src/pg.ts index ed8eb88..7c80358 100644 --- a/api/src/pg.ts +++ b/api/src/pg.ts @@ -3,6 +3,8 @@ import type Log from 'bunyan'; import type { PrismaClient, Want, User, Listing, Offer, Prisma } from '@prisma/client'; import * as Bluebird from 'bluebird'; +import { Status } from './offers'; + export interface UserCreateParams { email: string, password: string, @@ -14,10 +16,11 @@ export interface Pg { userCreate: (params: UserCreateParams) => Promise, userWants: (user: User) => Promise, userOffers: (user: User) => Promise, + userOfferFind: (user: User, id: UUID) => Promise, listingUpsert: (listing: Listing, want: Want) => Promise, listingFind: (id: DiscogsListing) => Promise, - listingFindSeller: (id: DiscogsSeller) => Promise, + listingByIds: (ids: DiscogsListing[]) => Promise, listingRmUnlisted: (want: Want, ids: DiscogsListing[]) => Promise, wantCreate: (discogsWant: any, user: User) => Promise, @@ -27,6 +30,8 @@ export interface Pg { bundleCandidates: () => Promise, offersCreate: (offers: Offer[]) => Promise, + offerSetStatus: (id: UUID, status: Status) => Promise, + offersAdmin: () => Promise, } export default function createPg(log: Log, prisma: PrismaClient): Pg { @@ -45,6 +50,21 @@ export default function createPg(log: Log, prisma: PrismaClient): Pg { async function userOffers(user: User) { return prisma.offer.findMany({ where: { userId: user.id }, + include: { + want: true, + }, + orderBy: { + status: 'desc', + }, + }); + } + + async function userOfferFind(user: User, id: UUID) { + return prisma.offer.findFirstOrThrow({ + where: { + userId: user.id, + id, + }, include: { want: true, } @@ -145,10 +165,10 @@ export default function createPg(log: Log, prisma: PrismaClient): Pg { }); } - async function listingFindSeller(seller: DiscogsSeller): Promise { + async function listingByIds(ids: DiscogsListing[]) { return prisma.listing.findMany({ where: { - seller, + id: { in: ids }, }, include: { want: { @@ -199,12 +219,30 @@ export default function createPg(log: Log, prisma: PrismaClient): Pg { ); } + async function offerSetStatus(id: UUID, status: Status) { + return prisma.offer.update({ + where: { id: id }, + data: { status }, + }); + } + + async function offersAdmin() { + return prisma.offer.findMany({ + include: { + listing: true, + user: true, + want: true, + }, + }); + } + return { userFindId, userFindEmail, userCreate, userWants, userOffers, + userOfferFind, wantCreate, wantList, @@ -212,9 +250,12 @@ export default function createPg(log: Log, prisma: PrismaClient): Pg { listingUpsert, listingFind, listingRmUnlisted, - listingFindSeller, + listingByIds, bundleCandidates, + offersCreate, + offerSetStatus, + offersAdmin, }; } \ No newline at end of file diff --git a/client/package-lock.json b/client/package-lock.json index 0fba0d3..4ef0604 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "lodash": "^4.17.21", "preact": "^10.10.0", "preact-render-to-string": "^5.2.1", "preact-router": "^3.2.1", @@ -11861,8 +11862,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash.debounce": { "version": "4.0.8", @@ -30319,8 +30319,7 @@ "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "lodash.debounce": { "version": "4.0.8", diff --git a/client/package.json b/client/package.json index c42a3b9..bd7d133 100644 --- a/client/package.json +++ b/client/package.json @@ -19,6 +19,7 @@ ] }, "dependencies": { + "lodash": "^4.17.21", "preact": "^10.10.0", "preact-render-to-string": "^5.2.1", "preact-router": "^3.2.1", diff --git a/client/src/actions.ts b/client/src/actions.ts index b465cb9..4352ac3 100644 --- a/client/src/actions.ts +++ b/client/src/actions.ts @@ -33,13 +33,17 @@ api.interceptors.response.use(onSuccess, onError); const actions = { getBundles: async function getBundles() { - return api.get('/bundles'); + return api.get('/acp/bundles'); }, getOffers: async function getOffers() { return api.get('/user/offers'); }, + getOffersAcp: async function getOffersAcp() { + return api.get('/acp/offers'); + }, + getWants: async function getWants() { return api.get('/want/all'); }, @@ -74,6 +78,20 @@ const actions = { password, }); }, + + postOffers: async function postOffers(listingIds, shipping, baseShipping) { + return api.post('/offers', { + listingIds, + shipping, + baseShipping, + }); + }, + + postOfferAccept: async function postOffers(id) { + return api.post('/offer/accept', { + id, + }); + }, }; export default actions; diff --git a/client/src/app.tsx b/client/src/app.tsx index 67c1a19..368510d 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -8,6 +8,7 @@ import Header from './components/header'; import Home from './routes/home'; import Bundles from './routes/bundles'; +import Admin from './routes/acp'; import Login from './routes/login'; import Search from './routes/search'; import List from './routes/list'; @@ -36,10 +37,13 @@ function App() {
- + + {/* ACP */} + + ); diff --git a/client/src/components/acp.offer.tsx b/client/src/components/acp.offer.tsx new file mode 100644 index 0000000..d44d39d --- /dev/null +++ b/client/src/components/acp.offer.tsx @@ -0,0 +1,35 @@ +import { h } from 'preact'; +import { ExternalLink, Check } from 'react-feather'; +import { useState } from 'preact/hooks'; + +import actions from '../actions'; + +export default function Offer(offer) { + const { want: { data: details } } = offer; + + const [accepted, setAccepted] = useState(offer.status !== 'pending'); + + async function onClick() { + const [error] = await actions.postOfferAccept(offer.id); + if (error) return false; + setAccepted(true); + } + + const classes = `${accepted ? 'accepted' : ''}`; + const btnText = accepted + ? offer.status + : 'Accept'; + + return ( +
+
+

{details.artists_sort} - {details.title}

+
{details.year}
+
{details.formats[0].descriptions.join(' ')}
+
${offer.total}
+
+ + +
+ ) +} diff --git a/client/src/components/bundle.tsx b/client/src/components/bundle.tsx index 5b53bb9..7d011e3 100644 --- a/client/src/components/bundle.tsx +++ b/client/src/components/bundle.tsx @@ -1,7 +1,59 @@ import { h } from 'preact'; +import { Square, CheckSquare } from 'react-feather'; +import { useState, useReducer } from 'preact/hooks'; + +import actions from '../actions'; + +function Item(item, state, dispatch) { + const icon = state.checkedIds.includes(item.id) + ? dispatch({ id: item.id})} /> + : dispatch({ id: item.id})} />; + + return ( +
+ +

{item.description}

+
+
{item.price.toFixed(2)} {item.currency}
+ {icon} +
+ ); +} + +const reducer = (state, action) => { + if (state.checkedIds.includes(action.id)) { + return { + ...state, + checkedIds: state.checkedIds.filter(id => id !== action.id) + } + } + + return { + ...state, + checkedIds: [ + ...state.checkedIds, + action.id + ] + } +} export default function Bundle(bundle) { + const initialState = { checkedIds: bundle.items.map(i => i.id) } + const [state, dispatch] = useReducer(reducer, initialState) + const [shipping, setShipping] = useState(0); + const [baseShipping, setBaseShipping] = useState(0); + + const items = bundle.items.map(item => Item(item, state, dispatch)); + + // 100.71 + // 33.08 + + async function submit(e) { + e.preventDefault(); + const [error, offers] = await actions.postOffers(state.checkedIds, shipping, baseShipping); + console.log(error, offers); + } return (
@@ -15,28 +67,20 @@ export default function Bundle(bundle) {
- + setShipping(Number.parseFloat(e.target.value))} step="0.01" min="0" required />
- + setBaseShipping(Number.parseFloat(e.target.value))} step="0.01" min="0" required />
- + submit(e)} />
- {bundle.items.map(i => ( -
- -

{i.description}

-
-
{i.user.name}
-
{i.price.toFixed(2)} {i.currency}
-
- ))} + {items}
) diff --git a/client/src/components/header.tsx b/client/src/components/header.tsx index 38907a8..96955c2 100644 --- a/client/src/components/header.tsx +++ b/client/src/components/header.tsx @@ -5,16 +5,24 @@ const Header = ({ user }) => { const nav = () => ( ); + const adminNav = () => ( + + + ) + return (
{user ? nav() : null}
+ {user ? adminNav() : null}
); } diff --git a/client/src/components/offer.tsx b/client/src/components/offer.tsx index 0c7509f..d44d39d 100644 --- a/client/src/components/offer.tsx +++ b/client/src/components/offer.tsx @@ -1,15 +1,35 @@ import { h } from 'preact'; -import { ExternalLink } from 'react-feather'; +import { ExternalLink, Check } from 'react-feather'; +import { useState } from 'preact/hooks'; + +import actions from '../actions'; export default function Offer(offer) { const { want: { data: details } } = offer; + + const [accepted, setAccepted] = useState(offer.status !== 'pending'); + + async function onClick() { + const [error] = await actions.postOfferAccept(offer.id); + if (error) return false; + setAccepted(true); + } + + const classes = `${accepted ? 'accepted' : ''}`; + const btnText = accepted + ? offer.status + : 'Accept'; + return (
-

{details.artists_sort} - {details.title}

-
{details.year}
-
{details.formats[0].descriptions.join(' ')}
+
+

{details.artists_sort} - {details.title}

+
{details.year}
+
{details.formats[0].descriptions.join(' ')}
+
${offer.total}
+
-
${offer.total}
+
) } diff --git a/client/src/routes/acp.tsx b/client/src/routes/acp.tsx new file mode 100644 index 0000000..98a9b0d --- /dev/null +++ b/client/src/routes/acp.tsx @@ -0,0 +1,66 @@ +import { h } from 'preact'; +import { useState, useEffect } from 'preact/hooks'; + +import actions from './../actions'; +import AcpOffer from './../components/acp.offer'; + +function Item(item) { + + console.log(item); + + return ( +
+ +

{item.listing.data.release.description}

+
+
{item.total}
+
{item.status}
+
+ ); +} + +function SellerGroup(group) { + return ( +
+
+

{group.username} - {group.seller}

+

{group.offers.length} offers

+

Ships: {group.mentionsAustralia && 'AU'} {group.mentionsWorldwide && 'WORLD'}

+
+ +
+ {group.offers.map(Item)} +
+
+ ) +} + +function Home() { + const [offers, setOffers] = useState([]); + + useEffect(() => { + async function getOffers() { + const [error, offers] = await actions.getOffersAcp(); + if (error) return console.error(error); + setOffers(offers); + } + getOffers(); + }, []); + + if (!offers.length) { + return ( +
+ loading... +
+ ); + } + + + return ( +
+ {offers.map(SellerGroup)} +
+ ); +} + +export default Home; diff --git a/client/src/style/index.less b/client/src/style/index.less index 46d1253..e8b2e8f 100644 --- a/client/src/style/index.less +++ b/client/src/style/index.less @@ -19,7 +19,7 @@ ol, ul { } img { - max-width: 100%; + // max-width: 100%; height: auto; } @@ -194,7 +194,7 @@ button, [type=submit] { .alt-text { font-family: var(--secondary-font-family); - font-size: var(--small-font-size); + // font-size: var(--small-font-size); text-transform: uppercase; } @@ -279,8 +279,12 @@ a { header { display: grid; - grid-template-columns: 1fr min-content 1fr; + grid-template-columns: 1fr 1fr min-content; padding-bottom: var(--base-spacing); + + a { + white-space: nowrap; + } } .bundles { @@ -289,7 +293,7 @@ header { .bundle { display: grid; grid-template-columns: 2fr 1fr; - margin-bottom: var(--base-spacing); + margin-bottom: var(--base-spacing-double); .items { grid-column: 1 / -1; @@ -300,6 +304,9 @@ header { div { margin-right: var(--base-spacing); margin-bottom: var(--base-spacing); + + display: grid; + grid-template-rows: 1fr 1fr 1fr; } } } @@ -308,5 +315,39 @@ header { .offers { display: grid; grid-template-columns: 1fr 1fr; + + .offer { + display: grid; + grid-template-areas: + "details img" + "btn btn"; + + padding: var(--base-spacing); + margin: var(--base-spacing-half); + background: var(--secondary-background-color); + + section { + grid-area: details; + } + + img { + grid-area: img; + justify-self: end; + margin-left: var(--base-spacing-half); + } + + button { + grid-area: btn; + + &.accepted { + background: var(--color-accent-blue); + cursor: auto; + } + } + } } +.number { + font-family: var(--secondary-font-family); + text-transform: uppercase; +}