Skip to content
41 changes: 40 additions & 1 deletion docs/developer-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,48 @@ Returns a ranked page of published results:
}
```

### `GET /api/v1/catalog`

Downloads one shared, lightweight snapshot of every published gear record. It is
slug-sorted and contains only `name`, `slug`, `brandName`, `gearType`,
`thumbnailUrl`, `releaseDate`, `releaseDatePrecision`, `announcedDate`, and
`announceDatePrecision` for each item. It deliberately excludes specifications,
aliases, samples, colourways, prices, IDs, and audit metadata.

```json
{
"version": "sha256-...",
"generatedAt": "2026-07-15T15:00:00.000Z",
"itemCount": 123,
"data": []
}
```

`version` is a SHA-256 hash of the deterministic `data` array. The endpoint
returns the same value as a quoted `ETag`; `generatedAt` is fixed for the cached
snapshot and does not affect the version. Send a standard `If-None-Match` header
to revalidate a locally stored snapshot. Matching strong or weak tags,
comma-separated tags, and `*` receive `304 Not Modified` with `ETag`, request,
rate-limit, and cache headers. A `200` response also includes the normal
`meta.requestId`.

```sh
curl -i -H "Authorization: Bearer sharply_live_..." \
-H 'If-None-Match: "sha256-..."' \
https://www.sharplyphoto.com/api/v1/catalog
```

The endpoint sends `Cache-Control: private, max-age=0, must-revalidate` and
`Vary: Authorization, If-None-Match`. Its shared server snapshot is invalidated
when published catalog membership or an included catalog field changes.

### `GET /api/v1/gear/:slug`

Returns the complete currently publishable catalog record, including available related specifications, aliases, media, samples, and colourways. Database primary keys and audit timestamps are omitted at every nesting level. Hidden and rumored gear return `404`.
Returns the complete currently publishable catalog record, including available related specifications, aliases, media, and colourways. The response keeps its established field names: `brands`, flattened image URL fields, `regionalAliases`, and `colorways`.

The response is an explicit public allowlist. It includes catalog identity, release and price data, dimensions, public links, approved relation fields, and type-specific specification values. `mounts` contains `{ value, shortName }` records from the gear-to-mount relationship. `cameraSpecs.sensorFormat` and `lensSpecs.imageCircle` / `fixedLensSpecs.imageCircle` contain `{ slug, name, cropFactor }` when the referenced sensor format exists, otherwise `null`.

Primary and foreign keys, audit timestamps, search helpers, publication workflow state, genre tags, raw samples, selected colourway IDs, internal notes, video-mode matrices, and flexible `extra` JSON are never returned. Adding a public field requires an intentional serializer, contract, and documentation update. Hidden and rumored gear return `404`.

### `GET /api/v1/specs`

Expand Down
4 changes: 4 additions & 0 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1893,8 +1893,12 @@
"endpointsTitle": "Endpoints",
"searchTitle": "Search gear",
"searchDescription": "Returns a ranked page of published gear matching a query.",
"catalogTitle": "Download catalog",
"catalogDescription": "Returns a lightweight, slug-sorted snapshot of all published gear for local search prefilling.",
"catalogConditionalRequest": "Store the ETag and send it as If-None-Match on the next request. A matching snapshot returns 304 Not Modified.",
"gearTitle": "Get gear detail",
"gearDescription": "Returns the complete published catalog record and its available related data.",
"gearTaxonomyNote": "Mounts use value and shortName. Camera sensorFormat and lens/fixed-lens imageCircle use slug, name, and cropFactor; a missing sensor format is returned as null.",
Comment thread
Flohhhhh marked this conversation as resolved.
"parametersTitle": "Parameters",
"exampleRequest": "Beispielanfrage",
"exampleCurl": "cURL",
Expand Down
4 changes: 4 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1893,8 +1893,12 @@
"endpointsTitle": "Endpoints",
"searchTitle": "Search gear",
"searchDescription": "Returns a ranked page of published gear matching a query.",
"catalogTitle": "Download catalog",
"catalogDescription": "Returns a lightweight, slug-sorted snapshot of all published gear for local search prefilling.",
"catalogConditionalRequest": "Store the ETag and send it as If-None-Match on the next request. A matching snapshot returns 304 Not Modified.",
"gearTitle": "Get gear detail",
"gearDescription": "Returns the complete published catalog record and its available related data.",
"gearTaxonomyNote": "Mounts use value and shortName. Camera sensorFormat and lens/fixed-lens imageCircle use slug, name, and cropFactor; a missing sensor format is returned as null.",
"parametersTitle": "Parameters",
"exampleRequest": "Example request",
"exampleCurl": "cURL",
Expand Down
4 changes: 4 additions & 0 deletions messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -1893,8 +1893,12 @@
"endpointsTitle": "Endpoints",
"searchTitle": "Search gear",
"searchDescription": "Returns a ranked page of published gear matching a query.",
"catalogTitle": "Download catalog",
"catalogDescription": "Returns a lightweight, slug-sorted snapshot of all published gear for local search prefilling.",
"catalogConditionalRequest": "Store the ETag and send it as If-None-Match on the next request. A matching snapshot returns 304 Not Modified.",
"gearTitle": "Get gear detail",
"gearDescription": "Returns the complete published catalog record and its available related data.",
"gearTaxonomyNote": "Mounts use value and shortName. Camera sensorFormat and lens/fixed-lens imageCircle use slug, name, and cropFactor; a missing sensor format is returned as null.",
"parametersTitle": "Parameters",
"exampleRequest": "Solicitud de ejemplo",
"exampleCurl": "cURL",
Expand Down
4 changes: 4 additions & 0 deletions messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -1893,8 +1893,12 @@
"endpointsTitle": "Endpoints",
"searchTitle": "Search gear",
"searchDescription": "Returns a ranked page of published gear matching a query.",
"catalogTitle": "Download catalog",
"catalogDescription": "Returns a lightweight, slug-sorted snapshot of all published gear for local search prefilling.",
"catalogConditionalRequest": "Store the ETag and send it as If-None-Match on the next request. A matching snapshot returns 304 Not Modified.",
"gearTitle": "Get gear detail",
"gearDescription": "Returns the complete published catalog record and its available related data.",
"gearTaxonomyNote": "Mounts use value and shortName. Camera sensorFormat and lens/fixed-lens imageCircle use slug, name, and cropFactor; a missing sensor format is returned as null.",
"parametersTitle": "Parameters",
"exampleRequest": "Exemple de requête",
"exampleCurl": "cURL",
Expand Down
4 changes: 4 additions & 0 deletions messages/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -1893,8 +1893,12 @@
"endpointsTitle": "Endpoints",
"searchTitle": "Search gear",
"searchDescription": "Returns a ranked page of published gear matching a query.",
"catalogTitle": "Download catalog",
"catalogDescription": "Returns a lightweight, slug-sorted snapshot of all published gear for local search prefilling.",
"catalogConditionalRequest": "Store the ETag and send it as If-None-Match on the next request. A matching snapshot returns 304 Not Modified.",
"gearTitle": "Get gear detail",
"gearDescription": "Returns the complete published catalog record and its available related data.",
"gearTaxonomyNote": "Mounts use value and shortName. Camera sensorFormat and lens/fixed-lens imageCircle use slug, name, and cropFactor; a missing sensor format is returned as null.",
"parametersTitle": "Parameters",
"exampleRequest": "Esempio di richiesta",
"exampleCurl": "cURL",
Expand Down
4 changes: 4 additions & 0 deletions messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -1893,8 +1893,12 @@
"endpointsTitle": "Endpoints",
"searchTitle": "Search gear",
"searchDescription": "Returns a ranked page of published gear matching a query.",
"catalogTitle": "Download catalog",
"catalogDescription": "Returns a lightweight, slug-sorted snapshot of all published gear for local search prefilling.",
"catalogConditionalRequest": "Store the ETag and send it as If-None-Match on the next request. A matching snapshot returns 304 Not Modified.",
"gearTitle": "Get gear detail",
"gearDescription": "Returns the complete published catalog record and its available related data.",
"gearTaxonomyNote": "Mounts use value and shortName. Camera sensorFormat and lens/fixed-lens imageCircle use slug, name, and cropFactor; a missing sensor format is returned as null.",
"parametersTitle": "Parameters",
"exampleRequest": "リクエスト例",
"exampleCurl": "cURL",
Expand Down
4 changes: 4 additions & 0 deletions messages/ms.json
Original file line number Diff line number Diff line change
Expand Up @@ -1893,8 +1893,12 @@
"endpointsTitle": "Endpoints",
"searchTitle": "Search gear",
"searchDescription": "Returns a ranked page of published gear matching a query.",
"catalogTitle": "Download catalog",
"catalogDescription": "Returns a lightweight, slug-sorted snapshot of all published gear for local search prefilling.",
"catalogConditionalRequest": "Store the ETag and send it as If-None-Match on the next request. A matching snapshot returns 304 Not Modified.",
"gearTitle": "Get gear detail",
"gearDescription": "Returns the complete published catalog record and its available related data.",
"gearTaxonomyNote": "Mounts use value and shortName. Camera sensorFormat and lens/fixed-lens imageCircle use slug, name, and cropFactor; a missing sensor format is returned as null.",
"parametersTitle": "Parameters",
"exampleRequest": "Contoh permintaan",
"exampleCurl": "cURL",
Expand Down
4 changes: 4 additions & 0 deletions messages/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -1893,8 +1893,12 @@
"endpointsTitle": "Endpoints",
"searchTitle": "Search gear",
"searchDescription": "Returns a ranked page of published gear matching a query.",
"catalogTitle": "Download catalog",
"catalogDescription": "Returns a lightweight, slug-sorted snapshot of all published gear for local search prefilling.",
"catalogConditionalRequest": "Store the ETag and send it as If-None-Match on the next request. A matching snapshot returns 304 Not Modified.",
"gearTitle": "Get gear detail",
"gearDescription": "Returns the complete published catalog record and its available related data.",
"gearTaxonomyNote": "Mounts use value and shortName. Camera sensorFormat and lens/fixed-lens imageCircle use slug, name, and cropFactor; a missing sensor format is returned as null.",
"parametersTitle": "Parameters",
"exampleRequest": "请求示例",
"exampleCurl": "cURL",
Expand Down
78 changes: 73 additions & 5 deletions src/app/[locale]/(pages)/developer/docs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,63 @@ const { data, pagination } = await response.json();`}
</div>
</article>

<article className="border-b py-8">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="text-primary text-sm font-semibold">GET</span>
<code className="font-mono text-sm">/api/v1/catalog</code>
</div>
<h3 className="mt-3 text-xl font-semibold tracking-tight">
{t("catalogTitle")}
</h3>
<p className="text-muted-foreground mt-2 max-w-2xl text-sm leading-6">
{t("catalogDescription")}
</p>
<div className="pl-12">
<h4 className="mt-6 text-sm font-semibold">{t("exampleRequest")}</h4>
<RequestExampleTabs
curl={`curl -sS \\
-H "Authorization: Bearer YOUR_API_KEY" \\
"https://www.sharplyphoto.com/api/v1/catalog"`}
typescript={`const response = await fetch(
"https://www.sharplyphoto.com/api/v1/catalog",
{
headers: {
Authorization: "Bearer " + process.env.SHARPLY_API_KEY,
},
},
);

if (!response.ok) throw new Error("Sharply API request failed");

const catalog = await response.json();`}
/>
<h4 className="mt-6 text-sm font-semibold">{t("responseTitle")}</h4>
<CodeBlock>{`{
"version": "sha256-…",
"generatedAt": "2026-07-15T15:00:00.000Z",
"itemCount": 1,
"data": [{
"name": "Nikon Z6III",
"slug": "nikon-z6iii",
"brandName": "Nikon",
"gearType": "CAMERA",
"thumbnailUrl": "https://…",
"releaseDate": "2024-06-24T00:00:00.000Z",
"releaseDatePrecision": "DAY",
"announcedDate": "2024-06-17T00:00:00.000Z",
"announceDatePrecision": "DAY"
}]
}`}</CodeBlock>
<CodeBlock>{`curl -i \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-H 'If-None-Match: "sha256-…"' \\
"https://www.sharplyphoto.com/api/v1/catalog"`}</CodeBlock>
<p className="text-muted-foreground mt-4 max-w-2xl text-sm leading-6">
{t("catalogConditionalRequest")}
</p>
</div>
</article>

<article className="border-b py-8">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="text-primary text-sm font-semibold">GET</span>
Expand All @@ -210,9 +267,9 @@ const { data, pagination } = await response.json();`}
<RequestExampleTabs
curl={`curl -sS \\
-H "Authorization: Bearer YOUR_API_KEY" \\
"https://www.sharplyphoto.com/api/v1/gear/nikon-z6-iii"`}
"https://www.sharplyphoto.com/api/v1/gear/nikon-z6iii"`}
typescript={`const response = await fetch(
"https://www.sharplyphoto.com/api/v1/gear/nikon-z6-iii",
"https://www.sharplyphoto.com/api/v1/gear/nikon-z6iii",
{
headers: {
Authorization: "Bearer " + process.env.SHARPLY_API_KEY,
Expand All @@ -227,12 +284,23 @@ const { data } = await response.json();`}
<h4 className="mt-6 text-sm font-semibold">{t("responseTitle")}</h4>
<CodeBlock>{`{
"data": {
"slug": "nikon-z6-iii",
"name": "Nikon Z6 III",
"slug": "nikon-z6iii",
"name": "Nikon Z6III",
"gearType": "CAMERA",
"cameraSpecs": { "resolutionMp": "24.5" }
"mounts": [{ "value": "z-nikon", "shortName": "z" }],
"cameraSpecs": {
"sensorFormat": {
"slug": "full-frame",
"name": "Full-frame",
"cropFactor": "1.00"
},
"resolutionMp": "24.50"
}
}
}`}</CodeBlock>
<p className="text-muted-foreground mt-4 max-w-2xl text-sm leading-6">
{t("gearTaxonomyNote")}
</p>
</div>
</article>

Expand Down
10 changes: 7 additions & 3 deletions src/app/[locale]/(pages)/gear/[slug]/@edit/(.)edit/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { EditAlreadyPendingRedirect } from "~/app/[locale]/(pages)/gear/_components/edit-already-pending-redirect";
import { EditGearModal } from "~/app/[locale]/(pages)/gear/_components/edit-gear/edit-gear-modal";
import { auth } from "~/auth";
import { requireRole } from "~/lib/auth/auth-helpers";
Expand Down Expand Up @@ -32,12 +33,15 @@ export default async function EditGearModalPage({
redirect(`/auth/signin?callbackUrl=${encodeURIComponent(editUrl)}`);
}

// Prevent duplicate submissions: if user already has a pending edit for this gear,
// redirect back to gear page with flags to show a toast
// Prevent duplicate submissions: if user already has a pending edit for this
// gear, render a client component that navigates back and shows a toast.
// NOTE: Do NOT use server-side `redirect()` here — it causes a runaway
// request loop because Next.js re-fetches parallel-route interception slots
// on every prefetch/soft-nav, and each fetch triggers a new redirect.
if (session?.user) {
const pendingId = await fetchPendingEditId(slug).catch(() => null);
if (pendingId) {
redirect(`/gear/${slug}?editAlreadyPending=1&id=${pendingId}`);
return <EditAlreadyPendingRedirect slug={slug} pendingId={pendingId} />;
}
}

Expand Down
9 changes: 7 additions & 2 deletions src/app/[locale]/(pages)/gear/[slug]/edit/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { EditAlreadyPendingRedirect } from "~/app/[locale]/(pages)/gear/_components/edit-already-pending-redirect";
import EditGearClient from "~/app/[locale]/(pages)/gear/_components/edit-gear/edit-gear-page-client";
import { auth } from "~/auth";
import { requireRole } from "~/lib/auth/auth-helpers";
Expand Down Expand Up @@ -49,11 +50,15 @@ export default async function EditGearPage({
redirect(`/auth/signin?callbackUrl=${encodeURIComponent(editUrl)}`);
}

// Prevent duplicate submissions: redirect back if user already has a pending edit
// Prevent duplicate submissions: if user already has a pending edit,
// render a client component that navigates back and shows a toast.
// NOTE: Do NOT use server-side `redirect()` here — in the intercepting
// route variant this causes a runaway request loop; keep both routes
// consistent to avoid the same class of issue on hard navigations.
if (session?.user) {
const pendingId = await fetchPendingEditId(slug).catch(() => null);
if (pendingId) {
redirect(`/gear/${slug}?editAlreadyPending=1&id=${pendingId}`);
return <EditAlreadyPendingRedirect slug={slug} pendingId={pendingId} />;
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/app/[locale]/(pages)/gear/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
} from "~/server/popularity/service";
import { AiReviewBanner } from "../_components/ai-review-banner";
import { CreatorVideosSection } from "../_components/creator-videos-section";
import { EditAlreadyPendingToast } from "../_components/edit-already-pending-toast";
import { EditAppliedToast } from "../_components/edit-applied-toast";
import { GearAlternativesSection } from "../_components/gear-alternatives-section";
import { buildGearBreadcrumbItems } from "../_components/gear-breadcrumb-items";
Expand Down Expand Up @@ -256,6 +257,7 @@ export default async function GearPage({ params }: GearPageProps) {
<main className="mx-auto max-w-7xl space-y-8 px-4 pt-20 sm:px-6">
<Suspense fallback={null}>
<EditAppliedToast />
<EditAlreadyPendingToast />
</Suspense>
<GearItemDock
slug={slug}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use client";

import { useRouter } from "next/navigation";
import { useEffect } from "react";

/**
* Client component that handles the "already has a pending edit" case.
*
* Rendered by the (.)edit intercepting route instead of a server-side
* `redirect()`. A server redirect inside a parallel-route interception slot
* causes the Next.js router to re-fetch the slot on every prefetch / soft-nav,
* producing a runaway request loop (~100+ req/s from a single tab).
*
* This component navigates back on the client and shows a toast, which avoids
* the RSC re-fetch loop entirely.
*/
export function EditAlreadyPendingRedirect({
slug,
pendingId,
}: {
slug: string;
pendingId: string;
}) {
const router = useRouter();

useEffect(() => {
router.replace(`/gear/${slug}?editAlreadyPending=1&id=${pendingId}`, {
scroll: false,
});
}, [router, slug, pendingId]);

return null;
}
Comment thread
Flohhhhh marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"use client";

import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { toast } from "sonner";

/**
* Reads the `editAlreadyPending` search param set by the edit route redirect,
* shows a user-facing toast, and cleans up the URL.
*
* Mirrors the pattern used by `EditAppliedToast` for the `editApplied` param.
*/
export function EditAlreadyPendingToast() {
const rawPathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();

useEffect(() => {
if (searchParams.get("editAlreadyPending") !== "1") return;

toast.info("You already have a pending edit for this item.", {
description: "Your previous suggestion is still under review.",
});

Comment thread
Flohhhhh marked this conversation as resolved.
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.delete("editAlreadyPending");
nextParams.delete("id");

router.replace(
nextParams.size > 0
? `${rawPathname}?${nextParams.toString()}`
: rawPathname,
{ scroll: false },
);
}, [rawPathname, router, searchParams]);

return null;
}
Loading
Loading