Compare commits

...

2 Commits

Author SHA1 Message Date
2362f9ab1f add plugin 2026-09-12 21:13:25 +03:30
7ada8860f8 Add viator-collector plugin (Hop On Hop Off front-end reskin)
Consolidate the plugin into the TourWebsite repository (its separate
nested git repo has been removed). Includes the full new-design reskin:
scoped design stylesheet, SVG icon sprite, rebuilt listing/single/section
templates, and homepage hero/FAQ/testimonials shortcodes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J4hNL1PSHxXbu94SCenpKj
2026-09-12 21:11:20 +03:30
72 changed files with 9019 additions and 0 deletions

View File

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

Before

Width:  |  Height:  |  Size: 440 KiB

After

Width:  |  Height:  |  Size: 440 KiB

View File

Before

Width:  |  Height:  |  Size: 997 KiB

After

Width:  |  Height:  |  Size: 997 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

Before

Width:  |  Height:  |  Size: 667 KiB

After

Width:  |  Height:  |  Size: 667 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 3.7 MiB

View File

Before

Width:  |  Height:  |  Size: 321 B

After

Width:  |  Height:  |  Size: 321 B

View File

Before

Width:  |  Height:  |  Size: 321 B

After

Width:  |  Height:  |  Size: 321 B

View File

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 2.6 MiB

View File

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

Before

Width:  |  Height:  |  Size: 707 KiB

After

Width:  |  Height:  |  Size: 707 KiB

View File

Before

Width:  |  Height:  |  Size: 595 KiB

After

Width:  |  Height:  |  Size: 595 KiB

1
viator-collector/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/gwc

View File

@@ -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 (`<br>` 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<br>wanna go?` | Heading (`<br>` 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 `<use href="#i-…">`.
- **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.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,176 @@
<svg class="sprite" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
<defs>
<path id="ringPathScroll" fill="none" d="M50,50 m-39,0 a39,39 0 1,1 78,0 a39,39 0 1,1 -78,0" />
</defs>
<symbol id="i-arrow-right" viewBox="0 0 16 16" fill="none">
<path d="M2.66699 8H13.3337M9.33366 12L13.3337 8L9.33366 4" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" />
</symbol>
<symbol id="i-arrow-left" viewBox="0 0 16 16" fill="none">
<path d="M13.333 8H2.66634M6.66634 12L2.66634 8L6.66634 4" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" />
</symbol>
<symbol id="i-arrow-ne" viewBox="0 0 16 16" fill="none">
<path d="M4 12L12 4M12 10V4H6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" />
</symbol>
<symbol id="i-arrow-up" viewBox="0 0 16 16" fill="none">
<path d="M8 13.3333L8 2.66659M12 6.66659L8 2.66659L4 6.66659" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" />
</symbol>
<symbol id="i-chevron" viewBox="0 0 16 16" fill="none">
<path d="M12.6663 6L7.99967 10L3.33301 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" />
</symbol>
<symbol id="i-search" viewBox="0 0 24 24" fill="none">
<circle cx="11.5" cy="11.5" r="9.5" stroke="currentColor" stroke-width="1.5" />
<path d="M18.5 18.5L22 22" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</symbol>
<symbol id="i-user" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="6" r="4" stroke="currentColor" stroke-width="1.5" />
<ellipse cx="12" cy="17" rx="7" ry="4" stroke="currentColor" stroke-width="1.5" />
</symbol>
<symbol id="i-location" viewBox="0 0 16 16" fill="none">
<path
d="M2.66699 6.76224C2.66699 3.76396 5.05481 1.33337 8.00033 1.33337C10.9458 1.33337 13.3337 3.76396 13.3337 6.76224C13.3337 9.73702 11.6314 13.2083 8.97561 14.4497C8.3565 14.7391 7.64415 14.7391 7.02504 14.4497C4.36921 13.2083 2.66699 9.73702 2.66699 6.76224Z"
stroke="currentColor" stroke-width="1.5" />
<circle cx="8" cy="6.66663" r="2" stroke="currentColor" stroke-width="1.5" />
</symbol>
<symbol id="i-calendar" viewBox="0 0 16 16" fill="none">
<path
d="M1.33301 7.99996C1.33301 5.4858 1.33301 4.22872 2.11406 3.44767C2.8951 2.66663 4.15218 2.66663 6.66634 2.66663H9.33301C11.8472 2.66663 13.1042 2.66663 13.8853 3.44767C14.6663 4.22872 14.6663 5.4858 14.6663 7.99996V9.33329C14.6663 11.8475 14.6663 13.1045 13.8853 13.8856C13.1042 14.6666 11.8472 14.6666 9.33301 14.6666H6.66634C4.15218 14.6666 2.8951 14.6666 2.11406 13.8856C1.33301 13.1045 1.33301 11.8475 1.33301 9.33329V7.99996Z"
stroke="currentColor" stroke-width="1.5" />
<path d="M4.66699 2.66663V1.66663" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M11.333 2.66663V1.66663" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M1.66699 6H14.3337" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</symbol>
<symbol id="i-seat" viewBox="0 0 16 16" fill="none">
<circle cx="5.99967" cy="4.00004" r="2.66667" stroke="currentColor" stroke-width="1.5" />
<path d="M10 6C11.1046 6 12 5.10457 12 4C12 2.89543 11.1046 2 10 2" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" />
<ellipse cx="5.99967" cy="11.3333" rx="4.66667" ry="2.66667" stroke="currentColor" stroke-width="1.5" />
<path d="M12 9.33337C13.1695 9.58984 14 10.2393 14 11C14 11.6863 13.3242 12.282 12.3333 12.5803"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</symbol>
<symbol id="i-mouse" viewBox="0 0 16 16" fill="none">
<path
d="M3.33301 5.99998C3.33301 3.42265 5.42235 1.33331 7.99967 1.33331C10.577 1.33331 12.6663 3.42265 12.6663 5.99998V9.99998C12.6663 12.5773 10.577 14.6666 7.99967 14.6666C5.42235 14.6666 3.33301 12.5773 3.33301 9.99998V5.99998Z"
stroke="currentColor" stroke-width="1.5" />
<path
d="M7 5.66669C7 5.1144 7.44772 4.66669 8 4.66669C8.55228 4.66669 9 5.1144 9 5.66669V7.00002C9 7.5523 8.55228 8.00002 8 8.00002C7.44772 8.00002 7 7.5523 7 7.00002V5.66669Z"
stroke="currentColor" stroke-width="1.5" />
<path d="M8 1.33331V4.66665" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</symbol>
<symbol id="i-grid" viewBox="0 0 16 16" fill="none">
<path
d="M1.66699 4.33329C1.66699 3.07621 1.66699 2.44767 2.05752 2.05715C2.44804 1.66663 3.07658 1.66663 4.33366 1.66663C5.59074 1.66663 6.21928 1.66663 6.6098 2.05715C7.00033 2.44767 7.00033 3.07621 7.00033 4.33329C7.00033 5.59037 7.00033 6.21891 6.6098 6.60944C6.21928 6.99996 5.59074 6.99996 4.33366 6.99996C3.07658 6.99996 2.44804 6.99996 2.05752 6.60944C1.66699 6.21891 1.66699 5.59037 1.66699 4.33329Z"
stroke="currentColor" stroke-width="1.5" />
<path
d="M9 11.6667C9 10.4096 9 9.78105 9.39052 9.39052C9.78105 9 10.4096 9 11.6667 9C12.9237 9 13.5523 9 13.9428 9.39052C14.3333 9.78105 14.3333 10.4096 14.3333 11.6667C14.3333 12.9237 14.3333 13.5523 13.9428 13.9428C13.5523 14.3333 12.9237 14.3333 11.6667 14.3333C10.4096 14.3333 9.78105 14.3333 9.39052 13.9428C9 13.5523 9 12.9237 9 11.6667Z"
stroke="currentColor" stroke-width="1.5" />
<path
d="M1.66699 11.6667C1.66699 10.4096 1.66699 9.78105 2.05752 9.39052C2.44804 9 3.07658 9 4.33366 9C5.59074 9 6.21928 9 6.6098 9.39052C7.00033 9.78105 7.00033 10.4096 7.00033 11.6667C7.00033 12.9237 7.00033 13.5523 6.6098 13.9428C6.21928 14.3333 5.59074 14.3333 4.33366 14.3333C3.07658 14.3333 2.44804 14.3333 2.05752 13.9428C1.66699 13.5523 1.66699 12.9237 1.66699 11.6667Z"
stroke="currentColor" stroke-width="1.5" />
<path
d="M9 4.33329C9 3.07621 9 2.44767 9.39052 2.05715C9.78105 1.66663 10.4096 1.66663 11.6667 1.66663C12.9237 1.66663 13.5523 1.66663 13.9428 2.05715C14.3333 2.44767 14.3333 3.07621 14.3333 4.33329C14.3333 5.59037 14.3333 6.21891 13.9428 6.60944C13.5523 6.99996 12.9237 6.99996 11.6667 6.99996C10.4096 6.99996 9.78105 6.99996 9.39052 6.60944C9 6.21891 9 5.59037 9 4.33329Z"
stroke="currentColor" stroke-width="1.5" />
</symbol>
<symbol id="i-price" viewBox="0 0 16 16" fill="none">
<circle cx="7.99967" cy="8.00004" r="6.66667" stroke="currentColor" stroke-width="1.5" />
<path d="M8 4V12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path
d="M10 6.33335C10 5.41288 9.10457 4.66669 8 4.66669C6.89543 4.66669 6 5.41288 6 6.33335C6 7.25383 6.89543 8.00002 8 8.00002C9.10457 8.00002 10 8.74621 10 9.66669C10 10.5872 9.10457 11.3334 8 11.3334C6.89543 11.3334 6 10.5872 6 9.66669"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</symbol>
<symbol id="i-duration" viewBox="0 0 16 16" fill="none">
<path
d="M1.33366 8.00004C1.33366 11.6819 4.31843 14.6667 8.00033 14.6667C11.6822 14.6667 14.667 11.6819 14.667 8.00004C14.667 4.31814 11.6822 1.33337 8.00033 1.33337"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M8 6V8.66667H10.6667" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" />
<circle cx="7.99967" cy="8.00004" r="6.66667" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
stroke-dasharray="0.5 3.5" />
</symbol>
<symbol id="i-sort" viewBox="0 0 16 16" fill="none">
<path d="M2.66699 11.3333H7.33366" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M2.66699 8L7.33366 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M2.66699 4.66669L7.33366 4.66669" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path
d="M11.333 2.66665L11.333 13.3333M13.333 5.33331L11.333 2.66665L9.33301 5.33331M9.33301 10.6666L11.333 13.3333L13.333 10.6666"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</symbol>
<symbol id="i-check" viewBox="0 0 16 16" fill="none">
<path d="M3.33301 8.33333L6.33301 11.3333L12.6663 5" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" />
</symbol>
<symbol id="i-info" viewBox="0 0 16 16" fill="none">
<circle cx="7.99967" cy="7.99992" r="6.66667" stroke="currentColor" stroke-width="1.5" />
<path d="M8 11.3333V7.33325" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<circle cx="8" cy="5.33333" r="0.666667" fill="currentColor" />
</symbol>
<symbol id="i-person" viewBox="0 0 16 16" fill="none">
<circle cx="7.99967" cy="4.00004" r="2.66667" stroke="currentColor" stroke-width="1.5" />
<ellipse cx="7.99967" cy="11.3333" rx="4.66667" ry="2.66667" stroke="currentColor" stroke-width="1.5" />
</symbol>
<symbol id="i-shield-check" viewBox="0 0 16 16" fill="none">
<path
d="M2 6.94448C2 4.81275 2 3.74689 2.25168 3.38831C2.50335 3.02973 3.50555 2.68668 5.50994 2.00057L5.89182 1.86985C6.93666 1.5122 7.45907 1.33337 8 1.33337C8.54093 1.33337 9.06335 1.5122 10.1082 1.86985L10.4901 2.00057C12.4945 2.68668 13.4966 3.02973 13.7483 3.38831C14 3.74689 14 4.81275 14 6.94448C14 7.26647 14 7.61565 14 7.99428C14 11.753 11.174 13.577 9.40096 14.3516C8.91999 14.5617 8.67951 14.6667 8 14.6667C7.32049 14.6667 7.08001 14.5617 6.59904 14.3516C4.82597 13.577 2 11.753 2 7.99428C2 7.61565 2 7.26647 2 6.94448Z"
stroke="currentColor" stroke-width="1.5" />
<path d="M6.33301 8.26663L7.28539 9.33329L9.66634 6.66663" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" />
</symbol>
<symbol id="i-x" viewBox="0 0 16 16" fill="none">
<path d="M12.6663 3.33337L3.33301 12.6667M3.33301 3.33337L12.6663 12.6667" stroke="currentColor"
stroke-width="2" stroke-linecap="round" />
</symbol>
<symbol id="i-star" viewBox="0 0 12 12">
<path
d="M4.21205 2.06464C4.94362 0.701584 5.30941 0.0200537 5.87277 0.000515809C6.43613 -0.019022 6.82396 0.636371 7.59964 1.94716L7.80031 2.28628C8.02073 2.65876 8.13095 2.845 8.29997 2.96455C8.46899 3.08409 8.67769 3.12341 9.09509 3.20203L9.4751 3.27362C10.9439 3.55031 11.6784 3.68865 11.8616 4.23622C12.0448 4.78378 11.5555 5.37794 10.5768 6.56625L10.3236 6.87368C10.0455 7.21136 9.90649 7.3802 9.84606 7.58521C9.78563 7.79021 9.81067 8.00818 9.86075 8.44411L9.90634 8.84099C10.0826 10.3751 10.1707 11.1421 9.72055 11.5C9.27043 11.858 8.58018 11.5698 7.19966 10.9934L6.84251 10.8443C6.45021 10.6805 6.25406 10.5986 6.04769 10.6058C5.84132 10.613 5.6481 10.7084 5.26165 10.8991L4.90982 11.0729C3.54988 11.7443 2.86992 12.08 2.40851 11.7536C1.9471 11.4273 2.00982 10.655 2.13527 9.1105L2.16772 8.71092C2.20337 8.27201 2.2212 8.05256 2.15408 7.85197C2.08696 7.65139 1.9425 7.49238 1.65359 7.17437L1.39055 6.88484C0.373843 5.76573 -0.134512 5.20618 0.0304441 4.64653C0.1954 4.08689 0.924422 3.89779 2.38247 3.5196L2.75968 3.42175C3.17401 3.31428 3.38117 3.26054 3.54606 3.12942C3.71095 2.99829 3.8149 2.80462 4.02279 2.41729L4.21205 2.06464Z"
fill="currentColor" />
</symbol>
<symbol id="i-plus" viewBox="0 0 14 14" fill="none">
<path d="M12.75 6.75005L6.75 6.75005M6.75 6.75005L0.75 6.75005M6.75 6.75005L6.75 0.75M6.75 6.75005L6.75 12.75"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</symbol>
<symbol id="i-minus" viewBox="0 0 14 14" fill="none">
<path d="M12.75 7L0.75 7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</symbol>
<symbol id="i-headset" viewBox="0 0 24 24" fill="none">
<path d="M21 17V12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12V17" stroke="currentColor"
stroke-width="1.5" />
<path d="M22 15.5V17.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M2 15.5V17.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path
d="M8 13.8446C8 13.0802 8 12.698 7.82526 12.4323C7.73733 12.2985 7.62061 12.188 7.4844 12.1095C7.21371 11.9535 6.84812 11.9896 6.11694 12.0617C4.88487 12.1831 4.26884 12.2439 3.82737 12.5764C3.60394 12.7448 3.41638 12.9593 3.27646 13.2067C3 13.6955 3 14.3395 3 15.6276V17.1933C3 18.4685 3 19.1061 3.28198 19.5986C3.38752 19.7829 3.51981 19.9491 3.67416 20.0913C4.08652 20.4714 4.68844 20.5901 5.89227 20.8275C6.73944 20.9945 7.16302 21.078 7.47564 20.9021C7.591 20.8372 7.69296 20.7493 7.77572 20.6434C8 20.3565 8 19.9078 8 19.0104V13.8446Z"
stroke="currentColor" stroke-width="1.5" />
<path
d="M16 13.8446C16 13.0802 16 12.698 16.1747 12.4323C16.2627 12.2985 16.3794 12.188 16.5156 12.1095C16.7863 11.9535 17.1519 11.9896 17.8831 12.0617C19.1151 12.1831 19.7312 12.2439 20.1726 12.5764C20.3961 12.7448 20.5836 12.9593 20.7235 13.2067C21 13.6955 21 14.3395 21 15.6276V17.1933C21 18.4685 21 19.1061 20.718 19.5986C20.6125 19.7829 20.4802 19.9491 20.3258 20.0913C19.9135 20.4714 19.3116 20.5901 18.1077 20.8275C17.2606 20.9945 16.837 21.078 16.5244 20.9021C16.409 20.8372 16.307 20.7493 16.2243 20.6434C16 20.3565 16 19.9078 16 19.0104V13.8446Z"
stroke="currentColor" stroke-width="1.5" />
</symbol>
<symbol id="i-whatsapp" viewBox="0 0 20 20">
<path
d="M17 2.875C15.125 1 12.625 0 10 0C4.5 0 0 4.5 0 10C0 11.75 0.500008 13.5 1.37501 15L0 20L5.25002 18.625C6.75002 19.375 8.375 19.875 10 19.875C15.5 19.875 20 15.375 20 9.875C20 7.25 18.875 4.75 17 2.875ZM10 18.25C8.5 18.25 7.00001 17.875 5.75001 17.125L5.49999 17L2.37499 17.875L3.25001 14.875L2.99999 14.5C2.12499 13.125 1.74999 11.625 1.74999 10.125C1.74999 5.625 5.5 1.875 10 1.875C12.25 1.875 14.25 2.75 15.875 4.25C17.5 5.875 18.25 7.875 18.25 10.125C18.25 14.5 14.625 18.25 10 18.25ZM14.5 12C14.25 11.875 13 11.25 12.75 11.25C12.5 11.125 12.375 11.125 12.25 11.375C12.125 11.625 11.625 12.125 11.5 12.375C11.375 12.5 11.25 12.5 11 12.5C10.75 12.375 10 12.125 9.00002 11.25C8.25002 10.625 7.75001 9.75 7.62501 9.5C7.50001 9.25 7.62502 9.125 7.75002 9C7.87502 8.875 8 8.75 8.125 8.625C8.25 8.5 8.25002 8.375 8.37502 8.25C8.50002 8.125 8.37502 8 8.37502 7.875C8.37502 7.75 7.87501 6.5 7.62501 6C7.50001 5.625 7.25002 5.625 7.12502 5.625C7.00002 5.625 6.87498 5.625 6.62498 5.625C6.49998 5.625 6.24998 5.625 5.99998 5.875C5.74998 6.125 5.12501 6.75 5.12501 8C5.12501 9.25 5.99999 10.375 6.12499 10.625C6.24999 10.75 7.87498 13.375 10.375 14.375C12.5 15.25 12.875 15 13.375 15C13.875 15 14.875 14.375 15 13.875C15.25 13.25 15.25 12.75 15.125 12.75C15 12.125 14.75 12.125 14.5 12Z"
fill="currentColor" />
</symbol>
<symbol id="i-instagram" viewBox="0 0 20 20">
<path
d="M10 1.80723C12.6506 1.80723 13.0121 1.80723 14.0964 1.80723C15.0602 1.80723 15.5422 2.04819 15.9036 2.16868C16.3855 2.40964 16.747 2.53012 17.1084 2.89157C17.4699 3.25301 17.7108 3.61446 17.8313 4.09639C17.9518 4.45783 18.0723 4.93976 18.1928 5.90362C18.1928 6.98795 18.1928 7.22892 18.1928 10C18.1928 12.7711 18.1928 13.0121 18.1928 14.0964C18.1928 15.0602 17.9518 15.5422 17.8313 15.9036C17.5904 16.3855 17.4699 16.747 17.1084 17.1084C16.747 17.4699 16.3855 17.7108 15.9036 17.8313C15.5422 17.9518 15.0602 18.0723 14.0964 18.1928C13.0121 18.1928 12.7711 18.1928 10 18.1928C7.22892 18.1928 6.98795 18.1928 5.90362 18.1928C4.93976 18.1928 4.45783 17.9518 4.09639 17.8313C3.61446 17.5904 3.25301 17.4699 2.89157 17.1084C2.53012 16.747 2.28916 16.3855 2.16868 15.9036C2.04819 15.5422 1.92771 15.0602 1.80723 14.0964C1.80723 13.0121 1.80723 12.7711 1.80723 10C1.80723 7.22892 1.80723 6.98795 1.80723 5.90362C1.80723 4.93976 2.04819 4.45783 2.16868 4.09639C2.40964 3.61446 2.53012 3.25301 2.89157 2.89157C3.25301 2.53012 3.61446 2.28916 4.09639 2.16868C4.45783 2.04819 4.93976 1.92771 5.90362 1.80723C6.98795 1.80723 7.3494 1.80723 10 1.80723ZM10 0C7.22892 0 6.98795 0 5.90362 0C4.81928 0 4.09639 0.240965 3.49398 0.481928C2.89157 0.722892 2.28916 1.08434 1.68675 1.68675C1.08434 2.28916 0.843374 2.77109 0.481928 3.49398C0.240965 4.09639 0.120482 4.81928 0 5.90362C0 6.98795 0 7.3494 0 10C0 12.7711 0 13.0121 0 14.0964C0 15.1807 0.240965 15.9036 0.481928 16.506C0.722892 17.1084 1.08434 17.7108 1.68675 18.3133C2.28916 18.9157 2.77109 19.1566 3.49398 19.5181C4.09639 19.759 4.81928 19.8795 5.90362 20C6.98795 20 7.3494 20 10 20C12.6506 20 13.0121 20 14.0964 20C15.1807 20 15.9036 19.759 16.506 19.5181C17.1084 19.2771 17.7108 18.9157 18.3133 18.3133C18.9157 17.7108 19.1566 17.2289 19.5181 16.506C19.759 15.9036 19.8795 15.1807 20 14.0964C20 13.0121 20 12.6506 20 10C20 7.3494 20 6.98795 20 5.90362C20 4.81928 19.759 4.09639 19.5181 3.49398C19.2771 2.89157 18.9157 2.28916 18.3133 1.68675C17.7108 1.08434 17.2289 0.843374 16.506 0.481928C15.9036 0.240965 15.1807 0.120482 14.0964 0C13.0121 0 12.7711 0 10 0Z"
fill="currentColor" />
<path
d="M10 4.81928C7.10843 4.81928 4.81928 7.10843 4.81928 10C4.81928 12.8916 7.10843 15.1807 10 15.1807C12.8916 15.1807 15.1807 12.8916 15.1807 10C15.1807 7.10843 12.8916 4.81928 10 4.81928ZM10 13.3735C8.19277 13.3735 6.62651 11.9277 6.62651 10C6.62651 8.19277 8.07229 6.62651 10 6.62651C11.8072 6.62651 13.3735 8.07229 13.3735 10C13.3735 11.8072 11.8072 13.3735 10 13.3735Z"
fill="currentColor" />
<path
d="M15.3012 5.90362C15.9666 5.90362 16.506 5.3642 16.506 4.6988C16.506 4.03339 15.9666 3.49398 15.3012 3.49398C14.6358 3.49398 14.0964 4.03339 14.0964 4.6988C14.0964 5.3642 14.6358 5.90362 15.3012 5.90362Z"
fill="currentColor" />
</symbol>
<symbol id="i-telegram" viewBox="0 0 20 17">
<path
d="M1.3749 7.31846C6.74359 4.91938 10.3236 3.33777 12.1148 2.57361C17.2292 0.391776 18.2919 0.0127698 18.9846 0.00012133C19.1369 -0.00249777 19.4776 0.0362266 19.6982 0.219862C19.8845 0.37492 19.9358 0.584381 19.9603 0.731393C19.9848 0.878405 20.0154 1.2133 19.9911 1.47498C19.714 4.46174 18.5147 11.7098 17.9046 15.055C17.6465 16.4705 17.1382 16.9451 16.6461 16.9916C15.5766 17.0925 14.7645 16.2667 13.7287 15.5702C12.1078 14.4805 11.1921 13.8021 9.61879 12.7387C7.80053 11.5097 8.97923 10.8343 10.0154 9.73042C10.2866 9.44153 14.9987 5.04555 15.0899 4.64677C15.1013 4.59689 15.1119 4.41098 15.0042 4.31282C14.8965 4.21465 14.7376 4.24822 14.6229 4.27492C14.4604 4.31276 11.871 6.06814 6.85486 9.54107C6.11988 10.0587 5.45416 10.3109 4.8577 10.2977C4.20015 10.2831 2.93528 9.91637 1.99498 9.60288C0.84166 9.21836 -0.0749729 9.01506 0.00484519 8.36203C0.0464194 8.0219 0.503103 7.67404 1.3749 7.31846Z"
fill="currentColor" />
</symbol>
<symbol id="i-facebook" viewBox="0 0 20 20">
<path
d="M20 10C20 4.5 15.5 0 10 0C4.5 0 0 4.5 0 10C0 15 3.625 19.125 8.375 19.875V12.875H5.875V10H8.375V7.75C8.375 5.25 9.875 3.875 12.125 3.875C13.25 3.875 14.375 4.125 14.375 4.125V6.625H13.125C11.875 6.625 11.5 7.375 11.5 8.125V10H14.25L13.75 12.875H11.375V20C16.375 19.25 20 15 20 10Z"
fill="currentColor" />
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 KiB

View File

@@ -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" });
});
})();

View File

@@ -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();

View File

@@ -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 `<symbol id="i-…">` set from `new front/index.html` into `assets/icons/sprite.svg`, plus a `<defs><path id="ringPathScroll" …></defs>`.
- [ ] `vc_print_icon_sprite()` — prints the sprite once (static guard).
- [ ] `vc_icon($name, $extra_class='')` — returns `<svg class="icon …"><use href="#i-$name"></svg>`.
- [ ] `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 `<select>`→`price`, duration `<select>`→`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.

View File

@@ -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 <symbol> 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 `<svg class="sprite">`
holding `<symbol id="i-…">` defs, referenced by
`<svg class="icon"><use href="#i-…"></svg>`. 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
`<svg class="icon"><use href="#i-$name"></svg>`.
- Replace all current `<i data-lucide="…">` 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 `<select>`, which the existing `get_filter_state()` already parses.
### 4.5 Enqueue changes (`class-vc-display.php::enqueue_assets`)
- Remove: Lucide, noUiSlider (css+js), `vc-listing.css`, `vc-single.css`,
`viator-collector.css`.
- Add: Google Fonts "Plus Jakarta Sans"; `vc-design.css`; `vc-design.js`.
- Keep: `vc-frontend.js` (trimmed).
- Gating logic (single vs archive vs shortcode page) stays as-is.
## 5. Surface-by-surface mapping
### 5.1 Listing / archive — `templates/listing.php` + `templates/card-tour.php`
Target markup: `new front/tours.html`.
- **Hero** → `section.hero.hero--archive` with eyebrow pill, `h1`, and the
horizontal **filter bar** `form.filters` (submits GET to the same
action as today). Replaces the old sidebar + noUiSlider layout.
- Search input → `vc_q`
- Destination `<select>` → `dest[]` (single value)
- Category `<select>` → `cat[]`
- Price Range `<select>` → `price` (values like `0-50`, `50-150`,
`150-1000`; already parsed by `get_filter_state()`)
- Duration `<select>` → `dur[]` buckets (`half`/`full`/`multi`, labeled
Half Day / Full Day / Multi-Day — adapted from the mockup's day
options to the plugin's real buckets)
- "Apply Filters" submit button
- Checkbox row (`filters__bar`): Free Cancellation → `freecancel`,
Best Deal → `bestdeal`, Likely to Sell Out → `sellout`
- A sort control (styled to the design) → `sort`
- **Results head** → `.results` with `#resultCount` ("Showing X–Y of Z
tours", server values) and a "Clear filters" link when filters active.
- **Grid** → `ul.tgrid` of `li.titem > article.dcard.dcard--wide` cards
(see 5.2). Active-filter chips retained, restyled.
- **Pager** → custom server-rendered pager matching `.pager` /
`.pager__num` design (replaces `paginate_links` default markup and the
JS pager). Preserves current query string.
- Empty state → styled "no tours" message.
### 5.2 Tour card — `templates/card-tour.php`
Target: the `.dcard--wide` card in `tours.html` (and the rail `.dcard`
in `index.html` for homepage rails — one template, optional modifier).
Fields → design slots:
| Design slot | Data source |
|---|---|
| `.dcard__img` | `vc_tour_image_html()` (real image) |
| `.rating` pill (`<b>4.0</b>/5` + star) | `_vc_rating` |
| `.dcard__title` | `get_the_title()` |
| meta "Start at" `$xx` | `_vc_price_from` + `_vc_currency` via `vc_money()` |
| meta "Duration" | `_vc_duration_text` / `vc_duration_human()` |
| ring "BOOK NOW" badge link | permalink |
| `.titem__desc` (listing only) | excerpt |
| card badge (Best Deal / Sell-out) | `vc_card_badge()` |
| destination line (if kept) | `viator_destination` term |
The card must render correctly both as a listing `li.titem` and as a
homepage rail `li.dcard`; a `$variant` arg selects wrapper/markup.
### 5.3 Single tour — `templates/single-viator_tour.php`
Target: `new front/tour.html`.
- **Hero-tour banner** → `section.hero-tour` with `hero-tour__photo`
(primary image), `hero-tour__kicker` ("Welcome to"), `hero-tour__word`
(destination/title), decorative `hero-tour__figure` (`tower.png`,
decorative asset). Breadcrumb retained.
- **Layout** → `.container.tour__layout` = `.tour__main` + `aside.tour__aside`.
- **Head** → `.tour-head__title` + `.tmeta` (rating, location, supplier).
- **Facts** → `ul.facts` of `li.fact` (Duration, Max Travelers, Free
Cancellation, Category) from `_vc_duration_minutes`,
`_vc_max_travelers`, `_vc_free_cancellation`, `viator_tag`.
- **Overview** → `section.tour__block` with `the_content()`.
- **Gallery** → `.gallery` (hero + thumbs) from `_vc_gallery_image_urls`.
- **Includes / Excludes** → `.lists` of `.ilist` with `.tick--yes/--no`
from `_vc_inclusions` / `_vc_exclusions`.
- **Good to know** → `tour__block` from `_vc_additional_info`.
- **Cancellation policy** → `.policy` of `.statcard--yes/--no` refund
tiers from `_vc_cancellation_policy`.
- **Reviews** → restyled summary from `_vc_rating` / `_vc_review_count`.
- **Booking aside** → `.bookcard`: From/price (`_vc_price_from`),
"Check Availability" → `_vc_booking_url`, `statcard--teal` Free
Cancellation + `statcard--yes` Reserve-now cards.
- **Mobile book bar** retained, restyled.
### 5.4 Homepage sections — `class-vc-sections.php` / `class-vc-display.php` shortcodes
Existing data shortcodes are re-skinned; new static-chrome shortcodes are
added.
Re-skinned (data-driven):
- `viator_top_destinations` → "Where you wanna go?" section: `sec-head`
(title + lead + See-all + `rail-nav` prev/next) over a
`ul.rail.rail--dest` of `.dcard`s. (`card-destination.php`)
- `viator_top_tours` → "Most Liked Tours" rail (same rail pattern,
`card-tour.php` in `dcard` variant).
- `viator_top_attractions` → the feature / "bus-tours" section styling.
- `viator_browse_activities` → activity section styling.
New (static chrome, per decision — full `index.html` look):
- `viator_hero` → the homepage hero + booking form
(`section.hero` + `form.booking` with Location/Date/Seats). Location
select is populated from `viator_destination` terms; submit routes to
the listing archive with the chosen filters (real behavior, not a
console stub).
- `viator_faq` → `section.faq` accordion (`[data-accordion]`). Content
supplied via shortcode attributes / inner content or a simple options
list (final content source TBD in plan).
- `viator_testimonials` → `section.testimonials` marquee
(`[data-marquee]`). Content via shortcode attributes/inner content.
The About and Articles sections from `index.html` are ordinary editorial
content; they are **not** plugin shortcodes (the user builds them with
the theme/builder). Noted here to bound scope.
## 6. Out of scope
- Site header, primary nav, burger menu, sticky header, site footer
(theme-owned).
- Any change to importing, cron, API, CPT/taxonomy registration, meta
keys, or query/filter logic.
- Client-side filtering/sorting/pagination (server-side retained).
- About / Articles homepage sections as plugin features.
## 7. Risks & mitigations
- **Style leakage / theme conflicts** → strict `#vc-app` scoping; drop
global resets; no header/footer CSS. Verify against Astra.
- **Generic class collisions** (`.container`, `.btn`, `.hero`) → all
scoped under `#vc-app`; test with theme active.
- **Duration option mismatch** (mockup "1/3/7 days" vs plugin
half/full/multi buckets) → use the plugin's real buckets with mockup
styling.
- **Icon coverage** → any glyph used by a template but missing from the
sprite is added to `sprite.svg`.
- **Ring-badge dependency** → ensure `#ringPathScroll` path def is in the
sprite so the rotating badge renders.
## 8. Acceptance criteria
- Listing, single, and homepage sections visually match the mockup within
the plugin's content area, with the theme's header/footer around them.
- All dynamic values render from real tour data; no demo images/text ship
as content.
- Server-side filtering, sorting, and pagination work via GET params as
before; shareable URLs preserved.
- No new console errors; Lucide and noUiSlider no longer loaded.
- No style bleed into the theme outside `#vc-app` (verified with Astra).

View File

@@ -0,0 +1,217 @@
<?php
defined( 'ABSPATH' ) || exit;
class VC_API {
public function get( $endpoint ) {
return $this->request( 'GET', $endpoint );
}
public function post( $endpoint, array $body = array() ) {
return $this->request( 'POST', $endpoint, $body );
}
public function request( $method, $endpoint, array $body = array() ) {
$api_key = vc_get_api_key();
if ( '' === $api_key ) {
return new WP_Error( 'vc_missing_api_key', __( 'Viator API key is missing.', 'viator-collector' ) );
}
$this->throttle();
$base = untrailingslashit( esc_url_raw( vc_get_option( 'api_base_url', 'https://api.viator.com/partner' ) ) );
$endpoint = '/' . ltrim( $endpoint, '/' );
$url = $base . $endpoint;
$timeout = max( 5, absint( vc_get_option( 'timeout', 20 ) ) );
$args = array(
'method' => strtoupper( $method ),
'timeout' => $timeout,
'headers' => array(
'Accept' => 'application/json;version=2.0',
'Accept-Language' => sanitize_text_field( vc_get_option( 'language', 'en-US' ) ),
'Content-Type' => 'application/json',
'exp-api-key' => $api_key,
),
);
if ( ! empty( $body ) && 'GET' !== strtoupper( $method ) ) {
$args['body'] = wp_json_encode( $body );
}
$response = wp_remote_request( $url, $args );
if ( is_wp_error( $response ) ) {
VC_Logger::log( 'API request failed: ' . $response->get_error_message(), true );
return $response;
}
$code = (int) wp_remote_retrieve_response_code( $response );
$raw = wp_remote_retrieve_body( $response );
if ( $code < 200 || $code >= 300 ) {
$message = sprintf( __( 'Viator API request failed with HTTP status %d.', 'viator-collector' ), $code );
$data = json_decode( $raw, true );
if ( is_array( $data ) && ! empty( $data['message'] ) ) {
$message .= ' ' . sanitize_text_field( $data['message'] );
}
VC_Logger::log( $message, true );
return new WP_Error( 'vc_api_http_error', $message, array( 'status' => $code ) );
}
$data = json_decode( $raw, true );
if ( JSON_ERROR_NONE !== json_last_error() ) {
return new WP_Error( 'vc_api_json_error', __( 'Viator API returned invalid JSON.', 'viator-collector' ) );
}
return is_array( $data ) ? $data : array();
}
/**
* Block until we are under the configured per-minute request budget.
*
* Timestamps of recent requests are persisted in an option so the limit
* holds across the separate PHP processes that WP-Cron continuation
* ticks run in, not just within a single request.
*/
private function throttle() {
$limit = max( 1, (int) vc_get_option( 'rate_limit_per_minute', 25 ) );
$now = microtime( true );
$window = get_option( 'vc_rate_window', array() );
if ( ! is_array( $window ) ) {
$window = array();
}
$window = array_values(
array_filter(
$window,
static function ( $t ) use ( $now ) {
return ( $now - (float) $t ) < 60.0;
}
)
);
if ( count( $window ) >= $limit ) {
$oldest = min( $window );
$wait = 60.0 - ( $now - (float) $oldest ) + 0.05;
if ( $wait > 0 ) {
usleep( (int) min( 60.0, $wait ) * 1000000 );
}
$now = microtime( true );
$window = array_values(
array_filter(
$window,
static function ( $t ) use ( $now ) {
return ( $now - (float) $t ) < 60.0;
}
)
);
}
$window[] = $now;
update_option( 'vc_rate_window', $window, false );
}
/**
* Search products within a single destination. This is the only way to
* enumerate the catalogue on Basic access: a destination filter is
* required and results are returned in pages (max 50 per page).
*
* @return array|WP_Error Decoded response, typically with `products` and `totalCount`.
*/
public function search_products( $destination, $start = 1, $count = 50, $currency = 'USD', array $extra_filters = array() ) {
$filtering = array_merge( array( 'destination' => (string) $destination ), $extra_filters );
$body = array(
'filtering' => $filtering,
'pagination' => array(
'start' => max( 1, (int) $start ),
'count' => max( 1, min( 50, (int) $count ) ),
),
'currency' => sanitize_text_field( $currency ),
);
return $this->post( '/products/search', $body );
}
public function get_product( string $product_code ) {
return $this->get( '/products/' . rawurlencode( $product_code ) );
}
public function get_destinations() {
$cache_key = 'vc_destinations_' . md5( vc_get_option( 'language', 'en-US' ) );
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
return $cached;
}
$data = $this->get( '/destinations' );
if ( ! is_wp_error( $data ) ) {
set_transient( $cache_key, $data, 25 * HOUR_IN_SECONDS );
}
return $data;
}
public function get_tags() {
$cache_key = 'vc_tags_' . md5( vc_get_option( 'language', 'en-US' ) );
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
return $cached;
}
$data = $this->get( '/products/tags' );
if ( ! is_wp_error( $data ) ) {
set_transient( $cache_key, $data, 25 * HOUR_IN_SECONDS );
}
return $data;
}
/**
* Destination dictionary as a map of destinationId => name.
*/
public function get_destination_map() {
$data = $this->get_destinations();
if ( is_wp_error( $data ) ) {
return $data;
}
$map = array();
foreach ( (array) vc_array_get( $data, 'destinations', array() ) as $destination ) {
$id = vc_array_get( $destination, 'destinationId' );
$name = vc_first_non_empty( array( vc_array_get( $destination, 'name' ), vc_array_get( $destination, 'destinationName' ) ) );
if ( null !== $id && '' !== (string) $name ) {
$map[ (string) $id ] = $name;
}
}
return $map;
}
/**
* Tag dictionary as a map of tagId => localized name.
*/
public function get_tag_map() {
$data = $this->get_tags();
if ( is_wp_error( $data ) ) {
return $data;
}
$lang = vc_get_option( 'language', 'en-US' );
$short = strtolower( substr( (string) $lang, 0, 2 ) );
$map = array();
foreach ( (array) vc_array_get( $data, 'tags', array() ) as $tag ) {
$id = vc_array_get( $tag, 'tagId' );
$names = vc_array_get( $tag, 'allNamesByLocale', array() );
$name = '';
if ( is_array( $names ) && ! empty( $names ) ) {
if ( isset( $names[ $lang ] ) ) {
$name = $names[ $lang ];
} elseif ( isset( $names[ $short ] ) ) {
$name = $names[ $short ];
} elseif ( isset( $names['en'] ) ) {
$name = $names['en'];
} else {
$name = reset( $names );
}
}
if ( '' === (string) $name ) {
$name = vc_array_get( $tag, 'name', '' );
}
if ( null !== $id && '' !== (string) $name ) {
$map[ (string) $id ] = $name;
}
}
return $map;
}
}

View File

@@ -0,0 +1,85 @@
<?php
defined( 'ABSPATH' ) || exit;
class VC_CPT {
public static function init() {
add_action( 'init', array( __CLASS__, 'register' ) );
// "Featured" flag: sorts a tour first in search/filter results.
add_action( 'add_meta_boxes', array( __CLASS__, 'add_featured_box' ) );
add_action( 'save_post_viator_tour', array( __CLASS__, 'save_featured' ) );
add_filter( 'manage_viator_tour_posts_columns', array( __CLASS__, 'featured_column' ) );
add_action( 'manage_viator_tour_posts_custom_column', array( __CLASS__, 'featured_column_value' ), 10, 2 );
}
public static function register() {
register_post_type(
'viator_tour',
array(
'labels' => array(
'name' => __( 'Viator Tours', 'viator-collector' ),
'singular_name' => __( 'Viator Tour', 'viator-collector' ),
'add_new_item' => __( 'Add New Viator Tour', 'viator-collector' ),
'edit_item' => __( 'Edit Viator Tour', 'viator-collector' ),
),
'public' => true,
'has_archive' => true,
'rewrite' => array( 'slug' => 'tours' ),
'show_in_rest' => true,
'menu_icon' => 'dashicons-location-alt',
'supports' => array( 'title', 'editor', 'excerpt', 'thumbnail', 'custom-fields' ),
)
);
}
public static function add_featured_box() {
add_meta_box(
'vc_featured',
__( 'Featured', 'viator-collector' ),
array( __CLASS__, 'render_featured_box' ),
'viator_tour',
'side',
'high'
);
}
public static function render_featured_box( $post ) {
wp_nonce_field( 'vc_featured', 'vc_featured_nonce' );
$on = (int) get_post_meta( $post->ID, '_vc_featured', true );
?>
<label>
<input type="checkbox" name="vc_featured" value="1" <?php checked( $on, 1 ); ?>>
<?php echo esc_html__( 'Show this tour first in search & filter results.', 'viator-collector' ); ?>
</label>
<?php
}
public static function save_featured( $post_id ) {
if ( ! isset( $_POST['vc_featured_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['vc_featured_nonce'] ), 'vc_featured' ) ) {
return;
}
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
if ( ! empty( $_POST['vc_featured'] ) ) {
update_post_meta( $post_id, '_vc_featured', '1' );
} else {
delete_post_meta( $post_id, '_vc_featured' );
}
}
public static function featured_column( $cols ) {
$cols['vc_featured'] = __( 'Featured', 'viator-collector' );
return $cols;
}
public static function featured_column_value( $col, $post_id ) {
if ( 'vc_featured' === $col && get_post_meta( $post_id, '_vc_featured', true ) ) {
echo '<span class="dashicons dashicons-star-filled" style="color:#f5a623" title="' . esc_attr__( 'Featured', 'viator-collector' ) . '"></span>';
}
}
}

View File

@@ -0,0 +1,344 @@
<?php
defined( 'ABSPATH' ) || exit;
/**
* Drives the catalogue sync.
*
* Basic access has no product "modified-since" feed, so the catalogue is
* enumerated by crawling /products/search one destination at a time. Each
* daily run rebuilds the destination queue and re-crawls everything, updating
* existing posts in place (products are deduplicated by code within a run).
*
* Because the catalogue is large, a run is processed across many short ticks:
* each tick crawls a few search pages, saves its position in the queue, and
* (if work remains) schedules an immediate follow-up tick. Requests are
* throttled by VC_API to stay under the per-minute budget.
*/
class VC_Cron {
const HOOK = 'vc_cron_sync'; // Recurring daily trigger.
const CONTINUE = 'vc_cron_continue'; // Single follow-up tick within a run.
const LOCK = 'vc_sync_lock';
const PAGE_SIZE = 50; // Search maximum per page.
const MAX_RUN_ERRORS = 6;
public static function init() {
add_action( self::HOOK, array( __CLASS__, 'run' ) );
add_action( self::CONTINUE, array( __CLASS__, 'run' ) );
add_action( 'update_option_vc_settings', array( __CLASS__, 'settings_updated' ), 10, 2 );
}
public static function settings_updated( $old, $new ) {
if ( ! empty( $new['automatic_sync'] ) ) {
self::maybe_schedule();
} else {
self::unschedule();
}
}
public static function maybe_schedule() {
if ( ! vc_get_option( 'automatic_sync', false ) ) {
return;
}
if ( ! wp_next_scheduled( self::HOOK ) ) {
wp_schedule_event( time() + MINUTE_IN_SECONDS, 'daily', self::HOOK );
}
}
public static function unschedule() {
foreach ( array( self::HOOK, self::CONTINUE ) as $hook ) {
$timestamp = wp_next_scheduled( $hook );
while ( $timestamp ) {
wp_unschedule_event( $timestamp, $hook );
$timestamp = wp_next_scheduled( $hook );
}
}
}
/**
* Abort any in-progress run and clear progress so the next run restarts.
*/
public static function reset() {
delete_option( 'vc_ingest_in_progress' );
delete_option( 'vc_crawl_run_id' );
delete_option( 'vc_crawl_queue' );
delete_option( 'vc_crawl_start' );
delete_option( 'vc_ingest_totals' );
delete_option( 'vc_ingest_error_count' );
delete_option( 'vc_log_last_count' );
delete_transient( self::LOCK );
VC_Logger::info( 'Ingestion progress reset.' );
}
/**
* Run one tick of the current sync run, starting a new run if idle.
*/
public static function run() {
if ( get_transient( self::LOCK ) ) {
VC_Logger::log( 'Sync tick skipped: another tick holds the lock.' );
return;
}
set_transient( self::LOCK, 1, 3 * MINUTE_IN_SECONDS );
// Keep working even if the triggering HTTP request goes away.
if ( function_exists( 'ignore_user_abort' ) ) {
ignore_user_abort( true );
}
try {
$importer = new VC_Importer();
if ( ! get_option( 'vc_ingest_in_progress' ) ) {
if ( is_wp_error( self::start_run( $importer ) ) ) {
return;
}
} else {
$importer->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 ),
);
}
}

View File

@@ -0,0 +1,759 @@
<?php
defined( 'ABSPATH' ) || exit;
class VC_Display {
const SHORTCODES = array(
'viator_tours',
'viator_listing',
'viator_hero',
'viator_hero_slider',
'viator_top_tours',
'viator_top_destinations',
'viator_top_attractions',
'viator_browse_activities',
'viator_faq',
'viator_testimonials',
'viator_tour_card',
'viator_itinerary',
'viator_inclusions',
'viator_exclusions',
'viator_booking_button',
'viator_duration',
'viator_rating',
'viator_gallery',
);
public static function init() {
add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
add_action( 'template_redirect', array( __CLASS__, 'maybe_enrich_single' ) );
add_action( 'pre_get_posts', array( __CLASS__, 'filter_archive_query' ) );
add_filter( 'single_template', array( __CLASS__, 'single_template' ) );
add_filter( 'archive_template', array( __CLASS__, 'archive_template' ) );
add_filter( 'taxonomy_template', array( __CLASS__, 'taxonomy_template' ) );
// Full-width layout, applied only to Viator pages.
add_filter( 'body_class', array( __CLASS__, 'body_class' ) );
add_filter( 'astra_page_layout', array( __CLASS__, 'astra_page_layout' ) );
add_filter( 'astra_get_content_layout', array( __CLASS__, 'astra_content_layout' ) );
add_shortcode( 'viator_tours', array( __CLASS__, 'shortcode_tours' ) );
add_shortcode( 'viator_listing', array( __CLASS__, 'shortcode_listing' ) );
// `viator_top_tours` is registered by VC_Sections (landing-page design).
// `viator_top_destinations` is registered by VC_Sections (landing-page design).
// `viator_top_attractions` is registered by VC_Sections (landing-page design).
// `viator_browse_activities` is registered by VC_Sections (landing-page design).
add_shortcode( 'viator_tour_card', array( __CLASS__, 'shortcode_card' ) );
add_shortcode( 'viator_itinerary', array( __CLASS__, 'shortcode_itinerary' ) );
add_shortcode( 'viator_inclusions', array( __CLASS__, 'shortcode_inclusions' ) );
add_shortcode( 'viator_exclusions', array( __CLASS__, 'shortcode_exclusions' ) );
add_shortcode( 'viator_booking_button', array( __CLASS__, 'shortcode_booking_button' ) );
add_shortcode( 'viator_duration', array( __CLASS__, 'shortcode_duration' ) );
add_shortcode( 'viator_rating', array( __CLASS__, 'shortcode_rating' ) );
add_shortcode( 'viator_gallery', array( __CLASS__, 'shortcode_gallery' ) );
}
/**
* True on the tours archive, a Viator term archive, or a single tour.
* Everything layout-related keys off this so nothing leaks site-wide.
*/
public static function is_vc_page() {
if ( is_singular( 'viator_tour' )
|| is_post_type_archive( 'viator_tour' )
|| is_tax( array( 'viator_destination', 'viator_tag', 'viator_category' ) ) ) {
return true;
}
// A Page hosting the full-listing shortcode also wants full width.
$post = ( is_singular() ) ? get_post() : null;
return $post && has_shortcode( $post->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 '<div class="vc-app vc-listing">' . $inner . '</div>';
}
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 '<div class="vc-destination-grid">';
foreach ( $terms as $term ) {
echo vc_get_template( 'card-destination.php', array( 'term' => $term ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
echo '</div>';
$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 '<div class="vc-attraction-grid">';
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 '</div>';
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 '<div class="vc-activity-list">';
foreach ( $terms as $term ) {
echo vc_get_template( 'card-activity.php', array( 'term' => $term ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
echo '</div>';
$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 '<div class="vc-app vc-listing"><ul class="tgrid">';
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 '</ul></div>';
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();
?>
<section class="vc-section <?php echo esc_attr( $modifier ); ?>">
<div class="vc-container">
<header class="vc-section__head vc-reveal">
<?php if ( $eyebrow ) : ?><span class="vc-eyebrow"><?php echo esc_html( $eyebrow ); ?></span><?php endif; ?>
<h2 class="vc-section__title"><?php echo esc_html( $title ); ?></h2>
<?php if ( $view_all ) : ?><a class="vc-view-all" href="<?php echo esc_url( $view_all ); ?>"><?php echo esc_html__( 'View all', 'viator-collector' ); ?> <span aria-hidden="true">&rarr;</span></a><?php endif; ?>
</header>
<?php echo $inner; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
</section>
<?php
return ob_get_clean();
}
public static function shortcode_card( $atts ) {
$post_id = self::post_id_from_atts( $atts );
if ( ! $post_id ) {
return '';
}
$card = vc_get_template( 'card-tour.php', array( 'post_id' => $post_id, 'variant' => 'wide' ) );
return '<div class="vc-app vc-listing"><ul class="tgrid">' . $card . '</ul></div>';
}
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 = '<div class="vc-gallery">';
foreach ( array_map( 'absint', $ids ) as $id ) {
$out .= wp_get_attachment_image( $id, 'large', false, array( 'class' => 'vc-gallery-image' ) );
}
$out .= '</div>';
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 = '<div class="vc-gallery">';
foreach ( (array) $urls as $url ) {
$out .= '<img class="vc-gallery-image" src="' . esc_url( $url ) . '" alt="' . $title . '" loading="lazy">';
}
$out .= '</div>';
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;
}
}

View File

@@ -0,0 +1,518 @@
<?php
defined( 'ABSPATH' ) || exit;
class VC_Importer {
private $api;
private $destination_map = array();
private $tag_map = array();
public function __construct( VC_API $api = null ) {
$this->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;
}
}

View File

@@ -0,0 +1,103 @@
<?php
defined( 'ABSPATH' ) || exit;
class VC_Logger {
const STORE = 'vc_activity_log';
const MAX = 300;
public static function log( $message, $force = false ) {
if ( ! $force && ! vc_get_option( 'debug_logging', false ) ) {
return;
}
if ( is_array( $message ) || is_object( $message ) ) {
$message = wp_json_encode( self::redact( $message ) );
} else {
$message = self::redact_string( (string) $message );
}
error_log( '[Viator Collector] ' . $message ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
}
/**
* Record an informational event in the admin activity log.
*/
public static function info( $message ) {
self::record( 'info', $message );
self::log( $message );
}
/**
* Record an error event in the admin activity log and the PHP error log.
*/
public static function error( $message ) {
self::record( 'error', $message );
self::log( $message, true );
}
/**
* Append an entry to the rolling, in-database activity log shown in admin.
*/
public static function record( $level, $message ) {
if ( is_array( $message ) || is_object( $message ) ) {
$message = wp_json_encode( self::redact( $message ) );
} else {
$message = self::redact_string( (string) $message );
}
$log = get_option( self::STORE, array() );
if ( ! is_array( $log ) ) {
$log = array();
}
$log[] = array(
't' => 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 );
}
}

View File

@@ -0,0 +1,64 @@
<?php
defined( 'ABSPATH' ) || exit;
final class VC_Plugin {
private static $instance = null;
public static function instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {}
public function init() {
$this->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();
}
}

View File

@@ -0,0 +1,517 @@
<?php
defined( 'ABSPATH' ) || exit;
/**
* Front-page section shortcodes — "Hop On Hop Off" design.
*
* All markup is scoped under a .vc-app wrapper and styled by assets/css/
* vc-design.css. Assets are enqueued by VC_Display when a page contains one of
* these shortcodes, so nothing loads on pages that don't use them.
*/
class VC_Sections {
const HERO = 'viator_hero';
const TOP_DESTS = 'viator_top_destinations';
const TOP_ATTRS = 'viator_top_attractions';
const ACTIVITIES = 'viator_browse_activities';
const TOP_TOURS = 'viator_top_tours';
const FAQ = 'viator_faq';
const REVIEWS = 'viator_testimonials';
/** Per-request counter so each rail gets a unique id for its nav buttons. */
private static $rail_seq = 0;
public static function init() {
add_shortcode( self::HERO, array( __CLASS__, 'hero' ) );
add_shortcode( 'viator_hero_slider', array( __CLASS__, 'hero' ) ); // back-compat alias
add_shortcode( self::TOP_DESTS, array( __CLASS__, 'top_destinations' ) );
add_shortcode( self::TOP_ATTRS, array( __CLASS__, 'top_attractions' ) );
add_shortcode( self::ACTIVITIES, array( __CLASS__, 'browse_activities' ) );
add_shortcode( self::TOP_TOURS, array( __CLASS__, 'top_tours' ) );
add_shortcode( self::FAQ, array( __CLASS__, 'faq' ) );
add_shortcode( self::REVIEWS, array( __CLASS__, 'testimonials' ) );
}
/**
* [viator_hero title="Set out on the journey." image="https://…"]
*
* The booking hero: a location/date/seat form that routes to the tours
* archive pre-filtered by the chosen destination.
*/
public static function hero( $atts ) {
$atts = shortcode_atts(
array(
'title' => __( '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();
?>
<div class="vc-app vc-sections">
<section class="hero">
<div class="hero__media">
<img class="hero__img" src="<?php echo esc_url( $img ); ?>" alt="" fetchpriority="high">
<div class="hero__scrim" aria-hidden="true"></div>
</div>
<div class="container hero__content">
<p class="pill hero__eyebrow"><?php echo esc_html( $atts['eyebrow'] ); ?></p>
<h1 class="hero__title"><?php echo esc_html( $atts['title'] ); ?></h1>
<form class="booking" method="get" action="<?php echo esc_url( $archive ); ?>" novalidate>
<div class="booking__row">
<div class="field">
<span class="field__label"><?php echo vc_icon( 'location' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><label for="vc-f-location"><?php echo esc_html__( 'Location', 'viator-collector' ); ?></label></span>
<div class="select">
<select class="select__input" id="vc-f-location" name="dest[]">
<option value=""><?php echo esc_html__( 'All destinations', 'viator-collector' ); ?></option>
<?php foreach ( $dests as $t ) : ?>
<option value="<?php echo esc_attr( $t->slug ); ?>"><?php echo esc_html( $t->name ); ?></option>
<?php endforeach; ?>
</select>
<?php echo vc_icon( 'chevron', 'select__chevron' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
</div>
<div class="field">
<span class="field__label"><?php echo vc_icon( 'calendar' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><label for="vc-f-date"><?php echo esc_html__( 'Date', 'viator-collector' ); ?></label></span>
<div class="select">
<input class="select__input select__input--date" id="vc-f-date" name="date" type="text" placeholder="<?php echo esc_attr__( 'Select Date', 'viator-collector' ); ?>" data-datefield>
<?php echo vc_icon( 'chevron', 'select__chevron' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
</div>
<div class="field">
<span class="field__label"><?php echo vc_icon( 'seat' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><label for="vc-f-seat"><?php echo esc_html__( 'Seat', 'viator-collector' ); ?></label></span>
<div class="select">
<select class="select__input" id="vc-f-seat" name="seat">
<option value="1"><?php echo esc_html__( 'One Seat', 'viator-collector' ); ?></option>
<option value="2"><?php echo esc_html__( 'Two Seats', 'viator-collector' ); ?></option>
<option value="3"><?php echo esc_html__( 'Three Seats', 'viator-collector' ); ?></option>
<option value="4"><?php echo esc_html__( 'Four Seats', 'viator-collector' ); ?></option>
</select>
<?php echo vc_icon( 'chevron', 'select__chevron' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
</div>
<button class="btn btn--dark booking__submit" type="submit"><?php echo esc_html__( 'Book now', 'viator-collector' ); ?></button>
</div>
</form>
</div>
<?php echo vc_ring_badge( 'SCROLL DOWN · SCROLL DOWN · ', $archive, 'scroll' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</section>
</div>
<?php
return ob_get_clean();
}
/**
* Open a titled rail section and return its unique rail id. Caller fills the
* <ul> 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();
?>
<div class="vc-app vc-sections">
<section class="section">
<div class="container">
<div class="sec-head">
<h2 class="sec-head__title"><?php echo wp_kses( $title, array( 'br' => array() ) ); ?></h2>
<?php if ( $lead ) : ?><p class="sec-head__lead"><?php echo esc_html( $lead ); ?></p><?php endif; ?>
<div class="sec-head__actions">
<?php if ( $view_all_url ) : ?>
<a class="btn btn--ghost btn--wide" href="<?php echo esc_url( $view_all_url ); ?>"><span><?php echo esc_html( $view_all_text ); ?></span><?php echo vc_icon( 'arrow-ne' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></a>
<?php endif; ?>
<div class="rail-nav">
<button class="btn btn--ghost rail-nav__btn" type="button" data-rail-prev="<?php echo esc_attr( $id ); ?>" aria-label="<?php echo esc_attr__( 'Previous', 'viator-collector' ); ?>"><?php echo vc_icon( 'arrow-left' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></button>
<button class="btn btn--ghost rail-nav__btn" type="button" data-rail-next="<?php echo esc_attr( $id ); ?>" aria-label="<?php echo esc_attr__( 'Next', 'viator-collector' ); ?>"><?php echo vc_icon( 'arrow-right' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></button>
</div>
</div>
</div>
<ul class="rail rail--dest" id="<?php echo esc_attr( $id ); ?>">
<?php
return array( 'id' => $id, 'head' => ob_get_clean() );
}
private static function close_rail() {
return '</ul></div></section></div>';
}
/**
* [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<br>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<br>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<br>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<br>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' );
?>
<li class="dcard">
<?php if ( $image ) : ?>
<img class="dcard__img" src="<?php echo esc_url( $image ); ?>" alt="<?php echo esc_attr( $term->name ); ?>" loading="lazy">
<?php endif; ?>
<div class="dcard__body">
<h3 class="dcard__title"><a href="<?php echo esc_url( $link ); ?>"><?php echo esc_html( $term->name ); ?></a></h3>
<div class="dcard__meta">
<div class="meta">
<span class="meta__k"><?php echo esc_html__( 'Tours', 'viator-collector' ); ?></span>
<span class="meta__v"><?php echo esc_html( number_format_i18n( (int) $term->count ) ); ?></span>
</div>
<?php echo vc_ring_badge( 'VIEW TOURS · VIEW TOURS · ', $link, 'card' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
</div>
</li>
<?php
}
echo self::close_rail(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
return ob_get_clean();
}
/**
* [viator_faq title="FAQ's" items="Question|Answer;;Question 2|Answer 2"]
*
* Items may also be supplied via the `vc_faq_items` filter.
*/
public static function faq( $atts ) {
$atts = shortcode_atts(
array(
'title' => __( "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();
?>
<div class="vc-app vc-sections">
<section class="faq">
<span class="faq__watermark" aria-hidden="true">FAQ</span>
<div class="container faq__inner">
<div class="faq__intro">
<h2 class="faq__title"><?php echo esc_html( $atts['title'] ); ?></h2>
<?php if ( $atts['lead'] ) : ?><p class="faq__lead"><?php echo esc_html( $atts['lead'] ); ?></p><?php endif; ?>
</div>
<div class="accordion" data-accordion>
<?php foreach ( $items as $i => $item ) : ?>
<div class="acc">
<h3>
<button class="acc__q" type="button" aria-expanded="<?php echo 0 === $i ? 'true' : 'false'; ?>">
<span><?php echo esc_html( $item['q'] ); ?></span>
<?php echo vc_icon( 'plus', 'icon--14 acc__plus' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php echo vc_icon( 'minus', 'icon--14 acc__minus' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</button>
</h3>
<div class="acc__a"><p><?php echo esc_html( $item['a'] ); ?></p></div>
</div>
<?php endforeach; ?>
</div>
</div>
</section>
</div>
<?php
return ob_get_clean();
}
/**
* [viator_testimonials title="Your Comments"]
*
* Items may be supplied via the `vc_testimonials` filter
* (each: name, role, text, avatar).
*/
public static function testimonials( $atts ) {
$atts = shortcode_atts(
array(
'title' => __( '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();
?>
<div class="vc-app vc-sections">
<section class="section testimonials">
<div class="container">
<div class="testimonials__head">
<h2 class="testimonials__title"><?php echo esc_html( $atts['title'] ); ?></h2>
<?php if ( $atts['lead'] ) : ?><p class="testimonials__lead"><?php echo esc_html( $atts['lead'] ); ?></p><?php endif; ?>
</div>
</div>
<div class="marquee" data-marquee>
<div class="marquee__track">
<?php foreach ( $items as $item ) : ?>
<?php $size = ! empty( $item['size'] ) ? ' quote--' . sanitize_html_class( $item['size'] ) : ''; ?>
<figure class="quote<?php echo esc_attr( $size ); ?>">
<figcaption class="quote__head">
<?php if ( ! empty( $item['avatar'] ) ) : ?>
<img class="quote__avatar" src="<?php echo esc_url( $item['avatar'] ); ?>" alt="" loading="lazy">
<?php endif; ?>
<span><b><?php echo esc_html( $item['name'] ); ?></b><i><?php echo esc_html( $item['role'] ); ?></i></span>
</figcaption>
<blockquote><?php echo esc_html( $item['text'] ); ?></blockquote>
</figure>
<?php endforeach; ?>
</div>
</div>
</section>
</div>
<?php
return ob_get_clean();
}
/**
* Shared tour query for the landing sections. Supports curation (ids /
* product codes) and filtering (destination / tag), with a sane fallback.
*/
private static function tour_query( array $atts ) {
$args = array(
'post_type' => '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;
}
}

View File

@@ -0,0 +1,542 @@
<?php
defined( 'ABSPATH' ) || exit;
class VC_Settings {
public static function init() {
add_action( 'admin_menu', array( __CLASS__, 'admin_menu' ) );
add_action( 'admin_init', array( __CLASS__, 'register_settings' ) );
add_action( 'admin_post_vc_test_api', array( __CLASS__, 'handle_test_api' ) );
add_action( 'admin_post_vc_manual_sync', array( __CLASS__, 'handle_manual_sync' ) );
add_action( 'admin_post_vc_reset_sync', array( __CLASS__, 'handle_reset_sync' ) );
add_action( 'admin_post_vc_delete_all', array( __CLASS__, 'handle_delete_all' ) );
add_action( 'admin_post_vc_clear_log', array( __CLASS__, 'handle_clear_log' ) );
add_action( 'admin_post_vc_import_product', array( __CLASS__, 'handle_import_product' ) );
add_action( 'wp_ajax_vc_search_destinations', array( __CLASS__, 'ajax_search_destinations' ) );
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'admin_assets' ) );
}
/**
* Load the WordPress media library on our settings screen so the hero
* image can be picked/uploaded without editing plugin files.
*/
public static function admin_assets( $hook ) {
if ( 'settings_page_viator-collector' === $hook ) {
wp_enqueue_media();
}
}
/**
* Keep only numeric, comma-separated destination IDs.
*/
public static function sanitize_id_list( $value ) {
$ids = array_filter( array_map( 'trim', explode( ',', (string) $value ) ), 'ctype_digit' );
return implode( ',', array_values( array_unique( $ids ) ) );
}
/**
* Live destination lookup for the settings screen so the exact Viator IDs
* can be found and added to the crawl filter.
*/
public static function ajax_search_destinations() {
check_ajax_referer( 'vc_search_destinations' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( '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();
?>
<div class="wrap">
<h1><?php echo esc_html__( 'Viator Collector', 'viator-collector' ); ?></h1>
<?php if ( $notice ) : ?>
<div class="notice notice-info is-dismissible"><p><?php echo esc_html( $notice ); ?></p></div>
<?php endif; ?>
<h2><?php echo esc_html__( 'Sync status', 'viator-collector' ); ?></h2>
<table class="widefat striped" style="max-width:640px;margin-bottom:1.5em;">
<tbody>
<tr><td><?php echo esc_html__( 'State', 'viator-collector' ); ?></td><td><?php echo $status['in_progress'] ? esc_html__( 'Running', 'viator-collector' ) : esc_html__( 'Idle', 'viator-collector' ); ?></td></tr>
<tr><td><?php echo esc_html__( 'Destinations left in current run', 'viator-collector' ); ?></td><td><?php echo esc_html( (string) $status['queue_remaining'] ); ?></td></tr>
<tr><td><?php echo esc_html__( 'Last started', 'viator-collector' ); ?></td><td><?php echo esc_html( $status['started_at'] ? $status['started_at'] : '—' ); ?></td></tr>
<tr><td><?php echo esc_html__( 'Last completed', 'viator-collector' ); ?></td><td><?php echo esc_html( $status['completed_at'] ? $status['completed_at'] : '—' ); ?></td></tr>
<tr><td><?php echo esc_html__( 'Current run progress', 'viator-collector' ); ?></td><td><?php echo esc_html( wp_json_encode( $status['totals'] ) ); ?></td></tr>
<tr><td><?php echo esc_html__( 'Next automatic run', 'viator-collector' ); ?></td><td><?php echo esc_html( $status['next_scheduled'] ? gmdate( 'Y-m-d H:i:s', (int) $status['next_scheduled'] ) . ' UTC' : __( 'Not scheduled', 'viator-collector' ) ); ?></td></tr>
</tbody>
</table>
<form method="post" action="options.php">
<?php settings_fields( 'vc_settings_group' ); ?>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><label for="vc_api_key"><?php echo esc_html__( 'API key', 'viator-collector' ); ?></label></th>
<td>
<input id="vc_api_key" type="password" class="regular-text" name="vc_settings[api_key]" value="<?php echo esc_attr( $options['api_key'] ); ?>" autocomplete="off">
<?php if ( defined( 'VIATOR_API_KEY' ) && VIATOR_API_KEY ) : ?>
<p class="description"><?php echo esc_html__( 'VIATOR_API_KEY is defined in wp-config.php and will be used instead.', 'viator-collector' ); ?></p>
<?php endif; ?>
</td>
</tr>
<tr><th scope="row"><label for="vc_api_base_url"><?php echo esc_html__( 'API base URL', 'viator-collector' ); ?></label></th><td><input id="vc_api_base_url" type="url" class="regular-text" name="vc_settings[api_base_url]" value="<?php echo esc_attr( $options['api_base_url'] ); ?>"></td></tr>
<tr><th scope="row"><label for="vc_language"><?php echo esc_html__( 'Language', 'viator-collector' ); ?></label></th><td><input id="vc_language" type="text" name="vc_settings[language]" value="<?php echo esc_attr( $options['language'] ); ?>"></td></tr>
<tr><th scope="row"><label for="vc_currency"><?php echo esc_html__( 'Currency', 'viator-collector' ); ?></label></th><td><input id="vc_currency" type="text" name="vc_settings[currency]" value="<?php echo esc_attr( $options['currency'] ); ?>" maxlength="3"></td></tr>
<tr><th scope="row"><label for="vc_timeout"><?php echo esc_html__( 'Timeout (seconds)', 'viator-collector' ); ?></label></th><td><input id="vc_timeout" type="number" min="5" name="vc_settings[timeout]" value="<?php echo esc_attr( $options['timeout'] ); ?>"></td></tr>
<tr>
<th scope="row"><label for="vc_default_post_status"><?php echo esc_html__( 'Default post status', 'viator-collector' ); ?></label></th>
<td>
<select id="vc_default_post_status" name="vc_settings[default_post_status]">
<?php foreach ( array( 'publish', 'draft', 'pending' ) as $post_status ) : ?>
<option value="<?php echo esc_attr( $post_status ); ?>" <?php selected( $options['default_post_status'], $post_status ); ?>><?php echo esc_html( ucfirst( $post_status ) ); ?></option>
<?php endforeach; ?>
</select>
<p class="description"><?php echo esc_html__( 'Deactivated products are always set to draft regardless of this setting.', 'viator-collector' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="vc_hero_image"><?php echo esc_html__( 'Hero image', 'viator-collector' ); ?></label></th>
<td>
<input id="vc_hero_image" type="url" class="regular-text" name="vc_settings[hero_image]" value="<?php echo esc_attr( $options['hero_image'] ); ?>" placeholder="https://…">
<button type="button" class="button" id="vc-hero-pick"><?php echo esc_html__( 'Select image', 'viator-collector' ); ?></button>
<button type="button" class="button" id="vc-hero-clear"><?php echo esc_html__( 'Clear', 'viator-collector' ); ?></button>
<p class="description"><?php echo esc_html__( 'Banner shown at the top of the tours listing. Leave empty for a gradient. Destination pages use their own image when one is set.', 'viator-collector' ); ?></p>
<div id="vc-hero-preview" style="margin-top:8px;">
<?php if ( $options['hero_image'] ) : ?>
<img src="<?php echo esc_url( $options['hero_image'] ); ?>" alt="" style="max-width:320px;height:auto;border-radius:8px;">
<?php endif; ?>
</div>
<script>
jQuery(function ($) {
var frame;
$('#vc-hero-pick').on('click', function (e) {
e.preventDefault();
if (frame) { frame.open(); return; }
frame = wp.media({
title: <?php echo wp_json_encode( __( 'Select hero image', 'viator-collector' ) ); ?>,
button: { text: <?php echo wp_json_encode( __( 'Use this image', 'viator-collector' ) ); ?> },
library: { type: 'image' },
multiple: false
});
frame.on('select', function () {
var img = frame.state().get('selection').first().toJSON();
$('#vc_hero_image').val(img.url);
$('#vc-hero-preview').html($('<img>', { src: img.url, alt: '' }).css({ maxWidth: '320px', height: 'auto', borderRadius: '8px' }));
});
frame.open();
});
$('#vc-hero-clear').on('click', function (e) {
e.preventDefault();
$('#vc_hero_image').val('');
$('#vc-hero-preview').empty();
});
});
</script>
</td>
</tr>
<tr>
<th scope="row"><label for="vc_hero_slides"><?php echo esc_html__( 'Hero slider images', 'viator-collector' ); ?></label></th>
<td>
<input id="vc_hero_slides" type="hidden" name="vc_settings[hero_slides]" value="<?php echo esc_attr( $options['hero_slides'] ); ?>">
<button type="button" class="button" id="vc-slides-pick"><?php echo esc_html__( 'Select images', 'viator-collector' ); ?></button>
<button type="button" class="button" id="vc-slides-clear"><?php echo esc_html__( 'Clear', 'viator-collector' ); ?></button>
<p class="description">
<?php echo esc_html__( 'Slides for the [viator_hero_slider] shortcode. Drag is not supported — reselect to change the order.', 'viator-collector' ); ?>
</p>
<div id="vc-slides-preview" style="display:flex;flex-wrap:wrap;gap:8px;margin-top:10px;">
<?php
$vc_slide_ids = array_filter( array_map( 'absint', explode( ',', (string) $options['hero_slides'] ) ) );
foreach ( $vc_slide_ids as $vc_slide_id ) {
echo wp_get_attachment_image( $vc_slide_id, 'thumbnail', false, array( 'style' => 'width:80px;height:60px;object-fit:cover;border-radius:6px;' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
?>
</div>
<script>
jQuery(function ($) {
var frame;
function paint(list) {
var $p = $('#vc-slides-preview').empty();
list.forEach(function (item) {
$('<img>', { src: item.url }).css({
width: '80px', height: '60px', objectFit: 'cover', borderRadius: '6px'
}).appendTo($p);
});
}
$('#vc-slides-pick').on('click', function (e) {
e.preventDefault();
frame = wp.media({
title: <?php echo wp_json_encode( __( 'Select hero slides', 'viator-collector' ) ); ?>,
button: { text: <?php echo wp_json_encode( __( 'Use these images', 'viator-collector' ) ); ?> },
library: { type: 'image' },
multiple: 'add'
});
frame.on('select', function () {
var ids = [], list = [];
frame.state().get('selection').each(function (a) {
var j = a.toJSON();
ids.push(j.id);
list.push({ url: (j.sizes && j.sizes.thumbnail) ? j.sizes.thumbnail.url : j.url });
});
$('#vc_hero_slides').val(ids.join(','));
paint(list);
});
frame.open();
});
$('#vc-slides-clear').on('click', function (e) {
e.preventDefault();
$('#vc_hero_slides').val('');
$('#vc-slides-preview').empty();
});
});
</script>
</td>
</tr>
<tr>
<th scope="row"><label for="vc_crawl_destinations"><?php echo esc_html__( 'Destination filter', 'viator-collector' ); ?></label></th>
<td>
<input id="vc_crawl_destinations" type="text" class="large-text" name="vc_settings[crawl_destinations]" value="<?php echo esc_attr( $options['crawl_destinations'] ); ?>" placeholder="<?php echo esc_attr__( 'e.g. 664, 662, 761', 'viator-collector' ); ?>">
<p class="description"><?php echo esc_html__( 'Comma-separated Viator destination IDs to crawl. Leave empty to crawl the whole catalogue. Use the search below to find IDs.', 'viator-collector' ); ?></p>
<div class="vc-dest-finder" style="margin-top:8px;max-width:640px;">
<input type="search" id="vc-dest-search" class="regular-text" placeholder="<?php echo esc_attr__( 'Search destinations, e.g. Orlando', 'viator-collector' ); ?>">
<button type="button" class="button" id="vc-dest-search-btn"><?php echo esc_html__( 'Search', 'viator-collector' ); ?></button>
<div id="vc-dest-results" style="margin-top:8px;"></div>
</div>
<script>
(function(){
var btn=document.getElementById('vc-dest-search-btn'),
q=document.getElementById('vc-dest-search'),
out=document.getElementById('vc-dest-results'),
field=document.getElementById('vc_crawl_destinations'),
nonce=<?php echo wp_json_encode( wp_create_nonce( 'vc_search_destinations' ) ); ?>;
function addId(id){
var ids=field.value.split(',').map(function(s){return s.trim();}).filter(Boolean);
if(ids.indexOf(id)===-1){ids.push(id);} field.value=ids.join(', ');
}
function search(){
out.textContent='<?php echo esc_js( __( 'Searching…', 'viator-collector' ) ); ?>';
var d=new FormData();
d.append('action','vc_search_destinations');
d.append('_ajax_nonce',nonce);
d.append('q',q.value);
fetch(ajaxurl,{method:'POST',credentials:'same-origin',body:d})
.then(function(r){return r.json();})
.then(function(res){
if(!res||!res.success){out.textContent=(res&&res.data&&res.data.message)||'<?php echo esc_js( __( 'Search failed.', 'viator-collector' ) ); ?>';return;}
if(!res.data.results.length){out.textContent='<?php echo esc_js( __( 'No matches.', 'viator-collector' ) ); ?>';return;}
out.innerHTML='';
res.data.results.forEach(function(item){
var row=document.createElement('div');
row.style.cssText='display:flex;align-items:center;gap:8px;padding:3px 0;';
var b=document.createElement('button');
b.type='button';b.className='button button-small';b.textContent='<?php echo esc_js( __( 'Add', 'viator-collector' ) ); ?>';
b.addEventListener('click',function(){addId(item.id);});
var span=document.createElement('span');
span.textContent=item.name+' (ID '+item.id+')';
row.appendChild(b);row.appendChild(span);out.appendChild(row);
});
})
.catch(function(){out.textContent='<?php echo esc_js( __( 'Request failed.', 'viator-collector' ) ); ?>';});
}
btn.addEventListener('click',search);
q.addEventListener('keydown',function(e){if(e.key==='Enter'){e.preventDefault();search();}});
})();
</script>
</td>
</tr>
<tr><th scope="row"><label for="vc_rate_limit"><?php echo esc_html__( 'Max API requests per minute', 'viator-collector' ); ?></label></th><td><input id="vc_rate_limit" type="number" min="1" max="60" name="vc_settings[rate_limit_per_minute]" value="<?php echo esc_attr( $options['rate_limit_per_minute'] ); ?>"><p class="description"><?php echo esc_html__( 'Requests are throttled to stay under this limit. Viator Basic access allows 25.', 'viator-collector' ); ?></p></td></tr>
<tr><th scope="row"><label for="vc_products_per_page"><?php echo esc_html__( 'Products per search page', 'viator-collector' ); ?></label></th><td><input id="vc_products_per_page" type="number" min="1" max="50" name="vc_settings[products_per_page]" value="<?php echo esc_attr( $options['products_per_page'] ); ?>"><p class="description"><?php echo esc_html__( 'Maximum 50 (Viator limit). Lower this only if full detail is ON and ticks time out.', 'viator-collector' ); ?></p></td></tr>
<tr><th scope="row"><label for="vc_pages_per_run"><?php echo esc_html__( 'Search pages per tick', 'viator-collector' ); ?></label></th><td><input id="vc_pages_per_run" type="number" min="1" max="100" name="vc_settings[pages_per_run]" value="<?php echo esc_attr( $options['pages_per_run'] ); ?>"><p class="description"><?php echo esc_html__( 'How many search pages every background tick processes before scheduling the next. Lower values avoid PHP timeouts.', 'viator-collector' ); ?></p></td></tr>
<tr><th scope="row"><?php echo esc_html__( 'Fetch full detail during crawl', 'viator-collector' ); ?></th><td><label><input type="checkbox" name="vc_settings[fetch_full_detail]" value="1" <?php checked( $options['fetch_full_detail'], 1 ); ?>> <?php echo esc_html__( 'Call /products/{code} for every product while crawling.', 'viator-collector' ); ?></label><p class="description"><?php echo esc_html__( 'Leave OFF (recommended): the crawl stays fast (1 request per 50 products) and full detail (itinerary, inclusions, cancellation policy) is fetched and cached the first time each tour page is viewed. Turn ON only if you must have full detail for every product up front — far slower under the rate limit.', 'viator-collector' ); ?></p></td></tr>
<tr><th scope="row"><label for="vc_detail_batch_size"><?php echo esc_html__( 'Detail batch size per tick', 'viator-collector' ); ?></label></th><td><input id="vc_detail_batch_size" type="number" min="1" max="25" name="vc_settings[detail_batch_size]" value="<?php echo esc_attr( $options['detail_batch_size'] ); ?>"><p class="description"><?php echo esc_html__( 'Only used when full detail is ON. Products processed per cron minute. Lower this if ticks time out; raise it (up to ~20) to go faster, staying under your per-minute limit.', 'viator-collector' ); ?></p></td></tr>
<tr><th scope="row"><label for="vc_log_every"><?php echo esc_html__( 'Log progress every N products', 'viator-collector' ); ?></label></th><td><input id="vc_log_every" type="number" min="1" max="1000" name="vc_settings[log_every]" value="<?php echo esc_attr( $options['log_every'] ); ?>"><p class="description"><?php echo esc_html__( 'How often a progress line is written to the activity log below.', 'viator-collector' ); ?></p></td></tr>
<tr><th scope="row"><?php echo esc_html__( 'Automatic sync', 'viator-collector' ); ?></th><td><label><input type="checkbox" name="vc_settings[automatic_sync]" value="1" <?php checked( $options['automatic_sync'], 1 ); ?>> <?php echo esc_html__( 'Refresh the catalogue every 24 hours', 'viator-collector' ); ?></label></td></tr>
<tr><th scope="row"><?php echo esc_html__( 'Debug logging', 'viator-collector' ); ?></th><td><label><input type="checkbox" name="vc_settings[debug_logging]" value="1" <?php checked( $options['debug_logging'], 1 ); ?>> <?php echo esc_html__( 'Enable debug logging', 'viator-collector' ); ?></label></td></tr>
</table>
<?php submit_button(); ?>
</form>
<hr>
<h2><?php echo esc_html__( 'Manual actions', 'viator-collector' ); ?></h2>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline-block;margin-right:1em;">
<input type="hidden" name="action" value="vc_test_api">
<?php wp_nonce_field( 'vc_test_api' ); ?>
<?php submit_button( __( 'Test API connection', 'viator-collector' ), 'secondary', 'submit', false ); ?>
</form>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline-block;margin-right:1em;">
<input type="hidden" name="action" value="vc_manual_sync">
<?php wp_nonce_field( 'vc_manual_sync' ); ?>
<?php submit_button( __( 'Run sync now', 'viator-collector' ), 'primary', 'submit', false ); ?>
</form>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline-block;" onsubmit="return confirm('<?php echo esc_js( __( 'Reset ingestion progress and start a full crawl on the next run?', 'viator-collector' ) ); ?>');">
<input type="hidden" name="action" value="vc_reset_sync">
<?php wp_nonce_field( 'vc_reset_sync' ); ?>
<?php submit_button( __( 'Reset ingestion', 'viator-collector' ), 'delete', 'submit', false ); ?>
</form>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline-block;margin-left:1em;" onsubmit="return confirm('<?php echo esc_js( __( 'This permanently deletes ALL imported tours, destination/tag terms, and sync data. Your settings are kept. Continue?', 'viator-collector' ) ); ?>');">
<input type="hidden" name="action" value="vc_delete_all">
<?php wp_nonce_field( 'vc_delete_all' ); ?>
<?php submit_button( __( 'Delete all imported data', 'viator-collector' ), 'delete', 'submit', false ); ?>
<p class="description"><?php echo esc_html__( 'Removes every tour and term this plugin created. Settings (API key, destination filter) are preserved.', 'viator-collector' ); ?></p>
</form>
<hr>
<h2><?php echo esc_html__( 'Import a single product', 'viator-collector' ); ?></h2>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="vc_import_product">
<?php wp_nonce_field( 'vc_import_product' ); ?>
<label for="vc_product_code"><?php echo esc_html__( 'Viator product code', 'viator-collector' ); ?></label>
<input id="vc_product_code" type="text" name="product_code" required>
<?php submit_button( __( 'Import product', 'viator-collector' ), 'secondary', 'submit', false ); ?>
</form>
<hr>
<h2><?php echo esc_html__( 'Activity log', 'viator-collector' ); ?></h2>
<?php $log = VC_Logger::get_log(); ?>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="margin-bottom:1em;">
<input type="hidden" name="action" value="vc_clear_log">
<?php wp_nonce_field( 'vc_clear_log' ); ?>
<?php submit_button( __( 'Clear log', 'viator-collector' ), 'secondary', 'submit', false ); ?>
</form>
<?php if ( empty( $log ) ) : ?>
<p><?php echo esc_html__( 'No activity recorded yet.', 'viator-collector' ); ?></p>
<?php else : ?>
<table class="widefat striped" style="max-width:900px;">
<thead>
<tr>
<th style="width:160px;"><?php echo esc_html__( 'Time', 'viator-collector' ); ?></th>
<th style="width:70px;"><?php echo esc_html__( 'Level', 'viator-collector' ); ?></th>
<th><?php echo esc_html__( 'Message', 'viator-collector' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $log as $entry ) : ?>
<?php $is_error = isset( $entry['level'] ) && 'error' === $entry['level']; ?>
<tr>
<td><?php echo esc_html( isset( $entry['t'] ) ? $entry['t'] : '' ); ?></td>
<td><span style="font-weight:600;color:<?php echo $is_error ? '#b32d2e' : '#2271b1'; ?>;"><?php echo $is_error ? esc_html__( 'ERROR', 'viator-collector' ) : esc_html__( 'INFO', 'viator-collector' ); ?></span></td>
<td><?php echo esc_html( isset( $entry['msg'] ) ? $entry['msg'] : '' ); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php
}
public static function handle_test_api() {
self::verify_action( 'vc_test_api' );
$result = ( new VC_API() )->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;
}
}

View File

@@ -0,0 +1,257 @@
<?php
defined( 'ABSPATH' ) || exit;
class VC_Taxonomies {
public static function init() {
add_action( 'init', array( __CLASS__, 'register' ) );
foreach ( array( 'viator_destination', 'viator_tag', 'viator_category' ) as $taxonomy ) {
add_action( "{$taxonomy}_add_form_fields", array( __CLASS__, 'add_image_field' ) );
add_action( "{$taxonomy}_edit_form_fields", array( __CLASS__, 'edit_image_field' ), 10, 1 );
add_action( "created_{$taxonomy}", array( __CLASS__, 'save_image_field' ) );
add_action( "edited_{$taxonomy}", array( __CLASS__, 'save_image_field' ) );
}
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'admin_media' ) );
}
/**
* The media library is only needed on our term screens.
*/
public static function admin_media( $hook ) {
if ( 'term.php' !== $hook && 'edit-tags.php' !== $hook ) {
return;
}
$taxonomy = isset( $_GET['taxonomy'] ) ? sanitize_key( $_GET['taxonomy'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification
if ( in_array( $taxonomy, array( 'viator_destination', 'viator_tag', 'viator_category' ), true ) ) {
wp_enqueue_media();
}
}
/**
* Media-library picker shared by the add and edit term forms.
*/
private static function image_picker( $value ) {
wp_nonce_field( 'vc_term_image', 'vc_term_image_nonce' );
?>
<input type="hidden" id="vc_image_url" name="vc_image_url" value="<?php echo esc_attr( $value ); ?>">
<button type="button" class="button" id="vc-term-img-pick"><?php echo esc_html__( 'Select image', 'viator-collector' ); ?></button>
<button type="button" class="button" id="vc-term-img-clear"><?php echo esc_html__( 'Remove', 'viator-collector' ); ?></button>
<div id="vc-term-img-preview" style="margin-top:8px;">
<?php if ( $value ) : ?>
<img src="<?php echo esc_url( $value ); ?>" alt="" style="max-width:200px;height:auto;border-radius:6px;">
<?php endif; ?>
</div>
<p class="description">
<?php echo esc_html__( 'Used on the Top Destinations cards and as this term archive\'s banner. If empty, a tour photo from this term is used automatically.', 'viator-collector' ); ?>
</p>
<script>
jQuery(function ($) {
var frame;
$('#vc-term-img-pick').on('click', function (e) {
e.preventDefault();
frame = wp.media({ library: { type: 'image' }, multiple: false });
frame.on('select', function () {
var img = frame.state().get('selection').first().toJSON();
$('#vc_image_url').val(img.url);
$('#vc-term-img-preview').html($('<img>', { src: img.url }).css({ maxWidth: '200px', height: 'auto', borderRadius: '6px' }));
});
frame.open();
});
$('#vc-term-img-clear').on('click', function (e) {
e.preventDefault();
$('#vc_image_url').val('');
$('#vc-term-img-preview').empty();
});
});
</script>
<?php
}
public static function add_image_field() {
?>
<div class="form-field">
<label><?php echo esc_html__( 'Image', 'viator-collector' ); ?></label>
<?php self::image_picker( '' ); ?>
</div>
<?php
}
public static function edit_image_field( $term ) {
$value = (string) get_term_meta( $term->term_id, '_vc_image_url', true );
?>
<tr class="form-field">
<th scope="row"><label><?php echo esc_html__( 'Image', 'viator-collector' ); ?></label></th>
<td><?php self::image_picker( $value ); ?></td>
</tr>
<?php
}
public static function save_image_field( $term_id ) {
if ( ! current_user_can( 'manage_categories' ) ) {
return;
}
if ( ! isset( $_POST['vc_term_image_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['vc_term_image_nonce'] ) ), 'vc_term_image' ) ) {
return;
}
if ( ! isset( $_POST['vc_image_url'] ) ) {
return;
}
$url = esc_url_raw( wp_unslash( $_POST['vc_image_url'] ) );
if ( '' === $url ) {
delete_term_meta( $term_id, '_vc_image_url' );
} else {
update_term_meta( $term_id, '_vc_image_url', $url );
}
delete_transient( 'vc_term_img_' . absint( $term_id ) );
}
public static function register() {
self::register_taxonomy( 'viator_destination', __( 'Viator Destinations', 'viator-collector' ), __( 'Viator Destination', 'viator-collector' ), true, 'tour-destination' );
self::register_taxonomy( 'viator_tag', __( 'Viator Tags', 'viator-collector' ), __( 'Viator Tag', 'viator-collector' ), false, 'tour-tag' );
self::register_taxonomy( 'viator_category', __( 'Viator Categories', 'viator-collector' ), __( 'Viator Category', 'viator-collector' ), true, 'tour-category' );
}
private static function register_taxonomy( $taxonomy, $plural, $singular, $hierarchical, $slug ) {
register_taxonomy(
$taxonomy,
array( 'viator_tour' ),
array(
'labels' => 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 ) );
}
}
}
}

View File

@@ -0,0 +1,557 @@
<?php
defined( 'ABSPATH' ) || exit;
function vc_get_option( $key = '', $default = null ) {
$options = get_option( 'vc_settings', array() );
if ( '' === $key ) {
return is_array( $options ) ? $options : array();
}
return isset( $options[ $key ] ) ? $options[ $key ] : $default;
}
function vc_update_option( $key, $value ) {
$options = get_option( 'vc_settings', array() );
if ( ! is_array( $options ) ) {
$options = array();
}
$options[ $key ] = $value;
update_option( 'vc_settings', $options );
}
function vc_get_api_key() {
if ( defined( 'VIATOR_API_KEY' ) && VIATOR_API_KEY ) {
return (string) VIATOR_API_KEY;
}
return (string) vc_get_option( 'api_key', '' );
}
function vc_json_decode_meta( $value, $default = array() ) {
if ( is_array( $value ) || is_object( $value ) ) {
return $value;
}
if ( ! is_string( $value ) || '' === $value ) {
return $default;
}
$decoded = json_decode( $value, true );
return JSON_ERROR_NONE === json_last_error() ? $decoded : $default;
}
function vc_array_get( $array, $path, $default = null ) {
if ( ! is_array( $array ) ) {
return $default;
}
$segments = is_array( $path ) ? $path : explode( '.', (string) $path );
$value = $array;
foreach ( $segments as $segment ) {
if ( is_array( $value ) && array_key_exists( $segment, $value ) ) {
$value = $value[ $segment ];
} else {
return $default;
}
}
return $value;
}
function vc_first_non_empty( array $values, $default = '' ) {
foreach ( $values as $value ) {
if ( is_string( $value ) ) {
$value = trim( $value );
}
if ( ! empty( $value ) || '0' === $value || 0 === $value ) {
return $value;
}
}
return $default;
}
function vc_get_template( $template_name, $args = array() ) {
$template_name = ltrim( str_replace( array( '..', '\\' ), '', $template_name ), '/' );
$theme_path = trailingslashit( get_stylesheet_directory() ) . 'viator-collector/' . $template_name;
$plugin_path = VC_PLUGIN_DIR . 'templates/' . $template_name;
$template = file_exists( $theme_path ) ? $theme_path : $plugin_path;
if ( ! file_exists( $template ) ) {
return '';
}
if ( is_array( $args ) ) {
extract( $args, EXTR_SKIP );
}
ob_start();
include $template;
return ob_get_clean();
}
function vc_clean_text( $value ) {
if ( is_array( $value ) || is_object( $value ) ) {
return '';
}
return sanitize_text_field( wp_unslash( (string) $value ) );
}
function vc_tour_image_html( $post_id, $class = 'vc-tour-image' ) {
$post_id = absint( $post_id );
if ( $post_id && has_post_thumbnail( $post_id ) ) {
return get_the_post_thumbnail( $post_id, 'large', array( 'class' => $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 '<img class="' . esc_attr( $class ) . '" src="' . esc_url( $url ) . '" alt="' . esc_attr( get_the_title( $post_id ) ) . '" loading="lazy">';
}
/**
* 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 '<span class="vc-stars" role="img" aria-label="' . esc_attr( sprintf( __( 'Rated %s out of 5', 'viator-collector' ), number_format_i18n( $rating, 1 ) ) ) . '"><span class="vc-stars-fill" style="width:' . esc_attr( $pct ) . '%"></span></span>';
}
/**
* 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 .= '<span class="stars">' . str_repeat( '★', $full ) . '</span>';
}
if ( $full < 5 ) {
$out .= '<span class="stars muted">' . str_repeat( '★', 5 - $full ) . '</span>';
}
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<int|null>
*/
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 <symbol id="i-…"> plus the #ringPathScroll circular text path
* used by the rotating "BOOK NOW" badges. Referenced via vc_icon() / <use>.
*/
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' ) => <svg class="icon rating__star">…</svg>
*/
function vc_icon( $name, $extra_class = '' ) {
$name = preg_replace( '/[^a-z0-9\-]/', '', 'i-' . ltrim( (string) $name, 'i-' ) );
$class = 'icon' . ( $extra_class ? ' ' . $extra_class : '' );
return '<svg class="' . esc_attr( $class ) . '" aria-hidden="true"><use href="#' . esc_attr( $name ) . '"></use></svg>';
}
/**
* 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();
?>
<a class="ring-badge ring-badge--<?php echo esc_attr( $variant ); ?>" href="<?php echo esc_url( $url ); ?>" aria-label="<?php echo esc_attr( $aria ); ?>"<?php echo $target; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
<svg class="ring-badge__ring" viewBox="0 0 100 100" aria-hidden="true">
<text class="ring-badge__text"><textPath href="#ringPathScroll" startOffset="0"><?php echo esc_html( $label ); ?></textPath></text>
</svg>
<span class="ring-badge__dot"><?php echo vc_icon( 'arrow-ne' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
</a>
<?php
return ob_get_clean();
}

View File

@@ -0,0 +1,22 @@
<?php
/**
* Post-type archive for viator_tour — renders the shared listing UI.
*/
defined( 'ABSPATH' ) || exit;
get_header();
?>
<main class="vc-app vc-listing">
<?php
echo vc_get_template( // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
'listing.php',
array(
// Deliberately location-agnostic; override with the vc_listing_title filter.
'vc_title' => __( 'Explore Tours & Experiences', 'viator-collector' ),
'vc_subtitle' => __( 'Find the best tours, activities and unforgettable experiences.', 'viator-collector' ),
'vc_crumb_label' => __( 'Tours', 'viator-collector' ),
)
);
?>
</main>
<?php
get_footer();

View File

@@ -0,0 +1,9 @@
<?php
defined( 'ABSPATH' ) || exit;
$post_id = isset( $post_id ) ? absint( $post_id ) : get_the_ID();
$url = get_post_meta( $post_id, '_vc_booking_url', true );
if ( ! $url ) {
return;
}
?>
<a class="vc-booking-button" href="<?php echo esc_url( $url ); ?>" target="_blank" rel="nofollow sponsored noopener"><?php echo esc_html__( 'Book this tour', 'viator-collector' ); ?></a>

View File

@@ -0,0 +1,25 @@
<?php
/**
* Activity row (Browse By Activity). Args: $term (WP_Term).
*/
defined( 'ABSPATH' ) || exit;
if ( empty( $term ) || ! is_object( $term ) ) {
return;
}
$link = get_term_link( $term );
$link = is_wp_error( $link ) ? '#' : $link;
$image = vc_term_image_url( $term->term_id, $term->taxonomy );
$count = vc_term_tour_count( $term );
?>
<a class="vc-activity vc-reveal" href="<?php echo esc_url( $link ); ?>">
<span class="vc-activity__thumb">
<?php if ( $image ) : ?>
<img src="<?php echo esc_url( $image ); ?>" alt="<?php echo esc_attr( $term->name ); ?>" loading="lazy">
<?php else : ?>
<span class="vc-activity__thumb-ph" aria-hidden="true"></span>
<?php endif; ?>
</span>
<span class="vc-activity__name"><?php echo esc_html( $term->name ); ?></span>
<?php if ( $count ) : ?><span class="vc-activity__count"><?php echo esc_html( number_format_i18n( $count ) ); ?></span><?php endif; ?>
<span class="vc-activity__arrow" aria-hidden="true">&rsaquo;</span>
</a>

View File

@@ -0,0 +1,32 @@
<?php
/**
* Large attraction feature card. Args: $post_id.
*/
defined( 'ABSPATH' ) || exit;
$post_id = isset( $post_id ) ? absint( $post_id ) : get_the_ID();
if ( ! $post_id ) {
return;
}
$permalink = get_permalink( $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 );
$destinations = get_the_terms( $post_id, 'viator_destination' );
$destination = ( ! empty( $destinations ) && ! is_wp_error( $destinations ) ) ? $destinations[0]->name : '';
?>
<a class="vc-attraction vc-reveal" href="<?php echo esc_url( $permalink ); ?>">
<span class="vc-attraction__media">
<?php echo vc_tour_image_html( $post_id, 'vc-attraction__img' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<span class="vc-attraction__scrim"></span>
</span>
<?php if ( '' !== $price ) : ?>
<span class="vc-attraction__badge"><span><?php echo esc_html__( 'From', 'viator-collector' ); ?></span> <?php echo esc_html( vc_money( $price, $currency ) ); ?></span>
<?php endif; ?>
<span class="vc-attraction__body">
<?php if ( $destination ) : ?><span class="vc-attraction__place"><?php echo esc_html( $destination ); ?></span><?php endif; ?>
<span class="vc-attraction__title"><?php echo esc_html( get_the_title( $post_id ) ); ?></span>
<?php if ( '' !== $rating ) : ?>
<span class="vc-attraction__rating"><?php echo vc_render_stars( $rating ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> <strong><?php echo esc_html( $rating ); ?></strong></span>
<?php endif; ?>
</span>
</a>

View File

@@ -0,0 +1,27 @@
<?php
/**
* Destination card. Args: $term (WP_Term).
*/
defined( 'ABSPATH' ) || exit;
if ( empty( $term ) || ! is_object( $term ) ) {
return;
}
$link = get_term_link( $term );
$link = is_wp_error( $link ) ? '#' : $link;
$image = vc_term_image_url( $term->term_id, $term->taxonomy );
$count = vc_term_tour_count( $term );
?>
<a class="vc-dest-card vc-reveal" href="<?php echo esc_url( $link ); ?>">
<span class="vc-dest-card__media">
<?php if ( $image ) : ?>
<img class="vc-dest-card__img" src="<?php echo esc_url( $image ); ?>" alt="<?php echo esc_attr( $term->name ); ?>" loading="lazy">
<?php else : ?>
<span class="vc-dest-card__placeholder" aria-hidden="true"></span>
<?php endif; ?>
<span class="vc-dest-card__overlay"></span>
</span>
<span class="vc-dest-card__body">
<span class="vc-dest-card__name"><?php echo esc_html( $term->name ); ?></span>
<?php if ( $count ) : ?><span class="vc-dest-card__count"><?php echo esc_html( sprintf( _n( '%d tour', '%d tours', $count, 'viator-collector' ), $count ) ); ?></span><?php endif; ?>
</span>
</a>

View File

@@ -0,0 +1,97 @@
<?php
/**
* Tour card — "Hop On Hop Off" design.
*
* Args:
* $post_id int Tour post ID (defaults to the loop post).
* $variant string 'wide' → listing item (<li class="titem">),
* 'rail' → homepage rail card (<li class="dcard">).
*
* 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 = '<span class="rating">' . vc_icon( 'star', 'icon--12 rating__star' )
. '<b>' . esc_html( number_format_i18n( (float) $rating, 1 ) ) . '</b><i>/5</i></span>';
}
$img_html = vc_tour_image_html( $post_id, 'dcard__img' );
$title_link = '<a href="' . esc_url( $permalink ) . '">' . esc_html( $title ) . '</a>';
ob_start();
?>
<div class="dcard__meta">
<?php if ( '' !== (string) $price ) : ?>
<div class="meta">
<span class="meta__k"><?php echo esc_html__( 'Start at', 'viator-collector' ); ?></span>
<span class="meta__v"><?php echo esc_html( vc_money( $price, $currency ) ); ?></span>
</div>
<?php endif; ?>
<?php if ( $duration ) : ?>
<div class="meta">
<span class="meta__k"><?php echo esc_html__( 'Duration', 'viator-collector' ); ?></span>
<span class="meta__v"><?php echo esc_html( $duration ); ?></span>
</div>
<?php endif; ?>
<?php echo vc_ring_badge( 'BOOK NOW · BOOK NOW · ', $permalink, 'card' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
<?php
$meta_html = ob_get_clean();
if ( 'rail' === $variant ) :
?>
<li class="dcard">
<?php echo $img_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php echo $rating_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php if ( $excerpt ) : ?>
<div class="dcard__desc">
<span class="dcard__desc-k"><?php echo esc_html__( 'Tour Description', 'viator-collector' ); ?></span>
<p><?php echo esc_html( $excerpt ); ?></p>
</div>
<?php endif; ?>
<div class="dcard__body">
<h3 class="dcard__title"><?php echo $title_link; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></h3>
<?php echo $meta_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
</li>
<?php
else :
?>
<li class="titem">
<article class="dcard dcard--wide">
<?php echo $img_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php echo $rating_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<div class="dcard__body">
<h2 class="dcard__title"><?php echo $title_link; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></h2>
<?php echo $meta_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
</article>
<?php if ( $excerpt ) : ?>
<p class="titem__desc"><?php echo esc_html( $excerpt ); ?></p>
<?php endif; ?>
</li>
<?php
endif;

View File

@@ -0,0 +1,13 @@
<?php
defined( 'ABSPATH' ) || exit;
$post_id = isset( $post_id ) ? absint( $post_id ) : get_the_ID();
$text = get_post_meta( $post_id, '_vc_duration_text', true );
$minutes = absint( get_post_meta( $post_id, '_vc_duration_minutes', true ) );
if ( ! $text && ! $minutes ) {
return;
}
if ( ! $text && $minutes ) {
$text = sprintf( _n( '%d minute', '%d minutes', $minutes, 'viator-collector' ), $minutes );
}
?>
<p class="vc-duration"><?php echo esc_html__( 'Duration: ', 'viator-collector' ) . esc_html( $text ); ?></p>

View File

@@ -0,0 +1,23 @@
<?php
defined( 'ABSPATH' ) || exit;
$post_id = isset( $post_id ) ? absint( $post_id ) : get_the_ID();
$items = vc_json_decode_meta( get_post_meta( $post_id, '_vc_exclusions', true ), array() );
$rows = array();
foreach ( (array) $items as $item ) {
$text = vc_feature_text( $item );
if ( '' !== $text ) {
$rows[] = $text;
}
}
if ( empty( $rows ) ) {
return;
}
?>
<section class="vc-block vc-reveal vc-exclusions">
<h2><?php echo esc_html__( "What's not included", 'viator-collector' ); ?></h2>
<ul class="vc-features">
<?php foreach ( $rows as $row ) : ?>
<li class="vc-feature vc-feature--exc"><span class="vc-feature__icon" aria-hidden="true"></span><span><?php echo esc_html( $row ); ?></span></li>
<?php endforeach; ?>
</ul>
</section>

View File

@@ -0,0 +1,23 @@
<?php
defined( 'ABSPATH' ) || exit;
$post_id = isset( $post_id ) ? absint( $post_id ) : get_the_ID();
$items = vc_json_decode_meta( get_post_meta( $post_id, '_vc_inclusions', true ), array() );
$rows = array();
foreach ( (array) $items as $item ) {
$text = vc_feature_text( $item );
if ( '' !== $text ) {
$rows[] = $text;
}
}
if ( empty( $rows ) ) {
return;
}
?>
<section class="vc-block vc-reveal vc-inclusions">
<h2><?php echo esc_html__( "What's included", 'viator-collector' ); ?></h2>
<ul class="vc-features">
<?php foreach ( $rows as $row ) : ?>
<li class="vc-feature vc-feature--inc"><span class="vc-feature__icon" aria-hidden="true"></span><span><?php echo esc_html( $row ); ?></span></li>
<?php endforeach; ?>
</ul>
</section>

View File

@@ -0,0 +1,35 @@
<?php
defined( 'ABSPATH' ) || exit;
$post_id = isset( $post_id ) ? absint( $post_id ) : get_the_ID();
$itinerary = vc_json_decode_meta( get_post_meta( $post_id, '_vc_itinerary', true ), array() );
$steps = vc_itinerary_steps( $itinerary );
if ( empty( $steps ) ) {
return;
}
?>
<section class="vc-block vc-reveal vc-itinerary">
<h2><?php echo esc_html__( 'Itinerary', 'viator-collector' ); ?></h2>
<ol class="vc-itin">
<?php foreach ( $steps as $i => $step ) : ?>
<li class="vc-itin__step">
<span class="vc-itin__marker"><?php echo esc_html( $i + 1 ); ?></span>
<div class="vc-itin__content">
<p class="vc-itin__desc"><?php echo esc_html( $step['description'] ); ?></p>
<?php if ( ! empty( $step['duration'] ) || ! empty( $step['admission'] ) || ! empty( $step['passby'] ) ) : ?>
<div class="vc-itin__meta">
<?php if ( ! empty( $step['duration'] ) ) : ?>
<span class="vc-tag"><?php echo esc_html( vc_duration_human( $step['duration'] ) ); ?></span>
<?php endif; ?>
<?php if ( ! empty( $step['admission'] ) ) : ?>
<span class="vc-tag vc-tag--ok"><?php echo esc_html__( 'Admission included', 'viator-collector' ); ?></span>
<?php endif; ?>
<?php if ( ! empty( $step['passby'] ) ) : ?>
<span class="vc-tag vc-tag--muted"><?php echo esc_html__( 'Pass by', 'viator-collector' ); ?></span>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</li>
<?php endforeach; ?>
</ol>
</section>

View File

@@ -0,0 +1,253 @@
<?php
/**
* Tours listing UI — "Hop On Hop Off" design (post-type archive, taxonomy
* archives, and the [viator_listing] shortcode).
*
* Filtering stays server-side: every control lives inside #vc-filter-form and
* submits via GET. VC_Display::filter_archive_query() reads the same state.
*
* Args: $vc_title, $vc_subtitle, $vc_query, $vc_form_action.
*/
defined( 'ABSPATH' ) || exit;
$f = VC_Display::get_filter_state();
$vc_title = isset( $vc_title ) ? $vc_title : __( 'Explore Tours', 'viator-collector' );
$vc_subtitle = isset( $vc_subtitle ) ? $vc_subtitle : '';
$vc_title = apply_filters( 'vc_listing_title', $vc_title );
$vc_subtitle = apply_filters( 'vc_listing_subtitle', $vc_subtitle );
$vc_query = ( isset( $vc_query ) && $vc_query instanceof WP_Query ) ? $vc_query : $GLOBALS['wp_query'];
$vc_is_shortcode = ( $vc_query !== $GLOBALS['wp_query'] );
$vc_term = is_tax() ? get_queried_object() : null;
if ( isset( $vc_form_action ) && $vc_form_action ) {
$vc_action = $vc_form_action;
} else {
$vc_action = $vc_term ? get_term_link( $vc_term ) : get_post_type_archive_link( 'viator_tour' );
}
if ( is_wp_error( $vc_action ) || ! $vc_action ) {
$vc_action = home_url( '/' );
}
$vc_destinations = get_terms( array( 'taxonomy' => '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' );
}
?>
<section class="hero hero--archive">
<div class="hero__media">
<img class="hero__img" src="<?php echo esc_url( $vc_hero_img ); ?>" alt="" fetchpriority="high">
<div class="hero__scrim" aria-hidden="true"></div>
</div>
<div class="container hero__content">
<p class="pill hero__eyebrow"><?php echo esc_html__( 'Tour & Travel', 'viator-collector' ); ?></p>
<h1 class="hero__title"><?php echo esc_html( $vc_title ); ?></h1>
<form class="filters" id="vc-filter-form" method="get" action="<?php echo esc_url( $vc_action ); ?>" novalidate>
<div class="filters__row">
<div class="fsearch">
<?php echo vc_icon( 'search', 'fsearch__icon' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<input class="fsearch__input" name="vc_q" type="search" value="<?php echo esc_attr( $f['q'] ); ?>" placeholder="<?php echo esc_attr__( 'Search tours, cities …', 'viator-collector' ); ?>" aria-label="<?php echo esc_attr__( 'Search tours and cities', 'viator-collector' ); ?>">
</div>
<div class="fsel">
<?php echo vc_icon( 'location', 'fsel__icon' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<select class="fsel__input" name="dest[]" aria-label="<?php echo esc_attr__( 'Destination', 'viator-collector' ); ?>">
<option value=""><?php echo esc_html__( 'Destination', 'viator-collector' ); ?></option>
<?php foreach ( $vc_destinations as $t ) : ?>
<option value="<?php echo esc_attr( $t->slug ); ?>" <?php selected( in_array( $t->slug, $f['dest'], true ) ); ?>><?php echo esc_html( $t->name ); ?></option>
<?php endforeach; ?>
</select>
<?php echo vc_icon( 'chevron', 'fsel__chevron' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
<div class="fsel">
<?php echo vc_icon( 'grid', 'fsel__icon' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<select class="fsel__input" name="cat[]" aria-label="<?php echo esc_attr__( 'Category', 'viator-collector' ); ?>">
<option value=""><?php echo esc_html__( 'Categorize', 'viator-collector' ); ?></option>
<?php foreach ( $vc_categories as $t ) : ?>
<option value="<?php echo esc_attr( $t->slug ); ?>" <?php selected( in_array( $t->slug, $f['cat'], true ) ); ?>><?php echo esc_html( $t->name ); ?></option>
<?php endforeach; ?>
</select>
<?php echo vc_icon( 'chevron', 'fsel__chevron' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
<div class="fsel">
<?php echo vc_icon( 'price', 'fsel__icon' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<select class="fsel__input" name="price" aria-label="<?php echo esc_attr__( 'Price range', 'viator-collector' ); ?>">
<option value=""><?php echo esc_html__( 'Price Range', 'viator-collector' ); ?></option>
<option value="0-50" <?php selected( $vc_price_val, '0-50' ); ?>><?php echo esc_html__( 'Up to $50', 'viator-collector' ); ?></option>
<option value="50-150" <?php selected( $vc_price_val, '50-150' ); ?>>$50 &ndash; $150</option>
<option value="150-1000" <?php selected( $vc_price_val, '150-1000' ); ?>><?php echo esc_html__( '$150 and up', 'viator-collector' ); ?></option>
</select>
<?php echo vc_icon( 'chevron', 'fsel__chevron' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
<div class="fsel">
<?php echo vc_icon( 'duration', 'fsel__icon' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<select class="fsel__input" name="dur[]" aria-label="<?php echo esc_attr__( 'Duration', 'viator-collector' ); ?>">
<option value=""><?php echo esc_html__( 'Duration', 'viator-collector' ); ?></option>
<?php foreach ( $vc_dur_labels as $key => $label ) : ?>
<option value="<?php echo esc_attr( $key ); ?>" <?php selected( in_array( $key, $f['dur'], true ) ); ?>><?php echo esc_html( $label ); ?></option>
<?php endforeach; ?>
</select>
<?php echo vc_icon( 'chevron', 'fsel__chevron' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
<button class="btn btn--dark filters__apply" type="submit"><?php echo esc_html__( 'Apply Filters', 'viator-collector' ); ?></button>
</div>
<div class="filters__bar">
<div class="filters__checks">
<label class="check">
<input class="check__input vc-autosubmit" type="checkbox" name="freecancel" value="1" <?php checked( $f['freecancel'], 1 ); ?>>
<span class="check__box" aria-hidden="true"><?php echo vc_icon( 'check', 'icon--12' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
<span class="check__text"><?php echo esc_html__( 'Free Cancellation', 'viator-collector' ); ?></span>
</label>
<label class="check">
<input class="check__input vc-autosubmit" type="checkbox" name="bestdeal" value="1" <?php checked( $f['bestdeal'], 1 ); ?>>
<span class="check__box" aria-hidden="true"><?php echo vc_icon( 'check', 'icon--12' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
<span class="check__text"><?php echo esc_html__( 'Best Deal', 'viator-collector' ); ?></span>
</label>
<label class="check">
<input class="check__input vc-autosubmit" type="checkbox" name="sellout" value="1" <?php checked( $f['sellout'], 1 ); ?>>
<span class="check__box" aria-hidden="true"><?php echo vc_icon( 'check', 'icon--12' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
<span class="check__text"><?php echo esc_html__( 'Likely to Sell Out', 'viator-collector' ); ?></span>
</label>
</div>
<div class="filters__sort">
<span class="filters__sort-label"><?php echo vc_icon( 'sort' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><label for="f-sort"><?php echo esc_html__( 'Sort by', 'viator-collector' ); ?></label></span>
<div class="fsel fsel--bare">
<select class="fsel__input vc-autosubmit" id="f-sort" name="sort">
<option value="newest" <?php selected( $f['sort'], 'newest' ); ?>><?php echo esc_html__( 'Newest', 'viator-collector' ); ?></option>
<option value="price_asc" <?php selected( $f['sort'], 'price_asc' ); ?>><?php echo esc_html__( 'Price: low to high', 'viator-collector' ); ?></option>
<option value="price_desc" <?php selected( $f['sort'], 'price_desc' ); ?>><?php echo esc_html__( 'Price: high to low', 'viator-collector' ); ?></option>
<option value="rating" <?php selected( $f['sort'], 'rating' ); ?>><?php echo esc_html__( 'Top rated', 'viator-collector' ); ?></option>
</select>
<?php echo vc_icon( 'chevron', 'fsel__chevron' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
</div>
</div>
</form>
</div>
<?php echo vc_ring_badge( 'SCROLL DOWN · SCROLL DOWN · ', '#tour-list', 'scroll' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</section>
<section class="section" id="tour-list">
<div class="container">
<div class="results">
<p class="results__count" role="status">
<?php if ( $vc_total ) : ?>
<?php
printf(
/* translators: 1: first result, 2: last result, 3: total. */
wp_kses( __( 'Showing <b>%1$d&ndash;%2$d</b> of <b>%3$d</b> tours', 'viator-collector' ), array( 'b' => array() ) ),
(int) $vc_first,
(int) $vc_last,
(int) $vc_total
);
?>
<?php else : ?>
<?php echo esc_html__( 'No tours found', 'viator-collector' ); ?>
<?php endif; ?>
</p>
<?php if ( $vc_active ) : ?>
<a class="btn btn--ghost results__reset" href="<?php echo esc_url( $vc_action ); ?>"><?php echo esc_html__( 'Clear filters', 'viator-collector' ); ?></a>
<?php endif; ?>
</div>
<?php if ( $vc_query->have_posts() ) : ?>
<ul class="tgrid" id="tourGrid">
<?php
while ( $vc_query->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();
?>
</ul>
<?php if ( $vc_max > 1 ) : ?>
<nav class="pager" aria-label="<?php echo esc_attr__( 'Tour list pages', 'viator-collector' ); ?>">
<?php if ( $vc_paged > 1 ) : ?>
<a class="pager__btn pager__btn--icon" href="<?php echo esc_url( $vc_page_url( $vc_paged - 1 ) ); ?>" aria-label="<?php echo esc_attr__( 'Previous page', 'viator-collector' ); ?>"><?php echo vc_icon( 'arrow-left' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></a>
<?php endif; ?>
<span class="pager__pages">
<?php
$vc_range = vc_pager_range( $vc_paged, $vc_max );
foreach ( $vc_range as $n ) :
if ( null === $n ) :
?>
<span class="pager__gap" aria-hidden="true">&hellip;</span>
<?php
elseif ( $n === $vc_paged ) :
?>
<span class="pager__num is-current" aria-current="page"><?php echo esc_html( $n ); ?></span>
<?php
else :
?>
<a class="pager__num" href="<?php echo esc_url( $vc_page_url( $n ) ); ?>"><?php echo esc_html( $n ); ?></a>
<?php
endif;
endforeach;
?>
</span>
<?php if ( $vc_paged < $vc_max ) : ?>
<a class="pager__btn" href="<?php echo esc_url( $vc_page_url( $vc_paged + 1 ) ); ?>"><span><?php echo esc_html__( 'Next', 'viator-collector' ); ?></span><?php echo vc_icon( 'arrow-right' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></a>
<?php endif; ?>
</nav>
<?php endif; ?>
<?php else : ?>
<p class="tgrid__empty"><?php echo esc_html__( 'No tours match these filters. Try widening your search or', 'viator-collector' ); ?> <a class="linkbtn" href="<?php echo esc_url( $vc_action ); ?>"><?php echo esc_html__( 'clear the filters', 'viator-collector' ); ?></a>.</p>
<?php endif; ?>
</div>
</section>

View File

@@ -0,0 +1,10 @@
<?php
defined( 'ABSPATH' ) || exit;
$post_id = isset( $post_id ) ? absint( $post_id ) : get_the_ID();
$rating = get_post_meta( $post_id, '_vc_rating', true );
$count = absint( get_post_meta( $post_id, '_vc_review_count', true ) );
if ( '' === $rating && ! $count ) {
return;
}
?>
<p class="vc-rating"><?php echo esc_html( $rating ); ?><?php if ( $count ) : ?> <span><?php echo esc_html( sprintf( _n( '(%d review)', '(%d reviews)', $count, 'viator-collector' ), $count ) ); ?></span><?php endif; ?></p>

View File

@@ -0,0 +1,255 @@
<?php
/**
* Single Viator tour — "Hop On Hop Off" design (ported from new front/tour.html).
* Itinerary and Reviews are not in the mockup; they reuse the design's own
* primitives (.notes, .tmeta) so they sit naturally alongside the rest.
*/
defined( 'ABSPATH' ) || exit;
get_header();
while ( have_posts() ) :
the_post();
$post_id = get_the_ID();
$rating = get_post_meta( $post_id, '_vc_rating', true );
$reviews = (int) get_post_meta( $post_id, '_vc_review_count', true );
$price = get_post_meta( $post_id, '_vc_price_from', true );
$currency = get_post_meta( $post_id, '_vc_currency', true );
$supplier = get_post_meta( $post_id, '_vc_supplier', true );
$duration_min = (int) get_post_meta( $post_id, '_vc_duration_minutes', true );
$max_trav = (int) get_post_meta( $post_id, '_vc_max_travelers', true );
$freecancel = (int) get_post_meta( $post_id, '_vc_free_cancellation', true );
$booking_url = get_post_meta( $post_id, '_vc_booking_url', true );
$dest_terms = get_the_terms( $post_id, 'viator_destination' );
$dest_names = ( $dest_terms && ! is_wp_error( $dest_terms ) ) ? wp_list_pluck( $dest_terms, 'name' ) : array();
$cat_terms = get_the_terms( $post_id, 'viator_tag' );
$cat_names = ( $cat_terms && ! is_wp_error( $cat_terms ) ) ? array_slice( wp_list_pluck( $cat_terms, 'name' ), 0, 2 ) : array();
$gallery = vc_json_decode_meta( get_post_meta( $post_id, '_vc_gallery_image_urls', true ), array() );
$gallery = array_values( array_filter( (array) $gallery ) );
$primary = get_post_meta( $post_id, '_vc_primary_image_url', true );
if ( ! $primary && ! empty( $gallery ) ) {
$primary = $gallery[0];
}
$more = array_slice( array_values( array_diff( $gallery, array( $primary ) ) ), 0, 5 );
$big_word = ! empty( $dest_names ) ? $dest_names[0] : get_the_title();
?>
<div class="vc-app vc-single">
<section class="hero-tour">
<?php if ( $primary ) : ?>
<img class="hero-tour__photo" src="<?php echo esc_url( $primary ); ?>" alt="" fetchpriority="high">
<?php endif; ?>
<div class="hero-tour__sky" aria-hidden="true"></div>
<div class="container">
<div class="hero-tour__block">
<p class="hero-tour__kicker"><?php echo esc_html__( 'Welcome to', 'viator-collector' ); ?></p>
<div class="hero-tour__stage">
<h1 class="hero-tour__word"><?php echo esc_html( $big_word ); ?></h1>
<img class="hero-tour__figure" src="<?php echo esc_url( vc_asset_url( 'img/tower.png' ) ); ?>" alt="" loading="eager">
</div>
</div>
</div>
</section>
<section class="section tour">
<div class="container tour__layout">
<div class="tour__main">
<header class="tour-head">
<h2 class="tour-head__title"><?php the_title(); ?></h2>
<div class="tmeta">
<?php if ( '' !== (string) $rating ) : ?>
<span class="tmeta__item"><?php echo vc_icon( 'star', 'icon--12 tmeta__star' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><b><?php echo esc_html( number_format_i18n( (float) $rating, 1 ) ); ?></b><i>/5</i></span>
<?php endif; ?>
<?php if ( ! empty( $dest_names ) ) : ?>
<span class="tmeta__rule" aria-hidden="true"></span>
<span class="tmeta__item"><?php echo vc_icon( 'location', 'tmeta__icon' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><?php echo esc_html( implode( ', ', array_slice( $dest_names, 0, 2 ) ) ); ?></span>
<?php endif; ?>
<?php if ( $supplier ) : ?>
<span class="tmeta__rule" aria-hidden="true"></span>
<span class="tmeta__item"><?php echo vc_icon( 'person', 'tmeta__icon' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><?php echo esc_html__( 'from', 'viator-collector' ) . ' ' . esc_html( $supplier ); ?></span>
<?php endif; ?>
</div>
</header>
<ul class="facts">
<?php if ( $duration_min ) : ?>
<li class="fact"><?php echo vc_icon( 'duration', 'fact__icon' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><div class="fact__text"><span class="fact__k"><?php echo esc_html__( 'Duration', 'viator-collector' ); ?></span><span class="fact__v"><?php echo esc_html( vc_duration_human( $duration_min ) ); ?></span></div></li>
<?php endif; ?>
<?php if ( $max_trav ) : ?>
<li class="fact"><?php echo vc_icon( 'person', 'fact__icon' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><div class="fact__text"><span class="fact__k"><?php echo esc_html__( 'Max Travelers', 'viator-collector' ); ?></span><span class="fact__v"><?php echo esc_html( sprintf( __( '%d people', 'viator-collector' ), $max_trav ) ); ?></span></div></li>
<?php endif; ?>
<?php if ( $freecancel ) : ?>
<li class="fact"><?php echo vc_icon( 'shield-check', 'fact__icon' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><div class="fact__text"><span class="fact__k"><?php echo esc_html__( 'Free Cancellation', 'viator-collector' ); ?></span><span class="fact__v"><?php echo esc_html__( 'Up to 24 hrs', 'viator-collector' ); ?></span></div></li>
<?php endif; ?>
<?php if ( ! empty( $cat_names ) ) : ?>
<li class="fact"><?php echo vc_icon( 'grid', 'fact__icon' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><div class="fact__text"><span class="fact__k"><?php echo esc_html__( 'Category', 'viator-collector' ); ?></span><span class="fact__v"><?php echo esc_html( implode( ', ', $cat_names ) ); ?></span></div></li>
<?php endif; ?>
</ul>
<?php if ( get_the_content() ) : ?>
<section class="tour__block">
<h3 class="tour__ct"><?php echo esc_html__( 'Overview', 'viator-collector' ); ?></h3>
<div class="tour__text"><?php the_content(); ?></div>
</section>
<?php endif; ?>
<?php if ( $primary ) : ?>
<section class="tour__block">
<h3 class="tour__ct"><?php echo esc_html__( 'Gallery', 'viator-collector' ); ?></h3>
<div class="gallery">
<img class="gallery__hero" src="<?php echo esc_url( $primary ); ?>" alt="<?php echo esc_attr( get_the_title() ); ?>" loading="lazy">
<?php foreach ( $more as $url ) : ?>
<img src="<?php echo esc_url( $url ); ?>" alt="<?php echo esc_attr( get_the_title() ); ?>" loading="lazy">
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
<?php
$inc = array_filter( array_map( 'vc_feature_text', (array) vc_json_decode_meta( get_post_meta( $post_id, '_vc_inclusions', true ), array() ) ) );
$exc = array_filter( array_map( 'vc_feature_text', (array) vc_json_decode_meta( get_post_meta( $post_id, '_vc_exclusions', true ), array() ) ) );
if ( $inc || $exc ) :
?>
<div class="lists">
<?php if ( $inc ) : ?>
<section class="ilist">
<h3 class="tour__ct"><?php echo esc_html__( "What's included", 'viator-collector' ); ?></h3>
<ul class="ilist__items">
<?php foreach ( $inc as $row ) : ?>
<li><span class="tick tick--yes" aria-hidden="true"><?php echo vc_icon( 'check', 'icon--12' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span><p><?php echo esc_html( $row ); ?></p></li>
<?php endforeach; ?>
</ul>
</section>
<?php endif; ?>
<?php if ( $exc ) : ?>
<section class="ilist">
<h3 class="tour__ct"><?php echo esc_html__( "What's Not included", 'viator-collector' ); ?></h3>
<ul class="ilist__items">
<?php foreach ( $exc as $row ) : ?>
<li><span class="tick tick--no" aria-hidden="true"><?php echo vc_icon( 'x', 'icon--12' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span><p><?php echo esc_html( $row ); ?></p></li>
<?php endforeach; ?>
</ul>
</section>
<?php endif; ?>
</div>
<?php endif; ?>
<?php
$steps = vc_itinerary_steps( vc_json_decode_meta( get_post_meta( $post_id, '_vc_itinerary', true ), array() ) );
if ( ! empty( $steps ) ) :
?>
<section class="tour__block">
<h3 class="tour__ct"><?php echo esc_html__( 'Itinerary', 'viator-collector' ); ?></h3>
<ul class="notes">
<?php foreach ( $steps as $step ) : ?>
<li class="note"><?php echo vc_icon( 'location', 'note__icon' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><p><?php
$name = ! empty( $step['name'] ) ? '<strong>' . esc_html( $step['name'] ) . '</strong> ' : '';
echo $name . esc_html( ! empty( $step['description'] ) ? $step['description'] : '' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
?></p></li>
<?php endforeach; ?>
</ul>
</section>
<?php endif; ?>
<?php
$info = (array) vc_json_decode_meta( get_post_meta( $post_id, '_vc_additional_info', true ), array() );
$info_rows = array_filter( array_map( 'vc_feature_text', $info ) );
if ( ! empty( $info_rows ) ) :
?>
<section class="tour__block">
<h3 class="tour__ct"><?php echo esc_html__( 'Good to know', 'viator-collector' ); ?></h3>
<ul class="notes">
<?php foreach ( $info_rows as $row ) : ?>
<li class="note"><?php echo vc_icon( 'info', 'note__icon' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><p><?php echo esc_html( $row ); ?></p></li>
<?php endforeach; ?>
</ul>
</section>
<?php endif; ?>
<?php
$policy = vc_json_decode_meta( get_post_meta( $post_id, '_vc_cancellation_policy', true ), array() );
$policy_text = is_scalar( $policy ) ? (string) $policy : (string) vc_array_get( $policy, 'description' );
$tiers = is_array( $policy ) ? (array) vc_array_get( $policy, 'refundEligibility', array() ) : array();
if ( $policy_text || $tiers ) :
?>
<section class="tour__block">
<h3 class="tour__ct"><?php echo esc_html__( 'Cancellation Policy', 'viator-collector' ); ?></h3>
<?php if ( $policy_text ) : ?><p class="tour__text"><?php echo esc_html( $policy_text ); ?></p><?php endif; ?>
<?php if ( $tiers ) : ?>
<div class="policy">
<?php
foreach ( $tiers as $tier ) :
$pct = (int) vc_array_get( $tier, 'percentageRefundable', 0 );
$max = vc_array_get( $tier, 'dayRangeMax', null );
$min = vc_array_get( $tier, 'dayRangeMin', null );
$yes = $pct > 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 );
?>
<div class="statcard statcard--<?php echo $yes ? 'yes' : 'no'; ?>">
<?php echo vc_icon( $yes ? 'check' : 'x', 'icon--12 statcard__icon' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<div class="statcard__text">
<span class="statcard__k"><?php echo esc_html( sprintf( __( '%d%% Refund', 'viator-collector' ), $pct ) ); ?></span>
<span class="statcard__v"><?php echo esc_html( $when ); ?></span>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php endif; ?>
<?php if ( '' !== (string) $rating && $reviews ) : ?>
<section class="tour__block" id="reviews">
<h3 class="tour__ct"><?php echo esc_html__( 'Reviews', 'viator-collector' ); ?></h3>
<div class="tmeta">
<span class="tmeta__item"><?php echo vc_icon( 'star', 'icon--12 tmeta__star' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><b><?php echo esc_html( number_format_i18n( (float) $rating, 1 ) ); ?></b><i>/5</i></span>
<span class="tmeta__rule" aria-hidden="true"></span>
<span class="tmeta__item"><?php echo esc_html( sprintf( _n( '%s review', '%s reviews', $reviews, 'viator-collector' ), number_format_i18n( $reviews ) ) ); ?></span>
</div>
</section>
<?php endif; ?>
</div>
<aside class="tour__aside">
<div class="bookcard">
<h2 class="bookcard__title"><?php echo vc_icon( 'info' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><?php echo esc_html__( 'Tour Details', 'viator-collector' ); ?></h2>
<div class="bookcard__price">
<?php if ( '' !== (string) $price ) : ?>
<p class="price">
<span class="price__k"><?php echo esc_html__( 'From', 'viator-collector' ); ?></span>
<span class="price__v"><?php echo esc_html( vc_money( $price, $currency ) ); ?></span>
<span class="price__unit"><?php echo esc_html( ( $currency ? strtoupper( $currency ) . ' ' : '' ) . __( '/ Per person', 'viator-collector' ) ); ?></span>
</p>
<?php endif; ?>
<a class="btn btn--dark bookcard__cta" href="<?php echo esc_url( $booking_url ? $booking_url : '#' ); ?>"<?php echo $booking_url ? ' target="_blank" rel="nofollow noopener"' : ''; ?>><?php echo esc_html__( 'Check Availability', 'viator-collector' ); ?></a>
</div>
<div class="statcard statcard--teal">
<?php echo vc_icon( 'shield-check', 'statcard__icon' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<div class="statcard__text">
<span class="statcard__k"><?php echo esc_html__( 'Free Cancellation', 'viator-collector' ); ?></span>
<span class="statcard__v"><?php echo esc_html__( 'Up to 24 hrs', 'viator-collector' ); ?></span>
</div>
</div>
<div class="statcard statcard--yes">
<?php echo vc_icon( 'shield-check', 'statcard__icon' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<div class="statcard__text">
<span class="statcard__k"><?php echo esc_html__( 'Reserve now, pay later!', 'viator-collector' ); ?></span>
<span class="statcard__v"><?php echo esc_html__( 'Secure your spot today', 'viator-collector' ); ?></span>
</div>
</div>
</div>
</aside>
</div>
</section>
</div><!-- #vc-app -->
<?php
endwhile;
get_footer();

View File

@@ -0,0 +1,32 @@
<?php
/**
* Shared archive for Viator destination / tag / category terms.
* Reuses the same listing UI (filters, sort, pagination) as the main archive.
*/
defined( 'ABSPATH' ) || exit;
get_header();
$vc_term = get_queried_object();
$vc_name = ( $vc_term && ! is_wp_error( $vc_term ) ) ? $vc_term->name : __( 'Tours', 'viator-collector' );
$vc_desc = term_description();
?>
<main class="vc-app vc-listing vc-listing--term">
<?php
echo vc_get_template( // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
'listing.php',
array(
'vc_title' => $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' ),
),
),
)
);
?>
</main>
<?php
get_footer();

View File

@@ -0,0 +1,29 @@
<?php
defined('ABSPATH') || exit;
/**
* Plugin Name: Viator Collector
* Description: Import Viator tours using the Viator Partner API and display them with native WordPress post types, templates, and shortcodes.
* Version: 1.8.0
* Author: Amir Bagheri
* License: GPL-2.0-or-later
* Text Domain: viator-collector
*/
define('VC_VERSION', '1.8.0');
define('VC_PLUGIN_FILE', __FILE__);
define('VC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('VC_PLUGIN_URL', plugin_dir_url(__FILE__));
define('VC_PLUGIN_BASENAME', plugin_basename(__FILE__));
require_once VC_PLUGIN_DIR . 'includes/class-vc-plugin.php';
register_activation_hook(__FILE__, array('VC_Plugin', 'activate'));
register_deactivation_hook(__FILE__, array('VC_Plugin', 'deactivate'));
add_action(
'plugins_loaded',
static function () {
VC_Plugin::instance()->init();
}
);