playttm 0.0.1

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/src/lib.ts ADDED
@@ -0,0 +1,1138 @@
1
+ import axios, { AxiosInstance } from "axios";
2
+ import * as cheerio from "cheerio";
3
+ import { wrapper } from "axios-cookiejar-support";
4
+ import { CookieJar } from "tough-cookie";
5
+ import qrcodeTerminal from "qrcode-terminal";
6
+
7
+ // ==========================================
8
+ // Interfaces & Types
9
+ // ==========================================
10
+
11
+ export interface TTMConfig {
12
+ cookies?: string;
13
+ headers?: Record<string, string>;
14
+ userAgent?: string;
15
+ timeout?: number;
16
+ endpoint?: string;
17
+ onProgress?: (step: string, message: string, data?: any) => void;
18
+ }
19
+
20
+ export interface RoundInfo {
21
+ id: string;
22
+ label: string;
23
+ date: string;
24
+ time: string;
25
+ url: string;
26
+ query: string;
27
+ isDisabled: boolean;
28
+ }
29
+
30
+ export interface ZoneItem {
31
+ id: string;
32
+ value: string;
33
+ available: number;
34
+ page: "fixed.php" | "festival.php" | string;
35
+ isAvailable: boolean;
36
+ }
37
+
38
+ export interface SeatItem {
39
+ id: string;
40
+ seat: string;
41
+ seatk: string;
42
+ price?: string;
43
+ }
44
+
45
+ export interface Attendee {
46
+ fullname: string;
47
+ email?: string;
48
+ phone?: string;
49
+ firstname?: string;
50
+ lastname?: string;
51
+ options?: string;
52
+ }
53
+
54
+ export interface QuickBookOptions {
55
+ eventUrl: string;
56
+ roundIndex?: number;
57
+ roundId?: string;
58
+ zoneId: string;
59
+ ticketCount?: number;
60
+ seatSelection?: "random" | "first" | SeatItem[] | string[];
61
+ attendees?: Attendee[];
62
+ deliveryType?: string; // '1' = Delivery, etc.
63
+ payType?: string; // Default: 'KBQR'
64
+ paymentDelayMs?: number;
65
+ printQR?: boolean; // Default: true (พิมพ์ QR Code บน Terminal)
66
+ onProgress?: (step: string, message: string, data?: any) => void;
67
+ }
68
+
69
+ export interface QuickBookResult {
70
+ success: boolean;
71
+ orderId: string;
72
+ amount: string;
73
+ qrRawCode?: string;
74
+ qrData: any;
75
+ summary: {
76
+ performance?: string;
77
+ showDate?: string;
78
+ showTime?: string;
79
+ venue?: string;
80
+ zone?: string;
81
+ ticketCount: number;
82
+ totalAmount: string;
83
+ seats?: string[];
84
+ };
85
+ rawPayload?: any;
86
+ displayQR: (title?: string) => void;
87
+ }
88
+
89
+ // ==========================================
90
+ // Main Library Class
91
+ // ==========================================
92
+
93
+ export class ThaiTicketMajor {
94
+ private client: AxiosInstance;
95
+ private jar: CookieJar;
96
+ private endpoint: string;
97
+ private defaultHeaders: Record<string, string>;
98
+ private onProgressCallback?: (step: string, message: string, data?: any) => void;
99
+
100
+ constructor(config: TTMConfig = {}) {
101
+ this.jar = new CookieJar();
102
+ this.endpoint = config.endpoint || "https://booking.thaiticketmajor.com/booking/3m";
103
+ this.onProgressCallback = config.onProgress;
104
+
105
+ const defaultUA =
106
+ config.userAgent ||
107
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
108
+
109
+ this.defaultHeaders = {
110
+ accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
111
+ "accept-language": "th,en-US;q=0.9,en;q=0.8",
112
+ "user-agent": defaultUA,
113
+ referer: "https://booking.thaiticketmajor.com",
114
+ ...(config.headers || {}),
115
+ };
116
+
117
+ if (config.cookies) {
118
+ this.defaultHeaders["cookie"] = config.cookies;
119
+ }
120
+
121
+ this.client = wrapper(
122
+ axios.create({
123
+ jar: this.jar,
124
+ withCredentials: true,
125
+ timeout: config.timeout || 30000,
126
+ } as any) as any
127
+ ) as any;
128
+ }
129
+
130
+ private emitProgress(step: string, message: string, data?: any) {
131
+ if (this.onProgressCallback) {
132
+ this.onProgressCallback(step, message, data);
133
+ }
134
+ }
135
+
136
+ // ==========================================
137
+ // Low-Level API Methods
138
+ // ==========================================
139
+
140
+ /**
141
+ * Step 1: ดึงรอบการแสดงทั้งหมดจากหน้าหลักคอนเสิร์ต
142
+ */
143
+ public async getRounds(eventUrl: string): Promise<RoundInfo[]> {
144
+ this.emitProgress("GET_ROUNDS", `Fetching rounds from ${eventUrl}`);
145
+
146
+ const response = await this.client.get(eventUrl, {
147
+ headers: {
148
+ ...this.defaultHeaders,
149
+ referer: "https://www.thaiticketmajor.com/all-event/",
150
+ },
151
+ });
152
+
153
+ const $ = cheerio.load(response.data);
154
+ const rounds: RoundInfo[] = [];
155
+
156
+ $("a[data-button]").each((_, el) => {
157
+ const link = $(el);
158
+ const href = link.attr("href") ?? "";
159
+ const onclick = link.attr("onclick") ?? "";
160
+ const dataUrl = link.attr("data-url") ?? "";
161
+ const dataHref = link.attr("data-href") ?? "";
162
+ const allLinkAttrs = `${href} ${onclick} ${dataUrl} ${dataHref}`;
163
+
164
+ const queryMatch = allLinkAttrs.match(/query=([0-9a-zA-Z_-]+)/);
165
+ const query = queryMatch ? queryMatch[1] : "";
166
+ const time = link.find(".item-show").text().trim();
167
+ const date = link.closest(".row").find(".date").text().trim();
168
+ const isDisabled = link.is(":disabled") || link.attr("disabled") !== undefined || !query;
169
+
170
+ rounds.push({
171
+ id: query || link.attr("data-button") || "",
172
+ label: `${date} ${time}`.trim(),
173
+ date,
174
+ time,
175
+ url: href,
176
+ query,
177
+ isDisabled,
178
+ });
179
+ });
180
+
181
+ return rounds;
182
+ }
183
+
184
+ /**
185
+ * Step 2: ตรวจสอบและยอมรับเงื่อนไขการซื้อบัตร
186
+ */
187
+ public async verifyCheckCondition(query: string): Promise<{ result: boolean; message: string; query: string }> {
188
+ if (!query) {
189
+ throw new Error("Query ID is missing. The selected round might be disabled or not open for sale yet.");
190
+ }
191
+
192
+ this.emitProgress("VERIFY_CONDITION", `Verifying condition for query ${query}`);
193
+
194
+ const response = await this.client.post(
195
+ `${this.endpoint}/verify_checkcondition.php`,
196
+ new URLSearchParams({
197
+ rdagree: "1",
198
+ rdId: "",
199
+ autopopup: "",
200
+ query: query,
201
+ }),
202
+ { headers: this.defaultHeaders }
203
+ );
204
+
205
+ return response.data;
206
+ }
207
+
208
+ /**
209
+ * Step 3: ดึงข้อมูลโซน, Token (k, tk) และรอบการแสดง
210
+ */
211
+ public async getZones(query: string): Promise<{ k: string; tk: string; query: string; rounds: { id: string; label: string }[]; rawInputs: Record<string, string> }> {
212
+ this.emitProgress("GET_ZONES", `Fetching zone data and tokens for query ${query}`);
213
+
214
+ const response = await this.client.get(`${this.endpoint}/zones.php?query=${query}`, {
215
+ headers: this.defaultHeaders,
216
+ });
217
+
218
+ const $ = cheerio.load(response.data);
219
+ const rawInputs: Record<string, string> = {};
220
+
221
+ $("#frm input[type='hidden']").each((_, el) => {
222
+ const name = $(el).attr("name");
223
+ const value = $(el).attr("value") ?? "";
224
+ if (name) {
225
+ rawInputs[name] = value;
226
+ }
227
+ });
228
+
229
+ const rounds = $("#rdId option")
230
+ .filter((_, el) => !!$(el).attr("value"))
231
+ .map((_, el) => {
232
+ const opt = $(el);
233
+ return {
234
+ id: opt.attr("value") ?? "",
235
+ label: opt.text().trim(),
236
+ };
237
+ })
238
+ .get();
239
+
240
+ return {
241
+ k: rawInputs["k"] || "",
242
+ tk: rawInputs["tk"] || "",
243
+ query,
244
+ rounds,
245
+ rawInputs,
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Step 4: ตรวจสอบสถานะที่นั่งว่างของแต่ละโซน
251
+ */
252
+ public async getZoneAvailability(roundId: string, tk: string, k: string, query: string): Promise<ZoneItem[]> {
253
+ this.emitProgress("GET_ZONE_AVAIL", `Checking zone availability for round ${roundId}`);
254
+
255
+ const response = await this.client.get(`${this.endpoint}/zonesavail.php?round=${roundId}&tk=${tk}`, {
256
+ headers: {
257
+ ...this.defaultHeaders,
258
+ referer: `${this.endpoint}/zones.php?query=${query}`,
259
+ },
260
+ });
261
+
262
+ const $ = cheerio.load(response.data);
263
+ const zones: ZoneItem[] = [];
264
+
265
+ $(".table tbody tr").each((_, el) => {
266
+ const row = $(el);
267
+ const zoneId = row.find("td:nth-child(1) a").attr("id") ?? "";
268
+ const onclick = row.attr("onclick") ?? "";
269
+ const match = onclick.match(/gonextstep\('([^']+)','([^']+)'/);
270
+
271
+ const page = (match?.[1] ?? "") as "fixed.php" | "festival.php" | string;
272
+ const zoneValue = match?.[2] ?? "";
273
+ const statusText = row.find("td:nth-child(2) a").text().trim();
274
+
275
+ let available = 0;
276
+ let isAvailable = false;
277
+
278
+ if (statusText.toLowerCase() === "available") {
279
+ available = 1;
280
+ isAvailable = true;
281
+ } else {
282
+ const num = Number(statusText);
283
+ if (!Number.isNaN(num) && num > 0) {
284
+ available = num;
285
+ isAvailable = true;
286
+ }
287
+ }
288
+
289
+ zones.push({
290
+ id: zoneId,
291
+ value: zoneValue,
292
+ available,
293
+ page,
294
+ isAvailable,
295
+ });
296
+ });
297
+
298
+ return zones;
299
+ }
300
+
301
+ // --- Flow บัตรนั่ง (Fixed Seats) ---
302
+
303
+ /**
304
+ * Step 5 (Fixed): ดึงผังที่นั่งว่างและ form payload
305
+ */
306
+ public async getFixedSeats(k: string, zone: string, roundId: string): Promise<{ seats: SeatItem[]; form: Record<string, string> }> {
307
+ this.emitProgress("GET_FIXED_SEATS", `Fetching seat layout for zone ${zone}`);
308
+
309
+ const response = await this.client.get(`${this.endpoint}/fixed.php?k=${k}&zone=${zone}&round=${roundId}`, {
310
+ headers: this.defaultHeaders,
311
+ });
312
+
313
+ const $ = cheerio.load(response.data);
314
+ const seats: SeatItem[] = $("#tableseats .seatuncheck")
315
+ .map((_, el) => {
316
+ const seat = $(el);
317
+ return {
318
+ id: seat.attr("id") ?? "",
319
+ seat: seat.attr("data-seat") ?? "",
320
+ seatk: seat.attr("data-seatk") ?? "",
321
+ };
322
+ })
323
+ .get();
324
+
325
+ const form: Record<string, string> = {};
326
+ $("#frmPayment input").each((_, el) => {
327
+ const name = $(el).attr("name");
328
+ if (name) {
329
+ form[name] = $(el).attr("value") ?? "";
330
+ }
331
+ });
332
+
333
+ return { seats, form };
334
+ }
335
+
336
+ /**
337
+ * Step 6 (Fixed): Validate ที่นั่งทีละที่นั่งตามลำดับ
338
+ */
339
+ public async validateFixedSeats(
340
+ k: string,
341
+ zone: string,
342
+ payload: Record<string, any>,
343
+ chkSeats: SeatItem[],
344
+ bookType: string = "fix"
345
+ ): Promise<{ result: boolean; status: number; message: string }> {
346
+ this.emitProgress("VALIDATE_FIXED_SEATS", `Validating ${chkSeats.length} fixed seats`);
347
+
348
+ let lastResponse: any = null;
349
+ const accumulatedSeats: string[] = [];
350
+
351
+ for (let i = 0; i < chkSeats.length; i++) {
352
+ const currentSeat = chkSeats[i];
353
+ const priceVal = currentSeat.price || "5335";
354
+ const seatEntry = `${currentSeat.seat}*${priceVal}`;
355
+
356
+ accumulatedSeats.unshift(seatEntry);
357
+
358
+ const formData = new URLSearchParams();
359
+
360
+ for (const seat of accumulatedSeats) {
361
+ formData.append("chkSeats[]", seat);
362
+ }
363
+
364
+ formData.append("ehId", payload.ehId ?? "");
365
+ formData.append("curentdate", payload.curentdate ?? "");
366
+ formData.append("max_payment", payload.max_payment ?? "6");
367
+ formData.append("payment_cnt", payload.payment_cnt ?? "0");
368
+ formData.append("paytype", payload.paytype ?? "BOAI");
369
+ formData.append("performance", payload.performance ?? "");
370
+ formData.append("pricelist", "");
371
+ formData.append("rdId", payload.rdId ?? "");
372
+ formData.append("seatlist", "");
373
+ formData.append("showdate", payload.showdate ?? "");
374
+ formData.append("showtime", payload.showtime ?? "");
375
+ formData.append("venue", payload.venue ?? "");
376
+ formData.append("zone", payload.zone ?? "");
377
+ formData.append("zoneDesc", payload.zoneDesc ?? "");
378
+ formData.append("travelChild1", "");
379
+ formData.append("travelChild2", "");
380
+ formData.append("travelChild2", "");
381
+ formData.append("enroll_val", payload.enroll_val ?? "");
382
+ formData.append("dval", payload.dval ?? "");
383
+ formData.append("companyid", payload.companyid ?? "");
384
+ formData.append("ks", payload.ks ?? "");
385
+ formData.append("inclvat", "");
386
+
387
+ const seatParts = currentSeat.seat.split("-");
388
+ const row = seatParts[0];
389
+ const seatNo = seatParts[1] || "";
390
+
391
+ formData.append("row", row);
392
+ formData.append("seat", seatNo);
393
+ formData.append("book_type", bookType);
394
+
395
+ const response = await this.client.post(
396
+ `${this.endpoint}/validateseat.php?k=${k}&zw=${zone}`,
397
+ formData.toString(),
398
+ {
399
+ headers: {
400
+ ...this.defaultHeaders,
401
+ "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
402
+ "x-requested-with": "XMLHttpRequest",
403
+ referer: `${this.endpoint}/fixed.php?k=${k}&zone=${payload.zone}&round=${payload.rdId}`,
404
+ },
405
+ }
406
+ );
407
+
408
+ lastResponse = response.data;
409
+ if (!lastResponse.result) {
410
+ throw new Error(`Validate seat failed (${currentSeat.seat}): ${lastResponse.message || "Unknown error"}`);
411
+ }
412
+ }
413
+
414
+ return lastResponse;
415
+ }
416
+
417
+ /**
418
+ * Step 7 (Fixed): จองที่นั่ง Fixed
419
+ */
420
+ public async bookFixedSeats(
421
+ k: string,
422
+ payload: Record<string, any>,
423
+ seats: SeatItem[]
424
+ ): Promise<{ data: any; nextPayload: URLSearchParams }> {
425
+ this.emitProgress("BOOK_FIXED_SEATS", `Booking fixed seats`);
426
+
427
+ let pricelistvalue = "";
428
+ let seatlistvalue = "";
429
+ let seatklistvalue = "";
430
+
431
+ seats.forEach((s) => {
432
+ const parts = s.seat.split("*");
433
+ pricelistvalue += `${parts[1] || ""},`;
434
+ seatlistvalue += `${parts[0]},`;
435
+ seatklistvalue += `${s.seatk},`;
436
+ });
437
+
438
+ const bodyParams = new URLSearchParams();
439
+ bodyParams.set("ehId", payload.ehId || "");
440
+ bodyParams.set("curentdate", payload.curentdate || "");
441
+ bodyParams.set("max_payment", payload.max_payment || "6");
442
+ bodyParams.set("payment_cnt", payload.payment_cnt || "0");
443
+ bodyParams.set("paytype", payload.paytype || "BOAI");
444
+ bodyParams.set("performance", payload.performance || "");
445
+ bodyParams.set("pricelist", pricelistvalue);
446
+ bodyParams.set("rdId", payload.rdId || "");
447
+ bodyParams.set("seatlist", seatlistvalue);
448
+ bodyParams.set("showdate", payload.showdate || "");
449
+ bodyParams.set("showtime", payload.showtime || "");
450
+ bodyParams.set("venue", payload.venue || "");
451
+ bodyParams.set("zone", payload.zone || "");
452
+ bodyParams.set("zoneDesc", payload.zoneDesc || "");
453
+ bodyParams.set("travelChild1", "");
454
+ bodyParams.set("travelChild2", "");
455
+ bodyParams.append("travelChild2", "");
456
+ bodyParams.set("enroll_val", String(seats.length));
457
+ bodyParams.set("dval", payload.dval || "");
458
+ bodyParams.set("companyid", payload.companyid || "");
459
+ bodyParams.set("ks", "");
460
+ bodyParams.set("inclvat", "");
461
+ bodyParams.set("seatklist", seatklistvalue);
462
+
463
+ const response = await this.client.post(
464
+ `${this.endpoint}/bookingseats.php?k=${k}`,
465
+ bodyParams.toString(),
466
+ {
467
+ headers: {
468
+ ...this.defaultHeaders,
469
+ accept: "application/json, text/javascript, */*; q=0.01",
470
+ "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
471
+ "x-requested-with": "XMLHttpRequest",
472
+ referer: `${this.endpoint}/fixed.php?k=${k}&zone=${payload.zone}&round=${payload.rdId}`,
473
+ origin: "https://booking.thaiticketmajor.com",
474
+ },
475
+ }
476
+ );
477
+
478
+ return {
479
+ data: response.data,
480
+ nextPayload: bodyParams,
481
+ };
482
+ }
483
+
484
+ // --- Flow บัตรยืน (Festival / Standing) ---
485
+
486
+ /**
487
+ * Step 5 (Festival): ดึงข้อมูลหน้าบัตรยืน
488
+ */
489
+ public async getFestival(k: string, tk: string, query: string, zone: string, roundId: string): Promise<Record<string, string>> {
490
+ this.emitProgress("GET_FESTIVAL", `Fetching festival zone ${zone}`);
491
+
492
+ const response = await this.client.get(`${this.endpoint}/festival.php?k=${k}&zone=${zone}&round=${roundId}`, {
493
+ headers: {
494
+ ...this.defaultHeaders,
495
+ referer: `${this.endpoint}/zones.php?rdId=${roundId}&k=${k}&tk=${tk}&query=${query}`,
496
+ },
497
+ });
498
+
499
+ const $ = cheerio.load(response.data);
500
+ const formData: Record<string, string> = {};
501
+
502
+ $("#frm input").each((_, el) => {
503
+ const name = $(el).attr("name");
504
+ if (name) {
505
+ formData[name] = $(el).attr("value") ?? "";
506
+ }
507
+ });
508
+
509
+ return formData;
510
+ }
511
+
512
+ /**
513
+ * Step 6 (Festival): Validate บัตรยืน
514
+ */
515
+ public async validateFestivalSeats(k: string, payload: Record<string, any>, ticketCount: number): Promise<any> {
516
+ this.emitProgress("VALIDATE_FESTIVAL", `Validating ${ticketCount} festival tickets`);
517
+
518
+ const preparePayload = {
519
+ ...payload,
520
+ book_cnt: ticketCount.toString(),
521
+ book_type: "fest",
522
+ };
523
+
524
+ const formData = new URLSearchParams(preparePayload);
525
+
526
+ const response = await this.client.post(
527
+ `${this.endpoint}/validateseat.php?k=${k}&zw=${payload.zone}`,
528
+ formData.toString(),
529
+ {
530
+ headers: {
531
+ ...this.defaultHeaders,
532
+ "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
533
+ "x-requested-with": "XMLHttpRequest",
534
+ referer: `${this.endpoint}/festival.php?k=${k}&zone=${payload.zone}&round=${payload.rdId}`,
535
+ },
536
+ }
537
+ );
538
+
539
+ return response.data;
540
+ }
541
+
542
+ /**
543
+ * Step 7 (Festival): จองบัตรยืน
544
+ */
545
+ public async bookFestivalSeats(k: string, payload: Record<string, any>, ticketCount: number): Promise<any> {
546
+ this.emitProgress("BOOK_FESTIVAL", `Booking ${ticketCount} festival tickets`);
547
+
548
+ const preparePayload = {
549
+ ...payload,
550
+ book_cnt: ticketCount.toString(),
551
+ };
552
+
553
+ const formData = new URLSearchParams(preparePayload);
554
+
555
+ const response = await this.client.post(
556
+ `${this.endpoint}/bookingfestival.php?k=${k}`,
557
+ formData.toString(),
558
+ {
559
+ headers: {
560
+ ...this.defaultHeaders,
561
+ "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
562
+ "x-requested-with": "XMLHttpRequest",
563
+ referer: `${this.endpoint}/festival.php?k=${k}&zone=${payload.zone}&round=${payload.rdId}`,
564
+ },
565
+ }
566
+ );
567
+
568
+ return response.data;
569
+ }
570
+
571
+ // --- Flow การลงทะเบียนผู้เข้าชม (Enrollment) ---
572
+
573
+ /**
574
+ * Step 8 (Enroll): ดึง Form ลงทะเบียนผู้เข้าชม
575
+ */
576
+ public async enroll(k: string, zone: string, roundId: string, payload: URLSearchParams | string): Promise<Record<string, string>> {
577
+ this.emitProgress("GET_ENROLL", `Fetching enrollment form`);
578
+
579
+ const response = await this.client.post(
580
+ `${this.endpoint}/enroll.php?k=${k}&zone=${zone}&round=${roundId}`,
581
+ payload.toString(),
582
+ {
583
+ headers: {
584
+ ...this.defaultHeaders,
585
+ referer: `${this.endpoint}/fixed.php?k=${k}&zone=${zone}&round=${roundId}`,
586
+ },
587
+ }
588
+ );
589
+
590
+ const $ = cheerio.load(response.data);
591
+ const formData: Record<string, string> = {};
592
+
593
+ $("#form input, #form select").each((_, el) => {
594
+ const name = $(el).attr("name");
595
+ if (name) {
596
+ formData[name] = $(el).attr("value") ?? $(el).find("option:selected").attr("value") ?? "";
597
+ }
598
+ });
599
+
600
+ return formData;
601
+ }
602
+
603
+ /**
604
+ * Step 9 (Enroll Process): ส่งข้อมูลผู้เข้าชม
605
+ */
606
+ public async enrollProcess(
607
+ k: string,
608
+ payload: Record<string, any>,
609
+ attendees: Attendee[]
610
+ ): Promise<{ data: any; nextPayload: URLSearchParams }> {
611
+ this.emitProgress("ENROLL_PROCESS", `Submitting info for ${attendees.length} attendees`);
612
+
613
+ const formData = new URLSearchParams();
614
+
615
+ for (const user of attendees) {
616
+ formData.append("txt_fullname[]", user.fullname || "");
617
+ formData.append("txt_email[]", user.email || "");
618
+ formData.append("txt_firstname[]", user.firstname || "");
619
+ formData.append("txt_lastname[]", user.lastname || "");
620
+ formData.append("txt_phone[]", user.phone || "");
621
+ formData.append("sel_options[]", user.options || "");
622
+ formData.append("zones[]", payload.zone ?? "");
623
+ }
624
+
625
+ const excludedKeys = new Set([
626
+ "txt_fullname[]",
627
+ "txt_email[]",
628
+ "txt_firstname[]",
629
+ "txt_lastname[]",
630
+ "txt_phone[]",
631
+ "sel_options[]",
632
+ "zones[]",
633
+ ]);
634
+
635
+ for (const [key, value] of Object.entries(payload)) {
636
+ if (!excludedKeys.has(key)) {
637
+ formData.append(key, value !== undefined && value !== null ? String(value) : "");
638
+ }
639
+ }
640
+
641
+ const response = await this.client.post(
642
+ `${this.endpoint}/enroll_process.php?k=${k}`,
643
+ formData.toString(),
644
+ {
645
+ headers: {
646
+ ...this.defaultHeaders,
647
+ "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
648
+ "x-requested-with": "XMLHttpRequest",
649
+ referer: `${this.endpoint}/enroll.php?k=${k}&zone=${payload.zone}&round=${payload.rdId}`,
650
+ },
651
+ }
652
+ );
653
+
654
+ return {
655
+ data: response.data,
656
+ nextPayload: formData,
657
+ };
658
+ }
659
+
660
+ // --- Flow การชำระเงิน (Payment) ---
661
+
662
+ /**
663
+ * Step 10: ดึงข้อมูลหน้าสรุปการชำระเงินและที่อยู่จัดส่ง
664
+ */
665
+ public async getPaymentDetails(
666
+ k: string,
667
+ formData: URLSearchParams | Record<string, any>,
668
+ zone: string,
669
+ roundId: string,
670
+ delayMs: number = 1000
671
+ ): Promise<Record<string, string>> {
672
+ this.emitProgress("GET_PAYMENT_DETAILS", `Loading payment confirmation page`);
673
+
674
+ const body = formData instanceof URLSearchParams ? formData.toString() : new URLSearchParams(formData).toString();
675
+
676
+ const response = await this.client.post(`${this.endpoint}/paymentall.php?k=${k}`, body, {
677
+ headers: {
678
+ ...this.defaultHeaders,
679
+ "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
680
+ "x-requested-with": "XMLHttpRequest",
681
+ referer: `${this.endpoint}/enroll.php?k=${k}&zone=${zone}&round=${roundId}`,
682
+ },
683
+ });
684
+
685
+ const $ = cheerio.load(response.data);
686
+ const hiddenInputs: Record<string, string> = {};
687
+
688
+ $("#frm-confirm input").each((_, el) => {
689
+ const key = $(el).attr("name") ?? $(el).attr("id");
690
+ if (key) {
691
+ hiddenInputs[key] = $(el).val()?.toString() ?? "";
692
+ }
693
+ });
694
+
695
+ const addressForm = $("#frm-address");
696
+ if (addressForm.length > 0) {
697
+ const fname = addressForm.find("input[name='c_fname_m']").val()?.toString() ?? "";
698
+ const lname = addressForm.find("input[name='c_lname_m']").val()?.toString() ?? "";
699
+ const address = addressForm.find("textarea[name='c_address_m']").val()?.toString() ?? "";
700
+ const ctId = addressForm.find("select[name='c_ctCode'] option:selected").val()?.toString() ?? "";
701
+ const pvId = addressForm.find("select[name='c_pvId'] option:selected").val()?.toString() ?? "";
702
+ const amId = addressForm.find("select[name='c_amId'] option:selected").val()?.toString() ?? "";
703
+ const zipcode = addressForm.find("input[name='c_PostCode']").val()?.toString() ?? "";
704
+
705
+ let rawPhone = addressForm.find("input[name='c_ContactRecipient']").val()?.toString() ?? "";
706
+ if (rawPhone.startsWith("66")) {
707
+ rawPhone = rawPhone.slice(2);
708
+ }
709
+ const phoneArea = addressForm.find("input[name='telephoneArea']").val()?.toString() ?? "66";
710
+
711
+ hiddenInputs["adr_fname"] = fname;
712
+ hiddenInputs["adr_lname"] = lname;
713
+ hiddenInputs["adr_address"] = address;
714
+ hiddenInputs["adr_ctId"] = ctId;
715
+ hiddenInputs["adr_pvId"] = pvId;
716
+ hiddenInputs["adr_amId"] = amId;
717
+ hiddenInputs["adr_zipcode"] = zipcode;
718
+ hiddenInputs["adr_mobile"] = rawPhone.replace(/(\d{2})(\d{3})(\d{4})/, "$1 $2 $3");
719
+ hiddenInputs["adr_mobilearea"] = phoneArea;
720
+ }
721
+
722
+ if (delayMs > 0) {
723
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
724
+ }
725
+
726
+ return hiddenInputs;
727
+ }
728
+
729
+ /**
730
+ * Step 11: คำนวณยอดเงินรวมสุทธิและยืนยันคำสั่งซื้อ
731
+ */
732
+ public async confirmPayment(
733
+ k: string,
734
+ payload: Record<string, string>,
735
+ deliver: string = "1",
736
+ payType: string = "KBQR"
737
+ ): Promise<Record<string, string>> {
738
+ this.emitProgress("CONFIRM_PAYMENT", `Calculating total and confirming payment (${payType})`);
739
+
740
+ const cntTicket = parseInt(payload.cal_cntticket || "0", 10);
741
+ const amountCode = parseFloat(payload.amountcode || "0");
742
+ const deliverFee = parseFloat(payload.cal_deliverfee || payload.val_deliverfee || "80");
743
+ const addCharge = parseFloat(payload.cal_addcharge || "0");
744
+ const discount = parseFloat(payload.cal_discount || "0");
745
+ const voucher = parseFloat(payload.cal_voucher || "0");
746
+
747
+ const ticketFee = cntTicket * 191; // Ticket Fee 191 THB per ticket
748
+ const totalAmount = amountCode + deliverFee + ticketFee + addCharge - discount - voucher;
749
+
750
+ const computedPayload: Record<string, string> = {
751
+ ...payload,
752
+ paytype: payType,
753
+ deliver: deliver,
754
+ cal_ticketfee: ticketFee.toString(),
755
+ cal_totalamount: Math.round(totalAmount).toString(),
756
+ };
757
+
758
+ const formData = new URLSearchParams();
759
+ for (const [key, value] of Object.entries(computedPayload)) {
760
+ if (key !== "check-protect") {
761
+ formData.append(key, value !== undefined && value !== null ? String(value) : "");
762
+ }
763
+ }
764
+
765
+ const response = await this.client.post(`${this.endpoint}/paycfmall.php?k=${k}`, formData.toString(), {
766
+ headers: {
767
+ ...this.defaultHeaders,
768
+ "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
769
+ "x-requested-with": "XMLHttpRequest",
770
+ referer: `${this.endpoint}/paymentall.php?k=${k}`,
771
+ },
772
+ });
773
+
774
+ const $ = cheerio.load(response.data);
775
+ const nextJsonData: Record<string, string> = {};
776
+
777
+ $("#payallfrm input[type='hidden']").each((_, el) => {
778
+ const name = $(el).attr("name");
779
+ const value = $(el).attr("value") ?? "";
780
+ if (name) {
781
+ nextJsonData[name] = value;
782
+ }
783
+ });
784
+
785
+ return nextJsonData;
786
+ }
787
+
788
+ /**
789
+ * Step 12: ดึง payload เข้ารหัสสำหรับ KBank
790
+ */
791
+ public async orderEncKBankQR(k: string, payCfmData: Record<string, string>): Promise<Record<string, string>> {
792
+ this.emitProgress("ORDER_ENC_KBANK", `Encrypting order payload for KBank`);
793
+
794
+ const formData = new URLSearchParams(payCfmData);
795
+
796
+ const response = await this.client.post(`${this.endpoint}/orderenc_kbankqr.php`, formData.toString(), {
797
+ headers: {
798
+ ...this.defaultHeaders,
799
+ "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
800
+ "x-requested-with": "XMLHttpRequest",
801
+ referer: `${this.endpoint}/paycfmall.php?k=${k}`,
802
+ },
803
+ });
804
+
805
+ const $ = cheerio.load(response.data);
806
+ const kbankqrData: Record<string, string> = {};
807
+
808
+ $("#kbankqr input[type='hidden']").each((_, el) => {
809
+ const name = $(el).attr("name");
810
+ const value = $(el).attr("value") ?? "";
811
+ if (name) {
812
+ kbankqrData[name] = value;
813
+ }
814
+ });
815
+
816
+ return kbankqrData;
817
+ }
818
+
819
+ /**
820
+ * Step 13: เรียก getkbankqr.php
821
+ */
822
+ public async getKBankQR(orderEncData: Record<string, string>): Promise<any> {
823
+ this.emitProgress("GET_KBANK_QR", `Calling KBank Gateway info`);
824
+
825
+ const formData = new URLSearchParams(orderEncData);
826
+
827
+ const response = await this.client.post(`${this.endpoint}/getkbankqr.php`, formData.toString(), {
828
+ headers: {
829
+ ...this.defaultHeaders,
830
+ "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
831
+ "x-requested-with": "XMLHttpRequest",
832
+ referer: `${this.endpoint}/orderenc_kbankqr.php`,
833
+ },
834
+ });
835
+
836
+ return response.data;
837
+ }
838
+
839
+ /**
840
+ * Step 14: ทำรายการ payment_kbankqr.php เพื่อดึง Order ID และ API Key
841
+ */
842
+ public async paymentKBankQR(
843
+ orderEncData: Record<string, string>,
844
+ kbankPayload: Record<string, any>
845
+ ): Promise<{ orderId: string; apiKey: string }> {
846
+ this.emitProgress("PAYMENT_KBANK_QR", `Extracting KBank OrderID and API Key`);
847
+
848
+ const preparePayload = {
849
+ ...orderEncData,
850
+ reasoncode: kbankPayload.reasoncode,
851
+ eventname: kbankPayload.eventname,
852
+ rounddetail: kbankPayload.rounddetail,
853
+ totalamount: kbankPayload.totalamount,
854
+ orderstatus: kbankPayload.orderstatus,
855
+ kbankqr_txExpiry: kbankPayload.kbankqr_txExpiry,
856
+ };
857
+
858
+ const formData = new URLSearchParams(preparePayload);
859
+
860
+ const response = await this.client.post(`${this.endpoint}/payment_kbankqr.php`, formData.toString(), {
861
+ headers: {
862
+ ...this.defaultHeaders,
863
+ "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
864
+ "x-requested-with": "XMLHttpRequest",
865
+ referer: `${this.endpoint}/orderenc_kbankqr.php`,
866
+ },
867
+ });
868
+
869
+ const $ = cheerio.load(response.data);
870
+ const script = $("#kbankpost script[data-order-id]");
871
+
872
+ const orderId = script.attr("data-order-id") ?? "";
873
+ const apiKey = script.attr("data-apikey") ?? "";
874
+
875
+ if (!orderId || !apiKey) {
876
+ throw new Error("Unable to extract KBank orderId or apiKey from response");
877
+ }
878
+
879
+ return { orderId, apiKey };
880
+ }
881
+
882
+ /**
883
+ * Step 15: ยิงตรง Kasikorn Bank Gateway เพื่อสร้าง ThaiQR Code
884
+ */
885
+ public async generateThaiQR(orderId: string, apiKey: string, amount: string | number): Promise<any> {
886
+ this.emitProgress("GENERATE_THAI_QR", `Requesting ThaiQR code from Kasikorn Bank`);
887
+
888
+ const payload = {
889
+ amount: amount,
890
+ currency: "THB",
891
+ metadata: [],
892
+ order_id: orderId,
893
+ sof: "ThaiQR",
894
+ };
895
+
896
+ const response = await this.client.post("https://kpaymentgateway-services.kasikornbank.com/qr/v2/qr", payload, {
897
+ headers: {
898
+ ...this.defaultHeaders,
899
+ referer: "https://kpaymentgateway.kasikornbank.com/",
900
+ "x-api-key": apiKey,
901
+ },
902
+ });
903
+
904
+ return response.data;
905
+ }
906
+
907
+ // ==========================================
908
+ // High-Level All-In-One Method
909
+ // ==========================================
910
+
911
+ /**
912
+ * ทำการจองแบบครบวงจร (End-to-End) ตั้งแต่เลือกรอบจนได้ QR Code
913
+ */
914
+ public async quickBook(options: QuickBookOptions): Promise<QuickBookResult> {
915
+ const {
916
+ eventUrl,
917
+ roundIndex = 0,
918
+ roundId,
919
+ zoneId,
920
+ ticketCount = 1,
921
+ seatSelection = "random",
922
+ attendees = [],
923
+ deliveryType = "1",
924
+ payType = "KBQR",
925
+ paymentDelayMs = 1500,
926
+ } = options;
927
+
928
+ if (options.onProgress) {
929
+ this.onProgressCallback = options.onProgress;
930
+ }
931
+
932
+ // 1. ดึงรอบการแสดง
933
+ const rounds = await this.getRounds(eventUrl);
934
+ if (rounds.length === 0) {
935
+ throw new Error("No rounds found for this event");
936
+ }
937
+
938
+ let selectedRound: RoundInfo | undefined;
939
+ if (roundId) {
940
+ selectedRound = rounds.find((r) => r.id === roundId || r.query === roundId);
941
+ } else if (options.roundIndex !== undefined) {
942
+ selectedRound = rounds[options.roundIndex];
943
+ } else {
944
+ // Default: pick the first available active round
945
+ selectedRound = rounds.find((r) => !r.isDisabled && !!r.query) || rounds[0];
946
+ }
947
+
948
+ if (!selectedRound) {
949
+ throw new Error(`Target round not found (Index: ${roundIndex}, ID: ${roundId})`);
950
+ }
951
+
952
+ if (selectedRound.isDisabled || !selectedRound.query) {
953
+ throw new Error(`Selected round "${selectedRound.label || selectedRound.id}" is currently disabled or not open for sale yet.`);
954
+ }
955
+
956
+ // 2. ตรวจสอบเงื่อนไข
957
+ const verifyRes = await this.verifyCheckCondition(selectedRound.query);
958
+ if (!verifyRes.result) {
959
+ throw new Error(`Condition check failed: ${verifyRes.message || "Unknown error"}`);
960
+ }
961
+
962
+ // 3. ดึง Tokens และ Zone Map
963
+ const zoneData = await this.getZones(selectedRound.query);
964
+
965
+ // หารอบการแสดงใน zone_json
966
+ let targetPerformRound = zoneData.rounds[0];
967
+ if (roundId) {
968
+ const match = zoneData.rounds.find((r) => r.id === roundId);
969
+ if (match) targetPerformRound = match;
970
+ } else {
971
+ const dateNum = selectedRound.date.match(/\d+/)?.[0];
972
+ if (dateNum) {
973
+ const match = zoneData.rounds.find((r) => r.label.includes(dateNum));
974
+ if (match) targetPerformRound = match;
975
+ }
976
+ }
977
+
978
+ if (!targetPerformRound) {
979
+ throw new Error("Target performance round ID not found in zone list");
980
+ }
981
+
982
+ // 4. เช็คความพร้อมของโซน
983
+ const zoneAvails = await this.getZoneAvailability(
984
+ targetPerformRound.id,
985
+ zoneData.tk,
986
+ zoneData.k,
987
+ selectedRound.query
988
+ );
989
+
990
+ const targetZone = zoneAvails.find((z) => z.id.toLowerCase() === zoneId.toLowerCase() || z.value.toLowerCase() === zoneId.toLowerCase());
991
+ if (!targetZone) {
992
+ throw new Error(`Zone "${zoneId}" not found in available zones`);
993
+ }
994
+
995
+ let summarySeats: string[] = [];
996
+ let paymentAllData: Record<string, string>;
997
+
998
+ // 5. ดำเนินการตามประเภทโซน (Fixed vs Festival)
999
+ if (targetZone.page === "fixed.php") {
1000
+ const fixedData = await this.getFixedSeats(zoneData.k, targetZone.value, targetPerformRound.id);
1001
+ const availableSeats = fixedData.seats;
1002
+
1003
+ if (availableSeats.length < ticketCount) {
1004
+ throw new Error(`Not enough seats available in zone ${zoneId} (Available: ${availableSeats.length}, Required: ${ticketCount})`);
1005
+ }
1006
+
1007
+ let selectedSeats: SeatItem[] = [];
1008
+
1009
+ if (Array.isArray(seatSelection)) {
1010
+ if (typeof seatSelection[0] === "string") {
1011
+ selectedSeats = availableSeats.filter((s) => (seatSelection as string[]).includes(s.seat));
1012
+ } else {
1013
+ selectedSeats = seatSelection as SeatItem[];
1014
+ }
1015
+ } else if (seatSelection === "first") {
1016
+ selectedSeats = availableSeats.slice(0, ticketCount);
1017
+ } else {
1018
+ // Random
1019
+ const shuffled = [...availableSeats].sort(() => 0.5 - Math.random());
1020
+ selectedSeats = shuffled.slice(0, ticketCount);
1021
+ }
1022
+
1023
+ if (selectedSeats.length < ticketCount) {
1024
+ throw new Error(`Could not fulfill requested seats selection.`);
1025
+ }
1026
+
1027
+ summarySeats = selectedSeats.map((s) => s.seat);
1028
+
1029
+ // Validate ที่นั่ง
1030
+ await this.validateFixedSeats(zoneData.k, targetZone.value, fixedData.form, selectedSeats);
1031
+
1032
+ // Booking
1033
+ const bookingRes = await this.bookFixedSeats(zoneData.k, fixedData.form, selectedSeats);
1034
+
1035
+ const enrollVal = bookingRes.nextPayload.get("enroll_val");
1036
+ if (enrollVal && enrollVal !== "0" && attendees.length > 0) {
1037
+ const enrollForm = await this.enroll(zoneData.k, targetZone.value, targetPerformRound.id, bookingRes.nextPayload);
1038
+ const enrollProcessRes = await this.enrollProcess(zoneData.k, enrollForm, attendees);
1039
+ paymentAllData = await this.getPaymentDetails(zoneData.k, enrollProcessRes.nextPayload, targetZone.value, targetPerformRound.id, paymentDelayMs);
1040
+ } else {
1041
+ paymentAllData = await this.getPaymentDetails(zoneData.k, bookingRes.nextPayload, targetZone.value, targetPerformRound.id, paymentDelayMs);
1042
+ }
1043
+ } else {
1044
+ // Festival Flow
1045
+ const festivalForm = await this.getFestival(zoneData.k, zoneData.tk, selectedRound.query, targetZone.value, targetPerformRound.id);
1046
+
1047
+ await this.validateFestivalSeats(zoneData.k, festivalForm, ticketCount);
1048
+ const bookingFestivalRes = await this.bookFestivalSeats(zoneData.k, festivalForm, ticketCount);
1049
+
1050
+ if (festivalForm.enroll_val && festivalForm.enroll_val !== "0" && attendees.length > 0) {
1051
+ const enrollProcessRes = await this.enrollProcess(zoneData.k, festivalForm, attendees);
1052
+ paymentAllData = await this.getPaymentDetails(zoneData.k, enrollProcessRes.nextPayload, festivalForm.zone || targetZone.value, festivalForm.rdId || targetPerformRound.id, paymentDelayMs);
1053
+ } else {
1054
+ paymentAllData = await this.getPaymentDetails(zoneData.k, bookingFestivalRes, festivalForm.zone || targetZone.value, festivalForm.rdId || targetPerformRound.id, paymentDelayMs);
1055
+ }
1056
+ }
1057
+
1058
+ // 6. Confirm Payment
1059
+ const payCfmData = await this.confirmPayment(zoneData.k, paymentAllData, deliveryType, payType);
1060
+
1061
+ // 7. KBank Flow & Generate QR
1062
+ const orderEncData = await this.orderEncKBankQR(zoneData.k, payCfmData);
1063
+ const kbankInfo = await this.getKBankQR(orderEncData);
1064
+ const kbankAuth = await this.paymentKBankQR(orderEncData, kbankInfo);
1065
+
1066
+ const qrResult = await this.generateThaiQR(kbankAuth.orderId, kbankAuth.apiKey, orderEncData.amount);
1067
+
1068
+ const qrRawCode =
1069
+ qrResult?.paint_text ||
1070
+ qrResult?.raw_qr_code ||
1071
+ qrResult?.qr_code ||
1072
+ qrResult?.data?.paint_text ||
1073
+ qrResult?.data?.qr_code ||
1074
+ qrResult?.data?.raw_qr_code ||
1075
+ (typeof qrResult === "string" ? qrResult : "");
1076
+
1077
+ const displayQR = (customTitle?: string) => {
1078
+ const title = customTitle || `ThaiQR Payment (KBank) - ยอดชำระ: ${orderEncData.amount || paymentAllData["cal_totalamount"]} บาท`;
1079
+ ThaiTicketMajor.displayQRCode(qrRawCode, title);
1080
+ };
1081
+
1082
+ if (options.printQR !== false && qrRawCode) {
1083
+ displayQR();
1084
+ }
1085
+
1086
+ return {
1087
+ success: true,
1088
+ orderId: kbankAuth.orderId,
1089
+ amount: orderEncData.amount || paymentAllData["cal_totalamount"],
1090
+ qrRawCode,
1091
+ qrData: qrResult,
1092
+ displayQR,
1093
+ summary: {
1094
+ performance: paymentAllData["performance"] || selectedRound.label,
1095
+ showDate: paymentAllData["showdate"] || selectedRound.date,
1096
+ showTime: paymentAllData["showtime"] || selectedRound.time,
1097
+ venue: paymentAllData["venue"],
1098
+ zone: targetZone.value,
1099
+ ticketCount,
1100
+ totalAmount: orderEncData.amount || paymentAllData["cal_totalamount"],
1101
+ seats: summarySeats.length > 0 ? summarySeats : undefined,
1102
+ },
1103
+ rawPayload: {
1104
+ orderEncData,
1105
+ kbankAuth,
1106
+ },
1107
+ };
1108
+ }
1109
+
1110
+ /**
1111
+ * แสดงผล QR Code บนหน้าจอ Terminal / Console เพื่อให้สแกนได้ทันที
1112
+ */
1113
+ public static displayQRCode(qrString: string, title?: string): void {
1114
+ if (!qrString) {
1115
+ console.warn("No QR code string provided to display.");
1116
+ return;
1117
+ }
1118
+
1119
+ console.log("\n" + "=".repeat(46));
1120
+ if (title) {
1121
+ console.log(`📱 ${title}`);
1122
+ } else {
1123
+ console.log("📱 กรุณาสแกน QR Code ด้านล่างเพื่อชำระเงิน:");
1124
+ }
1125
+ console.log("=".repeat(46));
1126
+
1127
+ qrcodeTerminal.generate(qrString, { small: true });
1128
+
1129
+ console.log("=".repeat(46) + "\n");
1130
+ }
1131
+
1132
+ public displayQRCode(qrString: string, title?: string): void {
1133
+ ThaiTicketMajor.displayQRCode(qrString, title);
1134
+ }
1135
+ }
1136
+
1137
+ export const displayQRCode = ThaiTicketMajor.displayQRCode;
1138
+ export default ThaiTicketMajor;