@cshah18/sdk 1.0.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/README.md +454 -0
- package/dist/cobuy-sdk.esm.js +1226 -0
- package/dist/cobuy-sdk.esm.js.map +1 -0
- package/dist/cobuy-sdk.umd.js +1231 -0
- package/dist/cobuy-sdk.umd.js.map +1 -0
- package/dist/types/core/analytics.d.ts +38 -0
- package/dist/types/core/api-client.d.ts +58 -0
- package/dist/types/core/cobuy.d.ts +55 -0
- package/dist/types/core/config.d.ts +24 -0
- package/dist/types/core/endpoints.d.ts +31 -0
- package/dist/types/core/errors.d.ts +12 -0
- package/dist/types/core/logger.d.ts +28 -0
- package/dist/types/core/types.d.ts +284 -0
- package/dist/types/core/utils.d.ts +14 -0
- package/dist/types/index.d.ts +11 -0
- package/dist/types/ui/widget/theme.d.ts +19 -0
- package/dist/types/ui/widget/widget-root.d.ts +79 -0
- package/package.json +42 -0
|
@@ -0,0 +1,1231 @@
|
|
|
1
|
+
var CoBuySDK = (function () {
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Thrown when the SDK is used before initialization
|
|
6
|
+
*/
|
|
7
|
+
class CoBuyNotInitializedError extends Error {
|
|
8
|
+
constructor() {
|
|
9
|
+
super("CoBuy SDK has not been initialized. Call CoBuy.init(options) first.");
|
|
10
|
+
this.name = "CoBuyNotInitializedError";
|
|
11
|
+
Object.setPrototypeOf(this, CoBuyNotInitializedError.prototype);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Thrown when invalid configuration is provided
|
|
16
|
+
*/
|
|
17
|
+
class CoBuyInvalidConfigError extends Error {
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(`Invalid CoBuy configuration: ${message}`);
|
|
20
|
+
this.name = "CoBuyInvalidConfigError";
|
|
21
|
+
Object.setPrototypeOf(this, CoBuyInvalidConfigError.prototype);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Manages SDK configuration with validation and defaults
|
|
27
|
+
*/
|
|
28
|
+
class ConfigManager {
|
|
29
|
+
constructor() {
|
|
30
|
+
this.config = null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Set and validate SDK configuration
|
|
34
|
+
*/
|
|
35
|
+
setConfig(options) {
|
|
36
|
+
// Validate merchantKey
|
|
37
|
+
if (!options.merchantKey || typeof options.merchantKey !== "string") {
|
|
38
|
+
throw new CoBuyInvalidConfigError("merchantKey is required and must be a string");
|
|
39
|
+
}
|
|
40
|
+
// Set environment with default
|
|
41
|
+
const env = options.env || "production";
|
|
42
|
+
// Set API base URL based on environment (computed internally)
|
|
43
|
+
const apiBaseUrl = ConfigManager.DEFAULT_API_URLS[env];
|
|
44
|
+
// Theme configuration
|
|
45
|
+
const theme = options.theme || {};
|
|
46
|
+
// Debug flag
|
|
47
|
+
const debug = options.debug || false;
|
|
48
|
+
// Create and store internal config
|
|
49
|
+
this.config = {
|
|
50
|
+
merchantKey: options.merchantKey,
|
|
51
|
+
env,
|
|
52
|
+
apiBaseUrl,
|
|
53
|
+
theme,
|
|
54
|
+
debug,
|
|
55
|
+
events: options.events,
|
|
56
|
+
};
|
|
57
|
+
return this.config;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Get current configuration
|
|
61
|
+
* @throws CoBuyNotInitializedError if SDK has not been initialized
|
|
62
|
+
*/
|
|
63
|
+
getConfig() {
|
|
64
|
+
if (!this.config) {
|
|
65
|
+
throw new Error("CoBuy SDK has not been initialized. Call CoBuy.init(options) first.");
|
|
66
|
+
}
|
|
67
|
+
return this.config;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Check if SDK has been initialized
|
|
71
|
+
*/
|
|
72
|
+
isInitialized() {
|
|
73
|
+
return this.config !== null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Default API base URLs for each environment
|
|
78
|
+
*/
|
|
79
|
+
ConfigManager.DEFAULT_API_URLS = {
|
|
80
|
+
production: "https://api.cobuyza.co.za/api",
|
|
81
|
+
local: "http://localhost:9000/api",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Logger utility for CoBuy SDK with optional debug mode
|
|
86
|
+
*/
|
|
87
|
+
class Logger {
|
|
88
|
+
constructor(debug = false) {
|
|
89
|
+
this.debugEnabled = debug;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Log info level messages (only shown in debug mode)
|
|
93
|
+
*/
|
|
94
|
+
info(message, ...args) {
|
|
95
|
+
if (this.debugEnabled) {
|
|
96
|
+
console.log(`${Logger.PREFIX} ${message}`, ...args);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Log debug level messages (only shown in debug mode)
|
|
101
|
+
*/
|
|
102
|
+
debug(message, ...args) {
|
|
103
|
+
if (this.debugEnabled) {
|
|
104
|
+
console.debug(`${Logger.PREFIX} ${message}`, ...args);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Log warning level messages
|
|
109
|
+
*/
|
|
110
|
+
warn(message, ...args) {
|
|
111
|
+
console.warn(`${Logger.PREFIX} ${message}`, ...args);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Log error level messages
|
|
115
|
+
*/
|
|
116
|
+
error(message, ...args) {
|
|
117
|
+
console.error(`${Logger.PREFIX} ${message}`, ...args);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Update debug flag
|
|
121
|
+
*/
|
|
122
|
+
setDebug(debug) {
|
|
123
|
+
this.debugEnabled = debug;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
Logger.PREFIX = "[CoBuy]";
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* API endpoint paths
|
|
130
|
+
*
|
|
131
|
+
* NOTE: These endpoints are planned for future implementation in the CoBuy backend.
|
|
132
|
+
* The SDK is structured to integrate with these endpoints once they become available.
|
|
133
|
+
* Until then, API calls to these endpoints will fail gracefully without crashing the application.
|
|
134
|
+
*
|
|
135
|
+
* Expected backend implementation timeline: TBD
|
|
136
|
+
*/
|
|
137
|
+
const API_ENDPOINTS = {
|
|
138
|
+
// Product endpoints (Active)
|
|
139
|
+
PRODUCT_DETAILS: "/v1/products/:productId",
|
|
140
|
+
PRODUCT_AVAILABILITY: "/v1/products/:productId/availability",
|
|
141
|
+
PRODUCT_REWARD: "/v1/sdk/products/:productId/reward", // ✅ Implemented
|
|
142
|
+
// Group endpoints (Future implementation)
|
|
143
|
+
GROUP_CREATE: "/v1/groups",
|
|
144
|
+
GROUP_JOIN: "/v1/groups/:groupId/join",
|
|
145
|
+
GROUP_DETAILS: "/v1/groups/:groupId",
|
|
146
|
+
GROUP_MEMBERS: "/v1/groups/:groupId/members",
|
|
147
|
+
// Checkout endpoints (Future implementation)
|
|
148
|
+
CHECKOUT_INIT: "/v1/checkout/init",
|
|
149
|
+
CHECKOUT_STATUS: "/v1/checkout/:checkoutId/status",
|
|
150
|
+
// Widget endpoints (Future implementation - reserved for modal/group lobby features)
|
|
151
|
+
// WIDGET_CONFIG: Controls display settings, group rules, feature flags, and theming
|
|
152
|
+
// Will be activated when implementing modal group lobby, social sharing, and advanced customization
|
|
153
|
+
WIDGET_CONFIG: "/v1/widget/config",
|
|
154
|
+
WIDGET_ANALYTICS: "/v1/widget/analytics", // ✅ Implemented
|
|
155
|
+
};
|
|
156
|
+
/**
|
|
157
|
+
* Build full API URL from base URL and endpoint path
|
|
158
|
+
*/
|
|
159
|
+
function buildApiUrl(baseUrl, endpoint, params) {
|
|
160
|
+
let url = endpoint;
|
|
161
|
+
// Replace path parameters
|
|
162
|
+
if (params) {
|
|
163
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
164
|
+
url = url.replace(`:${key}`, encodeURIComponent(value));
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return `${baseUrl}${url}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Default theme values for the CoBuy widget
|
|
172
|
+
* These provide a consistent baseline if merchants don't specify custom colors
|
|
173
|
+
*/
|
|
174
|
+
const DEFAULT_THEME = {
|
|
175
|
+
primaryColor: "#4f46e5", // Indigo-600
|
|
176
|
+
secondaryColor: "#6366f1", // Indigo-500
|
|
177
|
+
textColor: "#111827", // Gray-900
|
|
178
|
+
borderRadius: "8px",
|
|
179
|
+
fontFamily: "Inter, system-ui, -apple-system, sans-serif",
|
|
180
|
+
animationSpeed: "normal",
|
|
181
|
+
animationStyle: "subtle",
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* Animation duration mappings (in milliseconds)
|
|
185
|
+
*/
|
|
186
|
+
const ANIMATION_DURATIONS = {
|
|
187
|
+
none: 0,
|
|
188
|
+
fast: 150,
|
|
189
|
+
normal: 300,
|
|
190
|
+
slow: 500,
|
|
191
|
+
};
|
|
192
|
+
/**
|
|
193
|
+
* Animation easing and transform mappings
|
|
194
|
+
*/
|
|
195
|
+
const ANIMATION_STYLES = {
|
|
196
|
+
none: { easing: "linear", transform: "translateY(0)" },
|
|
197
|
+
subtle: { easing: "ease-out", transform: "translateY(0)" }, // Fade only
|
|
198
|
+
smooth: { easing: "ease-out", transform: "translateY(8px)" }, // Fade + slide
|
|
199
|
+
bouncy: { easing: "cubic-bezier(0.68, -0.55, 0.265, 1.55)", transform: "translateY(12px)" }, // Spring
|
|
200
|
+
};
|
|
201
|
+
/**
|
|
202
|
+
* Merge global theme with optional widget-specific overrides
|
|
203
|
+
* Priority: widgetTheme > globalTheme > defaults
|
|
204
|
+
*/
|
|
205
|
+
function mergeTheme(globalTheme = {}, widgetTheme = {}) {
|
|
206
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
|
|
207
|
+
return {
|
|
208
|
+
primaryColor: (_b = (_a = widgetTheme.primaryColor) !== null && _a !== void 0 ? _a : globalTheme.primaryColor) !== null && _b !== void 0 ? _b : DEFAULT_THEME.primaryColor,
|
|
209
|
+
secondaryColor: (_d = (_c = widgetTheme.secondaryColor) !== null && _c !== void 0 ? _c : globalTheme.secondaryColor) !== null && _d !== void 0 ? _d : DEFAULT_THEME.secondaryColor,
|
|
210
|
+
textColor: (_f = (_e = widgetTheme.textColor) !== null && _e !== void 0 ? _e : globalTheme.textColor) !== null && _f !== void 0 ? _f : DEFAULT_THEME.textColor,
|
|
211
|
+
borderRadius: (_h = (_g = widgetTheme.borderRadius) !== null && _g !== void 0 ? _g : globalTheme.borderRadius) !== null && _h !== void 0 ? _h : DEFAULT_THEME.borderRadius,
|
|
212
|
+
fontFamily: (_k = (_j = widgetTheme.fontFamily) !== null && _j !== void 0 ? _j : globalTheme.fontFamily) !== null && _k !== void 0 ? _k : DEFAULT_THEME.fontFamily,
|
|
213
|
+
animationSpeed: (_m = (_l = widgetTheme.animationSpeed) !== null && _l !== void 0 ? _l : globalTheme.animationSpeed) !== null && _m !== void 0 ? _m : DEFAULT_THEME.animationSpeed,
|
|
214
|
+
animationStyle: (_p = (_o = widgetTheme.animationStyle) !== null && _o !== void 0 ? _o : globalTheme.animationStyle) !== null && _p !== void 0 ? _p : DEFAULT_THEME.animationStyle,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Apply theme to widget container using scoped CSS custom properties
|
|
219
|
+
* This approach ensures:
|
|
220
|
+
* 1. Styles are isolated to this widget instance
|
|
221
|
+
* 2. No conflicts with merchant's global styles
|
|
222
|
+
* 3. Easy runtime theme updates
|
|
223
|
+
* 4. Browser handles invalid values gracefully
|
|
224
|
+
*/
|
|
225
|
+
function applyTheme(container, theme) {
|
|
226
|
+
// Apply color and style CSS variables
|
|
227
|
+
container.style.setProperty("--cobuy-primary-color", theme.primaryColor);
|
|
228
|
+
container.style.setProperty("--cobuy-secondary-color", theme.secondaryColor);
|
|
229
|
+
container.style.setProperty("--cobuy-text-color", theme.textColor);
|
|
230
|
+
container.style.setProperty("--cobuy-border-radius", theme.borderRadius);
|
|
231
|
+
container.style.setProperty("--cobuy-font-family", theme.fontFamily);
|
|
232
|
+
// Apply animation CSS variables
|
|
233
|
+
const duration = ANIMATION_DURATIONS[theme.animationSpeed];
|
|
234
|
+
const style = ANIMATION_STYLES[theme.animationStyle];
|
|
235
|
+
container.style.setProperty("--cobuy-animation-duration", `${duration}ms`);
|
|
236
|
+
container.style.setProperty("--cobuy-animation-easing", style.easing);
|
|
237
|
+
container.style.setProperty("--cobuy-animation-transform", style.transform);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Debounce utility to prevent rapid function calls
|
|
242
|
+
* @param func Function to debounce
|
|
243
|
+
* @param delay Time in milliseconds to wait before executing
|
|
244
|
+
* @returns Debounced function
|
|
245
|
+
*/
|
|
246
|
+
function debounce(func, delay) {
|
|
247
|
+
let timeoutId = null;
|
|
248
|
+
return function debounced(...args) {
|
|
249
|
+
if (timeoutId !== null) {
|
|
250
|
+
clearTimeout(timeoutId);
|
|
251
|
+
}
|
|
252
|
+
const argsToPass = args;
|
|
253
|
+
timeoutId = setTimeout(() => {
|
|
254
|
+
func(...argsToPass);
|
|
255
|
+
timeoutId = null;
|
|
256
|
+
}, delay);
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Widget state enumeration for tracking render lifecycle
|
|
262
|
+
*/
|
|
263
|
+
var WidgetState;
|
|
264
|
+
(function (WidgetState) {
|
|
265
|
+
WidgetState["LOADING"] = "loading";
|
|
266
|
+
WidgetState["LOADED"] = "loaded";
|
|
267
|
+
WidgetState["ERROR"] = "error";
|
|
268
|
+
})(WidgetState || (WidgetState = {}));
|
|
269
|
+
/**
|
|
270
|
+
* WidgetRoot handles rendering of the CoBuy widget into a DOM container
|
|
271
|
+
*/
|
|
272
|
+
class WidgetRoot {
|
|
273
|
+
constructor(config, apiClient, analyticsClient = null) {
|
|
274
|
+
this.state = WidgetState.LOADING;
|
|
275
|
+
this.retryCount = 0;
|
|
276
|
+
this.MAX_RETRIES = 1;
|
|
277
|
+
this.currentProductId = null;
|
|
278
|
+
this.currentContainer = null;
|
|
279
|
+
this.debouncedCTAClick = null;
|
|
280
|
+
this.logger = new Logger(config.debug);
|
|
281
|
+
this.apiClient = apiClient;
|
|
282
|
+
this.analyticsClient = analyticsClient;
|
|
283
|
+
this.config = config;
|
|
284
|
+
this.events = config.events;
|
|
285
|
+
// Initialize debounced CTA click handler
|
|
286
|
+
this.debouncedCTAClick = debounce((productId) => {
|
|
287
|
+
this.handleCTAClick(productId);
|
|
288
|
+
}, 300); // 300ms debounce to prevent double-clicks
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Render the widget into the specified container
|
|
292
|
+
*/
|
|
293
|
+
async render(options) {
|
|
294
|
+
var _a, _b, _c, _d, _e;
|
|
295
|
+
try {
|
|
296
|
+
// Resolve container
|
|
297
|
+
const container = this.resolveContainer(options.container);
|
|
298
|
+
if (!container) {
|
|
299
|
+
const selector = typeof options.container === "string" ? options.container : "<HTMLElement>";
|
|
300
|
+
const errorMessage = `CoBuy: container not found for ${selector}`;
|
|
301
|
+
this.logger.error(errorMessage);
|
|
302
|
+
const error = new Error(errorMessage);
|
|
303
|
+
(_a = options.onError) === null || _a === void 0 ? void 0 : _a.call(options, error);
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
// Host safety + idempotency markers
|
|
307
|
+
container.setAttribute("data-cobuy-mounted", "true");
|
|
308
|
+
container.style.maxWidth = "100%";
|
|
309
|
+
container.style.width = "100%";
|
|
310
|
+
container.style.boxSizing = "border-box";
|
|
311
|
+
container.style.setProperty("--cobuy-gap", "8px");
|
|
312
|
+
container.style.setProperty("--cobuy-cta-min-height", "44px");
|
|
313
|
+
container.style.setProperty("--cobuy-reward-font-size", "14px");
|
|
314
|
+
this.injectBaseStyles();
|
|
315
|
+
// Apply theme (merge global + widget-specific overrides)
|
|
316
|
+
const theme = mergeTheme(this.config.theme, options.theme);
|
|
317
|
+
applyTheme(container, theme);
|
|
318
|
+
this.logger.info("Theme applied to widget", theme);
|
|
319
|
+
// Store container and productId for retry functionality
|
|
320
|
+
this.currentContainer = container;
|
|
321
|
+
this.currentProductId = options.productId;
|
|
322
|
+
// Reset state and retry count
|
|
323
|
+
this.state = WidgetState.LOADING;
|
|
324
|
+
this.retryCount = 0;
|
|
325
|
+
// Emit loading event
|
|
326
|
+
(_c = (_b = this.events) === null || _b === void 0 ? void 0 : _b.onLoading) === null || _c === void 0 ? void 0 : _c.call(_b, options.productId);
|
|
327
|
+
// Render skeleton loading state
|
|
328
|
+
this.renderSkeleton(container);
|
|
329
|
+
// Fetch widget configuration and reward data if API client is available
|
|
330
|
+
let rewardData = null;
|
|
331
|
+
if (this.apiClient) {
|
|
332
|
+
await this.fetchWidgetConfig(options.productId);
|
|
333
|
+
rewardData = await this.fetchRewardWithRetry(options.productId);
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
this.state = WidgetState.LOADED;
|
|
337
|
+
}
|
|
338
|
+
// Render widget or error based on final state
|
|
339
|
+
// Note: state may be ERROR after fetchRewardWithRetry despite TypeScript's flow analysis
|
|
340
|
+
if (this.state === WidgetState.ERROR) {
|
|
341
|
+
this.renderError(container);
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
// LOADED state - render widget (even if rewardData is null, createWidget handles it)
|
|
345
|
+
this.createWidget(rewardData, container);
|
|
346
|
+
this.logger.info(`Widget rendered for product: ${options.productId}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
this.logger.error("Failed to render widget", error);
|
|
351
|
+
this.state = WidgetState.ERROR;
|
|
352
|
+
(_e = (_d = this.events) === null || _d === void 0 ? void 0 : _d.onError) === null || _e === void 0 ? void 0 : _e.call(_d, options.productId, error);
|
|
353
|
+
if (options.onError) {
|
|
354
|
+
options.onError(error);
|
|
355
|
+
}
|
|
356
|
+
if (this.currentContainer) {
|
|
357
|
+
this.renderError(this.currentContainer);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Fetch reward data with automatic retry on failure
|
|
363
|
+
*/
|
|
364
|
+
async fetchRewardWithRetry(productId) {
|
|
365
|
+
var _a, _b, _c, _d;
|
|
366
|
+
try {
|
|
367
|
+
const reward = await this.fetchReward(productId);
|
|
368
|
+
this.state = WidgetState.LOADED;
|
|
369
|
+
this.logger.info("Reward data fetched successfully");
|
|
370
|
+
(_b = (_a = this.events) === null || _a === void 0 ? void 0 : _a.onLoaded) === null || _b === void 0 ? void 0 : _b.call(_a, productId, reward);
|
|
371
|
+
return reward;
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
if (this.retryCount < this.MAX_RETRIES) {
|
|
375
|
+
this.retryCount++;
|
|
376
|
+
this.logger.info(`Retrying reward fetch (${this.retryCount}/${this.MAX_RETRIES})`, error);
|
|
377
|
+
// Immediate retry
|
|
378
|
+
return this.fetchRewardWithRetry(productId);
|
|
379
|
+
}
|
|
380
|
+
this.state = WidgetState.ERROR;
|
|
381
|
+
this.logger.error("Failed to fetch reward after retries", error);
|
|
382
|
+
(_d = (_c = this.events) === null || _c === void 0 ? void 0 : _c.onError) === null || _d === void 0 ? void 0 : _d.call(_c, productId, error);
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Fetch reward data for a product
|
|
388
|
+
*/
|
|
389
|
+
async fetchReward(productId) {
|
|
390
|
+
var _a;
|
|
391
|
+
if (!this.apiClient) {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
const response = await this.apiClient.getProductReward(productId);
|
|
395
|
+
if (response.success && response.data) {
|
|
396
|
+
this.logger.info("Reward data loaded", response.data);
|
|
397
|
+
return response.data;
|
|
398
|
+
}
|
|
399
|
+
// API call failed or returned no data - throw error to trigger retry
|
|
400
|
+
const errorMessage = ((_a = response.error) === null || _a === void 0 ? void 0 : _a.message) || "Reward data unavailable";
|
|
401
|
+
this.logger.warn(errorMessage, response.error);
|
|
402
|
+
throw new Error(errorMessage);
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Fetch widget configuration from API
|
|
406
|
+
*
|
|
407
|
+
* NOTE: This endpoint is currently NOT USED in the SDK implementation.
|
|
408
|
+
* It is reserved for future enhancements such as:
|
|
409
|
+
* - Server-side control of widget display settings (show/hide elements)
|
|
410
|
+
* - Per-merchant feature flags (enable/disable social sharing, group creation)
|
|
411
|
+
* - Group size requirements and timeout configurations
|
|
412
|
+
* - Modal layout and behavior customization
|
|
413
|
+
* - A/B testing and dynamic theming
|
|
414
|
+
*
|
|
415
|
+
* For now, widget behavior is controlled by:
|
|
416
|
+
* 1. Merchant's init() options (theme, debug)
|
|
417
|
+
* 2. Reward data from /v1/sdk/products/:productId/reward
|
|
418
|
+
*
|
|
419
|
+
* This method is kept for future integration when modal group lobby,
|
|
420
|
+
* social sharing, and advanced customization features are implemented.
|
|
421
|
+
*
|
|
422
|
+
* @future Will be activated in modal implementation (post-SCRUM-248)
|
|
423
|
+
*/
|
|
424
|
+
async fetchWidgetConfig(productId) {
|
|
425
|
+
if (!this.apiClient) {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
const response = await this.apiClient.get(API_ENDPOINTS.WIDGET_CONFIG + `?productId=${productId}`);
|
|
430
|
+
if (response.success) {
|
|
431
|
+
this.logger.info("Widget configuration loaded", response.data);
|
|
432
|
+
// TODO: Use configuration data to customize widget appearance and behavior
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
// Gracefully handle API errors - widget will still render with defaults
|
|
436
|
+
this.logger.warn("Widget configuration not available (backend endpoint pending implementation)", response.error);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
// Catch any network errors and continue - widget rendering should not fail
|
|
441
|
+
this.logger.warn("Could not fetch widget configuration (backend endpoint pending)", error);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Resolve container from string selector or HTMLElement
|
|
446
|
+
*/
|
|
447
|
+
resolveContainer(container) {
|
|
448
|
+
if (typeof container === "string") {
|
|
449
|
+
return document.querySelector(container);
|
|
450
|
+
}
|
|
451
|
+
return container || null;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Render skeleton loading state with animated shimmer effect
|
|
455
|
+
*/
|
|
456
|
+
renderSkeleton(container) {
|
|
457
|
+
// Inject animations once
|
|
458
|
+
const shimmerStyle = document.createElement("style");
|
|
459
|
+
shimmerStyle.textContent = `
|
|
460
|
+
@keyframes cobuy-shimmer {
|
|
461
|
+
0% { background-position: -200px 0; }
|
|
462
|
+
100% { background-position: calc(200px + 100%) 0; }
|
|
463
|
+
}
|
|
464
|
+
@keyframes cobuy-fadeIn {
|
|
465
|
+
from {
|
|
466
|
+
opacity: 0;
|
|
467
|
+
transform: var(--cobuy-animation-transform, translateY(8px));
|
|
468
|
+
}
|
|
469
|
+
to {
|
|
470
|
+
opacity: 1;
|
|
471
|
+
transform: translateY(0);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
.cobuy-skeleton-text,
|
|
475
|
+
.cobuy-skeleton-button {
|
|
476
|
+
animation: cobuy-shimmer 1.5s ease-in-out infinite;
|
|
477
|
+
background: linear-gradient(90deg, #f0f0f0 0px, #e0e0e0 40px, #f0f0f0 80px);
|
|
478
|
+
background-size: 200px 100%;
|
|
479
|
+
}
|
|
480
|
+
`;
|
|
481
|
+
if (!document.querySelector("#cobuy-shimmer-style")) {
|
|
482
|
+
shimmerStyle.id = "cobuy-shimmer-style";
|
|
483
|
+
document.head.appendChild(shimmerStyle);
|
|
484
|
+
}
|
|
485
|
+
// Reward text placeholder - render directly with accessibility
|
|
486
|
+
const textPlaceholder = document.createElement("div");
|
|
487
|
+
textPlaceholder.className = "cobuy-skeleton-text";
|
|
488
|
+
textPlaceholder.setAttribute("aria-busy", "true");
|
|
489
|
+
textPlaceholder.setAttribute("role", "status");
|
|
490
|
+
textPlaceholder.setAttribute("aria-label", "Loading reward information");
|
|
491
|
+
textPlaceholder.style.height = "18px";
|
|
492
|
+
textPlaceholder.style.width = "70%";
|
|
493
|
+
textPlaceholder.style.borderRadius = "4px";
|
|
494
|
+
textPlaceholder.style.marginBottom = "8px";
|
|
495
|
+
textPlaceholder.style.opacity = "0";
|
|
496
|
+
textPlaceholder.style.animation =
|
|
497
|
+
"cobuy-fadeIn var(--cobuy-animation-duration, 300ms) var(--cobuy-animation-easing, ease-out) forwards";
|
|
498
|
+
// Button placeholder - render directly with accessibility
|
|
499
|
+
const buttonPlaceholder = document.createElement("div");
|
|
500
|
+
buttonPlaceholder.className = "cobuy-skeleton-button";
|
|
501
|
+
buttonPlaceholder.setAttribute("aria-busy", "true");
|
|
502
|
+
buttonPlaceholder.setAttribute("role", "button");
|
|
503
|
+
buttonPlaceholder.setAttribute("aria-label", "Loading action button");
|
|
504
|
+
buttonPlaceholder.style.height = "var(--cobuy-cta-min-height, 44px)";
|
|
505
|
+
buttonPlaceholder.style.minHeight = "var(--cobuy-cta-min-height, 44px)";
|
|
506
|
+
buttonPlaceholder.style.width = "100%";
|
|
507
|
+
buttonPlaceholder.style.borderRadius = "6px";
|
|
508
|
+
buttonPlaceholder.style.opacity = "0";
|
|
509
|
+
buttonPlaceholder.style.animation =
|
|
510
|
+
"cobuy-fadeIn var(--cobuy-animation-duration, 300ms) var(--cobuy-animation-easing, ease-out) forwards 100ms";
|
|
511
|
+
container.innerHTML = "";
|
|
512
|
+
container.appendChild(textPlaceholder);
|
|
513
|
+
container.appendChild(buttonPlaceholder);
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Render error state with retry button
|
|
517
|
+
*/
|
|
518
|
+
renderError(container) {
|
|
519
|
+
// Error message - render directly with accessibility
|
|
520
|
+
const message = document.createElement("div");
|
|
521
|
+
message.className = "cobuy-error-message";
|
|
522
|
+
message.setAttribute("role", "alert");
|
|
523
|
+
message.setAttribute("aria-live", "polite");
|
|
524
|
+
message.setAttribute("aria-label", "Error loading CoBuy offer");
|
|
525
|
+
message.style.fontSize = "13px";
|
|
526
|
+
message.style.color = "#991b1b";
|
|
527
|
+
message.style.fontFamily =
|
|
528
|
+
"var(--cobuy-font-family, Inter, system-ui, -apple-system, sans-serif)";
|
|
529
|
+
message.style.marginBottom = "12px";
|
|
530
|
+
message.style.lineHeight = "1.4";
|
|
531
|
+
message.style.wordWrap = "break-word";
|
|
532
|
+
message.style.opacity = "0";
|
|
533
|
+
message.style.animation =
|
|
534
|
+
"cobuy-fadeIn var(--cobuy-animation-duration, 300ms) var(--cobuy-animation-easing, ease-out) forwards";
|
|
535
|
+
message.textContent = "Unable to load CoBuy offer. Please try again.";
|
|
536
|
+
// Retry button - render directly with accessibility
|
|
537
|
+
const retryButton = document.createElement("button");
|
|
538
|
+
retryButton.type = "button";
|
|
539
|
+
retryButton.className = "cobuy-error-button";
|
|
540
|
+
retryButton.textContent = "Retry";
|
|
541
|
+
retryButton.setAttribute("aria-label", "Retry loading CoBuy offer");
|
|
542
|
+
retryButton.setAttribute("role", "button");
|
|
543
|
+
retryButton.setAttribute("tabindex", "0");
|
|
544
|
+
retryButton.title = "Click to retry loading the CoBuy offer";
|
|
545
|
+
retryButton.style.padding = "10px 14px";
|
|
546
|
+
retryButton.style.minHeight = "36px";
|
|
547
|
+
retryButton.style.backgroundColor = "#dc2626";
|
|
548
|
+
retryButton.style.color = "white";
|
|
549
|
+
retryButton.style.border = "none";
|
|
550
|
+
retryButton.style.borderRadius = "var(--cobuy-border-radius, 6px)";
|
|
551
|
+
retryButton.style.fontSize = "13px";
|
|
552
|
+
retryButton.style.fontWeight = "600";
|
|
553
|
+
retryButton.style.fontFamily =
|
|
554
|
+
"var(--cobuy-font-family, Inter, system-ui, -apple-system, sans-serif)";
|
|
555
|
+
retryButton.style.cursor = "pointer";
|
|
556
|
+
retryButton.style.transition = "background-color 0.2s, transform 0.1s";
|
|
557
|
+
retryButton.style.opacity = "0";
|
|
558
|
+
retryButton.style.animation =
|
|
559
|
+
"cobuy-fadeIn var(--cobuy-animation-duration, 300ms) var(--cobuy-animation-easing, ease-out) forwards 100ms";
|
|
560
|
+
retryButton.addEventListener("mouseenter", () => {
|
|
561
|
+
retryButton.style.backgroundColor = "#b91c1c";
|
|
562
|
+
});
|
|
563
|
+
retryButton.addEventListener("mouseleave", () => {
|
|
564
|
+
retryButton.style.backgroundColor = "#dc2626";
|
|
565
|
+
});
|
|
566
|
+
retryButton.addEventListener("mousedown", () => {
|
|
567
|
+
retryButton.style.transform = "scale(0.97)";
|
|
568
|
+
});
|
|
569
|
+
retryButton.addEventListener("mouseup", () => {
|
|
570
|
+
retryButton.style.transform = "scale(1)";
|
|
571
|
+
});
|
|
572
|
+
retryButton.addEventListener("click", () => this.handleRetry());
|
|
573
|
+
container.innerHTML = "";
|
|
574
|
+
container.appendChild(message);
|
|
575
|
+
container.appendChild(retryButton);
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Handle retry button click - reset retry count and re-fetch
|
|
579
|
+
*/
|
|
580
|
+
async handleRetry() {
|
|
581
|
+
var _a, _b;
|
|
582
|
+
if (!this.currentContainer || !this.currentProductId) {
|
|
583
|
+
this.logger.error("Cannot retry: missing container or productId");
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
this.retryCount = 0;
|
|
587
|
+
this.state = WidgetState.LOADING;
|
|
588
|
+
(_b = (_a = this.events) === null || _a === void 0 ? void 0 : _a.onLoading) === null || _b === void 0 ? void 0 : _b.call(_a, this.currentProductId);
|
|
589
|
+
this.renderSkeleton(this.currentContainer);
|
|
590
|
+
const rewardData = await this.fetchRewardWithRetry(this.currentProductId);
|
|
591
|
+
// Render based on final state (state may be ERROR after fetchRewardWithRetry)
|
|
592
|
+
if (this.state === WidgetState.ERROR) {
|
|
593
|
+
this.renderError(this.currentContainer);
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
// LOADED state
|
|
597
|
+
this.createWidget(rewardData, this.currentContainer);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
createWidget(rewardData, container) {
|
|
601
|
+
var _a, _b;
|
|
602
|
+
const wrapper = document.createElement("div");
|
|
603
|
+
wrapper.className = "cobuy-widget";
|
|
604
|
+
wrapper.style.display = "grid";
|
|
605
|
+
wrapper.style.gridTemplateColumns = "1fr";
|
|
606
|
+
wrapper.style.gap = "var(--cobuy-gap, 8px)";
|
|
607
|
+
wrapper.style.alignItems = "stretch";
|
|
608
|
+
wrapper.style.width = "100%";
|
|
609
|
+
wrapper.style.maxWidth = "100%";
|
|
610
|
+
wrapper.style.boxSizing = "border-box";
|
|
611
|
+
// Reward text - use semantic <p> tag with accessibility attributes
|
|
612
|
+
const rewardLine = document.createElement("p");
|
|
613
|
+
rewardLine.className = "cobuy-reward-text";
|
|
614
|
+
rewardLine.setAttribute("role", "region");
|
|
615
|
+
rewardLine.setAttribute("aria-label", "CoBuy reward offer");
|
|
616
|
+
rewardLine.style.fontSize = "var(--cobuy-reward-font-size, 14px)";
|
|
617
|
+
rewardLine.style.fontWeight = "600";
|
|
618
|
+
rewardLine.style.color = "var(--cobuy-text-color, #111827)";
|
|
619
|
+
rewardLine.style.fontFamily =
|
|
620
|
+
"var(--cobuy-font-family, Inter, system-ui, -apple-system, sans-serif)";
|
|
621
|
+
rewardLine.style.margin = "0";
|
|
622
|
+
rewardLine.style.lineHeight = "1.4";
|
|
623
|
+
rewardLine.style.wordWrap = "break-word";
|
|
624
|
+
rewardLine.style.overflowWrap = "break-word";
|
|
625
|
+
rewardLine.style.whiteSpace = "normal";
|
|
626
|
+
rewardLine.style.width = "100%";
|
|
627
|
+
rewardLine.style.opacity = "0";
|
|
628
|
+
rewardLine.style.animation =
|
|
629
|
+
"cobuy-fadeIn var(--cobuy-animation-duration, 300ms) var(--cobuy-animation-easing, ease-out) forwards";
|
|
630
|
+
if (((_a = rewardData === null || rewardData === void 0 ? void 0 : rewardData.eligibility) === null || _a === void 0 ? void 0 : _a.isEligible) && rewardData.reward) {
|
|
631
|
+
const rewardText = this.formatRewardText(rewardData.reward);
|
|
632
|
+
rewardLine.textContent = rewardText
|
|
633
|
+
? `Save up to ${rewardText} with CoBuy`
|
|
634
|
+
: "CoBuy reward available";
|
|
635
|
+
rewardLine.setAttribute("aria-label", `Eligible for CoBuy reward: ${rewardLine.textContent}`);
|
|
636
|
+
rewardLine.title = rewardLine.textContent;
|
|
637
|
+
}
|
|
638
|
+
else if (rewardData && !((_b = rewardData.eligibility) === null || _b === void 0 ? void 0 : _b.isEligible)) {
|
|
639
|
+
rewardLine.textContent = "Join with CoBuy to unlock rewards";
|
|
640
|
+
rewardLine.setAttribute("aria-label", "Join with CoBuy to unlock rewards");
|
|
641
|
+
rewardLine.title = "Join with CoBuy to unlock rewards";
|
|
642
|
+
rewardLine.style.color = "#6b7280";
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
rewardLine.textContent = "CoBuy offer loading or unavailable";
|
|
646
|
+
rewardLine.setAttribute("aria-label", "CoBuy offer loading or unavailable");
|
|
647
|
+
rewardLine.title = "CoBuy offer loading or unavailable";
|
|
648
|
+
rewardLine.style.color = "#6b7280";
|
|
649
|
+
}
|
|
650
|
+
// Button - semantic button element with accessibility
|
|
651
|
+
const button = document.createElement("button");
|
|
652
|
+
button.type = "button";
|
|
653
|
+
button.className = "cobuy-button";
|
|
654
|
+
button.textContent = "Buy with CoBuy";
|
|
655
|
+
button.setAttribute("aria-label", "Buy this product with CoBuy for group buying discounts");
|
|
656
|
+
button.setAttribute("role", "button");
|
|
657
|
+
button.setAttribute("tabindex", "0");
|
|
658
|
+
button.title = "Buy with CoBuy for discounts when others join";
|
|
659
|
+
button.style.padding = "10px 14px";
|
|
660
|
+
button.style.backgroundColor = "var(--cobuy-primary-color, #4f46e5)";
|
|
661
|
+
button.style.color = "var(--cobuy-button-text-color, white)";
|
|
662
|
+
button.style.border = "none";
|
|
663
|
+
button.style.borderRadius = "var(--cobuy-border-radius, 6px)";
|
|
664
|
+
button.style.fontSize = "13px";
|
|
665
|
+
button.style.fontWeight = "700";
|
|
666
|
+
button.style.fontFamily =
|
|
667
|
+
"var(--cobuy-font-family, Inter, system-ui, -apple-system, sans-serif)";
|
|
668
|
+
button.style.cursor = "pointer";
|
|
669
|
+
button.style.width = "100%";
|
|
670
|
+
button.style.minHeight = "var(--cobuy-cta-min-height, 44px)";
|
|
671
|
+
button.style.height = "auto";
|
|
672
|
+
button.style.lineHeight = "1.4";
|
|
673
|
+
button.style.wordWrap = "break-word";
|
|
674
|
+
button.style.whiteSpace = "normal";
|
|
675
|
+
button.style.transition = "opacity 0.2s, transform 0.1s";
|
|
676
|
+
button.style.opacity = "0";
|
|
677
|
+
button.style.animation =
|
|
678
|
+
"cobuy-fadeIn var(--cobuy-animation-duration, 300ms) var(--cobuy-animation-easing, ease-out) forwards 100ms";
|
|
679
|
+
// Hover effect using opacity for better theme compatibility
|
|
680
|
+
button.addEventListener("mouseenter", () => {
|
|
681
|
+
button.style.opacity = "0.9";
|
|
682
|
+
});
|
|
683
|
+
button.addEventListener("mouseleave", () => {
|
|
684
|
+
button.style.opacity = "1";
|
|
685
|
+
});
|
|
686
|
+
button.addEventListener("mousedown", () => {
|
|
687
|
+
button.style.transform = "scale(0.97)";
|
|
688
|
+
});
|
|
689
|
+
button.addEventListener("mouseup", () => {
|
|
690
|
+
button.style.transform = "scale(1)";
|
|
691
|
+
});
|
|
692
|
+
// Emit button click event
|
|
693
|
+
button.addEventListener("click", () => {
|
|
694
|
+
if (this.currentProductId && this.debouncedCTAClick) {
|
|
695
|
+
this.debouncedCTAClick(this.currentProductId);
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
wrapper.appendChild(rewardLine);
|
|
699
|
+
wrapper.appendChild(button);
|
|
700
|
+
container.innerHTML = "";
|
|
701
|
+
container.appendChild(wrapper);
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Inject base layout styles for responsive widget rendering
|
|
705
|
+
*/
|
|
706
|
+
injectBaseStyles() {
|
|
707
|
+
if (document.querySelector("#cobuy-widget-base-styles")) {
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
const style = document.createElement("style");
|
|
711
|
+
style.id = "cobuy-widget-base-styles";
|
|
712
|
+
style.textContent = `
|
|
713
|
+
.cobuy-widget {
|
|
714
|
+
display: grid;
|
|
715
|
+
grid-template-columns: 1fr;
|
|
716
|
+
gap: var(--cobuy-gap, 8px);
|
|
717
|
+
align-items: stretch;
|
|
718
|
+
width: 100%;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
.cobuy-widget .cobuy-reward-text {
|
|
722
|
+
margin: 0;
|
|
723
|
+
width: 100%;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
.cobuy-widget .cobuy-button {
|
|
727
|
+
min-height: var(--cobuy-cta-min-height, 44px);
|
|
728
|
+
width: 100%;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
@media (max-width: 640px) {
|
|
732
|
+
.cobuy-widget {
|
|
733
|
+
grid-template-columns: 1fr;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
`;
|
|
737
|
+
document.head.appendChild(style);
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Handle CTA button click with analytics and modal opening
|
|
741
|
+
*/
|
|
742
|
+
handleCTAClick(productId) {
|
|
743
|
+
var _a, _b;
|
|
744
|
+
this.logger.info(`CTA clicked for product: ${productId}`);
|
|
745
|
+
// Track analytics event asynchronously (fire-and-forget)
|
|
746
|
+
if (this.analyticsClient) {
|
|
747
|
+
this.analyticsClient.trackCtaClick(productId).catch((error) => {
|
|
748
|
+
this.logger.warn("Analytics tracking failed", error);
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
// Emit button click event
|
|
752
|
+
(_b = (_a = this.events) === null || _a === void 0 ? void 0 : _a.onButtonClick) === null || _b === void 0 ? void 0 : _b.call(_a, productId);
|
|
753
|
+
// Open modal if SDK instance is available
|
|
754
|
+
// Access the global CoBuy SDK instance through window
|
|
755
|
+
if (typeof window !== "undefined") {
|
|
756
|
+
const cobuySDK = window.CoBuy;
|
|
757
|
+
if (cobuySDK) {
|
|
758
|
+
try {
|
|
759
|
+
cobuySDK.openModal({
|
|
760
|
+
productId,
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
catch (error) {
|
|
764
|
+
this.logger.warn("Failed to open modal", error);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
formatRewardText(reward) {
|
|
770
|
+
var _a;
|
|
771
|
+
if (!reward)
|
|
772
|
+
return "";
|
|
773
|
+
const value = (_a = reward.value) === null || _a === void 0 ? void 0 : _a.toString();
|
|
774
|
+
switch (reward.type) {
|
|
775
|
+
case "percentage":
|
|
776
|
+
case "cashback":
|
|
777
|
+
return value ? `${value}%` : "";
|
|
778
|
+
case "points":
|
|
779
|
+
return value ? `${value} points` : "";
|
|
780
|
+
case "fixed":
|
|
781
|
+
return value ? `$${value}` : "";
|
|
782
|
+
default:
|
|
783
|
+
return "";
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* HTTP client for communicating with CoBuy API
|
|
790
|
+
*
|
|
791
|
+
* NOTE: Backend API endpoints are currently under development.
|
|
792
|
+
* All API calls are designed to fail gracefully with proper error handling,
|
|
793
|
+
* ensuring the SDK continues to function (with placeholder UI) even when
|
|
794
|
+
* backend endpoints are not yet available.
|
|
795
|
+
*/
|
|
796
|
+
class ApiClient {
|
|
797
|
+
constructor(config) {
|
|
798
|
+
this.baseUrl = config.baseUrl;
|
|
799
|
+
this.merchantKey = config.merchantKey;
|
|
800
|
+
this.logger = new Logger(config.debug || false);
|
|
801
|
+
this.defaultTimeout = config.timeout || 30000; // 30 seconds default
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Make an HTTP request to the API
|
|
805
|
+
*/
|
|
806
|
+
async request(endpoint, options = {}) {
|
|
807
|
+
const url = `${this.baseUrl}${endpoint}`;
|
|
808
|
+
const method = options.method || "GET";
|
|
809
|
+
const timeout = options.timeout || this.defaultTimeout;
|
|
810
|
+
this.logger.info(`API Request: ${method} ${url}`);
|
|
811
|
+
try {
|
|
812
|
+
const controller = new AbortController();
|
|
813
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
814
|
+
const response = await fetch(url, {
|
|
815
|
+
method,
|
|
816
|
+
headers: Object.assign({ "Content-Type": "application/json", "X-CoBuy-Merchant-Key": this.merchantKey, "X-CoBuy-SDK-Version": "1.0.0" }, options.headers),
|
|
817
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
818
|
+
signal: controller.signal,
|
|
819
|
+
});
|
|
820
|
+
clearTimeout(timeoutId);
|
|
821
|
+
const responseData = await response.json();
|
|
822
|
+
if (!response.ok) {
|
|
823
|
+
this.logger.error(`API Error: ${response.status} ${response.statusText}`, responseData);
|
|
824
|
+
return {
|
|
825
|
+
success: false,
|
|
826
|
+
error: {
|
|
827
|
+
message: responseData.message || response.statusText,
|
|
828
|
+
code: responseData.code,
|
|
829
|
+
statusCode: response.status,
|
|
830
|
+
},
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
this.logger.info(`API Response: ${method} ${url} - Success`);
|
|
834
|
+
return {
|
|
835
|
+
success: true,
|
|
836
|
+
data: responseData,
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
catch (error) {
|
|
840
|
+
if (error instanceof Error) {
|
|
841
|
+
if (error.name === "AbortError") {
|
|
842
|
+
this.logger.error(`API Timeout: ${method} ${url}`);
|
|
843
|
+
return {
|
|
844
|
+
success: false,
|
|
845
|
+
error: {
|
|
846
|
+
message: "Request timeout",
|
|
847
|
+
code: "TIMEOUT",
|
|
848
|
+
},
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
this.logger.error(`API Error: ${method} ${url}`, error);
|
|
852
|
+
return {
|
|
853
|
+
success: false,
|
|
854
|
+
error: {
|
|
855
|
+
message: error.message,
|
|
856
|
+
code: "NETWORK_ERROR",
|
|
857
|
+
},
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
return {
|
|
861
|
+
success: false,
|
|
862
|
+
error: {
|
|
863
|
+
message: "Unknown error occurred",
|
|
864
|
+
code: "UNKNOWN_ERROR",
|
|
865
|
+
},
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Make a GET request
|
|
871
|
+
*/
|
|
872
|
+
async get(endpoint, options) {
|
|
873
|
+
return this.request(endpoint, Object.assign(Object.assign({}, options), { method: "GET" }));
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Make a POST request
|
|
877
|
+
*/
|
|
878
|
+
async post(endpoint, body, options) {
|
|
879
|
+
return this.request(endpoint, Object.assign(Object.assign({}, options), { method: "POST", body }));
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Make a PUT request
|
|
883
|
+
*/
|
|
884
|
+
async put(endpoint, body, options) {
|
|
885
|
+
return this.request(endpoint, Object.assign(Object.assign({}, options), { method: "PUT", body }));
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Make a PATCH request
|
|
889
|
+
*/
|
|
890
|
+
async patch(endpoint, body, options) {
|
|
891
|
+
return this.request(endpoint, Object.assign(Object.assign({}, options), { method: "PATCH", body }));
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Make a DELETE request
|
|
895
|
+
*/
|
|
896
|
+
async delete(endpoint, options) {
|
|
897
|
+
return this.request(endpoint, Object.assign(Object.assign({}, options), { method: "DELETE" }));
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Update the base URL
|
|
901
|
+
*/
|
|
902
|
+
setBaseUrl(baseUrl) {
|
|
903
|
+
this.baseUrl = baseUrl;
|
|
904
|
+
this.logger.info(`API Base URL updated: ${baseUrl}`);
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Update the merchant key
|
|
908
|
+
*/
|
|
909
|
+
setMerchantKey(merchantKey) {
|
|
910
|
+
this.merchantKey = merchantKey;
|
|
911
|
+
this.logger.info("Merchant key updated");
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Get product reward information
|
|
915
|
+
*
|
|
916
|
+
* Retrieves reward data and eligibility for a specific product.
|
|
917
|
+
* This is a core method for SCRUM-247: Retrieve reward information.
|
|
918
|
+
*
|
|
919
|
+
* @param productId - The product ID to fetch reward information for
|
|
920
|
+
* @returns Promise with reward data including eligibility status
|
|
921
|
+
*/
|
|
922
|
+
async getProductReward(productId) {
|
|
923
|
+
var _a;
|
|
924
|
+
const endpoint = buildApiUrl("", API_ENDPOINTS.PRODUCT_REWARD, { productId });
|
|
925
|
+
this.logger.info(`Fetching reward for product: ${productId}`);
|
|
926
|
+
const response = await this.get(endpoint);
|
|
927
|
+
// Handle the nested response structure from backend
|
|
928
|
+
if (response.success && ((_a = response.data) === null || _a === void 0 ? void 0 : _a.data)) {
|
|
929
|
+
return {
|
|
930
|
+
success: true,
|
|
931
|
+
data: response.data.data,
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
// Return error response
|
|
935
|
+
return {
|
|
936
|
+
success: false,
|
|
937
|
+
error: response.error || {
|
|
938
|
+
message: "Failed to fetch reward data",
|
|
939
|
+
code: "REWARD_FETCH_ERROR",
|
|
940
|
+
},
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* AnalyticsClient handles SDK event tracking and analytics
|
|
947
|
+
* Uses session ID provided by the SDK for all analytics events
|
|
948
|
+
*/
|
|
949
|
+
class AnalyticsClient {
|
|
950
|
+
constructor(_merchantKey, sdkVersion, sessionId, apiClient, debug = false) {
|
|
951
|
+
this.logger = new Logger(debug);
|
|
952
|
+
this.apiClient = apiClient;
|
|
953
|
+
this.sdkVersion = sdkVersion;
|
|
954
|
+
this.sessionId = sessionId;
|
|
955
|
+
this.logger.debug(`[Analytics] Initialized with session ID: ${sessionId}`);
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Track a CTA click event
|
|
959
|
+
*/
|
|
960
|
+
async trackCtaClick(productId) {
|
|
961
|
+
const event = {
|
|
962
|
+
event: "cta_clicked",
|
|
963
|
+
productId,
|
|
964
|
+
timestamp: new Date().toISOString(),
|
|
965
|
+
sessionId: this.sessionId,
|
|
966
|
+
context: {
|
|
967
|
+
pageUrl: typeof window !== "undefined" ? window.location.href : undefined,
|
|
968
|
+
userAgent: typeof navigator !== "undefined" ? navigator.userAgent : undefined,
|
|
969
|
+
sdkVersion: this.sdkVersion,
|
|
970
|
+
},
|
|
971
|
+
};
|
|
972
|
+
try {
|
|
973
|
+
await this.sendEvent(event);
|
|
974
|
+
this.logger.info(`[Analytics] CTA click tracked for product: ${productId}`);
|
|
975
|
+
}
|
|
976
|
+
catch (error) {
|
|
977
|
+
// Log but don't throw - analytics failure should not break UX
|
|
978
|
+
this.logger.error("[Analytics] Failed to track CTA click", error);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Send event to backend analytics endpoint using unified ApiClient
|
|
983
|
+
*/
|
|
984
|
+
async sendEvent(event) {
|
|
985
|
+
var _a, _b;
|
|
986
|
+
if (!this.apiClient) {
|
|
987
|
+
this.logger.warn("[Analytics] ApiClient not available, skipping event");
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
try {
|
|
991
|
+
const response = await this.apiClient.request("/v1/sdk/analytics", {
|
|
992
|
+
method: "POST",
|
|
993
|
+
body: event,
|
|
994
|
+
});
|
|
995
|
+
if (!response.success) {
|
|
996
|
+
throw new Error(((_a = response.error) === null || _a === void 0 ? void 0 : _a.message) || "Failed to send analytics event");
|
|
997
|
+
}
|
|
998
|
+
this.logger.debug("[Analytics] Event recorded", { id: (_b = response.data) === null || _b === void 0 ? void 0 : _b.id });
|
|
999
|
+
}
|
|
1000
|
+
catch (error) {
|
|
1001
|
+
this.logger.error("[Analytics] Failed to send event", error);
|
|
1002
|
+
// Re-throw for caller to handle if needed
|
|
1003
|
+
throw error;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* Track custom event (extensible for future use)
|
|
1008
|
+
*/
|
|
1009
|
+
async trackEvent(eventName, productId, metadata) {
|
|
1010
|
+
const event = {
|
|
1011
|
+
event: eventName,
|
|
1012
|
+
productId,
|
|
1013
|
+
timestamp: new Date().toISOString(),
|
|
1014
|
+
sessionId: this.sessionId,
|
|
1015
|
+
context: Object.assign({ pageUrl: typeof window !== "undefined" ? window.location.href : undefined, userAgent: typeof navigator !== "undefined" ? navigator.userAgent : undefined, sdkVersion: this.sdkVersion }, metadata),
|
|
1016
|
+
};
|
|
1017
|
+
try {
|
|
1018
|
+
await this.sendEvent(event);
|
|
1019
|
+
this.logger.info(`[Analytics] Event tracked: ${eventName}`);
|
|
1020
|
+
}
|
|
1021
|
+
catch (error) {
|
|
1022
|
+
this.logger.error(`[Analytics] Failed to track event: ${eventName}`, error);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* SDK version constant
|
|
1029
|
+
*/
|
|
1030
|
+
const SDK_VERSION = "1.0.0";
|
|
1031
|
+
/**
|
|
1032
|
+
* Main CoBuy SDK implementation
|
|
1033
|
+
*/
|
|
1034
|
+
class CoBuy {
|
|
1035
|
+
constructor() {
|
|
1036
|
+
this.apiClient = null;
|
|
1037
|
+
this.analyticsClient = null;
|
|
1038
|
+
this.sessionId = "";
|
|
1039
|
+
this.SESSION_STORAGE_KEY = "cobuy_sdk_session_id";
|
|
1040
|
+
this.configManager = new ConfigManager();
|
|
1041
|
+
this.logger = new Logger(false);
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Initialize or retrieve existing session ID from storage
|
|
1045
|
+
*/
|
|
1046
|
+
initializeSessionId() {
|
|
1047
|
+
// Try to retrieve from localStorage
|
|
1048
|
+
if (typeof window !== "undefined" && window.localStorage) {
|
|
1049
|
+
const stored = window.localStorage.getItem(this.SESSION_STORAGE_KEY);
|
|
1050
|
+
if (stored) {
|
|
1051
|
+
this.logger.debug(`[SDK] Using existing session ID: ${stored}`);
|
|
1052
|
+
return stored;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
// Generate new UUID v4 session ID
|
|
1056
|
+
const newSessionId = this.generateUUID();
|
|
1057
|
+
// Store in localStorage for persistence across page reloads
|
|
1058
|
+
if (typeof window !== "undefined" && window.localStorage) {
|
|
1059
|
+
try {
|
|
1060
|
+
window.localStorage.setItem(this.SESSION_STORAGE_KEY, newSessionId);
|
|
1061
|
+
}
|
|
1062
|
+
catch (e) {
|
|
1063
|
+
// Silently fail if localStorage unavailable (private mode, etc.)
|
|
1064
|
+
this.logger.warn("[SDK] Could not persist session ID to localStorage", e);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
this.logger.debug(`[SDK] Generated new session ID: ${newSessionId}`);
|
|
1068
|
+
return newSessionId;
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* Generate UUID v4 compatible ID
|
|
1072
|
+
*/
|
|
1073
|
+
generateUUID() {
|
|
1074
|
+
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
1075
|
+
return crypto.randomUUID();
|
|
1076
|
+
}
|
|
1077
|
+
// Fallback for environments without crypto.randomUUID
|
|
1078
|
+
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Get the current session ID (core SDK concept)
|
|
1082
|
+
*/
|
|
1083
|
+
getSessionId() {
|
|
1084
|
+
return this.sessionId;
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Initialize the SDK with configuration
|
|
1088
|
+
*/
|
|
1089
|
+
init(options) {
|
|
1090
|
+
try {
|
|
1091
|
+
const config = this.configManager.setConfig(options);
|
|
1092
|
+
this.logger.setDebug(config.debug);
|
|
1093
|
+
this.logger.info(`Initialized with environment: ${config.env}`);
|
|
1094
|
+
this.logger.info(`API Base URL: ${config.apiBaseUrl}`);
|
|
1095
|
+
// Initialize session ID (core SDK concept, used by all services)
|
|
1096
|
+
this.sessionId = this.initializeSessionId();
|
|
1097
|
+
// Initialize API client
|
|
1098
|
+
this.apiClient = new ApiClient({
|
|
1099
|
+
baseUrl: config.apiBaseUrl,
|
|
1100
|
+
merchantKey: config.merchantKey,
|
|
1101
|
+
debug: config.debug,
|
|
1102
|
+
});
|
|
1103
|
+
// Initialize Analytics client with the SDK's session ID
|
|
1104
|
+
this.analyticsClient = new AnalyticsClient(config.merchantKey, SDK_VERSION, this.sessionId, this.apiClient, config.debug);
|
|
1105
|
+
}
|
|
1106
|
+
catch (error) {
|
|
1107
|
+
this.logger.error("Failed to initialize SDK", error);
|
|
1108
|
+
throw error;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Render the CoBuy widget into a DOM container
|
|
1113
|
+
*/
|
|
1114
|
+
renderWidget(options) {
|
|
1115
|
+
// Check if SDK is initialized
|
|
1116
|
+
if (!this.configManager.isInitialized()) {
|
|
1117
|
+
throw new CoBuyNotInitializedError();
|
|
1118
|
+
}
|
|
1119
|
+
// Validate productId
|
|
1120
|
+
if (!options.productId) {
|
|
1121
|
+
const errorMessage = "productId is required for renderWidget";
|
|
1122
|
+
this.logger.error(errorMessage);
|
|
1123
|
+
if (options.onError) {
|
|
1124
|
+
options.onError(new Error(errorMessage));
|
|
1125
|
+
}
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
try {
|
|
1129
|
+
const config = this.configManager.getConfig();
|
|
1130
|
+
const widget = new WidgetRoot(config, this.apiClient, this.analyticsClient);
|
|
1131
|
+
widget.render(options);
|
|
1132
|
+
}
|
|
1133
|
+
catch (error) {
|
|
1134
|
+
this.logger.error("Failed to render widget", error);
|
|
1135
|
+
if (options.onError) {
|
|
1136
|
+
options.onError(error);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Open modal for product details
|
|
1142
|
+
*/
|
|
1143
|
+
openModal(options) {
|
|
1144
|
+
var _a;
|
|
1145
|
+
if (!this.configManager.isInitialized()) {
|
|
1146
|
+
this.logger.warn("SDK not initialized, cannot open modal");
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
this.logger.info(`Opening modal for product: ${options.productId}`);
|
|
1150
|
+
try {
|
|
1151
|
+
// Find or create modal container
|
|
1152
|
+
const modalContainer = document.getElementById("cobuy-modal-root");
|
|
1153
|
+
if (!modalContainer) {
|
|
1154
|
+
this.logger.warn("Modal container #cobuy-modal-root not found. Create an element with id='cobuy-modal-root' to use modals.");
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
// Show modal (placeholder - will be replaced with actual modal implementation)
|
|
1158
|
+
modalContainer.style.display = "block";
|
|
1159
|
+
modalContainer.setAttribute("data-product-id", options.productId);
|
|
1160
|
+
// Call callback if provided
|
|
1161
|
+
if (options.onOpen) {
|
|
1162
|
+
options.onOpen(options.productId);
|
|
1163
|
+
}
|
|
1164
|
+
// Emit event if configured
|
|
1165
|
+
const config = this.configManager.getConfig();
|
|
1166
|
+
if ((_a = config.events) === null || _a === void 0 ? void 0 : _a.onModalOpen) {
|
|
1167
|
+
config.events.onModalOpen(options.productId);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
catch (error) {
|
|
1171
|
+
this.logger.error("Failed to open modal", error);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* Close the modal
|
|
1176
|
+
*/
|
|
1177
|
+
closeModal() {
|
|
1178
|
+
var _a;
|
|
1179
|
+
try {
|
|
1180
|
+
const modalContainer = document.getElementById("cobuy-modal-root");
|
|
1181
|
+
if (modalContainer) {
|
|
1182
|
+
modalContainer.style.display = "none";
|
|
1183
|
+
const currentProductId = modalContainer.getAttribute("data-product-id");
|
|
1184
|
+
// Emit event if configured
|
|
1185
|
+
const config = this.configManager.getConfig();
|
|
1186
|
+
if (((_a = config.events) === null || _a === void 0 ? void 0 : _a.onModalClose) && currentProductId) {
|
|
1187
|
+
config.events.onModalClose(currentProductId);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
this.logger.info("Modal closed");
|
|
1191
|
+
}
|
|
1192
|
+
catch (error) {
|
|
1193
|
+
this.logger.error("Failed to close modal", error);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Get the initialized API client instance
|
|
1198
|
+
*/
|
|
1199
|
+
getApiClient() {
|
|
1200
|
+
return this.apiClient;
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Get the initialized Analytics client instance
|
|
1204
|
+
*/
|
|
1205
|
+
getAnalyticsClient() {
|
|
1206
|
+
return this.analyticsClient;
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Get SDK version
|
|
1210
|
+
*/
|
|
1211
|
+
get version() {
|
|
1212
|
+
return SDK_VERSION;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// Create singleton instance
|
|
1217
|
+
const instance = new CoBuy();
|
|
1218
|
+
// Attach to window if in browser environment
|
|
1219
|
+
if (typeof window !== "undefined") {
|
|
1220
|
+
if (window.CoBuy) {
|
|
1221
|
+
console.warn("[CoBuy] Multiple SDK bundles detected. This may cause unexpected behavior.");
|
|
1222
|
+
}
|
|
1223
|
+
else {
|
|
1224
|
+
window.CoBuy = instance;
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
return instance;
|
|
1229
|
+
|
|
1230
|
+
})();
|
|
1231
|
+
//# sourceMappingURL=cobuy-sdk.umd.js.map
|