From ef8050f079c6cb2e9927b0f50f09cf22d05a7190 Mon Sep 17 00:00:00 2001 From: EtimbukUdofia Date: Sat, 25 Apr 2026 11:04:52 +0100 Subject: [PATCH] feat: implement theme toggle functionality and context management - Added ThemeToggle component for switching between light and dark modes. - Created ThemeContext to manage theme preferences and resolved themes. - Integrated theme persistence using localStorage. - Updated MarketplaceListings, PartnersSection, and TicketQRModal components for improved styling and accessibility. - Refactored loading states and empty listings messages for better user experience. --- .../__tests__/components/Header.test.tsx | 66 ++- .../app/[locale]/analytics/page.tsx | 10 +- .../app/[locale]/create-event/page.tsx | 417 ++++++------------ .../app/[locale]/dashboard/page.tsx | 99 ++--- soroban-client/app/[locale]/events/page.tsx | 10 +- soroban-client/app/[locale]/layout.tsx | 33 +- soroban-client/app/[locale]/page.tsx | 2 +- soroban-client/app/analytics/page.tsx | 10 +- soroban-client/app/create-event/page.tsx | 38 +- soroban-client/app/dashboard/page.tsx | 99 ++--- soroban-client/app/events/page.tsx | 12 +- soroban-client/app/globals.css | 17 +- soroban-client/app/marketplace/page.tsx | 10 +- soroban-client/app/my-tickets/page.tsx | 122 ++--- soroban-client/app/verifier/page.tsx | 183 ++++---- soroban-client/components/AboutSection.tsx | 8 +- .../components/AnalyticsDashboard.tsx | 74 ++-- .../components/ContractEventFeed.tsx | 44 +- soroban-client/components/EventCatalog.tsx | 101 ++--- soroban-client/components/FeaturesSection.tsx | 14 +- soroban-client/components/Footer.tsx | 54 +-- soroban-client/components/Header.tsx | 100 +++-- soroban-client/components/Hero.tsx | 24 +- .../components/HowItWorksSection.tsx | 31 +- .../components/LanguageSwitcher.tsx | 4 +- .../Marketplace/CreateListingForm.tsx | 20 +- .../components/Marketplace/ListingCard.tsx | 24 +- .../Marketplace/MarketplaceCatalog.tsx | 47 +- .../Marketplace/MarketplaceListings.tsx | 42 +- soroban-client/components/PartnersSection.tsx | 10 +- soroban-client/components/ThemeToggle.tsx | 32 ++ soroban-client/components/TicketQRModal.tsx | 44 +- soroban-client/contexts/ThemeContext.tsx | 124 ++++++ soroban-client/package-lock.json | 31 +- soroban-client/yarn.lock | 99 +++-- 35 files changed, 1081 insertions(+), 974 deletions(-) create mode 100644 soroban-client/components/ThemeToggle.tsx create mode 100644 soroban-client/contexts/ThemeContext.tsx diff --git a/soroban-client/__tests__/components/Header.test.tsx b/soroban-client/__tests__/components/Header.test.tsx index c3c2a431..d5942aa2 100644 --- a/soroban-client/__tests__/components/Header.test.tsx +++ b/soroban-client/__tests__/components/Header.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import Header from '../../components/Header'; +import { ThemeProvider } from '@/contexts/ThemeContext'; import { useWallet } from '@/contexts/WalletContext'; // Mock Wallet Context Hook @@ -13,6 +14,33 @@ jest.mock('next/navigation', () => ({ })); describe('Header Component', () => { + beforeEach(() => { + window.localStorage.clear(); + + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); + + document.documentElement.classList.remove('dark'); + }); + + const renderHeader = () => + render( + +
+ + ); + it('renders navigation links properly', () => { (useWallet as jest.Mock).mockReturnValue({ address: null, @@ -27,7 +55,7 @@ describe('Header Component', () => { signTransaction: jest.fn(), }); - render(
); + renderHeader(); expect(screen.getByText('CrowdPass')).toBeInTheDocument(); // Home, Events, Analytics, and Create Events may appear multiple times (desktop + mobile) @@ -36,6 +64,7 @@ describe('Header Component', () => { expect(screen.getAllByText('Analytics').length).toBeGreaterThan(0); expect(screen.getAllByText('Create Events').length).toBeGreaterThan(0); expect(screen.getAllByText('Connect Freighter').length).toBeGreaterThan(0); + expect(screen.getAllByLabelText('Switch to dark mode').length).toBeGreaterThan(0); }); it('displays Install Freighter if wallet is not installed', () => { @@ -52,7 +81,7 @@ describe('Header Component', () => { signTransaction: jest.fn(), }); - render(
); + renderHeader(); // Select Wallet may appear multiple times (desktop + mobile) expect(screen.getAllByText('Select Wallet').length).toBeGreaterThan(0); }); @@ -74,15 +103,42 @@ describe('Header Component', () => { signTransaction: jest.fn(), }); - render(
); + renderHeader(); const formattedAddress = 'GBJ2...V2Y2'; // There are multiple address displays (desktop and mobile), so verify they exist expect(screen.getAllByText(formattedAddress).length).toBeGreaterThan(0); - + // Disconnect buttons may appear multiple times (desktop + mobile) const disconnectBtns = screen.getAllByText('Disconnect'); expect(disconnectBtns.length).toBeGreaterThan(0); fireEvent.click(disconnectBtns[0]); expect(disconnectMock).toHaveBeenCalledTimes(1); }); + + it('persists theme selection and toggles the root dark class', async () => { + (useWallet as jest.Mock).mockReturnValue({ + address: null, + providerId: 'freighter', + providerName: 'Freighter', + availableProviders: [], + isConnected: false, + isInstalled: true, + connect: jest.fn(), + disconnect: jest.fn(), + setProviderId: jest.fn(), + signTransaction: jest.fn(), + }); + + renderHeader(); + + const toggleButtons = screen.getAllByLabelText('Switch to dark mode'); + fireEvent.click(toggleButtons[0]); + + await waitFor(() => { + expect(window.localStorage.getItem('crowdpass-theme')).toBe('dark'); + expect(document.documentElement.classList.contains('dark')).toBe(true); + }); + + expect(screen.getAllByLabelText('Switch to light mode').length).toBeGreaterThan(0); + }); }); diff --git a/soroban-client/app/[locale]/analytics/page.tsx b/soroban-client/app/[locale]/analytics/page.tsx index c61cbc1a..b99c1914 100644 --- a/soroban-client/app/[locale]/analytics/page.tsx +++ b/soroban-client/app/[locale]/analytics/page.tsx @@ -4,19 +4,19 @@ import Header from "@/components/Header"; export default function AnalyticsPage() { return ( -
+
-
-

+

+

Analytics

-

+

Organizer and platform insights in one dashboard

-

+

Track page views, wallet connections, batch ticket purchases, conversion trends, and revenue signals without leaving the CrowdPass interface.

diff --git a/soroban-client/app/[locale]/create-event/page.tsx b/soroban-client/app/[locale]/create-event/page.tsx index 00c17083..c6bf2758 100644 --- a/soroban-client/app/[locale]/create-event/page.tsx +++ b/soroban-client/app/[locale]/create-event/page.tsx @@ -1,61 +1,64 @@ "use client"; -import React, { useState } from "react"; +import { useState } from "react"; import { useRouter } from "next/navigation"; -import AnalyticsPageView from "@/components/AnalyticsPageView"; -import Header from "@/components/Header"; -import { useWallet } from "@/contexts/WalletContext"; -import { createEvent } from "@/lib/soroban"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; +import AnalyticsPageView from "@/components/AnalyticsPageView"; import Header from "@/components/Header"; +import { useWallet } from "@/contexts/WalletContext"; +import { createEvent } from "@/lib/soroban"; -const eventSchema = z.object({ - theme: z.string().min(1, "Event name required"), - description: z.string().optional(), - startDate: z.string().min(1, "Start date is required"), - endDate: z.string().min(1, "End date is required"), - price: z.coerce.number({ invalid_type_error: "Price must be a number" }).min(0, "Price cannot be negative").max(1000000, "Price is too high"), - tickets: z.coerce.number({ invalid_type_error: "Tickets must be a number" }).int("Must be a positive integer").positive("Must be a positive integer"), -}).superRefine((data, ctx) => { - if (data.startDate) { - const start = new Date(data.startDate).getTime(); - if (start <= Date.now()) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["startDate"], - message: "Start date must be in the future", - }); +const eventSchema = z + .object({ + theme: z.string().min(1, "Event name required"), + description: z.string().optional(), + startDate: z.string().min(1, "Start date is required"), + endDate: z.string().min(1, "End date is required"), + price: z + .string() + .min(1, "Price required") + .refine((s) => !Number.isNaN(parseFloat(s)), "Price must be a number") + .refine((s) => parseFloat(s) >= 0, "Price cannot be negative") + .refine((s) => parseFloat(s) <= 1_000_000, "Price is too high"), + tickets: z + .string() + .min(1, "Total tickets required") + .refine((s) => /^[0-9]+$/.test(s), "Tickets must be a whole number") + .refine((s) => parseInt(s, 10) > 0, "Must be a positive integer"), + }) + .superRefine((data, ctx) => { + if (data.startDate) { + const start = new Date(data.startDate).getTime(); + if (start <= Date.now()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["startDate"], + message: "Start date must be in the future", + }); + } } - } - if (data.startDate && data.endDate) { - const start = new Date(data.startDate).getTime(); - const end = new Date(data.endDate).getTime(); - if (end <= start) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["endDate"], - message: "End date must be after start date", - }); + if (data.startDate && data.endDate) { + const start = new Date(data.startDate).getTime(); + const end = new Date(data.endDate).getTime(); + if (end <= start) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["endDate"], + message: "End date must be after start date", + }); + } } - } -}); + }); type EventFormData = z.infer; export default function CreateEventPage() { const router = useRouter(); - const { address, isInstalled, connect, providerName, signTransaction } = useWallet(); - - const [theme, setTheme] = useState(""); - const [description, setDescription] = useState(""); - const [startDate, setStartDate] = useState(""); - const [endDate, setEndDate] = useState(""); - const [price, setPrice] = useState(""); - const [tickets, setTickets] = useState(""); - const [image, setImage] = useState(null); + const { address, isInstalled, connect, providerName, signTransaction } = + useWallet(); const { register, @@ -70,37 +73,16 @@ export default function CreateEventPage() { const [errorMsg, setErrorMsg] = useState(""); const onSubmit = async (data: EventFormData) => { - const validate = () => { - const errs: { [key: string]: string } = {}; - const now = Date.now(); - - if (!theme.trim()) errs.theme = "Event name required"; - if (!startDate) errs.startDate = "Start date is required"; - if (!endDate) errs.endDate = "End date is required"; - if (startDate && new Date(startDate).getTime() <= now) - errs.startDate = "Start date must be in the future"; - if (startDate && endDate && new Date(endDate) <= new Date(startDate)) - errs.endDate = "End date must be after start date"; - if (!price) errs.price = "Price required"; - if (price && isNaN(Number(price))) errs.price = "Price must be a number"; - if (price && Number(price) < 0) errs.price = "Price cannot be negative"; - if (!tickets) errs.tickets = "Total tickets required"; - if (tickets && (!/^[0-9]+$/.test(tickets) || Number(tickets) <= 0)) - errs.tickets = "Must be a positive integer"; - - return errs; - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); let organizerAddress = address; - if (!address) { + if (!organizerAddress) { if (isInstalled) { await connect(); organizerAddress = localStorage.getItem("wallet_address"); } else { - alert(`Please install ${providerName} (or another Stellar wallet) to create an event.`); + alert( + `Please install ${providerName} (or another Stellar wallet) to create an event.` + ); return; } } @@ -110,37 +92,25 @@ export default function CreateEventPage() { return; } - const errs = validate(); - if (Object.keys(errs).length) { - setErrors(errs); - return; - } - setErrors({}); - setSubmitting(true); setErrorMsg(""); setSuccessMsg(""); try { - const organizer = address!; const startUnix = Math.floor(new Date(data.startDate).getTime() / 1000); const endUnix = Math.floor(new Date(data.endDate).getTime() / 1000); - const ticketPrice = BigInt(Math.floor(data.price * 1_000_000)); - const totalTickets = BigInt(data.tickets); - const organizer = organizerAddress; - const startUnix = Math.floor(new Date(startDate).getTime() / 1000); - const endUnix = Math.floor(new Date(endDate).getTime() / 1000); - const ticketPrice = BigInt(Math.floor(parseFloat(price) * 1_000_000)); - const totalTickets = BigInt(tickets); + const ticketPrice = BigInt( + Math.floor(parseFloat(data.price) * 10_000_000) + ); + const totalTickets = BigInt(parseInt(data.tickets, 10)); - // for simplicity we use zero address as payment token; replace with real - // token contract address or allow user selection later. - const paymentToken = "0000000000000000000000000000000000000000000000000000000000000000"; + const paymentToken = + "0000000000000000000000000000000000000000000000000000000000000000"; const res = await createEvent( { - organizer, - theme, - eventType: description, + organizer: organizerAddress, + theme: data.theme, + eventType: data.description || "", startTimeUnix: startUnix, endTimeUnix: endUnix, ticketPrice, @@ -149,313 +119,172 @@ export default function CreateEventPage() { }, signTransaction ); - const res = await createEvent({ - organizer, - theme: data.theme, - eventType: data.description || "", - startTimeUnix: startUnix, - endTimeUnix: endUnix, - ticketPrice, - totalTickets, - paymentToken, - }); - console.log("transaction result", res); - setSuccessMsg("Event created (tx " + res.hash + ")"); - // Optionally redirect to dashboard or home after creation + setSuccessMsg(`Event created (ledger ${res.ledger}, tx ${res.hash})`); setTimeout(() => router.push("/"), 3000); } catch (err: unknown) { console.error(err); - if (err && typeof err === "object" && "message" in err) { - setErrorMsg((err as { message?: string }).message || "unknown error"); - } else { - setErrorMsg("unknown error"); - } - } finally { - setSubmitting(false); - setErrorMsg(err.message || "unknown error"); + const message = + err && typeof err === "object" && "message" in err + ? String((err as { message?: string }).message) + : "Unknown error"; + setErrorMsg(message); } }; return ( -
-
-
-
-
-

- Create New Event -

-

- Launch your decentralized event with secure ticketing. -

-
- - {successMsg && ( -
- -
+
-
-
-

Create Event

-

- Launch a new CrowdPass experience with on-chain pricing, inventory, and - organizer ownership. +

+
+

Create Event

+

+ Launch a new CrowdPass experience with on-chain pricing, inventory, + and organizer ownership.

{successMsg && ( -
+
{successMsg}
)} {errorMsg && ( -
- +
{errorMsg}
-
- {errorMsg} -
- )} - -
-
- - setTheme(e.target.value)} - className="mt-1 block w-full rounded-2xl border border-white/10 bg-zinc-950 px-4 py-3 shadow-sm" - /> - {errors.theme && ( -

{errors.theme}

)} -
+
-
-