Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e785e0c8c | ||
|
|
6b73831b51 | ||
|
|
f0623cb74b | ||
|
|
2f8c883d75 | ||
|
|
7262d6b37b | ||
|
|
dfc783c936 | ||
|
|
631942bb41 | ||
|
|
e74ab36313 | ||
|
|
5115dc5f8e | ||
|
|
9e854f5d81 | ||
|
|
5a19bf62fd | ||
|
|
e185d04039 |
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
contact options on landing page
|
||||||
|
|
||||||
listing ids overflowing
|
move want add to rpc
|
||||||
|
|
||||||
skip unofficial release
|
skip unofficial release
|
||||||
|
|
||||||
@@ -9,7 +10,6 @@ status stepper
|
|||||||
queue up wantlist adds to gracefully handle rate limiting
|
queue up wantlist adds to gracefully handle rate limiting
|
||||||
|
|
||||||
push offers
|
push offers
|
||||||
cover off admin routes and commands
|
|
||||||
|
|
||||||
listen / notify
|
listen / notify
|
||||||
on offers
|
on offers
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ async function start() {
|
|||||||
events,
|
events,
|
||||||
} = createServices(log);
|
} = createServices(log);
|
||||||
|
|
||||||
// events.emit(Events.StartupSync);
|
// events.emit(Events.Sync);
|
||||||
// events.emit(Events.ListingsUpdated);
|
// events.emit(Events.ListingsUpdated);
|
||||||
|
|
||||||
api.listen();
|
api.listen();
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Listing {
|
model Listing {
|
||||||
id Int @id
|
id BigInt @id
|
||||||
seller Int
|
seller BigInt
|
||||||
data Json
|
data Json
|
||||||
|
|
||||||
wantId String @db.Uuid
|
wantId String @db.Uuid
|
||||||
@@ -43,7 +43,7 @@ model User {
|
|||||||
|
|
||||||
model Want {
|
model Want {
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||||
release Int
|
release BigInt
|
||||||
data Json
|
data Json
|
||||||
|
|
||||||
userId String @db.Uuid
|
userId String @db.Uuid
|
||||||
@@ -71,7 +71,7 @@ model Offer {
|
|||||||
shipping Decimal @db.Money
|
shipping Decimal @db.Money
|
||||||
total Decimal @db.Money
|
total Decimal @db.Money
|
||||||
|
|
||||||
listingId Int
|
listingId BigInt
|
||||||
listing Listing @relation(fields: [listingId], references: [id])
|
listing Listing @relation(fields: [listingId], references: [id])
|
||||||
|
|
||||||
userId String @db.Uuid
|
userId String @db.Uuid
|
||||||
@@ -80,6 +80,9 @@ model Offer {
|
|||||||
wantId String @db.Uuid
|
wantId String @db.Uuid
|
||||||
want Want @relation(fields: [wantId], references: [id], onDelete: Cascade)
|
want Want @relation(fields: [wantId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @default(now())
|
||||||
|
|
||||||
// only one offer per want for a user
|
// only one offer per want for a user
|
||||||
// @@unique([userId, wantId])
|
// @@unique([userId, wantId])
|
||||||
@@unique([listingId])
|
@@unique([listingId])
|
||||||
|
|||||||
+6
-19
@@ -13,9 +13,11 @@ import Bluebird from 'bluebird';
|
|||||||
import type Log from 'bunyan';
|
import type Log from 'bunyan';
|
||||||
import type { Listing, User, Want, Prisma } from '@prisma/client';
|
import type { Listing, User, Want, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
import { Pg } from './pg';
|
import type { Pg } from './pg';
|
||||||
import { Discogs } from './discogs';
|
import type { Discogs } from './discogs';
|
||||||
import { Offers } from './offers';
|
import type { Offers } from './offers';
|
||||||
|
import type { Rpc } from './rpc';
|
||||||
|
|
||||||
import routerAuth from './routers/router.auth';
|
import routerAuth from './routers/router.auth';
|
||||||
import { Events } from './events';
|
import { Events } from './events';
|
||||||
import createRpc, { RpcMessageType } from './rpc';
|
import createRpc, { RpcMessageType } from './rpc';
|
||||||
@@ -61,13 +63,6 @@ export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Of
|
|||||||
ctx.status = 200;
|
ctx.status = 200;
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/user/offers', async function userOffersGet(ctx, next) {
|
|
||||||
ctx.assert(ctx.isAuthenticated(), 401, 'log in required');
|
|
||||||
const user = ctx.state.user;
|
|
||||||
const offers = await pg.userOffers(user);
|
|
||||||
ctx.response.body = offers;
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/want', async function wantPost(ctx, next) {
|
router.post('/want', async function wantPost(ctx, next) {
|
||||||
ctx.assert(ctx.isAuthenticated(), 401, 'log in required');
|
ctx.assert(ctx.isAuthenticated(), 401, 'log in required');
|
||||||
const { releaseId } = ctx.request.body;
|
const { releaseId } = ctx.request.body;
|
||||||
@@ -87,14 +82,6 @@ export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Of
|
|||||||
ctx.response.body = wants;
|
ctx.response.body = wants;
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/want/all', async function wantGet(ctx, next) {
|
|
||||||
ctx.assert(ctx.isAuthenticated(), 401, 'log in required');
|
|
||||||
const user = ctx.state.user;
|
|
||||||
const wants = await pg.wantList(user);
|
|
||||||
|
|
||||||
ctx.response.body = wants;
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/want/search', async function wantSearch(ctx, next) {
|
router.post('/want/search', async function wantSearch(ctx, next) {
|
||||||
const { query } = ctx.request.body;
|
const { query } = ctx.request.body;
|
||||||
const results = await discogs.search(query);
|
const results = await discogs.search(query);
|
||||||
@@ -120,7 +107,7 @@ export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Of
|
|||||||
// log.child...
|
// log.child...
|
||||||
|
|
||||||
const ws = ctx.websocket;
|
const ws = ctx.websocket;
|
||||||
const rpc = createRpc(log, pg, ws, discogs, offers, ctx.state.user);
|
const rpc = createRpc(log, pg, ws, discogs, offers, events, ctx.state.user as User | null);
|
||||||
|
|
||||||
if (ctx.state.user) events.emit(Events.Connection, { ws, id: ctx.state.user.id });
|
if (ctx.state.user) events.emit(Events.Connection, { ws, id: ctx.state.user.id });
|
||||||
ws.on('message', async function incoming(message) {
|
ws.on('message', async function incoming(message) {
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@ export default function createDiscogs(log: Log, pg: Pg) {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const additions = results.filter(r => r.status === 'fulfilled').map(r => r.value);
|
const additions = results.filter(r => r.status === 'fulfilled').map(r => r.value);
|
||||||
|
|
||||||
log.info(additions);
|
log.info('want list additions', additions);
|
||||||
|
|
||||||
return additions;
|
return additions;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -10,7 +10,7 @@ import routerAuth from './routers/router.auth';
|
|||||||
|
|
||||||
export enum Events {
|
export enum Events {
|
||||||
WantsAdded = 'WantsAdded',
|
WantsAdded = 'WantsAdded',
|
||||||
StartupSync = 'StartupSync',
|
Sync = 'Sync',
|
||||||
ListingsUpdated = 'ListingsUpdated',
|
ListingsUpdated = 'ListingsUpdated',
|
||||||
Connection = 'Connection',
|
Connection = 'Connection',
|
||||||
}
|
}
|
||||||
@@ -51,11 +51,12 @@ export default function createEvents(log: Log, pg: Pg, discogs, offers) {
|
|||||||
log.info({ event: Events.WantsAdded, wants: wants.map(w => wantDetails(w)) }, 'starting listings population');
|
log.info({ event: Events.WantsAdded, wants: wants.map(w => wantDetails(w)) }, 'starting listings population');
|
||||||
await updateWantListings(wants);
|
await updateWantListings(wants);
|
||||||
log.info({ event: Events.WantsAdded, wants: wants.map(w => wantDetails(w)) }, 'finished populating listings');
|
log.info({ event: Events.WantsAdded, wants: wants.map(w => wantDetails(w)) }, 'finished populating listings');
|
||||||
|
events.emit(Events.ListingsUpdated);
|
||||||
});
|
});
|
||||||
|
|
||||||
events.on(Events.StartupSync, async function startupSync() {
|
events.on(Events.Sync, async function sync() {
|
||||||
const wants = await pg.wantsToSync();
|
const wants = await pg.wantsToSync();
|
||||||
log.info({ event: Events.StartupSync, wants: wants.length });
|
log.info({ event: Events.Sync, wants: wants.length });
|
||||||
await updateWantListings(wants);
|
await updateWantListings(wants);
|
||||||
events.emit(Events.ListingsUpdated);
|
events.emit(Events.ListingsUpdated);
|
||||||
});
|
});
|
||||||
|
|||||||
+7
-5
@@ -51,8 +51,7 @@ export default function createOffers(log: Log, pg: Pg) {
|
|||||||
// TODO probably need to take one of each condition of record from a store
|
// TODO probably need to take one of each condition of record from a store
|
||||||
// eg. they have a VG+ and a M we should make offers for each
|
// eg. they have a VG+ and a M we should make offers for each
|
||||||
|
|
||||||
log.info(sorted);
|
log.trace('generated offers', sorted);
|
||||||
|
|
||||||
const inserts = await pg.offersUpdate(sorted);
|
const inserts = await pg.offersUpdate(sorted);
|
||||||
|
|
||||||
return inserts;
|
return inserts;
|
||||||
@@ -113,12 +112,14 @@ export default function createOffers(log: Log, pg: Pg) {
|
|||||||
const offers = await pg.userOffers(user);
|
const offers = await pg.userOffers(user);
|
||||||
const groups = Object.values(groupBy(offers, (o) => o.wantId))
|
const groups = Object.values(groupBy(offers, (o) => o.wantId))
|
||||||
.map(g => {
|
.map(g => {
|
||||||
const offers = sortBy(g, sortOffers);
|
|
||||||
|
|
||||||
// the offer status is just the most advanced status
|
// the offer status is just the most advanced status
|
||||||
// based on sort
|
// based on sort
|
||||||
|
const offers = sortBy(g, sortOffers);
|
||||||
const status = offers[0].status;
|
const status = offers[0].status;
|
||||||
|
|
||||||
|
// now actually sort them by price
|
||||||
|
offers.sort((a, b) => a.total - b.total);
|
||||||
|
|
||||||
const details = offers[0].want.data;
|
const details = offers[0].want.data;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -134,7 +135,8 @@ export default function createOffers(log: Log, pg: Pg) {
|
|||||||
async function samples() {
|
async function samples() {
|
||||||
const user = await pg.userFindEmail('ntr@mnml.gg');
|
const user = await pg.userFindEmail('ntr@mnml.gg');
|
||||||
const offerGroups = await userOffers(user);
|
const offerGroups = await userOffers(user);
|
||||||
return offerGroups.slice(0, 1);
|
const i = Math.floor(Math.random() * offerGroups.length / 2);
|
||||||
|
return offerGroups[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+12
-2
@@ -21,7 +21,10 @@ export default function createPg(log: Log, prisma: PrismaClient) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function userWants(user: User) {
|
async function userWants(user: User) {
|
||||||
return prisma.want.findMany({ where: { userId: user.id }});
|
return prisma.want.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
include: { _count: { select: { offers: true }} },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function userOffers(user: User) {
|
async function userOffers(user: User) {
|
||||||
@@ -34,6 +37,7 @@ export default function createPg(log: Log, prisma: PrismaClient) {
|
|||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
orderBy: [
|
orderBy: [
|
||||||
|
{ updatedAt: 'asc' },
|
||||||
{ total: 'asc' },
|
{ total: 'asc' },
|
||||||
{ listingId: 'asc' },
|
{ listingId: 'asc' },
|
||||||
],
|
],
|
||||||
@@ -169,6 +173,8 @@ export default function createPg(log: Log, prisma: PrismaClient) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
log.info({ count }, 'second stage open offers removed');
|
||||||
|
|
||||||
return count;
|
return count;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -221,6 +227,7 @@ export default function createPg(log: Log, prisma: PrismaClient) {
|
|||||||
// but for ease of abstraction we get the listings themselves
|
// but for ease of abstraction we get the listings themselves
|
||||||
// grouped by seller
|
// grouped by seller
|
||||||
const candidates = await prisma.$queryRaw`
|
const candidates = await prisma.$queryRaw`
|
||||||
|
WITH groups AS (
|
||||||
select
|
select
|
||||||
seller, count(distinct("wantId")), array_agg(id) as listings
|
seller, count(distinct("wantId")), array_agg(id) as listings
|
||||||
from
|
from
|
||||||
@@ -231,7 +238,10 @@ export default function createPg(log: Log, prisma: PrismaClient) {
|
|||||||
seller
|
seller
|
||||||
order by
|
order by
|
||||||
count desc
|
count desc
|
||||||
limit 5;
|
)
|
||||||
|
select *
|
||||||
|
from groups
|
||||||
|
where count >= 5;
|
||||||
` as BundleCandidate[];
|
` as BundleCandidate[];
|
||||||
|
|
||||||
return Promise.all(candidates.map(async c => prisma.listing.findMany({
|
return Promise.all(candidates.map(async c => prisma.listing.findMany({
|
||||||
|
|||||||
+27
-6
@@ -1,5 +1,3 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
|
||||||
import { setTimeout } from 'timers/promises';
|
|
||||||
import assert from 'assert/strict';
|
import assert from 'assert/strict';
|
||||||
|
|
||||||
import type Log from 'bunyan';
|
import type Log from 'bunyan';
|
||||||
@@ -12,6 +10,7 @@ import type { Offers } from './offers';
|
|||||||
|
|
||||||
import { Status } from './offers';
|
import { Status } from './offers';
|
||||||
import { isAdmin } from './utils';
|
import { isAdmin } from './utils';
|
||||||
|
import { Events } from './events';
|
||||||
|
|
||||||
function checkMsgType(msgType: RpcMessageType): msgType is RpcMessageType {
|
function checkMsgType(msgType: RpcMessageType): msgType is RpcMessageType {
|
||||||
if (RpcMessageType[msgType]) {
|
if (RpcMessageType[msgType]) {
|
||||||
@@ -21,7 +20,7 @@ function checkMsgType(msgType: RpcMessageType): msgType is RpcMessageType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// rpc is created per connection from the api
|
// rpc is created per connection from the api
|
||||||
export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers: Offers, user: User | null) {
|
export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers: Offers, events, user: User | null) {
|
||||||
async function ping(): Promise<PongMessage> {
|
async function ping(): Promise<PongMessage> {
|
||||||
return [RpcMessageType.Pong, []];
|
return [RpcMessageType.Pong, []];
|
||||||
}
|
}
|
||||||
@@ -71,6 +70,12 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers
|
|||||||
return [RpcMessageType.AcpOffers, await offers.acpOffers()];
|
return [RpcMessageType.AcpOffers, await offers.acpOffers()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function acpListingsSync(): Promise<AcpListingsSyncMessage> {
|
||||||
|
assert(isAdmin(user), 'not authorised');
|
||||||
|
events.emit(Events.Sync);
|
||||||
|
return [RpcMessageType.AcpListingsSync, []];
|
||||||
|
}
|
||||||
|
|
||||||
async function receive(message: any): Promise<RpcMessage> {
|
async function receive(message: any): Promise<RpcMessage> {
|
||||||
const [msgType, params] = message;
|
const [msgType, params] = message;
|
||||||
|
|
||||||
@@ -101,6 +106,8 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers
|
|||||||
return acpOfferStatusSet(params);
|
return acpOfferStatusSet(params);
|
||||||
case RpcMessageType.AcpOffers:
|
case RpcMessageType.AcpOffers:
|
||||||
return acpOffers();
|
return acpOffers();
|
||||||
|
case RpcMessageType.AcpListingsSync:
|
||||||
|
return acpListingsSync();
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Message type ${msgType} is known but unhandled`);
|
throw new Error(`Message type ${msgType} is known but unhandled`);
|
||||||
@@ -113,9 +120,12 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function anonConnection() {
|
async function anonConnection() {
|
||||||
const samples = await offers.samples();
|
|
||||||
const sampleMsg: OfferSamplesMessage = [RpcMessageType.OfferSamples, samples];
|
}
|
||||||
send(sampleMsg);
|
|
||||||
|
async function adminConnection() {
|
||||||
|
// don't need it, requested on route
|
||||||
|
// send(await acpOffers());
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onConnection() {
|
async function onConnection() {
|
||||||
@@ -124,9 +134,15 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers
|
|||||||
const userMsg: UserMessage = [RpcMessageType.User, user];
|
const userMsg: UserMessage = [RpcMessageType.User, user];
|
||||||
send(userMsg);
|
send(userMsg);
|
||||||
|
|
||||||
|
const userOffers = await offers.userOffers(user);
|
||||||
|
const offersMsg: OffersMessage = [RpcMessageType.Offers, userOffers];
|
||||||
|
send(offersMsg);
|
||||||
|
|
||||||
const wants = await pg.userWants(user);
|
const wants = await pg.userWants(user);
|
||||||
const wantsMsg: WantsMessage = [RpcMessageType.Wants, wants];
|
const wantsMsg: WantsMessage = [RpcMessageType.Wants, wants];
|
||||||
send(wantsMsg);
|
send(wantsMsg);
|
||||||
|
|
||||||
|
if (isAdmin(user)) return adminConnection();
|
||||||
}
|
}
|
||||||
|
|
||||||
function send(msg: RpcMessage) {
|
function send(msg: RpcMessage) {
|
||||||
@@ -144,6 +160,8 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Rpc = ReturnType<typeof createRpc>;
|
||||||
|
|
||||||
export enum RpcMessageType {
|
export enum RpcMessageType {
|
||||||
Ping = 'Ping',
|
Ping = 'Ping',
|
||||||
Pong = 'Pong',
|
Pong = 'Pong',
|
||||||
@@ -164,6 +182,7 @@ export enum RpcMessageType {
|
|||||||
|
|
||||||
AcpOfferStatusSet = 'AcpOfferStatusSet',
|
AcpOfferStatusSet = 'AcpOfferStatusSet',
|
||||||
AcpOffers = 'AcpOffers',
|
AcpOffers = 'AcpOffers',
|
||||||
|
AcpListingsSync = 'AcpListingsSync',
|
||||||
|
|
||||||
WantRm = 'WantRm',
|
WantRm = 'WantRm',
|
||||||
}
|
}
|
||||||
@@ -180,6 +199,7 @@ type OfferSamplesMessage = [RpcMessageType.OfferSamples, Offer[]];
|
|||||||
|
|
||||||
type AcpOfferStatusSetMessage = [RpcMessageType.AcpOfferStatusSet, Offer[]];
|
type AcpOfferStatusSetMessage = [RpcMessageType.AcpOfferStatusSet, Offer[]];
|
||||||
type AcpOffersMessage = [RpcMessageType.AcpOffers, Offer[]];
|
type AcpOffersMessage = [RpcMessageType.AcpOffers, Offer[]];
|
||||||
|
type AcpListingsSyncMessage = [RpcMessageType.AcpListingsSync, []];
|
||||||
|
|
||||||
export type RpcMessage =
|
export type RpcMessage =
|
||||||
PingMessage |
|
PingMessage |
|
||||||
@@ -196,6 +216,7 @@ export type RpcMessage =
|
|||||||
|
|
||||||
AcpOfferStatusSetMessage |
|
AcpOfferStatusSetMessage |
|
||||||
AcpOffersMessage |
|
AcpOffersMessage |
|
||||||
|
AcpListingsSyncMessage |
|
||||||
|
|
||||||
ErrorMessage;
|
ErrorMessage;
|
||||||
|
|
||||||
|
|||||||
Generated
+11
@@ -10,6 +10,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@reduxjs/toolkit": "^1.8.5",
|
"@reduxjs/toolkit": "^1.8.5",
|
||||||
|
"izitoast": "^1.4.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"logrocket": "^3.0.1",
|
"logrocket": "^3.0.1",
|
||||||
"preact": "^10.10.0",
|
"preact": "^10.10.0",
|
||||||
@@ -10844,6 +10845,11 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/izitoast": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/izitoast/-/izitoast-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-Oc1X2wiQtPp39i5VpIjf3GJf5sfCtHKXZ5szx7RareyEeFLUlcEW0FSfBni28+Ul6KNKZRKzhVuWzSP4Xngh0w=="
|
||||||
|
},
|
||||||
"node_modules/jake": {
|
"node_modules/jake": {
|
||||||
"version": "10.8.5",
|
"version": "10.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz",
|
||||||
@@ -29566,6 +29572,11 @@
|
|||||||
"istanbul-lib-report": "^3.0.0"
|
"istanbul-lib-report": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"izitoast": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/izitoast/-/izitoast-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-Oc1X2wiQtPp39i5VpIjf3GJf5sfCtHKXZ5szx7RareyEeFLUlcEW0FSfBni28+Ul6KNKZRKzhVuWzSP4Xngh0w=="
|
||||||
|
},
|
||||||
"jake": {
|
"jake": {
|
||||||
"version": "10.8.5",
|
"version": "10.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@reduxjs/toolkit": "^1.8.5",
|
"@reduxjs/toolkit": "^1.8.5",
|
||||||
|
"izitoast": "^1.4.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"logrocket": "^3.0.1",
|
"logrocket": "^3.0.1",
|
||||||
"preact": "^10.10.0",
|
"preact": "^10.10.0",
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ function Want(want) {
|
|||||||
</div>
|
</div>
|
||||||
<img src={r.thumb} />
|
<img src={r.thumb} />
|
||||||
<div class='details'>
|
<div class='details'>
|
||||||
<div class='alt'>{r.year}</div>
|
<div class='alt'>{r.year} - {r.formats[0].descriptions.join(' ')}</div>
|
||||||
<div class='alt'>{r.formats[0].descriptions.join(' ')}</div>
|
<div class='alt'>{want._count.offers} offers</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClick} >{text}</button>
|
<button onClick={onClick} >{text}</button>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
+4
-15
@@ -32,21 +32,7 @@ function onError(error) {
|
|||||||
api.interceptors.response.use(onSuccess, onError);
|
api.interceptors.response.use(onSuccess, onError);
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
getBundles: async function getBundles() {
|
// TODO move to rpc
|
||||||
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');
|
|
||||||
},
|
|
||||||
|
|
||||||
postWantSearch: async function postWantSearch(query) {
|
postWantSearch: async function postWantSearch(query) {
|
||||||
return api.post('/want/search', { query });
|
return api.post('/want/search', { query });
|
||||||
@@ -60,6 +46,9 @@ const actions = {
|
|||||||
return api.post('/want/discogs', { username });
|
return api.post('/want/discogs', { username });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --------------------------------
|
||||||
|
// require as http to set cookies
|
||||||
|
|
||||||
getLogin: async function getLogin() {
|
getLogin: async function getLogin() {
|
||||||
return api.get('/auth/login')
|
return api.get('/auth/login')
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import 'izitoast/dist/css/iziToast.min.css';
|
||||||
import './style/index.less';
|
import './style/index.less';
|
||||||
|
|
||||||
import App from './app';
|
import App from './app';
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
@@ -70,22 +70,19 @@ function Home() {
|
|||||||
const offers = useSelector((state: State) => state.acpOffers);
|
const offers = useSelector((state: State) => state.acpOffers);
|
||||||
const ws = useSelector((state: State) => state.ws);
|
const ws = useSelector((state: State) => state.ws);
|
||||||
|
|
||||||
// useEffect(() => {
|
function sync() {
|
||||||
// ws.sendAcpOffers();
|
ws.sendAcpListingsSync();
|
||||||
// }, [offers, ws]);
|
|
||||||
|
|
||||||
if (!offers.length) {
|
|
||||||
return (
|
|
||||||
<div class={'offers'}>
|
|
||||||
loading...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={sync}> sync listings </button>
|
||||||
|
<button onClick={sync}> generate offers </button>
|
||||||
|
<hr />
|
||||||
<div class='bundles'>
|
<div class='bundles'>
|
||||||
{offers.map(SellerGroup)}
|
{offers.map(SellerGroup)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,23 +4,20 @@ import { useSelector } from 'react-redux'
|
|||||||
import { route } from 'preact-router';
|
import { route } from 'preact-router';
|
||||||
|
|
||||||
import { State } from './../store'
|
import { State } from './../store'
|
||||||
|
import * as toast from './../toast';
|
||||||
|
|
||||||
import Want from './../components/want';
|
import Want from './../components/want';
|
||||||
|
|
||||||
interface Props {
|
const List = () => {
|
||||||
user: any,
|
|
||||||
wants: any[],
|
|
||||||
getUserOffers: (userId: string) => void,
|
|
||||||
}
|
|
||||||
|
|
||||||
const List = ({ user }: Props) => {
|
|
||||||
const wants = useSelector((state: State) => state.wants);
|
const wants = useSelector((state: State) => state.wants);
|
||||||
|
const user = useSelector((state: State) => state.user);
|
||||||
|
|
||||||
if (!wants) {
|
if (!wants) {
|
||||||
return <div> loading... </div>;
|
return <div> loading... </div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!wants.length) {
|
if (!wants.length) {
|
||||||
|
toast.info('List Empty', 'You have no items in your list, add some with search.');
|
||||||
route('/search');
|
route('/search');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
-25
@@ -6,19 +6,19 @@ import { useSelector, useDispatch } from 'react-redux'
|
|||||||
import http from './../http';
|
import http from './../http';
|
||||||
import { State, userState } from './../store';
|
import { State, userState } from './../store';
|
||||||
import Swirl from './../components/swirl';
|
import Swirl from './../components/swirl';
|
||||||
import OfferGroup from './../components/offer.group';
|
|
||||||
|
|
||||||
const Login = ({ mode }) => {
|
const Login = ({ mode }) => {
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
const user = useSelector((state: State) => state.user);
|
const user = useSelector((state: State) => state.user);
|
||||||
const ws = useSelector((state: State) => state.ws);
|
const ws = useSelector((state: State) => state.ws);
|
||||||
|
|
||||||
const [email, setEmail] = useState('')
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
async function postLogin() {
|
function gotoRegister() {
|
||||||
|
return route('/register');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postLogin(email, password) {
|
||||||
const [error, user] = await http.postLogin(email, password);
|
const [error, user] = await http.postLogin(email, password);
|
||||||
if (error) return setError(error);
|
if (error) return setError(error);
|
||||||
route('/');
|
route('/');
|
||||||
@@ -27,17 +27,23 @@ const Login = ({ mode }) => {
|
|||||||
ws.connect();
|
ws.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postRegister() {
|
async function postRegister(email, password, confirm) {
|
||||||
|
if (password !== confirm) return setError('passwords do not match');
|
||||||
|
|
||||||
const [error, user] = await http.postRegister(email, password);
|
const [error, user] = await http.postRegister(email, password);
|
||||||
if (error) return setError(error);
|
if (error) return setError(error);
|
||||||
route('/search');
|
route('/search');
|
||||||
dispatch(userState.actions.set(user));
|
dispatch(userState.actions.set(user));
|
||||||
|
// reconnect ws to get logged in methods
|
||||||
|
ws.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(e) {
|
function handleSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (mode === 'login') return postLogin()
|
|
||||||
if (mode === 'register') return postRegister();
|
console.log(e.target.email, e.target.password, e.target.confirm);
|
||||||
|
if (mode === 'login') return postLogin(e.target.email.value, e.target.password.value);
|
||||||
|
if (mode === 'register') return postRegister(e.target.email.value, e.target.password.value, e.target.confirm.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailRef = useRef<HTMLInputElement>()
|
const emailRef = useRef<HTMLInputElement>()
|
||||||
@@ -53,21 +59,21 @@ const Login = ({ mode }) => {
|
|||||||
if (mode === 'register') return (
|
if (mode === 'register') return (
|
||||||
<div class="login">
|
<div class="login">
|
||||||
{errorEl}
|
{errorEl}
|
||||||
<form action="">
|
<form action="" onSubmit={handleSubmit}>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<label for="email">email</label>
|
<label for="email">email</label>
|
||||||
<input type="email" name="email" id="email" ref={emailRef} value={email} required onInput={(e) => setEmail((e.target as HTMLInputElement).value)} />
|
<input type="email" name="email" id="email" ref={emailRef} required />
|
||||||
</div>
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<label for="password">password</label>
|
<label for="password">password</label>
|
||||||
<input type="password" name="password" id="password" value={password} required onInput={(e) => setPassword((e.target as HTMLInputElement).value)} />
|
<input type="password" name="password" id="password" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<label for="password">confirm password</label>
|
<label for="password">confirm password</label>
|
||||||
<input type="password" name="password" id="password" value={confirmPassword} required onInput={(e) => setConfirmPassword((e.target as HTMLInputElement).value)} />
|
<input type="password" name="confirm" id="confirm" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<input type="submit" value="register" onClick={handleSubmit} />
|
<input type="submit" value="register" />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,38 +83,33 @@ const Login = ({ mode }) => {
|
|||||||
return (
|
return (
|
||||||
<div class="login">
|
<div class="login">
|
||||||
{errorEl}
|
{errorEl}
|
||||||
<form action="">
|
<form action="" onSubmit={handleSubmit}>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<label for="email">email</label>
|
<label for="email">email</label>
|
||||||
<input type="email" name="email" id="email" ref={emailRef} value={email} required onInput={(e) => setEmail((e.target as HTMLInputElement).value)} />
|
<input type="email" name="email" id="email" ref={emailRef} />
|
||||||
</div>
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<label for="password">password</label>
|
<label for="password">password</label>
|
||||||
<input type="password" name="password" id="password" value={password} required onInput={(e) => setPassword((e.target as HTMLInputElement).value)} />
|
<input type="password" name="password" id="password" />
|
||||||
</div>
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<input type="submit" value="login" onClick={handleSubmit} />
|
<input type="submit" value="login"/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<button onClick={gotoRegister}> create account </button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function LoginPage({ mode }) {
|
function LoginPage({ mode }) {
|
||||||
const offers = useSelector((state: State) => state.offers);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="landing">
|
<div class="landing">
|
||||||
<Login mode={mode} />
|
<Login mode={mode} />
|
||||||
<div class='offers'>
|
|
||||||
{offers.map(OfferGroup)}
|
|
||||||
<div>
|
|
||||||
<Swirl />
|
<Swirl />
|
||||||
<div class='words'>
|
<div class='words'>
|
||||||
|
<p>Mates Crates is a record importing service.</p>
|
||||||
<p>We bundle orders together from thousands of online stores to reduce the cost of shipping vinyl.</p>
|
<p>We bundle orders together from thousands of online stores to reduce the cost of shipping vinyl.</p>
|
||||||
<p>Based in Preston.</p>
|
<p>Records can then be picked up from Preston.</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { useState, useEffect } from 'preact/hooks';
|
|
||||||
import { route } from 'preact-router';
|
import { route } from 'preact-router';
|
||||||
import { useSelector, useDispatch } from 'react-redux'
|
import { useSelector } from 'react-redux'
|
||||||
|
|
||||||
import http from './../http';
|
import { State } from './../store';
|
||||||
import { State, offersState } from './../store';
|
import * as toast from './../toast';
|
||||||
|
|
||||||
import OfferGroup from './../components/offer.group';
|
import OfferGroup from './../components/offer.group';
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
const dispatch = useDispatch()
|
|
||||||
const offers = useSelector((state: State) => state.offers);
|
const offers = useSelector((state: State) => state.offers);
|
||||||
|
|
||||||
if (offers && offers.length === 0) {
|
if (offers && offers.length === 0) {
|
||||||
|
toast.info('no offers', 'You have no offers yet, search for some more items.');
|
||||||
route('/search');
|
route('/search');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { useState, useRef, useEffect } from 'preact/hooks';
|
import { useState, useRef, useEffect } from 'preact/hooks';
|
||||||
import { ExternalLink, Check } from 'react-feather';
|
import { ExternalLink, Check } from 'react-feather';
|
||||||
|
import { useSelector } from 'react-redux'
|
||||||
|
|
||||||
|
import { State } from './../store';
|
||||||
|
|
||||||
import http from '../http';
|
import http from '../http';
|
||||||
|
|
||||||
function Result(r) {
|
function Result(r, wants) {
|
||||||
const [added, setAdded] = useState(false)
|
const [added, setAdded] = useState(false)
|
||||||
|
|
||||||
async function onClick() {
|
async function onClick() {
|
||||||
@@ -13,7 +16,9 @@ function Result(r) {
|
|||||||
setAdded(true);
|
setAdded(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const classes = `${added ? 'added' : ''}`;
|
const wanted = wants.some(w => w.release === r.id);
|
||||||
|
|
||||||
|
const classes = `${added || wanted ? 'added' : ''}`;
|
||||||
const btnText = added
|
const btnText = added
|
||||||
? <Check />
|
? <Check />
|
||||||
: '+';
|
: '+';
|
||||||
@@ -39,6 +44,8 @@ const User = () => {
|
|||||||
const [results, setResults] = useState([]);
|
const [results, setResults] = useState([]);
|
||||||
const [infoText, setInfoText] = useState('');
|
const [infoText, setInfoText] = useState('');
|
||||||
|
|
||||||
|
const wants = useSelector((state: State) => state.wants);
|
||||||
|
|
||||||
async function fetchResults() {
|
async function fetchResults() {
|
||||||
const [error, results] = await http.postWantSearch(query);
|
const [error, results] = await http.postWantSearch(query);
|
||||||
|
|
||||||
@@ -106,7 +113,7 @@ const User = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class='results'>
|
<div class='results'>
|
||||||
{infoEl}
|
{infoEl}
|
||||||
{results.map(Result)}
|
{results.map(r => Result(r, wants))}
|
||||||
{topEl}
|
{topEl}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const userState = createSlice({
|
|||||||
|
|
||||||
export const offersState = createSlice({
|
export const offersState = createSlice({
|
||||||
name: 'offers',
|
name: 'offers',
|
||||||
initialState: [],
|
initialState: null,
|
||||||
reducers: {
|
reducers: {
|
||||||
set: (state, action) => action.payload
|
set: (state, action) => action.payload
|
||||||
},
|
},
|
||||||
@@ -52,6 +52,7 @@ const reducer = combineReducers({
|
|||||||
|
|
||||||
const store = createStore(
|
const store = createStore(
|
||||||
reducer,
|
reducer,
|
||||||
|
// @ts-ignore -- devtools window exists on dev
|
||||||
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
|
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -217,7 +217,6 @@ button, [type=submit] {
|
|||||||
grid-gap: calc(var(--spacing-double) * 2);
|
grid-gap: calc(var(--spacing-double) * 2);
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"login swirl"
|
"login swirl"
|
||||||
"login sample"
|
|
||||||
"login words";
|
"login words";
|
||||||
|
|
||||||
.login {
|
.login {
|
||||||
@@ -228,8 +227,8 @@ button, [type=submit] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.swirl {
|
.swirl {
|
||||||
// grid-area: swirl;
|
grid-area: swirl;
|
||||||
height: 5rem;
|
height: 15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.words {
|
.words {
|
||||||
@@ -345,6 +344,8 @@ article {
|
|||||||
}
|
}
|
||||||
|
|
||||||
form {
|
form {
|
||||||
|
margin-bottom: var(--spacing);
|
||||||
|
|
||||||
label, input {
|
label, input {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -364,6 +365,7 @@ form {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin-bottom: var(--spacing-half);
|
margin-bottom: var(--spacing-half);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -539,3 +541,21 @@ header {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ------------
|
||||||
|
// toasts
|
||||||
|
|
||||||
|
.iziToast {
|
||||||
|
border-radius: 0;
|
||||||
|
border-color: var(--base-border-color);
|
||||||
|
|
||||||
|
.iziToast-body {
|
||||||
|
font-family: var(--base-font-family);
|
||||||
|
font-size: var(--base-font-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iziToast-title {
|
||||||
|
font-family: var(--secondary-font-family);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import toast from 'izitoast';
|
||||||
|
|
||||||
|
export function info(title, message) {
|
||||||
|
toast.info({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
position: 'bottomCenter',
|
||||||
|
color: '#1f2128',
|
||||||
|
titleColor: '#F4F1D0',
|
||||||
|
messageColor: '#F4F1D0',
|
||||||
|
iconColor: '#F4F1D0',
|
||||||
|
icon: '',
|
||||||
|
theme: 'dark',
|
||||||
|
});
|
||||||
|
}
|
||||||
+5
-4
@@ -50,6 +50,10 @@ export default function createSocket(dispatch) {
|
|||||||
send(['AcpOfferStatusSet', { id, status }]);
|
send(['AcpOfferStatusSet', { id, status }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendAcpListingsSync() {
|
||||||
|
send(['AcpListingsSync', {}]);
|
||||||
|
}
|
||||||
|
|
||||||
let pongTimeout;
|
let pongTimeout;
|
||||||
function onPong() {
|
function onPong() {
|
||||||
pongTimeout = setTimeout(sendPing, 5000);
|
pongTimeout = setTimeout(sendPing, 5000);
|
||||||
@@ -84,7 +88,6 @@ export default function createSocket(dispatch) {
|
|||||||
Offers: onOffers,
|
Offers: onOffers,
|
||||||
User: onUser,
|
User: onUser,
|
||||||
|
|
||||||
OfferSamples: onOffers,
|
|
||||||
AcpOffers: onAcpOffers,
|
AcpOffers: onAcpOffers,
|
||||||
|
|
||||||
Pong: onPong,
|
Pong: onPong,
|
||||||
@@ -112,9 +115,6 @@ export default function createSocket(dispatch) {
|
|||||||
function onOpen() {
|
function onOpen() {
|
||||||
console.log('websocket connected', SOCKET_URL)
|
console.log('websocket connected', SOCKET_URL)
|
||||||
sendPing();
|
sendPing();
|
||||||
sendAcpOffers();
|
|
||||||
sendOffers();
|
|
||||||
sendWants();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onError(event) {
|
function onError(event) {
|
||||||
@@ -158,5 +158,6 @@ export default function createSocket(dispatch) {
|
|||||||
|
|
||||||
sendAcpOffers,
|
sendAcpOffers,
|
||||||
sendAcpOfferStatusSet,
|
sendAcpOfferStatusSet,
|
||||||
|
sendAcpListingsSync,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user