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.
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Plugin settings: the schema EmDash renders as the settings form, and a validating reader.
3
+ * Pure module.
4
+ */
5
+
6
+ import type { SettingField } from "emdash";
7
+
8
+ import { cleanRef, usableKey } from "./catalog.ts";
9
+ import {
10
+ CARD_STYLES,
11
+ IMAGE_RATIOS,
12
+ LAYOUTS,
13
+ oneOf,
14
+ THEMES,
15
+ type CardStyle,
16
+ type ImageRatio,
17
+ type Layout,
18
+ type Theme,
19
+ } from "./constants.ts";
20
+
21
+ const DEFAULT_DISCLOSURE =
22
+ "As an AVDEB affiliate, I earn a commission from qualifying purchases.";
23
+
24
+ /**
25
+ * `admin.settingsSchema` — EmDash generates the settings screen from this. `apiKey` is a `secret`
26
+ * field: write-only in the admin and encrypted at rest (needs EMDASH_ENCRYPTION_KEY).
27
+ */
28
+ export const settingsSchema = {
29
+ apiKey: {
30
+ type: "secret",
31
+ label: "AVDEB secret key",
32
+ description:
33
+ "Optional. avdeb_sk_… from Partner dashboard → Embeds & API. Adds your referral code automatically and allows up to 48 products per embed.",
34
+ },
35
+ refCode: {
36
+ type: "string",
37
+ label: "Referral code",
38
+ description:
39
+ "Only used without a secret key: added as ?ref= to every AVDEB link. Leave empty for plain links.",
40
+ default: "",
41
+ },
42
+ layout: {
43
+ type: "select",
44
+ label: "Default layout",
45
+ options: [
46
+ { value: "grid", label: "Grid" },
47
+ { value: "carousel", label: "Carousel" },
48
+ { value: "list", label: "List" },
49
+ { value: "spotlight", label: "Spotlight (first product featured)" },
50
+ ],
51
+ default: "grid",
52
+ },
53
+ columns: { type: "number", label: "Default columns", min: 1, max: 6, default: 3 },
54
+ cardStyle: {
55
+ type: "select",
56
+ label: "Card style",
57
+ options: [
58
+ { value: "elevated", label: "Elevated (soft shadow)" },
59
+ { value: "outline", label: "Outline" },
60
+ { value: "minimal", label: "Minimal" },
61
+ ],
62
+ default: "elevated",
63
+ },
64
+ theme: {
65
+ type: "select",
66
+ label: "Colour theme",
67
+ description: "Inherit uses your site's text colour and transparent cards.",
68
+ options: [
69
+ { value: "inherit", label: "Inherit from site" },
70
+ { value: "light", label: "Light" },
71
+ { value: "dark", label: "Dark" },
72
+ { value: "auto", label: "Follow visitor's system setting" },
73
+ ],
74
+ default: "inherit",
75
+ },
76
+ accentColor: {
77
+ type: "string",
78
+ label: "Accent colour",
79
+ description: "Hex colour for prices, badges and buttons, e.g. #4d7f5a. Empty = AVDEB green.",
80
+ default: "",
81
+ },
82
+ radius: { type: "number", label: "Corner radius (px)", min: 0, max: 32, default: 16 },
83
+ imageRatio: {
84
+ type: "select",
85
+ label: "Image shape",
86
+ options: [
87
+ { value: "square", label: "Square (1:1)" },
88
+ { value: "portrait", label: "Portrait (4:5)" },
89
+ { value: "landscape", label: "Landscape (4:3)" },
90
+ ],
91
+ default: "square",
92
+ },
93
+ ctaLabel: { type: "string", label: "Button text", default: "View product" },
94
+ showPrice: { type: "boolean", label: "Show prices", default: true },
95
+ showCreator: { type: "boolean", label: "Show designer name", default: true },
96
+ showBadges: { type: "boolean", label: "Show Sale / New badges", default: true },
97
+ newTab: { type: "boolean", label: "Open product links in a new tab", default: false },
98
+ showDisclosure: {
99
+ type: "boolean",
100
+ label: "Affiliate disclosure",
101
+ description:
102
+ "Show the note below under embeds when links carry a referral code (e.g. required by the US FTC).",
103
+ default: true,
104
+ },
105
+ disclosureText: { type: "string", label: "Disclosure text", default: DEFAULT_DISCLOSURE },
106
+ cacheMinutes: {
107
+ type: "number",
108
+ label: "Refresh products every (minutes)",
109
+ description: "Products are cached on your site; the last good copy is kept for a week if AVDEB is unreachable.",
110
+ min: 5,
111
+ max: 2880,
112
+ default: 360,
113
+ },
114
+ } satisfies Record<string, SettingField>;
115
+
116
+ export type SettingKey = keyof typeof settingsSchema;
117
+
118
+ /** Presentation settings — safe to send to the page (no credentials). */
119
+ export interface DisplaySettings {
120
+ layout: Layout;
121
+ columns: number;
122
+ cardStyle: CardStyle;
123
+ theme: Theme;
124
+ accentColor: string;
125
+ radius: number;
126
+ imageRatio: ImageRatio;
127
+ ctaLabel: string;
128
+ showPrice: boolean;
129
+ showCreator: boolean;
130
+ showBadges: boolean;
131
+ newTab: boolean;
132
+ showDisclosure: boolean;
133
+ disclosureText: string;
134
+ }
135
+
136
+ export interface ResolvedSettings extends DisplaySettings {
137
+ /** Usable secret key or "". */
138
+ apiKey: string;
139
+ /** What was saved in the key field (may be malformed) — only for diagnostics, never rendered. */
140
+ rawApiKey: string;
141
+ refCode: string;
142
+ cacheMinutes: number;
143
+ }
144
+
145
+ export const DEFAULT_DISPLAY: DisplaySettings = {
146
+ layout: "grid",
147
+ columns: 3,
148
+ cardStyle: "elevated",
149
+ theme: "inherit",
150
+ accentColor: "",
151
+ radius: 16,
152
+ imageRatio: "square",
153
+ ctaLabel: "View product",
154
+ showPrice: true,
155
+ showCreator: true,
156
+ showBadges: true,
157
+ newTab: false,
158
+ showDisclosure: true,
159
+ disclosureText: DEFAULT_DISCLOSURE,
160
+ };
161
+
162
+ const HEX_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
163
+
164
+ /** A hex colour or "" — the value is placed in a style attribute, so nothing else is accepted. */
165
+ export function safeColor(value: unknown): string {
166
+ const v = typeof value === "string" ? value.trim() : "";
167
+ return HEX_RE.test(v) ? v.toLowerCase() : "";
168
+ }
169
+
170
+ function int(value: unknown, min: number, max: number, fallback: number): number {
171
+ const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
172
+ return Number.isFinite(n) ? Math.min(max, Math.max(min, Math.round(n))) : fallback;
173
+ }
174
+
175
+ function bool(value: unknown, fallback: boolean): boolean {
176
+ if (typeof value === "boolean") return value;
177
+ if (value === "true" || value === 1) return true;
178
+ if (value === "false" || value === 0) return false;
179
+ return fallback;
180
+ }
181
+
182
+ function text(value: unknown, max: number, fallback: string): string {
183
+ const v = typeof value === "string" ? value.replace(/\s+/g, " ").trim().slice(0, max) : "";
184
+ return v || fallback;
185
+ }
186
+
187
+ /** Read every setting through `get` (e.g. `ctx.settings.get`) and validate it. */
188
+ export async function readSettings(
189
+ get: (key: SettingKey) => Promise<unknown>,
190
+ ): Promise<ResolvedSettings> {
191
+ const keys = Object.keys(settingsSchema) as SettingKey[];
192
+ const values = await Promise.all(keys.map((k) => get(k).catch(() => null)));
193
+ const s = Object.fromEntries(keys.map((k, i) => [k, values[i]])) as Record<SettingKey, unknown>;
194
+ const rawApiKey = typeof s.apiKey === "string" ? s.apiKey.trim() : "";
195
+ return {
196
+ ...parseDisplay(s),
197
+ apiKey: usableKey(rawApiKey),
198
+ rawApiKey,
199
+ refCode: cleanRef(s.refCode),
200
+ cacheMinutes: int(s.cacheMinutes, 5, 2880, 360),
201
+ };
202
+ }
203
+
204
+ export function displayOf(s: ResolvedSettings): DisplaySettings {
205
+ return {
206
+ layout: s.layout,
207
+ columns: s.columns,
208
+ cardStyle: s.cardStyle,
209
+ theme: s.theme,
210
+ accentColor: s.accentColor,
211
+ radius: s.radius,
212
+ imageRatio: s.imageRatio,
213
+ ctaLabel: s.ctaLabel,
214
+ showPrice: s.showPrice,
215
+ showCreator: s.showCreator,
216
+ showBadges: s.showBadges,
217
+ newTab: s.newTab,
218
+ showDisclosure: s.showDisclosure,
219
+ disclosureText: s.disclosureText,
220
+ };
221
+ }
222
+
223
+ /** Validate display settings (stored settings, or a route payload that crossed into Astro). */
224
+ export function parseDisplay(value: unknown): DisplaySettings {
225
+ const v = typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
226
+ const d = DEFAULT_DISPLAY;
227
+ return {
228
+ layout: oneOf(v.layout, LAYOUTS) ?? d.layout,
229
+ columns: int(v.columns, 1, 6, d.columns),
230
+ cardStyle: oneOf(v.cardStyle, CARD_STYLES) ?? d.cardStyle,
231
+ theme: oneOf(v.theme, THEMES) ?? d.theme,
232
+ accentColor: safeColor(v.accentColor),
233
+ radius: int(v.radius, 0, 32, d.radius),
234
+ imageRatio: oneOf(v.imageRatio, IMAGE_RATIOS) ?? d.imageRatio,
235
+ ctaLabel: text(v.ctaLabel, 40, d.ctaLabel),
236
+ showPrice: bool(v.showPrice, d.showPrice),
237
+ showCreator: bool(v.showCreator, d.showCreator),
238
+ showBadges: bool(v.showBadges, d.showBadges),
239
+ newTab: bool(v.newTab, d.newTab),
240
+ showDisclosure: bool(v.showDisclosure, d.showDisclosure),
241
+ disclosureText: text(v.disclosureText, 300, d.disclosureText),
242
+ };
243
+ }
package/src/ui.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Astro components for templates (outside Portable Text).
3
+ *
4
+ * ```astro
5
+ * ---
6
+ * import { AvdebProducts, AvdebProduct, AvdebButton } from "emdash-plugin-avdeb/ui";
7
+ * ---
8
+ * <AvdebProducts creator="your-slug" layout="carousel" />
9
+ * ```
10
+ */
11
+
12
+ export { default as AvdebProducts } from "./astro/AvdebProducts.astro";
13
+ export { default as AvdebProduct } from "./astro/AvdebProduct.astro";
14
+ export { default as AvdebButton } from "./astro/AvdebButton.astro";
package/src/view.ts ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Turns catalog products + display settings into ready-to-render view models.
3
+ * Pure module: Astro templates only print these values.
4
+ */
5
+
6
+ import { avdebLink, hasRef, isAvdebUrl, type CatalogQuery, type Product } from "./catalog.ts";
7
+ import {
8
+ CARD_STYLES,
9
+ LAYOUTS,
10
+ oneOf,
11
+ THEMES,
12
+ type CardStyle,
13
+ type ImageRatio,
14
+ type Layout,
15
+ type Theme,
16
+ } from "./constants.ts";
17
+ import type { DisplaySettings } from "./settings.ts";
18
+
19
+ /** Per-embed overrides (block fields or component props). Undefined = use the site setting. */
20
+ export interface DisplayOverrides {
21
+ layout?: unknown;
22
+ columns?: unknown;
23
+ cardStyle?: unknown;
24
+ theme?: unknown;
25
+ ctaLabel?: unknown;
26
+ hidePrice?: unknown;
27
+ hideCreator?: unknown;
28
+ }
29
+
30
+ export interface ResolvedDisplay extends DisplaySettings {
31
+ layout: Layout;
32
+ cardStyle: CardStyle;
33
+ theme: Theme;
34
+ }
35
+
36
+ export interface CardView {
37
+ key: string;
38
+ href: string;
39
+ rel: string;
40
+ target: "_blank" | undefined;
41
+ title: string;
42
+ image: string | null;
43
+ width: number;
44
+ height: number;
45
+ price: string | null;
46
+ compareAt: string | null;
47
+ discount: number | null;
48
+ isNew: boolean;
49
+ creator: string | null;
50
+ }
51
+
52
+ const RATIO_SIZE: Record<ImageRatio, [number, number]> = {
53
+ square: [600, 600],
54
+ portrait: [600, 750],
55
+ landscape: [600, 450],
56
+ };
57
+
58
+ export const RATIO_CSS: Record<ImageRatio, string> = {
59
+ square: "1 / 1",
60
+ portrait: "4 / 5",
61
+ landscape: "4 / 3",
62
+ };
63
+
64
+ const NEW_DAYS = 14;
65
+
66
+ export function resolveDisplay(base: DisplaySettings, o: DisplayOverrides = {}): ResolvedDisplay {
67
+ const columns = Math.trunc(Number(o.columns));
68
+ const cta = typeof o.ctaLabel === "string" ? o.ctaLabel.trim().slice(0, 40) : "";
69
+ return {
70
+ ...base,
71
+ layout: oneOf(o.layout, LAYOUTS) ?? base.layout,
72
+ cardStyle: oneOf(o.cardStyle, CARD_STYLES) ?? base.cardStyle,
73
+ theme: oneOf(o.theme, THEMES) ?? base.theme,
74
+ columns: Number.isFinite(columns) && columns >= 1 ? Math.min(6, columns) : base.columns,
75
+ ctaLabel: cta || base.ctaLabel,
76
+ showPrice: o.hidePrice === true ? false : base.showPrice,
77
+ showCreator: o.hideCreator === true ? false : base.showCreator,
78
+ };
79
+ }
80
+
81
+ export function formatPrice(amount: number | null, currency: string, locale = "en-US"): string | null {
82
+ if (amount === null) return null;
83
+ try {
84
+ return new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount);
85
+ } catch {
86
+ return `${amount.toFixed(2)} ${currency}`;
87
+ }
88
+ }
89
+
90
+ /** Whole-number discount (e.g. 25 for 25 % off), or null when there's no real markdown. */
91
+ export function discountPercent(price: number | null, compareAt: number | null): number | null {
92
+ if (price === null || compareAt === null || compareAt <= price || compareAt <= 0) return null;
93
+ const pct = Math.round((1 - price / compareAt) * 100);
94
+ return pct >= 1 && pct < 100 ? pct : null;
95
+ }
96
+
97
+ export function isNew(createdAt: string | null, now: number, days = NEW_DAYS): boolean {
98
+ if (!createdAt) return false;
99
+ const t = Date.parse(createdAt);
100
+ return Number.isFinite(t) && t <= now && now - t < days * 86_400_000;
101
+ }
102
+
103
+ /** Referral links are paid links: `sponsored` (Google) — plus `noopener` for new tabs. */
104
+ export function relFor(url: string): string {
105
+ return hasRef(url) ? "sponsored noopener" : "noopener";
106
+ }
107
+
108
+ export function buildCards(
109
+ products: Product[],
110
+ display: DisplaySettings,
111
+ { locale = "en-US", now = Date.now() }: { locale?: string; now?: number } = {},
112
+ ): CardView[] {
113
+ const [width, height] = RATIO_SIZE[display.imageRatio];
114
+ const cards: CardView[] = [];
115
+ for (const p of products) {
116
+ if (!isAvdebUrl(p.url)) continue;
117
+ const hasSale = display.showPrice && p.compareAtPrice !== null && p.price !== null && p.compareAtPrice > p.price;
118
+ cards.push({
119
+ key: p.handle,
120
+ href: p.url,
121
+ rel: relFor(p.url),
122
+ target: display.newTab ? "_blank" : undefined,
123
+ title: p.title,
124
+ image: p.image,
125
+ width,
126
+ height,
127
+ price: display.showPrice ? formatPrice(p.price, p.currency, locale) : null,
128
+ compareAt: hasSale ? formatPrice(p.compareAtPrice, p.currency, locale) : null,
129
+ discount: display.showBadges && display.showPrice ? discountPercent(p.price, p.compareAtPrice) : null,
130
+ isNew: display.showBadges && isNew(p.createdAt, now),
131
+ creator: display.showCreator && p.creator ? p.creator.name : null,
132
+ });
133
+ }
134
+ return cards;
135
+ }
136
+
137
+ /** "View all" target for a creator or category embed (with the referral code), else null. */
138
+ export function viewAllHref(query: CatalogQuery, products: Product[], ref: string): string | null {
139
+ if (query.handle || query.q || query.type) return null;
140
+ if (query.creator && !query.tag) {
141
+ const fromApi = products.find((p) => p.creator?.slug === query.creator)?.creator?.url;
142
+ return fromApi ?? (avdebLink(`/creators/${query.creator}/`, ref) || null);
143
+ }
144
+ if (query.tag && !query.creator) return avdebLink(`/category/${query.tag}/`, ref) || null;
145
+ return null;
146
+ }
147
+
148
+ export function disclosureFor(display: DisplaySettings, ref: string): string | null {
149
+ return ref && display.showDisclosure && display.disclosureText ? display.disclosureText : null;
150
+ }
151
+
152
+ /** Inline CSS custom properties for an embed root. Values are validated numbers/hex only. */
153
+ export function rootStyle(display: DisplaySettings, columns: number): string {
154
+ const parts = [
155
+ `--avdeb-cols:${Math.min(6, Math.max(1, Math.trunc(columns)))}`,
156
+ `--avdeb-radius:${display.radius}px`,
157
+ `--avdeb-ratio:${RATIO_CSS[display.imageRatio]}`,
158
+ ];
159
+ if (display.accentColor) parts.push(`--avdeb-accent:${display.accentColor}`);
160
+ return parts.join(";");
161
+ }
162
+
163
+ /** Columns actually used: never more than there are products (a lone card shouldn't be 1/3 wide). */
164
+ export function effectiveColumns(display: ResolvedDisplay, count: number): number {
165
+ if (display.layout === "list") return 1;
166
+ return Math.max(1, Math.min(display.columns, count || 1));
167
+ }