diff --git a/viator-collector/.gitignore b/viator-collector/.gitignore new file mode 100644 index 0000000..1032fe4 --- /dev/null +++ b/viator-collector/.gitignore @@ -0,0 +1 @@ +/gwc \ No newline at end of file diff --git a/viator-collector/SHORTCODES.md b/viator-collector/SHORTCODES.md new file mode 100644 index 0000000..e56ca36 --- /dev/null +++ b/viator-collector/SHORTCODES.md @@ -0,0 +1,195 @@ +# Viator Collector — Shortcode Reference + +All landing-page sections are shortcodes. Drop them into a **Shortcode block**, a +**Custom HTML block**, or a page template. Every section is wrapped in a +`.vc-app` container and styled by a single scoped stylesheet +(`assets/css/vc-design.css`), so the "Hop On Hop Off" design cannot leak into the +theme. Icons come from a bundled SVG sprite (no external icon library); the +listing/single/section assets load only on pages that use them. + +## Quick reference + +| Shortcode | Renders | Data source | +|---|---|---| +| `[viator_hero]` | Booking hero (location / date / seat) | `viator_destination` terms | +| `[viator_top_destinations]` | Rail of destination cards | `viator_destination` terms | +| `[viator_top_tours]` | Rail of tour cards | `viator_tour` posts | +| `[viator_top_attractions]` | Rail of tour cards | `viator_tour` posts | +| `[viator_browse_activities]` | Rail of tour cards | `viator_tour` posts | +| `[viator_faq]` | FAQ accordion | Shortcode `items` / `vc_faq_items` filter | +| `[viator_testimonials]` | Testimonials marquee | `vc_testimonials` filter | + +`[viator_hero_slider]` is kept as a **back-compat alias** for `[viator_hero]`. + +A typical home page: + +``` +[viator_hero] +[viator_top_destinations] +[viator_top_tours] +[viator_top_attractions] +[viator_browse_activities] +[viator_testimonials] +[viator_faq] +``` + +--- + +## `[viator_hero]` + +Full-width booking hero: a Location / Date / Seat form that submits (GET) to the +tours archive, pre-filtering by the chosen destination. + +| Attribute | Default | Description | +|---|---|---| +| `title` | `Set out on the journey.` | Hero heading. | +| `eyebrow` | `Tour & Travel` | Small pill above the heading. | +| `image` | *(setting / bundled)* | Background image URL. Falls back to the **Hero image** setting, then a bundled image. | + +``` +[viator_hero title="Explore the city your way." image="https://example.com/hero.jpg"] +``` + +--- + +## Shared attributes (tour rails) + +`[viator_top_tours]`, `[viator_top_attractions]` and `[viator_browse_activities]` +all select tours the same way and render a horizontally-scrolling rail of cards. + +| Attribute | Default | Description | +|---|---|---| +| `limit` | `8` | How many tours to show. | +| `ids` | — | Post IDs, comma-separated. **Order is preserved.** | +| `codes` | — | Viator product codes, e.g. `5010SYDNEY,2050ICON`. | +| `destination` | — | Destination term slug(s), e.g. `orlando,miami`. | +| `tag` | — | Activity/tag term slug(s), e.g. `theme-parks`. | +| `orderby` | `rating` | `rating` \| `price` (asc) \| `date` \| `rand`. Ignored when `ids` is used. | +| `title` | varies | Section heading (`
` allowed). | +| `lead` | varies | Sub-heading paragraph. | + +``` +[viator_top_tours limit="12" destination="london"] +[viator_top_tours ids="482,517,690" title="Editor's Picks"] +[viator_top_attractions codes="5010SYDNEY,2050ICON"] +``` + +> **`orderby="rating"`:** WordPress drops posts lacking the meta key when ordering +> by it, so if that would empty the section it falls back to newest-first. + +--- + +## `[viator_top_destinations]` + +Rail of destination-term cards; each links to that destination's tour archive. + +| Attribute | Default | Description | +|---|---|---| +| `limit` | `8` | Number of destinations. | +| `orderby` | `count` | `count` (most tours first) or `name`. | +| `ids` | — | Term IDs, comma-separated. **Order preserved.** | +| `slugs` | — | Term slugs, e.g. `london,paris,sydney`. | +| `title` | `Where you
wanna go?` | Heading (`
` allowed). | +| `lead` | *(default copy)* | Sub-heading. | +| `link_text` | `View tours` | "See all" action text. | + +Card image resolution: the term's own image (Viator Destinations → edit → Image), +otherwise the first tour photo in that destination (cached). Empty destinations +are hidden automatically. + +--- + +## `[viator_faq]` + +FAQ accordion. + +| Attribute | Default | Description | +|---|---|---| +| `title` | `FAQ's` | Heading. | +| `lead` | *(default copy)* | Sub-heading. | +| `items` | *(defaults)* | `Question|Answer;;Question 2|Answer 2` — `|` separates Q from A, `;;` separates pairs. | + +Without `items`, a sensible default set is used. Override globally in code: + +```php +add_filter( 'vc_faq_items', function ( $items ) { + return array( + array( 'q' => 'Where do the buses depart?', 'a' => 'From the central stop on Main St.' ), + array( 'q' => 'Are tickets refundable?', 'a' => 'Yes, up to 24 hours before start.' ), + ); +} ); +``` + +``` +[viator_faq items="Is it online?|Yes, fully.;;Can I cancel?|Up to 24h before."] +``` + +--- + +## `[viator_testimonials]` + +Animated marquee of traveller quotes. + +| Attribute | Default | Description | +|---|---|---| +| `title` | `Your Comments` | Heading. | +| `lead` | *(default copy)* | Sub-heading. | + +Content comes from the `vc_testimonials` filter (each item: +`name`, `role`, `text`, `avatar`, optional `size` = `lg`\|`sm`): + +```php +add_filter( 'vc_testimonials', function ( $items ) { + $items[] = array( + 'name' => 'Jane Doe', + 'role' => 'London Tour', + 'text' => 'Absolutely loved it!', + 'avatar' => 'https://example.com/jane.jpg', + 'size' => 'lg', + ); + return $items; +} ); +``` + +--- + +## Listing & single-tour pages + +These are **templates, not shortcodes** — they render automatically: + +- **Tours archive:** `/tours/` (filter bar, sort, search, server pagination) +- **Destination archive:** `/tour-destination/{slug}/` +- **Single tour:** `/tours/{slug}/` + +The archive banner text and hero image are filterable: + +```php +add_filter( 'vc_listing_title', fn() => 'Discover London Tours' ); +add_filter( 'vc_listing_subtitle', fn() => 'Hand-picked experiences.' ); +add_filter( 'vc_hero_image_url', fn( $url ) => $url ); +``` + +Useful archive query strings: + +``` +/tours/?bestdeal=1 +/tours/?freecancel=1 +/tours/?sellout=1 +/tours/?dest=london&price=50-150&sort=price_asc +``` + +Filtering, sorting and pagination are **server-side** (GET params), so results are +shareable and crawlable. + +--- + +## Notes & gotchas + +- **One scoped stylesheet.** All surfaces use `assets/css/vc-design.css`, scoped + to `.vc-app`. No Swiper, no Lucide, no external icon CDN. +- **Icons** are a bundled SVG sprite (`assets/icons/sprite.svg`), printed once per + page and referenced with ``. +- **Badges need flags.** `_vc_flags` is only populated on tours crawled after that + field was added — older tours may show no badge until the next full sync. +- **`max_travelers`** only appears once a tour's full detail has been fetched. +- If a section has no data it renders **nothing**, rather than an empty shell. diff --git a/viator-collector/assets/css/vc-design.css b/viator-collector/assets/css/vc-design.css new file mode 100644 index 0000000..352189c --- /dev/null +++ b/viator-collector/assets/css/vc-design.css @@ -0,0 +1,3115 @@ +/* Generated by build/scope-css.js from ../new front/css/style.css — do not edit by hand. */ + +/* ========================================================= + Tokens + ========================================================= */ +:root { + --c-black: #000; + --c-white: #fff; + --c-accent: #fc0; + --c-teal: #2cafc2; + --c-teal-05: rgba(44, 175, 194, 0.05); + --c-green: #34c759; + --c-red: #ff383c; + --c-teal-20: rgba(44, 175, 194, 0.2); + --c-ink: #101317; + --c-muted: rgba(0, 0, 0, 0.5); + --c-line: rgba(0, 0, 0, 0.1); + --c-glass: rgba(0, 0, 0, 0.5); + + --r-pill: 2.5rem; + --r-card: 1.5rem; + --r-xl: 2rem; + + --fs-body: 1rem; + --fs-hero: clamp(2.25rem, 1.25rem + 4.4vw, 4rem); + --fs-h2: clamp(1.75rem, 1.15rem + 2.6vw, 2.5rem); + --fs-card: clamp(1.125rem, 0.95rem + 0.75vw, 1.5rem); + + --sp-1: 0.5rem; + --sp-2: 0.6875rem; + --sp-3: 1rem; + --sp-4: 1.5rem; + --sp-5: 2rem; + --sp-6: clamp(2.5rem, 1.5rem + 3.5vw, 5rem); + + --control-h: clamp(2.75rem, 2.4rem + 0.9vw, 3rem); + --container: 81rem; + --gutter: clamp(1rem, 0.4rem + 2.2vw, 3rem); + --page-inset: clamp(0.5rem, 0.2rem + 0.6vw, 0.75rem); + --header-h: calc(var(--control-h) + var(--sp-5)); + --gap: clamp(0.75rem, 0.4rem + 1vw, 1.5rem); + + --ease: cubic-bezier(0.4, 0, 0.2, 1); +} + +/* ========================================================= + Base + ========================================================= */ +.vc-app *, +.vc-app *::before, +.vc-app *::after { + box-sizing: border-box; +} + +.vc-app { + -webkit-text-size-adjust: 100%; + scroll-behavior: smooth; + overflow-x: hidden; +} + +.vc-app { + margin: 0; + font-family: "Plus Jakarta Sans", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: var(--fs-body); + line-height: 1.5; + color: var(--c-ink); + background: var(--c-white); + overflow-x: hidden; +} + +.vc-app img, +.vc-app svg { + display: block; + max-width: 100%; +} + +.vc-app a { + color: inherit; + text-decoration: none; +} + +.vc-app button { + font: inherit; + color: inherit; + border: 0; + background: none; + cursor: pointer; +} + +.vc-app ul, +.vc-app ol { + margin: 0; + padding: 0; + list-style: none; +} + +.vc-app blockquote, +.vc-app figure, +.vc-app dl, +.vc-app dd { + margin: 0; +} + +.vc-app :where(a, +.vc-app button, +.vc-app input, +.vc-app select, +.vc-app summary):focus-visible { + outline: 2px solid var(--c-teal); + outline-offset: 3px; + border-radius: 0.25rem; +} + +.vc-app .sprite { + position: absolute; + width: 0; + height: 0; + overflow: hidden; +} + +.vc-app .skip-link { + position: absolute; + left: -100vmax; + top: var(--sp-1); + z-index: 100; + padding: var(--sp-1) var(--sp-3); + border-radius: var(--r-pill); + background: var(--c-white); + color: var(--c-black); +} + +.vc-app .skip-link:focus { + left: var(--sp-1); +} + +.vc-app .container { + width: 100%; + max-width: var(--container); + margin-inline: auto; + padding-inline: var(--gutter); +} + +.vc-app .icon { + width: 1rem; + height: 1rem; + flex: none; +} + +.vc-app .icon--12 { + width: 0.75rem; + height: 0.75rem; +} + +.vc-app .icon--14 { + width: 0.875rem; + height: 0.875rem; +} + +.vc-app .icon--20 { + width: 1.25rem; + height: 1.25rem; +} + +.vc-app .icon--24 { + width: 1.5rem; + height: 1.5rem; +} + +/* ========================================================= + Buttons, pills, chips + ========================================================= */ +.vc-app .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--sp-1); + min-height: var(--control-h); + padding-inline: var(--sp-4); + border-radius: var(--r-pill); + font-weight: 700; + white-space: nowrap; + transition: transform 0.2s var(--ease), filter 0.2s var(--ease), background 0.2s var(--ease); +} + +.vc-app .btn:hover { + filter: brightness(0.94); + transform: translateY(-1px); +} + +.vc-app .btn--accent { + background: var(--c-accent); + color: var(--c-black); +} + +.vc-app .btn--dark { + background: var(--c-black); + color: var(--c-white); +} + +.vc-app .btn--white { + background: var(--c-white); + color: var(--c-black); + font-weight: 500; +} + +.vc-app .btn--ghost { + border: 1px solid var(--c-line); + background: var(--c-white); + color: var(--c-black); + font-weight: 500; +} + +.vc-app .btn--ghost:hover { + filter: none; + background: rgba(0, 0, 0, 0.04); +} + +.vc-app .btn--wide { + justify-content: space-between; + width: 100%; + padding-inline: var(--sp-3); +} + +.vc-app .icon-btn { + display: inline-grid; + place-items: center; + aspect-ratio: 1; + width: var(--control-h); + border-radius: var(--r-pill); + color: var(--c-white); + background: var(--c-glass); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); + transition: background 0.2s var(--ease); +} + +.vc-app .icon-btn:hover { + background: rgba(0, 0, 0, 0.7); +} + +.vc-app .pill { + display: inline-flex; + align-items: center; + gap: var(--sp-1); + margin: 0; + padding: 0.375rem var(--sp-3); + border-radius: var(--r-pill); + color: var(--c-white); + background: var(--c-glass); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); +} + +.vc-app .chip { + display: inline-flex; + align-items: center; + gap: var(--sp-1); + padding: 0.125rem var(--sp-1); + border-radius: var(--r-pill); + font-size: 0.75rem; + font-weight: 700; + line-height: 1.6; + white-space: nowrap; +} + +.vc-app .chip--accent { + background: var(--c-accent); + color: var(--c-black); +} + +.vc-app .chip--glass { + color: var(--c-white); + background: var(--c-glass); + font-weight: 400; + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); +} + +.vc-app .rating { + position: absolute; + left: var(--sp-2); + top: var(--sp-2); + z-index: 2; + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.125rem var(--sp-1); + border-radius: var(--r-pill); + color: var(--c-white); + background: var(--c-glass); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); +} + +.vc-app .rating__star { + color: var(--c-accent); +} + +.vc-app .rating b { + font-size: 0.875rem; + font-weight: 500; +} + +.vc-app .rating i { + font-size: 0.75rem; + font-style: normal; + font-weight: 500; + opacity: 0.3; +} + +/* key / value pairs used on the cards */ +.vc-app .meta { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.vc-app .meta__k { + font-size: 0.75rem; + font-weight: 500; +} + +.vc-app .meta__v { + font-size: 1rem; + font-weight: 800; +} + +/* ========================================================= + Circular "ring" badge (scroll cue, book now, back to top) + ========================================================= */ +.vc-app .ring-badge { + position: relative; + display: grid; + place-items: center; + aspect-ratio: 1; + width: clamp(3.25rem, 2.6rem + 2vw, 4.5rem); + flex: none; +} + +.vc-app .ring-badge__ring { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0.75; + animation: spin 16s linear infinite; +} + +.vc-app .ring-badge__text { + fill: currentColor; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.vc-app .ring-badge__dot { + display: grid; + place-items: center; + padding: var(--sp-1); + border-radius: var(--r-pill); + color: var(--c-white); + background: var(--c-glass); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); + transition: transform 0.2s var(--ease); +} + +.vc-app .ring-badge:hover .ring-badge__dot { + transform: scale(1.08); +} + +.vc-app .ring-badge--accent .ring-badge__dot, +.vc-app .dcard:hover .ring-badge--card .ring-badge__dot, +.vc-app .dcard:focus-within .ring-badge--card .ring-badge__dot, +.vc-app .tcard:hover .ring-badge--card .ring-badge__dot, +.vc-app .tcard:focus-within .ring-badge--card .ring-badge__dot, +.vc-app .acard:hover .ring-badge--card .ring-badge__dot, +.vc-app .acard:focus-within .ring-badge--card .ring-badge__dot { + color: var(--c-black); + background: var(--c-accent); +} + +.vc-app .rail:hover .dcard.is-active:not(:hover):not(:focus-within) .ring-badge__dot, +.vc-app .rail:hover .acard.is-active:not(:hover):not(:focus-within) .ring-badge__dot, +.vc-app .bus__grid:hover .tcard.is-active:not(:hover):not(:focus-within) .ring-badge__dot { + color: var(--c-white); + background: var(--c-glass); +} + +.vc-app .ring-badge--scroll { + position: absolute; + left: 50%; + bottom: 0; + z-index: 5; + /* dark ink stays legible over both the teal hero foot and the white section below */ + color: var(--c-ink); + transform: translate(-50%, 50%); +} + +.vc-app .ring-badge--card { + width: clamp(2.875rem, 2.4rem + 1.6vw, 3.875rem); + color: var(--c-white); +} + +.vc-app .ring-badge--card .ring-badge__text { + font-size: 0.875rem; +} + +.vc-app .ring-badge--card .ring-badge__dot { + padding: 0.4375rem; +} + +.vc-app .ring-badge--top { + position: absolute; + left: 50%; + top: 0; + color: var(--c-ink); + transform: translate(-50%, -50%); +} + +.vc-app .ring-badge--top .ring-badge__text { + font-size: 0.625rem; + letter-spacing: 0.02em; +} + +.vc-app .ring-badge--scroll .ring-badge__dot .icon { + animation: nudge 2s var(--ease) infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes nudge { + + 0%, + 60%, + 100% { + transform: translateY(0); + } + + 30% { + transform: translateY(0.1875rem); + } +} + +/* ========================================================= + Header + ========================================================= */ +.vc-app .site-header { + position: absolute; + inset: var(--page-inset) 0 auto; + z-index: 20; + padding-block: var(--sp-4); + color: var(--c-white); +} + +.vc-app .site-header.is-stuck { + position: fixed; + inset-block-start: 0; + padding-block: var(--sp-2); + background: rgba(0, 0, 0, 0.55); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); + animation: slide-down 0.3s var(--ease); +} + +@keyframes slide-down { + from { + transform: translateY(-100%); + } +} + +.vc-app .header__inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-4); +} + +.vc-app .header__left { + display: flex; + align-items: center; + gap: clamp(1.5rem, 0.2rem + 4.5vw, 6rem); + min-width: 0; +} + +.vc-app .header__logo .logo { + width: clamp(4.25rem, 3.6rem + 1.6vw, 5.6875rem); + height: auto; +} + +.vc-app .header__actions { + display: flex; + align-items: center; + gap: var(--sp-1); +} + +.vc-app .nav__list { + display: flex; + align-items: center; + gap: clamp(1rem, 0.1rem + 1.8vw, 2.5rem); +} + +.vc-app .nav__link { + display: inline-flex; + align-items: center; + gap: var(--sp-1); + font-weight: 500; + white-space: nowrap; + transition: opacity 0.2s var(--ease); +} + +.vc-app .nav__link:hover { + opacity: 0.7; +} + +.vc-app .nav__item--has-menu { + position: relative; +} + +.vc-app .nav__toggle .icon--chevron { + transition: transform 0.25s var(--ease); +} + +.vc-app .nav__toggle[aria-expanded="true"] .icon--chevron { + transform: rotate(180deg); +} + +.vc-app .nav__submenu { + position: absolute; + top: calc(100% + var(--sp-2)); + left: 0; + z-index: 5; + display: grid; + gap: 0.25rem; + min-width: max-content; + padding: var(--sp-1); + border-radius: 1rem; + color: var(--c-black); + background: var(--c-white); + box-shadow: 0 1.25rem 2.5rem rgba(0, 0, 0, 0.18); + opacity: 0; + visibility: hidden; + transform: translateY(-0.375rem); + transition: opacity 0.2s var(--ease), transform 0.2s var(--ease), visibility 0.2s; +} + +.vc-app .nav__item--has-menu:hover .nav__submenu, +.vc-app .nav__toggle[aria-expanded="true"]+.nav__submenu { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.vc-app .nav__submenu a { + display: block; + padding: var(--sp-1) var(--sp-3); + border-radius: var(--r-pill); + font-weight: 500; +} + +.vc-app .nav__submenu a:hover { + background: rgba(0, 0, 0, 0.06); +} + +.vc-app .nav__mobile-cta { + display: none; +} + +.vc-app .burger { + display: none; + place-content: center; + place-items: center; + gap: 0.25rem; + aspect-ratio: 1; + width: var(--control-h); + border-radius: var(--r-pill); + background: var(--c-glass); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); +} + +.vc-app .burger span { + display: block; + width: 1.125rem; + height: 0.125rem; + border-radius: 1px; + background: var(--c-white); + transition: transform 0.25s var(--ease), opacity 0.25s var(--ease); +} + +.vc-app .burger[aria-expanded="true"] span:nth-child(1) { + transform: translateY(0.375rem) rotate(45deg); +} + +.vc-app .burger[aria-expanded="true"] span:nth-child(2) { + opacity: 0; +} + +.vc-app .burger[aria-expanded="true"] span:nth-child(3) { + transform: translateY(-0.375rem) rotate(-45deg); +} + +/* ========================================================= + Hero + ========================================================= */ +.vc-app .hero { + position: relative; + display: grid; + align-items: end; + min-height: clamp(34rem, 88svh, 56rem); + margin: var(--page-inset); + padding-block: calc(var(--header-h) + var(--sp-6)) clamp(3rem, 1.5rem + 6vw, 6rem); + border-radius: var(--r-xl); + isolation: isolate; +} + +.vc-app .hero__media { + position: absolute; + inset: 0; + z-index: -1; + border-radius: inherit; + overflow: clip; +} + +.vc-app .hero__img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; +} + +/* Figma gradients: radial vignette + teal fade into the next section */ +.vc-app .hero__scrim { + position: absolute; + inset: 0; + background: + radial-gradient(39.91% 77.37% at 60.22% 50%, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.5) 100%), + linear-gradient(180deg, rgba(44, 175, 194, 0) 51.6%, var(--c-teal) 100%); +} + +.vc-app .hero__content { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--sp-4); + text-align: left; +} + +.vc-app .hero__eyebrow { + order: 1; + font-size: 1rem; +} + +.vc-app .hero__title { + order: 2; + /* em keeps the two-line break ("Set out on" / "the journey.") at every size */ + max-width: 5.4em; + margin: 0; + font-size: var(--fs-hero); + font-weight: 500; + line-height: 1.05; + letter-spacing: -0.02em; + color: var(--c-white); +} + +.vc-app .booking { + order: 3; + width: 100%; + max-width: 46.625rem; + padding: var(--sp-3); + border-radius: var(--r-card); + background: var(--c-white); + box-shadow: 0 1.5rem 3.5rem rgba(0, 0, 0, 0.18); +} + +.vc-app .booking__row { + display: flex; + align-items: flex-end; + gap: var(--sp-3); +} + +.vc-app .field { + display: flex; + flex: 1 1 0; + flex-direction: column; + gap: var(--sp-2); + min-width: 0; +} + +.vc-app .field__label { + display: flex; + align-items: center; + gap: var(--sp-1); + font-size: 1rem; + font-weight: 500; + color: var(--c-black); +} + +.vc-app .field__label label { + cursor: pointer; +} + +.vc-app .select { + position: relative; + display: flex; + align-items: center; +} + +.vc-app .select__input { + width: 100%; + min-width: 0; + min-height: var(--control-h); + padding: 0 calc(var(--sp-3) + 1.5rem) 0 var(--sp-3); + border: 1px solid var(--c-line); + border-radius: var(--r-pill); + font: inherit; + font-weight: 500; + color: var(--c-black); + background: var(--c-white); + appearance: none; + -webkit-appearance: none; + text-overflow: ellipsis; + transition: border-color 0.2s var(--ease); +} + +.vc-app .select__input::placeholder { + color: var(--c-black); + opacity: 1; +} + +.vc-app .select__input:hover { + border-color: rgba(0, 0, 0, 0.3); +} + +.vc-app .select__input--date::-webkit-calendar-picker-indicator { + opacity: 0; + cursor: pointer; +} + +.vc-app .select__chevron { + position: absolute; + right: var(--sp-3); + pointer-events: none; + color: var(--c-black); +} + +.vc-app .booking__submit { + flex: none; +} + +/* ========================================================= + Section shell + section header + ========================================================= */ +.vc-app .section { + padding-block: var(--sp-6); +} + +.vc-app .section--tight { + padding-block-start: 0; +} + +.vc-app .sec-head { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: clamp(1.5rem, 0.5rem + 3vw, 4rem); + margin-bottom: var(--sp-6); +} + +.vc-app .sec-head__title { + margin: 0; + font-size: var(--fs-h2); + font-weight: 800; + line-height: 1.1; + letter-spacing: -0.03em; +} + +.vc-app .sec-head__lead { + max-width: 40ch; + margin: 0; + opacity: 0.7; + font-weight: 500; +} + +.vc-app .sec-head__actions { + display: flex; + flex-direction: column; + gap: var(--sp-1); + width: clamp(8rem, 6rem + 4vw, 9.125rem); +} + +.vc-app .rail-nav { + display: flex; + gap: var(--sp-1); +} + +.vc-app .rail-nav__btn { + flex: 1 1 0; + padding-inline: var(--sp-3); +} + +.vc-app .rail-nav__btn[disabled] { + opacity: 0.35; + cursor: not-allowed; +} + +/* ========================================================= + Horizontal rails + ========================================================= */ +.vc-app .rail { + display: flex; + gap: var(--gap); + overflow-x: auto; + scroll-snap-type: x mandatory; + scroll-behavior: smooth; + scrollbar-width: none; + /* negative margin + padding keeps the card glow from being clipped by the scroller */ + margin-inline: calc(var(--gap) * -1); + padding: var(--sp-4) var(--gap); +} + +.vc-app .rail::-webkit-scrollbar { + display: none; +} + +.vc-app .rail>* { + scroll-snap-align: start; +} + +.vc-app .rail--dest>* { + flex: 0 0 calc((100% - 4 * var(--gap)) / 5); +} + +.vc-app .rail--art>* { + flex: 0 0 calc((100% - 2 * var(--gap)) / 3); +} + +/* ========================================================= + Destination card + ========================================================= */ +.vc-app .dcard { + position: relative; + display: flex; + flex-direction: column; + justify-content: flex-end; + aspect-ratio: 246 / 346; + padding: clamp(0.75rem, 0.4rem + 1vw, 1.25rem); + border-radius: var(--r-card); + color: var(--c-white); + overflow: hidden; + isolation: isolate; + transition: transform 0.3s var(--ease); +} + +.vc-app .dcard:hover { + transform: translateY(-0.25rem); +} + +.vc-app .dcard__img { + position: absolute; + inset: 0; + z-index: -2; + width: 100%; + height: 100%; + object-fit: cover; +} + +.vc-app .dcard::after { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + background: linear-gradient(180deg, rgba(0, 0, 0, 0) 35%, rgba(0, 0, 0, 0.65) 100%); +} + +/* highlighted card — gradients copied from Figma */ +.vc-app .dcard.is-active, +.vc-app .dcard:hover, +.vc-app .dcard:focus-within { + box-shadow: 0 0.75rem 3.75rem 0 rgba(44, 175, 194, 0.5); +} + +.vc-app .dcard.is-active::after, +.vc-app .dcard:hover::after, +.vc-app .dcard:focus-within::after { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0) 51.6%, rgba(255, 255, 255, 0.4) 100%), + linear-gradient(0deg, rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.4)), + linear-gradient(180deg, rgba(44, 175, 194, 0) 37.33%, #2cafc2 84.53%); +} + +/* hovering any card in the rail hands the highlight over to it */ +.vc-app .rail:hover .dcard.is-active:not(:hover):not(:focus-within), +.vc-app .rail:focus-within .dcard.is-active:not(:hover):not(:focus-within) { + box-shadow: none; +} + +.vc-app .rail:hover .dcard.is-active:not(:hover):not(:focus-within)::after, +.vc-app .rail:focus-within .dcard.is-active:not(:hover):not(:focus-within)::after { + background: linear-gradient(180deg, rgba(0, 0, 0, 0) 35%, rgba(0, 0, 0, 0.65) 100%); +} + +.vc-app .dcard__desc { + position: absolute; + left: clamp(0.75rem, 0.4rem + 1vw, 1.25rem); + right: clamp(0.75rem, 0.4rem + 1vw, 1.25rem); + top: clamp(2.75rem, 2rem + 2vw, 3.5rem); + opacity: 0; + transform: translateY(-0.375rem); + transition: opacity 0.25s var(--ease), transform 0.25s var(--ease); +} + +.vc-app .dcard.is-active .dcard__desc, +.vc-app .dcard:hover .dcard__desc, +.vc-app .dcard:focus-within .dcard__desc { + opacity: 1; + transform: translateY(0); +} + +.vc-app .rail:hover .dcard.is-active:not(:hover):not(:focus-within) .dcard__desc, +.vc-app .rail:focus-within .dcard.is-active:not(:hover):not(:focus-within) .dcard__desc { + opacity: 0; + transform: translateY(-0.375rem); +} + +.vc-app .dcard__desc-k { + display: block; + font-size: 0.75rem; + font-weight: 500; + opacity: 0.5; +} + +.vc-app .dcard__desc p { + margin: 0.25rem 0 0; + font-size: 0.875rem; + font-weight: 500; + line-height: 1.45; +} + +.vc-app .dcard__body { + display: grid; + gap: var(--sp-2); +} + +.vc-app .dcard__title { + margin: 0; + font-size: var(--fs-card); + font-weight: 500; + letter-spacing: -0.01em; +} + +.vc-app .dcard__meta { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--sp-2); +} + +.vc-app .dcard__meta .ring-badge { + margin-inline-start: auto; +} + +/* ========================================================= + Open Top Bus Tours (promo + 2x2 grid) + ========================================================= */ +.vc-app .bus { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); + gap: var(--gap); + align-items: start; +} + +.vc-app .bus__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--gap); +} + +.vc-app .promo { + position: relative; + display: flex; + flex-direction: column; + aspect-ratio: 416 / 526; + padding: clamp(1.25rem, 0.6rem + 2vw, 2.5rem); + border-radius: var(--r-card); + background: var(--c-teal-05); + overflow: hidden; + isolation: isolate; +} + +.vc-app .promo__route { + position: absolute; + inset: 0; + z-index: -1; + width: 100%; + height: 100%; + opacity: 0.5; +} + +.vc-app .promo__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--sp-3); +} + +.vc-app .promo__title { + margin: 0; + font-size: var(--fs-h2); + font-weight: 800; + line-height: 1.1; + letter-spacing: -0.03em; + opacity: 0.8; +} + +.vc-app .promo__chip { + align-self: flex-start; + margin-top: calc(var(--sp-1) * -1); + transform: rotate(-6deg); +} + +.vc-app .promo__bus { + position: relative; + /* basis 0 so the coach takes only the space the title and note leave over */ + flex: 1 1 0; + display: grid; + place-items: center; + min-height: 0; + margin-inline: clamp(-2rem, -0.5rem - 3vw, -0.75rem); + /* padding-block: var(--sp-3) var(--sp-4); */ + padding-left: 2.5rem; +} + +.vc-app .promo__bus img { + width: 100%; + height: 100%; + /* grid items default to min-height:auto, which would keep the intrinsic height */ + min-height: 0; + object-fit: contain; + object-position: left; + /* Figma tints the blue coach towards the teal brand colour */ + filter: hue-rotate(-32deg) saturate(1.15) drop-shadow(0 1rem 1.5rem rgba(0, 0, 0, 0.18)); +} + +.vc-app .promo__note { + margin: 0; + margin-inline-start: auto; + max-width: 15rem; + font-weight: 500; + color: var(--c-teal); +} + +/* ========================================================= + Wide tour card + ========================================================= */ +.vc-app .tcard { + position: relative; + display: flex; + flex-direction: column; + justify-content: space-between; + aspect-ratio: 416 / 251; + padding: clamp(0.875rem, 0.5rem + 1vw, 1.25rem); + border-radius: var(--r-card); + color: var(--c-white); + overflow: hidden; + isolation: isolate; + transition: transform 0.3s var(--ease); +} + +.vc-app .tcard:hover { + transform: translateY(-0.25rem); +} + +.vc-app .tcard__img { + position: absolute; + inset: 0; + z-index: -2; + width: 100%; + height: 100%; + object-fit: cover; +} + +.vc-app .tcard::after { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + background: linear-gradient(180deg, rgba(0, 0, 0, 0.45) 0%, rgba(0, 0, 0, 0.1) 45%, rgba(0, 0, 0, 0.6) 100%); +} + +.vc-app .tcard.is-active, +.vc-app .tcard:hover, +.vc-app .tcard:focus-within { + box-shadow: 0 0.75rem 3.75rem 0 rgba(44, 175, 194, 0.5); +} + +.vc-app .tcard.is-active::after, +.vc-app .tcard:hover::after, +.vc-app .tcard:focus-within::after { + background: + radial-gradient(53.59% 137.34% at 50% 46.41%, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.4) 100%), + linear-gradient(0deg, rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.4)), + linear-gradient(180deg, rgba(44, 175, 194, 0) 37.33%, #2cafc2 84.53%); +} + +.vc-app .bus__grid:hover .tcard.is-active:not(:hover):not(:focus-within), +.vc-app .bus__grid:focus-within .tcard.is-active:not(:hover):not(:focus-within) { + box-shadow: none; +} + +.vc-app .bus__grid:hover .tcard.is-active:not(:hover):not(:focus-within)::after, +.vc-app .bus__grid:focus-within .tcard.is-active:not(:hover):not(:focus-within)::after { + background: linear-gradient(180deg, rgba(0, 0, 0, 0.45) 0%, rgba(0, 0, 0, 0.1) 45%, rgba(0, 0, 0, 0.6) 100%); +} + +.vc-app .tcard__k { + font-size: 0.75rem; + font-weight: 500; + opacity: 0.9; +} + +.vc-app .tcard__title { + max-width: 14ch; + margin: 0; + font-size: var(--fs-card); + font-weight: 500; + line-height: 1.15; + letter-spacing: -0.01em; +} + +.vc-app .tcard__bottom { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--sp-2); +} + +/* ========================================================= + About + ========================================================= */ +.vc-app .about { + display: grid; + grid-template-columns: minmax(0, 0.85fr) minmax(0, 1fr); + align-items: center; + gap: clamp(1.5rem, 0.5rem + 3vw, 3rem); +} + +.vc-app .about__art { + position: relative; + display: grid; + place-items: center; + aspect-ratio: 4 / 3; + color: var(--c-teal); +} + +.vc-app .about__glow { + display: none; +} + +/* straight speed-trail band running along the coach's axis */ +.vc-app .about__trail { + position: absolute; + left: -34%; + right: -34%; + top: 42%; + height: 28%; + background: linear-gradient(90deg, + rgba(44, 175, 194, 0) 0%, + rgba(44, 175, 194, 0.16) 22%, + rgba(44, 175, 194, 0.2) 55%, + rgba(44, 175, 194, 0) 100%); + /* small blur only — the band keeps its straight parallel edges */ + filter: blur(2px); + /* 25deg == the roof ridge angle, measured off the artwork itself */ + transform: rotate(32deg); + pointer-events: none; +} + +/* the arcs sit in front of the bus, as in the reference */ +.vc-app .about__arcs { + position: absolute; + z-index: 2; + inset: -12% 12% 2%; + width: auto; + height: 98%; + pointer-events: none; +} + +.vc-app .about__bus { + position: relative; + z-index: 1; + display: grid; + place-items: center; + width: 92%; +} + +.vc-app .about__bus img { + width: 100%; + height: auto; + object-fit: contain; + filter: drop-shadow(0 1.5rem 2.5rem rgba(0, 0, 0, 0.25)); +} + +.vc-app .about__body { + display: grid; + gap: clamp(1.25rem, 0.8rem + 1.4vw, 1.8125rem); +} + +.vc-app .about__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + flex-wrap: wrap; +} + +.vc-app .about__title { + position: relative; + margin: 0; + padding-right: 1.5rem; + font-size: var(--fs-h2); + font-weight: 800; + letter-spacing: -0.03em; + opacity: 0.8; +} + +.vc-app .about__chip { + position: absolute; + right: 0; + bottom: -0.75rem; + transform: rotate(-6deg); +} + +.vc-app .about__text { + margin: 0; + opacity: 0.7; + font-weight: 500; + text-align: justify; +} + +.vc-app .stats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--sp-4); +} + +.vc-app .stat { + display: flex; + align-items: flex-end; + gap: 0.75rem; +} + +.vc-app .stat__num { + margin: 0; + font-size: var(--fs-h2); + font-weight: 800; + line-height: 1; + letter-spacing: -0.03em; +} + +.vc-app .stat__num span { + color: var(--c-teal); +} + +.vc-app .stat__label { + opacity: 0.5; + font-weight: 500; +} + +/* ========================================================= + Testimonials marquee + ========================================================= */ +.vc-app .testimonials__head { + display: grid; + justify-items: center; + gap: var(--sp-3); + margin-bottom: var(--sp-6); + text-align: center; +} + +.vc-app .testimonials__title { + margin: 0; + font-size: var(--fs-h2); + font-weight: 800; + letter-spacing: -0.03em; +} + +.vc-app .testimonials__lead { + max-width: 42ch; + margin: 0; + opacity: 0.5; + font-weight: 500; +} + +.vc-app .marquee { + position: relative; + overflow: hidden; + -webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); + mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); +} + +.vc-app .marquee__route { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0.6; + pointer-events: none; +} + +.vc-app .marquee__track { + display: flex; + align-items: center; + gap: var(--gap); + width: max-content; + padding-block: var(--sp-3); + animation: marquee 60s linear infinite; +} + +.vc-app .marquee:hover .marquee__track, +.vc-app .marquee:focus-within .marquee__track { + animation-play-state: paused; +} + +@keyframes marquee { + to { + transform: translateX(calc(-50% - var(--gap) / 2)); + } +} + +.vc-app .quote { + display: grid; + gap: var(--sp-3); + flex: none; + width: clamp(17rem, 14rem + 12vw, 26rem); + padding: var(--sp-4) var(--sp-5); + border-radius: var(--r-card); + background: var(--c-teal-05); + -webkit-backdrop-filter: blur(20px); + backdrop-filter: blur(20px); +} + +.vc-app .quote--lg { + width: clamp(19rem, 15rem + 16vw, 32.875rem); +} + +.vc-app .quote--sm { + width: clamp(15rem, 12rem + 9vw, 21.375rem); +} + +.vc-app .quote__head { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.vc-app .quote__avatar { + aspect-ratio: 1; + width: 3rem; + border-radius: 50%; + object-fit: cover; +} + +.vc-app .quote__head b { + display: block; + font-weight: 700; +} + +.vc-app .quote__head i { + font-style: normal; + font-weight: 500; + color: var(--c-teal); +} + +.vc-app .quote blockquote { + opacity: 0.5; + font-weight: 500; + text-align: justify; +} + +/* ========================================================= + Article card + ========================================================= */ +.vc-app .acard { + display: flex; + flex-direction: column; + border-radius: var(--r-card); + background: var(--c-white); + transition: transform 0.3s var(--ease); +} + +.vc-app .acard:hover { + transform: translateY(-0.25rem); +} + +.vc-app .acard.is-active { + background: var(--c-teal-05); +} + +.vc-app .acard__media { + position: relative; + aspect-ratio: 416 / 251; + border-radius: var(--r-card); + color: var(--c-white); + overflow: hidden; + isolation: isolate; +} + +.vc-app .acard__img { + position: absolute; + inset: 0; + z-index: -2; + width: 100%; + height: 100%; + object-fit: cover; +} + +.vc-app .acard__media::after { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + background: linear-gradient(180deg, rgba(0, 0, 0, 0.35) 0%, rgba(0, 0, 0, 0.05) 40%, rgba(0, 0, 0, 0.65) 100%); +} + +.vc-app .acard.is-active .acard__media::after, +.vc-app .acard:hover .acard__media::after, +.vc-app .acard:focus-within .acard__media::after { + background: + radial-gradient(53.59% 137.34% at 50% 46.41%, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.4) 100%), + linear-gradient(0deg, rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.4)), + linear-gradient(180deg, rgba(44, 175, 194, 0) 37.33%, #2cafc2 84.53%); +} + +.vc-app .acard.is-active .acard__media, +.vc-app .acard:hover .acard__media, +.vc-app .acard:focus-within .acard__media { + box-shadow: 0 0.75rem 3.75rem 0 rgba(44, 175, 194, 0.5); +} + +.vc-app .rail:hover .acard.is-active:not(:hover):not(:focus-within) { + background: var(--c-white); +} + +.vc-app .rail:hover .acard.is-active:not(:hover):not(:focus-within) .acard__media { + box-shadow: none; +} + +.vc-app .rail:hover .acard.is-active:not(:hover):not(:focus-within) .acard__media::after { + background: linear-gradient(180deg, rgba(0, 0, 0, 0.35) 0%, rgba(0, 0, 0, 0.05) 40%, rgba(0, 0, 0, 0.65) 100%); +} + +.vc-app .acard__time { + position: absolute; + left: var(--sp-2); + top: var(--sp-2); +} + +.vc-app .acard__overlay { + position: absolute; + inset: auto 0 0; + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--sp-2); + padding: clamp(0.875rem, 0.5rem + 1vw, 1.5rem); +} + +.vc-app .acard__title { + max-width: 17ch; + margin: 0; + font-size: var(--fs-card); + font-weight: 500; + line-height: 1.15; + letter-spacing: -0.01em; +} + +.vc-app .acard__excerpt { + margin: 0; + padding: var(--sp-4) var(--sp-4) var(--sp-5); + opacity: 0.7; + font-weight: 500; + text-align: justify; +} + +.vc-app .acard.is-active .acard__excerpt, +.vc-app .acard:hover .acard__excerpt, +.vc-app .acard:focus-within .acard__excerpt { + opacity: 1; + color: var(--c-teal); +} + +.vc-app .acard.is-active, +.vc-app .acard:hover, +.vc-app .acard:focus-within { + background: var(--c-teal-05); +} + +.vc-app .rail:hover .acard.is-active:not(:hover):not(:focus-within) .acard__excerpt { + opacity: 0.7; + color: inherit; +} + +/* ========================================================= + FAQ + ========================================================= */ +.vc-app .faq { + position: relative; + margin: var(--page-inset); + padding-block: var(--sp-6); + border-radius: var(--r-xl); + background: var(--c-teal-05); + overflow: hidden; + isolation: isolate; +} + +.vc-app .faq__watermark { + position: absolute; + left: clamp(1rem, -1rem + 6vw, 4rem); + bottom: clamp(-2rem, -6vw, -1rem); + z-index: -1; + font-size: clamp(6rem, 2rem + 16vw, 15rem); + font-weight: 800; + letter-spacing: -0.04em; + line-height: 1; + color: transparent; + -webkit-text-stroke: 1px rgba(44, 175, 194, 0.35); + pointer-events: none; + user-select: none; +} + +.vc-app .faq__inner { + display: grid; + grid-template-columns: minmax(0, 0.55fr) minmax(0, 1fr); + gap: clamp(2rem, 0.5rem + 6vw, 8.375rem); + align-items: start; +} + +.vc-app .faq__intro { + display: grid; + gap: var(--sp-4); +} + +.vc-app .faq__title { + margin: 0; + font-size: var(--fs-h2); + font-weight: 800; + letter-spacing: -0.03em; +} + +.vc-app .faq__lead { + margin: 0; + opacity: 0.7; + font-weight: 500; +} + +.vc-app .accordion { + display: grid; + gap: var(--sp-4); +} + +.vc-app .acc+.acc { + border-top: 1px solid var(--c-line); + padding-top: var(--sp-4); +} + +.vc-app .acc h3 { + margin: 0; + font-size: inherit; + font-weight: inherit; +} + +.vc-app .acc__q { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + width: 100%; + padding: 0; + font-weight: 500; + text-align: left; + transition: color 0.2s var(--ease); +} + +.vc-app .acc__q .icon { + opacity: 0.3; +} + +.vc-app .acc__minus { + display: none; +} + +.vc-app .acc__q[aria-expanded="true"] { + color: var(--c-teal); + font-weight: 700; +} + +.vc-app .acc__q[aria-expanded="true"] .icon { + opacity: 1; +} + +.vc-app .acc__q[aria-expanded="true"] .acc__plus { + display: none; +} + +.vc-app .acc__q[aria-expanded="true"] .acc__minus { + display: block; +} + +.vc-app .acc__a { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.3s var(--ease); +} + +.vc-app .acc__q[aria-expanded="true"]+.acc__a { + grid-template-rows: 1fr; +} + +.vc-app .acc__a>* { + overflow: hidden; +} + +.vc-app .acc__a p { + margin: 0; + padding-top: var(--sp-4); + opacity: 0.7; + font-weight: 500; + text-align: justify; +} + +/* ========================================================= + Footer + ========================================================= */ +.vc-app .footer { + position: relative; + margin: calc(var(--sp-6) + var(--page-inset)) var(--page-inset) var(--page-inset); + padding-block: var(--sp-6) 0; + border-radius: var(--r-xl); + color: var(--c-white); + background: var(--c-black); +} + +.vc-app .footer__inner { + display: grid; + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); + gap: clamp(2rem, 0.5rem + 5vw, 6rem); + padding-bottom: var(--sp-6); +} + +.vc-app .footer__logo .logo { + width: clamp(5rem, 4rem + 2.5vw, 7.125rem); + height: auto; +} + +.vc-app .footer__brand { + display: grid; + gap: var(--sp-4); + align-content: start; +} + +.vc-app .footer__about { + margin: 0; + opacity: 0.5; + font-weight: 500; + text-align: justify; +} + +.vc-app .footer__cols { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--sp-4); + justify-items: start; +} + +.vc-app .footer__cols ul { + display: grid; + gap: 0.75rem; +} + +.vc-app .footer__ct { + margin: 0 0 var(--sp-3); + font-size: 1rem; + font-weight: 700; + color: var(--c-teal); +} + +.vc-app .footer__cols a { + opacity: 0.5; + font-weight: 500; + transition: opacity 0.2s var(--ease); +} + +.vc-app .footer__cols a:hover { + opacity: 1; +} + +.vc-app .footer__bar { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.4fr) minmax(0, 1fr); + align-items: center; + gap: var(--sp-4); + padding-block: var(--sp-4); + border-top: 1px solid rgba(255, 255, 255, 0.2); +} + +.vc-app .footer__copy { + margin: 0; + font-weight: 500; +} + +.vc-app .socials { + display: flex; + align-items: center; + justify-content: center; + gap: var(--sp-5); +} + +/* dashed rules either side of the social icons, as in the design */ +.vc-app .socials::before, +.vc-app .socials::after { + content: ""; + flex: 1 1 clamp(2rem, 8vw, 11rem); + border-top: 1px dashed rgba(255, 255, 255, 0.3); +} + +.vc-app .socials::before { + margin-inline-end: var(--sp-4); +} + +.vc-app .socials::after { + margin-inline-start: var(--sp-4); +} + +.vc-app .socials li { + flex: none; +} + +.vc-app .socials a { + display: grid; + place-items: center; + transition: opacity 0.2s var(--ease); +} + +.vc-app .socials a:hover { + opacity: 0.7; +} + +.vc-app .support { + position: absolute; + right: clamp(1.5rem, 0.5rem + 3vw, 4rem); + bottom: 0; + z-index: 2; + display: grid; + place-items: center; + color: var(--c-white); +} + +.vc-app .support__ring { + position: absolute; + aspect-ratio: 1; + scale: 140%; + opacity: 0.5; + top: -1.5rem; +} + +.vc-app .support__tab { + display: grid; + place-items: center; + padding: var(--sp-4); + padding-bottom: clamp(2rem, 1rem + 3vw, 3rem); + border-radius: var(--r-pill) var(--r-pill) 0 0; + background: linear-gradient(180deg, rgba(44, 175, 194, 0.99) 0%, var(--c-teal-20) 100%); + box-shadow: 0 0.75rem 8.25rem var(--c-teal); + -webkit-backdrop-filter: blur(4px); + backdrop-filter: blur(4px); + transition: transform 0.2s var(--ease); +} + +.vc-app .support:hover .support__tab { + transform: translateY(-0.25rem); +} + +/* ========================================================= + Responsive + ========================================================= */ +@media (max-width: 78rem) { +.vc-app .rail--dest>* { + flex: 0 0 calc((100% - 3 * var(--gap)) / 4); + } + +} + +@media (max-width: 68.75rem) { +.vc-app .booking__row { + flex-wrap: wrap; + } + +.vc-app .field { + flex: 1 1 14rem; + } + +.vc-app .booking__submit { + flex: 1 1 100%; + } + +.vc-app .sec-head { + grid-template-columns: minmax(0, 1fr) auto; + } + +.vc-app .sec-head__lead { + grid-column: 1 / -1; + grid-row: 2; + max-width: none; + } + +.vc-app .rail--dest>* { + flex: 0 0 calc((100% - 2 * var(--gap)) / 3); + } + +.vc-app .bus { + grid-template-columns: minmax(0, 1fr); + } + +.vc-app .promo { + aspect-ratio: 16 / 9; + } + +.vc-app .about { + grid-template-columns: minmax(0, 1fr); + } + +.vc-app .about__art { + order: 2; + aspect-ratio: 16 / 9; + } + +.vc-app .faq__inner { + grid-template-columns: minmax(0, 1fr); + } + +} + +@media (max-width: 60rem) { +.vc-app .burger { + display: inline-grid; + } + +.vc-app .header__cta { + display: none; + } + +.vc-app .nav { + position: absolute; + top: calc(100% + var(--sp-2)); + right: var(--gutter); + left: var(--gutter); + padding: var(--sp-2); + border-radius: var(--r-card); + color: var(--c-black); + background: var(--c-white); + box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.22); + opacity: 0; + visibility: hidden; + transform: translateY(-0.5rem); + transition: opacity 0.25s var(--ease), transform 0.25s var(--ease), visibility 0.25s; + } + +.vc-app .nav.is-open { + opacity: 1; + visibility: visible; + transform: translateY(0); + } + +.vc-app .nav__list { + flex-direction: column; + align-items: stretch; + gap: 0; + } + +.vc-app .nav__link { + width: 100%; + justify-content: space-between; + padding: var(--sp-2) var(--sp-3); + border-radius: var(--r-pill); + } + +.vc-app .nav__link:hover { + opacity: 1; + background: rgba(0, 0, 0, 0.05); + } + +.vc-app .nav__submenu { + position: static; + display: none; + padding: 0 0 var(--sp-1) var(--sp-3); + box-shadow: none; + opacity: 1; + visibility: visible; + transform: none; + } + +.vc-app .nav__item--has-menu:hover .nav__submenu { + display: none; + } + +.vc-app .nav__toggle[aria-expanded="true"]+.nav__submenu { + display: grid; + } + +.vc-app .nav__mobile-cta { + display: block; + margin-top: var(--sp-1); + } + +.vc-app .nav__cta { + width: 100%; + } + +.vc-app .rail--art>* { + flex: 0 0 calc((100% - var(--gap)) / 2); + } + +.vc-app .footer__inner { + grid-template-columns: minmax(0, 1fr); + } + +} + +@media (max-width: 48rem) { +.vc-app .rail--dest>* { + flex: 0 0 calc((100% - var(--gap)) / 2); + } + +.vc-app .bus__grid { + grid-template-columns: minmax(0, 1fr); + } + +.vc-app .stats { + gap: var(--sp-3); + } + +.vc-app .footer__bar { + grid-template-columns: minmax(0, 1fr); + justify-items: start; + } + +.vc-app .socials::before, +.vc-app .socials::after { + display: none; + } + +.vc-app .support { + right: var(--gutter); + } + +} + +@media (max-width: 40rem) { +.vc-app .hero { + min-height: clamp(30rem, 84svh, 44rem); + } + +.vc-app .booking { + padding: var(--sp-2); + } + +.vc-app .booking__row { + gap: var(--sp-2); + } + +.vc-app .field { + flex: 1 1 100%; + } + +.vc-app .sec-head { + grid-template-columns: minmax(0, 1fr); + gap: var(--sp-3); + } + +.vc-app .sec-head__lead { + grid-row: auto; + } + +.vc-app .sec-head__actions { + flex-direction: row; + width: 100%; + } + +.vc-app .sec-head__actions .btn--wide { + width: auto; + flex: 1 1 auto; + } + +.vc-app .rail--dest>* { + flex: 0 0 min(72%, 15rem); + } + +.vc-app .rail--art>* { + flex: 0 0 min(85%, 22rem); + } + +.vc-app .promo { + aspect-ratio: auto; + gap: var(--sp-4); + } + +.vc-app .promo__note { + max-width: none; + margin-inline-start: 0; + } + +.vc-app .stats { + grid-template-columns: minmax(0, 1fr); + } + +.vc-app .support { + display: none; + } + +.vc-app .footer__cols { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--sp-5) var(--sp-4); + } + +/* justified text tears open on narrow columns */ +.vc-app .about__text, +.vc-app .acard__excerpt, +.vc-app .quote blockquote, +.vc-app .acc__a p, +.vc-app .footer__about { + text-align: left; + } + +} + +@media (prefers-reduced-motion: reduce) { +.vc-app { + scroll-behavior: auto; + } + +.vc-app *, +.vc-app *::before, +.vc-app *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + +.vc-app .rail { + scroll-behavior: auto; + } + +} + +/* ========================================================= + Tour archive — hero filter bar + ========================================================= */ +.vc-app .hero--archive { + min-height: clamp(30rem, 78svh, 48rem); +} + +/* "Explore Tours" is short enough to stay on one line at every size */ +.vc-app .hero--archive .hero__title { + max-width: none; +} + +.vc-app .filters { + order: 3; + width: 100%; + border-radius: var(--r-card); + background: var(--c-white); + box-shadow: 0 1.5rem 3.5rem rgba(0, 0, 0, 0.18); +} + +.vc-app .filters__row { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-3); +} + +.vc-app .fsearch, +.vc-app .fsel { + position: relative; + display: flex; + flex: 1 1 0; + align-items: center; + min-width: 0; + min-height: var(--control-h); + border-radius: var(--r-pill); + transition: border-color 0.2s var(--ease); +} + +/* the search field carries the longest placeholder, so it takes a little more room */ +.vc-app .fsearch { + flex-grow: 1.3; + border: 1px solid var(--c-white); + background: rgba(0, 0, 0, 0.05); +} + +.vc-app .fsel { + border: 1px solid var(--c-line); + background: var(--c-white); +} + +.vc-app .fsel:hover { + border-color: rgba(0, 0, 0, 0.3); +} + +.vc-app .fsearch__icon, +.vc-app .fsel__icon { + position: absolute; + left: var(--sp-3); + pointer-events: none; + color: var(--c-black); +} + +.vc-app .fsel__chevron { + position: absolute; + right: var(--sp-3); + pointer-events: none; + color: var(--c-black); +} + +.vc-app .fsearch__input, +.vc-app .fsel__input { + width: 100%; + min-width: 0; + min-height: var(--control-h); + padding: 0 calc(var(--sp-3) + 1.5rem); + border: 0; + border-radius: inherit; + font: inherit; + font-weight: 500; + color: var(--c-black); + background: transparent; + appearance: none; + -webkit-appearance: none; + text-overflow: ellipsis; +} + +.vc-app .fsearch__input { + padding-inline: calc(var(--sp-3) + 1.375rem) var(--sp-2); + font-size: 0.875rem; +} + +.vc-app .fsearch__input::placeholder { + color: var(--c-black); + opacity: 0.5; +} + +.vc-app .fsearch__input::-webkit-search-cancel-button, +.vc-app .fsearch__input::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* the sort dropdown carries its label outside the pill, so it needs no lead icon */ +.vc-app .fsel--bare .fsel__input { + padding-left: var(--sp-3); +} + +.vc-app .filters__apply { + flex: none; +} + +.vc-app .filters__bar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--sp-3) var(--sp-4); + padding: var(--sp-3) var(--sp-3) var(--sp-3) var(--sp-4); + border-top: 1px solid var(--c-line); +} + +.vc-app .filters__checks { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--sp-2) 2.5rem; +} + +.vc-app .check { + display: inline-flex; + align-items: center; + gap: var(--sp-1); + cursor: pointer; +} + +.vc-app .check__input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.vc-app .check__box { + display: grid; + flex: none; + place-items: center; + width: 1rem; + height: 1rem; + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 0.25rem; + color: transparent; + background: var(--c-white); + transition: color 0.2s var(--ease), background-color 0.2s var(--ease), border-color 0.2s var(--ease); +} + +.vc-app .check:hover .check__box { + border-color: rgba(0, 0, 0, 0.45); +} + +.vc-app .check__input:checked+.check__box { + color: var(--c-white); + border-color: var(--c-black); + background: var(--c-black); +} + +.vc-app .check__input:focus-visible+.check__box { + outline: 2px solid var(--c-teal); + outline-offset: 2px; +} + +.vc-app .check__text { + font-size: 1rem; + font-weight: 500; + opacity: 0.7; + transition: opacity 0.2s var(--ease); +} + +.vc-app .check__input:checked~.check__text { + opacity: 1; +} + +.vc-app .filters__sort { + display: flex; + align-items: center; + gap: var(--sp-2); +} + +.vc-app .filters__sort-label { + display: inline-flex; + align-items: center; + gap: var(--sp-1); + font-size: 1rem; + font-weight: 500; + white-space: nowrap; +} + +.vc-app .filters__sort-label label { + cursor: pointer; +} + +.vc-app .filters__sort .fsel { + flex: none; + width: 8.8125rem; +} + +/* ========================================================= + Tour archive — result grid + ========================================================= */ +/* the script shows and hides cards, pages and buttons with the hidden + attribute, which the flex and grid display values would otherwise win against */ +.vc-app [hidden] { + display: none !important; +} + +.vc-app .results { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--sp-2) var(--sp-3); + margin-block-end: var(--sp-4); +} + +.vc-app .results__count { + margin: 0; + font-size: 1rem; + font-weight: 500; + opacity: 0.7; +} + +.vc-app .results__count b { + font-weight: 800; +} + +.vc-app .results__reset { + flex: none; +} + +.vc-app .tgrid__empty { + margin: 0; + padding-block: var(--sp-5); + font-size: 1.125rem; + font-weight: 500; + text-align: center; + opacity: 0.7; +} + +/* a button that reads as part of the sentence around it */ +.vc-app .linkbtn { + padding: 0; + border: 0; + font: inherit; + color: var(--c-teal); + background: none; + text-decoration: underline; + cursor: pointer; +} + +.vc-app .tgrid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 2.5rem var(--gap); + margin: 0; + padding: 0; + list-style: none; +} + +.vc-app .titem { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--sp-3); +} + +/* landscape variant of the destination card used on the homepage rail */ +.vc-app .dcard--wide { + width: 100%; + aspect-ratio: 416 / 251; +} + +/* the wider card gives the title and the key/value pairs more room to breathe */ +.vc-app .dcard--wide .dcard__body { + gap: var(--sp-3); +} + +.vc-app .dcard--wide .dcard__meta { + gap: var(--sp-4); +} + +.vc-app .titem__desc { + width: 88.5%; + margin: 0; + font-size: 1rem; + font-weight: 500; + line-height: 1.5; + text-align: justify; + opacity: 0.7; + transition: color 0.25s var(--ease), opacity 0.25s var(--ease); +} + +.vc-app .titem.is-active .titem__desc, +.vc-app .titem:hover .titem__desc, +.vc-app .titem:focus-within .titem__desc { + color: var(--c-teal); + opacity: 1; +} + +/* hovering any card in the grid hands the highlight over to it */ +.vc-app .tgrid:hover .titem.is-active:not(:hover):not(:focus-within) .titem__desc, +.vc-app .tgrid:focus-within .titem.is-active:not(:hover):not(:focus-within) .titem__desc { + color: inherit; + opacity: 0.7; +} + +.vc-app .tgrid:hover .dcard.is-active:not(:hover):not(:focus-within), +.vc-app .tgrid:focus-within .dcard.is-active:not(:hover):not(:focus-within) { + box-shadow: none; +} + +.vc-app .tgrid:hover .dcard.is-active:not(:hover):not(:focus-within)::after, +.vc-app .tgrid:focus-within .dcard.is-active:not(:hover):not(:focus-within)::after { + background: linear-gradient(180deg, rgba(0, 0, 0, 0) 35%, rgba(0, 0, 0, 0.65) 100%); +} + +.vc-app .tgrid:hover .dcard.is-active:not(:hover):not(:focus-within) .ring-badge__dot, +.vc-app .tgrid:focus-within .dcard.is-active:not(:hover):not(:focus-within) .ring-badge__dot { + color: var(--c-white); + background: var(--c-glass); +} + +/* ========================================================= + Tour archive — pagination + ========================================================= */ +.vc-app .pager { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: var(--sp-1); + margin-block-start: var(--sp-6); +} + +.vc-app .pager__pages { + display: flex; + align-items: center; + gap: var(--sp-1); +} + +.vc-app .pager__num, +.vc-app .pager__btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--sp-1); + min-height: var(--control-h); + padding: 0 var(--sp-3); + border: 0; + border-radius: var(--r-pill); + font: inherit; + font-weight: 500; + color: var(--c-black); + background: transparent; + cursor: pointer; + transition: color 0.2s var(--ease), background-color 0.2s var(--ease), border-color 0.2s var(--ease); +} + +.vc-app .pager__num { + min-width: var(--control-h); + padding: 0 var(--sp-1); +} + +.vc-app .pager__num:hover { + background: rgba(0, 0, 0, 0.05); +} + +.vc-app .pager__num.is-current { + color: var(--c-white); + background: var(--c-black); +} + +.vc-app .pager__btn { + border: 1px solid var(--c-line); + background: var(--c-white); +} + +.vc-app .pager__btn--icon { + min-width: 4.5rem; +} + +.vc-app .pager__btn:hover:not([disabled]) { + border-color: var(--c-black); +} + +.vc-app .pager__btn[disabled] { + opacity: 0.4; + cursor: not-allowed; +} + +.vc-app .pager__gap { + padding-inline: 0.25rem; + font-weight: 500; + opacity: 0.5; +} + +/* ========================================================= + Tour archive — responsive + ========================================================= */ +@media (max-width: 68.75rem) { +.vc-app .filters__row { + flex-wrap: wrap; + } + +.vc-app .fsearch, +.vc-app .fsel { + flex: 1 1 12rem; + } + +.vc-app .filters__apply { + flex: 1 1 100%; + } + +.vc-app .tgrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + +} + +@media (max-width: 48rem) { +.vc-app .filters__bar { + justify-content: flex-start; + } + +.vc-app .filters__checks { + gap: var(--sp-2) var(--sp-5); + } + +} + +@media (max-width: 40rem) { +.vc-app .filters__row { + gap: var(--sp-2); + padding: var(--sp-2); + } + +.vc-app .fsearch, +.vc-app .fsel { + flex: 1 1 100%; + } + +.vc-app .filters__bar { + padding: var(--sp-3); + } + +.vc-app .filters__sort { + width: 100%; + justify-content: space-between; + } + +.vc-app .tgrid { + grid-template-columns: minmax(0, 1fr); + } + +.vc-app .titem__desc { + width: 100%; + text-align: left; + } + +.vc-app .pager__btn--icon { + min-width: 0; + } + +} + +/* ========================================================= + Tour details — hero + ========================================================= */ +.vc-app .hero-tour { + position: relative; + display: grid; + align-items: end; + /* one scale drives the display word, the kicker and the figure, so the three + keep the proportions they have in the design at every width */ + --word: clamp(3rem, 0.4rem + 9.5vw, 11.25rem); + min-height: clamp(26rem, 62svh, 38rem); + margin: var(--page-inset); + padding-block: calc(var(--header-h) + var(--sp-5)) clamp(0.75rem, 0.4rem + 1vw, 1.5rem); + border-radius: var(--r-xl); + isolation: isolate; + overflow: clip; +} + +/* the sky photo is framed on its upper third so the grass at its foot stays out + of shot, and the wash over it carries the blue into the brand turquoise */ +.vc-app .hero-tour__photo { + position: absolute; + inset: 0; + z-index: -2; + width: 100%; + height: 100%; + border-radius: inherit; + object-fit: cover; + object-position: center 26%; +} + +.vc-app .hero-tour__sky { + position: absolute; + inset: 0; + z-index: -1; + border-radius: inherit; + background: + radial-gradient(70% 50% at 50% 100%, rgba(255, 255, 255, 0.3) 0%, rgba(255, 255, 255, 0) 70%), + linear-gradient(180deg, rgba(27, 147, 166, 0.82) 0%, rgba(44, 175, 194, 0.62) 45%, rgba(111, 208, 221, 0.78) 100%); +} + +.vc-app .hero-tour__inner { + display: flex; + flex-direction: column; + align-items: center; +} + +/* the block is exactly as wide as the display word, so it can sit centred in + the hero while the breadcrumb and the kicker line up on the word's left edge */ +.vc-app .hero-tour__block { + display: flex; + position: absolute; + top: 112px; + flex-direction: column; + align-items: flex-start; + gap: var(--sp-2); + width: max-content; + max-width: 100%; +} + +.vc-app .crumbs__list { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--sp-1); + margin: 0; + padding: 0; + list-style: none; + font-size: 0.75rem; + font-weight: 500; +} + +.vc-app .crumbs__list li+li::before { + content: "/"; + margin-inline-end: var(--sp-1); + opacity: 0.5; +} + +.vc-app .crumbs__list a { + opacity: 0.5; + transition: opacity 0.2s var(--ease); +} + +.vc-app .crumbs__list a:hover { + opacity: 1; +} + +.vc-app .hero-tour__kicker { + margin: 0; + font-size: max(1.25rem, calc(var(--word) * 0.222)); + font-weight: 500; + letter-spacing: -0.01em; +} + +/* The figure overlaps the word, so the stage carries the display size and both + children are measured in em against it - the offsets are the Figma ones + (word at x487/y193 at 180px, figure at x756/y58, 263x447) expressed as ratios, + which keeps the composition identical at every width. */ +.vc-app .hero-tour__stage { + position: relative; + align-self: stretch; + font-size: var(--word); + isolation: isolate; +} + +.vc-app .hero-tour__word { + margin: 0; + font-size: 1em; + font-weight: 500; + line-height: 0.95; + letter-spacing: -0.02em; + color: var(--c-white); +} + +.vc-app .hero-tour__figure { + position: absolute; + left: 2em; + top: -0.5em; + z-index: 1; + width: auto; + height: 2.48em; + object-fit: contain; + scale: 1.2; +} + +/* ========================================================= + Tour details — layout + ========================================================= */ +.vc-app .tour__layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 26rem; + align-items: start; + gap: clamp(1.5rem, 1rem + 1vw, 2rem); +} + +.vc-app .tour__main { + display: flex; + flex-direction: column; + gap: var(--sp-5); + min-width: 0; +} + +.vc-app .tour__block { + display: flex; + flex-direction: column; + gap: var(--sp-1); +} + +.vc-app .tour__ct { + margin: 0; + font-size: 1rem; + font-weight: 700; +} + +.vc-app .tour__text { + margin: 0; + font-size: 1rem; + font-weight: 500; + line-height: 1.6; + text-align: justify; + opacity: 0.7; +} + +.vc-app .tour__text+.tour__text { + margin-block-start: var(--sp-3); +} + +/* --- title row --- */ +.vc-app .tour-head { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--sp-3) var(--sp-4); +} + +.vc-app .tour-head__title { + margin: 0; + font-size: clamp(1.75rem, 1.2rem + 2.2vw, 2.5rem); + font-weight: 500; + letter-spacing: -0.01em; +} + +.vc-app .tmeta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--sp-4); + font-size: 1rem; + font-weight: 500; +} + +.vc-app .tmeta__item { + display: inline-flex; + align-items: center; + gap: var(--sp-1); +} + +.vc-app .tmeta__item b { + font-weight: 500; +} + +.vc-app .tmeta__item i { + font-size: 0.75rem; + font-style: normal; + opacity: 0.3; +} + +.vc-app .tmeta__star { + color: var(--c-accent); +} + +.vc-app .tmeta__icon { + opacity: 0.5; +} + +.vc-app .tmeta__rule { + width: 1px; + height: 1.5rem; + background: var(--c-line); +} + +/* --- the four facts under the title --- */ +.vc-app .facts { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--sp-1); + margin: 0; + padding: 0; + list-style: none; +} + +.vc-app .fact, +.vc-app .note { + display: flex; + align-items: center; + gap: var(--sp-2); + min-width: 0; + border-radius: var(--r-card); + background: var(--c-teal-05); +} + +.vc-app .fact { + padding: var(--sp-4) var(--sp-3); +} + +.vc-app .fact__icon, +.vc-app .note__icon { + flex: none; + color: var(--c-teal); +} + +.vc-app .fact__text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.vc-app .fact__k { + font-size: 0.75rem; + font-weight: 500; + color: var(--c-teal); +} + +.vc-app .fact__v { + font-size: 1rem; + font-weight: 500; +} + +/* --- gallery --- */ +.vc-app .gallery { + display: grid; + grid-template-columns: 350fr 245fr 245fr; + gap: var(--sp-3); +} + +.vc-app .gallery img { + width: 100%; + height: 100%; + aspect-ratio: 245 / 194; + border-radius: var(--r-xl); + object-fit: cover; +} + +.vc-app .gallery__hero { + grid-row: span 2; + aspect-ratio: auto; + border-radius: var(--r-card); +} + +/* --- included / not included --- */ +.vc-app .lists { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--sp-4) clamp(1.5rem, 1rem + 1.5vw, 1.6875rem); +} + +.vc-app .ilist { + display: flex; + flex-direction: column; + gap: var(--sp-3); +} + +.vc-app .ilist__items { + display: flex; + flex-direction: column; + gap: var(--sp-3); + margin: 0; + padding: 0; + list-style: none; +} + +.vc-app .ilist__items li { + display: flex; + align-items: center; + gap: var(--sp-3); +} + +.vc-app .ilist__items p { + margin: 0; + font-size: 1rem; + font-weight: 500; + line-height: 1.5; + text-align: justify; + opacity: 0.7; +} + +.vc-app .tick { + display: grid; + flex: none; + place-items: center; + width: 1.5rem; + height: 1.5rem; + border-radius: var(--r-pill); +} + +.vc-app .tick--yes { + color: var(--c-green); + background: rgba(52, 199, 89, 0.1); +} + +.vc-app .tick--no { + color: var(--c-red); + background: rgba(255, 56, 60, 0.1); +} + +/* --- good to know --- */ +.vc-app .notes { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--sp-1); + margin: 0; + padding: 0; + list-style: none; +} + +.vc-app .note { + gap: var(--sp-2); + padding: var(--sp-4) var(--sp-3); +} + +.vc-app .note p { + margin: 0; + font-size: 1rem; + font-weight: 500; + line-height: 1.4; +} + +/* --- the tinted key/value cards, shared by the policy row and the sidebar --- */ +.vc-app .statcard { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-4); + border-radius: var(--r-card); +} + +.vc-app .statcard__icon { + flex: none; +} + +.vc-app .statcard__text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.vc-app .statcard__k { + font-size: 0.75rem; + font-weight: 500; +} + +.vc-app .statcard__v { + font-size: 1rem; + font-weight: 500; +} + +.vc-app .statcard--yes { + background: rgba(52, 199, 89, 0.05); +} + +.vc-app .statcard--yes .statcard__icon, +.vc-app .statcard--yes .statcard__k { + color: var(--c-green); +} + +.vc-app .statcard--no { + background: rgba(255, 56, 60, 0.05); +} + +.vc-app .statcard--no .statcard__icon, +.vc-app .statcard--no .statcard__k { + color: var(--c-red); +} + +.vc-app .statcard--teal { + background: var(--c-teal-05); +} + +.vc-app .statcard--teal .statcard__icon, +.vc-app .statcard--teal .statcard__k { + color: var(--c-teal); +} + +.vc-app .policy { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--sp-3); + margin-block-start: var(--sp-3); +} + +/* ========================================================= + Tour details — booking sidebar + ========================================================= */ +.vc-app .tour__aside { + position: sticky; + top: calc(var(--header-h) + var(--sp-3)); +} + +.vc-app .bookcard { + display: flex; + flex-direction: column; + gap: var(--sp-1); + padding: var(--sp-3); + border: 1px solid var(--c-line); + border-radius: var(--r-card); + background: var(--c-white); +} + +.vc-app .bookcard__title { + display: flex; + align-items: center; + gap: var(--sp-1); + margin: 0 0 var(--sp-1); + padding: var(--sp-1) var(--sp-1) 0; + font-size: 1rem; + font-weight: 500; +} + +.vc-app .bookcard__price { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--sp-3); + padding: var(--sp-3); + border: 1px dashed var(--c-line); + border-radius: var(--r-card); +} + +.vc-app .price { + display: grid; + grid-template-areas: + "k k" + "v u"; + align-items: baseline; + gap: 0 var(--sp-1); + margin: 0; +} + +.vc-app .price__k { + grid-area: k; + font-size: 0.75rem; + font-weight: 500; + opacity: 0.3; +} + +.vc-app .price__v { + grid-area: v; + font-size: 2rem; + font-weight: 500; + letter-spacing: -0.01em; +} + +.vc-app .price__unit { + grid-area: u; + font-size: 0.75rem; + font-weight: 500; + opacity: 0.3; +} + +.vc-app .bookcard__cta { + flex: none; +} + +/* ========================================================= + Tour details — responsive + ========================================================= */ +@media (max-width: 68.75rem) { +.vc-app .tour__layout { + grid-template-columns: minmax(0, 1fr); + } + +.vc-app .tour__aside { + position: static; + order: -1; + } + +.vc-app .facts { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + +.vc-app .notes { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + +} + +@media (max-width: 48rem) { +.vc-app .gallery { + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: auto; + aspect-ratio: auto; + } + +.vc-app .gallery img { + aspect-ratio: 4 / 3; + } + +.vc-app .gallery__hero { + grid-column: span 2; + grid-row: auto; + aspect-ratio: 16 / 9; + } + +.vc-app .lists { + grid-template-columns: minmax(0, 1fr); + } + +} + +@media (max-width: 40rem) { +.vc-app .hero-tour { + min-height: clamp(24rem, 70svh, 34rem); + } + +.vc-app .facts, +.vc-app .notes { + grid-template-columns: minmax(0, 1fr); + } + +.vc-app .policy { + grid-template-columns: minmax(0, 1fr); + } + +.vc-app .tour__text, +.vc-app .ilist__items p { + text-align: left; + } + +.vc-app .bookcard__price { + justify-content: center; + } + +.vc-app .bookcard__cta { + width: 100%; + } + +} + diff --git a/viator-collector/assets/icons/sprite.svg b/viator-collector/assets/icons/sprite.svg new file mode 100644 index 0000000..7ab87c7 --- /dev/null +++ b/viator-collector/assets/icons/sprite.svg @@ -0,0 +1,176 @@ + diff --git a/viator-collector/assets/img/bus-iso.png b/viator-collector/assets/img/bus-iso.png new file mode 100644 index 0000000..ec779d7 Binary files /dev/null and b/viator-collector/assets/img/bus-iso.png differ diff --git a/viator-collector/assets/img/bus-open-top.png b/viator-collector/assets/img/bus-open-top.png new file mode 100644 index 0000000..193ad06 Binary files /dev/null and b/viator-collector/assets/img/bus-open-top.png differ diff --git a/viator-collector/assets/img/ellipse-17-2.png b/viator-collector/assets/img/ellipse-17-2.png new file mode 100644 index 0000000..6a0ccf5 Binary files /dev/null and b/viator-collector/assets/img/ellipse-17-2.png differ diff --git a/viator-collector/assets/img/ellipse-17.png b/viator-collector/assets/img/ellipse-17.png new file mode 100644 index 0000000..6a0ccf5 Binary files /dev/null and b/viator-collector/assets/img/ellipse-17.png differ diff --git a/viator-collector/assets/img/hero.png b/viator-collector/assets/img/hero.png new file mode 100644 index 0000000..888a70f Binary files /dev/null and b/viator-collector/assets/img/hero.png differ diff --git a/viator-collector/assets/img/hero2.png b/viator-collector/assets/img/hero2.png new file mode 100644 index 0000000..8b870a7 Binary files /dev/null and b/viator-collector/assets/img/hero2.png differ diff --git a/viator-collector/assets/img/hero3.jpg b/viator-collector/assets/img/hero3.jpg new file mode 100644 index 0000000..0233fad Binary files /dev/null and b/viator-collector/assets/img/hero3.jpg differ diff --git a/viator-collector/assets/img/tower.png b/viator-collector/assets/img/tower.png new file mode 100644 index 0000000..07b815a Binary files /dev/null and b/viator-collector/assets/img/tower.png differ diff --git a/viator-collector/assets/js/vc-design.js b/viator-collector/assets/js/vc-design.js new file mode 100644 index 0000000..b3cda7d --- /dev/null +++ b/viator-collector/assets/js/vc-design.js @@ -0,0 +1,95 @@ +/* Viator Collector — front-end interactions for the "Hop On Hop Off" design. + Ported from the mockup's main.js, trimmed to what the plugin renders inside + #vc-app. Listing filtering itself stays server-side (GET form submit); this + file only adds UX: card rails, FAQ accordion, testimonials marquee, the date + field, and auto-submit on quick filter toggles. */ +(function () { + "use strict"; + + // The design is scoped to .vc-app wrappers; there can be several on one page + // (e.g. multiple homepage section shortcodes), so query document-wide. + var app = document; + + /* ---------- card rails (prev / next) ---------- */ + function railStep(rail) { + var card = rail.firstElementChild; + if (!card) return rail.clientWidth; + var gap = parseFloat(getComputedStyle(rail).columnGap) || 0; + return card.getBoundingClientRect().width + gap; + } + + function syncRailButtons(rail, prev, next) { + var max = rail.scrollWidth - rail.clientWidth - 1; + if (prev) prev.disabled = rail.scrollLeft <= 0; + if (next) next.disabled = rail.scrollLeft >= max; + } + + Array.prototype.forEach.call(app.querySelectorAll(".rail"), function (rail) { + var prev = document.querySelector('[data-rail-prev="' + rail.id + '"]'); + var next = document.querySelector('[data-rail-next="' + rail.id + '"]'); + + if (prev) prev.addEventListener("click", function () { + rail.scrollBy({ left: -railStep(rail), behavior: "smooth" }); + }); + if (next) next.addEventListener("click", function () { + rail.scrollBy({ left: railStep(rail), behavior: "smooth" }); + }); + + rail.addEventListener("scroll", function () { + syncRailButtons(rail, prev, next); + }, { passive: true }); + window.addEventListener("resize", function () { + syncRailButtons(rail, prev, next); + }); + syncRailButtons(rail, prev, next); + }); + + /* ---------- testimonials marquee: duplicate for a seamless loop ---------- */ + Array.prototype.forEach.call(app.querySelectorAll("[data-marquee] .marquee__track"), function (track) { + Array.prototype.slice.call(track.children).forEach(function (node) { + var copy = node.cloneNode(true); + copy.setAttribute("aria-hidden", "true"); + track.appendChild(copy); + }); + }); + + /* ---------- FAQ accordion ---------- */ + Array.prototype.forEach.call(app.querySelectorAll("[data-accordion]"), function (accordion) { + accordion.addEventListener("click", function (e) { + var btn = e.target.closest(".acc__q"); + if (!btn) return; + var open = btn.getAttribute("aria-expanded") === "true"; + Array.prototype.forEach.call(accordion.querySelectorAll(".acc__q"), function (other) { + other.setAttribute("aria-expanded", "false"); + }); + btn.setAttribute("aria-expanded", String(!open)); + }); + }); + + /* ---------- date field: text placeholder, native picker on focus ---------- */ + Array.prototype.forEach.call(app.querySelectorAll("[data-datefield]"), function (dateField) { + dateField.addEventListener("focus", function () { + dateField.type = "date"; + if (typeof dateField.showPicker === "function") { + try { dateField.showPicker(); } catch (err) { /* not user-activated */ } + } + }); + dateField.addEventListener("blur", function () { + if (!dateField.value) dateField.type = "text"; + }); + }); + + /* ---------- listing: auto-submit on quick toggles (checks / sort) ---------- */ + Array.prototype.forEach.call(app.querySelectorAll("form.filters, form#vc-filter-form"), function (form) { + form.addEventListener("change", function (e) { + if (e.target.closest(".vc-autosubmit")) form.submit(); + }); + }); + + /* ---------- single tour: "view all photos" scrolls the gallery ---------- */ + var viewAll = app.querySelector(".tg-viewall, .gallery__viewall"); + if (viewAll) viewAll.addEventListener("click", function () { + var g = app.querySelector(".gallery, .tour-gallery"); + if (g) g.scrollIntoView({ behavior: "smooth" }); + }); +})(); diff --git a/viator-collector/build/scope-css.js b/viator-collector/build/scope-css.js new file mode 100644 index 0000000..31d37cf --- /dev/null +++ b/viator-collector/build/scope-css.js @@ -0,0 +1,159 @@ +#!/usr/bin/env node +/** + * scope-css.js — prefix every selector in the "new front" stylesheet with + * `#vc-app` so the ported design cannot leak into (or fight) the WordPress + * theme. Dependency-free brace-aware parser. + * + * node build/scope-css.js "../new front/css/style.css" assets/css/vc-design.css + * + * Rules: + * - `:root { ... }` is left untouched (global custom properties). + * - `@keyframes` names and step selectors (`0%`, `from`, `to`) are untouched. + * - `@font-face` descriptors are untouched. + * - `@media` / `@supports` wrappers are kept; their inner rules are scoped. + * - Leading `html` / `body` / `*` tokens are mapped into the `#vc-app` scope. + * - Every other selector gets a `#vc-app ` prefix. + */ +'use strict'; +const fs = require('fs'); + +const SCOPE = '.vc-app'; + +function prefixSelector(sel) { + sel = sel.trim(); + if (!sel) return sel; + // Leave :root and keyframe-ish untouched (keyframes handled by caller). + if (sel === ':root' || sel.startsWith(':root')) return sel; + // html -> scope root + if (sel === 'html' || sel.startsWith('html ') || sel.startsWith('html.') || + sel.startsWith('html:') || sel.startsWith('html[')) { + return SCOPE + sel.slice(4); + } + // body -> scope root + if (sel === 'body' || sel.startsWith('body ') || sel.startsWith('body.') || + sel.startsWith('body:') || sel.startsWith('body[') || sel.startsWith('body>')) { + return SCOPE + sel.slice(4); + } + // Universal selector: `*`, `*::before`, `* + *` ... + if (sel === '*' || sel.startsWith('*')) { + return SCOPE + ' ' + sel; + } + return SCOPE + ' ' + sel; +} + +function prefixSelectorList(prelude) { + return prelude + .split(',') + .map(function (s) { return prefixSelector(s); }) + .join(',\n'); +} + +/** + * Transform a run of CSS text at a given nesting context. + * @param {string} css + * @param {boolean} inKeyframes when true, rule preludes are step selectors -> keep as-is + */ +function transform(css, inKeyframes) { + let out = ''; + let i = 0; + const n = css.length; + let buf = ''; + + while (i < n) { + const ch = css[i]; + + // Preserve comments verbatim. + if (ch === '/' && css[i + 1] === '*') { + const end = css.indexOf('*/', i + 2); + const stop = end === -1 ? n : end + 2; + buf += css.slice(i, stop); + i = stop; + continue; + } + + if (ch === '{') { + const prelude = buf; + buf = ''; + // Find the matching closing brace for this block. + let depth = 1; + let j = i + 1; + while (j < n && depth > 0) { + if (css[j] === '/' && css[j + 1] === '*') { + const e = css.indexOf('*/', j + 2); + j = e === -1 ? n : e + 2; + continue; + } + if (css[j] === '{') depth++; + else if (css[j] === '}') depth--; + if (depth === 0) break; + j++; + } + const inner = css.slice(i + 1, j); + // Pull any comments out of the prelude and emit them before the rule, + // so a comment sitting in front of a selector never gets scoped into it. + const split = extractComments(prelude); + out += split.comments; + const trimmed = split.clean.trim(); + const lower = trimmed.toLowerCase(); + + if (lower.startsWith('@keyframes') || lower.startsWith('@-webkit-keyframes')) { + // Keep name; do not scope step selectors inside. + out += trimmed + ' {' + inner + '}'; + } else if (lower.startsWith('@font-face')) { + out += trimmed + ' {' + inner + '}'; + } else if (lower.startsWith('@media') || lower.startsWith('@supports') || + lower.startsWith('@container') || lower.startsWith('@layer')) { + // Recurse into the block to scope inner rules. + out += trimmed + ' {\n' + transform(inner, false) + '}'; + } else if (lower.startsWith('@')) { + // Any other at-rule with a block: leave prelude, recurse cautiously. + out += trimmed + ' {' + transform(inner, inKeyframes) + '}'; + } else if (inKeyframes) { + out += trimmed + ' {' + inner + '}'; + } else { + // Ordinary style rule -> scope the selector list. + out += prefixSelectorList(trimmed) + ' {' + inner + '}'; + } + out += '\n\n'; + i = j + 1; + continue; + } + + if (ch === '}') { + // Stray close (end of a recursion slice) — flush and stop-ish. + out += buf; + buf = ''; + i++; + continue; + } + + buf += ch; + i++; + } + out += buf; + return out; +} + +// Separate comments from the selector prelude. Comments are returned intact +// (to be emitted before the rule); `clean` is the comment-free selector text. +function extractComments(prelude) { + let comments = ''; + const clean = prelude.replace(/\/\*[\s\S]*?\*\//g, function (c) { + comments += c + '\n'; + return ' '; + }); + return { comments: comments, clean: clean }; +} + +function main() { + const src = process.argv[2] || '../new front/css/style.css'; + const dest = process.argv[3] || 'assets/css/vc-design.css'; + const css = fs.readFileSync(src, 'utf8'); + const header = '/* Generated by build/scope-css.js from ' + src + + ' — do not edit by hand. */\n\n'; + const out = header + transform(css, false).replace(/\n{3,}/g, '\n\n'); + fs.writeFileSync(dest, out, 'utf8'); + process.stdout.write('Wrote ' + dest + ' (' + out.length + ' bytes)\n'); +} + +main(); diff --git a/viator-collector/docs/superpowers/plans/2026-09-11-new-front-reskin.md b/viator-collector/docs/superpowers/plans/2026-09-11-new-front-reskin.md new file mode 100644 index 0000000..bc19e6e --- /dev/null +++ b/viator-collector/docs/superpowers/plans/2026-09-11-new-front-reskin.md @@ -0,0 +1,131 @@ +# New Front Re-skin — Implementation Plan + +> **For agentic workers:** Use superpowers:executing-plans to implement task-by-task. This is a WordPress/PHP visual re-skin: tasks verify by rendering markup / inspecting output, not by pytest. + +**Goal:** Replace the `viator-collector` plugin's entire front-end look with the "Hop On Hop Off" design from `../new front/`, keeping all WordPress data/query logic. + +**Architecture:** Port the mockup's markup into the plugin's PHP templates/shortcodes; port `new front/css/style.css` scoped under `#vc-app`; port the SVG icon sprite and the needed `main.js` interactions; ship decorative assets. Drop Lucide + noUiSlider + old CSS. + +**Tech Stack:** PHP (WordPress), CSS (token-based), vanilla JS, Node (build-time CSS scoper only). + +**Spec:** `docs/superpowers/specs/2026-09-11-new-front-reskin-design.md` + +## Global Constraints + +- All plugin output wrapped in `#vc-app`; every ported CSS rule prefixed `#vc-app` (except `:root`, `@keyframes` steps, `@font-face`). +- No change to CPT/taxonomy/meta keys, importer, cron, API, or filter/query logic. +- Server-side filtering/sort/pagination retained (GET params). +- Site header/nav/footer remain theme-owned (not rendered by plugin). +- Real tour data fills all content; only decorative images ship with plugin. +- Text domain `viator-collector`; escape all output. + +--- + +## Phase 1 — Foundation assets + +### Task 1: CSS scoper + scoped stylesheet +**Files:** Create `build/scope-css.js`, `assets/css/vc-design.css` (generated); source `../new front/css/style.css`. +- [ ] Write `build/scope-css.js`: brace-aware parser; prefix each selector with `#vc-app ` except `:root`, keyframe steps, `@font-face` descriptors; map `html`/`body`/`*` leading tokens to `#vc-app` scope; leave `@media`/`@supports` wrappers, recurse into them; keep `@keyframes` names/steps intact. +- [ ] Run it to generate `assets/css/vc-design.css`. +- [ ] Verify: no rule leaks (grep that every non-`:root`/non-keyframe selector line contains `#vc-app`); `:root` tokens present; keyframes intact. +- [ ] Commit. + +### Task 2: Icon sprite + helpers +**Files:** Create `assets/icons/sprite.svg`, add helpers to `includes/helpers.php`. +- [ ] Extract the `` set from `new front/index.html` into `assets/icons/sprite.svg`, plus a ``. +- [ ] `vc_print_icon_sprite()` — prints the sprite once (static guard). +- [ ] `vc_icon($name, $extra_class='')` — returns ``. +- [ ] `vc_ring_badge($label, $url, $variant='card')` — returns the ring-badge anchor markup. +- [ ] Verify: helpers return expected strings; sprite has all icons referenced by templates. +- [ ] Commit. + +### Task 3: Decorative assets +**Files:** Create `assets/img/` (copy bus-iso.png, bus-open-top.png, tower.png, ellipse-17.png, ellipse-17-2.png, hero.png, hero2.png, hero3.jpg from `../new front/assets`). +- [ ] Copy the decorative images. +- [ ] Add `vc_asset_url($rel)` helper (returns `VC_PLUGIN_URL . 'assets/' . $rel`) if not present. +- [ ] Commit. + +### Task 4: JS interactions +**Files:** Create `assets/js/vc-design.js`; trim `assets/js/vc-frontend.js`. +- [ ] Port from `main.js`: card rails, FAQ accordion, testimonials marquee (ring-badge spin is CSS). Omit header/nav/sticky and the client-side listing filter. +- [ ] In `vc-frontend.js`, remove noUiSlider init; keep auto-submit + mobile filter drawer behaviors used by the listing. +- [ ] Verify: no reference to removed globals; file loads without error. +- [ ] Commit. + +### Task 5: Enqueue swap +**Files:** Modify `includes/class-vc-display.php::enqueue_assets`. +- [ ] Remove Lucide, noUiSlider (css+js), `vc-listing.css`, `vc-single.css`, `viator-collector.css` enqueues. +- [ ] Add Plus Jakarta Sans (Google Fonts), `vc-design.css`, `vc-design.js`. Print sprite via `wp_footer`/inline on our pages. +- [ ] Keep gating (single/archive/shortcode). Bump `VC_VERSION`. +- [ ] Verify: on a tour page only new assets load; Lucide/noUiSlider absent. +- [ ] Commit. + +## Phase 2 — Tour card (shared) + +### Task 6: `card-tour.php` → dcard +**Files:** Modify `templates/card-tour.php`. +- [ ] Rewrite to emit `article.dcard` (variants: `dcard--wide` for listing `li.titem`, plain `dcard` for homepage `li` rail). Slots: `.dcard__img` (`vc_tour_image_html`), `.rating` (`_vc_rating` + `vc_icon('star')`), `.dcard__title` (permalinked), `.dcard__meta` (Start at → `vc_money`, Duration → `vc_duration_human`), `vc_ring_badge('BOOK NOW', permalink)`, badge via `vc_card_badge`, `.titem__desc` (excerpt) for listing variant. +- [ ] Accept `$variant` arg; default `wide`. +- [ ] Verify: renders both variants with real data; icons resolve. +- [ ] Commit. + +## Phase 3 — Listing / archive + +### Task 7: `listing.php` hero + filter bar +**Files:** Modify `templates/listing.php`. +- [ ] Replace hero + sidebar with `section.hero.hero--archive` + `form.filters` (GET). Map controls to existing params: search→`vc_q`, `dest[]`, `cat[]`, price ``→`dur[]` (half/full/multi), `filters__bar` checks→`freecancel`/`bestdeal`/`sellout`, sort control→`sort`, Apply submit. +- [ ] Verify: submitting each control updates results via server query; active-filter chips still work (restyled). +- [ ] Commit. + +### Task 8: `listing.php` grid + results + pager +**Files:** Modify `templates/listing.php`. +- [ ] Render `ul.tgrid` of `card-tour.php` (`wide`); `.results` with server count; custom `.pager`/`.pager__num` from `$vc_query->max_num_pages` preserving query string; styled empty state. +- [ ] Verify: pagination navigates server pages; count correct; URLs shareable. +- [ ] Commit. + +## Phase 4 — Single tour + +### Task 9: `single-viator_tour.php` hero + head + facts +**Files:** Modify `templates/single-viator_tour.php`. +- [ ] `section.hero-tour` (primary image, kicker, destination word, `tower.png` figure), breadcrumb, `.tour-head` + `.tmeta` (rating/location/supplier), `ul.facts` (duration/max/free-cancel/category) via `vc_icon`. +- [ ] Verify: renders with real meta; missing fields omitted cleanly. +- [ ] Commit. + +### Task 10: `single-viator_tour.php` body blocks +**Files:** Modify `templates/single-viator_tour.php`. +- [ ] `tour__block`s: Overview (`the_content`), Gallery (`.gallery` from gallery URLs), includes/excludes (`.ilist` + `.tick`), Good to know, Cancellation Policy (`.policy` + `.statcard--yes/--no`), Reviews. +- [ ] Verify: each block appears only when data present; matches mockup. +- [ ] Commit. + +### Task 11: `single-viator_tour.php` booking aside + mobile bar +**Files:** Modify `templates/single-viator_tour.php`. +- [ ] `aside.tour__aside > .bookcard`: From/price, Check Availability → `_vc_booking_url`, `statcard--teal` free-cancel + `statcard--yes` reserve-now. Restyle mobile book bar. +- [ ] Verify: CTA links to booking URL; price from meta. +- [ ] Commit. + +## Phase 5 — Homepage sections + +### Task 12: Re-skin data shortcodes to rails +**Files:** Modify `includes/class-vc-display.php` (render_section) or `class-vc-sections.php`; `templates/card-destination.php`, `card-attraction.php`, `card-activity.php`. +- [ ] `viator_top_destinations`/`viator_top_tours` → `section.section` + `.sec-head` (title, lead, See-all, `.rail-nav` prev/next) + `ul.rail` of `.dcard`s. Attractions/activities → feature/activity section styling. +- [ ] Verify: rails scroll via `vc-design.js`; real terms/tours render. +- [ ] Commit. + +### Task 13: New static-chrome shortcodes +**Files:** Modify `includes/class-vc-display.php` (register) + create `templates/section-hero.php`, `section-faq.php`, `section-testimonials.php`. +- [ ] `viator_hero` → `section.hero` + `form.booking` (Location from `viator_destination` terms, Date, Seats) submitting to listing archive with filter params. +- [ ] `viator_faq` → `section.faq` accordion; content via shortcode inner content / `atts` (Q/A pairs). +- [ ] `viator_testimonials` → `section.testimonials` marquee; items via inner content / `atts`. +- [ ] Register in `SHORTCODES` + enqueue gating. +- [ ] Verify: shortcodes render on a Page; booking form routes to filtered listing. +- [ ] Commit. + +## Phase 6 — Verify & finish + +### Task 14: Full-page verification +- [ ] With a theme (Astra) active, load listing, single, and a homepage-shortcode Page; confirm visual match to mockup within content area; no style bleed outside `#vc-app`; no console errors; Lucide/noUiSlider not loaded; server filtering/pagination work. +- [ ] Update `SHORTCODES.md`; bump version; final commit. + +## Self-review notes +- Spec coverage: scoping (T1), icons/ring-badge (T2), assets (T3), JS (T4), enqueue (T5), card (T6), listing (T7–8), single (T9–11), homepage data+chrome (T12–13), acceptance (T14) — all mapped. +- Duration mismatch handled in T7 (plugin buckets). FAQ/testimonials content source resolved in T13 (inner content/atts). Footer back-to-top badge dropped (theme-owned) per spec default. diff --git a/viator-collector/docs/superpowers/specs/2026-09-11-new-front-reskin-design.md b/viator-collector/docs/superpowers/specs/2026-09-11-new-front-reskin-design.md new file mode 100644 index 0000000..d72e99b --- /dev/null +++ b/viator-collector/docs/superpowers/specs/2026-09-11-new-front-reskin-design.md @@ -0,0 +1,299 @@ +# Viator Collector — "Hop On Hop Off" Front-End Re-skin + +**Date:** 2026-09-11 +**Status:** Design — pending approval +**Author:** Amir Bagheri (with Claude) + +## 1. Goal + +Replace the entire visual front-end of the `viator-collector` WordPress +plugin with the new "Hop On Hop Off" design that currently exists as a +static mockup in `../new front/`, **without changing any of the plugin's +data model, query logic, or WordPress plumbing**. Every dynamic value +keeps coming from the `viator_tour` post type, its meta, and its +taxonomies exactly as today; only markup, CSS, icons, JS interactions, +and decorative assets change. + +## 2. Decisions (locked) + +| Question | Decision | +|---|---| +| Surfaces to re-skin | All three: listing/archive, single tour, homepage sections | +| Header / nav / footer | **Content only** — the WordPress theme (e.g. Astra) keeps providing site header/nav/footer. The plugin re-skins only its content area. | +| Listing filter behavior | **Keep server-side** (WP_Query + GET params + server pagination). Only the look changes. No switch to `main.js` client-side filtering. | +| Homepage static chrome | **Include it** — plugin also ships hero/booking, FAQ, and testimonials as shortcodes so the full `index.html` look is achievable. | +| Old CSS/JS | **Replace** — retire `vc-listing.css`, `vc-single.css`, `viator-collector.css`, `vc-sections.css`, and the Lucide dependency. | + +## 3. Source of truth + +The three canonical mockup pages are: + +- `new front/index.html` — homepage (hero+booking, destinations rail, feature/bus section, most-liked tours rail, about, testimonials marquee, articles, FAQ) +- `new front/tours.html` — listing/archive (hero with horizontal filter bar, tour grid, results count, pager) +- `new front/tour.html` — single tour (hero-tour banner, facts, overview, gallery, includes/excludes, good-to-know, cancellation policy, reviews, booking aside) + +Driven by `new front/css/style.css` (~3096 lines, token-based) and +`new front/js/main.js` (~419 lines). + +**Ignored:** `new front/tourdetailsinfo.html` and +`new front/tourdetailscontent.html` are raw Figma/React export scraps +(absolute-positioned, Tailwind classNames). They are not part of the +design system and will not be used except as visual reference for the +booking aside (already fully expressed in `tour.html`). + +## 4. Architecture + +### 4.1 Scoping strategy (critical) + +Because the plugin renders inside a WordPress theme, the new design's +generic class names (`.container`, `.section`, `.hero`, `.btn`, +`.rating`, `.meta`, `.dcard`, `.footer`, `.site-header`…) and its global +resets (`*`, `html`, `body`, `ul`, `a`, `button`) **must not leak** into +or fight the theme. + +Approach: + +- Every plugin surface is wrapped in the existing `#vc-app` scope + (already used by `shortcode_listing` and `single-viator_tour.php`). + Homepage section shortcodes get wrapped in `#vc-app` too. +- The ported stylesheet is transformed so **every rule is prefixed with + `#vc-app`** (e.g. `.dcard` → `#vc-app .dcard`). The `:root { --… }` + token block stays global (harmless custom properties). +- Global resets on `*`/`html`/`body` are dropped or converted to scoped + equivalents (`#vc-app`, `#vc-app *`). `scroll-behavior`, + `overflow-x:hidden` on `html`/`body` are dropped (theme's concern). +- Header/nav/footer CSS (`.site-header`, `.nav`, `.burger`, `.footer`, + `.skip-link`) from the mockup is **not** ported — the theme owns those. + Exception: the `.footer` "back to top" ring-badge is a reusable widget + we may keep for section use; TBD, default = drop. + +### 4.2 Asset organization + +New plugin asset layout: + +``` +viator-collector/assets/ + css/ + vc-design.css # ported + scoped style.css (single stylesheet) + js/ + vc-frontend.js # existing server-side filter behaviors (kept, trimmed) + vc-design.js # ported interactions: rails, accordion, marquee, ring-badge + img/ + (decorative design assets copied from new front/assets: bus-iso.png, + bus-open-top.png, tower.png, ellipse-17.png, ellipse-17-2.png, + hero.png, hero2.png, hero3.jpg) + icons/ + sprite.svg # the icon set + #ringPathScroll path def +``` + +- **Decorative** images (bus, tower, ellipses, hero backdrops) ship with + the plugin — they are chrome, not tour data. +- **Demo tour images** (`city-*.png`, `liked-*.png`, `tour-*.png`) are + NOT shipped; those slots are filled by real tour data + (`_vc_primary_image_url`, gallery URLs) via `vc_tour_image_html()`. + +### 4.3 Icons + +The mockup uses an inline SVG sprite: a hidden `` +holding `` defs, referenced by +``. It also defines +`#ringPathScroll` (the circular text path for the "BOOK NOW" badge). + +- Ship the sprite as `assets/icons/sprite.svg` and print it once per + plugin page via a small helper (`vc_print_icon_sprite()`), guarded so + it prints at most once. +- Add a helper `vc_icon( $name )` returning + ``. +- Replace all current `` usages with `vc_icon()`. + Lucide enqueue is removed. + +Icon name mapping (Lucide → sprite): + +| Lucide | Sprite | +|---|---| +| map-pin | i-location | +| clock | i-duration | +| users / user | i-person / i-user | +| shield-check | i-shield-check | +| calendar-check | i-calendar | +| rotate-ccw | i-duration (or reuse) | +| clipboard-list | i-grid | +| images | i-grid | +| star (rating) | i-star | + +(Exact per-call mapping finalized during implementation; any missing +glyph is added to the sprite.) + +### 4.4 JavaScript + +`vc-design.js` (new) ports from `main.js` only what our content uses: + +- **Card rails** — `.rail` horizontal scroll with `[data-rail-prev/next]` + buttons (used by homepage destination/tour rails). +- **FAQ accordion** — `[data-accordion]` / `.acc__q`. +- **Testimonials marquee** — `[data-marquee]` duplicate-for-loop. +- **Ring-badge** — the rotating "BOOK NOW" SVG textPath (needs + `#ringPathScroll` in the sprite; animation is CSS). +- Hero booking/availability form `preventDefault` demo handlers become + real submits to the tour's booking URL / listing action (no console + stubs). + +**Explicitly NOT ported:** `main.js` client-side listing filter/sort/ +pager (server-side stays), and header/nav/burger/sticky-header (theme's). + +`vc-frontend.js` is kept for the server-side listing behaviors it already +provides (auto-submit selects/checks, filter drawer on mobile). The +noUiSlider price slider is **removed** — the new design uses a Price +Range `` → `dest[]` (single value) + - Category `` → `price` (values like `0-50`, `50-150`, + `150-1000`; already parsed by `get_filter_state()`) + - Duration `> + + + '; + } + } +} diff --git a/viator-collector/includes/class-vc-cron.php b/viator-collector/includes/class-vc-cron.php new file mode 100644 index 0000000..a2bfa3f --- /dev/null +++ b/viator-collector/includes/class-vc-cron.php @@ -0,0 +1,344 @@ +load_dictionaries(); + } + + self::process_tick( $importer, self::deadline() ); + } finally { + delete_transient( self::LOCK ); + // Spawn the next tick only after the lock is free, so the loopback + // request does not collide with this one. + if ( wp_next_scheduled( self::CONTINUE ) ) { + self::spawn(); + } + } + } + + /** + * Wall-clock time by which a tick must stop, so it never hits the PHP + * max_execution_time and dies mid-page (which would strand the lock). + */ + private static function deadline() { + $max = (int) ini_get( 'max_execution_time' ); + $budget = ( $max > 0 ) ? min( 25, max( 8, $max - 5 ) ) : 25; + return microtime( true ) + $budget; + } + + /** + * Products requested per search page (Viator caps this at 50). + */ + private static function page_size() { + return max( 1, min( 50, absint( vc_get_option( 'products_per_page', 50 ) ) ) ); + } + + /** + * Fire a non-blocking loopback request so WP-Cron processes the queued + * continuation tick even when the site has no visitor traffic. + */ + private static function spawn() { + if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) { + // A real server cron is responsible for running events. + return; + } + $url = site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ); + wp_remote_post( + $url, + array( + 'timeout' => 0.01, + 'blocking' => false, + 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), + 'headers' => array( 'Cache-Control' => 'no-cache' ), + ) + ); + } + + /** + * Initialise a run: refresh dictionaries and build the destination queue. + */ + private static function start_run( VC_Importer $importer ) { + $primed = $importer->prime_dictionaries(); + if ( is_wp_error( $primed ) ) { + VC_Logger::error( 'Sync aborted, could not load dictionaries: ' . $primed->get_error_message() ); + return $primed; + } + + $map = get_transient( 'vc_destination_map' ); + + // A destination allowlist (comma-separated IDs) narrows the crawl to a + // focused set; empty means the whole catalogue. + $allow = array_filter( array_map( 'trim', explode( ',', (string) vc_get_option( 'crawl_destinations', '' ) ) ) ); + if ( ! empty( $allow ) ) { + $queue = array_values( array_unique( $allow ) ); + } else { + $queue = is_array( $map ) ? array_keys( $map ) : array(); + } + + if ( empty( $queue ) ) { + VC_Logger::error( 'Sync aborted: destination list is empty.' ); + return new WP_Error( 'vc_no_destinations', 'No destinations to crawl.' ); + } + + update_option( 'vc_crawl_run_id', gmdate( 'Y-m-d\TH:i:s\Z' ) . '-' . wp_rand( 1000, 9999 ), false ); + update_option( 'vc_crawl_queue', array_values( $queue ), false ); + update_option( 'vc_crawl_start', 1, false ); + update_option( 'vc_ingest_error_count', 0, false ); + update_option( 'vc_log_last_count', 0, false ); + update_option( + 'vc_ingest_totals', + array( + 'destinations_total' => count( $queue ), + 'destinations_done' => 0, + 'pages' => 0, + 'imported' => 0, + 'updated' => 0, + 'duplicate' => 0, + 'deactivated' => 0, + 'failed' => 0, + ), + false + ); + update_option( 'vc_ingest_in_progress', 1, false ); + update_option( 'vc_last_sync_started_at', current_time( 'mysql' ), false ); + VC_Logger::info( sprintf( 'Sync run started — %d destinations queued.', count( $queue ) ) ); + return true; + } + + private static function process_tick( VC_Importer $importer, $deadline ) { + // With full-detail ON every product costs an extra API call, so each + // tick must stay small: one short page per cron minute. Without it, + // search-only is cheap and we can sweep many pages per tick. + if ( vc_get_option( 'fetch_full_detail', 0 ) ) { + $pages_per_run = 1; + $page_size = max( 1, min( 25, absint( vc_get_option( 'detail_batch_size', 15 ) ) ) ); + } else { + $pages_per_run = max( 1, absint( vc_get_option( 'pages_per_run', 10 ) ) ); + $page_size = self::page_size(); + } + $run_id = (string) get_option( 'vc_crawl_run_id', '' ); + $totals = get_option( 'vc_ingest_totals', array() ); + + for ( $i = 0; $i < $pages_per_run; $i++ ) { + $queue = get_option( 'vc_crawl_queue', array() ); + if ( empty( $queue ) ) { + self::finish_run( $totals ); + return; + } + + // Stop cleanly before we risk a PHP timeout; resume next tick. + if ( $i > 0 && microtime( true ) >= $deadline ) { + break; + } + + $destination = (string) $queue[0]; + $start = max( 1, (int) get_option( 'vc_crawl_start', 1 ) ); + + $result = $importer->crawl_destination( $destination, $start, $page_size, $run_id ); + + if ( is_wp_error( $result ) ) { + $errors = (int) get_option( 'vc_ingest_error_count', 0 ) + 1; + update_option( 'vc_ingest_error_count', $errors, false ); + VC_Logger::error( 'Error on destination ' . $destination . ' (failure ' . $errors . ' of ' . self::MAX_RUN_ERRORS . '): ' . $result->get_error_message() ); + + if ( $errors >= self::MAX_RUN_ERRORS ) { + VC_Logger::error( 'Sync aborted after too many errors. It will retry on the next scheduled run.' ); + update_option( 'vc_ingest_in_progress', 0, false ); + return; + } + self::schedule_continue( MINUTE_IN_SECONDS ); + return; + } + + update_option( 'vc_ingest_error_count', 0, false ); + + $totals['pages'] = (int) $totals['pages'] + 1; + $totals['imported'] = (int) $totals['imported'] + (int) $result['imported']; + $totals['updated'] = (int) $totals['updated'] + (int) $result['updated']; + $totals['duplicate'] = (int) $totals['duplicate'] + (int) $result['duplicate']; + $totals['deactivated'] = (int) $totals['deactivated'] + (int) $result['deactivated']; + $totals['failed'] = (int) $totals['failed'] + (int) $result['failed']; + + $returned = (int) $result['count']; + $total = (int) $result['total']; + $consumed = ( $start - 1 ) + $returned; + $has_more = $returned >= $page_size && ( $total <= 0 || $consumed < $total ); + + if ( $has_more ) { + update_option( 'vc_crawl_start', $start + $page_size, false ); + } else { + array_shift( $queue ); + update_option( 'vc_crawl_queue', array_values( $queue ), false ); + update_option( 'vc_crawl_start', 1, false ); + $totals['destinations_done'] = (int) $totals['destinations_done'] + 1; + } + + update_option( 'vc_ingest_totals', $totals, false ); + self::maybe_log_progress( $totals ); + + if ( empty( $queue ) && ! $has_more ) { + self::finish_run( $totals ); + return; + } + } + + self::schedule_continue( 0 ); + } + + /** + * Emit an info entry each time the processed-product count crosses the + * configured step (default every 10 products). + */ + private static function maybe_log_progress( array $totals ) { + $processed = (int) $totals['imported'] + (int) $totals['updated'] + (int) $totals['deactivated']; + $step = max( 1, absint( vc_get_option( 'log_every', 10 ) ) ); + $last = (int) get_option( 'vc_log_last_count', 0 ); + + if ( $processed - $last < $step ) { + return; + } + + update_option( 'vc_log_last_count', $processed, false ); + VC_Logger::info( sprintf( + 'Progress: %d products processed (new %d, updated %d, deactivated %d, failed %d) across %d pages; %d/%d destinations done.', + $processed, + (int) $totals['imported'], + (int) $totals['updated'], + (int) $totals['deactivated'], + (int) $totals['failed'], + (int) $totals['pages'], + (int) $totals['destinations_done'], + (int) $totals['destinations_total'] + ) ); + } + + private static function finish_run( array $totals ) { + update_option( 'vc_ingest_in_progress', 0, false ); + update_option( 'vc_last_sync_completed_at', current_time( 'mysql' ), false ); + update_option( 'vc_last_sync_totals', $totals, false ); + delete_option( 'vc_crawl_queue' ); + delete_option( 'vc_crawl_start' ); + VC_Logger::info( sprintf( + 'Sync run complete: %d new, %d updated, %d deactivated, %d failed (%d pages, %d destinations).', + (int) $totals['imported'], + (int) $totals['updated'], + (int) $totals['deactivated'], + (int) $totals['failed'], + (int) $totals['pages'], + (int) $totals['destinations_total'] + ) ); + } + + private static function schedule_continue( $delay ) { + if ( ! wp_next_scheduled( self::CONTINUE ) ) { + // The loopback spawn in run() runs due events, so default to "now". + wp_schedule_single_event( time() + max( 0, (int) $delay ), self::CONTINUE ); + } + } + + /** + * Human-readable status used by the settings screen. + */ + public static function status() { + $queue = get_option( 'vc_crawl_queue', array() ); + return array( + 'in_progress' => (bool) get_option( 'vc_ingest_in_progress' ), + 'queue_remaining' => is_array( $queue ) ? count( $queue ) : 0, + 'started_at' => get_option( 'vc_last_sync_started_at', '' ), + 'completed_at' => get_option( 'vc_last_sync_completed_at', '' ), + 'totals' => get_option( 'vc_ingest_totals', array() ), + 'last_totals' => get_option( 'vc_last_sync_totals', array() ), + 'next_scheduled' => wp_next_scheduled( self::HOOK ), + ); + } +} diff --git a/viator-collector/includes/class-vc-display.php b/viator-collector/includes/class-vc-display.php new file mode 100644 index 0000000..793d139 --- /dev/null +++ b/viator-collector/includes/class-vc-display.php @@ -0,0 +1,759 @@ +post_content, 'viator_listing' ); + } + + /** + * Marker class so the full-width CSS can be gated to our pages only. + */ + public static function body_class( $classes ) { + if ( self::is_vc_page() ) { + $classes[] = 'vc-fullwidth'; + } + return $classes; + } + + /** + * Astra: drop the sidebar on Viator pages. + */ + public static function astra_page_layout( $layout ) { + return self::is_vc_page() ? 'no-sidebar' : $layout; + } + + /** + * Astra: use the "page-builder" content layout, which removes the + * .ast-container max-width/padding for a true full-width canvas. + */ + public static function astra_content_layout( $layout ) { + return self::is_vc_page() ? 'page-builder' : $layout; + } + + /** + * Lazily fetch and cache full product detail the first time a tour page is + * viewed, so itinerary/inclusions/etc. are available without crawling + * detail for the whole catalogue up front. + */ + public static function maybe_enrich_single() { + if ( is_admin() || ! is_singular( 'viator_tour' ) ) { + return; + } + + $post_id = get_queried_object_id(); + if ( ! $post_id || get_post_meta( $post_id, '_vc_detail_fetched', true ) ) { + return; + } + + $code = get_post_meta( $post_id, '_vc_viator_product_code', true ); + if ( ! $code ) { + return; + } + + // Avoid repeated calls (and hammering on errors) for the same product. + $lock = 'vc_enrich_' . $post_id; + if ( get_transient( $lock ) ) { + return; + } + set_transient( $lock, 1, 10 * MINUTE_IN_SECONDS ); + + ( new VC_Importer() )->enrich_from_detail( $post_id, $code ); + } + + public static function enqueue_assets() { + $is_single = is_singular( 'viator_tour' ); + $is_archive = is_post_type_archive( 'viator_tour' ) || is_tax( array( 'viator_destination', 'viator_tag', 'viator_category' ) ); + $has_short = self::post_has_shortcode(); + + if ( ! $is_single && ! $is_archive && ! $has_short ) { + return; + } + + // Design typeface. + wp_enqueue_style( 'vc-fonts', 'https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;700;800&display=swap', array(), null ); + + // One scoped stylesheet (#vc-app) covers listing, single, and homepage + // sections — ported from the "Hop On Hop Off" design. + wp_enqueue_style( 'vc-design', VC_PLUGIN_URL . 'assets/css/vc-design.css', array( 'vc-fonts' ), VC_VERSION ); + + // Front-end interactions: card rails, FAQ accordion, testimonials + // marquee, date field, and auto-submit on quick filter toggles. + wp_enqueue_script( 'vc-design', VC_PLUGIN_URL . 'assets/js/vc-design.js', array(), VC_VERSION, true ); + + // The SVG icon sprite (+ ring-badge path) is printed once in the footer. + add_action( 'wp_footer', 'vc_print_icon_sprite' ); + } + + /** + * Read + sanitize the current filter state from the query string. Shared by + * the query builder and the archive template so both stay in sync. + */ + public static function get_filter_state() { + $slug_list = function ( $key ) { + $raw = isset( $_GET[ $key ] ) ? wp_unslash( $_GET[ $key ] ) : array(); // phpcs:ignore WordPress.Security.NonceVerification + $raw = is_array( $raw ) ? $raw : array( $raw ); + return array_values( array_filter( array_map( 'sanitize_title', $raw ) ) ); + }; + + $pmin = isset( $_GET['pmin'] ) ? max( 0, absint( $_GET['pmin'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification + $pmax = isset( $_GET['pmax'] ) ? absint( $_GET['pmax'] ) : 1000; // phpcs:ignore WordPress.Security.NonceVerification + + // A coarse "price" range select (value like "50-150") maps to pmin/pmax. + if ( isset( $_GET['price'] ) && '' !== $_GET['price'] ) { // phpcs:ignore WordPress.Security.NonceVerification + $parts = array_pad( explode( '-', sanitize_text_field( wp_unslash( $_GET['price'] ) ) ), 2, '' ); // phpcs:ignore WordPress.Security.NonceVerification + $pmin = absint( $parts[0] ); + $pmax = ( '' === $parts[1] ) ? 1000 : absint( $parts[1] ); + } + + return array( + 'q' => isset( $_GET['vc_q'] ) ? sanitize_text_field( wp_unslash( $_GET['vc_q'] ) ) : '', // phpcs:ignore WordPress.Security.NonceVerification + 'dest' => $slug_list( 'dest' ), + 'cat' => $slug_list( 'cat' ), + 'dur' => array_values( array_intersect( array( 'half', 'full', 'multi' ), $slug_list( 'dur' ) ) ), + 'pmin' => $pmin, + 'pmax' => $pmax, + 'rating' => isset( $_GET['rating'] ) ? floatval( $_GET['rating'] ) : 0, // phpcs:ignore WordPress.Security.NonceVerification + 'freecancel' => ! empty( $_GET['freecancel'] ) ? 1 : 0, // phpcs:ignore WordPress.Security.NonceVerification + 'bestdeal' => ! empty( $_GET['bestdeal'] ) ? 1 : 0, // phpcs:ignore WordPress.Security.NonceVerification + 'sellout' => ! empty( $_GET['sellout'] ) ? 1 : 0, // phpcs:ignore WordPress.Security.NonceVerification + 'sort' => isset( $_GET['sort'] ) ? sanitize_key( $_GET['sort'] ) : 'newest', // phpcs:ignore WordPress.Security.NonceVerification + ); + } + + /** + * Apply the filter state to the main tours query (archive + taxonomy pages). + */ + public static function filter_archive_query( $query ) { + if ( is_admin() || ! $query->is_main_query() ) { + return; + } + $is_archive = $query->is_post_type_archive( 'viator_tour' ); + $is_tax = $query->is_tax( array( 'viator_destination', 'viator_tag', 'viator_category' ) ); + if ( ! $is_archive && ! $is_tax ) { + return; + } + + $base_tax = array(); + foreach ( (array) $query->get( 'tax_query' ) as $k => $clause ) { + if ( 'relation' !== $k && is_array( $clause ) ) { + $base_tax[] = $clause; + } + } + + foreach ( self::build_query_vars( $base_tax ) as $key => $value ) { + $query->set( $key, $value ); + } + } + + /** + * Build WP_Query vars from the current filter state (query string) plus any + * locked base tax constraints. Shared by the archive query and the + * [viator_listing] shortcode so both filter/sort identically. + */ + public static function build_query_vars( array $base_tax = array() ) { + $f = self::get_filter_state(); + $vars = array( 'posts_per_page' => 12 ); + + if ( '' !== $f['q'] ) { + $vars['s'] = $f['q']; + } + + $tax_query = array(); + foreach ( $base_tax as $clause ) { + if ( is_array( $clause ) ) { + $tax_query[] = $clause; + } + } + if ( ! empty( $f['dest'] ) ) { + $tax_query[] = array( 'taxonomy' => 'viator_destination', 'field' => 'slug', 'terms' => $f['dest'] ); + } + if ( ! empty( $f['cat'] ) ) { + $tax_query[] = array( 'taxonomy' => 'viator_tag', 'field' => 'slug', 'terms' => $f['cat'] ); + } + if ( count( $tax_query ) > 1 ) { + $tax_query['relation'] = 'AND'; + } + if ( ! empty( $tax_query ) ) { + $vars['tax_query'] = $tax_query; + } + + $meta_query = array(); + if ( $f['pmin'] > 0 || $f['pmax'] < 1000 ) { + $meta_query[] = array( 'key' => '_vc_price_from', 'value' => array( $f['pmin'], $f['pmax'] ), 'type' => 'DECIMAL', 'compare' => 'BETWEEN' ); + } + if ( ! empty( $f['dur'] ) ) { + $dur = array( 'relation' => 'OR' ); + foreach ( $f['dur'] as $bucket ) { + if ( 'half' === $bucket ) { + $dur[] = array( 'key' => '_vc_duration_minutes', 'value' => array( 1, 300 ), 'type' => 'NUMERIC', 'compare' => 'BETWEEN' ); + } elseif ( 'full' === $bucket ) { + $dur[] = array( 'key' => '_vc_duration_minutes', 'value' => array( 301, 1439 ), 'type' => 'NUMERIC', 'compare' => 'BETWEEN' ); + } elseif ( 'multi' === $bucket ) { + $dur[] = array( 'key' => '_vc_duration_minutes', 'value' => 1440, 'type' => 'NUMERIC', 'compare' => '>=' ); + } + } + if ( count( $dur ) > 1 ) { + $meta_query[] = $dur; + } + } + if ( $f['rating'] > 0 ) { + $meta_query[] = array( 'key' => '_vc_rating', 'value' => $f['rating'], 'type' => 'DECIMAL', 'compare' => '>=' ); + } + if ( $f['freecancel'] ) { + $meta_query[] = array( 'key' => '_vc_free_cancellation', 'value' => '1' ); + } + // _vc_flags is a JSON array, so match the flag name inside it. + if ( $f['bestdeal'] ) { + $meta_query[] = array( 'key' => '_vc_flags', 'value' => 'SPECIAL_OFFER', 'compare' => 'LIKE' ); + } + if ( $f['sellout'] ) { + $meta_query[] = array( 'key' => '_vc_flags', 'value' => 'LIKELY_TO_SELL_OUT', 'compare' => 'LIKE' ); + } + // Featured tours always sort first, whatever the chosen sort. OR/NOT + // EXISTS keeps non-featured tours in the results too. + $meta_query['vc_featured'] = array( + 'relation' => 'OR', + array( 'key' => '_vc_featured', 'value' => '1', 'compare' => '=' ), + array( 'key' => '_vc_featured', 'compare' => 'NOT EXISTS' ), + ); + + $orderby = array( 'vc_featured' => 'DESC' ); + + switch ( $f['sort'] ) { + case 'price_asc': + $meta_query['vc_price'] = array( 'key' => '_vc_price_from', 'type' => 'DECIMAL', 'compare' => 'EXISTS' ); + $orderby['vc_price'] = 'ASC'; + break; + case 'price_desc': + $meta_query['vc_price'] = array( 'key' => '_vc_price_from', 'type' => 'DECIMAL', 'compare' => 'EXISTS' ); + $orderby['vc_price'] = 'DESC'; + break; + case 'rating': + $meta_query['vc_rating_sort'] = array( 'key' => '_vc_rating', 'type' => 'DECIMAL', 'compare' => 'EXISTS' ); + $orderby['vc_rating_sort'] = 'DESC'; + break; + } + + // Keep the selected sort as the primary order, then prefer the more + // established tour when two products otherwise rank the same. + $meta_query['vc_reviews_sort'] = array( + 'key' => '_vc_review_count', + 'type' => 'NUMERIC', + 'compare' => 'EXISTS', + ); + $orderby['vc_reviews_sort'] = 'DESC'; + $orderby['date'] = 'DESC'; // stable tiebreak + + if ( count( $meta_query ) > 1 ) { + $meta_query['relation'] = 'AND'; + } + $vars['meta_query'] = $meta_query; + $vars['orderby'] = $orderby; + + return $vars; + } + + /** + * [viator_listing tag="airport-hotel-transfers,private-transfers" title="Transportation"] + * + * Full listing UI (filters, sort, pagination) locked to a tag/destination. + * Use on a normal WordPress Page to build e.g. a "Transportation" page. + */ + public static function shortcode_listing( $atts ) { + $atts = shortcode_atts( + array( + 'tag' => '', + 'destination' => '', + 'title' => __( 'Tours', 'viator-collector' ), + 'subtitle' => '', + ), + $atts, + 'viator_listing' + ); + + $base_tax = array(); + if ( '' !== $atts['tag'] ) { + $base_tax[] = array( 'taxonomy' => 'viator_tag', 'field' => 'slug', 'terms' => array_filter( array_map( 'sanitize_title', explode( ',', $atts['tag'] ) ) ) ); + } + if ( '' !== $atts['destination'] ) { + $base_tax[] = array( 'taxonomy' => 'viator_destination', 'field' => 'slug', 'terms' => array_filter( array_map( 'sanitize_title', explode( ',', $atts['destination'] ) ) ) ); + } + + $vars = self::build_query_vars( $base_tax ); + $vars['post_type'] = 'viator_tour'; + $vars['post_status'] = 'publish'; + $vars['paged'] = max( 1, (int) get_query_var( 'paged' ), (int) get_query_var( 'page' ), isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1 ); // phpcs:ignore WordPress.Security.NonceVerification + + $query = new WP_Query( $vars ); + + $inner = vc_get_template( + 'listing.php', + array( + 'vc_title' => $atts['title'], + 'vc_subtitle' => $atts['subtitle'], + 'vc_query' => $query, + 'vc_form_action' => get_permalink(), + ) + ); + + // .vc-app is the scope every rule in vc-design.css hangs off. + return '
' . $inner . '
'; + } + + private static function post_has_shortcode() { + if ( ! is_singular() ) { + return false; + } + $post = get_post(); + if ( ! $post ) { + return false; + } + foreach ( self::SHORTCODES as $shortcode ) { + if ( has_shortcode( $post->post_content, $shortcode ) ) { + return true; + } + } + return false; + } + + public static function single_template( $template ) { + if ( is_singular( 'viator_tour' ) ) { + $path = get_stylesheet_directory() . '/viator-collector/single-viator_tour.php'; + if ( file_exists( $path ) ) { + return $path; + } + $plugin = VC_PLUGIN_DIR . 'templates/single-viator_tour.php'; + if ( file_exists( $plugin ) ) { + return $plugin; + } + } + return $template; + } + + public static function archive_template( $template ) { + if ( is_post_type_archive( 'viator_tour' ) ) { + $path = get_stylesheet_directory() . '/viator-collector/archive-viator_tour.php'; + if ( file_exists( $path ) ) { + return $path; + } + $plugin = VC_PLUGIN_DIR . 'templates/archive-viator_tour.php'; + if ( file_exists( $plugin ) ) { + return $plugin; + } + } + return $template; + } + + public static function taxonomy_template( $template ) { + if ( is_tax( array( 'viator_destination', 'viator_tag', 'viator_category' ) ) ) { + $path = get_stylesheet_directory() . '/viator-collector/taxonomy-viator.php'; + if ( file_exists( $path ) ) { + return $path; + } + $plugin = VC_PLUGIN_DIR . 'templates/taxonomy-viator.php'; + if ( file_exists( $plugin ) ) { + return $plugin; + } + } + return $template; + } + + public static function shortcode_tours( $atts ) { + $atts = shortcode_atts( + array( + 'limit' => 12, + 'destination' => '', + 'tag' => '', + 'category' => '', + 'orderby' => 'date', + 'order' => 'DESC', + ), + $atts, + 'viator_tours' + ); + + $primary_orderby = sanitize_key( $atts['orderby'] ); + $primary_order = 'ASC' === strtoupper( $atts['order'] ) ? 'ASC' : 'DESC'; + + $args = array( + 'post_type' => 'viator_tour', + 'post_status' => 'publish', + 'posts_per_page' => max( 1, absint( $atts['limit'] ) ), + ); + + if ( 'rand' === $primary_orderby ) { + $args['orderby'] = 'rand'; + } else { + $args['meta_query'] = array( + 'vc_reviews_sort' => array( + 'key' => '_vc_review_count', + 'type' => 'NUMERIC', + 'compare' => 'EXISTS', + ), + ); + $args['orderby'] = array( + $primary_orderby => $primary_order, + 'vc_reviews_sort' => 'DESC', + ); + if ( 'date' !== $primary_orderby ) { + $args['orderby']['date'] = 'DESC'; + } + } + + $tax_query = array(); + $tax_map = array( + 'destination' => 'viator_destination', + 'tag' => 'viator_tag', + 'category' => 'viator_category', + ); + foreach ( $tax_map as $att => $taxonomy ) { + if ( ! empty( $atts[ $att ] ) ) { + $tax_query[] = array( + 'taxonomy' => $taxonomy, + 'field' => 'slug', + 'terms' => array_map( 'sanitize_title', array_map( 'trim', explode( ',', $atts[ $att ] ) ) ), + ); + } + } + if ( $tax_query ) { + $args['tax_query'] = $tax_query; + } + + return self::render_tour_grid( $args ); + } + + /** + * "Top Tours" section: a titled grid of tours, highest rated first. + */ + public static function shortcode_top_tours( $atts ) { + $atts = shortcode_atts( + array( + 'limit' => 8, + 'title' => __( 'Top Tours', 'viator-collector' ), + 'eyebrow' => __( 'Hand-picked', 'viator-collector' ), + 'view_all' => '', + 'orderby' => 'rating', + ), + $atts, + 'viator_top_tours' + ); + + $args = array( + 'post_type' => 'viator_tour', + 'post_status' => 'publish', + 'posts_per_page' => max( 1, absint( $atts['limit'] ) ), + ); + if ( 'rating' === $atts['orderby'] ) { + $args['meta_key'] = '_vc_rating'; + $args['orderby'] = 'meta_value_num'; + $args['order'] = 'DESC'; + } else { + $args['orderby'] = 'date'; + $args['order'] = 'DESC'; + } + + $grid = self::render_tour_grid( $args ); + if ( '' === $grid ) { + return ''; + } + return self::render_section( 'vc-section--tours', $atts['eyebrow'], $atts['title'], $atts['view_all'], $grid ); + } + + /** + * "Top Destinations" section: a grid of destination term cards. + */ + public static function shortcode_top_destinations( $atts ) { + $atts = shortcode_atts( + array( + 'count' => 8, + 'ids' => '', + 'title' => __( 'Top Destinations', 'viator-collector' ), + 'eyebrow' => __( 'Explore', 'viator-collector' ), + 'view_all' => '', + ), + $atts, + 'viator_top_destinations' + ); + + $terms = self::get_section_terms( 'viator_destination', $atts['count'], $atts['ids'] ); + if ( empty( $terms ) ) { + return ''; + } + + ob_start(); + echo '
'; + foreach ( $terms as $term ) { + echo vc_get_template( 'card-destination.php', array( 'term' => $term ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + echo '
'; + $grid = ob_get_clean(); + + return self::render_section( 'vc-section--destinations', $atts['eyebrow'], $atts['title'], $atts['view_all'], $grid ); + } + + /** + * "Top Attractions" section. Backed by top-rated tours rendered as large + * feature cards (repoint to a dedicated attraction taxonomy once ingested). + */ + public static function shortcode_top_attractions( $atts ) { + $atts = shortcode_atts( + array( + 'limit' => 3, + 'title' => __( 'Top Attractions', 'viator-collector' ), + 'eyebrow' => __( 'Must see', 'viator-collector' ), + 'view_all' => '', + ), + $atts, + 'viator_top_attractions' + ); + + $query = new WP_Query( + array( + 'post_type' => 'viator_tour', + 'post_status' => 'publish', + 'posts_per_page' => max( 1, absint( $atts['limit'] ) ), + 'meta_key' => '_vc_rating', + 'orderby' => 'meta_value_num', + 'order' => 'DESC', + ) + ); + if ( ! $query->have_posts() ) { + return ''; + } + + ob_start(); + echo '
'; + while ( $query->have_posts() ) { + $query->the_post(); + echo vc_get_template( 'card-attraction.php', array( 'post_id' => get_the_ID() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + echo '
'; + wp_reset_postdata(); + $grid = ob_get_clean(); + + return self::render_section( 'vc-section--attractions', $atts['eyebrow'], $atts['title'], $atts['view_all'], $grid ); + } + + /** + * "Browse By Activity" section: a list of activity (tag) rows. + */ + public static function shortcode_browse_activities( $atts ) { + $atts = shortcode_atts( + array( + 'count' => 8, + 'ids' => '', + 'title' => __( 'Browse By Activity', 'viator-collector' ), + 'eyebrow' => __( 'Find your style', 'viator-collector' ), + 'view_all' => '', + ), + $atts, + 'viator_browse_activities' + ); + + $terms = self::get_section_terms( 'viator_tag', $atts['count'], $atts['ids'] ); + if ( empty( $terms ) ) { + return ''; + } + + ob_start(); + echo '
'; + foreach ( $terms as $term ) { + echo vc_get_template( 'card-activity.php', array( 'term' => $term ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + echo '
'; + $grid = ob_get_clean(); + + return self::render_section( 'vc-section--activities', $atts['eyebrow'], $atts['title'], $atts['view_all'], $grid ); + } + + /** + * Shared: render a WP_Query of tours into the card grid markup. + */ + private static function render_tour_grid( array $args ) { + $query = new WP_Query( $args ); + if ( ! $query->have_posts() ) { + wp_reset_postdata(); + return ''; + } + ob_start(); + echo '
    '; + while ( $query->have_posts() ) { + $query->the_post(); + echo vc_get_template( 'card-tour.php', array( 'post_id' => get_the_ID(), 'variant' => 'wide' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + echo '
'; + wp_reset_postdata(); + return ob_get_clean(); + } + + /** + * Shared: fetch terms for a section, either an explicit id list or the + * most-used terms in the taxonomy. + */ + private static function get_section_terms( $taxonomy, $count, $ids ) { + $args = array( + 'taxonomy' => $taxonomy, + 'hide_empty' => true, + 'number' => max( 1, absint( $count ) ), + ); + $ids = array_filter( array_map( 'absint', array_map( 'trim', explode( ',', (string) $ids ) ) ) ); + if ( ! empty( $ids ) ) { + $args['include'] = $ids; + $args['orderby'] = 'include'; + unset( $args['number'] ); + } else { + $args['orderby'] = 'count'; + $args['order'] = 'DESC'; + } + $terms = get_terms( $args ); + return ( is_wp_error( $terms ) || empty( $terms ) ) ? array() : $terms; + } + + /** + * Shared: wrap inner markup in a titled, reveal-animated section. + */ + private static function render_section( $modifier, $eyebrow, $title, $view_all, $inner ) { + ob_start(); + ?> +
+
+
+ +

+ +
+ +
+
+ $post_id, 'variant' => 'wide' ) ); + return '
    ' . $card . '
'; + } + + public static function shortcode_itinerary( $atts ) { + return vc_get_template( 'itinerary.php', array( 'post_id' => self::post_id_from_atts( $atts ) ) ); + } + + public static function shortcode_inclusions( $atts ) { + return vc_get_template( 'inclusions.php', array( 'post_id' => self::post_id_from_atts( $atts ) ) ); + } + + public static function shortcode_exclusions( $atts ) { + return vc_get_template( 'exclusions.php', array( 'post_id' => self::post_id_from_atts( $atts ) ) ); + } + + public static function shortcode_booking_button( $atts ) { + return vc_get_template( 'booking-button.php', array( 'post_id' => self::post_id_from_atts( $atts ) ) ); + } + + public static function shortcode_duration( $atts ) { + return vc_get_template( 'duration.php', array( 'post_id' => self::post_id_from_atts( $atts ) ) ); + } + + public static function shortcode_rating( $atts ) { + return vc_get_template( 'rating.php', array( 'post_id' => self::post_id_from_atts( $atts ) ) ); + } + + public static function shortcode_gallery( $atts ) { + $post_id = self::post_id_from_atts( $atts ); + + // Legacy installs may still have downloaded attachments. + $ids = vc_json_decode_meta( get_post_meta( $post_id, '_vc_gallery_image_ids', true ), array() ); + if ( ! empty( $ids ) ) { + $out = ''; + return $out; + } + + $urls = vc_json_decode_meta( get_post_meta( $post_id, '_vc_gallery_image_urls', true ), array() ); + if ( empty( $urls ) ) { + return ''; + } + $title = esc_attr( get_the_title( $post_id ) ); + $out = ''; + return $out; + } + + private static function post_id_from_atts( $atts ) { + $atts = shortcode_atts( array( 'post_id' => get_the_ID() ), (array) $atts ); + $post_id = absint( $atts['post_id'] ); + return 'viator_tour' === get_post_type( $post_id ) ? $post_id : 0; + } +} diff --git a/viator-collector/includes/class-vc-importer.php b/viator-collector/includes/class-vc-importer.php new file mode 100644 index 0000000..a89e40b --- /dev/null +++ b/viator-collector/includes/class-vc-importer.php @@ -0,0 +1,518 @@ +api = $api ? $api : new VC_API(); + } + + /** + * Force-refresh the destination and tag dictionaries and cache them. + * Returns a WP_Error if either lookup fails (so a run can abort early). + */ + public function prime_dictionaries() { + $dest = $this->api->get_destination_map(); + if ( is_wp_error( $dest ) ) { + return $dest; + } + $tags = $this->api->get_tag_map(); + if ( is_wp_error( $tags ) ) { + return $tags; + } + set_transient( 'vc_destination_map', $dest, 25 * HOUR_IN_SECONDS ); + set_transient( 'vc_tag_map', $tags, 25 * HOUR_IN_SECONDS ); + $this->destination_map = $dest; + $this->tag_map = $tags; + return true; + } + + /** + * Load dictionaries from cache, fetching them if the cache is cold. + */ + public function load_dictionaries() { + $dest = get_transient( 'vc_destination_map' ); + if ( false === $dest ) { + $dest = $this->api->get_destination_map(); + $dest = is_wp_error( $dest ) ? array() : $dest; + set_transient( 'vc_destination_map', $dest, 25 * HOUR_IN_SECONDS ); + } + $tags = get_transient( 'vc_tag_map' ); + if ( false === $tags ) { + $tags = $this->api->get_tag_map(); + $tags = is_wp_error( $tags ) ? array() : $tags; + set_transient( 'vc_tag_map', $tags, 25 * HOUR_IN_SECONDS ); + } + $this->destination_map = is_array( $dest ) ? $dest : array(); + $this->tag_map = is_array( $tags ) ? $tags : array(); + } + + /** + * Crawl one page of products for a single destination. + * + * Each product is deduplicated by code within a run (via $run_id) so its + * full detail is only fetched once even though it surfaces under several + * destinations. + * + * @return array|WP_Error Per-page summary including totalCount and page size. + */ + public function crawl_destination( $destination_id, $start, $count, $run_id ) { + $currency = vc_get_option( 'currency', 'USD' ); + $response = $this->api->search_products( $destination_id, $start, $count, $currency ); + if ( is_wp_error( $response ) ) { + return $response; + } + + $products = vc_array_get( $response, 'products', array() ); + $summary = array( + 'count' => 0, + 'imported' => 0, + 'updated' => 0, + 'duplicate' => 0, + 'deactivated' => 0, + 'failed' => 0, + 'total' => (int) vc_first_non_empty( array( vc_array_get( $response, 'totalCount' ), vc_array_get( $response, 'total' ) ), 0 ), + ); + + foreach ( (array) $products as $product ) { + if ( ! is_array( $product ) ) { + continue; + } + $summary['count']++; + $outcome = $this->process_search_product( $product, $destination_id, $run_id ); + if ( isset( $summary[ $outcome ] ) ) { + $summary[ $outcome ]++; + } + } + + return $summary; + } + + /** + * Process one product returned by search: skip if already handled this + * run, otherwise optionally fetch full detail and create/update the post. + * + * @return string One of imported|updated|duplicate|deactivated|failed. + */ + private function process_search_product( array $product, $destination_id, $run_id ) { + $code = (string) vc_first_non_empty( array( vc_array_get( $product, 'productCode' ), vc_array_get( $product, 'code' ) ) ); + if ( '' === $code ) { + return 'failed'; + } + + $existing = $this->find_existing_tour( $code ); + if ( $existing && (string) get_post_meta( $existing, '_vc_synced_run', true ) === (string) $run_id ) { + // Already fully processed under another destination during this run. + return 'duplicate'; + } + + // Optionally enrich the summary with full product detail. + if ( vc_get_option( 'fetch_full_detail', 0 ) ) { + $detail = $this->api->get_product( $code ); + if ( ! is_wp_error( $detail ) && is_array( $detail ) ) { + $product = $this->merge_detail( $product, $detail ); + } elseif ( is_wp_error( $detail ) ) { + VC_Logger::log( 'Detail fetch failed (' . $code . '): ' . $detail->get_error_message() ); + } + } + + $product = $this->ensure_destination( $product, $destination_id ); + $inactive = $this->is_product_inactive( $product ); + + $result = $this->create_or_update_tour( $product, $inactive ); + if ( is_wp_error( $result ) ) { + VC_Logger::error( 'Product import failed (' . $code . '): ' . $result->get_error_message() ); + return 'failed'; + } + + update_post_meta( (int) $result, '_vc_synced_run', (string) $run_id ); + + if ( $inactive ) { + return 'deactivated'; + } + return $existing ? 'updated' : 'imported'; + } + + /** + * Overlay full-detail fields onto the search summary while keeping + * summary-only fields (pricing/destinations/tags) when detail omits them. + */ + private function merge_detail( array $summary, array $detail ) { + $merged = array_replace( $summary, $detail ); + foreach ( array( 'pricing', 'destinations', 'tags', 'reviews', 'images' ) as $key ) { + if ( empty( $merged[ $key ] ) && ! empty( $summary[ $key ] ) ) { + $merged[ $key ] = $summary[ $key ]; + } + } + return $merged; + } + + /** + * Make sure the destination we searched is present on the product so its + * taxonomy term is assigned even if the payload omits it. + */ + private function ensure_destination( array $product, $destination_id ) { + $destinations = vc_array_get( $product, 'destinations', array() ); + if ( ! is_array( $destinations ) ) { + $destinations = array(); + } + foreach ( $destinations as $destination ) { + $ref = vc_first_non_empty( array( vc_array_get( $destination, 'ref' ), vc_array_get( $destination, 'destinationId' ), is_scalar( $destination ) ? $destination : null ) ); + if ( (string) $ref === (string) $destination_id ) { + return $product; + } + } + $destinations[] = array( 'ref' => (string) $destination_id ); + $product['destinations'] = $destinations; + return $product; + } + + /** + * Fetch full product detail for an already-imported post and merge it into + * the stored summary. Used for lazy, on-view enrichment so we only pay the + * per-product request for tours that are actually viewed. + * + * @return bool True on success. + */ + public function enrich_from_detail( $post_id, $product_code ) { + $product_code = sanitize_text_field( (string) $product_code ); + if ( '' === $product_code ) { + return false; + } + + $detail = $this->api->get_product( $product_code ); + if ( is_wp_error( $detail ) || ! is_array( $detail ) ) { + if ( is_wp_error( $detail ) ) { + VC_Logger::log( 'Detail enrichment failed (' . $product_code . '): ' . $detail->get_error_message() ); + } + return false; + } + + $this->load_dictionaries(); + + $summary = vc_json_decode_meta( get_post_meta( $post_id, '_vc_raw_response', true ), array() ); + $product = is_array( $summary ) && ! empty( $summary ) ? $this->merge_detail( $summary, $detail ) : $detail; + + $inactive = $this->is_product_inactive( $product ); + $this->save_tour_meta( $post_id, $product, $inactive ); + $this->assign_taxonomies( $post_id, $product ); + $this->save_image_urls( $post_id, $product ); + update_post_meta( $post_id, '_vc_detail_fetched', current_time( 'mysql' ) ); + + return true; + } + + public function import_product_by_code( $product_code ) { + $product_code = sanitize_text_field( $product_code ); + if ( '' === $product_code ) { + return new WP_Error( 'vc_missing_product_code', __( 'Product code is required.', 'viator-collector' ) ); + } + $product = $this->api->get_product( $product_code ); + if ( is_wp_error( $product ) ) { + return $product; + } + $this->load_dictionaries(); + return $this->create_or_update_tour( $product, $this->is_product_inactive( $product ) ); + } + + public function find_existing_tour( $product_code ) { + $query = new WP_Query( + array( + 'post_type' => 'viator_tour', + 'post_status' => 'any', + 'posts_per_page' => 1, + 'fields' => 'ids', + 'no_found_rows' => true, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + 'meta_query' => array( + array( + 'key' => '_vc_viator_product_code', + 'value' => sanitize_text_field( (string) $product_code ), + ), + ), + ) + ); + + return ! empty( $query->posts ) ? (int) $query->posts[0] : 0; + } + + public function create_or_update_tour( array $product, $inactive = false ) { + $product_code = vc_first_non_empty( array( vc_array_get( $product, 'productCode' ), vc_array_get( $product, 'code' ) ) ); + if ( '' === (string) $product_code ) { + return new WP_Error( 'vc_missing_product_code', __( 'Product data is missing a Viator product code.', 'viator-collector' ) ); + } + + $title = vc_first_non_empty( array( vc_array_get( $product, 'title' ), vc_array_get( $product, 'name' ), $product_code ) ); + $desc = vc_first_non_empty( array( vc_array_get( $product, 'description' ), vc_array_get( $product, 'overview' ) ) ); + $short = vc_first_non_empty( array( vc_array_get( $product, 'shortDescription' ), vc_array_get( $product, 'summary' ) ) ); + + if ( $inactive ) { + $status = 'draft'; + } else { + $status = vc_get_option( 'default_post_status', 'publish' ); + if ( ! in_array( $status, array( 'publish', 'draft', 'pending' ), true ) ) { + $status = 'publish'; + } + } + + $postarr = array( + 'post_type' => 'viator_tour', + 'post_title' => wp_strip_all_tags( $title ), + 'post_content' => wp_kses_post( $desc ), + 'post_excerpt' => wp_kses_post( $short ), + 'post_status' => $status, + ); + + $post_id = $this->find_existing_tour( $product_code ); + if ( $post_id ) { + $postarr['ID'] = $post_id; + $result = wp_update_post( wp_slash( $postarr ), true ); + } else { + $result = wp_insert_post( wp_slash( $postarr ), true ); + } + + if ( is_wp_error( $result ) ) { + return $result; + } + + $post_id = (int) $result; + update_post_meta( $post_id, '_vc_viator_product_code', sanitize_text_field( (string) $product_code ) ); + $this->save_tour_meta( $post_id, $product, $inactive ); + $this->assign_taxonomies( $post_id, $product ); + $this->save_image_urls( $post_id, $product ); + + return $post_id; + } + + public function save_tour_meta( $post_id, array $product, $inactive = false ) { + $price = vc_first_non_empty( + array( + vc_array_get( $product, 'pricing.summary.fromPrice' ), + vc_array_get( $product, 'price.fromPrice' ), + vc_array_get( $product, 'fromPrice' ), + ) + ); + $currency = vc_first_non_empty( + array( + vc_array_get( $product, 'pricing.currency' ), + vc_array_get( $product, 'currency' ), + vc_get_option( 'currency', 'USD' ), + ) + ); + $duration_minutes = vc_first_non_empty( + array( + vc_array_get( $product, 'duration.fixedDurationInMinutes' ), + vc_array_get( $product, 'duration.variableDurationFromMinutes' ), + vc_array_get( $product, 'durationMinutes' ), + ) + ); + $duration_text = vc_first_non_empty( + array( + vc_array_get( $product, 'duration.displayDuration' ), + vc_array_get( $product, 'durationText' ), + ) + ); + $booking_url = vc_first_non_empty( + array( + vc_array_get( $product, 'productUrl' ), + vc_array_get( $product, 'bookingUrl' ), + vc_array_get( $product, 'bookingLink' ), + ) + ); + $supplier = vc_first_non_empty( + array( + vc_array_get( $product, 'supplier.name' ), + vc_array_get( $product, 'supplierName' ), + ) + ); + $rating = vc_first_non_empty( + array( + vc_array_get( $product, 'reviews.combinedAverageRating' ), + vc_array_get( $product, 'rating' ), + ) + ); + $review_count = vc_first_non_empty( + array( + vc_array_get( $product, 'reviews.totalReviews' ), + vc_array_get( $product, 'reviewCount' ), + ) + ); + + // Marketing flags (FREE_CANCELLATION, SPECIAL_OFFER, LIKELY_TO_SELL_OUT, …). + $flags = vc_array_get( $product, 'flags', array() ); + $flags = is_array( $flags ) ? array_values( array_map( 'sanitize_text_field', $flags ) ) : array(); + + // Maximum group size (present in full product detail only). + $max_travelers = vc_first_non_empty( + array( + vc_array_get( $product, 'bookingRequirements.maxTravelersPerBooking' ), + vc_array_get( $product, 'bookingRequirements.maxTravelers' ), + vc_array_get( $product, 'maxTravelers' ), + ) + ); + + $meta = array( + '_vc_booking_url' => esc_url_raw( $booking_url ), + '_vc_price_from' => $price, + '_vc_currency' => sanitize_text_field( $currency ), + '_vc_duration_minutes' => absint( $duration_minutes ), + '_vc_duration_text' => sanitize_text_field( $duration_text ), + '_vc_itinerary' => wp_json_encode( vc_array_get( $product, 'itinerary', array() ) ), + '_vc_inclusions' => wp_json_encode( vc_array_get( $product, 'inclusions', array() ) ), + '_vc_exclusions' => wp_json_encode( vc_array_get( $product, 'exclusions', array() ) ), + '_vc_additional_info' => wp_json_encode( vc_array_get( $product, 'additionalInfo', array() ) ), + '_vc_cancellation_policy' => wp_json_encode( vc_first_non_empty( array( vc_array_get( $product, 'cancellationPolicy' ), vc_array_get( $product, 'cancellation' ) ), array() ) ), + '_vc_supplier' => sanitize_text_field( $supplier ), + '_vc_destinations' => wp_json_encode( vc_array_get( $product, 'destinations', array() ) ), + '_vc_tags' => wp_json_encode( vc_array_get( $product, 'tags', array() ) ), + '_vc_rating' => sanitize_text_field( (string) $rating ), + '_vc_review_count' => absint( $review_count ), + '_vc_flags' => wp_json_encode( $flags ), + '_vc_free_cancellation' => in_array( 'FREE_CANCELLATION', $flags, true ) ? 1 : 0, + '_vc_max_travelers' => absint( $max_travelers ), + '_vc_is_active' => $inactive ? 0 : 1, + '_vc_last_synced_at' => current_time( 'mysql' ), + ); + + foreach ( $meta as $key => $value ) { + update_post_meta( $post_id, $key, $value ); + } + + // The full API response is always stored so any field can be surfaced later. + update_post_meta( $post_id, '_vc_raw_response', wp_json_encode( $product ) ); + } + + public function assign_taxonomies( $post_id, array $product ) { + $destination_ids = array(); + foreach ( (array) vc_array_get( $product, 'destinations', array() ) as $destination ) { + $ref = vc_first_non_empty( + array( + vc_array_get( $destination, 'ref' ), + vc_array_get( $destination, 'destinationId' ), + is_scalar( $destination ) ? $destination : null, + ) + ); + if ( '' === (string) $ref ) { + continue; + } + $name = isset( $this->destination_map[ (string) $ref ] ) ? $this->destination_map[ (string) $ref ] : ''; + $term_id = VC_Taxonomies::get_or_create_destination_by_id( $ref, $name ); + if ( $term_id ) { + $destination_ids[] = (int) $term_id; + } + } + + $tag_ids = array(); + foreach ( (array) vc_array_get( $product, 'tags', array() ) as $tag ) { + $id = is_scalar( $tag ) ? $tag : vc_first_non_empty( array( vc_array_get( $tag, 'tagId' ), vc_array_get( $tag, 'id' ) ) ); + if ( '' === (string) $id ) { + continue; + } + $name = isset( $this->tag_map[ (string) $id ] ) ? $this->tag_map[ (string) $id ] : ''; + $term_id = VC_Taxonomies::get_or_create_tag_by_id( $id, $name ); + if ( $term_id ) { + $tag_ids[] = (int) $term_id; + } + } + + wp_set_object_terms( $post_id, array_values( array_unique( $destination_ids ) ), 'viator_destination', false ); + wp_set_object_terms( $post_id, array_values( array_unique( $tag_ids ) ), 'viator_tag', false ); + } + + /** + * Store image URLs (and a primary URL) without downloading anything. + */ + public function save_image_urls( $post_id, array $product ) { + $images = vc_first_non_empty( + array( + vc_array_get( $product, 'images', array() ), + vc_array_get( $product, 'media.images', array() ), + ), + array() + ); + + $urls = array(); + $primary = ''; + foreach ( (array) $images as $image ) { + $url = $this->best_image_url( $image ); + if ( '' === $url ) { + continue; + } + $urls[] = $url; + if ( '' === $primary && ( is_scalar( $image ) || vc_array_get( $image, 'isCover' ) ) ) { + $primary = $url; + } + } + + $urls = array_values( array_unique( array_filter( $urls ) ) ); + if ( '' === $primary && ! empty( $urls ) ) { + $primary = $urls[0]; + } + + update_post_meta( $post_id, '_vc_gallery_image_urls', wp_json_encode( $urls ) ); + update_post_meta( $post_id, '_vc_primary_image_url', esc_url_raw( $primary ) ); + } + + /** + * Pick the highest-resolution variant available for an image entry. + */ + private function best_image_url( $image ) { + if ( is_string( $image ) ) { + return esc_url_raw( $image ); + } + if ( ! is_array( $image ) ) { + return ''; + } + + $variants = vc_array_get( $image, 'variants', array() ); + if ( is_array( $variants ) && ! empty( $variants ) ) { + $best = ''; + $best_width = -1; + foreach ( $variants as $variant ) { + $url = vc_array_get( $variant, 'url' ); + if ( ! $url ) { + continue; + } + $width = (int) vc_array_get( $variant, 'width', 0 ); + if ( $width >= $best_width ) { + $best_width = $width; + $best = $url; + } + } + if ( '' !== $best ) { + return esc_url_raw( $best ); + } + } + + $url = vc_first_non_empty( + array( + vc_array_get( $image, 'url' ), + vc_array_get( $image, 'large' ), + vc_array_get( $image, 'medium' ), + vc_array_get( $image, 'imageSource' ), + ) + ); + return $url ? esc_url_raw( $url ) : ''; + } + + /** + * Detect whether the feed reports this product as no longer bookable. + */ + private function is_product_inactive( array $product ) { + $status = strtoupper( (string) vc_first_non_empty( array( vc_array_get( $product, 'status' ), vc_array_get( $product, 'productStatus' ) ) ) ); + if ( '' !== $status && in_array( $status, array( 'INACTIVE', 'DEACTIVATED', 'EXPIRED', 'REMOVED', 'DELETED' ), true ) ) { + return true; + } + if ( false === vc_array_get( $product, 'active', null ) ) { + return true; + } + if ( vc_array_get( $product, 'isDeactivated', false ) ) { + return true; + } + return false; + } +} diff --git a/viator-collector/includes/class-vc-logger.php b/viator-collector/includes/class-vc-logger.php new file mode 100644 index 0000000..a962ac2 --- /dev/null +++ b/viator-collector/includes/class-vc-logger.php @@ -0,0 +1,103 @@ + current_time( 'mysql' ), + 'level' => ( 'error' === $level ? 'error' : 'info' ), + 'msg' => $message, + ); + + if ( count( $log ) > self::MAX ) { + $log = array_slice( $log, -self::MAX ); + } + + update_option( self::STORE, $log, false ); + } + + /** + * Recent activity log entries, newest first. + */ + public static function get_log() { + $log = get_option( self::STORE, array() ); + if ( ! is_array( $log ) ) { + return array(); + } + return array_reverse( $log ); + } + + public static function clear_log() { + delete_option( self::STORE ); + } + + private static function redact( $value ) { + if ( is_array( $value ) ) { + foreach ( $value as $key => $item ) { + if ( preg_match( '/api.?key|exp-api-key|authorization|token|secret/i', (string) $key ) ) { + $value[ $key ] = '[redacted]'; + } else { + $value[ $key ] = self::redact( $item ); + } + } + } elseif ( is_string( $value ) ) { + $value = self::redact_string( $value ); + } + return $value; + } + + private static function redact_string( $value ) { + $key = vc_get_api_key(); + if ( $key ) { + $value = str_replace( $key, '[redacted]', $value ); + } + return preg_replace( '/(exp-api-key|api_key|apiKey|token|secret)\s*[:=]\s*[^,\s]+/i', '$1=[redacted]', $value ); + } +} diff --git a/viator-collector/includes/class-vc-plugin.php b/viator-collector/includes/class-vc-plugin.php new file mode 100644 index 0000000..12e3552 --- /dev/null +++ b/viator-collector/includes/class-vc-plugin.php @@ -0,0 +1,64 @@ +load_files(); + + VC_CPT::init(); + VC_Taxonomies::init(); + VC_Settings::init(); + VC_Cron::init(); + VC_Display::init(); + VC_Sections::init(); + } + + private function load_files() { + $files = array( + 'includes/helpers.php', + 'includes/class-vc-logger.php', + 'includes/class-vc-cpt.php', + 'includes/class-vc-taxonomies.php', + 'includes/class-vc-api.php', + 'includes/class-vc-importer.php', + 'includes/class-vc-cron.php', + 'includes/class-vc-settings.php', + 'includes/class-vc-display.php', + 'includes/class-vc-sections.php', + ); + + foreach ( $files as $file ) { + require_once VC_PLUGIN_DIR . $file; + } + } + + public static function activate() { + require_once VC_PLUGIN_DIR . 'includes/helpers.php'; + require_once VC_PLUGIN_DIR . 'includes/class-vc-cpt.php'; + require_once VC_PLUGIN_DIR . 'includes/class-vc-taxonomies.php'; + require_once VC_PLUGIN_DIR . 'includes/class-vc-cron.php'; + + VC_CPT::register(); + VC_Taxonomies::register(); + VC_Cron::maybe_schedule(); + flush_rewrite_rules(); + } + + public static function deactivate() { + require_once VC_PLUGIN_DIR . 'includes/class-vc-cron.php'; + VC_Cron::unschedule(); + delete_transient( 'vc_sync_lock' ); + flush_rewrite_rules(); + } +} diff --git a/viator-collector/includes/class-vc-sections.php b/viator-collector/includes/class-vc-sections.php new file mode 100644 index 0000000..ad773de --- /dev/null +++ b/viator-collector/includes/class-vc-sections.php @@ -0,0 +1,517 @@ + __( 'Set out on the journey.', 'viator-collector' ), + 'eyebrow' => __( 'Tour & Travel', 'viator-collector' ), + 'image' => '', + ), + $atts, + self::HERO + ); + + $img = $atts['image'] ? $atts['image'] : vc_hero_image_url(); + if ( ! $img ) { + $img = vc_asset_url( 'img/hero.png' ); + } + $archive = get_post_type_archive_link( 'viator_tour' ); + $archive = $archive ? $archive : home_url( '/' ); + + $dests = get_terms( array( 'taxonomy' => 'viator_destination', 'hide_empty' => true, 'number' => 12, 'orderby' => 'count', 'order' => 'DESC' ) ); + $dests = is_wp_error( $dests ) ? array() : $dests; + + ob_start(); + ?> +
+
+
+ + +
+
+

+

+ +
+
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ + +
+
+
+ + +
+
+ with cards and then calls close_rail(). + */ + private static function open_rail( $title, $lead, $view_all_url, $view_all_text ) { + $id = 'vcRail' . ( ++self::$rail_seq ); + ob_start(); + ?> +
+
+
+
+

array() ) ); ?>

+

+
+ + + +
+ + +
+
+
+
    + $id, 'head' => ob_get_clean() ); + } + + private static function close_rail() { + return '
'; + } + + /** + * [viator_top_tours limit="8" title="Most Liked Tours"] + */ + public static function top_tours( $atts ) { + $atts = shortcode_atts( + array( + 'ids' => '', + 'codes' => '', + 'destination' => '', + 'tag' => '', + 'limit' => 8, + 'orderby' => 'rating', + 'title' => __( 'Most Liked
Tours', 'viator-collector' ), + 'lead' => __( 'Hand-picked tours travellers loved most.', 'viator-collector' ), + ), + $atts, + self::TOP_TOURS + ); + return self::tour_rail( $atts ); + } + + /** + * [viator_top_attractions limit="8"] + */ + public static function top_attractions( $atts ) { + $atts = shortcode_atts( + array( + 'ids' => '', + 'codes' => '', + 'destination' => '', + 'tag' => '', + 'limit' => 8, + 'orderby' => 'rating', + 'title' => __( 'Top
Attractions', 'viator-collector' ), + 'lead' => __( 'The must-see experiences everyone books.', 'viator-collector' ), + ), + $atts, + self::TOP_ATTRS + ); + return self::tour_rail( $atts ); + } + + /** + * [viator_browse_activities limit="8"] + */ + public static function browse_activities( $atts ) { + $atts = shortcode_atts( + array( + 'ids' => '', + 'codes' => '', + 'destination' => '', + 'tag' => '', + 'limit' => 8, + 'orderby' => 'rating', + 'title' => __( 'Browse By
Activity', 'viator-collector' ), + 'lead' => __( 'Find your style — from city sights to river cruises.', 'viator-collector' ), + ), + $atts, + self::ACTIVITIES + ); + return self::tour_rail( $atts ); + } + + /** + * Shared: a rail of tour cards from a curated/filtered query. + */ + private static function tour_rail( array $atts ) { + $query = self::tour_query( $atts ); + if ( ! $query->have_posts() ) { + return ''; + } + + $archive = get_post_type_archive_link( 'viator_tour' ); + $rail = self::open_rail( $atts['title'], $atts['lead'], $archive, __( 'See all', 'viator-collector' ) ); + + ob_start(); + echo $rail['head']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + while ( $query->have_posts() ) { + $query->the_post(); + echo vc_get_template( 'card-tour.php', array( 'post_id' => get_the_ID(), 'variant' => 'rail' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + echo self::close_rail(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + wp_reset_postdata(); + return ob_get_clean(); + } + + /** + * [viator_top_destinations limit="8" title="Where you wanna go?"] + * + * A rail of destination-term cards; each links to the term archive. + */ + public static function top_destinations( $atts ) { + $atts = shortcode_atts( + array( + 'ids' => '', + 'slugs' => '', + 'limit' => 8, + 'orderby' => 'count', + 'title' => __( 'Where you
wanna go?', 'viator-collector' ), + 'lead' => __( 'Popular destinations our travellers explore.', 'viator-collector' ), + 'link_text' => __( 'View tours', 'viator-collector' ), + ), + $atts, + self::TOP_DESTS + ); + + $args = array( 'taxonomy' => 'viator_destination', 'hide_empty' => true ); + if ( '' !== $atts['ids'] ) { + $args['include'] = array_filter( array_map( 'absint', explode( ',', $atts['ids'] ) ) ); + $args['orderby'] = 'include'; + } elseif ( '' !== $atts['slugs'] ) { + $args['slug'] = array_filter( array_map( 'sanitize_title', explode( ',', $atts['slugs'] ) ) ); + $args['orderby'] = 'name'; + } else { + $args['number'] = max( 1, absint( $atts['limit'] ) ); + $args['orderby'] = ( 'name' === $atts['orderby'] ) ? 'name' : 'count'; + $args['order'] = ( 'name' === $atts['orderby'] ) ? 'ASC' : 'DESC'; + } + + $terms = get_terms( $args ); + if ( is_wp_error( $terms ) || empty( $terms ) ) { + return ''; + } + + $archive = get_post_type_archive_link( 'viator_tour' ); + $rail = self::open_rail( $atts['title'], $atts['lead'], $archive, __( 'See all', 'viator-collector' ) ); + + ob_start(); + echo $rail['head']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + foreach ( $terms as $term ) { + $link = get_term_link( $term ); + if ( is_wp_error( $link ) ) { + continue; + } + $image = vc_term_image_url( $term->term_id, 'viator_destination' ); + ?> +
  • + + <?php echo esc_attr( $term->name ); ?> + +
    +

    name ); ?>

    +
    +
    + + count ) ); ?> +
    + +
    +
    +
  • + __( "FAQ's", 'viator-collector' ), + 'lead' => __( 'Everything you need to know before you book.', 'viator-collector' ), + 'items' => '', + ), + $atts, + self::FAQ + ); + + $items = array(); + if ( '' !== $atts['items'] ) { + foreach ( explode( ';;', $atts['items'] ) as $pair ) { + $parts = array_map( 'trim', explode( '|', $pair, 2 ) ); + if ( '' !== $parts[0] && isset( $parts[1] ) ) { + $items[] = array( 'q' => $parts[0], 'a' => $parts[1] ); + } + } + } + if ( empty( $items ) ) { + $items = apply_filters( + 'vc_faq_items', + array( + array( 'q' => __( 'Is the booking process fully online?', 'viator-collector' ), 'a' => __( 'Yes — you can browse, choose a date and complete your booking entirely online in a few minutes.', 'viator-collector' ) ), + array( 'q' => __( 'How can I book a tour?', 'viator-collector' ), 'a' => __( 'Pick a tour, choose your date and number of seats, then confirm. You will receive your tickets by email.', 'viator-collector' ) ), + array( 'q' => __( 'Can I cancel or change my booking?', 'viator-collector' ), 'a' => __( 'Most tours offer free cancellation up to 24 hours before the start time. Check each tour for details.', 'viator-collector' ) ), + array( 'q' => __( 'Do I need to print my ticket?', 'viator-collector' ), 'a' => __( 'No — a mobile ticket is accepted for the vast majority of tours.', 'viator-collector' ) ), + ) + ); + } + if ( empty( $items ) ) { + return ''; + } + + ob_start(); + ?> +
    +
    + +
    +
    +

    +

    +
    +
    + $item ) : ?> +
    +

    + +

    +

    +
    + +
    +
    +
    +
    + __( 'Your Comments', 'viator-collector' ), + 'lead' => __( 'What travellers say about their tours.', 'viator-collector' ), + ), + $atts, + self::REVIEWS + ); + + $a1 = vc_asset_url( 'img/ellipse-17.png' ); + $a2 = vc_asset_url( 'img/ellipse-17-2.png' ); + $items = apply_filters( + 'vc_testimonials', + array( + array( 'name' => 'Malika Favre', 'role' => __( 'Paris Tour', 'viator-collector' ), 'text' => __( 'An unforgettable day — the open-top ride and the guide made the whole city feel new. Booking was effortless.', 'viator-collector' ), 'avatar' => $a1, 'size' => 'lg' ), + array( 'name' => 'James Carter', 'role' => __( 'London Tour', 'viator-collector' ), 'text' => __( 'Great value and flexible timing. We hopped on and off all day and saw everything on our list.', 'viator-collector' ), 'avatar' => $a2, 'size' => '' ), + array( 'name' => 'Sofia Rossi', 'role' => __( 'Sydney Tour', 'viator-collector' ), 'text' => __( 'Smooth from start to finish. Highly recommended.', 'viator-collector' ), 'avatar' => $a1, 'size' => 'sm' ), + array( 'name' => 'Liam Nguyen', 'role' => __( 'San Diego Tour', 'viator-collector' ), 'text' => __( 'Friendly crew, comfortable seats and the best views of the coast.', 'viator-collector' ), 'avatar' => $a2, 'size' => '' ), + ) + ); + if ( empty( $items ) ) { + return ''; + } + + ob_start(); + ?> +
    +
    +
    +
    +

    +

    +
    +
    +
    +
    + + +
    +
    + + + + +
    +
    +
    + +
    +
    +
    +
    + 'viator_tour', + 'post_status' => 'publish', + 'posts_per_page' => max( 1, absint( $atts['limit'] ) ), + 'no_found_rows' => true, + 'ignore_sticky_posts' => true, + ); + + if ( '' !== $atts['ids'] ) { + $args['post__in'] = array_filter( array_map( 'absint', explode( ',', $atts['ids'] ) ) ); + $args['orderby'] = 'post__in'; + } elseif ( '' !== $atts['codes'] ) { + $args['meta_query'] = array( + 'vc_product_codes' => array( + 'key' => '_vc_viator_product_code', + 'value' => array_filter( array_map( 'sanitize_text_field', explode( ',', $atts['codes'] ) ) ), + 'compare' => 'IN', + ), + ); + } + + $tax_query = array(); + if ( '' !== $atts['destination'] ) { + $tax_query[] = array( 'taxonomy' => 'viator_destination', 'field' => 'slug', 'terms' => array_map( 'sanitize_title', explode( ',', $atts['destination'] ) ) ); + } + if ( '' !== $atts['tag'] ) { + $tax_query[] = array( 'taxonomy' => 'viator_tag', 'field' => 'slug', 'terms' => array_map( 'sanitize_title', explode( ',', $atts['tag'] ) ) ); + } + if ( $tax_query ) { + $args['tax_query'] = $tax_query; + } + + if ( ! isset( $args['orderby'] ) ) { + switch ( $atts['orderby'] ) { + case 'price': + $args['meta_key'] = '_vc_price_from'; + $args['orderby'] = 'meta_value_num'; + $args['order'] = 'ASC'; + break; + case 'rand': + $args['orderby'] = 'rand'; + break; + case 'date': + $args['orderby'] = 'date'; + $args['order'] = 'DESC'; + break; + default: + $args['meta_key'] = '_vc_rating'; + $args['orderby'] = 'meta_value_num'; + $args['order'] = 'DESC'; + } + } + + $query = new WP_Query( $args ); + + // Ordering by a meta key silently drops posts that lack it; fall back to + // newest so the section never empties. + if ( ! $query->have_posts() && isset( $args['meta_key'] ) ) { + unset( $args['meta_key'] ); + $args['orderby'] = 'date'; + $args['order'] = 'DESC'; + $query = new WP_Query( $args ); + } + + return $query; + } +} diff --git a/viator-collector/includes/class-vc-settings.php b/viator-collector/includes/class-vc-settings.php new file mode 100644 index 0000000..c3110c3 --- /dev/null +++ b/viator-collector/includes/class-vc-settings.php @@ -0,0 +1,542 @@ + __( 'Not allowed.', 'viator-collector' ) ) ); + } + + $query = isset( $_POST['q'] ) ? sanitize_text_field( wp_unslash( $_POST['q'] ) ) : ''; + $map = ( new VC_API() )->get_destination_map(); + if ( is_wp_error( $map ) ) { + wp_send_json_error( array( 'message' => $map->get_error_message() ) ); + } + + $needle = strtolower( $query ); + $results = array(); + foreach ( (array) $map as $id => $name ) { + if ( '' === $query || false !== strpos( strtolower( $name ), $needle ) ) { + $results[] = array( + 'id' => (string) $id, + 'name' => $name, + ); + if ( count( $results ) >= 40 ) { + break; + } + } + } + + wp_send_json_success( array( 'results' => $results ) ); + } + + public static function defaults() { + return array( + 'api_key' => '', + 'api_base_url' => 'https://api.viator.com/partner', + 'language' => 'en-US', + 'currency' => 'USD', + 'timeout' => 20, + 'default_post_status' => 'publish', + 'hero_image' => '', + 'hero_slides' => '', + 'crawl_destinations' => '', + 'rate_limit_per_minute' => 25, + 'products_per_page' => 50, + 'pages_per_run' => 10, + 'fetch_full_detail' => 0, + 'detail_batch_size' => 15, + 'log_every' => 10, + 'automatic_sync' => 0, + 'debug_logging' => 0, + ); + } + + public static function admin_menu() { + add_options_page( + __( 'Viator Collector', 'viator-collector' ), + __( 'Viator Collector', 'viator-collector' ), + 'manage_options', + 'viator-collector', + array( __CLASS__, 'render_page' ) + ); + } + + public static function register_settings() { + register_setting( + 'vc_settings_group', + 'vc_settings', + array( + 'type' => 'array', + 'sanitize_callback' => array( __CLASS__, 'sanitize_settings' ), + 'default' => self::defaults(), + ) + ); + } + + public static function sanitize_settings( $input ) { + $input = is_array( $input ) ? $input : array(); + $defaults = self::defaults(); + + $output = array( + 'api_key' => sanitize_text_field( $input['api_key'] ?? '' ), + 'api_base_url' => esc_url_raw( $input['api_base_url'] ?? $defaults['api_base_url'] ), + 'language' => sanitize_text_field( $input['language'] ?? 'en-US' ), + 'currency' => strtoupper( sanitize_text_field( $input['currency'] ?? 'USD' ) ), + 'timeout' => max( 5, absint( $input['timeout'] ?? 20 ) ), + 'default_post_status' => sanitize_key( $input['default_post_status'] ?? 'publish' ), + 'hero_image' => esc_url_raw( $input['hero_image'] ?? '' ), + 'hero_slides' => self::sanitize_id_list( $input['hero_slides'] ?? '' ), + 'crawl_destinations' => self::sanitize_id_list( $input['crawl_destinations'] ?? '' ), + 'rate_limit_per_minute' => max( 1, min( 60, absint( $input['rate_limit_per_minute'] ?? 25 ) ) ), + 'products_per_page' => max( 1, min( 50, absint( $input['products_per_page'] ?? 50 ) ) ), + 'pages_per_run' => max( 1, min( 100, absint( $input['pages_per_run'] ?? 10 ) ) ), + 'fetch_full_detail' => ! empty( $input['fetch_full_detail'] ) ? 1 : 0, + 'detail_batch_size' => max( 1, min( 25, absint( $input['detail_batch_size'] ?? 15 ) ) ), + 'log_every' => max( 1, min( 1000, absint( $input['log_every'] ?? 10 ) ) ), + 'automatic_sync' => ! empty( $input['automatic_sync'] ) ? 1 : 0, + 'debug_logging' => ! empty( $input['debug_logging'] ) ? 1 : 0, + ); + + if ( ! in_array( $output['default_post_status'], array( 'publish', 'draft', 'pending' ), true ) ) { + $output['default_post_status'] = 'publish'; + } + + return $output; + } + + public static function render_page() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + $options = wp_parse_args( vc_get_option( '', array() ), self::defaults() ); + $notice = isset( $_GET['vc_notice'] ) ? sanitize_text_field( wp_unslash( $_GET['vc_notice'] ) ) : ''; + $status = VC_Cron::status(); + ?> +
    +

    + +

    + + +

    + + + + + + + + + +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +

    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    + + + +

    +
    + +
    +

    +
    + + + + + +
    + +
    +

    + +
    + + + +
    + +

    + + + + + + + + + + + + + + + + + + + +
    + +
    + get_destinations(); + if ( is_wp_error( $result ) ) { + VC_Logger::error( 'API test failed: ' . $result->get_error_message() ); + self::redirect_notice( $result->get_error_message() ); + } + VC_Logger::info( 'API test connection successful.' ); + self::redirect_notice( __( 'API connection successful.', 'viator-collector' ) ); + } + + public static function handle_manual_sync() { + self::verify_action( 'vc_manual_sync' ); + VC_Cron::run(); + $totals = get_option( 'vc_ingest_totals', array() ); + if ( get_option( 'vc_ingest_in_progress' ) ) { + self::redirect_notice( sprintf( + /* translators: %s: JSON progress counts. */ + __( 'Sync started and continues in the background. Progress so far: %s', 'viator-collector' ), + wp_json_encode( $totals ) + ) ); + } + self::redirect_notice( sprintf( + /* translators: %s: JSON result counts. */ + __( 'Sync complete. Result: %s', 'viator-collector' ), + wp_json_encode( get_option( 'vc_last_sync_totals', $totals ) ) + ) ); + } + + public static function handle_reset_sync() { + self::verify_action( 'vc_reset_sync' ); + VC_Cron::reset(); + self::redirect_notice( __( 'Ingestion progress reset. The next run will crawl the full catalogue.', 'viator-collector' ) ); + } + + public static function handle_clear_log() { + self::verify_action( 'vc_clear_log' ); + VC_Logger::clear_log(); + self::redirect_notice( __( 'Activity log cleared.', 'viator-collector' ) ); + } + + /** + * Delete every imported tour, all plugin taxonomy terms, and sync/log + * state. Plugin settings (API key, filter, etc.) are kept. + */ + public static function handle_delete_all() { + self::verify_action( 'vc_delete_all' ); + global $wpdb; + + // Stop and clear any in-progress run first. + VC_Cron::reset(); + + // Tour posts + their meta + term links. + $ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s", 'viator_tour' ) ); + if ( ! empty( $ids ) ) { + $in = implode( ',', array_map( 'intval', $ids ) ); + $wpdb->query( "DELETE FROM {$wpdb->postmeta} WHERE post_id IN ($in)" ); // phpcs:ignore WordPress.DB.PreparedSQL + $wpdb->query( "DELETE FROM {$wpdb->term_relationships} WHERE object_id IN ($in)" ); // phpcs:ignore WordPress.DB.PreparedSQL + $wpdb->query( "DELETE FROM {$wpdb->posts} WHERE ID IN ($in)" ); // phpcs:ignore WordPress.DB.PreparedSQL + } + + // Plugin taxonomy terms (WP API is safe; counts are small). + foreach ( array( 'viator_destination', 'viator_tag', 'viator_category' ) as $taxonomy ) { + $terms = get_terms( array( 'taxonomy' => $taxonomy, 'hide_empty' => false, 'fields' => 'ids' ) ); + if ( ! is_wp_error( $terms ) ) { + foreach ( $terms as $term_id ) { + wp_delete_term( (int) $term_id, $taxonomy ); + } + } + } + + // Sync state, logs, transients — but keep the saved settings. + $wpdb->query( "DELETE FROM {$wpdb->options} WHERE ( option_name LIKE 'vc\\_%' AND option_name <> 'vc_settings' ) OR option_name LIKE '\\_transient\\_vc\\_%' OR option_name LIKE '\\_transient\\_timeout\\_vc\\_%'" ); // phpcs:ignore WordPress.DB.PreparedSQL + + wp_cache_flush(); + + $count = is_array( $ids ) ? count( $ids ) : 0; + self::redirect_notice( sprintf( + /* translators: %d: number of deleted tours. */ + __( 'Deleted %d tours and all imported data. Your settings were kept.', 'viator-collector' ), + $count + ) ); + } + + public static function handle_import_product() { + self::verify_action( 'vc_import_product' ); + $product_code = isset( $_POST['product_code'] ) ? sanitize_text_field( wp_unslash( $_POST['product_code'] ) ) : ''; + $result = ( new VC_Importer() )->import_product_by_code( $product_code ); + if ( is_wp_error( $result ) ) { + VC_Logger::error( 'Manual product import failed (' . $product_code . '): ' . $result->get_error_message() ); + self::redirect_notice( $result->get_error_message() ); + } + VC_Logger::info( 'Manual product import succeeded: ' . $product_code ); + self::redirect_notice( __( 'Product imported successfully.', 'viator-collector' ) ); + } + + private static function verify_action( $nonce_action ) { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You do not have permission to perform this action.', 'viator-collector' ) ); + } + check_admin_referer( $nonce_action ); + } + + private static function redirect_notice( $message ) { + wp_safe_redirect( + add_query_arg( + array( + 'page' => 'viator-collector', + 'vc_notice' => rawurlencode( sanitize_text_field( $message ) ), + ), + admin_url( 'options-general.php' ) + ) + ); + exit; + } +} diff --git a/viator-collector/includes/class-vc-taxonomies.php b/viator-collector/includes/class-vc-taxonomies.php new file mode 100644 index 0000000..2b1b0c2 --- /dev/null +++ b/viator-collector/includes/class-vc-taxonomies.php @@ -0,0 +1,257 @@ + + + + +
    + + + +
    +

    + +

    + + +
    + + +
    + term_id, '_vc_image_url', true ); + ?> + + + + + array( + 'name' => $plural, + 'singular_name' => $singular, + ), + 'public' => true, + 'hierarchical' => $hierarchical, + 'rewrite' => array( 'slug' => $slug ), + 'show_in_rest' => true, + ) + ); + } + + public static function get_or_create_destination( $item ) { + $name = self::term_name( $item ); + if ( '' === $name ) { + return 0; + } + $id = vc_first_non_empty( array( vc_array_get( $item, 'destinationId' ), vc_array_get( $item, 'id' ) ) ); + $ref = vc_first_non_empty( array( vc_array_get( $item, 'destinationRef' ), vc_array_get( $item, 'ref' ) ) ); + return self::get_or_create_term( 'viator_destination', $name, '_vc_viator_destination_id', $id, array( '_vc_viator_destination_ref' => $ref ) ); + } + + public static function get_or_create_tag( $item ) { + $name = self::term_name( $item ); + $id = vc_first_non_empty( array( vc_array_get( $item, 'tagId' ), vc_array_get( $item, 'id' ) ) ); + return self::get_or_create_term( 'viator_tag', $name, '_vc_viator_tag_id', $id ); + } + + /** + * Resolve a destination term from a numeric Viator destination id, using + * a name looked up from the destinations dictionary. + */ + public static function get_or_create_destination_by_id( $id, $name = '' ) { + $id = (string) $id; + if ( '' === $id ) { + return 0; + } + $name = sanitize_text_field( (string) $name ); + if ( '' === $name ) { + /* translators: %s: Viator destination id. */ + $name = sprintf( __( 'Destination %s', 'viator-collector' ), $id ); + } + return self::get_or_create_term( 'viator_destination', $name, '_vc_viator_destination_id', $id ); + } + + /** + * Resolve a tag term from a numeric Viator tag id, using a name looked up + * from the tags dictionary. + */ + public static function get_or_create_tag_by_id( $id, $name = '' ) { + $id = (string) $id; + if ( '' === $id ) { + return 0; + } + $name = sanitize_text_field( (string) $name ); + if ( '' === $name ) { + /* translators: %s: Viator tag id. */ + $name = sprintf( __( 'Tag %s', 'viator-collector' ), $id ); + } + return self::get_or_create_term( 'viator_tag', $name, '_vc_viator_tag_id', $id ); + } + + public static function get_or_create_category( $item ) { + $name = self::term_name( $item ); + $id = vc_first_non_empty( array( vc_array_get( $item, 'categoryId' ), vc_array_get( $item, 'id' ) ) ); + return self::get_or_create_term( 'viator_category', $name, '_vc_viator_category_id', $id ); + } + + private static function term_name( $item ) { + if ( is_scalar( $item ) ) { + return sanitize_text_field( (string) $item ); + } + if ( ! is_array( $item ) ) { + return ''; + } + return sanitize_text_field( + vc_first_non_empty( + array( + vc_array_get( $item, 'name' ), + vc_array_get( $item, 'destinationName' ), + vc_array_get( $item, 'tagName' ), + vc_array_get( $item, 'categoryName' ), + vc_array_get( $item, 'title' ), + ) + ) + ); + } + + private static function get_or_create_term( $taxonomy, $name, $meta_key, $viator_id = '', $extra_meta = array() ) { + if ( '' === $name ) { + return 0; + } + + if ( '' !== (string) $viator_id ) { + $found = get_terms( + array( + 'taxonomy' => $taxonomy, + 'hide_empty' => false, + 'number' => 1, + 'meta_query' => array( + array( + 'key' => $meta_key, + 'value' => (string) $viator_id, + ), + ), + ) + ); + if ( ! is_wp_error( $found ) && ! empty( $found ) ) { + self::update_term_meta( $found[0]->term_id, $meta_key, $viator_id, $extra_meta ); + return (int) $found[0]->term_id; + } + } + + $term = term_exists( $name, $taxonomy ); + if ( 0 === $term || null === $term ) { + $term = wp_insert_term( $name, $taxonomy ); + } + + if ( is_wp_error( $term ) ) { + VC_Logger::log( 'Term error: ' . $term->get_error_message(), true ); + return 0; + } + + $term_id = is_array( $term ) ? (int) $term['term_id'] : (int) $term; + self::update_term_meta( $term_id, $meta_key, $viator_id, $extra_meta ); + return $term_id; + } + + private static function update_term_meta( $term_id, $meta_key, $viator_id, $extra_meta ) { + if ( '' !== (string) $viator_id ) { + update_term_meta( $term_id, $meta_key, sanitize_text_field( (string) $viator_id ) ); + } + foreach ( $extra_meta as $key => $value ) { + if ( '' !== (string) $value ) { + update_term_meta( $term_id, $key, sanitize_text_field( (string) $value ) ); + } + } + } +} diff --git a/viator-collector/includes/helpers.php b/viator-collector/includes/helpers.php new file mode 100644 index 0000000..e7beaf2 --- /dev/null +++ b/viator-collector/includes/helpers.php @@ -0,0 +1,557 @@ + $class, 'loading' => 'lazy' ) ); + } + $url = get_post_meta( $post_id, '_vc_primary_image_url', true ); + if ( ! $url ) { + $urls = vc_json_decode_meta( get_post_meta( $post_id, '_vc_gallery_image_urls', true ), array() ); + $url = ! empty( $urls ) ? reset( $urls ) : ''; + } + if ( ! $url ) { + return ''; + } + return '' . esc_attr( get_the_title( $post_id ) ) . ''; +} + +/** + * Resolve an image URL for a taxonomy term: a custom URL set on the term, + * otherwise the primary image of a tour in that term. Result is cached. + */ +/** + * Banner image for the listing hero. + * + * Resolution order: + * 1. On a term archive, that term's own image (term meta `_vc_image_url`). + * 2. The "Hero image" chosen in Settings → Viator Collector. + * 3. Nothing — the CSS falls back to a gradient. + * + * Filterable via `vc_hero_image_url` so a theme can override without touching + * the plugin. + */ +function vc_hero_image_url() { + $url = ''; + + if ( is_tax( array( 'viator_destination', 'viator_tag', 'viator_category' ) ) ) { + $term = get_queried_object(); + if ( $term && ! is_wp_error( $term ) ) { + $url = vc_term_image_url( $term->term_id, $term->taxonomy ); + } + } + + if ( ! $url ) { + $url = (string) vc_get_option( 'hero_image', '' ); + } + + return apply_filters( 'vc_hero_image_url', $url ); +} + +function vc_term_image_url( $term_id, $taxonomy = '' ) { + $term_id = absint( $term_id ); + if ( ! $term_id ) { + return ''; + } + + $custom = get_term_meta( $term_id, '_vc_image_url', true ); + if ( $custom ) { + return $custom; + } + + $cache_key = 'vc_term_img_' . $term_id; + $cached = get_transient( $cache_key ); + if ( false !== $cached ) { + return $cached; + } + + if ( '' === $taxonomy ) { + $term = get_term( $term_id ); + if ( $term && ! is_wp_error( $term ) ) { + $taxonomy = $term->taxonomy; + } + } + + $url = ''; + if ( $taxonomy ) { + $query = new WP_Query( + array( + 'post_type' => 'viator_tour', + 'posts_per_page' => 1, + 'fields' => 'ids', + 'no_found_rows' => true, + 'update_post_term_cache' => false, + 'tax_query' => array( + array( + 'taxonomy' => $taxonomy, + 'field' => 'term_id', + 'terms' => $term_id, + ), + ), + 'meta_query' => array( + array( + 'key' => '_vc_primary_image_url', + 'value' => '', + 'compare' => '!=', + ), + ), + ) + ); + if ( ! empty( $query->posts ) ) { + $url = (string) get_post_meta( (int) $query->posts[0], '_vc_primary_image_url', true ); + } + } + + set_transient( $cache_key, $url, 12 * HOUR_IN_SECONDS ); + return $url; +} + +/** + * Number of published tours assigned to a term. + */ +function vc_term_tour_count( $term ) { + if ( is_object( $term ) && isset( $term->count ) ) { + return (int) $term->count; + } + return 0; +} + +/** + * Render a 5-star rating widget from a numeric rating (0-5). + */ +function vc_render_stars( $rating ) { + $rating = (float) $rating; + if ( $rating <= 0 ) { + return ''; + } + $pct = max( 0, min( 100, ( $rating / 5 ) * 100 ) ); + return ''; +} + +/** + * Human label for a Viator inclusion / exclusion / additional-info object. + * These come in several shapes, so try the known text fields in order. + */ +function vc_feature_text( $item ) { + if ( is_scalar( $item ) ) { + return (string) $item; + } + if ( ! is_array( $item ) ) { + return ''; + } + return (string) vc_first_non_empty( + array( + vc_array_get( $item, 'description' ), + vc_array_get( $item, 'otherDescription' ), + vc_array_get( $item, 'typeDescription' ), + vc_array_get( $item, 'categoryDescription' ), + vc_array_get( $item, 'name' ), + vc_array_get( $item, 'title' ), + ) + ); +} + +/** + * Format a duration given in minutes as "45 min" or "2h 15m". + */ +function vc_duration_human( $minutes ) { + $minutes = (int) $minutes; + if ( $minutes <= 0 ) { + return ''; + } + if ( $minutes < 60 ) { + return sprintf( _n( '%d min', '%d min', $minutes, 'viator-collector' ), $minutes ); + } + $hours = intdiv( $minutes, 60 ); + $rem = $minutes % 60; + return $rem ? sprintf( '%dh %dm', $hours, $rem ) : sprintf( _n( '%d hour', '%d hours', $hours, 'viator-collector' ), $hours ); +} + +/** + * Normalise a Viator itinerary object into a flat list of steps. + * Handles unstructured itineraries, standard itineraryItems, and multi-day + * itineraries (days[].items[]). + * + * @return array[] Each step: description, duration (min), admission (bool), passby (bool). + */ +function vc_itinerary_steps( $itinerary ) { + if ( ! is_array( $itinerary ) ) { + return array(); + } + + $steps = array(); + + $unstructured = vc_first_non_empty( + array( + vc_array_get( $itinerary, 'unstructuredItinerary' ), + vc_array_get( $itinerary, 'unstructuredDescription' ), + ) + ); + if ( $unstructured ) { + $steps[] = array( 'description' => $unstructured ); + } + + $items = vc_array_get( $itinerary, 'itineraryItems', array() ); + if ( empty( $items ) ) { + foreach ( (array) vc_array_get( $itinerary, 'days', array() ) as $day ) { + foreach ( (array) vc_array_get( $day, 'items', array() ) as $item ) { + $items[] = $item; + } + } + } + + foreach ( (array) $items as $item ) { + if ( ! is_array( $item ) ) { + continue; + } + $description = vc_first_non_empty( array( vc_array_get( $item, 'description' ), vc_array_get( $item, 'name' ) ) ); + if ( '' === (string) $description ) { + continue; + } + $steps[] = array( + 'description' => $description, + 'duration' => (int) vc_first_non_empty( + array( + vc_array_get( $item, 'duration.fixedDurationInMinutes' ), + vc_array_get( $item, 'duration.variableDurationFromMinutes' ), + ), + 0 + ), + 'admission' => 'YES' === strtoupper( (string) vc_array_get( $item, 'admissionIncluded' ) ), + 'passby' => (bool) vc_array_get( $item, 'passByWithoutStopping', false ), + ); + } + + return $steps; +} + +/** + * Star string for a rating: gold stars + outlined stars, 5 total. + */ +/** + * Gold/grey star markup used by the listing + single templates. + * Matches the .stars / .stars.muted rules in vc-design.css. + */ +function vc_stars_html( $rating ) { + $full = (int) round( (float) $rating ); + $full = max( 0, min( 5, $full ) ); + $out = ''; + if ( $full > 0 ) { + $out .= '' . str_repeat( '★', $full ) . ''; + } + if ( $full < 5 ) { + $out .= '' . str_repeat( '★', 5 - $full ) . ''; + } + return $out; +} + +function vc_star_string( $rating ) { + $n = (int) round( (float) $rating ); + $n = max( 0, min( 5, $n ) ); + return str_repeat( '★', $n ) . str_repeat( '☆', 5 - $n ); +} + +/** + * Turn a product's flags array into display badges. + * + * @return array[] Each: array( 'label' => string, 'type' => blue|amber|red ). + */ +function vc_badges_from_flags( $flags ) { + $flags = is_array( $flags ) ? array_map( 'strtoupper', $flags ) : array(); + $map = array( + 'FREE_CANCELLATION' => array( 'label' => __( 'Free Cancellation', 'viator-collector' ), 'type' => 'blue' ), + 'SPECIAL_OFFER' => array( 'label' => __( 'Best Deal', 'viator-collector' ), 'type' => 'amber' ), + 'LIKELY_TO_SELL_OUT' => array( 'label' => __( 'Likely to Sell Out', 'viator-collector' ), 'type' => 'red' ), + ); + $out = array(); + foreach ( $map as $flag => $badge ) { + if ( in_array( $flag, $flags, true ) ) { + $out[] = $badge; + } + } + return $out; +} + +/** + * Pick ONE badge for a tour card, by scarcity. + * + * Nearly every Viator product carries FREE_CANCELLATION, so using it as the + * card badge makes every card look identical. Only genuinely distinguishing + * signals earn the badge; free cancellation is shown as a trust line instead. + * + * @return array|null array( 'label' => string, 'type' => red|amber|blue ) + */ +function vc_card_badge( $post_id ) { + $flags = vc_json_decode_meta( get_post_meta( $post_id, '_vc_flags', true ), array() ); + $flags = is_array( $flags ) ? array_map( 'strtoupper', $flags ) : array(); + + if ( in_array( 'LIKELY_TO_SELL_OUT', $flags, true ) ) { + return array( 'label' => __( 'Likely to Sell Out', 'viator-collector' ), 'type' => 'red' ); + } + if ( in_array( 'SPECIAL_OFFER', $flags, true ) ) { + return array( 'label' => __( 'Best Deal', 'viator-collector' ), 'type' => 'amber' ); + } + + // Derived from data we already store — highlights standouts. + $rating = (float) get_post_meta( $post_id, '_vc_rating', true ); + $reviews = (int) get_post_meta( $post_id, '_vc_review_count', true ); + if ( $rating >= 4.8 && $reviews >= 50 ) { + return array( 'label' => __( 'Top Rated', 'viator-collector' ), 'type' => 'blue' ); + } + + return null; +} + +/** + * Map a Viator additional-info type to a Lucide icon name. + */ +function vc_infotype_icon( $type ) { + $type = strtoupper( (string) $type ); + $map = array( + 'STROLLER_ACCESSIBLE' => 'baby', + 'INFANT_SEATS_AVAILABLE' => 'baby', + 'INFANTS_MUST_SIT_ON_LAPS' => 'baby', + 'PETS_WELCOME' => 'dog', + 'SERVICE_ANIMALS_ALLOWED' => 'dog', + 'PUBLIC_TRANSPORTATION_NEARBY' => 'bus', + 'WHEELCHAIR_ACCESSIBLE' => 'accessibility', + 'NOT_WHEELCHAIR_ACCESSIBLE' => 'accessibility', + 'PHYSICAL_EASY' => 'dumbbell', + 'PHYSICAL_MODERATE' => 'dumbbell', + 'PHYSICAL_DIFFICULT' => 'dumbbell', + ); + return isset( $map[ $type ] ) ? $map[ $type ] : 'info'; +} + +/** + * Currency symbol for an ISO code. Unknown codes fall back to the code itself + * (e.g. "PEN 45") rather than guessing. + */ +function vc_currency_symbol( $currency ) { + $currency = strtoupper( sanitize_text_field( (string) $currency ) ); + if ( '' === $currency ) { + return ''; + } + + $symbols = apply_filters( + 'vc_currency_symbols', + array( + 'USD' => '$', + 'EUR' => '€', + 'GBP' => '£', + 'JPY' => '¥', + 'CNY' => '¥', + 'AUD' => 'A$', + 'CAD' => 'CA$', + 'NZD' => 'NZ$', + 'SGD' => 'S$', + 'HKD' => 'HK$', + 'MXN' => 'MX$', + 'BRL' => 'R$', + 'INR' => '₹', + 'KRW' => '₩', + 'THB' => '฿', + 'TRY' => '₺', + 'ILS' => '₪', + 'ZAR' => 'R', + ) + ); + + return isset( $symbols[ $currency ] ) ? $symbols[ $currency ] : $currency . ' '; +} + +/** + * Format a price the way travel sites do: "$38", "$1,200", "$79.50". + * + * Trailing ".00" is dropped — Viator returns whole-dollar "from" prices for most + * products, and "USD 130.00" reads like an invoice, not a price tag. + */ +function vc_money( $amount, $currency ) { + if ( '' === $amount || null === $amount ) { + return ''; + } + + $amount = (float) $amount; + $decimals = ( 0.0 === fmod( $amount, 1 ) ) ? 0 : 2; + + return vc_currency_symbol( $currency ) . number_format_i18n( $amount, $decimals ); +} + +/** + * Page numbers for the listing pager: a compact run with ellipsis gaps. + * Mirrors the mockup's pager (first, current±1, last). `null` marks an ellipsis. + * + * @return array + */ +function vc_pager_range( $current, $total ) { + $current = max( 1, (int) $current ); + $total = max( 1, (int) $total ); + + if ( $total <= 7 ) { + return range( 1, $total ); + } + + $want = array( 1, $current - 1, $current, $current + 1, $total ); + $want = array_values( array_unique( array_filter( $want, function ( $n ) use ( $total ) { + return $n >= 1 && $n <= $total; + } ) ) ); + sort( $want ); + + $out = array(); + $prev = 0; + foreach ( $want as $n ) { + if ( $prev && $n - $prev > 1 ) { + $out[] = null; // ellipsis + } + $out[] = $n; + $prev = $n; + } + return $out; +} + +/** + * URL to a bundled plugin asset, e.g. vc_asset_url( 'img/tower.png' ). + */ +function vc_asset_url( $rel ) { + return VC_PLUGIN_URL . 'assets/' . ltrim( (string) $rel, '/' ); +} + +/** + * Print the "Hop On Hop Off" SVG icon sprite once per request. The sprite + * holds every plus the #ringPathScroll circular text path + * used by the rotating "BOOK NOW" badges. Referenced via vc_icon() / . + */ +function vc_print_icon_sprite() { + static $done = false; + if ( $done ) { + return; + } + $done = true; + $path = VC_PLUGIN_DIR . 'assets/icons/sprite.svg'; + if ( is_readable( $path ) ) { + echo file_get_contents( $path ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + } +} + +/** + * Inline-icon markup referencing a sprite symbol. + * e.g. vc_icon( 'star', 'rating__star' ) => … + */ +function vc_icon( $name, $extra_class = '' ) { + $name = preg_replace( '/[^a-z0-9\-]/', '', 'i-' . ltrim( (string) $name, 'i-' ) ); + $class = 'icon' . ( $extra_class ? ' ' . $extra_class : '' ); + return ''; +} + +/** + * Rotating ring badge (the circular "BOOK NOW ·" anchor in the design). + * + * @param string $label Repeating label text (e.g. "BOOK NOW · BOOK NOW · "). + * @param string $url Link target. + * @param string $variant Modifier suffix: card|top|scroll|accent. + * @param bool $blank Open in a new tab (external booking links). + */ +function vc_ring_badge( $label, $url, $variant = 'card', $blank = false ) { + $aria = trim( str_replace( '·', '', $label ) ); + $target = $blank ? ' target="_blank" rel="nofollow noopener"' : ''; + ob_start(); + ?> + > + + + + +
    + __( 'Explore Tours & Experiences', 'viator-collector' ), + 'vc_subtitle' => __( 'Find the best tours, activities and unforgettable experiences.', 'viator-collector' ), + 'vc_crumb_label' => __( 'Tours', 'viator-collector' ), + ) + ); + ?> +
    + + diff --git a/viator-collector/templates/card-activity.php b/viator-collector/templates/card-activity.php new file mode 100644 index 0000000..543c409 --- /dev/null +++ b/viator-collector/templates/card-activity.php @@ -0,0 +1,25 @@ +term_id, $term->taxonomy ); +$count = vc_term_tour_count( $term ); +?> + + + + <?php echo esc_attr( $term->name ); ?> + + + + + name ); ?> + + + diff --git a/viator-collector/templates/card-attraction.php b/viator-collector/templates/card-attraction.php new file mode 100644 index 0000000..0e4b968 --- /dev/null +++ b/viator-collector/templates/card-attraction.php @@ -0,0 +1,32 @@ +name : ''; +?> + + + + + + + + + + + + + + + + diff --git a/viator-collector/templates/card-destination.php b/viator-collector/templates/card-destination.php new file mode 100644 index 0000000..4aacc45 --- /dev/null +++ b/viator-collector/templates/card-destination.php @@ -0,0 +1,27 @@ +term_id, $term->taxonomy ); +$count = vc_term_tour_count( $term ); +?> + + + + <?php echo esc_attr( $term->name ); ?> + + + + + + + name ); ?> + + + diff --git a/viator-collector/templates/card-tour.php b/viator-collector/templates/card-tour.php new file mode 100644 index 0000000..ab14eb7 --- /dev/null +++ b/viator-collector/templates/card-tour.php @@ -0,0 +1,97 @@ +), + * 'rail' → homepage rail card (
  • ). + * + * Markup mirrors the .dcard rules in assets/css/vc-design.css (ported from + * new front/tours.html and index.html). + */ +defined( 'ABSPATH' ) || exit; + +$post_id = isset( $post_id ) ? absint( $post_id ) : get_the_ID(); +if ( ! $post_id ) { + return; +} +$variant = isset( $variant ) ? $variant : 'wide'; + +$permalink = get_permalink( $post_id ); +$title = get_the_title( $post_id ); +$price = get_post_meta( $post_id, '_vc_price_from', true ); +$currency = get_post_meta( $post_id, '_vc_currency', true ); +$rating = get_post_meta( $post_id, '_vc_rating', true ); +$duration = get_post_meta( $post_id, '_vc_duration_text', true ); +$minutes = absint( get_post_meta( $post_id, '_vc_duration_minutes', true ) ); +if ( '' === (string) $duration && $minutes ) { + $duration = vc_duration_human( $minutes ); +} +$excerpt = has_excerpt( $post_id ) + ? get_the_excerpt( $post_id ) + : wp_trim_words( wp_strip_all_tags( (string) get_post_field( 'post_content', $post_id ) ), 22 ); + +$rating_html = ''; +if ( '' !== (string) $rating ) { + $rating_html = '' . vc_icon( 'star', 'icon--12 rating__star' ) + . '' . esc_html( number_format_i18n( (float) $rating, 1 ) ) . '/5'; +} + +$img_html = vc_tour_image_html( $post_id, 'dcard__img' ); +$title_link = '' . esc_html( $title ) . ''; + +ob_start(); +?> +
    + +
    + + +
    + + +
    + + +
    + + +
    + +
  • + + + +
    + +

    +
    + +
    +

    + +
    +
  • + +
  • +
    + + +
    +

    + +
    +
    + +

    + +
  • + +

    diff --git a/viator-collector/templates/exclusions.php b/viator-collector/templates/exclusions.php new file mode 100644 index 0000000..d04b4ca --- /dev/null +++ b/viator-collector/templates/exclusions.php @@ -0,0 +1,23 @@ + +
    +

    +
      + +
    • + +
    +
    diff --git a/viator-collector/templates/inclusions.php b/viator-collector/templates/inclusions.php new file mode 100644 index 0000000..2c9a61c --- /dev/null +++ b/viator-collector/templates/inclusions.php @@ -0,0 +1,23 @@ + +
    +

    +
      + +
    • + +
    +
    diff --git a/viator-collector/templates/itinerary.php b/viator-collector/templates/itinerary.php new file mode 100644 index 0000000..3242739 --- /dev/null +++ b/viator-collector/templates/itinerary.php @@ -0,0 +1,35 @@ + +
    +

    +
      + $step ) : ?> +
    1. + +
      +

      + +
      + + + + + + + + + +
      + +
      +
    2. + +
    +
    diff --git a/viator-collector/templates/listing.php b/viator-collector/templates/listing.php new file mode 100644 index 0000000..6582b7e --- /dev/null +++ b/viator-collector/templates/listing.php @@ -0,0 +1,253 @@ + 'viator_destination', 'hide_empty' => true, 'number' => 20, 'orderby' => 'count', 'order' => 'DESC' ) ); +$vc_categories = get_terms( array( 'taxonomy' => 'viator_tag', 'hide_empty' => true, 'number' => 20, 'orderby' => 'count', 'order' => 'DESC' ) ); +$vc_destinations = is_wp_error( $vc_destinations ) ? array() : $vc_destinations; +$vc_categories = is_wp_error( $vc_categories ) ? array() : $vc_categories; + +$vc_total = (int) $vc_query->found_posts; +$vc_per = 12; +$vc_paged = max( 1, (int) $vc_query->get( 'paged' ), (int) get_query_var( 'paged' ), isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1 ); // phpcs:ignore WordPress.Security.NonceVerification +$vc_first = $vc_total ? ( ( $vc_paged - 1 ) * $vc_per ) + 1 : 0; +$vc_last = min( $vc_total, $vc_paged * $vc_per ); +$vc_max = (int) $vc_query->max_num_pages; + +$vc_price_val = ( $f['pmin'] > 0 || $f['pmax'] < 1000 ) ? $f['pmin'] . '-' . $f['pmax'] : ''; +$vc_dur_labels = array( + 'half' => __( 'Half Day (≤5h)', 'viator-collector' ), + 'full' => __( 'Full Day (5–24h)', 'viator-collector' ), + 'multi' => __( 'Multi-Day', 'viator-collector' ), +); + +// Whether any filter is active (drives the "Clear filters" control). +$vc_active = ( '' !== $f['q'] || ! empty( $f['dest'] ) || ! empty( $f['cat'] ) || ! empty( $f['dur'] ) + || $f['pmin'] > 0 || $f['pmax'] < 1000 || $f['rating'] > 0 + || $f['freecancel'] || $f['bestdeal'] || $f['sellout'] ); + +// Current filters as a base URL, so pagination links keep the query string. +$vc_base_args = array(); +if ( '' !== $f['q'] ) { $vc_base_args['vc_q'] = $f['q']; } +if ( ! empty( $f['dest'] ) ) { $vc_base_args['dest'] = $f['dest']; } +if ( ! empty( $f['cat'] ) ) { $vc_base_args['cat'] = $f['cat']; } +if ( ! empty( $f['dur'] ) ) { $vc_base_args['dur'] = $f['dur']; } +if ( '' !== $vc_price_val ) { $vc_base_args['price'] = $vc_price_val; } +if ( $f['rating'] > 0 ) { $vc_base_args['rating'] = $f['rating']; } +if ( $f['freecancel'] ) { $vc_base_args['freecancel'] = 1; } +if ( $f['bestdeal'] ) { $vc_base_args['bestdeal'] = 1; } +if ( $f['sellout'] ) { $vc_base_args['sellout'] = 1; } +if ( 'newest' !== $f['sort'] ) { $vc_base_args['sort'] = $f['sort']; } + +$vc_page_url = function ( $n ) use ( $vc_action, $vc_base_args ) { + $args = $vc_base_args; + if ( $n > 1 ) { + $args['paged'] = $n; + } + return empty( $args ) ? $vc_action : add_query_arg( $args, $vc_action ); +}; + +$vc_hero_img = vc_hero_image_url(); +if ( ! $vc_hero_img ) { + $vc_hero_img = vc_asset_url( 'img/hero2.png' ); +} +?> +
    +
    + + +
    + +
    +

    +

    + +
    +
    +
    + + +
    + +
    + + + +
    + +
    + + + +
    + +
    + + + +
    + +
    + + + +
    + + +
    + +
    +
    + + + +
    + +
    + +
    + + +
    +
    +
    +
    +
    + + +
    + +
    +
    +
    +

    + + %1$d–%2$d of %3$d tours', 'viator-collector' ), array( 'b' => array() ) ), + (int) $vc_first, + (int) $vc_last, + (int) $vc_total + ); + ?> + + + +

    + + + +
    + + have_posts() ) : ?> +
      + have_posts() ) : + $vc_query->the_post(); + echo vc_get_template( 'card-tour.php', array( 'post_id' => get_the_ID(), 'variant' => 'wide' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + endwhile; + wp_reset_postdata(); + ?> +
    + + 1 ) : ?> + + + +

    .

    + +
    +
    diff --git a/viator-collector/templates/rating.php b/viator-collector/templates/rating.php new file mode 100644 index 0000000..8252794 --- /dev/null +++ b/viator-collector/templates/rating.php @@ -0,0 +1,10 @@ + +

    diff --git a/viator-collector/templates/single-viator_tour.php b/viator-collector/templates/single-viator_tour.php new file mode 100644 index 0000000..4d733ee --- /dev/null +++ b/viator-collector/templates/single-viator_tour.php @@ -0,0 +1,255 @@ + +
    +
    + + + + +
    +
    +

    +
    +

    + +
    +
    +
    +
    + +
    +
    +
    +
    +

    +
    + + /5 + + + + + + + + + +
    +
    + +
      + +
    • + + +
    • + + +
    • + + +
    • + +
    + + +
    +

    +
    +
    + + + +
    +

    + +
    + + + +
    + +
    +

    +
      + +
    • + +
    +
    + + +
    +

    +
      + +
    • + +
    +
    + +
    + + + +
    +

    +
      + +
    • ' . esc_html( $step['name'] ) . ' ' : ''; + echo $name . esc_html( ! empty( $step['description'] ) ? $step['description'] : '' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + ?>

    • + +
    +
    + + + +
    +

    +
      + +
    • + +
    +
    + + + +
    +

    +

    + +
    + 0; + $when = ( null !== $max ) + ? sprintf( __( 'Less than %d hours before the start time', 'viator-collector' ), (int) $max * 24 ) + : sprintf( __( '%d hours or more before the start time', 'viator-collector' ), (int) $min * 24 ); + ?> +
    + +
    + + +
    +
    + +
    + +
    + + + +
    +

    +
    + /5 + + +
    +
    + +
    + + +
    +
    +
    + name : __( 'Tours', 'viator-collector' ); +$vc_desc = term_description(); +?> +
    + $vc_name, + /* translators: %s: term name. */ + 'vc_subtitle' => $vc_desc ? wp_strip_all_tags( $vc_desc ) : sprintf( __( 'Explore the best tours in %s.', 'viator-collector' ), $vc_name ), + 'vc_crumb' => array( + array( + 'label' => __( 'Tours', 'viator-collector' ), + 'url' => get_post_type_archive_link( 'viator_tour' ), + ), + ), + ) + ); + ?> +
    +init(); + } +);