clishop 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/.cursor/rules/commit-workflow.mdc +42 -0
- package/README.md +333 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3878 -0
- package/package.json +52 -0
- package/src/api.ts +89 -0
- package/src/auth.ts +117 -0
- package/src/commands/address.ts +213 -0
- package/src/commands/advertise.ts +702 -0
- package/src/commands/agent.ts +177 -0
- package/src/commands/auth.ts +122 -0
- package/src/commands/config.ts +56 -0
- package/src/commands/order.ts +334 -0
- package/src/commands/payment.ts +108 -0
- package/src/commands/review.ts +412 -0
- package/src/commands/search.ts +1319 -0
- package/src/commands/setup.ts +644 -0
- package/src/commands/status.ts +131 -0
- package/src/commands/store.ts +302 -0
- package/src/commands/support.ts +264 -0
- package/src/config.ts +127 -0
- package/src/index.ts +80 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,1319 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import inquirer from "inquirer";
|
|
5
|
+
import { getApiClient, handleApiError } from "../api.js";
|
|
6
|
+
import { getActiveAgent } from "../config.js";
|
|
7
|
+
|
|
8
|
+
export interface Product {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
description: string;
|
|
12
|
+
priceInCents: number;
|
|
13
|
+
currency: string;
|
|
14
|
+
category: string;
|
|
15
|
+
categoryId?: string;
|
|
16
|
+
vendor: string;
|
|
17
|
+
storeId: string;
|
|
18
|
+
storeName: string;
|
|
19
|
+
storeVerified: boolean;
|
|
20
|
+
storeRating: number | null;
|
|
21
|
+
rating: number;
|
|
22
|
+
reviewCount: number;
|
|
23
|
+
inStock: boolean;
|
|
24
|
+
imageUrl?: string;
|
|
25
|
+
sku?: string;
|
|
26
|
+
brand?: string;
|
|
27
|
+
model?: string;
|
|
28
|
+
gtin?: string;
|
|
29
|
+
variant?: string;
|
|
30
|
+
shippingPriceInCents?: number;
|
|
31
|
+
freeShipping: boolean;
|
|
32
|
+
shippingDays?: number;
|
|
33
|
+
stockQuantity?: number;
|
|
34
|
+
backorder: boolean;
|
|
35
|
+
freeReturns: boolean;
|
|
36
|
+
returnWindowDays?: number;
|
|
37
|
+
checkoutMode: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SearchResult {
|
|
41
|
+
products: Product[];
|
|
42
|
+
total: number;
|
|
43
|
+
page: number;
|
|
44
|
+
pageSize: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function formatPrice(cents: number, currency: string): string {
|
|
48
|
+
return new Intl.NumberFormat("en-US", {
|
|
49
|
+
style: "currency",
|
|
50
|
+
currency,
|
|
51
|
+
}).format(cents / 100);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Currency conversion ──────────────────────────────────────────────
|
|
55
|
+
// Uses the free open.er-api.com (no key needed, updates daily)
|
|
56
|
+
|
|
57
|
+
let rateCache: { base: string; rates: Record<string, number>; fetchedAt: number } | null = null;
|
|
58
|
+
|
|
59
|
+
async function fetchRates(baseCurrency: string): Promise<Record<string, number>> {
|
|
60
|
+
// Cache for 1 hour
|
|
61
|
+
if (rateCache && rateCache.base === baseCurrency && Date.now() - rateCache.fetchedAt < 3600000) {
|
|
62
|
+
return rateCache.rates;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(`https://open.er-api.com/v6/latest/${baseCurrency}`);
|
|
66
|
+
const data = await res.json() as { rates?: Record<string, number> };
|
|
67
|
+
if (data.rates) {
|
|
68
|
+
rateCache = { base: baseCurrency, rates: data.rates, fetchedAt: Date.now() };
|
|
69
|
+
return data.rates;
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// Silently fail — conversion is optional
|
|
73
|
+
}
|
|
74
|
+
return {};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function convertPrice(cents: number, fromCurrency: string, toCurrency: string, rates: Record<string, number>): number | null {
|
|
78
|
+
if (fromCurrency === toCurrency) return null; // same currency, no conversion needed
|
|
79
|
+
const rate = rates[toCurrency];
|
|
80
|
+
if (!rate) return null;
|
|
81
|
+
return Math.round(cents * rate);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function formatConverted(cents: number, fromCurrency: string, toCurrency: string, rates: Record<string, number>): string {
|
|
85
|
+
const converted = convertPrice(cents, fromCurrency, toCurrency, rates);
|
|
86
|
+
if (converted == null) return "";
|
|
87
|
+
return chalk.dim(` (~${formatPrice(converted, toCurrency)})`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function renderStars(rating: number): string {
|
|
91
|
+
const full = Math.floor(rating);
|
|
92
|
+
const half = rating - full >= 0.5 ? 1 : 0;
|
|
93
|
+
const empty = 5 - full - half;
|
|
94
|
+
return "★".repeat(full) + (half ? "½" : "") + "☆".repeat(empty);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function deliveryLabel(days: number): string {
|
|
98
|
+
if (days <= 0) return "Same-day";
|
|
99
|
+
if (days === 1) return "Next-day";
|
|
100
|
+
if (days === 2) return "2-day";
|
|
101
|
+
return `${days}-day`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function estimatedArrival(days: number): string {
|
|
105
|
+
const d = new Date();
|
|
106
|
+
d.setDate(d.getDate() + days);
|
|
107
|
+
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function scoreOutOf10(rating: number, maxScale = 5): string {
|
|
111
|
+
return ((rating / maxScale) * 10).toFixed(1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Free-form info renderer ───────────────────────────────────────────
|
|
115
|
+
// Recursively renders arbitrary key-value data from stores in a readable way.
|
|
116
|
+
// Handles nested objects, arrays, strings, numbers, booleans, etc.
|
|
117
|
+
|
|
118
|
+
function renderFreeFormInfo(data: any, indent: number = 0): void {
|
|
119
|
+
const pad = " ".repeat(indent);
|
|
120
|
+
|
|
121
|
+
if (data == null) return;
|
|
122
|
+
|
|
123
|
+
if (typeof data === "string") {
|
|
124
|
+
// Wrap long strings
|
|
125
|
+
if (data.length > 100) {
|
|
126
|
+
const words = data.split(/\s+/);
|
|
127
|
+
let line = "";
|
|
128
|
+
for (const word of words) {
|
|
129
|
+
if (line.length + word.length + 1 > 90) {
|
|
130
|
+
console.log(`${pad}${chalk.dim(line)}`);
|
|
131
|
+
line = word;
|
|
132
|
+
} else {
|
|
133
|
+
line = line ? `${line} ${word}` : word;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (line) console.log(`${pad}${chalk.dim(line)}`);
|
|
137
|
+
} else {
|
|
138
|
+
console.log(`${pad}${data}`);
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (typeof data === "number" || typeof data === "boolean") {
|
|
144
|
+
console.log(`${pad}${data}`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (Array.isArray(data)) {
|
|
149
|
+
for (const item of data) {
|
|
150
|
+
if (typeof item === "string") {
|
|
151
|
+
console.log(`${pad}${chalk.dim("•")} ${item}`);
|
|
152
|
+
} else if (typeof item === "object" && item !== null) {
|
|
153
|
+
renderFreeFormInfo(item, indent + 2);
|
|
154
|
+
console.log(); // spacing between array items
|
|
155
|
+
} else {
|
|
156
|
+
console.log(`${pad}${chalk.dim("•")} ${String(item)}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (typeof data === "object") {
|
|
163
|
+
for (const [key, value] of Object.entries(data)) {
|
|
164
|
+
// Skip internal/meta fields
|
|
165
|
+
if (key === "product_id" || key === "error" || key === "available") continue;
|
|
166
|
+
|
|
167
|
+
const label = key
|
|
168
|
+
.replace(/_/g, " ")
|
|
169
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
170
|
+
|
|
171
|
+
if (value == null) continue;
|
|
172
|
+
|
|
173
|
+
if (typeof value === "string") {
|
|
174
|
+
if (value.length > 80) {
|
|
175
|
+
console.log(`${pad}${chalk.bold(label + ":")}`);
|
|
176
|
+
renderFreeFormInfo(value, indent + 4);
|
|
177
|
+
} else {
|
|
178
|
+
console.log(`${pad}${chalk.bold(label + ":")} ${value}`);
|
|
179
|
+
}
|
|
180
|
+
} else if (typeof value === "number") {
|
|
181
|
+
console.log(`${pad}${chalk.bold(label + ":")} ${value}`);
|
|
182
|
+
} else if (typeof value === "boolean") {
|
|
183
|
+
console.log(`${pad}${chalk.bold(label + ":")} ${value ? chalk.green("Yes") : chalk.red("No")}`);
|
|
184
|
+
} else if (Array.isArray(value)) {
|
|
185
|
+
console.log(`${pad}${chalk.bold(label + ":")}`);
|
|
186
|
+
renderFreeFormInfo(value, indent + 4);
|
|
187
|
+
} else if (typeof value === "object") {
|
|
188
|
+
console.log(`${pad}${chalk.bold(label + ":")}`);
|
|
189
|
+
renderFreeFormInfo(value, indent + 4);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Render a single product info result (reusable by both `info` command and interactive mode) ──
|
|
196
|
+
|
|
197
|
+
function renderProductInfo(
|
|
198
|
+
result: any,
|
|
199
|
+
index: number,
|
|
200
|
+
totalResults: number,
|
|
201
|
+
formatPriceFn: (cents: number, currency: string) => string,
|
|
202
|
+
): void {
|
|
203
|
+
const num = index + 1;
|
|
204
|
+
|
|
205
|
+
// Header
|
|
206
|
+
const storeBadge = result.storeName
|
|
207
|
+
? chalk.dim(` from ${result.storeName}`)
|
|
208
|
+
: "";
|
|
209
|
+
|
|
210
|
+
console.log(
|
|
211
|
+
` ${chalk.dim(`[${num}]`)} ${chalk.bold.cyan(result.info?.title || result.info?.product_id || result.productId)}${storeBadge}`
|
|
212
|
+
);
|
|
213
|
+
console.log(` ${chalk.dim(`ID: ${result.productId}`)}`);
|
|
214
|
+
|
|
215
|
+
if (result.error) {
|
|
216
|
+
console.log(` ${chalk.red(`Error: ${result.error}`)}`);
|
|
217
|
+
console.log();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Product URL (if available)
|
|
222
|
+
if (result.info?.product_url) {
|
|
223
|
+
console.log(` ${chalk.blue.underline(result.info.product_url)}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
console.log();
|
|
227
|
+
|
|
228
|
+
// Render all the free-form info from the store
|
|
229
|
+
const info = result.info || {};
|
|
230
|
+
|
|
231
|
+
// Price display (if available)
|
|
232
|
+
if (info.price) {
|
|
233
|
+
const priceStr = info.price.amount && info.price.currency
|
|
234
|
+
? formatPriceFn(Math.round(parseFloat(info.price.amount) * 100), info.price.currency)
|
|
235
|
+
: `${info.price.amount || "N/A"}`;
|
|
236
|
+
let priceLine = ` ${chalk.bold("Price:")} ${chalk.bold.white(priceStr)}`;
|
|
237
|
+
|
|
238
|
+
if (info.list_price?.amount) {
|
|
239
|
+
const listStr = formatPriceFn(
|
|
240
|
+
Math.round(parseFloat(info.list_price.amount) * 100),
|
|
241
|
+
info.list_price.currency || info.price.currency
|
|
242
|
+
);
|
|
243
|
+
priceLine += chalk.dim.strikethrough(` ${listStr}`);
|
|
244
|
+
}
|
|
245
|
+
console.log(priceLine);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Pricing object (from darkstore format)
|
|
249
|
+
if (info.pricing && !info.price) {
|
|
250
|
+
const priceStr = info.pricing.amount && info.pricing.currency
|
|
251
|
+
? formatPriceFn(Math.round(parseFloat(info.pricing.amount) * 100), info.pricing.currency)
|
|
252
|
+
: `${info.pricing.amount || "N/A"}`;
|
|
253
|
+
let priceLine = ` ${chalk.bold("Price:")} ${chalk.bold.white(priceStr)}`;
|
|
254
|
+
if (info.pricing.compare_at) {
|
|
255
|
+
const listStr = formatPriceFn(
|
|
256
|
+
Math.round(parseFloat(info.pricing.compare_at) * 100),
|
|
257
|
+
info.pricing.currency
|
|
258
|
+
);
|
|
259
|
+
priceLine += chalk.dim.strikethrough(` ${listStr}`);
|
|
260
|
+
}
|
|
261
|
+
console.log(priceLine);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Rating
|
|
265
|
+
if (info.rating) {
|
|
266
|
+
const ratingScore = typeof info.rating === "object"
|
|
267
|
+
? `${info.rating.score}/${info.rating.max}`
|
|
268
|
+
: String(info.rating);
|
|
269
|
+
let ratingLine = ` ${chalk.bold("Rating:")} ${chalk.yellow(ratingScore)}`;
|
|
270
|
+
if (info.review_count) {
|
|
271
|
+
ratingLine += chalk.dim(` (${info.review_count.toLocaleString()} reviews)`);
|
|
272
|
+
}
|
|
273
|
+
console.log(ratingLine);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Brand
|
|
277
|
+
if (info.brand) {
|
|
278
|
+
console.log(` ${chalk.bold("Brand:")} ${info.brand}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Marketplace
|
|
282
|
+
if (info.marketplace) {
|
|
283
|
+
console.log(` ${chalk.bold("Marketplace:")} ${info.marketplace.name || info.marketplace.domain || ""}`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Availability
|
|
287
|
+
if (info.availability) {
|
|
288
|
+
if (typeof info.availability === "string") {
|
|
289
|
+
const isInStock = info.availability.toLowerCase().includes("in stock");
|
|
290
|
+
console.log(` ${chalk.bold("Availability:")} ${isInStock ? chalk.green(info.availability) : chalk.yellow(info.availability)}`);
|
|
291
|
+
} else if (typeof info.availability === "object") {
|
|
292
|
+
const status = info.availability.in_stock
|
|
293
|
+
? chalk.green("In Stock")
|
|
294
|
+
: chalk.red("Out of Stock");
|
|
295
|
+
let availLine = ` ${chalk.bold("Availability:")} ${status}`;
|
|
296
|
+
if (info.availability.quantity != null) {
|
|
297
|
+
availLine += chalk.dim(` (${info.availability.quantity} available)`);
|
|
298
|
+
}
|
|
299
|
+
console.log(availLine);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Prime
|
|
304
|
+
if (info.prime) {
|
|
305
|
+
console.log(` ${chalk.bold("Prime:")} ${chalk.blue("✓ Prime eligible")}`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Shipping
|
|
309
|
+
if (info.shipping && typeof info.shipping === "object") {
|
|
310
|
+
const parts: string[] = [];
|
|
311
|
+
if (info.shipping.free) parts.push(chalk.green("Free Shipping"));
|
|
312
|
+
if (info.shipping.estimated_days) parts.push(`${info.shipping.estimated_days}-day delivery`);
|
|
313
|
+
if (info.shipping.price?.amount) parts.push(`${info.shipping.price.amount} ${info.shipping.price.currency || ""}`);
|
|
314
|
+
if (info.shipping.weight_kg) parts.push(`${info.shipping.weight_kg}kg`);
|
|
315
|
+
if (parts.length > 0) {
|
|
316
|
+
console.log(` ${chalk.bold("Shipping:")} ${parts.join(" · ")}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Delivery info
|
|
321
|
+
if (info.delivery_info) {
|
|
322
|
+
console.log(` ${chalk.bold("Delivery:")} ${info.delivery_info}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Returns
|
|
326
|
+
if (info.returns && typeof info.returns === "object") {
|
|
327
|
+
const parts: string[] = [];
|
|
328
|
+
if (info.returns.free) parts.push(chalk.green("Free Returns"));
|
|
329
|
+
if (info.returns.window_days) parts.push(`${info.returns.window_days}-day window`);
|
|
330
|
+
if (info.returns.note) parts.push(info.returns.note);
|
|
331
|
+
if (parts.length > 0) {
|
|
332
|
+
console.log(` ${chalk.bold("Returns:")} ${parts.join(" · ")}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Checkout
|
|
337
|
+
if (info.checkout && typeof info.checkout === "object") {
|
|
338
|
+
const parts: string[] = [];
|
|
339
|
+
if (info.checkout.mode) parts.push(info.checkout.mode);
|
|
340
|
+
if (info.checkout.note) parts.push(info.checkout.note);
|
|
341
|
+
if (parts.length > 0) {
|
|
342
|
+
console.log(` ${chalk.bold("Checkout:")} ${parts.join(" — ")}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Seller
|
|
347
|
+
if (info.sold_by) {
|
|
348
|
+
console.log(` ${chalk.bold("Sold by:")} ${info.sold_by}`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Categories
|
|
352
|
+
if (info.categories && Array.isArray(info.categories)) {
|
|
353
|
+
console.log(` ${chalk.bold("Category:")} ${info.categories.join(" > ")}`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
console.log();
|
|
357
|
+
|
|
358
|
+
// Features / bullet points
|
|
359
|
+
if (info.features && Array.isArray(info.features) && info.features.length > 0) {
|
|
360
|
+
console.log(` ${chalk.bold("Key Features:")}`);
|
|
361
|
+
for (const feature of info.features) {
|
|
362
|
+
if (feature.length > 80) {
|
|
363
|
+
const words = feature.split(/\s+/);
|
|
364
|
+
let line = "";
|
|
365
|
+
let first = true;
|
|
366
|
+
for (const word of words) {
|
|
367
|
+
if (line.length + word.length + 1 > 76) {
|
|
368
|
+
if (first) {
|
|
369
|
+
console.log(` ${chalk.dim("•")} ${line}`);
|
|
370
|
+
first = false;
|
|
371
|
+
} else {
|
|
372
|
+
console.log(` ${line}`);
|
|
373
|
+
}
|
|
374
|
+
line = word;
|
|
375
|
+
} else {
|
|
376
|
+
line = line ? `${line} ${word}` : word;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (line) {
|
|
380
|
+
if (first) {
|
|
381
|
+
console.log(` ${chalk.dim("•")} ${line}`);
|
|
382
|
+
} else {
|
|
383
|
+
console.log(` ${line}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
} else {
|
|
387
|
+
console.log(` ${chalk.dim("•")} ${feature}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
console.log();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Description
|
|
394
|
+
if (info.description) {
|
|
395
|
+
console.log(` ${chalk.bold("Description:")}`);
|
|
396
|
+
const words = info.description.split(/\s+/);
|
|
397
|
+
let line = "";
|
|
398
|
+
for (const word of words) {
|
|
399
|
+
if (line.length + word.length + 1 > 76) {
|
|
400
|
+
console.log(` ${chalk.dim(line)}`);
|
|
401
|
+
line = word;
|
|
402
|
+
} else {
|
|
403
|
+
line = line ? `${line} ${word}` : word;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (line) console.log(` ${chalk.dim(line)}`);
|
|
407
|
+
console.log();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Specifications table
|
|
411
|
+
if (info.specifications && typeof info.specifications === "object") {
|
|
412
|
+
const specs = info.specifications;
|
|
413
|
+
const keys = Object.keys(specs);
|
|
414
|
+
if (keys.length > 0) {
|
|
415
|
+
console.log(` ${chalk.bold("Specifications:")}`);
|
|
416
|
+
const maxKeyLen = Math.min(30, Math.max(...keys.map((k: string) => k.length)));
|
|
417
|
+
for (const [key, value] of Object.entries(specs)) {
|
|
418
|
+
const paddedKey = key.padEnd(maxKeyLen);
|
|
419
|
+
console.log(` ${chalk.dim(paddedKey)} ${value}`);
|
|
420
|
+
}
|
|
421
|
+
console.log();
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Images
|
|
426
|
+
if (info.images && Array.isArray(info.images) && info.images.length > 0) {
|
|
427
|
+
console.log(` ${chalk.bold("Images:")} ${chalk.dim(`${info.images.length} available`)}`);
|
|
428
|
+
for (let j = 0; j < Math.min(3, info.images.length); j++) {
|
|
429
|
+
console.log(` ${chalk.dim(`[${j + 1}]`)} ${chalk.blue.underline(info.images[j])}`);
|
|
430
|
+
}
|
|
431
|
+
if (info.images.length > 3) {
|
|
432
|
+
console.log(` ${chalk.dim(`... and ${info.images.length - 3} more`)}`);
|
|
433
|
+
}
|
|
434
|
+
console.log();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// About section (A+ content)
|
|
438
|
+
if (info.about && Array.isArray(info.about) && info.about.length > 0) {
|
|
439
|
+
console.log(` ${chalk.bold("About This Item:")}`);
|
|
440
|
+
for (const section of info.about) {
|
|
441
|
+
const words = section.split(/\s+/);
|
|
442
|
+
let line = "";
|
|
443
|
+
for (const word of words) {
|
|
444
|
+
if (line.length + word.length + 1 > 76) {
|
|
445
|
+
console.log(` ${chalk.dim(line)}`);
|
|
446
|
+
line = word;
|
|
447
|
+
} else {
|
|
448
|
+
line = line ? `${line} ${word}` : word;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (line) console.log(` ${chalk.dim(line)}`);
|
|
452
|
+
console.log();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// SEO info
|
|
457
|
+
if (info.seo && typeof info.seo === "object" && Object.keys(info.seo).length > 0) {
|
|
458
|
+
if (info.seo.title || info.seo.description) {
|
|
459
|
+
console.log(` ${chalk.bold("SEO:")}`);
|
|
460
|
+
if (info.seo.title) console.log(` Title: ${info.seo.title}`);
|
|
461
|
+
if (info.seo.description) console.log(` Description: ${info.seo.description}`);
|
|
462
|
+
console.log();
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Any remaining fields not handled above (free-form catch-all)
|
|
467
|
+
const handledKeys = new Set([
|
|
468
|
+
"product_id", "title", "price", "list_price", "pricing", "rating",
|
|
469
|
+
"review_count", "brand", "marketplace", "availability", "prime",
|
|
470
|
+
"shipping", "delivery_info", "returns", "checkout", "sold_by",
|
|
471
|
+
"categories", "features", "description", "specifications",
|
|
472
|
+
"images", "about", "seo", "product_url", "asin", "error",
|
|
473
|
+
"available", "note", "updated_at", "sku", "model", "gtin",
|
|
474
|
+
"variant", "tags",
|
|
475
|
+
]);
|
|
476
|
+
|
|
477
|
+
const extraKeys = Object.keys(info).filter((k: string) => !handledKeys.has(k));
|
|
478
|
+
if (extraKeys.length > 0) {
|
|
479
|
+
console.log(` ${chalk.bold("Additional Information:")}`);
|
|
480
|
+
for (const key of extraKeys) {
|
|
481
|
+
const value = info[key];
|
|
482
|
+
if (value == null) continue;
|
|
483
|
+
const label = key.replace(/_/g, " ").replace(/\b\w/g, (c: string) => c.toUpperCase());
|
|
484
|
+
|
|
485
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
486
|
+
console.log(` ${chalk.dim(label + ":")} ${value}`);
|
|
487
|
+
} else if (Array.isArray(value)) {
|
|
488
|
+
console.log(` ${chalk.dim(label + ":")}`);
|
|
489
|
+
renderFreeFormInfo(value, 10);
|
|
490
|
+
} else if (typeof value === "object") {
|
|
491
|
+
console.log(` ${chalk.dim(label + ":")}`);
|
|
492
|
+
renderFreeFormInfo(value, 10);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
console.log();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Tags
|
|
499
|
+
if (info.tags && Array.isArray(info.tags) && info.tags.length > 0) {
|
|
500
|
+
console.log(` ${chalk.bold("Tags:")} ${info.tags.map((t: string) => chalk.dim(`#${t}`)).join(" ")}`);
|
|
501
|
+
console.log();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Note from store
|
|
505
|
+
if (info.note) {
|
|
506
|
+
console.log(` ${chalk.dim(`ℹ ${info.note}`)}`);
|
|
507
|
+
console.log();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Separator between products
|
|
511
|
+
if (index < totalResults - 1) {
|
|
512
|
+
console.log(chalk.dim(" " + "─".repeat(60)));
|
|
513
|
+
console.log();
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function registerSearchCommands(program: Command): void {
|
|
518
|
+
// ── SEARCH ─────────────────────────────────────────────────────────
|
|
519
|
+
program
|
|
520
|
+
.command("search <query>")
|
|
521
|
+
.description("Search for products")
|
|
522
|
+
|
|
523
|
+
// Product match
|
|
524
|
+
.option("-c, --category <category>", "Filter by category")
|
|
525
|
+
.option("--brand <brand>", "Filter by brand")
|
|
526
|
+
.option("--model <model>", "Filter by model name/number")
|
|
527
|
+
.option("--sku <sku>", "Filter by SKU")
|
|
528
|
+
.option("--gtin <gtin>", "Filter by GTIN (UPC/EAN/ISBN)")
|
|
529
|
+
.option("--variant <variant>", "Filter by variant (size/color/storage/etc.)")
|
|
530
|
+
|
|
531
|
+
// Cost
|
|
532
|
+
.option("--min-price <price>", "Minimum price (cents)", parseFloat)
|
|
533
|
+
.option("--max-price <price>", "Maximum price (cents)", parseFloat)
|
|
534
|
+
.option("--max-shipping <price>", "Maximum shipping cost (cents)", parseInt)
|
|
535
|
+
.option("--max-total <price>", "Maximum landed total: item + shipping (cents)", parseInt)
|
|
536
|
+
.option("--free-shipping", "Only show items with free shipping")
|
|
537
|
+
|
|
538
|
+
// Delivery location
|
|
539
|
+
.option("--ship-to <address>", "Saved address label or ID (resolves country/city/postal automatically)")
|
|
540
|
+
.option("--country <code>", "Delivery country (ISO 3166-1 alpha-2, e.g. US, BE, NL)")
|
|
541
|
+
.option("--city <city>", "Delivery city")
|
|
542
|
+
.option("--postal-code <code>", "Delivery postal/zip code")
|
|
543
|
+
.option("--region <region>", "Delivery state/province/region")
|
|
544
|
+
.option("--lat <latitude>", "Delivery latitude (for local/proximity search)", parseFloat)
|
|
545
|
+
.option("--lng <longitude>", "Delivery longitude (for local/proximity search)", parseFloat)
|
|
546
|
+
.option("--deliver-by <date>", "Need delivery by date (YYYY-MM-DD)")
|
|
547
|
+
.option("--max-delivery-days <days>", "Maximum delivery/transit days", parseInt)
|
|
548
|
+
|
|
549
|
+
// Availability
|
|
550
|
+
.option("--in-stock", "Only show in-stock items")
|
|
551
|
+
.option("--exclude-backorder", "Exclude backordered items")
|
|
552
|
+
.option("--min-qty <qty>", "Minimum quantity available", parseInt)
|
|
553
|
+
|
|
554
|
+
// Returns
|
|
555
|
+
.option("--free-returns", "Only show items with free returns")
|
|
556
|
+
.option("--min-return-window-days <days>", "Minimum return window in days", parseInt)
|
|
557
|
+
|
|
558
|
+
// Trust / eligibility
|
|
559
|
+
.option("--store <store>", "Limit to a store (ID, slug, or name)")
|
|
560
|
+
.option("--vendor <vendor>", "Filter by vendor name (alias for --store)")
|
|
561
|
+
.option("--trusted-only", "Only show products from verified stores")
|
|
562
|
+
.option("--min-store-rating <rating>", "Minimum store rating (0-5)", parseFloat)
|
|
563
|
+
.option("--checkout-mode <mode>", "Checkout mode: instant, handoff")
|
|
564
|
+
|
|
565
|
+
// Rating / sorting / pagination
|
|
566
|
+
.option("--min-rating <rating>", "Minimum product rating (1-5)", parseFloat)
|
|
567
|
+
.option("-s, --sort <field>", "Sort by: price, total-cost, rating, relevance, newest, delivery", "relevance")
|
|
568
|
+
.option("--order <dir>", "Sort order: asc, desc", "desc")
|
|
569
|
+
.option("-p, --page <page>", "Page number", parseInt, 1)
|
|
570
|
+
.option("-n, --per-page <count>", "Results per page", parseInt, 10)
|
|
571
|
+
|
|
572
|
+
// Delivery shortcuts
|
|
573
|
+
.option("--express", "Only show items with 2-day or faster delivery")
|
|
574
|
+
|
|
575
|
+
// Extended search
|
|
576
|
+
.option("-e, --extended-search", "Enable extended search: query darkstores when no local results found")
|
|
577
|
+
.option("--no-extended-search", "Disable automatic extended search when no local results found")
|
|
578
|
+
.option("--extended-timeout <seconds>", "Extended search timeout in seconds (default: 30, max: 60)", parseInt)
|
|
579
|
+
|
|
580
|
+
// Output
|
|
581
|
+
.option("--json", "Output raw JSON")
|
|
582
|
+
.option("--compact", "Compact one-line-per-result output")
|
|
583
|
+
.option("--detailed", "Show full product details inline")
|
|
584
|
+
|
|
585
|
+
// Interactive
|
|
586
|
+
.option("-i, --interactive", "After results, interactively select products to get more info from their store")
|
|
587
|
+
|
|
588
|
+
.action(async (query: string, opts) => {
|
|
589
|
+
try {
|
|
590
|
+
const spinner = ora(`Searching for "${query}"...`).start();
|
|
591
|
+
const api = getApiClient();
|
|
592
|
+
|
|
593
|
+
// Compute --deliver-by → maxDeliveryDays if not explicitly set
|
|
594
|
+
let maxDeliveryDays = opts.maxDeliveryDays;
|
|
595
|
+
if (!maxDeliveryDays && opts.deliverBy) {
|
|
596
|
+
const target = new Date(opts.deliverBy);
|
|
597
|
+
const now = new Date();
|
|
598
|
+
const diff = Math.ceil((target.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
|
599
|
+
if (diff > 0) maxDeliveryDays = diff;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Resolve --ship-to: if provided, fetch the saved address to extract location fields
|
|
603
|
+
let shipToCountry = opts.country || undefined;
|
|
604
|
+
let shipToCity = opts.city || undefined;
|
|
605
|
+
let shipToPostalCode = opts.postalCode || undefined;
|
|
606
|
+
let shipToRegion = opts.region || undefined;
|
|
607
|
+
let shipToLat = opts.lat || undefined;
|
|
608
|
+
let shipToLng = opts.lng || undefined;
|
|
609
|
+
|
|
610
|
+
if (opts.shipTo) {
|
|
611
|
+
try {
|
|
612
|
+
spinner.text = `Resolving address "${opts.shipTo}"...`;
|
|
613
|
+
const addrRes = await api.get("/addresses");
|
|
614
|
+
const addresses = addrRes.data.addresses || [];
|
|
615
|
+
// Match by label (case-insensitive) or by ID
|
|
616
|
+
const match = addresses.find((a: any) =>
|
|
617
|
+
a.id === opts.shipTo ||
|
|
618
|
+
(a.label && a.label.toLowerCase() === opts.shipTo.toLowerCase())
|
|
619
|
+
);
|
|
620
|
+
if (match) {
|
|
621
|
+
if (!shipToCountry) shipToCountry = match.country;
|
|
622
|
+
if (!shipToCity) shipToCity = match.city;
|
|
623
|
+
if (!shipToPostalCode) shipToPostalCode = match.postalCode;
|
|
624
|
+
if (!shipToRegion) shipToRegion = match.region;
|
|
625
|
+
} else {
|
|
626
|
+
spinner.warn(`Address "${opts.shipTo}" not found — ignoring --ship-to`);
|
|
627
|
+
spinner.start(`Searching for "${query}"...`);
|
|
628
|
+
}
|
|
629
|
+
} catch {
|
|
630
|
+
// Address lookup failed — continue without it
|
|
631
|
+
}
|
|
632
|
+
spinner.text = `Searching for "${query}"...`;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Auto-resolve default address when no location flags are set
|
|
636
|
+
// Uses the active agent's default address so searches target the right region
|
|
637
|
+
if (!shipToCountry && !shipToCity && !shipToPostalCode && !opts.shipTo) {
|
|
638
|
+
const agent = getActiveAgent();
|
|
639
|
+
if (agent.defaultAddressId) {
|
|
640
|
+
try {
|
|
641
|
+
spinner.text = `Resolving default address...`;
|
|
642
|
+
const addrRes = await api.get("/addresses");
|
|
643
|
+
const addresses = addrRes.data.addresses || [];
|
|
644
|
+
const defaultAddr = addresses.find((a: any) => a.id === agent.defaultAddressId);
|
|
645
|
+
if (defaultAddr) {
|
|
646
|
+
shipToCountry = defaultAddr.country;
|
|
647
|
+
shipToCity = defaultAddr.city;
|
|
648
|
+
shipToPostalCode = defaultAddr.postalCode;
|
|
649
|
+
shipToRegion = defaultAddr.region || undefined;
|
|
650
|
+
spinner.text = `Searching for "${query}" (delivering to: ${[shipToCity, shipToCountry].filter(Boolean).join(", ")})...`;
|
|
651
|
+
}
|
|
652
|
+
} catch {
|
|
653
|
+
// Default address lookup failed — continue without it
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Extended search: clamp timeout to 5-60s, default 30s
|
|
659
|
+
// Extended search is enabled by default (auto-triggers when no results found)
|
|
660
|
+
// User can force it with -e, or disable it with --no-extended-search
|
|
661
|
+
const forceExtended = opts.extendedSearch === true;
|
|
662
|
+
const disableExtended = opts.extendedSearch === false;
|
|
663
|
+
const extendedTimeout = opts.extendedTimeout
|
|
664
|
+
? Math.min(60, Math.max(5, opts.extendedTimeout))
|
|
665
|
+
: 30;
|
|
666
|
+
|
|
667
|
+
// --express shortcut → max 2-day delivery
|
|
668
|
+
if (opts.express && !maxDeliveryDays) {
|
|
669
|
+
maxDeliveryDays = 2;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Build common search params (reused for both regular & extended calls)
|
|
673
|
+
const searchParams: Record<string, any> = {
|
|
674
|
+
q: query,
|
|
675
|
+
// Product match
|
|
676
|
+
category: opts.category,
|
|
677
|
+
brand: opts.brand,
|
|
678
|
+
model: opts.model,
|
|
679
|
+
sku: opts.sku,
|
|
680
|
+
gtin: opts.gtin,
|
|
681
|
+
variant: opts.variant,
|
|
682
|
+
// Cost
|
|
683
|
+
minPrice: opts.minPrice,
|
|
684
|
+
maxPrice: opts.maxPrice,
|
|
685
|
+
maxShipping: opts.maxShipping,
|
|
686
|
+
maxTotal: opts.maxTotal,
|
|
687
|
+
freeShipping: opts.freeShipping || undefined,
|
|
688
|
+
// Delivery location
|
|
689
|
+
shipTo: opts.shipTo || undefined,
|
|
690
|
+
country: shipToCountry,
|
|
691
|
+
city: shipToCity,
|
|
692
|
+
postalCode: shipToPostalCode,
|
|
693
|
+
region: shipToRegion,
|
|
694
|
+
lat: shipToLat,
|
|
695
|
+
lng: shipToLng,
|
|
696
|
+
maxDeliveryDays: maxDeliveryDays,
|
|
697
|
+
// Availability
|
|
698
|
+
inStock: opts.inStock || undefined,
|
|
699
|
+
excludeBackorder: opts.excludeBackorder || undefined,
|
|
700
|
+
minQty: opts.minQty,
|
|
701
|
+
// Returns
|
|
702
|
+
freeReturns: opts.freeReturns || undefined,
|
|
703
|
+
minReturnDays: opts.minReturnWindowDays,
|
|
704
|
+
// Trust
|
|
705
|
+
store: opts.store,
|
|
706
|
+
vendor: opts.vendor,
|
|
707
|
+
trustedOnly: opts.trustedOnly || undefined,
|
|
708
|
+
minStoreRating: opts.minStoreRating,
|
|
709
|
+
checkoutMode: opts.checkoutMode,
|
|
710
|
+
// Rating / sorting / pagination
|
|
711
|
+
minRating: opts.minRating,
|
|
712
|
+
sort: opts.sort === "total-cost" ? "price" : opts.sort, // backend doesn't know total-cost yet
|
|
713
|
+
order: opts.order,
|
|
714
|
+
page: opts.page,
|
|
715
|
+
pageSize: opts.perPage,
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
// If user forced extended search (-e), include it in the first call
|
|
719
|
+
if (forceExtended) {
|
|
720
|
+
searchParams.extendedSearch = true;
|
|
721
|
+
searchParams.extendedTimeout = extendedTimeout;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const httpTimeout = forceExtended ? (extendedTimeout + 5) * 1000 : 15000;
|
|
725
|
+
|
|
726
|
+
if (forceExtended) {
|
|
727
|
+
const locationParts = [shipToCountry, shipToCity, shipToPostalCode].filter(Boolean);
|
|
728
|
+
const locationLabel = locationParts.length > 0 ? locationParts.join(", ") : "global";
|
|
729
|
+
spinner.text = `Searching for "${query}" (extended search: ${extendedTimeout}s timeout, deliver to: ${locationLabel})...`;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
let res = await api.get("/products/search", {
|
|
733
|
+
params: searchParams,
|
|
734
|
+
timeout: httpTimeout,
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
// ── Auto-trigger extended search when no results found ──
|
|
738
|
+
// If the regular search found nothing and extended search wasn't disabled,
|
|
739
|
+
// automatically run the extended search to query all registered stores.
|
|
740
|
+
const regularResult: SearchResult = res.data;
|
|
741
|
+
const regularExtended = (res.data as any).extended;
|
|
742
|
+
|
|
743
|
+
if (
|
|
744
|
+
regularResult.products.length === 0 &&
|
|
745
|
+
(!regularExtended || regularExtended.total === 0) &&
|
|
746
|
+
!forceExtended &&
|
|
747
|
+
!disableExtended
|
|
748
|
+
) {
|
|
749
|
+
spinner.text = `No local results for "${query}". Starting extended search across all stores (${extendedTimeout}s timeout)...`;
|
|
750
|
+
|
|
751
|
+
try {
|
|
752
|
+
res = await api.get("/products/search", {
|
|
753
|
+
params: {
|
|
754
|
+
...searchParams,
|
|
755
|
+
extendedSearch: true,
|
|
756
|
+
extendedTimeout,
|
|
757
|
+
},
|
|
758
|
+
timeout: (extendedTimeout + 5) * 1000,
|
|
759
|
+
});
|
|
760
|
+
} catch (extErr) {
|
|
761
|
+
// If extended search fails (e.g. timeout), continue with empty results
|
|
762
|
+
spinner.warn(`Extended search failed — showing regular results only.`);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
spinner.stop();
|
|
767
|
+
|
|
768
|
+
const result: SearchResult = res.data;
|
|
769
|
+
|
|
770
|
+
if (opts.json) {
|
|
771
|
+
console.log(JSON.stringify(result, null, 2));
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Check for extended search results
|
|
776
|
+
const extended = (res.data as any).extended;
|
|
777
|
+
const suggestAdvertise = (res.data as any).suggestAdvertise;
|
|
778
|
+
const didExtendedSearch = forceExtended || (res.data as any).extended != null;
|
|
779
|
+
|
|
780
|
+
if (result.products.length === 0 && (!extended || extended.total === 0)) {
|
|
781
|
+
if (didExtendedSearch) {
|
|
782
|
+
console.log(chalk.yellow(`\nNo results found for "${query}" (searched local catalog + all vendor stores).`));
|
|
783
|
+
} else {
|
|
784
|
+
console.log(chalk.yellow(`\nNo results found for "${query}".`));
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
if (!didExtendedSearch && disableExtended) {
|
|
788
|
+
console.log(
|
|
789
|
+
chalk.dim("\n 🔍 Tip: ") +
|
|
790
|
+
chalk.white("Extended search was disabled. Enable it to query vendor stores in real-time:") +
|
|
791
|
+
chalk.dim(`\n Run: `) +
|
|
792
|
+
chalk.cyan(`clishop search "${query}" --extended-search`) +
|
|
793
|
+
chalk.dim("\n")
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
console.log(
|
|
798
|
+
chalk.dim(" 💡 Tip: ") +
|
|
799
|
+
chalk.white("Can't find what you need? Advertise your request and let vendors come to you!") +
|
|
800
|
+
chalk.dim(`\n Run: `) +
|
|
801
|
+
chalk.cyan(`clishop advertise create`) +
|
|
802
|
+
chalk.dim(` or `) +
|
|
803
|
+
chalk.cyan(`clishop advertise quick "${query}"`) +
|
|
804
|
+
chalk.dim("\n")
|
|
805
|
+
);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// ── Fetch exchange rates for currency conversion ──────────
|
|
810
|
+
// Determine user's preferred currency from their country
|
|
811
|
+
const COUNTRY_CURRENCY: Record<string, string> = {
|
|
812
|
+
US: "USD", CA: "CAD", GB: "GBP", AU: "AUD", NZ: "NZD",
|
|
813
|
+
EU: "EUR", DE: "EUR", FR: "EUR", IT: "EUR", ES: "EUR", NL: "EUR",
|
|
814
|
+
BE: "EUR", AT: "EUR", IE: "EUR", PT: "EUR", FI: "EUR", GR: "EUR",
|
|
815
|
+
LU: "EUR", SK: "EUR", SI: "EUR", EE: "EUR", LV: "EUR", LT: "EUR",
|
|
816
|
+
SE: "SEK", NO: "NOK", DK: "DKK", PL: "PLN", CZ: "CZK",
|
|
817
|
+
CH: "CHF", HU: "HUF", RO: "RON", BG: "BGN", HR: "EUR",
|
|
818
|
+
JP: "JPY", CN: "CNY", KR: "KRW", IN: "INR", SG: "SGD",
|
|
819
|
+
TH: "THB", AE: "AED", IL: "ILS", TR: "TRY", ZA: "ZAR",
|
|
820
|
+
BR: "BRL", MX: "MXN", AR: "ARS", CO: "COP", CL: "CLP",
|
|
821
|
+
};
|
|
822
|
+
const userCurrency = shipToCountry ? (COUNTRY_CURRENCY[shipToCountry.toUpperCase()] || "EUR") : "EUR";
|
|
823
|
+
let exchangeRates: Record<string, number> = {};
|
|
824
|
+
try {
|
|
825
|
+
exchangeRates = await fetchRates(userCurrency);
|
|
826
|
+
} catch {
|
|
827
|
+
// Non-critical — conversion just won't show
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// ── Merge all products for display ──────────────────────────
|
|
831
|
+
// Combine local + extended into a unified list for rendering
|
|
832
|
+
type DisplayProduct = {
|
|
833
|
+
name: string; priceInCents: number; currency: string;
|
|
834
|
+
freeShipping: boolean; shippingPriceInCents?: number | null; shippingDays?: number | null;
|
|
835
|
+
vendor: string; storeVerified?: boolean; storeRating?: number | null;
|
|
836
|
+
brand?: string | null; category?: string | null;
|
|
837
|
+
rating?: number; reviewCount?: number;
|
|
838
|
+
variant?: string | null; variantLabel?: string | null;
|
|
839
|
+
description?: string | null; id?: string;
|
|
840
|
+
isExtended?: boolean;
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
const allProducts: DisplayProduct[] = [];
|
|
844
|
+
|
|
845
|
+
for (const p of result.products) {
|
|
846
|
+
allProducts.push({
|
|
847
|
+
...p,
|
|
848
|
+
vendor: p.vendor || p.storeName || "Unknown",
|
|
849
|
+
isExtended: false,
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if (extended?.products) {
|
|
854
|
+
for (const ep of extended.products) {
|
|
855
|
+
allProducts.push({
|
|
856
|
+
name: ep.name,
|
|
857
|
+
priceInCents: ep.priceInCents,
|
|
858
|
+
currency: ep.currency,
|
|
859
|
+
freeShipping: ep.freeShipping,
|
|
860
|
+
shippingPriceInCents: ep.shippingPriceInCents,
|
|
861
|
+
shippingDays: ep.shippingDays,
|
|
862
|
+
vendor: ep.storeName || "Unknown",
|
|
863
|
+
storeRating: ep.storeRating ?? null,
|
|
864
|
+
storeVerified: ep.storeVerified ?? false,
|
|
865
|
+
brand: ep.brand,
|
|
866
|
+
variant: ep.variant,
|
|
867
|
+
variantLabel: ep.variantLabel,
|
|
868
|
+
description: ep.description,
|
|
869
|
+
id: ep.id,
|
|
870
|
+
isExtended: true,
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Client-side sort for total-cost (backend doesn't support this directly)
|
|
876
|
+
if (opts.sort === "total-cost") {
|
|
877
|
+
allProducts.sort((a, b) => {
|
|
878
|
+
const aCost = a.priceInCents + (a.freeShipping ? 0 : (a.shippingPriceInCents ?? 0));
|
|
879
|
+
const bCost = b.priceInCents + (b.freeShipping ? 0 : (b.shippingPriceInCents ?? 0));
|
|
880
|
+
return opts.order === "desc" ? bCost - aCost : aCost - bCost;
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
if (allProducts.length === 0) {
|
|
885
|
+
// Already handled above
|
|
886
|
+
} else {
|
|
887
|
+
// Header
|
|
888
|
+
const totalCount = result.total + (extended?.total || 0);
|
|
889
|
+
if (result.products.length > 0 && extended?.total > 0) {
|
|
890
|
+
console.log(chalk.bold(`\nResults for "${query}" — ${result.total} local + ${extended.total} from stores\n`));
|
|
891
|
+
} else if (extended?.total > 0) {
|
|
892
|
+
console.log(chalk.bold(`\nExtended search for "${query}" — ${extended.total} result(s) from ${extended.storesResponded} store(s)\n`));
|
|
893
|
+
} else {
|
|
894
|
+
console.log(chalk.bold(`\nResults for "${query}" — ${result.total} found (page ${result.page})\n`));
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// ── Price comparison summary ──
|
|
898
|
+
if (allProducts.length >= 2) {
|
|
899
|
+
const withTotal = allProducts.map((p) => ({
|
|
900
|
+
...p,
|
|
901
|
+
totalCost: p.priceInCents + (p.freeShipping ? 0 : (p.shippingPriceInCents ?? 0)),
|
|
902
|
+
}));
|
|
903
|
+
const cheapest = withTotal.reduce((a, b) => a.totalCost < b.totalCost ? a : b);
|
|
904
|
+
const fastest = allProducts.filter((p) => p.shippingDays != null).sort((a, b) => (a.shippingDays ?? 99) - (b.shippingDays ?? 99))[0];
|
|
905
|
+
const bestRated = allProducts.filter((p) => (p.rating ?? 0) > 0).sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0))[0];
|
|
906
|
+
|
|
907
|
+
const parts: string[] = [];
|
|
908
|
+
parts.push(`${chalk.green("Best price:")} ${formatPrice(cheapest.totalCost, cheapest.currency)} at ${cheapest.vendor}`);
|
|
909
|
+
if (fastest?.shippingDays != null) {
|
|
910
|
+
parts.push(`${chalk.blue("Fastest:")} ${deliveryLabel(fastest.shippingDays)} at ${fastest.vendor}`);
|
|
911
|
+
}
|
|
912
|
+
if (bestRated?.rating) {
|
|
913
|
+
parts.push(`${chalk.yellow("Top rated:")} ${scoreOutOf10(bestRated.rating)}/10 at ${bestRated.vendor}`);
|
|
914
|
+
}
|
|
915
|
+
console.log(` ${chalk.dim("┌")} ${parts.join(chalk.dim(" │ "))}`);
|
|
916
|
+
|
|
917
|
+
// Price range
|
|
918
|
+
const prices = withTotal.map((p) => p.totalCost);
|
|
919
|
+
const minP = Math.min(...prices);
|
|
920
|
+
const maxP = Math.max(...prices);
|
|
921
|
+
const avgP = Math.round(prices.reduce((a, b) => a + b, 0) / prices.length);
|
|
922
|
+
const curr = allProducts[0].currency;
|
|
923
|
+
console.log(` ${chalk.dim("└")} ${chalk.dim(`Price range: ${formatPrice(minP, curr)} – ${formatPrice(maxP, curr)} · Average: ${formatPrice(avgP, curr)}`)}`);
|
|
924
|
+
console.log();
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// ── Render each product ──
|
|
928
|
+
for (let i = 0; i < allProducts.length; i++) {
|
|
929
|
+
const p = allProducts[i];
|
|
930
|
+
const num = i + 1;
|
|
931
|
+
const itemPrice = p.priceInCents;
|
|
932
|
+
const shippingPrice = p.freeShipping ? 0 : (p.shippingPriceInCents ?? 0);
|
|
933
|
+
const totalCost = itemPrice + shippingPrice;
|
|
934
|
+
|
|
935
|
+
// Badges
|
|
936
|
+
const badges: string[] = [];
|
|
937
|
+
if (i === 0) badges.push(chalk.bgGreen.black(" BEST MATCH "));
|
|
938
|
+
// Best value = lowest total cost
|
|
939
|
+
const allCosts = allProducts.map((x) => x.priceInCents + (x.freeShipping ? 0 : (x.shippingPriceInCents ?? 0)));
|
|
940
|
+
if (totalCost === Math.min(...allCosts) && i !== 0) badges.push(chalk.bgYellow.black(" BEST VALUE "));
|
|
941
|
+
|
|
942
|
+
// Currency conversion hint (only when product currency differs from user's)
|
|
943
|
+
const converted = formatConverted(totalCost, p.currency, userCurrency, exchangeRates);
|
|
944
|
+
|
|
945
|
+
// ── Compact mode ──
|
|
946
|
+
if (opts.compact) {
|
|
947
|
+
const priceStr = formatPrice(totalCost, p.currency) + converted;
|
|
948
|
+
const store = p.vendor;
|
|
949
|
+
const delivery = p.shippingDays != null ? `Arrives ${estimatedArrival(p.shippingDays)}` : "";
|
|
950
|
+
console.log(
|
|
951
|
+
` ${chalk.dim(`[${num}]`)} ${chalk.cyan(p.name.length > 60 ? p.name.slice(0, 57) + "..." : p.name)} ` +
|
|
952
|
+
`${chalk.bold.white(priceStr)} ${chalk.dim(store)}${delivery ? " " + chalk.dim(delivery) : ""}` +
|
|
953
|
+
(badges.length ? " " + badges.join(" ") : "")
|
|
954
|
+
);
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// ── Normal / Detailed mode ──
|
|
959
|
+
// Number + Title + badges
|
|
960
|
+
console.log(` ${chalk.dim(`[${num}]`)} ${chalk.bold.cyan(p.name)}${badges.length ? " " + badges.join(" ") : ""}`);
|
|
961
|
+
|
|
962
|
+
// Price line with currency conversion
|
|
963
|
+
let priceLine = ` ${chalk.bold.white(formatPrice(itemPrice, p.currency))}`;
|
|
964
|
+
if (p.freeShipping) {
|
|
965
|
+
priceLine += chalk.green(" Free Shipping");
|
|
966
|
+
} else if (p.shippingPriceInCents != null && (p.shippingPriceInCents ?? 0) > 0) {
|
|
967
|
+
priceLine += chalk.dim(` + ${formatPrice(shippingPrice, p.currency)} shipping`);
|
|
968
|
+
priceLine += chalk.bold(` = ${formatPrice(totalCost, p.currency)}`);
|
|
969
|
+
}
|
|
970
|
+
// Currency conversion
|
|
971
|
+
priceLine += converted;
|
|
972
|
+
// Delivery date
|
|
973
|
+
if (p.shippingDays != null) {
|
|
974
|
+
priceLine += chalk.blue(` Arrives ${estimatedArrival(p.shippingDays)}`);
|
|
975
|
+
}
|
|
976
|
+
console.log(priceLine);
|
|
977
|
+
|
|
978
|
+
// Store & ratings
|
|
979
|
+
const meta: string[] = [];
|
|
980
|
+
const storeBadge = p.storeVerified ? chalk.green(" ✓") : "";
|
|
981
|
+
const storeScore = p.storeRating != null
|
|
982
|
+
? chalk.dim(` ${p.storeRating.toFixed(1)}/10`)
|
|
983
|
+
: chalk.dim(" (no store rating)");
|
|
984
|
+
meta.push(`${p.vendor}${storeBadge}${storeScore}`);
|
|
985
|
+
if (p.brand) meta.push(p.brand);
|
|
986
|
+
if (p.rating && p.rating > 0) {
|
|
987
|
+
meta.push(chalk.yellow(`${scoreOutOf10(p.rating)}/10`) + (p.reviewCount ? chalk.dim(` (${p.reviewCount})`) : ""));
|
|
988
|
+
}
|
|
989
|
+
if (p.isExtended) meta.push(chalk.magenta("via extended search"));
|
|
990
|
+
console.log(` ${chalk.dim(meta.join(" · "))}`);
|
|
991
|
+
// Product ID
|
|
992
|
+
if (p.id) console.log(` ${chalk.dim(`ID: ${p.id}`)}`);;
|
|
993
|
+
|
|
994
|
+
// Detailed mode: extra info
|
|
995
|
+
if (opts.detailed) {
|
|
996
|
+
if (p.category) console.log(` ${chalk.dim(`Category: ${p.category}`)}`);
|
|
997
|
+
if (p.variant || p.variantLabel) console.log(` ${chalk.dim(`Variant: ${p.variant || p.variantLabel}`)}`);
|
|
998
|
+
if (p.description) {
|
|
999
|
+
console.log(` ${chalk.dim(p.description.length > 200 ? p.description.slice(0, 200) + "..." : p.description)}`);
|
|
1000
|
+
}
|
|
1001
|
+
} else {
|
|
1002
|
+
// Normal mode: short description
|
|
1003
|
+
if (p.description) {
|
|
1004
|
+
console.log(` ${chalk.dim(p.description.length > 80 ? p.description.slice(0, 80) + "..." : p.description)}`);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
console.log();
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// ── Interactive selection mode ──────────────────────────────
|
|
1012
|
+
// When --interactive / -i is used and there are extended results,
|
|
1013
|
+
// prompt the user to select products and fetch detailed info.
|
|
1014
|
+
const hasExtendedProducts = extended?.products?.length > 0;
|
|
1015
|
+
|
|
1016
|
+
if (opts.interactive && allProducts.length > 0) {
|
|
1017
|
+
// Build selectable choices from all products that have an ID
|
|
1018
|
+
// (both local products and extended products can be selected,
|
|
1019
|
+
// but only extended products can be queried for store info)
|
|
1020
|
+
const selectableProducts = allProducts
|
|
1021
|
+
.map((p, idx) => ({ product: p, index: idx }))
|
|
1022
|
+
.filter((item) => item.product.id);
|
|
1023
|
+
|
|
1024
|
+
if (selectableProducts.length > 0) {
|
|
1025
|
+
console.log(chalk.dim(" ─".repeat(30)));
|
|
1026
|
+
console.log();
|
|
1027
|
+
|
|
1028
|
+
const choices = selectableProducts.map((item) => {
|
|
1029
|
+
const p = item.product;
|
|
1030
|
+
const priceStr = formatPrice(p.priceInCents, p.currency);
|
|
1031
|
+
const storeStr = p.vendor || "Unknown";
|
|
1032
|
+
const extLabel = p.isExtended ? chalk.magenta(" [store]") : chalk.dim(" [local]");
|
|
1033
|
+
return {
|
|
1034
|
+
name: `${chalk.cyan(p.name.length > 50 ? p.name.slice(0, 47) + "..." : p.name)} ${chalk.bold(priceStr)} ${chalk.dim(storeStr)}${extLabel}`,
|
|
1035
|
+
value: item.product.id,
|
|
1036
|
+
short: p.name.length > 40 ? p.name.slice(0, 37) + "..." : p.name,
|
|
1037
|
+
};
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
const { selectedIds } = await inquirer.prompt([
|
|
1041
|
+
{
|
|
1042
|
+
type: "checkbox",
|
|
1043
|
+
name: "selectedIds",
|
|
1044
|
+
message: "Select product(s) to request more information from their store:",
|
|
1045
|
+
choices,
|
|
1046
|
+
pageSize: 15,
|
|
1047
|
+
},
|
|
1048
|
+
]);
|
|
1049
|
+
|
|
1050
|
+
if (selectedIds && selectedIds.length > 0) {
|
|
1051
|
+
// Separate extended vs local product IDs
|
|
1052
|
+
const extendedIds = selectedIds.filter((id: string) =>
|
|
1053
|
+
allProducts.find((p) => p.id === id && p.isExtended)
|
|
1054
|
+
);
|
|
1055
|
+
const localIds = selectedIds.filter((id: string) =>
|
|
1056
|
+
allProducts.find((p) => p.id === id && !p.isExtended)
|
|
1057
|
+
);
|
|
1058
|
+
|
|
1059
|
+
if (extendedIds.length > 0) {
|
|
1060
|
+
const infoSpinner = ora(`Requesting detailed info for ${extendedIds.length} product(s) from their stores...`).start();
|
|
1061
|
+
|
|
1062
|
+
try {
|
|
1063
|
+
const infoRes = await api.post("/products/info", {
|
|
1064
|
+
productIds: extendedIds,
|
|
1065
|
+
}, {
|
|
1066
|
+
timeout: 30000,
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
infoSpinner.stop();
|
|
1070
|
+
|
|
1071
|
+
const { results: infoResults } = infoRes.data;
|
|
1072
|
+
|
|
1073
|
+
if (infoResults && infoResults.length > 0) {
|
|
1074
|
+
console.log(chalk.bold(`\n Store Information — ${infoResults.length} result(s)\n`));
|
|
1075
|
+
|
|
1076
|
+
for (let i = 0; i < infoResults.length; i++) {
|
|
1077
|
+
const infoResult = infoResults[i];
|
|
1078
|
+
renderProductInfo(infoResult, i, infoResults.length, formatPrice);
|
|
1079
|
+
}
|
|
1080
|
+
} else {
|
|
1081
|
+
console.log(chalk.yellow("\n No additional information returned from the stores."));
|
|
1082
|
+
}
|
|
1083
|
+
} catch (infoErr) {
|
|
1084
|
+
infoSpinner.stop();
|
|
1085
|
+
console.error(chalk.red("\n Failed to fetch product info from stores."));
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
if (localIds.length > 0) {
|
|
1090
|
+
// For local products, fetch details from the backend directly
|
|
1091
|
+
for (const localId of localIds) {
|
|
1092
|
+
const detailSpinner = ora(`Fetching details for ${localId}...`).start();
|
|
1093
|
+
try {
|
|
1094
|
+
const detailRes = await api.get(`/products/${localId}`);
|
|
1095
|
+
detailSpinner.stop();
|
|
1096
|
+
const p = detailRes.data.product;
|
|
1097
|
+
if (p) {
|
|
1098
|
+
console.log();
|
|
1099
|
+
console.log(` ${chalk.bold.cyan(p.name)}`);
|
|
1100
|
+
console.log(` ${chalk.dim(`ID: ${p.id}`)}`);
|
|
1101
|
+
if (p.brand) console.log(` ${chalk.bold("Brand:")} ${p.brand}`);
|
|
1102
|
+
if (p.model) console.log(` ${chalk.bold("Model:")} ${p.model}`);
|
|
1103
|
+
if (p.sku) console.log(` ${chalk.bold("SKU:")} ${p.sku}`);
|
|
1104
|
+
console.log(` ${chalk.bold("Price:")} ${chalk.bold.white(formatPrice(p.priceInCents, p.currency))}`);
|
|
1105
|
+
const status = p.inStock ? chalk.green("In Stock") : chalk.red("Out of Stock");
|
|
1106
|
+
console.log(` ${chalk.bold("Availability:")} ${status}${p.stockQuantity != null ? chalk.dim(` (${p.stockQuantity} available)`) : ""}`);
|
|
1107
|
+
if (p.freeShipping) console.log(` ${chalk.bold("Shipping:")} ${chalk.green("Free")}`);
|
|
1108
|
+
else if (p.shippingPriceInCents != null) console.log(` ${chalk.bold("Shipping:")} ${formatPrice(p.shippingPriceInCents, p.currency)}`);
|
|
1109
|
+
if (p.shippingDays != null) console.log(` ${chalk.bold("Delivery:")} ${deliveryLabel(p.shippingDays)}`);
|
|
1110
|
+
if (p.freeReturns) console.log(` ${chalk.bold("Returns:")} ${chalk.green("Free Returns")}${p.returnWindowDays ? ` · ${p.returnWindowDays}-day window` : ""}`);
|
|
1111
|
+
if (p.description) {
|
|
1112
|
+
console.log(` ${chalk.bold("Description:")}`);
|
|
1113
|
+
const words = p.description.split(/\s+/);
|
|
1114
|
+
let line = "";
|
|
1115
|
+
for (const word of words) {
|
|
1116
|
+
if (line.length + word.length + 1 > 76) {
|
|
1117
|
+
console.log(` ${chalk.dim(line)}`);
|
|
1118
|
+
line = word;
|
|
1119
|
+
} else {
|
|
1120
|
+
line = line ? `${line} ${word}` : word;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
if (line) console.log(` ${chalk.dim(line)}`);
|
|
1124
|
+
}
|
|
1125
|
+
console.log();
|
|
1126
|
+
}
|
|
1127
|
+
} catch {
|
|
1128
|
+
detailSpinner.stop();
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
console.log();
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
} else if (hasExtendedProducts && !opts.interactive) {
|
|
1137
|
+
// Show "info" tip when there are extended results and interactive mode is off
|
|
1138
|
+
const sampleIds = extended.products.slice(0, 2).map((ep: any) => ep.id).join(" ");
|
|
1139
|
+
console.log(
|
|
1140
|
+
chalk.dim(" ℹ️ Tip: ") +
|
|
1141
|
+
chalk.white("Want more details? Request info from the store:") +
|
|
1142
|
+
chalk.dim(`\n Run: `) +
|
|
1143
|
+
chalk.cyan(`clishop info ${sampleIds}`) +
|
|
1144
|
+
chalk.dim(` or use `) +
|
|
1145
|
+
chalk.cyan(`--interactive`) +
|
|
1146
|
+
chalk.dim(` to select interactively`) +
|
|
1147
|
+
chalk.dim("\n")
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
const totalPages = Math.ceil(result.total / result.pageSize);
|
|
1152
|
+
if (totalPages > 1) {
|
|
1153
|
+
console.log(chalk.dim(` Page ${result.page} of ${totalPages}. Use --page to navigate.\n`));
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// Show advertise tip on the last page of results
|
|
1157
|
+
const showAdvertiseTip = result.page >= totalPages || suggestAdvertise;
|
|
1158
|
+
if (showAdvertiseTip) {
|
|
1159
|
+
// If no extended search was used and it was explicitly disabled, suggest it
|
|
1160
|
+
if (!didExtendedSearch && disableExtended && result.products.length > 0) {
|
|
1161
|
+
console.log(
|
|
1162
|
+
chalk.dim(" 🔍 Tip: ") +
|
|
1163
|
+
chalk.white("Want more results? Extended search was disabled. Enable it:") +
|
|
1164
|
+
chalk.dim(`\n Run: `) +
|
|
1165
|
+
chalk.cyan(`clishop search "${query}" --extended-search`) +
|
|
1166
|
+
chalk.dim("\n")
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
console.log(
|
|
1171
|
+
chalk.dim(" 💡 Tip: ") +
|
|
1172
|
+
chalk.white("Didn't find the right match? Advertise your request for vendors to bid on.") +
|
|
1173
|
+
chalk.dim(`\n Run: `) +
|
|
1174
|
+
chalk.cyan(`clishop advertise create`) +
|
|
1175
|
+
chalk.dim(` or `) +
|
|
1176
|
+
chalk.cyan(`clishop advertise quick "${query}"`) +
|
|
1177
|
+
chalk.dim("\n")
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
} catch (error) {
|
|
1181
|
+
handleApiError(error);
|
|
1182
|
+
}
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
// ── PRODUCT DETAIL ─────────────────────────────────────────────────
|
|
1186
|
+
program
|
|
1187
|
+
.command("product <id>")
|
|
1188
|
+
.description("View detailed product information")
|
|
1189
|
+
.option("--json", "Output raw JSON")
|
|
1190
|
+
.action(async (id: string, opts) => {
|
|
1191
|
+
try {
|
|
1192
|
+
const spinner = ora("Fetching product details...").start();
|
|
1193
|
+
const api = getApiClient();
|
|
1194
|
+
const res = await api.get(`/products/${id}`);
|
|
1195
|
+
spinner.stop();
|
|
1196
|
+
|
|
1197
|
+
const p: Product = res.data.product;
|
|
1198
|
+
|
|
1199
|
+
if (opts.json) {
|
|
1200
|
+
console.log(JSON.stringify(p, null, 2));
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const storeBadge = p.storeVerified ? chalk.green(" ✓ Verified") : "";
|
|
1205
|
+
|
|
1206
|
+
console.log();
|
|
1207
|
+
console.log(chalk.bold.cyan(` ${p.name}`));
|
|
1208
|
+
console.log(chalk.dim(` ID: ${p.id}`));
|
|
1209
|
+
if (p.brand) console.log(chalk.dim(` Brand: ${p.brand}`));
|
|
1210
|
+
if (p.model) console.log(chalk.dim(` Model: ${p.model}`));
|
|
1211
|
+
if (p.variant) console.log(chalk.dim(` Variant: ${p.variant}`));
|
|
1212
|
+
if (p.sku) console.log(chalk.dim(` SKU: ${p.sku}`));
|
|
1213
|
+
if (p.gtin) console.log(chalk.dim(` GTIN: ${p.gtin}`));
|
|
1214
|
+
console.log();
|
|
1215
|
+
|
|
1216
|
+
// Price & shipping
|
|
1217
|
+
console.log(` Price: ${chalk.bold(formatPrice(p.priceInCents, p.currency))}`);
|
|
1218
|
+
if (p.freeShipping) {
|
|
1219
|
+
console.log(` Shipping: ${chalk.green("Free")}`);
|
|
1220
|
+
} else if (p.shippingPriceInCents != null) {
|
|
1221
|
+
console.log(` Shipping: ${formatPrice(p.shippingPriceInCents, p.currency)}`);
|
|
1222
|
+
}
|
|
1223
|
+
if (p.shippingPriceInCents != null || p.freeShipping) {
|
|
1224
|
+
const total = p.priceInCents + (p.freeShipping ? 0 : (p.shippingPriceInCents ?? 0));
|
|
1225
|
+
console.log(` Total: ${chalk.bold(formatPrice(total, p.currency))}`);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// Availability
|
|
1229
|
+
const status = p.inStock
|
|
1230
|
+
? chalk.green("In Stock")
|
|
1231
|
+
: p.backorder
|
|
1232
|
+
? chalk.yellow("Backorder")
|
|
1233
|
+
: chalk.red("Out of Stock");
|
|
1234
|
+
console.log(` Status: ${status}${p.stockQuantity != null ? chalk.dim(` (${p.stockQuantity} available)`) : ""}`);
|
|
1235
|
+
|
|
1236
|
+
// Delivery
|
|
1237
|
+
if (p.shippingDays != null) console.log(` Delivery: ${deliveryLabel(p.shippingDays)}`);
|
|
1238
|
+
|
|
1239
|
+
// Rating
|
|
1240
|
+
console.log(` Rating: ${chalk.yellow(renderStars(p.rating))} ${chalk.dim(`(${p.reviewCount} reviews)`)}`);
|
|
1241
|
+
console.log(` Category: ${p.category}`);
|
|
1242
|
+
console.log(` Store: ${p.vendor}${storeBadge}${p.storeRating != null ? chalk.dim(` (${p.storeRating.toFixed(1)} store rating)`) : ""}`);
|
|
1243
|
+
|
|
1244
|
+
// Returns
|
|
1245
|
+
const returnParts: string[] = [];
|
|
1246
|
+
if (p.freeReturns) returnParts.push(chalk.green("Free Returns"));
|
|
1247
|
+
if (p.returnWindowDays) returnParts.push(`${p.returnWindowDays}-day return window`);
|
|
1248
|
+
if (returnParts.length) console.log(` Returns: ${returnParts.join(" · ")}`);
|
|
1249
|
+
|
|
1250
|
+
// Checkout mode
|
|
1251
|
+
if (p.checkoutMode && p.checkoutMode !== "instant") {
|
|
1252
|
+
console.log(` Checkout: ${chalk.yellow(p.checkoutMode)}`);
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
console.log();
|
|
1256
|
+
console.log(` ${p.description}`);
|
|
1257
|
+
console.log();
|
|
1258
|
+
} catch (error) {
|
|
1259
|
+
handleApiError(error);
|
|
1260
|
+
}
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
// ── INFO — Request detailed product information from stores ────────
|
|
1264
|
+
// After a search returns results, users can request more info about
|
|
1265
|
+
// specific products. The backend proxies the request to the originating
|
|
1266
|
+
// store, which can return ANY information it wants (free-form).
|
|
1267
|
+
program
|
|
1268
|
+
.command("info <ids...>")
|
|
1269
|
+
.description("Request detailed information about search result products from their stores")
|
|
1270
|
+
.option("--json", "Output raw JSON")
|
|
1271
|
+
.action(async (ids: string[], opts) => {
|
|
1272
|
+
try {
|
|
1273
|
+
if (ids.length === 0) {
|
|
1274
|
+
console.error(chalk.red("\n✗ Please provide one or more product IDs."));
|
|
1275
|
+
console.log(chalk.dim(" Usage: clishop info <product-id> [product-id...]"));
|
|
1276
|
+
console.log(chalk.dim(" Example: clishop info ep_abc123 ep_def456\n"));
|
|
1277
|
+
process.exit(1);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
if (ids.length > 20) {
|
|
1281
|
+
console.error(chalk.red("\n✗ Maximum 20 products per request."));
|
|
1282
|
+
process.exit(1);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
const spinner = ora(`Requesting detailed info for ${ids.length} product(s)...`).start();
|
|
1286
|
+
const api = getApiClient();
|
|
1287
|
+
|
|
1288
|
+
const res = await api.post("/products/info", {
|
|
1289
|
+
productIds: ids,
|
|
1290
|
+
}, {
|
|
1291
|
+
timeout: 30000,
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
spinner.stop();
|
|
1295
|
+
|
|
1296
|
+
const { results, total } = res.data;
|
|
1297
|
+
|
|
1298
|
+
if (opts.json) {
|
|
1299
|
+
console.log(JSON.stringify(res.data, null, 2));
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
if (!results || results.length === 0) {
|
|
1304
|
+
console.log(chalk.yellow("\nNo information returned for the requested products."));
|
|
1305
|
+
console.log(chalk.dim(" Make sure you're using product IDs from extended search results (ep_...)."));
|
|
1306
|
+
console.log(chalk.dim(" Product IDs are shown after each search result.\n"));
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
console.log(chalk.bold(`\nProduct Information — ${total} result(s)\n`));
|
|
1311
|
+
|
|
1312
|
+
for (let i = 0; i < results.length; i++) {
|
|
1313
|
+
renderProductInfo(results[i], i, results.length, formatPrice);
|
|
1314
|
+
}
|
|
1315
|
+
} catch (error) {
|
|
1316
|
+
handleApiError(error);
|
|
1317
|
+
}
|
|
1318
|
+
});
|
|
1319
|
+
}
|