=== TurnKey Directories ===
Contributors: turnkeydirectories
Tags: directory, business directory, listings, schema markup, local seo
Requires at least: 5.9
Tested up to: 7.1
Requires PHP: 7.4
Stable tag: 3.1.3
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

High-performance business directory. Create listings manually, dynamic directory pages, LocalBusiness schema, and XML + HTML sitemaps.

== Description ==

TurnKey Directories turns a WordPress install into a structured business directory. The free plugin lets you create unlimited listings manually — one at a time through a simple "Add a Listing" form — and ships with a setup wizard, a public-facing search and listing UI, dynamic virtual directory pages, LocalBusiness and directory-level schema, and XML + HTML sitemaps. Need to populate a directory in bulk? Bulk CSV/XLSX import and business-data import via the Outscraper API are available in the optional Pro add-on.

= Watch it in action =

https://youtu.be/P7u6VxR8YuU

Prefer shorter chapters? The same walkthrough is split into individual clips in [this YouTube playlist](https://www.youtube.com/playlist?list=PLGGOM5Ysk_ulJtMMcZpfgNEEZIxUPJOZA).

= Core Features =

* **Setup wizard** that walks through the optional integrations (Google Search Console/GA4, Mapillary), adding your first listings, and sitemap generation.
* **Manual listing creation** — add unlimited listings one at a time through an "Add a Listing" form, with a full editor for photos, working hours, and contact details.
* **Dynamic virtual directory pages** across niche, country, state, city, and business levels, generated on the fly from your listings.
* **Faceted search + Leaflet map** with filtering, search, and pagination.
* **Schema.org JSON-LD**: LocalBusiness and BreadcrumbList on every listing page, directory-level CollectionPage / ItemList schema on category (niche/country/state/city) pages, plus WebSite + SearchAction site-wide.
* **XML and HTML sitemaps** so newly added listings are discoverable.
* **Accent-color and typography customization** (Settings → Appearance) to match your brand.
* **Two shortcodes** — `[turnkeydir_dynamic_directory]` renders the category overview anywhere; `[turnkeydir_city_directory category="" state="" city=""]` embeds a city listing block.
* **Mapillary integration** to pull street-view imagery for listings with coordinates.
* **Google Search Console + Analytics (GA4)** connection with per-page analytics.
* **Optional "Powered by" credit** — opt-in (off by default).
* **Performance-first architecture**: object-cache-aware caching layer, prepared statements throughout, encrypted secrets at rest, rate-limited customer-facing AJAX endpoints.

= Upgrade to Pro =

[TurnKey Directories Pro](https://turnkeydirectories.com/) adds bulk CSV/XLSX import, business-data import via the Outscraper API, business-owner claims and a customer portal, on-site reviews, multi-engine indexing (Google Indexing API + IndexNow), keyword-rank tracking, Stripe subscription billing, display ad zones, and the cross-site directory link network (Network Hub). The free plugin runs fully on its own; Pro is an optional add-on.

= External services =

This plugin can talk to a number of third-party services. No service is contacted on its own: the Mapillary and Google integrations are opt-in via the setup wizard or the relevant settings tab, and the admin-side Nominatim geocoding runs only when you save an address in the listing editor. On the visitor side, directory pages load OpenStreetMap map tiles (the maps are a core feature of the directory pages), and a listing page embeds a video or street-view frame only when that listing has one.

* **OpenStreetMap tile server** (`tile.openstreetmap.org`) — whenever a directory page renders its Leaflet map (and likewise for the map preview in the wp-admin listing editor), the browser requests the map-tile images for the viewed area directly from OpenStreetMap. As with any image request, the viewer's IP address and user agent reach the tile server, along with the tile coordinates of the map area being viewed. Operated by the OpenStreetMap Foundation. [Tile usage policy](https://operations.osmfoundation.org/policies/tiles/) · [Privacy policy](https://osmfoundation.org/wiki/Privacy_Policy).
* **OpenStreetMap Nominatim** (`nominatim.openstreetmap.org`) — geocoding lookups (forward and reverse). Used in two places: (1) the admin listing editor sends the address text or coordinates you enter; (2) on the public directory search, when a visitor chooses to use their current location, their browser sends those coordinates to resolve them to a place name (user-initiated; the browser also asks for geolocation permission first). [Usage policy](https://operations.osmfoundation.org/policies/nominatim/) · [Privacy policy](https://osmfoundation.org/wiki/Privacy_Policy).
* **Mapillary / Meta** (`graph.mapillary.com`) — opt-in; when a Mapillary client token is configured, the plugin sends each listing's latitude/longitude (plus your token) to request street-view imagery for that listing. The returned images are then loaded by visitors' browsers from Mapillary's image CDN (`tiles.mapillary.com`, `scontent.xx.fbcdn.net`), and where a listing shows an interactive street-view embed the visitor's browser loads Mapillary's embed viewer from `www.mapillary.com` (the visitor's IP address and user agent reach Meta's servers). [Terms of use](https://www.mapillary.com/terms) · [Privacy policy](https://www.mapillary.com/privacy).
* **Google** — opt-in: when you connect Google integration, the admin requests Google Search Console and Analytics (GA4) reporting on your behalf, sending your OAuth tokens and your site URL (`accounts.google.com`, `oauth2.googleapis.com`, `googleapis.com`). This connection is used only to read your own reporting data into the admin Analytics dashboard; the plugin does not add any Google tracking tag to your public pages. [Terms of service](https://policies.google.com/terms) · [Privacy policy](https://policies.google.com/privacy).
* **YouTube / Vimeo video embeds** — when a listing has a video URL on record, its listing page embeds that video player in the visitor's browser (`youtube.com` / `player.vimeo.com`); the embed provider receives the visitor's IP address and user agent, as with any embedded player. No video services are contacted on pages without a listing video. [YouTube terms](https://www.youtube.com/t/terms) · [Google privacy](https://policies.google.com/privacy) · [Vimeo terms](https://vimeo.com/terms) · [Vimeo privacy](https://vimeo.com/privacy).
* **Directions links** — the "Get Directions" buttons are plain user-initiated links that open the visitor's maps application (Google Maps or the platform's geo: handler); nothing is contacted until the visitor taps them.
* **Share links** — the share buttons on directory pages are likewise plain user-initiated links to the visitor's chosen network (Facebook, X/Twitter, LinkedIn, or an email draft); nothing is contacted until the visitor taps one, at which point the shared page URL and title travel to that network as part of the link.

= Bundled third-party libraries =

All front-end libraries are bundled locally — the plugin loads none of its own assets from any CDN. (The only external scripts are the per-listing video/street-view embeds — see the external-services list above.) Bundled (with links to their readable source): [Leaflet 1.9.4](https://github.com/Leaflet/Leaflet) (BSD-2), [Chart.js 4.5.1](https://github.com/chartjs/Chart.js) (MIT), [Font Awesome Free 6.7.2](https://github.com/FortAwesome/Font-Awesome) (icons CC BY 4.0, code MIT, fonts OFL), [jsVectorMap 1.7.0](https://github.com/themustafaomar/jsvectormap) (MIT), and the [Inter](https://github.com/rsms/inter) typeface (OFL). Each library's full license text ships alongside it under `public/vendor/`.

= Source Code & Bug Reports =

Source code, issue tracker, and contribution guidelines are available at [turnkeydirectories.com](https://turnkeydirectories.com/).

== Installation ==

1. Upload the `turnkey-directories` folder to `/wp-content/plugins/`, or install the plugin through the WordPress Plugins screen.
2. Activate the plugin through the **Plugins** screen in WordPress.
3. Visit **TurnKey Directories** in the admin sidebar to launch the setup wizard.
4. Follow the wizard to connect the optional integrations (Google, Mapillary), add your first listings, and generate the sitemap. Branding (accent color, typography) lives under **Settings → Appearance**.

== Recommended hosting setup ==

The plugin schedules daily background tasks for Google Analytics / Search Console sync and Mapillary image fetching. By default WordPress's `wp_cron` piggybacks on regular page loads — meaning a logged-in admin viewing a public page can be the one that synchronously runs an overdue background task. If a Google API call is slow that day, that page load is slow too.

For best front-end performance, offload `wp_cron` to a real server cron:

1. In `wp-config.php`, above the "stop editing" comment, add:

   `define('DISABLE_WP_CRON', true);`

2. In your hosting control panel's cron-job manager (Hostinger hPanel → Advanced → Cron Jobs, or cPanel → Cron Jobs), add an every-five-minute job that hits `wp-cron.php`:

   `*/5 * * * * curl -s https://YOUR-DOMAIN.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1`

   (replace `YOUR-DOMAIN.com` with your actual site)

After this, all background tasks run in their own short-lived process and never block a visitor's page render.

As a defense-in-depth, each scheduled callback also has a `DOING_CRON` guard and an in-flight transient lock so even with default `wp_cron` enabled, a slow Google API call can't be re-entered by a concurrent page load and is bounded by a short HTTP timeout. With both the server-cron offload and the in-plugin guards in place, the impact of any third-party API outage on your front-end response time is effectively zero.

== Frequently Asked Questions ==

= Does this plugin work without any third-party services? =

Yes. The core directory (manual listing creation, public pages, schema, sitemap) works without any external integrations. Mapillary and Google are opt-in.

= How many listings can the plugin handle? =

Architecturally targeted at large directories on standard shared hosting. Performance scales with your DB, hosting tier, and whether you have an external object cache (Redis or Memcached) configured. The dynamic-routing layer caches city/state/country queries through `wp_cache_*` (with a transient fallback) so high-traffic pages do not hammer MySQL. The free plugin supports an unlimited number of manually created listings; to populate a directory in bulk, the Pro add-on adds CSV/XLSX import and business-data import via the Outscraper API.

= Are stored API keys encrypted? =

Yes. All third-party credentials (Mapillary, Google OAuth refresh/access tokens) are encrypted at rest using `sodium_crypto_secretbox` (XSalsa20-Poly1305) with a key derived from `wp_salt('auth')`. Existing plaintext options are migrated transparently on first read.

= What visitor data does the plugin store? =

The directory stores the business listings you create. For abuse prevention, the built-in security log records the IP address, user agent and request path of requests that trip a rate limit or look malicious, keeping only a rolling window of the most recent entries so repeat offenders can be blocked. No cookies are set on visitors, and the plugin adds no analytics or tracking tag to your public pages. You can clear the security log at any time from **Dashboard → Health → Security Logs**.

= How do I delete all plugin data on uninstall? =

Visit **Settings → Danger Zone → Delete all plugin data on uninstall** and check the box BEFORE deleting the plugin from the Plugins page. The default behavior preserves your data so you can reinstall without loss.

== Screenshots ==

1. Setup wizard — connect integrations and add your first listings.
2. Add a Listing — the manual listing editor.
3. Dynamic directory pages, cross-linked to nearby cities.
4. Single business listing with photos, hours, and contact details.
5. Admin dashboard with analytics.

== Changelog ==

= 3.1.3 =
* WordPress 7.1 compatibility: tested against the 7.1 release candidate.
* Changed: the Directory Listings editor block now registers with block API version 3, matching WordPress 7.1's always-iframed post editor.
* Fixed: the block's editor placeholder icon could render as an empty box in the iframed editor, where the admin icon font isn't loaded. The placeholder now uses a self-contained inline icon.
* Removed: an unused media-library enqueue on the Edit Listing screen (the screen uses its own uploader), so no unneeded media scripts are loaded there.

= 3.1.2 =
* Fixed: the directory landing page (your search page) 404'd after a bulk import. On a new install the page is created before any listings exist, so it falls back to a generic "/business/" slug; if the post-import refresh didn't reach it, the page stayed there while every link pointed at "/your-niche/". It now self-heals: once real listings exist, the landing page is regenerated at the correct slug.
* Fixed: PHP warning "Undefined variable $accent_color" on state pages, which also emitted empty theme-color meta tags.
* Fixed: bulk imports silently dropped several mapped columns (review counts, website, email, description, opening hours, image and booking links) whenever the source column names differed from the internal database names. All mapped fields are now resolved to their real columns before saving, on every import path (CSV, XLSX, scraped data, and the manual Add-a-Listing form). Re-import your data file to backfill listings imported before this release.
* Fixed: the Google review link on a listing pointed at the on-page review section instead of the business's Google reviews URL whenever the review count was zero. The link target is now chosen from the stored reviews URL independently of the count.
* Fixed: filtering a category search by rating (or any non-search filter) still showed the "All Businesses" heading and full count, so the filter looked like it did nothing. The results header now reflects the active filters and the filtered total.
* Fixed: opening-hours import corrupted day names that were already spelled out (e.g. "Monday" became "Mondayday"). Day-abbreviation expansion is now word-boundary aware.
* Fixed: the interactive street view showed only sky with its controls cut off. The embed was forced 100px taller than its container (which clips overflow), so it was cropped to the top of the image. It now fills the container exactly — the view is centred on the street and every control is visible. Also relabelled "360° View" to "Interactive Street View" (most coverage is flat perspective).

= 3.1.1 =
* Initial release.
