LocalLift Publisher
A two-module local SEO engine. A 7-step wizard turns Google Drive job photos and notes into publish-ready local posts, while a 12-status campaign state machine and Google Business Profile scheduling keep the pipeline honest.
Overview
- 7-step wizard turning Drive photos plus job notes into a WP blog draft and a GBP post draft
- Publish-ordering invariant: the GBP cron job is only enqueued inside the blog-publish success handler, never in parallel
- 12-status campaign state machine with an ALLOWED_TRANSITIONS map that rejects and logs forbidden moves
- Four-way AI provider abstraction (mock / OpenAI gpt-4o-mini / Anthropic claude-haiku-4-5 / DeepSeek) with an in-UI cost rate card
- Three-layer AI cost guard: kill switch, daily USD cap that hard-fails with WP_Error, per-user cooldown transient
- Public voice-review capture: star rating, Web Speech API dictation, editable text, clipboard copy plus Google write-review deep link
- Google OAuth for Drive (drive.file) and GBP (business.manage) with PKCE, state CSRF, auto token refresh, encrypted refresh tokens
- Drive photo import with MIME allow-list (jpeg/png/webp), 10MB and 8000px caps, and EXIF strip via Imagick with GD fallback
- Test-data generator seeding 2 clients, 3 locations, 5 services, 12 photos and 11 campaigns across every workflow status
- Setup checklist card measuring real completion (live AI key, both OAuth connections, a real client with location, a published real campaign)
Who it is for
A WordPress admin at a local service business (or the agency that manages it) who wants field job photos turned into a blog post plus a Google Business Profile post, and who wants a QR-code page where customers dictate a review and get handed off to Google. Day to day it is operated by one administrator: the plugin only grants its capabilities to the `administrator` role, so editors and clients cannot see any of it.
// illustrative interface preview. real screenshots land here before launch.
See it running
How you use it
Turn job photos into a blog post plus a GBP post (the 7-step wizard)
- LocalLift > Campaigns. The page always opens on Step 1 of a brand-new campaign.
- Step 1 – Drive: pick a folder from the dropdown. Disconnected, you get three mock folders; connected, you get whatever folders the `drive.file` scope can actually see. Click Continue.
- Step 2 – Photos: click one thumbnail. Selection is capped at one photo per campaign; clicking a second tile replaces the first. Click Continue.
- Step 3 – Details: choose Client, then Business location (the location and service dropdowns load by AJAX only after a client is picked). City and Target keyword are required. Job notes are what the AI actually writes from, so be specific. Click Continue – this is the point the campaign row is created in the database.
- Step 4 – AI: click Generate. The plugin calls your provider, writes a WordPress post in draft status with the featured image and the Yoast/Rank Math SEO meta, creates the GBP draft row, and moves the campaign to `in_review`.
- Step 5 – Blog: the four fields (Title, Body, SEO title, Meta description) render EMPTY – the AI output is not loaded back into them. Paste or retype the copy you want here, or open the draft in the WordPress editor in another tab and copy it across. Clicking 'Looks good' writes whatever is in these boxes.
- Step 6 – GBP: the 'Post text' box is likewise empty and clicking 'Looks good' overwrites the generated GBP text with it. Type the post here (1500 char max). 'Learn More CTA text' is prefilled with 'Learn more'; the CTA URL is set automatically to the live blog URL at publish time.
- Step 7 – choose one: 'Schedule' (needs a datetime, queues the blog publish and chains the GBP post after it succeeds), 'Publish now' (fires both immediately), or 'Save' (leaves it in review – but see the gotcha about resuming). A confirmation card replaces the wizard when it finishes.
Add a client and a business location
- LocalLift > Clients.
- 'Quick add client' card: type the client name, click '+ Create client'. The page reloads.
- 'Quick add location' card: pick the client, type the location name, paste the GBP location ID as `accounts/{N}/locations/{M}`, click '+ Create location'.
- Verify in the 'All clients' table: the location appears under the client with a grey 'GBP' pill if a GBP ID was saved.
- To remove: click Delete on the row, then 'Confirm delete' in the inline bar. There is no undo and no edit – to change a name you delete and recreate.
Set up voice review capture for a location
- LocalLift > Reviews. The 'Add / edit review config' card only renders if at least one client with a location exists, so do the Clients workflow first.
- Pick the Location, optionally set a Landing slug (auto-derived from the location name if left blank), and paste the Google Place ID (e.g. `ChIJN1t_tDeuEmsRUsoyG83frY4`). The Place ID is not optional in practice: without it the public shortcode renders 'Review capture is not set up for this location yet.'
- Click '+ Create review config'. The row now shows a status pill and a read-only shortcode box – click it to select, then copy.
- Create a WordPress page (e.g. /review/) and paste the shortcode into it. Publish, then open the page logged out over HTTPS and walk the flow yourself: tap a star, tap the mic, speak, tap 'Continue to Google'.
- Exclude that page from your page cache. The shortcode embeds a WordPress nonce generated at render time; once a cached copy is older than the nonce lifetime every visitor's submission fails with a generic error.
- Point your QR code at that page URL. The plugin does not generate QR codes – use any QR generator.
- To change the greeting, thank-you text, Place ID, or to deactivate: click Edit on the row. Location and slug are locked after creation; to move a config to another location, delete it and create a new one (deleting also deletes that config's captured sessions).
Check what actually happened after a publish
- LocalLift > Dashboard: read the KPI tiles (Pending drafts, In review, Scheduled, Published, Failed / Needs attention) and the 'Recent activity' table.
- LocalLift > Logs: filter by Event (e.g. 'Publish Failed', 'Ai Generation Failed', 'Gbp Cross Account Rejected') or by Entity type. Timestamps are UTC and the Status column shows the before-to-after transition. This is the only place failure reasons are surfaced.
- LocalLift > Calendar: month grid of campaigns by their `publish_at` date, up to 3 chips per day. Chips are not clickable; hover shows the target keyword.
- Posts > All Posts: the actual blog output. Every plugin-created post carries a `_llp_campaign_id` postmeta linking it back.
Watch and control AI spend
- LocalLift > Settings > API, 'AI Usage Panel' card: tokens this month, cost this month, daily cap, today's percentage of cap with a bar, and the last call timestamp.
- Adjust 'Daily spend cap (USD)' in the form below and click Save API. The cap is a hard fail, not a warning: once today's ledger total reaches it, every generation returns an error until midnight (site time) or you raise the cap.
- Untick 'Enable AI generation' to hard-stop all AI calls immediately.
- 'Reset stats' truncates the usage ledger (two-click confirm). It resets the cap accounting too, so treat it as a spend-cap override, not a cosmetic reset.
Tech specs
Straight from the codebase scan and the engineering docs. No marketing numbers.
How it works
The main file `locallift-publisher.php` defines LLP_* constants, registers a hand-rolled PSR-4-style autoloader (`Loader` maps `LocalLiftPublisherSubClass_Name` to `includes/sub/class-class-name.php`), wires activation/deactivation hooks, and instantiates the `Plugin` singleton. `Plugin::boot()` runs on `plugins_loaded`: it loads the text domain, compares `llp_db_version` against `LLP_DB_VERSION` and re-runs `Activator::create_tables()` on mismatch (so schema migrates on upgrade without reactivation), registers the capability filter, then wires admin services (Admin, Settings, five AJAX classes, OAuth_Admin) only inside `if ( is_admin() || wp_doing_ajax() )`. The Reviews shortcode and its public AJAX endpoints register outside that branch so anonymous visitors can reach them, and `Scheduler::register_hooks()` registers the two cron handlers globally. All data lives in 13 custom `{prefix}llp_*` tables; there are no custom post types, and blog output is a native `post_type=post` row. Admin pages are plain PHP includes wrapped by `Admin::render_chrome()`, which does a `current_user_can` check then emits a brand-row partial, a topnav partial, and a `.llp-page-body` div. Layered helpers sit under `includes/`: `HTTP_Client` (allow-listed outbound requests), `Encryption` (AES-256-CBC for secrets), `Logger` (append-only), `Repository` (client/location/service/image CRUD), `OAuth_Base` (PKCE + state CSRF, subclassed by Drive_OAuth and GBP_OAuth), and thin `Drive_Client` / `GBP_Client` / `AI_Provider` API wrappers that each facade dispatches to only when OAuth is connected, falling back to canned mock data otherwise.
Where the data lives
| Database / table | Type | Purpose |
|---|---|---|
| {prefix}llp_clients | custom table | Agency client records: name, contact email, notes, archive and test-data flags. |
| {prefix}llp_locations | custom table | Business locations per client, each with an optional GBP location ID (`accounts/{X}/locations/{Y}`), address and city. |
| {prefix}llp_service_categories | custom table | Per-client service types with a default target keyword and description. |
| {prefix}llp_image_imports | custom table | One row per Drive photo pulled in: drive_file_id, resulting WP attachment_id, filename, mime, byte size. |
| {prefix}llp_campaigns | custom table | The unit of work: client/location/service FKs, primary image, city, target keyword, job notes, publish_at, one of 12 statuses, and pointers to wp_post_id and gbp_draft_id. |
| {prefix}llp_ai_generations | custom table | One row per AI call for a campaign: provider, model, input/output tokens, cost, status, raw JSON response. |
| {prefix}llp_blog_drafts | custom table | Mirror of the generated blog copy (title, body, SEO title, meta description) linked one-to-one to a campaign and to its wp_posts row. |
| {prefix}llp_gbp_drafts | custom table | GBP post text, CTA text, CTA URL (set to the live blog permalink at publish), returned gbp_post_id, approved/published timestamps; unique per campaign. |
| {prefix}llp_schedule_jobs | custom table | Scheduled publish jobs with hook name, scheduled_at, fired_at, status, result message and a UNIQUE idempotency_key. |
| {prefix}llp_logs | custom table | Append-only event log: actor user, event name, entity type/id, status before/after, message, JSON context. |
| {prefix}llp_ai_ledger | custom table | Append-only AI spend ledger: provider, model, token counts, cost_usd, SHA-256 prompt hash, UNIQUE idempotency_key; SUM() over it is the source of truth for the daily cap. |
| {prefix}llp_review_configs | custom table | One review-capture config per location: Google Place ID, unique landing_slug, greeting and thank-you copy, active flag. |
| {prefix}llp_review_requests | custom table | One row per customer review session: 32-char session_id, star rating, dictated and edited text, continued_to_google flag and timestamp, SHA-256 ip_hash (rotating salt, raw IP never stored), truncated user agent. |
| llp_* options | option | ~24 options: AI kill switch/provider/cap/cooldown, three encrypted AI API keys, Drive and GBP OAuth client IDs, encrypted secrets, encrypted refresh tokens and display emails, notification settings, uninstall-drop-data flag, llp_db_version, llp_test_data_ids ID manifest, setup-checklist state, and the rotating llp_reviews_ip_salt. |
| llp_{provider}_access_token / llp_{provider}_oauth_pending / llp_ai_cooldown_{user} | transient | Cached short-lived OAuth access tokens, the 5-minute PKCE verifier plus state token during an authorize round trip, and the per-user AI cooldown lock. |
Talks to
Google Drive REST API v3 Listing folders and image files, fetching file metadata, and downloading photo bytes for import into the Media Library (drive.file scope). Google Business Profile APIs (mybusinessaccountmanagement v1, mybusinessbusinessinformation v1, mybusiness v4) Listing accounts and locations, verifying a location's parent account before posting, and creating a STANDARD local post with a photo and LEARN_MORE CTA. Google OAuth 2.0 (accounts.google.com / oauth2.googleapis.com) Authorization-code flow with PKCE for both Drive and GBP, plus refresh-token exchange. OpenAI Chat Completions API Generating the 8-field campaign content JSON with gpt-4o-mini in json_object response mode. Anthropic Messages API Same generation with claude-haiku-4-5 as an alternate provider. DeepSeek API Same generation with deepseek-chat via the OpenAI-compatible endpoint. Google Reviews write form (search.google.com/local/writereview) Client-side deep link built from the config's Place ID; no API call, the customer's browser navigates there. Browser Web Speech API Client-side speech-to-text on the review capture page, with a typed-text fallback when unsupported; no server or AI cost.
Public surface
One public surface: the `[llp_review_capture location="{slug}"]` shortcode, rendered from `public/templates/review-capture.php` with its own scoped CSS and JS enqueued only when the shortcode runs. It is a three-step card (star rating → mic and editable textarea capped at 5000 chars → thank-you screen) with a hidden honeypot field, a noscript fallback link, and ARIA roles throughout. Three `wp_ajax_nopriv_` endpoints back it (open_session, update_text, mark_continued), each gated by a public nonce, config-existence check, and a per-ip_hash rate limit of 5 sessions per 5 minutes per location, returning identical generic errors on all 4xx to prevent slug enumeration. The blog posts the Publisher module creates are ordinary published WordPress posts, not a plugin-owned template.
History
Changelog
60 releases since May 2026. The whole history, verbatim from the changelog:
Frequently asked questions
What is LocalLift Publisher?
It is an AI-assisted local SEO publishing engine for WordPress. It turns Google Drive job photos and job notes into publish-ready city and service posts, and schedules Google Business Profile posts alongside them.
How does the publishing wizard work?
It is a seven-step wizard that takes Drive photos plus job notes and produces a WordPress blog draft and a Google Business Profile post draft.
Does it post to Google Business Profile?
Yes. Google Business Profile posting runs on an ordered schedule, and the GBP job is only queued once the blog post has actually published successfully.
What stops a Google Business Profile post going out before the blog post?
A publish-ordering rule. The GBP job is enqueued inside the blog publish success handler and nowhere else, so the ordering cannot be skipped.
What is the campaign state machine?
Campaigns move through twelve statuses with an explicit map of allowed transitions. Forbidden moves are rejected and logged rather than silently applied.
Where do the photos come from?
Google Drive. The plugin reads job photos from Drive as part of the wizard, which suits crews who already dump photos into a shared folder.
Is this useful for a multi-location or multi-client setup?
Yes. It has a Clients screen alongside Campaigns and a Calendar, so the pipeline is organized per client rather than as one stream.
Does it handle reviews?
There is a Reviews screen in the plugin alongside campaigns, calendar, clients and logs.
How do I audit what it published?
There is a Logs screen, and the state machine records rejected transitions, so the publishing history is inspectable rather than assumed.
Can I buy LocalLift Publisher now?
Not yet. Checkout opens after the SWG License Manager clears its final security gate, and the listed prices are placeholders.
Want it the day checkout opens?
Checkout opens the day our license platform clears its final security gate. One email when it does. That is the whole funnel.
No spam. One email at launch, then the newsletter only if you opt in.