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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — unreleased
|
|
4
|
+
|
|
5
|
+
- First release: AVDEB Products / AVDEB Product / AVDEB Button Portable Text blocks; grid, carousel, list
|
|
6
|
+
and spotlight layouts; horizontal, hero and compact single-product styles; template components
|
|
7
|
+
(`emdash-plugin-avdeb/ui`); settings with encrypted secret key; KV cache with stale fallback, request
|
|
8
|
+
budget and daily cleanup; Block Kit overview page and dashboard widget; `search_avdeb_products` MCP tool.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AVDEB
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# AVDEB Products for EmDash
|
|
2
|
+
|
|
3
|
+
Embed [AVDEB](https://avdeb.com/developers/#emdash) products in an [EmDash](https://github.com/emdash-cms/emdash) site — a creator's shop, a category, a search or a single product — with **your referral code on every link**.
|
|
4
|
+
|
|
5
|
+
- **Three editor blocks** — type `/` in any rich-text field: **AVDEB Products**, **AVDEB Product**, **AVDEB Button**. Paste a creator, category or product link and the source is detected automatically.
|
|
6
|
+
- **Four layouts** — grid, carousel (scroll-snap, keyboard + arrow buttons), list ("shop the post") and spotlight (first product featured). Single products as compact card, horizontal card or hero, with your own badge and note.
|
|
7
|
+
- **Looks native on any theme** — container queries (embeds adapt to the column they sit in, not the viewport), inherit / light / dark / system themes, three card styles, accent colour, corner radius, image shape. Every selector is `:where()`-wrapped, so your CSS always wins.
|
|
8
|
+
- **Fast and resilient** — products are fetched server-side, cached in plugin KV per query, and the last good copy is served for a week if AVDEB is unreachable. Identical embeds on one page share a single lookup.
|
|
9
|
+
- **Honest links** — referral links get `rel="sponsored"`; an optional disclosure line appears under embeds that carry a code.
|
|
10
|
+
- **Private by default** — no third-party scripts, no cookies, no iframes, no tracking. The only client JS is ~40 lines for carousel arrows.
|
|
11
|
+
- **Admin overview** — connection status ("Connected as …"), cache stats, a live product preview, "Refresh products now", plus a dashboard widget.
|
|
12
|
+
- **Agent-ready** — an MCP tool (`search_avdeb_products`) returns products with ready-to-insert block data.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npm install emdash-plugin-avdeb
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
// astro.config.mjs
|
|
22
|
+
import { defineConfig } from "astro/config";
|
|
23
|
+
import emdash from "emdash/astro";
|
|
24
|
+
import { avdebProducts } from "emdash-plugin-avdeb";
|
|
25
|
+
|
|
26
|
+
export default defineConfig({
|
|
27
|
+
integrations: [
|
|
28
|
+
emdash({
|
|
29
|
+
// …database, storage…
|
|
30
|
+
plugins: [avdebProducts()],
|
|
31
|
+
}),
|
|
32
|
+
],
|
|
33
|
+
});
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Requires EmDash ≥ 1.1 and Astro ≥ 6. This is a **trusted (native) plugin**: it adds Portable Text block types and Astro render components, which the sandboxed registry format can't. It requests one capability, `network:request`, limited to `api.avdeb.com`.
|
|
37
|
+
|
|
38
|
+
## Configure
|
|
39
|
+
|
|
40
|
+
Open **Plugins → AVDEB Products → Settings** in the EmDash admin.
|
|
41
|
+
|
|
42
|
+
| Setting | |
|
|
43
|
+
|---|---|
|
|
44
|
+
| **Secret key** | Optional, recommended. `avdeb_sk_…` from the partner dashboard → *Embeds & API*. Your referral code is added automatically and embeds can show up to 48 products. Stored encrypted — the site needs `EMDASH_ENCRYPTION_KEY`. |
|
|
45
|
+
| **Referral code** | Used only without a key: added as `?ref=` to every AVDEB link (max 12 products per embed). |
|
|
46
|
+
| Layout, columns, card style, theme, accent colour, radius, image shape, button text | Site-wide defaults; every block can override them. |
|
|
47
|
+
| Prices / designer names / Sale & New badges / new tab | Toggles. |
|
|
48
|
+
| Disclosure | Shown under embeds whose links carry a referral code (e.g. US FTC rules). |
|
|
49
|
+
| Refresh every | Cache lifetime in minutes (default 360). |
|
|
50
|
+
|
|
51
|
+
Without a key or code the plugin still works and shows plain AVDEB links.
|
|
52
|
+
|
|
53
|
+
## Become an AVDEB affiliate (or creator)
|
|
54
|
+
|
|
55
|
+
You only need an account to earn commission. Program details:
|
|
56
|
+
[avdeb.com/affiliate](https://avdeb.com/affiliate/) — 5% commission, 30-day cookie, monthly PayPal payouts in USD
|
|
57
|
+
($50 minimum), open worldwide, approval usually within 24 hours.
|
|
58
|
+
|
|
59
|
+
1. Open [avdeb.com/affiliate/apply](https://avdeb.com/affiliate/apply/).
|
|
60
|
+
2. **Account** — email + password (or *Sign in* if you already have an AVDEB account).
|
|
61
|
+
3. **Program** — *Affiliate* (share products, earn commission), *Creator* (submit sticker / 3D designs that
|
|
62
|
+
AVDEB prints and ships; you earn on every sale), or both.
|
|
63
|
+
4. **Personal information & details** — first/last name, country, tax residency, legal name (business name optional).
|
|
64
|
+
5. **Address** — street, city, state/province, postal code.
|
|
65
|
+
6. **Payout & promotion** — PayPal email for commissions; optionally your website and social links.
|
|
66
|
+
7. Accept the Terms & Conditions and Privacy Policy → **Create Account & Apply**.
|
|
67
|
+
8. After approval you get your referral link by email and in the dashboard.
|
|
68
|
+
9. Partner dashboard → **Embeds & API** ([direct link](https://avdeb.com/affiliate/dashboard/developers/)) →
|
|
69
|
+
create a **secret key** (`avdeb_sk_…`), or copy your referral code from **Links**.
|
|
70
|
+
10. EmDash admin → **Plugins → AVDEB Products → Settings** → paste the key (or code) → **Save**. The overview
|
|
71
|
+
shows "Connected as …" and every embed now carries your code. (A key needs `EMDASH_ENCRYPTION_KEY` set on the site.)
|
|
72
|
+
|
|
73
|
+
Creators: once your designs are listed, show them with `<AvdebProducts creator="your-slug" />` or an
|
|
74
|
+
**AVDEB Products** block pointed at `https://avdeb.com/creators/your-slug/`.
|
|
75
|
+
|
|
76
|
+
Please disclose affiliate links to your readers — turn on the **Disclosure** setting and it's added for you.
|
|
77
|
+
|
|
78
|
+
## In content
|
|
79
|
+
|
|
80
|
+
| Block | Fields |
|
|
81
|
+
|---|---|
|
|
82
|
+
| **AVDEB Products** | Link or search (`https://avdeb.com/creators/you/`, `/category/stickers/`, `cat stickers`), source (auto, creator, category, product type, search, newest), layout, number, columns, heading, intro, "View all" link, card style, theme, button text, hide prices / designers |
|
|
83
|
+
| **AVDEB Product** | Product link or handle — or pick one of your recent products — style (horizontal, hero, compact), badge ("Editor's pick"), your note, button text |
|
|
84
|
+
| **AVDEB Button** | Any AVDEB link, text, style (solid, outline, text link), alignment |
|
|
85
|
+
|
|
86
|
+
Renderers are wired into `<PortableText>` automatically. Override one with your own component via `components.types["avdeb-products"]`.
|
|
87
|
+
|
|
88
|
+
## In templates
|
|
89
|
+
|
|
90
|
+
```astro
|
|
91
|
+
---
|
|
92
|
+
import { AvdebProducts, AvdebProduct, AvdebButton } from "emdash-plugin-avdeb/ui";
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
<AvdebProducts creator="your-slug" layout="carousel" limit={8} heading="From my shop" />
|
|
96
|
+
<AvdebProducts url="https://avdeb.com/category/stickers/" columns={4} cardStyle="outline" />
|
|
97
|
+
<AvdebProducts q="cat stickers" layout="list" limit={3} headingLevel={3} heading="In this post" />
|
|
98
|
+
<AvdebProduct handle="cat-sticker" variant="hero" badge="Editor's pick" note="I use it every day." />
|
|
99
|
+
<AvdebButton href="/creators/your-slug/" label="Visit my shop" variant="outline" align="center" />
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`AvdebProducts` accepts `url | creator | tag | type | q`, `limit`, `layout`, `columns`, `cardStyle`, `theme`, `heading`, `intro`, `headingLevel`, `viewAll`, `ctaLabel`, `hidePrice`, `hideCreator`, `class`.
|
|
103
|
+
|
|
104
|
+
## Theming
|
|
105
|
+
|
|
106
|
+
Everything is a CSS custom property on `.avdeb` (embeds) or `.avdeb-cta` (buttons):
|
|
107
|
+
|
|
108
|
+
```css
|
|
109
|
+
.avdeb {
|
|
110
|
+
--avdeb-accent: #4d7f5a; /* prices, sale badge, CTAs, focus ring */
|
|
111
|
+
--avdeb-accent-contrast: #fff; /* text on accent */
|
|
112
|
+
--avdeb-radius: 20px;
|
|
113
|
+
--avdeb-gap: 1.25rem;
|
|
114
|
+
--avdeb-card-bg: #fff;
|
|
115
|
+
--avdeb-border: #e5e7eb;
|
|
116
|
+
--avdeb-shadow: 0 12px 32px -16px rgb(0 0 0 / 0.3);
|
|
117
|
+
--avdeb-font-title: "Fraunces", serif;
|
|
118
|
+
}
|
|
119
|
+
.avdeb[data-theme="dark"] { --avdeb-card-bg: #111; }
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Also available: `--avdeb-surface`, `--avdeb-border-soft`, `--avdeb-media-bg`, `--avdeb-muted`, `--avdeb-badge-bg`, `--avdeb-badge-text`, `--avdeb-shadow-hover`, `--avdeb-pad`, `--avdeb-ratio`. BEM classes (`.avdeb-card__title`, `.avdeb-badge--sale`, …) are stable.
|
|
123
|
+
|
|
124
|
+
## How it works
|
|
125
|
+
|
|
126
|
+
```mermaid
|
|
127
|
+
sequenceDiagram
|
|
128
|
+
participant Page as Astro page
|
|
129
|
+
participant Route as Plugin route (in-process)
|
|
130
|
+
participant KV as Plugin KV
|
|
131
|
+
participant API as api.avdeb.com
|
|
132
|
+
Page->>Route: products { creator, limit }
|
|
133
|
+
Route->>KV: cached for this query?
|
|
134
|
+
alt fresh
|
|
135
|
+
KV-->>Route: products
|
|
136
|
+
else missing or stale
|
|
137
|
+
Route->>API: GET /v1/catalog/products (Bearer avdeb_sk_…)
|
|
138
|
+
API-->>Route: products + ref
|
|
139
|
+
Route->>KV: store (fresh N min, last good copy 7 days)
|
|
140
|
+
end
|
|
141
|
+
Route-->>Page: products + display settings (never the key)
|
|
142
|
+
Page->>Page: render cards (HTML + CSS)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The render route is public (EmDash's SSR dispatcher only calls public routes), so it returns only public catalog data, re-validates every input, caps AVDEB requests at 30 per minute and a daily cron removes expired cache entries. The secret key never leaves the server.
|
|
146
|
+
|
|
147
|
+
## Development
|
|
148
|
+
|
|
149
|
+
```sh
|
|
150
|
+
npm install
|
|
151
|
+
npm test # node --test (Node ≥ 22.18, TypeScript type-stripping, no build step)
|
|
152
|
+
npm run typecheck
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
src/index.ts descriptor (astro.config) + createPlugin(): capabilities, routes, cron, MCP, admin
|
|
157
|
+
src/catalog.ts catalog API contract: query sanitising, response validation, links
|
|
158
|
+
src/client.ts KV cache: fresh / stale / back-off, request budget, key check, cleanup
|
|
159
|
+
src/settings.ts settings schema + validation
|
|
160
|
+
src/blocks.ts Portable Text block definitions, block node → request
|
|
161
|
+
src/view.ts card view models, prices, badges, "View all", inline style
|
|
162
|
+
src/routes.ts route handlers src/admin.ts Block Kit overview + widget
|
|
163
|
+
src/payload.ts re-validation on the rendering side
|
|
164
|
+
src/astro/ Embed.astro (all layouts + styles), Button.astro, block renderers, loader
|
|
165
|
+
src/ui.ts template components
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "emdash-plugin-avdeb",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Embed AVDEB products in EmDash CMS — grid, carousel, list and spotlight blocks with your affiliate or creator referral link",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./astro": "./src/astro/index.ts",
|
|
10
|
+
"./ui": "./src/ui.ts"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"README.md",
|
|
15
|
+
"CHANGELOG.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"keywords": [
|
|
19
|
+
"emdash",
|
|
20
|
+
"emdash-plugin",
|
|
21
|
+
"cms",
|
|
22
|
+
"astro",
|
|
23
|
+
"portable-text",
|
|
24
|
+
"affiliate",
|
|
25
|
+
"product-embed",
|
|
26
|
+
"avdeb"
|
|
27
|
+
],
|
|
28
|
+
"homepage": "https://avdeb.com/developers/#emdash",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/Avdebcom/emdash-plugin-avdeb.git"
|
|
32
|
+
},
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/Avdebcom/emdash-plugin-avdeb/issues"
|
|
35
|
+
},
|
|
36
|
+
"author": {
|
|
37
|
+
"name": "AVDEB",
|
|
38
|
+
"url": "https://avdeb.com/developers/"
|
|
39
|
+
},
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"sideEffects": false,
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"astro": ">=6.0.0",
|
|
44
|
+
"emdash": ">=1.1.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^22.18.0",
|
|
48
|
+
"astro": "^6.0.0",
|
|
49
|
+
"emdash": "^1.1.0",
|
|
50
|
+
"typescript": "^5.9.0"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"test": "node --test \"tests/**/*.test.ts\"",
|
|
54
|
+
"typecheck": "tsc --noEmit"
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/admin.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Block Kit admin: the "AVDEB Products" overview page and the dashboard widget.
|
|
3
|
+
* The settings form itself is generated by EmDash from `settingsSchema`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { RouteContext } from "emdash";
|
|
7
|
+
|
|
8
|
+
import { keyProblem, maxLimit } from "./catalog.ts";
|
|
9
|
+
import type { CatalogClient, ClientStats, KeyInfo } from "./client.ts";
|
|
10
|
+
import { AFFILIATE_URL, DOCS_URL, PARTNER_DASHBOARD_URL } from "./constants.ts";
|
|
11
|
+
import { clientFor, loadSettings } from "./routes.ts";
|
|
12
|
+
import type { ResolvedSettings } from "./settings.ts";
|
|
13
|
+
import { formatPrice } from "./view.ts";
|
|
14
|
+
|
|
15
|
+
type Block = Record<string, unknown>;
|
|
16
|
+
export interface BlockResult {
|
|
17
|
+
blocks: Block[];
|
|
18
|
+
toast?: { message: string; type: "success" | "error" | "info" };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const WIDGET_ID = "status";
|
|
22
|
+
|
|
23
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
24
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** "3 min ago" style relative time. */
|
|
28
|
+
export function ago(then: number | null, now: number): string {
|
|
29
|
+
if (!then) return "never";
|
|
30
|
+
const s = Math.max(0, Math.round((now - then) / 1000));
|
|
31
|
+
if (s < 60) return "just now";
|
|
32
|
+
const m = Math.round(s / 60);
|
|
33
|
+
if (m < 60) return `${m} min ago`;
|
|
34
|
+
const h = Math.round(m / 60);
|
|
35
|
+
if (h < 48) return `${h} h ago`;
|
|
36
|
+
return `${Math.round(h / 24)} days ago`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Connection status as a banner (or null when everything is fine). */
|
|
40
|
+
export function connectionBanner(settings: ResolvedSettings, info: KeyInfo | null): Block | null {
|
|
41
|
+
const problem = keyProblem(settings.rawApiKey);
|
|
42
|
+
if (problem === "publishable") {
|
|
43
|
+
return {
|
|
44
|
+
type: "banner",
|
|
45
|
+
variant: "error",
|
|
46
|
+
title: "That is a publishable key",
|
|
47
|
+
description:
|
|
48
|
+
"Publishable keys (avdeb_pk_…) are for the browser embed. This plugin calls AVDEB from your server and needs a secret key that starts with avdeb_sk_.",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (problem === "malformed") {
|
|
52
|
+
return {
|
|
53
|
+
type: "banner",
|
|
54
|
+
variant: "error",
|
|
55
|
+
title: "The saved key doesn't look like an AVDEB secret key",
|
|
56
|
+
description: "Expected avdeb_sk_ followed by 32 letters and digits. Products still show, without your key.",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (info && !info.ok) {
|
|
60
|
+
return {
|
|
61
|
+
type: "banner",
|
|
62
|
+
variant: "error",
|
|
63
|
+
title: "AVDEB did not accept the secret key",
|
|
64
|
+
description: `${info.error}. Check the key in the partner dashboard, or remove it to use a referral code instead.`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (info?.ok && !info.owner.ref) {
|
|
68
|
+
return {
|
|
69
|
+
type: "banner",
|
|
70
|
+
variant: "alert",
|
|
71
|
+
title: "Connected, but your account has no referral code yet",
|
|
72
|
+
description: "Links work but aren't credited to you. Creators get the code of their linked affiliate account.",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (!settings.apiKey && !settings.refCode) {
|
|
76
|
+
return {
|
|
77
|
+
type: "banner",
|
|
78
|
+
variant: "alert",
|
|
79
|
+
title: "Links aren't credited to you yet",
|
|
80
|
+
description:
|
|
81
|
+
"Add a secret key (recommended) or your referral code in Settings. Without either, embeds show plain AVDEB links.",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function connectionFields(settings: ResolvedSettings, info: KeyInfo | null): Block {
|
|
88
|
+
const fields: Array<{ label: string; value: string }> = [];
|
|
89
|
+
if (info?.ok) {
|
|
90
|
+
fields.push({ label: "Connected as", value: `${info.owner.name || "AVDEB partner"} (${info.owner.type || "partner"})` });
|
|
91
|
+
fields.push({ label: "Referral code", value: info.owner.ref || "—" });
|
|
92
|
+
} else {
|
|
93
|
+
fields.push({ label: "Connection", value: settings.apiKey ? "Secret key (not verified)" : "No key — public catalog" });
|
|
94
|
+
fields.push({ label: "Referral code", value: settings.refCode || "—" });
|
|
95
|
+
}
|
|
96
|
+
fields.push({ label: "Products per embed", value: `up to ${maxLimit(settings.apiKey !== "" && info?.ok === true)}` });
|
|
97
|
+
fields.push({ label: "Cache", value: `${settings.cacheMinutes} min, last good copy kept 7 days` });
|
|
98
|
+
return { type: "fields", fields };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function statsBlock(stats: ClientStats, now: number): Block {
|
|
102
|
+
return {
|
|
103
|
+
type: "stats",
|
|
104
|
+
items: [
|
|
105
|
+
{ label: "Cached embeds", value: stats.cachedQueries, description: "distinct queries" },
|
|
106
|
+
{ label: "AVDEB requests", value: stats.requests, description: `last ${ago(stats.lastFetchAt, now)}` },
|
|
107
|
+
{
|
|
108
|
+
label: "Errors",
|
|
109
|
+
value: stats.errors,
|
|
110
|
+
description: stats.lastError ? `${stats.lastError} (${ago(stats.lastErrorAt, now)})` : "none",
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function actions(compact: boolean): Block {
|
|
117
|
+
const elements: Block[] = [
|
|
118
|
+
{ type: "button", action_id: "refresh_cache", label: "Refresh products now", style: "primary" },
|
|
119
|
+
];
|
|
120
|
+
if (!compact) elements.push({ type: "button", action_id: "test_connection", label: "Test connection" });
|
|
121
|
+
elements.push({ type: "link", label: "Settings", target: { kind: "plugin-settings" }, appearance: "secondary" });
|
|
122
|
+
if (!compact) {
|
|
123
|
+
elements.push({ type: "link", label: "Get a secret key", target: { kind: "external", url: PARTNER_DASHBOARD_URL } });
|
|
124
|
+
elements.push({ type: "link", label: "Become an affiliate", target: { kind: "external", url: AFFILIATE_URL } });
|
|
125
|
+
}
|
|
126
|
+
return { type: "actions", elements };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const TEMPLATE_SNIPPET = `---
|
|
130
|
+
import { AvdebProducts, AvdebProduct, AvdebButton } from "emdash-plugin-avdeb/ui";
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
<AvdebProducts creator="your-slug" layout="carousel" limit={8} heading="From my shop" />
|
|
134
|
+
<AvdebProducts tag="stickers" layout="grid" columns={4} cardStyle="outline" />
|
|
135
|
+
<AvdebProducts q="cat stickers" layout="list" limit={3} />
|
|
136
|
+
<AvdebProduct handle="cat-sticker" variant="hero" badge="Editor's pick" note="I use it every day." />
|
|
137
|
+
<AvdebButton href="/creators/your-slug/" label="Visit my shop" variant="outline" />`;
|
|
138
|
+
|
|
139
|
+
const CSS_SNIPPET = `/* Every value is a CSS custom property; selectors use :where(), so yours always win. */
|
|
140
|
+
.avdeb {
|
|
141
|
+
--avdeb-accent: #4d7f5a; /* prices, badges, buttons */
|
|
142
|
+
--avdeb-radius: 20px;
|
|
143
|
+
--avdeb-gap: 1.25rem;
|
|
144
|
+
--avdeb-card-bg: #fff;
|
|
145
|
+
--avdeb-font-title: "Fraunces", serif;
|
|
146
|
+
}`;
|
|
147
|
+
|
|
148
|
+
function usage(): Block[] {
|
|
149
|
+
return [
|
|
150
|
+
{
|
|
151
|
+
type: "accordion",
|
|
152
|
+
label: "Add products to a post",
|
|
153
|
+
default_open: false,
|
|
154
|
+
blocks: [
|
|
155
|
+
{
|
|
156
|
+
type: "context",
|
|
157
|
+
text: "In any rich-text field type “/” and choose AVDEB Products, AVDEB Product or AVDEB Button. Paste a creator, category or product link — the source is detected automatically.",
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
type: "accordion",
|
|
163
|
+
label: "Use in Astro templates",
|
|
164
|
+
default_open: false,
|
|
165
|
+
blocks: [{ type: "code", language: "tsx", code: TEMPLATE_SNIPPET }],
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
type: "accordion",
|
|
169
|
+
label: "Match your theme",
|
|
170
|
+
default_open: false,
|
|
171
|
+
blocks: [{ type: "code", language: "css", code: CSS_SNIPPET }],
|
|
172
|
+
},
|
|
173
|
+
{ type: "actions", elements: [{ type: "link", label: "Documentation", target: { kind: "external", url: DOCS_URL } }] },
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function previewTable(client: CatalogClient, settings: ResolvedSettings, info: KeyInfo | null): Promise<Block[]> {
|
|
178
|
+
const creator = info?.ok ? info.owner.creatorSlug : "";
|
|
179
|
+
const out = await client.products({ ...(creator ? { creator } : {}), limit: 6 });
|
|
180
|
+
return [
|
|
181
|
+
{ type: "header", text: creator ? "Your newest products" : "Newest on AVDEB" },
|
|
182
|
+
{
|
|
183
|
+
type: "table",
|
|
184
|
+
page_action_id: "preview_page",
|
|
185
|
+
columns: [
|
|
186
|
+
{ key: "title", label: "Product" },
|
|
187
|
+
{ key: "price", label: "Price" },
|
|
188
|
+
{ key: "creator", label: "Designer" },
|
|
189
|
+
{ key: "handle", label: "Handle", format: "code" },
|
|
190
|
+
],
|
|
191
|
+
rows: out.products.map((p) => ({
|
|
192
|
+
title: p.title,
|
|
193
|
+
price: formatPrice(p.price, p.currency) ?? "—",
|
|
194
|
+
creator: p.creator?.name ?? "—",
|
|
195
|
+
handle: p.handle,
|
|
196
|
+
})),
|
|
197
|
+
empty_text: out.error ? `AVDEB could not be reached: ${out.error}` : "No products found.",
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
type: "context",
|
|
201
|
+
text: settings.refCode || info?.ok ? "Every product link on your site carries your referral code and rel=\"sponsored\"." : "Links are plain until you add a key or referral code.",
|
|
202
|
+
},
|
|
203
|
+
];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function overviewPage(ctx: RouteContext, now = Date.now()): Promise<Block[]> {
|
|
207
|
+
const settings = await loadSettings(ctx);
|
|
208
|
+
const client = clientFor(ctx, settings);
|
|
209
|
+
const info = await client.keyInfo();
|
|
210
|
+
const banner = connectionBanner(settings, info);
|
|
211
|
+
return [
|
|
212
|
+
{ type: "header", text: "AVDEB Products" },
|
|
213
|
+
{
|
|
214
|
+
type: "context",
|
|
215
|
+
text: "Embed AVDEB products — a creator's shop, a category, a search or single products — with your referral code on every link.",
|
|
216
|
+
},
|
|
217
|
+
...(banner ? [banner] : []),
|
|
218
|
+
connectionFields(settings, info),
|
|
219
|
+
statsBlock(await client.stats(), now),
|
|
220
|
+
actions(false),
|
|
221
|
+
{ type: "divider" },
|
|
222
|
+
...(await previewTable(client, settings, info)),
|
|
223
|
+
{ type: "divider" },
|
|
224
|
+
...usage(),
|
|
225
|
+
];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function widget(ctx: RouteContext, now = Date.now()): Promise<Block[]> {
|
|
229
|
+
const settings = await loadSettings(ctx);
|
|
230
|
+
const client = clientFor(ctx, settings);
|
|
231
|
+
const info = await client.keyInfo();
|
|
232
|
+
const banner = connectionBanner(settings, info);
|
|
233
|
+
const stats = await client.stats();
|
|
234
|
+
return [
|
|
235
|
+
...(banner ? [banner] : []),
|
|
236
|
+
{
|
|
237
|
+
type: "fields",
|
|
238
|
+
fields: [
|
|
239
|
+
{ label: "Referral code", value: (info?.ok ? info.owner.ref : settings.refCode) || "—" },
|
|
240
|
+
{ label: "Last refresh", value: ago(stats.lastFetchAt, now) },
|
|
241
|
+
],
|
|
242
|
+
},
|
|
243
|
+
actions(true),
|
|
244
|
+
];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The `admin` route: page_load / block_action for the overview page and the widget. */
|
|
248
|
+
export async function adminRoute(ctx: RouteContext): Promise<BlockResult> {
|
|
249
|
+
const input = isRecord(ctx.input) ? ctx.input : {};
|
|
250
|
+
const page = typeof input.page === "string" ? input.page : "/";
|
|
251
|
+
const isWidget = page === `widget:${WIDGET_ID}`;
|
|
252
|
+
const render = () => (isWidget ? widget(ctx) : overviewPage(ctx));
|
|
253
|
+
|
|
254
|
+
if (input.type === "block_action") {
|
|
255
|
+
const settings = await loadSettings(ctx);
|
|
256
|
+
const client = clientFor(ctx, settings);
|
|
257
|
+
if (input.action_id === "refresh_cache") {
|
|
258
|
+
await client.flush();
|
|
259
|
+
return { blocks: await render(), toast: { type: "success", message: "Products will be re-fetched from AVDEB" } };
|
|
260
|
+
}
|
|
261
|
+
if (input.action_id === "test_connection") {
|
|
262
|
+
await client.flush();
|
|
263
|
+
const info = await client.keyInfo();
|
|
264
|
+
const toast: BlockResult["toast"] = !settings.apiKey
|
|
265
|
+
? { type: "info", message: settings.refCode ? `No key saved — using referral code ${settings.refCode}` : "No key saved" }
|
|
266
|
+
: info?.ok
|
|
267
|
+
? { type: "success", message: `Connected as ${info.owner.name || "AVDEB partner"}` }
|
|
268
|
+
: { type: "error", message: `Key not accepted: ${info?.error ?? "unknown error"}` };
|
|
269
|
+
return { blocks: await render(), toast };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return { blocks: await render() };
|
|
273
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A referral CTA to any AVDEB page in an Astro template.
|
|
4
|
+
*
|
|
5
|
+
* @example <AvdebButton href="/creators/cleo/" label="Visit my shop" variant="outline" align="center" />
|
|
6
|
+
*/
|
|
7
|
+
import type { Alignment, ButtonVariant } from "../constants.ts";
|
|
8
|
+
import Button from "./Button.astro";
|
|
9
|
+
import { loadContext } from "./load.ts";
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
/** avdeb.com URL or a path such as "/store/cat-sticker/". Other hosts render as plain text. */
|
|
13
|
+
href: string;
|
|
14
|
+
label?: string;
|
|
15
|
+
variant?: ButtonVariant;
|
|
16
|
+
align?: Alignment;
|
|
17
|
+
class?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { href, label = "Shop on AVDEB", variant, align, class: className } = Astro.props;
|
|
21
|
+
const context = await loadContext(Astro.locals, Astro.url);
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
<Button context={context} href={href} label={label} variant={variant} align={align} class={className} />
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* One featured product in an Astro template.
|
|
4
|
+
*
|
|
5
|
+
* @example <AvdebProduct handle="cat-sticker" variant="hero" badge="Editor's pick" note="I use it daily." />
|
|
6
|
+
*/
|
|
7
|
+
import { productRequest } from "../blocks.ts";
|
|
8
|
+
import type { CardStyle, ProductVariant, Theme } from "../constants.ts";
|
|
9
|
+
import Embed from "./Embed.astro";
|
|
10
|
+
import { loadEmbed } from "./load.ts";
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
/** Product handle, or a full https://avdeb.com/store/{handle}/ link. */
|
|
14
|
+
handle: string;
|
|
15
|
+
variant?: ProductVariant;
|
|
16
|
+
badge?: string;
|
|
17
|
+
note?: string;
|
|
18
|
+
ctaLabel?: string;
|
|
19
|
+
cardStyle?: CardStyle;
|
|
20
|
+
theme?: Theme;
|
|
21
|
+
hidePrice?: boolean;
|
|
22
|
+
hideCreator?: boolean;
|
|
23
|
+
headingLevel?: 2 | 3 | 4 | 5;
|
|
24
|
+
class?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const p = Astro.props;
|
|
28
|
+
const req = productRequest({ id: p.handle, variant: p.variant, badge: p.badge, note: p.note });
|
|
29
|
+
const data = req.handle ? await loadEmbed(Astro.locals, Astro.url, { handle: req.handle }) : null;
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
{
|
|
33
|
+
data && (
|
|
34
|
+
<Embed
|
|
35
|
+
data={data}
|
|
36
|
+
overrides={{
|
|
37
|
+
cardStyle: p.cardStyle,
|
|
38
|
+
theme: p.theme,
|
|
39
|
+
ctaLabel: p.ctaLabel,
|
|
40
|
+
hidePrice: p.hidePrice,
|
|
41
|
+
hideCreator: p.hideCreator,
|
|
42
|
+
}}
|
|
43
|
+
viewAll={false}
|
|
44
|
+
headingLevel={p.headingLevel}
|
|
45
|
+
single={{ variant: req.variant, badge: req.badge, note: req.note }}
|
|
46
|
+
class={p.class}
|
|
47
|
+
/>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Products in an Astro template.
|
|
4
|
+
*
|
|
5
|
+
* @example <AvdebProducts creator="cleo" layout="carousel" limit={8} heading="From my shop" />
|
|
6
|
+
* @example <AvdebProducts url="https://avdeb.com/category/stickers/" columns={4} />
|
|
7
|
+
*/
|
|
8
|
+
import { parseAvdebLink } from "../catalog.ts";
|
|
9
|
+
import type { CardStyle, Layout, Theme } from "../constants.ts";
|
|
10
|
+
import Embed from "./Embed.astro";
|
|
11
|
+
import { loadEmbed } from "./load.ts";
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
/** Any AVDEB creator, category or product link (source detected automatically). */
|
|
15
|
+
url?: string;
|
|
16
|
+
creator?: string;
|
|
17
|
+
tag?: string;
|
|
18
|
+
type?: string;
|
|
19
|
+
q?: string;
|
|
20
|
+
limit?: number;
|
|
21
|
+
layout?: Layout;
|
|
22
|
+
columns?: number;
|
|
23
|
+
cardStyle?: CardStyle;
|
|
24
|
+
theme?: Theme;
|
|
25
|
+
heading?: string;
|
|
26
|
+
intro?: string;
|
|
27
|
+
headingLevel?: 2 | 3 | 4 | 5;
|
|
28
|
+
viewAll?: boolean;
|
|
29
|
+
ctaLabel?: string;
|
|
30
|
+
hidePrice?: boolean;
|
|
31
|
+
hideCreator?: boolean;
|
|
32
|
+
class?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const p = Astro.props;
|
|
36
|
+
const link = parseAvdebLink(p.url);
|
|
37
|
+
const query: Record<string, unknown> = {
|
|
38
|
+
creator: p.creator,
|
|
39
|
+
tag: p.tag,
|
|
40
|
+
type: p.type,
|
|
41
|
+
q: p.q,
|
|
42
|
+
limit: p.limit,
|
|
43
|
+
...(link ? { [link.kind]: link.value } : {}),
|
|
44
|
+
};
|
|
45
|
+
const data = await loadEmbed(Astro.locals, Astro.url, query);
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
data && (
|
|
50
|
+
<Embed
|
|
51
|
+
data={data}
|
|
52
|
+
overrides={{
|
|
53
|
+
layout: p.layout,
|
|
54
|
+
columns: p.columns,
|
|
55
|
+
cardStyle: p.cardStyle,
|
|
56
|
+
theme: p.theme,
|
|
57
|
+
ctaLabel: p.ctaLabel,
|
|
58
|
+
hidePrice: p.hidePrice,
|
|
59
|
+
hideCreator: p.hideCreator,
|
|
60
|
+
}}
|
|
61
|
+
heading={p.heading}
|
|
62
|
+
intro={p.intro}
|
|
63
|
+
headingLevel={p.headingLevel}
|
|
64
|
+
viewAll={p.viewAll ?? true}
|
|
65
|
+
class={p.class}
|
|
66
|
+
/>
|
|
67
|
+
)
|
|
68
|
+
}
|