114114 font-size : .72rem ; font-weight : 700 ; white-space : nowrap; }
115115 .booking-price .unknown { color : var (--muted ); font-weight : 600 ; }
116116 .booking-copy p { margin : 0 ; color : var (--muted ); font-size : .82rem ; }
117+ .booking-copy .booking-item-meta { color : var (--ink ); font-weight : 600 ; }
117118 .booking-copy p + p { margin-top : 3px ; }
118119 .booking-empty { margin : 18px 0 8px ; color : var (--muted ); font-size : .88rem ; }
119120
@@ -978,9 +979,226 @@ <h2>📅 Day by day</h2>
978979 return "attraction" ;
979980 }
980981
981- function bookingDateParts ( value ) {
982+ function bookingMatchTokens ( value ) {
983+ var stop = [ "and" , "area" , "book" , "booking" , "cabin" , "camp" , "campsite" , "canyon" ,
984+ "cafe" , "city" , "dining" , "direct" , "grand" , "guided" , "hotel" , "hostel" , "inn" ,
985+ "lodge" , "lodging" , "meal" , "motel" , "national" , "park" , "point" , "reservation" ,
986+ "reserve" , "resort" , "restaurant" , "room" , "the" , "tour" ] ;
987+ return ( String ( value || "" ) . toLowerCase ( ) . match ( / [ a - z 0 - 9 \u3400 - \u9fff ] { 3 , } / g) || [ ] )
988+ . filter ( function ( token ) { return stop . indexOf ( token ) < 0 ; } ) ;
989+ }
990+
991+ function sharedBookingTokens ( left , right ) {
992+ var rightTokens = bookingMatchTokens ( right ) ;
993+ return bookingMatchTokens ( left ) . filter ( function ( token , index , tokens ) {
994+ return tokens . indexOf ( token ) === index && rightTokens . indexOf ( token ) >= 0 ;
995+ } ) . length ;
996+ }
997+
998+ function bookingDeadlineScore ( item , deadline ) {
999+ var deadlineText = [ deadline . item , deadline . where , deadline . note ] . join ( " " ) . toLowerCase ( ) ;
1000+ var name = String ( item . item || item . name || "" ) . toLowerCase ( ) ;
1001+ var context = String ( item . matchContext || item . area || "" ) . toLowerCase ( ) ;
1002+ var score = sharedBookingTokens ( name , deadlineText ) * 3 +
1003+ sharedBookingTokens ( context , deadlineText ) ;
1004+ if ( name . length >= 4 && deadlineText . indexOf ( name ) >= 0 ) score += 10 ;
1005+ if ( context . length >= 4 && deadlineText . indexOf ( context ) >= 0 ) score += 8 ;
1006+ return score ;
1007+ }
1008+
1009+ function bookingDeadlineAssignments ( items , deadlines ) {
1010+ var matches = { } ;
1011+ var usedDeadlines = { } ;
1012+ deadlines . forEach ( function ( deadline , deadlineIndex ) {
1013+ var bestItem = - 1 ;
1014+ var bestScore = 1 ;
1015+ items . forEach ( function ( item , itemIndex ) {
1016+ if ( matches [ itemIndex ] ) return ;
1017+ var score = bookingDeadlineScore ( item , deadline ) ;
1018+ if ( score > bestScore ) { bestScore = score ; bestItem = itemIndex ; }
1019+ } ) ;
1020+ if ( bestItem >= 0 ) {
1021+ matches [ bestItem ] = deadline ;
1022+ usedDeadlines [ deadlineIndex ] = true ;
1023+ }
1024+ } ) ;
1025+ return { matches : matches , usedDeadlines : usedDeadlines } ;
1026+ }
1027+
1028+ function bookingItemKey ( value ) {
1029+ return String ( value || "" ) . toLowerCase ( ) . replace ( / [ ^ a - z 0 - 9 \u3400 - \u9fff ] + / g, " " ) . trim ( ) ;
1030+ }
1031+
1032+ function appendVisitDate ( item , date ) {
1033+ if ( date && item . visitDates . indexOf ( date ) < 0 ) item . visitDates . push ( date ) ;
1034+ }
1035+
1036+ function hotelBookingItems ( list ) {
1037+ var deadlines = list . filter ( function ( item ) { return bookingCategory ( item ) === "hotel" ; } ) ;
1038+ var hotels = Array . isArray ( T . lodging ) ? T . lodging . filter ( function ( hotel ) {
1039+ return hotel && ( hotel . name || hotel . area ) ;
1040+ } ) : [ ] ;
1041+ if ( ! hotels . length ) return deadlines ;
1042+
1043+ var assignments = bookingDeadlineAssignments ( hotels , deadlines ) ;
1044+
1045+ var merged = hotels . map ( function ( hotel , hotelIndex ) {
1046+ var deadline = assignments . matches [ hotelIndex ] || { } ;
1047+ var price = deadline . price ;
1048+ if ( typeof hotel . pricePerNight === "number" && isFinite ( hotel . pricePerNight ) &&
1049+ hotel . pricePerNight >= 0 ) {
1050+ price = {
1051+ amount : hotel . pricePerNight ,
1052+ currency : price && price . currency ? price . currency : cur ,
1053+ unit : "night" ,
1054+ reliability : price && price . reliability ? price . reliability : "reference"
1055+ } ;
1056+ }
1057+ return {
1058+ item : hotel . name || deadline . item || hotel . area ,
1059+ bookBy : deadline . bookBy || "" ,
1060+ where : deadline . where || "" ,
1061+ category : "hotel" ,
1062+ price : price ,
1063+ priority : deadline . priority ,
1064+ note : deadline . note ,
1065+ stayArea : hotel . area || "" ,
1066+ nights : hotel . nights ,
1067+ bookingRequired : true ,
1068+ booked : hotel . booked === true
1069+ } ;
1070+ } ) ;
1071+ deadlines . forEach ( function ( deadline , deadlineIndex ) {
1072+ if ( ! assignments . usedDeadlines [ deadlineIndex ] ) merged . push ( deadline ) ;
1073+ } ) ;
1074+ return merged ;
1075+ }
1076+
1077+ function restaurantBookingItems ( list ) {
1078+ var deadlines = list . filter ( function ( item ) { return bookingCategory ( item ) === "restaurant" ; } ) ;
1079+ var meals = [ ] ;
1080+ var mealsByName = { } ;
1081+ ( T . days || [ ] ) . forEach ( function ( day ) {
1082+ var meal = day . meal ;
1083+ if ( ! meal || ! meal . name ) return ;
1084+ var key = bookingItemKey ( meal . name ) ;
1085+ var item = mealsByName [ key ] ;
1086+ if ( ! item ) {
1087+ item = {
1088+ item : meal . name ,
1089+ category : "restaurant" ,
1090+ visitDates : [ ] ,
1091+ matchContext : day . title || ""
1092+ } ;
1093+ if ( typeof meal . perPerson === "number" && isFinite ( meal . perPerson ) &&
1094+ meal . perPerson >= 0 ) {
1095+ item . price = {
1096+ amount : meal . perPerson , currency : cur , unit : "person" , reliability : "estimate"
1097+ } ;
1098+ }
1099+ mealsByName [ key ] = item ;
1100+ meals . push ( item ) ;
1101+ }
1102+ appendVisitDate ( item , day . date ) ;
1103+ } ) ;
1104+ if ( ! meals . length ) return deadlines ;
1105+
1106+ var assignments = bookingDeadlineAssignments ( meals , deadlines ) ;
1107+ var merged = meals . map ( function ( meal , mealIndex ) {
1108+ var deadline = assignments . matches [ mealIndex ] || { } ;
1109+ return {
1110+ item : meal . item ,
1111+ bookBy : deadline . bookBy || "" ,
1112+ where : deadline . where || "" ,
1113+ category : "restaurant" ,
1114+ price : deadline . price || meal . price ,
1115+ priority : deadline . priority ,
1116+ note : deadline . note ,
1117+ visitDates : meal . visitDates ,
1118+ bookingRequired : Boolean ( deadline . bookBy )
1119+ } ;
1120+ } ) ;
1121+ deadlines . forEach ( function ( deadline , deadlineIndex ) {
1122+ if ( ! assignments . usedDeadlines [ deadlineIndex ] ) merged . push ( deadline ) ;
1123+ } ) ;
1124+ return merged ;
1125+ }
1126+
1127+ function attractionBookingItems ( list ) {
1128+ var deadlines = list . filter ( function ( item ) { return bookingCategory ( item ) === "attraction" ; } ) ;
1129+ var excludedTypes = [ "charge" , "charging" , "city" , "food" , "fuel" , "gas" , "hotel" ,
1130+ "lodging" , "restaurant" ] ;
1131+ var attractions = [ ] ;
1132+ var attractionsByName = { } ;
1133+ ( T . days || [ ] ) . forEach ( function ( day ) {
1134+ ( day . stops || [ ] ) . forEach ( function ( stop ) {
1135+ if ( ! stop || ! stop . name || excludedTypes . indexOf ( String ( stop . type || "" ) . toLowerCase ( ) ) >= 0 ) {
1136+ return ;
1137+ }
1138+ var key = bookingItemKey ( stop . name ) ;
1139+ var item = attractionsByName [ key ] ;
1140+ if ( ! item ) {
1141+ var admission = stop . admission && typeof stop . admission === "object"
1142+ ? stop . admission : { } ;
1143+ var admissionStatus = String ( admission . status || "" ) . toLowerCase ( ) ;
1144+ if ( [ "free" , "included" , "paid" , "unknown" ] . indexOf ( admissionStatus ) < 0 ) {
1145+ var legacyTicket = String ( stop . ticket || "" ) . toLowerCase ( ) ;
1146+ admissionStatus = legacyTicket . indexOf ( "free" ) >= 0 ? "free" :
1147+ ( legacyTicket . indexOf ( "pass" ) >= 0 ? "included" :
1148+ ( / t i c k e t | f e e | g u i d e d t o u r / . test ( legacyTicket ) ? "paid" : "unknown" ) ) ;
1149+ }
1150+ item = {
1151+ item : stop . name ,
1152+ category : "attraction" ,
1153+ visitDates : [ ] ,
1154+ matchContext : [ day . title , stop . type , stop . ticket , admission . label ] . join ( " " ) ,
1155+ admissionStatus : admissionStatus ,
1156+ admissionLabel : admission . label || "" ,
1157+ price : admission . price
1158+ } ;
1159+ attractionsByName [ key ] = item ;
1160+ attractions . push ( item ) ;
1161+ }
1162+ appendVisitDate ( item , day . date ) ;
1163+ } ) ;
1164+ } ) ;
1165+ if ( ! attractions . length ) return deadlines ;
1166+
1167+ var assignments = bookingDeadlineAssignments ( attractions , deadlines ) ;
1168+ var merged = attractions . map ( function ( attraction , attractionIndex ) {
1169+ var deadline = assignments . matches [ attractionIndex ] || { } ;
1170+ return {
1171+ item : attraction . item ,
1172+ bookBy : deadline . bookBy || "" ,
1173+ where : deadline . where || "" ,
1174+ category : "attraction" ,
1175+ price : deadline . price || attraction . price ,
1176+ admissionStatus : attraction . admissionStatus ,
1177+ admissionLabel : attraction . admissionLabel ,
1178+ priority : deadline . priority ,
1179+ note : deadline . note ,
1180+ visitDates : attraction . visitDates ,
1181+ bookingRequired : Boolean ( deadline . bookBy )
1182+ } ;
1183+ } ) ;
1184+ deadlines . forEach ( function ( deadline , deadlineIndex ) {
1185+ if ( ! assignments . usedDeadlines [ deadlineIndex ] ) merged . push ( deadline ) ;
1186+ } ) ;
1187+ return merged ;
1188+ }
1189+
1190+ function bookingDateParts ( value , bookingRequired ) {
9821191 var match = / ^ ( \d { 4 } ) - ( \d { 2 } ) - ( \d { 2 } ) $ / . exec ( String ( value || "" ) ) ;
983- if ( ! match ) return { short : esc ( value || "—" ) , year : "" , iso : "" } ;
1192+ if ( ! match ) {
1193+ var noAdvanceBooking = bookingRequired === false ;
1194+ return {
1195+ short : esc ( value || ( noAdvanceBooking
1196+ ? ( isZh ( ) ? "无需提前预约" : "No advance booking" )
1197+ : ( isZh ( ) ? "暂时不知道截止日期" : "Deadline unknown" ) ) ) ,
1198+ year : "" ,
1199+ iso : ""
1200+ } ;
1201+ }
9841202 var months = isZh ( )
9851203 ? [ "1月" , "2月" , "3月" , "4月" , "5月" , "6月" , "7月" , "8月" , "9月" , "10月" , "11月" , "12月" ]
9861204 : [ "JAN" , "FEB" , "MAR" , "APR" , "MAY" , "JUN" , "JUL" , "AUG" , "SEP" , "OCT" , "NOV" , "DEC" ] ;
@@ -996,8 +1214,21 @@ <h2>📅 Day by day</h2>
9961214 if ( typeof price === "number" ) price = { amount : price } ;
9971215 if ( ! price || typeof price !== "object" || typeof price . amount !== "number" ||
9981216 ! isFinite ( price . amount ) || price . amount < 0 ) {
1217+ if ( item . category === "attraction" ) {
1218+ if ( item . admissionStatus === "free" ) {
1219+ return '<span class="booking-price">' + ( isZh ( ) ? "免费" : "Free" ) + "</span>" ;
1220+ }
1221+ if ( item . admissionStatus === "included" ) {
1222+ return '<span class="booking-price">' +
1223+ ( isZh ( ) ? "公园通票已包含" : "Included in park pass" ) + "</span>" ;
1224+ }
1225+ if ( item . admissionStatus === "paid" ) {
1226+ return '<span class="booking-price unknown">' +
1227+ ( isZh ( ) ? "收费 · 价格暂不可用" : "Paid · price unavailable" ) + "</span>" ;
1228+ }
1229+ }
9991230 return '<span class="booking-price unknown">' +
1000- ( isZh ( ) ? "价格待确认 " : "Check price " ) + "</span>" ;
1231+ ( isZh ( ) ? "价格暂不可用 " : "Price unavailable " ) + "</span>" ;
10011232 }
10021233 if ( price . amount === 0 ) {
10031234 return '<span class="booking-price">' + ( isZh ( ) ? "免费" : "Free" ) + "</span>" ;
@@ -1016,48 +1247,109 @@ <h2>📅 Day by day</h2>
10161247 Number ( price . amount ) . toLocaleString ( "en-US" ) + esc ( units [ unit ] || ( "/" + unit ) ) + "</span>" ;
10171248 }
10181249
1250+ function tripDateLabel ( value ) {
1251+ var slash = / ^ ( \d { 1 , 2 } ) \/ ( \d { 1 , 2 } ) $ / . exec ( String ( value || "" ) ) ;
1252+ if ( slash ) {
1253+ if ( isZh ( ) ) return Number ( slash [ 1 ] ) + "月" + Number ( slash [ 2 ] ) + "日" ;
1254+ var months = [ "JAN" , "FEB" , "MAR" , "APR" , "MAY" , "JUN" ,
1255+ "JUL" , "AUG" , "SEP" , "OCT" , "NOV" , "DEC" ] ;
1256+ return months [ Number ( slash [ 1 ] ) - 1 ] + " " + Number ( slash [ 2 ] ) ;
1257+ }
1258+ var iso = bookingDateParts ( value ) ;
1259+ return iso . iso ? iso . short : String ( value || "" ) ;
1260+ }
1261+
1262+ function bookingItemMetaMarkup ( item ) {
1263+ var bits = [ ] ;
1264+ if ( item . visitDates && item . visitDates . length ) {
1265+ bits . push ( ( isZh ( ) ? "行程日期 " : "Trip date " ) +
1266+ item . visitDates . map ( tripDateLabel ) . join ( ", " ) ) ;
1267+ }
1268+ if ( item . stayArea ) bits . push ( esc ( item . stayArea ) ) ;
1269+ if ( typeof item . nights === "number" && item . nights > 0 ) {
1270+ bits . push ( isZh ( ) ? item . nights + " 晚" :
1271+ item . nights + " night" + ( item . nights === 1 ? "" : "s" ) ) ;
1272+ }
1273+ return bits . length ? '<p class="booking-item-meta">' + bits . join ( " · " ) + "</p>" : "" ;
1274+ }
1275+
1276+ function bookingPanelTitle ( category , items ) {
1277+ var count = items . length ;
1278+ if ( category . id === "hotel" ) {
1279+ var totalNights = items . reduce ( function ( sum , item ) {
1280+ return sum + ( typeof item . nights === "number" && item . nights > 0 ? item . nights : 0 ) ;
1281+ } , 0 ) ;
1282+ if ( totalNights ) {
1283+ return isZh ( )
1284+ ? count + " 个住宿 · " + totalNights + " 晚"
1285+ : count + " stay" + ( count === 1 ? "" : "s" ) + " · " + totalNights +
1286+ " night" + ( totalNights === 1 ? "" : "s" ) ;
1287+ }
1288+ }
1289+ if ( isZh ( ) ) {
1290+ if ( category . id === "restaurant" ) return count + " 个用餐安排与预订截止日期" ;
1291+ if ( category . id === "hotel" ) return count + " 个住宿安排与预订截止日期" ;
1292+ return count + " 个景点与预订截止日期" ;
1293+ }
1294+ var noun = category . id === "restaurant" ? "dining plan" :
1295+ ( category . id === "hotel" ? "stay" : "attraction" ) ;
1296+ return count + " " + noun + ( count === 1 ? "" : "s" ) + " and booking deadlines" ;
1297+ }
1298+
10191299 function bookingPanelMarkup ( category , items , active ) {
10201300 var label = isZh ( ) ? category . zh : category . en ;
1021- var first = items . length ? bookingDateParts ( items [ 0 ] . bookBy ) : null ;
1301+ var first = null ;
1302+ items . some ( function ( item ) {
1303+ var date = bookingDateParts ( item . bookBy , item . bookingRequired ) ;
1304+ if ( ! date . iso ) return false ;
1305+ first = date ;
1306+ return true ;
1307+ } ) ;
10221308 var earliest = first
10231309 ? ( isZh ( ) ? "最早截止 · " : "Earliest deadline · " ) + first . short
10241310 : ( isZh ( ) ? "暂无截止日期" : "No deadlines yet" ) ;
1025- var title = isZh ( )
1026- ? items . length + " 个" + label + "预订需要安排"
1027- : items . length + " " + ( items . length === 1 ? category . singular : label . toLowerCase ( ) ) +
1028- " booking" + ( items . length === 1 ? "" : "s" ) +
1029- " to schedule" ;
1311+ var title = bookingPanelTitle ( category , items ) ;
10301312 var rows = items . map ( function ( item ) {
1031- var date = bookingDateParts ( item . bookBy ) ;
1313+ var date = bookingDateParts ( item . bookBy , item . bookingRequired ) ;
10321314 var source = item . where
10331315 ? "<p>" + ( isZh ( ) ? "预订渠道:" : "via " ) + esc ( item . where ) + "</p>" : "" ;
1316+ var meta = bookingItemMetaMarkup ( item ) ;
10341317 var note = item . note ? "<p>" + esc ( item . note ) + "</p>" : "" ;
10351318 var price = bookingPriceMarkup ( item ) ;
10361319 var priority = item . priority === "high" ? " pri-high" : "" ;
1320+ var deadlineLabel = date . iso
1321+ ? ( isZh ( ) ? "预订截止 " : "Book by " ) + item . bookBy
1322+ : ( item . bookingRequired === false
1323+ ? ( isZh ( ) ? "无需提前预约" : "No advance booking required" )
1324+ : ( isZh ( ) ? "暂时不知道截止日期" : "Booking deadline unknown" ) ) ;
10371325 return '<article class="booking-timeline-row">' +
10381326 '<time class="booking-timeline-date" datetime="' + esc ( date . iso ) + '" aria-label="' +
1039- ( isZh ( ) ? "预订截止 " : "Book by " ) + esc ( item . bookBy ) + '"><strong>' +
1327+ esc ( deadlineLabel ) + '"><strong>' +
10401328 date . short + "</strong><span>" + date . year + "</span></time>" +
10411329 '<span class="booking-timeline-dot' + priority + '" aria-hidden="true"></span>' +
10421330 '<div class="booking-copy"><div class="booking-title-row"><h3>' + esc ( item . item ) +
1043- "</h3>" + price + "</div>" + source + note + "</div></article>" ;
1331+ "</h3>" + price + "</div>" + meta + source + note + "</div></article>" ;
10441332 } ) . join ( "" ) ;
10451333 if ( ! rows ) rows = '<p class="booking-empty">' +
1046- ( isZh ( ) ? "这个分类暂时没有需要提前预订的项目。" :
1047- "Nothing in this category needs advance booking yet." ) + "</p>" ;
1334+ ( isZh ( ) ? "这个分类暂时没有项目。" : "Nothing in this category yet." ) + "</p>" ;
10481335 return '<section class="booking-panel" id="booking-panel-' + category . id +
10491336 '" role="tabpanel" tabindex="0" aria-labelledby="booking-tab-' + category . id + '"' +
10501337 ( active ? "" : " hidden" ) + '><div class="booking-panel-head"><div>' +
1051- '<p class="booking-kicker">' + label + ( isZh ( ) ? "预订" : " reservations" ) + "</p>" +
1338+ '<p class="booking-kicker">' + label + "</p>" +
10521339 "<h2>" + title + '</h2></div><span class="booking-earliest">' + earliest + "</span></div>" +
10531340 '<div class="booking-timeline">' + rows + "</div></section>" ;
10541341 }
10551342
10561343 function renderBookingTabs ( ) {
10571344 var list = T . bookingCountdown || [ ] ;
1058- if ( ! list . length ) { el ( "bookingTabs" ) . innerHTML = "" ; return ; }
10591345 var groups = { attraction : [ ] , restaurant : [ ] , hotel : [ ] } ;
1060- list . forEach ( function ( item ) { groups [ bookingCategory ( item ) ] . push ( item ) ; } ) ;
1346+ groups . attraction = attractionBookingItems ( list ) ;
1347+ groups . restaurant = restaurantBookingItems ( list ) ;
1348+ groups . hotel = hotelBookingItems ( list ) ;
1349+ if ( ! groups . attraction . length && ! groups . restaurant . length && ! groups . hotel . length ) {
1350+ el ( "bookingTabs" ) . innerHTML = "" ;
1351+ return ;
1352+ }
10611353 BOOKING_CATEGORIES . forEach ( function ( category ) {
10621354 groups [ category . id ] . sort ( function ( a , b ) {
10631355 return String ( a . bookBy || "9999" ) . localeCompare ( String ( b . bookBy || "9999" ) ) ;
0 commit comments