Loads Brevo's web tracker for a signed-in customer who has consented, reports route changes as page views, and tracks the three events the issue asked for: added_to_cart, favorited, and checkout_completed. The four design questions were settled on the issue in August and this implements those answers. The consent gate is the part worth reading. The decision recorded on the issue was "gate it behind consent", but the sentence customers actually agreed to named only email: "I want to receive occasional emails about new one-of-a-kind items". Gating a tracker on `marketing_consent` while that was the stored wording would have treated "email me about new items" as authorisation to send someone's browsing to a third party, which it does not say — and this project stores the wording verbatim against each customer precisely so that a record says what the customer saw. So the sentence is widened here, and the tracker is gated on `analytics_consent`, a field the server computes by comparing the wording stored against a customer with the current constant. Changing the sentence therefore does not retroactively widen anybody's consent: everyone who agreed to the old text keeps their email consent and is not tracked until they re-consent through the account page. A boolean alone could not tell those two populations apart, which is the whole reason the text is stored per customer. `analyticsConsent` is exported and has its own unit test, because "agreeing to the old wording does not authorise tracking" is the rule that silently tracks people if it regresses — their flag really is true. QA stays out of the live Brevo account by construction rather than by remembering. The key is per-environment, the tracker never loads without one, and `docker-compose.qa.yml` sets an empty literal with no stack variable behind it, so nothing can inherit a value from the host or be pasted in from production's stack. Same reasoning as QA_DB_PASSWORD and the QA_SMTP_ names beside it. Events are reported from the API layer rather than the UI call sites, so no caller can add to the cart or favorite an item without it being counted, and each fires only after the response was accepted — a refused add is not reported as one. The two checkout completions each name their processor, because a demo purchase charges nothing and counting it as a sale would overstate revenue. The privacy policy gains an analytics section in this change rather than a follow-up, since the published policy previously described none of this and would otherwise have lagged the code. It is deliberate about the limits: withdrawing consent stops further reporting, but anything already sent stays with Brevo, and a script already injected cannot be un-injected — `stopBrevoTracking` stops calls, it does not unload sa.js. That is said in the code too, because "tracking stops" reads as a stronger promise than any web tracker can make. Verified: backend tsc clean, both lint suites 0 errors with no new warnings, 474 unit tests passing across 33 suites, and the frontend production build green including the compose-environment guard. Not verified: integration and e2e, which need a database and a Node this machine does not have active, and no real Brevo key was exercised — the tracker has never been observed reporting to an actual account. Closes #56 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
216 lines
7.2 KiB
TypeScript
Executable File
216 lines
7.2 KiB
TypeScript
Executable File
import type { ItemFilters } from './filters';
|
|
import { filtersToSearchParams } from './filters';
|
|
import { setUploadsBase } from './uploadUrl';
|
|
|
|
export interface ItemTag {
|
|
id: number;
|
|
name: string;
|
|
color: string;
|
|
}
|
|
|
|
export interface Item {
|
|
id: number;
|
|
name: string;
|
|
description: string | null;
|
|
price_cents: number;
|
|
images: {
|
|
id: number;
|
|
image_path: string;
|
|
sort_order: number;
|
|
// Present on admin responses only — ADMIN_ITEM_SELECT's images aggregate
|
|
// carries it, PUBLIC_ITEM_SELECT's does not — so it stays optional on this
|
|
// shared type rather than a lie the public fetchItems() response can't back up.
|
|
original_image_path?: string | null;
|
|
}[];
|
|
status: 'pending' | 'available' | 'reserved' | 'sold';
|
|
category_id: number | null;
|
|
category_name: string | null;
|
|
tags: ItemTag[];
|
|
}
|
|
|
|
export interface Category {
|
|
id: number;
|
|
name: string;
|
|
parent_id: number | null;
|
|
sort_order: number;
|
|
item_count: number;
|
|
}
|
|
|
|
export interface Tag {
|
|
id: number;
|
|
name: string;
|
|
color: string;
|
|
item_count: number;
|
|
}
|
|
|
|
export interface FilterOptions {
|
|
categories: Category[];
|
|
tags: Tag[];
|
|
priceRange: { min_cents: number; max_cents: number };
|
|
}
|
|
|
|
export interface SiteConfig {
|
|
paypalClientId: string | null;
|
|
demoMode: boolean;
|
|
currency: string;
|
|
/** Origin for uploaded images. Empty means the app's own — see uploadUrl. */
|
|
uploadsBaseUrl: string;
|
|
/**
|
|
* Brevo Marketing Automation key (#56). Null when the environment sets none,
|
|
* which is how QA avoids reporting test browsing into the live Brevo account.
|
|
* A key alone does not start tracking — see brevo.ts.
|
|
*/
|
|
brevoTrackerKey: string | null;
|
|
}
|
|
|
|
export async function fetchConfig(): Promise<SiteConfig> {
|
|
const res = await fetch('/api/config');
|
|
const config = (await res.json()) as SiteConfig;
|
|
// Applied here rather than by each caller, so no caller can fetch the config
|
|
// and forget to — the uploads origin is a property of the deployment, not of
|
|
// whichever screen happened to ask for it.
|
|
setUploadsBase(config.uploadsBaseUrl);
|
|
return config;
|
|
}
|
|
|
|
export async function fetchItems(filters?: ItemFilters): Promise<Item[]> {
|
|
const query = filters ? filtersToSearchParams(filters).toString() : '';
|
|
const res = await fetch(query ? `/api/items?${query}` : '/api/items');
|
|
// An error response still parses as JSON — as `{ error: ... }`, not an array.
|
|
// Returning that unchecked would set it as the item list and crash the grid
|
|
// on `.map`, so a failure has to surface as a rejection the caller can show.
|
|
if (!res.ok) throw new Error('failed to load items');
|
|
return res.json();
|
|
}
|
|
|
|
export async function fetchFilterOptions(): Promise<FilterOptions> {
|
|
const res = await fetch('/api/filters');
|
|
if (!res.ok) throw new Error('failed to load filters');
|
|
return res.json();
|
|
}
|
|
|
|
// Every admin call goes through this. Without the res.ok check a 4xx/5xx still
|
|
// resolves — the caller then reports success for a write that never happened,
|
|
// which is worse than failing outright because nothing prompts the user to look
|
|
// for the missing row.
|
|
async function expectOk(res: Response, action: string): Promise<Response> {
|
|
if (res.ok) return res;
|
|
const detail = await res.json().catch(() => null);
|
|
throw new Error(detail?.error ? `${action}: ${detail.error}` : action);
|
|
}
|
|
|
|
export async function fetchAdminItems(filters?: ItemFilters): Promise<Item[]> {
|
|
const query = filters ? filtersToSearchParams(filters).toString() : '';
|
|
const res = await expectOk(
|
|
await fetch(query ? `/api/admin/items?${query}` : '/api/admin/items'),
|
|
'failed to load items'
|
|
);
|
|
return res.json();
|
|
}
|
|
|
|
export async function saveItem(id: number | null, formData: FormData): Promise<Item> {
|
|
const url = id ? `/api/admin/items/${id}` : '/api/admin/items';
|
|
const res = await expectOk(
|
|
await fetch(url, { method: id ? 'PUT' : 'POST', body: formData }),
|
|
'failed to save item'
|
|
);
|
|
return res.json();
|
|
}
|
|
|
|
export async function deleteItem(id: number): Promise<void> {
|
|
await expectOk(await fetch(`/api/admin/items/${id}`, { method: 'DELETE' }), 'failed to delete item');
|
|
}
|
|
|
|
export async function deleteItemImage(itemId: number, imageId: number): Promise<void> {
|
|
await expectOk(
|
|
await fetch(`/api/admin/items/${itemId}/images/${imageId}`, { method: 'DELETE' }),
|
|
'failed to remove image'
|
|
);
|
|
}
|
|
|
|
export async function markSold(id: number): Promise<Item> {
|
|
const res = await expectOk(
|
|
await fetch(`/api/admin/items/${id}/mark-sold`, { method: 'POST' }),
|
|
'failed to mark sold'
|
|
);
|
|
return res.json();
|
|
}
|
|
|
|
// Publishing a pending item is mark-available: it is the same transition and
|
|
// the same UPDATE, so the admin UI simply labels the button "Publish" when the
|
|
// item is pending rather than calling a second endpoint that does the same
|
|
// thing.
|
|
export async function markAvailable(id: number): Promise<Item> {
|
|
const res = await expectOk(
|
|
await fetch(`/api/admin/items/${id}/mark-available`, { method: 'POST' }),
|
|
'failed to mark available'
|
|
);
|
|
return res.json();
|
|
}
|
|
|
|
// Not symmetrical with the above: the server refuses to unpublish a reserved or
|
|
// sold item and says which, so the message it returns is worth surfacing rather
|
|
// than replacing with a generic one.
|
|
export async function unpublishItem(id: number): Promise<Item> {
|
|
const res = await expectOk(
|
|
await fetch(`/api/admin/items/${id}/unpublish`, { method: 'POST' }),
|
|
'failed to unpublish'
|
|
);
|
|
return res.json();
|
|
}
|
|
|
|
// The admin endpoints return a JSON error body on 4xx; surfacing its message
|
|
// lets the UI say "that name is already used here" instead of a generic
|
|
// failure.
|
|
async function sendJson<T>(url: string, method: string, body?: unknown): Promise<T> {
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: body === undefined ? undefined : JSON.stringify(body)
|
|
});
|
|
if (!res.ok) {
|
|
const detail = await res.json().catch(() => ({ error: 'request failed' }));
|
|
throw new Error(detail.error || 'request failed');
|
|
}
|
|
return res.status === 204 ? (undefined as T) : res.json();
|
|
}
|
|
|
|
export async function fetchAdminCategories(): Promise<Category[]> {
|
|
const res = await fetch('/api/admin/categories');
|
|
return res.json();
|
|
}
|
|
|
|
export function createCategory(name: string, parentId: number | null): Promise<Category> {
|
|
return sendJson('/api/admin/categories', 'POST', { name, parent_id: parentId });
|
|
}
|
|
|
|
export function updateCategory(
|
|
id: number,
|
|
changes: { name?: string; parent_id?: number | null }
|
|
): Promise<Category> {
|
|
return sendJson(`/api/admin/categories/${id}`, 'PUT', changes);
|
|
}
|
|
|
|
export function deleteCategory(
|
|
id: number
|
|
): Promise<{ deleted_categories: number; uncategorized_items: number }> {
|
|
return sendJson(`/api/admin/categories/${id}`, 'DELETE');
|
|
}
|
|
|
|
export async function fetchAdminTags(): Promise<Tag[]> {
|
|
const res = await fetch('/api/admin/tags');
|
|
return res.json();
|
|
}
|
|
|
|
export function createTag(name: string): Promise<Tag> {
|
|
return sendJson('/api/admin/tags', 'POST', { name });
|
|
}
|
|
|
|
export function updateTag(id: number, changes: { name?: string; color?: string }): Promise<Tag> {
|
|
return sendJson(`/api/admin/tags/${id}`, 'PUT', changes);
|
|
}
|
|
|
|
export function deleteTag(id: number): Promise<void> {
|
|
return sendJson(`/api/admin/tags/${id}`, 'DELETE');
|
|
}
|