initial checkin

This commit is contained in:
2026-04-10 21:48:23 -05:00
parent 28fe3bfa48
commit fdc05f8048
49 changed files with 10162 additions and 1 deletions

113
README.md
View File

@@ -1,2 +1,113 @@
# booking_webapp # WaterTrekk Webapp
Customer-facing booking storefront and vendor dashboard for **WaterTrekk** — tours, boat rentals, and equipment — built with React and Vite. The app talks to a Django-style REST API (`/api/v1`) via Axios, with JWT access/refresh tokens stored in `localStorage`.
## Tech stack
- **React 19** and **TypeScript**
- **Vite 6** for dev server and production builds
- **React Router 7** for routing and layouts
- **Axios** for HTTP, including automatic refresh on `401` responses
## Features
- **Storefront** (public): home, boat rentals and search, tour listings, equipment detail and booking flows (booking routes require sign-in).
- **Authentication**: sign in, register, forgot password; tokens persisted for authenticated API calls.
- **Vendor area** (`/dashboard/*`): dashboard, bookings, listings, analytics, and settings — gated for vendor accounts.
## Prerequisites
- **Node.js** (LTS recommended) and npm
- A running **backend** that serves `/api/v1` (see configuration below)
## Getting started
```bash
npm install
npm run dev
```
Open the URL Vite prints (typically `http://localhost:5173`).
### Production build
```bash
npm run build
npm run preview
```
### Lint
```bash
npm run lint
```
## Configuration
| Variable | Description |
| -------- | ----------- |
| `VITE_API_BASE_URL` | Optional. Base URL for the API **without** trailing slash. When unset, requests use relative `/api/v1` (see dev proxy). |
Example:
```bash
VITE_API_BASE_URL=https://api.example.com npm run build
```
### Local development proxy
With `VITE_API_BASE_URL` unset, `npm run dev` proxies `/api` to `http://127.0.0.1:8003` (see `vite.config.ts`). Start your API on that host/port, or change the proxy target to match your setup.
## Scripts
| Script | Purpose |
| ------ | ------- |
| `npm run dev` | Start Vite dev server with HMR |
| `npm run build` | Typecheck and emit production assets to `dist/` |
| `npm run preview` | Serve the production build locally |
| `npm run lint` | Run ESLint |
## Repository layout (high level)
- `src/pages/` — route-level screens (storefront, auth, vendor admin)
- `src/components/` — shared layout and UI pieces
- `src/auth/` — auth context, route guards (`RequireSignIn`, `RequireVendor`)
- `src/api/` — Axios client, token handling, service calls, types
## Screenshots
Captured from a local production preview at 1400×900 viewport (external images may depend on network).
### Home
![Home page](docs/screenshots/home.png)
### Boat rentals
![Boat rentals](docs/screenshots/boat-rentals.png)
### Boat search
![Boat search](docs/screenshots/boat-search.png)
### Tours
![Tours](docs/screenshots/tours.png)
### Sign in
![Sign in](docs/screenshots/sign-in.png)
### For vendors
![Vendors marketing page](docs/screenshots/vendors.png)
### Regenerating screenshots
With a preview server running on port **4173** (for example `npm run preview` after `npm run build`):
```bash
npx playwright@1.49.1 screenshot http://127.0.0.1:4173/ docs/screenshots/home.png --viewport-size=1400,900 --wait-for-timeout=4000
```
Repeat for other paths, or adjust viewport and output paths as needed. On first use, Playwright may download Chromium (`npx playwright install chromium`).

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
docs/screenshots/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
docs/screenshots/tours.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

28
eslint.config.js Normal file
View File

@@ -0,0 +1,28 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
);

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WaterTrekk — Bookings</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3562
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "watertrekk-webapp",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.15.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.6.0"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.21.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"typescript": "~5.7.2",
"typescript-eslint": "^8.24.1",
"vite": "^6.2.0"
}
}

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFBD4F"></stop><stop offset="100%" stop-color="#FF980E"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.954 7.318 3.223l1.122-.856L191.867 9.859c.571-2.512-1.509-4.853-4.065-4.853h-2.405Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

77
src/App.tsx Normal file
View File

@@ -0,0 +1,77 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { AdminLayout } from "./components/AdminLayout";
import { Layout } from "./components/Layout";
import { RequireSignIn } from "./auth/RequireSignIn";
import { RequireVendor } from "./auth/RequireVendor";
import { AdventureBookPage } from "./pages/AdventureBookPage";
import { AdventureDetailPage } from "./pages/AdventureDetailPage";
import { BoatRentalsPage } from "./pages/BoatRentalsPage";
import { BoatSearchPage } from "./pages/BoatSearchPage";
import { DashboardPage } from "./pages/DashboardPage";
import { EquipmentBookPage } from "./pages/EquipmentBookPage";
import { EquipmentDetailPage } from "./pages/EquipmentDetailPage";
import { HomePage } from "./pages/HomePage";
import { ToursPage } from "./pages/ToursPage";
import { VendorAnalyticsPage } from "./pages/VendorAnalyticsPage";
import { VendorBookingsPage } from "./pages/VendorBookingsPage";
import { VendorListingsPage } from "./pages/VendorListingsPage";
import { VendorSettingsPage } from "./pages/VendorSettingsPage";
import { VendorWhyPage } from "./pages/VendorWhyPage";
import { ForgotPasswordPage } from "./pages/auth/ForgotPasswordPage";
import { RegisterPage } from "./pages/auth/RegisterPage";
import { SignInPage } from "./pages/auth/SignInPage";
function App() {
return (
<Routes>
<Route path="/auth/sign-in" element={<SignInPage />} />
<Route path="/auth/register" element={<RegisterPage />} />
<Route path="/auth/forgot-password" element={<ForgotPasswordPage />} />
<Route element={<Layout />}>
<Route index element={<HomePage />} />
<Route path="vendors" element={<VendorWhyPage />} />
<Route path="boats" element={<BoatRentalsPage />} />
<Route path="boats/search" element={<BoatSearchPage />} />
<Route path="boats/item/:publicId" element={<EquipmentDetailPage />} />
<Route
path="boats/item/:publicId/book"
element={
<RequireSignIn>
<EquipmentBookPage />
</RequireSignIn>
}
/>
<Route path="tours" element={<ToursPage />} />
<Route path="tours/:publicId" element={<AdventureDetailPage />} />
<Route
path="tours/:publicId/book"
element={
<RequireSignIn>
<AdventureBookPage />
</RequireSignIn>
}
/>
</Route>
<Route
path="/dashboard"
element={
<RequireVendor>
<AdminLayout />
</RequireVendor>
}
>
<Route index element={<DashboardPage />} />
<Route path="bookings" element={<VendorBookingsPage />} />
<Route path="listings" element={<VendorListingsPage />} />
<Route path="analytics" element={<VendorAnalyticsPage />} />
<Route path="settings" element={<VendorSettingsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
export default App;

97
src/api/client.ts Normal file
View File

@@ -0,0 +1,97 @@
import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios";
const ACCESS_KEY = "watertrek_access";
const REFRESH_KEY = "watertrek_refresh";
function apiBaseUrl(): string {
const env = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace(/\/$/, "") ?? "";
return env ? `${env}/api/v1` : "/api/v1";
}
export const api = axios.create({
baseURL: apiBaseUrl(),
headers: { "Content-Type": "application/json" },
});
/** Storefront reads only — no Authorization header so invalid/expired tokens do not break public browsing. */
export const publicApi = axios.create({
baseURL: apiBaseUrl(),
headers: { "Content-Type": "application/json" },
});
export function getAccessToken(): string | null {
return localStorage.getItem(ACCESS_KEY);
}
export function getRefreshToken(): string | null {
return localStorage.getItem(REFRESH_KEY);
}
export function setTokens(access: string, refresh: string): void {
localStorage.setItem(ACCESS_KEY, access);
localStorage.setItem(REFRESH_KEY, refresh);
}
export function setAccessToken(access: string): void {
localStorage.setItem(ACCESS_KEY, access);
}
export function clearTokens(): void {
localStorage.removeItem(ACCESS_KEY);
localStorage.removeItem(REFRESH_KEY);
}
api.interceptors.request.use((config) => {
const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
type RetryConfig = InternalAxiosRequestConfig & { _retry?: boolean };
api.interceptors.response.use(
(res) => res,
async (error: AxiosError) => {
const original = error.config as RetryConfig | undefined;
const status = error.response?.status;
const url = original?.url ?? "";
if (
status !== 401 ||
!original ||
original._retry ||
url.includes("/accounts/token/") ||
!getRefreshToken()
) {
return Promise.reject(error);
}
original._retry = true;
try {
const { data } = await axios.post<{ access: string }>(
`${apiBaseUrl()}/accounts/token/refresh/`,
{ refresh: getRefreshToken() },
{ headers: { "Content-Type": "application/json" } },
);
setAccessToken(data.access);
original.headers.Authorization = `Bearer ${data.access}`;
return api(original);
} catch {
clearTokens();
return Promise.reject(error);
}
},
);
export function formatApiMessage(err: unknown): string {
if (axios.isAxiosError(err)) {
const data = err.response?.data as Record<string, unknown> | undefined;
if (data && typeof data.detail === "string") return data.detail;
if (data && Array.isArray(data.non_field_errors) && typeof data.non_field_errors[0] === "string") {
return data.non_field_errors[0];
}
if (err.message) return err.message;
}
if (err instanceof Error) return err.message;
return "Something went wrong.";
}

174
src/api/services.ts Normal file
View File

@@ -0,0 +1,174 @@
import { api, publicApi } from "./client";
import type {
AdventureOffering,
AdventureOfferingDetail,
AdventureStorefrontResponse,
AvailabilityResponse,
Booking,
CreateVendorAdventureRequest,
CreateVendorEquipmentRequest,
CustomerRegisterRequest,
EquipmentCategory,
EquipmentItem,
EquipmentItemDetail,
EquipmentStorefrontResponse,
ListingClick,
LoginRequest,
MarketingSummary,
TokenPair,
User,
VendorProfile,
VendorRegisterRequest,
} from "./types";
export async function login(body: LoginRequest): Promise<TokenPair> {
const { data } = await api.post<TokenPair>("/accounts/token/", body);
return data;
}
export async function fetchMe(): Promise<User> {
const { data } = await api.get<User>("/accounts/me/");
return data;
}
export async function registerVendor(body: VendorRegisterRequest): Promise<User> {
const { data } = await api.post<User>("/accounts/register/vendor/", body);
return data;
}
export async function registerCustomer(body: CustomerRegisterRequest): Promise<User> {
const { data } = await api.post<User>("/accounts/register/customer/", body);
return data;
}
export async function requestPasswordReset(email?: string): Promise<{ detail: string }> {
const { data } = await api.post<{ detail: string }>("/accounts/password/reset/request/", { email: email ?? "" });
return data;
}
export async function fetchVendorProfile(): Promise<VendorProfile> {
const { data } = await api.get<VendorProfile>("/accounts/vendor-profile/me/");
return data;
}
export async function patchVendorProfile(body: Partial<VendorProfile>): Promise<VendorProfile> {
const { data } = await api.patch<VendorProfile>("/accounts/vendor-profile/me/", body);
return data;
}
export type EquipmentListParams = {
category?: string;
location?: string;
vendor_slug?: string;
min_price?: string;
max_price?: string;
available_from?: string;
available_to?: string;
};
export async function listEquipmentItems(params?: EquipmentListParams): Promise<EquipmentItem[]> {
const { data } = await publicApi.get<EquipmentItem[]>("/equipment/items/", { params });
return data;
}
export async function getEquipmentItem(
publicId: string,
params?: Record<string, string>,
): Promise<EquipmentItemDetail> {
const { data } = await publicApi.get<EquipmentItemDetail>(`/equipment/items/${publicId}/`, { params });
return data;
}
export async function listVendorEquipment(): Promise<EquipmentItem[]> {
const { data } = await api.get<EquipmentItem[]>("/equipment/vendor/items/");
return data;
}
export async function listEquipmentCategories(): Promise<EquipmentCategory[]> {
const { data } = await publicApi.get<EquipmentCategory[]>("/equipment/categories/");
return data;
}
export async function createVendorEquipment(body: CreateVendorEquipmentRequest): Promise<EquipmentItem> {
const { data } = await api.post<EquipmentItem>("/equipment/vendor/items/", body);
return data;
}
export type AdventureListParams = EquipmentListParams;
export async function listAdventureOfferings(params?: AdventureListParams): Promise<AdventureOffering[]> {
const { data } = await publicApi.get<AdventureOffering[]>("/adventrues/offerings/", { params });
return data;
}
export async function getAdventureOffering(
publicId: string,
params?: Record<string, string>,
): Promise<AdventureOfferingDetail> {
const { data } = await publicApi.get<AdventureOfferingDetail>(`/adventrues/offerings/${publicId}/`, {
params,
});
return data;
}
export async function listVendorAdventures(): Promise<AdventureOffering[]> {
const { data } = await api.get<AdventureOffering[]>("/adventrues/vendor/offerings/");
return data;
}
export async function createVendorAdventure(body: CreateVendorAdventureRequest): Promise<AdventureOffering> {
const { data } = await api.post<AdventureOffering>("/adventrues/vendor/offerings/", body);
return data;
}
export async function getEquipmentStorefront(slug: string): Promise<EquipmentStorefrontResponse> {
const { data } = await publicApi.get<EquipmentStorefrontResponse>(`/equipment/storefront/${slug}/`);
return data;
}
export async function getAdventureStorefront(slug: string): Promise<AdventureStorefrontResponse> {
const { data } = await publicApi.get<AdventureStorefrontResponse>(`/adventrues/storefront/${slug}/`);
return data;
}
export async function checkAvailability(params: {
equipment_item_id?: number;
adventure_offering_id?: number;
starts_at: string;
ends_at: string;
}): Promise<AvailabilityResponse> {
const { data } = await api.get<AvailabilityResponse>("/booking/availability/", { params });
return data;
}
export async function listBookings(): Promise<Booking[]> {
const { data } = await api.get<Booking[]>("/booking/bookings/");
return data;
}
export async function getBooking(id: number): Promise<Booking> {
const { data } = await api.get<Booking>(`/booking/bookings/${id}/`);
return data;
}
export async function fetchVendorMarketingSummary(from: string, to: string): Promise<MarketingSummary> {
const { data } = await api.get<MarketingSummary>("/marketing/vendor/summary/", {
params: { from, to },
});
return data;
}
export async function fetchVendorListingClicks(
from: string,
to: string,
extra?: {
traffic_type?: "organic" | "marketing";
listing_type?: "equipment" | "adventure";
utm_campaign?: string;
},
): Promise<ListingClick[]> {
const { data } = await api.get<ListingClick[]>("/marketing/vendor/clicks/", {
params: { from, to, ...extra },
});
return data;
}

273
src/api/types.ts Normal file
View File

@@ -0,0 +1,273 @@
/** Mirrors WaterTrekk booking API (see booking_backend/api.md). */
export type TrafficType = "organic" | "marketing";
export type VendorProfile = {
business_name: string;
slug: string;
description: string;
contact_phone: string;
contact_email: string;
address_line1: string;
address_line2: string;
city: string;
state: string;
postal_code: string;
country: string;
created_at: string;
updated_at: string;
};
export type CustomerProfile = {
preferred_contact_method: string;
emergency_contact_name: string;
emergency_contact_phone: string;
created_at: string;
updated_at: string;
};
export type User = {
id: number;
email: string;
first_name: string;
last_name: string;
phone_number: string;
is_vendor: boolean;
is_customer: boolean;
vendor_profile: VendorProfile | null;
customer_profile: CustomerProfile | null;
};
export type TokenPair = {
refresh: string;
access: string;
};
export type TokenRefreshResponse = {
access: string;
};
export type VendorRegisterRequest = {
email: string;
password: string;
first_name?: string;
last_name?: string;
phone_number?: string;
business_name: string;
description?: string;
contact_phone?: string;
contact_email?: string;
address_line1?: string;
city?: string;
state?: string;
postal_code?: string;
country?: string;
};
export type CustomerRegisterRequest = {
email: string;
password: string;
first_name?: string;
last_name?: string;
phone_number?: string;
};
export type LoginRequest = {
email: string;
password: string;
};
export type EquipmentCategory = {
id: number;
name: string;
slug: string;
description: string;
};
export type EquipmentImage = {
id: number;
image_url: string;
alt_text: string;
sort_order: number;
is_primary: boolean;
};
export type EquipmentItem = {
id: number;
public_id: string;
title: string;
description: string;
details: Record<string, unknown>;
location: string;
price_per_day: string;
is_active: boolean;
category: EquipmentCategory;
vendor_slug: string;
vendor_business_name: string;
images: EquipmentImage[];
created_at: string;
updated_at: string;
};
export type EquipmentItemDetail = EquipmentItem & {
marketing_click_id: number;
click_traffic_type: TrafficType;
};
export type AdventureCategory = EquipmentCategory;
export type AdventureOffering = {
id: number;
public_id: string;
title: string;
description: string;
meeting_point: string;
duration_minutes: number;
capacity: number;
price_per_person: string;
is_active: boolean;
category: AdventureCategory;
vendor_slug: string;
vendor_business_name: string;
images: EquipmentImage[];
created_at: string;
updated_at: string;
};
export type AdventureOfferingDetail = AdventureOffering & {
marketing_click_id: number;
click_traffic_type: TrafficType;
};
export type StorefrontVendor = {
business_name: string;
slug: string;
description: string;
contact_email: string;
contact_phone: string;
city: string;
country: string;
};
export type EquipmentStorefrontResponse = {
vendor: StorefrontVendor;
items: EquipmentItem[];
};
export type AdventureStorefrontResponse = {
vendor: StorefrontVendor;
offerings: AdventureOffering[];
};
export type BookingStatus =
| "requested"
| "approved"
| "declined"
| "cancelled"
| "confirmed";
export type BookingEvent = {
id: number;
from_status: string;
to_status: string;
note: string;
actor_email: string;
created_at: string;
};
export type Booking = {
id: number;
customer_email: string;
vendor_slug: string;
equipment_item: number | null;
equipment_public_id: string | null;
adventure_public_id: string | null;
starts_at: string;
ends_at: string;
status: BookingStatus;
total_price: string;
customer_notes: string;
vendor_notes: string;
events: BookingEvent[];
listing_click: number | null;
created_at: string;
updated_at: string;
};
export type AvailabilityResponse = {
equipment_item_id: number | null;
adventure_offering_id: number | null;
starts_at: string;
ends_at: string;
is_available: boolean;
conflicts: number;
};
export type MarketingSummary = {
from: string;
to: string;
clicks: {
organic: number;
marketing: number;
total: number;
};
bookings_attributed: {
organic: number;
marketing: number;
total_attributed: number;
unattributed: number;
total_all: number;
};
conversion_rate_click_to_booking: {
organic: number | null;
marketing: number | null;
};
campaigns: {
utm_source: string;
utm_medium: string;
utm_campaign: string;
clicks: number;
bookings: number;
conversion_rate: number | null;
}[];
};
export type ListingClick = {
id: number;
listing_type: string;
traffic_type: TrafficType;
utm_source: string;
utm_medium: string;
utm_campaign: string;
created_at: string;
};
export type ApiErrorBody = {
detail?: string;
[key: string]: unknown;
};
/** POST /equipment/vendor/items/ — see booking_backend api.md */
export type CreateVendorEquipmentRequest = {
public_id: string;
title: string;
description: string;
location: string;
price_per_day: string;
category_id: number;
details?: Record<string, unknown>;
is_active?: boolean;
};
/** POST /adventrues/vendor/offerings/ — see booking_backend api.md */
export type CreateVendorAdventureRequest = {
public_id: string;
title: string;
description: string;
meeting_point: string;
duration_minutes: number;
capacity: number;
price_per_person: string;
category_id: number;
is_active?: boolean;
};

103
src/auth/AuthContext.tsx Normal file
View File

@@ -0,0 +1,103 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { clearTokens, formatApiMessage, getAccessToken, setTokens } from "../api/client";
import * as api from "../api/services";
import type { User } from "../api/types";
type AuthState = {
user: User | null;
loading: boolean;
error: string | null;
refreshUser: () => Promise<void>;
signIn: (email: string, password: string) => Promise<User>;
signOut: () => void;
};
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refreshUser = useCallback(async () => {
if (!getAccessToken()) {
setUser(null);
return;
}
const me = await api.fetchMe();
setUser(me);
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
try {
if (!getAccessToken()) {
if (!cancelled) setUser(null);
return;
}
const me = await api.fetchMe();
if (!cancelled) setUser(me);
} catch {
if (!cancelled) {
clearTokens();
setUser(null);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
const signIn = useCallback(async (email: string, password: string) => {
setError(null);
try {
const tokens = await api.login({ email, password });
setTokens(tokens.access, tokens.refresh);
const me = await api.fetchMe();
setUser(me);
return me;
} catch (e) {
const msg = formatApiMessage(e);
setError(msg);
throw e;
}
}, []);
const signOut = useCallback(() => {
clearTokens();
setUser(null);
setError(null);
}, []);
const value = useMemo(
() => ({
user,
loading,
error,
refreshUser,
signIn,
signOut,
}),
[user, loading, error, refreshUser, signIn, signOut],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

View File

@@ -0,0 +1,28 @@
import type { ReactNode } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "./AuthContext";
export function RequireSignIn({ children }: { children: ReactNode }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) {
return (
<div className="auth-loading">
<p>Loading</p>
</div>
);
}
if (!user) {
return (
<Navigate
to="/auth/sign-in"
state={{ from: `${location.pathname}${location.search}` }}
replace
/>
);
}
return children;
}

View File

@@ -0,0 +1,31 @@
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "./AuthContext";
export function RequireVendor({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) {
return (
<div className="auth-loading">
<p>Loading</p>
</div>
);
}
if (!user) {
return (
<Navigate
to="/auth/sign-in"
state={{ from: `${location.pathname}${location.search}` }}
replace
/>
);
}
if (!user.is_vendor) {
return <Navigate to="/" replace />;
}
return children;
}

8
src/auth/returnPath.ts Normal file
View File

@@ -0,0 +1,8 @@
/** Safe in-app path for post-auth redirects (open redirects / auth loops). */
export function safeReturnPath(from: unknown): string | null {
if (typeof from !== "string" || !from.startsWith("/")) return null;
if (from.startsWith("//")) return null;
if (from.startsWith("/auth/")) return null;
if (from.startsWith("/dashboard")) return null;
return from;
}

View File

@@ -0,0 +1,84 @@
import { NavLink, Outlet } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
const navItems = [
{ to: "/dashboard", label: "Dashboard", end: true },
{ to: "/dashboard/bookings", label: "Bookings" },
{ to: "/dashboard/listings", label: "Listings" },
{ to: "/dashboard/analytics", label: "Analytics" },
{ to: "/dashboard/settings", label: "Settings" },
];
export function AdminLayout() {
const { user, signOut } = useAuth();
const business = user?.vendor_profile?.business_name ?? "Vendor";
const initial = (user?.first_name?.[0] || user?.email?.[0] || "V").toUpperCase();
return (
<div className="admin-shell">
<aside className="admin-sidebar" aria-label="Vendor navigation">
<div className="admin-sidebar-brand">
<span className="admin-sidebar-logo" aria-hidden />
<div>
<div className="admin-sidebar-title">WaterTrekk Admin</div>
<div className="admin-sidebar-sub">Vendor console</div>
</div>
</div>
<nav className="admin-sidebar-nav">
{navItems.map(({ to, label, end }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
`admin-nav-item${isActive ? " admin-nav-item--active" : ""}`
}
>
{label}
</NavLink>
))}
</nav>
<div className="admin-sidebar-footer">
<NavLink to="/" className="admin-back-link">
Back to site
</NavLink>
</div>
</aside>
<div className="admin-main-wrap">
<header className="admin-topbar">
<div className="admin-topbar-search-wrap">
<label className="visually-hidden" htmlFor="admin-search">
Search
</label>
<input
id="admin-search"
type="search"
className="admin-topbar-search"
placeholder="Search bookings, guests…"
/>
</div>
<div className="admin-topbar-actions">
<button type="button" className="admin-icon-btn" aria-label="Notifications">
<span className="admin-bell" aria-hidden />
</button>
<div className="admin-user-chip">
<span className="admin-user-avatar" aria-hidden>
{initial}
</span>
<div className="admin-user-meta">
<span className="admin-user-name">{business}</span>
<span className="admin-user-role">{user?.email}</span>
</div>
</div>
<button type="button" className="admin-btn admin-btn--ghost admin-sign-out" onClick={() => signOut()}>
Sign out
</button>
</div>
</header>
<main className="admin-content">
<Outlet />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { formatApiMessage } from "../api/client";
import * as api from "../api/services";
import type { AdventureOffering, EquipmentItem } from "../api/types";
export function CatalogStrip() {
const [equipment, setEquipment] = useState<EquipmentItem[]>([]);
const [tours, setTours] = useState<AdventureOffering[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [eq, adv] = await Promise.all([api.listEquipmentItems(), api.listAdventureOfferings()]);
if (!cancelled) {
setEquipment(eq.slice(0, 6));
setTours(adv.slice(0, 6));
}
} catch (e) {
if (!cancelled) setError(formatApiMessage(e));
}
})();
return () => {
cancelled = true;
};
}, []);
if (error) {
return (
<section className="home-section" aria-labelledby="catalog-live-title">
<h2 id="catalog-live-title" className="home-section-title">
From the live catalog
</h2>
<p className="home-section-lede muted">Could not load listings: {error}</p>
</section>
);
}
if (equipment.length === 0 && tours.length === 0) {
return null;
}
return (
<section className="home-section" aria-labelledby="catalog-live-title">
<div className="home-section-head">
<h2 id="catalog-live-title" className="home-section-title-lg">
From the live catalog
</h2>
<span className="home-section-tag">API</span>
</div>
{equipment.length > 0 && (
<>
<h3 className="catalog-strip-sub">Equipment rentals</h3>
<div className="catalog-strip-grid">
{equipment.map((item) => {
const img =
item.images.find((i) => i.is_primary)?.image_url ?? item.images[0]?.image_url;
return (
<Link key={item.id} to={`/boats/item/${item.public_id}`} className="catalog-strip-card">
<div
className="catalog-strip-image"
style={
img
? { backgroundImage: `url(${img})` }
: { background: "linear-gradient(135deg, #e0f2fe, #bae6fd)" }
}
/>
<div className="catalog-strip-body">
<p className="catalog-strip-vendor">{item.vendor_business_name}</p>
<p className="catalog-strip-title">{item.title}</p>
<p className="catalog-strip-meta">{item.location}</p>
<p className="catalog-strip-price">${item.price_per_day} / day</p>
</div>
</Link>
);
})}
</div>
</>
)}
{tours.length > 0 && (
<>
<h3 className="catalog-strip-sub">Tours & adventures</h3>
<div className="catalog-strip-grid">
{tours.map((o) => {
const img =
o.images.find((i) => i.is_primary)?.image_url ?? o.images[0]?.image_url;
return (
<Link key={o.id} to={`/tours/${o.public_id}`} className="catalog-strip-card">
<div
className="catalog-strip-image"
style={
img
? { backgroundImage: `url(${img})` }
: { background: "linear-gradient(135deg, #fef3c7, #fde68a)" }
}
/>
<div className="catalog-strip-body">
<p className="catalog-strip-vendor">{o.vendor_business_name}</p>
<p className="catalog-strip-title">{o.title}</p>
<p className="catalog-strip-meta">{o.meeting_point}</p>
<p className="catalog-strip-price">${o.price_per_person} / person</p>
</div>
</Link>
);
})}
</div>
</>
)}
</section>
);
}

57
src/components/Layout.tsx Normal file
View File

@@ -0,0 +1,57 @@
import { NavLink, Outlet, useLocation } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
export function Layout() {
const { pathname } = useLocation();
const { user, signOut } = useAuth();
const fullBleed =
pathname === "/" ||
pathname === "/boats" ||
pathname.startsWith("/boats/search") ||
pathname.startsWith("/boats/item/") ||
pathname.startsWith("/tours/");
const mainClass = fullBleed ? "site-main site-main--full" : "site-main";
return (
<div className="app-shell">
<header className="site-header">
<NavLink to="/" className="brand" end>
WaterTrekk
</NavLink>
<nav className="site-nav" aria-label="Main">
<NavLink to="/boats" className="nav-link">
Boat rentals
</NavLink>
<NavLink to="/tours" className="nav-link">
Tours
</NavLink>
<NavLink to="/vendors" className="nav-link nav-link--vendor">
For vendors
</NavLink>
{user?.is_vendor ? (
<NavLink to="/dashboard" className="nav-link">
Dashboard
</NavLink>
) : null}
{!user ? (
<>
<NavLink to="/auth/sign-in" className="nav-link">
Sign in
</NavLink>
<NavLink to="/auth/register" className="nav-link">
Register
</NavLink>
</>
) : (
<button type="button" className="nav-link nav-link--btn" onClick={() => signOut()}>
Sign out
</button>
)}
</nav>
</header>
<main className={mainClass}>
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,417 @@
import { useEffect, useState } from "react";
import { formatApiMessage } from "../api/client";
import * as api from "../api/services";
import type { EquipmentCategory } from "../api/types";
type ListingKind = "rental" | "tour";
type Props = {
onCreated: () => void;
};
export function VendorCreateListingPanel({ onCreated }: Props) {
const [kind, setKind] = useState<ListingKind>("rental");
const [rentalPublicId, setRentalPublicId] = useState("");
const [rentalTitle, setRentalTitle] = useState("");
const [rentalDescription, setRentalDescription] = useState("");
const [rentalLocation, setRentalLocation] = useState("");
const [rentalPrice, setRentalPrice] = useState("");
const [rentalCategoryId, setRentalCategoryId] = useState("");
const [equipmentCategories, setEquipmentCategories] = useState<EquipmentCategory[]>([]);
const [equipmentCategoriesLoading, setEquipmentCategoriesLoading] = useState(true);
const [equipmentCategoriesError, setEquipmentCategoriesError] = useState<string | null>(null);
const [tourPublicId, setTourPublicId] = useState("");
const [tourTitle, setTourTitle] = useState("");
const [tourDescription, setTourDescription] = useState("");
const [tourMeeting, setTourMeeting] = useState("");
const [tourDuration, setTourDuration] = useState("");
const [tourCapacity, setTourCapacity] = useState("");
const [tourPrice, setTourPrice] = useState("");
const [tourCategoryId, setTourCategoryId] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const rows = await api.listEquipmentCategories();
if (!cancelled) {
setEquipmentCategories(rows);
setEquipmentCategoriesError(null);
}
} catch (e) {
if (!cancelled) setEquipmentCategoriesError(formatApiMessage(e));
} finally {
if (!cancelled) setEquipmentCategoriesLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
function resetRentalFields() {
setRentalPublicId("");
setRentalTitle("");
setRentalDescription("");
setRentalLocation("");
setRentalPrice("");
setRentalCategoryId("");
}
function resetTourFields() {
setTourPublicId("");
setTourTitle("");
setTourDescription("");
setTourMeeting("");
setTourDuration("");
setTourCapacity("");
setTourPrice("");
setTourCategoryId("");
}
async function submitRental(e: React.FormEvent) {
e.preventDefault();
setError(null);
setSuccess(null);
const publicId = rentalPublicId.trim();
if (!publicId) {
setError("Enter a public ID (URL slug for this listing).");
return;
}
const categoryId = Number.parseInt(rentalCategoryId, 10);
if (Number.isNaN(categoryId) || categoryId < 1) {
setError("Select a category.");
return;
}
const price = rentalPrice.trim();
if (!price) {
setError("Enter a price per day.");
return;
}
setSubmitting(true);
try {
await api.createVendorEquipment({
public_id: publicId,
title: rentalTitle.trim(),
description: rentalDescription.trim(),
location: rentalLocation.trim(),
price_per_day: price,
category_id: categoryId,
details: {},
is_active: true,
});
setSuccess("Rental listing created.");
resetRentalFields();
onCreated();
} catch (err) {
setError(formatApiMessage(err));
} finally {
setSubmitting(false);
}
}
async function submitTour(e: React.FormEvent) {
e.preventDefault();
setError(null);
setSuccess(null);
const publicId = tourPublicId.trim();
if (!publicId) {
setError("Enter a public ID (URL slug for this listing).");
return;
}
const categoryId = Number.parseInt(tourCategoryId, 10);
if (Number.isNaN(categoryId) || categoryId < 1) {
setError("Enter a valid category ID (positive integer).");
return;
}
const duration = Number.parseInt(tourDuration, 10);
const capacity = Number.parseInt(tourCapacity, 10);
if (Number.isNaN(duration) || duration < 1) {
setError("Duration must be a positive number of minutes.");
return;
}
if (Number.isNaN(capacity) || capacity < 1) {
setError("Capacity must be at least 1 guest.");
return;
}
const price = tourPrice.trim();
if (!price) {
setError("Enter a price per person.");
return;
}
setSubmitting(true);
try {
await api.createVendorAdventure({
public_id: publicId,
title: tourTitle.trim(),
description: tourDescription.trim(),
meeting_point: tourMeeting.trim(),
duration_minutes: duration,
capacity,
price_per_person: price,
category_id: categoryId,
is_active: true,
});
setSuccess("Tour listing created.");
resetTourFields();
onCreated();
} catch (err) {
setError(formatApiMessage(err));
} finally {
setSubmitting(false);
}
}
return (
<section className="admin-panel admin-panel--wide vendor-create-listing">
<div className="admin-panel-head">
<h2>Add listing</h2>
</div>
<p className="muted vendor-create-listing-note">
Rentals use equipment categories from the catalog API. For guided tours, enter the numeric adventure{" "}
<code>category_id</code> (no public category list is documented for adventures yet).
</p>
<div className="vendor-create-listing-toggle" role="tablist" aria-label="Listing type">
<button
type="button"
role="tab"
aria-selected={kind === "rental"}
className={`admin-btn${kind === "rental" ? " admin-btn--primary" : ""}`}
onClick={() => {
setKind("rental");
setError(null);
setSuccess(null);
}}
>
Boat / equipment rental
</button>
<button
type="button"
role="tab"
aria-selected={kind === "tour"}
className={`admin-btn${kind === "tour" ? " admin-btn--primary" : ""}`}
onClick={() => {
setKind("tour");
setError(null);
setSuccess(null);
}}
>
Guided tour
</button>
</div>
{kind === "rental" ? (
<form className="vendor-settings-form vendor-create-listing-form" onSubmit={submitRental}>
<label className="auth-label auth-label--full">
Public ID
<input
className="auth-input"
value={rentalPublicId}
onChange={(e) => setRentalPublicId(e.target.value)}
required
maxLength={120}
placeholder="e.g. jetski-001"
autoComplete="off"
/>
</label>
<label className="auth-label auth-label--full">
Title
<input
className="auth-input"
value={rentalTitle}
onChange={(e) => setRentalTitle(e.target.value)}
required
maxLength={200}
/>
</label>
<label className="auth-label auth-label--full">
Description
<textarea
className="auth-input auth-textarea"
rows={4}
value={rentalDescription}
onChange={(e) => setRentalDescription(e.target.value)}
required
/>
</label>
<label className="auth-label">
Location
<input
className="auth-input"
value={rentalLocation}
onChange={(e) => setRentalLocation(e.target.value)}
required
/>
</label>
<label className="auth-label">
Price / day (USD)
<input
className="auth-input"
type="text"
inputMode="decimal"
value={rentalPrice}
onChange={(e) => setRentalPrice(e.target.value)}
placeholder="e.g. 249.00"
required
/>
</label>
<label className="auth-label auth-label--full">
Category
{equipmentCategoriesLoading ? (
<select className="auth-input" disabled value="">
<option value="">Loading categories</option>
</select>
) : equipmentCategories.length > 0 ? (
<select
className="auth-input"
required
value={rentalCategoryId}
onChange={(e) => setRentalCategoryId(e.target.value)}
>
<option value="">Select a category</option>
{equipmentCategories.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name}
</option>
))}
</select>
) : (
<>
<input
className="auth-input"
type="number"
min={1}
step={1}
required
value={rentalCategoryId}
onChange={(e) => setRentalCategoryId(e.target.value)}
placeholder="Category ID"
/>
{equipmentCategoriesError ? (
<p className="muted vendor-create-listing-category-fallback">{equipmentCategoriesError}</p>
) : (
<p className="muted vendor-create-listing-category-fallback">
No equipment categories returned create categories in the backend admin, or enter an existing
category ID.
</p>
)}
</>
)}
</label>
{error && <p className="auth-error auth-label--full">{error}</p>}
{success && <p className="auth-success auth-label--full">{success}</p>}
<button
type="submit"
className="admin-btn admin-btn--primary"
disabled={submitting || equipmentCategoriesLoading}
>
{submitting ? "Creating…" : "Create rental"}
</button>
</form>
) : (
<form className="vendor-settings-form vendor-create-listing-form" onSubmit={submitTour}>
<label className="auth-label auth-label--full">
Public ID
<input
className="auth-input"
value={tourPublicId}
onChange={(e) => setTourPublicId(e.target.value)}
required
maxLength={120}
placeholder="e.g. sunset-kayak-001"
autoComplete="off"
/>
</label>
<label className="auth-label auth-label--full">
Title
<input
className="auth-input"
value={tourTitle}
onChange={(e) => setTourTitle(e.target.value)}
required
maxLength={200}
/>
</label>
<label className="auth-label auth-label--full">
Description
<textarea
className="auth-input auth-textarea"
rows={4}
value={tourDescription}
onChange={(e) => setTourDescription(e.target.value)}
required
/>
</label>
<label className="auth-label auth-label--full">
Meeting point
<input
className="auth-input"
value={tourMeeting}
onChange={(e) => setTourMeeting(e.target.value)}
required
/>
</label>
<label className="auth-label">
Duration (minutes)
<input
className="auth-input"
type="number"
min={1}
step={1}
value={tourDuration}
onChange={(e) => setTourDuration(e.target.value)}
required
/>
</label>
<label className="auth-label">
Max guests
<input
className="auth-input"
type="number"
min={1}
step={1}
value={tourCapacity}
onChange={(e) => setTourCapacity(e.target.value)}
required
/>
</label>
<label className="auth-label">
Price / person (USD)
<input
className="auth-input"
type="text"
inputMode="decimal"
value={tourPrice}
onChange={(e) => setTourPrice(e.target.value)}
placeholder="e.g. 89.00"
required
/>
</label>
<label className="auth-label">
Category ID
<input
className="auth-input"
type="number"
min={1}
step={1}
required
value={tourCategoryId}
onChange={(e) => setTourCategoryId(e.target.value)}
/>
</label>
{error && <p className="auth-error auth-label--full">{error}</p>}
{success && <p className="auth-success auth-label--full">{success}</p>}
<button type="submit" className="admin-btn admin-btn--primary" disabled={submitting}>
{submitting ? "Creating…" : "Create tour"}
</button>
</form>
)}
</section>
);
}

2419
src/index.css Normal file

File diff suppressed because it is too large Load Diff

16
src/main.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { AuthProvider } from "./auth/AuthContext";
import "./index.css";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</StrictMode>,
);

View File

@@ -0,0 +1,72 @@
import type { NavigateFunction } from "react-router-dom";
/** Query keys forwarded to public listing detail GETs for Django `extract_attribution_from_request`. */
export const ATTRIBUTION_QUERY_KEYS = [
"utm_source",
"utm_medium",
"utm_campaign",
"utm_term",
"utm_content",
"gclid",
"fbclid",
] as const;
export type ListingKind = "equipment" | "adventure";
const STORAGE_PREFIX = "watertrek_marketing_click";
function storageKey(kind: ListingKind, publicId: string): string {
return `${STORAGE_PREFIX}:${kind}:${publicId}`;
}
export function attributionParamsFromSearch(search: string): Record<string, string> {
const sp = new URLSearchParams(search);
const out: Record<string, string> = {};
for (const key of ATTRIBUTION_QUERY_KEYS) {
const v = sp.get(key);
if (v != null && v !== "") out[key] = v;
}
return out;
}
export function hasAttributionParams(params: Record<string, string>): boolean {
return Object.keys(params).length > 0;
}
export function persistMarketingClickId(kind: ListingKind, publicId: string, id: number): void {
try {
sessionStorage.setItem(storageKey(kind, publicId), String(id));
} catch {
// ignore quota / private mode
}
}
export function readMarketingClickId(kind: ListingKind, publicId: string): number | null {
try {
const raw = sessionStorage.getItem(storageKey(kind, publicId));
if (raw == null) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
} catch {
return null;
}
}
/** Remove attribution keys from the URL without losing other query params (replace, no new history entry). */
export function stripAttributionFromUrl(
navigate: NavigateFunction,
pathname: string,
search: string,
): void {
const sp = new URLSearchParams(search);
let changed = false;
for (const key of ATTRIBUTION_QUERY_KEYS) {
if (sp.has(key)) {
sp.delete(key);
changed = true;
}
}
if (!changed) return;
const next = sp.toString();
navigate({ pathname, search: next ? `?${next}` : "" }, { replace: true });
}

View File

@@ -0,0 +1,97 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { formatApiMessage } from "../api/client";
import * as api from "../api/services";
import { useAuth } from "../auth/AuthContext";
import {
attributionParamsFromSearch,
hasAttributionParams,
persistMarketingClickId,
readMarketingClickId,
stripAttributionFromUrl,
} from "../marketing/attribution";
import type { AdventureOfferingDetail } from "../api/types";
export function AdventureBookPage() {
const { publicId } = useParams<{ publicId: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const [offering, setOffering] = useState<AdventureOfferingDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!publicId) return;
const pathnameAtStart = window.location.pathname;
const searchAtStart = window.location.search;
const attribution = attributionParamsFromSearch(searchAtStart);
let cancelled = false;
(async () => {
try {
const data = await api.getAdventureOffering(
publicId,
hasAttributionParams(attribution) ? attribution : undefined,
);
if (!cancelled) {
setOffering(data);
if (hasAttributionParams(attribution)) {
persistMarketingClickId("adventure", publicId, data.marketing_click_id);
stripAttributionFromUrl(navigate, pathnameAtStart, searchAtStart);
}
}
} catch (e) {
if (!cancelled) setError(formatApiMessage(e));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [publicId, navigate]);
if (loading) {
return (
<div className="page">
<p className="muted">Loading</p>
</div>
);
}
if (error || !offering) {
return (
<div className="page">
<p className="lede">{error ?? "Tour not found."}</p>
<Link to="/tours" className="text-link">
Back to tours
</Link>
</div>
);
}
const marketingClickIdForConversion =
readMarketingClickId("adventure", offering.public_id) ?? offering.marketing_click_id;
return (
<div className="page booking-request-page">
<Link to={`/tours/${offering.public_id}`} className="text-link">
Back to tour
</Link>
<h1>Request this tour</h1>
<p className="lede">
You are signed in as <strong>{user?.email}</strong>. Confirm details below; a full booking form can submit to
the WaterTrekk booking API next.
</p>
<article className="booking-request-summary">
<h2>{offering.title}</h2>
<p className="muted">
{offering.vendor_business_name} · {offering.meeting_point} · ${offering.price_per_person} / person
</p>
<p className="muted">
<code>marketing_click_id</code> {marketingClickIdForConversion}{" "}
<span className="muted">(send this on the booking request for attribution)</span>
</p>
</article>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { formatApiMessage } from "../api/client";
import * as api from "../api/services";
import {
attributionParamsFromSearch,
hasAttributionParams,
persistMarketingClickId,
stripAttributionFromUrl,
} from "../marketing/attribution";
import type { AdventureOfferingDetail } from "../api/types";
export function AdventureDetailPage() {
const { publicId } = useParams<{ publicId: string }>();
const navigate = useNavigate();
const { user, loading: authLoading } = useAuth();
const [offering, setOffering] = useState<AdventureOfferingDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!publicId) return;
const pathnameAtStart = window.location.pathname;
const searchAtStart = window.location.search;
const attribution = attributionParamsFromSearch(searchAtStart);
let cancelled = false;
(async () => {
try {
const data = await api.getAdventureOffering(
publicId,
hasAttributionParams(attribution) ? attribution : undefined,
);
if (!cancelled) {
setOffering(data);
persistMarketingClickId("adventure", publicId, data.marketing_click_id);
if (hasAttributionParams(attribution)) {
stripAttributionFromUrl(navigate, pathnameAtStart, searchAtStart);
}
}
} catch (e) {
if (!cancelled) setError(formatApiMessage(e));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [publicId, navigate]);
if (loading) {
return (
<div className="page">
<p className="muted">Loading tour</p>
</div>
);
}
if (error || !offering) {
return (
<div className="page">
<p className="lede">{error ?? "Offering not found."}</p>
<Link to="/tours" className="text-link">
Back to tours
</Link>
</div>
);
}
const img =
offering.images.find((i) => i.is_primary)?.image_url ?? offering.images[0]?.image_url;
return (
<div className="listing-detail">
<Link to="/tours" className="listing-detail-back">
Tours
</Link>
<div className="listing-detail-grid">
<div
className="listing-detail-image"
style={img ? { backgroundImage: `url(${img})` } : undefined}
/>
<div>
<p className="listing-detail-kicker">{offering.vendor_business_name}</p>
<h1 className="listing-detail-title">{offering.title}</h1>
<p className="listing-detail-meta">
Meet at {offering.meeting_point} · Up to {offering.capacity} guests · {offering.duration_minutes} minutes
</p>
<p className="listing-detail-price">${offering.price_per_person} / person</p>
<p className="listing-detail-body">{offering.description}</p>
<div className="listing-detail-actions">
{authLoading ? (
<span className="listing-detail-book-btn listing-detail-book-btn--pending">Loading</span>
) : (
<Link to={`/tours/${offering.public_id}/book`} className="listing-detail-book-btn">
{user ? "Request to book" : "Sign in to book"}
</Link>
)}
{!authLoading && !user && (
<p className="listing-detail-book-hint muted">
You can browse without an account. Sign in (or create one) when you are ready to request this tour.
</p>
)}
</div>
<p className="muted">
<code>marketing_click_id</code> {offering.marketing_click_id} ({offering.click_traffic_type})
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,129 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { formatApiMessage } from "../api/client";
import * as api from "../api/services";
import type { EquipmentItem } from "../api/types";
const highlights = [
{
title: "Licensed & insured",
text: "Every listing is verified so you can book with confidence.",
},
{
title: "Flexible dates",
text: "Half-day, full-day, and multi-day rentals depending on the vessel.",
},
{
title: "Local docks",
text: "Pick up from marinas and piers across the region.",
},
];
export function BoatRentalsPage() {
const [featured, setFeatured] = useState<EquipmentItem[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const items = await api.listEquipmentItems();
if (!cancelled) setFeatured(items.slice(0, 12));
} catch (e) {
if (!cancelled) setError(formatApiMessage(e));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
return (
<div className="boat-landing">
<section className="boat-landing-hero">
<p className="boat-landing-kicker">Boat rentals</p>
<h1 className="boat-landing-title">Your day on the water starts here</h1>
<p className="boat-landing-lede">
Browse live equipment from WaterTrekk vendors. Filter on the map search page by location and price.
</p>
<div className="boat-landing-actions">
<Link to="/boats/search" className="boat-landing-btn boat-landing-btn--primary">
Search & filter
</Link>
<Link to="/tours" className="boat-landing-btn boat-landing-btn--ghost">
Prefer a guided tour?
</Link>
</div>
</section>
<section className="boat-landing-section">
<h2 className="boat-landing-h2">Why rent with WaterTrekk</h2>
<ul className="boat-landing-highlights">
{highlights.map((h) => (
<li key={h.title} className="boat-landing-highlight">
<h3>{h.title}</h3>
<p>{h.text}</p>
</li>
))}
</ul>
</section>
<section className="boat-landing-section boat-landing-section--featured">
<div className="boat-landing-section-head">
<h2 className="boat-landing-h2">From the API catalog</h2>
<Link to="/boats/search" className="boat-landing-link-all">
Open search
</Link>
</div>
{loading && <p className="muted">Loading listings</p>}
{error && <p className="muted">{error}</p>}
<div className="boat-landing-grid">
{featured.map((f) => {
const img =
f.images.find((i) => i.is_primary)?.image_url ?? f.images[0]?.image_url;
return (
<article key={f.id} className="boat-landing-card">
<div
className="boat-landing-card-image"
style={
img
? { backgroundImage: `url(${img})` }
: { background: "linear-gradient(135deg, #e0f2fe, #7dd3fc)" }
}
/>
<div className="boat-landing-card-body">
<h3 className="boat-landing-card-title">{f.title}</h3>
<p className="boat-landing-card-tag">{f.category.name}</p>
<p className="boat-landing-card-price">
From ${f.price_per_day} / day · {f.location}
</p>
<Link to={`/boats/item/${f.public_id}`} className="boat-landing-card-cta">
View listing
</Link>
</div>
</article>
);
})}
</div>
{!loading && !error && featured.length === 0 && (
<p className="muted">No equipment published yet. Check back soon.</p>
)}
</section>
<section className="boat-landing-cta-banner">
<div className="boat-landing-cta-inner">
<h2 className="boat-landing-cta-title">Ready to pick a dock?</h2>
<p className="boat-landing-cta-text">
Use search to filter the live catalog by location and price, then open a listing for full detail.
</p>
<Link to="/boats/search" className="boat-landing-btn boat-landing-btn--on-dark">
Go to search
</Link>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,158 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { formatApiMessage } from "../api/client";
import * as api from "../api/services";
import type { EquipmentItem } from "../api/types";
export function BoatSearchPage() {
const [items, setItems] = useState<EquipmentItem[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [locationQ, setLocationQ] = useState("");
const [minPrice, setMinPrice] = useState("");
useEffect(() => {
let cancelled = false;
(async () => {
try {
const data = await api.listEquipmentItems();
if (!cancelled) setItems(data);
} catch (e) {
if (!cancelled) setError(formatApiMessage(e));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
const filtered = useMemo(() => {
const q = locationQ.trim().toLowerCase();
const min = minPrice ? Number.parseFloat(minPrice) : null;
return items.filter((b) => {
if (q && !b.location.toLowerCase().includes(q)) return false;
if (min != null && !Number.isNaN(min) && Number.parseFloat(b.price_per_day) < min) return false;
return true;
});
}, [items, locationQ, minPrice]);
return (
<div className="boat-search-page">
<header className="boat-search-top">
<Link to="/boats" className="boat-search-back">
Boat rentals
</Link>
<h1 className="boat-search-title">Find a boat</h1>
<p className="boat-search-sub">Live equipment from GET /api/v1/equipment/items/</p>
</header>
<div className="boat-search-body">
<aside className="boat-filter-panel" aria-label="Filters">
<h2 className="boat-filter-heading">Filters</h2>
<div className="boat-filter-fieldset boat-filter-fieldset--solo">
<label className="boat-filter-legend" htmlFor="boat-loc">
Location contains
</label>
<p className="boat-filter-hint">Matches the listing location field (case insensitive).</p>
<input
id="boat-loc"
type="search"
className="boat-filter-select"
value={locationQ}
onChange={(e) => setLocationQ(e.target.value)}
placeholder="e.g. Miami"
/>
</div>
<div className="boat-filter-fieldset boat-filter-fieldset--solo">
<label className="boat-filter-legend" htmlFor="boat-minp">
Minimum price / day
</label>
<input
id="boat-minp"
type="number"
min={0}
step={1}
className="boat-filter-select"
value={minPrice}
onChange={(e) => setMinPrice(e.target.value)}
placeholder="Any"
/>
</div>
<p className="boat-filter-summary">
{loading
? "Loading…"
: `${filtered.length} listing${filtered.length === 1 ? "" : "s"} match your filters.`}
</p>
<button
type="button"
className="boat-filter-reset"
onClick={() => {
setLocationQ("");
setMinPrice("");
}}
>
Reset filters
</button>
</aside>
<div className="boat-search-main">
<div className="boat-map" aria-label="Map preview">
<div className="boat-map-inner">
<span className="boat-map-pin boat-map-pin--1" title="North" />
<span className="boat-map-pin boat-map-pin--2" title="East" />
<span className="boat-map-pin boat-map-pin--3" title="South" />
<span className="boat-map-pin boat-map-pin--4" title="West" />
</div>
<p className="boat-map-caption">Map preview wire geocoded pins when backend exposes coordinates</p>
</div>
<section className="boat-results" aria-label="Search results">
<h2 className="boat-results-title">Catalog</h2>
{error && <p className="boat-results-empty">{error}</p>}
<div className="boat-results-grid">
{filtered.map((b) => {
const img =
b.images.find((i) => i.is_primary)?.image_url ?? b.images[0]?.image_url;
return (
<article key={b.id} className="boat-result-card">
<div
className="boat-result-image"
style={
img
? { backgroundImage: `url(${img})` }
: { background: "linear-gradient(135deg, #e0f2fe, #38bdf8)" }
}
/>
<div className="boat-result-body">
<h3 className="boat-result-name">{b.title}</h3>
<p className="boat-result-meta">
<span>{b.category.name}</span>
<span>·</span>
<span>{b.location}</span>
<span>·</span>
<span>{b.vendor_business_name}</span>
</p>
<p className="boat-result-price">${b.price_per_day} / day</p>
<Link to={`/boats/item/${b.public_id}`} className="boat-result-cta">
View listing
</Link>
</div>
</article>
);
})}
</div>
{!loading && !error && filtered.length === 0 && (
<p className="boat-results-empty">No listings match try clearing filters.</p>
)}
</section>
</div>
</div>
</div>
);
}

204
src/pages/DashboardPage.tsx Normal file
View File

@@ -0,0 +1,204 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { formatApiMessage } from "../api/client";
import * as api from "../api/services";
import type { Booking, BookingStatus, MarketingSummary } from "../api/types";
function rangeUtc(days: number): { from: string; to: string } {
const to = new Date();
const from = new Date(to);
from.setUTCDate(from.getUTCDate() - days);
return { from: from.toISOString(), to: to.toISOString() };
}
function inRange(iso: string, fromIso: string, toIso: string): boolean {
const t = new Date(iso).getTime();
return t >= new Date(fromIso).getTime() && t < new Date(toIso).getTime();
}
function badgeClass(status: BookingStatus): string {
return `admin-badge admin-badge--${status}`;
}
function formatWhen(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" });
}
function listingLabel(b: Booking): string {
if (b.equipment_public_id) return b.equipment_public_id;
if (b.adventure_public_id) return b.adventure_public_id;
return "—";
}
export function DashboardPage() {
const { from, to } = useMemo(() => rangeUtc(30), []);
const [summary, setSummary] = useState<MarketingSummary | null>(null);
const [bookings, setBookings] = useState<Booking[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [sum, bookList] = await Promise.all([
api.fetchVendorMarketingSummary(from, to),
api.listBookings(),
]);
if (!cancelled) {
setSummary(sum);
setBookings(bookList);
}
} catch (e) {
if (!cancelled) setError(formatApiMessage(e));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [from, to]);
const windowBookings = useMemo(
() => bookings.filter((b) => inRange(b.created_at, from, to)),
[bookings, from, to],
);
const revenue30 = useMemo(() => {
return windowBookings.reduce((acc, b) => acc + Number.parseFloat(b.total_price || "0"), 0);
}, [windowBookings]);
const pending = useMemo(() => bookings.filter((b) => b.status === "requested").length, [bookings]);
const recent = useMemo(() => {
return [...bookings]
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, 8);
}, [bookings]);
if (loading) {
return (
<div className="admin-page">
<p className="muted">Loading dashboard</p>
</div>
);
}
if (error) {
return (
<div className="admin-page">
<h1 className="admin-page-title">Dashboard</h1>
<p className="admin-page-sub">{error}</p>
</div>
);
}
const clicksTotal = summary?.clicks.total ?? 0;
const bookingsTotal = summary?.bookings_attributed.total_all ?? windowBookings.length;
return (
<div className="admin-page">
<div className="admin-page-head">
<div>
<h1 className="admin-page-title">Dashboard</h1>
<p className="admin-page-sub">Live data from WaterTrekk (bookings + marketing, last 30 days UTC)</p>
</div>
<Link to="/dashboard/analytics" className="admin-btn admin-btn--primary">
Full analytics
</Link>
</div>
<div className="admin-stats">
<article className="admin-stat-card">
<p className="admin-stat-label">Revenue (30d)</p>
<p className="admin-stat-value">
{revenue30.toLocaleString(undefined, { style: "currency", currency: "USD" })}
</p>
<p className="admin-stat-delta admin-stat-delta--up">{windowBookings.length} bookings in window</p>
</article>
<article className="admin-stat-card">
<p className="admin-stat-label">Listing clicks (30d)</p>
<p className="admin-stat-value">{clicksTotal}</p>
<p className="admin-stat-delta">
{summary?.clicks.organic ?? 0} organic · {summary?.clicks.marketing ?? 0} marketing
</p>
</article>
<article className="admin-stat-card">
<p className="admin-stat-label">Bookings (30d)</p>
<p className="admin-stat-value">{bookingsTotal}</p>
<p className="admin-stat-delta">{summary?.bookings_attributed.unattributed ?? 0} unattributed</p>
</article>
<article className="admin-stat-card">
<p className="admin-stat-label">Pending requests</p>
<p className="admin-stat-value">{pending}</p>
<p className="admin-stat-delta">Across all time</p>
</article>
</div>
<div className="admin-panels">
<section className="admin-panel admin-panel--wide">
<div className="admin-panel-head">
<h2>Recent bookings</h2>
<Link to="/dashboard/bookings" className="admin-btn admin-btn--ghost">
View all
</Link>
</div>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>ID</th>
<th>Guest</th>
<th>Listing</th>
<th>Starts</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{recent.map((row) => (
<tr key={row.id}>
<td className="admin-table-mono">{row.id}</td>
<td>{row.customer_email}</td>
<td>{listingLabel(row)}</td>
<td>{formatWhen(row.starts_at)}</td>
<td>
<span className={badgeClass(row.status)}>{row.status}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{recent.length === 0 && <p className="muted admin-panel-pad">No bookings yet.</p>}
</section>
<section className="admin-panel">
<div className="admin-panel-head">
<h2>Attribution snapshot</h2>
</div>
<ul className="admin-todo-list">
<li>
<span className="admin-todo-dot" />
Organic bookings: {summary?.bookings_attributed.organic ?? 0}
</li>
<li>
<span className="admin-todo-dot" />
Marketing bookings: {summary?.bookings_attributed.marketing ?? 0}
</li>
<li>
<span className="admin-todo-dot admin-todo-dot--muted" />
Send <code>marketing_click_id</code> on checkout to reduce unattributed totals.
</li>
</ul>
<p className="admin-mini-chart-caption" style={{ marginTop: "1rem" }}>
Conversion (organic):{" "}
{summary?.conversion_rate_click_to_booking.organic == null
? "—"
: `${(summary.conversion_rate_click_to_booking.organic * 100).toFixed(2)}%`}
</p>
</section>
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { formatApiMessage } from "../api/client";
import * as api from "../api/services";
import { useAuth } from "../auth/AuthContext";
import {
attributionParamsFromSearch,
hasAttributionParams,
persistMarketingClickId,
readMarketingClickId,
stripAttributionFromUrl,
} from "../marketing/attribution";
import type { EquipmentItemDetail } from "../api/types";
export function EquipmentBookPage() {
const { publicId } = useParams<{ publicId: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const [item, setItem] = useState<EquipmentItemDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!publicId) return;
const pathnameAtStart = window.location.pathname;
const searchAtStart = window.location.search;
const attribution = attributionParamsFromSearch(searchAtStart);
let cancelled = false;
(async () => {
try {
const data = await api.getEquipmentItem(
publicId,
hasAttributionParams(attribution) ? attribution : undefined,
);
if (!cancelled) {
setItem(data);
if (hasAttributionParams(attribution)) {
persistMarketingClickId("equipment", publicId, data.marketing_click_id);
stripAttributionFromUrl(navigate, pathnameAtStart, searchAtStart);
}
}
} catch (e) {
if (!cancelled) setError(formatApiMessage(e));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [publicId, navigate]);
if (loading) {
return (
<div className="page">
<p className="muted">Loading</p>
</div>
);
}
if (error || !item) {
return (
<div className="page">
<p className="lede">{error ?? "Listing not found."}</p>
<Link to="/boats" className="text-link">
Back to boat rentals
</Link>
</div>
);
}
const marketingClickIdForConversion =
readMarketingClickId("equipment", item.public_id) ?? item.marketing_click_id;
return (
<div className="page booking-request-page">
<Link to={`/boats/item/${item.public_id}`} className="text-link">
Back to listing
</Link>
<h1>Request this rental</h1>
<p className="lede">
You are signed in as <strong>{user?.email}</strong>. Confirm details below; a full booking form can submit to
the WaterTrekk booking API next.
</p>
<article className="booking-request-summary">
<h2>{item.title}</h2>
<p className="muted">
{item.vendor_business_name} · {item.location} · ${item.price_per_day} / day
</p>
<p className="muted">
<code>marketing_click_id</code> {marketingClickIdForConversion}{" "}
<span className="muted">(send this on the booking request for attribution)</span>
</p>
</article>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { formatApiMessage } from "../api/client";
import * as api from "../api/services";
import {
attributionParamsFromSearch,
hasAttributionParams,
persistMarketingClickId,
stripAttributionFromUrl,
} from "../marketing/attribution";
import type { EquipmentItemDetail } from "../api/types";
export function EquipmentDetailPage() {
const { publicId } = useParams<{ publicId: string }>();
const navigate = useNavigate();
const { user, loading: authLoading } = useAuth();
const [item, setItem] = useState<EquipmentItemDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!publicId) return;
const pathnameAtStart = window.location.pathname;
const searchAtStart = window.location.search;
const attribution = attributionParamsFromSearch(searchAtStart);
let cancelled = false;
(async () => {
try {
const data = await api.getEquipmentItem(
publicId,
hasAttributionParams(attribution) ? attribution : undefined,
);
if (!cancelled) {
setItem(data);
persistMarketingClickId("equipment", publicId, data.marketing_click_id);
if (hasAttributionParams(attribution)) {
stripAttributionFromUrl(navigate, pathnameAtStart, searchAtStart);
}
}
} catch (e) {
if (!cancelled) setError(formatApiMessage(e));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [publicId, navigate]);
if (loading) {
return (
<div className="page">
<p className="muted">Loading listing</p>
</div>
);
}
if (error || !item) {
return (
<div className="page">
<p className="lede">{error ?? "Listing not found."}</p>
<Link to="/boats/search" className="text-link">
Back to search
</Link>
</div>
);
}
const img = item.images.find((i) => i.is_primary)?.image_url ?? item.images[0]?.image_url;
return (
<div className="listing-detail">
<Link to="/boats/search" className="listing-detail-back">
Search
</Link>
<div className="listing-detail-grid">
<div
className="listing-detail-image"
style={img ? { backgroundImage: `url(${img})` } : undefined}
/>
<div>
<p className="listing-detail-kicker">{item.vendor_business_name}</p>
<h1 className="listing-detail-title">{item.title}</h1>
<p className="listing-detail-meta">
{item.location} · {item.category.name}
</p>
<p className="listing-detail-price">${item.price_per_day} / day</p>
<p className="listing-detail-body">{item.description}</p>
<div className="listing-detail-actions">
{authLoading ? (
<span className="listing-detail-book-btn listing-detail-book-btn--pending">Loading</span>
) : (
<Link to={`/boats/item/${item.public_id}/book`} className="listing-detail-book-btn">
{user ? "Request to book" : "Sign in to book"}
</Link>
)}
{!authLoading && !user && (
<p className="listing-detail-book-hint muted">
You can browse without an account. Sign in (or create one) when you are ready to request this rental.
</p>
)}
</div>
<p className="muted">
Use <code>marketing_click_id</code> {item.marketing_click_id} when creating a booking request to attribute
this visit ({item.click_traffic_type}).
</p>
</div>
</div>
</div>
);
}

448
src/pages/HomePage.tsx Normal file
View File

@@ -0,0 +1,448 @@
import { useCallback, useState } from "react";
import { Link } from "react-router-dom";
import { CatalogStrip } from "../components/CatalogStrip";
type TourCard = {
title: string;
subtitle: string;
rating: string;
was: string;
now: string;
image: string;
};
const asiaTours: TourCard[] = [
{
title: "Bali & Singapore",
subtitle: "Hawaii Oahu",
rating: "4.8",
was: "$250",
now: "$200",
image:
"https://images.unsplash.com/photo-1537996194471-e657df975ab4?w=600&q=80",
},
{
title: "Hong Kong highlights",
subtitle: "City & harbor",
rating: "4.8",
was: "$250",
now: "$200",
image:
"https://images.unsplash.com/photo-1536599018102-9f803c140fc1?w=600&q=80",
},
{
title: "Hawaii escape",
subtitle: "Islands & coast",
rating: "4.8",
was: "$250",
now: "$200",
image:
"https://images.unsplash.com/photo-1542259683-9e0ba716065e?w=600&q=80",
},
];
const americaTours: TourCard[] = [
{
title: "Eastern USA",
subtitle: "USA, Canada",
rating: "4.8",
was: "$250",
now: "$200",
image:
"https://images.unsplash.com/photo-1496442226666-8d4d0e62e6e9?w=600&q=80",
},
{
title: "Canada escape",
subtitle: "USA, Canada",
rating: "4.8",
was: "$250",
now: "$200",
image:
"https://images.unsplash.com/photo-1517935706615-2717063cc922?w=600&q=80",
},
{
title: "Canadian Rockies",
subtitle: "USA, Canada",
rating: "4.8",
was: "$250",
now: "$200",
image:
"https://images.unsplash.com/photo-1503614472-8c93d56e92ce?w=600&q=80",
},
];
const europeTours: TourCard[] = [
{
title: "Europe escape",
subtitle: "Prague · Paris · Barcelona",
rating: "4.8",
was: "$250",
now: "$200",
image:
"https://images.unsplash.com/photo-1467269204594-9661b134dd2b?w=600&q=80",
},
{
title: "Europe dream",
subtitle: "Prague · Paris · Barcelona",
rating: "4.8",
was: "$250",
now: "$200",
image:
"https://images.unsplash.com/photo-1523906834658-6e24ef2386f9?w=600&q=80",
},
{
title: "Europe Wonder",
subtitle: "Prague · Paris · Barcelona",
rating: "4.8",
was: "$250",
now: "$200",
image:
"https://images.unsplash.com/photo-1516483638261-f4dbaf036963?w=600&q=80",
},
];
const continents = [
{ name: "European tour", price: "from $5 / km", img: "https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=400&q=80" },
{ name: "American tour", price: "from $5 / km", img: "https://images.unsplash.com/photo-1501594907352-04cda38ebc29?w=400&q=80" },
{ name: "Australian tour", price: "from $5 / km", img: "https://images.unsplash.com/photo-1523482580672-f109ba8cb9be?w=400&q=80" },
{ name: "Asian tour", price: "from $5 / km", img: "https://images.unsplash.com/photo-1480796927426-f609979314bd?w=400&q=80" },
];
const themes = [
{ title: "Adventure", price: "from $5000", img: "https://images.unsplash.com/photo-1504280390367-361c6d9f38f4?w=500&q=80" },
{ title: "City tour", price: "from $5000", img: "https://images.unsplash.com/photo-1449824913935-59a10b8d2000?w=500&q=80" },
{ title: "Historical", price: "from $5000", img: "https://images.unsplash.com/photo-1555993539-1732b0258235?w=500&q=80" },
{ title: "Beach lover", price: "from $5000", img: "https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=500&q=80" },
{ title: "Desert routes", price: "from $5000", img: "https://images.unsplash.com/photo-1509316785289-025f5b846b35?w=500&q=80" },
];
const testimonials = [
{
quote:
"When you innovate, you make mistakes. It is best to admit them quickly, and get on with improving your other innovations.",
name: "Lara Denal",
},
{
quote:
"Booking our coastal tour was effortless. Clear pricing, instant confirmation, and a memorable day on the water.",
name: "Sam Rivera",
},
{
quote:
"WaterTrekk made it easy to compare experiences. We booked a wine tour and a boat day in one checkout.",
name: "Alex Chen",
},
];
function TourCarousel({
regionLabel,
items,
}: {
regionLabel: string;
items: TourCard[];
}) {
const [i, setI] = useState(0);
const pageSize = 3;
const maxStart = Math.max(0, items.length - pageSize);
const start = Math.min(i, maxStart);
const visible = items.slice(start, start + pageSize);
const prev = useCallback(() => setI((x) => Math.max(0, x - 1)), []);
const next = useCallback(() => setI((x) => Math.min(maxStart, x + 1)), [maxStart]);
return (
<div className="tour-carousel">
<div className="tour-carousel-head">
<h3 className="tour-region-title">{regionLabel}</h3>
<div className="tour-carousel-nav">
<button type="button" className="tour-carousel-btn" onClick={prev} aria-label="Previous">
Previous
</button>
<button type="button" className="tour-carousel-btn" onClick={next} aria-label="Next">
Next
</button>
</div>
</div>
<div className="tour-card-row">
{visible.map((t) => (
<article key={t.title} className="tour-card">
<div
className="tour-card-image"
style={{ backgroundImage: `url(${t.image})` }}
/>
<div className="tour-card-body">
<h4 className="tour-card-title">{t.title}</h4>
<div className="tour-card-meta">
<span className="tour-rating"> {t.rating}</span>
<span className="tour-card-sub">{t.subtitle}</span>
</div>
<p className="tour-price">
<span className="tour-price-was">{t.was}</span>{" "}
<span className="tour-price-now">{t.now}</span>
<span className="tour-price-badge">25%</span>
</p>
</div>
</article>
))}
</div>
</div>
);
}
function ThemeStrip() {
const [i, setI] = useState(0);
const visible = 4;
const maxStart = Math.max(0, themes.length - visible);
const start = Math.min(i, maxStart);
const slice = themes.slice(start, start + visible);
return (
<div className="theme-strip-wrap">
<div className="tour-carousel-nav theme-strip-nav">
<button type="button" className="tour-carousel-btn" onClick={() => setI((x) => Math.max(0, x - 1))}>
Previous
</button>
<button
type="button"
className="tour-carousel-btn"
onClick={() => setI((x) => Math.min(maxStart, x + 1))}
>
Next
</button>
</div>
<div className="theme-strip">
{slice.map((th) => (
<article key={th.title} className="theme-card">
<div className="theme-card-image" style={{ backgroundImage: `url(${th.img})` }} />
<div className="theme-card-body">
<h3 className="theme-card-title">{th.title}</h3>
<p className="theme-card-price">{th.price}</p>
</div>
</article>
))}
</div>
</div>
);
}
function TestimonialCarousel() {
const [i, setI] = useState(0);
const t = testimonials[i];
return (
<div className="testimonial-block">
<div className="tour-carousel-nav">
<button
type="button"
className="tour-carousel-btn"
onClick={() => setI((x) => (x - 1 + testimonials.length) % testimonials.length)}
>
Previous
</button>
<button
type="button"
className="tour-carousel-btn"
onClick={() => setI((x) => (x + 1) % testimonials.length)}
>
Next
</button>
</div>
<blockquote className="testimonial-quote">{t.quote}</blockquote>
<p className="testimonial-name">{t.name}</p>
</div>
);
}
export function HomePage() {
return (
<div className="home-tour">
<section className="home-hero">
<p className="home-hero-kicker">WaterTrekk</p>
<h1 className="home-hero-title">Book your travel</h1>
<p className="home-hero-brand">tours & boats</p>
<form className="home-search" role="search" onSubmit={(e) => e.preventDefault()}>
<label className="visually-hidden" htmlFor="home-q">
Search tours and boats
</label>
<input
id="home-q"
type="search"
className="home-search-input"
placeholder="Where do you want to go?"
/>
<button type="submit" className="home-search-btn">
Search
</button>
</form>
<div className="home-hero-split">
<Link to="/tours" className="home-hero-split-link home-hero-split-link--tours">
<span className="home-hero-split-label">Tours & packages</span>
<span className="home-hero-split-title">Guided experiences</span>
</Link>
<Link to="/boats" className="home-hero-split-link home-hero-split-link--boats">
<span className="home-hero-split-label">On the water</span>
<span className="home-hero-split-title">Boat rentals</span>
</Link>
</div>
</section>
<section className="home-section home-section--intro">
<h2 className="home-section-title">Welcome to WaterTrekk search</h2>
<p className="home-section-lede">
Find guided tours, coastal cruises, and boat rentals in one place. Compare
dates, read ratings, and book experiences that fit your tripwhether you are
planning a weekend on the water or a multi-day adventure.
</p>
</section>
<CatalogStrip />
<section className="home-section home-section--boats-hub" aria-labelledby="boats-hub-title">
<div className="home-boats-hub">
<div className="home-boats-hub-copy">
<p className="home-boats-hub-kicker">Boat rentals</p>
<h2 id="boats-hub-title" className="home-boats-hub-title">
Rent the right boat for your crew
</h2>
<p className="home-boats-hub-text">
Pontoons, skiffs, sailboats, and more filter by dock, vessel type, and how many
people you are bringing. Open the dedicated boat hub or jump straight to the map
search.
</p>
<div className="home-boats-hub-actions">
<Link to="/boats" className="home-boats-hub-btn home-boats-hub-btn--primary">
Boat rentals home
</Link>
<Link to="/boats/search" className="home-boats-hub-btn home-boats-hub-btn--outline">
Search on map
</Link>
</div>
</div>
<div
className="home-boats-hub-visual"
style={{
backgroundImage:
"url(https://images.unsplash.com/photo-1567899378494-47b22a2ae96a?w=900&q=80)",
}}
role="img"
aria-label="Boat on the water"
/>
</div>
</section>
<section className="home-section">
<div className="home-section-head">
<h2 className="home-section-title-lg">Amazing holiday packages</h2>
<span className="home-section-tag">tours</span>
</div>
<TourCarousel regionLabel="Asia" items={asiaTours} />
<TourCarousel regionLabel="America" items={americaTours} />
<TourCarousel regionLabel="Europe" items={europeTours} />
</section>
<section className="home-section home-section--continents">
<div className="home-section-head">
<h2 className="home-section-title-lg">Explore through continents</h2>
<span className="home-section-tag">tours</span>
</div>
<div className="continent-grid">
{continents.map((c) => (
<article key={c.name} className="continent-card">
<div className="continent-card-image" style={{ backgroundImage: `url(${c.img})` }} />
<div className="continent-card-body">
<h3 className="continent-card-title">{c.name}</h3>
<p className="continent-card-price">{c.price}</p>
</div>
</article>
))}
</div>
</section>
<section className="home-section">
<div className="home-section-head">
<h2 className="home-section-title-lg">Explore through themes</h2>
<span className="home-section-tag">best</span>
</div>
<ThemeStrip />
</section>
<section className="home-section home-section--testimonials">
<div className="home-section-head">
<span className="home-section-tag home-section-tag--muted">our</span>
<h2 className="home-section-title-lg">Our happy customers</h2>
<span className="home-section-tag">customer</span>
</div>
<TestimonialCarousel />
</section>
<footer className="home-footer">
<div className="home-footer-grid">
<div>
<h3 className="home-footer-heading">Contact us</h3>
<p className="home-footer-text">
Questions about a booking or a listing? Reach our teamwe typically reply
within one business day.
</p>
<ul className="home-footer-list">
<li>A-32, Albany, New York</li>
<li>518-457-5181</li>
<li>contact@harbortrail.example</li>
</ul>
</div>
<div>
<h3 className="home-footer-heading">About</h3>
<ul className="home-footer-links">
<li>
<Link to="/">About us</Link>
</li>
<li>
<a href="#faq">FAQ</a>
</li>
<li>
<Link to="/auth/sign-in">Vendor sign in</Link>
</li>
<li>
<a href="#terms">Terms</a>
</li>
<li>
<a href="#privacy">Privacy</a>
</li>
</ul>
</div>
<div>
<h3 className="home-footer-heading">Top places</h3>
<ul className="home-footer-tags">
<li>Japan</li>
<li>Beach</li>
<li>New York</li>
<li>City</li>
<li>Mountain</li>
<li>Wild</li>
</ul>
</div>
<div>
<h3 className="home-footer-heading">Useful links</h3>
<ul className="home-footer-links">
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/boats">Boat rentals</Link>
</li>
<li>
<Link to="/boats/search">Boat map search</Link>
</li>
<li>
<Link to="/tours">Tours</Link>
</li>
<li>
<Link to="/vendors">For vendors</Link>
</li>
</ul>
</div>
</div>
<p className="home-footer-copy">© {new Date().getFullYear()} WaterTrekk</p>
</footer>
</div>
);
}

74
src/pages/ToursPage.tsx Normal file
View File

@@ -0,0 +1,74 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { formatApiMessage } from "../api/client";
import * as api from "../api/services";
import type { AdventureOffering } from "../api/types";
export function ToursPage() {
const [offerings, setOfferings] = useState<AdventureOffering[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const data = await api.listAdventureOfferings();
if (!cancelled) setOfferings(data);
} catch (e) {
if (!cancelled) setError(formatApiMessage(e));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
return (
<div className="page tours-catalog">
<h1>Tours & adventures</h1>
<p className="lede">
Guided experiences from GET <code>/api/v1/adventrues/offerings/</code>. Open a listing for marketing
attribution IDs used at booking time.
</p>
{loading && <p className="muted">Loading</p>}
{error && <p className="muted">{error}</p>}
<ul className="card-list">
{offerings.map((o) => {
const img =
o.images.find((i) => i.is_primary)?.image_url ?? o.images[0]?.image_url;
return (
<li key={o.id}>
<article className="card">
<div
className="tours-card-image"
style={
img
? { backgroundImage: `url(${img})` }
: { background: "linear-gradient(135deg, #fef9c3, #facc15)" }
}
/>
<h2>{o.title}</h2>
<p>{o.vendor_business_name}</p>
<p className="muted">
{o.meeting_point} · {o.duration_minutes} min · up to {o.capacity} guests
</p>
<p>
<strong>${o.price_per_person}</strong> / person
</p>
<Link to={`/tours/${o.public_id}`} className="text-link">
View details
</Link>
</article>
</li>
);
})}
</ul>
{!loading && !error && offerings.length === 0 && (
<p className="muted">No adventures published yet.</p>
)}
</div>
);
}

View File

@@ -0,0 +1,124 @@
import { useEffect, useMemo, useState } from "react";
import { formatApiMessage } from "../api/client";
import * as api from "../api/services";
import type { MarketingSummary } from "../api/types";
function rangeUtc(days: number): { from: string; to: string } {
const to = new Date();
const from = new Date(to);
from.setUTCDate(from.getUTCDate() - days);
return { from: from.toISOString(), to: to.toISOString() };
}
export function VendorAnalyticsPage() {
const { from, to } = useMemo(() => rangeUtc(30), []);
const [summary, setSummary] = useState<MarketingSummary | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const data = await api.fetchVendorMarketingSummary(from, to);
if (!cancelled) setSummary(data);
} catch (e) {
if (!cancelled) setError(formatApiMessage(e));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [from, to]);
if (loading) {
return (
<div className="admin-page">
<p className="muted">Loading analytics</p>
</div>
);
}
if (error || !summary) {
return (
<div className="admin-page">
<p className="admin-page-sub">{error ?? "No data."}</p>
</div>
);
}
const pct = (n: number | null | undefined) =>
n == null ? "—" : `${(n * 100).toFixed(2)}%`;
return (
<div className="admin-page">
<div className="admin-page-head">
<div>
<h1 className="admin-page-title">Analytics</h1>
<p className="admin-page-sub">
Marketing summary (last 30 days, UTC). From {new Date(summary.from).toLocaleDateString()} to{" "}
{new Date(summary.to).toLocaleDateString()}.
</p>
</div>
</div>
<div className="admin-stats">
<article className="admin-stat-card">
<p className="admin-stat-label">Listing clicks</p>
<p className="admin-stat-value">{summary.clicks.total}</p>
<p className="admin-stat-delta">
{summary.clicks.organic} organic · {summary.clicks.marketing} marketing
</p>
</article>
<article className="admin-stat-card">
<p className="admin-stat-label">Bookings (all)</p>
<p className="admin-stat-value">{summary.bookings_attributed.total_all}</p>
<p className="admin-stat-delta">{summary.bookings_attributed.unattributed} unattributed</p>
</article>
<article className="admin-stat-card">
<p className="admin-stat-label">Conv. (organic clicks bookings)</p>
<p className="admin-stat-value">{pct(summary.conversion_rate_click_to_booking.organic)}</p>
</article>
<article className="admin-stat-card">
<p className="admin-stat-label">Conv. (marketing clicks bookings)</p>
<p className="admin-stat-value">{pct(summary.conversion_rate_click_to_booking.marketing)}</p>
</article>
</div>
<section className="admin-panel admin-panel--wide">
<div className="admin-panel-head">
<h2>Campaigns (top by clicks)</h2>
</div>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>Source</th>
<th>Medium</th>
<th>Campaign</th>
<th>Clicks</th>
<th>Bookings</th>
<th>Rate</th>
</tr>
</thead>
<tbody>
{summary.campaigns.map((c, i) => (
<tr key={`${c.utm_campaign}-${i}`}>
<td>{c.utm_source || "—"}</td>
<td>{c.utm_medium || "—"}</td>
<td>{c.utm_campaign || "—"}</td>
<td>{c.clicks}</td>
<td>{c.bookings}</td>
<td>{pct(c.conversion_rate)}</td>
</tr>
))}
</tbody>
</table>
{summary.campaigns.length === 0 && (
<p className="muted admin-panel-pad">No tagged campaigns in this window.</p>
)}
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,106 @@
import { useEffect, useState } from "react";
import { formatApiMessage } from "../api/client";
import * as api from "../api/services";
import type { Booking, BookingStatus } from "../api/types";
function badgeClass(status: BookingStatus): string {
return `admin-badge admin-badge--${status}`;
}
function formatWhen(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
}
function listingLabel(b: Booking): string {
if (b.equipment_public_id) return `Equipment · ${b.equipment_public_id}`;
if (b.adventure_public_id) return `Adventure · ${b.adventure_public_id}`;
return "—";
}
export function VendorBookingsPage() {
const [rows, setRows] = useState<Booking[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const data = await api.listBookings();
if (!cancelled) {
setRows(
[...data].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()),
);
}
} catch (e) {
if (!cancelled) setError(formatApiMessage(e));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
if (loading) {
return (
<div className="admin-page">
<p className="muted">Loading bookings</p>
</div>
);
}
if (error) {
return (
<div className="admin-page">
<p className="admin-page-sub">{error}</p>
</div>
);
}
return (
<div className="admin-page">
<div className="admin-page-head">
<div>
<h1 className="admin-page-title">Bookings</h1>
<p className="admin-page-sub">Requests and reservations for your vendor profile</p>
</div>
</div>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>ID</th>
<th>Guest</th>
<th>Listing</th>
<th>Window</th>
<th>Total</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{rows.map((b) => (
<tr key={b.id}>
<td className="admin-table-mono">{b.id}</td>
<td>{b.customer_email}</td>
<td>{listingLabel(b)}</td>
<td>
{formatWhen(b.starts_at)} {formatWhen(b.ends_at)}
</td>
<td>${b.total_price}</td>
<td>
<span className={badgeClass(b.status)}>{b.status}</span>
</td>
</tr>
))}
</tbody>
</table>
{rows.length === 0 && <p className="muted admin-panel-pad">No bookings yet.</p>}
</div>
</div>
);
}

View File

@@ -0,0 +1,116 @@
import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { formatApiMessage } from "../api/client";
import * as api from "../api/services";
import type { AdventureOffering, EquipmentItem } from "../api/types";
import { VendorCreateListingPanel } from "../components/VendorCreateListingPanel";
export function VendorListingsPage() {
const [equipment, setEquipment] = useState<EquipmentItem[]>([]);
const [adventures, setAdventures] = useState<AdventureOffering[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const refetchListings = useCallback(async () => {
try {
const [eq, adv] = await Promise.all([api.listVendorEquipment(), api.listVendorAdventures()]);
setEquipment(eq);
setAdventures(adv);
setError(null);
} catch (e) {
setError(formatApiMessage(e));
}
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [eq, adv] = await Promise.all([api.listVendorEquipment(), api.listVendorAdventures()]);
if (!cancelled) {
setEquipment(eq);
setAdventures(adv);
}
} catch (e) {
if (!cancelled) setError(formatApiMessage(e));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
if (loading) {
return (
<div className="admin-page">
<p className="muted">Loading listings</p>
</div>
);
}
if (error) {
return (
<div className="admin-page">
<p className="admin-page-sub">{error}</p>
</div>
);
}
return (
<div className="admin-page">
<div className="admin-page-head">
<div>
<h1 className="admin-page-title">Listings</h1>
<p className="admin-page-sub">Equipment and adventures on your vendor account</p>
</div>
</div>
<div className="admin-panels">
<VendorCreateListingPanel onCreated={refetchListings} />
<section className="admin-panel admin-panel--wide">
<div className="admin-panel-head">
<h2>Equipment</h2>
</div>
<ul className="vendor-listing-list">
{equipment.map((item) => (
<li key={item.id}>
<span className="vendor-listing-title">{item.title}</span>
<span className="muted">{item.public_id}</span>
<span>${item.price_per_day}/day</span>
<span className={item.is_active ? "admin-badge admin-badge--confirmed" : "admin-badge"}>
{item.is_active ? "active" : "inactive"}
</span>
<Link to={`/boats/item/${item.public_id}`} className="text-link">
Public view
</Link>
</li>
))}
</ul>
{equipment.length === 0 && <p className="muted admin-panel-pad">No equipment items.</p>}
</section>
<section className="admin-panel admin-panel--wide">
<div className="admin-panel-head">
<h2>Adventures</h2>
</div>
<ul className="vendor-listing-list">
{adventures.map((o) => (
<li key={o.id}>
<span className="vendor-listing-title">{o.title}</span>
<span className="muted">{o.public_id}</span>
<span>${o.price_per_person}/person</span>
<span className={o.is_active ? "admin-badge admin-badge--confirmed" : "admin-badge"}>
{o.is_active ? "active" : "inactive"}
</span>
<Link to={`/tours/${o.public_id}`} className="text-link">
Public view
</Link>
</li>
))}
</ul>
{adventures.length === 0 && <p className="muted admin-panel-pad">No adventure offerings.</p>}
</section>
</div>
</div>
);
}

View File

@@ -0,0 +1,148 @@
import { useEffect, useState } from "react";
import { formatApiMessage } from "../api/client";
import * as api from "../api/services";
import type { VendorProfile } from "../api/types";
export function VendorSettingsPage() {
const [profile, setProfile] = useState<VendorProfile | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [businessName, setBusinessName] = useState("");
const [description, setDescription] = useState("");
const [contactPhone, setContactPhone] = useState("");
const [contactEmail, setContactEmail] = useState("");
const [addressLine1, setAddressLine1] = useState("");
const [city, setCity] = useState("");
const [state, setState] = useState("");
const [postalCode, setPostalCode] = useState("");
const [country, setCountry] = useState("");
useEffect(() => {
let cancelled = false;
(async () => {
try {
const p = await api.fetchVendorProfile();
if (!cancelled) {
setProfile(p);
setBusinessName(p.business_name);
setDescription(p.description);
setContactPhone(p.contact_phone);
setContactEmail(p.contact_email);
setAddressLine1(p.address_line1);
setCity(p.city);
setState(p.state);
setPostalCode(p.postal_code);
setCountry(p.country);
}
} catch (e) {
if (!cancelled) setError(formatApiMessage(e));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setSaved(false);
setSaving(true);
try {
const p = await api.patchVendorProfile({
business_name: businessName,
description,
contact_phone: contactPhone,
contact_email: contactEmail,
address_line1: addressLine1,
city,
state,
postal_code: postalCode,
country,
});
setProfile(p);
setSaved(true);
} catch (err) {
setError(formatApiMessage(err));
} finally {
setSaving(false);
}
}
if (loading) {
return (
<div className="admin-page">
<p className="muted">Loading profile</p>
</div>
);
}
return (
<div className="admin-page">
<div className="admin-page-head">
<div>
<h1 className="admin-page-title">Settings</h1>
<p className="admin-page-sub">Vendor storefront profile (slug: {profile?.slug})</p>
</div>
</div>
<form className="vendor-settings-form" onSubmit={onSubmit}>
<label className="auth-label">
Business name
<input className="auth-input" value={businessName} onChange={(e) => setBusinessName(e.target.value)} required />
</label>
<label className="auth-label auth-label--full">
Description
<textarea
className="auth-input auth-textarea"
rows={4}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</label>
<label className="auth-label">
Contact phone
<input className="auth-input" value={contactPhone} onChange={(e) => setContactPhone(e.target.value)} />
</label>
<label className="auth-label">
Contact email
<input
type="email"
className="auth-input"
value={contactEmail}
onChange={(e) => setContactEmail(e.target.value)}
/>
</label>
<label className="auth-label auth-label--full">
Address line 1
<input className="auth-input" value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} />
</label>
<label className="auth-label">
City
<input className="auth-input" value={city} onChange={(e) => setCity(e.target.value)} />
</label>
<label className="auth-label">
State / region
<input className="auth-input" value={state} onChange={(e) => setState(e.target.value)} />
</label>
<label className="auth-label">
Postal code
<input className="auth-input" value={postalCode} onChange={(e) => setPostalCode(e.target.value)} />
</label>
<label className="auth-label">
Country
<input className="auth-input" value={country} onChange={(e) => setCountry(e.target.value)} />
</label>
{error && <p className="auth-error auth-label--full">{error}</p>}
{saved && <p className="auth-success auth-label--full">Saved.</p>}
<button type="submit" className="admin-btn admin-btn--primary" disabled={saving}>
{saving ? "Saving…" : "Save changes"}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { Link } from "react-router-dom";
const benefits = [
{
title: "Unified inventory",
text: "Publish boat and equipment rentals alongside guided adventures from one vendor profile and storefront URL.",
},
{
title: "Booking workflow",
text: "Guests submit requests; you approve or decline. Overlap protection and quoted totals are handled server-side.",
},
{
title: "Marketing attribution",
text: "Listing views capture UTM and ad click IDs. When guests book, tie revenue to organic vs paid traffic and campaigns.",
},
{
title: "Payments ready",
text: "Mock Stripe-style payment intents let you test checkout flows today and swap in live keys when you are ready.",
},
];
export function VendorWhyPage() {
return (
<div className="vendor-why">
<section className="vendor-why-hero">
<p className="vendor-why-kicker">For operators</p>
<h1 className="vendor-why-title">Why list on WaterTrekk</h1>
<p className="vendor-why-lede">
WaterTrekk powers this marketplace: real availability checks, booking states, and vendor analyticsnot just a
brochure site.
</p>
<div className="vendor-why-actions">
<Link to="/auth/register" className="vendor-why-btn vendor-why-btn--primary">
Create vendor account
</Link>
<Link to="/auth/sign-in" className="vendor-why-btn vendor-why-btn--ghost">
Vendor sign in
</Link>
</div>
</section>
<ul className="vendor-why-grid">
{benefits.map((b) => (
<li key={b.title} className="vendor-why-card">
<h2>{b.title}</h2>
<p>{b.text}</p>
</li>
))}
</ul>
<section className="vendor-why-cta">
<h2>Ready to onboard?</h2>
<p>Register as a vendor with your business name and contact details. You can refine your public profile anytime.</p>
<Link to="/auth/register" className="vendor-why-btn vendor-why-btn--primary">
Get started
</Link>
</section>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { formatApiMessage } from "../../api/client";
import * as api from "../../api/services";
export function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [done, setDone] = useState(false);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await api.requestPasswordReset(email.trim() || undefined);
setDone(true);
} catch (err) {
setError(formatApiMessage(err));
} finally {
setSubmitting(false);
}
}
return (
<div className="auth-page">
<div className="auth-card">
<p className="auth-kicker">WaterTrekk</p>
<h1 className="auth-title">Forgot password</h1>
{done ? (
<>
<p className="auth-lede">
If an account exists for that email, you will receive reset instructions when outbound email is enabled
on the server.
</p>
<p className="auth-footer">
<Link to="/auth/sign-in">Back to sign in</Link>
</p>
</>
) : (
<>
<p className="auth-lede">
Enter the email you used to register. The API acknowledges the request even when email delivery is not yet
configured.
</p>
<form className="auth-form" onSubmit={onSubmit}>
<label className="auth-label">
Email
<input
type="email"
className="auth-input"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</label>
{error && <p className="auth-error">{error}</p>}
<button type="submit" className="auth-submit" disabled={submitting}>
{submitting ? "Sending…" : "Request reset"}
</button>
</form>
<p className="auth-footer">
<Link to="/auth/sign-in">Back to sign in</Link>
</p>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,215 @@
import { useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { formatApiMessage, setTokens } from "../../api/client";
import * as api from "../../api/services";
import { safeReturnPath } from "../../auth/returnPath";
import type { VendorRegisterRequest } from "../../api/types";
type Role = "customer" | "vendor";
export function RegisterPage() {
const navigate = useNavigate();
const location = useLocation();
const fromState = (location.state as { from?: string } | null)?.from;
const [role, setRole] = useState<Role>("customer");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [phone, setPhone] = useState("");
const [businessName, setBusinessName] = useState("");
const [description, setDescription] = useState("");
const [contactPhone, setContactPhone] = useState("");
const [contactEmail, setContactEmail] = useState("");
const [addressLine1, setAddressLine1] = useState("");
const [city, setCity] = useState("");
const [state, setState] = useState("");
const [postalCode, setPostalCode] = useState("");
const [country, setCountry] = useState("");
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
if (role === "customer") {
await api.registerCustomer({
email,
password,
first_name: firstName,
last_name: lastName,
phone_number: phone,
});
} else {
const body: VendorRegisterRequest = {
email,
password,
first_name: firstName,
last_name: lastName,
phone_number: phone,
business_name: businessName,
description,
contact_phone: contactPhone || phone,
contact_email: contactEmail || email,
address_line1: addressLine1,
city,
state,
postal_code: postalCode,
country,
};
await api.registerVendor(body);
}
const tokens = await api.login({ email, password });
setTokens(tokens.access, tokens.refresh);
if (role === "vendor") {
navigate("/dashboard", { replace: true });
} else {
navigate(safeReturnPath(fromState) ?? "/", { replace: true });
}
} catch (err) {
setError(formatApiMessage(err));
} finally {
setSubmitting(false);
}
}
return (
<div className="auth-page">
<div className="auth-card auth-card--wide">
<p className="auth-kicker">WaterTrekk</p>
<h1 className="auth-title">Create account</h1>
<div className="auth-role-toggle" role="tablist" aria-label="Account type">
<button
type="button"
role="tab"
aria-selected={role === "customer"}
className={`auth-role-btn${role === "customer" ? " auth-role-btn--active" : ""}`}
onClick={() => setRole("customer")}
>
Customer
</button>
<button
type="button"
role="tab"
aria-selected={role === "vendor"}
className={`auth-role-btn${role === "vendor" ? " auth-role-btn--active" : ""}`}
onClick={() => setRole("vendor")}
>
Vendor
</button>
</div>
<p className="auth-lede">
{role === "customer"
? "Book equipment and adventures. You will sign in after registration."
: "List rentals and tours on WaterTrekk. Business details help guests find you."}
</p>
<form className="auth-form auth-form--grid" onSubmit={onSubmit}>
<label className="auth-label">
Email
<input
type="email"
className="auth-input"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</label>
<label className="auth-label">
Password (min 8 characters)
<input
type="password"
className="auth-input"
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={8}
required
/>
</label>
<label className="auth-label">
First name
<input className="auth-input" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</label>
<label className="auth-label">
Last name
<input className="auth-input" value={lastName} onChange={(e) => setLastName(e.target.value)} />
</label>
<label className="auth-label auth-label--full">
Phone
<input className="auth-input" value={phone} onChange={(e) => setPhone(e.target.value)} />
</label>
{role === "vendor" && (
<>
<label className="auth-label auth-label--full">
Business name *
<input
className="auth-input"
value={businessName}
onChange={(e) => setBusinessName(e.target.value)}
required
/>
</label>
<label className="auth-label auth-label--full">
Description
<textarea
className="auth-input auth-textarea"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</label>
<label className="auth-label">
Contact phone
<input className="auth-input" value={contactPhone} onChange={(e) => setContactPhone(e.target.value)} />
</label>
<label className="auth-label">
Contact email
<input
type="email"
className="auth-input"
value={contactEmail}
onChange={(e) => setContactEmail(e.target.value)}
/>
</label>
<label className="auth-label auth-label--full">
Address line 1
<input className="auth-input" value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} />
</label>
<label className="auth-label">
City
<input className="auth-input" value={city} onChange={(e) => setCity(e.target.value)} />
</label>
<label className="auth-label">
State / region
<input className="auth-input" value={state} onChange={(e) => setState(e.target.value)} />
</label>
<label className="auth-label">
Postal code
<input className="auth-input" value={postalCode} onChange={(e) => setPostalCode(e.target.value)} />
</label>
<label className="auth-label">
Country
<input className="auth-input" value={country} onChange={(e) => setCountry(e.target.value)} />
</label>
</>
)}
{error && <p className="auth-error auth-label--full">{error}</p>}
<button type="submit" className="auth-submit auth-label--full" disabled={submitting}>
{submitting ? "Creating account…" : "Register"}
</button>
</form>
<p className="auth-footer">
Already have an account?{" "}
<Link to="/auth/sign-in" state={location.state}>
Sign in
</Link>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { useState } from "react";
import { Link, Navigate, useLocation, useNavigate } from "react-router-dom";
import { formatApiMessage } from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
import { safeReturnPath } from "../../auth/returnPath";
export function SignInPage() {
const { signIn, user, loading } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const fromState = (location.state as { from?: string } | null)?.from;
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!loading && user?.is_vendor) {
return <Navigate to={fromState && fromState.startsWith("/dashboard") ? fromState : "/dashboard"} replace />;
}
if (!loading && user && !user.is_vendor) {
return <Navigate to={safeReturnPath(fromState) ?? "/"} replace />;
}
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
const me = await signIn(email, password);
if (me.is_vendor) {
navigate(fromState && fromState.startsWith("/dashboard") ? fromState : "/dashboard", {
replace: true,
});
} else {
navigate(safeReturnPath(fromState) ?? "/", { replace: true });
}
} catch (err) {
setError(formatApiMessage(err));
} finally {
setSubmitting(false);
}
}
return (
<div className="auth-page">
<div className="auth-card">
<p className="auth-kicker">WaterTrekk</p>
<h1 className="auth-title">Sign in</h1>
<p className="auth-lede">
Vendors go to the dashboard after sign-in. Customers use the same account to book rentals and
tours.
</p>
<form className="auth-form" onSubmit={onSubmit}>
<label className="auth-label">
Email
<input
type="email"
autoComplete="email"
className="auth-input"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</label>
<label className="auth-label">
Password
<input
type="password"
autoComplete="current-password"
className="auth-input"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
{error && <p className="auth-error">{error}</p>}
<button type="submit" className="auth-submit" disabled={submitting}>
{submitting ? "Signing in…" : "Sign in"}
</button>
</form>
<p className="auth-footer">
<Link to="/auth/forgot-password">Forgot password?</Link>
{" · "}
<Link to="/auth/register" state={location.state}>
Create an account
</Link>
</p>
<p className="auth-footer">
<Link to="/vendors">Why list as a vendor?</Link>
</p>
</div>
</div>
);
}

9
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

26
tsconfig.app.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
tsconfig.node.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

15
vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": {
target: "http://127.0.0.1:8003",
changeOrigin: true,
},
},
},
});