@redeemid/emdash 0.0.8 → 0.0.10

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.
Files changed (41) hide show
  1. package/README.md +127 -2
  2. package/docs/shopify-setup.md +5 -1
  3. package/package.json +5 -1
  4. package/src/access.ts +9 -3
  5. package/src/admin-api.ts +191 -0
  6. package/src/blocks/ContributionBlock.astro +1 -1
  7. package/src/blocks/PlanBlock.astro +1 -1
  8. package/src/blocks/PreferencesBlock.astro +12 -0
  9. package/src/blocks/index.ts +2 -0
  10. package/src/blocks-names.ts +1 -0
  11. package/src/components/AccessBadge.astro +4 -3
  12. package/src/components/Account.astro +5 -4
  13. package/src/components/Cart.astro +4 -1
  14. package/src/components/CartView.astro +14 -13
  15. package/src/components/Collection.astro +2 -1
  16. package/src/components/Content.astro +10 -4
  17. package/src/components/Contribution.astro +4 -3
  18. package/src/components/Feature.astro +20 -0
  19. package/src/components/LockedPanel.astro +10 -8
  20. package/src/components/MemberCard.astro +22 -13
  21. package/src/components/Notices.astro +10 -2
  22. package/src/components/Plan.astro +3 -2
  23. package/src/components/Preferences.astro +102 -0
  24. package/src/components/Product.astro +6 -5
  25. package/src/config.ts +53 -0
  26. package/src/descriptor.ts +1 -0
  27. package/src/events.ts +75 -0
  28. package/src/features.ts +39 -0
  29. package/src/files.ts +191 -0
  30. package/src/guard.ts +43 -2
  31. package/src/index.ts +1 -0
  32. package/src/plugin.ts +142 -3
  33. package/src/preferences.ts +298 -0
  34. package/src/routes/callback.ts +8 -1
  35. package/src/routes/preferences.ts +82 -0
  36. package/src/routes/webhook.ts +5 -3
  37. package/src/staff.ts +220 -0
  38. package/src/storage.ts +3 -1
  39. package/src/text.ts +174 -0
  40. package/src/version.ts +1 -1
  41. package/src/webhooks.ts +11 -2
package/src/storage.ts CHANGED
@@ -37,6 +37,8 @@ export interface MemberRecord {
37
37
  linkedAt?: string | null;
38
38
  /** Tags Shopify has removed, with when each was held (newest last, at most 50). */
39
39
  pastTags?: PastTag[];
40
+ /** When the member last saved preferences (ms), newest last, at most 10: the rate limit. */
41
+ prefSaves?: number[];
40
42
  }
41
43
 
42
44
  /** A tag the customer held from `from` (when known) until `to`. */
@@ -78,7 +80,7 @@ export interface DeliveryRecord {
78
80
  }
79
81
 
80
82
  export const STORAGE_CONFIG = {
81
- members: { indexes: ["email", "userId"] },
83
+ members: { indexes: ["email", "userId", "syncedAt"] },
82
84
  deliveries: { indexes: ["receivedAt"] },
83
85
  meter: { indexes: ["userId"] },
84
86
  };
package/src/text.ts ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Replacement text: give any string members and visitors see new words, one
3
+ * string at a time. Config `text` maps a string's original English to its
4
+ * replacement:
5
+ *
6
+ * text: {
7
+ * "Sign in": "Log in",
8
+ * "This is for {level} members.": "{level} members only.",
9
+ * }
10
+ *
11
+ * Only the strings named change: there is no find-and-replace across
12
+ * strings, so nothing changes that nobody looked at. STRINGS lists every
13
+ * original. Some have placeholders such as `{level}`, filled in when the
14
+ * text shows; a replacement may use its original's placeholders, in any
15
+ * order or not at all, but no others. A replacement that breaks this, or
16
+ * names a string RedeemID doesn't show, is ignored (the original shows) and
17
+ * listed on the Status page. Text is plain: HTML in a replacement shows as
18
+ * typed. Explicit component props (`label`, `title`, Notices `messages`) win
19
+ * over `text`.
20
+ */
21
+
22
+ import type { RedeemIDConfig } from "./config.js";
23
+
24
+ /** Every string RedeemID shows members and visitors, by where it shows. */
25
+ export const STRINGS = {
26
+ account: ["Signed in", "Account", "Sign out", "Sign in"],
27
+ badge: ["Free", "Signed in", " (locked)"],
28
+ cart: [
29
+ "Cart",
30
+ "Your cart is empty.",
31
+ "Your cart isn't available right now. Try again in a few minutes.",
32
+ "{price} each · {total}",
33
+ "Quantity",
34
+ "Update",
35
+ "Remove",
36
+ "Discount code",
37
+ "Apply",
38
+ "{code} doesn't apply to this cart.",
39
+ "Subtotal",
40
+ "Total",
41
+ "Taxes and shipping are worked out at checkout.",
42
+ "Checkout",
43
+ ],
44
+ collection: ["From {price}", "Sold out"],
45
+ contribution: ["Give", "Another amount ({currency})", "Quantity ({price} each)"],
46
+ meter: ["This is your last free read this week.", "This is your last free read this month.", "You have 1 free read left this week.", "You have 1 free read left this month.", "You have {count} free reads left this week.", "You have {count} free reads left this month."],
47
+ wall: [
48
+ "Become a member",
49
+ "This is for members. {signIn} to read on.",
50
+ "Sign in",
51
+ "Not a member yet? {join}.",
52
+ "You've used your free reads this week.",
53
+ "You've used your free reads this month.",
54
+ "This opens for you on {date}.",
55
+ "This opens for you after you've been a member a little longer.",
56
+ "This is for {level} members.",
57
+ "Your membership doesn't include this yet.",
58
+ "Your account is turned off. Contact the store for help.",
59
+ "This is for members.",
60
+ ],
61
+ member: [
62
+ "Your membership",
63
+ "Your account is turned off. Contact the store for help.",
64
+ "since {date}",
65
+ "You don't have a membership yet.",
66
+ "Become a member",
67
+ "Includes: {features}",
68
+ "Before",
69
+ "{from} to {to}",
70
+ "until {to}",
71
+ "Free reads this week: {used} of {limit} used.",
72
+ "Free reads this month: {used} of {limit} used.",
73
+ "Orders and details",
74
+ "Refresh membership",
75
+ "Sign out",
76
+ "Just bought a plan? It can take a minute to show here. Refresh membership checks again.",
77
+ "{signIn} to see your membership.",
78
+ "Sign in",
79
+ ],
80
+ notices: [
81
+ "Sign-in isn't set up on this site yet.",
82
+ "Sign-in isn't available right now. Try again in a few minutes.",
83
+ "That sign-in took too long. Try again.",
84
+ "This account can't sign in here. Use a different email.",
85
+ "This site isn't ready for sign-in yet.",
86
+ "Your account needs an email address to sign in.",
87
+ "Sign-in didn't finish. Try again.",
88
+ "That plan isn't available right now.",
89
+ "Checkout didn't open. Try again.",
90
+ "Your cart is empty.",
91
+ "Added to your cart.",
92
+ "Your cart is updated.",
93
+ "Discount applied.",
94
+ "That discount code doesn't apply to this cart.",
95
+ "Your membership is up to date.",
96
+ "Your preferences are saved.",
97
+ "Some answers weren't saved. Check them and try again.",
98
+ "Your preferences can't be saved right now. Try again in a few minutes.",
99
+ "Your account page isn't available right now. Try again in a few minutes.",
100
+ ],
101
+ plan: ["Join", "You're on this plan.", "Not available right now."],
102
+ preferences: ["Your preferences", "Save preferences", "Your preferences can't be loaded right now. Try again in a few minutes.", "Not set", "Yes", "No", "One per line."],
103
+ product: ["Add to cart", "Buy now", "Option", "{option} ({price})", "{option} ({price}) sold out", "Quantity", "Sold out."],
104
+ files: ["File not available", "Sign in to download this file.", "Sign in", "Your membership doesn't include this file."],
105
+ } as const;
106
+
107
+ const ALL = new Set<string>(Object.values(STRINGS).flat());
108
+
109
+ const PLACEHOLDER = /\{(\w+)\}/g;
110
+
111
+ export function placeholders(s: string): string[] {
112
+ return [...s.matchAll(PLACEHOLDER)].map((m) => m[1] as string);
113
+ }
114
+
115
+ /** Why a replacement can't be used, or null when it can. */
116
+ export function textProblem(original: string, replacement: unknown): string | null {
117
+ if (!ALL.has(original)) return "RedeemID doesn't show this string";
118
+ if (typeof replacement !== "string") return "The replacement isn't text";
119
+ const allowed = new Set(placeholders(original));
120
+ const extra = placeholders(replacement).filter((p) => !allowed.has(p));
121
+ if (extra.length) return `Uses ${[...new Set(extra)].map((p) => `{${p}}`).join(", ")}, which the original doesn't have`;
122
+ return null;
123
+ }
124
+
125
+ const checked = new WeakMap<object, { ok: Map<string, string>; problems: Array<{ original: string; problem: string }> }>();
126
+
127
+ function table(config: RedeemIDConfig | null | undefined) {
128
+ const map = config?.text;
129
+ if (!map || typeof map !== "object") return null;
130
+ let t = checked.get(map);
131
+ if (!t) {
132
+ t = { ok: new Map(), problems: [] };
133
+ for (const [original, replacement] of Object.entries(map)) {
134
+ const problem = textProblem(original, replacement);
135
+ if (problem) t.problems.push({ original, problem });
136
+ else t.ok.set(original, replacement as string);
137
+ }
138
+ if (t.problems.length) console.warn(`[redeemid] ${t.problems.length} text replacement(s) ignored; see Status.`);
139
+ checked.set(map, t);
140
+ }
141
+ return t;
142
+ }
143
+
144
+ /** Replacements that are ignored, and why. */
145
+ export function textProblems(config: RedeemIDConfig | null | undefined): Array<{ original: string; problem: string }> {
146
+ return table(config)?.problems ?? [];
147
+ }
148
+
149
+ /** The text for one string, with plain placeholders filled in. */
150
+ export function text(config: RedeemIDConfig | null | undefined, original: string, vars: Record<string, string | number> = {}): string {
151
+ const template = table(config)?.ok.get(original) ?? original;
152
+ return template.replace(PLACEHOLDER, (whole, name: string) => (name in vars ? String(vars[name]) : whole));
153
+ }
154
+
155
+ /**
156
+ * The text for a string whose placeholders are elements (a link inside a
157
+ * sentence): the parts to render, in order.
158
+ *
159
+ * {textParts(config, "This is for members. {signIn} to read on.", { signIn: <a href={u}>Sign in</a> })}
160
+ */
161
+ export function textParts(config: RedeemIDConfig | null | undefined, original: string, slots: Record<string, unknown>): unknown[] {
162
+ const template = table(config)?.ok.get(original) ?? original;
163
+ const out: unknown[] = [];
164
+ let last = 0;
165
+ for (const m of template.matchAll(PLACEHOLDER)) {
166
+ const name = m[1] as string;
167
+ if (!(name in slots)) continue;
168
+ if (m.index > last) out.push(template.slice(last, m.index));
169
+ out.push(slots[name]);
170
+ last = m.index + m[0].length;
171
+ }
172
+ if (last < template.length) out.push(template.slice(last));
173
+ return out;
174
+ }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.0.8";
1
+ export const VERSION = "0.0.10";
package/src/webhooks.ts CHANGED
@@ -88,7 +88,13 @@ export type Outcome = "processed" | "skipped" | "stale" | "ignored";
88
88
  * Apply one webhook to the mirror. Throws on storage errors so the caller can
89
89
  * answer 500 and let Shopify retry.
90
90
  */
91
- export async function apply(s: Stores, topic: string, payload: Record<string, unknown>): Promise<Outcome> {
91
+ export async function apply(
92
+ s: Stores,
93
+ topic: string,
94
+ payload: Record<string, unknown>,
95
+ /** Called after a member is written, with their tags before (null when new). */
96
+ onWrite?: (beforeTags: string[] | null, after: MemberRecord) => Promise<void> | void,
97
+ ): Promise<Outcome> {
92
98
  const kind = kindOf(topic);
93
99
  if (!kind || kind === "ignored") return "ignored";
94
100
 
@@ -128,7 +134,9 @@ export async function apply(s: Stores, topic: string, payload: Record<string, un
128
134
  return "processed";
129
135
  }
130
136
 
131
- const current: MemberRecord = (await s.members.get(id)) ?? emptyMember(id);
137
+ const existing = await s.members.get(id);
138
+ const beforeTags = existing ? [...existing.tags] : null;
139
+ const current: MemberRecord = existing ?? emptyMember(id);
132
140
  const updatedAt = time(payload.updated_at);
133
141
 
134
142
  if (kind === "profile") {
@@ -157,5 +165,6 @@ export async function apply(s: Stores, topic: string, payload: Record<string, un
157
165
  current.syncedAt = new Date().toISOString();
158
166
  current.source = topic;
159
167
  await s.members.put(id, stampTags(current));
168
+ await onWrite?.(beforeTags, current);
160
169
  return "processed";
161
170
  }