From 87d7a9ba50c79f5af23c7cb0a7ae087e135ae9eb Mon Sep 17 00:00:00 2001 From: ohamamarachi474-del Date: Fri, 29 May 2026 18:17:08 +0100 Subject: [PATCH] feat: implement events page with infinite scroll, filtering, and newsletter subscription utilities --- e2e/smoke.spec.ts | 27 ++++ package-lock.json | 104 +++++++++++++-- package.json | 1 + playwright.config.ts | 34 +++++ src/__tests__/NewsletterForm.test.tsx | 78 +++++++++++ src/__tests__/ticketUtilities.test.ts | 178 ++++++++++++++++++++++++++ src/app/(public)/events/page.tsx | 60 ++++----- src/app/(public)/page.tsx | 60 ++++++++- src/components/NewsletterForm.tsx | 13 +- src/lib/ticketHelpers.ts | 15 +++ src/lib/ticketValidation.ts | 30 +++++ 11 files changed, 553 insertions(+), 47 deletions(-) create mode 100644 e2e/smoke.spec.ts create mode 100644 playwright.config.ts create mode 100644 src/__tests__/NewsletterForm.test.tsx create mode 100644 src/__tests__/ticketUtilities.test.ts diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 0000000..1397eb8 --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Smoke Tests", () => { + test("landing page should load successfully", async ({ page }) => { + await page.goto("/"); + // Check if header title "VeriTix" is visible + await expect(page.getByLabel("VeriTix home")).toBeVisible(); + // Check if the hero search bar inputs are present + await expect(page.getByPlaceholder("Search events, artists...")).toBeVisible(); + }); + + test("login page should load successfully", async ({ page }) => { + await page.goto("/login"); + // Check if "Welcome Back" header is visible + await expect(page.getByRole("heading", { name: "Welcome Back" })).toBeVisible(); + // Check if email input is present + await expect(page.getByLabel("Email")).toBeVisible(); + }); + + test("event discovery page should load successfully", async ({ page }) => { + await page.goto("/events"); + // Check if the search inputs are present + await expect(page.getByPlaceholder("Search events, artists, or venues")).toBeVisible(); + await expect(page.getByPlaceholder("Location")).toBeVisible(); + await expect(page.getByPlaceholder("Date")).toBeVisible(); + }); +}); diff --git a/package-lock.json b/package-lock.json index 2bb6b80..0673ef5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "zod": "^4.3.5" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@tailwindcss/postcss": "^4.1.18", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", @@ -117,6 +118,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -466,6 +468,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -489,6 +492,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1897,6 +1901,23 @@ "node": ">=12.4.0" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -2663,7 +2684,6 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -2768,8 +2788,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2924,6 +2943,7 @@ "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2934,6 +2954,7 @@ "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2944,6 +2965,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2999,6 +3021,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -3634,6 +3657,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3684,7 +3708,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -4083,6 +4106,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4737,8 +4761,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -5061,6 +5084,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5246,6 +5270,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6645,6 +6670,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -7142,7 +7168,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -7714,6 +7739,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -7744,6 +7816,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7903,7 +7976,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -7919,7 +7991,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -7932,8 +8003,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/prop-types": { "version": "15.8.1", @@ -7990,6 +8060,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7999,6 +8070,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8011,6 +8083,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -8043,6 +8116,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -8155,7 +8229,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -9139,6 +9214,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9394,6 +9470,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9572,6 +9649,7 @@ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -9688,6 +9766,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10038,6 +10117,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index fbfdd9c..add6bd8 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "zod": "^4.3.5" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@tailwindcss/postcss": "^4.1.18", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..fac1424 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, devices } from "@playwright/test"; + +const PORT = process.env.PORT || 3000; +const baseURL = `http://localhost:${PORT}`; + +export default defineConfig({ + testDir: "./e2e", + timeout: 30 * 1000, + expect: { + timeout: 5000, + }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: "line", + use: { + baseURL, + trace: "on-first-retry", + video: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: { + command: "npm run dev", + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, +}); diff --git a/src/__tests__/NewsletterForm.test.tsx b/src/__tests__/NewsletterForm.test.tsx new file mode 100644 index 0000000..bdab531 --- /dev/null +++ b/src/__tests__/NewsletterForm.test.tsx @@ -0,0 +1,78 @@ +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import NewsletterForm from "../components/NewsletterForm"; + +describe("NewsletterForm", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("renders email input and submit button", () => { + render(); + expect(screen.getByPlaceholderText(/Enter your email address/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Subscribe/i })).toBeInTheDocument(); + }); + + it("shows validation error on invalid email", async () => { + render(); + const input = screen.getByPlaceholderText(/Enter your email address/i); + const button = screen.getByRole("button", { name: /Subscribe/i }); + + fireEvent.change(input, { target: { value: "invalid-email" } }); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByText(/Please enter a valid email address/i)).toBeInTheDocument(); + }); + }); + + it("shows success confirmation on valid email submission", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({}), + }); + vi.stubGlobal("fetch", mockFetch); + + render(); + const input = screen.getByPlaceholderText(/Enter your email address/i); + const button = screen.getByRole("button", { name: /Subscribe/i }); + + fireEvent.change(input, { target: { value: "test@example.com" } }); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByText(/You're subscribed! Check your inbox for a confirmation./i)).toBeInTheDocument(); + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/newsletter"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ email: "test@example.com" }), + }) + ); + }); + + it("shows server error message on API failure", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({ message: "This email is already subscribed" }), + }); + vi.stubGlobal("fetch", mockFetch); + + render(); + const input = screen.getByPlaceholderText(/Enter your email address/i); + const button = screen.getByRole("button", { name: /Subscribe/i }); + + fireEvent.change(input, { target: { value: "duplicate@example.com" } }); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByText(/This email is already subscribed/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/src/__tests__/ticketUtilities.test.ts b/src/__tests__/ticketUtilities.test.ts new file mode 100644 index 0000000..4018d68 --- /dev/null +++ b/src/__tests__/ticketUtilities.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect } from "vitest"; +import { + groupTicketsByStatus, + resolveStatusLabel, + formatTicketPrice, + UserTicket, +} from "../lib/ticketHelpers"; +import { + validateTicketRow, + validateAllTickets, + validateTicketDetails, + TicketRow, + TicketDetails, +} from "../lib/ticketValidation"; + +describe("ticketHelpers", () => { + describe("groupTicketsByStatus", () => { + it("should group tickets by their status", () => { + const tickets: UserTicket[] = [ + { + id: "1", + eventId: "e1", + eventName: "Event 1", + eventDate: "Date 1", + ticketType: "VIP", + seatOrReference: "A1", + status: "active", + qrCode: "qr1", + }, + { + id: "2", + eventId: "e2", + eventName: "Event 2", + eventDate: "Date 2", + ticketType: "Regular", + seatOrReference: "B1", + status: "used", + qrCode: "qr2", + }, + { + id: "3", + eventId: "e3", + eventName: "Event 3", + eventDate: "Date 3", + ticketType: "Regular", + seatOrReference: "C1", + status: "active", + qrCode: "qr3", + }, + ]; + + const grouped = groupTicketsByStatus(tickets); + expect(grouped.active).toHaveLength(2); + expect(grouped.used).toHaveLength(1); + expect(grouped.cancelled).toHaveLength(0); + expect(grouped.expired).toHaveLength(0); + }); + }); + + describe("resolveStatusLabel", () => { + it("should resolve active status correctly", () => { + expect(resolveStatusLabel("active")).toBe("Active"); + }); + it("should resolve used status correctly", () => { + expect(resolveStatusLabel("used")).toBe("Used"); + }); + it("should resolve cancelled status correctly", () => { + expect(resolveStatusLabel("cancelled")).toBe("Cancelled"); + }); + it("should resolve expired status correctly", () => { + expect(resolveStatusLabel("expired")).toBe("Expired"); + }); + }); + + describe("formatTicketPrice", () => { + it("should format 0 as Free", () => { + expect(formatTicketPrice(0)).toBe("Free"); + }); + it("should format non-zero price as ETH with 2 decimal places", () => { + expect(formatTicketPrice(0.08)).toBe("0.08 ETH"); + expect(formatTicketPrice(1.5)).toBe("1.50 ETH"); + }); + }); +}); + +describe("ticketValidation", () => { + describe("validateTicketRow", () => { + it("should return no errors for a valid ticket row", () => { + const ticket: TicketRow = { name: "General Admission", quantity: 10, price: 5 }; + const errors = validateTicketRow(ticket); + expect(errors).toHaveLength(0); + }); + + it("should flag empty ticket names", () => { + const ticket: TicketRow = { name: " ", quantity: 10, price: 5 }; + const errors = validateTicketRow(ticket); + expect(errors).toContainEqual({ field: "name", message: "Ticket name is required." }); + }); + + it("should flag invalid quantities", () => { + const ticket1: TicketRow = { name: "VIP", quantity: 0, price: 5 }; + const ticket2: TicketRow = { name: "VIP", quantity: -2.5, price: 5 }; + expect(validateTicketRow(ticket1)).toContainEqual({ field: "quantity", message: "Quantity must be at least 1." }); + expect(validateTicketRow(ticket2)).toContainEqual({ field: "quantity", message: "Quantity must be at least 1." }); + }); + + it("should flag negative prices", () => { + const ticket: TicketRow = { name: "VIP", quantity: 5, price: -1 }; + const errors = validateTicketRow(ticket); + expect(errors).toContainEqual({ field: "price", message: "Price cannot be negative." }); + }); + }); + + describe("validateAllTickets", () => { + it("should return index mapped validation errors", () => { + const tickets: TicketRow[] = [ + { name: "Valid", quantity: 10, price: 5 }, + { name: "", quantity: -1, price: 5 }, + ]; + const result = validateAllTickets(tickets); + expect(result.has(0)).toBe(false); + expect(result.has(1)).toBe(true); + expect(result.get(1)).toHaveLength(2); + }); + }); + + describe("validateTicketDetails (expiration and limit validations)", () => { + it("should return no errors for a valid ticket detail check", () => { + const ticket: TicketDetails = { + id: "t1", + name: "VIP", + price: 10, + quantityLimit: 100, + quantitySold: 50, + expirationDate: new Date(Date.now() + 86400000).toISOString(), + }; + const errors = validateTicketDetails(ticket, 5); + expect(errors).toHaveLength(0); + }); + + it("should flag expired tickets", () => { + const ticket: TicketDetails = { + id: "t1", + name: "VIP", + price: 10, + quantityLimit: 100, + quantitySold: 50, + expirationDate: new Date(Date.now() - 86400000).toISOString(), + }; + const errors = validateTicketDetails(ticket, 5); + expect(errors).toContain("Ticket has expired."); + }); + + it("should flag limit exceed cases", () => { + const ticket: TicketDetails = { + id: "t1", + name: "VIP", + price: 10, + quantityLimit: 100, + quantitySold: 95, + }; + const errors = validateTicketDetails(ticket, 10); + expect(errors).toContain("Purchase quantity exceeds available ticket limit."); + }); + + it("should flag invalid purchase quantities", () => { + const ticket: TicketDetails = { + id: "t1", + name: "VIP", + price: 10, + quantityLimit: 100, + quantitySold: 50, + }; + const errors = validateTicketDetails(ticket, 0); + expect(errors).toContain("Purchase quantity must be at least 1."); + }); + }); +}); diff --git a/src/app/(public)/events/page.tsx b/src/app/(public)/events/page.tsx index 3d99f43..c89b0b2 100644 --- a/src/app/(public)/events/page.tsx +++ b/src/app/(public)/events/page.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useState, useMemo, useEffect, useRef, useCallback } from 'react'; +import { useState, useMemo, useEffect, useRef, useCallback, Suspense } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { HiSearch, HiLocationMarker, HiCalendar } from 'react-icons/hi'; +import { useSearchParams } from 'next/navigation'; import CategoryFilter from '@/components/events/CategoryFilter'; import FilterInput from '@/components/events/FilterInput'; import TabSelector from '@/components/TabSelector'; @@ -14,43 +15,25 @@ const PAGE_SIZE = 9; type ViewMode = 'upcoming' | 'featured'; -export default function EventsPage() { +function EventsPageContent() { const { events, loading, error } = useEvents(); const [activeFilters, setActiveFilters] = useState(['music', 'festival']); const [viewMode, setViewMode] = useState('upcoming'); - const [searchQuery, setSearchQuery] = useState(''); - const [locationFilter, setLocationFilter] = useState(''); - const [dateFilter, setDateFilter] = useState(''); - const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); - const sentinelRef = useRef(null); + + const searchParams = useSearchParams(); + const [searchQuery, setSearchQuery] = useState(() => searchParams.get('q') || ''); + const [locationFilter, setLocationFilter] = useState(() => searchParams.get('location') || ''); + const [dateFilter, setDateFilter] = useState(() => searchParams.get('date') || ''); + // Sync state if URL query params change useEffect(() => { - fetchEvents() - .then((data) => { - setEvents(data); - setError(null); - }) - .catch((err) => { - setError(err.message || 'Failed to load events. Please try again.'); - setEvents([]); - }) - .finally(() => setLoading(false)); - }, []); + setSearchQuery(searchParams.get('q') || ''); + setLocationFilter(searchParams.get('location') || ''); + setDateFilter(searchParams.get('date') || ''); + }, [searchParams]); - const handleRetry = () => { - setLoading(true); - setError(null); - fetchEvents() - .then((data) => { - setEvents(data); - setError(null); - }) - .catch((err) => { - setError(err.message || 'Failed to load events. Please try again.'); - setEvents([]); - }) - .finally(() => setLoading(false)); - }; + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + const sentinelRef = useRef(null); const filteredEvents = useMemo(() => { let list = viewMode === 'featured' ? events.filter((e) => e.featured) : events; @@ -256,3 +239,16 @@ export default function EventsPage() { ); } + +export default function EventsPage() { + return ( + +
+

Loading events page...

+
+ }> + +
+ ); +} diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx index c766f37..3c6539e 100644 --- a/src/app/(public)/page.tsx +++ b/src/app/(public)/page.tsx @@ -1,10 +1,11 @@ "use client"; +import { useState } from "react"; import Image from "next/image"; import Link from "next/link"; import dynamic from "next/dynamic"; import { motion } from "framer-motion"; -import dynamic from "next/dynamic"; +import { useRouter } from "next/navigation"; import { Calendar, Clock, @@ -13,6 +14,7 @@ import { Share2, Ticket, ChevronDown, + Search, } from "lucide-react"; import { howItWorksSteps, trendingEvents } from "@/mocks/landing"; @@ -39,6 +41,20 @@ const fadeUp = { }; export default function Home() { + const router = useRouter(); + const [searchQ, setSearchQ] = useState(""); + const [searchLocation, setSearchLocation] = useState(""); + const [searchDate, setSearchDate] = useState(""); + + const handleSearchSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const params = new URLSearchParams(); + if (searchQ.trim()) params.set("q", searchQ.trim()); + if (searchLocation.trim()) params.set("location", searchLocation.trim()); + if (searchDate.trim()) params.set("date", searchDate.trim()); + router.push(`/events?${params.toString()}`); + }; + return (
+
+
+ + setSearchQ(e.target.value)} + className="w-full bg-transparent text-sm focus:outline-none text-white placeholder:text-white/40" + aria-label="Event name or keyword" + /> +
+
+ + setSearchLocation(e.target.value)} + className="w-full bg-transparent text-sm focus:outline-none text-white placeholder:text-white/40" + aria-label="Event location" + /> +
+
+ + setSearchDate(e.target.value)} + className="w-full bg-transparent text-sm focus:outline-none text-white placeholder:text-white/40" + aria-label="Event date" + /> +
+ +
+
("idle"); @@ -12,7 +15,13 @@ export default function NewsletterForm() { const handleSubmit = async (e: FormEvent) => { e.preventDefault(); - if (!email.trim()) return; + + const validationResult = emailSchema.safeParse(email); + if (!validationResult.success) { + setStatus("error"); + setErrorMsg(validationResult.error.issues[0]?.message || "Invalid email address"); + return; + } setStatus("loading"); setErrorMsg(""); @@ -21,7 +30,7 @@ export default function NewsletterForm() { const res = await fetch(NEWSLETTER_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email }), + body: JSON.stringify({ email: validationResult.data }), }); if (!res.ok) { diff --git a/src/lib/ticketHelpers.ts b/src/lib/ticketHelpers.ts index 4f15694..2dbf67a 100644 --- a/src/lib/ticketHelpers.ts +++ b/src/lib/ticketHelpers.ts @@ -25,4 +25,19 @@ export function groupTicketsByStatus(tickets: UserTicket[]) { }, { active: [], used: [], cancelled: [], expired: [] } ); +} + +export function resolveStatusLabel(status: UserTicket["status"]): string { + const labels: Record = { + active: "Active", + used: "Used", + cancelled: "Cancelled", + expired: "Expired", + }; + return labels[status] || "Unknown"; +} + +export function formatTicketPrice(price: number): string { + if (price === 0) return "Free"; + return `${price.toFixed(2)} ETH`; } \ No newline at end of file diff --git a/src/lib/ticketValidation.ts b/src/lib/ticketValidation.ts index 0bbce86..90f695b 100644 --- a/src/lib/ticketValidation.ts +++ b/src/lib/ticketValidation.ts @@ -32,4 +32,34 @@ export function validateAllTickets(tickets: TicketRow[]): Map 0) result.set(index, errors); }); return result; +} + +export interface TicketDetails { + id: string; + name: string; + price: number; + quantityLimit: number; + quantitySold: number; + expirationDate?: string; +} + +export function validateTicketDetails(ticket: TicketDetails, purchaseQuantity: number): string[] { + const errors: string[] = []; + + if (purchaseQuantity < 1) { + errors.push("Purchase quantity must be at least 1."); + } + + if (ticket.expirationDate) { + const expDate = new Date(ticket.expirationDate); + if (!isNaN(expDate.getTime()) && expDate.getTime() < Date.now()) { + errors.push("Ticket has expired."); + } + } + + if (ticket.quantitySold + purchaseQuantity > ticket.quantityLimit) { + errors.push("Purchase quantity exceeds available ticket limit."); + } + + return errors; } \ No newline at end of file