From 95f38aaaeead4e10ddfaf8b9d3fae952b131de24 Mon Sep 17 00:00:00 2001 From: Yuwang Cai Date: Sun, 6 Oct 2024 12:27:23 +0800 Subject: [PATCH] feat: browse items (#23) --- .env.gpg | Bin 282 -> 336 bytes .github/workflows/ci-item-service.yaml | 17 + services/item/.gitignore | 4 +- services/item/container-compose.test.yaml | 2 +- services/item/database/dev/seed.js | 365 +++++++++++++++- services/item/database/test/seed.js | 365 +++++++++++++++- services/item/src/items/controller.ts | 16 +- services/item/src/items/repository.ts | 36 +- services/item/src/items/service.ts | 21 +- services/item/src/types.ts | 12 +- services/item/tests/items.test.ts | 15 - .../item/tests/items/get-all-items.test.ts | 157 +++++++ services/web/next.config.js | 7 + services/web/package.json | 2 +- services/web/pnpm-lock.yaml | 47 +-- services/web/src/app/api/items/route.ts | 390 ++++++++++++++++++ services/web/src/app/layout.tsx | 23 +- services/web/src/app/login/form.tsx | 66 ++- services/web/src/app/page.tsx | 32 +- services/web/src/app/register/form.tsx | 61 ++- services/web/src/app/register/page.tsx | 5 + .../settings/cards/delete-account-card.tsx | 59 ++- .../app/settings/cards/update-email-card.tsx | 45 +- .../settings/cards/update-nickname-card.tsx | 45 +- .../settings/cards/update-whatsapp-card.tsx | 45 +- .../web/src/app/settings/contacts/page.tsx | 5 + .../web/src/app/settings/display/page.tsx | 5 + services/web/src/app/settings/layout.tsx | 7 +- services/web/src/app/settings/page.tsx | 5 + .../framework/me-card/log-out-button.tsx | 57 ++- .../framework/me-card/me-card-client.tsx | 18 +- services/web/src/components/item/index.ts | 1 + .../item/item-preview-card-list.tsx | 69 ++++ .../item/single-item-preview-card.tsx | 72 ++++ services/web/src/components/ui/badge.tsx | 36 ++ services/web/src/contexts/query.tsx | 17 + services/web/src/hooks/use-infinite-scroll.ts | 29 ++ services/web/src/types.ts | 41 +- 38 files changed, 1865 insertions(+), 334 deletions(-) delete mode 100644 services/item/tests/items.test.ts create mode 100644 services/item/tests/items/get-all-items.test.ts create mode 100644 services/web/src/app/api/items/route.ts create mode 100644 services/web/src/components/item/index.ts create mode 100644 services/web/src/components/item/item-preview-card-list.tsx create mode 100644 services/web/src/components/item/single-item-preview-card.tsx create mode 100644 services/web/src/components/ui/badge.tsx create mode 100644 services/web/src/contexts/query.tsx create mode 100644 services/web/src/hooks/use-infinite-scroll.ts diff --git a/.env.gpg b/.env.gpg index 65ec618acbd455c47cabe6402cb9fe055940c519..537b49adef9aabc501d0b06c6219e8a9fd6a0587 100644 GIT binary patch literal 336 zcmV-W0k8gy4Fm}T2*q+6p${L#J^#|cegP1Dzd$J7QcG3`pgvvpa%f18FL?mg5L^%M zeoHt?R}26EPkqO=hzm0*=(Py0DRvblwe06G0ga3NO zMU9t1KIkpYr$dmqohX+0ycs=&0&IfM*1x#Hw`ru$v@Oe&oel~Jdo6)yWsW}=bCL#1 z2W?jz6XLHzy?KNC{1$F@cOlcLBC_Z50Nous3nrx+57q>|f${Ne0#I}-$H~;bL5=5- zApf6VtMnrW+cKRLJYWS7%C2YaR*O?6BTbNb!7Tk7c20n+NMlbrz7T#gz+#&AFs@4r zZ43=4pR#{ovT*PW%nntmrH5{Y=Pn7n+_@qiWG;b%?aDQzPoyFm&Z3Nj;#XwimsD!N i*~_y>9CbEImJCNCE!LEr0ZI#)kk|=UAzZxWe#!qEw>LF31$J z*b%~2fRjb;61sJKB>@J_UYizWS=#mUEL=*!ZRJPk4{~;)d4q_BlOxsj*Ed-_<6@{E zuvm4JO?n4qZ-IVHzFy9$M*dc458q}KB7W76WW+$9R;B5cR0EY_JP&4jbMU(JQXYDi zLVGjxEw~TNgL$N$mf~hrVH*m}$jg}b24$VM&@uDw19?SoC}chgk|`ivtvzN0Svtpl z;z`!{+6IS_cWnd6!|*^T|EbKE4Lu!VI4MQ($-rB@j { - const dto = c.req.valid("query"); - const [items, total] = await itemsService.getAllItems(dto); - return c.json({ items, total }, 200); + const query = c.req.valid("query"); + const result = await itemsService.getAllItems(query); + return c.json(result, 200); }, ); diff --git a/services/item/src/items/repository.ts b/services/item/src/items/repository.ts index 409e60fd..9bc82501 100644 --- a/services/item/src/items/repository.ts +++ b/services/item/src/items/repository.ts @@ -1,5 +1,6 @@ -import { ItemStatus, type Item, type SingleItem } from "@/types"; +import { ItemStatus, type SingleItem } from "@/types"; import { itemsCollection } from "@/utils/db"; +import { ObjectId } from "mongodb"; /** * Data access layer for items. @@ -12,20 +13,37 @@ export const itemsRepository = { type FindAllDto = { limit: number; - skip: number; + cursor?: string | undefined; + type?: "single" | "pack" | undefined; + status: ItemStatus; }; async function findAll(dto: FindAllDto) { return await itemsCollection - .find() - .project({ _id: 0 }) - .limit(dto.limit) - .skip(dto.skip) + .find( + { + ...(dto.type ? { type: dto.type } : {}), + status: dto.status, + ...(dto.cursor ? { _id: { $lt: new ObjectId(dto.cursor) } } : {}), + }, + { + sort: { _id: -1 }, + limit: dto.limit, + }, + ) .toArray(); } -async function count() { - return await itemsCollection.estimatedDocumentCount(); +type CountDto = { + type?: "single" | "pack" | undefined; + status: ItemStatus; +}; + +async function count(dto: CountDto) { + return await itemsCollection.countDocuments({ + ...(dto.type ? { type: dto.type } : {}), + status: dto.status, + }); } type InsertOneDto = { @@ -46,7 +64,7 @@ async function insertOne(dto: InsertOneDto) { id: crypto.randomUUID(), type: "single", status: ItemStatus.FOR_SALE, - created_at: new Date(), + created_at: new Date().toISOString(), deleted_at: null, }); diff --git a/services/item/src/items/service.ts b/services/item/src/items/service.ts index 4df69820..44c02612 100644 --- a/services/item/src/items/service.ts +++ b/services/item/src/items/service.ts @@ -1,4 +1,4 @@ -import { type Account } from "@/types"; +import { ItemStatus, type Account } from "@/types"; import { itemsRepository } from "./repository"; /** @@ -11,16 +11,27 @@ export const itemsService = { type GetAllItemsDto = { limit: number; - skip: number; + cursor?: string | undefined; + type?: "single" | "pack" | undefined; + status: ItemStatus; }; async function getAllItems(dto: GetAllItemsDto) { - const [items, total] = await Promise.all([ + const [items, count] = await Promise.all([ itemsRepository.findAll(dto), - itemsRepository.count(), + itemsRepository.count(dto), ]); - return [items, total] as const; + const nextCursor = + items.length < dto.limit ? null : items[items.length - 1]!._id; + + const idStrippedItems = items.map((item) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { _id, ...rest } = item; + return { ...rest }; + }); + + return { items: idStrippedItems, count, nextCursor }; } type CreateItemDto = { diff --git a/services/item/src/types.ts b/services/item/src/types.ts index d845043c..5582a91b 100644 --- a/services/item/src/types.ts +++ b/services/item/src/types.ts @@ -34,8 +34,8 @@ export type Account = AccountPreview & { } | null; phone_code: string | null; phone_number: string | null; - created_at: Date; - deleted_at: Date | null; + created_at: string; + deleted_at: string | null; }; /** @@ -61,8 +61,8 @@ export type SingleItem = { price: number; photo_urls: string[]; status: ItemStatus; - created_at: Date; - deleted_at: Date | null; + created_at: string; + deleted_at: string | null; }; /** @@ -77,8 +77,8 @@ export type ItemPack = { discount: number; status: ItemStatus; children: (SingleItem | ItemPack)[]; - created_at: Date; - deleted_at: Date | null; + created_at: string; + deleted_at: string | null; }; /** diff --git a/services/item/tests/items.test.ts b/services/item/tests/items.test.ts deleted file mode 100644 index 9703a799..00000000 --- a/services/item/tests/items.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { request } from "./utils"; - -describe("GET /", () => { - it("returns all items by default", async () => { - const res = await request("/"); - const body = await res.json(); - - expect(res.status).toEqual(200); - expect(body).toMatchObject({ - items: expect.any(Array), - total: expect.any(Number), - }); - }); -}); diff --git a/services/item/tests/items/get-all-items.test.ts b/services/item/tests/items/get-all-items.test.ts new file mode 100644 index 00000000..1d47a6ab --- /dev/null +++ b/services/item/tests/items/get-all-items.test.ts @@ -0,0 +1,157 @@ +import { ItemStatus, type Item } from "@/types"; +import { describe, expect, it } from "bun:test"; +import { request } from "../utils"; + +type ExpectedResponse = { + items: Item[]; + count: number; + nextCursor: string | null; +}; + +describe("Default behavior", () => { + it("returns a list of items", async () => { + const res = await request("/"); + const body = (await res.json()) as ExpectedResponse; + + expect(res.status).toEqual(200); + expect(body.items).toBeArray(); + expect(body.count).toBeNumber(); + }); +}); + +describe("Given limit", () => { + it("returns only the given amount of items", async () => { + const res = await request("/?limit=1"); + const body = (await res.json()) as ExpectedResponse; + + expect(res.status).toEqual(200); + expect(body.items).toBeArrayOfSize(1); + expect(body.count).toBeGreaterThan(1); + }); + + it("returns 400 when limit is not a number", async () => { + const res = await request("/?limit=foo"); + const body = await res.json(); + + expect(res.status).toEqual(400); + expect(body).toMatchObject({ error: expect.any(String) }); + }); + + it("returns 400 when limit is not an integer", async () => { + const res = await request("/?limit=1.5"); + const body = await res.json(); + + expect(res.status).toEqual(400); + expect(body).toMatchObject({ error: expect.any(String) }); + }); + + it("returns 400 when limit is not positive", async () => { + const res = await request("/?limit=0"); + const body = await res.json(); + + expect(res.status).toEqual(400); + expect(body).toMatchObject({ error: expect.any(String) }); + }); +}); + +describe("Given cursor", () => { + it("skips every item before the given cursor", async () => { + const res1 = await request("/?limit=1"); + const body1 = (await res1.json()) as ExpectedResponse; + const nextCursor = body1.nextCursor; + + expect(res1.status).toEqual(200); + expect(body1.items).toBeArrayOfSize(1); + expect(nextCursor).toBeString(); + + const res2 = await request(`/?limit=1&cursor=${nextCursor}`); + const body2 = (await res2.json()) as ExpectedResponse; + + expect(res2.status).toEqual(200); + expect(body2.items).toBeArrayOfSize(1); + + expect(new Date(body2.items[0]!.created_at).getTime()).toBeLessThan( + new Date(body1.items[0]!.created_at).getTime(), + ); + }); + + it("returns null cursor when coming to the end", async () => { + const res = await request("/?limit=100"); + const body = (await res.json()) as ExpectedResponse; + + expect(res.status).toEqual(200); + expect(body.nextCursor).toBeNull(); + }); +}); + +describe("Given type", () => { + it("filters out single items when type is single", async () => { + const res = await request("/?type=single"); + const body = (await res.json()) as ExpectedResponse; + + expect(res.status).toEqual(200); + + for (const item of body.items) { + expect(item.type).toEqual("single"); + } + }); + + it("filters out item packs when type is pack", async () => { + const res = await request("/?type=pack"); + const body = (await res.json()) as ExpectedResponse; + + expect(res.status).toEqual(200); + + for (const item of body.items) { + expect(item.type).toEqual("pack"); + } + }); + + it("returns 400 when type is invalid", async () => { + const res = await request("/?type=foo"); + const body = await res.json(); + + expect(res.status).toEqual(400); + expect(body).toMatchObject({ error: expect.any(String) }); + }); +}); + +describe("Given status", () => { + it("returns FOR_SALE items when status is not given", async () => { + const res = await request("/"); + const body = (await res.json()) as ExpectedResponse; + + expect(res.status).toEqual(200); + + for (const item of body.items) { + expect(item.status).toEqual(ItemStatus.FOR_SALE); + } + }); + + it("filters out items of the given status", async () => { + const res = await request("/?status=1"); + const body = (await res.json()) as ExpectedResponse; + + expect(res.status).toEqual(200); + + for (const item of body.items) { + expect(item.status).toEqual(ItemStatus.DEALT); + } + }); + + it("returns 400 when status is not a valid status", async () => { + const res = await request("/?status=100"); + const body = await res.json(); + + expect(res.status).toEqual(400); + expect(body).toMatchObject({ error: expect.any(String) }); + }); + + it("returns 400 when status is not a number", async () => { + const res = await request("/?status=foo"); + const body = await res.json(); + + expect(res.status).toEqual(400); + expect(body).toMatchObject({ error: expect.any(String) }); + }); +}); diff --git a/services/web/next.config.js b/services/web/next.config.js index 232a42b2..b56e5121 100644 --- a/services/web/next.config.js +++ b/services/web/next.config.js @@ -1,6 +1,13 @@ /** @type {import("next").NextConfig} */ const config = { output: "standalone", + images: { + remotePatterns: [ + { + hostname: "picsum.photos", + }, + ], + }, experimental: { typedRoutes: true, }, diff --git a/services/web/package.json b/services/web/package.json index 2c73dcbb..fd2ee3cb 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -17,13 +17,13 @@ "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-toast": "^1.2.2", + "@tanstack/react-query": "^5.59.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "lucide-react": "^0.447.0", "next": "^14.2.14", "react": "^18.3.1", "react-dom": "^18.3.1", - "swr": "^2.2.5", "tailwind-merge": "^2.5.3", "tailwindcss-animate": "^1.0.7", "valibot": "1.0.0-beta.0" diff --git a/services/web/pnpm-lock.yaml b/services/web/pnpm-lock.yaml index 50401ec2..c712457b 100644 --- a/services/web/pnpm-lock.yaml +++ b/services/web/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@radix-ui/react-toast': specifier: ^1.2.2 version: 1.2.2(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-query': + specifier: ^5.59.0 + version: 5.59.0(react@18.3.1) class-variance-authority: specifier: ^0.7.0 version: 0.7.0 @@ -47,9 +50,6 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) - swr: - specifier: ^2.2.5 - version: 2.2.5(react@18.3.1) tailwind-merge: specifier: ^2.5.3 version: 2.5.3 @@ -767,6 +767,14 @@ packages: '@swc/helpers@0.5.5': resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==} + '@tanstack/query-core@5.59.0': + resolution: {integrity: sha512-WGD8uIhX6/deH/tkZqPNcRyAhDUqs729bWKoByYHSogcshXfFbppOdTER5+qY7mFvu8KEFJwT0nxr8RfPTVh0Q==} + + '@tanstack/react-query@5.59.0': + resolution: {integrity: sha512-YDXp3OORbYR+8HNQx+lf4F73NoiCmCcSvZvgxE29OifmQFk0sBlO26NWLHpcNERo92tVk3w+JQ53/vkcRUY1hA==} + peerDependencies: + react: ^18 || ^19 + '@ts-morph/common@0.19.0': resolution: {integrity: sha512-Unz/WHmd4pGax91rdIKWi51wnVUW11QttMEPpBiBgIewnc9UQIX7UDLxr5vRlqeByXCwhkF6VabSsI0raWcyAQ==} @@ -2480,11 +2488,6 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - swr@2.2.5: - resolution: {integrity: sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==} - peerDependencies: - react: ^16.11.0 || ^17.0.0 || ^18.0.0 - tailwind-merge@2.5.3: resolution: {integrity: sha512-d9ZolCAIzom1nf/5p4LdD5zvjmgSxY0BGgdSvmXIoMYAiPdAW/dSpP7joCDYFY7r/HkEa2qmPtkgsu0xjQeQtw==} @@ -2609,11 +2612,6 @@ packages: '@types/react': optional: true - use-sync-external-store@1.2.2: - resolution: {integrity: sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -3350,6 +3348,13 @@ snapshots: '@swc/counter': 0.1.3 tslib: 2.7.0 + '@tanstack/query-core@5.59.0': {} + + '@tanstack/react-query@5.59.0(react@18.3.1)': + dependencies: + '@tanstack/query-core': 5.59.0 + react: 18.3.1 + '@ts-morph/common@0.19.0': dependencies: fast-glob: 3.3.2 @@ -4009,7 +4014,7 @@ snapshots: debug: 4.3.7 enhanced-resolve: 5.17.1 eslint: 9.12.0(jiti@2.2.1) - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.8.0(eslint@9.12.0(jiti@2.2.1))(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.8.0(eslint@9.12.0(jiti@2.2.1))(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@9.12.0(jiti@2.2.1)))(eslint@9.12.0(jiti@2.2.1)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.8.0(eslint@9.12.0(jiti@2.2.1))(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.12.0(jiti@2.2.1)) fast-glob: 3.3.2 get-tsconfig: 4.8.1 is-bun-module: 1.2.1 @@ -4022,7 +4027,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.8.0(eslint@9.12.0(jiti@2.2.1))(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.8.0(eslint@9.12.0(jiti@2.2.1))(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@9.12.0(jiti@2.2.1)))(eslint@9.12.0(jiti@2.2.1)): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.8.0(eslint@9.12.0(jiti@2.2.1))(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.12.0(jiti@2.2.1)): dependencies: debug: 3.2.7 optionalDependencies: @@ -4044,7 +4049,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.12.0(jiti@2.2.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.8.0(eslint@9.12.0(jiti@2.2.1))(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.8.0(eslint@9.12.0(jiti@2.2.1))(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@9.12.0(jiti@2.2.1)))(eslint@9.12.0(jiti@2.2.1)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.8.0(eslint@9.12.0(jiti@2.2.1))(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.12.0(jiti@2.2.1)) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 @@ -5304,12 +5309,6 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - swr@2.2.5(react@18.3.1): - dependencies: - client-only: 0.0.1 - react: 18.3.1 - use-sync-external-store: 1.2.2(react@18.3.1) - tailwind-merge@2.5.3: {} tailwindcss-animate@1.0.7(tailwindcss@3.4.13): @@ -5463,10 +5462,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.11 - use-sync-external-store@1.2.2(react@18.3.1): - dependencies: - react: 18.3.1 - util-deprecate@1.0.2: {} valibot@1.0.0-beta.0(typescript@5.6.2): diff --git a/services/web/src/app/api/items/route.ts b/services/web/src/app/api/items/route.ts new file mode 100644 index 00000000..d3a16d8d --- /dev/null +++ b/services/web/src/app/api/items/route.ts @@ -0,0 +1,390 @@ +import { NextRequest, NextResponse } from "next/server"; + +const mockAccounts = [ + { + id: 1, + nickname: "Johnny", + avatar_url: "https://avatars.githubusercontent.com/u/78269445?v=4", + }, + { + id: 2, + nickname: "JaneS", + avatar_url: "https://avatars.githubusercontent.com/u/69978374?v=4", + }, + { + id: 3, + nickname: "AlexL", + avatar_url: "https://avatars.githubusercontent.com/u/13389461?v=4", + }, + { + id: 4, + nickname: "MikeB", + avatar_url: "https://avatars.githubusercontent.com/u/60336739?v=4", + }, + { + id: 5, + nickname: "EmJ", + avatar_url: "https://avatars.githubusercontent.com/u/83934144?v=4", + }, +] as const; + +const mockItems = [ + { + id: crypto.randomUUID(), + type: "single", + seller: mockAccounts[0], + name: "Vintage Lamp", + description: + "A beautiful vintage lamp from the 1950s, in perfect condition.", + price: 150.0, + photo_urls: [], + status: 0, + created_at: "2023-01-15T10:30:00Z", + deleted_at: null, + }, + { + id: crypto.randomUUID(), + type: "single", + seller: mockAccounts[1], + name: "iPhone 12", + description: "iPhone 12 in great condition, barely used.", + price: 900.0, + photo_urls: [ + "https://picsum.photos/200?r=1", + "https://picsum.photos/200?r=2", + ], + status: 2, + created_at: "2023-02-01T14:15:00Z", + deleted_at: null, + }, + { + id: crypto.randomUUID(), + type: "single", + seller: mockAccounts[3], + name: "Gaming Laptop", + description: "High-performance gaming laptop with RTX 3070. Barely used.", + price: 1800.0, + photo_urls: ["https://picsum.photos/200?r=3"], + status: 1, + created_at: "2023-04-05T12:00:00Z", + deleted_at: null, + }, + { + id: crypto.randomUUID(), + type: "single", + seller: mockAccounts[4], + name: "Mountain Bike", + description: "Lightweight aluminum mountain bike, perfect for trails.", + price: 750.0, + photo_urls: ["https://picsum.photos/200?r=4"], + status: 2, + created_at: "2023-05-20T09:30:00Z", + deleted_at: null, + }, + { + id: crypto.randomUUID(), + type: "single", + seller: mockAccounts[0], + name: "Smartwatch", + description: "Water-resistant smartwatch with heart-rate monitor and GPS.", + price: 220.0, + photo_urls: ["https://picsum.photos/200?r=5"], + status: 0, + created_at: "2023-06-10T11:15:00Z", + deleted_at: null, + }, + { + id: crypto.randomUUID(), + type: "single", + seller: mockAccounts[1], + name: "Bluetooth Speaker", + description: + "Portable Bluetooth speaker, 20-hour battery life, waterproof.", + price: 75.0, + photo_urls: ["https://picsum.photos/200?r=6"], + status: 1, + created_at: "2023-08-15T13:00:00Z", + deleted_at: null, + }, + { + id: crypto.randomUUID(), + type: "single", + seller: mockAccounts[3], + name: "Air Purifier", + description: "HEPA air purifier for large rooms, 3 fan speeds.", + price: 200.0, + photo_urls: ["https://picsum.photos/200?r=7"], + status: 0, + created_at: "2023-09-20T10:00:00Z", + deleted_at: null, + }, + { + id: crypto.randomUUID(), + type: "single", + seller: mockAccounts[4], + name: "Leather Jacket", + description: "Men’s leather jacket, genuine leather, worn twice.", + price: 300.0, + photo_urls: ["https://picsum.photos/200?r=8"], + status: 2, + created_at: "2023-10-01T14:20:00Z", + deleted_at: null, + }, + { + id: crypto.randomUUID(), + type: "single", + seller: mockAccounts[0], + name: "Vintage Lamp 1", + description: + "A beautiful vintage lamp from the 1950s, in perfect condition.", + price: 150.0, + photo_urls: [], + status: 0, + created_at: "2023-01-15T10:30:00Z", + deleted_at: null, + }, + { + id: crypto.randomUUID(), + type: "single", + seller: mockAccounts[1], + name: "iPhone 12 1", + description: "iPhone 12 in great condition, barely used.", + price: 900.0, + photo_urls: [ + "https://picsum.photos/200?r=9", + "https://picsum.photos/200?r=10", + ], + status: 2, + created_at: "2023-02-01T14:15:00Z", + deleted_at: null, + }, + { + id: crypto.randomUUID(), + type: "single", + seller: mockAccounts[3], + name: "Gaming Laptop 1", + description: "High-performance gaming laptop with RTX 3070. Barely used.", + price: 1800.0, + photo_urls: ["https://picsum.photos/200?r=11"], + status: 1, + created_at: "2023-04-05T12:00:00Z", + deleted_at: null, + }, + { + id: crypto.randomUUID(), + type: "single", + seller: mockAccounts[4], + name: "Mountain Bike 1", + description: "Lightweight aluminum mountain bike, perfect for trails.", + price: 750.0, + photo_urls: ["https://picsum.photos/200?r=12"], + status: 2, + created_at: "2023-05-20T09:30:00Z", + deleted_at: null, + }, + { + id: crypto.randomUUID(), + type: "single", + seller: mockAccounts[0], + name: "Smartwatch 1", + description: "Water-resistant smartwatch with heart-rate monitor and GPS.", + price: 220.0, + photo_urls: ["https://picsum.photos/200?r=13"], + status: 0, + created_at: "2023-06-10T11:15:00Z", + deleted_at: null, + }, + { + id: crypto.randomUUID(), + type: "single", + seller: mockAccounts[1], + name: "Bluetooth Speaker 1", + description: + "Portable Bluetooth speaker, 20-hour battery life, waterproof.", + price: 75.0, + photo_urls: ["https://picsum.photos/200?r=14"], + status: 1, + created_at: "2023-08-15T13:00:00Z", + deleted_at: null, + }, + { + id: crypto.randomUUID(), + type: "single", + seller: mockAccounts[3], + name: "Air Purifier 1", + description: "HEPA air purifier for large rooms, 3 fan speeds.", + price: 200.0, + photo_urls: ["https://picsum.photos/200?r=15"], + status: 0, + created_at: "2023-09-20T10:00:00Z", + deleted_at: null, + }, + { + id: crypto.randomUUID(), + type: "single", + seller: mockAccounts[4], + name: "Leather Jacket 1", + description: "Men’s leather jacket, genuine leather, worn twice.", + price: 300.0, + photo_urls: ["https://picsum.photos/200?r=16"], + status: 2, + created_at: "2023-10-01T14:20:00Z", + deleted_at: null, + }, + { + id: crypto.randomUUID(), + type: "single", + seller: mockAccounts[0], + name: "Vintage Lamp 2", + description: + "A beautiful vintage lamp from the 1950s, in perfect condition.", + price: 150.0, + photo_urls: [], + status: 0, + created_at: "2023-01-15T10:30:00Z", + deleted_at: null, + }, + { + id: crypto.randomUUID(), + type: "single", + seller: mockAccounts[1], + name: "iPhone 12 2", + description: "iPhone 12 in great condition, barely used.", + price: 900.0, + photo_urls: [ + "https://picsum.photos/200?r=17", + "https://picsum.photos/200?r=18", + ], + status: 2, + created_at: "2023-02-01T14:15:00Z", + deleted_at: null, + }, + { + id: crypto.randomUUID(), + type: "single", + seller: mockAccounts[3], + name: "Gaming Laptop 2", + description: "High-performance gaming laptop with RTX 3070. Barely used.", + price: 1800.0, + photo_urls: ["https://picsum.photos/200?r=19"], + status: 1, + created_at: "2023-04-05T12:00:00Z", + deleted_at: null, + }, + { + id: crypto.randomUUID(), + type: "single", + seller: mockAccounts[4], + name: "Mountain Bike 2", + description: "Lightweight aluminum mountain bike, perfect for trails.", + price: 750.0, + photo_urls: ["https://picsum.photos/200?r=20"], + status: 2, + created_at: "2023-05-20T09:30:00Z", + deleted_at: null, + }, + { + id: crypto.randomUUID(), + type: "single", + seller: mockAccounts[0], + name: "Smartwatch 2", + description: "Water-resistant smartwatch with heart-rate monitor and GPS.", + price: 220.0, + photo_urls: ["https://picsum.photos/200?r=21"], + status: 0, + created_at: "2023-06-10T11:15:00Z", + deleted_at: null, + }, + { + id: crypto.randomUUID(), + type: "single", + seller: mockAccounts[1], + name: "Bluetooth Speaker 2", + description: + "Portable Bluetooth speaker, 20-hour battery life, waterproof.", + price: 75.0, + photo_urls: ["https://picsum.photos/200?r=22"], + status: 1, + created_at: "2023-08-15T13:00:00Z", + deleted_at: null, + }, + { + id: crypto.randomUUID(), + type: "single", + seller: mockAccounts[3], + name: "Air Purifier 2", + description: "HEPA air purifier for large rooms, 3 fan speeds.", + price: 200.0, + photo_urls: ["https://picsum.photos/200?r=23"], + status: 0, + created_at: "2023-09-20T10:00:00Z", + deleted_at: null, + }, + // { + // id: crypto.randomUUID(), + // type: "single", + // seller: mockAccounts[4], + // name: "Leather Jacket 2", + // description: "Men’s leather jacket, genuine leather, worn twice.", + // price: 300.0, + // photo_urls: ["https://picsum.photos/200?r=24"], + // status: 2, + // created_at: "2023-10-01T14:20:00Z", + // deleted_at: null, + // }, + // { + // id: crypto.randomUUID(), + // type: "pack", + // seller: mockAccounts[2], + // name: "Give back to the community", + // description: "A bonus pack of my items for sale. Get a 20% discount!", + // discount: 0.2, + // status: 0, + // children: [ + // { + // id: crypto.randomUUID(), + // type: "single", + // seller: mockAccounts[2], + // name: "Office Chair", + // description: + // "Ergonomic office chair with lumbar support. Used for 6 months.", + // price: 120.5, + // photo_urls: ["https://picsum.photos/200", "https://picsum.photos/200"], + // status: 0, + // created_at: "2023-03-10T08:45:00Z", + // deleted_at: null, + // }, + // { + // id: crypto.randomUUID(), + // type: "single", + // seller: mockAccounts[2], + // name: "Electric Kettle", + // description: + // "Fast-boil electric kettle, 1.7L capacity, stainless steel.", + // price: 45.0, + // photo_urls: ["https://picsum.photos/200"], + // status: 0, + // created_at: "2023-07-05T08:10:00Z", + // deleted_at: null, + // }, + // ], + // created_at: "2023-11-15T10:30:00Z", + // deleted_at: null, + // }, +] as const; + +export async function GET(request: NextRequest) { + const cursorStr = request.nextUrl.searchParams.get("cursor"); + const cursor = cursorStr ? +cursorStr : 0; + + const items = mockItems.slice(cursor, cursor + 8); + + return NextResponse.json( + { + items, + count: 24, + nextCursor: items.length < 8 ? null : String(cursor + 8), + }, + { status: 200 }, + ); +} diff --git a/services/web/src/app/layout.tsx b/services/web/src/app/layout.tsx index 43d09279..b7b42499 100644 --- a/services/web/src/app/layout.tsx +++ b/services/web/src/app/layout.tsx @@ -1,5 +1,6 @@ import { Header, Sidebar, ThemeInitializer } from "@/components/framework"; import { Toaster } from "@/components/ui/toaster"; +import { QueryProvider } from "@/contexts/query"; import type { Metadata } from "next"; import { Nunito as FontSans } from "next/font/google"; import type { PropsWithChildren } from "react"; @@ -26,15 +27,19 @@ export default function RootLayout({ children }: PropsWithChildren) { -
- -
-
-
-
-
-
{children}
-
+ +
+ +
+
+
+
+
+
+ {children} +
+
+
diff --git a/services/web/src/app/login/form.tsx b/services/web/src/app/login/form.tsx index 2bfd7d7b..71683be1 100644 --- a/services/web/src/app/login/form.tsx +++ b/services/web/src/app/login/form.tsx @@ -6,13 +6,18 @@ import { Label } from "@/components/ui/label"; import { useToast } from "@/hooks/use-toast"; import type { Account } from "@/types"; import { ClientRequester } from "@/utils/requester/client"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Loader2Icon, LogInIcon } from "lucide-react"; +import type { Metadata } from "next"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { type FormEvent } from "react"; -import useSWRMutation from "swr/mutation"; import * as v from "valibot"; +export const metadata: Metadata = { + title: "Log in", +}; + const formSchema = v.object({ email: v.pipe( v.string("Email should be a text string."), @@ -30,49 +35,42 @@ export function LoginForm() { const { toast } = useToast(); - const { trigger, isMutating } = useSWRMutation< - Account, - Error, - string, - FormEvent - >( - "/auth/me", - async (_, { arg: event }) => { + const queryClient = useQueryClient(); + + const { mutate, isPending } = useMutation({ + mutationFn: (event: FormEvent) => { event.preventDefault(); - const formData = Object.fromEntries(new FormData(event.currentTarget)); + const target = event.target as HTMLFormElement; + const formData = Object.fromEntries(new FormData(target)); const { email, password } = v.parse(formSchema, formData); - return await new ClientRequester().post("/auth/token", { + return new ClientRequester().post("/auth/token", { email, password, }); }, - { - populateCache: true, - revalidate: false, - onSuccess: (account) => { - toast({ - title: "Login successful", - description: `Welcome back, ${account.nickname ?? account.email}!`, - }); - router.push("/"); - router.refresh(); - }, - throwOnError: false, - onError: (error) => { - toast({ - variant: "destructive", - title: "Login failed", - description: error.message, - }); - }, + onSuccess: (account) => { + queryClient.setQueryData(["auth", "me"], account); + toast({ + title: "Login successful", + description: `Welcome back, ${account.nickname ?? account.email}!`, + }); + router.push("/"); + router.refresh(); + }, + onError: (error) => { + toast({ + variant: "destructive", + title: "Login failed", + description: error.message, + }); }, - ); + }); return ( -
+
- +
+
+

Marketplace

+

+ We found something you might be interested in! +

+
+
); } diff --git a/services/web/src/app/register/form.tsx b/services/web/src/app/register/form.tsx index b4718dd6..9f972fb9 100644 --- a/services/web/src/app/register/form.tsx +++ b/services/web/src/app/register/form.tsx @@ -6,10 +6,10 @@ import { Label } from "@/components/ui/label"; import { useToast } from "@/hooks/use-toast"; import type { Account } from "@/types"; import { ClientRequester } from "@/utils/requester/client"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Loader2Icon, UserRoundPlusIcon } from "lucide-react"; import { useRouter } from "next/navigation"; import { type FormEvent } from "react"; -import useSWRMutation from "swr/mutation"; import * as v from "valibot"; const formSchema = v.object({ @@ -34,17 +34,14 @@ export function RegisterForm() { const { toast } = useToast(); - const { trigger, isMutating } = useSWRMutation< - Account, - Error, - string, - FormEvent - >( - "/auth/me", - async (_, { arg: event }) => { + const queryClient = useQueryClient(); + + const { mutate, isPending } = useMutation({ + mutationFn: (event: FormEvent) => { event.preventDefault(); - const formData = Object.fromEntries(new FormData(event.currentTarget)); + const target = event.target as HTMLFormElement; + const formData = Object.fromEntries(new FormData(target)); const { email, password, confirmation } = v.parse(formSchema, formData); @@ -52,35 +49,31 @@ export function RegisterForm() { throw new Error("Passwords do not match. Please double check."); } - return await new ClientRequester().post("/auth/me", { + return new ClientRequester().post("/auth/me", { email, password, }); }, - { - populateCache: true, - revalidate: false, - onSuccess: (account) => { - toast({ - title: "Registration successful", - description: `Welcome on board, ${account.email}!`, - }); - router.push("/"); - router.refresh(); - }, - throwOnError: false, - onError: (error) => { - toast({ - variant: "destructive", - title: "Registration failed", - description: error.message, - }); - }, + onSuccess: (account) => { + queryClient.setQueryData(["auth", "me"], account); + toast({ + title: "Login successful", + description: `Welcome on board, ${account.email}!`, + }); + router.push("/"); + router.refresh(); }, - ); + onError: (error) => { + toast({ + variant: "destructive", + title: "Registration failed", + description: error.message, + }); + }, + }); return ( - +
-