admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( AffKeeper_Ajax::NONCE ), 'canManage' => current_user_can( 'manage_options' ), 'panelBase' => AffKeeper_API::base_url(), // In local (free) mode the plugin edits links here in WP; when connected, editing an // existing link happens in the full panel instead. 'local' => ! $connected, 'localHost' => wp_parse_url( home_url(), PHP_URL_HOST ), 'localPrefix' => AffKeeper_Local::prefix(), 'i18n' => array( 'deleteTitle' => __( 'Delete this link?', 'affkeeper' ), 'confirmDelete' => __( 'The short URL will stop working. This cannot be undone.', 'affkeeper' ), 'deleteConfirm' => __( 'Delete', 'affkeeper' ), 'cancel' => __( 'Cancel', 'affkeeper' ), 'copied' => __( 'Copied', 'affkeeper' ), 'copyShortcode' => __( 'Copy shortcode', 'affkeeper' ), 'copiedToast' => __( 'Shortcode copied to clipboard', 'affkeeper' ), 'autoSlug' => __( 'auto', 'affkeeper' ), 'saving' => __( 'Saving…', 'affkeeper' ), 'genericError' => __( 'Something went wrong.', 'affkeeper' ), 'addTitle' => __( 'Add link', 'affkeeper' ), 'editTitle' => __( 'Edit link', 'affkeeper' ), 'createBtn' => __( 'Create link', 'affkeeper' ), 'saveBtn' => __( 'Save changes', 'affkeeper' ), /* translators: 1: first row number, 2: last row number, 3: total rows. */ 'pagerOf' => __( '%1$s–%2$s of %3$s', 'affkeeper' ), 'slugLabelNew' => __( 'Custom slug (optional)', 'affkeeper' ), 'slugLabelEdit' => __( 'Slug', 'affkeeper' ), ), ) ); } /** * Human-readable label + CSS modifier for an availability status. * * @param string $status Raw status. * @return array [ label, modifier ] */ private function badge( $status ) { switch ( $status ) { case 'available': return array( __( 'In stock', 'affkeeper' ), 'ok' ); case 'low_stock': return array( __( 'Low stock', 'affkeeper' ), 'low' ); case 'partial': return array( __( 'Partly available', 'affkeeper' ), 'low' ); case 'out_of_stock': return array( __( 'Out of stock', 'affkeeper' ), 'out' ); case 'gone': return array( __( 'Removed', 'affkeeper' ), 'out' ); case 'error': case 'unknown': return array( __( 'Unknown', 'affkeeper' ), 'muted' ); default: return array( '—', 'muted' ); } } /** * Label + CSS modifier for a redirect link's liveness status. * * @param string|null $status Health status from the API. * @return array [ label, modifier ] */ private function health_badge( $status ) { switch ( $status ) { case 'ok': return array( __( 'Live', 'affkeeper' ), 'ok' ); case 'broken': return array( __( 'Broken', 'affkeeper' ), 'out' ); case 'unreachable': return array( __( 'Unreachable', 'affkeeper' ), 'out' ); case 'blocked': return array( __( 'Blocked', 'affkeeper' ), 'muted' ); default: return array( __( 'Not checked', 'affkeeper' ), 'muted' ); } } public function render() { if ( ! current_user_can( 'edit_posts' ) ) { return; } $can_manage = current_user_can( 'manage_options' ); $connected = AffKeeper_API::is_configured(); // Pull the link list from the connected account, or from the local (free-tier) store. if ( $connected ) { $map = AffKeeper_API::link_map(); if ( is_wp_error( $map ) ) { echo ''; return; } // Downward sync leg: apply panel-side edits to the local redirects right now, so // opening this screen IS the "apply immediately" the panel promises. Re-fetch when // something changed so the list shows the applied state, not the stale one. if ( $can_manage && AffKeeper_Sync::pull_updates( $map ) ) { AffKeeper_API::flush_cache(); $map = AffKeeper_API::link_map(); if ( is_wp_error( $map ) ) { $map = array(); } } $map = $this->reconcile_local( $map ); // Local links that never made it to the account (plan limit, failed push) must not // vanish from this list — they still redirect. Shown with a "Not monitored" pill. $local30 = AffKeeper_Local::clicks_30d_map(); foreach ( AffKeeper_Local::all() as $ll ) { if ( ! empty( $ll->remote_id ) ) { continue; } $row = AffKeeper_Local::to_row( $ll ); $row['clicks_30d'] = isset( $local30[ (int) $ll->id ] ) ? $local30[ (int) $ll->id ] : 0; $row['is_local'] = true; $row['unsynced'] = true; $row['local_id'] = (string) $ll->id; $row['copy_slug'] = $ll->slug; $row['id'] = ''; $map[ 'local:' . $ll->id ] = $row; } } else { $map = AffKeeper_Local::link_map(); } echo ''; } /** * Upsell strip shown in local (free) mode: connect a free account to unlock monitoring. */ private function connect_banner() { $url = admin_url( 'admin.php?page=' . AffKeeper_Settings::MENU_SLUG ); echo '
'; echo '
'; echo '' . esc_html__( 'Get told before you lose a sale.', 'affkeeper' ) . ' '; echo esc_html__( 'Connect a free account to monitor these links — you\'ll be emailed when an affiliate product goes out of stock or a redirect breaks, and you get full click stats (charts, countries, trends).', 'affkeeper' ); echo '
'; echo '' . esc_html__( 'Connect account', 'affkeeper' ) . ''; echo '
'; } /** * For links synced from this WP install (source='wordpress'), swap the panel's short URL for the * real local one: the redirect + clicks live here on the user's site, the account only monitors. * The row then shows one truth — local link + local clicks + panel monitoring — no duplicate. * * @param array $map slug => API link data. * @return array */ private function reconcile_local( $map ) { $local30 = AffKeeper_Local::clicks_30d_map(); $by_remote = array(); foreach ( AffKeeper_Local::all() as $ll ) { if ( ! empty( $ll->remote_id ) ) { $by_remote[ $ll->remote_id ] = $ll; } } foreach ( $map as $k => $l ) { if ( ( isset( $l['source'] ) ? $l['source'] : 'panel' ) !== 'wordpress' ) { continue; } $l['is_local'] = true; $rid = isset( $l['id'] ) ? $l['id'] : ''; if ( isset( $by_remote[ $rid ] ) ) { $ll = $by_remote[ $rid ]; $l['local_id'] = (string) $ll->id; $l['short_url'] = AffKeeper_Local::short_url( $ll ); $l['short_display'] = AffKeeper_Local::prefix() . '/' . $ll->slug; $l['copy_slug'] = $ll->slug; $l['clicks'] = (int) $ll->clicks; // clicks_30d comes from the API untouched — the panel's windowed, bot-filtered // number is the one truth both UIs display. } elseif ( ! empty( $l['external_url'] ) ) { // Synced from a different install (or the local row is gone): use the stored URL. $l['short_url'] = $l['external_url']; $path = (string) wp_parse_url( $l['external_url'], PHP_URL_PATH ); $l['short_display'] = ltrim( $path, '/' ); $l['copy_slug'] = basename( $path ); } $map[ $k ] = $l; } return $map; } /** * Clicks shown everywhere on this screen: the last-30-days window. Falls back to the lifetime * total only when no windowed number exists (clicks_30d null/absent — e.g. an older panel). * * @param array $l Row-shaped link data. * @return int */ private function clicks_30d( $l ) { if ( array_key_exists( 'clicks_30d', $l ) && null !== $l['clicks_30d'] ) { return (int) $l['clicks_30d']; } return isset( $l['clicks'] ) ? (int) $l['clicks'] : 0; } private function tile( $label, $value, $mod ) { echo '
'; echo '' . esc_html( $value ) . ''; echo '' . esc_html( $label ) . ''; echo '
'; } /** * @param string $slug Link slug (map key). * @param array $link Row-shaped link data. * @param bool $connected Whether an account is connected (shows monitoring + panel edit). */ /** * Allowed markup for the tiny inline SVG icons this screen echoes through wp_kses() — * just the shapes the icons actually use. */ private const SVG_KSES = array( 'svg' => array( 'class' => true, 'viewbox' => true, 'aria-hidden' => true, 'focusable' => true, 'xmlns' => true, 'width' => true, 'height' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true, ), 'path' => array( 'fill' => true, 'd' => true, ), 'rect' => array( 'x' => true, 'y' => true, 'width' => true, 'height' => true, 'rx' => true, ), 'polyline' => array( 'points' => true ), 'line' => array( 'x1' => true, 'y1' => true, 'x2' => true, 'y2' => true, ), 'circle' => array( 'cx' => true, 'cy' => true, 'r' => true, ), ); /** * Destination URL shortened for the list: scheme stripped, tracking query collapsed to "?…", * over-long paths cut in the MIDDLE so the tail (product id / filename) stays recognisable. * The full URL always lives in the anchor's href and tooltip. * * @param string $url Destination URL. * @return string */ private function dest_display( $url ) { $disp = preg_replace( '#^https?://#', '', (string) $url ); $q = strpos( $disp, '?' ); if ( false !== $q ) { $disp = substr( $disp, 0, $q ) . '?…'; } if ( function_exists( 'mb_strlen' ) ? mb_strlen( $disp ) > 64 : strlen( $disp ) > 64 ) { $disp = ( function_exists( 'mb_substr' ) ? mb_substr( $disp, 0, 42 ) . '…' . mb_substr( $disp, -18 ) : substr( $disp, 0, 42 ) . '…' . substr( $disp, -18 ) ); } return $disp; } private function row( $slug, $link, $connected ) { $is_aff = ! empty( $link['is_affiliate'] ); $status = isset( $link['availability']['status'] ) ? $link['availability']['status'] : ''; list( $label, $mod ) = $this->badge( $status ); $display = isset( $link['short_display'] ) ? $link['short_display'] : $slug; $href = isset( $link['short_url'] ) ? $link['short_url'] : ''; $product = isset( $link['product_title'] ) ? $link['product_title'] : ''; $dest = isset( $link['destination_url'] ) ? (string) $link['destination_url'] : ''; $stock = isset( $link['availability']['stock'] ) ? $link['availability']['stock'] : null; $clicks = $this->clicks_30d( $link ); $health = isset( $link['health']['status'] ) ? $link['health']['status'] : null; // A link synced up from this WP install: its shortcode/redirect use the LOCAL slug, and we // mark it so the user sees it redirects from their own site (the account only monitors it). $copy_slug = isset( $link['copy_slug'] ) ? $link['copy_slug'] : $slug; $is_local = ! empty( $link['is_local'] ); // "Needs attention" = an affiliate product that's hard to buy, OR a redirect that's dead. $attn = $is_aff ? in_array( $status, array( 'gone', 'out_of_stock', 'low_stock', 'partial' ), true ) : in_array( $health, array( 'broken', 'unreachable' ), true ); if ( $attn ) { $group = 'attention'; } elseif ( ( $is_aff && 'available' === $status ) || ( ! $is_aff && 'ok' === $health ) ) { $group = 'ok'; } else { $group = 'other'; } // Everything JS needs for filtering, sorting and the edit modal is stashed as data-* on the row. printf( '', esc_attr( $group ), $is_aff ? 'aff' : 'redir', esc_attr( strtolower( $product . ' ' . $display . ' ' . $slug ) ), esc_attr( isset( $link['id'] ) ? $link['id'] : '' ), // For a link that lives HERE, the edit modal must prefill the LOCAL slug — the panel's // internal slug would silently rename the local redirect on a no-op save. esc_attr( $is_local ? $copy_slug : $slug ), esc_attr( $is_local ? $copy_slug : ( isset( $link['custom_slug'] ) && $link['custom_slug'] ? $link['custom_slug'] : '' ) ), esc_attr( $display ), esc_attr( isset( $link['destination_url'] ) ? $link['destination_url'] : '' ), esc_attr( isset( $link['source_url'] ) ? (string) $link['source_url'] : '' ), $is_aff ? '1' : '0', ! empty( $link['is_active'] ) ? '1' : '0', absint( $clicks ), // Unix timestamp: API rows are ISO strings, local rows MySQL datetimes — string // comparison interleaves them wrongly, numbers sort clean. esc_attr( (string) max( 0, (int) strtotime( isset( $link['created_at'] ) ? (string) $link['created_at'] : '' ) ) ), esc_attr( strtolower( $product ? $product : $display ) ), esc_attr( $product ), esc_attr( isset( $link['local_id'] ) ? $link['local_id'] : '' ), esc_attr( isset( $link['fallback_url'] ) ? (string) $link['fallback_url'] : '' ), // Panel-native links are inherently monitored; local rows carry their toggle state. esc_attr( $is_local ? ( ! isset( $link['monitored'] ) || $link['monitored'] ? '1' : '0' ) : '1' ), esc_attr( isset( $link['short_url'] ) ? (string) $link['short_url'] : '' ) ); // Short link + copy-shortcode icon + a type chip (Affiliate is monitored; Redirect is plain). $type_cls = $is_aff ? 'aff' : 'redir'; $type_label = $is_aff ? __( 'Affiliate', 'affkeeper' ) : __( 'Redirect', 'affkeeper' ); $copy_ic = ''; $copy_ok_ic = ''; echo '
' . '' . esc_html( $display ) . '' . '' . '' . esc_html( $type_label ) . '' . ( $is_local ? '' . esc_html__( 'On your site', 'affkeeper' ) . '' // Panel-hosted twin of the "On your site" chip: the AffKeeper mark + name, so the // two homes of a link are told apart by a label, not just by colour. : '' ) . '
' // Where the short link actually points — otherwise you'd have to open Edit to find out. // The arrow is a plain marker OUTSIDE the anchor, so it never underlines or reacts to hover. . ( $dest ? '' . esc_html( $this->dest_display( $dest ) ) . '' : '' ) . ''; // Ellipsised in CSS — marketplace titles run to hundreds of characters; the full name sits // in the tooltip (and data-search still matches the whole thing). echo '' . ( $product ? '' . esc_html( $product ) . '' : '—' ) . ''; if ( $connected ) { if ( ! empty( $link['unsynced'] ) ) { // Local link the account can't monitor (plan limit / failed push): say so instead // of a misleading "Unknown" — the redirect itself works fine. echo '' . esc_html__( 'Not monitored', 'affkeeper' ) . ''; } elseif ( $is_aff ) { echo '' . esc_html( $label ) . ''; // Show the count whenever there is stock; amber "N left" when it's a warning // (low/partial), muted "N in stock" otherwise. Skip it for out-of-stock/removed (0). if ( null !== $stock && '' !== $stock && (int) $stock > 0 && ! in_array( $status, array( 'out_of_stock', 'gone' ), true ) ) { $is_warn = in_array( $status, array( 'low_stock', 'partial' ), true ); $text = $is_warn /* translators: %s: formatted number of units left. */ ? sprintf( __( '%s left', 'affkeeper' ), number_format_i18n( (int) $stock ) ) /* translators: %s: formatted number of units in stock. */ : sprintf( __( '%s in stock', 'affkeeper' ), number_format_i18n( (int) $stock ) ); echo '' . esc_html( $text ) . ''; } echo ''; } else { // Redirect link: show destination liveness instead of product availability. list( $hlabel, $hmod ) = $this->health_badge( $health ); echo '' . esc_html( $hlabel ) . ''; } } echo '' . esc_html( number_format_i18n( $clicks ) ) . ''; echo ''; $detail_id = isset( $link['id'] ) ? $link['id'] : ''; $local_id = isset( $link['local_id'] ) ? $link['local_id'] : ''; if ( $connected && '' !== $detail_id ) { // Per-row shortcut to THIS link's full stats (clicks chart, countries, history) in the panel. $stats_url = AffKeeper_API::base_url() . '/links/' . rawurlencode( $detail_id ); echo ''; } else { // Same icon, muted — the affordance is always there; the tooltip explains the unlock. echo ''; } if ( $connected && '' !== $local_id && current_user_can( 'manage_options' ) ) { // Synced link that lives here: the redirect is local, so edit it in place (the change is // mirrored to the account's monitoring). Editing it in the panel would leave the real // redirect stale, so we don't send it there. echo ''; } elseif ( $connected && '' !== $detail_id && current_user_can( 'manage_options' ) ) { // Panel-hosted link: destination, slug alias and the affiliate flag are all editable // right here (the update goes through the account's API); the panel link next to it // covers the service-side extras — variants, monitoring settings, full stats. echo ''; } elseif ( $connected ) { // A panel-native link: editing (slug regeneration, variants, price history, monitoring // settings) lives in the full panel, so WP links out instead of half-duplicating it. if ( '' !== $detail_id ) { $detail_url = AffKeeper_API::base_url() . '/links/' . rawurlencode( $detail_id ); echo '' . esc_html__( 'Edit', 'affkeeper' ) . ' '; } } elseif ( current_user_can( 'manage_options' ) ) { // Local (free) mode: there's no panel, so edit right here. echo ''; } if ( current_user_can( 'manage_options' ) ) { echo ''; } echo ''; echo ''; } /** * The shared add/edit dialog (populated by JS). Only rendered for managers. * * @param bool $can_manage Whether the current user may write. * @param bool $connected Connected mode hides the local-only fields (product name) and shows the * branded-domain picker; local mode is the reverse. */ private function modal( $can_manage, $connected ) { if ( ! $can_manage ) { return; } $intro = $connected ? __( 'Create a short link. To edit an existing one, open it in the full panel.', 'affkeeper' ) : __( 'Create a short link on your own domain. It works right away — no account needed.', 'affkeeper' ); ?>