diff --git a/TODO.md b/TODO.md index 40006d3..abbb330 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,9 @@ # TODO +contact options on landing page + +move want add to rpc + listing ids overflowing skip unofficial release @@ -9,7 +13,6 @@ status stepper queue up wantlist adds to gracefully handle rate limiting push offers -cover off admin routes and commands listen / notify on offers diff --git a/api/index.ts b/api/index.ts index 0c70153..f8a6823 100644 --- a/api/index.ts +++ b/api/index.ts @@ -28,7 +28,7 @@ async function start() { events, } = createServices(log); - events.emit(Events.StartupSync); + // events.emit(Events.Sync); // events.emit(Events.ListingsUpdated); api.listen(); diff --git a/api/src/api.ts b/api/src/api.ts index e9d0525..df45f0d 100644 --- a/api/src/api.ts +++ b/api/src/api.ts @@ -63,13 +63,6 @@ export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Of 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; @@ -89,14 +82,6 @@ export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Of 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); @@ -122,7 +107,7 @@ export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Of // log.child... const ws = ctx.websocket; - const rpc = createRpc(log, pg, ws, discogs, offers, ctx.state.user as User | null); + 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) { diff --git a/api/src/discogs.ts b/api/src/discogs.ts index b843f11..77e6002 100644 --- a/api/src/discogs.ts +++ b/api/src/discogs.ts @@ -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; } diff --git a/api/src/events.ts b/api/src/events.ts index 0618e24..ba6ab62 100644 --- a/api/src/events.ts +++ b/api/src/events.ts @@ -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); }); diff --git a/api/src/offers.ts b/api/src/offers.ts index 4acd346..f250e31 100644 --- a/api/src/offers.ts +++ b/api/src/offers.ts @@ -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; diff --git a/api/src/rpc.ts b/api/src/rpc.ts index 8780eac..3b367d4 100644 --- a/api/src/rpc.ts +++ b/api/src/rpc.ts @@ -1,5 +1,3 @@ -import { EventEmitter } from 'node:events'; -import { setTimeout } from 'timers/promises'; import assert from 'assert/strict'; import type Log from 'bunyan'; @@ -12,6 +10,7 @@ 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]) { @@ -21,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, 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 { 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()]; } + async function acpListingsSync(): Promise { + assert(isAdmin(user), 'not authorised'); + events.emit(Events.Sync); + return [RpcMessageType.AcpListingsSync, []]; + } + async function receive(message: any): Promise { const [msgType, params] = message; @@ -101,6 +106,8 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers return acpOfferStatusSet(params); case RpcMessageType.AcpOffers: return acpOffers(); + case RpcMessageType.AcpListingsSync: + return acpListingsSync(); default: throw new Error(`Message type ${msgType} is known but unhandled`); @@ -112,18 +119,30 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers 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); - const samples = await offers.samples(); - const sampleMsg: OfferSamplesMessage = [RpcMessageType.OfferSamples, samples]; - // console.log(samples); - send(sampleMsg); + if (isAdmin(user)) return adminConnection(); } function send(msg: RpcMessage) { @@ -163,6 +182,7 @@ export enum RpcMessageType { AcpOfferStatusSet = 'AcpOfferStatusSet', AcpOffers = 'AcpOffers', + AcpListingsSync = 'AcpListingsSync', WantRm = 'WantRm', } @@ -179,6 +199,7 @@ type OfferSamplesMessage = [RpcMessageType.OfferSamples, Offer[]]; type AcpOfferStatusSetMessage = [RpcMessageType.AcpOfferStatusSet, Offer[]]; type AcpOffersMessage = [RpcMessageType.AcpOffers, Offer[]]; +type AcpListingsSyncMessage = [RpcMessageType.AcpListingsSync, []]; export type RpcMessage = PingMessage | @@ -195,6 +216,7 @@ export type RpcMessage = AcpOfferStatusSetMessage | AcpOffersMessage | + AcpListingsSyncMessage | ErrorMessage; diff --git a/client/package-lock.json b/client/package-lock.json index dc36a28..fb24194 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", diff --git a/client/package.json b/client/package.json index a44f292..4b24086 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/app.tsx b/client/src/app.tsx index 7dbc6c5..a81e3d5 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -19,7 +19,7 @@ import Info from './routes/info'; if (window.location.host === 'matescrates.au') LogRocket.init('yh0dy3/mcrates'); const Main = () => { - const dispatch = useDispatch() + const dispatch = useDispatch(); // i have no idea why this is needed, // but it makes sure the user changes actually rerender diff --git a/client/src/http.ts b/client/src/http.ts index 29a92e2..a1e9605 100644 --- a/client/src/http.ts +++ b/client/src/http.ts @@ -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') }, diff --git a/client/src/routes/acp.tsx b/client/src/routes/acp.tsx index dbfc975..f22fe62 100644 --- a/client/src/routes/acp.tsx +++ b/client/src/routes/acp.tsx @@ -70,9 +70,9 @@ function Home() { const offers = useSelector((state: State) => state.acpOffers); const ws = useSelector((state: State) => state.ws); - // useEffect(() => { - // ws.sendAcpOffers(); - // }, [offers, ws]); + function sync() { + ws.sendAcpListingsSync(); + } if (!offers.length) { return ( @@ -83,8 +83,12 @@ function Home() { } return ( -
- {offers.map(SellerGroup)} +
+ +
+
+ {offers.map(SellerGroup)} +
); } diff --git a/client/src/routes/login.tsx b/client/src/routes/login.tsx index f712487..87742f7 100644 --- a/client/src/routes/login.tsx +++ b/client/src/routes/login.tsx @@ -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() @@ -52,21 +59,21 @@ const Login = ({ mode }) => { if (mode === 'register') return ( @@ -76,19 +83,20 @@ const Login = ({ mode }) => { return ( ); }; @@ -97,12 +105,11 @@ function LoginPage({ mode }) { return (
-
-
- + +

Mates Crates is a record importing service.

We bundle orders together from thousands of online stores to reduce the cost of shipping vinyl.

-

Based in Preston.

+

Records can then be picked up from Preston.

) diff --git a/client/src/routes/offers.tsx b/client/src/routes/offers.tsx index d1ac1ae..98fd108 100644 --- a/client/src/routes/offers.tsx +++ b/client/src/routes/offers.tsx @@ -16,6 +16,8 @@ function Home() { route('/search'); } + if (!offers) return
loading...
; + return (
{offers.map(OfferGroup)} diff --git a/client/src/routes/search.tsx b/client/src/routes/search.tsx index 0b6faaa..46ec12a 100644 --- a/client/src/routes/search.tsx +++ b/client/src/routes/search.tsx @@ -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 ? : '+'; @@ -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); @@ -104,10 +111,9 @@ const User = () => {

Data provided by Discogs.

-
-
+
{infoEl} - {results.map(Result)} + {results.map(r => Result(r, wants))} {topEl}
diff --git a/client/src/store.tsx b/client/src/store.tsx index a25dd5d..b2cb56e 100644 --- a/client/src/store.tsx +++ b/client/src/store.tsx @@ -11,7 +11,7 @@ export const userState = createSlice({ export const offersState = createSlice({ name: 'offers', - initialState: [], + initialState: null, reducers: { set: (state, action) => action.payload }, @@ -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__() ); diff --git a/client/src/style/index.less b/client/src/style/index.less index 4c4cccb..33e679b 100644 --- a/client/src/style/index.less +++ b/client/src/style/index.less @@ -201,7 +201,7 @@ body { } main { - padding: 0 5% 0 5%; + padding: 0 var(--spacing-double) 0 var(--spacing-double); } button, [type=submit] { @@ -212,19 +212,27 @@ button, [type=submit] { .landing { display: grid; - grid-template-columns: 1fr 0.5fr 2fr; + + grid-template-columns: 1fr 2fr; + grid-gap: calc(var(--spacing-double) * 2); + grid-template-areas: + "login swirl" + "login words"; .login { + grid-area: login; button { width: 100%; } } .swirl { + grid-area: swirl; height: 15rem; } - .text { + .words { + grid-area: words; text-align: center; } } @@ -274,13 +282,18 @@ article { .search { display: grid; - grid-template-columns: 1fr 0.5fr 2fr; + grid-template-columns: 1fr 2fr; - article { + .results { display: grid; - padding: var(--spacing); - margin-bottom: var(--spacing); - grid-template-columns: 1fr 4fr 1fr; + padding-left: var(--spacing); + grid-gap: var(--spacing); + + article { + display: grid; + padding: var(--spacing); + grid-template-columns: 1fr 4fr min-content; + } } button { @@ -300,8 +313,7 @@ article { grid-template-columns: 1fr 1fr 1fr; - grid-row-gap: var(--spacing); - grid-column-gap: var(--spacing); + grid-gap: var(--spacing); article { display: grid; @@ -324,6 +336,10 @@ article { } padding: var(--spacing); + + img { + justify-self: end; + } } } @@ -412,6 +428,7 @@ header { .offers { display: grid; grid-template-columns: 1fr 1fr; + grid-gap: var(--spacing); .offer { display: grid; @@ -422,7 +439,6 @@ header { "lines lines"; padding: var(--spacing); - margin: var(--spacing-half); background: var(--background-1); .details { @@ -482,4 +498,46 @@ header { background-position: center; margin-bottom: var(--spacing); -} \ No newline at end of file +} + +@media (max-width:900px) { + .landing { + grid-template-columns: 1fr; + grid-template-areas: + "swirl" + "login" + "words"; + } + + .list { + grid-template-columns: 1fr; + } + + .search { + grid-template-columns: 1fr; + } + + .offers { + grid-template-columns: 1fr; + } + + .info { + grid-template-columns: 1fr; + // is necessary for some reason + grid-template-areas: + "swirl" + "contact" + "words"; + } + + main { + padding: 0 var(--spacing) 0 var(--spacing); + } +} + +@media (max-width:1200px) { + img { + width: 5rem; + } +} + diff --git a/client/src/template.html b/client/src/template.html new file mode 100644 index 0000000..ce1e78f --- /dev/null +++ b/client/src/template.html @@ -0,0 +1,14 @@ + + + + + matescrates.au - record import service + + + + <% preact.headEnd %> + + + <% preact.bodyEnd %> + + \ No newline at end of file diff --git a/client/src/ws.ts b/client/src/ws.ts index 69fbe68..2afbe62 100644 --- a/client/src/ws.ts +++ b/client/src/ws.ts @@ -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, }; }