//, with what is in stock, what sold and for how much, and the notify * form; and // listing them all. They hang off one real WordPress page, so every * theme shows them as it shows a page; * - a sold item's page, once sold a while, redirects to its model page (else its * category) or is taken out of search results, as the shop chooses; * - the condition goes into titles and descriptions where the product has none of its * own, and Yoast and Rank Math get it as a variable for their templates; * - sitemaps: model pages in, aged-out sold items out (WordPress's own, Yoast, Rank Math). * * Off until the shop turns it on, like every module. */ if (!defined('ABSPATH')) { exit; } require_once __DIR__ . '/seo-calc.php'; const USEDKIT_MODEL_YEAR = 365 * DAY_IN_SECONDS; add_filter('usedkit_default_settings', function ($d) { return $d + array('seo_models' => 'no', 'seo_base' => usedkit_seo_base(get_locale()), 'seo_prices' => 'yes', 'seo_sold' => 'keep', 'seo_sold_days' => 30, 'seo_meta' => 'no'); }); add_filter('usedkit_save_settings', function ($out, $post) { $base = sanitize_title($post['seo_base'] ?? '') ?: usedkit_seo_base(get_locale()); return $out + array( 'seo_models' => !empty($post['seo_models']) ? 'yes' : 'no', 'seo_base' => $base, 'seo_prices' => !empty($post['seo_prices']) ? 'yes' : 'no', 'seo_sold' => in_array($post['seo_sold'] ?? '', array('keep', 'noindex', 'redirect'), true) ? $post['seo_sold'] : 'keep', 'seo_sold_days' => max(0, min(3650, absint($post['seo_sold_days'] ?? 30))), 'seo_meta' => !empty($post['seo_meta']) ? 'yes' : 'no', ); }, 10, 2); add_action('usedkit_settings_rows', function ($s) { $page = usedkit_models_page_id(); $fates = array('keep' => __('stay as they are', 'usedkit'), 'noindex' => __('leave search results', 'usedkit'), 'redirect' => __('redirect to the model page', 'usedkit')); ?>
·

'page', 'post_status' => 'publish', 'post_name' => $base, /* translators: page title for the list of used models */ 'post_title' => __('Used, by model', 'usedkit'), 'post_content' => '[usedkit_models]')); update_option('usedkit_models_page', (int) $id); } elseif (get_post_field('post_name', $id) !== $base) { wp_update_post(array('ID' => $id, 'post_name' => $base)); } usedkit_model_rules(); flush_rewrite_rules(false); usedkit_model_index_reset(); }, 10, 2); function usedkit_model_rules() { $s = usedkit_seo(); if ($s['seo_models'] !== 'yes' || !($id = usedkit_models_page_id())) { return; } $path = get_page_uri($id); add_rewrite_rule('^' . preg_quote($path, '#') . '/([^/]+)/?$', 'index.php?page_id=' . $id . '&usedkit_model=$matches[1]', 'top'); } add_action('init', 'usedkit_model_rules'); add_filter('query_vars', function ($v) { $v[] = 'usedkit_model'; return $v; }); /** A model page's address. */ function usedkit_model_url($slug) { $id = usedkit_models_page_id(); if (!$id) { return ''; } // Plain permalinks (?page_id=28) have no path to add to: the model goes as a query var. if (!get_option('permalink_structure')) { return add_query_arg('usedkit_model', rawurlencode($slug), get_permalink($id)); } return trailingslashit(get_permalink($id)) . $slug . '/'; } /** * Every model with a page: [slug => ['key', 'label', 'in' => [ids], 'sold' => [sale, ...]]], * in stock and sold in the last year. Rebuilt when stock changes (or twice a day); a * visitor's page view does not wait on the service for keys. */ function usedkit_model_index($ask_service = false) { $index = get_transient('usedkit_model_index'); if (is_array($index) && !$ask_service) { return $index; } $in = wc_get_products(array('limit' => 3000, 'status' => 'publish', 'stock_status' => 'instock', 'visibility' => 'catalog')); $keys = usedkit_model_keys($in, $ask_service); $groups = array(); foreach ($in as $p) { if (($k = $keys[$p->get_id()] ?? '') !== '') { $groups[$k]['in'][] = $p; } } $since = time() - USEDKIT_MODEL_YEAR; foreach (usedkit_sales_index() as $k => $sales) { foreach ($sales as $sale) { if ($sale['at'] >= $since) { $groups[$k]['sold'][] = $sale; } } } $index = array(); foreach ($groups as $key => $g) { $g += array('in' => array(), 'sold' => array()); if (!usedkit_model_eligible(count($g['in']), count($g['sold']))) { continue; } $titles = array(); $label = ''; foreach ($g['in'] as $p) { $titles[] = $p->get_name(); $label = $label ?: (string) $p->get_meta('_usedkit_model_label'); } foreach ($g['sold'] as $sale) { $titles[] = get_the_title($sale['id']); $label = $label ?: (string) get_post_meta($sale['id'], '_usedkit_model_label', true); } $label = usedkit_model_label($key, $label, $titles); $slug = usedkit_model_slug($label); if ($slug === '') { continue; } $ids = array_map(function ($p) { return $p->get_id(); }, $g['in']); if (isset($index[$slug])) { // The same name twice is the same model, keyed once by the service and once by // its words (an item the service never saw): one page, the service's key. $m = &$index[$slug]; $m['in'] = array_merge($m['in'], $ids); $m['sold'] = array_merge($m['sold'], $g['sold']); $m['keys'][] = $key; if (strpos($m['key'], 'plain:') === 0) { $m['key'] = $key; } unset($m); continue; } $index[$slug] = array('key' => $key, 'keys' => array($key), 'label' => $label, 'in' => $ids, 'sold' => $g['sold']); } foreach ($index as &$m) { usort($m['sold'], function ($a, $b) { return $b['at'] <=> $a['at']; }); } unset($m); ksort($index); set_transient('usedkit_model_index', $index, 12 * HOUR_IN_SECONDS); return $index; } function usedkit_model_index_reset() { delete_transient('usedkit_model_index'); } add_action('woocommerce_product_set_stock_status', 'usedkit_model_index_reset'); add_action('usedkit_sold_price_recorded', 'usedkit_model_index_reset'); add_action('save_post_product', 'usedkit_model_index_reset'); /* Twice a day with the service's keys, so a model is known by its name, not its words. */ add_action('usedkit_model_index_refresh', function () { if (usedkit_seo()['seo_models'] === 'yes') { usedkit_model_index(true); } }); add_action('init', function () { if (!wp_next_scheduled('usedkit_model_index_refresh')) { wp_schedule_event(time() + HOUR_IN_SECONDS, 'twicedaily', 'usedkit_model_index_refresh'); } }); /** The model a product is, when it has a page: [slug, model] or null. */ function usedkit_product_model($product) { if (usedkit_seo()['seo_models'] !== 'yes') { return null; } $key = usedkit_model_keys(array($product), false)[$product->get_id()] ?? ''; foreach (usedkit_model_index() as $slug => $m) { if (in_array($key, $m['keys'] ?? array($m['key']), true)) { return array($slug, $m); } } return null; } /** The model this request is for, when it is a model page. */ function usedkit_current_model() { static $found = false; if ($found !== false) { return $found; } $slug = sanitize_title((string) get_query_var('usedkit_model')); $found = null; if ($slug !== '' && usedkit_seo()['seo_models'] === 'yes') { $index = usedkit_model_index(); $found = isset($index[$slug]) ? array($slug, $index[$slug]) : null; } return $found; } /* An address for a model with no page (any more): not found. */ add_action('template_redirect', function () { if (get_query_var('usedkit_model') !== '' && !usedkit_current_model()) { global $wp_query; $wp_query->set_404(); status_header(404); } }); /** "Used Sony A7 III". */ function usedkit_model_title($m) { /* translators: %s: a model, e.g. Sony A7 III */ return sprintf(__('Used %s', 'usedkit'), $m['label']); } /** "3 for sale now, 410–520 €. 6 sold in the last year." */ function usedkit_model_summary($m) { $prices = array(); foreach ($m['in'] as $id) { if ($p = wc_get_product($id)) { $prices[] = (float) $p->get_price(); } } $out = array(); if ($prices) { $range = min($prices) === max($prices) ? usedkit_money(min($prices)) : usedkit_money(min($prices)) . '–' . usedkit_money(max($prices)); /* translators: 1: number for sale, 2: price or price range */ $out[] = sprintf(_n('%1$d for sale now, %2$s.', '%1$d for sale now, %2$s.', count($prices), 'usedkit'), count($prices), $range); } else { $out[] = __('None for sale right now.', 'usedkit'); } if ($m['sold']) { /* translators: %d: number sold */ $out[] = sprintf(_n('%d sold in the last year.', '%d sold in the last year.', count($m['sold']), 'usedkit'), count($m['sold'])); } return implode(' ', $out); } /* The page's title, in the page and in the browser tab. */ add_filter('the_title', function ($title, $id = 0) { if ($id && $id === usedkit_models_page_id() && in_the_loop() && ($cur = usedkit_current_model())) { return esc_html(usedkit_model_title($cur[1])); } return $title; }, 10, 2); add_filter('document_title_parts', function ($parts) { if ($cur = usedkit_current_model()) { $parts['title'] = usedkit_model_title($cur[1]); } return $parts; }); /* [usedkit_models]: the list of models, or one model when the address names one. */ add_shortcode('usedkit_models', function () { $cur = usedkit_current_model(); return wp_kses($cur ? usedkit_model_html($cur[0], $cur[1]) : usedkit_models_html(), usedkit_allowed_html()); }); function usedkit_models_html() { $index = usedkit_model_index(); if (!$index) { return '

' . esc_html__('Model pages appear here once you have sold or listed the same model twice.', 'usedkit') . '

'; } $out = ''; } function usedkit_model_html($slug, $m) { $s = usedkit_seo(); $out = '

' . esc_html(usedkit_model_summary($m)) . '

'; if ($m['in']) { $out .= '

' . esc_html__('For sale now', 'usedkit') . '

' . do_shortcode('[products ids="' . esc_attr(implode(',', $m['in'])) . '" columns="4" orderby="post__in"]'); } $out .= usedkit_form_html(null, $m['label'], $m['in'] ? __('Want to hear when another one comes in?', 'usedkit') : ''); if ($m['sold']) { $out .= '

' . esc_html__('Sold recently', 'usedkit') . '

    '; foreach (array_slice($m['sold'], 0, 12) as $sale) { $grade = $sale['grade'] && function_exists('usedkit_grade_names') ? (usedkit_grade_names()[$sale['grade']] ?? '') : ''; $bits = array_filter(array(date_i18n('F Y', $sale['at']), $grade, $s['seo_prices'] === 'yes' ? usedkit_money($sale['price']) : '')); $out .= '
  • ' . esc_html(implode(' · ', $bits)) . '
  • '; } $out .= '
'; } return $out . '
'; } /* Search engines: the model page's description, canonical address and structured data. */ function usedkit_model_description($m) { return usedkit_clip(usedkit_model_title($m) . '. ' . usedkit_model_summary($m) . ' ' . __('Leave your email to hear when the next one comes in.', 'usedkit')); } add_action('wp_head', function () { if (!($cur = usedkit_current_model())) { return; } list($slug, $m) = $cur; if (!usedkit_seo_plugin()) { printf('' . "\n", esc_attr(usedkit_model_description($m))); printf('' . "\n", esc_url(usedkit_model_url($slug))); } $offers = array(); $image = ''; foreach ($m['in'] as $id) { if ($p = wc_get_product($id)) { $offers[] = (float) $p->get_price(); $image = $image ?: (string) wp_get_attachment_image_url($p->get_image_id(), 'large'); } } if ($offers) { $data = array('@context' => 'https://schema.org', '@type' => 'Product', 'name' => $m['label'], 'url' => usedkit_model_url($slug), 'offers' => array( '@type' => 'AggregateOffer', 'priceCurrency' => get_woocommerce_currency(), 'lowPrice' => min($offers), 'highPrice' => max($offers), 'offerCount' => count($offers), 'availability' => 'https://schema.org/InStock', 'itemCondition' => 'https://schema.org/UsedCondition')); if ($image) { $data['image'] = $image; } echo '\n"; } }, 2); remove_action('wp_head', 'rel_canonical'); add_action('wp_head', function () { if (!usedkit_current_model()) { rel_canonical(); } }); /** Yoast SEO or Rank Math is in charge of titles, descriptions, canonicals and robots. */ function usedkit_seo_plugin() { return defined('WPSEO_VERSION') || defined('RANK_MATH_VERSION'); } add_filter('wpseo_title', function ($t) { return ($cur = usedkit_current_model()) ? usedkit_model_title($cur[1]) . ' - ' . get_bloginfo('name') : $t; }); add_filter('rank_math/frontend/title', function ($t) { return ($cur = usedkit_current_model()) ? usedkit_model_title($cur[1]) . ' - ' . get_bloginfo('name') : $t; }); foreach (array('wpseo_metadesc', 'rank_math/frontend/description') as $usedkit_hook) { add_filter($usedkit_hook, function ($d) { return ($cur = usedkit_current_model()) ? usedkit_model_description($cur[1]) : $d; }); } foreach (array('wpseo_canonical', 'rank_math/frontend/canonical') as $usedkit_hook) { add_filter($usedkit_hook, function ($c) { return ($cur = usedkit_current_model()) ? usedkit_model_url($cur[0]) : $c; }); } foreach (array('wpseo_opengraph_url') as $usedkit_hook) { add_filter($usedkit_hook, function ($u) { return ($cur = usedkit_current_model()) ? usedkit_model_url($cur[0]) : $u; }); } /* From an item's page to its model's: "All used Sony A7 III". */ add_action('woocommerce_single_product_summary', function () { global $product; if ($product && ($m = usedkit_product_model($product))) { printf('', esc_url(usedkit_model_url($m[0])), /* translators: %s: a model, e.g. Sony A7 III */ esc_html(sprintf(__('All used %s', 'usedkit'), $m[1]['label']))); } }, 42); /* ---- sold items' pages -------------------------------------------------------------------- */ /** What a sold product's page does now: keep, noindex or redirect. */ function usedkit_product_fate($product) { $s = usedkit_seo(); if (!$product || $product->is_in_stock()) { return 'keep'; } $sold = (int) $product->get_meta('_usedkit_sold_at'); return usedkit_sold_fate($sold, time(), (int) $s['seo_sold_days'], $s['seo_sold']); } add_action('template_redirect', function () { if (!function_exists('is_product') || !is_product() || current_user_can('edit_products')) { return; } $product = wc_get_product(get_queried_object_id()); if (usedkit_product_fate($product) !== 'redirect') { return; } $model = usedkit_seo()['seo_models'] === 'yes' ? usedkit_product_model($product) : null; $to = $model ? usedkit_model_url($model[0]) : ''; if (!$to) { $cats = $product->get_category_ids(); $link = $cats ? get_term_link((int) $cats[0], 'product_cat') : ''; $to = $link && !is_wp_error($link) ? $link : wc_get_page_permalink('shop'); } wp_safe_redirect($to, 301); exit; }); /** Pages to keep out of search results: a sold item past its time, under "noindex". */ function usedkit_noindex_here() { if (!function_exists('is_product') || !is_product()) { return false; } return usedkit_product_fate(wc_get_product(get_queried_object_id())) === 'noindex'; } add_filter('wp_robots', function ($robots) { if (usedkit_noindex_here()) { $robots['noindex'] = true; $robots['follow'] = true; unset($robots['index']); } return $robots; }); add_filter('wpseo_robots_array', function ($robots) { if (usedkit_noindex_here()) { $robots['index'] = 'noindex'; } return $robots; }); add_filter('rank_math/frontend/robots', function ($robots) { if (usedkit_noindex_here()) { $robots['index'] = 'noindex'; } return $robots; }); /** Sold items whose pages no longer belong in sitemaps (noindex or redirected). */ function usedkit_sitemap_excluded() { $s = usedkit_seo(); if ($s['seo_sold'] === 'keep') { return array(); } $ids = get_transient('usedkit_sitemap_excluded'); if (!is_array($ids)) { $ids = wc_get_products(array('limit' => -1, 'return' => 'ids', 'stock_status' => 'outofstock', 'meta_key' => '_usedkit_sold_at', 'meta_compare' => '<', 'meta_value' => time() - (int) $s['seo_sold_days'] * DAY_IN_SECONDS)); set_transient('usedkit_sitemap_excluded', $ids, 6 * HOUR_IN_SECONDS); } return array_map('intval', $ids); } add_action('woocommerce_product_set_stock_status', function () { delete_transient('usedkit_sitemap_excluded'); }); add_filter('wp_sitemaps_posts_query_args', function ($args, $type) { if ($type === 'product' && ($ex = usedkit_sitemap_excluded())) { $args['post__not_in'] = array_merge($args['post__not_in'] ?? array(), $ex); } return $args; }, 10, 2); add_filter('wpseo_exclude_from_sitemap_by_post_ids', function ($ids) { return array_merge((array) $ids, usedkit_sitemap_excluded()); }); add_filter('rank_math/sitemap/entry', function ($url, $type, $object) { if ($type === 'post' && isset($object->ID) && in_array((int) $object->ID, usedkit_sitemap_excluded(), true)) { return false; } return $url; }, 10, 3); /* ---- model pages in sitemaps ------------------------------------------------------------- */ /** The model pages' own sitemap, at /usedkit-models.xml: listed in robots.txt, and in the * sitemap index of Yoast or Rank Math when the shop uses one. */ function usedkit_models_sitemap_url() { return home_url('/usedkit-models.xml'); } add_action('init', function () { add_rewrite_rule('^usedkit-models\.xml$', 'index.php?usedkit_models_sitemap=1', 'top'); }); add_filter('query_vars', function ($v) { $v[] = 'usedkit_models_sitemap'; return $v; }); add_action('template_redirect', function () { if (!get_query_var('usedkit_models_sitemap')) { return; } if (usedkit_seo()['seo_models'] !== 'yes') { return; // WordPress answers 404 } header('Content-Type: application/xml; charset=UTF-8'); echo '' . "\n" . '' . "\n"; echo '' . esc_url(get_permalink(usedkit_models_page_id())) . "\n"; foreach (usedkit_model_index() as $slug => $m) { $latest = 0; foreach ($m['in'] as $id) { $latest = max($latest, (int) get_post_modified_time('U', true, $id)); } foreach ($m['sold'] as $sale) { $latest = max($latest, (int) $sale['at']); } echo '' . esc_url(usedkit_model_url($slug)) . '' . ($latest ? '' . esc_html(gmdate('c', $latest)) . '' : '') . "\n"; } echo "\n"; exit; }); /* WordPress would add a slash to the address; a sitemap's stays as it is. */ add_filter('redirect_canonical', function ($to) { return get_query_var('usedkit_models_sitemap') ? false : $to; }); add_filter('robots_txt', function ($txt) { if (usedkit_seo()['seo_models'] === 'yes') { $txt .= "\nSitemap: " . usedkit_models_sitemap_url() . "\n"; } return $txt; }); add_filter('wpseo_sitemap_index', function ($xml) { if (usedkit_seo()['seo_models'] === 'yes') { $xml .= '' . esc_url(usedkit_models_sitemap_url()) . ''; } return $xml; }); add_filter('rank_math/sitemap/index', function ($xml) { if (usedkit_seo()['seo_models'] === 'yes') { $xml .= '' . esc_url(usedkit_models_sitemap_url()) . ''; } return $xml; }, 11); /* ---- condition in titles and descriptions -------------------------------------------------- */ /** "Used, good condition": the grade's words for a title or a description. */ function usedkit_condition_phrase($product) { $g = function_exists('usedkit_condition') ? usedkit_condition($product) : ''; if ($g === '') { return ''; } $name = usedkit_grade_names()[$g] ?? ''; /* translators: %s: a condition grade, e.g. Good */ return $name !== '' ? sprintf(__('Used, %s', 'usedkit'), function_exists('mb_strtolower') ? mb_strtolower($name) : strtolower($name)) : ''; } /** An item's description for search results, from what Usedkit knows of it. */ function usedkit_product_description($product) { $included = function_exists('usedkit_split_list') ? usedkit_split_list($product->get_meta('_usedkit_included')) : array(); return usedkit_seo_description(array( 'grade' => usedkit_condition_phrase($product), 'note' => (string) $product->get_meta('_usedkit_condition_note'), /* translators: %s: what comes with the item, e.g. battery, charger */ 'included' => $included ? sprintf(__('Included: %s', 'usedkit'), implode(', ', $included)) : '', )); } function usedkit_seo_meta_on() { return usedkit_seo()['seo_meta'] === 'yes' && function_exists('is_product') && is_product(); } /* Without an SEO plugin: the title gets the condition, and the page a description. */ add_filter('document_title_parts', function ($parts) { if (usedkit_seo_meta_on() && !usedkit_seo_plugin() && ($product = wc_get_product(get_queried_object_id())) && ($c = usedkit_condition_phrase($product)) !== '') { $parts['title'] .= ' – ' . $c; } return $parts; }); add_action('wp_head', function () { if (!usedkit_seo_meta_on() || usedkit_seo_plugin()) { return; } $product = wc_get_product(get_queried_object_id()); $desc = $product ? (trim(wp_strip_all_tags($product->get_short_description())) ?: usedkit_product_description($product)) : ''; if ($desc !== '') { printf('' . "\n", esc_attr(usedkit_clip($desc))); } }, 3); /* With Yoast or Rank Math: their description where the product has one, ours where empty. */ foreach (array('wpseo_metadesc', 'rank_math/frontend/description') as $usedkit_hook) { add_filter($usedkit_hook, function ($d) { if (usedkit_seo_meta_on() && trim((string) $d) === '' && ($product = wc_get_product(get_queried_object_id()))) { return usedkit_product_description($product); } return $d; }, 20); } /* The variables for their templates: %%usedkit_condition%% and %usedkit_condition%. */ function usedkit_condition_var() { $product = function_exists('is_product') && is_product() ? wc_get_product(get_queried_object_id()) : null; return $product ? usedkit_condition_phrase($product) : ''; } add_action('wpseo_register_extra_replacements', function () { if (function_exists('wpseo_register_var_replacement')) { wpseo_register_var_replacement('%%usedkit_condition%%', 'usedkit_condition_var', 'advanced', __('The item\'s condition, e.g. "Used, good" (Usedkit)', 'usedkit')); } }); add_action('rank_math/vars/register_extra_replacements', function () { if (function_exists('rank_math_register_var_replacement')) { rank_math_register_var_replacement('usedkit_condition', array( 'name' => __('Condition (Usedkit)', 'usedkit'), 'description' => __('The item\'s condition, e.g. "Used, good"', 'usedkit'), 'variable' => 'usedkit_condition', 'example' => usedkit_condition_var()), 'usedkit_condition_var'); } });