initial checkin
113
README.md
@@ -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
|
||||
|
||||

|
||||
|
||||
### Boat rentals
|
||||
|
||||

|
||||
|
||||
### Boat search
|
||||
|
||||

|
||||
|
||||
### Tours
|
||||
|
||||

|
||||
|
||||
### Sign in
|
||||
|
||||

|
||||
|
||||
### For vendors
|
||||
|
||||

|
||||
|
||||
### 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`).
|
||||
|
||||
BIN
docs/screenshots/boat-rentals.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
docs/screenshots/boat-search.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
docs/screenshots/home.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
docs/screenshots/sign-in.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
docs/screenshots/tours.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
docs/screenshots/vendors.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
28
eslint.config.js
Normal 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
@@ -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
31
package.json
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
28
src/auth/RequireSignIn.tsx
Normal 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;
|
||||
}
|
||||
31
src/auth/RequireVendor.tsx
Normal 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
@@ -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;
|
||||
}
|
||||
84
src/components/AdminLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
src/components/CatalogStrip.tsx
Normal 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
@@ -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>
|
||||
);
|
||||
}
|
||||
417
src/components/VendorCreateListingPanel.tsx
Normal 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
16
src/main.tsx
Normal 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>,
|
||||
);
|
||||
72
src/marketing/attribution.ts
Normal 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 });
|
||||
}
|
||||
97
src/pages/AdventureBookPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
src/pages/AdventureDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
src/pages/BoatRentalsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
src/pages/BoatSearchPage.tsx
Normal 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
@@ -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>
|
||||
);
|
||||
}
|
||||
97
src/pages/EquipmentBookPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
src/pages/EquipmentDetailPage.tsx
Normal 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
@@ -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 trip—whether 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 team—we 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
@@ -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>
|
||||
);
|
||||
}
|
||||
124
src/pages/VendorAnalyticsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
src/pages/VendorBookingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
src/pages/VendorListingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
src/pages/VendorSettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
src/pages/VendorWhyPage.tsx
Normal 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 analytics—not 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>
|
||||
);
|
||||
}
|
||||
70
src/pages/auth/ForgotPasswordPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
215
src/pages/auth/RegisterPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
src/pages/auth/SignInPage.tsx
Normal 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
@@ -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
@@ -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
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
tsconfig.node.json
Normal 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
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||