13 Commits
Author SHA1 Message Date
ntr 0e785e0c8c bigint 2022-12-21 23:32:57 +11:00
ntr 6b73831b51 expand offers 2022-12-21 22:02:24 +11:00
ntr f0623cb74b toasts 2022-12-02 11:09:16 +11:00
ntr 2f8c883d75 Merge branch 'master' of ssh://git.mnml.gg:40022/home/ntr/mcrates into sample-offers 2022-12-02 09:22:25 +11:00
ntr 7262d6b37b additional typing 2022-12-02 09:18:45 +11:00
ntr dfc783c936 acp listings sync 2022-11-28 10:46:19 +11:00
ntr 631942bb41 clean up wants flow and http usage 2022-11-28 10:31:37 +11:00
ntr e74ab36313 fix offers comparison 2022-11-28 10:10:37 +11:00
ntr 5115dc5f8e create account btn 2022-11-28 09:58:34 +11:00
ntr 9e854f5d81 properly reconnect on register 2022-11-28 09:52:45 +11:00
ntr 5a19bf62fd adminconnection 2022-11-27 15:08:59 +11:00
ntr e185d04039 fix login form 2022-11-27 14:48:15 +11:00
ntr a0cd49b411 discogs and offers inferred types 2022-11-24 09:35:05 +11:00
25 changed files with 246 additions and 101 deletions

No files matched your search

+2 -2
View File
@@ -1,6 +1,7 @@
# TODO
contact options on landing page
listing ids overflowing
move want add to rpc
skip unofficial release
@@ -9,7 +10,6 @@ status stepper
queue up wantlist adds to gracefully handle rate limiting
push offers
cover off admin routes and commands
listen / notify
on offers
+1 -1
View File
@@ -28,7 +28,7 @@ async function start() {
events,
} = createServices(log);
// events.emit(Events.StartupSync);
// events.emit(Events.Sync);
// events.emit(Events.ListingsUpdated);
api.listen();
+41
View File
@@ -29,6 +29,7 @@
"@types/bcrypt": "^5.0.0",
"@types/bluebird": "^3.5.36",
"@types/koa": "^2.13.5",
"@types/koa-websocket": "^5.0.7",
"@types/lodash": "^4.14.182",
"@types/mocha": "^9.1.1",
"axios": "^0.27.2",
@@ -325,6 +326,17 @@
"@types/koa": "*"
}
},
"node_modules/@types/koa-websocket": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/@types/koa-websocket/-/koa-websocket-5.0.7.tgz",
"integrity": "sha512-zv5F0iaV9zk3iXLzvskUZKgX35RW7F7UNX3tK1FNQUn2nierQWlY2aM0dBTYs0kLVLRtBhsxcUdiKzDp9JPOAw==",
"dev": true,
"dependencies": {
"@types/koa": "*",
"@types/koa-compose": "*",
"@types/ws": "*"
}
},
"node_modules/@types/lodash": {
"version": "4.14.182",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz",
@@ -382,6 +394,15 @@
"integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==",
"dev": true
},
"node_modules/@types/ws": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz",
"integrity": "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@ungap/promise-all-settled": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz",
@@ -3961,6 +3982,17 @@
"@types/koa": "*"
}
},
"@types/koa-websocket": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/@types/koa-websocket/-/koa-websocket-5.0.7.tgz",
"integrity": "sha512-zv5F0iaV9zk3iXLzvskUZKgX35RW7F7UNX3tK1FNQUn2nierQWlY2aM0dBTYs0kLVLRtBhsxcUdiKzDp9JPOAw==",
"dev": true,
"requires": {
"@types/koa": "*",
"@types/koa-compose": "*",
"@types/ws": "*"
}
},
"@types/lodash": {
"version": "4.14.182",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz",
@@ -4018,6 +4050,15 @@
"integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==",
"dev": true
},
"@types/ws": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz",
"integrity": "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@ungap/promise-all-settled": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz",
+1
View File
@@ -16,6 +16,7 @@
"@types/bcrypt": "^5.0.0",
"@types/bluebird": "^3.5.36",
"@types/koa": "^2.13.5",
"@types/koa-websocket": "^5.0.7",
"@types/lodash": "^4.14.182",
"@types/mocha": "^9.1.1",
"axios": "^0.27.2",
+7 -4
View File
@@ -11,8 +11,8 @@ datasource db {
}
model Listing {
id Int @id
seller Int
id BigInt @id
seller BigInt
data Json
wantId String @db.Uuid
@@ -43,7 +43,7 @@ model User {
model Want {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
release Int
release BigInt
data Json
userId String @db.Uuid
@@ -71,7 +71,7 @@ model Offer {
shipping Decimal @db.Money
total Decimal @db.Money
listingId Int
listingId BigInt
listing Listing @relation(fields: [listingId], references: [id])
userId String @db.Uuid
@@ -80,6 +80,9 @@ model Offer {
wantId String @db.Uuid
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
// @@unique([userId, wantId])
@@unique([listingId])
+12 -18
View File
@@ -13,12 +13,16 @@ import Bluebird from 'bluebird';
import type Log from 'bunyan';
import type { Listing, User, Want, Prisma } from '@prisma/client';
import { Pg } from './pg';
import type { Pg } from './pg';
import type { Discogs } from './discogs';
import type { Offers } from './offers';
import type { Rpc } from './rpc';
import routerAuth from './routers/router.auth';
import { Events } from './events';
import createRpc, { RpcMessageType } from './rpc';
export default function createApi(log: Log, pg: Pg, discogs, offers, events) {
export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Offers, events) {
const PORT = 41337;
const WS_PORT = 40055;
const SECRET = 'ohja4Eif9eidah3aj5ta2haa4iemooth';
@@ -59,13 +63,6 @@ export default function createApi(log: Log, pg: Pg, discogs, offers, events) {
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) {
ctx.assert(ctx.isAuthenticated(), 401, 'log in required');
const { releaseId } = ctx.request.body;
@@ -85,14 +82,6 @@ export default function createApi(log: Log, pg: Pg, discogs, offers, events) {
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) {
const { query } = ctx.request.body;
const results = await discogs.search(query);
@@ -110,13 +99,15 @@ export default function createApi(log: Log, pg: Pg, discogs, offers, events) {
app.ws.use(passport.initialize());
app.ws.use(passport.session());
app.ws.use(async (ctx, next) => {
try {
log.info('new ws connection', { user: ctx.state.user });
// log.child...
const ws = ctx.websocket;
const rpc = createRpc(log, pg, ctx.state.user, ws, discogs, offers);
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 });
ws.on('message', async function incoming(message) {
@@ -137,6 +128,9 @@ export default function createApi(log: Log, pg: Pg, discogs, offers, events) {
});
await rpc.onConnection();
} catch (e) {
log.error(e);
}
});
return {
+3 -1
View File
@@ -48,7 +48,7 @@ export default function createDiscogs(log: Log, pg: Pg) {
// @ts-ignore
const additions = results.filter(r => r.status === 'fulfilled').map(r => r.value);
log.info(additions);
log.info('want list additions', additions);
return additions;
}
@@ -112,3 +112,5 @@ export default function createDiscogs(log: Log, pg: Pg) {
search,
};
}
export type Discogs = ReturnType<typeof createDiscogs>;
+4 -3
View File
@@ -10,7 +10,7 @@ import routerAuth from './routers/router.auth';
export enum Events {
WantsAdded = 'WantsAdded',
StartupSync = 'StartupSync',
Sync = 'Sync',
ListingsUpdated = 'ListingsUpdated',
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');
await updateWantListings(wants);
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();
log.info({ event: Events.StartupSync, wants: wants.length });
log.info({ event: Events.Sync, wants: wants.length });
await updateWantListings(wants);
events.emit(Events.ListingsUpdated);
});
+15 -4
View File
@@ -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
// 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);
return inserts;
@@ -113,12 +112,14 @@ export default function createOffers(log: Log, pg: Pg) {
const offers = await pg.userOffers(user);
const groups = Object.values(groupBy(offers, (o) => o.wantId))
.map(g => {
const offers = sortBy(g, sortOffers);
// the offer status is just the most advanced status
// based on sort
const offers = sortBy(g, sortOffers);
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;
return {
@@ -131,11 +132,21 @@ export default function createOffers(log: Log, pg: Pg) {
return groups;
}
async function samples() {
const user = await pg.userFindEmail('ntr@mnml.gg');
const offerGroups = await userOffers(user);
const i = Math.floor(Math.random() * offerGroups.length / 2);
return offerGroups[i];
}
return {
generate,
accept,
statusSet,
acpOffers,
userOffers,
samples,
};
}
export type Offers = ReturnType<typeof createOffers>;
+12 -2
View File
@@ -21,7 +21,10 @@ export default function createPg(log: Log, prisma: PrismaClient) {
}
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) {
@@ -34,6 +37,7 @@ export default function createPg(log: Log, prisma: PrismaClient) {
want: true,
},
orderBy: [
{ updatedAt: 'asc' },
{ total: '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;
});
}
@@ -221,6 +227,7 @@ export default function createPg(log: Log, prisma: PrismaClient) {
// but for ease of abstraction we get the listings themselves
// grouped by seller
const candidates = await prisma.$queryRaw`
WITH groups AS (
select
seller, count(distinct("wantId")), array_agg(id) as listings
from
@@ -231,7 +238,10 @@ export default function createPg(log: Log, prisma: PrismaClient) {
seller
order by
count desc
limit 5;
)
select *
from groups
where count >= 5;
` as BundleCandidate[];
return Promise.all(candidates.map(async c => prisma.listing.findMany({
+39 -4
View File
@@ -1,14 +1,16 @@
import { EventEmitter } from 'node:events';
import { setTimeout } from 'timers/promises';
import assert from 'assert/strict';
import type Log from 'bunyan';
import type { Want, Offer, User } from '@prisma/client';
import { Pg } from './pg';
import type { Pg } from './pg';
import type { Discogs } from './discogs';
import type { Offers } from './offers';
import { Status } from './offers';
import { isAdmin } from './utils';
import { Events } from './events';
function checkMsgType(msgType: RpcMessageType): msgType is RpcMessageType {
if (RpcMessageType[msgType]) {
@@ -18,7 +20,7 @@ function checkMsgType(msgType: RpcMessageType): msgType is RpcMessageType {
}
// rpc is created per connection from the api
export default function createRpc(log: Log, pg: Pg, user: User, ws, discogs, offers) {
export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers: Offers, events, user: User | null) {
async function ping(): Promise<PongMessage> {
return [RpcMessageType.Pong, []];
}
@@ -68,6 +70,12 @@ export default function createRpc(log: Log, pg: Pg, user: User, ws, discogs, off
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> {
const [msgType, params] = message;
@@ -98,6 +106,8 @@ export default function createRpc(log: Log, pg: Pg, user: User, ws, discogs, off
return acpOfferStatusSet(params);
case RpcMessageType.AcpOffers:
return acpOffers();
case RpcMessageType.AcpListingsSync:
return acpListingsSync();
default:
throw new Error(`Message type ${msgType} is known but unhandled`);
@@ -109,13 +119,30 @@ export default function createRpc(log: Log, pg: Pg, user: User, ws, discogs, off
throw new Error(`Invalid parameters for message type ${msgType}: ${JSON.stringify(message)}`);
}
async function anonConnection() {
}
async function adminConnection() {
// don't need it, requested on route
// send(await acpOffers());
}
async function onConnection() {
if (!user) return anonConnection();
const userMsg: UserMessage = [RpcMessageType.User, user];
send(userMsg);
const userOffers = await offers.userOffers(user);
const offersMsg: OffersMessage = [RpcMessageType.Offers, userOffers];
send(offersMsg);
const wants = await pg.userWants(user);
const wantsMsg: WantsMessage = [RpcMessageType.Wants, wants];
send(wantsMsg);
if (isAdmin(user)) return adminConnection();
}
function send(msg: RpcMessage) {
@@ -133,6 +160,8 @@ export default function createRpc(log: Log, pg: Pg, user: User, ws, discogs, off
}
}
export type Rpc = ReturnType<typeof createRpc>;
export enum RpcMessageType {
Ping = 'Ping',
Pong = 'Pong',
@@ -148,10 +177,12 @@ export enum RpcMessageType {
Error = 'Error',
OfferSamples = 'OfferSamples',
OfferStatusSet = 'OfferStatusSet',
AcpOfferStatusSet = 'AcpOfferStatusSet',
AcpOffers = 'AcpOffers',
AcpListingsSync = 'AcpListingsSync',
WantRm = 'WantRm',
}
@@ -164,9 +195,11 @@ type UserMessage = [RpcMessageType.User, User];
type WantsMessage = [RpcMessageType.Wants, Want[]];
type OffersMessage = [RpcMessageType.Offers, Offer[]];
type OfferStatusSetMessage = [RpcMessageType.OfferStatusSet, Offer[]];
type OfferSamplesMessage = [RpcMessageType.OfferSamples, Offer[]];
type AcpOfferStatusSetMessage = [RpcMessageType.AcpOfferStatusSet, Offer[]];
type AcpOffersMessage = [RpcMessageType.AcpOffers, Offer[]];
type AcpListingsSyncMessage = [RpcMessageType.AcpListingsSync, []];
export type RpcMessage =
PingMessage |
@@ -178,10 +211,12 @@ export type RpcMessage =
WantsMessage |
OffersMessage |
OfferSamplesMessage |
OfferStatusSetMessage |
AcpOfferStatusSetMessage |
AcpOffersMessage |
AcpListingsSyncMessage |
ErrorMessage;
+11
View File
@@ -10,6 +10,7 @@
"license": "MIT",
"dependencies": {
"@reduxjs/toolkit": "^1.8.5",
"izitoast": "^1.4.0",
"lodash": "^4.17.21",
"logrocket": "^3.0.1",
"preact": "^10.10.0",
@@ -10844,6 +10845,11 @@
"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": {
"version": "10.8.5",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz",
@@ -29566,6 +29572,11 @@
"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": {
"version": "10.8.5",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz",
+1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@reduxjs/toolkit": "^1.8.5",
"izitoast": "^1.4.0",
"lodash": "^4.17.21",
"logrocket": "^3.0.1",
"preact": "^10.10.0",
+2 -2
View File
@@ -30,8 +30,8 @@ function Want(want) {
</div>
<img src={r.thumb} />
<div class='details'>
<div class='alt'>{r.year}</div>
<div class='alt'>{r.formats[0].descriptions.join(' ')}</div>
<div class='alt'>{r.year} - {r.formats[0].descriptions.join(' ')}</div>
<div class='alt'>{want._count.offers} offers</div>
</div>
<button onClick={onClick} >{text}</button>
</article>
+4 -15
View File
@@ -32,21 +32,7 @@ function onError(error) {
api.interceptors.response.use(onSuccess, onError);
const actions = {
getBundles: async function getBundles() {
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');
},
// TODO move to rpc
postWantSearch: async function postWantSearch(query) {
return api.post('/want/search', { query });
@@ -60,6 +46,9 @@ const actions = {
return api.post('/want/discogs', { username });
},
// --------------------------------
// require as http to set cookies
getLogin: async function getLogin() {
return api.get('/auth/login')
},
+2
View File
@@ -1,4 +1,6 @@
import 'izitoast/dist/css/iziToast.min.css';
import './style/index.less';
import App from './app';
export default App;
+7 -10
View File
@@ -70,22 +70,19 @@ function Home() {
const offers = useSelector((state: State) => state.acpOffers);
const ws = useSelector((state: State) => state.ws);
// useEffect(() => {
// ws.sendAcpOffers();
// }, [offers, ws]);
if (!offers.length) {
return (
<div class={'offers'}>
loading...
</div>
);
function sync() {
ws.sendAcpListingsSync();
}
return (
<div>
<button onClick={sync}> sync listings </button>
<button onClick={sync}> generate offers </button>
<hr />
<div class='bundles'>
{offers.map(SellerGroup)}
</div>
</div>
);
}
+4 -7
View File
@@ -4,23 +4,20 @@ import { useSelector } from 'react-redux'
import { route } from 'preact-router';
import { State } from './../store'
import * as toast from './../toast';
import Want from './../components/want';
interface Props {
user: any,
wants: any[],
getUserOffers: (userId: string) => void,
}
const List = ({ user }: Props) => {
const List = () => {
const wants = useSelector((state: State) => state.wants);
const user = useSelector((state: State) => state.user);
if (!wants) {
return <div> loading... </div>;
}
if (!wants.length) {
toast.info('List Empty', 'You have no items in your list, add some with search.');
route('/search');
}
+25 -17
View File
@@ -12,12 +12,13 @@ const Login = ({ mode }) => {
const user = useSelector((state: State) => state.user);
const ws = useSelector((state: State) => state.ws);
const [email, setEmail] = useState('')
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = 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);
if (error) return setError(error);
route('/');
@@ -26,17 +27,23 @@ const Login = ({ mode }) => {
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);
if (error) return setError(error);
route('/search');
dispatch(userState.actions.set(user));
// reconnect ws to get logged in methods
ws.connect();
}
function handleSubmit(e) {
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>()
@@ -52,21 +59,21 @@ const Login = ({ mode }) => {
if (mode === 'register') return (
<div class="login">
{errorEl}
<form action="">
<form action="" onSubmit={handleSubmit}>
<div class="item">
<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 class="item">
<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 class="item">
<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 class="item">
<input type="submit" value="register" onClick={handleSubmit} />
<input type="submit" value="register" />
</div>
</form>
</div>
@@ -76,19 +83,20 @@ const Login = ({ mode }) => {
return (
<div class="login">
{errorEl}
<form action="">
<form action="" onSubmit={handleSubmit}>
<div class="item">
<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 class="item">
<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 class="item">
<input type="submit" value="login" onClick={handleSubmit} />
<input type="submit" value="login"/>
</div>
</form>
<button onClick={gotoRegister}> create account </button>
</div>
);
};
@@ -101,7 +109,7 @@ function LoginPage({ mode }) {
<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>Based in Preston.</p>
<p>Records can then be picked up from Preston.</p>
</div>
</div>
)
+4 -5
View File
@@ -1,18 +1,17 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { route } from 'preact-router';
import { useSelector, useDispatch } from 'react-redux'
import { useSelector } from 'react-redux'
import http from './../http';
import { State, offersState } from './../store';
import { State } from './../store';
import * as toast from './../toast';
import OfferGroup from './../components/offer.group';
function Home() {
const dispatch = useDispatch()
const offers = useSelector((state: State) => state.offers);
if (offers && offers.length === 0) {
toast.info('no offers', 'You have no offers yet, search for some more items.');
route('/search');
}
+10 -3
View File
@@ -1,10 +1,13 @@
import { h } from 'preact';
import { useState, useRef, useEffect } from 'preact/hooks';
import { ExternalLink, Check } from 'react-feather';
import { useSelector } from 'react-redux'
import { State } from './../store';
import http from '../http';
function Result(r) {
function Result(r, wants) {
const [added, setAdded] = useState(false)
async function onClick() {
@@ -13,7 +16,9 @@ function Result(r) {
setAdded(true);
}
const classes = `${added ? 'added' : ''}`;
const wanted = wants.some(w => w.release === r.id);
const classes = `${added || wanted ? 'added' : ''}`;
const btnText = added
? <Check />
: '+';
@@ -39,6 +44,8 @@ const User = () => {
const [results, setResults] = useState([]);
const [infoText, setInfoText] = useState('');
const wants = useSelector((state: State) => state.wants);
async function fetchResults() {
const [error, results] = await http.postWantSearch(query);
@@ -106,7 +113,7 @@ const User = () => {
</div>
<div class='results'>
{infoEl}
{results.map(Result)}
{results.map(r => Result(r, wants))}
{topEl}
</div>
</div>
+1
View File
@@ -52,6 +52,7 @@ const reducer = combineReducers({
const store = createStore(
reducer,
// @ts-ignore -- devtools window exists on dev
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
);
+18
View File
@@ -541,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;
}
}
+15
View File
@@ -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 -3
View File
@@ -50,6 +50,10 @@ export default function createSocket(dispatch) {
send(['AcpOfferStatusSet', { id, status }]);
}
function sendAcpListingsSync() {
send(['AcpListingsSync', {}]);
}
let pongTimeout;
function onPong() {
pongTimeout = setTimeout(sendPing, 5000);
@@ -111,9 +115,6 @@ export default function createSocket(dispatch) {
function onOpen() {
console.log('websocket connected', SOCKET_URL)
sendPing();
sendAcpOffers();
sendOffers();
sendWants();
}
function onError(event) {
@@ -157,5 +158,6 @@ export default function createSocket(dispatch) {
sendAcpOffers,
sendAcpOfferStatusSet,
sendAcpListingsSync,
};
}