Compare commits
30 Commits
e4d2afc2fa
...
feature/ne
| Author | SHA1 | Date | |
|---|---|---|---|
| 25debff066 | |||
| 2ded000a27 | |||
| 466ea0ac5f | |||
| 023b998694 | |||
| d89f8c2a0b | |||
| a54ddee853 | |||
| de9814cf3d | |||
| 2f1ecd99b4 | |||
| fd6dba3203 | |||
| 98e706477c | |||
| d0c48e0e8e | |||
| f58ed49d07 | |||
| 930b7e0534 | |||
| f5c4c89f1a | |||
| c73189f2db | |||
| f6153376f9 | |||
| 8b70ae9104 | |||
| 9c707b7075 | |||
| 02b6ce0ff4 | |||
| 614ea98825 | |||
| cfa23b6384 | |||
|
|
eeb759f4bf | ||
|
|
3f25d5e828 | ||
| 6a7f43311b | |||
| d5686ed14b | |||
| b0e302be15 | |||
| 01f414e10b | |||
| 911d2b4d2e | |||
| 35368fb5f9 | |||
| 67c88c795b |
5
.gitignore
vendored
@@ -1 +1,4 @@
|
||||
.playwright-mcp
|
||||
.playwright-mcp_home.png
|
||||
_tours.png
|
||||
*.tmp.png
|
||||
ux-review
|
||||
@@ -50,6 +50,28 @@ styles the page content (listing / single / homepage sections).
|
||||
add_filter( 'hoho_support_url', fn() => 'https://example.com/contact' );
|
||||
```
|
||||
|
||||
## Design pages (About, Contact, FAQ, Blog)
|
||||
|
||||
`inc/pages.php` gives these pages the "new front" designs automatically, by
|
||||
address — no page template to pick:
|
||||
|
||||
| Design | Used on | Template |
|
||||
|---|---|---|
|
||||
| About | page `about-us` (or `about`) | `templates/about.php` |
|
||||
| Contact | page `contact-us` (or `contact`) | `templates/contact.php` |
|
||||
| FAQ | page `faq` (or `faqs`) | `templates/faq.php` |
|
||||
| Blog list | the Posts page, or page `blogs` / `blog` | `templates/blog.php` |
|
||||
| Blog post | every single post | `templates/post.php` |
|
||||
|
||||
- About/FAQ/Contact text is fixed in the templates (edit the files to change it).
|
||||
- The contact form emails **Settings → Viator Collector → Contact email**
|
||||
(falls back to the site admin email), with the visitor as Reply-To.
|
||||
- Blog list search/sort/paging use `?q=`, `?sort=` and `?pg=`; post "Review's"
|
||||
are WordPress comments.
|
||||
- Styles: `assets/pages.css` is generated — rebuild after changing the design CSS:
|
||||
`VC_SCOPE='.hoho-page.hoho-page' node viator-collector/build/scope-css.js "new front/css/style.css" astra-child-hoponhopoff/assets/pages.css`.
|
||||
WordPress-specific fixes live in `assets/pages-wp.css`.
|
||||
|
||||
## Header behavior
|
||||
|
||||
The header is **transparent and overlays a full-bleed hero** (homepage, and the
|
||||
|
||||
@@ -421,6 +421,7 @@
|
||||
}
|
||||
|
||||
.hoho .ring-badge--top .ring-badge__text {
|
||||
fill: #808080;
|
||||
font-size: 0.625rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
@@ -546,15 +547,26 @@
|
||||
box-shadow: 0 1.25rem 2.5rem rgba(0, 0, 0, 0.18);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-0.375rem);
|
||||
transition: opacity 0.2s var(--ease), transform 0.2s var(--ease), visibility 0.2s;
|
||||
transform: translateY(-0.4rem) scale(0.97);
|
||||
transform-origin: top center;
|
||||
/* Exit: fade/slide out, then flip visibility only once the fade is done, so
|
||||
the close animates instead of snapping. */
|
||||
transition:
|
||||
opacity 0.16s var(--ease),
|
||||
transform 0.16s var(--ease),
|
||||
visibility 0s linear 0.16s;
|
||||
}
|
||||
|
||||
.hoho .nav__item--has-menu:hover .nav__submenu,
|
||||
.hoho .nav__toggle[aria-expanded="true"]+.nav__submenu {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
transform: translateY(0) scale(1);
|
||||
/* Enter: a slightly longer, soft ease-out so it settles in gently. */
|
||||
transition:
|
||||
opacity 0.24s var(--ease),
|
||||
transform 0.28s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
visibility 0s;
|
||||
}
|
||||
|
||||
.hoho .nav__submenu a {
|
||||
@@ -1868,14 +1880,24 @@
|
||||
box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.22);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-0.5rem);
|
||||
transition: opacity 0.25s var(--ease), transform 0.25s var(--ease), visibility 0.25s;
|
||||
transform: translateY(-0.6rem) scale(0.98);
|
||||
transform-origin: top right;
|
||||
/* Exit: fade/slide up, then hide once the fade completes. */
|
||||
transition:
|
||||
opacity 0.18s var(--ease),
|
||||
transform 0.18s var(--ease),
|
||||
visibility 0s linear 0.18s;
|
||||
}
|
||||
|
||||
.hoho .nav.is-open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
transform: translateY(0) scale(1);
|
||||
/* Enter: soft ease-out settle. */
|
||||
transition:
|
||||
opacity 0.26s var(--ease),
|
||||
transform 0.3s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
visibility 0s;
|
||||
}
|
||||
|
||||
.hoho .nav__list {
|
||||
|
||||
@@ -63,6 +63,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- header search: expand, then submit ---------- */
|
||||
var search = document.querySelector(".header__search");
|
||||
if (search) {
|
||||
var searchInput = search.querySelector(".header__search-input");
|
||||
var searchBtn = search.querySelector(".header__search-btn");
|
||||
if (searchInput && searchBtn) {
|
||||
searchBtn.addEventListener("click", function (e) {
|
||||
if (!search.classList.contains("is-open")) {
|
||||
e.preventDefault();
|
||||
search.classList.add("is-open");
|
||||
searchInput.focus();
|
||||
} else if (!searchInput.value.trim()) {
|
||||
e.preventDefault();
|
||||
search.classList.remove("is-open"); /* empty → collapse instead of submit */
|
||||
}
|
||||
/* open + has text → let the form submit to the tours archive (?vc_q=) */
|
||||
});
|
||||
document.addEventListener("click", function (e) {
|
||||
if (!search.contains(e.target) && !searchInput.value.trim()) {
|
||||
search.classList.remove("is-open");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- footer: back to top ---------- */
|
||||
var toTop = document.querySelector(".ring-badge--top");
|
||||
if (toTop) {
|
||||
|
||||
BIN
astra-child-hoponhopoff/assets/img/about-hero.webp
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
astra-child-hoponhopoff/assets/img/blog-hero.webp
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
astra-child-hoponhopoff/assets/img/blogs-hero.webp
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
astra-child-hoponhopoff/assets/img/city-san-diego.webp
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
astra-child-hoponhopoff/assets/img/contact-hero.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
astra-child-hoponhopoff/assets/img/ellipse-17-2.webp
Normal file
|
After Width: | Height: | Size: 148 B |
BIN
astra-child-hoponhopoff/assets/img/ellipse-17.webp
Normal file
|
After Width: | Height: | Size: 148 B |
BIN
astra-child-hoponhopoff/assets/img/faq-hero.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
astra-child-hoponhopoff/assets/img/tour-london.webp
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
astra-child-hoponhopoff/assets/img/tour-paris.webp
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
astra-child-hoponhopoff/assets/img/tour-san-diego.webp
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
astra-child-hoponhopoff/assets/img/tour-sydney.webp
Normal file
|
After Width: | Height: | Size: 89 KiB |
96
astra-child-hoponhopoff/assets/pages-wp.css
Normal file
@@ -0,0 +1,96 @@
|
||||
/* Astra overrides for the theme's design pages — ported from VC_Display::astra_overrides() (scope .hoho-page, body class .hoho-fullwidth). */
|
||||
.hoho-fullwidth .site-main>article,.hoho-fullwidth .ast-page-builder-template .site-main>article,.hoho-fullwidth.ast-page-builder-template .site-main>article{padding:0!important;margin:0!important;border:0!important;max-width:none!important;}
|
||||
.hoho-fullwidth #primary,.hoho-fullwidth #content{margin:0!important;padding:0!important;}
|
||||
.hoho-fullwidth .site-content .ast-container,.hoho-fullwidth .ast-container{max-width:none!important;padding-left:0!important;padding-right:0!important;display:block!important;}
|
||||
.hoho-fullwidth .entry-content{margin-block:0!important;}
|
||||
.hoho-fullwidth .site-content{padding:0!important;}
|
||||
body.hoho-fullwidth{overflow-x:hidden;}
|
||||
.hoho-page.hoho-page .hero__media,.hoho-page.hoho-page .hero-tour{background:linear-gradient(120deg,#101317,#2cafc2);}
|
||||
.hoho-page.hoho-page a{text-decoration:none;box-shadow:none;}
|
||||
.hoho-page.hoho-page :is(ul,ol){list-style:none!important;margin:0;padding:0;}
|
||||
.hoho-page.hoho-page li{margin:0;}
|
||||
.hoho-page.hoho-page .tour__text a{text-decoration:underline;}
|
||||
.hoho-page.hoho-page .tour__text ul{list-style:disc!important;margin:1em 0!important;padding-inline-start:1.5em!important;}
|
||||
.hoho-page.hoho-page .tour__text ol{list-style:decimal!important;margin:1em 0!important;padding-inline-start:1.5em!important;}
|
||||
.hoho-page.hoho-page .tour__text li{margin:.25em 0!important;}
|
||||
.hoho-page.hoho-page a:not(.btn):not(.ring-badge){color:inherit;}
|
||||
.hoho-page.hoho-page .tour__text a{color:var(--c-teal);}
|
||||
.hoho-page.hoho-page .dcard__title{display:-webkit-box;-webkit-line-clamp:2;line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
|
||||
.hoho-page.hoho-page .titem__desc{display:-webkit-box;-webkit-line-clamp:2;line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
|
||||
.hoho-page.hoho-page .dcard__desc p{display:-webkit-box;-webkit-line-clamp:3;line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;}
|
||||
.hoho-page.hoho-page .dcard__meta .meta__v{white-space:nowrap;}
|
||||
.hoho-page.hoho-page>*{max-width:none!important;margin-inline:0!important;}
|
||||
.hoho-page.hoho-page>.hero,.hoho-page.hoho-page>.hero-tour{margin:var(--page-inset)!important;}
|
||||
.hoho-page.hoho-page{overflow-x:clip;overflow-y:visible;}
|
||||
.hoho-fullwidth .site-main,.hoho-fullwidth #primary,.hoho-fullwidth #content,.hoho-fullwidth .site-content,.hoho-fullwidth .ast-container,.hoho-fullwidth .entry-content,.hoho-fullwidth .site-main>article{overflow:visible!important;}
|
||||
.hoho-page.hoho-page .acc__q{box-shadow:none!important;}
|
||||
.hoho-page.hoho-page :is(h1,h2,h3,h4,h5,h6){color:inherit;}
|
||||
.hoho-page.hoho-page,.hoho-page.hoho-page h1,.hoho-page.hoho-page h2,.hoho-page.hoho-page h3,.hoho-page.hoho-page h4,.hoho-page.hoho-page h5,.hoho-page.hoho-page h6,.hoho-page.hoho-page p,.hoho-page.hoho-page a,.hoho-page.hoho-page span,.hoho-page.hoho-page li,.hoho-page.hoho-page button,.hoho-page.hoho-page input,.hoho-page.hoho-page select,.hoho-page.hoho-page textarea,.hoho-page.hoho-page blockquote{font-family:"Plus Jakarta Sans",system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;}
|
||||
|
||||
/* WordPress content inside the design. */
|
||||
|
||||
/* Spam-trap field on the contact form: off-screen, never seen by people. */
|
||||
.hoho-page.hoho-page .hoho-hp{position:absolute!important;left:-9999px!important;width:1px;height:1px;overflow:hidden;}
|
||||
|
||||
/* Post body from the editor, styled like the design's .tour__block text. */
|
||||
.hoho-page.hoho-page .hoho-prose{display:flex;flex-direction:column;gap:var(--sp-3);}
|
||||
.hoho-page.hoho-page .hoho-prose>*{margin:0;}
|
||||
.hoho-page.hoho-page .hoho-prose :is(p,li){font-size:1rem;font-weight:500;line-height:1.6;opacity:.7;}
|
||||
.hoho-page.hoho-page .hoho-prose a{color:var(--c-teal);text-decoration:underline;}
|
||||
.hoho-page.hoho-page .hoho-prose ul{list-style:disc!important;padding-inline-start:1.5em;}
|
||||
.hoho-page.hoho-page .hoho-prose ol{list-style:decimal!important;padding-inline-start:1.5em;}
|
||||
.hoho-page.hoho-page .hoho-prose figure{margin:0;}
|
||||
.hoho-page.hoho-page .hoho-prose blockquote{margin:0;padding:var(--sp-3) var(--sp-4);border-radius:var(--r-card);background:var(--c-teal-05);}
|
||||
|
||||
/* Review replies need their indent back after the list reset. */
|
||||
.hoho-page.hoho-page .rcard__text p{margin:0;}
|
||||
|
||||
/* Server-rendered pager uses links where the mockup used buttons. */
|
||||
.hoho-page.hoho-page a.pager__btn,.hoho-page.hoho-page a.pager__num{text-decoration:none;}
|
||||
|
||||
/* Contact: centre the two cards (the design pushed them right) and let them
|
||||
overlap the hero a little less. */
|
||||
.hoho-page.hoho-page .contactpage{margin-block-start:calc(var(--sp-6) * -2);}
|
||||
.hoho-page.hoho-page .contactpage__grid{margin-inline:auto;}
|
||||
@media (max-width: 68.75rem){
|
||||
.hoho-page.hoho-page .contactpage{margin-block-start:calc(var(--sp-6) * -1.5);}
|
||||
}
|
||||
|
||||
/* Form fields stay flat as in the design (Astra adds a shadow/border). */
|
||||
.hoho-page.hoho-page .rinput{box-shadow:none!important;border:0!important;}
|
||||
|
||||
/* A page's own Featured Image banner is shown as-is: no brand-teal tint. */
|
||||
.hoho-page.hoho-page .hero--has-thumb .hero__media::after{display:none;}
|
||||
|
||||
/* Article headings by level (imported posts use h2–h6). */
|
||||
.hoho-page.hoho-page .hoho-prose h2{margin:var(--sp-4) 0 0;font-size:clamp(1.25rem,1.1rem + .6vw,1.625rem);font-weight:700;line-height:1.25;}
|
||||
.hoho-page.hoho-page .hoho-prose h3{margin:var(--sp-3) 0 0;font-size:1.25rem;font-weight:700;line-height:1.3;}
|
||||
.hoho-page.hoho-page .hoho-prose :is(h4,h5,h6){margin:var(--sp-3) 0 0;font-size:1.0625rem;font-weight:700;line-height:1.35;}
|
||||
|
||||
/* Photos keep their own shape (portrait ones were cropped to a strip). */
|
||||
.hoho-page.hoho-page .hoho-prose img{display:block;width:auto;max-width:100%;height:auto;max-height:36rem;margin-block:var(--sp-2);border-radius:var(--r-card);object-fit:contain;}
|
||||
|
||||
/* Unsized inline icons from imported content stay icon-sized. */
|
||||
.hoho-page.hoho-page .hoho-prose svg{width:1em;height:1em;vertical-align:-.125em;}
|
||||
|
||||
/* Embedded maps/videos: full width, sensible height. */
|
||||
.hoho-page.hoho-page .hoho-prose iframe{display:block;width:100%;height:auto;aspect-ratio:16/9;border:0;border-radius:var(--r-card);}
|
||||
|
||||
/* Breadcrumb — same rules as the plugin pages (VC_Display::astra_overrides):
|
||||
first thing above the page title, in the title's colour; links softer,
|
||||
current page bold and shortened if long. */
|
||||
.hoho-page.hoho-page .crumbs{color:inherit;}
|
||||
.hoho-page.hoho-page .crumbs__list{color:inherit;font-size:.8125rem;font-weight:500;}
|
||||
.hoho-page.hoho-page .crumbs__list a{opacity:.75;}
|
||||
.hoho-page.hoho-page .crumbs__list a:hover{opacity:1;}
|
||||
.hoho-page.hoho-page .crumbs__list [aria-current]{max-width:min(40ch,60vw);overflow:hidden;font-weight:700;text-overflow:ellipsis;white-space:nowrap;}
|
||||
/* the design paints .hero--page breadcrumbs black; follow the title instead */
|
||||
.hoho-page.hoho-page .hero .crumbs,.hoho-page.hoho-page .hero .crumbs__list{color:var(--c-white);}
|
||||
.hoho-page.hoho-page .hero--about .crumbs,.hoho-page.hoho-page .hero--about .crumbs__list{color:var(--c-black);}
|
||||
.hoho-page.hoho-page .hero__content>.crumbs{margin-block-end:calc(var(--sp-1) * -1);}
|
||||
|
||||
/* Blog post: title, breadcrumb and meta sit in the banner like every page. */
|
||||
.hoho-page.hoho-page .hero--post .hero__title{max-width:none;font-size:clamp(1.75rem,1.2rem + 2vw,3rem);line-height:1.15;}
|
||||
.hoho-page.hoho-page .hero--post .tmeta{order:3;color:var(--c-white);}
|
||||
.hoho-page.hoho-page .hero--post .tmeta__rule{background:rgba(255,255,255,.4);}
|
||||
.hoho-page.hoho-page .hero--post .hero__content{gap:var(--sp-3);}
|
||||
4327
astra-child-hoponhopoff/assets/pages.css
Normal file
177
astra-child-hoponhopoff/assets/pages.js
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Behaviour for the theme's design pages (About, Contact, FAQ, blog list and
|
||||
* post) — the parts of "new front/js/main.js" these pages use. Header/footer
|
||||
* behaviour stays in chrome.js.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var cfg = window.hohoPages || {};
|
||||
var page = document.querySelector('main.hoho-page');
|
||||
if (!page) return;
|
||||
|
||||
var each = function (list, fn) { Array.prototype.forEach.call(list, fn); };
|
||||
|
||||
/* ---------- testimonials marquee: duplicate for a seamless loop ---------- */
|
||||
var marquee = page.querySelector('[data-marquee] .marquee__track');
|
||||
if (marquee) {
|
||||
each(Array.prototype.slice.call(marquee.children), function (node) {
|
||||
var copy = node.cloneNode(true);
|
||||
copy.setAttribute('aria-hidden', 'true');
|
||||
marquee.appendChild(copy);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- FAQ accordion ---------- */
|
||||
each(page.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';
|
||||
each(accordion.querySelectorAll('.acc__q'), function (other) {
|
||||
other.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
btn.setAttribute('aria-expanded', String(!open));
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- FAQ page: category nav follows the scroll ---------- */
|
||||
var catLinks = Array.prototype.slice.call(page.querySelectorAll('.catnav__link'));
|
||||
if (catLinks.length && 'IntersectionObserver' in window) {
|
||||
var setCat = function (id) {
|
||||
catLinks.forEach(function (a) {
|
||||
var on = a.getAttribute('href') === '#' + id;
|
||||
a.classList.toggle('is-active', on);
|
||||
if (on) a.setAttribute('aria-current', 'true');
|
||||
else a.removeAttribute('aria-current');
|
||||
});
|
||||
};
|
||||
var catObserver = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (entry) {
|
||||
if (entry.isIntersecting) setCat(entry.target.id);
|
||||
});
|
||||
}, { rootMargin: '-30% 0px -60% 0px' });
|
||||
catLinks.forEach(function (a) {
|
||||
var target = document.querySelector(a.getAttribute('href'));
|
||||
if (target) catObserver.observe(target);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- blog list: sort applies on change ---------- */
|
||||
each(page.querySelectorAll('select[data-autosubmit]'), function (select) {
|
||||
select.addEventListener('change', function () { select.form.submit(); });
|
||||
});
|
||||
|
||||
/* ---------- single article: share links, copy link, more reviews ---------- */
|
||||
var post = document.getElementById('post');
|
||||
if (post) {
|
||||
var pageUrl = encodeURIComponent(window.location.href.split('#')[0]);
|
||||
var shareTargets = {
|
||||
telegram: 'https://t.me/share/url?url=',
|
||||
facebook: 'https://www.facebook.com/sharer/sharer.php?u=',
|
||||
whatsapp: 'https://wa.me/?text='
|
||||
};
|
||||
each(post.querySelectorAll('[data-share]'), function (a) {
|
||||
var base = shareTargets[a.getAttribute('data-share')];
|
||||
if (base) a.href = base + pageUrl;
|
||||
});
|
||||
|
||||
var copyBtn = post.querySelector('[data-copy-link]');
|
||||
if (copyBtn && navigator.clipboard) {
|
||||
var copyLabel = copyBtn.textContent;
|
||||
copyBtn.addEventListener('click', function () {
|
||||
navigator.clipboard.writeText(window.location.href.split('#')[0]).then(function () {
|
||||
copyBtn.textContent = cfg.copied || 'Link copied';
|
||||
copyBtn.classList.add('is-done');
|
||||
window.setTimeout(function () {
|
||||
copyBtn.textContent = copyLabel;
|
||||
copyBtn.classList.remove('is-done');
|
||||
}, 2000);
|
||||
}, function () {});
|
||||
});
|
||||
}
|
||||
|
||||
var reviewList = document.getElementById('reviewList');
|
||||
var reviewMore = document.getElementById('reviewMore');
|
||||
if (reviewList && reviewMore) {
|
||||
var moreLabel = reviewMore.querySelector('span');
|
||||
reviewMore.addEventListener('click', function () {
|
||||
var open = reviewList.classList.toggle('is-collapsed') === false;
|
||||
reviewMore.setAttribute('aria-expanded', String(open));
|
||||
moreLabel.textContent = open ? cfg.fewer : cfg.more;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- contact form: validate, then send without leaving the page ---------- */
|
||||
var ticketForm = document.getElementById('ticketForm');
|
||||
var ticketStatus = document.getElementById('ticketStatus');
|
||||
if (ticketForm && ticketStatus && window.fetch && window.FormData) {
|
||||
var submitBtn = ticketForm.querySelector('[type="submit"]');
|
||||
ticketForm.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var bad = null;
|
||||
each(ticketForm.querySelectorAll('.rinput'), function (field) {
|
||||
var ok = field.value.trim() !== '' && field.checkValidity();
|
||||
field.setAttribute('aria-invalid', String(!ok));
|
||||
if (!ok && !bad) bad = field;
|
||||
});
|
||||
ticketStatus.classList.toggle('is-error', !!bad);
|
||||
if (bad) {
|
||||
ticketStatus.textContent = cfg.invalid;
|
||||
bad.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
ticketStatus.textContent = cfg.sending;
|
||||
submitBtn.disabled = true;
|
||||
fetch(ticketForm.getAttribute('data-ajax'), { method: 'POST', credentials: 'same-origin', body: new FormData(ticketForm) })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
var ok = res && res.success;
|
||||
ticketStatus.classList.toggle('is-error', !ok);
|
||||
ticketStatus.textContent = (res && res.data && res.data.message) || cfg.failed;
|
||||
if (ok) ticketForm.reset();
|
||||
})
|
||||
.catch(function () {
|
||||
ticketStatus.classList.add('is-error');
|
||||
ticketStatus.textContent = cfg.failed;
|
||||
})
|
||||
.then(function () { submitBtn.disabled = false; });
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- scroll reveal: sections fade/rise in, cards cascade after ---------- */
|
||||
var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (!reduceMotion && 'IntersectionObserver' in window) {
|
||||
var sections = Array.prototype.slice.call(page.querySelectorAll(':scope > section, :scope > article, :scope > div.section'));
|
||||
var cardSelector = '.dcard, .acard, .titem, .rcard, .quote, .statcard';
|
||||
var groupCounts = new Map();
|
||||
|
||||
sections.forEach(function (section) {
|
||||
section.classList.add('reveal');
|
||||
each(section.querySelectorAll(cardSelector), function (card) {
|
||||
var group = card.parentElement;
|
||||
var i = groupCounts.get(group) || 0;
|
||||
groupCounts.set(group, i + 1);
|
||||
card.classList.add('reveal-item');
|
||||
card.style.animationDelay = (Math.min(i, 7) * 0.07) + 's';
|
||||
});
|
||||
});
|
||||
|
||||
var revealObserver = new IntersectionObserver(function (entries, obs) {
|
||||
entries.forEach(function (entry) {
|
||||
if (!entry.isIntersecting) return;
|
||||
entry.target.classList.add('is-visible');
|
||||
each(entry.target.querySelectorAll('.reveal-item'), function (item) {
|
||||
item.classList.add('is-visible');
|
||||
});
|
||||
obs.unobserve(entry.target);
|
||||
});
|
||||
// threshold 0, not a ratio: a section taller than the screen (a long
|
||||
// article) can never be 15% visible and would stay hidden forever.
|
||||
}, { threshold: 0, rootMargin: '0px 0px -8% 0px' });
|
||||
|
||||
sections.forEach(function (section) { revealObserver.observe(section); });
|
||||
}
|
||||
})();
|
||||
29
astra-child-hoponhopoff/assets/sprite-pages.svg
Normal file
@@ -0,0 +1,29 @@
|
||||
<svg class="sprite" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" style="position:absolute;width:0;height:0;overflow:hidden">
|
||||
<symbol id="i-chat" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M8.00033 14.6668C11.6822 14.6668 14.667 11.6821 14.667 8.00016C14.667 4.31826 11.6822 1.3335 8.00033 1.3335C4.31843 1.3335 1.33366 4.31826 1.33366 8.00016C1.33366 9.06662 1.58407 10.0746 2.0293 10.9685C2.14762 11.206 2.187 11.4775 2.11841 11.7339L1.72133 13.2179C1.54896 13.8622 2.13834 14.4515 2.78256 14.2792L4.26658 13.8821C4.52295 13.8135 4.79446 13.8529 5.03201 13.9712C5.9259 14.4164 6.93387 14.6668 8.00033 14.6668Z" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M5.33301 7H10.6663M5.33301 9.3335H8.99967" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</symbol>
|
||||
<symbol id="i-mail" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M1.33301 7.99984C1.33301 5.48568 1.33301 4.2286 2.11406 3.44755C2.8951 2.6665 4.15218 2.6665 6.66634 2.6665H9.33301C11.8472 2.6665 13.1042 2.6665 13.8853 3.44755C14.6663 4.2286 14.6663 5.48568 14.6663 7.99984C14.6663 10.514 14.6663 11.7711 13.8853 12.5521C13.1042 13.3332 11.8472 13.3332 9.33301 13.3332H6.66634C4.15218 13.3332 2.8951 13.3332 2.11406 12.5521C1.33301 11.7711 1.33301 10.514 1.33301 7.99984Z" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M4 5.3335L5.43926 6.53288C6.66369 7.55324 7.2759 8.06341 8 8.06341C8.7241 8.06341 9.33631 7.55324 10.5607 6.53288L12 5.3335" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</symbol>
|
||||
<symbol id="i-phone" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M9.33398 1.33301C9.33398 1.33301 10.8007 1.46634 12.6673 3.33301C14.534 5.19967 14.6673 6.66634 14.6673 6.66634" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
<path d="M9.4707 3.69043C9.4707 3.69043 10.1307 3.87899 11.1206 4.86894C12.1106 5.85889 12.2991 6.51886 12.2991 6.51886" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
<path d="M6.69108 3.54444L7.12375 4.31973C7.51422 5.01938 7.35747 5.93721 6.74249 6.55219C6.74249 6.55219 5.99653 7.29816 7.34903 8.65065C8.7015 10.0031 9.44748 9.25719 9.44748 9.25719C10.0625 8.6422 10.9803 8.48546 11.6799 8.87592L12.4552 9.3086C13.5117 9.89821 13.6365 11.3798 12.7079 12.3085C12.1499 12.8665 11.4663 13.3006 10.7106 13.3293C9.43853 13.3775 7.2782 13.0556 5.11115 10.8885C2.9441 8.72147 2.62216 6.56114 2.67038 5.28905C2.69903 4.53339 3.13322 3.84982 3.69122 3.29181C4.61985 2.36318 6.10146 2.48795 6.69108 3.54444Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</symbol>
|
||||
<symbol id="i-send" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M12.4244 10.4464L13.5687 7.01354C14.5684 4.01462 15.0682 2.51516 14.2767 1.72364C13.4852 0.932131 11.9857 1.43195 8.98679 2.43159L5.5539 3.57589C3.13347 4.3827 1.92326 4.7861 1.57936 5.37766C1.25219 5.94042 1.25219 6.63547 1.57936 7.19823C1.92326 7.78979 3.13347 8.1932 5.55389 9C5.94253 9.12955 6.13685 9.19432 6.29927 9.30306C6.45668 9.40843 6.5919 9.54365 6.69727 9.70106C6.806 9.86348 6.87078 10.0578 7.00032 10.4464C7.80713 12.8668 8.21053 14.0771 8.80209 14.421C9.36485 14.7481 10.0599 14.7481 10.6227 14.421C11.2142 14.0771 11.6176 12.8668 12.4244 10.4464Z" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M10.4571 5.54364L6.75781 9.20215" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</symbol>
|
||||
<symbol id="i-chev-right" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M6 3.33366L10 8.00033L6 12.667" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</symbol>
|
||||
<symbol id="i-thumb" viewBox="0 0 16 16">
|
||||
<path d="M13.9834 8.12362L13.2444 7.99582L12.7741 10.7157L13.5131 10.8435L14.2521 10.9713L14.7225 8.25143L13.9834 8.12362ZM8.82998 14.6668V13.9168H5.7309V14.6668V15.4168H8.82998V14.6668ZM4.62518 13.6515L5.37239 13.5869L4.83095 7.32515L4.08374 7.38976L3.33653 7.45437L3.87797 13.7162L4.62518 13.6515ZM13.5131 10.8435L12.7741 10.7157C12.458 12.5431 10.8059 13.9168 8.82998 13.9168V14.6668V15.4168C11.4958 15.4168 13.805 13.5564 14.2521 10.9713L13.5131 10.8435ZM9.32997 3.48111L8.58984 3.35984L8.14805 6.05622L8.88818 6.17748L9.62832 6.29875L10.0701 3.60238L9.32997 3.48111ZM4.46543 6.45182L4.95502 7.01998L5.91418 6.19345L5.42459 5.62529L4.93499 5.05714L3.97584 5.88367L4.46543 6.45182ZM7.01204 3.18932L7.73801 3.37764L8.05515 2.15507L7.32917 1.96676L6.6032 1.77844L6.28606 3.00101L7.01204 3.18932ZM9.43579 6.82296V7.57296H12.8899V6.82296V6.07296H9.43579V6.82296ZM2.64773 14.314L3.39494 14.2494L2.74721 6.75835L2 6.82296L1.25279 6.88757L1.90052 14.3786L2.64773 14.314ZM2 14.342H2.75V6.82296H2H1.25V14.342H2ZM13.9834 8.12362L14.7225 8.25143C14.9191 7.1146 14.0448 6.07296 12.8899 6.07296V6.82296V7.57296C13.1122 7.57296 13.2828 7.774 13.2444 7.99582L13.9834 8.12362ZM2.32447 14.6668V13.9168C2.56029 13.9168 2.75 14.108 2.75 14.342H2H1.25C1.25 14.9348 1.73025 15.4168 2.32447 15.4168V14.6668Z" fill="currentColor" />
|
||||
</symbol>
|
||||
<symbol id="i-tip" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M10.6 3.3l2.1 2.06M3.08 10.2l2.75 2.75M2 13.5l3-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M7.8 1.36c-.6.13-.9.9-1.8 2.96l-.45 1.26c-.16.42-.2.53-.44.86l-.8.6 6.55 6.55.6-.8c.2-.2.36-.4.53-.86l.6-1.5c1.4-.55 2.1-.83 2.1-1.4 0-.6-.5-1.1-1.5-2.4-.7-.9-1.9-2.1-2.5-2.7-.6-.6-1.3-1-2.3-.6Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" />
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
@@ -1,4 +1,4 @@
|
||||
<svg class="sprite" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg class="sprite" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" style="position:absolute;width:0;height:0;overflow:hidden">
|
||||
<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>
|
||||
|
||||
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
@@ -17,19 +17,55 @@ add_action( 'wp_enqueue_scripts', function () {
|
||||
// Design typeface (same as the plugin's pages).
|
||||
wp_enqueue_style( 'hoho-fonts', 'https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;700;800&display=swap', array(), null );
|
||||
|
||||
// Header/footer design, scoped to .hoho so it can't fight Astra.
|
||||
wp_enqueue_style( 'hoho-chrome', get_stylesheet_directory_uri() . '/assets/chrome.css', array( 'astra-parent' ), '1.0.0' );
|
||||
// Header/footer design, scoped to .hoho so it can't fight Astra. Depend on
|
||||
// the Astra stylesheet (when present) so our rules load AFTER it and win ties.
|
||||
$deps = array( 'astra-parent' );
|
||||
if ( wp_style_is( 'astra-theme-css', 'registered' ) ) {
|
||||
$deps[] = 'astra-theme-css';
|
||||
}
|
||||
wp_enqueue_style( 'hoho-chrome', get_stylesheet_directory_uri() . '/assets/chrome.css', $deps, '1.0.16' );
|
||||
|
||||
// Header interactions: burger, submenu, sticky, back-to-top.
|
||||
wp_enqueue_script( 'hoho-chrome', get_stylesheet_directory_uri() . '/assets/chrome.js', array(), '1.0.0', true );
|
||||
// Header interactions: burger, submenu, sticky, back-to-top, search toggle.
|
||||
wp_enqueue_script( 'hoho-chrome', get_stylesheet_directory_uri() . '/assets/chrome.js', array(), '1.0.16', true );
|
||||
|
||||
// On pages without a full-bleed hero to overlay, the header renders solid and
|
||||
// sticky (added by chrome.js as .is-solid) so it stays readable and doesn't
|
||||
// cover content. Hero pages keep the transparent overlay + is-stuck on scroll.
|
||||
wp_add_inline_style(
|
||||
'hoho-chrome',
|
||||
'.hoho .site-header.is-solid{position:sticky;inset-block-start:0;padding-block:var(--sp-2);'
|
||||
. 'background:rgba(0,0,0,.85);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);}'
|
||||
implode(
|
||||
'',
|
||||
array(
|
||||
// Solid, sticky header on pages without a hero to overlay (added by
|
||||
// chrome.js as .is-solid) so it stays readable and doesn't cover content.
|
||||
'.hoho .site-header.is-solid{position:sticky;inset-block-start:0;padding-block:var(--sp-2);background:rgba(0,0,0,.85);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);}',
|
||||
// Force the design font over Astra inside the header/footer.
|
||||
'.hoho,.hoho h1,.hoho h2,.hoho h3,.hoho p,.hoho a,.hoho span,.hoho li,.hoho button,.hoho input{font-family:"Plus Jakarta Sans",system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;}',
|
||||
// Normalize: stop Astra decorating header/footer links & lists.
|
||||
'.hoho a,.hoho.hoho a{text-decoration:none!important;box-shadow:none!important;}',
|
||||
// …but not ring-badges: they set their own colour on the <a>, and this
|
||||
// inherit rule out-specifies .ring-badge--top, turning the footer
|
||||
// "back to top" text white (inherited) on the light area above it.
|
||||
'.hoho a:not(.btn):not(.ring-badge){color:inherit;}',
|
||||
'.hoho :is(ul,ol){list-style:none!important;margin:0!important;padding:0!important;}',
|
||||
'.hoho li{margin:0!important;}',
|
||||
// Let the footer\'s decorative bits (back-to-top ring, support tab)
|
||||
// spill vertically, but keep the horizontal axis clipped so nothing
|
||||
// causes a mobile sideways scroll. overflow-x:clip (unlike hidden)
|
||||
// does not force overflow-y to auto, so the y-axis stays visible.
|
||||
'.hoho,.hoho .footer{overflow-x:clip;overflow-y:visible;}',
|
||||
// Icon buttons: Astra gives <button> a filled background + its own
|
||||
// colour, which turned the header search icon into an empty dark
|
||||
// circle. Reset them (real .btn buttons keep their styling).
|
||||
'.hoho .icon-btn{background:transparent!important;border:0!important;color:#fff!important;padding:.4rem;}',
|
||||
'.hoho .site-header .icon{color:#fff;}',
|
||||
'.hoho button:not(.btn):not(.burger){background:transparent;border:0;color:inherit;}',
|
||||
// Expanding header search field.
|
||||
'.hoho .header__search{display:flex;align-items:center;gap:.25rem;}',
|
||||
// !important beats Astra's input[type=search] styling, which forces a
|
||||
// white field background (and, with a white placeholder, hid the text).
|
||||
'.hoho .header__search-input{width:0;min-width:0;padding:0;border:0!important;outline:0;opacity:0;height:var(--control-h);border-radius:var(--r-pill)!important;background:rgba(255,255,255,.15)!important;color:#fff!important;box-shadow:none!important;transition:width .25s var(--ease),opacity .2s var(--ease),padding .25s var(--ease);}',
|
||||
'.hoho .header__search.is-open .header__search-input{width:min(52vw,15rem);padding:0 1rem;opacity:1;}',
|
||||
'.hoho .header__search-input::placeholder{color:rgba(255,255,255,.7)!important;opacity:1;}',
|
||||
)
|
||||
)
|
||||
);
|
||||
}, 20 );
|
||||
|
||||
@@ -79,9 +115,11 @@ add_action( 'wp', function () {
|
||||
}
|
||||
add_action( 'astra_header', 'hoho_render_header' );
|
||||
|
||||
if ( function_exists( 'astra_footer_markup' ) ) {
|
||||
remove_action( 'astra_footer', 'astra_footer_markup' );
|
||||
}
|
||||
// Astra renders its footer via the Footer Builder (a class method on the
|
||||
// `astra_footer` hook), so removing the legacy astra_footer_markup() alone
|
||||
// left a second, default footer. Clear every callback on the hook, then add
|
||||
// ours — a clean, full replacement regardless of classic vs builder footer.
|
||||
remove_all_actions( 'astra_footer' );
|
||||
add_action( 'astra_footer', 'hoho_render_footer' );
|
||||
} );
|
||||
|
||||
@@ -93,6 +131,7 @@ function hoho_render_footer() {
|
||||
}
|
||||
|
||||
require get_stylesheet_directory() . '/inc/nav-walker.php';
|
||||
require get_stylesheet_directory() . '/inc/pages.php';
|
||||
|
||||
/**
|
||||
* The logo: a linked WordPress Custom Logo if one is set, otherwise the bundled
|
||||
@@ -120,7 +159,10 @@ function hoho_logo() {
|
||||
* Small helper: inline-icon markup referencing the sprite.
|
||||
*/
|
||||
function hoho_icon( $name, $extra = '' ) {
|
||||
$name = preg_replace( '/[^a-z0-9\-]/', '', 'i-' . ltrim( (string) $name, 'i-' ) );
|
||||
// Strip an existing "i-" prefix (once), then re-add it. ltrim() with a
|
||||
// charlist would eat any leading i/-, turning "instagram" into "nstagram".
|
||||
$slug = preg_replace( '/^i-/', '', (string) $name );
|
||||
$name = preg_replace( '/[^a-z0-9\-]/', '', 'i-' . $slug );
|
||||
$class = 'icon' . ( $extra ? ' ' . $extra : '' );
|
||||
return '<svg class="' . esc_attr( $class ) . '" aria-hidden="true"><use href="#' . esc_attr( $name ) . '"></use></svg>';
|
||||
}
|
||||
|
||||
@@ -10,18 +10,53 @@ $hoho_about = apply_filters(
|
||||
get_bloginfo( 'description' ) ? get_bloginfo( 'description' ) : __( 'Open-top hop on, hop off bus tours — explore the city at your own pace, with flexible tickets and free cancellation.', 'hoponhopoff' )
|
||||
);
|
||||
|
||||
$hoho_socials = apply_filters(
|
||||
'hoho_socials',
|
||||
array(
|
||||
'whatsapp' => '#',
|
||||
'instagram' => '#',
|
||||
'telegram' => '#',
|
||||
'facebook' => '#',
|
||||
)
|
||||
);
|
||||
$hoho_socials = hoho_socials();
|
||||
|
||||
$hoho_footer_col = function ( $location ) {
|
||||
/**
|
||||
* Default links for a footer column that has no menu assigned, so the footer
|
||||
* is never three empty headings. Pages are linked only if they exist.
|
||||
*/
|
||||
$hoho_footer_defaults = function ( $location ) {
|
||||
$page = function ( $slugs, $label ) {
|
||||
foreach ( (array) $slugs as $slug ) {
|
||||
$p = get_page_by_path( $slug );
|
||||
if ( $p && 'publish' === $p->post_status ) {
|
||||
return array( $label, get_permalink( $p ) );
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
$links = array();
|
||||
if ( 'hoho_quick' === $location ) {
|
||||
$links[] = array( __( 'Home', 'hoponhopoff' ), home_url( '/' ) );
|
||||
if ( get_post_type_archive_link( 'viator_tour' ) ) {
|
||||
$links[] = array( __( 'All Tours', 'hoponhopoff' ), get_post_type_archive_link( 'viator_tour' ) );
|
||||
}
|
||||
$links[] = get_option( 'page_for_posts' ) ? array( __( 'Blog', 'hoponhopoff' ), get_permalink( get_option( 'page_for_posts' ) ) ) : $page( array( 'blogs', 'blog' ), __( 'Blog', 'hoponhopoff' ) );
|
||||
} elseif ( 'hoho_tours' === $location && class_exists( 'VC_Structure' ) ) {
|
||||
// The first cities from Settings → Viator Structure.
|
||||
foreach ( array_slice( VC_Structure::cities( VC_Structure::get() ), 0, 6 ) as $city ) {
|
||||
$links[] = array( $city['name'], home_url( '/' . $city['slug'] . '/' ) );
|
||||
}
|
||||
} elseif ( 'hoho_account' === $location ) {
|
||||
$links[] = $page( array( 'contact-us', 'contact' ), __( 'Contact Us', 'hoponhopoff' ) );
|
||||
$links[] = $page( array( 'about-us', 'about' ), __( 'About Us', 'hoponhopoff' ) );
|
||||
$links[] = $page( array( 'faq', 'faqs' ), __( 'FAQ', 'hoponhopoff' ) );
|
||||
}
|
||||
$links = array_filter( $links );
|
||||
if ( ! $links ) {
|
||||
return;
|
||||
}
|
||||
echo '<ul>';
|
||||
foreach ( $links as $link ) {
|
||||
echo '<li><a href="' . esc_url( $link[1] ) . '">' . esc_html( $link[0] ) . '</a></li>';
|
||||
}
|
||||
echo '</ul>';
|
||||
};
|
||||
|
||||
$hoho_footer_col = function ( $location ) use ( $hoho_footer_defaults ) {
|
||||
if ( ! has_nav_menu( $location ) ) {
|
||||
$hoho_footer_defaults( $location );
|
||||
return;
|
||||
}
|
||||
wp_nav_menu(
|
||||
@@ -39,7 +74,10 @@ $hoho_footer_col = function ( $location ) {
|
||||
<footer class="footer" id="footer">
|
||||
<a class="ring-badge ring-badge--top" href="#top" aria-label="<?php echo esc_attr__( 'Back to top', 'hoponhopoff' ); ?>">
|
||||
<svg class="ring-badge__ring" viewBox="0 0 100 100" aria-hidden="true">
|
||||
<text class="ring-badge__text"><textPath href="#ringPathScroll" startOffset="0">BACK TO TOP · BACK TO TOP · </textPath></text>
|
||||
<text class="ring-badge__text" text-anchor="middle">
|
||||
<textPath href="#ringPathScroll" startOffset="25%"><?php echo esc_html__( 'BACK TO TOP', 'hoponhopoff' ); ?></textPath>
|
||||
<textPath href="#ringPathScroll" startOffset="75%"><?php echo esc_html__( 'BACK TO TOP', 'hoponhopoff' ); ?></textPath>
|
||||
</text>
|
||||
</svg>
|
||||
<span class="ring-badge__dot"><svg class="icon" aria-hidden="true"><use href="#i-arrow-up"></use></svg></span>
|
||||
</a>
|
||||
|
||||
@@ -32,12 +32,12 @@ $hoho_archive = $hoho_archive ? $hoho_archive : home_url( '/' );
|
||||
</div>
|
||||
|
||||
<div class="header__actions">
|
||||
<button class="icon-btn" type="button" aria-label="<?php echo esc_attr__( 'Search', 'hoponhopoff' ); ?>">
|
||||
<form class="header__search" role="search" method="get" action="<?php echo esc_url( $hoho_archive ); ?>">
|
||||
<input class="header__search-input" type="search" name="vc_q" placeholder="<?php echo esc_attr__( 'Search tours…', 'hoponhopoff' ); ?>" aria-label="<?php echo esc_attr__( 'Search tours', 'hoponhopoff' ); ?>">
|
||||
<button class="icon-btn header__search-btn" type="submit" aria-label="<?php echo esc_attr__( 'Search', 'hoponhopoff' ); ?>">
|
||||
<?php echo hoho_icon( 'search', 'icon--24' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
</button>
|
||||
<a class="icon-btn" href="<?php echo esc_url( wp_login_url() ); ?>" aria-label="<?php echo esc_attr__( 'Account', 'hoponhopoff' ); ?>">
|
||||
<?php echo hoho_icon( 'user', 'icon--24' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
</a>
|
||||
</form>
|
||||
<a class="btn btn--accent header__cta" href="<?php echo esc_url( $hoho_archive ); ?>">
|
||||
<span><?php echo esc_html__( 'Book now', 'hoponhopoff' ); ?></span>
|
||||
<?php echo hoho_icon( 'arrow-right' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
|
||||
@@ -54,5 +54,12 @@ function hoho_nav_fallback() {
|
||||
echo '<ul class="nav__list">';
|
||||
echo '<li><a class="nav__link" href="' . esc_url( home_url( '/' ) ) . '">' . esc_html__( 'Home', 'hoponhopoff' ) . '</a></li>';
|
||||
echo '<li><a class="nav__link" href="' . esc_url( $archive ) . '">' . esc_html__( 'Tours', 'hoponhopoff' ) . '</a></li>';
|
||||
// Regions + cities from the Viator Collector structure, when active.
|
||||
if ( class_exists( 'VC_Menu' ) ) {
|
||||
$items = VC_Menu::items();
|
||||
if ( $items ) {
|
||||
echo walk_nav_menu_tree( $items, 2, (object) array( 'walker' => new Hoho_Nav_Walker() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
}
|
||||
}
|
||||
echo '</ul>';
|
||||
}
|
||||
|
||||
355
astra-child-hoponhopoff/inc/pages.php
Normal file
@@ -0,0 +1,355 @@
|
||||
<?php
|
||||
/**
|
||||
* Design pages from "new front": About, Contact, FAQ, the blog list and single
|
||||
* posts. Each is routed to a template in /templates by the page's address, so
|
||||
* nobody has to pick a page template in the editor.
|
||||
*/
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Page slugs each design answers to (live site uses the "-us" forms).
|
||||
*/
|
||||
function hoho_page_slugs() {
|
||||
return apply_filters(
|
||||
'hoho_page_slugs',
|
||||
array(
|
||||
'about' => array( 'about-us', 'about' ),
|
||||
'contact' => array( 'contact-us', 'contact' ),
|
||||
'faq' => array( 'faq', 'faqs' ),
|
||||
'blog' => array( 'blogs', 'blog' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which design the current request uses: about|contact|faq|blog|post, or ''.
|
||||
*/
|
||||
function hoho_page_kind() {
|
||||
static $kind = null;
|
||||
if ( null !== $kind ) {
|
||||
return $kind;
|
||||
}
|
||||
// The query is not resolved yet: answer, but don't cache the answer.
|
||||
if ( ! did_action( 'wp' ) ) {
|
||||
return '';
|
||||
}
|
||||
$kind = '';
|
||||
if ( is_singular( 'post' ) ) {
|
||||
$kind = 'post';
|
||||
} elseif ( is_home() && ! is_front_page() ) {
|
||||
// The Posts page — but never a "Your latest posts" homepage.
|
||||
$kind = 'blog';
|
||||
} elseif ( is_page() ) {
|
||||
foreach ( hoho_page_slugs() as $name => $slugs ) {
|
||||
if ( is_page( $slugs ) ) {
|
||||
$kind = $name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $kind;
|
||||
}
|
||||
|
||||
function hoho_asset( $path ) {
|
||||
return get_stylesheet_directory_uri() . '/assets/' . ltrim( $path, '/' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Banner image for a design page: the page's Featured Image when one is set
|
||||
* (for the blog list, the Posts page's), otherwise the design's own photo.
|
||||
*/
|
||||
function hoho_banner_url( $fallback ) {
|
||||
$page_id = ( is_home() && get_option( 'page_for_posts' ) ) ? (int) get_option( 'page_for_posts' ) : get_queried_object_id();
|
||||
$url = $page_id ? get_the_post_thumbnail_url( $page_id, 'full' ) : '';
|
||||
return $url ? $url : hoho_asset( 'img/' . $fallback . '.webp' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadcrumb in the same markup as the plugin's vc_breadcrumb_html(), so
|
||||
* every page shares one style. $trail: list of [label, url]; Home is added.
|
||||
*/
|
||||
function hoho_breadcrumb_html( array $trail, $current ) {
|
||||
$trail = array_merge( array( array( __( 'Home', 'hoponhopoff' ), home_url( '/' ) ) ), $trail );
|
||||
$out = '<nav class="crumbs" aria-label="' . esc_attr__( 'Breadcrumb', 'hoponhopoff' ) . '"><ol class="crumbs__list">';
|
||||
foreach ( $trail as $item ) {
|
||||
$out .= '<li><a href="' . esc_url( $item[1] ) . '">' . esc_html( $item[0] ) . '</a></li>';
|
||||
}
|
||||
return $out . '<li aria-current="page">' . esc_html( $current ) . '</li></ol></nav>';
|
||||
}
|
||||
|
||||
/**
|
||||
* URL of the blog list (the Posts page, else a page slugged blogs/blog).
|
||||
*/
|
||||
function hoho_blog_url() {
|
||||
if ( get_option( 'page_for_posts' ) ) {
|
||||
return get_permalink( get_option( 'page_for_posts' ) );
|
||||
}
|
||||
foreach ( hoho_page_slugs()['blog'] as $slug ) {
|
||||
$page = get_page_by_path( $slug );
|
||||
if ( $page ) {
|
||||
return get_permalink( $page );
|
||||
}
|
||||
}
|
||||
return home_url( '/blogs/' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Social profile links (footer + contact page). Override with the
|
||||
* `hoho_socials` filter.
|
||||
*/
|
||||
function hoho_socials() {
|
||||
return apply_filters(
|
||||
'hoho_socials',
|
||||
array(
|
||||
'whatsapp' => '#',
|
||||
'instagram' => '#',
|
||||
'telegram' => '#',
|
||||
'facebook' => '#',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
add_filter(
|
||||
'template_include',
|
||||
function ( $template ) {
|
||||
$kind = hoho_page_kind();
|
||||
if ( '' === $kind ) {
|
||||
return $template;
|
||||
}
|
||||
$path = get_stylesheet_directory() . '/templates/' . $kind . '.php';
|
||||
return file_exists( $path ) ? $path : $template;
|
||||
},
|
||||
99
|
||||
);
|
||||
|
||||
// Full-width, no-sidebar canvas under Astra (same treatment as the plugin pages).
|
||||
add_filter(
|
||||
'body_class',
|
||||
function ( $classes ) {
|
||||
if ( hoho_page_kind() ) {
|
||||
$classes[] = 'hoho-fullwidth';
|
||||
}
|
||||
return $classes;
|
||||
}
|
||||
);
|
||||
add_filter(
|
||||
'astra_page_layout',
|
||||
function ( $layout ) {
|
||||
return hoho_page_kind() ? 'no-sidebar' : $layout;
|
||||
}
|
||||
);
|
||||
add_filter(
|
||||
'astra_get_content_layout',
|
||||
function ( $layout ) {
|
||||
return hoho_page_kind() ? 'page-builder' : $layout;
|
||||
}
|
||||
);
|
||||
|
||||
add_action(
|
||||
'wp_enqueue_scripts',
|
||||
function () {
|
||||
if ( ! hoho_page_kind() ) {
|
||||
return;
|
||||
}
|
||||
$ver = wp_get_theme()->get( 'Version' );
|
||||
// pages.css is the whole design stylesheet scoped to .hoho-page; the
|
||||
// Astra overrides load after it so they win ties.
|
||||
wp_enqueue_style( 'hoho-pages', hoho_asset( 'pages.css' ), array( 'hoho-chrome' ), $ver );
|
||||
wp_enqueue_style( 'hoho-pages-wp', hoho_asset( 'pages-wp.css' ), array( 'hoho-pages' ), $ver );
|
||||
wp_enqueue_script( 'hoho-pages', hoho_asset( 'pages.js' ), array(), $ver, true );
|
||||
wp_localize_script(
|
||||
'hoho-pages',
|
||||
'hohoPages',
|
||||
array(
|
||||
'sending' => __( 'Sending…', 'hoponhopoff' ),
|
||||
'invalid' => __( 'Please fill in your name, a valid email and your message.', 'hoponhopoff' ),
|
||||
'failed' => hoho_contact_message( 'error' ),
|
||||
'copied' => __( 'Link copied', 'hoponhopoff' ),
|
||||
'more' => __( 'Show all reviews', 'hoponhopoff' ),
|
||||
'fewer' => __( 'Show fewer reviews', 'hoponhopoff' ),
|
||||
)
|
||||
);
|
||||
},
|
||||
30
|
||||
);
|
||||
|
||||
// Icons the design pages use that the site-wide sprite lacks.
|
||||
add_action(
|
||||
'wp_footer',
|
||||
function () {
|
||||
if ( hoho_page_kind() ) {
|
||||
echo file_get_contents( get_stylesheet_directory() . '/assets/sprite-pages.svg' ); // phpcs:ignore
|
||||
}
|
||||
},
|
||||
2
|
||||
);
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Blog
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Blog list state from the query string. `q` (not `s`) so WordPress does not
|
||||
* switch to its search template; `pg` so paging works the same whether the
|
||||
* list is the Posts page or an ordinary page.
|
||||
*/
|
||||
function hoho_blog_state() {
|
||||
$q = isset( $_GET['q'] ) ? sanitize_text_field( wp_unslash( $_GET['q'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification
|
||||
$sort = isset( $_GET['sort'] ) ? sanitize_key( $_GET['sort'] ) : 'newest'; // phpcs:ignore WordPress.Security.NonceVerification
|
||||
$pg = isset( $_GET['pg'] ) ? max( 1, absint( $_GET['pg'] ) ) : 1; // phpcs:ignore WordPress.Security.NonceVerification
|
||||
if ( ! in_array( $sort, array( 'newest', 'oldest', 'title' ), true ) ) {
|
||||
$sort = 'newest';
|
||||
}
|
||||
return compact( 'q', 'sort', 'pg' );
|
||||
}
|
||||
|
||||
function hoho_blog_query( array $state ) {
|
||||
$order = array(
|
||||
'newest' => array( 'date', 'DESC' ),
|
||||
'oldest' => array( 'date', 'ASC' ),
|
||||
'title' => array( 'title', 'ASC' ),
|
||||
);
|
||||
return new WP_Query(
|
||||
array(
|
||||
'post_type' => 'post',
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => 12,
|
||||
'paged' => $state['pg'],
|
||||
's' => $state['q'],
|
||||
'orderby' => $order[ $state['sort'] ][0],
|
||||
'order' => $order[ $state['sort'] ][1],
|
||||
'ignore_sticky_posts' => true,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Imported posts start with a stray "Home" breadcrumb line (left over from the
|
||||
* old page builder). Drop it from the article and from card excerpts.
|
||||
*/
|
||||
function hoho_strip_leading_home( $text ) {
|
||||
return preg_replace( '#^\s*(?:<p>\s*Home\s*</p>|Home\b\s*)#i', '', (string) $text );
|
||||
}
|
||||
add_filter(
|
||||
'the_content',
|
||||
function ( $content ) {
|
||||
return ( is_singular( 'post' ) && in_the_loop() ) ? hoho_strip_leading_home( $content ) : $content;
|
||||
},
|
||||
20
|
||||
);
|
||||
|
||||
/**
|
||||
* Card date chip, as in the design: relative for the last week, then a date.
|
||||
*/
|
||||
function hoho_time_label( $post = null ) {
|
||||
$time = get_post_time( 'U', true, $post );
|
||||
if ( time() - $time < WEEK_IN_SECONDS ) {
|
||||
/* translators: %s: human time difference, e.g. "3 hours". */
|
||||
return sprintf( __( '%s ago', 'hoponhopoff' ), human_time_diff( $time ) );
|
||||
}
|
||||
return get_the_date( 'Y/m/d', $post );
|
||||
}
|
||||
|
||||
/**
|
||||
* Image for a post card/hero: the featured image, else a design photo.
|
||||
*/
|
||||
function hoho_post_image_url( $post = null, $size = 'large' ) {
|
||||
$url = get_the_post_thumbnail_url( $post, $size );
|
||||
if ( $url ) {
|
||||
return $url;
|
||||
}
|
||||
$fallbacks = array( 'tour-london', 'tour-paris', 'tour-sydney', 'tour-san-diego' );
|
||||
return hoho_asset( 'img/' . $fallbacks[ get_post( $post )->ID % count( $fallbacks ) ] . '.webp' );
|
||||
}
|
||||
|
||||
/**
|
||||
* One article card (.acard). $wrap: 'li' (blog grid, wrapped in .titem) or
|
||||
* 'related' (the post page's related row, where the card is the <li>).
|
||||
*/
|
||||
function hoho_article_card( $post, $wrap = 'li' ) {
|
||||
$post = get_post( $post );
|
||||
$heading = 'related' === $wrap ? 'h3' : 'h2';
|
||||
ob_start();
|
||||
?>
|
||||
<div class="acard__media">
|
||||
<img class="acard__img" src="<?php echo esc_url( hoho_post_image_url( $post, 'medium_large' ) ); ?>" alt="" loading="lazy" />
|
||||
<span class="chip chip--glass acard__time"><?php echo esc_html( hoho_time_label( $post ) ); ?></span>
|
||||
<div class="acard__overlay">
|
||||
<<?php echo $heading; // phpcs:ignore ?> class="acard__title"><?php echo esc_html( get_the_title( $post ) ); ?></<?php echo $heading; // phpcs:ignore ?>>
|
||||
<a class="ring-badge ring-badge--card" href="<?php echo esc_url( get_permalink( $post ) ); ?>" aria-label="<?php echo esc_attr__( 'Read the article', 'hoponhopoff' ); ?>">
|
||||
<svg class="ring-badge__ring" viewBox="0 0 100 100" aria-hidden="true">
|
||||
<text class="ring-badge__text"><textPath href="#ringPathScroll" startOffset="0">READ MORE · READ MORE · </textPath></text>
|
||||
</svg>
|
||||
<span class="ring-badge__dot"><?php echo hoho_icon( 'arrow-ne' ); // phpcs:ignore ?></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<p class="acard__excerpt"><?php echo esc_html( wp_trim_words( hoho_strip_leading_home( get_the_excerpt( $post ) ), 24, '…' ) ); ?></p>
|
||||
<?php
|
||||
$inner = ob_get_clean();
|
||||
if ( 'related' === $wrap ) {
|
||||
return '<li class="acard">' . $inner . '</li>';
|
||||
}
|
||||
return '<li class="titem"><article class="acard">' . $inner . '</article></li>';
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Contact form
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
function hoho_contact_message( $status ) {
|
||||
return 'sent' === $status
|
||||
? __( 'Thanks! Your ticket has been sent. We will reply by email.', 'hoponhopoff' )
|
||||
: __( 'Sorry, your message could not be sent. Please check the fields and try again.', 'hoponhopoff' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Recipient: "Contact email" on Settings → Viator Collector, else the site
|
||||
* admin email.
|
||||
*/
|
||||
function hoho_contact_recipient() {
|
||||
$settings = get_option( 'vc_settings', array() );
|
||||
$email = is_array( $settings ) && ! empty( $settings['contact_email'] ) ? $settings['contact_email'] : '';
|
||||
return is_email( $email ) ? $email : get_option( 'admin_email' );
|
||||
}
|
||||
|
||||
function hoho_handle_contact() {
|
||||
$ajax = wp_doing_ajax();
|
||||
$ok = false;
|
||||
|
||||
$valid_nonce = isset( $_POST['hoho_contact_nonce'] ) && wp_verify_nonce( sanitize_key( wp_unslash( $_POST['hoho_contact_nonce'] ) ), 'hoho_contact' );
|
||||
$name = isset( $_POST['name'] ) ? sanitize_text_field( wp_unslash( $_POST['name'] ) ) : '';
|
||||
$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
|
||||
$message = isset( $_POST['message'] ) ? sanitize_textarea_field( wp_unslash( $_POST['message'] ) ) : '';
|
||||
$trap = isset( $_POST['website'] ) ? trim( (string) wp_unslash( $_POST['website'] ) ) : '';
|
||||
|
||||
if ( '' !== $trap ) {
|
||||
// A bot filled the hidden field: pretend it worked, send nothing.
|
||||
$ok = true;
|
||||
} elseif ( $valid_nonce && '' !== $name && is_email( $email ) && '' !== $message ) {
|
||||
$subject = sprintf(
|
||||
/* translators: 1: site name, 2: sender name. */
|
||||
__( '[%1$s] Contact form: %2$s', 'hoponhopoff' ),
|
||||
wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ),
|
||||
$name
|
||||
);
|
||||
$body = sprintf( "Name: %s\nEmail: %s\n\n%s\n", $name, $email, $message );
|
||||
$headers = array( 'Reply-To: ' . str_replace( array( "\r", "\n" ), '', $name ) . ' <' . $email . '>' );
|
||||
$ok = wp_mail( hoho_contact_recipient(), $subject, $body, $headers );
|
||||
}
|
||||
|
||||
if ( $ajax ) {
|
||||
if ( $ok ) {
|
||||
wp_send_json_success( array( 'message' => hoho_contact_message( 'sent' ) ) );
|
||||
}
|
||||
wp_send_json_error( array( 'message' => hoho_contact_message( 'error' ) ) );
|
||||
}
|
||||
|
||||
$back = wp_get_referer() ? wp_get_referer() : home_url( '/contact-us/' );
|
||||
wp_safe_redirect( add_query_arg( 'contact', $ok ? 'sent' : 'error', remove_query_arg( 'contact', $back ) ) . '#ticketForm' );
|
||||
exit;
|
||||
}
|
||||
foreach ( array( 'admin_post_hoho_contact', 'admin_post_nopriv_hoho_contact', 'wp_ajax_hoho_contact', 'wp_ajax_nopriv_hoho_contact' ) as $hoho_hook ) {
|
||||
add_action( $hoho_hook, 'hoho_handle_contact' );
|
||||
}
|
||||
unset( $hoho_hook );
|
||||
BIN
astra-child-hoponhopoff/screenshot.jpg
Normal file
|
After Width: | Height: | Size: 155 KiB |
@@ -3,8 +3,9 @@ Theme Name: Hop On Hop Off (Astra Child)
|
||||
Theme URI: https://git.kaamyab.dev/vargha/TourWebsite
|
||||
Description: Astra child theme that replaces the Astra header and footer with the "Hop On Hop Off" design. Pairs with the Viator Collector plugin.
|
||||
Author: Amir Bagheri
|
||||
Author URI: https://ravisite.com
|
||||
Template: astra
|
||||
Version: 1.0.0
|
||||
Version: 1.0.16
|
||||
Text Domain: hoponhopoff
|
||||
*/
|
||||
|
||||
|
||||
272
astra-child-hoponhopoff/templates/about.php
Normal file
@@ -0,0 +1,272 @@
|
||||
<?php
|
||||
/**
|
||||
* About page — markup from "new front/about.html" (fixed copy).
|
||||
* Routed by hoho_page_template() in inc/pages.php.
|
||||
*/
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
get_header();
|
||||
?>
|
||||
<main id="main" class="hoho-page">
|
||||
<section class="hero hero--page hero--about">
|
||||
<div class="hero__media">
|
||||
<img class="hero__img" src="<?php echo esc_url( hoho_banner_url( 'about-hero' ) ); ?>" alt="" fetchpriority="high" />
|
||||
</div>
|
||||
|
||||
<div class="container hero__content">
|
||||
<div class="about-hero__block">
|
||||
<nav class="crumbs" aria-label="Breadcrumb">
|
||||
<ol class="crumbs__list">
|
||||
<li><a href="<?php echo esc_url( home_url( '/' ) ); ?>">Home</a></li>
|
||||
<li aria-current="page">About us</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="about-hero__title">Know more about</h1>
|
||||
<div class="about-hero__mark" role="img" aria-label="Hop On Hop Off">
|
||||
<svg class="about-hero__logo" viewBox="0 0 91 34" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<g clip-path="url(#clip0_117_3713)">
|
||||
<path
|
||||
d="M70.0559 6.63419C69.9035 7.36616 69.76 8.05456 69.6194 8.74587C69.2766 10.4306 68.9338 12.1153 68.6027 13.8C68.5471 14.0759 68.424 14.195 68.1311 14.1892C67.217 14.1776 66.3029 14.1863 65.3888 14.1863C65.0216 14.1863 64.8752 14.0023 64.9494 13.6344C65.2892 11.9381 65.632 10.2418 65.9719 8.54545C66.5021 5.90221 67.0324 3.26188 67.551 0.618649C67.6037 0.348516 67.7209 0.229425 67.9934 0.252662C68.8371 0.316565 69.678 0.235234 70.5217 0.243948C70.9289 0.249757 71.175 0.365944 71.3625 0.758072C72.4407 3.01499 73.551 5.25738 74.6526 7.50268C74.7053 7.61306 74.7288 7.74667 74.8929 7.83671C74.9749 7.44749 75.0569 7.0786 75.1302 6.70971C75.5286 4.72873 75.9212 2.74776 76.3196 0.763881C76.4192 0.272995 76.4397 0.243948 76.9349 0.255567C77.8343 0.272995 78.7366 0.272995 79.6361 0.264281C80.1195 0.258471 80.1458 0.316565 80.0521 0.769691C79.1527 5.11505 78.2474 9.45751 77.3567 13.8058C77.2923 14.1166 77.1575 14.2037 76.8645 14.2008C75.9856 14.1892 75.1067 14.1863 74.2278 14.2008C73.926 14.2066 73.759 14.0904 73.6331 13.8261C72.5374 11.5663 71.4299 9.30937 70.3254 7.05246C70.261 6.92465 70.2229 6.77361 70.0559 6.6429V6.63419Z"
|
||||
fill="white"></path>
|
||||
<path
|
||||
d="M25.5852 14.4942C24.0706 14.5029 22.6584 14.1834 21.4426 13.2249C20.218 12.2605 19.5764 10.9825 19.4299 9.45462C19.0314 5.2661 22.1252 0.682561 27.2552 0.0783925C28.6204 -0.0842682 29.9739 -0.0232704 31.2659 0.502472C33.2464 1.30706 34.3363 2.79715 34.5853 4.8885C35.0658 8.90564 31.887 13.8435 26.7776 14.4303C26.3792 14.4768 25.9837 14.4855 25.5852 14.4942ZM30.6068 6.2014C30.6068 6.05036 30.6155 5.89932 30.6068 5.74828C30.5247 4.54866 29.7015 3.59012 28.5589 3.35484C27.5452 3.14571 26.5843 3.3258 25.7317 3.87478C23.9358 5.03664 23.2473 6.75619 23.4289 8.81559C23.5461 10.1575 24.425 11.0202 25.7288 11.2148C26.5901 11.3426 27.4192 11.1771 28.1546 10.7152C29.8157 9.66956 30.5247 8.09814 30.6068 6.20431V6.2014Z"
|
||||
fill="white"></path>
|
||||
<path
|
||||
d="M31.9078 23.3679C31.8873 27.3705 29.107 30.9636 25.3159 31.8698C23.4995 32.3055 21.6889 32.3346 19.9428 31.5706C18.1176 30.7719 17.0717 29.3544 16.8314 27.4083C16.348 23.455 18.7855 19.6035 22.5766 18.2383C24.188 17.6573 25.8374 17.4831 27.5161 17.8607C30.3316 18.4939 31.9224 20.5039 31.9078 23.3679ZM27.9321 23.9343C27.9321 23.7716 27.9439 23.609 27.9321 23.4463C27.8208 22.0695 26.9184 21.111 25.6001 20.9745C24.8383 20.896 24.1118 21.0296 23.4145 21.3521C21.3139 22.328 20.3412 25.2675 20.9008 27.0858C21.1908 28.0299 21.8266 28.6398 22.8051 28.8461C23.7895 29.0523 24.7329 28.8838 25.5708 28.3552C27.1909 27.3327 27.8384 25.7671 27.9321 23.9314V23.9343Z"
|
||||
fill="white"></path>
|
||||
<path
|
||||
d="M58.5507 0.0115716C60.0009 -0.00876093 61.2607 0.243944 62.3915 0.964298C63.83 1.88217 64.5859 3.21541 64.75 4.88558C65.122 8.63549 62.7783 12.7049 58.7792 14.0411C56.9569 14.651 55.1024 14.7324 53.2713 14.0556C51.1766 13.28 49.7937 11.3862 49.7557 9.16704C49.6912 5.3358 51.4051 2.53572 54.8592 0.807447C55.9549 0.261372 57.2733 0.00866699 58.5536 0.00866699L58.5507 0.0115716ZM60.8417 6.23044C60.8417 6.09101 60.8505 5.95159 60.8417 5.81217C60.7685 4.66773 60.08 3.72082 59.0985 3.42454C58.2079 3.15441 57.3378 3.22993 56.494 3.60172C54.5282 4.46731 53.4178 6.79103 53.7371 8.93177C53.9305 10.2243 54.7508 11.0144 56.0048 11.2119C56.6903 11.3194 57.37 11.2293 57.9911 10.9244C59.9482 9.96582 60.6952 8.27241 60.8388 6.22753L60.8417 6.23044Z"
|
||||
fill="white"></path>
|
||||
<path
|
||||
d="M52.5947 32.1313C51.2353 32.1488 50.0048 31.9077 48.8915 31.2367C47.4149 30.3479 46.6444 28.9943 46.4364 27.3299C45.9969 23.8647 48.1122 20.0044 51.5898 18.4795C53.8046 17.5093 56.0869 17.2973 58.3721 18.2819C60.1328 19.043 61.1231 20.4285 61.3604 22.2904C61.6885 24.8552 60.9238 27.1266 59.2861 29.125C57.7012 31.0595 55.0293 32.201 52.5976 32.1313H52.5947ZM53.1718 28.9043C54.0126 28.9362 54.8095 28.669 55.4804 28.1229C57.0683 26.8332 57.707 25.1456 57.4463 23.1414C57.2969 21.9883 56.4414 21.1488 55.3076 20.9891C54.3378 20.8526 53.4003 21.0094 52.5859 21.5729C50.9716 22.6912 50.3036 24.2888 50.33 26.1971C50.3534 27.8383 51.4726 28.9014 53.1689 28.9014L53.1718 28.9043Z"
|
||||
fill="white"></path>
|
||||
<path
|
||||
d="M11.6694 5.54499C12.3843 5.54499 13.0991 5.52466 13.814 5.55371C14.189 5.56823 14.3149 5.4259 14.3794 5.07444C14.6607 3.60178 14.9741 2.13783 15.2642 0.668076C15.3169 0.392134 15.4429 0.261425 15.7271 0.261425C16.7788 0.261425 17.8306 0.25852 18.8824 0.255615C19.1431 0.255615 19.2222 0.365992 19.1666 0.630316C18.4488 4.06362 17.7369 7.49692 17.0279 10.9302C16.8286 11.8859 16.6265 12.8386 16.4449 13.7971C16.3921 14.076 16.2749 14.1921 15.982 14.1892C14.9624 14.1776 13.9429 14.1892 12.9233 14.1834C12.5395 14.1834 12.5014 14.1341 12.5806 13.7448C12.8794 12.278 13.1811 10.8111 13.4829 9.34428C13.5767 8.89115 13.5767 8.88534 13.1284 8.88534C11.6518 8.88534 10.1753 8.89115 8.7016 8.87663C8.37934 8.87663 8.25629 8.98701 8.19476 9.30071C7.90472 10.8169 7.58538 12.3274 7.28362 13.8436C7.23382 14.0934 7.11956 14.1951 6.85881 14.1921C5.82755 14.1834 4.79629 14.1834 3.76503 14.1921C3.46327 14.1921 3.38416 14.0934 3.44276 13.7942C3.95546 11.2672 4.45351 8.74011 4.95742 6.21016C5.32364 4.37732 5.69571 2.54448 6.05021 0.708741C6.1088 0.403753 6.22892 0.278852 6.55412 0.284662C7.59417 0.30209 8.63422 0.29628 9.6772 0.290471C9.93209 0.290471 10.0083 0.374706 9.95552 0.624506C9.6479 2.09136 9.35493 3.55821 9.04731 5.02506C8.94184 5.53337 8.94477 5.5508 9.48384 5.55371C10.2104 5.55371 10.937 5.55371 11.6636 5.55371L11.6694 5.54499Z"
|
||||
fill="white"></path>
|
||||
<path
|
||||
d="M8.30004 26.537C7.55003 26.537 6.80002 26.5486 6.05002 26.5311C5.76876 26.5253 5.66036 26.6328 5.60763 26.8971C5.31173 28.425 4.99825 29.9499 4.70528 31.4778C4.65254 31.7479 4.53535 31.8438 4.25996 31.8409C3.24042 31.8292 2.22087 31.8263 1.20426 31.8409C0.879063 31.8467 0.799961 31.7479 0.864414 31.4342C1.50895 28.2246 2.1447 25.012 2.78338 21.8024C3.0119 20.6521 3.24921 19.5048 3.46893 18.3516C3.51874 18.0815 3.64179 17.9856 3.91425 17.9915C4.93379 18.0031 5.95334 18.006 6.96995 17.9915C7.29515 17.9856 7.37132 18.0931 7.30687 18.4039C6.99925 19.8708 6.7092 21.3376 6.4133 22.8074C6.35666 23.0901 6.47287 23.2324 6.76194 23.2344C8.23852 23.2344 9.71509 23.2256 11.1887 23.2431C11.5315 23.246 11.6546 23.1298 11.7161 22.8074C12.0003 21.3492 12.3167 19.8969 12.6009 18.4388C12.6683 18.0989 12.8118 17.9856 13.1604 17.9944C14.1565 18.0176 15.1527 18.0118 16.1458 17.9944C16.4593 17.9886 16.5618 18.067 16.4974 18.3923C15.7152 22.1916 14.9417 25.9909 14.1653 29.7902C14.0481 30.3595 13.928 30.9259 13.8226 31.4981C13.7757 31.745 13.6614 31.8496 13.3978 31.8467C12.3665 31.835 11.3352 31.835 10.3069 31.8467C9.96998 31.8496 9.92018 31.7218 9.98463 31.4226C10.2952 29.9441 10.5852 28.4656 10.8841 26.9843C10.9719 26.5428 10.9778 26.5428 10.512 26.5428C9.77369 26.5428 9.0354 26.5428 8.29711 26.5428L8.30004 26.537Z"
|
||||
fill="white"></path>
|
||||
<path
|
||||
d="M41.409 0.252687C42.3202 0.26721 43.536 0.223641 44.7548 0.287543C45.3817 0.319494 45.9882 0.426966 46.5683 0.659339C48.247 1.33031 48.7509 2.55608 48.7451 4.05198C48.7451 4.96404 48.5107 5.84124 48.1093 6.67197C47.1366 8.67909 45.4755 9.7451 43.3251 10.1547C42.1766 10.3754 41.0135 10.2912 39.8563 10.2854C39.4666 10.2854 39.3319 10.4045 39.2674 10.7733C39.0858 11.7784 38.8543 12.7747 38.6639 13.7768C38.6053 14.0905 38.4647 14.1979 38.1453 14.1921C37.1609 14.1776 36.1766 14.1776 35.1951 14.1921C34.8787 14.195 34.782 14.105 34.8494 13.7826C35.7342 9.4372 36.616 5.09184 37.4774 0.743574C37.5535 0.363064 37.7264 0.243973 38.0838 0.246878C39.0916 0.255592 40.0995 0.249782 41.412 0.249782L41.409 0.252687ZM42.1883 3.47395C41.8251 3.47395 41.4618 3.48557 41.1014 3.47104C40.87 3.46233 40.7674 3.54657 40.7206 3.77313C40.5213 4.78685 40.3133 5.79767 40.0995 6.80559C40.0497 7.03796 40.1082 7.128 40.3514 7.1251C41.0428 7.11639 41.7342 7.14253 42.4227 7.11639C43.7001 7.0641 44.4354 6.47446 44.6991 5.34164C44.9657 4.18849 44.4413 3.51461 43.243 3.47976C42.8915 3.46814 42.5399 3.47976 42.1913 3.47976L42.1883 3.47395Z"
|
||||
fill="white"></path>
|
||||
<path
|
||||
d="M38.2882 27.9951C37.8546 27.9951 37.421 28.0038 36.9874 27.9922C36.7442 27.9864 36.6387 28.0793 36.5918 28.3233C36.3956 29.3486 36.1641 30.3682 35.9737 31.3964C35.9122 31.7392 35.7715 31.8554 35.42 31.8467C34.459 31.8234 33.4981 31.8408 32.54 31.8379C32.0947 31.8379 32.0772 31.8147 32.165 31.379C32.7305 28.5789 33.2988 25.7817 33.8643 22.9816C34.1778 21.4335 34.4971 19.8882 34.7989 18.34C34.8487 18.0844 34.9571 17.9915 35.2178 17.9944C37.4913 18.0031 39.7618 17.9769 42.0353 18.0147C43.1105 18.0321 44.1124 18.3458 44.9269 19.101C45.7062 19.8243 45.9435 20.7625 45.9289 21.7617C45.9025 23.4464 45.299 24.922 44.089 26.1129C42.9845 27.1992 41.6163 27.7598 40.0811 27.9399C39.4864 28.0096 38.8887 27.9922 38.2911 27.9951H38.2882ZM38.7833 24.7825C39.088 24.7825 39.3927 24.7883 39.6974 24.7825C40.9396 24.7593 41.6573 24.1871 41.9415 22.9904C42.2023 21.8982 41.6778 21.204 40.5499 21.1779C39.8233 21.1604 39.0968 21.1779 38.3731 21.1691C38.1475 21.1662 38.0362 21.2359 37.9923 21.4654C37.793 22.4791 37.585 23.49 37.3682 24.5008C37.3243 24.7099 37.3946 24.7825 37.5909 24.7825C37.9893 24.7825 38.3878 24.7825 38.7862 24.7825H38.7833Z"
|
||||
fill="white"></path>
|
||||
<path
|
||||
d="M69.259 17.9973C70.7707 17.9973 72.2825 17.9973 73.7913 17.9973C74.2395 17.9973 74.2483 18.0147 74.1546 18.4562C73.9905 19.2288 73.8206 20.0015 73.677 20.777C73.6213 21.0733 73.4866 21.1779 73.1819 21.175C71.4006 21.1633 69.6193 21.1779 67.841 21.1633C67.507 21.1604 67.3547 21.265 67.299 21.5903C67.1994 22.1625 67.0734 22.729 66.9387 23.2954C66.8713 23.58 66.9562 23.6555 67.2463 23.6526C68.8869 23.641 70.5276 23.6468 72.1653 23.6468C72.637 23.6468 72.6545 23.6701 72.5666 24.1174C72.4172 24.8581 72.2532 25.5958 72.1213 26.3365C72.0569 26.6909 71.9192 26.839 71.5237 26.8332C69.9065 26.81 68.2893 26.8332 66.675 26.8187C66.3498 26.8158 66.2267 26.9291 66.1681 27.2341C65.9015 28.6254 65.6056 30.0109 65.342 31.4023C65.2804 31.7218 65.1632 31.8525 64.8175 31.8467C63.8566 31.8263 62.8956 31.8321 61.9347 31.8409C61.6212 31.8438 61.5275 31.7566 61.5948 31.4313C62.3449 27.7569 63.0802 24.0796 63.8214 20.4023C63.9591 19.7197 64.1056 19.0371 64.2316 18.3516C64.2785 18.096 64.3693 17.9827 64.6623 17.9857C66.1974 18.0031 67.7326 17.9944 69.2648 17.9944L69.259 17.9973Z"
|
||||
fill="white"></path>
|
||||
<path
|
||||
d="M80.8548 17.9943C82.3783 17.9943 83.9017 18.0001 85.4252 17.9885C85.7181 17.9885 85.7972 18.067 85.7357 18.3516C85.5541 19.1562 85.3812 19.9637 85.2318 20.7741C85.1761 21.0762 85.0472 21.1778 84.7425 21.1749C82.9613 21.1633 81.18 21.1807 79.3987 21.1633C79.0442 21.1604 78.9007 21.2708 78.8479 21.6164C78.763 22.177 78.6458 22.7376 78.5081 23.2895C78.4319 23.5916 78.5345 23.6497 78.8128 23.6497C80.4534 23.641 82.0941 23.6468 83.7347 23.6497C84.2064 23.6497 84.2122 23.6642 84.1156 24.1261C83.9574 24.8871 83.7933 25.6481 83.6556 26.4149C83.5999 26.7199 83.4769 26.839 83.1488 26.8361C81.5198 26.8216 79.8909 26.8361 78.262 26.8245C77.9339 26.8245 77.7874 26.9058 77.7229 27.2457C77.4622 28.6254 77.1575 29.9993 76.8938 31.379C76.8264 31.7333 76.677 31.8583 76.3137 31.8495C75.3645 31.8263 74.4153 31.835 73.466 31.8437C73.1731 31.8437 73.0735 31.7711 73.1379 31.4545C73.9524 27.4722 74.7522 23.487 75.5579 19.5019C75.634 19.1271 75.7366 18.7554 75.7835 18.3748C75.8245 18.0495 75.9944 17.9943 76.2815 17.9972C77.805 18.006 79.3284 18.003 80.8519 18.003V17.9972L80.8548 17.9943Z"
|
||||
fill="white"></path>
|
||||
<path
|
||||
d="M69.751 31.7769C70.4014 31.8728 70.999 31.9744 71.5967 32.0412C75.7891 32.5176 79.8409 31.9889 83.7287 30.3391C84.8713 29.854 85.9611 29.2644 87.0158 28.6108C87.2824 28.4453 87.3996 28.2797 87.3088 27.9602C87.2209 27.6494 87.1828 27.3241 87.1242 27.0046C87.1037 26.8826 87.01 26.7286 87.1242 26.6444C87.2385 26.5601 87.3644 26.6879 87.4699 26.7548C88.4397 27.3444 89.4065 27.9311 90.3703 28.5295C91.1643 29.0233 91.1614 29.0262 90.5989 29.7843C89.9045 30.7196 89.216 31.6607 88.5217 32.5931C88.4572 32.6802 88.4162 32.8371 88.2756 32.7993C88.1555 32.7674 88.1936 32.6222 88.1701 32.5263C88.0969 32.1865 88.0266 31.8437 87.965 31.501C87.9211 31.2541 87.8127 31.2076 87.5783 31.3034C85.4953 32.1458 83.3332 32.6919 81.1066 32.991C77.3595 33.4994 73.6797 33.2496 70.0938 31.9977C69.9883 31.9599 69.8652 31.9483 69.751 31.7798V31.7769Z"
|
||||
fill="white"></path>
|
||||
<path
|
||||
d="M65.8896 15.0316C66.2324 15.0083 66.54 15.1274 66.8535 15.171C72.5343 15.9756 77.8634 15.081 82.7443 11.973C83.0432 11.7813 83.1252 11.5925 83.0314 11.2642C82.9435 10.9534 82.8996 10.6281 82.8439 10.3086C82.8293 10.2244 82.7648 10.114 82.8644 10.053C82.9582 9.99781 83.0432 10.085 83.1193 10.1314C84.1096 10.7327 85.094 11.3369 86.0813 11.9381C86.2893 12.0659 86.4973 12.1995 86.7082 12.3244C86.8811 12.4261 86.8899 12.5423 86.7698 12.6933C85.9026 13.8058 85.0412 14.9212 84.174 16.0337C84.1272 16.0947 84.0861 16.1905 83.9865 16.1586C83.8898 16.1295 83.9016 16.0279 83.8869 15.9524C83.8107 15.5893 83.7346 15.2262 83.6672 14.8602C83.6262 14.6365 83.5676 14.5378 83.301 14.6453C81.3029 15.444 79.2316 15.9698 77.0988 16.2544C75.1065 16.5188 73.1114 16.5449 71.1104 16.3271C69.4375 16.1441 67.8057 15.8013 66.2207 15.2407C66.1064 15.2 65.9629 15.203 65.8896 15.0345V15.0316Z"
|
||||
fill="white"></path>
|
||||
<path
|
||||
d="M67.8024 37.6762C68.1276 37.1302 68.4997 36.6073 68.7663 36.0351C69.0592 35.4048 69.4577 35.0766 70.1725 35.2218C70.2165 35.2305 70.2663 35.2218 70.3132 35.2218C70.7175 35.2276 70.7468 35.2567 70.6764 35.643C70.4743 36.7264 70.2663 37.8098 70.0612 38.8933C70.0143 39.1315 69.9674 39.3726 69.9294 39.6136C69.8942 39.8315 69.7829 39.9215 69.5514 39.9302C68.7047 39.9593 68.7047 39.9651 68.8659 39.1344C68.986 38.5099 69.1061 37.8854 69.1559 37.2289C68.9567 37.5717 68.7604 37.9173 68.5612 38.2601C68.3122 38.6929 68.0573 39.1257 67.817 39.5643C67.7057 39.7647 67.5768 39.8954 67.3219 39.8634C67.0612 39.8286 67.067 39.6224 67.0319 39.4481C66.8766 38.6987 66.7242 37.9464 66.5133 37.1883C66.3961 37.8128 66.2789 38.4373 66.1617 39.0647C66.15 39.1228 66.1442 39.1808 66.1354 39.236C66.0035 39.9361 66.0035 39.9215 65.2682 39.9273C64.9371 39.9302 64.8668 39.8199 64.9283 39.5149C65.1568 38.3879 65.3678 37.258 65.5875 36.1281C65.6139 35.9915 65.6402 35.855 65.6695 35.7185C65.7379 35.3835 65.9381 35.216 66.2701 35.216C66.9303 35.216 67.32 35.5336 67.4391 36.1687C67.5329 36.6596 67.6325 37.1476 67.7291 37.6356C67.7555 37.6472 67.7819 37.6559 67.8082 37.6675L67.8024 37.6762Z"
|
||||
fill="white"></path>
|
||||
<path
|
||||
d="M58.6563 37.9842C58.6681 36.3082 59.8868 35.1115 61.548 35.1521C62.5499 35.1754 63.2999 35.7941 63.4406 36.7787C63.6691 38.3763 62.4562 40.1656 60.423 39.9884C59.3126 39.8925 58.6475 39.1199 58.6563 37.9871V37.9842ZM59.8165 37.8099C59.8048 38.2543 59.8985 38.6551 60.3556 38.8498C60.8155 39.0444 61.2609 38.9747 61.6388 38.658C62.1368 38.2398 62.3478 37.685 62.2628 37.0431C62.1749 36.3605 61.6505 36.0264 60.9737 36.2007C60.297 36.375 59.8282 37.0256 59.8136 37.8099H59.8165Z"
|
||||
fill="white"></path>
|
||||
<path
|
||||
d="M34.8758 39.9274C34.4305 39.9274 33.9852 39.9245 33.5399 39.9274C33.3143 39.9303 33.2176 39.8606 33.2645 39.6166C33.5311 38.2369 33.7889 36.8572 34.0438 35.4775C34.0789 35.2945 34.1727 35.219 34.3543 35.2219C35.2684 35.2219 36.1825 35.2219 37.0936 35.2219C37.3133 35.2219 37.3924 35.3119 37.3397 35.524C37.3221 35.5908 37.3192 35.6605 37.3075 35.7302C37.245 36.0633 37.0428 36.2298 36.701 36.2298C36.2674 36.2298 35.8338 36.2269 35.4002 36.2298C35.1043 36.2298 34.8143 36.6684 34.9198 36.9443C34.9666 37.0634 35.0721 37.0315 35.16 37.0315C35.6522 37.0344 36.1444 37.0489 36.6366 37.0315C36.9559 37.0199 37.0292 37.1477 36.9559 37.4265C36.9296 37.5282 36.9061 37.6298 36.8944 37.7344C36.871 37.9639 36.745 38.051 36.5165 38.0452C36.036 38.0365 35.5555 38.0394 35.075 38.0452C34.7528 38.0452 34.4334 38.5506 34.5682 38.8382C34.6209 38.9515 34.7264 38.9166 34.8143 38.9195C35.3416 38.9224 35.869 38.9311 36.3963 38.9195C36.6483 38.9137 36.745 39.0037 36.6835 39.2477C36.6747 39.2826 36.6747 39.3174 36.6688 39.3523C36.6024 39.7415 36.3661 39.9361 35.9598 39.9361C35.5965 39.9361 35.2332 39.9361 34.87 39.9361L34.8758 39.9274Z"
|
||||
fill="white"></path>
|
||||
<path
|
||||
d="M78.8237 38.0422C77.1361 38.0422 75.4516 38.0422 73.764 38.0422C73.2806 38.0422 73.2689 38.0277 73.3539 37.5804C73.4281 37.1776 73.6732 36.9762 74.0892 36.9762C77.4145 36.9762 80.7397 36.9762 84.0649 36.9762C84.5776 36.9762 84.5835 36.9849 84.4809 37.4874C84.4067 37.8553 84.1831 38.0393 83.81 38.0393C82.146 38.0393 80.4848 38.0393 78.8207 38.0393L78.8237 38.0422Z"
|
||||
fill="white"></path>
|
||||
<path
|
||||
d="M29.0368 36.9792C29.4616 36.5377 29.904 36.1078 30.3083 35.6489C30.5954 35.3236 30.9147 35.158 31.3512 35.2248C31.5739 35.2597 31.9167 35.1115 31.9928 35.3061C32.0661 35.4891 31.735 35.6489 31.5768 35.8086C31.1374 36.2589 30.695 36.7062 30.2321 37.1332C30.0299 37.3191 30.0182 37.473 30.1501 37.7025C30.5075 38.3299 30.8356 38.9689 31.1872 39.5992C31.3219 39.8403 31.2721 39.9332 30.9909 39.9245C29.8161 39.8984 30.1266 40.1017 29.6051 39.0967C29.4997 38.8905 29.3854 38.6842 29.3004 38.4693C29.1774 38.1643 29.0485 38.2224 28.8522 38.4112C28.6178 38.6378 28.6178 38.9369 28.5621 39.2129C28.4039 39.9942 28.6002 39.9216 27.6129 39.9245C27.358 39.9245 27.2906 39.8229 27.3405 39.5789C27.5543 38.4867 27.7565 37.3917 27.9616 36.2966C27.985 36.1717 28.0055 36.0439 28.0289 35.919C28.1637 35.2016 28.3248 35.0999 29.069 35.2219C29.3092 35.2626 29.2623 35.4136 29.2389 35.5588C29.1569 36.0207 29.069 36.4825 28.9811 36.9444C29.0016 36.956 29.0192 36.9647 29.0397 36.9763L29.0368 36.9792Z"
|
||||
fill="white"></path>
|
||||
<path
|
||||
d="M45.7943 39.9971C45.267 39.9738 44.7133 39.8664 44.2445 39.4859C44.0424 39.3203 43.9955 39.1605 44.1918 38.9659C44.2093 38.9485 44.2211 38.9311 44.2386 38.9136C44.3881 38.751 44.5082 38.5157 44.6928 38.4431C44.9008 38.3589 45.0092 38.6609 45.1937 38.7539C45.5951 38.9543 46.0023 39.0618 46.4447 38.9107C46.6234 38.8497 46.7582 38.7365 46.767 38.5418C46.7758 38.3443 46.6264 38.2543 46.4652 38.2049C46.143 38.1032 45.809 38.0306 45.4896 37.9202C44.6049 37.6124 44.3383 36.8571 44.8129 36.0526C45.2025 35.3961 46.0609 35.0446 46.9604 35.1753C47.3588 35.2334 47.7309 35.3612 48.0619 35.5965C48.2377 35.7214 48.3051 35.8434 48.1322 36.0177C48.0824 36.0671 48.0443 36.1281 48.0004 36.1804C47.7719 36.4515 47.5024 36.5125 47.1918 36.3634C46.89 36.2181 46.5766 36.1339 46.2396 36.1775C46.017 36.2065 45.8119 36.3024 45.7826 36.5405C45.7504 36.7845 45.9818 36.8281 46.1547 36.8833C46.4096 36.9675 46.6762 37.0227 46.9311 37.1069C47.4115 37.2667 47.8715 37.47 47.9389 38.0481C48.0151 38.7219 47.7924 39.2883 47.1977 39.6659C46.7904 39.9245 46.3305 40 45.7973 40.0029L45.7943 39.9971Z"
|
||||
fill="white"></path>
|
||||
<path
|
||||
d="M5.07682 38.0422C3.51821 38.0422 1.96253 38.0422 0.403916 38.0422C-0.0384717 38.0422 -0.0443311 38.0335 0.0435605 37.6094C0.131452 37.1873 0.398057 36.9762 0.843374 36.9762C3.84341 36.9762 6.84051 36.9762 9.84054 36.9762C10.2175 36.9762 10.3708 37.1553 10.3005 37.5136C10.2302 37.8718 10.0124 38.047 9.64718 38.0393C8.12373 38.0393 6.60027 38.0393 5.07975 38.0393L5.07682 38.0422Z"
|
||||
fill="white"></path>
|
||||
<path
|
||||
d="M52.6826 37.9812C52.6914 36.0613 54.5401 34.6757 56.2422 35.2712C56.6524 35.4135 56.9307 35.6953 57.1387 36.0613C57.25 36.2559 57.209 36.3895 56.9922 36.4679C56.9043 36.4999 56.8135 36.5318 56.7373 36.5812C56.4385 36.7729 56.2159 36.7584 55.9815 36.4447C55.7412 36.1194 55.3574 36.1048 54.9883 36.2152C54.1797 36.4505 53.6436 37.4526 53.8867 38.2543C54.1182 39.0182 55.0821 39.2041 55.7149 38.6145C55.8174 38.5186 55.8789 38.292 56.0518 38.3618C56.3067 38.4663 56.5381 38.6348 56.7696 38.7887C56.837 38.8352 56.7842 38.9165 56.7432 38.9746C56.2627 39.6253 55.6153 39.9767 54.8008 39.9942C54.0274 40.0116 53.374 39.7734 52.9551 39.0763C52.7412 38.7248 52.668 38.3443 52.6855 37.9812H52.6826Z"
|
||||
fill="white"></path>
|
||||
<path
|
||||
d="M21.4606 37.8941C21.4635 36.2966 22.7731 35.0563 24.4636 35.1579C25.1052 35.1957 25.5945 35.4978 25.9197 36.0555C26.0368 36.2559 26.0427 36.4011 25.782 36.4737C25.6823 36.5028 25.5798 36.5435 25.489 36.5928C25.2165 36.7497 25.0232 36.7729 24.7976 36.4592C24.5632 36.131 24.156 36.1019 23.7692 36.2094C22.902 36.4476 22.363 37.5368 22.697 38.3705C22.9665 39.0473 23.9245 39.1751 24.4958 38.6116C24.5954 38.5128 24.6511 38.2979 24.8415 38.3734C25.0993 38.478 25.3513 38.6145 25.5534 38.8091C25.6238 38.8788 25.5359 38.9572 25.4919 39.024C24.8298 40 23.2331 40.3108 22.2546 39.6427C21.6745 39.2477 21.446 38.6726 21.4606 37.8912V37.8941Z"
|
||||
fill="white"></path>
|
||||
<path
|
||||
d="M14.983 35.2219C15.4986 35.2219 16.0113 35.2277 16.527 35.2219C16.776 35.219 16.8756 35.3119 16.8053 35.5588C16.7907 35.614 16.7877 35.6721 16.776 35.7302C16.7115 36.0632 16.5114 36.2298 16.1754 36.2298C15.4137 36.2298 15.4108 36.2298 15.2672 36.9763C15.1119 37.7954 14.9596 38.6174 14.8102 39.4394C14.7223 39.9216 14.7193 39.9245 14.2301 39.9274C13.7887 39.9274 13.61 39.7086 13.6939 39.271C13.8639 38.3821 14.025 37.4933 14.2066 36.6074C14.2652 36.3198 14.2066 36.1949 13.8873 36.2269C13.6676 36.2472 13.442 36.2298 13.2223 36.2269C13.0435 36.2269 12.9644 36.1397 13.0025 35.9597C13.0084 35.9364 13.0113 35.9132 13.0142 35.8899C13.0728 35.4426 13.3297 35.2199 13.7848 35.2219C13.9605 35.2219 14.1363 35.2219 14.3121 35.2219C14.5348 35.2219 14.7574 35.2219 14.9801 35.2219H14.983Z"
|
||||
fill="white"></path>
|
||||
<path
|
||||
d="M41.2414 35.2219C41.7219 35.2219 42.2024 35.2393 42.6799 35.2161C43.0198 35.1987 43.0959 35.3352 43.011 35.6256C43.0051 35.6489 43.0022 35.6721 42.9993 35.6953C42.9309 36.0517 42.7151 36.2298 42.3518 36.2298C41.4524 36.2298 41.6457 36.1659 41.4875 36.9502C41.3117 37.8245 41.1447 38.7017 40.9953 39.5818C40.9514 39.8403 40.8488 39.9361 40.5852 39.9332C39.633 39.9158 39.759 40.0204 39.9201 39.1432C40.0783 38.2892 40.2424 37.4352 40.4211 36.5813C40.4768 36.3082 40.383 36.2124 40.1193 36.2327C40.049 36.2385 39.9787 36.2327 39.9084 36.2327C39.1203 36.2327 39.1994 36.3169 39.3313 35.5065C39.3664 35.2916 39.4895 35.2219 39.6975 35.2248C40.2131 35.2306 40.7258 35.2248 41.2414 35.2248V35.2219Z"
|
||||
fill="white"></path>
|
||||
<path
|
||||
d="M18.5449 39.9216C18.4511 39.9216 18.3574 39.9216 18.2636 39.9216C18.0644 39.9216 17.9911 39.8374 18.0292 39.637C18.29 38.2689 18.5507 36.9008 18.8027 35.5327C18.8466 35.2887 18.9814 35.219 19.2158 35.2161C20.0214 35.2016 20.0097 35.1928 19.8574 35.9626C19.6347 37.1012 19.4296 38.2427 19.2128 39.3814C19.1103 39.9245 19.1074 39.9216 18.5419 39.9187L18.5449 39.9216Z"
|
||||
fill="white"></path>
|
||||
<path
|
||||
d="M50.9005 39.2506C50.9005 39.6514 50.59 39.9797 50.212 39.9826C49.8839 39.9855 49.629 39.7357 49.6349 39.4133C49.6437 39.0095 49.9864 38.6493 50.3526 38.6522C50.6661 38.6522 50.9005 38.9108 50.9005 39.2506Z"
|
||||
fill="white"></path>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_117_3713">
|
||||
<rect width="91" height="40" fill="white"></rect>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<span class="chip chip--accent about-hero__chip">Memorable</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="ring-badge ring-badge--scroll" href="#story" aria-label="Scroll to the story">
|
||||
<svg class="ring-badge__ring" viewBox="0 0 100 100" aria-hidden="true">
|
||||
<text class="ring-badge__text">
|
||||
<textPath href="#ringPathScroll" startOffset="0">SCROLL DOWN · SCROLL DOWN · </textPath>
|
||||
</text>
|
||||
</svg>
|
||||
<span class="ring-badge__dot">
|
||||
<svg class="icon" aria-hidden="true"><use href="#i-mouse" /></svg>
|
||||
</span>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section class="section aboutpage" id="story">
|
||||
<div class="container">
|
||||
<div class="aboutpage__inner">
|
||||
<div class="post__body">
|
||||
<section class="tour__block">
|
||||
<h2 class="post__h2">Hop On Hop Off</h2>
|
||||
<p class="tour__text">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliquaLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliquaLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliquaLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliquaLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliquaLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliquaLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua</p>
|
||||
<p class="tour__text">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliquaLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliquaLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliquaLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua</p>
|
||||
</section>
|
||||
|
||||
<dl class="astats">
|
||||
<div class="astat">
|
||||
<dt class="astat__num">25<span>+</span></dt>
|
||||
<dd class="astat__label">Years Experience</dd>
|
||||
</div>
|
||||
<div class="astat">
|
||||
<dt class="astat__num">500<span>+</span></dt>
|
||||
<dd class="astat__label">Tours Conducted</dd>
|
||||
</div>
|
||||
<div class="astat">
|
||||
<dt class="astat__num">430<span>+</span></dt>
|
||||
<dd class="astat__label">Tours this Year</dd>
|
||||
</div>
|
||||
<div class="astat">
|
||||
<dt class="astat__num">15<span>+</span></dt>
|
||||
<dd class="astat__label">Different Tours</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<section class="tour__block">
|
||||
<h2 class="tour__ct">Gallery</h2>
|
||||
<div class="agallery">
|
||||
<img class="agallery__hero" src="<?php echo esc_url( hoho_asset( 'img/tour-london.webp' ) ); ?>" alt="" loading="lazy" />
|
||||
<img src="<?php echo esc_url( hoho_asset( 'img/tour-paris.webp' ) ); ?>" alt="" loading="lazy" />
|
||||
<img src="<?php echo esc_url( hoho_asset( 'img/tour-sydney.webp' ) ); ?>" alt="" loading="lazy" />
|
||||
<img src="<?php echo esc_url( hoho_asset( 'img/city-san-diego.webp' ) ); ?>" alt="" loading="lazy" />
|
||||
<img src="<?php echo esc_url( hoho_asset( 'img/tour-san-diego.webp' ) ); ?>" alt="" loading="lazy" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tour__block tour__block--roomy">
|
||||
<h2 class="post__h2">Why Choose us?</h2>
|
||||
<ul class="facts">
|
||||
<li class="fact">
|
||||
<svg class="icon fact__icon" aria-hidden="true"><use href="#i-duration" /></svg>
|
||||
<div class="fact__text">
|
||||
<span class="fact__k">Duration</span>
|
||||
<span class="fact__v">1h 30m</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="fact">
|
||||
<svg class="icon fact__icon" aria-hidden="true"><use href="#i-person" /></svg>
|
||||
<div class="fact__text">
|
||||
<span class="fact__k">Max Travelers</span>
|
||||
<span class="fact__v">15 people</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="fact">
|
||||
<svg class="icon fact__icon" aria-hidden="true"><use href="#i-shield-check" /></svg>
|
||||
<div class="fact__text">
|
||||
<span class="fact__k">Free Cancellation</span>
|
||||
<span class="fact__v">Up to 24 hrs</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="fact">
|
||||
<svg class="icon fact__icon" aria-hidden="true"><use href="#i-grid" /></svg>
|
||||
<div class="fact__text">
|
||||
<span class="fact__k">Category</span>
|
||||
<span class="fact__v">Best Conversion</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ testimonials ============ -->
|
||||
<section class="section testimonials" id="testimonials">
|
||||
<div class="container">
|
||||
<div class="testimonials__head">
|
||||
<h2 class="testimonials__title">Your Comment’s</h2>
|
||||
<p class="testimonials__lead">Lorem ipsum dolor sit amet, consectetur adipiscing elit</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="marquee" data-marquee>
|
||||
<svg class="marquee__route" viewBox="0 0 1200 280" fill="none" aria-hidden="true" preserveAspectRatio="none">
|
||||
<path d="M0 210 C 90 60, 170 60, 260 150 S 420 250, 520 120 S 640 20, 720 130 S 860 250, 960 120 S 1120 60, 1200 170"
|
||||
stroke="#2CAFC2" stroke-dasharray="3 3" />
|
||||
</svg>
|
||||
<div class="marquee__track">
|
||||
<figure class="quote quote--lg">
|
||||
<figcaption class="quote__head">
|
||||
<img class="quote__avatar" src="<?php echo esc_url( hoho_asset( 'img/ellipse-17.webp' ) ); ?>" alt="" loading="lazy" />
|
||||
<span>
|
||||
<b>Malika Favre</b>
|
||||
<i>Paris Tour</i>
|
||||
</span>
|
||||
</figcaption>
|
||||
<blockquote>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
|
||||
labore et dolore magna aliquaLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
|
||||
tempor incididunt ut labore et dolore magna aliqua</blockquote>
|
||||
</figure>
|
||||
<figure class="quote">
|
||||
<figcaption class="quote__head">
|
||||
<img class="quote__avatar" src="<?php echo esc_url( hoho_asset( 'img/ellipse-17-2.webp' ) ); ?>" alt="" loading="lazy" />
|
||||
<span>
|
||||
<b>Malika Favre</b>
|
||||
<i>Paris Tour</i>
|
||||
</span>
|
||||
</figcaption>
|
||||
<blockquote>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
|
||||
labore et dolore magna aliquaLorem ipsum dolor sit amet, consectetur adipiscing elit</blockquote>
|
||||
</figure>
|
||||
<figure class="quote quote--sm">
|
||||
<figcaption class="quote__head">
|
||||
<img class="quote__avatar" src="<?php echo esc_url( hoho_asset( 'img/ellipse-17.webp' ) ); ?>" alt="" loading="lazy" />
|
||||
<span>
|
||||
<b>Malika Favre</b>
|
||||
<i>Paris Tour</i>
|
||||
</span>
|
||||
</figcaption>
|
||||
<blockquote>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
|
||||
labore</blockquote>
|
||||
</figure>
|
||||
<figure class="quote">
|
||||
<figcaption class="quote__head">
|
||||
<img class="quote__avatar" src="<?php echo esc_url( hoho_asset( 'img/ellipse-17-2.webp' ) ); ?>" alt="" loading="lazy" />
|
||||
<span>
|
||||
<b>Malika Favre</b>
|
||||
<i>Paris Tour</i>
|
||||
</span>
|
||||
</figcaption>
|
||||
<blockquote>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
|
||||
labore et dolore magna aliqua</blockquote>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<?php
|
||||
get_footer();
|
||||
129
astra-child-hoponhopoff/templates/blog.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
/**
|
||||
* Blog list — "new front/blog.html" with real posts. Search, sort and paging
|
||||
* are server-side GET parameters (q, sort, pg); see hoho_blog_state().
|
||||
*/
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
get_header();
|
||||
|
||||
$hoho_state = hoho_blog_state();
|
||||
$hoho_query = hoho_blog_query( $hoho_state );
|
||||
$hoho_base = is_home() ? get_permalink( get_option( 'page_for_posts' ) ) : get_permalink();
|
||||
$hoho_base = $hoho_base ? $hoho_base : home_url( '/' );
|
||||
$hoho_max = (int) $hoho_query->max_num_pages;
|
||||
$hoho_pg = min( $hoho_state['pg'], max( 1, $hoho_max ) );
|
||||
|
||||
$hoho_url = function ( $pg ) use ( $hoho_base, $hoho_state ) {
|
||||
$args = array();
|
||||
if ( '' !== $hoho_state['q'] ) {
|
||||
$args['q'] = $hoho_state['q'];
|
||||
}
|
||||
if ( 'newest' !== $hoho_state['sort'] ) {
|
||||
$args['sort'] = $hoho_state['sort'];
|
||||
}
|
||||
if ( $pg > 1 ) {
|
||||
$args['pg'] = $pg;
|
||||
}
|
||||
return add_query_arg( $args, $hoho_base ) . '#blog-list';
|
||||
};
|
||||
|
||||
// Page numbers with gaps: 1 … 4 5 6 … 12.
|
||||
$hoho_range = array();
|
||||
for ( $n = 1; $n <= $hoho_max; $n++ ) {
|
||||
if ( $n <= 1 || $n >= $hoho_max || abs( $n - $hoho_pg ) <= 1 ) {
|
||||
$hoho_range[] = $n;
|
||||
} elseif ( null !== end( $hoho_range ) ) {
|
||||
$hoho_range[] = null;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<main id="main" class="hoho-page">
|
||||
<section class="hero hero--archive hero--blog">
|
||||
<div class="hero__media">
|
||||
<img class="hero__img" src="<?php echo esc_url( hoho_banner_url( 'blogs-hero' ) ); ?>" alt="" fetchpriority="high" />
|
||||
<div class="hero__scrim" aria-hidden="true"></div>
|
||||
</div>
|
||||
|
||||
<div class="container hero__content">
|
||||
<?php echo hoho_breadcrumb_html( array(), __( 'Blog', 'hoponhopoff' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
<h1 class="hero__title"><?php echo esc_html__( 'HOHO Weblog', 'hoponhopoff' ); ?></h1>
|
||||
|
||||
<form class="filters filters--blog" id="filters" method="get" action="<?php echo esc_url( $hoho_base ); ?>" role="search">
|
||||
<div class="filters__row">
|
||||
<div class="fsearch">
|
||||
<?php echo hoho_icon( 'search', 'fsearch__icon' ); // phpcs:ignore ?>
|
||||
<input class="fsearch__input" id="f-q" name="q" type="search" value="<?php echo esc_attr( $hoho_state['q'] ); ?>" placeholder="<?php echo esc_attr__( 'Search Tours, cities ...', 'hoponhopoff' ); ?>" aria-label="<?php echo esc_attr__( 'Search articles', 'hoponhopoff' ); ?>" />
|
||||
</div>
|
||||
|
||||
<div class="filters__sort">
|
||||
<span class="filters__sort-label">
|
||||
<?php echo hoho_icon( 'sort' ); // phpcs:ignore ?>
|
||||
<label for="f-sort"><?php echo esc_html__( 'Sort by', 'hoponhopoff' ); ?></label>
|
||||
</span>
|
||||
<div class="fsel fsel--bare">
|
||||
<select class="fsel__input" id="f-sort" name="sort" data-autosubmit>
|
||||
<option value="newest" <?php selected( $hoho_state['sort'], 'newest' ); ?>><?php echo esc_html__( 'Newest', 'hoponhopoff' ); ?></option>
|
||||
<option value="oldest" <?php selected( $hoho_state['sort'], 'oldest' ); ?>><?php echo esc_html__( 'Oldest', 'hoponhopoff' ); ?></option>
|
||||
<option value="title" <?php selected( $hoho_state['sort'], 'title' ); ?>><?php echo esc_html__( 'Title A–Z', 'hoponhopoff' ); ?></option>
|
||||
</select>
|
||||
<?php echo hoho_icon( 'chevron', 'fsel__chevron' ); // phpcs:ignore ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="blog-list">
|
||||
<div class="container">
|
||||
<div class="results">
|
||||
<p class="results__count" role="status">
|
||||
<?php
|
||||
/* translators: %s: number of articles. */
|
||||
echo esc_html( sprintf( _n( '%s article', '%s articles', (int) $hoho_query->found_posts, 'hoponhopoff' ), number_format_i18n( (int) $hoho_query->found_posts ) ) );
|
||||
?>
|
||||
</p>
|
||||
<?php if ( '' !== $hoho_state['q'] ) : ?>
|
||||
<a class="btn btn--ghost results__reset" href="<?php echo esc_url( $hoho_base ); ?>"><?php echo esc_html__( 'Clear search', 'hoponhopoff' ); ?></a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ( $hoho_query->have_posts() ) : ?>
|
||||
<ul class="tgrid" id="tourGrid">
|
||||
<?php
|
||||
foreach ( $hoho_query->posts as $hoho_post ) {
|
||||
echo hoho_article_card( $hoho_post ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
}
|
||||
?>
|
||||
</ul>
|
||||
<?php else : ?>
|
||||
<p class="tgrid__empty"><?php echo esc_html__( 'No articles match your search.', 'hoponhopoff' ); ?>
|
||||
<a class="linkbtn" href="<?php echo esc_url( $hoho_base ); ?>"><?php echo esc_html__( 'Clear the search', 'hoponhopoff' ); ?></a>.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ( $hoho_max > 1 ) : ?>
|
||||
<nav class="pager" aria-label="<?php echo esc_attr__( 'Article list pages', 'hoponhopoff' ); ?>">
|
||||
<?php if ( $hoho_pg > 1 ) : ?>
|
||||
<a class="pager__btn pager__btn--icon" href="<?php echo esc_url( $hoho_url( $hoho_pg - 1 ) ); ?>" aria-label="<?php echo esc_attr__( 'Previous page', 'hoponhopoff' ); ?>"><?php echo hoho_icon( 'arrow-left' ); // phpcs:ignore ?></a>
|
||||
<?php endif; ?>
|
||||
<span class="pager__pages">
|
||||
<?php foreach ( $hoho_range as $n ) : ?>
|
||||
<?php if ( null === $n ) : ?>
|
||||
<span class="pager__gap" aria-hidden="true">…</span>
|
||||
<?php elseif ( $n === $hoho_pg ) : ?>
|
||||
<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( $hoho_url( $n ) ); ?>"><?php echo esc_html( $n ); ?></a>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</span>
|
||||
<?php if ( $hoho_pg < $hoho_max ) : ?>
|
||||
<a class="pager__btn" href="<?php echo esc_url( $hoho_url( $hoho_pg + 1 ) ); ?>"><span><?php echo esc_html__( 'Next', 'hoponhopoff' ); ?></span><?php echo hoho_icon( 'arrow-right' ); // phpcs:ignore ?></a>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<?php
|
||||
get_footer();
|
||||
109
astra-child-hoponhopoff/templates/contact.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
/**
|
||||
* Contact page — markup from "new front/contact.html" (fixed copy).
|
||||
* Routed by hoho_page_template() in inc/pages.php.
|
||||
*/
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
get_header();
|
||||
?>
|
||||
<main id="main" class="hoho-page">
|
||||
<section class="hero hero--page hero--contact<?php echo has_post_thumbnail( get_queried_object_id() ) ? ' hero--has-thumb' : ''; ?>">
|
||||
<div class="hero__media">
|
||||
<img class="hero__img" src="<?php echo esc_url( hoho_banner_url( 'contact-hero' ) ); ?>" alt="" fetchpriority="high" />
|
||||
</div>
|
||||
|
||||
<div class="container hero__content">
|
||||
<nav class="crumbs" aria-label="Breadcrumb">
|
||||
<ol class="crumbs__list">
|
||||
<li><a href="<?php echo esc_url( home_url( '/' ) ); ?>">Home</a></li>
|
||||
<li aria-current="page">Contact us</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="hero__title">Contact us</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="section pagebody contactpage">
|
||||
<div class="container">
|
||||
<div class="contactpage__grid">
|
||||
<section class="rform contactcard" aria-labelledby="ciTitle">
|
||||
<p class="rform__title" id="ciTitle"><svg class="icon" aria-hidden="true"><use href="#i-info" /></svg>Contact Information</p>
|
||||
<ul class="facts facts--col">
|
||||
<li class="fact">
|
||||
<svg class="icon fact__icon" aria-hidden="true"><use href="#i-location" /></svg>
|
||||
<div class="fact__text">
|
||||
<span class="fact__k">Address</span>
|
||||
<span class="fact__v">Up to 24 hrs</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="fact">
|
||||
<svg class="icon fact__icon" aria-hidden="true"><use href="#i-phone" /></svg>
|
||||
<div class="fact__text">
|
||||
<span class="fact__k">CEO unit</span>
|
||||
<span class="fact__v">+1 1234 5678</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="fact">
|
||||
<svg class="icon fact__icon" aria-hidden="true"><use href="#i-phone" /></svg>
|
||||
<div class="fact__text">
|
||||
<span class="fact__k">Support</span>
|
||||
<span class="fact__v">+1 1234 5678</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="fact">
|
||||
<svg class="icon fact__icon" aria-hidden="true"><use href="#i-mail" /></svg>
|
||||
<div class="fact__text">
|
||||
<span class="fact__k">Mail</span>
|
||||
<span class="fact__v"><a href="mailto:info@hoho.com">info@hoho.com</a></span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="contactcard__social">
|
||||
<p class="rform__title"><svg class="icon" aria-hidden="true"><use href="#i-info" /></svg>Social Media</p>
|
||||
<div class="share__btns">
|
||||
<?php foreach ( hoho_socials() as $hoho_network => $hoho_url ) : ?>
|
||||
<a class="share__btn" href="<?php echo esc_url( $hoho_url ); ?>" target="_blank" rel="noopener" aria-label="<?php echo esc_attr( ucfirst( $hoho_network ) ); ?>"><?php echo hoho_icon( $hoho_network ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php
|
||||
// Without JS the form posts normally and comes back with ?contact=sent|error.
|
||||
$hoho_contact = isset( $_GET['contact'] ) ? sanitize_key( $_GET['contact'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification
|
||||
?>
|
||||
<form class="rform ticketform" id="ticketForm" method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" data-ajax="<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>" novalidate>
|
||||
<input type="hidden" name="action" value="hoho_contact" />
|
||||
<?php wp_nonce_field( 'hoho_contact', 'hoho_contact_nonce' ); ?>
|
||||
<?php // Spam trap: hidden from people, filled in by bots. ?>
|
||||
<div class="hoho-hp" aria-hidden="true"><label for="t-website">Website</label><input id="t-website" name="website" type="text" tabindex="-1" autocomplete="off" /></div>
|
||||
<p class="rform__title"><svg class="icon" aria-hidden="true"><use href="#i-send" /></svg>Send Ticket</p>
|
||||
<div class="rform__row">
|
||||
<div class="field">
|
||||
<span class="field__label"><svg class="icon" aria-hidden="true"><use href="#i-person" /></svg><label for="t-name">Full Name</label></span>
|
||||
<input class="rinput" id="t-name" name="name" type="text" placeholder="Enter full name" autocomplete="name" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field__label"><svg class="icon" aria-hidden="true"><use href="#i-mail" /></svg><label for="t-email">Email</label></span>
|
||||
<input class="rinput" id="t-email" name="email" type="email" placeholder="Enter email" autocomplete="email" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field ticketform__grow">
|
||||
<span class="field__label"><svg class="icon" aria-hidden="true"><use href="#i-chat" /></svg><label for="t-msg">Your message</label></span>
|
||||
<textarea class="rinput rinput--area" id="t-msg" name="message" rows="6" placeholder="Type your message here ..." required></textarea>
|
||||
</div>
|
||||
<p class="rform__status<?php echo 'error' === $hoho_contact ? ' is-error' : ''; ?>" id="ticketStatus" role="status"><?php
|
||||
if ( 'sent' === $hoho_contact ) {
|
||||
echo esc_html( hoho_contact_message( 'sent' ) );
|
||||
} elseif ( 'error' === $hoho_contact ) {
|
||||
echo esc_html( hoho_contact_message( 'error' ) );
|
||||
}
|
||||
?></p>
|
||||
<button class="btn btn--dark rform__submit" type="submit">Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<?php
|
||||
get_footer();
|
||||
304
astra-child-hoponhopoff/templates/faq.php
Normal file
@@ -0,0 +1,304 @@
|
||||
<?php
|
||||
/**
|
||||
* FAQ page — markup from "new front/faq.html" (fixed copy).
|
||||
* Routed by hoho_page_template() in inc/pages.php.
|
||||
*/
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
get_header();
|
||||
?>
|
||||
<main id="main" class="hoho-page">
|
||||
<section class="hero hero--page hero--faq<?php echo has_post_thumbnail( get_queried_object_id() ) ? ' hero--has-thumb' : ''; ?>">
|
||||
<div class="hero__media">
|
||||
<img class="hero__img" src="<?php echo esc_url( hoho_banner_url( 'faq-hero' ) ); ?>" alt="" fetchpriority="high" />
|
||||
</div>
|
||||
|
||||
<div class="container hero__content">
|
||||
<nav class="crumbs" aria-label="Breadcrumb">
|
||||
<ol class="crumbs__list">
|
||||
<li><a href="<?php echo esc_url( home_url( '/' ) ); ?>">Home</a></li>
|
||||
<li aria-current="page">FAQ’s</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="hero__title">FAQ’s</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="section pagebody faqpage">
|
||||
<div class="container faqpage__layout">
|
||||
<aside class="catnav" aria-label="FAQ categories">
|
||||
<p class="catnav__title"><svg class="icon" aria-hidden="true"><use href="#i-grid" /></svg>Categorize</p>
|
||||
<ul class="catnav__list">
|
||||
<li><a class="catnav__link is-active" href="#faq-general" aria-current="true"><span>General</span><svg class="icon" aria-hidden="true"><use href="#i-chev-right" /></svg></a></li>
|
||||
<li><a class="catnav__link" href="#faq-tours"><span>Tours</span><svg class="icon" aria-hidden="true"><use href="#i-chev-right" /></svg></a></li>
|
||||
<li><a class="catnav__link" href="#faq-accounts"><span>Accounts</span><svg class="icon" aria-hidden="true"><use href="#i-chev-right" /></svg></a></li>
|
||||
<li><a class="catnav__link" href="#faq-other"><span>Other</span><svg class="icon" aria-hidden="true"><use href="#i-chev-right" /></svg></a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<div class="faqpage__main">
|
||||
<section class="faqcard" id="faq-general" aria-labelledby="faq-general-t">
|
||||
<h2 class="faqcard__title" id="faq-general-t">General</h2>
|
||||
<div class="accordion" data-accordion>
|
||||
<div class="acc">
|
||||
<h3>
|
||||
<button class="acc__q" type="button" aria-expanded="false">
|
||||
<span>is the booking process, full online?</span>
|
||||
<svg class="icon icon--14 acc__plus" aria-hidden="true"><use href="#i-plus" /></svg>
|
||||
<svg class="icon icon--14 acc__minus" aria-hidden="true"><use href="#i-minus" /></svg>
|
||||
</button>
|
||||
</h3>
|
||||
<div class="acc__a">
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut laboreLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="acc">
|
||||
<h3>
|
||||
<button class="acc__q" type="button" aria-expanded="true">
|
||||
<span>How can i book a tour ?</span>
|
||||
<svg class="icon icon--14 acc__plus" aria-hidden="true"><use href="#i-plus" /></svg>
|
||||
<svg class="icon icon--14 acc__minus" aria-hidden="true"><use href="#i-minus" /></svg>
|
||||
</button>
|
||||
</h3>
|
||||
<div class="acc__a">
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut laboreLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="acc">
|
||||
<h3>
|
||||
<button class="acc__q" type="button" aria-expanded="false">
|
||||
<span>is the booking process, full online?</span>
|
||||
<svg class="icon icon--14 acc__plus" aria-hidden="true"><use href="#i-plus" /></svg>
|
||||
<svg class="icon icon--14 acc__minus" aria-hidden="true"><use href="#i-minus" /></svg>
|
||||
</button>
|
||||
</h3>
|
||||
<div class="acc__a">
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut laboreLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="acc">
|
||||
<h3>
|
||||
<button class="acc__q" type="button" aria-expanded="false">
|
||||
<span>How can i book a tour ?</span>
|
||||
<svg class="icon icon--14 acc__plus" aria-hidden="true"><use href="#i-plus" /></svg>
|
||||
<svg class="icon icon--14 acc__minus" aria-hidden="true"><use href="#i-minus" /></svg>
|
||||
</button>
|
||||
</h3>
|
||||
<div class="acc__a">
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut laboreLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="acc">
|
||||
<h3>
|
||||
<button class="acc__q" type="button" aria-expanded="false">
|
||||
<span>is the booking process, full online?</span>
|
||||
<svg class="icon icon--14 acc__plus" aria-hidden="true"><use href="#i-plus" /></svg>
|
||||
<svg class="icon icon--14 acc__minus" aria-hidden="true"><use href="#i-minus" /></svg>
|
||||
</button>
|
||||
</h3>
|
||||
<div class="acc__a">
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut laboreLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="faqcard" id="faq-tours" aria-labelledby="faq-tours-t">
|
||||
<h2 class="faqcard__title" id="faq-tours-t">Tours</h2>
|
||||
<div class="accordion" data-accordion>
|
||||
<div class="acc">
|
||||
<h3>
|
||||
<button class="acc__q" type="button" aria-expanded="false">
|
||||
<span>is the booking process, full online?</span>
|
||||
<svg class="icon icon--14 acc__plus" aria-hidden="true"><use href="#i-plus" /></svg>
|
||||
<svg class="icon icon--14 acc__minus" aria-hidden="true"><use href="#i-minus" /></svg>
|
||||
</button>
|
||||
</h3>
|
||||
<div class="acc__a">
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut laboreLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="acc">
|
||||
<h3>
|
||||
<button class="acc__q" type="button" aria-expanded="true">
|
||||
<span>How can i book a tour ?</span>
|
||||
<svg class="icon icon--14 acc__plus" aria-hidden="true"><use href="#i-plus" /></svg>
|
||||
<svg class="icon icon--14 acc__minus" aria-hidden="true"><use href="#i-minus" /></svg>
|
||||
</button>
|
||||
</h3>
|
||||
<div class="acc__a">
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut laboreLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="acc">
|
||||
<h3>
|
||||
<button class="acc__q" type="button" aria-expanded="false">
|
||||
<span>is the booking process, full online?</span>
|
||||
<svg class="icon icon--14 acc__plus" aria-hidden="true"><use href="#i-plus" /></svg>
|
||||
<svg class="icon icon--14 acc__minus" aria-hidden="true"><use href="#i-minus" /></svg>
|
||||
</button>
|
||||
</h3>
|
||||
<div class="acc__a">
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut laboreLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="acc">
|
||||
<h3>
|
||||
<button class="acc__q" type="button" aria-expanded="false">
|
||||
<span>How can i book a tour ?</span>
|
||||
<svg class="icon icon--14 acc__plus" aria-hidden="true"><use href="#i-plus" /></svg>
|
||||
<svg class="icon icon--14 acc__minus" aria-hidden="true"><use href="#i-minus" /></svg>
|
||||
</button>
|
||||
</h3>
|
||||
<div class="acc__a">
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut laboreLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="acc">
|
||||
<h3>
|
||||
<button class="acc__q" type="button" aria-expanded="false">
|
||||
<span>is the booking process, full online?</span>
|
||||
<svg class="icon icon--14 acc__plus" aria-hidden="true"><use href="#i-plus" /></svg>
|
||||
<svg class="icon icon--14 acc__minus" aria-hidden="true"><use href="#i-minus" /></svg>
|
||||
</button>
|
||||
</h3>
|
||||
<div class="acc__a">
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut laboreLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="faqcard" id="faq-accounts" aria-labelledby="faq-accounts-t">
|
||||
<h2 class="faqcard__title" id="faq-accounts-t">Accounts</h2>
|
||||
<div class="accordion" data-accordion>
|
||||
<div class="acc">
|
||||
<h3>
|
||||
<button class="acc__q" type="button" aria-expanded="false">
|
||||
<span>is the booking process, full online?</span>
|
||||
<svg class="icon icon--14 acc__plus" aria-hidden="true"><use href="#i-plus" /></svg>
|
||||
<svg class="icon icon--14 acc__minus" aria-hidden="true"><use href="#i-minus" /></svg>
|
||||
</button>
|
||||
</h3>
|
||||
<div class="acc__a">
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut laboreLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="acc">
|
||||
<h3>
|
||||
<button class="acc__q" type="button" aria-expanded="true">
|
||||
<span>How can i book a tour ?</span>
|
||||
<svg class="icon icon--14 acc__plus" aria-hidden="true"><use href="#i-plus" /></svg>
|
||||
<svg class="icon icon--14 acc__minus" aria-hidden="true"><use href="#i-minus" /></svg>
|
||||
</button>
|
||||
</h3>
|
||||
<div class="acc__a">
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut laboreLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="acc">
|
||||
<h3>
|
||||
<button class="acc__q" type="button" aria-expanded="false">
|
||||
<span>is the booking process, full online?</span>
|
||||
<svg class="icon icon--14 acc__plus" aria-hidden="true"><use href="#i-plus" /></svg>
|
||||
<svg class="icon icon--14 acc__minus" aria-hidden="true"><use href="#i-minus" /></svg>
|
||||
</button>
|
||||
</h3>
|
||||
<div class="acc__a">
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut laboreLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="acc">
|
||||
<h3>
|
||||
<button class="acc__q" type="button" aria-expanded="false">
|
||||
<span>How can i book a tour ?</span>
|
||||
<svg class="icon icon--14 acc__plus" aria-hidden="true"><use href="#i-plus" /></svg>
|
||||
<svg class="icon icon--14 acc__minus" aria-hidden="true"><use href="#i-minus" /></svg>
|
||||
</button>
|
||||
</h3>
|
||||
<div class="acc__a">
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut laboreLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="acc">
|
||||
<h3>
|
||||
<button class="acc__q" type="button" aria-expanded="false">
|
||||
<span>is the booking process, full online?</span>
|
||||
<svg class="icon icon--14 acc__plus" aria-hidden="true"><use href="#i-plus" /></svg>
|
||||
<svg class="icon icon--14 acc__minus" aria-hidden="true"><use href="#i-minus" /></svg>
|
||||
</button>
|
||||
</h3>
|
||||
<div class="acc__a">
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut laboreLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="faqcard" id="faq-other" aria-labelledby="faq-other-t">
|
||||
<h2 class="faqcard__title" id="faq-other-t">Other</h2>
|
||||
<div class="accordion" data-accordion>
|
||||
<div class="acc">
|
||||
<h3>
|
||||
<button class="acc__q" type="button" aria-expanded="false">
|
||||
<span>is the booking process, full online?</span>
|
||||
<svg class="icon icon--14 acc__plus" aria-hidden="true"><use href="#i-plus" /></svg>
|
||||
<svg class="icon icon--14 acc__minus" aria-hidden="true"><use href="#i-minus" /></svg>
|
||||
</button>
|
||||
</h3>
|
||||
<div class="acc__a">
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut laboreLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="acc">
|
||||
<h3>
|
||||
<button class="acc__q" type="button" aria-expanded="true">
|
||||
<span>How can i book a tour ?</span>
|
||||
<svg class="icon icon--14 acc__plus" aria-hidden="true"><use href="#i-plus" /></svg>
|
||||
<svg class="icon icon--14 acc__minus" aria-hidden="true"><use href="#i-minus" /></svg>
|
||||
</button>
|
||||
</h3>
|
||||
<div class="acc__a">
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut laboreLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="acc">
|
||||
<h3>
|
||||
<button class="acc__q" type="button" aria-expanded="false">
|
||||
<span>is the booking process, full online?</span>
|
||||
<svg class="icon icon--14 acc__plus" aria-hidden="true"><use href="#i-plus" /></svg>
|
||||
<svg class="icon icon--14 acc__minus" aria-hidden="true"><use href="#i-minus" /></svg>
|
||||
</button>
|
||||
</h3>
|
||||
<div class="acc__a">
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut laboreLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="acc">
|
||||
<h3>
|
||||
<button class="acc__q" type="button" aria-expanded="false">
|
||||
<span>How can i book a tour ?</span>
|
||||
<svg class="icon icon--14 acc__plus" aria-hidden="true"><use href="#i-plus" /></svg>
|
||||
<svg class="icon icon--14 acc__minus" aria-hidden="true"><use href="#i-minus" /></svg>
|
||||
</button>
|
||||
</h3>
|
||||
<div class="acc__a">
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut laboreLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="acc">
|
||||
<h3>
|
||||
<button class="acc__q" type="button" aria-expanded="false">
|
||||
<span>is the booking process, full online?</span>
|
||||
<svg class="icon icon--14 acc__plus" aria-hidden="true"><use href="#i-plus" /></svg>
|
||||
<svg class="icon icon--14 acc__minus" aria-hidden="true"><use href="#i-minus" /></svg>
|
||||
</button>
|
||||
</h3>
|
||||
<div class="acc__a">
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut laboreLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<?php
|
||||
get_footer();
|
||||
198
astra-child-hoponhopoff/templates/post.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
/**
|
||||
* Single blog post — "new front/blog-post.html" with the post's own title,
|
||||
* image and content. "Review's" are the post's WordPress comments.
|
||||
*/
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
get_header();
|
||||
|
||||
the_post();
|
||||
$hoho_post_id = get_the_ID();
|
||||
$hoho_cats = get_the_category();
|
||||
$hoho_reviews = get_comments(
|
||||
array(
|
||||
'post_id' => $hoho_post_id,
|
||||
'status' => 'approve',
|
||||
'parent' => 0,
|
||||
'order' => 'ASC',
|
||||
)
|
||||
);
|
||||
$hoho_related = get_posts(
|
||||
array(
|
||||
'post_type' => 'post',
|
||||
'posts_per_page' => 4,
|
||||
'post__not_in' => array( $hoho_post_id ),
|
||||
'category__in' => wp_list_pluck( $hoho_cats, 'term_id' ),
|
||||
'ignore_sticky_posts' => true,
|
||||
)
|
||||
);
|
||||
if ( count( $hoho_related ) < 4 ) {
|
||||
$hoho_related = array_merge(
|
||||
$hoho_related,
|
||||
get_posts(
|
||||
array(
|
||||
'post_type' => 'post',
|
||||
'posts_per_page' => 4 - count( $hoho_related ),
|
||||
'post__not_in' => array_merge( array( $hoho_post_id ), wp_list_pluck( $hoho_related, 'ID' ) ),
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
$hoho_blog_url = hoho_blog_url();
|
||||
|
||||
/**
|
||||
* One review card in the design's markup.
|
||||
*/
|
||||
$hoho_review = function ( $comment, $classes ) use ( $hoho_post_id ) {
|
||||
$by_author = (int) $comment->user_id && (int) $comment->user_id === (int) get_post_field( 'post_author', $hoho_post_id );
|
||||
?>
|
||||
<article class="rcard <?php echo esc_attr( $classes ); ?>" id="comment-<?php echo (int) $comment->comment_ID; ?>">
|
||||
<header class="rcard__head">
|
||||
<?php echo get_avatar( $comment, 48, '', '', array( 'class' => 'rcard__avatar' ) ); ?>
|
||||
<div class="rcard__who">
|
||||
<b><?php echo esc_html( get_comment_author( $comment ) ); ?></b>
|
||||
<span><?php echo esc_html( $by_author ? __( 'Admin', 'hoponhopoff' ) : get_comment_date( 'Y/m/d', $comment ) ); ?></span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="rcard__text"><?php echo wp_kses_post( wpautop( get_comment_text( $comment ) ) ); ?></div>
|
||||
</article>
|
||||
<?php
|
||||
};
|
||||
?>
|
||||
<main id="main" class="hoho-page">
|
||||
<section class="hero hero--post">
|
||||
<div class="hero__media">
|
||||
<img class="hero__img" src="<?php echo esc_url( hoho_post_image_url( $hoho_post_id, 'full' ) ); ?>" alt="" fetchpriority="high" />
|
||||
<div class="hero__scrim" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="container hero__content">
|
||||
<?php echo hoho_breadcrumb_html( array( array( __( 'Blog', 'hoponhopoff' ), hoho_blog_url() ) ), get_the_title() ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
<h1 class="hero__title post__title"><?php the_title(); ?></h1>
|
||||
<div class="tmeta">
|
||||
<?php if ( $hoho_cats ) : ?>
|
||||
<span class="tmeta__item"><?php echo hoho_icon( 'location', 'tmeta__icon' ); // phpcs:ignore ?><?php echo esc_html( $hoho_cats[0]->name ); ?></span>
|
||||
<span class="tmeta__rule" aria-hidden="true"></span>
|
||||
<?php endif; ?>
|
||||
<span class="tmeta__item"><?php echo hoho_icon( 'calendar', 'tmeta__icon' ); // phpcs:ignore ?><?php echo esc_html( get_the_date() ); ?></span>
|
||||
<span class="tmeta__rule" aria-hidden="true"></span>
|
||||
<span class="tmeta__item"><?php echo hoho_icon( 'person', 'tmeta__icon' ); // phpcs:ignore ?><?php
|
||||
/* translators: %s: author name. */
|
||||
echo esc_html( sprintf( __( 'from %s', 'hoponhopoff' ), get_the_author() ) );
|
||||
?></span>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
if ( function_exists( 'vc_ring_badge' ) ) {
|
||||
echo vc_ring_badge( 'SCROLL DOWN · SCROLL DOWN · ', '#post', 'scroll' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
}
|
||||
?>
|
||||
</section>
|
||||
|
||||
<article class="section post" id="post">
|
||||
<div class="container">
|
||||
<div class="post__inner">
|
||||
<div class="post__body">
|
||||
<div class="hoho-prose">
|
||||
<?php the_content(); ?>
|
||||
</div>
|
||||
<hr class="post__rule" />
|
||||
|
||||
<div class="share">
|
||||
<h2 class="share__title"><?php echo esc_html__( 'Share with your friends!', 'hoponhopoff' ); ?></h2>
|
||||
<div class="share__btns">
|
||||
<a class="share__btn" data-share="telegram" href="https://t.me/share/url" target="_blank" rel="noopener" aria-label="<?php echo esc_attr__( 'Share on Telegram', 'hoponhopoff' ); ?>"><?php echo hoho_icon( 'telegram', 'icon--20' ); // phpcs:ignore ?></a>
|
||||
<a class="share__btn" data-share="facebook" href="https://www.facebook.com/sharer/sharer.php" target="_blank" rel="noopener" aria-label="<?php echo esc_attr__( 'Share on Facebook', 'hoponhopoff' ); ?>"><?php echo hoho_icon( 'facebook', 'icon--20' ); // phpcs:ignore ?></a>
|
||||
<a class="share__btn" href="<?php echo esc_url( hoho_socials()['instagram'] ); ?>" target="_blank" rel="noopener" aria-label="Instagram"><?php echo hoho_icon( 'instagram', 'icon--20' ); // phpcs:ignore ?></a>
|
||||
<a class="share__btn" data-share="whatsapp" href="https://wa.me/" target="_blank" rel="noopener" aria-label="<?php echo esc_attr__( 'Share on WhatsApp', 'hoponhopoff' ); ?>"><?php echo hoho_icon( 'whatsapp', 'icon--20' ); // phpcs:ignore ?></a>
|
||||
<button class="share__btn share__btn--copy" type="button" data-copy-link><?php echo esc_html__( 'Copy page link', 'hoponhopoff' ); ?></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ( comments_open() || $hoho_reviews ) : ?>
|
||||
<section class="reviews" id="reviews" aria-labelledby="reviewsTitle">
|
||||
<h2 class="post__h2" id="reviewsTitle"><?php echo esc_html__( 'Review’s', 'hoponhopoff' ); ?></h2>
|
||||
|
||||
<?php if ( comments_open() ) : ?>
|
||||
<form class="rform" id="reviewForm" method="post" action="<?php echo esc_url( site_url( '/wp-comments-post.php' ) ); ?>">
|
||||
<p class="rform__title"><?php echo hoho_icon( 'chat' ); // phpcs:ignore ?><?php echo esc_html__( 'Submit review', 'hoponhopoff' ); ?></p>
|
||||
<?php if ( ! is_user_logged_in() ) : ?>
|
||||
<div class="rform__row">
|
||||
<div class="field">
|
||||
<span class="field__label"><?php echo hoho_icon( 'person' ); // phpcs:ignore ?><label for="r-name"><?php echo esc_html__( 'Full Name', 'hoponhopoff' ); ?></label></span>
|
||||
<input class="rinput" id="r-name" name="author" type="text" placeholder="<?php echo esc_attr__( 'Enter full name', 'hoponhopoff' ); ?>" autocomplete="name" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field__label"><?php echo hoho_icon( 'mail' ); // phpcs:ignore ?><label for="r-email"><?php echo esc_html__( 'Email', 'hoponhopoff' ); ?></label></span>
|
||||
<input class="rinput" id="r-email" name="email" type="email" placeholder="<?php echo esc_attr__( 'Enter email', 'hoponhopoff' ); ?>" autocomplete="email" required />
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="field">
|
||||
<span class="field__label"><?php echo hoho_icon( 'chat' ); // phpcs:ignore ?><label for="r-text"><?php echo esc_html__( 'Your Review', 'hoponhopoff' ); ?></label></span>
|
||||
<textarea class="rinput rinput--area" id="r-text" name="comment" rows="4" placeholder="<?php echo esc_attr__( 'Type your review here ...', 'hoponhopoff' ); ?>" required></textarea>
|
||||
</div>
|
||||
<?php comment_id_fields( $hoho_post_id ); ?>
|
||||
<?php do_action( 'comment_form', $hoho_post_id ); ?>
|
||||
<p class="rform__status" role="status"><?php echo isset( $_GET['unapproved'] ) ? esc_html__( 'Thanks! Your review has been submitted for approval.', 'hoponhopoff' ) : ''; // phpcs:ignore WordPress.Security.NonceVerification ?></p>
|
||||
<button class="btn btn--dark rform__submit" type="submit"><?php echo esc_html__( 'Submit', 'hoponhopoff' ); ?></button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ( $hoho_reviews ) : ?>
|
||||
<div class="reviews__list<?php echo count( $hoho_reviews ) > 3 ? ' is-collapsed' : ''; ?>" id="reviewList">
|
||||
<?php
|
||||
foreach ( $hoho_reviews as $hoho_comment ) {
|
||||
$hoho_review( $hoho_comment, 'rcard--tint' );
|
||||
$hoho_replies = get_comments(
|
||||
array(
|
||||
'post_id' => $hoho_post_id,
|
||||
'status' => 'approve',
|
||||
'parent' => $hoho_comment->comment_ID,
|
||||
'order' => 'ASC',
|
||||
)
|
||||
);
|
||||
if ( $hoho_replies ) {
|
||||
echo '<div class="replies"><span class="replies__k">' . esc_html__( 'Replies', 'hoponhopoff' ) . '</span>';
|
||||
foreach ( $hoho_replies as $i => $hoho_reply ) {
|
||||
$hoho_review( $hoho_reply, $i % 2 ? 'rcard--tint rcard--reply' : 'rcard--reply' );
|
||||
}
|
||||
echo '</div>';
|
||||
}
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
<?php if ( count( $hoho_reviews ) > 3 ) : ?>
|
||||
<button class="reviews__more" type="button" id="reviewMore" aria-expanded="false" aria-controls="reviewList">
|
||||
<span><?php echo esc_html__( 'Show all reviews', 'hoponhopoff' ); ?></span>
|
||||
<?php echo hoho_icon( 'chevron' ); // phpcs:ignore ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ( $hoho_related ) : ?>
|
||||
<section class="related" aria-labelledby="relatedTitle">
|
||||
<div class="related__head">
|
||||
<h2 class="post__h2" id="relatedTitle"><?php echo esc_html__( 'Related Articles', 'hoponhopoff' ); ?></h2>
|
||||
<a class="btn btn--ghost btn--wide related__all" href="<?php echo esc_url( $hoho_blog_url ); ?>">
|
||||
<span><?php echo esc_html__( 'See all', 'hoponhopoff' ); ?></span>
|
||||
<?php echo hoho_icon( 'arrow-ne' ); // phpcs:ignore ?>
|
||||
</a>
|
||||
</div>
|
||||
<ul class="tgrid tgrid--related">
|
||||
<?php
|
||||
foreach ( $hoho_related as $hoho_rel ) {
|
||||
echo hoho_article_card( $hoho_rel, 'related' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
}
|
||||
?>
|
||||
</ul>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
<?php
|
||||
get_footer();
|
||||
70
build-zip.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
/**
|
||||
* Distribution zipper for the plugin / theme.
|
||||
*
|
||||
* php build-zip.php <source-dir> <output.zip>
|
||||
*
|
||||
* Produces a clean, distributable archive: excludes docs, build tooling,
|
||||
* markdown, VCS and editor/AI metadata — nothing that documents how the code
|
||||
* was produced ships to the client.
|
||||
*
|
||||
* Exclusion rule (keep in sync with the "no docs/AI files in zips" preference):
|
||||
* - any path segment: .git, .claude, .github, docs, build, node_modules, tests
|
||||
* - any *.md file
|
||||
* - dotfiles at any level except .htaccess
|
||||
*/
|
||||
|
||||
$src = isset( $argv[1] ) ? rtrim( str_replace( '\\', '/', $argv[1] ), '/' ) : '';
|
||||
$out = isset( $argv[2] ) ? $argv[2] : '';
|
||||
|
||||
if ( '' === $src || '' === $out || ! is_dir( $src ) ) {
|
||||
fwrite( STDERR, "Usage: php build-zip.php <source-dir> <output.zip>\n" );
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
$excluded_dirs = array( '.git', '.claude', '.github', 'docs', 'build', 'node_modules', 'tests' );
|
||||
|
||||
function is_excluded( $rel, array $excluded_dirs ) {
|
||||
$parts = explode( '/', $rel );
|
||||
$base = end( $parts );
|
||||
|
||||
foreach ( $parts as $seg ) {
|
||||
if ( in_array( $seg, $excluded_dirs, true ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Markdown / doc files.
|
||||
if ( preg_match( '/\.md$/i', $base ) ) {
|
||||
return true;
|
||||
}
|
||||
// Dotfiles (e.g. .gitignore, .DS_Store) except .htaccess.
|
||||
if ( '' !== $base && '.' === $base[0] && '.htaccess' !== $base ) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@unlink( $out );
|
||||
$zip = new ZipArchive();
|
||||
if ( true !== $zip->open( $out, ZipArchive::CREATE | ZipArchive::OVERWRITE ) ) {
|
||||
fwrite( STDERR, "Cannot create $out\n" );
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
$root = basename( $src );
|
||||
$it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $src, FilesystemIterator::SKIP_DOTS ) );
|
||||
$added = 0;
|
||||
$skip = 0;
|
||||
foreach ( $it as $file ) {
|
||||
$path = str_replace( '\\', '/', $file->getPathname() );
|
||||
// Path relative to the source's parent, so the archive contains <root>/…
|
||||
$rel_from_src = ltrim( substr( $path, strlen( $src ) ), '/' );
|
||||
if ( is_excluded( $rel_from_src, $excluded_dirs ) ) {
|
||||
$skip++;
|
||||
continue;
|
||||
}
|
||||
$zip->addFile( $path, $root . '/' . $rel_from_src );
|
||||
$added++;
|
||||
}
|
||||
$zip->close();
|
||||
fwrite( STDOUT, "Wrote $out — $added files (skipped $skip)\n" );
|
||||
@@ -58,6 +58,15 @@ html {
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
"Plus Jakarta Sans",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
Helvetica,
|
||||
Arial,
|
||||
sans-serif;
|
||||
font-family:
|
||||
"Plus Jakarta Sans",
|
||||
system-ui,
|
||||
@@ -624,7 +633,6 @@ dd {
|
||||
position: relative;
|
||||
display: grid;
|
||||
align-items: end;
|
||||
min-height: clamp(34rem, 88svh, 56rem);
|
||||
margin: var(--page-inset);
|
||||
padding-block: calc(var(--header-h) + var(--sp-6))
|
||||
clamp(3rem, 1.5rem + 6vw, 6rem);
|
||||
@@ -836,9 +844,13 @@ dd {
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none;
|
||||
/* negative margin + padding keeps the card glow from being clipped by the scroller */
|
||||
margin-inline: calc(var(--gap) * -1);
|
||||
padding: var(--sp-4) var(--gap);
|
||||
/* overflow-x:auto forces overflow-y to auto, so the hover glow (a ~3.75rem
|
||||
blur) would be clipped by the scroller. Don't switch to overflow:visible —
|
||||
that kills the horizontal scroll. Instead give the rail enough padding for
|
||||
the glow to paint INSIDE the scroll box, and pull it back with an equal
|
||||
negative margin so the visible spacing is unchanged. Scrolling stays intact. */
|
||||
margin: calc(var(--sp-5) * -1) calc(var(--gap) * -1);
|
||||
padding: 4.5rem var(--gap);
|
||||
}
|
||||
|
||||
.rail::-webkit-scrollbar {
|
||||
@@ -1696,7 +1708,10 @@ dd {
|
||||
transition: grid-template-rows 0.3s var(--ease);
|
||||
}
|
||||
|
||||
.acc__q[aria-expanded="true"] + .acc__a {
|
||||
/* The button sits inside an <h3>, so .acc__a is the h3's sibling — not the
|
||||
button's. Match on the .acc wrapper's expanded state instead of an adjacent
|
||||
sibling, which never matched through the heading. */
|
||||
.acc:has(.acc__q[aria-expanded="true"]) .acc__a {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
@@ -2044,10 +2059,6 @@ dd {
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
.hero {
|
||||
min-height: clamp(30rem, 84svh, 44rem);
|
||||
}
|
||||
|
||||
.booking {
|
||||
padding: var(--sp-2);
|
||||
}
|
||||
@@ -2717,6 +2728,13 @@ dd {
|
||||
border-radius: inherit;
|
||||
object-fit: cover;
|
||||
object-position: center 26%;
|
||||
/* Viator only ships ~720px images, which look soft blown up to a full-width
|
||||
banner. Rather than fight it, lean in: a gentle blur turns the photo into an
|
||||
intentional backdrop (the crisp image lives in the gallery lightbox). The
|
||||
scale hides the blur's feathered edges, which overflow:clip on .hero-tour
|
||||
then trims to the rounded frame. */
|
||||
filter: blur(10px) saturate(1.12);
|
||||
transform: scale(1.12);
|
||||
}
|
||||
|
||||
.hero-tour__sky {
|
||||
@@ -2732,9 +2750,9 @@ dd {
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(27, 147, 166, 0.82) 0%,
|
||||
rgba(44, 175, 194, 0.62) 45%,
|
||||
rgba(111, 208, 221, 0.78) 100%
|
||||
rgba(27, 147, 166, 0.55) 0%,
|
||||
rgba(44, 175, 194, 0.42) 45%,
|
||||
rgba(111, 208, 221, 0.72) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2838,7 +2856,7 @@ dd {
|
||||
|
||||
.hero-tour__figure--sydney {
|
||||
left: 1.05em;
|
||||
top: 0.35em;
|
||||
top: 0;
|
||||
height: 2.3em;
|
||||
scale: 1;
|
||||
}
|
||||
@@ -3013,6 +3031,119 @@ dd {
|
||||
border-radius: var(--r-card);
|
||||
}
|
||||
|
||||
.gallery img {
|
||||
cursor: zoom-in;
|
||||
transition: transform 0.25s var(--ease), filter 0.25s var(--ease);
|
||||
}
|
||||
|
||||
.gallery img:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
Gallery lightbox (built by vc-design.js)
|
||||
========================================================= */
|
||||
.vc-lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: clamp(1rem, 0.5rem + 3vw, 3.5rem);
|
||||
background: rgba(0, 0, 0, 0.86);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(6px);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.25s var(--ease),
|
||||
visibility 0s linear 0.25s;
|
||||
}
|
||||
|
||||
.vc-lightbox.is-open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition: opacity 0.25s var(--ease);
|
||||
}
|
||||
|
||||
.vc-lightbox__img {
|
||||
max-width: min(100%, 78rem);
|
||||
max-height: 84vh;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: var(--r-card);
|
||||
box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.5);
|
||||
transform: scale(0.96);
|
||||
transition: transform 0.25s var(--ease), opacity 0.15s var(--ease);
|
||||
}
|
||||
|
||||
.vc-lightbox.is-open .vc-lightbox__img {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.vc-lightbox__btn {
|
||||
position: absolute;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--r-pill);
|
||||
color: var(--c-white);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s var(--ease), transform 0.2s var(--ease);
|
||||
}
|
||||
|
||||
.vc-lightbox__btn:hover {
|
||||
background: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
|
||||
.vc-lightbox__close {
|
||||
top: clamp(1rem, 3vw, 2rem);
|
||||
right: clamp(1rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
.vc-lightbox__prev,
|
||||
.vc-lightbox__next {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.vc-lightbox__prev {
|
||||
left: clamp(0.5rem, 2vw, 2rem);
|
||||
}
|
||||
|
||||
.vc-lightbox__next {
|
||||
right: clamp(0.5rem, 2vw, 2rem);
|
||||
}
|
||||
|
||||
.vc-lightbox__prev:hover,
|
||||
.vc-lightbox__next:hover {
|
||||
transform: translateY(-50%) scale(1.06);
|
||||
}
|
||||
|
||||
.vc-lightbox__count {
|
||||
position: absolute;
|
||||
bottom: clamp(1rem, 3vw, 2rem);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* single image → hide the arrows */
|
||||
.vc-lightbox--single .vc-lightbox__prev,
|
||||
.vc-lightbox--single .vc-lightbox__next,
|
||||
.vc-lightbox--single .vc-lightbox__count {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* --- included / not included --- */
|
||||
.lists {
|
||||
display: grid;
|
||||
@@ -3437,11 +3568,6 @@ dd {
|
||||
border-color: var(--c-black);
|
||||
}
|
||||
|
||||
white);
|
||||
border-color: var(--c-black);
|
||||
background: var(--c-black);
|
||||
}
|
||||
|
||||
/* --- reviews --- */
|
||||
.reviews {
|
||||
display: flex;
|
||||
|
||||
BIN
removed-images/bus-iso.png
Normal file
|
After Width: | Height: | Size: 320 KiB |
BIN
removed-images/bus-open-top.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
removed-images/ellipse-17-2.png
Normal file
|
After Width: | Height: | Size: 344 B |
BIN
removed-images/ellipse-17.png
Normal file
|
After Width: | Height: | Size: 344 B |
BIN
removed-images/hero.png
Normal file
|
After Width: | Height: | Size: 630 KiB |
BIN
removed-images/hero2.png
Normal file
|
After Width: | Height: | Size: 696 KiB |
BIN
removed-images/hero3.jpg
Normal file
|
After Width: | Height: | Size: 825 KiB |
BIN
removed-images/tower.png
Normal file
|
After Width: | Height: | Size: 164 KiB |
BIN
viator-collector.zip
Normal file
@@ -16,23 +16,31 @@ listing/single/section assets load only on pages that use them.
|
||||
| `[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_bus_tours]` | "Open Top Bus Tours" promo + 4-up card grid | `viator_tour` posts |
|
||||
| `[viator_about]` | Static "About / Memorable" block + stat counters | Shortcode attributes |
|
||||
| `[viator_articles]` | Rail of latest blog-post cards | `post` 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:
|
||||
Home page matching the design mockup (`new front/index.html`), top to bottom:
|
||||
|
||||
```
|
||||
[viator_hero]
|
||||
[viator_hero image_id="125304"]
|
||||
[viator_top_destinations]
|
||||
[viator_bus_tours image_id="125310"]
|
||||
[viator_top_tours]
|
||||
[viator_top_attractions]
|
||||
[viator_browse_activities]
|
||||
[viator_about image_id="125311"]
|
||||
[viator_testimonials]
|
||||
[viator_articles]
|
||||
[viator_faq]
|
||||
```
|
||||
|
||||
Heavy decorative images (hero photo, open-top bus, isometric bus) are **not
|
||||
bundled** — upload them in **Media** and pass the attachment `image_id` (exactly
|
||||
like `[viator_hero image_id="…"]`). Icons and tiny decorations are inline SVG.
|
||||
|
||||
---
|
||||
|
||||
## `[viator_hero]`
|
||||
@@ -99,6 +107,71 @@ are hidden automatically.
|
||||
|
||||
---
|
||||
|
||||
## `[viator_bus_tours]`
|
||||
|
||||
The "Open Top Bus Tours…" promo block (route line, headline, "See all", a chip,
|
||||
a bus illustration and note) followed by a 4-up grid of tour cards. Selects tours
|
||||
with the **same attributes as the tour rails** (`ids` / `codes` / `destination` /
|
||||
`tag` / `limit` / `orderby`), plus:
|
||||
|
||||
| Attribute | Default | Description |
|
||||
|---|---|---|
|
||||
| `title` | `Open Top<br>Bus Tours…` | Promo headline (`<br>` allowed). |
|
||||
| `note` | *(default copy)* | Paragraph under the headline. |
|
||||
| `chip` | `Very Fun!` | Small accent chip. |
|
||||
| `image_id` | — | **Media attachment ID** of the open-top bus illustration. |
|
||||
| `image` | — | …or a direct image URL (use `image_id` when possible). |
|
||||
|
||||
```
|
||||
[viator_bus_tours image_id="125310" tag="hop-on-hop-off" limit="4"]
|
||||
[viator_bus_tours ids="482,517,690,701"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `[viator_about]`
|
||||
|
||||
Static "About" marketing block: headline with an accent chip, a paragraph, four
|
||||
stat counters and a decorative isometric bus. All copy is editable.
|
||||
|
||||
| Attribute | Default | Description |
|
||||
|---|---|---|
|
||||
| `title` | `Hop On Hop Off` | Headline. |
|
||||
| `highlight` | `Memorable` | Accent chip appended to the headline. |
|
||||
| `text` | *(default copy)* | Paragraph. |
|
||||
| `button_text` | `About us` | Button label. |
|
||||
| `button_url` | *(tours archive)* | Button link. |
|
||||
| `image_id` | — | **Media attachment ID** of the isometric bus illustration. |
|
||||
| `image` | — | …or a direct image URL. |
|
||||
| `stats` | *(4 defaults)* | `25+|Years Experience;;500+|Tours Conducted` — `|` separates number from label, `;;` separates stats. A trailing `+` is styled. |
|
||||
|
||||
```
|
||||
[viator_about image_id="125311" stats="25+|Years Experience;;500+|Tours Conducted;;430+|Tours this Year;;15+|Different Tours"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `[viator_articles]`
|
||||
|
||||
A rail of the latest blog posts styled as the design's "Latest Articles" cards
|
||||
(featured image, relative time, title, excerpt → links to the post).
|
||||
|
||||
| Attribute | Default | Description |
|
||||
|---|---|---|
|
||||
| `title` | `Latest<br>Articles` | Heading (`<br>` allowed). |
|
||||
| `lead` | *(default copy)* | Sub-heading. |
|
||||
| `limit` | `8` | Number of posts. |
|
||||
| `category` | — | Restrict to a category slug. |
|
||||
| `view_all` | *(posts page)* | "See all" link; defaults to the blog page. |
|
||||
|
||||
Cards use each post's **featured image**; set one so the card isn't blank.
|
||||
|
||||
```
|
||||
[viator_articles limit="8" category="travel-tips"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `[viator_faq]`
|
||||
|
||||
FAQ accordion.
|
||||
|
||||
BIN
viator-collector/assets.zip
Normal file
270
viator-collector/assets/css/vc-city.css
Normal file
@@ -0,0 +1,270 @@
|
||||
/* City guide on a destination page (VC_City_Guide): tagline in the banner,
|
||||
facts bar, About and Seasons above the tours. */
|
||||
|
||||
.vc-app.vc-app .hero__tagline {
|
||||
order: 2;
|
||||
max-width: 40ch;
|
||||
margin: calc(var(--sp-2) * -1) 0 0;
|
||||
color: var(--c-white);
|
||||
font-size: clamp(1.05rem, 0.9rem + 0.6vw, 1.375rem);
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
/* ---------- facts bar ---------- */
|
||||
.vc-app.vc-app .cty-top {
|
||||
margin-block-start: var(--sp-5);
|
||||
}
|
||||
|
||||
.vc-app.vc-app .cty-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
margin: 0;
|
||||
padding: var(--sp-3) var(--sp-2);
|
||||
list-style: none;
|
||||
background: var(--c-white);
|
||||
border-radius: var(--r-card);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08), 0 0 0 1px var(--c-line);
|
||||
}
|
||||
|
||||
.vc-app.vc-app .cfact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
margin: 0;
|
||||
padding: 0.25rem 1rem;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .cfact + .cfact {
|
||||
border-inline-start: 1px solid var(--c-line);
|
||||
}
|
||||
|
||||
.vc-app.vc-app .cfact__ic {
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 50%;
|
||||
color: #0f7f90;
|
||||
background: var(--c-teal-20);
|
||||
}
|
||||
|
||||
.vc-app.vc-app .cfact__ic svg {
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .cfact small {
|
||||
display: block;
|
||||
color: var(--c-muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .cfact b {
|
||||
display: block;
|
||||
color: var(--c-ink);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ---------- shared headings ---------- */
|
||||
.vc-app.vc-app .cty-eyebrow {
|
||||
margin: 0 0 var(--sp-2);
|
||||
}
|
||||
|
||||
.vc-app.vc-app .cty-h2 {
|
||||
margin: 0 0 var(--sp-3);
|
||||
color: var(--c-ink);
|
||||
font-size: var(--fs-h2);
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .cty-tours-h {
|
||||
margin-block-end: var(--sp-4);
|
||||
}
|
||||
|
||||
/* ---------- about ---------- */
|
||||
.vc-app.vc-app .cty-about__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 5fr 6fr;
|
||||
gap: var(--sp-6);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .cty-about__grid--text {
|
||||
grid-template-columns: minmax(0, 48rem);
|
||||
}
|
||||
|
||||
.vc-app.vc-app .cty-about__img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: var(--r-xl);
|
||||
}
|
||||
|
||||
.vc-app.vc-app .cty-about__text {
|
||||
position: relative;
|
||||
color: rgba(0, 0, 0, 0.72);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .cty-about__text p {
|
||||
margin: 0 0 0.8rem;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .cty-about__text.is-clamped {
|
||||
max-height: 11.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .cty-about__text.is-clamped::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: auto 0 0;
|
||||
height: 4rem;
|
||||
background: linear-gradient(rgba(255, 255, 255, 0), var(--c-white));
|
||||
}
|
||||
|
||||
.vc-app.vc-app .cty-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-block-start: var(--sp-2);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: var(--c-ink);
|
||||
background: none;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .cty-more[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .cty-more[aria-expanded="true"] .icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* ---------- seasons ---------- */
|
||||
.vc-app.vc-app .cty-seasons {
|
||||
padding-block-start: 0;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .seasons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: var(--gap);
|
||||
margin: var(--sp-4) 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .season {
|
||||
--tint: #e9f7f9;
|
||||
--ink: #0f7f90;
|
||||
margin: 0;
|
||||
padding: var(--sp-4);
|
||||
border-radius: var(--r-card);
|
||||
background: var(--tint);
|
||||
}
|
||||
|
||||
.vc-app.vc-app .season--summer { --tint: #fff8db; --ink: #a37b00; }
|
||||
.vc-app.vc-app .season--fall { --tint: #fdeee6; --ink: #c2551b; }
|
||||
.vc-app.vc-app .season--winter { --tint: #eef1fb; --ink: #4a64c9; }
|
||||
|
||||
.vc-app.vc-app .season__ic {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin-block-end: var(--sp-3);
|
||||
border-radius: 50%;
|
||||
color: var(--ink);
|
||||
background: var(--c-white);
|
||||
}
|
||||
|
||||
.vc-app.vc-app .season__ic svg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .season h3 {
|
||||
margin: 0;
|
||||
color: var(--c-ink);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .season small {
|
||||
display: block;
|
||||
margin: 0.15rem 0 0.7rem;
|
||||
color: var(--ink);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .season p {
|
||||
margin: 0;
|
||||
color: rgba(0, 0, 0, 0.7);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* The tours follow the guide directly: no double section gap. */
|
||||
.vc-app.vc-app .cty-seasons + #tour-list,
|
||||
.vc-app.vc-app .cty-about + #tour-list {
|
||||
padding-block-start: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.vc-app.vc-app .cty-facts {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
row-gap: var(--sp-3);
|
||||
padding-inline: 0.25rem;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .cfact {
|
||||
padding-inline: 0.6rem;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .cfact:nth-child(odd) {
|
||||
border-inline-start: 0;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .cfact__ic {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .cfact b {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .cty-about__grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
/* Seasons swipe sideways. */
|
||||
.vc-app.vc-app .seasons {
|
||||
display: flex;
|
||||
margin-inline: calc(var(--gutter) * -1);
|
||||
padding-inline: var(--gutter);
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .season {
|
||||
flex: 0 0 78%;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
}
|
||||
162
viator-collector/assets/css/vc-tour.css
Normal file
@@ -0,0 +1,162 @@
|
||||
/* Single tour — top section (photos + booking card first) and the mobile
|
||||
booking bar. Hand-written, loaded after vc-design.css on tour pages only. */
|
||||
|
||||
/* No hero any more: the header turns solid (chrome.js) and the page starts
|
||||
with the breadcrumb. */
|
||||
.vc-app.vc-app .tour--top {
|
||||
padding-block-start: var(--sp-5);
|
||||
}
|
||||
|
||||
.vc-app.vc-app .tour__crumbs {
|
||||
margin-block-end: var(--sp-3);
|
||||
color: var(--c-ink);
|
||||
}
|
||||
|
||||
/* ---------- gallery: one large photo + two, the rest in the lightbox ---------- */
|
||||
.vc-app.vc-app .tgal {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
grid-template-rows: repeat(2, clamp(9rem, 6rem + 6vw, 12.5rem));
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.vc-app.vc-app .tgal img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: auto;
|
||||
border-radius: var(--r-card);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .tgal .tgal__main {
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .tgal .tgal__extra {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .tgal--few {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .tgal--few .tgal__thumb {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .tgal__all {
|
||||
position: absolute;
|
||||
right: var(--sp-2);
|
||||
bottom: var(--sp-2);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border: 0;
|
||||
border-radius: var(--r-pill);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: var(--c-ink);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ---------- title row under the photos ---------- */
|
||||
.vc-app.vc-app .tour--top .tour-head__title {
|
||||
font-size: clamp(1.5rem, 1.1rem + 1.4vw, 2.25rem);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .tmeta__reviews {
|
||||
margin-inline-start: 0.25rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ---------- mobile booking bar ---------- */
|
||||
.vc-app.vc-app .tbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 68.75rem) {
|
||||
/* The bar does the booking now: the full card moves below the content. */
|
||||
.vc-app.vc-app .tour--top .tour__aside {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .tbar {
|
||||
position: fixed;
|
||||
inset: auto 0 0 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
padding: 0.75rem var(--gutter) calc(0.75rem + env(safe-area-inset-bottom));
|
||||
background: var(--c-white);
|
||||
box-shadow: 0 -6px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.vc-app.vc-app .tbar__price {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .tbar__price span {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .tbar__price b {
|
||||
font-size: 1.375rem;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .tbar__cta {
|
||||
background: var(--c-accent);
|
||||
color: var(--c-black);
|
||||
}
|
||||
|
||||
/* room for the bar so it never covers the footer */
|
||||
.vc-app.vc-single {
|
||||
padding-block-end: 5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
/* Swipe through every photo; tap one (or the button) for the lightbox. */
|
||||
.vc-app.vc-app .tgal {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-width: none;
|
||||
margin-inline: calc(var(--gutter) * -1);
|
||||
padding-inline: var(--gutter);
|
||||
}
|
||||
|
||||
.vc-app.vc-app .tgal::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vc-app.vc-app .tgal img,
|
||||
.vc-app.vc-app .tgal .tgal__extra,
|
||||
.vc-app.vc-app .tgal--few .tgal__thumb {
|
||||
display: block;
|
||||
flex: 0 0 88%;
|
||||
aspect-ratio: 4 / 3;
|
||||
scroll-snap-align: center;
|
||||
}
|
||||
|
||||
/* every photo is already in the swipe row */
|
||||
.vc-app.vc-app .tgal__all {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Compact 2 × 2 facts instead of four stacked full-width cards. */
|
||||
.vc-app.vc-app .tour--top .facts {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<svg class="sprite" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg class="sprite" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" style="position:absolute;width:0;height:0;overflow:hidden">
|
||||
<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>
|
||||
|
||||
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 440 KiB |
|
Before Width: | Height: | Size: 321 B |
|
Before Width: | Height: | Size: 321 B |
|
Before Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 595 KiB |
@@ -86,10 +86,82 @@
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- city guide: long About text folds behind "Read more" ---------- */
|
||||
Array.prototype.forEach.call(app.querySelectorAll("[data-vc-clamp]"), function (text) {
|
||||
var btn = text.nextElementSibling;
|
||||
text.classList.add("is-clamped");
|
||||
if (!btn || text.scrollHeight <= text.clientHeight + 8) {
|
||||
text.classList.remove("is-clamped");
|
||||
return;
|
||||
}
|
||||
btn.hidden = false;
|
||||
btn.addEventListener("click", function () {
|
||||
var open = text.classList.toggle("is-clamped") === false;
|
||||
btn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
btn.querySelector("span").textContent = open ? "Read less" : "Read more";
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- 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" });
|
||||
});
|
||||
|
||||
/* ---------- gallery lightbox (zoom / view photos) ---------- */
|
||||
(function () {
|
||||
var imgs = Array.prototype.slice.call(app.querySelectorAll(".gallery img"));
|
||||
if (!imgs.length) return;
|
||||
|
||||
var host = imgs[0].closest(".vc-app") || document.body;
|
||||
var box = document.createElement("div");
|
||||
box.className = "vc-lightbox" + (imgs.length < 2 ? " vc-lightbox--single" : "");
|
||||
box.setAttribute("role", "dialog");
|
||||
box.setAttribute("aria-modal", "true");
|
||||
box.innerHTML =
|
||||
'<button class="vc-lightbox__btn vc-lightbox__prev" type="button" aria-label="Previous"><svg class="icon icon--24" aria-hidden="true"><use href="#i-arrow-left"></use></svg></button>' +
|
||||
'<img class="vc-lightbox__img" alt="">' +
|
||||
'<button class="vc-lightbox__btn vc-lightbox__next" type="button" aria-label="Next"><svg class="icon icon--24" aria-hidden="true"><use href="#i-arrow-right"></use></svg></button>' +
|
||||
'<button class="vc-lightbox__btn vc-lightbox__close" type="button" aria-label="Close"><svg class="icon icon--24" aria-hidden="true"><use href="#i-x"></use></svg></button>' +
|
||||
'<span class="vc-lightbox__count"></span>';
|
||||
host.appendChild(box);
|
||||
|
||||
var bigImg = box.querySelector(".vc-lightbox__img");
|
||||
var count = box.querySelector(".vc-lightbox__count");
|
||||
var idx = 0;
|
||||
|
||||
function show(i) {
|
||||
idx = (i + imgs.length) % imgs.length;
|
||||
bigImg.src = imgs[idx].currentSrc || imgs[idx].src;
|
||||
bigImg.alt = imgs[idx].alt || "";
|
||||
count.textContent = (idx + 1) + " / " + imgs.length;
|
||||
}
|
||||
function open(i) {
|
||||
show(i);
|
||||
box.classList.add("is-open");
|
||||
document.documentElement.style.overflow = "hidden";
|
||||
}
|
||||
function close() {
|
||||
box.classList.remove("is-open");
|
||||
document.documentElement.style.overflow = "";
|
||||
}
|
||||
|
||||
imgs.forEach(function (img, i) {
|
||||
img.addEventListener("click", function () { open(i); });
|
||||
});
|
||||
box.querySelector(".vc-lightbox__prev").addEventListener("click", function () { show(idx - 1); });
|
||||
box.querySelector(".vc-lightbox__next").addEventListener("click", function () { show(idx + 1); });
|
||||
box.querySelector(".vc-lightbox__close").addEventListener("click", close);
|
||||
box.addEventListener("click", function (e) { if (e.target === box) close(); });
|
||||
/* tour page top gallery: "All N photos" opens at the first photo not on show */
|
||||
var allBtn = app.querySelector(".tgal__all");
|
||||
if (allBtn) allBtn.addEventListener("click", function () { open(Math.min(3, imgs.length - 1)); });
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (!box.classList.contains("is-open")) return;
|
||||
if (e.key === "Escape") close();
|
||||
else if (e.key === "ArrowLeft") show(idx - 1);
|
||||
else if (e.key === "ArrowRight") show(idx + 1);
|
||||
});
|
||||
})();
|
||||
})();
|
||||
|
||||
181
viator-collector/assets/js/vc-structure-admin.js
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Settings → Viator Structure editor. Regions and cities are plain DOM
|
||||
* blocks; cities can only be added from the Viator destination search, and
|
||||
* indexed field names are assigned on submit.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var cfg = window.vcStructure || {};
|
||||
var i18n = cfg.i18n || {};
|
||||
var form = document.getElementById('vc-structure-form');
|
||||
var list = document.getElementById('vc-regions');
|
||||
if (!form || !list) {
|
||||
return;
|
||||
}
|
||||
|
||||
function slugify(s) {
|
||||
return String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function fromTemplate(id) {
|
||||
var tpl = document.getElementById(id);
|
||||
return tpl.content.firstElementChild.cloneNode(true);
|
||||
}
|
||||
|
||||
function usedIds() {
|
||||
var ids = {};
|
||||
list.querySelectorAll('.vc-city [data-f="id"]').forEach(function (input) {
|
||||
ids[input.value] = true;
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
function move(el, dir) {
|
||||
if (dir < 0 && el.previousElementSibling) {
|
||||
el.parentNode.insertBefore(el, el.previousElementSibling);
|
||||
} else if (dir > 0 && el.nextElementSibling) {
|
||||
el.parentNode.insertBefore(el.nextElementSibling, el);
|
||||
}
|
||||
}
|
||||
|
||||
function addCity(region, item) {
|
||||
var row = fromTemplate('vc-tpl-city');
|
||||
row.querySelector('[data-f="id"]').value = item.id;
|
||||
row.querySelector('[data-f="name"]').value = item.name;
|
||||
row.querySelector('[data-f="slug"]').value = item.slug || slugify(item.name);
|
||||
row.querySelector('.vc-city__name').textContent = item.name;
|
||||
row.querySelector('.vc-city__id').textContent = item.id;
|
||||
region.querySelector('.vc-cities tbody').appendChild(row);
|
||||
}
|
||||
|
||||
function search(region) {
|
||||
var q = region.querySelector('.vc-city-search input[type="search"]');
|
||||
var out = region.querySelector('.vc-results');
|
||||
out.textContent = i18n.searching || '…';
|
||||
|
||||
var data = new FormData();
|
||||
data.append('action', 'vc_search_destinations');
|
||||
data.append('_ajax_nonce', cfg.nonce);
|
||||
data.append('q', q.value);
|
||||
|
||||
fetch(cfg.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: data })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (!res || !res.success) {
|
||||
out.textContent = (res && res.data && res.data.message) || i18n.failed;
|
||||
return;
|
||||
}
|
||||
if (!res.data.results.length) {
|
||||
out.textContent = i18n.noMatches;
|
||||
return;
|
||||
}
|
||||
var used = usedIds();
|
||||
out.innerHTML = '';
|
||||
res.data.results.forEach(function (item) {
|
||||
var row = document.createElement('div');
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'button button-small';
|
||||
if (used[item.id]) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = i18n.used;
|
||||
} else {
|
||||
btn.textContent = i18n.add;
|
||||
btn.addEventListener('click', function () {
|
||||
addCity(region, item);
|
||||
out.innerHTML = '';
|
||||
q.value = '';
|
||||
});
|
||||
}
|
||||
var label = document.createElement('span');
|
||||
label.textContent = item.name + ' (ID ' + item.id + ')';
|
||||
row.appendChild(btn);
|
||||
row.appendChild(label);
|
||||
out.appendChild(row);
|
||||
});
|
||||
})
|
||||
.catch(function () { out.textContent = i18n.failed; });
|
||||
}
|
||||
|
||||
list.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-act]');
|
||||
if (!btn) {
|
||||
return;
|
||||
}
|
||||
var region = btn.closest('.vc-region');
|
||||
var city = btn.closest('.vc-city');
|
||||
var target = city || region;
|
||||
switch (btn.getAttribute('data-act')) {
|
||||
case 'up': move(target, -1); break;
|
||||
case 'down': move(target, 1); break;
|
||||
case 'remove-city': city.remove(); break;
|
||||
case 'remove-region':
|
||||
if (window.confirm(i18n.confirm)) {
|
||||
region.remove();
|
||||
}
|
||||
break;
|
||||
case 'search': search(region); break;
|
||||
}
|
||||
});
|
||||
|
||||
list.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter' && e.target.matches('.vc-city-search input[type="search"]')) {
|
||||
e.preventDefault();
|
||||
search(e.target.closest('.vc-region'));
|
||||
}
|
||||
});
|
||||
|
||||
// Suggest a region slug from its name until the slug is edited by hand.
|
||||
list.addEventListener('input', function (e) {
|
||||
var input = e.target;
|
||||
if (input.matches('.vc-region__head [data-f="slug"]')) {
|
||||
input.dataset.touched = '1';
|
||||
}
|
||||
if (input.matches('.vc-region__head [data-f="name"]')) {
|
||||
var slug = input.closest('.vc-region__head').querySelector('[data-f="slug"]');
|
||||
if (!slug.dataset.touched && (slug.value === '' || slug.dataset.auto === '1')) {
|
||||
slug.value = slugify(input.value);
|
||||
slug.dataset.auto = '1';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('vc-add-region').addEventListener('click', function () {
|
||||
var region = fromTemplate('vc-tpl-region');
|
||||
list.appendChild(region);
|
||||
region.querySelector('[data-f="name"]').focus();
|
||||
});
|
||||
|
||||
function hidden(name) {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = name;
|
||||
input.value = '1';
|
||||
form.appendChild(input);
|
||||
}
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
var regions = list.querySelectorAll('.vc-region');
|
||||
if (!regions.length) {
|
||||
if (!window.confirm(i18n.confirmEmpty)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
hidden('vc_confirm_empty');
|
||||
}
|
||||
// Proves the editor ran: the server refuses a save without it, so a
|
||||
// failed script can never submit an empty structure by accident.
|
||||
hidden('vc_structure_present');
|
||||
regions.forEach(function (region, i) {
|
||||
region.querySelectorAll('.vc-region__head [data-f]').forEach(function (input) {
|
||||
input.name = 'vc_structure[' + i + '][' + input.dataset.f + ']';
|
||||
});
|
||||
region.querySelectorAll('.vc-city').forEach(function (city, j) {
|
||||
city.querySelectorAll('[data-f]').forEach(function (input) {
|
||||
input.name = 'vc_structure[' + i + '][cities][' + j + '][' + input.dataset.f + ']';
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -17,7 +17,11 @@
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
|
||||
const SCOPE = process.env.VC_SCOPE || '.vc-app';
|
||||
// Doubled class (`.vc-app.vc-app`) so every ported rule carries an extra class
|
||||
// of specificity — enough to win against Astra's content selectors (e.g.
|
||||
// `.ast-… .entry-content a`) without touching the markup, which still only
|
||||
// needs a single `class="vc-app"`. Override with VC_SCOPE if ever needed.
|
||||
const SCOPE = process.env.VC_SCOPE || '.vc-app.vc-app';
|
||||
|
||||
function prefixSelector(sel) {
|
||||
sel = sel.trim();
|
||||
|
||||
236
viator-collector/data/city-guides.php
Normal file
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
/**
|
||||
* Starter city guides, imported once into empty guides (see
|
||||
* VC_City_Guide::import_bundled()). The first six were recovered from the old
|
||||
* /tour-destination/{city}/ pages (Wayback Machine, 14 Aug 2026); the rest
|
||||
* were written for the site.
|
||||
*/
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
return array (
|
||||
'new-york-city' =>
|
||||
array (
|
||||
'language' => 'English',
|
||||
'currency' => 'US Dollar (USD)',
|
||||
'timezone' => 'GMT-5',
|
||||
'best_time' => 'April – June',
|
||||
'tagline' => 'NYC is the best ever!',
|
||||
'about_title' => 'New York’s Past: A Quick Look',
|
||||
'about' => '<p>New York began with the Lenape people on its islands and rivers. Dutch settlers arrived in the 1600s, building a trade hub. Today, it’s a global city famed for finance, art, and vibrant culture. Historic brownstones stand alongside gleaming skyscrapers, blending old and new. From iconic landmarks to diverse neighborhoods, New York offers a dynamic mix of history and energy, making it an exciting destination for visitors to explore and enjoy.</p>
|
||||
<p>New York City’s hop-on hop-off bus tour, bookable at hoponhopofftickets.com , is a dynamic way to explore with operators like Big Bus Tours. Covering 15 stops, including Times Square, Empire State Building, and Central Park, buses run every 10-15 minutes with a 90-minute loop. Attracting over 60 million annual visitors, it offers multilingual audio guides and wheelchair-accessible buses, though upper decks have restrictions. A downtown loop and night tour showcase Manhattan’s iconic skyline. Pets (except service dogs) are not allowed. Book at hoponhopofftickets.com for an unforgettable NYC adventure.</p>',
|
||||
'spring' => 'Stroll Central Park for vibrant blooming trees or photograph the iconic Brooklyn Bridge. Enjoy a classic bagel at Ess-a-Bagel or catch a Yankees game at the lively stadium.',
|
||||
'summer' => 'Relax at Coney Island’s beach or bike the Hudson River’s scenic path. Savor street food at Smorgasburg or enjoy free summer concerts in vibrant Central Park.',
|
||||
'fall' => 'Wander the High Line for stunning fall colors or explore Greenwich Village’s charming streets. Sip coffee in trendy SoHo or attend the New York Film Festival.',
|
||||
'winter' => 'Skate at Rockefeller Center’s rink or admire Dyker Heights’ holiday lights. On chilly days, visit the Met Museum or shop at iconic Fifth Avenue stores.',
|
||||
),
|
||||
'new-orleans' =>
|
||||
array (
|
||||
'language' => 'English',
|
||||
'currency' => 'US Dollar (USD)',
|
||||
'timezone' => 'GMT-6',
|
||||
'best_time' => 'February – May',
|
||||
'tagline' => 'America’s soulful city',
|
||||
'about_title' => 'New Orleans Past',
|
||||
'about' => '<p>New Orleans began with Chitimacha and Choctaw peoples along the Mississippi River. In the 1700s, French settlers established a vibrant port town thriving on music and trade. Today, it’s a cultural gem celebrated for jazz, Creole cuisine, and festive parties. Historic French Quarter buildings stand beside lively bars, blending old-world charm with modern energy. From iconic festivals to soulful streets, New Orleans offers a unique mix of history and revelry, making it an exciting destination to explore.</p>
|
||||
<p>New Orleans’ hop-on hop-off bus tour, bookable at hoponhopofftickets.com, is a lively way to explore the Crescent City with City Sightseeing. Covering 19 stops, including the French Quarter, Garden District, and Jackson Square, buses run every 30-45 minutes with a 90-minute loop. Popular with over 17 million annual visitors, it offers live or audio-guided commentary in English, with wheelchair-accessible buses (lower deck only). A free self-guided walking tour of the French Quarter is included. Pets (except service dogs) are not allowed. Book at hoponhopofftickets.com for a vibrant Big Easy adventure.</p>',
|
||||
'spring' => 'Walk the French Quarter for vibrant street music or snap photos at Jackson Square. Savor beignets at Café du Monde or catch a Mardi Gras parade with colorful floats.',
|
||||
'summer' => 'Enjoy a Mississippi riverboat ride or savor gumbo in the Garden District. Experience live music at Essence Festival or cool off with sno-balls at a local stand.',
|
||||
'fall' => 'Wander City Park for autumn vibes or join a spooky ghost tour. Sip cocktails on lively Bourbon Street or enjoy music and art at Voodoo Fest.',
|
||||
'winter' => 'Admire holiday lights in the French Quarter or enjoy warm beignets at Café du Monde. On mild days, bike along the Mississippi River or shop at the vibrant French Market.',
|
||||
),
|
||||
'miami' =>
|
||||
array (
|
||||
'language' => 'English',
|
||||
'currency' => 'US Dollar (USD)',
|
||||
'timezone' => 'GMT-5',
|
||||
'best_time' => 'December – April',
|
||||
'tagline' => 'Where is America’s tropical star?',
|
||||
'about_title' => 'Miami’s Past',
|
||||
'about' => '<p>Miami began with the Tequesta people along its ocean and rivers. In the 1800s, it emerged as a trading hub, later booming with tourism and vibrant Latin culture. Today, it’s a global city renowned for pristine beaches, dynamic art, and pulsating music. Historic Art Deco buildings stand beside sleek modern clubs, blending retro charm with contemporary energy. From colorful neighborhoods to iconic shorelines, Miami offers a lively mix of culture and history, making it an exciting destination to explore.</p>
|
||||
<p>Miami’s hop-on hop-off bus tour, bookable at hoponhopofftickets.com, offers a lively way to explore with Big Bus Tours. Covering 11 stops, including South Beach, Wynwood Walls, and Little Havana, buses run every 30 minutes with a 90-minute loop. Popular with over 8 million annual visitors to Miami, it features multilingual audio guides and wheelchair-accessible buses, though upper decks have restrictions. A night tour highlights Miami’s vibrant skyline. Pets (except service dogs) are not allowed. Book at hoponhopofftickets.com for a colorful city adventure.</p>',
|
||||
'spring' => 'Stroll South Beach for sun and vibrant people-watching or photograph Wynwood Walls’ colorful street art. Kayak in scenic Biscayne Bay or join Spring Break parties for lively vibes.',
|
||||
'summer' => 'Swim at Key Biscayne’s beaches or sail around Biscayne Bay. Enjoy ceviche in vibrant Little Havana or dance to live music at the Ultra Music Festival.',
|
||||
'fall' => 'Wander Ocean Drive for vibrant neon lights or explore Everglades National Park for alligator spotting. Sip mojitos in Coconut Grove or browse the Miami Book Fair for exciting reads.',
|
||||
'winter' => 'Enjoy Miami Beach’s festive holiday lights or dance at a lively salsa club. On sunny days, bike the scenic Venetian Causeway or shop at Lincoln Road’s vibrant outdoor mall.',
|
||||
),
|
||||
'las-vegas' =>
|
||||
array (
|
||||
'language' => 'English',
|
||||
'currency' => 'US Dollar (USD)',
|
||||
'timezone' => 'GMT-8',
|
||||
'best_time' => 'March – May',
|
||||
'tagline' => 'America’s fun desert',
|
||||
'about_title' => 'Las Vegas’s Past: A Quick Look',
|
||||
'about' => '<p>Las Vegas began with the Paiute people in desert oases. In the 1900s, it grew as a railway hub, later booming with casinos and vibrant shows. Today, it’s the world’s entertainment capital, famed for dazzling glitz, high-stakes gambling, and grand dreams. Retro neon signs stand beside sleek modern resorts, blending nostalgia with extravagance. From iconic Strip landmarks to electrifying nightlife, Las Vegas offers a thrilling mix of history and spectacle, making it an exciting destination to explore.</p>
|
||||
<p>Las Vegas’ hop-on hop-off bus tour, bookable at hoponhopofftickets.com, is a vibrant way to explore Sin City with Big Bus Tours. The Red Route covers 9 stops, including the Welcome to Fabulous Las Vegas Sign, Bellagio Fountains, and Treasure Island, with buses every 30-60 minutes and a 2.5-hour loop. Attracting over 41 million annual visitors, it offers pre-recorded English and Spanish commentary, wheelchair-accessible lower decks, and a 2-hour night tour showcasing the Strip’s neon lights. Pets (except service dogs) are not allowed, and upper decks restrict mobility-impaired guests for safety. Book at hoponhopofftickets.com.</p>',
|
||||
'spring' => 'Stroll the Strip for dazzling neon lights or photograph the iconic “Welcome to Las Vegas” sign. Hike Red Rock Canyon for stunning desert views or join a lively pool party at the Cosmopolitan.',
|
||||
'summer' => 'Relax at Mandalay Bay’s beach pool or enjoy the Blue Man Group show. Savor tacos at Fremont Street’s food stalls or explore the Neon Museum’s glowing displays at night.',
|
||||
'fall' => 'Wander Valley of Fire’s striking red rocks or watch Bellagio’s mesmerizing fountain show. Sip cocktails at a chic rooftop bar or enjoy the Life is Beautiful music and art festival.',
|
||||
'winter' => 'Admire holiday lights at Ethel M Cactus Garden or enjoy a buffet at Caesars Palace. On mild days, golf at a nearby course or shop at the Grand Canal Shoppes.',
|
||||
),
|
||||
'montreal' =>
|
||||
array (
|
||||
'language' => 'French',
|
||||
'currency' => 'Canadian Dollar (CAD)',
|
||||
'timezone' => 'GMT-5',
|
||||
'best_time' => 'June – September',
|
||||
'tagline' => 'Enjoy Canada’s artsy star!',
|
||||
'about_title' => 'Montreal’s Past',
|
||||
'about' => '<p>Montreal began with the Kanien’kehá:ka thriving along the St. Lawrence River. In the 1600s, French settlers established a town that became a bustling trade hub. Today, as Quebec’s cultural heart, it blends French heritage with global influences. Historic stone buildings stand alongside vibrant modern art, with music filling the air. From lively festivals to charming cobblestone streets, Montreal’s unique fusion of old and new creates a dynamic, inviting atmosphere perfect for exploration and discovery.</p>
|
||||
<p>Montreal’s hop-on hop-off bus tour, bookable at hoponhopofftickets.com, is a convenient way to explore Quebec’s vibrant metropolis with Gray Line Montreal. The double-decker buses cover 10 stops, including Notre-Dame Basilica, Old Port, Chinatown, and Mount Royal Park, with a 2-hour loop and departures every 15-30 minutes seasonally. Popular with over 7 million annual visitors to the Old Port alone, the tour offers bilingual (English/French) live commentary, though some reviews note inconsistent guide quality. Wheelchair access is limited, and strollers may face restrictions. Book at hoponhopofftickets.com for a flexible city adventure.</p>',
|
||||
'spring' => 'Stroll Mount Royal for lush views and skyline shots. Savor a bagel at St-Viateur or wander Old Montreal’s historic streets. Bike the Lachine Canal for a scenic spring adventure.',
|
||||
'summer' => 'Relax at Parc Jean-Drapeau for festivals like Osheaga or picnic by the St. Lawrence River. Enjoy street food at La Ronde or catch live music at the Montreal Jazz Festival.',
|
||||
'fall' => 'Wander Parc La Fontaine for vibrant fall foliage or stroll the Plateau’s colorful streets. Sip coffee at a Mile End café or enjoy films at the Montreal International Documentary Festival.',
|
||||
'winter' => 'Skate at Old Port’s rink or sip hot chocolate at Igloofest’s music fest. On snowy days, explore the Montreal Museum of Fine Arts or shop in the Underground City. ',
|
||||
),
|
||||
'vancouver' =>
|
||||
array (
|
||||
'language' => 'English',
|
||||
'currency' => 'Canadian Dollar (CAD)',
|
||||
'timezone' => 'GMT-8',
|
||||
'best_time' => 'June – September',
|
||||
'tagline' => 'Vancouver’s got the magic.',
|
||||
'about_title' => 'Vancouver’s Story: A Quick Dive into Its Cool Past',
|
||||
'about' => '<p>Vancouver blends chill and vibrant energy, rooted in Coast Salish traditions of thriving on fish and forests. The 1800s gold rush and railway transformed Gastown into a bustling hub. Now, as “Hollywood North,” it buzzes with film crews, diverse global cuisine, and eco-conscious style. Neon-lit nights and Indigenous art create a dynamic cultural mosaic. From Stanley Park’s greenery to urban energy, Vancouver invites exploration with its unique mix of nature, culture, and modern flair, perfect for diving in.</p>',
|
||||
),
|
||||
|
||||
/*
|
||||
* Written for the site (no old page existed for these cities). Review
|
||||
* before relying on them; they are only imported into empty guides.
|
||||
*/
|
||||
'boston' =>
|
||||
array (
|
||||
'language' => 'English',
|
||||
'currency' => 'US Dollar (USD)',
|
||||
'timezone' => 'GMT-5',
|
||||
'best_time' => 'May – October',
|
||||
'tagline' => 'Where America’s story began.',
|
||||
'about_title' => 'Boston’s Past: A Quick Look',
|
||||
'about' => '<p>Boston began on the Shawmut Peninsula, home to the Massachusett people, before Puritan settlers founded the town in 1630. It became the spark of the American Revolution, from the Boston Tea Party to Paul Revere’s midnight ride. Today it is a city of world-famous universities, historic brick streets and a lively waterfront, where colonial meeting houses sit beside glass towers.</p>
|
||||
<p>A hop-on hop-off tour, bookable at hoponhopofftickets.com, is an easy way to see it all: follow the Freedom Trail past Faneuil Hall and the Old State House, cross into Cambridge for Harvard and MIT, and hop off for Beacon Hill, the North End and the Seaport. Book at hoponhopofftickets.com and explore Boston at your own pace.</p>',
|
||||
'spring' => 'Watch the Public Garden bloom and ride the Swan Boats, or cheer on runners at the Boston Marathon in April. Catch an early-season Red Sox game at Fenway Park.',
|
||||
'summer' => 'Walk the Freedom Trail in the sunshine, take a harbor cruise to the Boston Harbor Islands or join a whale-watching trip. Enjoy cannoli in the North End and free concerts on the Esplanade.',
|
||||
'fall' => 'See New England’s famous fall colors on a day trip, stroll Harvard Yard among the leaves or visit Salem for its October festivities. Warm up with clam chowder at Quincy Market.',
|
||||
'winter' => 'Skate on the Frog Pond in Boston Common and admire the holiday lights. On cold days, explore the Museum of Fine Arts or the Isabella Stewart Gardner Museum.',
|
||||
),
|
||||
'chicago' =>
|
||||
array (
|
||||
'language' => 'English',
|
||||
'currency' => 'US Dollar (USD)',
|
||||
'timezone' => 'GMT-6',
|
||||
'best_time' => 'May – September',
|
||||
'tagline' => 'The Windy City of big skylines.',
|
||||
'about_title' => 'Chicago’s Past: A Quick Look',
|
||||
'about' => '<p>Chicago grew from a trading post where the Chicago River meets Lake Michigan, land long home to the Potawatomi people. After the Great Fire of 1871, the city rebuilt itself with daring new buildings and gave the world the skyscraper. Today it is famous for bold architecture, blues and jazz, deep-dish pizza and a lakefront lined with beaches and parks.</p>
|
||||
<p>A hop-on hop-off tour, bookable at hoponhopofftickets.com, links the highlights: the Magnificent Mile, Millennium Park and “The Bean,” Willis Tower, Navy Pier and the Museum Campus. Pair it with an architecture river cruise for the best views of the skyline. Book at hoponhopofftickets.com for a classic Chicago day out.</p>',
|
||||
'spring' => 'See the river dyed green for St. Patrick’s Day and the tulips along Michigan Avenue. Catch a Cubs game at historic Wrigley Field as the season opens.',
|
||||
'summer' => 'Take an architecture boat tour on the Chicago River, relax on North Avenue Beach or ride the Ferris wheel at Navy Pier. Join outdoor festivals and free concerts in Millennium Park.',
|
||||
'fall' => 'Walk Lincoln Park under golden trees, or watch the Chicago Marathon in October. Spend an afternoon at the Art Institute and finish with deep-dish pizza.',
|
||||
'winter' => 'Skate at the Maggie Daley Park ribbon and browse the German-style Christkindlmarket. Admire the holiday lights on the Magnificent Mile, then warm up in a cozy jazz club.',
|
||||
),
|
||||
'los-angeles' =>
|
||||
array (
|
||||
'language' => 'English, Spanish',
|
||||
'currency' => 'US Dollar (USD)',
|
||||
'timezone' => 'GMT-8',
|
||||
'best_time' => 'March – May',
|
||||
'tagline' => 'Where the stars come out to play.',
|
||||
'about_title' => 'Los Angeles’ Past: A Quick Look',
|
||||
'about' => '<p>Los Angeles began as the home of the Tongva people and was founded as a small Spanish pueblo in 1781. Oil, oranges and railroads brought rapid growth, and in the early 1900s Hollywood turned it into the movie capital of the world. Today it is a sprawling, sunny city of beaches, film studios, palm-lined boulevards and neighborhoods from every corner of the globe.</p>
|
||||
<p>Because the city is so spread out, a hop-on hop-off tour, bookable at hoponhopofftickets.com, is one of the easiest ways to see it: the Hollywood Walk of Fame, the TCL Chinese Theatre, Beverly Hills and Rodeo Drive, with routes out to Santa Monica and the coast. Book at hoponhopofftickets.com and leave the driving to someone else.</p>',
|
||||
'spring' => 'Hike Griffith Park for views of the Hollywood Sign and the city, or watch wildflowers bloom in the nearby hills. Stroll Olvera Street to see where the city began.',
|
||||
'summer' => 'Spend a day at Santa Monica Pier or the boardwalk in Venice Beach. Catch an outdoor movie or a concert at the Hollywood Bowl on a warm evening.',
|
||||
'fall' => 'Enjoy warm, clear days on Malibu’s beaches, or tour a working film studio. Visit the Getty Center for art, gardens and sunset views.',
|
||||
'winter' => 'Expect mild sunshine for walking the Hollywood Walk of Fame and shopping on Rodeo Drive. Spot migrating gray whales on a boat trip from the coast.',
|
||||
),
|
||||
'san-diego' =>
|
||||
array (
|
||||
'language' => 'English, Spanish',
|
||||
'currency' => 'US Dollar (USD)',
|
||||
'timezone' => 'GMT-8',
|
||||
'best_time' => 'March – May',
|
||||
'tagline' => 'California’s sunny sweet spot.',
|
||||
'about_title' => 'San Diego’s Past: A Quick Look',
|
||||
'about' => '<p>San Diego was home to the Kumeyaay people for thousands of years before Spain built its first California mission here in 1769, earning the city the title “Birthplace of California.” A large Navy presence and a mild climate helped it grow into a relaxed coastal city. Today it is known for its beaches, its harbor, Balboa Park’s museums and a laid-back, outdoor way of life.</p>
|
||||
<p>A hop-on hop-off tour, bookable at hoponhopofftickets.com, connects the best of it: the Gaslamp Quarter, Old Town, Balboa Park, Little Italy, the USS Midway Museum on the harbor and the beach town of Coronado. Book at hoponhopofftickets.com for an easy, sunny day of sightseeing.</p>',
|
||||
'spring' => 'Wander Balboa Park’s gardens and museums, or visit the world-famous San Diego Zoo. Sample tacos and margaritas in historic Old Town.',
|
||||
'summer' => 'Swim and surf at La Jolla Shores or Pacific Beach, and watch the sea lions at La Jolla Cove. Enjoy the fair in Del Mar or a sunset on Coronado Beach.',
|
||||
'fall' => 'Explore the USS Midway Museum and the waterfront while the crowds thin out. Head out for craft beer in North Park or a whale-watching trip.',
|
||||
'winter' => 'Enjoy mild, sunny days by the bay and the holiday lights along the harbor. Watch gray whales migrate along the coast from Cabrillo National Monument.',
|
||||
),
|
||||
'san-francisco' =>
|
||||
array (
|
||||
'language' => 'English',
|
||||
'currency' => 'US Dollar (USD)',
|
||||
'timezone' => 'GMT-8',
|
||||
'best_time' => 'September – November',
|
||||
'tagline' => 'The city by the bay.',
|
||||
'about_title' => 'San Francisco’s Past: A Quick Look',
|
||||
'about' => '<p>San Francisco was home to the Ohlone people before Spanish settlers built a mission and presidio here in 1776. The 1849 Gold Rush turned a small village into a boomtown almost overnight, and the city rebuilt with flair after the 1906 earthquake. Today it is famous for its steep hills, cable cars, painted Victorian houses, foggy mornings and creative, open-minded spirit.</p>
|
||||
<p>A hop-on hop-off tour, bookable at hoponhopofftickets.com, takes the strain out of the hills: ride across the Golden Gate Bridge, then hop off at Fisherman’s Wharf, Union Square, Chinatown, Haight-Ashbury and Golden Gate Park. Add a ferry to Alcatraz for the full story. Book at hoponhopofftickets.com to see the city by the bay.</p>',
|
||||
'spring' => 'Walk Golden Gate Park’s gardens or watch the Japanese Tea Garden in bloom. Hop on a cable car up to Nob Hill for views over the bay.',
|
||||
'summer' => 'Bring a jacket for foggy days on a bay cruise or a walk across the Golden Gate Bridge. Spend a sunny afternoon in the Mission District and Dolores Park.',
|
||||
'fall' => 'Enjoy the city’s warmest, clearest weather and outdoor festivals. Take a day trip to the vineyards of Napa or Sonoma for the harvest season.',
|
||||
'winter' => 'Skate in Union Square and see the holiday window displays. Watch the sea lions at Pier 39 or tour Alcatraz on a crisp, clear day.',
|
||||
),
|
||||
'seattle' =>
|
||||
array (
|
||||
'language' => 'English',
|
||||
'currency' => 'US Dollar (USD)',
|
||||
'timezone' => 'GMT-8',
|
||||
'best_time' => 'June – September',
|
||||
'tagline' => 'Coffee, mountains and the Space Needle.',
|
||||
'about_title' => 'Seattle’s Past: A Quick Look',
|
||||
'about' => '<p>Seattle takes its name from Chief Si’ahl of the Duwamish and Suquamish peoples, who lived on these shores long before settlers arrived in 1851. Logging, the Klondike Gold Rush and later aircraft and tech companies drove the city’s growth. Today it is a green, creative city set between Puget Sound and the mountains, famous for coffee, music and the Space Needle.</p>
|
||||
<p>A hop-on hop-off tour, bookable at hoponhopofftickets.com, is an easy way to see the highlights: Pike Place Market, the waterfront, Pioneer Square and the Seattle Center with the Space Needle and Chihuly Garden and Glass. Book at hoponhopofftickets.com and add a harbor cruise for views of Mount Rainier on a clear day.</p>',
|
||||
'spring' => 'See the tulip fields of the Skagit Valley in bloom on a day trip. Browse Pike Place Market and watch the famous fish throwers at work.',
|
||||
'summer' => 'Enjoy long, dry days on a Puget Sound ferry or kayaking on Lake Union. Take in the view from the top of the Space Needle and dine on the waterfront.',
|
||||
'fall' => 'Walk the Washington Park Arboretum for autumn colors, or explore the Museum of Pop Culture. Warm up with coffee in the city that made it famous.',
|
||||
'winter' => 'Explore Chihuly Garden and Glass and the city’s museums on rainy days. Take a day trip to the snowy Cascade Mountains for skiing or snowshoeing.',
|
||||
),
|
||||
'toronto' =>
|
||||
array (
|
||||
'language' => 'English',
|
||||
'currency' => 'Canadian Dollar (CAD)',
|
||||
'timezone' => 'GMT-5',
|
||||
'best_time' => 'May – September',
|
||||
'tagline' => 'Canada’s big city by the lake.',
|
||||
'about_title' => 'Toronto’s Past: A Quick Look',
|
||||
'about' => '<p>Toronto sits on land long home to the Anishinaabe, Haudenosaunee and Wendat peoples; its name is thought to come from a word meaning “where there are trees standing in the water.” The British founded the town of York here in 1793, and it grew into Canada’s largest city. Today Toronto is one of the most diverse cities in the world, known for its skyline, neighborhoods, food and arts.</p>
|
||||
<p>A hop-on hop-off tour, bookable at hoponhopofftickets.com, covers the highlights: the CN Tower, the Harbourfront, the Distillery District, St. Lawrence Market, Chinatown and Kensington Market, and the Royal Ontario Museum. Book at hoponhopofftickets.com and save a day for a trip to Niagara Falls.</p>',
|
||||
'spring' => 'Watch the cherry blossoms bloom in High Park and stroll the Distillery District’s cobbled lanes. Catch a Blue Jays game as the baseball season starts.',
|
||||
'summer' => 'Take a ferry to the Toronto Islands for beaches and skyline views. Join street festivals, patios and outdoor concerts all over the city.',
|
||||
'fall' => 'See the city at its most glamorous during the Toronto International Film Festival. Enjoy autumn colors along the Don Valley trails or on a day trip to Niagara Falls.',
|
||||
'winter' => 'Skate at Nathan Phillips Square under the lights and browse the Distillery Winter Village. Stay warm in the Royal Ontario Museum or the Art Gallery of Ontario.',
|
||||
),
|
||||
'victoria' =>
|
||||
array (
|
||||
'language' => 'English',
|
||||
'currency' => 'Canadian Dollar (CAD)',
|
||||
'timezone' => 'GMT-8',
|
||||
'best_time' => 'May – September',
|
||||
'tagline' => 'Canada’s garden city.',
|
||||
'about_title' => 'Victoria’s Past: A Quick Look',
|
||||
'about' => '<p>Victoria lies on the traditional territory of the Lekwungen-speaking Songhees and Esquimalt peoples. The Hudson’s Bay Company built a fort here in 1843, and the Fraser Canyon Gold Rush soon turned it into a busy port. Today the capital of British Columbia is a charming harbor city with British-style architecture, colorful gardens and a gentle, mild climate.</p>
|
||||
<p>A hop-on hop-off tour, bookable at hoponhopofftickets.com, is a relaxed way to explore: the Inner Harbour and the BC Parliament Buildings, the Fairmont Empress, Chinatown, Craigdarroch Castle and the scenic waterfront drive. Book at hoponhopofftickets.com and make time for the world-famous Butchart Gardens.</p>',
|
||||
'spring' => 'See Butchart Gardens burst into color with tulips and cherry blossoms. Walk the Inner Harbour as the first whale-watching trips set out.',
|
||||
'summer' => 'Go whale watching for orcas and humpbacks, or kayak along the coast. Enjoy afternoon tea at the Fairmont Empress and street performers on the harbor.',
|
||||
'fall' => 'Stroll Beacon Hill Park among the autumn leaves and visit the Royal BC Museum. Sample local cider and seafood at the Fisherman’s Wharf.',
|
||||
'winter' => 'Enjoy one of Canada’s mildest winters, with the Parliament Buildings lit up at night. See Butchart Gardens’ holiday lights and explore historic Craigdarroch Castle.',
|
||||
),
|
||||
'washington-dc' =>
|
||||
array (
|
||||
'language' => 'English',
|
||||
'currency' => 'US Dollar (USD)',
|
||||
'timezone' => 'GMT-5',
|
||||
'best_time' => 'March – May',
|
||||
'tagline' => 'The heart of American history.',
|
||||
'about_title' => 'Washington DC’s Past: A Quick Look',
|
||||
'about' => '<p>Washington DC stands on land once home to the Nacotchtank people along the Potomac and Anacostia rivers. Chosen as the nation’s capital in 1790 and laid out by Pierre L’Enfant, the city grew around wide avenues, grand monuments and the National Mall. Today it is a city of world-class museums, historic neighborhoods and powerful landmarks, many of them free to visit.</p>
|
||||
<p>A hop-on hop-off tour, bookable at hoponhopofftickets.com, is the easiest way to cover the distances: the White House, the Capitol, the Lincoln Memorial, the Washington Monument, the Smithsonian museums and Arlington National Cemetery. Book at hoponhopofftickets.com and see the capital without the long walks.</p>',
|
||||
'spring' => 'See the famous cherry blossoms around the Tidal Basin and join the National Cherry Blossom Festival. Walk the National Mall from the Capitol to the Lincoln Memorial.',
|
||||
'summer' => 'Escape the heat in the free Smithsonian museums, then watch July 4th fireworks over the Mall. Take an evening monuments tour when the landmarks are lit up.',
|
||||
'fall' => 'Enjoy cooler days for exploring Georgetown’s streets and the C&O Canal. See the autumn colors at Arlington National Cemetery and along the Potomac.',
|
||||
'winter' => 'Visit the National Christmas Tree near the White House and skate in the National Gallery Sculpture Garden. Spend cold days in the Air and Space Museum or the National Archives.',
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,293 @@
|
||||
# Region / City / Product URLs Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Serve regions at `/{region}/`, cities at `/{city}/` and products at `/{city}/{product}/`. The client manages the region → city structure from a validated settings screen.
|
||||
|
||||
**Architecture:** A pure-PHP `VC_Structure` class holds the structure, validation, canonical-city choice and rewrite-rule generation, and is unit-tested without WordPress. Thin WordPress layers sit on top of it:
|
||||
- `VC_Structure_Admin`: settings screen, save, sync to `viator_destination` terms
|
||||
- `VC_Routing`: rewrite rules, link filters, 301s
|
||||
- `VC_Menu`: region items in the header menu
|
||||
|
||||
These layers are verified by `php -l` and a manual QA pass on a WordPress install.
|
||||
|
||||
**Tech Stack:** WordPress, PHP 8 (local CLI is PHP 8.4), plugin `viator-collector`, child theme `astra-child-hoponhopoff`. No Composer/PHPUnit: tests use a tiny runner at `viator-collector/tests/run.php`.
|
||||
|
||||
**Spec:** `viator-collector/docs/superpowers/specs/2026-10-29-region-city-urls-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Preset regions, in order: UK `uk`, Europe `europe`, Asia `asia`, Middle East and Africa `middle-east-and-africa`, Americas `americas`, Australasia `australasia`.
|
||||
- Valid slug: `^[a-z0-9]+(?:-[a-z0-9]+)*$`.
|
||||
- Reserved slugs: `wp-admin`, `wp-content`, `wp-includes`, `wp-json`, `feed`, `page`, `search`, `tag`, `category`, `author`, `tours`, `tour-destination`, `tour-tag`, `tour-category`, `blog`, `about`, `contact`, `faq`, `embed`, `attachment`.
|
||||
- Term meta: region flag `_vc_region` = `1`; city link `_vc_viator_destination_id` (already exists).
|
||||
- Post meta: `_vc_primary_destination` (Viator destination ref string).
|
||||
- Options: `vc_structure` (structure array), `vc_flush_rewrite` (flag).
|
||||
- All redirects are 301 and keep the query string.
|
||||
- Match plugin style: static classes, `VC_` prefix, tabs, `vc_get_option`, `VC_Logger`, text domain `viator-collector`.
|
||||
- Work on branch `feature/region-city-urls`, not `main`.
|
||||
- `tests/` and `docs/` never ship in the zip.
|
||||
|
||||
**Structure array shape** (used everywhere):
|
||||
```php
|
||||
array(
|
||||
array(
|
||||
'name' => 'Europe',
|
||||
'slug' => 'europe',
|
||||
'term_id' => 12, // 0 until synced
|
||||
'cities' => array(
|
||||
array( 'id' => '737', 'name' => 'Amsterdam', 'slug' => 'amsterdam' ),
|
||||
),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. A city slug equal to an **unconfigured** existing destination term's slug (e.g. two Viator "Victoria"s) must be a validation error, not a silent `wp_update_term` failure. Covered by `test_validate_taken_slug` (Task 1).
|
||||
2. A product whose Viator primary destination is not configured but which belongs to two configured cities must use the first city in settings order. Covered by `test_canonical_city_fallback_order` (Task 1).
|
||||
3. A product in no configured city keeps `/tours/{slug}/`: `canonical_city()` returns `''`. Covered by `test_canonical_city_none` (Task 1).
|
||||
4. An admin-typed slug like `New York` or `amsterdam!` must be rejected, not silently changed. Covered by `test_validate_bad_slug` (Task 1).
|
||||
5. Filter and sort query strings (`?dest=…&sort=…&paged=2`) must survive every 301. Covered by manual check M6 (Task 7).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `VC_Structure` core and test runner
|
||||
|
||||
**Files:**
|
||||
- Create: `viator-collector/includes/class-vc-structure.php`
|
||||
- Create: `viator-collector/tests/run.php`, `viator-collector/tests/test-structure.php`
|
||||
- Modify: `build-zip.php` (add `'tests'` to `$excluded_dirs`)
|
||||
|
||||
**Interfaces (Produces):** class `VC_Structure`, all `public static`:
|
||||
- `const OPTION = 'vc_structure';`
|
||||
- `default_regions(): array`: the six presets, `term_id` 0, no cities.
|
||||
- `get(): array`: `get_option( self::OPTION, self::default_regions() )`, cached in a static per request. Untested; WordPress only.
|
||||
- `normalize( array $raw ): array`
|
||||
- Shape POST data into the structure shape: trim names, `trim(strtolower())` slugs, cast `term_id` to int and `id` to a digit string, drop city rows with an empty `id`, reindex.
|
||||
- An empty region slug becomes `slugify( name )`. An empty city slug becomes `slugify( city name )`.
|
||||
- `slugify( string $s ): string`: lowercase, runs of non `[a-z0-9]` become `-`, trim `-`.
|
||||
- `reserved_slugs(): array`: the list in Global Constraints.
|
||||
- `validate( array $structure, array $destination_map, array $taken ): array`
|
||||
- `$destination_map` is Viator `id => name`. `$taken` is `slug => human label` for slugs owned by something outside the structure.
|
||||
- Returns a list of error strings, empty when valid. Messages are exact:
|
||||
- `Region %d has no name.` (1-based)
|
||||
- `City ID %s is not a Viator destination.`
|
||||
- `%s is listed more than once.` (city name)
|
||||
- `Slug "%s" is invalid: use only a-z, 0-9 and -.`
|
||||
- `Slug "%s" is reserved.`
|
||||
- `Slug "%s" is used more than once.`
|
||||
- `Slug "%s" is already used by %s.`
|
||||
- `cities( array $structure ): array`: ordered `viator_id => city + array( 'region' => region slug )`.
|
||||
- `canonical_city( array $structure, array $dest_ids, string $primary_id ): string`
|
||||
- Returns the city slug for `$primary_id` if it is configured.
|
||||
- Otherwise returns the slug of the first configured city (in `cities()` order) that appears in `$dest_ids`.
|
||||
- Otherwise returns `''`.
|
||||
- `primary_destination_id( array $destinations ): string`: `ref` (or `destinationId`) of the entry with truthy `primary`, else `''`.
|
||||
- `rewrite_rules( array $structure ): array`, regex => query, in this order:
|
||||
- each region: `'^europe/?$' => 'index.php?viator_destination=europe'`
|
||||
- each city: `'^amsterdam/?$' => 'index.php?viator_destination=amsterdam'` and `'^amsterdam/([^/]+)/?$' => 'index.php?viator_tour=$matches[1]'`
|
||||
|
||||
- [ ] **Step 1: Write the runner.** `tests/run.php` defines `ABSPATH`, requires `includes/class-vc-structure.php` and every `tests/test-*.php`, then calls each function whose name starts with `test_`. It provides `vc_assert_same( $expected, $actual, string $msg )`, which throws on mismatch, and `vc_assert_contains( string $needle, array $errors )`. It prints `N passed, M failed` and exits 1 on any failure.
|
||||
|
||||
- [ ] **Step 2: Write the failing tests** in `tests/test-structure.php`. Use the fixture `$s` = Europe(`amsterdam` id 737, `paris` id 479) + Americas(`boston` id 678) and `$map = array('737'=>'Amsterdam','479'=>'Paris','678'=>'Boston','22'=>'Victoria')`.
|
||||
```php
|
||||
test_default_regions // 6 regions; slugs exactly the Global Constraints list, in order
|
||||
test_normalize_fills_slugs // region name 'Middle East and Africa', slug '' -> 'middle-east-and-africa'; city row with id '' dropped
|
||||
test_validate_ok // validate($s,$map,[]) === []
|
||||
test_validate_unknown_city // city id '999' -> contains 'City ID 999 is not a Viator destination.'
|
||||
test_validate_duplicate_city // 737 in Europe and Americas -> contains 'Amsterdam is listed more than once.'
|
||||
test_validate_duplicate_slug // region slug 'paris' + city 'paris' -> contains 'Slug "paris" is used more than once.'
|
||||
test_validate_bad_slug // 'New York' and 'amsterdam!' -> both 'Slug "…" is invalid: use only a-z, 0-9 and -.'
|
||||
test_validate_reserved // city slug 'contact' -> contains 'Slug "contact" is reserved.'
|
||||
test_validate_taken_slug // $taken=['victoria'=>'destination "Victoria"'], city slug 'victoria' -> contains 'Slug "victoria" is already used by destination "Victoria".'
|
||||
test_validate_empty_region // region name '' -> contains 'Region 1 has no name.'
|
||||
test_canonical_city_primary // canonical_city($s,['479','737'],'479') === 'paris'
|
||||
test_canonical_city_fallback_order // canonical_city($s,['678','479','737'],'5555') === 'amsterdam'
|
||||
test_canonical_city_none // canonical_city($s,['5555'],'') === ''
|
||||
test_primary_destination_id // [['ref'=>'1'],['ref'=>'2','primary'=>true]] -> '2'; [] -> ''
|
||||
test_rewrite_rules // exact array: europe, americas, amsterdam, amsterdam product, paris, paris product, boston, boston product (region rules first, then cities)
|
||||
test_rewrite_rules_empty_region // region with no cities still yields '^europe/?$'
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run and confirm the failure.** Run `php tests/run.php` from `viator-collector/`. Expected: a fatal error saying class `VC_Structure` was not found.
|
||||
|
||||
- [ ] **Step 4: Implement `VC_Structure`** with the interface above. Only `get()` may call WordPress functions.
|
||||
|
||||
- [ ] **Step 5: Run and confirm the pass.** Run `php tests/run.php`. Expected: `16 passed, 0 failed`.
|
||||
|
||||
- [ ] **Step 6: Exclude tests from the zip.** Add `'tests'` to `$excluded_dirs` in `build-zip.php`. Then run `php build-zip.php viator-collector "$TMP/vc.zip"` and confirm no `tests/` entries are in the zip.
|
||||
|
||||
- [ ] **Step 7: Commit.** Commit message: `feat(structure): add region/city structure model with validation`.
|
||||
|
||||
### Task 2: Importer stores the primary destination; crawl follows the structure
|
||||
|
||||
**Files:**
|
||||
- Modify: `viator-collector/includes/class-vc-importer.php` (the `$meta` array in `save_tour_meta`, ~line 370)
|
||||
- Modify: `viator-collector/includes/class-vc-cron.php` (`start_run`, ~lines 159-166)
|
||||
- Modify: `viator-collector/includes/class-vc-settings.php` (description of the crawl destinations field)
|
||||
- Modify: `viator-collector/includes/class-vc-plugin.php` (load `class-vc-structure.php` before the importer)
|
||||
|
||||
**Interfaces (Consumes):** `VC_Structure::primary_destination_id()`, `VC_Structure::cities()`, `VC_Structure::get()`.
|
||||
|
||||
- [ ] **Step 1: Store the primary destination.** Add `'_vc_primary_destination' => VC_Structure::primary_destination_id( (array) vc_array_get( $product, 'destinations', array() ) )` to the `$meta` array.
|
||||
|
||||
- [ ] **Step 2: Build the crawl queue from the structure.** In `start_run`:
|
||||
- If `array_keys( VC_Structure::cities( VC_Structure::get() ) )` is non-empty, the queue is exactly that list.
|
||||
- Otherwise keep the existing allowlist/full-catalogue logic.
|
||||
- Log `Sync run started from structure — %d cities queued.` in the structure case.
|
||||
|
||||
- [ ] **Step 3: Relabel the old allowlist.** Append `Used only when no cities are set on the Viator Structure screen.` to the crawl destinations field's description.
|
||||
|
||||
- [ ] **Step 4: Lint.** Run `php -l` on each changed file. Expected: `No syntax errors detected` for each. Then run `php tests/run.php`, which should still pass.
|
||||
|
||||
- [ ] **Step 5: Commit.** Commit message: `feat(import): store primary destination; crawl configured cities`.
|
||||
|
||||
### Task 3: Structure settings screen, validated save, and term sync
|
||||
|
||||
**Files:**
|
||||
- Create: `viator-collector/includes/class-vc-structure-admin.php`
|
||||
- Create: `viator-collector/assets/js/vc-structure-admin.js`
|
||||
- Modify: `viator-collector/includes/class-vc-plugin.php` (load the file and call `VC_Structure_Admin::init()`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: all of `VC_Structure`; existing AJAX action `vc_search_destinations` (nonce `vc_search_destinations`, POST `q`, returns `{results:[{id,name}]}`); `VC_Taxonomies::get_or_create_destination_by_id( $id, $name )`; `VC_API::get_destination_map()`.
|
||||
- Produces:
|
||||
- `VC_Structure_Admin::init()`
|
||||
- `VC_Structure_Admin::taken_slugs( array $structure ): array`, slug => label
|
||||
- `VC_Structure_Admin::sync_terms( array $old, array $new ): array`, returns `$new` with region `term_id`s filled
|
||||
- Sets option `vc_flush_rewrite` = 1 after a successful save
|
||||
|
||||
- [ ] **Step 1: Add the screen.** Register a submenu under Settings with `add_options_page`: title `Viator Structure`, slug `viator-collector-structure`, capability `manage_options`.
|
||||
- The page renders one block per region: name input, slug input, a hidden `term_id`, and up/down/remove buttons.
|
||||
- Each region block holds its city rows. A city row shows the name read-only, an editable slug, and a hidden `id` and `name`, with up/down/remove buttons.
|
||||
- Each region has a city search box that calls `vc_search_destinations`. Results whose id is already used anywhere in the form are shown disabled.
|
||||
- An "Add region" button sits at the end.
|
||||
- Field names: `vc_structure[i][name|slug|term_id]` and `vc_structure[i][cities][j][id|name|slug]`. The JS renumbers indexes before submit.
|
||||
- The form posts to `admin-post.php`, action `vc_save_structure`, nonce `vc_save_structure`.
|
||||
|
||||
- [ ] **Step 2: Implement `taken_slugs( $structure )`.** It returns these as `slug => label`:
|
||||
- top-level Pages (`post_parent` 0), labelled `page "Title"`
|
||||
- posts of every public post type in any status except trash and auto-draft, labelled `post "Title"`
|
||||
- `viator_destination` terms that are neither a structure region (`term_id`) nor a structure city (matched through `_vc_viator_destination_id`), labelled `destination "Name"`
|
||||
|
||||
- [ ] **Step 3: Implement the save handler.**
|
||||
1. Check the capability and nonce.
|
||||
2. Run `normalize( wp_unslash( $_POST['vc_structure'] ?? array() ) )`.
|
||||
3. Load the destination map. If that returns a `WP_Error`, treat it as the single error `Could not load Viator destinations; nothing was saved.`
|
||||
4. Run `validate( $s, $map, self::taken_slugs( $s ) )`.
|
||||
5. On errors, store `array( 'errors' => …, 'structure' => $s )` in transient `vc_structure_draft_{user_id}` (10 minutes) and redirect back. The screen re-renders that draft with a red notice listing every error. Nothing is saved.
|
||||
6. On success: `$s = self::sync_terms( VC_Structure::get(), $s )`, then `update_option( VC_Structure::OPTION, $s )`, then `update_option( 'vc_flush_rewrite', 1 )`, then redirect with the notice `Structure saved.`
|
||||
|
||||
- [ ] **Step 4: Implement `sync_terms( $old, $new )`.**
|
||||
- **Region:** update the term by `term_id` if it exists (name, slug, parent 0). Otherwise `wp_insert_term( name, 'viator_destination', array( 'slug' => slug ) )`. In both cases set `_vc_region` = 1 and store the `term_id` back.
|
||||
- **City:** `$tid = VC_Taxonomies::get_or_create_destination_by_id( id, name )`, then `wp_update_term( $tid, 'viator_destination', array( 'slug' => slug, 'parent' => region term_id ) )`.
|
||||
- **Removed regions** (old `term_id`s that are no longer present): `wp_delete_term`.
|
||||
- **Removed cities** (old ids that are no longer present): set `parent` to 0.
|
||||
- Log every `WP_Error` with `VC_Logger::error`.
|
||||
|
||||
- [ ] **Step 5: Add the page-slug clash notice.** On `admin_notices`, on the page edit screen, if the current page's `post_name` is a structure region or city slug, show the warning `This page's URL is also used by the Viator region/city "%s"; the Viator page will take priority.`
|
||||
|
||||
- [ ] **Step 6: Lint.** Run `php -l includes/class-vc-structure-admin.php` and `node --check assets/js/vc-structure-admin.js`. Expected: no errors.
|
||||
|
||||
- [ ] **Step 7: Commit.** Commit message: `feat(structure): admin screen with validated save and term sync`.
|
||||
|
||||
### Task 4: Routing — rewrite rules, links, redirects
|
||||
|
||||
**Files:**
|
||||
- Create: `viator-collector/includes/class-vc-routing.php`
|
||||
- Modify: `viator-collector/includes/class-vc-plugin.php` (load the file and call `VC_Routing::init()`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `VC_Structure::get()`, `::rewrite_rules()`, `::cities()`, `::canonical_city()`; option `vc_flush_rewrite`.
|
||||
- Produces: `VC_Routing::init()`, `VC_Routing::is_configured_term( WP_Term $term ): bool`, `VC_Routing::product_city( int $post_id ): string`
|
||||
|
||||
- [ ] **Step 1: Register the rules.** On `init` at priority 20, call `add_rewrite_rule( $regex, $query, 'top' )` for each `rewrite_rules( get() )` entry. Then, if `get_option( 'vc_flush_rewrite' )` is set, delete it and call `flush_rewrite_rules( false )`.
|
||||
|
||||
- [ ] **Step 2: Implement `is_configured_term`.** It is true when the term is in `viator_destination` and either has `_vc_region` = 1 or its `_vc_viator_destination_id` is a key of `cities()`.
|
||||
|
||||
- [ ] **Step 3: Implement `product_city`.** Build `$dest_ids` from the `ref` / `destinationId` values in the post's `_vc_destinations` JSON meta. Return `canonical_city( get(), $dest_ids, (string) _vc_primary_destination )`. Cache the result per `post_id` in a static.
|
||||
|
||||
- [ ] **Step 4: Add the link filters.**
|
||||
- `term_link`: for a configured term, return `home_url( '/' . $term->slug . '/' )`.
|
||||
- `post_type_link`: for a `viator_tour` with a non-empty `post_name` and a non-empty `product_city`, return `home_url( "/{city}/{post_name}/" )`.
|
||||
- Everything else is returned unchanged.
|
||||
|
||||
- [ ] **Step 5: Add the 301s.** On `template_redirect`, for `is_singular( 'viator_tour' )` (published, not a preview) and for configured `is_tax( 'viator_destination' )`:
|
||||
- Compute the canonical URL (`get_permalink()` / `get_term_link()`).
|
||||
- Compare `untrailingslashit` paths of the canonical URL and `REQUEST_URI` with the query string removed.
|
||||
- If they differ, `wp_safe_redirect( canonical + '?' + original query string if any, 301 )` and exit.
|
||||
|
||||
- [ ] **Step 6: Add the term canonical tag.** On `wp_head`, for a configured term archive, print `<link rel="canonical" href="…">`. Skip this when `WPSEO_VERSION` or `RANK_MATH_VERSION` is defined. Singular products already get core's canonical from `get_permalink()`.
|
||||
|
||||
- [ ] **Step 7: Lint and commit.** Run `php -l includes/class-vc-routing.php`. Expected: no errors. Commit message: `feat(routing): region/city/product URLs with 301s from old paths`.
|
||||
|
||||
### Task 5: Region landing page
|
||||
|
||||
**Files:**
|
||||
- Create: `viator-collector/templates/region.php`
|
||||
- Modify: `viator-collector/includes/class-vc-display.php` (`taxonomy_template`, ~line 534)
|
||||
- Modify: `viator-collector/templates/listing.php` (new optional arg `$vc_after_hero`)
|
||||
|
||||
**Interfaces (Consumes):** `VC_Routing::is_configured_term()`; existing `vc_get_template()`, `card-destination.php` (arg `term`), `listing.php`.
|
||||
|
||||
- [ ] **Step 1: Route region terms.** In `taxonomy_template`, when the queried term has `_vc_region` = 1, return `get_stylesheet_directory() . '/viator-collector/region.php'` if it exists, else `VC_PLUGIN_DIR . 'templates/region.php'`.
|
||||
|
||||
- [ ] **Step 2: Add `$vc_after_hero` to `listing.php`.** It is an optional HTML string, default `''`, echoed immediately after the listing hero section closes.
|
||||
|
||||
- [ ] **Step 3: Write `templates/region.php`.**
|
||||
- Copy the structure of `taxonomy-viator.php`.
|
||||
- Build `$vc_after_hero` as `<section class="vc-section vc-section--destinations">`. It holds the heading `Cities in %s` and a `vc-destination-grid` of `card-destination.php` cards.
|
||||
- The cards are the child terms from `get_terms( array( 'taxonomy' => 'viator_destination', 'parent' => term_id, 'hide_empty' => false ) )`, ordered as in the structure (the order of `cities()`).
|
||||
- The listing's main query already includes child terms, so it shows every tour in the region.
|
||||
|
||||
- [ ] **Step 4: Lint and commit.** Run `php -l` on the three files. Commit message: `feat(display): region landing page with city grid`.
|
||||
|
||||
### Task 6: Regions in the header menu
|
||||
|
||||
**Files:**
|
||||
- Create: `viator-collector/includes/class-vc-menu.php`
|
||||
- Modify: `viator-collector/includes/class-vc-plugin.php` (load the file and call `VC_Menu::init()`)
|
||||
- Modify: `astra-child-hoponhopoff/inc/nav-walker.php` (`hoho_nav_fallback`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `VC_Structure::get()`; region and city terms (`term_id`, links via `get_term_link`).
|
||||
- Produces: `VC_Menu::items(): array` of menu-item objects.
|
||||
|
||||
- [ ] **Step 1: Implement `VC_Menu::items()`.** Build `stdClass` items with these fields: `ID`, `db_id`, `menu_item_parent` (string), `title`, `url`, `classes` (array), `type` `'custom'`, `object` `'custom'`, `object_id`, `target` `''`, `xfn` `''`, `attr_title` `''`, `description` `''`, `current` false.
|
||||
- Use negative unique `ID`/`db_id`: `-1000 - n`.
|
||||
- Each region gets a top-level item.
|
||||
- A region with cities gets these children, in order: `All of %s` (the region URL, because the theme walker turns a parent with children into a toggle button with no link), then one item per city.
|
||||
- Regions whose term no longer exists are skipped.
|
||||
|
||||
- [ ] **Step 2: Append to the header menu.** Add the filter `wp_nav_menu_objects` (`$items`, `$args`). When `$args->theme_location === 'hoho_primary'`, return `array_merge( $items, self::items() )`. Set `menu_order` sequentially after the existing items.
|
||||
|
||||
- [ ] **Step 3: Update the theme fallback.** In `hoho_nav_fallback`, after the Tours link and when `class_exists( 'VC_Menu' )`, echo `walk_nav_menu_tree( VC_Menu::items(), 2, (object) array( 'walker' => new Hoho_Nav_Walker() ) )` inside the same `<ul>`.
|
||||
|
||||
- [ ] **Step 4: Lint and commit.** Run `php -l` on both PHP files. Commit message: `feat(menu): list regions and cities in the header menu`.
|
||||
|
||||
### Task 7: Version bump, build, and manual QA
|
||||
|
||||
**Files:**
|
||||
- Modify: `viator-collector/viator-collector.php` (Version header and `VC_VERSION` → `1.9.0`)
|
||||
|
||||
- [ ] **Step 1: Bump the version, run the tests and build the zips.**
|
||||
- Bump the version to 1.9.0.
|
||||
- Run `php tests/run.php`. Expected: all tests pass.
|
||||
- From the repo root, run `php build-zip.php viator-collector viator-collector.zip`.
|
||||
|
||||
- [ ] **Step 2: Manual QA on a WordPress install** with the client's live data copied over. Record pass or fail for each check:
|
||||
- M1. Save the preset regions and add `Boston` and `New York City` under Americas. The menu shows the six regions, and Americas drops down to `All of Americas`, Boston and New York City.
|
||||
- M2. `/americas/` shows the city grid and all of their tours. `/boston/` lists Boston tours.
|
||||
- M3. `/tour-destination/boston/` gives a 301 to `/boston/`. Do the same check for all 15 of the client's URLs once those cities are added.
|
||||
- M4. A Boston tour's card links to `/boston/{slug}/`. `/tours/{slug}/` and `/new-york-city/{slug}/` both give a 301 to it.
|
||||
- M5. Saving each of these shows an error, and the previous structure is unchanged:
|
||||
- a city twice
|
||||
- slug `contact`
|
||||
- slug `New York`
|
||||
- a city slug equal to an existing page's slug
|
||||
- M6. `/boston/?sort=price_asc&paged=2` keeps its query. `/tour-destination/boston/?sort=price_asc` gives a 301 to `/boston/?sort=price_asc`.
|
||||
- M7. Run a manual sync. The log reads `cities queued` with the configured count.
|
||||
- M8. `/contact/` and other pages still load.
|
||||
|
||||
- [ ] **Step 3: Commit.** Commit message: `chore: bump viator-collector to 1.9.0`.
|
||||
@@ -0,0 +1,159 @@
|
||||
# Region / City / Product URL Structure — Design
|
||||
|
||||
Date: 2026-10-29
|
||||
Status: draft, awaiting approval
|
||||
|
||||
## Intent
|
||||
|
||||
The client wants the site organised by place, like hoponhopoffplus.com:
|
||||
visitors pick a **region**, then a **city**, then a **product**. URLs should
|
||||
read the same way:
|
||||
|
||||
| Page | URL | Example |
|
||||
|---------|---------------------------------------|----------------------------------------------------------------|
|
||||
| Region | `/{region-slug}/` | `/europe/` |
|
||||
| City | `/{city-slug}/` | `/amsterdam/` |
|
||||
| Product | `/{city-slug}/{product-slug}/` | `/amsterdam/city-sightseeing-hop-on-hop-off-amsterdam-bus-tour-24-hours/` |
|
||||
|
||||
The header menu lists the regions: **UK, Europe, Asia, Middle East and
|
||||
Africa, Americas, Australasia**.
|
||||
|
||||
## Goals
|
||||
|
||||
- The client builds the region → city structure in the plugin settings.
|
||||
- Products keep importing automatically from Viator. A city page shows
|
||||
**every** Viator product in that city, not only hop-on hop-off tours.
|
||||
- The settings screen makes bad data impossible to save (see Validation).
|
||||
- Existing live URLs keep working through 301 redirects. The client sent
|
||||
these, which are live today and likely the first Americas cities:
|
||||
washington-dc, boston, chicago, new-york-city, new-orleans, miami,
|
||||
las-vegas, los-angeles, san-diego, san-francisco, seattle, victoria,
|
||||
vancouver, montreal, toronto (all under `/tour-destination/{slug}/`).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- The new static pages from `new front/` (about, contact, faq, blog,
|
||||
blog-post). That is a separate, later project.
|
||||
- A hand-built sample product page. Products come from the Viator plugin.
|
||||
- Redesigning the listing, card or single-tour templates.
|
||||
- Region in the city URL (`/europe/amsterdam/`). The client's examples
|
||||
rule this out.
|
||||
|
||||
## Anti-goals (would count as failure even if it "works")
|
||||
|
||||
- A city or region URL that hijacks an existing WordPress page, post or
|
||||
system path (e.g. a city slugged `about` or `wp-admin`).
|
||||
- The same product reachable at two indexable URLs (duplicate content).
|
||||
- Any of the client's existing `/tour-destination/...` links returning 404.
|
||||
- Admin able to type a city that does not exist in Viator.
|
||||
|
||||
## Decisions (agreed in conversation)
|
||||
|
||||
- Structure lives in plugin settings; client owns it.
|
||||
- Cities are picked only from Viator's destination list (reuse the existing
|
||||
`vc_search_destinations` AJAX lookup in `class-vc-settings.php`).
|
||||
- A product that belongs to several configured cities **appears on every
|
||||
one of those city pages**.
|
||||
- Old `/tour-destination/{slug}/` URLs 301 to `/{slug}/`.
|
||||
|
||||
## Decisions made by Claude (user delegated URL choices)
|
||||
|
||||
1. **One canonical product URL.** City in the URL = the product's Viator
|
||||
*primary* destination if that destination is a configured city;
|
||||
otherwise the first configured city (in settings order) the product
|
||||
belongs to. Requesting the product under any other city
|
||||
(`/paris/same-tour/`) 301s to the canonical URL. `/tours/{slug}/`
|
||||
also 301s to the canonical URL.
|
||||
2. **Products in no configured city** (left over from older crawls) keep
|
||||
`/tours/{slug}/` and do not appear on any region/city page.
|
||||
3. **Data model: reuse the `viator_destination` taxonomy (hierarchical).**
|
||||
- Region = top-level term with no Viator ID, marked by term meta
|
||||
`_vc_region = 1`.
|
||||
- City = term linked to a Viator destination ID (existing
|
||||
`_vc_viator_destination_id` meta), with its parent set to its region.
|
||||
- Existing city terms are reused by Viator ID, so current slugs such as
|
||||
`washington-dc` are kept as-is.
|
||||
- A region page queries its child terms with `include_children`, so it
|
||||
lists every product in its cities without extra assignment.
|
||||
- The structure itself is stored as one option (`vc_structure`): an
|
||||
ordered list of regions, each with an ordered list of
|
||||
`{viator_id, name, slug}` cities. Saving the settings syncs it into
|
||||
terms (create/re-parent/rename), never deleting products.
|
||||
4. **The six regions come preset** (slugs: `uk`, `europe`, `asia`,
|
||||
`middle-east-and-africa`, `americas`, `australasia`). The client can
|
||||
rename, reorder, add or remove regions.
|
||||
5. **City slug** defaults to `sanitize_title( Viator name )`, or the
|
||||
existing term's slug if one exists. The admin may edit it; edits go
|
||||
through the same validation.
|
||||
6. **Routing: explicit rewrite rules per configured slug, not catch-alls.**
|
||||
For each region and city, add rules for `^{slug}/?$`; for each city also
|
||||
`^{slug}/([^/]+)/?$` → product. Rules are rebuilt and flushed when the
|
||||
structure is saved. Unconfigured paths fall through to normal WordPress
|
||||
routing, so pages like `/contact/` are unaffected. Listing pagination
|
||||
already uses `?paged=`, so no `/page/N/` rule is needed (the builder
|
||||
should confirm this).
|
||||
7. **Links.** `term_link` filter returns `/{slug}/` for region and city
|
||||
terms; `post_type_link` filter returns the canonical product URL.
|
||||
Unconfigured destination terms keep `/tour-destination/{slug}/`.
|
||||
8. **Crawl.** When the structure has at least one city, the crawl queue is
|
||||
exactly the configured cities' Viator IDs. The old
|
||||
`crawl_destinations` allowlist is used only when the structure is empty,
|
||||
and its field is labelled as such.
|
||||
9. **Primary destination.** The importer stores the Viator primary
|
||||
destination ref (the entry in `destinations` with `primary: true`) in
|
||||
post meta `_vc_primary_destination`. Products imported before this change
|
||||
get it on the next sync; until then the fallback rule in (1) applies.
|
||||
10. **Menu.** Regions are added automatically to the "HoHo — Header Menu"
|
||||
location as top-level items, each with a dropdown of its cities (the
|
||||
existing nav walker already renders dropdowns). Items the client added
|
||||
by hand in the menu stay, and the regions are appended after them.
|
||||
11. **Region landing page.** Hero (region name and term image), a grid of
|
||||
its city cards (reuse `card-destination.php`), then the standard
|
||||
listing of all tours in the region. **City landing page** = the
|
||||
existing taxonomy listing template, unchanged apart from the URLs.
|
||||
12. **SEO.** Canonical tags follow the canonical URLs above. All redirects
|
||||
are 301.
|
||||
|
||||
## Validation (settings save)
|
||||
|
||||
The whole save is rejected, with the previous structure kept, and the admin
|
||||
sees a list of every problem, if any of these is true:
|
||||
|
||||
- A city's Viator ID is not in Viator's destination list.
|
||||
- The same city (Viator ID) appears twice, in one region or across regions.
|
||||
- Two items (regions or cities) share a slug.
|
||||
- A region has an empty name.
|
||||
- A slug is empty or contains anything other than `a-z`, `0-9` and `-`.
|
||||
- A slug collides with an existing page, post, or other public post-type
|
||||
slug or archive (`tours`), or with a reserved word: WordPress reserved
|
||||
query vars, `wp-admin`, `wp-content`, `wp-includes`, `feed`, `page`,
|
||||
`search`, `tag`, `category`, `author`, `tour-destination`, `tour-tag`,
|
||||
`tour-category`, `blog`, `about`, `contact`, `faq`.
|
||||
|
||||
The editor UI also prevents most of these before submit: cities are added
|
||||
only from search results, and already-used cities are greyed out in the
|
||||
results.
|
||||
|
||||
Also, when a Page is created or renamed to a slug a region or city already
|
||||
uses, show an admin notice warning about the clash. (WordPress itself does
|
||||
not block this.)
|
||||
|
||||
## Constraints
|
||||
|
||||
- WordPress, Astra child theme `astra-child-hoponhopoff`, plugin
|
||||
`viator-collector`. Match the existing plugin style (static classes,
|
||||
`VC_` prefix, `vc_get_option`, `VC_Logger`).
|
||||
- Viator Basic access: the catalogue is only enumerable per destination,
|
||||
which suits per-city crawling.
|
||||
- No changes to how products are fetched or priced.
|
||||
|
||||
## Left to the builder
|
||||
|
||||
- Settings editor UI details (drag-to-reorder or up/down buttons, etc.)
|
||||
as long as the validation and pick-from-Viator rules hold.
|
||||
- Whether the structure editor is a section of the existing settings page
|
||||
or its own sub-page.
|
||||
- Exact redirect hook (`template_redirect` is the expected place).
|
||||
- What happens to a city removed from the structure: its term stays (it is
|
||||
un-parented and loses its `/{slug}/` route, so it falls back to
|
||||
`/tour-destination/{slug}/`). Products are not deleted.
|
||||
387
viator-collector/includes/class-vc-city-guide.php
Normal file
@@ -0,0 +1,387 @@
|
||||
<?php
|
||||
/**
|
||||
* City guide: the editable text shown on a city page above its tours — a
|
||||
* tagline in the banner, a facts bar, an "About" section and seasonal cards.
|
||||
*
|
||||
* Stored per destination term in one meta array (_vc_guide). Edited on the
|
||||
* term screen (Viator tours → Destinations → edit a city). Shown on page 1 of
|
||||
* the city's listing only, and never while filters are applied, so browsing
|
||||
* and filtering land straight on the tours.
|
||||
*/
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
class VC_City_Guide {
|
||||
const META = '_vc_guide';
|
||||
|
||||
/**
|
||||
* Slugs from data/city-guides.php already imported. "_v2" re-runs the
|
||||
* import once for sites that imported before "Best time to visit" was
|
||||
* added to the data.
|
||||
*/
|
||||
const IMPORTED_OPTION = 'vc_city_guides_imported_v2';
|
||||
|
||||
public static function init() {
|
||||
add_action( 'viator_destination_edit_form_fields', array( __CLASS__, 'edit_fields' ), 20 );
|
||||
add_action( 'edited_viator_destination', array( __CLASS__, 'save' ) );
|
||||
add_action( 'admin_init', array( __CLASS__, 'import_bundled' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Field key => [label, type, help]. Pure (no WordPress calls) so tests can
|
||||
* check the bundled data against it.
|
||||
*/
|
||||
public static function fields() {
|
||||
return array(
|
||||
'tagline' => array( 'Tagline', 'text', 'One short line under the city name in the banner, e.g. "Where is America’s tropical star?"' ),
|
||||
'language' => array( 'Language', 'text', 'Facts bar. E.g. "English, Spanish".' ),
|
||||
'currency' => array( 'Currency', 'text', 'Facts bar. E.g. "US Dollar (USD)".' ),
|
||||
'timezone' => array( 'Time zone', 'text', 'Facts bar. E.g. "GMT-5".' ),
|
||||
'best_time' => array( 'Best time to visit', 'text', 'Facts bar. E.g. "March – May".' ),
|
||||
'about_title' => array( 'About heading', 'text', 'Heading of the About section. Empty uses "Discover {city}".' ),
|
||||
'about' => array( 'About text', 'html', 'History and introduction. Long text folds behind a "Read more" button.' ),
|
||||
'about_image' => array( 'About photo', 'image', 'Photo beside the About text. Empty uses one of the city\'s tour photos.' ),
|
||||
'spring' => array( 'Spring', 'textarea', 'Things to do in spring (2–3 sentences).' ),
|
||||
'summer' => array( 'Summer', 'textarea', 'Things to do in summer.' ),
|
||||
'fall' => array( 'Fall', 'textarea', 'Things to do in fall.' ),
|
||||
'winter' => array( 'Winter', 'textarea', 'Things to do in winter.' ),
|
||||
'southern' => array( 'Southern hemisphere', 'checkbox', 'Tick for cities south of the equator (e.g. Sydney, Cape Town) so the season months are flipped.' ),
|
||||
);
|
||||
}
|
||||
|
||||
public static function get( $term_id ) {
|
||||
$saved = get_term_meta( (int) $term_id, self::META, true );
|
||||
$saved = is_array( $saved ) ? $saved : array();
|
||||
$guide = array();
|
||||
foreach ( self::fields() as $key => $field ) {
|
||||
$guide[ $key ] = isset( $saved[ $key ] ) ? $saved[ $key ] : ( 'checkbox' === $field[1] ? false : '' );
|
||||
}
|
||||
return $guide;
|
||||
}
|
||||
|
||||
public static function sanitize( array $raw ) {
|
||||
$clean = array();
|
||||
foreach ( self::fields() as $key => $field ) {
|
||||
$value = isset( $raw[ $key ] ) ? $raw[ $key ] : '';
|
||||
switch ( $field[1] ) {
|
||||
case 'html':
|
||||
$clean[ $key ] = trim( wp_kses_post( (string) $value ) );
|
||||
break;
|
||||
case 'textarea':
|
||||
$clean[ $key ] = trim( sanitize_textarea_field( (string) $value ) );
|
||||
break;
|
||||
case 'image':
|
||||
$clean[ $key ] = esc_url_raw( (string) $value );
|
||||
break;
|
||||
case 'checkbox':
|
||||
$clean[ $key ] = ! empty( $value );
|
||||
break;
|
||||
default:
|
||||
$clean[ $key ] = trim( sanitize_text_field( (string) $value ) );
|
||||
}
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/** True when no text field is filled (the hemisphere flag alone doesn't count). */
|
||||
public static function is_empty( array $guide ) {
|
||||
foreach ( self::fields() as $key => $field ) {
|
||||
if ( 'checkbox' !== $field[1] && 'about_image' !== $key && ! empty( $guide[ $key ] ) ) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* $guide with its empty fields taken from $defaults. Pure.
|
||||
*/
|
||||
public static function fill_empty( array $guide, array $defaults ) {
|
||||
foreach ( $defaults as $key => $value ) {
|
||||
if ( empty( $guide[ $key ] ) ) {
|
||||
$guide[ $key ] = $value;
|
||||
}
|
||||
}
|
||||
return $guide;
|
||||
}
|
||||
|
||||
/**
|
||||
* Months for each season. Pure.
|
||||
*/
|
||||
public static function season_months( $southern ) {
|
||||
$north = array(
|
||||
'spring' => 'March – May',
|
||||
'summer' => 'June – August',
|
||||
'fall' => 'September – November',
|
||||
'winter' => 'December – February',
|
||||
);
|
||||
if ( ! $southern ) {
|
||||
return $north;
|
||||
}
|
||||
return array(
|
||||
'spring' => $north['fall'],
|
||||
'summer' => $north['winter'],
|
||||
'fall' => $north['spring'],
|
||||
'winter' => $north['summer'],
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ admin */
|
||||
|
||||
public static function edit_fields( $term ) {
|
||||
if ( get_term_meta( $term->term_id, '_vc_region', true ) ) {
|
||||
return; // Regions use their own page; the guide is for cities.
|
||||
}
|
||||
$guide = self::get( $term->term_id );
|
||||
wp_nonce_field( 'vc_guide_save', 'vc_guide_nonce' );
|
||||
?>
|
||||
<tr class="form-field"><th colspan="2" style="padding-bottom:0">
|
||||
<h2 style="margin:1.5em 0 .25em"><?php echo esc_html__( 'City guide', 'viator-collector' ); ?></h2>
|
||||
<p class="description" style="font-weight:400"><?php echo esc_html__( 'Shown on the city page above its tours. Anything left empty is hidden.', 'viator-collector' ); ?></p>
|
||||
</th></tr>
|
||||
<?php
|
||||
foreach ( self::fields() as $key => $field ) {
|
||||
list( $label, $type, $help ) = $field;
|
||||
$name = 'vc_guide[' . $key . ']';
|
||||
$id = 'vc-guide-' . $key;
|
||||
$value = $guide[ $key ];
|
||||
?>
|
||||
<tr class="form-field">
|
||||
<th scope="row"><label for="<?php echo esc_attr( $id ); ?>"><?php echo esc_html( $label ); ?></label></th>
|
||||
<td>
|
||||
<?php
|
||||
if ( 'html' === $type ) {
|
||||
wp_editor(
|
||||
(string) $value,
|
||||
'vcguideabout',
|
||||
array(
|
||||
'textarea_name' => $name,
|
||||
'textarea_rows' => 8,
|
||||
'media_buttons' => false,
|
||||
'teeny' => true,
|
||||
)
|
||||
);
|
||||
} elseif ( 'textarea' === $type ) {
|
||||
echo '<textarea id="' . esc_attr( $id ) . '" name="' . esc_attr( $name ) . '" rows="3">' . esc_textarea( $value ) . '</textarea>';
|
||||
} elseif ( 'checkbox' === $type ) {
|
||||
echo '<label><input type="checkbox" id="' . esc_attr( $id ) . '" name="' . esc_attr( $name ) . '" value="1" ' . checked( (bool) $value, true, false ) . ' style="width:auto"> ' . esc_html__( 'Yes', 'viator-collector' ) . '</label>';
|
||||
} elseif ( 'image' === $type ) {
|
||||
self::image_field( $id, $name, (string) $value );
|
||||
} else {
|
||||
echo '<input type="text" id="' . esc_attr( $id ) . '" name="' . esc_attr( $name ) . '" value="' . esc_attr( $value ) . '">';
|
||||
}
|
||||
?>
|
||||
<p class="description"><?php echo esc_html( $help ); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
|
||||
private static function image_field( $id, $name, $value ) {
|
||||
?>
|
||||
<input type="hidden" id="<?php echo esc_attr( $id ); ?>" name="<?php echo esc_attr( $name ); ?>" value="<?php echo esc_attr( $value ); ?>">
|
||||
<button type="button" class="button" id="<?php echo esc_attr( $id ); ?>-pick"><?php echo esc_html__( 'Select image', 'viator-collector' ); ?></button>
|
||||
<button type="button" class="button" id="<?php echo esc_attr( $id ); ?>-clear"><?php echo esc_html__( 'Remove', 'viator-collector' ); ?></button>
|
||||
<div id="<?php echo esc_attr( $id ); ?>-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>
|
||||
<script>
|
||||
jQuery(function ($) {
|
||||
var id = <?php echo wp_json_encode( $id ); ?>, frame;
|
||||
$('#' + id + '-pick').on('click', function (e) {
|
||||
e.preventDefault();
|
||||
frame = frame || wp.media({ library: { type: 'image' }, multiple: false });
|
||||
frame.off('select').on('select', function () {
|
||||
var img = frame.state().get('selection').first().toJSON();
|
||||
$('#' + id).val(img.url);
|
||||
$('#' + id + '-preview').html($('<img>', { src: img.url }).css({ maxWidth: '200px', height: 'auto', borderRadius: '6px' }));
|
||||
});
|
||||
frame.open();
|
||||
});
|
||||
$('#' + id + '-clear').on('click', function (e) {
|
||||
e.preventDefault();
|
||||
$('#' + id).val('');
|
||||
$('#' + id + '-preview').empty();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
|
||||
public static function save( $term_id ) {
|
||||
if ( ! current_user_can( 'manage_categories' ) ) {
|
||||
return;
|
||||
}
|
||||
if ( ! isset( $_POST['vc_guide_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['vc_guide_nonce'] ) ), 'vc_guide_save' ) ) {
|
||||
return;
|
||||
}
|
||||
$raw = isset( $_POST['vc_guide'] ) && is_array( $_POST['vc_guide'] ) ? wp_unslash( $_POST['vc_guide'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized per field below.
|
||||
$guide = self::sanitize( $raw );
|
||||
if ( self::is_empty( $guide ) && ! $guide['southern'] && '' === $guide['about_image'] ) {
|
||||
delete_term_meta( $term_id, self::META );
|
||||
} else {
|
||||
update_term_meta( $term_id, self::META, $guide );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the empty fields of each city's guide from data/city-guides.php;
|
||||
* text already entered is never replaced. Each slug is handled once;
|
||||
* cities that don't exist yet are retried at most hourly.
|
||||
*/
|
||||
public static function import_bundled() {
|
||||
if ( ! current_user_can( 'manage_categories' ) || get_transient( 'vc_city_guides_check' ) ) {
|
||||
return;
|
||||
}
|
||||
$data = include VC_PLUGIN_DIR . 'data/city-guides.php';
|
||||
$done = (array) get_option( self::IMPORTED_OPTION, array() );
|
||||
$todo = array_diff( array_keys( $data ), $done );
|
||||
foreach ( $todo as $slug ) {
|
||||
$term = get_term_by( 'slug', $slug, 'viator_destination' );
|
||||
if ( ! $term ) {
|
||||
continue;
|
||||
}
|
||||
update_term_meta( $term->term_id, self::META, self::fill_empty( self::get( $term->term_id ), self::sanitize( $data[ $slug ] ) ) );
|
||||
$done[] = $slug;
|
||||
}
|
||||
update_option( self::IMPORTED_OPTION, array_values( array_unique( $done ) ), false );
|
||||
if ( array_diff( array_keys( $data ), $done ) ) {
|
||||
set_transient( 'vc_city_guides_check', 1, HOUR_IN_SECONDS );
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- front end */
|
||||
|
||||
/** The guide is shown on page 1 of an unfiltered city listing only. */
|
||||
public static function is_visible_here() {
|
||||
$paged = max( (int) get_query_var( 'paged' ), isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1 ); // phpcs:ignore WordPress.Security.NonceVerification
|
||||
return $paged <= 1 && ! VC_Display::filters_active( VC_Display::get_filter_state() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Facts bar, About and Seasons for $term, or '' when the guide is empty.
|
||||
*/
|
||||
public static function render( $term ) {
|
||||
$guide = self::get( $term->term_id );
|
||||
if ( self::is_empty( $guide ) ) {
|
||||
return '';
|
||||
}
|
||||
$city = $term->name;
|
||||
$out = '';
|
||||
|
||||
$facts = array(
|
||||
'language' => array( __( 'Language', 'viator-collector' ), 'lang' ),
|
||||
'currency' => array( __( 'Currency', 'viator-collector' ), 'cur' ),
|
||||
'timezone' => array( __( 'Time zone', 'viator-collector' ), 'time' ),
|
||||
'best_time' => array( __( 'Best time to visit', 'viator-collector' ), 'sun' ),
|
||||
);
|
||||
$items = '';
|
||||
foreach ( $facts as $key => $fact ) {
|
||||
if ( '' !== $guide[ $key ] ) {
|
||||
$items .= '<li class="cfact"><span class="cfact__ic">' . self::icon( $fact[1] ) . '</span><span><small>' . esc_html( $fact[0] ) . '</small><b>' . esc_html( $guide[ $key ] ) . '</b></span></li>';
|
||||
}
|
||||
}
|
||||
if ( $items ) {
|
||||
// Wrapped: direct children of .vc-app are forced full width.
|
||||
$out .= '<div class="cty-top"><div class="container"><ul class="cty-facts">' . $items . '</ul></div></div>';
|
||||
}
|
||||
|
||||
if ( '' !== $guide['about'] ) {
|
||||
$img = $guide['about_image'] ? $guide['about_image'] : self::fallback_image( $term );
|
||||
/* translators: %s: city name. */
|
||||
$title = '' !== $guide['about_title'] ? $guide['about_title'] : sprintf( __( 'Discover %s', 'viator-collector' ), $city );
|
||||
$out .= '<section class="section cty-about"><div class="container"><div class="cty-about__grid' . ( $img ? '' : ' cty-about__grid--text' ) . '">';
|
||||
if ( $img ) {
|
||||
$out .= '<img class="cty-about__img" src="' . esc_url( $img ) . '" alt="' . esc_attr( $city ) . '" loading="lazy" decoding="async">';
|
||||
}
|
||||
/* translators: %s: city name. */
|
||||
$out .= '<div><p class="pill cty-eyebrow">' . esc_html( sprintf( __( 'About %s', 'viator-collector' ), $city ) ) . '</p>'
|
||||
. '<h2 class="cty-h2">' . esc_html( $title ) . '</h2>'
|
||||
. '<div class="cty-about__text" data-vc-clamp>' . wpautop( $guide['about'] ) . '</div>'
|
||||
. '<button class="cty-more" type="button" hidden aria-expanded="false"><span>' . esc_html__( 'Read more', 'viator-collector' ) . '</span>' . vc_icon( 'chevron' ) . '</button>'
|
||||
. '</div></div></div></section>';
|
||||
}
|
||||
|
||||
$months = self::season_months( $guide['southern'] );
|
||||
$seasons = array(
|
||||
'spring' => array( __( 'Spring', 'viator-collector' ), 'flower' ),
|
||||
'summer' => array( __( 'Summer', 'viator-collector' ), 'sun' ),
|
||||
'fall' => array( __( 'Fall', 'viator-collector' ), 'leaf' ),
|
||||
'winter' => array( __( 'Winter', 'viator-collector' ), 'snow' ),
|
||||
);
|
||||
$cards = '';
|
||||
foreach ( $seasons as $key => $season ) {
|
||||
if ( '' !== $guide[ $key ] ) {
|
||||
$cards .= '<li class="season season--' . esc_attr( $key ) . '"><span class="season__ic">' . self::icon( $season[1] ) . '</span>'
|
||||
. '<h3>' . esc_html( $season[0] ) . '</h3><small>' . esc_html( $months[ $key ] ) . '</small>'
|
||||
. '<p>' . esc_html( $guide[ $key ] ) . '</p></li>';
|
||||
}
|
||||
}
|
||||
if ( $cards ) {
|
||||
/* translators: %s: city name. */
|
||||
$out .= '<section class="section cty-seasons"><div class="container"><p class="pill cty-eyebrow">' . esc_html__( 'Seasonal activities', 'viator-collector' ) . '</p>'
|
||||
. '<h2 class="cty-h2">' . esc_html( sprintf( __( 'What to do in %s, all year round', 'viator-collector' ), $city ) ) . '</h2>'
|
||||
. '<ul class="seasons">' . $cards . '</ul></div></section>';
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function tagline( $term ) {
|
||||
return self::get( $term->term_id )['tagline'];
|
||||
}
|
||||
|
||||
/**
|
||||
* A tour photo of the city that isn't the banner photo, cached for a day.
|
||||
*/
|
||||
private static function fallback_image( $term ) {
|
||||
$key = 'vc_guide_img_' . $term->term_id;
|
||||
$cached = get_transient( $key );
|
||||
if ( false !== $cached ) {
|
||||
return $cached;
|
||||
}
|
||||
$hero = vc_term_image_url( $term->term_id, $term->taxonomy );
|
||||
$query = new WP_Query(
|
||||
array(
|
||||
'post_type' => 'viator_tour',
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => 10,
|
||||
'fields' => 'ids',
|
||||
'no_found_rows' => true,
|
||||
'update_post_term_cache' => false,
|
||||
'tax_query' => array(
|
||||
array(
|
||||
'taxonomy' => $term->taxonomy,
|
||||
'field' => 'term_id',
|
||||
'terms' => $term->term_id,
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
$url = '';
|
||||
foreach ( $query->posts as $post_id ) {
|
||||
$candidate = (string) get_post_meta( (int) $post_id, '_vc_primary_image_url', true );
|
||||
if ( '' !== $candidate && $candidate !== $hero ) {
|
||||
$url = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
set_transient( $key, $url, $url ? DAY_IN_SECONDS : HOUR_IN_SECONDS );
|
||||
return $url;
|
||||
}
|
||||
|
||||
private static function icon( $name ) {
|
||||
$paths = array(
|
||||
'lang' => '<path d="M4 5h9M8.5 3v2m3 0c-.8 4-3.6 7-7 8.5m2.5-5c1 2 2.6 3.6 4.5 4.5M13 21l4-9 4 9m-6.8-3h5.6"/>',
|
||||
'cur' => '<rect x="2.5" y="6" width="19" height="12" rx="2"/><circle cx="12" cy="12" r="2.5"/><path d="M6 9.5v5M18 9.5v5"/>',
|
||||
'time' => '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
||||
'sun' => '<circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
|
||||
'flower' => '<circle cx="12" cy="12" r="2.5"/><path d="M12 9.5C10 6 10 4 12 3c2 1 2 3 0 6.5Zm0 5c2 3.5 2 5.5 0 6.5-2-1-2-3 0-6.5Zm2.5-2.5c3.5-2 5.5-2 6.5 0-1 2-3 2-6.5 0Zm-5 0c-3.5 2-5.5 2-6.5 0 1-2 3-2 6.5 0Z"/>',
|
||||
'leaf' => '<path d="M5 19C5 10 10 5 20 4c-1 10-6 15-15 15Zm0 0 7-7"/>',
|
||||
'snow' => '<path d="M12 2v20M3.3 7l17.4 10M3.3 17 20.7 7M9 4l3 2 3-2M9 20l3-2 3 2"/>',
|
||||
);
|
||||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' . $paths[ $name ] . '</svg>';
|
||||
}
|
||||
}
|
||||
@@ -156,10 +156,14 @@ class VC_Cron {
|
||||
|
||||
$map = get_transient( 'vc_destination_map' );
|
||||
|
||||
// A destination allowlist (comma-separated IDs) narrows the crawl to a
|
||||
// focused set; empty means the whole catalogue.
|
||||
// Cities configured on the Viator Structure screen define the crawl.
|
||||
// Without any, a destination allowlist (comma-separated IDs) narrows
|
||||
// the crawl to a focused set; empty means the whole catalogue.
|
||||
$cities = array_map( 'strval', array_keys( VC_Structure::cities( VC_Structure::get() ) ) );
|
||||
$allow = array_filter( array_map( 'trim', explode( ',', (string) vc_get_option( 'crawl_destinations', '' ) ) ) );
|
||||
if ( ! empty( $allow ) ) {
|
||||
if ( ! empty( $cities ) ) {
|
||||
$queue = $cities;
|
||||
} elseif ( ! empty( $allow ) ) {
|
||||
$queue = array_values( array_unique( $allow ) );
|
||||
} else {
|
||||
$queue = is_array( $map ) ? array_keys( $map ) : array();
|
||||
@@ -191,7 +195,11 @@ class VC_Cron {
|
||||
);
|
||||
update_option( 'vc_ingest_in_progress', 1, false );
|
||||
update_option( 'vc_last_sync_started_at', current_time( 'mysql' ), false );
|
||||
if ( ! empty( $cities ) ) {
|
||||
VC_Logger::info( sprintf( 'Sync run started from structure — %d cities queued.', count( $queue ) ) );
|
||||
} else {
|
||||
VC_Logger::info( sprintf( 'Sync run started — %d destinations queued.', count( $queue ) ) );
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ class VC_Display {
|
||||
'viator_top_destinations',
|
||||
'viator_top_attractions',
|
||||
'viator_browse_activities',
|
||||
'viator_bus_tours',
|
||||
'viator_about',
|
||||
'viator_articles',
|
||||
'viator_faq',
|
||||
'viator_testimonials',
|
||||
'viator_tour_card',
|
||||
@@ -24,9 +27,14 @@ class VC_Display {
|
||||
);
|
||||
|
||||
public static function init() {
|
||||
add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
|
||||
// Priority 100 so the theme (Astra + child) has already enqueued ALL of
|
||||
// its stylesheets — including the parent style.css and the child's chrome
|
||||
// CSS, which are enqueued by the theme at priority 20 and would otherwise
|
||||
// print AFTER us (plugins run before themes at equal priority) and win
|
||||
// equal-specificity ties. Loading last makes our design win those ties.
|
||||
add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ), 100 );
|
||||
add_action( 'template_redirect', array( __CLASS__, 'maybe_enrich_single' ) );
|
||||
add_action( 'pre_get_posts', array( __CLASS__, 'filter_archive_query' ) );
|
||||
add_action( 'pre_get_posts', array( __CLASS__, 'filter_archive_query' ), 999 );
|
||||
add_filter( 'single_template', array( __CLASS__, 'single_template' ) );
|
||||
add_filter( 'archive_template', array( __CLASS__, 'archive_template' ) );
|
||||
add_filter( 'taxonomy_template', array( __CLASS__, 'taxonomy_template' ) );
|
||||
@@ -62,9 +70,18 @@ class VC_Display {
|
||||
|| is_tax( array( 'viator_destination', 'viator_tag', 'viator_category' ) ) ) {
|
||||
return true;
|
||||
}
|
||||
// A Page hosting the full-listing shortcode also wants full width.
|
||||
// Any Page hosting a Viator shortcode (hero, sections, listing, …) also
|
||||
// wants the full-width, no-sidebar canvas.
|
||||
$post = ( is_singular() ) ? get_post() : null;
|
||||
return $post && has_shortcode( $post->post_content, 'viator_listing' );
|
||||
if ( ! $post ) {
|
||||
return false;
|
||||
}
|
||||
foreach ( self::SHORTCODES as $shortcode ) {
|
||||
if ( has_shortcode( $post->post_content, $shortcode ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,9 +151,27 @@ class VC_Display {
|
||||
// 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 );
|
||||
// One scoped stylesheet (.vc-app) covers listing, single, and homepage
|
||||
// sections — ported from the "Hop On Hop Off" design. Declare every theme
|
||||
// stylesheet that could fight ours as a dependency (when present) so our
|
||||
// rules always print AFTER them and win equal-specificity ties. We enqueue
|
||||
// at priority 100, so by now the theme (priority 20) has registered them.
|
||||
$deps = array( 'vc-fonts' );
|
||||
foreach ( array( 'astra-theme-css', 'astra-parent', 'hoho-chrome' ) as $handle ) {
|
||||
if ( wp_style_is( $handle, 'registered' ) ) {
|
||||
$deps[] = $handle;
|
||||
}
|
||||
}
|
||||
wp_enqueue_style( 'vc-design', VC_PLUGIN_URL . 'assets/css/vc-design.css', $deps, VC_VERSION );
|
||||
wp_add_inline_style( 'vc-design', self::astra_overrides() );
|
||||
if ( $is_single ) {
|
||||
// Tour page top section + mobile booking bar (after the overrides above).
|
||||
wp_enqueue_style( 'vc-tour', VC_PLUGIN_URL . 'assets/css/vc-tour.css', array( 'vc-design' ), VC_VERSION );
|
||||
}
|
||||
if ( is_tax( 'viator_destination' ) ) {
|
||||
// City guide: tagline, facts bar, About and Seasons (see VC_City_Guide).
|
||||
wp_enqueue_style( 'vc-city', VC_PLUGIN_URL . 'assets/css/vc-city.css', array( 'vc-design' ), VC_VERSION );
|
||||
}
|
||||
|
||||
// Front-end interactions: card rails, FAQ accordion, testimonials
|
||||
// marquee, date field, and auto-submit on quick filter toggles.
|
||||
@@ -146,6 +181,122 @@ class VC_Display {
|
||||
add_action( 'wp_footer', 'vc_print_icon_sprite' );
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS that neutralises Astra's content spacing/width on Viator pages and
|
||||
* makes the design typography win over the theme. Injected after vc-design.css.
|
||||
*/
|
||||
private static function astra_overrides() {
|
||||
return implode(
|
||||
'',
|
||||
array(
|
||||
// Full-bleed: strip Astra's page-builder article padding/width so
|
||||
// the hero and sections reach the viewport edges. !important beats
|
||||
// Astra's own high-specificity rules (e.g. the 2em article padding
|
||||
// that leaves a gap above the hero).
|
||||
'.vc-fullwidth .site-main>article,'
|
||||
. '.vc-fullwidth .ast-page-builder-template .site-main>article,'
|
||||
. '.vc-fullwidth.ast-page-builder-template .site-main>article'
|
||||
. '{padding:0!important;margin:0!important;border:0!important;max-width:none!important;}',
|
||||
'.vc-fullwidth #primary,.vc-fullwidth #content{margin:0!important;padding:0!important;}',
|
||||
// display:block undoes Astra's `.site-content .ast-container{display:flex}`
|
||||
// (≥922px), which otherwise sizes our content to its contents instead
|
||||
// of filling the full-width container.
|
||||
'.vc-fullwidth .site-content .ast-container,.vc-fullwidth .ast-container'
|
||||
. '{max-width:none!important;padding-left:0!important;padding-right:0!important;display:block!important;}',
|
||||
'.vc-fullwidth .entry-content{margin-block:0!important;}',
|
||||
'.vc-fullwidth .site-content{padding:0!important;}',
|
||||
// Guard against a stray horizontal scrollbar from any full-bleed child.
|
||||
'body.vc-fullwidth{overflow-x:hidden;}',
|
||||
// A gradient stands in when no hero image is set, so the banner is
|
||||
// never blank.
|
||||
'.vc-app.vc-app .hero__media,.vc-app.vc-app .hero-tour{background:linear-gradient(120deg,#101317,#2cafc2);}',
|
||||
// Normalize: stop Astra from decorating our components. Its
|
||||
// .entry-content rules (underlined links, list bullets/indent)
|
||||
// out-specify our scoped base, so reset them here (!important beats
|
||||
// e.g. `.ast-single-post .entry-content a`). Every selector matches
|
||||
// the doubled `.vc-app.vc-app` scope of the main stylesheet so these
|
||||
// patches keep winning against it, not just against the theme.
|
||||
'.vc-app.vc-app a{text-decoration:none;box-shadow:none;}',
|
||||
// No !important on margin/padding: the doubled .vc-app.vc-app scope
|
||||
// already out-specifies Astra's list rules, and !important here was
|
||||
// steamrolling design lists that need their own spacing — e.g. the
|
||||
// rail (<ul class="rail">) whose vertical padding gives the hover glow
|
||||
// room. list-style stays !important (purely cosmetic; prose lists
|
||||
// re-enable bullets via a higher-specificity rule).
|
||||
'.vc-app.vc-app :is(ul,ol){list-style:none!important;margin:0;padding:0;}',
|
||||
'.vc-app.vc-app li{margin:0;}',
|
||||
// …but keep the tour "Overview" prose (the_content) readable.
|
||||
'.vc-app.vc-app .tour__text a{text-decoration:underline;}',
|
||||
'.vc-app.vc-app .tour__text ul{list-style:disc!important;margin:1em 0!important;padding-inline-start:1.5em!important;}',
|
||||
'.vc-app.vc-app .tour__text ol{list-style:decimal!important;margin:1em 0!important;padding-inline-start:1.5em!important;}',
|
||||
'.vc-app.vc-app .tour__text li{margin:.25em 0!important;}',
|
||||
// Links must inherit the design colour (Astra colours links with its
|
||||
// theme colour, which turned white card titles/destination names into
|
||||
// coloured text). Buttons keep their own colour.
|
||||
// …but not ring-badges: they set their own colour on the <a>, and this
|
||||
// inherit rule out-specifies .ring-badge--top/--scroll, which turned
|
||||
// the badge text white (inherited) on light backgrounds.
|
||||
'.vc-app.vc-app a:not(.btn):not(.ring-badge){color:inherit;}',
|
||||
'.vc-app.vc-app .tour__text a{color:var(--c-teal);}',
|
||||
// Real tour titles are far longer than the mockup\'s city names — clamp
|
||||
// them and stop price/duration values wrapping mid-number.
|
||||
'.vc-app.vc-app .dcard__title{display:-webkit-box;-webkit-line-clamp:2;line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}',
|
||||
'.vc-app.vc-app .titem__desc{display:-webkit-box;-webkit-line-clamp:2;line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}',
|
||||
// Real tour descriptions run long; the on-hover card blurb clamps to
|
||||
// three lines with an ellipsis instead of spilling over the card.
|
||||
'.vc-app.vc-app .dcard__desc p{display:-webkit-box;-webkit-line-clamp:3;line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;}',
|
||||
'.vc-app.vc-app .dcard__meta .meta__v{white-space:nowrap;}',
|
||||
// Astra's page-builder layout centres/limits every entry-content child
|
||||
// (max-width:none; margin:auto), which flattened the hero inset. Our
|
||||
// sections are full-bleed, so re-assert 0 side-margins; !important
|
||||
// beats Astra's 4-class selector. The hero keeps its small inset — the
|
||||
// only section with an intentional side margin.
|
||||
'.vc-app.vc-app>*{max-width:none!important;margin-inline:0!important;}',
|
||||
// Heroes keep their small inset (rounded card look) — .hero on the
|
||||
// home/archive pages and .hero-tour on the single-tour page. Without
|
||||
// this they went full-bleed after the margin-inline:0 reset above.
|
||||
'.vc-app.vc-app>.hero,.vc-app.vc-app>.hero-tour{margin:var(--page-inset)!important;}',
|
||||
// Let vertical decorations (rotating badges, card glow, promo bus, FAQ
|
||||
// watermark) escape, but keep the horizontal axis clipped so a
|
||||
// full-bleed section or decorative element can't cause a mobile
|
||||
// sideways scroll. overflow-x:clip (unlike hidden) does NOT force
|
||||
// overflow-y to auto, so the y-axis stays truly visible.
|
||||
'.vc-app.vc-app{overflow-x:clip;overflow-y:visible;}',
|
||||
// Astra wraps the content in article/.entry-content/.ast-container,
|
||||
// some of which clip overflow — that cut the card hover glow at the
|
||||
// rail edges. Open them up on our pages only (page scroll stays
|
||||
// contained by body.vc-fullwidth{overflow-x:hidden}).
|
||||
'.vc-fullwidth .site-main,.vc-fullwidth #primary,.vc-fullwidth #content,'
|
||||
. '.vc-fullwidth .site-content,.vc-fullwidth .ast-container,'
|
||||
. '.vc-fullwidth .entry-content,.vc-fullwidth .site-main>article'
|
||||
. '{overflow:visible!important;}',
|
||||
// Breadcrumb — one style on every page (the theme's design pages use
|
||||
// the same rules): takes the colour of the title it sits above,
|
||||
// links softer, current page bold and shortened if long.
|
||||
'.vc-app.vc-app .crumbs{color:inherit;}',
|
||||
'.vc-app.vc-app .crumbs__list{color:inherit;font-size:.8125rem;font-weight:500;}',
|
||||
'.vc-app.vc-app .crumbs__list a{opacity:.75;}',
|
||||
'.vc-app.vc-app .crumbs__list a:hover{opacity:1;}',
|
||||
'.vc-app.vc-app .crumbs__list [aria-current]{max-width:min(40ch,60vw);overflow:hidden;font-weight:700;text-overflow:ellipsis;white-space:nowrap;}',
|
||||
'.vc-app.vc-app .hero__content>.crumbs{margin-block-end:calc(var(--sp-1) * -1);}',
|
||||
// In a banner the title is white, so the breadcrumb is too.
|
||||
'.vc-app.vc-app .hero .crumbs{color:var(--c-white);}',
|
||||
// FAQ question buttons must stay flat — Astra gives <button> a shadow.
|
||||
'.vc-app.vc-app .acc__q{box-shadow:none!important;}',
|
||||
// Astra colours every heading on the element itself (h1–h6 and
|
||||
// .entry-content :where(h1..h6)), beating our container-inherited
|
||||
// colours. Make headings inherit again — no !important, so design
|
||||
// classes (.hero__title, the white .tcard titles) keep their own.
|
||||
'.vc-app.vc-app :is(h1,h2,h3,h4,h5,h6){color:inherit;}',
|
||||
// Typography: force the design font over Astra inside our scopes.
|
||||
'.vc-app.vc-app,.vc-app.vc-app h1,.vc-app.vc-app h2,.vc-app.vc-app h3,.vc-app.vc-app h4,.vc-app.vc-app h5,.vc-app.vc-app h6,'
|
||||
. '.vc-app.vc-app p,.vc-app.vc-app a,.vc-app.vc-app span,.vc-app.vc-app li,.vc-app.vc-app button,.vc-app.vc-app input,'
|
||||
. '.vc-app.vc-app select,.vc-app.vc-app textarea,.vc-app.vc-app blockquote'
|
||||
. '{font-family:"Plus Jakarta Sans",system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;}',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read + sanitize the current filter state from the query string. Shared by
|
||||
* the query builder and the archive template so both stay in sync.
|
||||
@@ -182,10 +333,22 @@ class VC_Display {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any filter in $f (from get_filter_state()) narrows the results.
|
||||
* Sorting alone doesn't count.
|
||||
*/
|
||||
public static function filters_active( array $f ) {
|
||||
return '' !== $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'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the filter state to the main tours query (archive + taxonomy pages).
|
||||
*/
|
||||
public static function filter_archive_query( $query ) {
|
||||
// Runs late (priority 999, see init) so another plugin/theme setting the
|
||||
// page size can't leave the listing at 10 while it says "1–12".
|
||||
if ( is_admin() || ! $query->is_main_query() ) {
|
||||
return;
|
||||
}
|
||||
@@ -214,7 +377,9 @@ class VC_Display {
|
||||
*/
|
||||
public static function build_query_vars( array $base_tax = array() ) {
|
||||
$f = self::get_filter_state();
|
||||
$vars = array( 'posts_per_page' => 12 );
|
||||
// posts_per_archive_page too: when set (Settings or another plugin), WP_Query
|
||||
// uses it instead of posts_per_page on archives.
|
||||
$vars = array( 'posts_per_page' => 12, 'posts_per_archive_page' => 12, 'ignore_sticky_posts' => true );
|
||||
|
||||
if ( '' !== $f['q'] ) {
|
||||
$vars['s'] = $f['q'];
|
||||
@@ -271,46 +436,39 @@ class VC_Display {
|
||||
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' );
|
||||
|
||||
// Sort. Performance: the default (newest) orders by post date only — no
|
||||
// postmeta JOIN and no featured/review tiebreak — so the unfiltered
|
||||
// archive stays fast even with a very large catalogue (tens of thousands
|
||||
// of tours). Price/rating sorts add a single meta_key JOIN, on demand.
|
||||
switch ( $f['sort'] ) {
|
||||
case 'price_asc':
|
||||
$meta_query['vc_price'] = array( 'key' => '_vc_price_from', 'type' => 'DECIMAL', 'compare' => 'EXISTS' );
|
||||
$orderby['vc_price'] = 'ASC';
|
||||
$vars['meta_key'] = '_vc_price_from';
|
||||
$vars['orderby'] = 'meta_value_num';
|
||||
$vars['order'] = 'ASC';
|
||||
break;
|
||||
case 'price_desc':
|
||||
$meta_query['vc_price'] = array( 'key' => '_vc_price_from', 'type' => 'DECIMAL', 'compare' => 'EXISTS' );
|
||||
$orderby['vc_price'] = 'DESC';
|
||||
$vars['meta_key'] = '_vc_price_from';
|
||||
$vars['orderby'] = 'meta_value_num';
|
||||
$vars['order'] = 'DESC';
|
||||
break;
|
||||
case 'rating':
|
||||
$meta_query['vc_rating_sort'] = array( 'key' => '_vc_rating', 'type' => 'DECIMAL', 'compare' => 'EXISTS' );
|
||||
$orderby['vc_rating_sort'] = 'DESC';
|
||||
$vars['meta_key'] = '_vc_rating';
|
||||
$vars['orderby'] = 'meta_value_num';
|
||||
$vars['order'] = 'DESC';
|
||||
break;
|
||||
default:
|
||||
$vars['orderby'] = 'date';
|
||||
$vars['order'] = 'DESC';
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// Only attach a meta_query when a filter is actually active, so the plain
|
||||
// archive query carries no postmeta joins at all.
|
||||
if ( ! empty( $meta_query ) ) {
|
||||
if ( count( $meta_query ) > 1 ) {
|
||||
$meta_query['relation'] = 'AND';
|
||||
}
|
||||
$vars['meta_query'] = $meta_query;
|
||||
$vars['orderby'] = $orderby;
|
||||
}
|
||||
|
||||
return $vars;
|
||||
}
|
||||
@@ -407,6 +565,10 @@ class VC_Display {
|
||||
}
|
||||
|
||||
public static function taxonomy_template( $template ) {
|
||||
if ( is_tax( 'viator_destination' ) && get_term_meta( get_queried_object_id(), '_vc_region', true ) ) {
|
||||
$path = get_stylesheet_directory() . '/viator-collector/region.php';
|
||||
return file_exists( $path ) ? $path : VC_PLUGIN_DIR . 'templates/region.php';
|
||||
}
|
||||
if ( is_tax( array( 'viator_destination', 'viator_tag', 'viator_category' ) ) ) {
|
||||
$path = get_stylesheet_directory() . '/viator-collector/taxonomy-viator.php';
|
||||
if ( file_exists( $path ) ) {
|
||||
@@ -612,7 +774,7 @@ class VC_Display {
|
||||
'viator_browse_activities'
|
||||
);
|
||||
|
||||
$terms = self::get_section_terms( 'viator_tag', $atts['count'], $atts['ids'] );
|
||||
$terms = VC_Tags::public_terms( self::get_section_terms( 'viator_tag', $atts['count'], $atts['ids'] ) );
|
||||
if ( empty( $terms ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -368,6 +368,7 @@ class VC_Importer {
|
||||
'_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_primary_destination' => VC_Structure::primary_destination_id( (array) 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 ),
|
||||
|
||||
105
viator-collector/includes/class-vc-menu.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Appends the Viator Structure regions (each with a dropdown of its cities)
|
||||
* to the theme's header menu, so the menu follows the structure without
|
||||
* hand-editing Appearance → Menus.
|
||||
*/
|
||||
class VC_Menu {
|
||||
const LOCATION = 'hoho_primary';
|
||||
|
||||
public static function init() {
|
||||
add_filter( 'wp_nav_menu_objects', array( __CLASS__, 'append' ), 10, 2 );
|
||||
}
|
||||
|
||||
public static function append( $items, $args ) {
|
||||
if ( empty( $args->theme_location ) || self::LOCATION !== $args->theme_location ) {
|
||||
return $items;
|
||||
}
|
||||
$extra = self::items();
|
||||
if ( ! $extra ) {
|
||||
return $items;
|
||||
}
|
||||
|
||||
// Regions go right after the top-level link to the tours archive ("All
|
||||
// Tours"); without one, at the end. The walker renders top-level items
|
||||
// in array order.
|
||||
$items = array_values( $items );
|
||||
$position = count( $items );
|
||||
$archive = untrailingslashit( (string) get_post_type_archive_link( 'viator_tour' ) );
|
||||
foreach ( $items as $i => $item ) {
|
||||
if ( empty( $item->menu_item_parent ) && '' !== $archive && untrailingslashit( (string) $item->url ) === $archive ) {
|
||||
$position = $i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
array_splice( $items, $position, 0, $extra );
|
||||
|
||||
foreach ( $items as $i => $item ) {
|
||||
$item->menu_order = $i + 1;
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Menu-item objects for the walker. The theme walker turns a parent with
|
||||
* children into a toggle button (no link), so a region's dropdown starts
|
||||
* with an "All of {region}" link to the region page itself.
|
||||
*
|
||||
* @return object[]
|
||||
*/
|
||||
public static function items() {
|
||||
$items = array();
|
||||
$n = 0;
|
||||
foreach ( VC_Structure::get() as $region ) {
|
||||
$term = $region['term_id'] ? get_term( (int) $region['term_id'], 'viator_destination' ) : null;
|
||||
if ( ! $term instanceof WP_Term ) {
|
||||
continue;
|
||||
}
|
||||
$region_url = get_term_link( $term );
|
||||
if ( is_wp_error( $region_url ) ) {
|
||||
continue;
|
||||
}
|
||||
$parent = self::item( ++$n, 0, $term->name, $region_url );
|
||||
$items[] = $parent;
|
||||
if ( ! $region['cities'] ) {
|
||||
continue;
|
||||
}
|
||||
/* translators: %s: region name. */
|
||||
$items[] = self::item( ++$n, $parent->db_id, sprintf( __( 'All of %s', 'viator-collector' ), $term->name ), $region_url );
|
||||
foreach ( $region['cities'] as $city ) {
|
||||
$items[] = self::item( ++$n, $parent->db_id, $city['name'], home_url( '/' . $city['slug'] . '/' ) );
|
||||
}
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
private static function item( $n, $parent_id, $title, $url ) {
|
||||
$id = -1000 - $n;
|
||||
$item = new stdClass();
|
||||
|
||||
$item->ID = $id;
|
||||
$item->db_id = $id;
|
||||
$item->menu_item_parent = (string) $parent_id;
|
||||
$item->object_id = (string) $id;
|
||||
$item->object = 'custom';
|
||||
$item->type = 'custom';
|
||||
$item->type_label = '';
|
||||
$item->title = $title;
|
||||
$item->url = $url;
|
||||
$item->target = '';
|
||||
$item->attr_title = '';
|
||||
$item->description = '';
|
||||
$item->classes = array( 'menu-item', 'vc-structure-item' );
|
||||
$item->xfn = '';
|
||||
$item->status = 'publish';
|
||||
$item->post_parent = 0;
|
||||
$item->menu_order = $n;
|
||||
$item->current = false;
|
||||
$item->current_item_ancestor = false;
|
||||
$item->current_item_parent = false;
|
||||
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,13 @@ final class VC_Plugin {
|
||||
VC_CPT::init();
|
||||
VC_Taxonomies::init();
|
||||
VC_Settings::init();
|
||||
VC_Structure_Admin::init();
|
||||
VC_Routing::init();
|
||||
VC_Menu::init();
|
||||
VC_Cron::init();
|
||||
VC_Display::init();
|
||||
VC_Sections::init();
|
||||
VC_City_Guide::init();
|
||||
}
|
||||
|
||||
private function load_files() {
|
||||
@@ -30,12 +34,18 @@ final class VC_Plugin {
|
||||
'includes/class-vc-logger.php',
|
||||
'includes/class-vc-cpt.php',
|
||||
'includes/class-vc-taxonomies.php',
|
||||
'includes/class-vc-structure.php',
|
||||
'includes/class-vc-tags.php',
|
||||
'includes/class-vc-api.php',
|
||||
'includes/class-vc-importer.php',
|
||||
'includes/class-vc-cron.php',
|
||||
'includes/class-vc-settings.php',
|
||||
'includes/class-vc-structure-admin.php',
|
||||
'includes/class-vc-routing.php',
|
||||
'includes/class-vc-menu.php',
|
||||
'includes/class-vc-display.php',
|
||||
'includes/class-vc-sections.php',
|
||||
'includes/class-vc-city-guide.php',
|
||||
);
|
||||
|
||||
foreach ( $files as $file ) {
|
||||
@@ -53,6 +63,9 @@ final class VC_Plugin {
|
||||
VC_Taxonomies::register();
|
||||
VC_Cron::maybe_schedule();
|
||||
flush_rewrite_rules();
|
||||
// Structure routes are added on init, which has not run for this
|
||||
// request; flush again on the next one so they are included.
|
||||
update_option( 'vc_flush_rewrite', 1 );
|
||||
}
|
||||
|
||||
public static function deactivate() {
|
||||
|
||||
147
viator-collector/includes/class-vc-routing.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Place-based URLs from the Viator Structure: /{region}/, /{city}/ and
|
||||
* /{city}/{tour}/. Old /tour-destination/… and /tours/… addresses 301 to
|
||||
* the new ones, and a tour opened under the wrong city 301s to its one
|
||||
* canonical city.
|
||||
*/
|
||||
class VC_Routing {
|
||||
private static $product_city = array();
|
||||
|
||||
public static function init() {
|
||||
// After the CPT/taxonomies register (default priority 10).
|
||||
add_action( 'init', array( __CLASS__, 'add_rules' ), 20 );
|
||||
add_filter( 'term_link', array( __CLASS__, 'term_link' ), 10, 3 );
|
||||
add_filter( 'post_type_link', array( __CLASS__, 'post_type_link' ), 10, 2 );
|
||||
// Ahead of core's redirect_canonical (priority 10).
|
||||
add_action( 'template_redirect', array( __CLASS__, 'redirect' ), 5 );
|
||||
add_action( 'wp_head', array( __CLASS__, 'term_canonical' ), 1 );
|
||||
}
|
||||
|
||||
public static function add_rules() {
|
||||
foreach ( VC_Structure::rewrite_rules( VC_Structure::get() ) as $regex => $query ) {
|
||||
add_rewrite_rule( $regex, $query, 'top' );
|
||||
}
|
||||
if ( get_option( 'vc_flush_rewrite' ) ) {
|
||||
delete_option( 'vc_flush_rewrite' );
|
||||
flush_rewrite_rules( false );
|
||||
}
|
||||
}
|
||||
|
||||
public static function is_configured_term( $term ) {
|
||||
if ( ! $term instanceof WP_Term || 'viator_destination' !== $term->taxonomy ) {
|
||||
return false;
|
||||
}
|
||||
if ( get_term_meta( $term->term_id, '_vc_region', true ) ) {
|
||||
return true;
|
||||
}
|
||||
$viator_id = (string) get_term_meta( $term->term_id, '_vc_viator_destination_id', true );
|
||||
if ( '' === $viator_id ) {
|
||||
return false;
|
||||
}
|
||||
$cities = VC_Structure::cities( VC_Structure::get() );
|
||||
return isset( $cities[ $viator_id ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Slug of the city a tour's URL lives under, or '' (it stays at /tours/).
|
||||
*/
|
||||
public static function product_city( $post_id ) {
|
||||
$post_id = (int) $post_id;
|
||||
if ( isset( self::$product_city[ $post_id ] ) ) {
|
||||
return self::$product_city[ $post_id ];
|
||||
}
|
||||
$dest_ids = array();
|
||||
$destinations = json_decode( (string) get_post_meta( $post_id, '_vc_destinations', true ), true );
|
||||
foreach ( is_array( $destinations ) ? $destinations : array() as $destination ) {
|
||||
$ref = is_array( $destination ) ? ( $destination['ref'] ?? ( $destination['destinationId'] ?? '' ) ) : $destination;
|
||||
if ( '' !== (string) $ref ) {
|
||||
$dest_ids[] = (string) $ref;
|
||||
}
|
||||
}
|
||||
$primary = (string) get_post_meta( $post_id, '_vc_primary_destination', true );
|
||||
|
||||
self::$product_city[ $post_id ] = VC_Structure::canonical_city( VC_Structure::get(), $dest_ids, $primary );
|
||||
return self::$product_city[ $post_id ];
|
||||
}
|
||||
|
||||
public static function term_link( $url, $term, $taxonomy ) {
|
||||
if ( 'viator_destination' === $taxonomy && self::is_configured_term( $term ) ) {
|
||||
return home_url( '/' . $term->slug . '/' );
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
|
||||
public static function post_type_link( $url, $post ) {
|
||||
if ( 'viator_tour' !== $post->post_type || '' === $post->post_name ) {
|
||||
return $url;
|
||||
}
|
||||
$city = self::product_city( $post->ID );
|
||||
if ( '' === $city ) {
|
||||
return $url;
|
||||
}
|
||||
return home_url( '/' . $city . '/' . $post->post_name . '/' );
|
||||
}
|
||||
|
||||
public static function redirect() {
|
||||
if ( is_preview() ) {
|
||||
return;
|
||||
}
|
||||
$canonical = '';
|
||||
if ( is_singular( 'viator_tour' ) ) {
|
||||
$post = get_queried_object();
|
||||
if ( $post && 'publish' === $post->post_status ) {
|
||||
$canonical = get_permalink( $post );
|
||||
}
|
||||
} elseif ( is_tax( 'viator_destination' ) ) {
|
||||
$term = get_queried_object();
|
||||
if ( self::is_configured_term( $term ) ) {
|
||||
$canonical = get_term_link( $term );
|
||||
// Keep the page: /tour-destination/x/page/2/ and /x/?paged=2
|
||||
// both land on /x/page/2/ (the form core's redirect_canonical
|
||||
// would produce anyway).
|
||||
$paged = (int) get_query_var( 'paged' );
|
||||
if ( $paged > 1 && ! is_wp_error( $canonical ) ) {
|
||||
$canonical = trailingslashit( $canonical ) . 'page/' . $paged . '/';
|
||||
}
|
||||
}
|
||||
}
|
||||
if ( ! $canonical || is_wp_error( $canonical ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
|
||||
$parts = explode( '?', $request, 2 );
|
||||
$current = untrailingslashit( rawurldecode( $parts[0] ) );
|
||||
$target = untrailingslashit( rawurldecode( (string) wp_parse_url( $canonical, PHP_URL_PATH ) ) );
|
||||
if ( $current === $target ) {
|
||||
return;
|
||||
}
|
||||
if ( isset( $parts[1] ) && '' !== $parts[1] ) {
|
||||
$canonical .= '?' . $parts[1];
|
||||
$canonical = remove_query_arg( 'paged', $canonical );
|
||||
}
|
||||
wp_safe_redirect( $canonical, 301 );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core prints rel=canonical for single tours only; add it for region and
|
||||
* city archives unless an SEO plugin already handles canonicals.
|
||||
*/
|
||||
public static function term_canonical() {
|
||||
if ( defined( 'WPSEO_VERSION' ) || defined( 'RANK_MATH_VERSION' ) || ! is_tax( 'viator_destination' ) ) {
|
||||
return;
|
||||
}
|
||||
$term = get_queried_object();
|
||||
if ( ! self::is_configured_term( $term ) ) {
|
||||
return;
|
||||
}
|
||||
$link = get_term_link( $term );
|
||||
if ( ! is_wp_error( $link ) ) {
|
||||
echo '<link rel="canonical" href="' . esc_url( $link ) . '">' . "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ class VC_Sections {
|
||||
const TOP_ATTRS = 'viator_top_attractions';
|
||||
const ACTIVITIES = 'viator_browse_activities';
|
||||
const TOP_TOURS = 'viator_top_tours';
|
||||
const BUS_TOURS = 'viator_bus_tours';
|
||||
const ABOUT = 'viator_about';
|
||||
const ARTICLES = 'viator_articles';
|
||||
const FAQ = 'viator_faq';
|
||||
const REVIEWS = 'viator_testimonials';
|
||||
|
||||
@@ -28,6 +31,9 @@ class VC_Sections {
|
||||
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::BUS_TOURS, array( __CLASS__, 'bus_tours' ) );
|
||||
add_shortcode( self::ABOUT, array( __CLASS__, 'about' ) );
|
||||
add_shortcode( self::ARTICLES, array( __CLASS__, 'articles' ) );
|
||||
add_shortcode( self::FAQ, array( __CLASS__, 'faq' ) );
|
||||
add_shortcode( self::REVIEWS, array( __CLASS__, 'testimonials' ) );
|
||||
}
|
||||
@@ -44,14 +50,21 @@ class VC_Sections {
|
||||
'title' => __( 'Set out on the journey.', 'viator-collector' ),
|
||||
'eyebrow' => __( 'Tour & Travel', 'viator-collector' ),
|
||||
'image' => '',
|
||||
'image_id' => '', // WordPress media attachment ID (responsive).
|
||||
),
|
||||
$atts,
|
||||
self::HERO
|
||||
);
|
||||
|
||||
$img = $atts['image'] ? $atts['image'] : vc_hero_image_url();
|
||||
if ( ! $img ) {
|
||||
$img = vc_asset_url( 'img/hero.png' );
|
||||
// Prefer a media-library image (responsive srcset): shortcode image_id,
|
||||
// then a shortcode URL, then the plugin's Hero image setting. No bundled
|
||||
// fallback — an empty source shows the CSS gradient.
|
||||
if ( '' !== $atts['image_id'] ) {
|
||||
$img = absint( $atts['image_id'] );
|
||||
} elseif ( '' !== $atts['image'] ) {
|
||||
$img = $atts['image'];
|
||||
} else {
|
||||
$img = vc_hero_image_source();
|
||||
}
|
||||
$archive = get_post_type_archive_link( 'viator_tour' );
|
||||
$archive = $archive ? $archive : home_url( '/' );
|
||||
@@ -64,7 +77,7 @@ class VC_Sections {
|
||||
<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">
|
||||
<?php echo vc_hero_img_html( $img, 'hero__img', true ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
<div class="hero__scrim" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="container hero__content">
|
||||
@@ -123,7 +136,7 @@ class VC_Sections {
|
||||
* 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 ) {
|
||||
private static function open_rail( $title, $lead, $view_all_url, $view_all_text, $rail_class = 'rail--dest' ) {
|
||||
$id = 'vcRail' . ( ++self::$rail_seq );
|
||||
ob_start();
|
||||
?>
|
||||
@@ -143,7 +156,7 @@ class VC_Sections {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="rail rail--dest" id="<?php echo esc_attr( $id ); ?>">
|
||||
<ul class="rail <?php echo esc_attr( $rail_class ); ?>" id="<?php echo esc_attr( $id ); ?>">
|
||||
<?php
|
||||
return array( 'id' => $id, 'head' => ob_get_clean() );
|
||||
}
|
||||
@@ -396,15 +409,15 @@ class VC_Sections {
|
||||
self::REVIEWS
|
||||
);
|
||||
|
||||
$a1 = vc_asset_url( 'img/ellipse-17.png' );
|
||||
$a2 = vc_asset_url( 'img/ellipse-17-2.png' );
|
||||
// Avatars are optional; supply them via the vc_testimonials filter
|
||||
// ('avatar' => attachment URL) — none are bundled with the plugin.
|
||||
$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' => '' ),
|
||||
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' => '', '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' => '', 'size' => '' ),
|
||||
array( 'name' => 'Sofia Rossi', 'role' => __( 'Sydney Tour', 'viator-collector' ), 'text' => __( 'Smooth from start to finish. Highly recommended.', 'viator-collector' ), 'avatar' => '', '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' => '', 'size' => '' ),
|
||||
)
|
||||
);
|
||||
if ( empty( $items ) ) {
|
||||
@@ -443,6 +456,299 @@ class VC_Sections {
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* [viator_bus_tours limit="4" tag="hop-on-hop-off" image_id="123"]
|
||||
*
|
||||
* The "Open Top Bus Tours…" promo block + a 4-up grid of tour cards. Backed
|
||||
* by the same curated/filtered tour query as the rails. The bus illustration
|
||||
* is a heavy image, so it comes from the Media Library via image_id (or a
|
||||
* direct image URL) — nothing is bundled with the plugin.
|
||||
*/
|
||||
public static function bus_tours( $atts ) {
|
||||
$atts = shortcode_atts(
|
||||
array(
|
||||
'ids' => '',
|
||||
'codes' => '',
|
||||
'destination' => '',
|
||||
'tag' => '',
|
||||
'limit' => 4,
|
||||
'orderby' => 'rating',
|
||||
'title' => __( 'Open Top<br>Bus Tours…', 'viator-collector' ),
|
||||
'note' => __( 'An open-top hop on hop off bus is the best way to discover the historic and cultural sights of any city.', 'viator-collector' ),
|
||||
'chip' => __( 'Very Fun!', 'viator-collector' ),
|
||||
'image_id' => '', // WordPress media attachment ID for the bus illustration.
|
||||
'image' => '', // …or a direct image URL.
|
||||
),
|
||||
$atts,
|
||||
self::BUS_TOURS
|
||||
);
|
||||
|
||||
$query = self::tour_query( $atts );
|
||||
if ( ! $query->have_posts() ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$archive = get_post_type_archive_link( 'viator_tour' );
|
||||
$archive = $archive ? $archive : home_url( '/' );
|
||||
|
||||
$bus_img = '';
|
||||
if ( '' !== $atts['image_id'] ) {
|
||||
// 'full' (uncropped original) — some sites register a hard-cropped
|
||||
// square 'large', which slices decorative illustrations in half.
|
||||
$bus_img = wp_get_attachment_image( absint( $atts['image_id'] ), 'full', false, array( 'alt' => '', 'loading' => 'lazy' ) );
|
||||
} elseif ( '' !== $atts['image'] ) {
|
||||
$bus_img = '<img src="' . esc_url( $atts['image'] ) . '" alt="" loading="lazy">';
|
||||
}
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<div class="vc-app vc-sections">
|
||||
<section class="section section--tight">
|
||||
<div class="container bus">
|
||||
<div class="promo">
|
||||
<svg class="promo__route" viewBox="0 0 416 526" fill="none" aria-hidden="true" preserveAspectRatio="none">
|
||||
<path d="M-20 470 C 90 470, 60 350, 150 330 S 300 300, 300 210 S 210 120, 300 60 S 430 40, 440 -10" stroke="#2CAFC2" stroke-dasharray="3 3" />
|
||||
</svg>
|
||||
<div class="promo__head">
|
||||
<h2 class="promo__title"><?php echo wp_kses( $atts['title'], array( 'br' => array() ) ); ?></h2>
|
||||
<a class="btn btn--white" href="<?php echo esc_url( $archive ); ?>"><span><?php echo esc_html__( 'See all', 'viator-collector' ); ?></span><?php echo vc_icon( 'arrow-ne' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></a>
|
||||
</div>
|
||||
<?php if ( $atts['chip'] ) : ?><span class="chip chip--accent promo__chip"><?php echo esc_html( $atts['chip'] ); ?></span><?php endif; ?>
|
||||
<?php if ( $bus_img ) : ?><div class="promo__bus" aria-hidden="true"><?php echo $bus_img; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div><?php endif; ?>
|
||||
<?php if ( $atts['note'] ) : ?><p class="promo__note"><?php echo esc_html( $atts['note'] ); ?></p><?php endif; ?>
|
||||
</div>
|
||||
|
||||
<ul class="bus__grid">
|
||||
<?php
|
||||
$i = 0;
|
||||
while ( $query->have_posts() ) :
|
||||
$query->the_post();
|
||||
++$i;
|
||||
$pid = get_the_ID();
|
||||
$price = get_post_meta( $pid, '_vc_price_from', true );
|
||||
$currency = get_post_meta( $pid, '_vc_currency', true );
|
||||
$terms = get_the_terms( $pid, 'viator_destination' );
|
||||
$kicker = ( $terms && ! is_wp_error( $terms ) ) ? $terms[0]->name : '';
|
||||
$active = ( 1 === $i );
|
||||
$badge = vc_ring_badge( 'BOOK NOW · BOOK NOW · ', get_permalink( $pid ), 'card' );
|
||||
if ( $active ) {
|
||||
$badge = str_replace( 'ring-badge--card', 'ring-badge--card ring-badge--accent', $badge );
|
||||
}
|
||||
?>
|
||||
<li class="tcard<?php echo $active ? ' is-active' : ''; ?>">
|
||||
<?php echo vc_tour_image_html( $pid, 'tcard__img' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
<div class="tcard__top">
|
||||
<?php if ( $kicker ) : ?><span class="tcard__k"><?php echo esc_html( $kicker ); ?></span><?php endif; ?>
|
||||
<h3 class="tcard__title"><a href="<?php echo esc_url( get_permalink( $pid ) ); ?>"><?php echo esc_html( get_the_title( $pid ) ); ?></a></h3>
|
||||
</div>
|
||||
<div class="tcard__bottom">
|
||||
<?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 echo $badge; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
</div>
|
||||
</li>
|
||||
<?php endwhile; ?>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<?php
|
||||
wp_reset_postdata();
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* [viator_about image_id="123" title="Hop On Hop Off" highlight="Memorable"
|
||||
* stats="25+|Years Experience;;500+|Tours Conducted"]
|
||||
*
|
||||
* A static "About" marketing block (headline + chip, paragraph, stat counters
|
||||
* and a decorative bus). All copy is editable via attributes; the isometric
|
||||
* bus is a heavy image supplied from the Media Library (image_id / image).
|
||||
*/
|
||||
public static function about( $atts ) {
|
||||
$atts = shortcode_atts(
|
||||
array(
|
||||
'title' => __( 'Hop On Hop Off', 'viator-collector' ),
|
||||
'highlight' => __( 'Memorable', 'viator-collector' ),
|
||||
'text' => __( 'Discover cities the memorable way — hop on and off our open-top buses at every major sight and explore each place at your own pace, with expert local commentary along the way.', 'viator-collector' ),
|
||||
'button_text' => __( 'About us', 'viator-collector' ),
|
||||
'button_url' => '',
|
||||
'image_id' => '',
|
||||
'image' => '',
|
||||
'stats' => '', // "25+|Years Experience;;500+|Tours Conducted;;430+|Tours this Year;;15+|Different Tours"
|
||||
),
|
||||
$atts,
|
||||
self::ABOUT
|
||||
);
|
||||
|
||||
$stats = array();
|
||||
if ( '' !== $atts['stats'] ) {
|
||||
foreach ( explode( ';;', $atts['stats'] ) as $pair ) {
|
||||
$p = array_map( 'trim', explode( '|', $pair, 2 ) );
|
||||
if ( '' !== $p[0] && isset( $p[1] ) ) {
|
||||
$stats[] = array( 'num' => $p[0], 'label' => $p[1] );
|
||||
}
|
||||
}
|
||||
}
|
||||
if ( empty( $stats ) ) {
|
||||
$stats = array(
|
||||
array( 'num' => '25+', 'label' => __( 'Years Experience', 'viator-collector' ) ),
|
||||
array( 'num' => '500+', 'label' => __( 'Tours Conducted', 'viator-collector' ) ),
|
||||
array( 'num' => '430+', 'label' => __( 'Tours this Year', 'viator-collector' ) ),
|
||||
array( 'num' => '15+', 'label' => __( 'Different Tours', 'viator-collector' ) ),
|
||||
);
|
||||
}
|
||||
|
||||
$button_url = $atts['button_url'];
|
||||
if ( '' === $button_url ) {
|
||||
$button_url = get_post_type_archive_link( 'viator_tour' );
|
||||
$button_url = $button_url ? $button_url : home_url( '/' );
|
||||
}
|
||||
|
||||
$bus_img = '';
|
||||
if ( '' !== $atts['image_id'] ) {
|
||||
// 'full' (uncropped original) — some sites register a hard-cropped
|
||||
// square 'large', which slices decorative illustrations in half.
|
||||
$bus_img = wp_get_attachment_image( absint( $atts['image_id'] ), 'full', false, array( 'alt' => '', 'loading' => 'lazy' ) );
|
||||
} elseif ( '' !== $atts['image'] ) {
|
||||
$bus_img = '<img src="' . esc_url( $atts['image'] ) . '" alt="" loading="lazy">';
|
||||
}
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<div class="vc-app vc-sections">
|
||||
<section class="section">
|
||||
<div class="container about">
|
||||
<div class="about__art" aria-hidden="true">
|
||||
<span class="about__glow"></span>
|
||||
<span class="about__trail" aria-hidden="true"></span>
|
||||
<?php if ( $bus_img ) : ?><span class="about__bus"><?php echo $bus_img; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span><?php endif; ?>
|
||||
<svg class="about__arcs" viewBox="0 0 480 440" fill="none" aria-hidden="true">
|
||||
<defs>
|
||||
<path id="busArc" pathLength="100" d="M20 336.6V221.6C20 96.6 170 23.4 170 148.4V263.4" />
|
||||
</defs>
|
||||
<g stroke="#FFCC00" stroke-width="9" stroke-linecap="round" fill="none" transform="translate(16 24)">
|
||||
<use href="#busArc" opacity="0.1" stroke-dasharray="62 100" />
|
||||
<use href="#busArc" opacity="0.25" stroke-dasharray="69 100" transform="translate(48 22)" />
|
||||
<use href="#busArc" opacity="0.45" stroke-dasharray="76 100" transform="translate(96 44)" />
|
||||
<use href="#busArc" opacity="0.8" stroke-dasharray="83 100" transform="translate(144 66)" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="about__body">
|
||||
<div class="about__head">
|
||||
<h2 class="about__title"><?php echo esc_html( $atts['title'] ); ?><?php if ( $atts['highlight'] ) : ?><span class="chip chip--accent about__chip"><?php echo esc_html( $atts['highlight'] ); ?></span><?php endif; ?></h2>
|
||||
<a class="btn btn--dark" href="<?php echo esc_url( $button_url ); ?>"><span><?php echo esc_html( $atts['button_text'] ); ?></span><?php echo vc_icon( 'arrow-right' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></a>
|
||||
</div>
|
||||
|
||||
<?php if ( $atts['text'] ) : ?><p class="about__text"><?php echo esc_html( $atts['text'] ); ?></p><?php endif; ?>
|
||||
|
||||
<dl class="stats">
|
||||
<?php
|
||||
foreach ( $stats as $s ) :
|
||||
$num = $s['num'];
|
||||
$suffix = '';
|
||||
if ( preg_match( '/^(.*?)(\+)$/', $num, $m ) ) {
|
||||
$num = $m[1];
|
||||
$suffix = $m[2];
|
||||
}
|
||||
?>
|
||||
<div class="stat">
|
||||
<dt class="stat__num"><?php echo esc_html( $num ); ?><?php if ( $suffix ) : ?><span><?php echo esc_html( $suffix ); ?></span><?php endif; ?></dt>
|
||||
<dd class="stat__label"><?php echo esc_html( $s['label'] ); ?></dd>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* [viator_articles limit="8" category="news"]
|
||||
*
|
||||
* A rail of the latest blog posts, styled as the design's "Latest Articles"
|
||||
* cards. Uses each post's featured image, time and excerpt; links to the post.
|
||||
*/
|
||||
public static function articles( $atts ) {
|
||||
$atts = shortcode_atts(
|
||||
array(
|
||||
'title' => __( 'Latest<br>Articles', 'viator-collector' ),
|
||||
'lead' => __( 'News, tips and stories from our tours.', 'viator-collector' ),
|
||||
'limit' => 8,
|
||||
'category' => '',
|
||||
'view_all' => '',
|
||||
),
|
||||
$atts,
|
||||
self::ARTICLES
|
||||
);
|
||||
|
||||
$args = array(
|
||||
'post_type' => 'post',
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => max( 1, absint( $atts['limit'] ) ),
|
||||
'ignore_sticky_posts' => true,
|
||||
'no_found_rows' => true,
|
||||
);
|
||||
if ( '' !== $atts['category'] ) {
|
||||
$args['category_name'] = sanitize_title( $atts['category'] );
|
||||
}
|
||||
|
||||
$query = new WP_Query( $args );
|
||||
if ( ! $query->have_posts() ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$view_all = $atts['view_all'];
|
||||
if ( '' === $view_all ) {
|
||||
$posts_page = (int) get_option( 'page_for_posts' );
|
||||
$view_all = $posts_page ? get_permalink( $posts_page ) : home_url( '/' );
|
||||
}
|
||||
|
||||
$rail = self::open_rail( $atts['title'], $atts['lead'], $view_all, __( 'See all', 'viator-collector' ), 'rail--art' );
|
||||
|
||||
ob_start();
|
||||
echo $rail['head']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
$i = 0;
|
||||
while ( $query->have_posts() ) :
|
||||
$query->the_post();
|
||||
++$i;
|
||||
$pid = get_the_ID();
|
||||
$active = ( 1 === $i );
|
||||
$img = has_post_thumbnail( $pid ) ? get_the_post_thumbnail( $pid, 'large', array( 'class' => 'acard__img', 'loading' => 'lazy', 'alt' => '' ) ) : '';
|
||||
$time = human_time_diff( (int) get_post_time( 'U', true, $pid ), (int) current_time( 'timestamp', true ) );
|
||||
$excerpt = get_the_excerpt( $pid );
|
||||
$badge = vc_ring_badge( 'READ MORE · READ MORE · ', get_permalink( $pid ), 'card' );
|
||||
if ( $active ) {
|
||||
$badge = str_replace( 'ring-badge--card', 'ring-badge--card ring-badge--accent', $badge );
|
||||
}
|
||||
?>
|
||||
<li class="acard<?php echo $active ? ' is-active' : ''; ?>">
|
||||
<div class="acard__media">
|
||||
<?php echo $img; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
<span class="chip chip--glass acard__time"><?php printf( /* translators: %s: human-readable time difference, e.g. "3 hours". */ esc_html__( '%s ago', 'viator-collector' ), esc_html( $time ) ); ?></span>
|
||||
<div class="acard__overlay">
|
||||
<h3 class="acard__title"><a href="<?php echo esc_url( get_permalink( $pid ) ); ?>"><?php echo esc_html( get_the_title( $pid ) ); ?></a></h3>
|
||||
<?php echo $badge; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ( $excerpt ) : ?><p class="acard__excerpt"><?php echo esc_html( $excerpt ); ?></p><?php endif; ?>
|
||||
</li>
|
||||
<?php
|
||||
endwhile;
|
||||
echo self::close_rail(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
wp_reset_postdata();
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared tour query for the landing sections. Supports curation (ids /
|
||||
* product codes) and filtering (destination / tag), with a sane fallback.
|
||||
|
||||
@@ -63,6 +63,33 @@ class VC_Settings {
|
||||
}
|
||||
}
|
||||
|
||||
// Suggested URL slug for the Viator Structure screen: keep an existing
|
||||
// term's slug so live /tour-destination/{slug}/ links still resolve
|
||||
// (and 301), otherwise WordPress's own slug for the name.
|
||||
$existing = array();
|
||||
if ( $results ) {
|
||||
$terms = get_terms(
|
||||
array(
|
||||
'taxonomy' => 'viator_destination',
|
||||
'hide_empty' => false,
|
||||
'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery
|
||||
array(
|
||||
'key' => '_vc_viator_destination_id',
|
||||
'value' => array_column( $results, 'id' ),
|
||||
'compare' => 'IN',
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
foreach ( is_wp_error( $terms ) ? array() : $terms as $term ) {
|
||||
$existing[ (string) get_term_meta( $term->term_id, '_vc_viator_destination_id', true ) ] = $term->slug;
|
||||
}
|
||||
}
|
||||
foreach ( $results as &$result ) {
|
||||
$result['slug'] = isset( $existing[ $result['id'] ] ) ? $existing[ $result['id'] ] : sanitize_title( $result['name'] );
|
||||
}
|
||||
unset( $result );
|
||||
|
||||
wp_send_json_success( array( 'results' => $results ) );
|
||||
}
|
||||
|
||||
@@ -74,7 +101,10 @@ class VC_Settings {
|
||||
'currency' => 'USD',
|
||||
'timeout' => 20,
|
||||
'default_post_status' => 'publish',
|
||||
'contact_email' => '',
|
||||
'hero_image' => '',
|
||||
'hero_image_id' => 0,
|
||||
'single_hero_figure_id' => 0,
|
||||
'hero_slides' => '',
|
||||
'crawl_destinations' => '',
|
||||
'rate_limit_per_minute' => 25,
|
||||
@@ -121,7 +151,10 @@ class VC_Settings {
|
||||
'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' ),
|
||||
'contact_email' => sanitize_email( $input['contact_email'] ?? '' ),
|
||||
'hero_image' => esc_url_raw( $input['hero_image'] ?? '' ),
|
||||
'hero_image_id' => absint( $input['hero_image_id'] ?? 0 ),
|
||||
'single_hero_figure_id' => absint( $input['single_hero_figure_id'] ?? 0 ),
|
||||
'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 ) ) ),
|
||||
@@ -194,13 +227,21 @@ class VC_Settings {
|
||||
<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_contact_email"><?php echo esc_html__( 'Contact email', 'viator-collector' ); ?></label></th>
|
||||
<td>
|
||||
<input id="vc_contact_email" type="email" class="regular-text" name="vc_settings[contact_email]" value="<?php echo esc_attr( $options['contact_email'] ); ?>" placeholder="<?php echo esc_attr( get_option( 'admin_email' ) ); ?>">
|
||||
<p class="description"><?php echo esc_html__( 'Where the Contact page form is sent. Leave empty to use the site admin email.', '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://…">
|
||||
<input id="vc_hero_image_id" type="hidden" name="vc_settings[hero_image_id]" value="<?php echo esc_attr( $options['hero_image_id'] ); ?>">
|
||||
<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>
|
||||
<p class="description"><?php echo esc_html__( 'Banner at the top of the tours listing / home hero. Pick from the Media Library so the browser gets responsive, device-sized images. 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;">
|
||||
@@ -221,6 +262,7 @@ class VC_Settings {
|
||||
frame.on('select', function () {
|
||||
var img = frame.state().get('selection').first().toJSON();
|
||||
$('#vc_hero_image').val(img.url);
|
||||
$('#vc_hero_image_id').val(img.id);
|
||||
$('#vc-hero-preview').html($('<img>', { src: img.url, alt: '' }).css({ maxWidth: '320px', height: 'auto', borderRadius: '8px' }));
|
||||
});
|
||||
frame.open();
|
||||
@@ -228,12 +270,56 @@ class VC_Settings {
|
||||
$('#vc-hero-clear').on('click', function (e) {
|
||||
e.preventDefault();
|
||||
$('#vc_hero_image').val('');
|
||||
$('#vc_hero_image_id').val('');
|
||||
$('#vc-hero-preview').empty();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label><?php echo esc_html__( 'Single tour hero figure', 'viator-collector' ); ?></label></th>
|
||||
<td>
|
||||
<input id="vc_single_figure_id" type="hidden" name="vc_settings[single_hero_figure_id]" value="<?php echo esc_attr( $options['single_hero_figure_id'] ); ?>">
|
||||
<button type="button" class="button" id="vc-figure-pick"><?php echo esc_html__( 'Select image', 'viator-collector' ); ?></button>
|
||||
<button type="button" class="button" id="vc-figure-clear"><?php echo esc_html__( 'Clear', 'viator-collector' ); ?></button>
|
||||
<p class="description"><?php echo esc_html__( 'Optional decorative cut-out shown over the single-tour hero (e.g. a landmark PNG with a transparent background). Leave empty to hide it.', 'viator-collector' ); ?></p>
|
||||
<div id="vc-figure-preview" style="margin-top:8px;">
|
||||
<?php
|
||||
if ( $options['single_hero_figure_id'] ) {
|
||||
echo wp_get_attachment_image( (int) $options['single_hero_figure_id'], 'medium', false, array( 'style' => 'max-width:200px;height:auto;' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
<script>
|
||||
jQuery(function ($) {
|
||||
var frame;
|
||||
$('#vc-figure-pick').on('click', function (e) {
|
||||
e.preventDefault();
|
||||
if (frame) { frame.open(); return; }
|
||||
frame = wp.media({
|
||||
title: <?php echo wp_json_encode( __( 'Select hero figure', '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_single_figure_id').val(img.id);
|
||||
var src = (img.sizes && img.sizes.medium) ? img.sizes.medium.url : img.url;
|
||||
$('#vc-figure-preview').html($('<img>', { src: src, alt: '' }).css({ maxWidth: '200px', height: 'auto' }));
|
||||
});
|
||||
frame.open();
|
||||
});
|
||||
$('#vc-figure-clear').on('click', function (e) {
|
||||
e.preventDefault();
|
||||
$('#vc_single_figure_id').val('');
|
||||
$('#vc-figure-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>
|
||||
@@ -295,7 +381,7 @@ class VC_Settings {
|
||||
<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>
|
||||
<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. Used only when no cities are set on the Viator Structure screen.', '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>
|
||||
|
||||
475
viator-collector/includes/class-vc-structure-admin.php
Normal file
@@ -0,0 +1,475 @@
|
||||
<?php
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Settings → Viator Structure: the client's region → city tree. Cities can
|
||||
* only be picked from Viator's destination list, and a save that fails
|
||||
* VC_Structure::validate() is rejected as a whole (the draft is kept so the
|
||||
* admin can fix it).
|
||||
*/
|
||||
class VC_Structure_Admin {
|
||||
const PAGE = 'viator-collector-structure';
|
||||
|
||||
private static $sync_errors = array();
|
||||
|
||||
public static function init() {
|
||||
add_action( 'admin_menu', array( __CLASS__, 'admin_menu' ) );
|
||||
add_action( 'admin_post_vc_save_structure', array( __CLASS__, 'handle_save' ) );
|
||||
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'admin_assets' ) );
|
||||
add_action( 'admin_notices', array( __CLASS__, 'page_clash_notice' ) );
|
||||
}
|
||||
|
||||
public static function admin_menu() {
|
||||
add_options_page(
|
||||
__( 'Viator Structure', 'viator-collector' ),
|
||||
__( 'Viator Structure', 'viator-collector' ),
|
||||
'manage_options',
|
||||
self::PAGE,
|
||||
array( __CLASS__, 'render_page' )
|
||||
);
|
||||
}
|
||||
|
||||
public static function admin_assets( $hook ) {
|
||||
if ( 'settings_page_' . self::PAGE !== $hook ) {
|
||||
return;
|
||||
}
|
||||
wp_enqueue_script( 'vc-structure-admin', VC_PLUGIN_URL . 'assets/js/vc-structure-admin.js', array(), VC_VERSION, true );
|
||||
wp_localize_script(
|
||||
'vc-structure-admin',
|
||||
'vcStructure',
|
||||
array(
|
||||
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||
'nonce' => wp_create_nonce( 'vc_search_destinations' ),
|
||||
'i18n' => array(
|
||||
'searching' => __( 'Searching…', 'viator-collector' ),
|
||||
'noMatches' => __( 'No matches.', 'viator-collector' ),
|
||||
'failed' => __( 'Search failed.', 'viator-collector' ),
|
||||
'add' => __( 'Add', 'viator-collector' ),
|
||||
'used' => __( 'Already added', 'viator-collector' ),
|
||||
'confirm' => __( 'Remove this region and all its cities from the structure?', 'viator-collector' ),
|
||||
'confirmEmpty' => __( 'Save with no regions at all? Every region and city URL will stop working.', 'viator-collector' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private static function draft_key() {
|
||||
return 'vc_structure_draft_' . get_current_user_id();
|
||||
}
|
||||
|
||||
public static function render_page() {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return;
|
||||
}
|
||||
$draft = get_transient( self::draft_key() );
|
||||
$errors = array();
|
||||
$structure = VC_Structure::get();
|
||||
if ( is_array( $draft ) ) {
|
||||
delete_transient( self::draft_key() );
|
||||
$errors = (array) $draft['errors'];
|
||||
$structure = (array) $draft['structure'];
|
||||
}
|
||||
$warnings = get_transient( self::warnings_key() );
|
||||
$warnings = is_array( $warnings ) ? $warnings : array();
|
||||
if ( $warnings ) {
|
||||
delete_transient( self::warnings_key() );
|
||||
}
|
||||
$notice = isset( $_GET['vc_notice'] ) ? sanitize_text_field( wp_unslash( $_GET['vc_notice'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php echo esc_html__( 'Viator Structure', 'viator-collector' ); ?></h1>
|
||||
<p><?php echo esc_html__( 'Regions appear in the header menu and at /{region}/. Each city is a Viator destination: its tours are imported automatically and listed at /{city}/, with tour pages at /{city}/{tour}/.', 'viator-collector' ); ?></p>
|
||||
|
||||
<?php if ( $notice ) : ?>
|
||||
<div class="notice <?php echo $warnings ? 'notice-warning' : 'notice-success'; ?> is-dismissible"><p><?php echo esc_html( $notice ); ?></p></div>
|
||||
<?php endif; ?>
|
||||
<?php if ( $warnings ) : ?>
|
||||
<div class="notice notice-warning">
|
||||
<ul style="list-style:disc;margin-left:20px;">
|
||||
<?php foreach ( $warnings as $warning ) : ?>
|
||||
<li><?php echo esc_html( $warning ); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ( $errors ) : ?>
|
||||
<div class="notice notice-error">
|
||||
<p><strong><?php echo esc_html__( 'Nothing was saved. Please fix these problems:', 'viator-collector' ); ?></strong></p>
|
||||
<ul style="list-style:disc;margin-left:20px;">
|
||||
<?php foreach ( $errors as $error ) : ?>
|
||||
<li><?php echo esc_html( $error ); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<style>
|
||||
.vc-region{background:#fff;border:1px solid #c3c4c7;border-radius:6px;padding:12px 16px;margin:0 0 16px;max-width:900px}
|
||||
.vc-region__head{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
||||
.vc-region__head .vc-tools{margin-left:auto}
|
||||
.vc-cities{margin:12px 0;width:100%}
|
||||
.vc-cities td{padding:4px 8px 4px 0;vertical-align:middle}
|
||||
.vc-city-search{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
||||
.vc-results{width:100%}
|
||||
.vc-results div{display:flex;gap:8px;align-items:center;padding:2px 0}
|
||||
.vc-slug-prefix{color:#646970}
|
||||
</style>
|
||||
|
||||
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" id="vc-structure-form">
|
||||
<input type="hidden" name="action" value="vc_save_structure">
|
||||
<?php wp_nonce_field( 'vc_save_structure' ); ?>
|
||||
<div id="vc-regions">
|
||||
<?php
|
||||
foreach ( $structure as $region ) {
|
||||
self::render_region( $region );
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
<p><button type="button" class="button" id="vc-add-region"><?php echo esc_html__( 'Add region', 'viator-collector' ); ?></button></p>
|
||||
<?php submit_button( __( 'Save structure', 'viator-collector' ) ); ?>
|
||||
</form>
|
||||
|
||||
<template id="vc-tpl-region"><?php self::render_region( array( 'name' => '', 'slug' => '', 'term_id' => 0, 'cities' => array() ) ); ?></template>
|
||||
<template id="vc-tpl-city"><?php self::render_city( array( 'id' => '', 'name' => '', 'slug' => '' ) ); ?></template>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Inputs carry data-f (field) only; the script assigns indexed names on
|
||||
* submit so reordering never leaves gaps.
|
||||
*/
|
||||
private static function render_region( array $region ) {
|
||||
?>
|
||||
<div class="vc-region">
|
||||
<div class="vc-region__head">
|
||||
<label><?php echo esc_html__( 'Region', 'viator-collector' ); ?>
|
||||
<input type="text" data-f="name" value="<?php echo esc_attr( $region['name'] ); ?>" required>
|
||||
</label>
|
||||
<label><span class="vc-slug-prefix">/</span><input type="text" data-f="slug" value="<?php echo esc_attr( $region['slug'] ); ?>" pattern="[a-z0-9]+(-[a-z0-9]+)*" placeholder="europe">/</label>
|
||||
<input type="hidden" data-f="term_id" value="<?php echo esc_attr( (string) (int) $region['term_id'] ); ?>">
|
||||
<span class="vc-tools">
|
||||
<button type="button" class="button button-small" data-act="up" aria-label="<?php echo esc_attr__( 'Move up', 'viator-collector' ); ?>">↑</button>
|
||||
<button type="button" class="button button-small" data-act="down" aria-label="<?php echo esc_attr__( 'Move down', 'viator-collector' ); ?>">↓</button>
|
||||
<button type="button" class="button button-small button-link-delete" data-act="remove-region"><?php echo esc_html__( 'Remove region', 'viator-collector' ); ?></button>
|
||||
</span>
|
||||
</div>
|
||||
<table class="vc-cities"><tbody>
|
||||
<?php
|
||||
foreach ( $region['cities'] as $city ) {
|
||||
self::render_city( $city );
|
||||
}
|
||||
?>
|
||||
</tbody></table>
|
||||
<div class="vc-city-search">
|
||||
<input type="search" class="regular-text" placeholder="<?php echo esc_attr__( 'Find a Viator city, e.g. Amsterdam', 'viator-collector' ); ?>">
|
||||
<button type="button" class="button" data-act="search"><?php echo esc_html__( 'Search', 'viator-collector' ); ?></button>
|
||||
<div class="vc-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
private static function render_city( array $city ) {
|
||||
?>
|
||||
<tr class="vc-city">
|
||||
<td>
|
||||
<strong class="vc-city__name"><?php echo esc_html( $city['name'] ); ?></strong>
|
||||
<span class="description">(ID <span class="vc-city__id"><?php echo esc_html( $city['id'] ); ?></span>)</span>
|
||||
<input type="hidden" data-f="id" value="<?php echo esc_attr( $city['id'] ); ?>">
|
||||
<input type="hidden" data-f="name" value="<?php echo esc_attr( $city['name'] ); ?>">
|
||||
</td>
|
||||
<td><span class="vc-slug-prefix">/</span><input type="text" data-f="slug" value="<?php echo esc_attr( $city['slug'] ); ?>" pattern="[a-z0-9]+(-[a-z0-9]+)*" required>/</td>
|
||||
<td>
|
||||
<button type="button" class="button button-small" data-act="up" aria-label="<?php echo esc_attr__( 'Move up', 'viator-collector' ); ?>">↑</button>
|
||||
<button type="button" class="button button-small" data-act="down" aria-label="<?php echo esc_attr__( 'Move down', 'viator-collector' ); ?>">↓</button>
|
||||
<button type="button" class="button button-small button-link-delete" data-act="remove-city"><?php echo esc_html__( 'Remove', 'viator-collector' ); ?></button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php
|
||||
}
|
||||
|
||||
public static function handle_save() {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to perform this action.', 'viator-collector' ) );
|
||||
}
|
||||
check_admin_referer( 'vc_save_structure' );
|
||||
|
||||
$raw = isset( $_POST['vc_structure'] ) && is_array( $_POST['vc_structure'] ) ? wp_unslash( $_POST['vc_structure'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- normalized + validated below.
|
||||
$structure = VC_Structure::normalize( $raw );
|
||||
foreach ( $structure as &$region ) {
|
||||
$region['name'] = sanitize_text_field( $region['name'] );
|
||||
foreach ( $region['cities'] as &$city ) {
|
||||
$city['name'] = sanitize_text_field( $city['name'] );
|
||||
}
|
||||
unset( $city );
|
||||
}
|
||||
unset( $region );
|
||||
|
||||
$map = ( new VC_API() )->get_destination_map();
|
||||
if ( empty( $_POST['vc_structure_present'] ) ) {
|
||||
$errors = array( __( 'The structure editor did not load correctly, so nothing was saved. Reload the page and try again.', 'viator-collector' ) );
|
||||
} elseif ( ! $structure && empty( $_POST['vc_confirm_empty'] ) ) {
|
||||
$errors = array( __( 'Saving an empty structure needs confirmation; nothing was saved.', 'viator-collector' ) );
|
||||
} elseif ( is_wp_error( $map ) ) {
|
||||
$errors = array( __( 'Could not load Viator destinations; nothing was saved.', 'viator-collector' ) );
|
||||
} else {
|
||||
$errors = VC_Structure::validate( $structure, (array) $map, self::taken_slugs( $structure ) );
|
||||
// Names always come from Viator, never from the form.
|
||||
foreach ( $structure as &$region ) {
|
||||
foreach ( $region['cities'] as &$city ) {
|
||||
if ( isset( $map[ $city['id'] ] ) ) {
|
||||
$city['name'] = $map[ $city['id'] ];
|
||||
}
|
||||
}
|
||||
unset( $city );
|
||||
}
|
||||
unset( $region );
|
||||
}
|
||||
|
||||
if ( $errors ) {
|
||||
set_transient(
|
||||
self::draft_key(),
|
||||
array(
|
||||
'errors' => $errors,
|
||||
// Without the editor's fields there is no draft worth keeping.
|
||||
'structure' => empty( $_POST['vc_structure_present'] ) ? VC_Structure::get() : $structure,
|
||||
),
|
||||
10 * MINUTE_IN_SECONDS
|
||||
);
|
||||
self::redirect();
|
||||
}
|
||||
|
||||
self::$sync_errors = array();
|
||||
$structure = self::sync_terms( VC_Structure::get(), $structure );
|
||||
update_option( VC_Structure::OPTION, $structure );
|
||||
VC_Structure::flush_cache();
|
||||
update_option( 'vc_flush_rewrite', 1 );
|
||||
VC_Logger::info( sprintf( 'Structure saved: %d regions, %d cities.', count( $structure ), count( VC_Structure::cities( $structure ) ) ) );
|
||||
if ( self::$sync_errors ) {
|
||||
set_transient( self::warnings_key(), self::$sync_errors, 10 * MINUTE_IN_SECONDS );
|
||||
self::redirect( __( 'Structure saved, but some pages could not be set up (see below).', 'viator-collector' ) );
|
||||
}
|
||||
self::redirect( __( 'Structure saved.', 'viator-collector' ) );
|
||||
}
|
||||
|
||||
private static function warnings_key() {
|
||||
return 'vc_structure_warnings_' . get_current_user_id();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a term-sync failure: logged, and shown to the admin after save.
|
||||
*/
|
||||
private static function sync_error( $message ) {
|
||||
VC_Logger::error( 'Structure: ' . $message );
|
||||
self::$sync_errors[] = $message;
|
||||
}
|
||||
|
||||
private static function redirect( $notice = '' ) {
|
||||
$args = array( 'page' => self::PAGE );
|
||||
if ( '' !== $notice ) {
|
||||
$args['vc_notice'] = rawurlencode( $notice );
|
||||
}
|
||||
wp_safe_redirect( add_query_arg( $args, admin_url( 'options-general.php' ) ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slugs (from this structure) already owned by something outside it:
|
||||
* slug => human label.
|
||||
*/
|
||||
public static function taken_slugs( array $structure ) {
|
||||
global $wpdb;
|
||||
|
||||
$slugs = array();
|
||||
foreach ( $structure as $region ) {
|
||||
$slugs[] = $region['slug'];
|
||||
}
|
||||
$cities = VC_Structure::cities( $structure );
|
||||
foreach ( $cities as $city ) {
|
||||
$slugs[] = $city['slug'];
|
||||
}
|
||||
$slugs = array_values( array_filter( array_unique( $slugs ) ) );
|
||||
if ( ! $slugs ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$taken = array();
|
||||
|
||||
$types = array_diff( get_post_types( array( 'public' => true ) ), array( 'attachment', 'viator_tour' ) );
|
||||
if ( $types ) {
|
||||
$slug_in = implode( ',', array_fill( 0, count( $slugs ), '%s' ) );
|
||||
$type_in = implode( ',', array_fill( 0, count( $types ), '%s' ) );
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery
|
||||
$rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_name, post_title, post_type, post_parent FROM {$wpdb->posts} WHERE post_name IN ($slug_in) AND post_type IN ($type_in) AND post_status NOT IN ('trash','auto-draft','inherit')", array_merge( $slugs, array_values( $types ) ) ) );
|
||||
foreach ( (array) $rows as $row ) {
|
||||
if ( 'page' === $row->post_type && (int) $row->post_parent ) {
|
||||
continue; // Child pages live under their parent's path.
|
||||
}
|
||||
$label = 'page' === $row->post_type ? 'page "%s"' : 'post "%s"';
|
||||
$taken[ $row->post_name ] = sprintf( $label, $row->post_title );
|
||||
}
|
||||
}
|
||||
|
||||
// Destination terms that the structure does not own.
|
||||
$region_ids = array_filter( array_map( 'intval', array_column( $structure, 'term_id' ) ) );
|
||||
// A new region (no term yet) adopts an existing term with its slug.
|
||||
$adoptable = array();
|
||||
foreach ( $structure as $region ) {
|
||||
if ( ! (int) $region['term_id'] ) {
|
||||
$adoptable[] = $region['slug'];
|
||||
}
|
||||
}
|
||||
$terms = get_terms(
|
||||
array(
|
||||
'taxonomy' => 'viator_destination',
|
||||
'slug' => $slugs,
|
||||
'hide_empty' => false,
|
||||
)
|
||||
);
|
||||
foreach ( is_wp_error( $terms ) ? array() : $terms as $term ) {
|
||||
if ( in_array( (int) $term->term_id, $region_ids, true ) || in_array( $term->slug, $adoptable, true ) ) {
|
||||
continue;
|
||||
}
|
||||
$viator_id = (string) get_term_meta( $term->term_id, '_vc_viator_destination_id', true );
|
||||
if ( '' !== $viator_id && isset( $cities[ $viator_id ] ) ) {
|
||||
continue;
|
||||
}
|
||||
$taken[ $term->slug ] = sprintf( 'destination "%s"', $term->name );
|
||||
}
|
||||
|
||||
return $taken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the structure into viator_destination terms: regions are
|
||||
* top-level terms flagged _vc_region, cities are re-parented under them.
|
||||
* Products are never touched.
|
||||
*
|
||||
* @return array $new with every region's term_id filled in.
|
||||
*/
|
||||
public static function sync_terms( array $old, array $new ) {
|
||||
$tax = 'viator_destination';
|
||||
|
||||
foreach ( $new as &$region ) {
|
||||
$term_id = (int) $region['term_id'];
|
||||
if ( $term_id && ! get_term( $term_id, $tax ) ) {
|
||||
$term_id = 0;
|
||||
}
|
||||
if ( ! $term_id ) {
|
||||
$existing = get_term_by( 'slug', $region['slug'], $tax );
|
||||
$term_id = $existing ? (int) $existing->term_id : 0;
|
||||
}
|
||||
if ( $term_id ) {
|
||||
$result = wp_update_term(
|
||||
$term_id,
|
||||
$tax,
|
||||
array(
|
||||
'name' => $region['name'],
|
||||
'slug' => $region['slug'],
|
||||
'parent' => 0,
|
||||
)
|
||||
);
|
||||
} else {
|
||||
$result = wp_insert_term( $region['name'], $tax, array( 'slug' => $region['slug'] ) );
|
||||
}
|
||||
if ( is_wp_error( $result ) ) {
|
||||
self::sync_error( 'region "' . $region['name'] . '": ' . $result->get_error_message() );
|
||||
continue;
|
||||
}
|
||||
$region['term_id'] = (int) $result['term_id'];
|
||||
update_term_meta( $region['term_id'], '_vc_region', 1 );
|
||||
|
||||
foreach ( $region['cities'] as $city ) {
|
||||
$city_term = VC_Taxonomies::get_or_create_destination_by_id( $city['id'], $city['name'] );
|
||||
if ( ! $city_term ) {
|
||||
self::sync_error( 'could not create the page for city ' . $city['id'] );
|
||||
continue;
|
||||
}
|
||||
$result = wp_update_term(
|
||||
$city_term,
|
||||
$tax,
|
||||
array(
|
||||
'slug' => $city['slug'],
|
||||
'parent' => $region['term_id'],
|
||||
)
|
||||
);
|
||||
if ( is_wp_error( $result ) ) {
|
||||
self::sync_error( 'city "' . $city['name'] . '": ' . $result->get_error_message() );
|
||||
}
|
||||
}
|
||||
}
|
||||
unset( $region );
|
||||
|
||||
// Regions that were removed: delete our own terms; an adopted Viator
|
||||
// destination term just loses its region flag (it may carry products).
|
||||
$kept = array_map( 'intval', array_column( $new, 'term_id' ) );
|
||||
foreach ( $old as $region ) {
|
||||
$term_id = (int) $region['term_id'];
|
||||
if ( ! $term_id || in_array( $term_id, $kept, true ) ) {
|
||||
continue;
|
||||
}
|
||||
if ( '' !== (string) get_term_meta( $term_id, '_vc_viator_destination_id', true ) ) {
|
||||
delete_term_meta( $term_id, '_vc_region' );
|
||||
} else {
|
||||
wp_delete_term( $term_id, $tax );
|
||||
}
|
||||
}
|
||||
|
||||
// Cities that were removed go back to the top level.
|
||||
$new_cities = VC_Structure::cities( $new );
|
||||
foreach ( array_keys( VC_Structure::cities( $old ) ) as $id ) {
|
||||
if ( isset( $new_cities[ (string) $id ] ) ) {
|
||||
continue;
|
||||
}
|
||||
$found = get_terms(
|
||||
array(
|
||||
'taxonomy' => $tax,
|
||||
'hide_empty' => false,
|
||||
'number' => 1,
|
||||
'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery
|
||||
array(
|
||||
'key' => '_vc_viator_destination_id',
|
||||
'value' => (string) $id,
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
if ( ! is_wp_error( $found ) && $found ) {
|
||||
wp_update_term( $found[0]->term_id, $tax, array( 'parent' => 0 ) );
|
||||
}
|
||||
}
|
||||
|
||||
return $new;
|
||||
}
|
||||
|
||||
/**
|
||||
* WordPress does not stop a Page from taking a region/city slug, so warn.
|
||||
*/
|
||||
public static function page_clash_notice() {
|
||||
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
|
||||
if ( ! $screen || 'post' !== $screen->base || 'page' !== $screen->post_type ) {
|
||||
return;
|
||||
}
|
||||
$post = get_post();
|
||||
if ( ! $post || '' === $post->post_name || $post->post_parent ) {
|
||||
return;
|
||||
}
|
||||
$structure = VC_Structure::get();
|
||||
$owner = '';
|
||||
foreach ( $structure as $region ) {
|
||||
if ( $region['slug'] === $post->post_name ) {
|
||||
$owner = $region['name'];
|
||||
}
|
||||
}
|
||||
foreach ( VC_Structure::cities( $structure ) as $city ) {
|
||||
if ( $city['slug'] === $post->post_name ) {
|
||||
$owner = $city['name'];
|
||||
}
|
||||
}
|
||||
if ( '' === $owner ) {
|
||||
return;
|
||||
}
|
||||
echo '<div class="notice notice-warning"><p>' . esc_html( sprintf( __( 'This page\'s URL is also used by the Viator region/city "%s"; the Viator page will take priority.', 'viator-collector' ), $owner ) ) . '</p></div>';
|
||||
}
|
||||
}
|
||||
221
viator-collector/includes/class-vc-structure.php
Normal file
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* The client-managed region → city structure behind the /{region}/,
|
||||
* /{city}/ and /{city}/{product}/ URLs.
|
||||
*
|
||||
* Everything here except get() is plain PHP so it can be unit-tested
|
||||
* without WordPress (see tests/run.php).
|
||||
*
|
||||
* Shape: list of regions, each
|
||||
* array( 'name', 'slug', 'term_id', 'cities' => list of array( 'id', 'name', 'slug' ) )
|
||||
* where a city's 'id' is its Viator destination id.
|
||||
*/
|
||||
class VC_Structure {
|
||||
const OPTION = 'vc_structure';
|
||||
|
||||
private static $cache = null;
|
||||
|
||||
public static function default_regions() {
|
||||
$regions = array(
|
||||
'uk' => 'UK',
|
||||
'europe' => 'Europe',
|
||||
'asia' => 'Asia',
|
||||
'middle-east-and-africa' => 'Middle East and Africa',
|
||||
'americas' => 'Americas',
|
||||
'australasia' => 'Australasia',
|
||||
);
|
||||
$out = array();
|
||||
foreach ( $regions as $slug => $name ) {
|
||||
$out[] = array(
|
||||
'name' => $name,
|
||||
'slug' => $slug,
|
||||
'term_id' => 0,
|
||||
'cities' => array(),
|
||||
);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function get() {
|
||||
if ( null === self::$cache ) {
|
||||
$value = get_option( self::OPTION, self::default_regions() );
|
||||
self::$cache = is_array( $value ) ? $value : self::default_regions();
|
||||
}
|
||||
return self::$cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the per-request cache (after the option is saved).
|
||||
*/
|
||||
public static function flush_cache() {
|
||||
self::$cache = null;
|
||||
}
|
||||
|
||||
public static function slugify( $s ) {
|
||||
$s = strtolower( (string) $s );
|
||||
$s = preg_replace( '/[^a-z0-9]+/', '-', $s );
|
||||
return trim( $s, '-' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape submitted form data into the structure. Admin-typed slugs are only
|
||||
* trimmed/lowercased here — validate() rejects anything still invalid
|
||||
* rather than silently rewriting it.
|
||||
*/
|
||||
public static function normalize( array $raw ) {
|
||||
$out = array();
|
||||
foreach ( $raw as $region ) {
|
||||
if ( ! is_array( $region ) ) {
|
||||
continue;
|
||||
}
|
||||
$name = trim( (string) ( $region['name'] ?? '' ) );
|
||||
$slug = strtolower( trim( (string) ( $region['slug'] ?? '' ) ) );
|
||||
$cities = array();
|
||||
foreach ( (array) ( $region['cities'] ?? array() ) as $city ) {
|
||||
$id = trim( (string) ( is_array( $city ) ? ( $city['id'] ?? '' ) : '' ) );
|
||||
if ( '' === $id ) {
|
||||
continue;
|
||||
}
|
||||
$city_name = trim( (string) ( $city['name'] ?? '' ) );
|
||||
$city_slug = strtolower( trim( (string) ( $city['slug'] ?? '' ) ) );
|
||||
$cities[] = array(
|
||||
'id' => $id,
|
||||
'name' => $city_name,
|
||||
'slug' => '' !== $city_slug ? $city_slug : self::slugify( $city_name ),
|
||||
);
|
||||
}
|
||||
$out[] = array(
|
||||
'name' => $name,
|
||||
'slug' => '' !== $slug ? $slug : self::slugify( $name ),
|
||||
'term_id' => (int) ( $region['term_id'] ?? 0 ),
|
||||
'cities' => $cities,
|
||||
);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function reserved_slugs() {
|
||||
return array( 'wp-admin', 'wp-content', 'wp-includes', 'wp-json', 'feed', 'page', 'search', 'tag', 'category', 'author', 'tours', 'tour-destination', 'tour-tag', 'tour-category', 'blog', 'about', 'contact', 'faq', 'embed', 'attachment' );
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $destination_map Viator destination id => name.
|
||||
* @param array $taken slug => label, for slugs owned outside the structure.
|
||||
* @return string[] Errors; empty when valid.
|
||||
*/
|
||||
public static function validate( array $structure, array $destination_map, array $taken ) {
|
||||
$errors = array();
|
||||
$slugs = array();
|
||||
$city_ids = array();
|
||||
|
||||
foreach ( $structure as $i => $region ) {
|
||||
if ( '' === trim( (string) $region['name'] ) ) {
|
||||
$errors[] = sprintf( 'Region %d has no name.', $i + 1 );
|
||||
}
|
||||
$slugs[] = (string) $region['slug'];
|
||||
|
||||
foreach ( $region['cities'] as $city ) {
|
||||
$id = (string) $city['id'];
|
||||
if ( ! isset( $destination_map[ $id ] ) ) {
|
||||
$errors[] = sprintf( 'City ID %s is not a Viator destination.', $id );
|
||||
}
|
||||
if ( isset( $city_ids[ $id ] ) ) {
|
||||
$errors[] = sprintf( '%s is listed more than once.', '' !== $city['name'] ? $city['name'] : $id );
|
||||
}
|
||||
$city_ids[ $id ] = true;
|
||||
$slugs[] = (string) $city['slug'];
|
||||
}
|
||||
}
|
||||
|
||||
$reserved = self::reserved_slugs();
|
||||
foreach ( array_count_values( $slugs ) as $slug => $count ) {
|
||||
$slug = (string) $slug;
|
||||
if ( ! preg_match( '/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug ) ) {
|
||||
$errors[] = sprintf( 'Slug "%s" is invalid: use only a-z, 0-9 and -.', $slug );
|
||||
continue;
|
||||
}
|
||||
if ( in_array( $slug, $reserved, true ) ) {
|
||||
$errors[] = sprintf( 'Slug "%s" is reserved.', $slug );
|
||||
}
|
||||
if ( $count > 1 ) {
|
||||
$errors[] = sprintf( 'Slug "%s" is used more than once.', $slug );
|
||||
}
|
||||
if ( isset( $taken[ $slug ] ) ) {
|
||||
$errors[] = sprintf( 'Slug "%s" is already used by %s.', $slug, $taken[ $slug ] );
|
||||
}
|
||||
}
|
||||
|
||||
return array_values( array_unique( $errors ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Configured cities in settings order: viator id => city + 'region' slug.
|
||||
*/
|
||||
public static function cities( array $structure ) {
|
||||
$out = array();
|
||||
foreach ( $structure as $region ) {
|
||||
foreach ( $region['cities'] as $city ) {
|
||||
$out[ (string) $city['id'] ] = $city + array( 'region' => $region['slug'] );
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The city a product's URL lives under: its Viator primary destination
|
||||
* when configured, else the first configured city it belongs to, else ''.
|
||||
*/
|
||||
public static function canonical_city( array $structure, array $dest_ids, $primary_id ) {
|
||||
$cities = self::cities( $structure );
|
||||
if ( '' !== (string) $primary_id && isset( $cities[ (string) $primary_id ] ) ) {
|
||||
return $cities[ (string) $primary_id ]['slug'];
|
||||
}
|
||||
$dest_ids = array_map( 'strval', $dest_ids );
|
||||
foreach ( $cities as $id => $city ) {
|
||||
if ( in_array( (string) $id, $dest_ids, true ) ) {
|
||||
return $city['slug'];
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
public static function primary_destination_id( array $destinations ) {
|
||||
foreach ( $destinations as $destination ) {
|
||||
if ( is_array( $destination ) && ! empty( $destination['primary'] ) ) {
|
||||
$ref = $destination['ref'] ?? ( $destination['destinationId'] ?? '' );
|
||||
return (string) $ref;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit per-slug rules (no catch-alls), regions first, then cities.
|
||||
* Only regions synced to a term (term_id set) are routed, so the unsaved
|
||||
* presets never claim /europe/ etc. over existing pages. Archives get a
|
||||
* /page/N/ rule because core's redirect_canonical turns ?paged=N into it.
|
||||
*/
|
||||
public static function rewrite_rules( array $structure ) {
|
||||
$synced = array_values(
|
||||
array_filter(
|
||||
$structure,
|
||||
function ( $region ) {
|
||||
return (int) $region['term_id'] > 0;
|
||||
}
|
||||
)
|
||||
);
|
||||
$rules = array();
|
||||
foreach ( $synced as $region ) {
|
||||
$rules[ '^' . $region['slug'] . '/page/([0-9]+)/?$' ] = 'index.php?viator_destination=' . $region['slug'] . '&paged=$matches[1]';
|
||||
$rules[ '^' . $region['slug'] . '/?$' ] = 'index.php?viator_destination=' . $region['slug'];
|
||||
}
|
||||
foreach ( self::cities( $synced ) as $city ) {
|
||||
$rules[ '^' . $city['slug'] . '/page/([0-9]+)/?$' ] = 'index.php?viator_destination=' . $city['slug'] . '&paged=$matches[1]';
|
||||
$rules[ '^' . $city['slug'] . '/?$' ] = 'index.php?viator_destination=' . $city['slug'];
|
||||
$rules[ '^' . $city['slug'] . '/([^/]+)/?$' ] = 'index.php?viator_tour=$matches[1]';
|
||||
}
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
41
viator-collector/includes/class-vc-tags.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Viator attaches some internal, trade-only tags to products (e.g. "Agent Low
|
||||
* Quality Product"). They are imported like any tag but must never be shown
|
||||
* to visitors — not as a tour's category, not as a filter option.
|
||||
*/
|
||||
class VC_Tags {
|
||||
/**
|
||||
* Patterns (case-insensitive) that mark a tag as internal. Extend with the
|
||||
* `vc_internal_tag_patterns` filter when WordPress is loaded.
|
||||
*/
|
||||
public static function patterns() {
|
||||
$patterns = array( '/\bagent\b/i', '/\blow quality\b/i', '/\bquality product\b/i' );
|
||||
return function_exists( 'apply_filters' ) ? (array) apply_filters( 'vc_internal_tag_patterns', $patterns ) : $patterns;
|
||||
}
|
||||
|
||||
public static function is_internal( $name ) {
|
||||
foreach ( self::patterns() as $pattern ) {
|
||||
if ( preg_match( $pattern, (string) $name ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop internal tags from a list of term objects (keeps order).
|
||||
*/
|
||||
public static function public_terms( $terms ) {
|
||||
return array_values(
|
||||
array_filter(
|
||||
(array) $terms,
|
||||
function ( $term ) {
|
||||
return is_object( $term ) && ! self::is_internal( $term->name );
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,7 @@ class VC_Taxonomies {
|
||||
} else {
|
||||
update_term_meta( $term_id, '_vc_image_url', $url );
|
||||
}
|
||||
delete_transient( 'vc_term_img_' . absint( $term_id ) );
|
||||
delete_transient( 'vc_term_img2_' . absint( $term_id ) );
|
||||
}
|
||||
|
||||
public static function register() {
|
||||
|
||||
@@ -138,6 +138,42 @@ function vc_hero_image_url() {
|
||||
return apply_filters( 'vc_hero_image_url', $url );
|
||||
}
|
||||
|
||||
/**
|
||||
* Hero image *source* for the current view: an attachment ID when available
|
||||
* (so it can be rendered responsively), otherwise a URL. Resolution order:
|
||||
* 1. On a page (e.g. the homepage hosting [viator_hero]), its Featured Image.
|
||||
* 2. On a term archive, that term's own image.
|
||||
* 3. The "Hero image" chosen in Settings (attachment ID preferred).
|
||||
* 4. Nothing.
|
||||
*
|
||||
* @return int|string Attachment ID, image URL, or '' .
|
||||
*/
|
||||
function vc_hero_image_source() {
|
||||
if ( is_singular() && ! is_singular( 'viator_tour' ) ) {
|
||||
$thumb = (int) get_post_thumbnail_id( get_queried_object_id() );
|
||||
if ( $thumb ) {
|
||||
return apply_filters( 'vc_hero_image_source', $thumb );
|
||||
}
|
||||
}
|
||||
|
||||
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 ) {
|
||||
return apply_filters( 'vc_hero_image_source', $url );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$id = (int) vc_get_option( 'hero_image_id', 0 );
|
||||
if ( $id ) {
|
||||
return apply_filters( 'vc_hero_image_source', $id );
|
||||
}
|
||||
|
||||
return apply_filters( 'vc_hero_image_source', (string) vc_get_option( 'hero_image', '' ) );
|
||||
}
|
||||
|
||||
function vc_term_image_url( $term_id, $taxonomy = '' ) {
|
||||
$term_id = absint( $term_id );
|
||||
if ( ! $term_id ) {
|
||||
@@ -149,7 +185,9 @@ function vc_term_image_url( $term_id, $taxonomy = '' ) {
|
||||
return $custom;
|
||||
}
|
||||
|
||||
$cache_key = 'vc_term_img_' . $term_id;
|
||||
// "img2": the v1 key cached empty results for 12h, which left cards blank
|
||||
// long after their tours had photos. Empty results now expire in an hour.
|
||||
$cache_key = 'vc_term_img2_' . $term_id;
|
||||
$cached = get_transient( $cache_key );
|
||||
if ( false !== $cached ) {
|
||||
return $cached;
|
||||
@@ -164,10 +202,14 @@ function vc_term_image_url( $term_id, $taxonomy = '' ) {
|
||||
|
||||
$url = '';
|
||||
if ( $taxonomy ) {
|
||||
// A few recent tours in the term; the first with any photo wins. The
|
||||
// photo is read in PHP (primary, else first gallery image) — the same
|
||||
// fallback the tour cards use — rather than filtered in SQL.
|
||||
$query = new WP_Query(
|
||||
array(
|
||||
'post_type' => 'viator_tour',
|
||||
'posts_per_page' => 1,
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => 10,
|
||||
'fields' => 'ids',
|
||||
'no_found_rows' => true,
|
||||
'update_post_term_cache' => false,
|
||||
@@ -178,21 +220,21 @@ function vc_term_image_url( $term_id, $taxonomy = '' ) {
|
||||
'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 );
|
||||
foreach ( $query->posts as $post_id ) {
|
||||
$url = (string) get_post_meta( (int) $post_id, '_vc_primary_image_url', true );
|
||||
if ( '' === $url ) {
|
||||
$gallery = vc_json_decode_meta( get_post_meta( (int) $post_id, '_vc_gallery_image_urls', true ), array() );
|
||||
$url = $gallery ? (string) reset( $gallery ) : '';
|
||||
}
|
||||
if ( '' !== $url ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set_transient( $cache_key, $url, 12 * HOUR_IN_SECONDS );
|
||||
set_transient( $cache_key, $url, '' !== $url ? 12 * HOUR_IN_SECONDS : HOUR_IN_SECONDS );
|
||||
return $url;
|
||||
}
|
||||
|
||||
@@ -506,6 +548,45 @@ function vc_asset_url( $rel ) {
|
||||
return VC_PLUGIN_URL . 'assets/' . ltrim( (string) $rel, '/' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Hero <img> markup. When the source is a WordPress media item (an attachment
|
||||
* ID, or a URL that resolves to one), it is rendered with wp_get_attachment_image
|
||||
* so the browser gets responsive `srcset`/`sizes` and device-appropriate files.
|
||||
* Anything else (external URLs, bundled fallbacks) falls back to a plain <img>.
|
||||
*
|
||||
* @param int|string $src Attachment ID or image URL.
|
||||
* @param string $class CSS class.
|
||||
* @param bool $eager Prioritise loading (above-the-fold hero).
|
||||
*/
|
||||
function vc_hero_img_html( $src, $class = 'hero__img', $eager = true ) {
|
||||
$loading = $eager ? 'eager' : 'lazy';
|
||||
|
||||
$id = is_numeric( $src ) ? (int) $src : (int) attachment_url_to_postid( (string) $src );
|
||||
if ( $id ) {
|
||||
$img = wp_get_attachment_image(
|
||||
$id,
|
||||
'full',
|
||||
false,
|
||||
array(
|
||||
'class' => $class,
|
||||
'loading' => $loading,
|
||||
'alt' => '',
|
||||
'sizes' => '100vw',
|
||||
'fetchpriority' => $eager ? 'high' : 'auto',
|
||||
)
|
||||
);
|
||||
if ( $img ) {
|
||||
return $img;
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $src ) {
|
||||
return '';
|
||||
}
|
||||
$fetch = $eager ? ' fetchpriority="high"' : '';
|
||||
return '<img class="' . esc_attr( $class ) . '" src="' . esc_url( $src ) . '" alt="" loading="' . esc_attr( $loading ) . '"' . $fetch . '>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -528,11 +609,72 @@ function vc_print_icon_sprite() {
|
||||
* 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-' ) );
|
||||
// Strip an existing "i-" prefix (once), then re-add it. ltrim() with a
|
||||
// charlist would eat any leading i/-, turning "instagram" into "nstagram".
|
||||
$slug = preg_replace( '/^i-/', '', (string) $name );
|
||||
$name = preg_replace( '/[^a-z0-9\-]/', '', 'i-' . $slug );
|
||||
$class = 'icon' . ( $extra_class ? ' ' . $extra_class : '' );
|
||||
return '<svg class="' . esc_attr( $class ) . '" aria-hidden="true"><use href="#' . esc_attr( $name ) . '"></use></svg>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadcrumb trail for the current Viator view: list of [label, url] links
|
||||
* (Home first); the current page itself is not included.
|
||||
* tours archive Home
|
||||
* region / city Home › Region (for a city)
|
||||
* tag / category Home › Tours
|
||||
* single tour Home › Region › City (its canonical city)
|
||||
* other pages Home
|
||||
*/
|
||||
function vc_breadcrumb_trail() {
|
||||
$trail = array( array( __( 'Home', 'viator-collector' ), home_url( '/' ) ) );
|
||||
|
||||
$city = null;
|
||||
if ( is_tax( 'viator_destination' ) ) {
|
||||
$term = get_queried_object();
|
||||
if ( $term instanceof WP_Term && $term->parent ) {
|
||||
$city = $term; // its parent (region) is added below
|
||||
}
|
||||
} elseif ( is_tax( array( 'viator_tag', 'viator_category' ) ) ) {
|
||||
$trail[] = array( __( 'Tours', 'viator-collector' ), get_post_type_archive_link( 'viator_tour' ) );
|
||||
} elseif ( is_singular( 'viator_tour' ) ) {
|
||||
$post_id = get_queried_object_id();
|
||||
$city_slug = class_exists( 'VC_Routing' ) ? VC_Routing::product_city( $post_id ) : '';
|
||||
$city = $city_slug ? get_term_by( 'slug', $city_slug, 'viator_destination' ) : null;
|
||||
if ( ! $city ) {
|
||||
$terms = get_the_terms( $post_id, 'viator_destination' );
|
||||
$city = ( $terms && ! is_wp_error( $terms ) ) ? $terms[0] : null;
|
||||
}
|
||||
}
|
||||
|
||||
if ( $city instanceof WP_Term ) {
|
||||
if ( $city->parent ) {
|
||||
$region = get_term( $city->parent, 'viator_destination' );
|
||||
if ( $region instanceof WP_Term && ! is_wp_error( get_term_link( $region ) ) ) {
|
||||
$trail[] = array( $region->name, get_term_link( $region ) );
|
||||
}
|
||||
}
|
||||
// On a city archive the city is the current page, not a link.
|
||||
if ( is_singular( 'viator_tour' ) && ! is_wp_error( get_term_link( $city ) ) ) {
|
||||
$trail[] = array( $city->name, get_term_link( $city ) );
|
||||
}
|
||||
}
|
||||
|
||||
return apply_filters( 'vc_breadcrumb_trail', $trail );
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadcrumb markup shared by every page (the theme's design pages print the
|
||||
* same structure): links, then the current page in bold.
|
||||
*/
|
||||
function vc_breadcrumb_html( array $trail, $current, $extra_class = '' ) {
|
||||
$out = '<nav class="crumbs' . ( $extra_class ? ' ' . esc_attr( $extra_class ) : '' ) . '" aria-label="' . esc_attr__( 'Breadcrumb', 'viator-collector' ) . '"><ol class="crumbs__list">';
|
||||
foreach ( $trail as $item ) {
|
||||
$out .= '<li><a href="' . esc_url( $item[1] ) . '">' . esc_html( $item[0] ) . '</a></li>';
|
||||
}
|
||||
return $out . '<li aria-current="page">' . esc_html( $current ) . '</li></ol></nav>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotating ring badge (the circular "BOOK NOW ·" anchor in the design).
|
||||
*
|
||||
|
||||
@@ -6,10 +6,18 @@
|
||||
* 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.
|
||||
* Args: $vc_title, $vc_subtitle, $vc_query, $vc_form_action, $vc_after_hero
|
||||
* (HTML printed between the hero and the results, e.g. the region city grid),
|
||||
* $vc_tagline (line under the title), $vc_list_heading (heading above the
|
||||
* results) and $vc_jump (true: filtering and paging land on the results).
|
||||
*/
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
$vc_after_hero = isset( $vc_after_hero ) ? (string) $vc_after_hero : '';
|
||||
$vc_tagline = isset( $vc_tagline ) ? (string) $vc_tagline : '';
|
||||
$vc_list_heading = isset( $vc_list_heading ) ? (string) $vc_list_heading : '';
|
||||
$vc_anchor = empty( $vc_jump ) ? '' : '#tour-list';
|
||||
|
||||
$f = VC_Display::get_filter_state();
|
||||
|
||||
$vc_title = isset( $vc_title ) ? $vc_title : __( 'Explore Tours', 'viator-collector' );
|
||||
@@ -33,7 +41,7 @@ if ( is_wp_error( $vc_action ) || ! $vc_action ) {
|
||||
$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_categories = is_wp_error( $vc_categories ) ? array() : VC_Tags::public_terms( $vc_categories );
|
||||
|
||||
$vc_total = (int) $vc_query->found_posts;
|
||||
$vc_per = 12;
|
||||
@@ -50,9 +58,7 @@ $vc_dur_labels = array(
|
||||
);
|
||||
|
||||
// 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'] );
|
||||
$vc_active = VC_Display::filters_active( $f );
|
||||
|
||||
// Current filters as a base URL, so pagination links keep the query string.
|
||||
$vc_base_args = array();
|
||||
@@ -67,30 +73,35 @@ 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 ) {
|
||||
$vc_page_url = function ( $n ) use ( $vc_action, $vc_base_args, $vc_anchor ) {
|
||||
$args = $vc_base_args;
|
||||
if ( $n > 1 ) {
|
||||
$args['paged'] = $n;
|
||||
}
|
||||
return empty( $args ) ? $vc_action : add_query_arg( $args, $vc_action );
|
||||
return ( empty( $args ) ? $vc_action : add_query_arg( $args, $vc_action ) ) . $vc_anchor;
|
||||
};
|
||||
|
||||
$vc_hero_img = vc_hero_image_url();
|
||||
if ( ! $vc_hero_img ) {
|
||||
$vc_hero_img = vc_asset_url( 'img/hero2.png' );
|
||||
}
|
||||
$vc_hero_img = vc_hero_image_source();
|
||||
?>
|
||||
<section class="hero hero--archive">
|
||||
<div class="hero__media">
|
||||
<img class="hero__img" src="<?php echo esc_url( $vc_hero_img ); ?>" alt="" fetchpriority="high">
|
||||
<?php echo vc_hero_img_html( $vc_hero_img, 'hero__img', true ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
<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>
|
||||
<?php
|
||||
// Short current-page label: "Tours" on the archive, the page's own
|
||||
// title on a page hosting [viator_listing], else the heading.
|
||||
$vc_crumb_current = ! empty( $vc_crumb_label ) ? $vc_crumb_label : ( is_page() ? get_the_title() : $vc_title );
|
||||
echo vc_breadcrumb_html( vc_breadcrumb_trail(), $vc_crumb_current ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
?>
|
||||
<h1 class="hero__title"><?php echo esc_html( $vc_title ); ?></h1>
|
||||
<?php if ( '' !== $vc_tagline ) : ?>
|
||||
<p class="hero__tagline"><?php echo esc_html( $vc_tagline ); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<form class="filters" id="vc-filter-form" method="get" action="<?php echo esc_url( $vc_action ); ?>" novalidate>
|
||||
<form class="filters" id="vc-filter-form" method="get" action="<?php echo esc_url( $vc_action . $vc_anchor ); ?>" novalidate>
|
||||
<div class="filters__row">
|
||||
<div class="fsearch">
|
||||
<?php echo vc_icon( 'search', 'fsearch__icon' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
@@ -182,8 +193,14 @@ if ( ! $vc_hero_img ) {
|
||||
<?php echo vc_ring_badge( 'SCROLL DOWN · SCROLL DOWN · ', '#tour-list', 'scroll' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
</section>
|
||||
|
||||
<?php echo $vc_after_hero; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built by the calling template. ?>
|
||||
|
||||
<section class="section" id="tour-list">
|
||||
<div class="container">
|
||||
<?php if ( '' !== $vc_list_heading ) : ?>
|
||||
<p class="pill cty-eyebrow"><?php echo esc_html__( 'Tours', 'viator-collector' ); ?></p>
|
||||
<h2 class="cty-h2 cty-tours-h"><?php echo esc_html( $vc_list_heading ); ?></h2>
|
||||
<?php endif; ?>
|
||||
<div class="results">
|
||||
<p class="results__count" role="status">
|
||||
<?php if ( $vc_total ) : ?>
|
||||
|
||||
99
viator-collector/templates/region.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
/**
|
||||
* Region landing page (/{region}/): hero, a grid of the region's cities in
|
||||
* Viator Structure order, then every tour in those cities — the main query
|
||||
* on a parent term already includes its child terms.
|
||||
*/
|
||||
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();
|
||||
|
||||
// Child city terms, ordered as on the Viator Structure screen.
|
||||
$vc_cities = array();
|
||||
$vc_order = array_keys( VC_Structure::cities( VC_Structure::get() ) );
|
||||
$vc_terms = get_terms(
|
||||
array(
|
||||
'taxonomy' => 'viator_destination',
|
||||
'parent' => $vc_term ? (int) $vc_term->term_id : 0,
|
||||
'hide_empty' => false,
|
||||
)
|
||||
);
|
||||
foreach ( is_wp_error( $vc_terms ) ? array() : $vc_terms as $vc_city ) {
|
||||
$vc_pos = array_search( (string) get_term_meta( $vc_city->term_id, '_vc_viator_destination_id', true ), array_map( 'strval', $vc_order ), true );
|
||||
$vc_cities[ $vc_pos === false ? PHP_INT_MAX - count( $vc_cities ) : $vc_pos ] = $vc_city;
|
||||
}
|
||||
ksort( $vc_cities );
|
||||
|
||||
$vc_after_hero = '';
|
||||
if ( $vc_cities ) {
|
||||
ob_start();
|
||||
?>
|
||||
<?php // Same destination rail as the homepage's Top Destinations (VC_Sections). ?>
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="sec-head">
|
||||
<?php /* translators: %s: region name. */ ?>
|
||||
<h2 class="sec-head__title"><?php echo esc_html( sprintf( __( 'Cities in %s', 'viator-collector' ), $vc_name ) ); ?></h2>
|
||||
<div class="sec-head__actions">
|
||||
<div class="rail-nav">
|
||||
<button class="btn btn--ghost rail-nav__btn" type="button" data-rail-prev="vcRailRegion" 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="vcRailRegion" 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="vcRailRegion">
|
||||
<?php
|
||||
foreach ( $vc_cities as $vc_city ) :
|
||||
$vc_link = get_term_link( $vc_city );
|
||||
$vc_image = vc_term_image_url( $vc_city->term_id, 'viator_destination' );
|
||||
if ( is_wp_error( $vc_link ) ) {
|
||||
continue;
|
||||
}
|
||||
?>
|
||||
<li class="dcard">
|
||||
<?php if ( $vc_image ) : ?>
|
||||
<img class="dcard__img" src="<?php echo esc_url( $vc_image ); ?>" alt="<?php echo esc_attr( $vc_city->name ); ?>" loading="lazy">
|
||||
<?php endif; ?>
|
||||
<div class="dcard__body">
|
||||
<h3 class="dcard__title"><a href="<?php echo esc_url( $vc_link ); ?>"><?php echo esc_html( $vc_city->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) $vc_city->count ) ); ?></span>
|
||||
</div>
|
||||
<?php echo vc_ring_badge( 'VIEW TOURS · VIEW TOURS · ', $vc_link, 'card' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
$vc_after_hero = ob_get_clean();
|
||||
}
|
||||
?>
|
||||
<main class="vc-app vc-listing vc-listing--term vc-listing--region">
|
||||
<?php
|
||||
echo vc_get_template( // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
'listing.php',
|
||||
array(
|
||||
'vc_title' => $vc_name,
|
||||
/* translators: %s: region name. */
|
||||
'vc_subtitle' => $vc_desc ? wp_strip_all_tags( $vc_desc ) : sprintf( __( 'Explore the best tours in %s.', 'viator-collector' ), $vc_name ),
|
||||
'vc_after_hero' => $vc_after_hero,
|
||||
'vc_crumb' => array(
|
||||
array(
|
||||
'label' => __( 'Tours', 'viator-collector' ),
|
||||
'url' => get_post_type_archive_link( 'viator_tour' ),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
?>
|
||||
</main>
|
||||
<?php
|
||||
get_footer();
|
||||
@@ -25,7 +25,7 @@ while ( have_posts() ) :
|
||||
$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();
|
||||
$cat_names = ( $cat_terms && ! is_wp_error( $cat_terms ) ) ? array_slice( wp_list_pluck( VC_Tags::public_terms( $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 ) );
|
||||
@@ -33,34 +33,38 @@ while ( have_posts() ) :
|
||||
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();
|
||||
// The featured image (sharp, full size) leads the gallery when set;
|
||||
// Viator's own photos follow.
|
||||
$lead = has_post_thumbnail( $post_id ) ? (string) get_the_post_thumbnail_url( $post_id, 'large' ) : $primary;
|
||||
$all = array_values( array_unique( array_filter( array_merge( array( $lead, $primary ), $gallery ) ) ) );
|
||||
|
||||
$book_href = $booking_url ? $booking_url : '#';
|
||||
$book_attrs = $booking_url ? ' target="_blank" rel="nofollow noopener"' : '';
|
||||
?>
|
||||
<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>
|
||||
<section class="section tour tour--top">
|
||||
<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">
|
||||
<?php echo vc_breadcrumb_html( vc_breadcrumb_trail(), get_the_title(), 'tour__crumbs' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section tour">
|
||||
<div class="container tour__layout">
|
||||
<div class="tour__main">
|
||||
<?php if ( $all ) : ?>
|
||||
<?php // .gallery: the lightbox (vc-design.js) opens on any of these images, hidden ones included. ?>
|
||||
<div class="gallery tgal<?php echo count( $all ) < 3 ? ' tgal--few' : ''; ?>">
|
||||
<?php foreach ( $all as $i => $url ) : ?>
|
||||
<img class="<?php echo 0 === $i ? 'tgal__main' : ( $i < 3 ? 'tgal__thumb' : 'tgal__extra' ); ?>" src="<?php echo esc_url( $url ); ?>" alt="<?php echo esc_attr( get_the_title() ); ?>"<?php echo 0 === $i ? ' fetchpriority="high"' : ' loading="lazy"'; ?>>
|
||||
<?php endforeach; ?>
|
||||
<?php if ( count( $all ) > 3 ) : ?>
|
||||
<button class="tgal__all" type="button"><?php echo vc_icon( 'grid' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><?php echo esc_html( sprintf( __( 'All %d photos', 'viator-collector' ), count( $all ) ) ); ?></button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<header class="tour-head">
|
||||
<h2 class="tour-head__title"><?php the_title(); ?></h2>
|
||||
<h1 class="tour-head__title"><?php the_title(); ?></h1>
|
||||
<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>
|
||||
<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><?php if ( $reviews ) : ?> <a class="tmeta__reviews" href="#reviews">(<?php echo esc_html( sprintf( _n( '%s review', '%s reviews', $reviews, 'viator-collector' ), number_format_i18n( $reviews ) ) ); ?>)</a><?php endif; ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if ( ! empty( $dest_names ) ) : ?>
|
||||
<span class="tmeta__rule" aria-hidden="true"></span>
|
||||
@@ -95,18 +99,6 @@ while ( have_posts() ) :
|
||||
</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() ) ) );
|
||||
@@ -226,7 +218,7 @@ while ( have_posts() ) :
|
||||
<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>
|
||||
<a class="btn btn--dark bookcard__cta" href="<?php echo esc_url( $book_href ); ?>"<?php echo $book_attrs; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>><?php echo esc_html__( 'Check Availability', 'viator-collector' ); ?></a>
|
||||
</div>
|
||||
|
||||
<div class="statcard statcard--teal">
|
||||
@@ -248,6 +240,14 @@ while ( have_posts() ) :
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php // Phones/tablets: price + booking button stay on screen (hidden on desktop, where the sidebar is sticky). ?>
|
||||
<div class="tbar">
|
||||
<?php if ( '' !== (string) $price ) : ?>
|
||||
<p class="tbar__price"><span><?php echo esc_html__( 'From', 'viator-collector' ); ?></span><b><?php echo esc_html( vc_money( $price, $currency ) ); ?></b></p>
|
||||
<?php endif; ?>
|
||||
<a class="btn tbar__cta" href="<?php echo esc_url( $book_href ); ?>"<?php echo $book_attrs; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>><?php echo esc_html__( 'Check Availability', 'viator-collector' ); ?></a>
|
||||
</div>
|
||||
</div><!-- #vc-app -->
|
||||
<?php
|
||||
endwhile;
|
||||
|
||||
@@ -9,6 +9,17 @@ 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();
|
||||
|
||||
// City guide (destinations only): tagline in the banner; facts, About and
|
||||
// Seasons above the tours on page 1 of an unfiltered listing.
|
||||
$vc_guide = '';
|
||||
$vc_tagline = '';
|
||||
if ( is_tax( 'viator_destination' ) && $vc_term && ! is_wp_error( $vc_term ) ) {
|
||||
$vc_tagline = VC_City_Guide::tagline( $vc_term );
|
||||
if ( VC_City_Guide::is_visible_here() ) {
|
||||
$vc_guide = VC_City_Guide::render( $vc_term );
|
||||
}
|
||||
}
|
||||
?>
|
||||
<main class="vc-app vc-listing vc-listing--term">
|
||||
<?php
|
||||
@@ -24,6 +35,11 @@ $vc_desc = term_description();
|
||||
'url' => get_post_type_archive_link( 'viator_tour' ),
|
||||
),
|
||||
),
|
||||
'vc_tagline' => $vc_tagline,
|
||||
'vc_after_hero' => $vc_guide,
|
||||
/* translators: %s: city name. */
|
||||
'vc_list_heading' => $vc_guide ? sprintf( __( 'Things to do in %s', 'viator-collector' ), $vc_name ) : '',
|
||||
'vc_jump' => '' !== $vc_guide,
|
||||
)
|
||||
);
|
||||
?>
|
||||
|
||||
46
viator-collector/tests/run.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
/**
|
||||
* Minimal test runner for the WordPress-free parts of the plugin.
|
||||
*
|
||||
* php tests/run.php
|
||||
*/
|
||||
|
||||
define( 'ABSPATH', __DIR__ . '/' );
|
||||
|
||||
require_once dirname( __DIR__ ) . '/includes/class-vc-structure.php';
|
||||
require_once dirname( __DIR__ ) . '/includes/class-vc-tags.php';
|
||||
require_once dirname( __DIR__ ) . '/includes/class-vc-city-guide.php';
|
||||
|
||||
function vc_assert_same( $expected, $actual, $msg = '' ) {
|
||||
if ( $expected !== $actual ) {
|
||||
throw new Exception( $msg . "\n expected: " . var_export( $expected, true ) . "\n actual: " . var_export( $actual, true ) );
|
||||
}
|
||||
}
|
||||
|
||||
function vc_assert_contains( $needle, array $errors ) {
|
||||
if ( ! in_array( $needle, $errors, true ) ) {
|
||||
throw new Exception( 'Missing error: ' . $needle . "\n got: " . var_export( $errors, true ) );
|
||||
}
|
||||
}
|
||||
|
||||
foreach ( glob( __DIR__ . '/test-*.php' ) as $file ) {
|
||||
require_once $file;
|
||||
}
|
||||
|
||||
$passed = 0;
|
||||
$failed = 0;
|
||||
foreach ( get_defined_functions()['user'] as $fn ) {
|
||||
if ( 0 !== strpos( $fn, 'test_' ) ) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$fn();
|
||||
$passed++;
|
||||
} catch ( Throwable $e ) {
|
||||
$failed++;
|
||||
echo 'FAIL ' . $fn . ': ' . $e->getMessage() . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo $passed . ' passed, ' . $failed . " failed\n";
|
||||
exit( $failed ? 1 : 0 );
|
||||
55
viator-collector/tests/test-city-guide.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
/**
|
||||
* VC_City_Guide unit tests: bundled guide data and season months.
|
||||
*/
|
||||
|
||||
function vc_test_bundled_guides() {
|
||||
return include dirname( __DIR__ ) . '/data/city-guides.php';
|
||||
}
|
||||
|
||||
function test_bundled_guides_use_known_fields() {
|
||||
$fields = VC_City_Guide::fields();
|
||||
foreach ( vc_test_bundled_guides() as $slug => $guide ) {
|
||||
vc_assert_same( array(), array_values( array_diff( array_keys( $guide ), array_keys( $fields ) ) ), "unknown fields for $slug" );
|
||||
}
|
||||
}
|
||||
|
||||
function test_bundled_guides_have_core_text() {
|
||||
foreach ( vc_test_bundled_guides() as $slug => $guide ) {
|
||||
foreach ( array( 'tagline', 'language', 'currency', 'timezone', 'best_time', 'about_title', 'about' ) as $key ) {
|
||||
vc_assert_same( true, ! empty( $guide[ $key ] ), "$slug.$key empty" );
|
||||
}
|
||||
vc_assert_same( false, (bool) preg_match( '/elementor|\{|\}/', implode( ' ', $guide ) ), "$slug has page junk" );
|
||||
}
|
||||
}
|
||||
|
||||
function test_bundled_guides_cover_structure_cities() {
|
||||
vc_assert_same(
|
||||
array( 'boston', 'chicago', 'las-vegas', 'los-angeles', 'miami', 'montreal', 'new-orleans', 'new-york-city', 'san-diego', 'san-francisco', 'seattle', 'toronto', 'vancouver', 'victoria', 'washington-dc' ),
|
||||
( function ( $k ) { sort( $k ); return $k; } )( array_keys( vc_test_bundled_guides() ) )
|
||||
);
|
||||
}
|
||||
|
||||
function test_empty_guide_detection() {
|
||||
$blank = array_fill_keys( array_keys( VC_City_Guide::fields() ), '' );
|
||||
vc_assert_same( true, VC_City_Guide::is_empty( $blank ) );
|
||||
vc_assert_same( true, VC_City_Guide::is_empty( array_merge( $blank, array( 'southern' => true, 'about_image' => 'https://x/y.jpg' ) ) ) );
|
||||
vc_assert_same( false, VC_City_Guide::is_empty( array_merge( $blank, array( 'winter' => 'Skate.' ) ) ) );
|
||||
}
|
||||
|
||||
function test_season_months_flip_south() {
|
||||
$north = VC_City_Guide::season_months( false );
|
||||
$south = VC_City_Guide::season_months( true );
|
||||
vc_assert_same( 'March – May', $north['spring'] );
|
||||
vc_assert_same( 'September – November', $south['spring'] );
|
||||
vc_assert_same( 'June – August', $south['winter'] );
|
||||
}
|
||||
|
||||
function test_fill_empty_keeps_entered_text() {
|
||||
$guide = VC_City_Guide::fill_empty(
|
||||
array( 'tagline' => 'Mine', 'best_time' => '' ),
|
||||
array( 'tagline' => 'Bundled', 'best_time' => 'May – June' )
|
||||
);
|
||||
vc_assert_same( 'Mine', $guide['tagline'] );
|
||||
vc_assert_same( 'May – June', $guide['best_time'] );
|
||||
}
|
||||
173
viator-collector/tests/test-structure.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
/**
|
||||
* VC_Structure unit tests.
|
||||
*/
|
||||
|
||||
function vc_fixture_structure() {
|
||||
return array(
|
||||
array(
|
||||
'name' => 'Europe',
|
||||
'slug' => 'europe',
|
||||
'term_id' => 0,
|
||||
'cities' => array(
|
||||
array( 'id' => '737', 'name' => 'Amsterdam', 'slug' => 'amsterdam' ),
|
||||
array( 'id' => '479', 'name' => 'Paris', 'slug' => 'paris' ),
|
||||
),
|
||||
),
|
||||
array(
|
||||
'name' => 'Americas',
|
||||
'slug' => 'americas',
|
||||
'term_id' => 0,
|
||||
'cities' => array(
|
||||
array( 'id' => '678', 'name' => 'Boston', 'slug' => 'boston' ),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function vc_fixture_map() {
|
||||
return array( '737' => 'Amsterdam', '479' => 'Paris', '678' => 'Boston', '22' => 'Victoria' );
|
||||
}
|
||||
|
||||
function test_default_regions() {
|
||||
$regions = VC_Structure::default_regions();
|
||||
vc_assert_same(
|
||||
array( 'uk', 'europe', 'asia', 'middle-east-and-africa', 'americas', 'australasia' ),
|
||||
array_column( $regions, 'slug' )
|
||||
);
|
||||
vc_assert_same( 'Middle East and Africa', $regions[3]['name'] );
|
||||
vc_assert_same( array(), $regions[0]['cities'] );
|
||||
vc_assert_same( 0, $regions[0]['term_id'] );
|
||||
}
|
||||
|
||||
function test_normalize_fills_slugs() {
|
||||
$s = VC_Structure::normalize(
|
||||
array(
|
||||
array(
|
||||
'name' => ' Middle East and Africa ',
|
||||
'slug' => '',
|
||||
'term_id' => '7',
|
||||
'cities' => array(
|
||||
array( 'id' => '', 'name' => 'Ghost', 'slug' => 'ghost' ),
|
||||
array( 'id' => '828', 'name' => 'Dubai', 'slug' => '' ),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
vc_assert_same( 'Middle East and Africa', $s[0]['name'] );
|
||||
vc_assert_same( 'middle-east-and-africa', $s[0]['slug'] );
|
||||
vc_assert_same( 7, $s[0]['term_id'] );
|
||||
vc_assert_same( array( array( 'id' => '828', 'name' => 'Dubai', 'slug' => 'dubai' ) ), $s[0]['cities'] );
|
||||
}
|
||||
|
||||
function test_validate_ok() {
|
||||
vc_assert_same( array(), VC_Structure::validate( vc_fixture_structure(), vc_fixture_map(), array() ) );
|
||||
}
|
||||
|
||||
function test_validate_unknown_city() {
|
||||
$s = vc_fixture_structure();
|
||||
$s[1]['cities'][0]['id'] = '999';
|
||||
vc_assert_contains( 'City ID 999 is not a Viator destination.', VC_Structure::validate( $s, vc_fixture_map(), array() ) );
|
||||
}
|
||||
|
||||
function test_validate_duplicate_city() {
|
||||
$s = vc_fixture_structure();
|
||||
$s[1]['cities'][] = array( 'id' => '737', 'name' => 'Amsterdam', 'slug' => 'amsterdam-2' );
|
||||
vc_assert_contains( 'Amsterdam is listed more than once.', VC_Structure::validate( $s, vc_fixture_map(), array() ) );
|
||||
}
|
||||
|
||||
function test_validate_duplicate_slug() {
|
||||
$s = vc_fixture_structure();
|
||||
$s[1]['slug'] = 'paris';
|
||||
vc_assert_contains( 'Slug "paris" is used more than once.', VC_Structure::validate( $s, vc_fixture_map(), array() ) );
|
||||
}
|
||||
|
||||
function test_validate_bad_slug() {
|
||||
$s = vc_fixture_structure();
|
||||
$s[0]['cities'][0]['slug'] = 'amsterdam!';
|
||||
$s[1]['cities'][0]['slug'] = 'New York';
|
||||
$errors = VC_Structure::validate( $s, vc_fixture_map(), array() );
|
||||
vc_assert_contains( 'Slug "amsterdam!" is invalid: use only a-z, 0-9 and -.', $errors );
|
||||
vc_assert_contains( 'Slug "New York" is invalid: use only a-z, 0-9 and -.', $errors );
|
||||
}
|
||||
|
||||
function test_validate_reserved() {
|
||||
$s = vc_fixture_structure();
|
||||
$s[0]['cities'][0]['slug'] = 'contact';
|
||||
vc_assert_contains( 'Slug "contact" is reserved.', VC_Structure::validate( $s, vc_fixture_map(), array() ) );
|
||||
}
|
||||
|
||||
function test_validate_taken_slug() {
|
||||
$s = vc_fixture_structure();
|
||||
$s[1]['cities'][] = array( 'id' => '22', 'name' => 'Victoria', 'slug' => 'victoria' );
|
||||
$errors = VC_Structure::validate( $s, vc_fixture_map(), array( 'victoria' => 'destination "Victoria"' ) );
|
||||
vc_assert_contains( 'Slug "victoria" is already used by destination "Victoria".', $errors );
|
||||
}
|
||||
|
||||
function test_validate_empty_region() {
|
||||
$s = vc_fixture_structure();
|
||||
$s[0]['name'] = '';
|
||||
vc_assert_contains( 'Region 1 has no name.', VC_Structure::validate( $s, vc_fixture_map(), array() ) );
|
||||
}
|
||||
|
||||
function test_canonical_city_primary() {
|
||||
vc_assert_same( 'paris', VC_Structure::canonical_city( vc_fixture_structure(), array( '479', '737' ), '479' ) );
|
||||
}
|
||||
|
||||
function test_canonical_city_fallback_order() {
|
||||
vc_assert_same( 'amsterdam', VC_Structure::canonical_city( vc_fixture_structure(), array( '678', '479', '737' ), '5555' ) );
|
||||
}
|
||||
|
||||
function test_canonical_city_none() {
|
||||
vc_assert_same( '', VC_Structure::canonical_city( vc_fixture_structure(), array( '5555' ), '' ) );
|
||||
}
|
||||
|
||||
function test_primary_destination_id() {
|
||||
vc_assert_same( '2', VC_Structure::primary_destination_id( array( array( 'ref' => '1' ), array( 'ref' => '2', 'primary' => true ) ) ) );
|
||||
vc_assert_same( '', VC_Structure::primary_destination_id( array() ) );
|
||||
}
|
||||
|
||||
function vc_fixture_synced() {
|
||||
$s = vc_fixture_structure();
|
||||
$s[0]['term_id'] = 11;
|
||||
$s[1]['term_id'] = 12;
|
||||
return $s;
|
||||
}
|
||||
|
||||
function test_rewrite_rules() {
|
||||
vc_assert_same(
|
||||
array(
|
||||
'^europe/page/([0-9]+)/?$' => 'index.php?viator_destination=europe&paged=$matches[1]',
|
||||
'^europe/?$' => 'index.php?viator_destination=europe',
|
||||
'^americas/page/([0-9]+)/?$' => 'index.php?viator_destination=americas&paged=$matches[1]',
|
||||
'^americas/?$' => 'index.php?viator_destination=americas',
|
||||
'^amsterdam/page/([0-9]+)/?$' => 'index.php?viator_destination=amsterdam&paged=$matches[1]',
|
||||
'^amsterdam/?$' => 'index.php?viator_destination=amsterdam',
|
||||
'^amsterdam/([^/]+)/?$' => 'index.php?viator_tour=$matches[1]',
|
||||
'^paris/page/([0-9]+)/?$' => 'index.php?viator_destination=paris&paged=$matches[1]',
|
||||
'^paris/?$' => 'index.php?viator_destination=paris',
|
||||
'^paris/([^/]+)/?$' => 'index.php?viator_tour=$matches[1]',
|
||||
'^boston/page/([0-9]+)/?$' => 'index.php?viator_destination=boston&paged=$matches[1]',
|
||||
'^boston/?$' => 'index.php?viator_destination=boston',
|
||||
'^boston/([^/]+)/?$' => 'index.php?viator_tour=$matches[1]',
|
||||
),
|
||||
VC_Structure::rewrite_rules( vc_fixture_synced() )
|
||||
);
|
||||
}
|
||||
|
||||
function test_rewrite_rules_empty_region() {
|
||||
$rules = VC_Structure::rewrite_rules( array( array( 'name' => 'Europe', 'slug' => 'europe', 'term_id' => 11, 'cities' => array() ) ) );
|
||||
vc_assert_same(
|
||||
array(
|
||||
'^europe/page/([0-9]+)/?$' => 'index.php?viator_destination=europe&paged=$matches[1]',
|
||||
'^europe/?$' => 'index.php?viator_destination=europe',
|
||||
),
|
||||
$rules
|
||||
);
|
||||
}
|
||||
|
||||
// Before the structure is ever saved, get() returns the unsynced presets:
|
||||
// they must not claim /uk/, /europe/, … over existing pages.
|
||||
function test_rewrite_rules_skip_unsynced_regions() {
|
||||
vc_assert_same( array(), VC_Structure::rewrite_rules( VC_Structure::default_regions() ) );
|
||||
}
|
||||
28
viator-collector/tests/test-tags.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
/**
|
||||
* VC_Tags unit tests: Viator's internal tags never reach visitors.
|
||||
*/
|
||||
|
||||
function test_internal_tag_agent_low_quality() {
|
||||
vc_assert_same( true, VC_Tags::is_internal( 'Agent Low Quality Product' ) );
|
||||
}
|
||||
|
||||
function test_internal_tag_variants() {
|
||||
vc_assert_same( true, VC_Tags::is_internal( 'Low Quality Supplier' ) );
|
||||
vc_assert_same( true, VC_Tags::is_internal( 'agent quality product' ) );
|
||||
}
|
||||
|
||||
function test_public_tags_kept() {
|
||||
vc_assert_same( false, VC_Tags::is_internal( 'Audio Guides' ) );
|
||||
vc_assert_same( false, VC_Tags::is_internal( 'Excellent Quality' ) );
|
||||
vc_assert_same( false, VC_Tags::is_internal( 'Hop-on Hop-off Tours' ) );
|
||||
}
|
||||
|
||||
function test_public_terms_filters_objects() {
|
||||
$terms = array(
|
||||
(object) array( 'name' => 'Agent Low Quality Product' ),
|
||||
(object) array( 'name' => 'Audio Guides' ),
|
||||
(object) array( 'name' => 'Bus Tours' ),
|
||||
);
|
||||
vc_assert_same( array( 'Audio Guides', 'Bus Tours' ), array_column( VC_Tags::public_terms( $terms ), 'name' ) );
|
||||
}
|
||||
@@ -4,13 +4,13 @@ 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
|
||||
* Version: 1.9.7
|
||||
* Author: Amir Bagheri
|
||||
* License: GPL-2.0-or-later
|
||||
* Text Domain: viator-collector
|
||||
*/
|
||||
|
||||
define('VC_VERSION', '1.8.0');
|
||||
define('VC_VERSION', '1.9.7');
|
||||
define('VC_PLUGIN_FILE', __FILE__);
|
||||
define('VC_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||
define('VC_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||
|
||||