# Pinterest Feed Widget

> Renders Pinterest content from the connected account — user pins, board pins, or boards list — via the Pinterest v5 REST API. OAuth credentials live site-wide in the EA Dashboard; one connection serves all widget instances.

**Class file:** `includes/Elements/Pinterest_Feed.php` (2832 lines)
**Slug:** `pinterest-feed` (widget id `eael-pinterest-feed`)
**Public docs:** <https://essential-addons.com/elementor/docs/pinterest-feed/>
**Pro-shared:** Pro-only. **External integration: Pinterest API v5** (`api.pinterest.com/v5`, OAuth 2.0).

## Overview

Three feed types — **User Pins**, **Board Pins** (per-board), **Boards** (board cards) — rendered in one of five layouts: Grid Overlay, Masonry Overlay, Polaroid Wall, Card Slider, Featured Slider. Boards feed is limited to Grid + Slider since pin-specific skins would render duplicates. OAuth runs through a custom `/eael-pinterest-auth/` front-end rewrite (Pinterest rejects redirect URIs that carry query parameters). Tokens auto-refresh via WP-Cron ~10 min before expiry, with an inline 5-min-buffer fallback inside `get_current_pinterest_access_token()`.

## Pro vs Lite

Pro-only. No Lite counterpart, no Extender hook.

## File Map

| File | Role |
| --- | --- |
| `includes/Elements/Pinterest_Feed.php` | Widget class — controls + render orchestration |
| `includes/Traits/Pinterest_Feed.php` (1520 lines) | OAuth flow, token refresh, API fetchers, transient cache, AJAX load-more handler |
| `src/css/view/pinterest-feed.scss` (1501 lines) → `assets/front-end/css/view/pinterest-feed.min.css` | All five layouts + boards card + profile header + slider styles |
| `src/js/view/pinterest-feed.js` (177 lines) → `assets/front-end/js/view/pinterest-feed.min.js` | Swiper init for `layout-4`/`layout-5`, force-square ResizeObserver, load-more AJAX |
| `assets/front-end/css/view/load-more.min.css` | Pagination button CSS (Lite-owned — referenced via `EAEL_PLUGIN_PATH` in `config.php:1256`) |
| `config.php` entry `'pinterest-feed'` (`:1251`) | Mixed Lite + Pro CSS, self JS |
| Site option `eael_pinterest_client_id` / `_client_secret` | App credentials saved from EA Dashboard |
| Site option `eael_pinterest_access_token` / `_refresh_token` / `_token_expires_at` / `_user_info` | Per-site OAuth state |
| Site option `eael_pinterest_connection_status` | `''` or `'revoked'` |

## Architecture

- **Site-wide OAuth, not per-widget tokens.** All instances read `get_option( 'eael_pinterest_access_token' )` via `get_current_pinterest_access_token()` (`Traits/Pinterest_Feed.php:409`). A **legacy per-widget `eael_pinterest_feed_access_token`** setting is still read from raw `_elementor_data` post meta in `Elements/Pinterest_Feed.php:165-178` as a migration fallback — keep both paths until older saved widgets are migrated.
- **Custom rewrite for OAuth callback** — `^eael-pinterest-auth/?$` (`Traits/Pinterest_Feed.php:32`). Pinterest's v5 OAuth does not accept redirect URIs with query strings; the rewrite mints a clean URL. Schema version pin `eael_pinterest_rewrite_version=v1` auto-flushes once (`:35`); bump the version string if the slug changes.
- **State nonce is user-bound** (`wp_create_nonce( 'eael_pinterest_oauth' )` → `wp_verify_nonce` at `Traits:180`). Only the admin who started the flow can complete it.
- **Token endpoint uses HTTP Basic auth** for client credentials, not POST body (`Traits:197-220`, `:326-345`). Pinterest v5 rejects POST-body client auth.
- **Token refresh = two paths in one.** A scheduled single event `eael_pinterest_feed_token_refresh` fires ~10 min before expiry (`Traits:267`); `get_current_pinterest_access_token()` also refreshes inline if called within a 5-min buffer of expiry. On cron failure, a fallback retry is rescheduled for `time() + HOUR_IN_SECONDS` (`:322`).
- **Refresh-token rotation honoured** — when the token endpoint returns a new `refresh_token`, the new value overwrites the previous one in the same write. See [`.claude/rules/external-api-integrations.md`](../../.claude/rules/external-api-integrations.md) § Google APIs for the rotation rule (same pattern applies here).
- **AJAX load-more** registered in `Bootstrap.php:228-229` against both `wp_ajax_pinterest_feed_load_more` and `wp_ajax_nopriv_pinterest_feed_load_more`. Bookmark pagination is provided by Pinterest's `bookmark` cursor (saved into the transient with the page payload at `Traits:643`).
- **Two-layer transient cache** — page payload + cache-duration sentinel. The companion `_dur` transient lets the trait detect when the user changed the TTL setting and invalidate the data transient (`Traits:488-493`, `:621-624`). Cache key includes the Pinterest username so a token rotation that keeps the same identity doesn't orphan the cache (`:615`).
- **Render short-circuits when not connected** — `render()` returns early if `get_current_pinterest_access_token()` is empty (`Elements:2610`). Avoids rendering empty wrappers and stale slider markup.
- **Editor-only `data-settings` payload** — when in `Plugin::instance()->editor->is_edit_mode()`, every visibility / layout / count setting is serialized into a `data-settings` attribute on the load-more button so the editor preview reflects unsaved changes without a server round-trip (`Elements:2734-2769`). Frontend renders never carry this payload.
- **Boards feed has its own layout control + own style section.** `eael_pinterest_feed_layout_boards` (only Grid + Slider) keeps the boards card visual independent of the pin-layout state so switching feed type doesn't ship a broken render.

## Render Output

```html
[?] <div class="eael-pinterest-feed-profile-header eael-pf-header-{inline|stacked}">...</div>

<div id="eael-pinterest-feed-{element-id}"
     class="eael-pinterest-feed
            eael-pinterest-feed-mode-{grid|masonry|slider}
            eael-pf-layout-{layout-1..5}
            [?] eael-pinterest-feed-overlay [?] eael-pinterest-feed-overlay-always
            [?] swiper
            [?] eael-pinterest-feed-square-img
            [?] eael-pf-polaroid-always-tilt"
     style="--eael-pf-img-ratio: {ratio};"
     [?] data-slides-desktop data-slides-tablet data-slides-mobile
     [?] data-gap data-effect data-speed data-loop data-autoplay
     [?] data-autoplay-delay data-pause-on-hover data-grab-cursor
     [?] data-navigation data-dots
     [?] data-pagination data-arrow-next data-arrow-prev>

  [?] <div class="swiper-wrapper">

    <!-- Pins feed item (layout-1, layout-2 — overlay visual) -->
    <div class="eael-pinterest-feed-item [?] swiper-slide">
      <div class="eael-pinterest-feed-item-inner">
        <a href="{permalink}" target="_blank" rel="noopener">
          <img class="eael-pinterest-feed-img" src="..." alt="..." />
          [?] <div class="eael-pinterest-feed-domain">{outbound-domain}</div>
          [?] <div class="eael-pinterest-feed-caption">
            <div class="eael-pinterest-feed-caption-text">{title}</div>
            <div class="eael-pinterest-feed-meta">
              [?] <span class="eael-pinterest-feed-board">{board-name}</span>
              [?] <span class="eael-pinterest-feed-domain-inline">{outbound-domain}</span>
            </div>
          </div>
        </a>
      </div>
    </div>

    <!-- Card slider (layout-4) -->
    <div class="eael-pinterest-feed-item swiper-slide">
      <div class="eael-pinterest-feed-item-inner">
        [?] <span class="eael-pf-card-chip"><i class="..."></i>{board-name}</span>
        <a class="eael-pf-card-link" href="...">
          <img class="eael-pinterest-feed-img" ... />
          <div class="eael-pf-card-body">
            [?] <h3 class="eael-pf-card-title">...</h3>
            [?] <span class="eael-pf-card-date">...</span>
            [?] <p class="eael-pf-card-description">...</p>
            [?] <span class="eael-pf-card-read-more">{read-more-text}</span>
          </div>
        </a>
      </div>
    </div>

    <!-- Featured slider (layout-5) -->
    <div class="eael-pinterest-feed-item swiper-slide">
      <div class="eael-pinterest-feed-item-inner">
        <img ... />
        <div class="eael-pf-editorial-body">
          [?] <span class="eael-pf-editorial-board">...</span>
          <h2 class="eael-pf-editorial-title">...</h2>
          [?] <time class="eael-pf-editorial-date">...</time>
          [?] <p class="eael-pf-editorial-description">...</p>
          [?] <a class="eael-pf-editorial-url" href="...">{domain}</a>
          [?] <a class="eael-pf-editorial-button" href="...">{read-more-text}</a>
        </div>
      </div>
    </div>

    <!-- Polaroid wall (layout-3) -->
    <div class="eael-pinterest-feed-item">
      <div class="eael-pf-polaroid-frame">
        <img ... />
        [?] <div class="eael-pf-polaroid-title">...</div>
        <div class="eael-pf-polaroid-meta">
          [?] <span class="eael-pf-polaroid-board">...</span>
          [?] <span class="eael-pf-polaroid-domain">...</span>
        </div>
      </div>
    </div>

    <!-- Boards feed item (eael_pinterest_feed_type = boards) -->
    <div class="eael-pf-board-card">
      <div class="eael-pf-board-cover eael-pf-board-cover-{single-image|split-2|mosaic-3|grid-4|multi-image}">
        <img ... /> ...
      </div>
      <div class="eael-pf-board-info">
        [?] <h3 class="eael-pf-board-name">...</h3>
        [?] <span class="eael-pf-board-pin-count">{count} pins</span>
      </div>
    </div>

  [?] </div>

  [?] <div class="eael-pf-pagination swiper-pagination eael-pf-pagination-{wid}"></div>
  [?] <div class="eael-pf-arrow eael-pf-arrow-next swiper-button-next eael-pf-arrow-next-{wid}"><i class="fas fa-angle-right"></i></div>
  [?] <div class="eael-pf-arrow eael-pf-arrow-prev swiper-button-prev eael-pf-arrow-prev-{wid}"><i class="fas fa-angle-left"></i></div>
</div>
<div class="clearfix"></div>

[?] <div class="eael-pf-credit"><span>Powered by</span> <span class="eael-pf-credit-mark">Pinterest</span></div>

[?] <div class="eael-load-more-button-wrap eael-pf-mode-{grid|masonry}">
  <button class="eael-load-more-button eael-pinterest-feed-load-more"
          data-widget-id="..." data-post-id="..." data-page="1" data-per-page="..."
          [?] data-settings="{editor-only urlencoded payload}">
    <div class="eael-btn-loader button__loader"></div>
    <span>{loadmore_text}</span>
  </button>
</div>
```

## Controls Reference

Account Settings:

| Control id | Type | Purpose |
| --- | --- | --- |
| `eael_pinterest_feed_connection_status` | RAW_HTML | Shows connected username + EA Dashboard link, or a warning when credentials/connection are missing/revoked |
| `eael_pinterest_feed_data_cache_limit` | SELECT (minutes) | Transient TTL — 1 min through 1 day |
| `eael_pinterest_feed_type` | SELECT | `user_pins` / `board_pins` / `boards` |
| `eael_pinterest_feed_board_id` | SELECT2 (or TEXT fallback) | Cached boards dropdown; falls back to free-text on first load before the cache populates |
| `eael_pinterest_feed_board_cover_style` | SELECT | `single-image` / `split-2` / `mosaic-3` / `grid-4` / `multi-image` (boards only) |

Feed Settings (selected):

| Control id | Type | Purpose |
| --- | --- | --- |
| `eael_pinterest_feed_layout` | SELECT | `layout-1` Grid Overlay, `layout-2` Masonry Overlay, `layout-3` Polaroid Wall, `layout-4` Card Slider, `layout-5` Featured Slider (pins only) |
| `eael_pinterest_feed_layout_boards` | SELECT | `layout-1` Grid, `layout-4` Slider (boards only) |
| `eael_pinterest_feed_overlay_mode` | CHOOSE | `always` / `hover` (layouts 1+2 only) |
| `eael_pinterest_feed_columns` | SELECT (responsive) | Grid/Masonry/Polaroid/Board-grid column count |
| `eael_pinterest_feed_image_count` / `_board_count` | NUMBER | Items per page — 1–100 |
| `eael_pinterest_feed_force_square` | SWITCHER | layout-1 grid only — JS-driven via ResizeObserver (`pinterest-feed.js:13-29`) |
| `eael_pinterest_feed_pagination` | SWITCHER | Load-more — non-slider only |

Slider (layout-4/5 or boards layout-4):

| Control id | Type | Purpose |
| --- | --- | --- |
| `eael_pinterest_feed_slides_desktop` / `_tablet` / `_mobile` | NUMBER | Slides per view. **Featured Slider forces 1** in render regardless of stored values (`Elements:2686-2688`) |
| `eael_pinterest_feed_slider_effect` | SELECT | `slide` / `coverflow` |
| `eael_pinterest_feed_slider_speed` / `_autoplay_delay` | NUMBER (ms) | Swiper transition + autoplay timing |
| `eael_pinterest_feed_slider_loop` / `_autoplay` / `_pause_on_hover` / `_navigation` / `_dots` / `_grab_cursor` | SWITCHER | Swiper behaviour flags |

Profile Header:

| Control id | Type | Purpose |
| --- | --- | --- |
| `eael_pinterest_feed_show_header` | SWITCHER | Master toggle |
| `eael_pinterest_feed_header_style` | SELECT | `inline` / `stacked` |
| `eael_pinterest_feed_header_meta_order` | SELECT | `stats-first` / `button-first` |
| `eael_pinterest_feed_custom_avatar` | MEDIA | Override avatar from Pinterest user info |
| `eael_pinterest_feed_pinterest_url` | URL | Follow button target |
| `eael_pinterest_feed_show_follow_btn` / `_show_stats` | SWITCHER | Visibility |

Style sections (one per visual): Pin Item, Caption, Overlay, Card Style, Featured Card, Polaroid, Board Card, Meta, Profile Header, Slider, Load More.

## Conditional Dependencies

```text
eael_pinterest_feed_type
  ├── user_pins / board_pins
  │     ├── layout = layout-1 (Grid Overlay)   → overlay_mode, force_square, overlay_show_board, overlay_show_domain
  │     ├── layout = layout-2 (Masonry Overlay) → overlay_mode, overlay_show_board, overlay_show_domain (no force_square)
  │     ├── layout = layout-3 (Polaroid Wall)  → polaroid_always_tilt, modern_show_board, modern_show_domain,
  │     │                                          modern_description_lines
  │     ├── layout = layout-4 (Card Slider)    → slider_* controls, card_show_chip, show_date, show_description,
  │     │                                          description_length, show_read_more, read_more_text
  │     └── layout = layout-5 (Featured Slider) → slider_* controls, featured_show_board, featured_show_date,
  │                                                featured_show_read_more, featured_read_more_text,
  │                                                editorial_show_url, show_description
  │
  └── boards
        ├── board_cover_style, show_board_name, show_pin_count
        ├── layout_boards = layout-1 (Grid)   → columns, force_square
        └── layout_boards = layout-4 (Slider) → slider_* controls

eael_pinterest_feed_slider_autoplay = yes
  └── slider_autoplay_delay, slider_pause_on_hover

eael_pinterest_feed_pagination = yes  (non-slider layouts only)
  └── loadmore_text
```

## JavaScript Lifecycle

`src/js/view/pinterest-feed.js`:

- Registers via `frontend/element_ready/eael-pinterest-feed.default`.
- **Slider branch** (`mode-slider`): reads all `data-*` attrs into a Swiper config, picks `coverflow` vs `slide`. For `coverflow`, fixes `slidesPerView: 1.4`; otherwise sets up breakpoints (1024 / 768 / 320). Loop is automatically downgraded when `slideCount < slidesPerView * 2` (Swiper requires double the visible count for seamless loops). Swiper instantiated via `eaelPfSwiperLoader` — the canonical async loader pattern from [`.claude/rules/widget-development.md`](../../.claude/rules/widget-development.md) § "Elementor-Provided Libraries".
- **Grid branch** (default): `force_square()` clamps `.eael-pinterest-feed-item-inner` `max-height` to current item width when `eael-pinterest-feed-square-img` is on the wrap. Rebound on `resize.pf-<id>`. In editor, a ResizeObserver re-fires the same clamp because Elementor's responsive preview doesn't always emit `resize`.
- **Load-more handler**: namespaced as `click.eaelPf` and unbound-before-bound so editor re-init can't stack handlers. Page derived from DOM count (`visible / perPage`) — jQuery `.data()` state does not survive editor re-renders. AJAX action `pinterest_feed_load_more` against `localize.ajaxurl` with `localize.nonce`. On success, appends `response.html`; when `num_pages <= page`, removes the button.
- **Hook subscriptions** — `force_square()` is rebound on `ea-lightbox-triggered`, `ea-advanced-tabs-triggered`, `ea-advanced-accordion-triggered`, `ea-toggle-triggered` so layout recalculates when the widget becomes visible inside a hidden container.

## Hooks & Filters

### WordPress hooks consumed by the trait (`init_pinterest_feed_hooks`)

| Hook | Purpose |
| --- | --- |
| `eael/admin/modal/pinterestFeedSetting` (filter) | Injects the Pinterest accordion into the EA Dashboard settings modal |
| `eael/admin/after_save_settings` (action) | Persists `pf_pinterest_client_id` / `_client_secret` |
| `init` | Registers `^eael-pinterest-auth/?$` rewrite + schedules `eael_pinterest_feed_token_refresh` event |
| `query_vars` (filter) | Registers `eael_pinterest_auth` |
| `template_redirect` | OAuth callback handler (front-end, on the rewrite URL) |
| `admin_init` | Disconnect handler (`?eael_pinterest_disconnect=1`) |
| `eael_pinterest_feed_token_refresh` (single event) | Refresh-token rotation |

### AJAX endpoints (registered in `includes/Classes/Bootstrap.php:228-229`)

| Action | Capability | Notes |
| --- | --- | --- |
| `wp_ajax_pinterest_feed_load_more` | logged-in | Forwarded to `Pinterest_Feed::pinterest_feed_load_more()` |
| `wp_ajax_nopriv_pinterest_feed_load_more` | public | Same handler. Audit per [`.claude/rules/external-api-integrations.md`](../../.claude/rules/external-api-integrations.md) + nopriv-ajax-hardening skill — page/per-page/widget-id come from the client |

### Outbound HTTP

| URL | Method | Timeout | Caller |
| --- | --- | --- | --- |
| `https://api.pinterest.com/v5/oauth/token` | POST (Basic auth) | 60s | `handle_pinterest_feed_oauth_callback`, `eael_refresh_pinterest_access_token` |
| `https://api.pinterest.com/v5/pins`, `/boards/{id}/pins`, `/boards`, `/boards/{id}/pins` (board covers) | GET | 30s | `fetch_pinterest_data`, `fetch_pinterest_boards`, board-cover prefetch |
| `https://api.pinterest.com/v5/user_account` | GET | 15s | `fetch_pinterest_user_info` |

OAuth scopes requested: `user_accounts:read,pins:read,boards:read` (`Traits:153`).

### Pro / EA hooks emitted

None. Pinterest Feed does not register `eael/*` extension hooks.

## Common Issues

| Symptom | Likely cause | Diagnose | Fix |
| --- | --- | --- | --- |
| Editor warning "Please connect your Pinterest account…" | `eael_pinterest_access_token` empty or `eael_pinterest_connection_status === 'revoked'` | Check the option values in `wp_options` | Re-run OAuth from EA Dashboard » Elements » Pinterest Feed |
| Frontend renders nothing | `render()` short-circuits when token is empty (`Elements:2610`) | View source — no `.eael-pinterest-feed` wrapper at all | Confirm token, then hard-refresh; check `wp_remote_post` response on the next refresh-token cron tick |
| "Security check failed" after Pinterest redirect | `wp_verify_nonce( $state, 'eael_pinterest_oauth' )` failed — different user, nonce lifetime, or session cookie loss | `eael_pinterest_last_error` option holds the message | User must re-initiate the OAuth flow as the same admin that started it |
| Board dropdown stays blank | First-render race: dropdown options fetch keyed by token, not yet cached at editor load | `eael_pinterest_boards_dropdown_*` transient absent | Re-open the widget panel — the prefetch in `render()` (`Elements:2775`) seeds the cache for the next editor session. The TEXT fallback accepts a free-form board ID/slug meanwhile |
| Slider loops broken with few pins | Swiper requires `slidesPerView * 2` slides to loop seamlessly — JS auto-downgrades `loop` to `false` when below threshold (`pinterest-feed.js:50-54`) | Inspect Swiper config in console | Reduce slides-per-view or fetch more pins |
| Load-more returns duplicates | Cursor (`bookmark`) lost between requests — happens if cache TTL expired between page 1 and page 2 | Inspect transient; check AJAX response payload | Increase cache TTL, or wait for full cache rebuild |
| Rewrite returns 404 on `/eael-pinterest-auth/` | Permalinks were flushed without re-saving Settings → Permalinks; auto-flush only runs when `eael_pinterest_rewrite_version` differs from `'v1'` | Delete the option, reload `/wp-admin` once | Or bump the version string in `Traits/Pinterest_Feed.php:35` to re-trigger flush |

## Known Limitations

- **OAuth state binding** — nonce is user-bound, so a different admin can't complete the flow the original admin started. Intentional (CSRF defence) but it does surprise teams that share Pinterest setup duties.
- **Token endpoint timeout of 60s** is wide for a synchronous HTTP call. Acceptable for an admin-initiated OAuth exchange; flag if reused from a render path.
- **Pin/board fetch timeouts of 30s** are at the upper end of [`.claude/rules/external-api-integrations.md`](../../.claude/rules/external-api-integrations.md) § Transport (≤30s recommended). Acceptable but slow Pinterest responses block the AJAX request that long.
- **No stale-cache-on-error fallback** — when a `wp_remote_*` returns `is_wp_error` or non-200, `fetch_pinterest_data` / `fetch_pinterest_boards` return early and the page renders with the existing cache only if one already exists. There is no explicit "serve last-good payload" branch on hard failure.
- **AJAX load-more is `_nopriv`-registered.** Audit `pinterest_feed_load_more()` for what it echoes — any post/widget/board identifier coming from the client is operating without a capability check. The OAuth token itself is server-side and not echoed back; verify the response body before adding any new field.
- **Legacy per-widget `eael_pinterest_feed_access_token` setting** is still read from `_elementor_data` via regex (`Elements:175`). Once all known sites are confirmed migrated to site-wide OAuth, this fallback can be removed.
- **Featured Slider forces 1 slide per view** at render time (`Elements:2686-2688`) regardless of the user's stored `slides_desktop` / `_tablet` / `_mobile`. Switching from layout-4 to layout-5 silently overrides those values.
- **Boards layout does not support layouts 2/3/5** — pin-specific styles wouldn't render meaningfully against board card data. Switching `feed_type` to `boards` and then back will preserve the previous pin layout value, but inverse drift (boards-layout → pins-layout) is governed by a separate control.
- **No retry/backoff** on Pinterest API errors. Cron token refresh failure reschedules once an hour (`Traits:322`); data-fetch failures rely on whatever the next page load attempts.
- **No request id / response header logging** for upstream debugging. When the API behaves unexpectedly, the only signal is the WP_Error message and HTTP status.

## Cross-References

- Architecture: [`docs/architecture/external-api-integrations.md`](../architecture/external-api-integrations.md) — outbound HTTP surface
- Shared pattern: [`_patterns.md`](_patterns.md) § "External-API widget pattern"
- Rule: [`.claude/rules/external-api-integrations.md`](../../.claude/rules/external-api-integrations.md)
- Rule: [`.claude/rules/widget-development.md`](../../.claude/rules/widget-development.md) — dashboard registration two-step
- Pro/Lite bridge: Lite dashboard entry at `essential-addons-for-elementor-lite/includes/Traits/Admin.php:1347`; Pro default-enabled in `includes/Traits/Core.php:55`
