emdash-plugin-avdeb 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/package.json +56 -0
- package/src/admin.ts +273 -0
- package/src/astro/AvdebButton.astro +24 -0
- package/src/astro/AvdebProduct.astro +49 -0
- package/src/astro/AvdebProducts.astro +68 -0
- package/src/astro/Button.astro +131 -0
- package/src/astro/ButtonBlock.astro +15 -0
- package/src/astro/Embed.astro +1192 -0
- package/src/astro/ProductBlock.astro +24 -0
- package/src/astro/ProductsBlock.astro +15 -0
- package/src/astro/index.ts +15 -0
- package/src/astro/load.ts +68 -0
- package/src/blocks.ts +258 -0
- package/src/catalog.ts +292 -0
- package/src/client.ts +304 -0
- package/src/constants.ts +53 -0
- package/src/index.ts +125 -0
- package/src/payload.ts +78 -0
- package/src/routes.ts +100 -0
- package/src/settings.ts +243 -0
- package/src/ui.ts +14 -0
- package/src/view.ts +167 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Portable Text renderer for `avdeb-product` blocks (one featured product). */
|
|
3
|
+
import { productRequest } from "../blocks.ts";
|
|
4
|
+
import Embed from "./Embed.astro";
|
|
5
|
+
import { loadEmbed } from "./load.ts";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
node: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const req = productRequest(Astro.props.node ?? {});
|
|
12
|
+
const data = req.handle ? await loadEmbed(Astro.locals, Astro.url, { handle: req.handle }) : null;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
{
|
|
16
|
+
data && (
|
|
17
|
+
<Embed
|
|
18
|
+
data={data}
|
|
19
|
+
overrides={req.overrides}
|
|
20
|
+
viewAll={false}
|
|
21
|
+
single={{ variant: req.variant, badge: req.badge, note: req.note }}
|
|
22
|
+
/>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Portable Text renderer for `avdeb-products` blocks. */
|
|
3
|
+
import { productsRequest } from "../blocks.ts";
|
|
4
|
+
import Embed from "./Embed.astro";
|
|
5
|
+
import { loadEmbed } from "./load.ts";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
node: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const req = productsRequest(Astro.props.node ?? {});
|
|
12
|
+
const data = await loadEmbed(Astro.locals, Astro.url, req.query);
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
{data && <Embed data={data} overrides={req.overrides} heading={req.heading} intro={req.intro} viewAll={req.viewAll} />}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site-side Portable Text renderers. EmDash merges `blockComponents` into <PortableText>
|
|
3
|
+
* automatically; a site can still override any type via its own `components.types`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { BLOCK_TYPES } from "../constants.ts";
|
|
7
|
+
import ButtonBlock from "./ButtonBlock.astro";
|
|
8
|
+
import ProductBlock from "./ProductBlock.astro";
|
|
9
|
+
import ProductsBlock from "./ProductsBlock.astro";
|
|
10
|
+
|
|
11
|
+
export const blockComponents = {
|
|
12
|
+
[BLOCK_TYPES.products]: ProductsBlock,
|
|
13
|
+
[BLOCK_TYPES.product]: ProductBlock,
|
|
14
|
+
[BLOCK_TYPES.button]: ButtonBlock,
|
|
15
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side data loading for the Astro components. Calls the plugin's public routes in-process
|
|
3
|
+
* through EmDash's public route dispatcher (no HTTP round-trip), and de-duplicates identical
|
|
4
|
+
* requests within one page render.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getPublicPluginApiRouteHandler, type PublicPluginRuntimeLocals } from "emdash/plugin-utils";
|
|
8
|
+
|
|
9
|
+
import { PLUGIN_ID } from "../constants.ts";
|
|
10
|
+
import { parseContextPayload, parseEmbedPayload, type ContextData, type EmbedData } from "../payload.ts";
|
|
11
|
+
|
|
12
|
+
type Route = "products" | "context";
|
|
13
|
+
|
|
14
|
+
const perRequest = new WeakMap<object, Map<string, Promise<unknown>>>();
|
|
15
|
+
|
|
16
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
17
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function call(locals: unknown, base: URL, route: Route, body: unknown): Promise<unknown> {
|
|
21
|
+
const handler = getPublicPluginApiRouteHandler(locals as PublicPluginRuntimeLocals);
|
|
22
|
+
const request = new Request(new URL(`/_emdash/api/plugins/${PLUGIN_ID}/${route}`, base), {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { "Content-Type": "application/json" },
|
|
25
|
+
body: JSON.stringify(body),
|
|
26
|
+
});
|
|
27
|
+
try {
|
|
28
|
+
if (handler) {
|
|
29
|
+
const res = await handler(PLUGIN_ID, "POST", `/${route}`, request);
|
|
30
|
+
return res.success ? res.data : null;
|
|
31
|
+
}
|
|
32
|
+
// No in-process dispatcher (unusual setups): fall back to the public HTTP route.
|
|
33
|
+
const res = await fetch(request);
|
|
34
|
+
if (!res.ok) return null;
|
|
35
|
+
const json: unknown = await res.json();
|
|
36
|
+
return isRecord(json) ? json.data : null;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.warn(`[${PLUGIN_ID}] ${route} failed:`, error);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function memo<T>(locals: unknown, key: string, run: () => Promise<T>): Promise<T> {
|
|
44
|
+
if (!isRecord(locals)) return run();
|
|
45
|
+
let map = perRequest.get(locals);
|
|
46
|
+
if (!map) {
|
|
47
|
+
map = new Map();
|
|
48
|
+
perRequest.set(locals, map);
|
|
49
|
+
}
|
|
50
|
+
let hit = map.get(key) as Promise<T> | undefined;
|
|
51
|
+
if (!hit) {
|
|
52
|
+
hit = run();
|
|
53
|
+
map.set(key, hit);
|
|
54
|
+
}
|
|
55
|
+
return hit;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Products for a query (unsanitised; the route validates it). Null when nothing can be shown. */
|
|
59
|
+
export function loadEmbed(locals: unknown, base: URL, query: Record<string, unknown>): Promise<EmbedData | null> {
|
|
60
|
+
return memo(locals, `p:${JSON.stringify(query)}`, async () =>
|
|
61
|
+
parseEmbedPayload(await call(locals, base, "products", query)),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Referral code + display settings (buttons). */
|
|
66
|
+
export function loadContext(locals: unknown, base: URL): Promise<ContextData | null> {
|
|
67
|
+
return memo(locals, "ctx", async () => parseContextPayload(await call(locals, base, "context", {})));
|
|
68
|
+
}
|
package/src/blocks.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable Text block types (editor slash menu → Block Kit modal) and the mapping from a saved
|
|
3
|
+
* block node to a catalog request. Pure module.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { PortableTextBlockConfig } from "emdash";
|
|
7
|
+
|
|
8
|
+
import { cleanSearch, cleanSlug, cleanType, parseAvdebLink } from "./catalog.ts";
|
|
9
|
+
import {
|
|
10
|
+
ALIGNMENTS,
|
|
11
|
+
BLOCK_TYPES,
|
|
12
|
+
BUTTON_VARIANTS,
|
|
13
|
+
oneOf,
|
|
14
|
+
PRODUCT_VARIANTS,
|
|
15
|
+
type Alignment,
|
|
16
|
+
type ButtonVariant,
|
|
17
|
+
type ProductVariant,
|
|
18
|
+
} from "./constants.ts";
|
|
19
|
+
import type { DisplayOverrides } from "./view.ts";
|
|
20
|
+
|
|
21
|
+
const DEFAULT = { label: "Site default", value: "default" };
|
|
22
|
+
|
|
23
|
+
const layoutOptions = [
|
|
24
|
+
DEFAULT,
|
|
25
|
+
{ label: "Grid", value: "grid" },
|
|
26
|
+
{ label: "Carousel", value: "carousel" },
|
|
27
|
+
{ label: "List (shop the post)", value: "list" },
|
|
28
|
+
{ label: "Spotlight (first product featured)", value: "spotlight" },
|
|
29
|
+
];
|
|
30
|
+
const styleOptions = [
|
|
31
|
+
DEFAULT,
|
|
32
|
+
{ label: "Elevated", value: "elevated" },
|
|
33
|
+
{ label: "Outline", value: "outline" },
|
|
34
|
+
{ label: "Minimal", value: "minimal" },
|
|
35
|
+
];
|
|
36
|
+
const themeOptions = [
|
|
37
|
+
DEFAULT,
|
|
38
|
+
{ label: "Inherit from site", value: "inherit" },
|
|
39
|
+
{ label: "Light", value: "light" },
|
|
40
|
+
{ label: "Dark", value: "dark" },
|
|
41
|
+
{ label: "Follow system", value: "auto" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
export const portableTextBlocks: PortableTextBlockConfig[] = [
|
|
45
|
+
{
|
|
46
|
+
type: BLOCK_TYPES.products,
|
|
47
|
+
label: "AVDEB Products",
|
|
48
|
+
icon: "link-external",
|
|
49
|
+
category: "Marketing",
|
|
50
|
+
description: "A grid, carousel or list of AVDEB products with your referral link",
|
|
51
|
+
fields: [
|
|
52
|
+
{
|
|
53
|
+
type: "text_input",
|
|
54
|
+
action_id: "id",
|
|
55
|
+
label: "AVDEB link or search",
|
|
56
|
+
placeholder: "https://avdeb.com/creators/your-slug/ · /category/stickers/ · cat stickers",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
type: "select",
|
|
60
|
+
action_id: "source",
|
|
61
|
+
label: "Show",
|
|
62
|
+
options: [
|
|
63
|
+
{ label: "Auto-detect from the link or text", value: "auto" },
|
|
64
|
+
{ label: "A creator's products (slug)", value: "creator" },
|
|
65
|
+
{ label: "A category (slug)", value: "tag" },
|
|
66
|
+
{ label: "A product type (e.g. Sticker)", value: "type" },
|
|
67
|
+
{ label: "Search results", value: "search" },
|
|
68
|
+
{ label: "Newest products", value: "newest" },
|
|
69
|
+
],
|
|
70
|
+
initial_value: "auto",
|
|
71
|
+
},
|
|
72
|
+
{ type: "select", action_id: "layout", label: "Layout", options: layoutOptions, initial_value: "default" },
|
|
73
|
+
{ type: "number_input", action_id: "limit", label: "Number of products (max 12 without a key, 48 with)", min: 1, max: 48, initial_value: 6 },
|
|
74
|
+
{ type: "number_input", action_id: "columns", label: "Columns (0 = site default)", min: 0, max: 6, initial_value: 0 },
|
|
75
|
+
{ type: "text_input", action_id: "heading", label: "Heading (optional)", placeholder: "Shop my favourite stickers" },
|
|
76
|
+
{ type: "text_input", action_id: "intro", label: "Intro text (optional)", multiline: true },
|
|
77
|
+
{ type: "toggle", action_id: "viewAll", label: "“View all” link to the creator or category page", initial_value: true },
|
|
78
|
+
{ type: "select", action_id: "cardStyle", label: "Card style", options: styleOptions, initial_value: "default" },
|
|
79
|
+
{ type: "select", action_id: "theme", label: "Colour theme", options: themeOptions, initial_value: "default" },
|
|
80
|
+
{ type: "text_input", action_id: "ctaLabel", label: "Button text (optional)", placeholder: "View product" },
|
|
81
|
+
{ type: "toggle", action_id: "hidePrice", label: "Hide prices", initial_value: false },
|
|
82
|
+
{ type: "toggle", action_id: "hideCreator", label: "Hide designer names", initial_value: false },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
type: BLOCK_TYPES.product,
|
|
87
|
+
label: "AVDEB Product",
|
|
88
|
+
icon: "link-external",
|
|
89
|
+
category: "Marketing",
|
|
90
|
+
description: "Feature one AVDEB product with your own recommendation",
|
|
91
|
+
fields: [
|
|
92
|
+
{
|
|
93
|
+
type: "text_input",
|
|
94
|
+
action_id: "id",
|
|
95
|
+
label: "Product link or handle",
|
|
96
|
+
placeholder: "https://avdeb.com/store/product-handle/",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
type: "select",
|
|
100
|
+
action_id: "pick",
|
|
101
|
+
label: "…or pick a recent product",
|
|
102
|
+
options: [],
|
|
103
|
+
optionsRoute: "options/products",
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
type: "select",
|
|
107
|
+
action_id: "variant",
|
|
108
|
+
label: "Style",
|
|
109
|
+
options: [
|
|
110
|
+
{ label: "Horizontal card", value: "horizontal" },
|
|
111
|
+
{ label: "Hero (large)", value: "hero" },
|
|
112
|
+
{ label: "Compact card", value: "card" },
|
|
113
|
+
],
|
|
114
|
+
initial_value: "horizontal",
|
|
115
|
+
},
|
|
116
|
+
{ type: "text_input", action_id: "badge", label: "Badge (optional)", placeholder: "Editor's pick" },
|
|
117
|
+
{ type: "text_input", action_id: "note", label: "Your note (optional)", multiline: true, placeholder: "Why you recommend it" },
|
|
118
|
+
{ type: "text_input", action_id: "ctaLabel", label: "Button text (optional)", placeholder: "View product" },
|
|
119
|
+
{ type: "select", action_id: "cardStyle", label: "Card style", options: styleOptions, initial_value: "default" },
|
|
120
|
+
{ type: "select", action_id: "theme", label: "Colour theme", options: themeOptions, initial_value: "default" },
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
type: BLOCK_TYPES.button,
|
|
125
|
+
label: "AVDEB Button",
|
|
126
|
+
icon: "link",
|
|
127
|
+
category: "Marketing",
|
|
128
|
+
description: "A call-to-action button to any AVDEB page, with your referral code",
|
|
129
|
+
fields: [
|
|
130
|
+
{ type: "text_input", action_id: "id", label: "AVDEB link", placeholder: "https://avdeb.com/creators/your-slug/" },
|
|
131
|
+
{ type: "text_input", action_id: "label", label: "Button text", initial_value: "Shop on AVDEB" },
|
|
132
|
+
{
|
|
133
|
+
type: "select",
|
|
134
|
+
action_id: "variant",
|
|
135
|
+
label: "Style",
|
|
136
|
+
options: [
|
|
137
|
+
{ label: "Solid", value: "solid" },
|
|
138
|
+
{ label: "Outline", value: "outline" },
|
|
139
|
+
{ label: "Text link with arrow", value: "link" },
|
|
140
|
+
],
|
|
141
|
+
initial_value: "solid",
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
type: "select",
|
|
145
|
+
action_id: "align",
|
|
146
|
+
label: "Alignment",
|
|
147
|
+
options: [
|
|
148
|
+
{ label: "Start", value: "start" },
|
|
149
|
+
{ label: "Center", value: "center" },
|
|
150
|
+
{ label: "End", value: "end" },
|
|
151
|
+
],
|
|
152
|
+
initial_value: "start",
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
type Node = Record<string, unknown>;
|
|
159
|
+
|
|
160
|
+
function s(v: unknown): string {
|
|
161
|
+
return typeof v === "string" ? v.trim() : "";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function overridesOf(node: Node): DisplayOverrides {
|
|
165
|
+
const pick = (v: unknown) => (v === "default" || v === "" ? undefined : v);
|
|
166
|
+
return {
|
|
167
|
+
layout: pick(node.layout),
|
|
168
|
+
columns: node.columns,
|
|
169
|
+
cardStyle: pick(node.cardStyle),
|
|
170
|
+
theme: pick(node.theme),
|
|
171
|
+
ctaLabel: node.ctaLabel,
|
|
172
|
+
hidePrice: node.hidePrice,
|
|
173
|
+
hideCreator: node.hideCreator,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface ProductsRequest {
|
|
178
|
+
/** Unsanitised query for the route (it re-validates and clamps to the tier max). */
|
|
179
|
+
query: Record<string, unknown>;
|
|
180
|
+
overrides: DisplayOverrides;
|
|
181
|
+
heading: string;
|
|
182
|
+
intro: string;
|
|
183
|
+
viewAll: boolean;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** `avdeb-products` node → catalog request. */
|
|
187
|
+
export function productsRequest(node: Node): ProductsRequest {
|
|
188
|
+
const raw = s(node.id);
|
|
189
|
+
const source = s(node.source) || "auto";
|
|
190
|
+
const link = parseAvdebLink(raw);
|
|
191
|
+
const query: Record<string, unknown> = { limit: node.limit };
|
|
192
|
+
const slug = (kind: "creator" | "tag") => (link?.kind === kind ? link.value : cleanSlug(raw));
|
|
193
|
+
|
|
194
|
+
switch (source) {
|
|
195
|
+
case "creator":
|
|
196
|
+
query.creator = slug("creator");
|
|
197
|
+
break;
|
|
198
|
+
case "tag":
|
|
199
|
+
query.tag = slug("tag");
|
|
200
|
+
break;
|
|
201
|
+
case "type":
|
|
202
|
+
query.type = cleanType(raw);
|
|
203
|
+
break;
|
|
204
|
+
case "search":
|
|
205
|
+
query.q = cleanSearch(raw);
|
|
206
|
+
break;
|
|
207
|
+
case "newest":
|
|
208
|
+
break;
|
|
209
|
+
default:
|
|
210
|
+
if (link) query[link.kind] = link.value;
|
|
211
|
+
else if (raw) query.q = cleanSearch(raw);
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
query,
|
|
215
|
+
overrides: overridesOf(node),
|
|
216
|
+
heading: s(node.heading).slice(0, 120),
|
|
217
|
+
intro: s(node.intro).slice(0, 400),
|
|
218
|
+
viewAll: node.viewAll !== false,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface ProductRequest {
|
|
223
|
+
handle: string;
|
|
224
|
+
variant: ProductVariant;
|
|
225
|
+
badge: string;
|
|
226
|
+
note: string;
|
|
227
|
+
overrides: DisplayOverrides;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** `avdeb-product` node → one product. A link/handle in `id` wins over the picker. */
|
|
231
|
+
export function productRequest(node: Node): ProductRequest {
|
|
232
|
+
const raw = s(node.id);
|
|
233
|
+
const link = parseAvdebLink(raw);
|
|
234
|
+
const fromId = link ? (link.kind === "handle" ? link.value : "") : /[/.:]/.test(raw) ? "" : cleanSlug(raw);
|
|
235
|
+
return {
|
|
236
|
+
handle: fromId || cleanSlug(node.pick),
|
|
237
|
+
variant: oneOf(node.variant, PRODUCT_VARIANTS) ?? "horizontal",
|
|
238
|
+
badge: s(node.badge).slice(0, 40),
|
|
239
|
+
note: s(node.note).slice(0, 600),
|
|
240
|
+
overrides: overridesOf(node),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface ButtonRequest {
|
|
245
|
+
href: string;
|
|
246
|
+
label: string;
|
|
247
|
+
variant: ButtonVariant;
|
|
248
|
+
align: Alignment;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function buttonRequest(node: Node): ButtonRequest {
|
|
252
|
+
return {
|
|
253
|
+
href: s(node.id) || "/",
|
|
254
|
+
label: s(node.label).slice(0, 80) || "Shop on AVDEB",
|
|
255
|
+
variant: oneOf(node.variant, BUTTON_VARIANTS) ?? "solid",
|
|
256
|
+
align: oneOf(node.align, ALIGNMENTS) ?? "start",
|
|
257
|
+
};
|
|
258
|
+
}
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AVDEB catalog API contract: query sanitising, response validation, link helpers.
|
|
3
|
+
* Pure module — safe to import from routes, Astro components and tests.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
API_BASE,
|
|
8
|
+
DEFAULT_LIMIT,
|
|
9
|
+
MAX_LIMIT_ANONYMOUS,
|
|
10
|
+
MAX_LIMIT_SECRET,
|
|
11
|
+
PUBLISHABLE_KEY_PREFIX,
|
|
12
|
+
SECRET_KEY_RE,
|
|
13
|
+
SITE_ORIGIN,
|
|
14
|
+
} from "./constants.ts";
|
|
15
|
+
|
|
16
|
+
export interface CatalogQuery {
|
|
17
|
+
creator?: string;
|
|
18
|
+
tag?: string;
|
|
19
|
+
type?: string;
|
|
20
|
+
handle?: string;
|
|
21
|
+
q?: string;
|
|
22
|
+
limit: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ProductCreator {
|
|
26
|
+
slug: string;
|
|
27
|
+
name: string;
|
|
28
|
+
url: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Product {
|
|
32
|
+
handle: string;
|
|
33
|
+
title: string;
|
|
34
|
+
url: string;
|
|
35
|
+
image: string | null;
|
|
36
|
+
price: number | null;
|
|
37
|
+
compareAtPrice: number | null;
|
|
38
|
+
currency: string;
|
|
39
|
+
type: string | null;
|
|
40
|
+
tags: string[];
|
|
41
|
+
creator: ProductCreator | null;
|
|
42
|
+
createdAt: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type Tier = "anonymous" | "publishable" | "secret";
|
|
46
|
+
|
|
47
|
+
export interface ProductsResult {
|
|
48
|
+
products: Product[];
|
|
49
|
+
ref: string;
|
|
50
|
+
tier: Tier;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface KeyOwner {
|
|
54
|
+
type: string;
|
|
55
|
+
name: string;
|
|
56
|
+
ref: string;
|
|
57
|
+
creatorSlug: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type KeyProblem = "publishable" | "malformed";
|
|
61
|
+
|
|
62
|
+
const SLUG_RE = /[^a-z0-9-]+/g;
|
|
63
|
+
const TYPE_RE = /[^\p{L}\p{N} &'-]+/gu;
|
|
64
|
+
// eslint-disable-next-line no-control-regex -- strip control characters from search input
|
|
65
|
+
const CONTROL_RE = /[\u0000-\u001f\u007f]+/g;
|
|
66
|
+
const CURRENCY_RE = /^[A-Z]{3}$/;
|
|
67
|
+
|
|
68
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
69
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function str(value: unknown): string {
|
|
73
|
+
return typeof value === "string" ? value : typeof value === "number" ? String(value) : "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Referral codes are letters, digits, "-" and "_" (as issued in the AVDEB affiliate dashboard). */
|
|
77
|
+
export function cleanRef(code: unknown): string {
|
|
78
|
+
return str(code).replace(/[^A-Za-z0-9_-]/g, "").slice(0, 40);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Lower-case URL slug (creator, category, product handle). */
|
|
82
|
+
export function cleanSlug(value: unknown): string {
|
|
83
|
+
return str(value).trim().toLowerCase().replace(SLUG_RE, "-").replace(/^-+|-+$/g, "").slice(0, 100);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Product type such as "Sticker" or "Phone Case". */
|
|
87
|
+
export function cleanType(value: unknown): string {
|
|
88
|
+
return str(value).replace(TYPE_RE, "").replace(/\s+/g, " ").trim().slice(0, 40);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Free-text search phrase. */
|
|
92
|
+
export function cleanSearch(value: unknown): string {
|
|
93
|
+
return str(value).replace(CONTROL_RE, " ").replace(/\s+/g, " ").trim().slice(0, 100);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** `null` for a usable secret key, otherwise why it can't be used. Empty input is not a problem. */
|
|
97
|
+
export function keyProblem(key: unknown): KeyProblem | null {
|
|
98
|
+
const k = str(key).trim();
|
|
99
|
+
if (k === "" || SECRET_KEY_RE.test(k)) return null;
|
|
100
|
+
return k.startsWith(PUBLISHABLE_KEY_PREFIX) ? "publishable" : "malformed";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The key when it is a well-formed secret key, otherwise "". */
|
|
104
|
+
export function usableKey(key: unknown): string {
|
|
105
|
+
const k = str(key).trim();
|
|
106
|
+
return SECRET_KEY_RE.test(k) ? k : "";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function maxLimit(hasKey: boolean): number {
|
|
110
|
+
return hasKey ? MAX_LIMIT_SECRET : MAX_LIMIT_ANONYMOUS;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function isAvdebUrl(url: unknown): url is string {
|
|
114
|
+
if (typeof url !== "string") return false;
|
|
115
|
+
try {
|
|
116
|
+
const u = new URL(url);
|
|
117
|
+
const host = u.hostname.toLowerCase();
|
|
118
|
+
return u.protocol === "https:" && (host === "avdeb.com" || host === "www.avdeb.com");
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Absolute avdeb.com URL for an avdeb.com URL or a site path ("/store/cat/"), with `ref` set when
|
|
126
|
+
* given. Anything pointing elsewhere returns "" so callers never link off-site by accident.
|
|
127
|
+
*/
|
|
128
|
+
export function avdebLink(input: unknown, ref = ""): string {
|
|
129
|
+
let raw = str(input).trim();
|
|
130
|
+
if (raw === "") return "";
|
|
131
|
+
if (raw.startsWith("/") && !raw.startsWith("//")) raw = SITE_ORIGIN + raw;
|
|
132
|
+
else if (/^(www\.)?avdeb\.com(\/|$)/i.test(raw)) raw = "https://" + raw;
|
|
133
|
+
if (!isAvdebUrl(raw)) return "";
|
|
134
|
+
const u = new URL(raw);
|
|
135
|
+
const code = cleanRef(ref);
|
|
136
|
+
if (code !== "") u.searchParams.set("ref", code);
|
|
137
|
+
return u.toString();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function hasRef(url: string): boolean {
|
|
141
|
+
try {
|
|
142
|
+
return new URL(url).searchParams.has("ref");
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type LinkTarget = { kind: "creator" | "tag" | "handle"; value: string };
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Recognise an AVDEB page a user pasted: /creators/{slug}/, /category/{slug}/, /store/{handle}/.
|
|
152
|
+
* Accepts full URLs, scheme-less "avdeb.com/…" and bare paths.
|
|
153
|
+
*/
|
|
154
|
+
export function parseAvdebLink(input: unknown): LinkTarget | null {
|
|
155
|
+
const raw = str(input).trim();
|
|
156
|
+
if (raw === "") return null;
|
|
157
|
+
let path: string;
|
|
158
|
+
if (raw.startsWith("/") && !raw.startsWith("//")) {
|
|
159
|
+
path = raw;
|
|
160
|
+
} else {
|
|
161
|
+
const withScheme = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
|
|
162
|
+
try {
|
|
163
|
+
const u = new URL(withScheme);
|
|
164
|
+
const host = u.hostname.toLowerCase();
|
|
165
|
+
if (host !== "avdeb.com" && host !== "www.avdeb.com") return null;
|
|
166
|
+
path = u.pathname;
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const [section, slug] = path.split(/[?#]/)[0]!.split("/").filter(Boolean);
|
|
172
|
+
const value = cleanSlug(slug);
|
|
173
|
+
if (!section || value === "") return null;
|
|
174
|
+
const kind = ({ creators: "creator", category: "tag", store: "handle" } as const)[
|
|
175
|
+
section.toLowerCase() as "creators" | "category" | "store"
|
|
176
|
+
];
|
|
177
|
+
return kind ? { kind, value } : null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Validate an untrusted query (route input). Unknown keys are ignored; an empty filter set means
|
|
182
|
+
* "newest products". `limit` is clamped to the tier max; a handle always means one product.
|
|
183
|
+
*/
|
|
184
|
+
export function sanitizeQuery(input: unknown, max: number): CatalogQuery {
|
|
185
|
+
const src = isRecord(input) ? input : {};
|
|
186
|
+
const query: CatalogQuery = { limit: DEFAULT_LIMIT };
|
|
187
|
+
const creator = cleanSlug(src.creator);
|
|
188
|
+
const tag = cleanSlug(src.tag);
|
|
189
|
+
const type = cleanType(src.type);
|
|
190
|
+
const handle = cleanSlug(src.handle);
|
|
191
|
+
const q = cleanSearch(src.q);
|
|
192
|
+
if (creator) query.creator = creator;
|
|
193
|
+
if (tag) query.tag = tag;
|
|
194
|
+
if (type) query.type = type;
|
|
195
|
+
if (handle) query.handle = handle;
|
|
196
|
+
if (q) query.q = q;
|
|
197
|
+
const limit = Math.trunc(Number(src.limit));
|
|
198
|
+
query.limit = handle ? 1 : Number.isFinite(limit) && limit > 0 ? Math.min(max, limit) : Math.min(max, DEFAULT_LIMIT);
|
|
199
|
+
return query;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Stable string for a query (cache key, request de-duplication). */
|
|
203
|
+
export function queryKey(q: CatalogQuery): string {
|
|
204
|
+
return [q.creator ?? "", q.tag ?? "", q.type ?? "", q.handle ?? "", q.q ?? "", q.limit].join("|");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** GET /v1/catalog/products URL. `ref` is only sent without a key (key holders get their own code). */
|
|
208
|
+
export function productsUrl(q: CatalogQuery, ref = ""): string {
|
|
209
|
+
const params = new URLSearchParams();
|
|
210
|
+
for (const k of ["creator", "tag", "type", "handle", "q"] as const) {
|
|
211
|
+
const v = q[k];
|
|
212
|
+
if (v) params.set(k, v);
|
|
213
|
+
}
|
|
214
|
+
params.set("limit", String(q.limit));
|
|
215
|
+
const code = cleanRef(ref);
|
|
216
|
+
if (code) params.set("ref", code);
|
|
217
|
+
return `${API_BASE}/products?${params.toString()}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function money(value: unknown): number | null {
|
|
221
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Keep only fields the plugin renders, validated. `null` for anything unusable. */
|
|
225
|
+
export function normalizeProduct(raw: unknown): Product | null {
|
|
226
|
+
if (!isRecord(raw)) return null;
|
|
227
|
+
const handle = cleanSlug(raw.handle);
|
|
228
|
+
const title = str(raw.title).replace(CONTROL_RE, " ").trim().slice(0, 200);
|
|
229
|
+
const url = str(raw.url);
|
|
230
|
+
if (!handle || !title || !isAvdebUrl(url)) return null;
|
|
231
|
+
|
|
232
|
+
const image = str(raw.image);
|
|
233
|
+
const currency = str(raw.currency).toUpperCase();
|
|
234
|
+
let creator: ProductCreator | null = null;
|
|
235
|
+
if (isRecord(raw.creator)) {
|
|
236
|
+
const slug = cleanSlug(raw.creator.slug);
|
|
237
|
+
if (slug) {
|
|
238
|
+
const creatorUrl = str(raw.creator.url);
|
|
239
|
+
creator = {
|
|
240
|
+
slug,
|
|
241
|
+
name: str(raw.creator.name).trim().slice(0, 120) || slug,
|
|
242
|
+
url: isAvdebUrl(creatorUrl) ? creatorUrl : null,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const createdAt = str(raw.created_at);
|
|
247
|
+
return {
|
|
248
|
+
handle,
|
|
249
|
+
title,
|
|
250
|
+
url,
|
|
251
|
+
image: image.startsWith("https://") ? image : null,
|
|
252
|
+
price: money(raw.price),
|
|
253
|
+
compareAtPrice: money(raw.compare_at_price),
|
|
254
|
+
currency: CURRENCY_RE.test(currency) ? currency : "USD",
|
|
255
|
+
type: cleanType(raw.type) || null,
|
|
256
|
+
tags: Array.isArray(raw.tags) ? raw.tags.map(cleanSlug).filter(Boolean).slice(0, 20) : [],
|
|
257
|
+
creator,
|
|
258
|
+
createdAt: createdAt && !Number.isNaN(Date.parse(createdAt)) ? createdAt : null,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function parseProductsResponse(json: unknown): ProductsResult | null {
|
|
263
|
+
if (!isRecord(json) || !Array.isArray(json.products)) return null;
|
|
264
|
+
const products: Product[] = [];
|
|
265
|
+
const seen = new Set<string>();
|
|
266
|
+
for (const raw of json.products) {
|
|
267
|
+
const p = normalizeProduct(raw);
|
|
268
|
+
if (p && !seen.has(p.handle)) {
|
|
269
|
+
seen.add(p.handle);
|
|
270
|
+
products.push(p);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const tier = json.tier === "secret" || json.tier === "publishable" ? json.tier : "anonymous";
|
|
274
|
+
return { products, ref: cleanRef(json.ref), tier };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function parseKeyResponse(json: unknown): KeyOwner | null {
|
|
278
|
+
if (!isRecord(json) || !isRecord(json.owner)) return null;
|
|
279
|
+
const o = json.owner;
|
|
280
|
+
return {
|
|
281
|
+
type: str(o.type).replace(/[^a-z_-]/gi, "").toLowerCase().slice(0, 20),
|
|
282
|
+
name: str(o.name).trim().slice(0, 120),
|
|
283
|
+
ref: cleanRef(o.ref),
|
|
284
|
+
creatorSlug: cleanSlug(o.creator_slug),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Error message from an API error body (`{ "error": "…" }`) or the HTTP status. */
|
|
289
|
+
export function apiErrorMessage(status: number, json: unknown): string {
|
|
290
|
+
const msg = isRecord(json) ? str(json.error).trim().slice(0, 200) : "";
|
|
291
|
+
return msg || `HTTP ${status}`;
|
|
292
|
+
}
|