@simonfestl/husky-cli 1.12.0 → 1.14.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/dist/commands/biz/emove.d.ts +12 -0
- package/dist/commands/biz/emove.js +249 -0
- package/dist/commands/biz/skuterzone.d.ts +12 -0
- package/dist/commands/biz/skuterzone.js +248 -0
- package/dist/commands/biz/wattiz.d.ts +12 -0
- package/dist/commands/biz/wattiz.js +252 -0
- package/dist/commands/biz.js +7 -1
- package/dist/commands/config.d.ts +7 -0
- package/dist/commands/config.js +12 -1
- package/dist/index.js +1 -0
- package/dist/lib/biz/emove-playwright.d.ts +22 -0
- package/dist/lib/biz/emove-playwright.js +289 -0
- package/dist/lib/biz/emove-types.d.ts +60 -0
- package/dist/lib/biz/emove-types.js +7 -0
- package/dist/lib/biz/skuterzone-playwright.d.ts +21 -0
- package/dist/lib/biz/skuterzone-playwright.js +271 -0
- package/dist/lib/biz/skuterzone-types.d.ts +60 -0
- package/dist/lib/biz/skuterzone-types.js +7 -0
- package/dist/lib/biz/wattiz-playwright.d.ts +22 -0
- package/dist/lib/biz/wattiz-playwright.js +530 -0
- package/dist/lib/biz/wattiz-types.d.ts +61 -0
- package/dist/lib/biz/wattiz-types.js +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wattiz Client with Playwright
|
|
3
|
+
*
|
|
4
|
+
* Uses real browser automation for PrestaShop platform
|
|
5
|
+
*/
|
|
6
|
+
import { chromium } from 'playwright';
|
|
7
|
+
import { getConfig } from '../../commands/config.js';
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
export class WattizPlaywrightClient {
|
|
10
|
+
config;
|
|
11
|
+
browser;
|
|
12
|
+
page;
|
|
13
|
+
contextCreated = false;
|
|
14
|
+
constructor(config) {
|
|
15
|
+
this.config = config;
|
|
16
|
+
}
|
|
17
|
+
static fromConfig() {
|
|
18
|
+
const config = getConfig();
|
|
19
|
+
const env = process.env.HUSKY_ENV || 'PROD';
|
|
20
|
+
const wattizConfig = {
|
|
21
|
+
username: process.env[`${env}_WATTIZ_USERNAME`] ||
|
|
22
|
+
process.env.WATTIZ_USERNAME ||
|
|
23
|
+
config.wattizUsername || '',
|
|
24
|
+
password: process.env[`${env}_WATTIZ_PASSWORD`] ||
|
|
25
|
+
process.env.WATTIZ_PASSWORD ||
|
|
26
|
+
config.wattizPassword || '',
|
|
27
|
+
baseUrl: process.env[`${env}_WATTIZ_BASE_URL`] ||
|
|
28
|
+
process.env.WATTIZ_BASE_URL ||
|
|
29
|
+
config.wattizBaseUrl ||
|
|
30
|
+
'https://www.wattiz.fr',
|
|
31
|
+
language: (process.env[`${env}_WATTIZ_LANGUAGE`] ||
|
|
32
|
+
process.env.WATTIZ_LANGUAGE ||
|
|
33
|
+
config.wattizLanguage ||
|
|
34
|
+
'gb'),
|
|
35
|
+
};
|
|
36
|
+
if (!wattizConfig.username || !wattizConfig.password) {
|
|
37
|
+
throw new Error('Missing Wattiz credentials. Configure with:\n' +
|
|
38
|
+
' husky config set wattiz-username <username>\n' +
|
|
39
|
+
' husky config set wattiz-password <password>');
|
|
40
|
+
}
|
|
41
|
+
return new WattizPlaywrightClient(wattizConfig);
|
|
42
|
+
}
|
|
43
|
+
async ensureBrowser() {
|
|
44
|
+
if (!this.browser || !this.page) {
|
|
45
|
+
this.browser = await chromium.launch({
|
|
46
|
+
headless: true,
|
|
47
|
+
args: [
|
|
48
|
+
'--no-sandbox',
|
|
49
|
+
'--disable-setuid-sandbox',
|
|
50
|
+
'--disable-dev-shm-usage',
|
|
51
|
+
'--disable-accelerated-2d-canvas',
|
|
52
|
+
'--disable-gpu'
|
|
53
|
+
]
|
|
54
|
+
});
|
|
55
|
+
if (!this.contextCreated) {
|
|
56
|
+
const context = await this.browser.newContext({
|
|
57
|
+
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
58
|
+
viewport: { width: 1920, height: 1080 },
|
|
59
|
+
locale: this.config.language === 'gb' ? 'en-GB' : 'fr-FR',
|
|
60
|
+
});
|
|
61
|
+
this.page = await context.newPage();
|
|
62
|
+
this.contextCreated = true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return this.page;
|
|
66
|
+
}
|
|
67
|
+
async login() {
|
|
68
|
+
try {
|
|
69
|
+
const page = await this.ensureBrowser();
|
|
70
|
+
// Navigate to login page (PrestaShop - language-specific URLs)
|
|
71
|
+
// English uses /login, French uses /connexion
|
|
72
|
+
const loginPath = this.config.language === 'gb' ? 'login' : 'connexion';
|
|
73
|
+
await page.goto(`${this.config.baseUrl}/${this.config.language}/${loginPath}`, {
|
|
74
|
+
waitUntil: 'domcontentloaded',
|
|
75
|
+
timeout: 30000
|
|
76
|
+
});
|
|
77
|
+
await page.waitForLoadState('domcontentloaded').catch(() => { });
|
|
78
|
+
// PrestaShop can use different field patterns - try multiple selectors
|
|
79
|
+
const emailSelectors = [
|
|
80
|
+
'input[name="email"]',
|
|
81
|
+
'input[type="email"]',
|
|
82
|
+
'input#email',
|
|
83
|
+
'input[id*="email"]',
|
|
84
|
+
'input[name*="email"]'
|
|
85
|
+
];
|
|
86
|
+
const passwordSelectors = [
|
|
87
|
+
'input[name="password"]',
|
|
88
|
+
'input[type="password"]',
|
|
89
|
+
'input#password',
|
|
90
|
+
'input[id*="password"]'
|
|
91
|
+
];
|
|
92
|
+
// Find email input
|
|
93
|
+
let emailInput = null;
|
|
94
|
+
for (const selector of emailSelectors) {
|
|
95
|
+
const count = await page.locator(selector).count();
|
|
96
|
+
if (count > 0) {
|
|
97
|
+
emailInput = selector;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!emailInput) {
|
|
102
|
+
const html = await page.content();
|
|
103
|
+
await fs.promises.writeFile('/tmp/wattiz-login-debug.html', html);
|
|
104
|
+
throw new Error('Could not find email input field. Debug HTML saved to /tmp/wattiz-login-debug.html');
|
|
105
|
+
}
|
|
106
|
+
// Find password input
|
|
107
|
+
let passwordInput = null;
|
|
108
|
+
for (const selector of passwordSelectors) {
|
|
109
|
+
const count = await page.locator(selector).count();
|
|
110
|
+
if (count > 0) {
|
|
111
|
+
passwordInput = selector;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!passwordInput) {
|
|
116
|
+
throw new Error('Could not find password input field');
|
|
117
|
+
}
|
|
118
|
+
// Fill in login form
|
|
119
|
+
await page.fill(emailInput, this.config.username);
|
|
120
|
+
await page.fill(passwordInput, this.config.password);
|
|
121
|
+
// Find and click submit button
|
|
122
|
+
const submitSelectors = [
|
|
123
|
+
'button[type="submit"]',
|
|
124
|
+
'button[name="submit"]',
|
|
125
|
+
'input[type="submit"]',
|
|
126
|
+
'button.btn-primary'
|
|
127
|
+
];
|
|
128
|
+
let submitClicked = false;
|
|
129
|
+
for (const selector of submitSelectors) {
|
|
130
|
+
const count = await page.locator(selector).count();
|
|
131
|
+
if (count > 0) {
|
|
132
|
+
await page.click(selector);
|
|
133
|
+
submitClicked = true;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (!submitClicked) {
|
|
138
|
+
throw new Error('Could not find submit button');
|
|
139
|
+
}
|
|
140
|
+
// Wait for navigation
|
|
141
|
+
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => { });
|
|
142
|
+
// Check if logged in by looking for my-account page or logged-in indicators
|
|
143
|
+
const currentUrl = page.url();
|
|
144
|
+
const isLoggedIn = currentUrl.includes('/my-account') ||
|
|
145
|
+
currentUrl.includes('/mon-compte') ||
|
|
146
|
+
await page.locator('.account-link').count() > 0 ||
|
|
147
|
+
await page.locator('[data-link-action="sign-out"]').count() > 0 ||
|
|
148
|
+
await page.locator('.logout, .sign-out').count() > 0 ||
|
|
149
|
+
await page.locator('a[href*="logout"]').count() > 0;
|
|
150
|
+
if (!isLoggedIn) {
|
|
151
|
+
return {
|
|
152
|
+
success: false,
|
|
153
|
+
cookies: '',
|
|
154
|
+
error: `Login failed - check credentials. Current URL: ${currentUrl}`
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
// Extract cookies
|
|
158
|
+
const cookies = await page.context().cookies();
|
|
159
|
+
const cookieString = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
|
160
|
+
return {
|
|
161
|
+
success: true,
|
|
162
|
+
cookies: cookieString
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
return {
|
|
167
|
+
success: false,
|
|
168
|
+
cookies: '',
|
|
169
|
+
error: `Login error: ${error.message}`
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async listOrders() {
|
|
174
|
+
const page = await this.ensureBrowser();
|
|
175
|
+
// Navigate to orders page (PrestaShop order-history)
|
|
176
|
+
await page.goto(`${this.config.baseUrl}/${this.config.language}/order-history`);
|
|
177
|
+
await page.waitForLoadState('networkidle');
|
|
178
|
+
// Check if we need to login
|
|
179
|
+
const needsLogin = await page.url().includes('/login');
|
|
180
|
+
if (needsLogin) {
|
|
181
|
+
await this.login();
|
|
182
|
+
await page.goto(`${this.config.baseUrl}/${this.config.language}/order-history`);
|
|
183
|
+
await page.waitForLoadState('networkidle');
|
|
184
|
+
}
|
|
185
|
+
const orders = [];
|
|
186
|
+
// Find all order rows (PrestaShop structure)
|
|
187
|
+
const orderRows = page.locator('table tbody tr, .order-line');
|
|
188
|
+
const count = await orderRows.count();
|
|
189
|
+
for (let i = 0; i < count; i++) {
|
|
190
|
+
const row = orderRows.nth(i);
|
|
191
|
+
// PrestaShop order structure - get link for order ID
|
|
192
|
+
const orderLinkEl = row.locator('a[href*="order-detail"]').first();
|
|
193
|
+
const orderLink = await orderLinkEl.getAttribute('href').catch(() => '');
|
|
194
|
+
// Extract order ID from PrestaShop controller URL
|
|
195
|
+
const orderIdMatch = orderLink?.match(/id_order=(\d+)/);
|
|
196
|
+
const orderId = orderIdMatch ? orderIdMatch[1] : '';
|
|
197
|
+
// Get all table cells
|
|
198
|
+
const cells = row.locator('td');
|
|
199
|
+
const cellCount = await cells.count();
|
|
200
|
+
// Wattiz order-history table structure (customized PrestaShop):
|
|
201
|
+
// Col 0: Date, Col 1: Total, Col 2: ?, Col 3: ?, Col 4: Status, Col 5: Actions
|
|
202
|
+
// Order reference is in the link or we use order ID
|
|
203
|
+
let orderNumber = orderId;
|
|
204
|
+
let date = '';
|
|
205
|
+
let total = '';
|
|
206
|
+
let status = '';
|
|
207
|
+
if (cellCount >= 2) {
|
|
208
|
+
// First cell contains Date
|
|
209
|
+
date = (await cells.nth(0).textContent().catch(() => '')) || '';
|
|
210
|
+
// Second cell contains Total
|
|
211
|
+
total = (await cells.nth(1).textContent().catch(() => '')) || '';
|
|
212
|
+
}
|
|
213
|
+
if (cellCount >= 5) {
|
|
214
|
+
// Status is in column 5 (index 4)
|
|
215
|
+
status = (await cells.nth(4).textContent().catch(() => '')) || '';
|
|
216
|
+
}
|
|
217
|
+
// Try to find actual order reference in row text
|
|
218
|
+
const rowText = await row.textContent().catch(() => '') || '';
|
|
219
|
+
const refMatch = rowText.match(/WATTIZ[A-Z0-9]+|[A-Z]{2,}[0-9]{6,}/i);
|
|
220
|
+
if (refMatch) {
|
|
221
|
+
orderNumber = refMatch[0];
|
|
222
|
+
}
|
|
223
|
+
if (orderId) {
|
|
224
|
+
orders.push({
|
|
225
|
+
id: orderId,
|
|
226
|
+
orderNumber: orderNumber.trim(),
|
|
227
|
+
date: date.trim(),
|
|
228
|
+
status: status.trim(),
|
|
229
|
+
total: total.trim(),
|
|
230
|
+
itemCount: 0,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return orders;
|
|
235
|
+
}
|
|
236
|
+
async getOrder(orderId) {
|
|
237
|
+
const page = await this.ensureBrowser();
|
|
238
|
+
// PrestaShop controller-based URL
|
|
239
|
+
await page.goto(`${this.config.baseUrl}/${this.config.language}/index.php?controller=order-detail&id_order=${orderId}`, {
|
|
240
|
+
waitUntil: 'domcontentloaded',
|
|
241
|
+
timeout: 30000
|
|
242
|
+
});
|
|
243
|
+
await page.waitForLoadState('domcontentloaded').catch(() => { });
|
|
244
|
+
// Check if we need to login
|
|
245
|
+
const needsLogin = page.url().includes('/login');
|
|
246
|
+
if (needsLogin) {
|
|
247
|
+
await this.login();
|
|
248
|
+
await page.goto(`${this.config.baseUrl}/${this.config.language}/index.php?controller=order-detail&id_order=${orderId}`, {
|
|
249
|
+
waitUntil: 'domcontentloaded',
|
|
250
|
+
timeout: 30000
|
|
251
|
+
});
|
|
252
|
+
await page.waitForLoadState('domcontentloaded').catch(() => { });
|
|
253
|
+
}
|
|
254
|
+
// Extract order information from Wattiz order detail page
|
|
255
|
+
// Use order ID as the order number (Wattiz doesn't display a separate reference)
|
|
256
|
+
const orderNumber = orderId;
|
|
257
|
+
let orderDate = '';
|
|
258
|
+
let orderStatus = '';
|
|
259
|
+
// Get visible page text for pattern matching
|
|
260
|
+
const pageText = await page.locator('body').textContent().catch(() => '') || '';
|
|
261
|
+
// Find date - look for format DD/MM/YYYY or YYYY-MM-DD
|
|
262
|
+
const dateMatches = pageText.match(/\b(\d{2}[\/\-]\d{2}[\/\-]20\d{2}|20\d{2}[\/\-]\d{2}[\/\-]\d{2})\b/g);
|
|
263
|
+
if (dateMatches && dateMatches.length > 0 && dateMatches[0]) {
|
|
264
|
+
orderDate = dateMatches[0];
|
|
265
|
+
}
|
|
266
|
+
// Get status - look for common shipping status text
|
|
267
|
+
const statusPatterns = ['Shipped', 'Delivered', 'Processing', 'Pending', 'Cancelled', 'En cours', 'Expédié', 'Livré'];
|
|
268
|
+
for (const pattern of statusPatterns) {
|
|
269
|
+
if (pageText.includes(pattern)) {
|
|
270
|
+
orderStatus = pattern;
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// Also try status elements if no pattern found
|
|
275
|
+
if (!orderStatus) {
|
|
276
|
+
const statusSelectors = ['.label.bright', '.badge', '.order-status'];
|
|
277
|
+
for (const selector of statusSelectors) {
|
|
278
|
+
const statusText = await page.locator(selector).first().textContent().catch(() => '');
|
|
279
|
+
if (statusText && statusText.length > 2 && statusText.length < 25 && !statusText.includes('€')) {
|
|
280
|
+
orderStatus = statusText.trim();
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Extract customer info from address blocks
|
|
286
|
+
let customerName = '';
|
|
287
|
+
let customerAddress = '';
|
|
288
|
+
let customerEmail = '';
|
|
289
|
+
let customerPhone = '';
|
|
290
|
+
try {
|
|
291
|
+
// PrestaShop uses .address class for address blocks
|
|
292
|
+
const addressBlocks = page.locator('.address, article.address');
|
|
293
|
+
const blockCount = await addressBlocks.count();
|
|
294
|
+
for (let i = 0; i < blockCount; i++) {
|
|
295
|
+
const block = addressBlocks.nth(i);
|
|
296
|
+
const addressText = await block.textContent() || '';
|
|
297
|
+
const lines = addressText.split('\n').map(l => l.trim()).filter(l => l && l.length > 1);
|
|
298
|
+
// First line is usually the name
|
|
299
|
+
if (lines.length > 0 && !customerName) {
|
|
300
|
+
customerName = lines[0];
|
|
301
|
+
}
|
|
302
|
+
// Combine address lines (skip name, email, phone)
|
|
303
|
+
if (lines.length > 1 && !customerAddress) {
|
|
304
|
+
customerAddress = lines.slice(1)
|
|
305
|
+
.filter(l => !l.includes('@') && !l.match(/^\+?\d[\d\s\-]+$/))
|
|
306
|
+
.join(', ');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// Try to find customer email (exclude wattiz domain)
|
|
310
|
+
const mailLinks = await page.locator('[href^="mailto:"]').all();
|
|
311
|
+
for (const link of mailLinks) {
|
|
312
|
+
const href = await link.getAttribute('href').catch(() => '') || '';
|
|
313
|
+
const email = href.replace('mailto:', '');
|
|
314
|
+
if (email && !email.includes('wattiz')) {
|
|
315
|
+
customerEmail = email;
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// Fallback: look for email pattern in address blocks (exclude wattiz)
|
|
320
|
+
if (!customerEmail) {
|
|
321
|
+
for (let i = 0; i < blockCount; i++) {
|
|
322
|
+
const blockText = await addressBlocks.nth(i).textContent().catch(() => '') || '';
|
|
323
|
+
const emailMatch = blockText.match(/[\w.+-]+@[\w.-]+\.\w+/);
|
|
324
|
+
if (emailMatch && !emailMatch[0].includes('wattiz')) {
|
|
325
|
+
customerEmail = emailMatch[0];
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
customerPhone = await page.locator('[href^="tel:"]').first().textContent().catch(() => '') || '';
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
// Customer details not available
|
|
334
|
+
}
|
|
335
|
+
// Extract line items from product table
|
|
336
|
+
// Look for the products section specifically
|
|
337
|
+
const items = [];
|
|
338
|
+
// Try different selectors for the product table
|
|
339
|
+
const productSelectors = [
|
|
340
|
+
'#order-products table tbody tr',
|
|
341
|
+
'.order-products tbody tr',
|
|
342
|
+
'[id*="product"] table tbody tr',
|
|
343
|
+
'table.table-striped tbody tr'
|
|
344
|
+
];
|
|
345
|
+
let productRows = null;
|
|
346
|
+
for (const selector of productSelectors) {
|
|
347
|
+
const rows = page.locator(selector);
|
|
348
|
+
const count = await rows.count();
|
|
349
|
+
if (count > 0) {
|
|
350
|
+
// Check if first row looks like a product (not a date or header)
|
|
351
|
+
const firstRowText = await rows.first().textContent().catch(() => '') || '';
|
|
352
|
+
if (!firstRowText.match(/^\d{4}[\/\-]\d{2}/) && firstRowText.length > 10) {
|
|
353
|
+
productRows = rows;
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (productRows) {
|
|
359
|
+
const itemCount = await productRows.count();
|
|
360
|
+
for (let i = 0; i < itemCount; i++) {
|
|
361
|
+
const itemRow = productRows.nth(i);
|
|
362
|
+
const rowText = await itemRow.textContent() || '';
|
|
363
|
+
// Skip rows that look like dates or headers
|
|
364
|
+
if (rowText.match(/^\s*\d{4}[\/\-]\d{2}/) || rowText.includes('Product') || rowText.trim().length < 5) {
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
const cells = itemRow.locator('td');
|
|
368
|
+
const cellCount = await cells.count();
|
|
369
|
+
if (cellCount >= 2) {
|
|
370
|
+
const nameCell = await cells.nth(0).textContent() || '';
|
|
371
|
+
const name = nameCell.replace(/\s+/g, ' ').trim();
|
|
372
|
+
let qty = '1';
|
|
373
|
+
let total = '';
|
|
374
|
+
// Find quantity and price cells
|
|
375
|
+
for (let c = 1; c < cellCount; c++) {
|
|
376
|
+
const cellText = (await cells.nth(c).textContent() || '').trim();
|
|
377
|
+
if (cellText.match(/^\d+$/) && parseInt(cellText, 10) < 1000) {
|
|
378
|
+
qty = cellText;
|
|
379
|
+
}
|
|
380
|
+
else if (cellText.includes('€') || cellText.includes('$')) {
|
|
381
|
+
total = cellText;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// Only add if name looks like a product
|
|
385
|
+
if (name && name.length > 5 && !name.match(/^\d{4}[\/\-]/)) {
|
|
386
|
+
items.push({
|
|
387
|
+
sku: '',
|
|
388
|
+
name: name,
|
|
389
|
+
quantity: parseInt(qty, 10) || 1,
|
|
390
|
+
price: '',
|
|
391
|
+
total: total,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// Look for invoice link
|
|
398
|
+
const invoiceLink = await page.locator('a[href*="pdf-invoice"], a[href*="get-invoice"], a[href*="invoice"]').first().getAttribute('href').catch(() => '');
|
|
399
|
+
// Extract total - look for price patterns in page
|
|
400
|
+
let totalText = '';
|
|
401
|
+
// Look for total amount in page text
|
|
402
|
+
const priceMatches = pageText.match(/€\s*[\d,]+\.?\d*/g) || [];
|
|
403
|
+
if (priceMatches.length > 0) {
|
|
404
|
+
// Get the largest price as total (usually the order total)
|
|
405
|
+
let maxPrice = 0;
|
|
406
|
+
let maxPriceText = '';
|
|
407
|
+
for (const priceText of priceMatches) {
|
|
408
|
+
const value = parseFloat(priceText.replace('€', '').replace(/\s/g, '').replace(',', '.'));
|
|
409
|
+
if (value > maxPrice) {
|
|
410
|
+
maxPrice = value;
|
|
411
|
+
maxPriceText = priceText.trim();
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (maxPriceText) {
|
|
415
|
+
totalText = maxPriceText;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// Fallback: try specific selectors
|
|
419
|
+
if (!totalText) {
|
|
420
|
+
const totalSelectors = [
|
|
421
|
+
'.order-totals tr:last-child td:last-child',
|
|
422
|
+
'.total-value',
|
|
423
|
+
'table tfoot td:last-child'
|
|
424
|
+
];
|
|
425
|
+
for (const selector of totalSelectors) {
|
|
426
|
+
const text = await page.locator(selector).last().textContent().catch(() => '');
|
|
427
|
+
if (text && text.includes('€')) {
|
|
428
|
+
totalText = text.trim();
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// Extract tracking number if available
|
|
434
|
+
const trackingNumber = await page.locator('.tracking-number, [href*="track"], [data-tracking]').first().textContent().catch(() => '');
|
|
435
|
+
return {
|
|
436
|
+
id: orderId,
|
|
437
|
+
orderNumber: orderNumber?.trim() || orderId,
|
|
438
|
+
date: orderDate?.trim() || '',
|
|
439
|
+
status: orderStatus?.trim() || '',
|
|
440
|
+
total: totalText?.trim() || '',
|
|
441
|
+
itemCount: items.length,
|
|
442
|
+
invoiceUrl: invoiceLink || undefined,
|
|
443
|
+
trackingNumber: trackingNumber?.trim() || undefined,
|
|
444
|
+
customer: {
|
|
445
|
+
name: customerName.trim(),
|
|
446
|
+
address: customerAddress.trim(),
|
|
447
|
+
city: '',
|
|
448
|
+
postcode: '',
|
|
449
|
+
email: customerEmail.trim(),
|
|
450
|
+
phone: customerPhone.trim(),
|
|
451
|
+
},
|
|
452
|
+
items,
|
|
453
|
+
subtotal: '',
|
|
454
|
+
shipping: '',
|
|
455
|
+
tax: '',
|
|
456
|
+
paymentMethod: '',
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
async downloadInvoice(orderId, savePath) {
|
|
460
|
+
try {
|
|
461
|
+
const order = await this.getOrder(orderId);
|
|
462
|
+
if (!order.invoiceUrl) {
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
const page = await this.ensureBrowser();
|
|
466
|
+
// Construct full URL
|
|
467
|
+
const invoiceUrl = order.invoiceUrl.startsWith('http')
|
|
468
|
+
? order.invoiceUrl
|
|
469
|
+
: `${this.config.baseUrl}${order.invoiceUrl}`;
|
|
470
|
+
// Set up download listener before navigating
|
|
471
|
+
const downloadPromise = page.waitForEvent('download', { timeout: 30000 });
|
|
472
|
+
// Navigate to invoice URL (this triggers the download)
|
|
473
|
+
await page.goto(invoiceUrl, { waitUntil: 'commit' }).catch(() => {
|
|
474
|
+
// Ignore navigation error since download starts immediately
|
|
475
|
+
});
|
|
476
|
+
// Wait for the download to start
|
|
477
|
+
const download = await downloadPromise;
|
|
478
|
+
// Save the downloaded file
|
|
479
|
+
await download.saveAs(savePath);
|
|
480
|
+
return true;
|
|
481
|
+
}
|
|
482
|
+
catch (error) {
|
|
483
|
+
console.error('Invoice download error:', error);
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
async searchProducts(query) {
|
|
488
|
+
const page = await this.ensureBrowser();
|
|
489
|
+
const searchUrl = `${this.config.baseUrl}/${this.config.language}/search?s=${encodeURIComponent(query)}`;
|
|
490
|
+
await page.goto(searchUrl);
|
|
491
|
+
await page.waitForLoadState('networkidle');
|
|
492
|
+
// Check if we need to login to see prices (B2B feature)
|
|
493
|
+
const needsLogin = await page.url().includes('/login');
|
|
494
|
+
if (needsLogin) {
|
|
495
|
+
await this.login();
|
|
496
|
+
await page.goto(searchUrl);
|
|
497
|
+
await page.waitForLoadState('networkidle');
|
|
498
|
+
}
|
|
499
|
+
const products = [];
|
|
500
|
+
// Find all product items (PrestaShop structure)
|
|
501
|
+
const productItems = page.locator('.product-miniature, .js-product-miniature, article.product');
|
|
502
|
+
const count = await productItems.count();
|
|
503
|
+
for (let i = 0; i < count; i++) {
|
|
504
|
+
const item = productItems.nth(i);
|
|
505
|
+
const link = item.locator('a.product-thumbnail, h3 a').first();
|
|
506
|
+
const url = await link.getAttribute('href') || '';
|
|
507
|
+
const name = await item.locator('.product-title, h3 a, h2 a').first().textContent() || '';
|
|
508
|
+
const price = await item.locator('.price, .product-price-and-shipping').first().textContent().catch(() => undefined);
|
|
509
|
+
const img = await item.locator('img').first().getAttribute('src');
|
|
510
|
+
if (name && url) {
|
|
511
|
+
products.push({
|
|
512
|
+
id: '',
|
|
513
|
+
name: name.trim(),
|
|
514
|
+
url,
|
|
515
|
+
price: price?.trim(),
|
|
516
|
+
imageUrl: img || undefined,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return products;
|
|
521
|
+
}
|
|
522
|
+
async close() {
|
|
523
|
+
if (this.browser) {
|
|
524
|
+
await this.browser.close();
|
|
525
|
+
this.browser = undefined;
|
|
526
|
+
this.page = undefined;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
export default WattizPlaywrightClient;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wattiz Types
|
|
3
|
+
*
|
|
4
|
+
* TypeScript interfaces for wattiz.fr (B2B e-mobility parts supplier - PrestaShop)
|
|
5
|
+
*/
|
|
6
|
+
export interface WattizConfig {
|
|
7
|
+
username: string;
|
|
8
|
+
password: string;
|
|
9
|
+
baseUrl: string;
|
|
10
|
+
language: 'gb' | 'fr' | 'de' | 'es';
|
|
11
|
+
}
|
|
12
|
+
export interface WattizOrder {
|
|
13
|
+
id: string;
|
|
14
|
+
orderNumber: string;
|
|
15
|
+
date: string;
|
|
16
|
+
status: string;
|
|
17
|
+
total: string;
|
|
18
|
+
itemCount: number;
|
|
19
|
+
invoiceUrl?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface WattizOrderDetails extends WattizOrder {
|
|
22
|
+
customer: {
|
|
23
|
+
name: string;
|
|
24
|
+
company?: string;
|
|
25
|
+
address: string;
|
|
26
|
+
city: string;
|
|
27
|
+
postcode: string;
|
|
28
|
+
email: string;
|
|
29
|
+
phone?: string;
|
|
30
|
+
};
|
|
31
|
+
items: WattizLineItem[];
|
|
32
|
+
subtotal: string;
|
|
33
|
+
shipping: string;
|
|
34
|
+
tax: string;
|
|
35
|
+
paymentMethod: string;
|
|
36
|
+
shippingMethod?: string;
|
|
37
|
+
trackingNumber?: string;
|
|
38
|
+
}
|
|
39
|
+
export interface WattizLineItem {
|
|
40
|
+
sku: string;
|
|
41
|
+
name: string;
|
|
42
|
+
quantity: number;
|
|
43
|
+
price: string;
|
|
44
|
+
total: string;
|
|
45
|
+
}
|
|
46
|
+
export interface WattizProduct {
|
|
47
|
+
id: string;
|
|
48
|
+
name: string;
|
|
49
|
+
sku?: string;
|
|
50
|
+
ean?: string;
|
|
51
|
+
price?: string;
|
|
52
|
+
url: string;
|
|
53
|
+
imageUrl?: string;
|
|
54
|
+
stockStatus?: string;
|
|
55
|
+
category?: string;
|
|
56
|
+
}
|
|
57
|
+
export interface WattizLoginResult {
|
|
58
|
+
success: boolean;
|
|
59
|
+
cookies: string;
|
|
60
|
+
error?: string;
|
|
61
|
+
}
|