@curless/hotel-storefront 0.55.2 → 0.55.4

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.
@@ -1,4 +1,6 @@
1
- const ALL = ['clubmed', 'atour', 'ji', 'ascott', 'shangrila', 'curlessatour'];
1
+ /** The groups `/site` lists on 11-04, in its order. */
2
+ export const SITES = ['clubmed', 'atour', 'shangrila', 'ji', 'ascott'];
3
+ const ALL = SITES;
2
4
  /** The dev backend's search answer, 2026-11-03 (总线程 and 酒店对接 both read it). */
3
5
  export const RECORDED_DEGRADED = {
4
6
  code: 200,
@@ -158,11 +160,734 @@ export const FAKE_ROOMS = {
158
160
  };
159
161
  const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
160
162
  const nightsBetween = (a, b) => Math.max(1, Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 86_400_000));
163
+ /** The dev backend's `POST /hotels` answers of 2026-11-04 (酒店对接, read-only), word for word. */
164
+ /**
165
+ * …and when the place matches none of a group's hotels, the backend answers with
166
+ * every hotel the group has (11-04): `atourAll` is Atour asked for Singapore,
167
+ * `ascottAll` Ascott asked for 上海 - word for word.
168
+ */
169
+ export const RECORDED_1104_ALL = {
170
+ atourAll: {
171
+ code: 200,
172
+ data: {
173
+ totalCount: 10,
174
+ queriedProviders: ['atour'],
175
+ successProviders: ['atour'],
176
+ degradedProviders: [],
177
+ hotels: [
178
+ {
179
+ unifiedId: 'atour:sanya-haitang-bay',
180
+ providerCode: 'atour',
181
+ brand: 'ATOUR',
182
+ originalId: 'sanya-haitang-bay',
183
+ name: 'A.T. House Sanya Haitang Bay',
184
+ city: 'Sanya',
185
+ country: 'China',
186
+ address: '',
187
+ latitude: null,
188
+ longitude: null,
189
+ distanceKm: null,
190
+ score: 4.9,
191
+ minPrice: {
192
+ originalCurrency: 'USD',
193
+ originalAmount: '226.0',
194
+ settlementCurrency: 'USD',
195
+ settlementAmount: '226.0',
196
+ feeNotice: null,
197
+ },
198
+ amenities: [
199
+ 'Private beach',
200
+ 'Open-air spa',
201
+ 'Infinity pool',
202
+ 'Seafood restaurant',
203
+ 'Kids club',
204
+ ],
205
+ rooms: [],
206
+ extensions: {
207
+ category: 'hainan',
208
+ },
209
+ },
210
+ {
211
+ unifiedId: 'atour:hangzhou-westlake',
212
+ providerCode: 'atour',
213
+ brand: 'ATOUR',
214
+ originalId: 'hangzhou-westlake',
215
+ name: 'Atour Hotel Hangzhou West Lake',
216
+ city: 'Hangzhou',
217
+ country: 'China',
218
+ address: '',
219
+ latitude: null,
220
+ longitude: null,
221
+ distanceKm: null,
222
+ score: 4.8,
223
+ minPrice: {
224
+ originalCurrency: 'USD',
225
+ originalAmount: '121.0',
226
+ settlementCurrency: 'USD',
227
+ settlementAmount: '121.0',
228
+ feeNotice: null,
229
+ },
230
+ amenities: ['Tea service', 'Lake walk', 'Bicycles', 'Garden view', 'Breakfast buffet'],
231
+ rooms: [],
232
+ extensions: {
233
+ category: 'huadong',
234
+ },
235
+ },
236
+ {
237
+ unifiedId: 'atour:shanghai-bund',
238
+ providerCode: 'atour',
239
+ brand: 'ATOUR',
240
+ originalId: 'shanghai-bund',
241
+ name: 'Atour Hotel Shanghai Bund',
242
+ city: 'Shanghai',
243
+ country: 'China',
244
+ address: '',
245
+ latitude: null,
246
+ longitude: null,
247
+ distanceKm: null,
248
+ score: 4.8,
249
+ minPrice: {
250
+ originalCurrency: 'USD',
251
+ originalAmount: '132.0',
252
+ settlementCurrency: 'USD',
253
+ settlementAmount: '132.0',
254
+ feeNotice: null,
255
+ },
256
+ amenities: [
257
+ 'Library lounge',
258
+ 'River view',
259
+ 'Deep-sleep bedding',
260
+ 'All-day café',
261
+ 'Fitness studio',
262
+ ],
263
+ rooms: [],
264
+ extensions: {
265
+ category: 'huadong',
266
+ },
267
+ },
268
+ {
269
+ unifiedId: 'atour:xian-bell-tower',
270
+ providerCode: 'atour',
271
+ brand: 'ATOUR',
272
+ originalId: 'xian-bell-tower',
273
+ name: 'Atour Hotel Xi’an Bell Tower',
274
+ city: "Xi'an",
275
+ country: 'China',
276
+ address: '',
277
+ latitude: null,
278
+ longitude: null,
279
+ distanceKm: null,
280
+ score: 4.7,
281
+ minPrice: {
282
+ originalCurrency: 'USD',
283
+ originalAmount: '96.0',
284
+ settlementCurrency: 'USD',
285
+ settlementAmount: '96.0',
286
+ feeNotice: null,
287
+ },
288
+ amenities: [
289
+ 'Rooftop view',
290
+ 'Late-night noodles',
291
+ 'Heritage lobby',
292
+ 'City wall walk',
293
+ 'Breakfast',
294
+ ],
295
+ rooms: [],
296
+ extensions: {
297
+ category: 'huabei',
298
+ },
299
+ },
300
+ {
301
+ unifiedId: 'atour:suzhou-jinji-lake',
302
+ providerCode: 'atour',
303
+ brand: 'ATOUR',
304
+ originalId: 'suzhou-jinji-lake',
305
+ name: 'Atour Hotel Suzhou Jinji Lake',
306
+ city: 'Suzhou',
307
+ country: 'China',
308
+ address: '',
309
+ latitude: null,
310
+ longitude: null,
311
+ distanceKm: null,
312
+ score: 4.7,
313
+ minPrice: {
314
+ originalCurrency: 'USD',
315
+ originalAmount: '104.0',
316
+ settlementCurrency: 'USD',
317
+ settlementAmount: '104.0',
318
+ feeNotice: null,
319
+ },
320
+ amenities: ['Lake view', 'Reading room', 'Running route', 'Garden terrace', 'Breakfast'],
321
+ rooms: [],
322
+ extensions: {
323
+ category: 'huadong',
324
+ },
325
+ },
326
+ {
327
+ unifiedId: 'atour:chengdu-taikooli',
328
+ providerCode: 'atour',
329
+ brand: 'ATOUR',
330
+ originalId: 'chengdu-taikooli',
331
+ name: 'Atour Hotel Chengdu Taikoo Li',
332
+ city: 'Chengdu',
333
+ country: 'China',
334
+ address: '',
335
+ latitude: null,
336
+ longitude: null,
337
+ distanceKm: null,
338
+ score: 4.7,
339
+ minPrice: {
340
+ originalCurrency: 'USD',
341
+ originalAmount: '108.0',
342
+ settlementCurrency: 'USD',
343
+ settlementAmount: '108.0',
344
+ feeNotice: null,
345
+ },
346
+ amenities: [
347
+ 'Courtyard teahouse',
348
+ 'Local breakfast',
349
+ 'Soundproof rooms',
350
+ 'Laundry',
351
+ 'Gym',
352
+ ],
353
+ rooms: [],
354
+ extensions: {
355
+ category: 'xinan',
356
+ },
357
+ },
358
+ {
359
+ unifiedId: 'atour:beijing-sanlitun',
360
+ providerCode: 'atour',
361
+ brand: 'ATOUR',
362
+ originalId: 'beijing-sanlitun',
363
+ name: 'Atour S Hotel Beijing Sanlitun',
364
+ city: 'Beijing',
365
+ country: 'China',
366
+ address: '',
367
+ latitude: null,
368
+ longitude: null,
369
+ distanceKm: null,
370
+ score: 4.7,
371
+ minPrice: {
372
+ originalCurrency: 'USD',
373
+ originalAmount: '148.0',
374
+ settlementCurrency: 'USD',
375
+ settlementAmount: '148.0',
376
+ feeNotice: null,
377
+ },
378
+ amenities: ['Vinyl bar', 'Art corridor', 'Late checkout', 'Smart room controls', 'Gym'],
379
+ rooms: [],
380
+ extensions: {
381
+ category: 'huabei',
382
+ },
383
+ },
384
+ {
385
+ unifiedId: 'atour:guangzhou-zhujiang',
386
+ providerCode: 'atour',
387
+ brand: 'ATOUR',
388
+ originalId: 'guangzhou-zhujiang',
389
+ name: 'Atour Hotel Guangzhou Zhujiang New Town',
390
+ city: 'Guangzhou',
391
+ country: 'China',
392
+ address: '',
393
+ latitude: null,
394
+ longitude: null,
395
+ distanceKm: null,
396
+ score: 4.6,
397
+ minPrice: {
398
+ originalCurrency: 'USD',
399
+ originalAmount: '112.0',
400
+ settlementCurrency: 'USD',
401
+ settlementAmount: '112.0',
402
+ feeNotice: null,
403
+ },
404
+ amenities: ['Sky lounge', 'Dim sum breakfast', 'River view', 'Metro access', 'Fitness'],
405
+ rooms: [],
406
+ extensions: {
407
+ category: 'huanan',
408
+ },
409
+ },
410
+ {
411
+ unifiedId: 'atour:shenzhen-nanshan',
412
+ providerCode: 'atour',
413
+ brand: 'ATOUR',
414
+ originalId: 'shenzhen-nanshan',
415
+ name: 'Atour X Hotel Shenzhen Nanshan',
416
+ city: 'Shenzhen',
417
+ country: 'China',
418
+ address: '',
419
+ latitude: null,
420
+ longitude: null,
421
+ distanceKm: null,
422
+ score: 4.6,
423
+ minPrice: {
424
+ originalCurrency: 'USD',
425
+ originalAmount: '116.0',
426
+ settlementCurrency: 'USD',
427
+ settlementAmount: '116.0',
428
+ feeNotice: null,
429
+ },
430
+ amenities: [
431
+ 'Meeting pods',
432
+ 'Workspace desk',
433
+ '24h pantry',
434
+ 'Express laundry',
435
+ 'Airport shuttle',
436
+ ],
437
+ rooms: [],
438
+ extensions: {
439
+ category: 'huanan',
440
+ },
441
+ },
442
+ {
443
+ unifiedId: 'atour:nanjing-xinjiekou',
444
+ providerCode: 'atour',
445
+ brand: 'ATOUR',
446
+ originalId: 'nanjing-xinjiekou',
447
+ name: 'Atour Light Hotel Nanjing Xinjiekou',
448
+ city: 'Nanjing',
449
+ country: 'China',
450
+ address: '',
451
+ latitude: null,
452
+ longitude: null,
453
+ distanceKm: null,
454
+ score: 4.5,
455
+ minPrice: {
456
+ originalCurrency: 'USD',
457
+ originalAmount: '78.0',
458
+ settlementCurrency: 'USD',
459
+ settlementAmount: '78.0',
460
+ feeNotice: null,
461
+ },
462
+ amenities: [
463
+ 'Self-serve breakfast',
464
+ 'Metro 2 min',
465
+ 'Compact smart rooms',
466
+ 'Luggage room',
467
+ '24h front desk',
468
+ ],
469
+ rooms: [],
470
+ extensions: {
471
+ category: 'huadong',
472
+ },
473
+ },
474
+ ],
475
+ executionTimes: {
476
+ atour: 287,
477
+ },
478
+ },
479
+ msg: 'Operation successful',
480
+ },
481
+ ascottAll: {
482
+ code: 200,
483
+ data: {
484
+ totalCount: 6,
485
+ queriedProviders: ['ascott'],
486
+ successProviders: ['ascott'],
487
+ degradedProviders: [],
488
+ hotels: [
489
+ {
490
+ unifiedId: 'ascott:lyf-funan-singapore',
491
+ providerCode: 'ascott',
492
+ brand: 'lyf',
493
+ originalId: 'lyf-funan-singapore',
494
+ name: 'lyf Funan Singapore',
495
+ city: 'Singapore',
496
+ country: 'Singapore',
497
+ address: '',
498
+ latitude: null,
499
+ longitude: null,
500
+ distanceKm: null,
501
+ score: 4.8,
502
+ minPrice: {
503
+ originalCurrency: 'SGD',
504
+ originalAmount: '135.89',
505
+ settlementCurrency: 'USD',
506
+ settlementAmount: '101.92',
507
+ feeNotice: null,
508
+ },
509
+ amenities: [
510
+ 'Social kitchen',
511
+ 'Co-working lounge',
512
+ 'Gym',
513
+ 'Self-service laundry',
514
+ 'Above Funan mall',
515
+ ],
516
+ rooms: [],
517
+ extensions: {
518
+ price_per_night_local: '183.45',
519
+ category: 'singapore',
520
+ price_note: 'from SGD 153++ per apartment per night → SGD 183.45 incl. service charge and GST 9% (≈ $135.89) · Advance Purchase',
521
+ },
522
+ },
523
+ {
524
+ unifiedId: 'ascott:citadines-raffles-place-singapore',
525
+ providerCode: 'ascott',
526
+ brand: 'Citadines',
527
+ originalId: 'citadines-raffles-place-singapore',
528
+ name: 'Citadines Raffles Place Singapore',
529
+ city: 'Singapore',
530
+ country: 'Singapore',
531
+ address: '',
532
+ latitude: null,
533
+ longitude: null,
534
+ distanceKm: null,
535
+ score: 4.8,
536
+ minPrice: {
537
+ originalCurrency: 'SGD',
538
+ originalAmount: '207.83',
539
+ settlementCurrency: 'USD',
540
+ settlementAmount: '155.87',
541
+ feeNotice: null,
542
+ },
543
+ amenities: [
544
+ '24-hour gym',
545
+ 'Outdoor pool and jacuzzi',
546
+ 'Fitness park',
547
+ 'Garden BBQ',
548
+ 'Launderette',
549
+ 'Residents’ kitchen',
550
+ 'Shared working space',
551
+ ],
552
+ rooms: [],
553
+ extensions: {
554
+ price_per_night_local: '280.57',
555
+ category: 'singapore',
556
+ price_note: 'from SGD 234++ per apartment per night → SGD 280.57 incl. service charge and GST 9% (≈ $207.83) · Advance Purchase',
557
+ },
558
+ },
559
+ {
560
+ unifiedId: 'ascott:ascott-orchard-singapore',
561
+ providerCode: 'ascott',
562
+ brand: 'Ascott The Residence',
563
+ originalId: 'ascott-orchard-singapore',
564
+ name: 'Ascott Orchard Singapore',
565
+ city: 'Singapore',
566
+ country: 'Singapore',
567
+ address: '',
568
+ latitude: null,
569
+ longitude: null,
570
+ distanceKm: null,
571
+ score: 4.8,
572
+ minPrice: {
573
+ originalCurrency: 'SGD',
574
+ originalAmount: '303.75',
575
+ settlementCurrency: 'USD',
576
+ settlementAmount: '227.81',
577
+ feeNotice: null,
578
+ },
579
+ amenities: [
580
+ 'Fully equipped kitchen in every apartment',
581
+ 'Washer in one- and two-bedroom apartments',
582
+ 'Dual-key layouts for families',
583
+ 'Fendi Casa Bespoke Residences',
584
+ 'Laundry and dry cleaning service',
585
+ 'Walk to Orchard Road',
586
+ ],
587
+ rooms: [],
588
+ extensions: {
589
+ price_per_night_local: '410.06',
590
+ category: 'singapore',
591
+ price_note: 'from SGD 342++ per apartment per night → SGD 410.06 incl. service charge and GST 9% (≈ $303.75) · Advance Purchase',
592
+ },
593
+ },
594
+ {
595
+ unifiedId: 'ascott:ascott-marunouchi-tokyo',
596
+ providerCode: 'ascott',
597
+ brand: 'Ascott The Residence',
598
+ originalId: 'ascott-marunouchi-tokyo',
599
+ name: 'Ascott Marunouchi Tokyo',
600
+ city: 'Tokyo',
601
+ country: 'Japan',
602
+ address: '',
603
+ latitude: null,
604
+ longitude: null,
605
+ distanceKm: null,
606
+ score: 4.8,
607
+ minPrice: {
608
+ originalCurrency: 'JPY',
609
+ originalAmount: '307.59',
610
+ settlementCurrency: 'USD',
611
+ settlementAmount: '1.98',
612
+ feeNotice: null,
613
+ },
614
+ amenities: [
615
+ 'From the 23rd floor up',
616
+ 'Beside Tokyo Station',
617
+ 'Fully equipped kitchens',
618
+ 'Washer-dryer',
619
+ 'Residents’ lounge',
620
+ 'Fitness centre',
621
+ ],
622
+ rooms: [],
623
+ extensions: {
624
+ price_per_night_local: '46138',
625
+ category: 'japan',
626
+ price_note: 'from JPY 37,800++ per apartment per night → JPY 46,138 incl. service charge and Consumption Tax 10% (≈ $307.59) · Advance Purchase',
627
+ },
628
+ },
629
+ {
630
+ unifiedId: 'ascott:ascott-raffles-city-beijing',
631
+ providerCode: 'ascott',
632
+ brand: 'Ascott The Residence',
633
+ originalId: 'ascott-raffles-city-beijing',
634
+ name: 'Ascott Raffles City Beijing',
635
+ city: 'Beijing',
636
+ country: 'China',
637
+ address: '',
638
+ latitude: null,
639
+ longitude: null,
640
+ distanceKm: null,
641
+ score: 4.8,
642
+ minPrice: {
643
+ originalCurrency: 'CNY',
644
+ originalAmount: '189.48',
645
+ settlementCurrency: 'USD',
646
+ settlementAmount: '26.32',
647
+ feeNotice: null,
648
+ },
649
+ amenities: [
650
+ 'Above Raffles City mall',
651
+ 'Fully equipped kitchens',
652
+ 'Home-style amenities',
653
+ 'Fitness centre',
654
+ 'Residents’ lounge',
655
+ ],
656
+ rooms: [],
657
+ extensions: {
658
+ price_per_night_local: '1364.22',
659
+ category: 'china',
660
+ price_note: 'from CNY 1,170++ per apartment per night → CNY 1,364.22 incl. service charge and VAT 6% (≈ $189.48) · Advance Purchase',
661
+ },
662
+ },
663
+ {
664
+ unifiedId: 'ascott:citadines-les-halles-paris',
665
+ providerCode: 'ascott',
666
+ brand: 'Citadines',
667
+ originalId: 'citadines-les-halles-paris',
668
+ name: 'Citadines Les Halles Paris',
669
+ city: 'Paris',
670
+ country: 'France',
671
+ address: '',
672
+ latitude: null,
673
+ longitude: null,
674
+ distanceKm: null,
675
+ score: 4.8,
676
+ minPrice: {
677
+ originalCurrency: 'EUR',
678
+ originalAmount: '213.91',
679
+ settlementCurrency: 'USD',
680
+ settlementAmount: '231.02',
681
+ feeNotice: null,
682
+ },
683
+ amenities: [
684
+ 'Kitchenette with dishwasher',
685
+ 'Walk to the Louvre',
686
+ 'Self-service laundry',
687
+ 'Fitness corner',
688
+ ],
689
+ rooms: [],
690
+ extensions: {
691
+ price_per_night_local: '196.8',
692
+ category: 'europe',
693
+ price_note: 'from EUR 189 per apartment per night → EUR 196.80 incl. VAT 10% and city tax for 2 (≈ $213.91) · Advance Purchase',
694
+ },
695
+ },
696
+ ],
697
+ executionTimes: {
698
+ ascott: 225,
699
+ },
700
+ },
701
+ msg: 'Operation successful',
702
+ },
703
+ };
704
+ export const RECORDED_1104 = {
705
+ atourShanghai: {
706
+ code: 200,
707
+ data: {
708
+ totalCount: 1,
709
+ queriedProviders: ['atour'],
710
+ successProviders: ['atour'],
711
+ degradedProviders: [],
712
+ hotels: [
713
+ {
714
+ unifiedId: 'atour:shanghai-bund',
715
+ providerCode: 'atour',
716
+ brand: 'ATOUR',
717
+ originalId: 'shanghai-bund',
718
+ name: 'Atour Hotel Shanghai Bund',
719
+ city: 'Shanghai',
720
+ country: 'China',
721
+ address: '',
722
+ latitude: null,
723
+ longitude: null,
724
+ distanceKm: null,
725
+ score: 4.8,
726
+ minPrice: {
727
+ originalCurrency: 'USD',
728
+ originalAmount: '132.0',
729
+ settlementCurrency: 'USD',
730
+ settlementAmount: '132.0',
731
+ feeNotice: null,
732
+ },
733
+ amenities: [
734
+ 'Library lounge',
735
+ 'River view',
736
+ 'Deep-sleep bedding',
737
+ 'All-day café',
738
+ 'Fitness studio',
739
+ ],
740
+ rooms: [],
741
+ extensions: {
742
+ category: 'huadong',
743
+ },
744
+ },
745
+ ],
746
+ executionTimes: {
747
+ atour: 218,
748
+ },
749
+ },
750
+ msg: 'Operation successful',
751
+ },
752
+ ascottSingapore: {
753
+ code: 200,
754
+ data: {
755
+ totalCount: 3,
756
+ queriedProviders: ['ascott'],
757
+ successProviders: ['ascott'],
758
+ degradedProviders: [],
759
+ hotels: [
760
+ {
761
+ unifiedId: 'ascott:lyf-funan-singapore',
762
+ providerCode: 'ascott',
763
+ brand: 'lyf',
764
+ originalId: 'lyf-funan-singapore',
765
+ name: 'lyf Funan Singapore',
766
+ city: 'Singapore',
767
+ country: 'Singapore',
768
+ address: '',
769
+ latitude: null,
770
+ longitude: null,
771
+ distanceKm: null,
772
+ score: 4.8,
773
+ minPrice: {
774
+ originalCurrency: 'SGD',
775
+ originalAmount: '135.89',
776
+ settlementCurrency: 'USD',
777
+ settlementAmount: '101.92',
778
+ feeNotice: null,
779
+ },
780
+ amenities: [
781
+ 'Social kitchen',
782
+ 'Co-working lounge',
783
+ 'Gym',
784
+ 'Self-service laundry',
785
+ 'Above Funan mall',
786
+ ],
787
+ rooms: [],
788
+ extensions: {
789
+ price_per_night_local: '183.45',
790
+ category: 'singapore',
791
+ price_note: 'from SGD 153++ per apartment per night → SGD 183.45 incl. service charge and GST 9% (≈ $135.89) · Advance Purchase',
792
+ },
793
+ },
794
+ {
795
+ unifiedId: 'ascott:citadines-raffles-place-singapore',
796
+ providerCode: 'ascott',
797
+ brand: 'Citadines',
798
+ originalId: 'citadines-raffles-place-singapore',
799
+ name: 'Citadines Raffles Place Singapore',
800
+ city: 'Singapore',
801
+ country: 'Singapore',
802
+ address: '',
803
+ latitude: null,
804
+ longitude: null,
805
+ distanceKm: null,
806
+ score: 4.8,
807
+ minPrice: {
808
+ originalCurrency: 'SGD',
809
+ originalAmount: '207.83',
810
+ settlementCurrency: 'USD',
811
+ settlementAmount: '155.87',
812
+ feeNotice: null,
813
+ },
814
+ amenities: [
815
+ '24-hour gym',
816
+ 'Outdoor pool and jacuzzi',
817
+ 'Fitness park',
818
+ 'Garden BBQ',
819
+ 'Launderette',
820
+ 'Residents’ kitchen',
821
+ 'Shared working space',
822
+ ],
823
+ rooms: [],
824
+ extensions: {
825
+ price_per_night_local: '280.57',
826
+ category: 'singapore',
827
+ price_note: 'from SGD 234++ per apartment per night → SGD 280.57 incl. service charge and GST 9% (≈ $207.83) · Advance Purchase',
828
+ },
829
+ },
830
+ {
831
+ unifiedId: 'ascott:ascott-orchard-singapore',
832
+ providerCode: 'ascott',
833
+ brand: 'Ascott The Residence',
834
+ originalId: 'ascott-orchard-singapore',
835
+ name: 'Ascott Orchard Singapore',
836
+ city: 'Singapore',
837
+ country: 'Singapore',
838
+ address: '',
839
+ latitude: null,
840
+ longitude: null,
841
+ distanceKm: null,
842
+ score: 4.8,
843
+ minPrice: {
844
+ originalCurrency: 'SGD',
845
+ originalAmount: '303.75',
846
+ settlementCurrency: 'USD',
847
+ settlementAmount: '227.81',
848
+ feeNotice: null,
849
+ },
850
+ amenities: [
851
+ 'Fully equipped kitchen in every apartment',
852
+ 'Washer in one- and two-bedroom apartments',
853
+ 'Dual-key layouts for families',
854
+ 'Fendi Casa Bespoke Residences',
855
+ 'Laundry and dry cleaning service',
856
+ 'Walk to Orchard Road',
857
+ ],
858
+ rooms: [],
859
+ extensions: {
860
+ price_per_night_local: '410.06',
861
+ category: 'singapore',
862
+ price_note: 'from SGD 342++ per apartment per night → SGD 410.06 incl. service charge and GST 9% (≈ $303.75) · Advance Purchase',
863
+ },
864
+ },
865
+ ],
866
+ executionTimes: {
867
+ ascott: 143,
868
+ },
869
+ },
870
+ msg: 'Operation successful',
871
+ },
872
+ };
161
873
  export const createOtalessFake = (opts = {}) => {
162
874
  let scene = opts.scene ?? 'data';
163
875
  let down = [];
876
+ let failing = [];
164
877
  let repeat = false;
165
878
  const calls = [];
879
+ const answer = (code, hotels, degraded = false) => json({
880
+ code: 200,
881
+ msg: '操作成功',
882
+ data: {
883
+ totalCount: hotels.length,
884
+ queriedProviders: [code],
885
+ successProviders: degraded ? [] : [code],
886
+ degradedProviders: degraded ? [code] : [],
887
+ executionTimes: { [code]: degraded ? 6 : 420 },
888
+ hotels,
889
+ },
890
+ });
166
891
  const fetchFake = (async (input, init) => {
167
892
  const url = new URL(String(input instanceof Request ? input.url : input));
168
893
  const method = init?.method ?? 'GET';
@@ -175,38 +900,59 @@ export const createOtalessFake = (opts = {}) => {
175
900
  repeat = false;
176
901
  return json({ code: 10014, msg: 'Repeat submit is not allowed' });
177
902
  }
178
- if (url.pathname === '/api/v1/ota/search' && method === 'POST') {
179
- if (scene === 'degraded')
180
- return json(RECORDED_DEGRADED);
181
- const asked = body?.channelCodes?.length
182
- ? body.channelCodes
183
- : ALL;
184
- const failing = scene === 'empty' ? [] : asked.filter((c) => down.includes(c));
185
- const where = String(body?.destination ?? '').toLowerCase();
186
- const hotels = scene === 'empty'
187
- ? []
188
- : FAKE_HOTELS.filter((h) => asked.includes(h.providerCode ?? '') &&
189
- !failing.includes(h.providerCode ?? '') &&
190
- (!where ||
191
- String(h.city).toLowerCase().includes(where) ||
192
- (where === 'shanghai' && h.city === '上海') ||
193
- String(h.name).toLowerCase().includes(where)));
903
+ if (url.pathname === '/api/v1/ota/site' && method === 'GET') {
904
+ // A page, not an R<T> (the document, §0) - and none of its `auth` here.
194
905
  return json({
906
+ total: SITES.length,
195
907
  code: 200,
196
- msg: '操作成功',
197
- data: {
198
- totalCount: hotels.length,
199
- queriedProviders: asked,
200
- successProviders: asked.filter((c) => !failing.includes(c)),
201
- degradedProviders: failing,
202
- executionTimes: Object.fromEntries(asked.map((c) => [c, 200])),
203
- hotels,
204
- },
908
+ msg: 'Query Success',
909
+ rows: SITES.map((host) => ({ host, category: 'integrated', status: 0 })),
205
910
  });
206
911
  }
912
+ // Gone on 11-04, as the dev backend answers it.
913
+ if (url.pathname === '/api/v1/ota/search')
914
+ return json({ code: 404, msg: 'No static resource api/v1/ota/search.' }, 404);
915
+ if ((url.pathname === '/api/v1/ota/hotels' || url.pathname === '/api/v1/ota/list_hotels') &&
916
+ method === 'POST') {
917
+ // ⚠️ THE FIRST CHANNEL ONLY, as the backend does (11-04); atour by default.
918
+ const code = String(body?.channelCodes?.[0] ?? 'atour');
919
+ const where = String(body?.destination ?? '').toLowerCase();
920
+ if (scene === 'real') {
921
+ if (code === 'shangrila')
922
+ return json({ code: 500, msg: 'Internal Server Error' }, 500);
923
+ // ⚠️ A PLACE IT CANNOT MATCH IS ANSWERED WITH EVERY HOTEL OF THE GROUP, as
924
+ // the dev backend does (11-04).
925
+ const rec = code === 'atour'
926
+ ? /shanghai|上海/.test(where)
927
+ ? RECORDED_1104.atourShanghai
928
+ : RECORDED_1104_ALL.atourAll
929
+ : code === 'ascott'
930
+ ? /singapore|新加坡/.test(where)
931
+ ? RECORDED_1104.ascottSingapore
932
+ : RECORDED_1104_ALL.ascottAll
933
+ : undefined;
934
+ return rec ? json(rec) : answer(code, []);
935
+ }
936
+ if (scene === 'degraded')
937
+ return answer(code, [], true);
938
+ if (failing.includes(code))
939
+ return json({ code: 500, msg: 'Internal Server Error' }, 500);
940
+ if (scene === 'empty' || down.includes(code))
941
+ return answer(code, [], down.includes(code));
942
+ const hotels = FAKE_HOTELS.filter((h) => h.providerCode === code &&
943
+ (!where ||
944
+ String(h.city).toLowerCase().includes(where) ||
945
+ (where === 'shanghai' && h.city === '上海') ||
946
+ String(h.name).toLowerCase().includes(where)));
947
+ return answer(code, hotels);
948
+ }
207
949
  if (url.pathname === '/api/v1/ota/rooms' && method === 'GET') {
208
950
  const id = url.searchParams.get('unifiedHotelId') ?? '';
209
- return json({ code: 200, msg: '操作成功', data: FAKE_ROOMS[id] ?? [] });
951
+ return json({
952
+ code: 200,
953
+ msg: '操作成功',
954
+ data: scene === 'real' ? [] : (FAKE_ROOMS[id] ?? []),
955
+ });
210
956
  }
211
957
  if (url.pathname === '/api/v1/ota/rooms/quote' && method === 'POST') {
212
958
  const room = (FAKE_ROOMS[String(body?.unifiedHotelId)] ?? []).find((r) => r.unifiedRoomId === `${String(body?.unifiedHotelId).split(':')[0]}:${body?.roomId}`);
@@ -231,6 +977,9 @@ export const createOtalessFake = (opts = {}) => {
231
977
  degrade: (codes) => {
232
978
  down = codes;
233
979
  },
980
+ fail: (codes) => {
981
+ failing = codes;
982
+ },
234
983
  refuseNextAsRepeat: () => {
235
984
  repeat = true;
236
985
  },