`,
+ );
+ }
+ })
+ .always(function () {
+ setLoading(false);
+ });
+ }
+
+ openButton.on("click.directoristTeActivity", function () {
+ setDrawerOpen(true);
+ });
+
+ closeButtons.on("click.directoristTeActivity", function () {
+ setDrawerOpen(false);
+ });
+
+ filters.on("click.directoristTeActivity", function () {
+ activityType = $(this).attr("data-activity-filter") || "all";
+ filters.removeClass("is-active").attr("aria-pressed", "false");
+ $(this).addClass("is-active").attr("aria-pressed", "true");
+ loadActivity(true);
+ });
+
+ loadMoreButton.on("click.directoristTeActivity", function () {
+ if (hasMore) {
+ loadActivity(false);
+ }
+ });
+
+ page.on("directoristTeViewChange", function (event, target) {
+ if (target !== "dashboard" && !drawer.prop("hidden")) {
+ setDrawerOpen(false);
+ }
+ });
+
+ $(document).on("keydown.directoristTeActivity", function (event) {
+ if (drawer.prop("hidden")) {
+ return;
+ }
+
+ if (event.key === "Escape") {
+ event.preventDefault();
+ setDrawerOpen(false);
+ return;
+ }
+
+ if (event.key !== "Tab") {
+ return;
+ }
+
+ const focusable = activityFocusable();
+
+ if (!focusable.length) {
+ event.preventDefault();
+ return;
+ }
+
+ const first = focusable.first()[0];
+ const last = focusable.last()[0];
+
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault();
+ $(last).trigger("focus");
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault();
+ $(first).trigger("focus");
+ }
+ });
+
+ setDrawerOpen(false);
+ }
+
+ function setupRequiredExtensionsCompatibility() {
+ const legacyHash = "#atbdp-required-extensions-form";
+
+ function showRequiredExtensions() {
+ if (window.location.hash !== legacyHash) {
+ return;
+ }
+
+ const requiredFilter = $(
+ '.directorist-te-segmented button[data-filter-status="required"]',
+ ).first();
+ const legacyTarget = $("#atbdp-required-extensions-form").first();
+ const extensionTab = $(
+ '.directorist-te-tab[data-filter-type="extension"]',
+ ).first();
+ const isConnected = page.hasClass("directorist-te-page--connected");
+
+ if (isConnected) {
+ $('[data-directorist-te-view-target="addons"]')
+ .first()
+ .trigger("click");
+ $(".directorist-te-search-input").val("");
+ state.query = "";
+ extensionTab.trigger("click");
+ }
+
+ if (!requiredFilter.length) {
+ const fallbackTarget = legacyTarget.length
+ ? legacyTarget
+ : $(".directorist-te-connect").first();
+
+ if (!fallbackTarget.length) {
+ return;
+ }
+
+ window.setTimeout(function () {
+ fallbackTarget[0].scrollIntoView({
+ block: "start",
+ behavior: "auto",
+ });
+
+ fallbackTarget
+ .find('input[name="username"]')
+ .first()
+ .trigger("focus");
+
+ if (isConnected) {
+ extensionTab.trigger("focus");
+ }
+ }, 0);
+
+ return;
+ }
+
+ requiredFilter.trigger("click");
+
+ window.setTimeout(function () {
+ const scrollTarget = legacyTarget.length
+ ? legacyTarget[0]
+ : requiredFilter[0];
+
+ scrollTarget.scrollIntoView({
+ block: "start",
+ behavior: "auto",
+ });
+ requiredFilter.trigger("focus");
+ }, 100);
+ }
+
+ $(window).on(
+ "hashchange.directoristTeRequiredExtensions",
+ showRequiredExtensions,
+ );
+ showRequiredExtensions();
+ }
+
+ function setupDashboardRecommendations() {
+ const section = $("[data-directorist-te-recommendations]").first();
+
+ if (!section.length) {
+ return;
+ }
+
+ const groups = section.find("[data-recommendation-group]");
+ const previousButton = section.find("[data-recommendation-previous]");
+ const nextButton = section.find("[data-recommendation-next]");
+ const directorySelect = section.find(
+ "[data-recommendation-directory-select]",
+ );
+ const autoplayButton = section.find("[data-recommendation-autoplay]");
+ const heading = section.find("[data-recommendation-heading]");
+ const description = section.find("[data-recommendation-description]");
+ const liveRegion = section.find("[data-recommendation-live]");
+ const headingTemplate =
+ section.attr("data-heading-template") || "Recommended for %s";
+ const directoryIds = groups
+ .map(function () {
+ return String($(this).attr("data-recommendation-group") || "");
+ })
+ .get()
+ .filter(Boolean);
+ const cardOffsets = {};
+ const paintedDirectories = {};
+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
+ const rotationInterval = Math.max(
+ 4000,
+ Number(section.attr("data-rotation-interval")) || 6000,
+ );
+ let selectedId =
+ String(section.attr("data-default-directory") || "") || directoryIds[0];
+ let autoplayTimer = 0;
+ let autoplayPaused = reducedMotion.matches;
+
+ function getGroup(directoryId) {
+ return groups.filter(function () {
+ return (
+ String($(this).attr("data-recommendation-group")) ===
+ String(directoryId)
+ );
+ });
+ }
+
+ function paintCards(group, directoryId, advance) {
+ const cards = group.find("[data-recommendation-card]");
+ const cardCount = cards.length;
+ let offset = cardOffsets[directoryId] || 0;
+
+ cards.prop("hidden", true);
+
+ if (!cardCount) {
+ return;
+ }
+
+ if (advance && paintedDirectories[directoryId] && cardCount > 3) {
+ offset = (offset + 3) % cardCount;
+ cardOffsets[directoryId] = offset;
+ }
+
+ paintedDirectories[directoryId] = true;
+
+ for (let index = 0; index < Math.min(3, cardCount); index += 1) {
+ cards.eq((offset + index) % cardCount).prop("hidden", false);
+ }
+ }
+
+ function selectDirectory(directoryId, announce, advanceCards) {
+ const group = getGroup(directoryId);
+
+ if (!group.length) {
+ return;
+ }
+
+ selectedId = String(directoryId);
+
+ groups.prop("hidden", true);
+ group.prop("hidden", false);
+ paintCards(group, selectedId, advanceCards);
+
+ const name =
+ String(group.attr("data-directory-name") || "").trim() ||
+ "your directory";
+ const summary = String(group.attr("data-directory-description") || "");
+
+ heading.text(headingTemplate.replace("%s", name));
+ description.text(summary);
+ directorySelect.val(selectedId);
+
+ if (announce) {
+ liveRegion.text(`${name} recommendations shown.`);
+ }
+ }
+
+ function moveDirectory(step, announce) {
+ if (directoryIds.length > 1) {
+ const currentIndex = directoryIds.indexOf(selectedId);
+ const nextIndex =
+ (currentIndex + step + directoryIds.length) % directoryIds.length;
+
+ selectDirectory(directoryIds[nextIndex], announce, true);
+ return;
+ }
+
+ selectDirectory(selectedId, announce, true);
+ }
+
+ function stopAutoplay() {
+ window.clearInterval(autoplayTimer);
+ autoplayTimer = 0;
+ }
+
+ function canAutoplay() {
+ return (
+ directoryIds.length > 1 ||
+ groups.filter(function () {
+ return $(this).find("[data-recommendation-card]").length > 3;
+ }).length > 0
+ );
+ }
+
+ function isInteracting() {
+ return (
+ section.is(":hover") ||
+ section.find(":focus").length > 0 ||
+ document.hidden
+ );
+ }
+
+ function startAutoplay() {
+ stopAutoplay();
+
+ if (!canAutoplay() || autoplayPaused || isInteracting()) {
+ return;
+ }
+
+ autoplayTimer = window.setInterval(function () {
+ moveDirectory(1, false);
+ }, rotationInterval);
+ }
+
+ function updateAutoplayButton() {
+ const label = autoplayPaused
+ ? "Resume automatic recommendations"
+ : "Pause automatic recommendations";
+
+ autoplayButton
+ .attr("aria-pressed", autoplayPaused ? "true" : "false")
+ .attr("aria-label", label)
+ .attr("title", label)
+ .find("i")
+ .attr("class", autoplayPaused ? "la la-play" : "la la-pause");
+ }
+
+ if (!directoryIds.includes(selectedId)) {
+ selectedId = directoryIds[0];
+ }
+
+ const hasMultipleDirectories = directoryIds.length > 1;
+
+ previousButton.prop("hidden", !hasMultipleDirectories);
+ nextButton.prop("hidden", !hasMultipleDirectories);
+ directorySelect.prop("hidden", !hasMultipleDirectories);
+ autoplayButton.prop("hidden", !canAutoplay());
+ selectDirectory(selectedId, false, false);
+ updateAutoplayButton();
+ startAutoplay();
+
+ previousButton.on("click.directoristTeRecommendations", function () {
+ moveDirectory(-1, true);
+ startAutoplay();
+ });
+
+ nextButton.on("click.directoristTeRecommendations", function () {
+ moveDirectory(1, true);
+ startAutoplay();
+ });
+
+ directorySelect.on("change.directoristTeRecommendations", function () {
+ selectDirectory(String($(this).val() || ""), true, true);
+ startAutoplay();
+ });
+
+ autoplayButton.on("click.directoristTeRecommendations", function () {
+ autoplayPaused = !autoplayPaused;
+ updateAutoplayButton();
+ startAutoplay();
+ });
+
+ section.on(
+ "mouseenter.directoristTeRecommendations focusin.directoristTeRecommendations",
+ stopAutoplay,
+ );
+ section.on(
+ "mouseleave.directoristTeRecommendations focusout.directoristTeRecommendations",
+ function () {
+ window.setTimeout(startAutoplay, 0);
+ },
+ );
+
+ $(document).on(
+ "visibilitychange.directoristTeRecommendations",
+ startAutoplay,
+ );
+
+ const handleMotionPreference = function (event) {
+ autoplayPaused = event.matches;
+ updateAutoplayButton();
+ startAutoplay();
+ };
+
+ if (reducedMotion.addEventListener) {
+ reducedMotion.addEventListener("change", handleMotionPreference);
+ } else {
+ reducedMotion.addListener(handleMotionPreference);
+ }
+ }
+
+ function setupDashboardQuickActions() {
+ const card = $("[data-directorist-te-quick-actions]").first();
+ const directorySelect = card.find("[data-quick-actions-directory-select]");
+
+ if (!card.length || !directorySelect.length) {
+ return;
+ }
+
+ const storageKey = "directorist_te_quick_actions_directory";
+ const liveRegion = card.find("[data-quick-actions-live]");
+ const actionAttributes = {
+ "add-listing": {
+ url: "data-add-listing-url",
+ ariaLabel: "data-add-listing-aria-label",
+ description: "data-add-listing-description",
+ },
+ "manage-categories": {
+ url: "data-manage-categories-url",
+ ariaLabel: "data-manage-categories-aria-label",
+ },
+ "listing-layout": {
+ url: "data-listing-layout-url",
+ ariaLabel: "data-listing-layout-aria-label",
+ },
+ "submission-form": {
+ url: "data-submission-form-url",
+ ariaLabel: "data-submission-form-aria-label",
+ },
+ };
+
+ function selectedOption() {
+ return directorySelect.find("option:selected").first();
+ }
+
+ function updateActions(announce) {
+ const option = selectedOption();
+
+ if (!option.length) {
+ return;
+ }
+
+ Object.entries(actionAttributes).forEach(([key, attributes]) => {
+ const action = card.find(`[data-quick-action="${key}"]`);
+ const url = option.attr(attributes.url);
+ const ariaLabel = option.attr(attributes.ariaLabel);
+
+ if (!action.length || !url) {
+ return;
+ }
+
+ action.attr("href", url);
+
+ if (ariaLabel) {
+ action.attr("aria-label", ariaLabel);
+ }
+
+ if (attributes.description) {
+ const description = option.attr(attributes.description);
+
+ if (description) {
+ action.find("em").text(description);
+ }
+ }
+ });
+
+ if (!announce || !liveRegion.length) {
+ return;
+ }
+
+ const directoryName = String(
+ option.attr("data-directory-name") || option.text(),
+ )
+ .replace(/\s+/g, " ")
+ .trim();
+ const messageTemplate =
+ card.attr("data-directory-change-message") ||
+ "Quick actions now use %s.";
+
+ liveRegion.text(messageTemplate.replace("%s", directoryName));
+ }
+
+ try {
+ const storedDirectory = window.sessionStorage.getItem(storageKey);
+ const storedOption = directorySelect.find("option").filter(function () {
+ return String(this.value) === storedDirectory;
+ });
+
+ if (storedOption.length) {
+ directorySelect.val(storedDirectory);
+ }
+ } catch (error) {}
+
+ updateActions(false);
+
+ directorySelect.on("change.directoristTeQuickActions", function () {
+ try {
+ window.sessionStorage.setItem(storageKey, this.value);
+ } catch (error) {}
+
+ updateActions(true);
+ });
+ }
+
+ function setupDashboardPreview() {
+ const dashboard = $(".directorist-te-dashboard");
+ const nudge = dashboard.find(".directorist-te-dashboard-nudge");
+ const steps = dashboard.find(".directorist-te-dashboard-step");
+ const ring = dashboard.find(".directorist-te-dashboard-ring circle").last();
+ const ringLabel = dashboard.find(".directorist-te-dashboard-ring span");
+ const radius = 19;
+ const circumference = 2 * Math.PI * radius;
+
+ if (!dashboard.length) {
+ return;
+ }
+
+ function paintProgress() {
+ if (!steps.length || !ring.length || !ringLabel.length) {
+ return;
+ }
+
+ const done = steps.filter(".is-done").length;
+ const percent = Math.round((done / steps.length) * 100);
+
+ ring
+ .attr("stroke-dasharray", circumference.toFixed(1))
+ .attr(
+ "stroke-dashoffset",
+ (circumference * (1 - percent / 100)).toFixed(1),
+ );
+ ringLabel.text(`${percent}%`);
+ }
+
+ paintProgress();
+
+ dashboard
+ .find(".directorist-te-dashboard-nudge__dismiss")
+ .on("click.directoristTeDashboard", function () {
+ nudge.prop("hidden", true).attr("aria-hidden", "true");
+ });
+
+ steps.on("click.directoristTeDashboard", function () {
+ if ($(this).hasClass("is-done")) {
+ return;
+ }
+
+ $(this).addClass("is-done");
+ paintProgress();
+ });
+ }
+
+ function runPluginBulkTask(task, pluginItems, button) {
+ if (!pluginItems.length) {
+ return;
+ }
+
+ setButtonLoading(button, "Working");
+
+ $.ajax({
+ type: "post",
+ url: ajaxUrl(),
+ data: {
+ action: "atbdp_plugins_bulk_action",
+ task,
+ plugin_items: pluginItems,
+ directorist_nonce: directoristNonce(),
+ },
+ success(response) {
+ if (response?.status && response.status.success === false) {
+ alert(response.status.message || "Action failed.");
+ resetButton(button);
+ return;
+ }
+
+ window.location.reload();
+ },
+ error() {
+ alert("Action failed. Please reload the page and try again.");
+ resetButton(button);
+ },
+ });
+ }
+
+ function runSelectedInstalls(items, button) {
+ const queue = items.slice();
+
+ if (!queue.length) {
+ return;
+ }
+
+ setButtonLoading(button, "Installing");
+
+ const next = () => {
+ const item = queue.shift();
+
+ if (!item) {
+ window.location.reload();
+ return;
+ }
+
+ $.ajax({
+ type: "post",
+ url: ajaxUrl(),
+ data: {
+ action: "atbdp_install_file_from_subscriptions",
+ item_key: item.item,
+ type: item.type,
+ nonce: nonce(),
+ },
+ success(response) {
+ if (response?.status && response.status.success === false) {
+ alert(response.status.message || "Install failed.");
+ resetButton(button);
+ return;
+ }
+
+ next();
+ },
+ error() {
+ alert("Install failed. Please reload the page and try again.");
+ resetButton(button);
+ },
+ });
+ };
+
+ next();
+ }
+
+ function runSelectedUpdates(items, button) {
+ const queue = items.slice();
+
+ if (!queue.length) {
+ return;
+ }
+
+ setButtonLoading(button, "Updating");
+
+ const next = () => {
+ const item = queue.shift();
+
+ if (!item) {
+ window.location.reload();
+ return;
+ }
+
+ const data =
+ item.type === "theme"
+ ? {
+ action: "atbdp_update_theme",
+ theme_stylesheet: item.item,
+ nonce: nonce(),
+ }
+ : {
+ action: "atbdp_update_plugins",
+ plugin_key: item.item,
+ nonce: nonce(),
+ };
+
+ $.ajax({
+ type: "post",
+ url: ajaxUrl(),
+ data,
+ success(response) {
+ if (response?.status && response.status.success === false) {
+ alert(response.status.message || "Update failed.");
+ resetButton(button);
+ return;
+ }
+
+ next();
+ },
+ error() {
+ alert("Update failed. Please reload the page and try again.");
+ resetButton(button);
+ },
+ });
+ };
+
+ next();
+ }
+
+ $(".directorist-te-tab").on("click", function () {
+ $(".directorist-te-tab").removeClass("is-active");
+ $(this).addClass("is-active");
+ state.type = $(this).data("filter-type") || "all";
+ updateFilters();
+ syncPageStateUrl();
+ });
+
+ $(".directorist-te-segmented button").on("click", function () {
+ $(".directorist-te-segmented button").removeClass("is-active");
+ $(this).addClass("is-active");
+ state.status = $(this).data("filter-status") || "all";
+ updateFilters();
+ });
+
+ $(".directorist-te-search-input").on("input", function () {
+ state.query = $(this).val();
+ updateFilters();
+ });
+
+ $(".directorist-te-empty-reset").on("click", function () {
+ resetCatalogFilters();
+ });
+
+ $("#atbdp-directorist-license-login-form.directorist-te-connect-form").on(
+ "submit",
+ function (event) {
+ const form = $(this);
+ const authMethod = connectAuthMethod(form);
+ const username = form.find('input[name="username"]').first();
+ const password = form.find('input[name="password"]').first();
+ const accessKey = form.find('input[name="access_key"]').first();
+
+ event.preventDefault();
+ event.stopImmediatePropagation();
+
+ if (form.attr("aria-busy") === "true") {
+ return;
+ }
+
+ clearConnectFeedback(form);
+
+ if (authMethod === "access_key" && !accessKey.val().trim()) {
+ accessKey.attr("aria-invalid", "true").trigger("focus");
+ renderConnectFeedback(
+ form,
+ "danger",
+ formMessage(
+ form,
+ "access-key-required",
+ "Enter your Directorist account access key.",
+ ),
+ );
+ return;
+ }
+
+ if (authMethod === "account" && !username.val().trim()) {
+ username.attr("aria-invalid", "true").trigger("focus");
+ renderConnectFeedback(
+ form,
+ "danger",
+ formMessage(
+ form,
+ "username-required",
+ "Enter your Directorist account username or email address.",
+ ),
+ );
+ return;
+ }
+
+ if (authMethod === "account" && !password.val()) {
+ password.attr("aria-invalid", "true").trigger("focus");
+ renderConnectFeedback(
+ form,
+ "danger",
+ formMessage(
+ form,
+ "password-required",
+ "Enter your Directorist account password.",
+ ),
+ );
+ return;
+ }
+
+ setConnectBusy(form, true);
+
+ $.ajax({
+ type: "post",
+ url: ajaxUrl(),
+ directoristTeOwned: true,
+ data: {
+ action: "atbdp_authenticate_the_customer",
+ auth_method: authMethod,
+ access_key: authMethod === "access_key" ? accessKey.val().trim() : "",
+ username: authMethod === "account" ? username.val().trim() : "",
+ password: authMethod === "account" ? password.val() : "",
+ nonce: nonce(),
+ },
+ success(response) {
+ if (
+ response?.has_previous_subscriptions ||
+ response?.status?.success
+ ) {
+ window.location.reload();
+ return;
+ }
+
+ if (authHasRemoteConnectionError(response)) {
+ renderConnectFeedback(
+ form,
+ "danger",
+ formMessage(
+ form,
+ "network-error",
+ "Could not reach Directorist.com. Please try again.",
+ ),
+ );
+ } else {
+ renderConnectFeedback(
+ form,
+ "danger",
+ formMessage(
+ form,
+ authMethod === "access_key"
+ ? "invalid-access-key"
+ : "invalid-credentials",
+ authMethod === "access_key"
+ ? "The access key is invalid. Check the key in your Directorist account and try again."
+ : "The username, email address, or password is incorrect. Please check your details and try again.",
+ ),
+ );
+ }
+
+ setConnectBusy(form, false);
+ },
+ error() {
+ renderConnectFeedback(
+ form,
+ "danger",
+ formMessage(
+ form,
+ "network-error",
+ "Could not reach Directorist.com. Please try again.",
+ ),
+ );
+ setConnectBusy(form, false);
+ },
+ });
+ },
+ );
+
+ $(
+ '#atbdp-directorist-license-login-form.directorist-te-connect-form input[name="username"], #atbdp-directorist-license-login-form.directorist-te-connect-form input[name="password"], #atbdp-directorist-license-login-form.directorist-te-connect-form input[name="access_key"]',
+ ).on("input", function () {
+ const form = $(this).closest(".directorist-te-connect-form");
+
+ $(this).removeAttr("aria-invalid");
+
+ if (!form.find('[aria-invalid="true"]').length) {
+ connectFeedback(form).attr("role", "status").empty();
+ }
+ });
+
+ $(".directorist-te-auth-methods [data-auth-method]").on(
+ "click",
+ function () {
+ const button = $(this);
+ const form = button.closest(".directorist-te-connect-form");
+
+ if (!form.length || form.attr("aria-busy") === "true") {
+ return;
+ }
+
+ form
+ .find('input[name="auth_method"]')
+ .val(
+ button.attr("data-auth-method") === "access_key"
+ ? "access_key"
+ : "account",
+ );
+ clearConnectFeedback(form);
+ syncConnectAuthMethod(form, true);
+ },
+ );
+
+ $(".directorist-te-auth-methods [data-auth-method]").on(
+ "keydown",
+ function (event) {
+ if (
+ !["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)
+ ) {
+ return;
+ }
+
+ event.preventDefault();
+
+ const buttons = $(this)
+ .closest(".directorist-te-auth-methods")
+ .find("[data-auth-method]:not(:disabled)");
+ const currentIndex = buttons.index(this);
+ let nextIndex = currentIndex;
+
+ if (event.key === "Home") {
+ nextIndex = 0;
+ } else if (event.key === "End") {
+ nextIndex = buttons.length - 1;
+ } else if (event.key === "ArrowRight") {
+ nextIndex = (currentIndex + 1) % buttons.length;
+ } else {
+ nextIndex = (currentIndex - 1 + buttons.length) % buttons.length;
+ }
+
+ buttons.eq(nextIndex).trigger("click").trigger("focus");
+ },
+ );
+
+ $(
+ "#atbdp-directorist-license-login-form.directorist-te-connect-form",
+ ).each(function () {
+ syncConnectAuthMethod($(this), false);
+ });
+
+ $(document).on("ajaxSuccess", function (_event, _xhr, settings, response) {
+ if (settings?.directoristTeOwned || !isAuthRequest(settings)) {
+ return;
+ }
+
+ const form = $(
+ "#atbdp-directorist-license-login-form.directorist-te-connect-form",
+ );
+ const authMethod = connectAuthMethod(form);
+
+ if (!form.length || response?.has_previous_subscriptions) {
+ return;
+ }
+
+ if (response?.status?.success) {
+ return;
+ }
+
+ if (
+ authHasRemoteConnectionError(response) ||
+ isRemoteConnectionMessage(connectFeedback(form).text())
+ ) {
+ renderConnectFeedback(
+ form,
+ "danger",
+ formMessage(
+ form,
+ "network-error",
+ "Could not reach Directorist.com. Please try again.",
+ ),
+ );
+ } else {
+ renderConnectFeedback(
+ form,
+ "danger",
+ formMessage(
+ form,
+ authMethod === "access_key"
+ ? "invalid-access-key"
+ : "invalid-credentials",
+ authMethod === "access_key"
+ ? "The access key is invalid. Check the key in your Directorist account and try again."
+ : "The username, email address, or password is incorrect. Please check your details and try again.",
+ ),
+ );
+ }
+
+ setConnectBusy(form, false);
+ });
+
+ $(document).on("ajaxError", function (_event, _xhr, settings) {
+ if (settings?.directoristTeOwned || !isAuthRequest(settings)) {
+ return;
+ }
+
+ const form = $(
+ "#atbdp-directorist-license-login-form.directorist-te-connect-form",
+ );
+
+ if (!form.length) {
+ return;
+ }
+
+ renderConnectFeedback(
+ form,
+ "danger",
+ formMessage(
+ form,
+ "network-error",
+ "Could not reach Directorist.com. Please try again.",
+ ),
+ );
+ setConnectBusy(form, false);
+ });
+
+ $(".directorist-te-password-toggle").on("click", function (event) {
+ event.preventDefault();
+
+ const button = $(this);
+ const input = button
+ .closest(".directorist-te-password-control")
+ .find("input")
+ .first();
+
+ if (!input.length) {
+ return;
+ }
+
+ const isHidden = input.attr("type") === "password";
+ const nextLabel = isHidden
+ ? button.data("hide-label")
+ : button.data("show-label");
+
+ input.attr("type", isHidden ? "text" : "password").trigger("focus");
+ button.attr({
+ "aria-label": nextLabel,
+ "aria-pressed": isHidden ? "true" : "false",
+ });
+ button.find("i").toggleClass("la-eye", !isHidden);
+ button.find("i").toggleClass("la-eye-slash", isHidden);
+ });
+
+ $("#atbdp-my-extensions-form").on(
+ "change",
+ ".directorist-te-select-checkbox",
+ function () {
+ setCheckbox(this, this.checked);
+ updateBulkBar();
+ },
+ );
+
+ $("#select-all-installed").on("change", function () {
+ const checked = this.checked;
+
+ visibleSelectableChecks().each(function () {
+ setCheckbox(this, checked);
+ });
+
+ updateBulkBar();
+ });
+
+ $(".directorist-te-bulk-clear").on("click", function () {
+ selectableChecks().each(function () {
+ setCheckbox(this, false);
+ });
+
+ updateBulkBar();
+ });
+
+ $(document).on("keydown", function (event) {
+ if (event.key !== "Escape" || !checkedSelectableChecks().length) {
+ return;
+ }
+
+ selectableChecks().each(function () {
+ setCheckbox(this, false);
+ });
+
+ $(".ext-action-drop").removeClass("active");
+ updateBulkBar();
+ });
+
+ $(".directorist-te-bulk-action").on("click", function (event) {
+ event.preventDefault();
+
+ const task = $(this).data("task");
+ const selectedItems = selectedBulkItems(task);
+ const selectedCount = checkedSelectableChecks().length;
+ const skippedCount = Math.max(selectedCount - selectedItems.length, 0);
+
+ if (!task || !selectedItems.length) {
+ return;
+ }
+
+ if (
+ task === "uninstall" &&
+ !window.confirm(
+ `Delete removes ${itemCountText(
+ selectedItems.length,
+ )} from this site.` +
+ (skippedCount
+ ? ` ${itemCountText(skippedCount)} will be skipped.`
+ : "") +
+ " Continue?",
+ )
+ ) {
+ return;
+ }
+
+ if (task === "install") {
+ runSelectedInstalls(selectedItems, this);
+ return;
+ }
+
+ if (task === "update") {
+ runSelectedUpdates(selectedItems, this);
+ return;
+ }
+
+ const pluginItems = selectedItems
+ .filter((item) => item.type === "plugin")
+ .map((item) => item.item);
+
+ if (pluginItems.length !== selectedItems.length) {
+ alert("This bulk action is only available for plugins.");
+ return;
+ }
+
+ runPluginBulkTask(task, pluginItems, this);
+ });
+
+ $(".directorist-te-single-plugin-task").on("click", function (event) {
+ event.preventDefault();
+
+ const task = $(this).data("task");
+ const target = $(this).data("target");
+
+ if (!task || !target) {
+ return;
+ }
+
+ runPluginBulkTask(task, [target], this);
+ });
+
+ $(".directorist-te-update-all").on("click", function (event) {
+ event.preventDefault();
+
+ const button = this;
+ const updateExtensions = $(button).data("update-extensions") === 1;
+ const updateThemes = $(button).data("update-themes") === 1;
+ const queue = [];
+
+ if (updateExtensions) {
+ queue.push("atbdp_update_plugins");
+ }
+
+ if (updateThemes) {
+ queue.push("atbdp_update_theme");
+ }
+
+ if (!queue.length) {
+ return;
+ }
+
+ setButtonLoading(button, "Updating");
+
+ const next = () => {
+ const action = queue.shift();
+
+ if (!action) {
+ window.location.reload();
+ return;
+ }
+
+ $.ajax({
+ type: "post",
+ url: ajaxUrl(),
+ data: {
+ action,
+ nonce: nonce(),
+ },
+ success(response) {
+ if (response?.status && response.status.success === false) {
+ alert(response.status.message || "Update failed.");
+ resetButton(button);
+ return;
+ }
+
+ next();
+ },
+ error() {
+ alert("Update failed. Please reload the page and try again.");
+ resetButton(button);
+ },
+ });
+ };
+
+ next();
+ });
+
+ $(".directorist-te-menu-toggle").on("click", function (event) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const wasOpen = $(this).hasClass("active");
+
+ $(".ext-action-drop").removeClass("active");
+
+ if (!wasOpen) {
+ $(this).addClass("active");
+ }
+ });
+
+ $(".directorist-te-menu__items").on("click", function (event) {
+ event.stopPropagation();
+ });
+
+ $(".directorist-te-menu-link").on("click", function () {
+ $(".ext-action-drop").removeClass("active");
+ });
+
+ $(document).on("click", function (event) {
+ if ($(event.target).closest(".directorist-te-menu").length) {
+ return;
+ }
+
+ $(".ext-action-drop").removeClass("active");
+ });
+
+ document.addEventListener(
+ "click",
+ function (event) {
+ const menuToggle = event.target.closest(".directorist-te-menu-toggle");
+
+ if (menuToggle) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const wasOpen = menuToggle.classList.contains("active");
+
+ document
+ .querySelectorAll(".ext-action-drop.active")
+ .forEach((toggle) => toggle.classList.remove("active"));
+
+ if (!wasOpen) {
+ menuToggle.classList.add("active");
+ }
+
+ return;
+ }
+
+ const themeButton = event.target.closest(".theme-activate-btn");
+
+ if (themeButton && !themeButton.dataset.directoristConfirmed) {
+ if (
+ !window.confirm(
+ "Activating this theme changes the live site theme. Continue?",
+ )
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ event.stopImmediatePropagation();
+ return;
+ }
+
+ themeButton.dataset.directoristConfirmed = "1";
+ window.setTimeout(() => {
+ delete themeButton.dataset.directoristConfirmed;
+ }, 1000);
+ }
+
+ const uninstallLink = event.target.closest(".ext-action-uninstall");
+
+ if (!uninstallLink) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ event.stopImmediatePropagation();
+
+ const target = uninstallLink.dataset.target;
+
+ if (!target) {
+ return;
+ }
+
+ if (
+ !window.confirm("Delete removes this plugin from the server. Continue?")
+ ) {
+ return;
+ }
+
+ runPluginBulkTask("uninstall", [target], uninstallLink);
+ },
+ true,
+ );
+
+ selectableChecks().each(function () {
+ setCheckbox(this, this.checked);
+ });
+
+ setupAccountMenu();
+ setupNotificationMenu();
+ setupRefreshPurchasePanel();
+ setupViewSwitcher();
+ setupDashboardActivity();
+ setupRequiredExtensionsCompatibility();
+ setupDashboardQuickActions();
+ setupDashboardRecommendations();
+ setupDashboardPreview();
+ updateFilters();
+})(jQuery);
diff --git a/assets/src/js/admin/components/subscriptionManagement.js b/assets/src/js/admin/components/subscriptionManagement.js
index c755db5c9e..26cd8ffe3a 100644
--- a/assets/src/js/admin/components/subscriptionManagement.js
+++ b/assets/src/js/admin/components/subscriptionManagement.js
@@ -16,6 +16,9 @@ window.addEventListener('load', () => {
const form_data = {
action: 'atbdp_authenticate_the_customer',
+ auth_method:
+ form.find('input[name="auth_method"]').val() || 'account',
+ access_key: form.find('input[name="access_key"]').val() || '',
username: form.find('input[name="username"]').val(),
password: form.find('input[name="password"]').val(),
nonce: directorist_admin.nonce,
diff --git a/assets/src/js/admin/vue/apps/cpt-manager/CPT_Manager.vue b/assets/src/js/admin/vue/apps/cpt-manager/CPT_Manager.vue
index 1661cc6cf3..db9b82c313 100644
--- a/assets/src/js/admin/vue/apps/cpt-manager/CPT_Manager.vue
+++ b/assets/src/js/admin/vue/apps/cpt-manager/CPT_Manager.vue
@@ -187,6 +187,13 @@ export default {
return;
}
+ const requestedNavIndex = this.getRequestedNavigationIndex(layoutKeys);
+
+ if (requestedNavIndex >= 0) {
+ this.$store.commit("swichNav", requestedNavIndex);
+ return;
+ }
+
let activeNavIndex = 0;
try {
@@ -208,6 +215,19 @@ export default {
this.$store.commit("swichNav", activeNavIndex);
},
+ getRequestedNavigationIndex(layoutKeys) {
+ try {
+ const hash = decodeURIComponent(
+ String(window.location.hash || "").replace(/^#/, ""),
+ );
+ const menuKey = hash.split("__")[0];
+
+ return menuKey ? layoutKeys.indexOf(menuKey) : -1;
+ } catch (error) {
+ return -1;
+ }
+ },
+
focusDirectoryNameForNewDirectory() {
const directoryName = this.options?.name?.value;
diff --git a/assets/src/js/admin/vue/modules/Submenu_Module.vue b/assets/src/js/admin/vue/modules/Submenu_Module.vue
index 78dc9b987e..338d3d4473 100644
--- a/assets/src/js/admin/vue/modules/Submenu_Module.vue
+++ b/assets/src/js/admin/vue/modules/Submenu_Module.vue
@@ -112,6 +112,20 @@ export default {
return 0;
}
+ try {
+ const hash = decodeURIComponent(
+ String(window.location.hash || "").replace(/^#/, ""),
+ );
+ const [menuKey, submenuKey] = hash.split("__");
+ const submenuKeys = Object.keys(this.submenu || {});
+ const requestedIndex =
+ menuKey === this.menuKey ? submenuKeys.indexOf(submenuKey) : -1;
+
+ if (requestedIndex >= 0) {
+ return requestedIndex;
+ }
+ } catch (error) {}
+
let fallbackIndex = this.subNavigation.findIndex(
(submenu) => submenu.active === true,
);
diff --git a/docs/agents/directorist-themes-extensions-page/README.md b/docs/agents/directorist-themes-extensions-page/README.md
new file mode 100644
index 0000000000..ee1f71f2d2
--- /dev/null
+++ b/docs/agents/directorist-themes-extensions-page/README.md
@@ -0,0 +1,48 @@
+# Directorist Themes & Extensions Page Agent
+
+This folder contains the repo-local skill/playbook for agents working on the Directorist admin `Themes & Extensions` page.
+
+## What This Skill Is For
+
+Use it when working on:
+
+- `wp-admin/edit.php?post_type=at_biz_dir&page=atbdp-extension`
+- Directorist account connection from the admin product page
+- extension/theme subscription discovery
+- product install, update, activation, bulk actions, or required-extension UI
+- no-reload performance improvements on this page
+- responsive redesign of this page from a new design
+
+Do not use it for the frontend user dashboard or installed extension settings unless the task directly connects those systems back to this admin page.
+
+## How To Use
+
+Tell the agent:
+
+```text
+Use docs/agents/directorist-themes-extensions-page/SKILL.md for this task.
+```
+
+Then provide the design, bug, feature request, or admin URL.
+
+The agent should inspect the live admin page with Agent Browser, read the mapped source files, cross-check Directorist docs when copy/product claims are involved, and produce a feasibility report before implementation.
+
+## Dynamic Data Policy
+
+This skill must not store site-specific runtime data. Product counts, installed items, subscribed products, update counts, connected user/account state, screenshots, and network logs must be collected fresh during each task and treated as temporary evidence only.
+
+## Files In This Package
+
+- `SKILL.md`: required agent workflow and safety rules
+- `references/page-architecture-map.md`: code, templates, assets, hooks, endpoints, and remote dependencies
+- `references/api-data-flow-report.md`: step-by-step source/API flow from page load to rendered sections and product actions
+- `references/licensing-system-prd.md`: to-the-point product/system requirements for account, license, extension, and theme setup
+- `references/dynamic-data-contract.md`: dynamic data shapes and collection methods
+- `references/account-license-journey-map.md`: account, license, subscription, and product-action journeys
+- `references/performance-improvement-guide.md`: reload-heavy behavior and safe no-reload strategy
+- `references/rewrite-issue-register.md`: full rewrite issue notes, fix priorities, and regression checklist
+- `references/qa-checklist.md`: verification matrix and destructive-action safeguards
+
+## Maintenance
+
+Update this package only with durable architecture facts, stable contracts, confirmed issue patterns, and safe workflow improvements. Do not add local runtime snapshots.
diff --git a/docs/agents/directorist-themes-extensions-page/SKILL.md b/docs/agents/directorist-themes-extensions-page/SKILL.md
new file mode 100644
index 0000000000..3b42f708b6
--- /dev/null
+++ b/docs/agents/directorist-themes-extensions-page/SKILL.md
@@ -0,0 +1,146 @@
+---
+name: directorist-themes-extensions-page-agent
+description: 'Use this repo-local skill when redesigning, auditing, planning, testing, or implementing features for the Directorist WordPress admin Themes & Extensions page at edit.php?post_type=at_biz_dir&page=atbdp-extension. Trigger for work on Directorist extension/theme discovery, account connection, subscription/license management, install/update/activate flows, no-reload performance improvements, or designs that touch this admin page. This skill is strict: re-collect dynamic runtime state every time, preserve existing customer compatibility, and never run destructive product actions without explicit confirmation.'
+---
+
+# Directorist Themes & Extensions Page Agent
+
+This skill guides safe work on the Directorist admin `Themes & Extensions` page. The page is production-critical because it connects customer accounts, subscriptions, product downloads, plugin/theme activation, updates, and promotional product discovery.
+
+## Core Rule
+
+Re-collect current runtime data on every task. Do not treat previously observed product counts, subscription contents, installed item lists, update counts, user names, screenshots, or local site values as source of truth.
+
+Store and use architecture, data contracts, selectors, endpoint names, and journey rules only.
+
+## Required Context Pass
+
+Before planning or editing, inspect these files:
+
+- `includes/classes/class-extension.php`
+- `views/admin-templates/theme-extensions/theme-extension.php`
+- `views/admin-templates/theme-extensions/auth/license-auth-section.php`
+- `views/admin-templates/theme-extensions/statistics/statistics.php`
+- `views/admin-templates/theme-extensions/my-themes-extensions/my-themes-extensions.php`
+- `views/admin-templates/theme-extensions/my-themes-extensions/extensions-tab.php`
+- `views/admin-templates/theme-extensions/my-themes-extensions/themes-tab.php`
+- `views/admin-templates/theme-extensions/all-themes-extensions.php`
+- `assets/src/js/admin/components/subscriptionManagement.js`
+- `assets/src/js/admin/admin.js`
+- `assets/src/scss/layout/admin/admin-style.scss`
+- `includes/asset-loader/helper.php`
+- `includes/asset-loader/init.php`
+- `includes/asset-loader/scripts.php`
+- `includes/asset-loader/localized_data.php`
+
+For product docs, copy, labels, or public claims, cross-check:
+
+- `README.md`
+- `readme.txt`
+- Official Directorist docs: `https://directorist.com/documentation/directorist/`
+- Official themes docs: `https://directorist.com/documentation/themes/`
+- Official extensions docs: `https://directorist.com/documentation/extensions`
+
+Load reference files as needed:
+
+- Architecture and file map: `references/page-architecture-map.md`
+- API/source flow report: `references/api-data-flow-report.md`
+- Cross-repository licensing integration map: `references/licensing-integration-map.md`
+- Licensing system PRD: `references/licensing-system-prd.md`
+- Runtime state and data shapes: `references/dynamic-data-contract.md`
+- Account/license journeys: `references/account-license-journey-map.md`
+- Performance and no-reload guidance: `references/performance-improvement-guide.md`
+- Full rewrite issue register and fix priorities: `references/rewrite-issue-register.md`
+- Current implementation changes and locked UI decisions: `references/current-implementation-change-notes.md`
+- Verification checklist: `references/qa-checklist.md`
+
+## Live Inspection Requirement
+
+Use Agent Browser for the live admin page when a logged-in session is available:
+
+```bash
+agent-browser --session directorist-themes-extensions --profile Default --ignore-https-errors open "https://directorist-core.local/wp-admin/edit.php?post_type=at_biz_dir&page=atbdp-extension"
+agent-browser --session directorist-themes-extensions snapshot -c -d 4
+agent-browser --session directorist-themes-extensions eval 'JSON.stringify({url:location.href,title:document.title,connected:!!document.querySelector("#purchase-refresh-form"),auth:!!document.querySelector("#atbdp-directorist-license-login-form"),overflow:document.documentElement.scrollWidth>innerWidth})'
+```
+
+Treat this as an inspection step only. Do not save the collected runtime values into this skill or its references.
+
+Close the browser session after inspection:
+
+```bash
+agent-browser --session directorist-themes-extensions close
+```
+
+## Safety Rules
+
+- Never click or automate Install, Update, Activate, Deactivate, Uninstall, Logout, Refresh Purchase, or theme switch actions on a real/client site without explicit confirmation for that exact action.
+- Treat the disconnected view as locked as of 2026-07-22. Unless the user explicitly asks for disconnected-view changes in the current task, do not change disconnected-view PHP markup, copy, header/resource links, connect-card UX, marketplace/search/tab layout, product-row actions, CSS/responsive behavior, or JS behavior that only affects disconnected users.
+- Preserve the page slug `atbdp-extension`, parent post type `at_biz_dir`, capability `manage_options`, existing AJAX action names, nonce expectations, response compatibility, settings links, filters, aliases, and legacy user meta keys.
+- Preserve legacy misspellings that are part of stored data or public contracts, including `_atbdp_has_subscriptions_sassion` and `atbdp_close_subscriptions_sassion`.
+- Do not remove or repurpose old selectors, classes, template paths, filters, or action hooks without a compatibility shim and explicit migration plan.
+- After a new UI replaces an old page section, audit old design-specific CSS and remove unused rules after verifying they are not used by the rewritten page, legacy compatibility shims, template overrides, or other Directorist admin screens.
+- Keep new Themes & Extensions styles page-scoped. Do not add broad global admin selectors or shared Directorist selectors for this page unless a reusable design-system change is explicitly requested and verified across affected screens.
+- Treat install, update, download, uninstall, and theme activation as high-risk operations. Use read-only inspection by default.
+- Keep this page separate from installed extension settings. Settings-panel work belongs to `docs/agents/directorist-settings-panel/SKILL.md`.
+- Treat Figma/design input as design intent. Do not rewrite backend product/install logic only to match a visual design.
+
+## Performance Rule
+
+This page is PHP-rendered admin markup plus legacy jQuery. Improve performance through progressive enhancement first:
+
+1. Keep the current server-rendered page as canonical fallback.
+2. Add small, tested no-reload flows around existing AJAX responses.
+3. Re-fetch or re-render canonical state after expensive server actions.
+4. Keep full page reload fallback when state cannot be safely reconciled.
+
+Preferred rewrite architecture: keep PHP-rendered templates as the compatibility baseline, introduce focused PHP service/resolver classes behind existing AJAX actions, and add a small page-specific JavaScript state adapter for no-reload UI updates.
+
+Do not replace the page with Vue/React or a new persistence/API layer unless the user explicitly approves that architecture change after seeing the compatibility risks. Vue 2 is legacy in the settings panel, and React should only be considered for this page after an explicit full admin UI migration decision.
+
+## Required First Output
+
+Before implementation, produce a compact report:
+
+```markdown
+# Themes & Extensions Feasibility Report
+
+## Summary
+- Request:
+- Recommended approach:
+- Risk level:
+
+## Dynamic State Rechecked
+- Live page:
+- Connected/account state:
+- Product state source:
+- Docs checked:
+
+## Easy To Achieve
+- ...
+
+## Complex Or Risky
+- ...
+
+## Affected Areas
+- PHP/templates:
+- AJAX/server actions:
+- JS/state updates:
+- SCSS/responsive:
+- External Directorist/EDD APIs:
+
+## Compatibility Rules
+- ...
+
+## Verification Plan
+- Static checks:
+- Agent Browser checks:
+- No-reload/performance checks:
+- Destructive-action policy:
+```
+
+If the task is a small docs-only update, the report can be shorter but must still say whether dynamic state was intentionally not stored.
+
+## Maintenance
+
+Update the reference files when a future task confirms a durable architecture fact, compatibility rule, or recurring issue. Do not update them with local runtime counts, purchased product lists, user-specific state, or one-time screenshots.
diff --git a/docs/agents/directorist-themes-extensions-page/references/account-license-journey-map.md b/docs/agents/directorist-themes-extensions-page/references/account-license-journey-map.md
new file mode 100644
index 0000000000..8dcaf8bc53
--- /dev/null
+++ b/docs/agents/directorist-themes-extensions-page/references/account-license-journey-map.md
@@ -0,0 +1,127 @@
+# Account And License Journey Map
+
+This map covers the Directorist.com account, license, subscription, and product-management journey inside the admin `Themes & Extensions` page. It does not cover the frontend `[directorist_user_dashboard]`.
+
+## Page Load Journey
+
+1. WordPress loads the admin submenu page `atbdp-extension`.
+2. `ATBDP_Extensions::initial_setup()` prepares aliases, required extensions, product lists, and update data.
+3. `show_extension_view()` reads current user meta to decide whether the account is connected.
+4. The root template renders either the account connection form or connected account product-management UI.
+5. The promo catalog is rendered as product discovery regardless of account state.
+
+All values are dynamic and must be re-collected on each task.
+
+## Logged-Out Account Journey
+
+1. The page renders `#atbdp-directorist-license-login-form` with Account login selected by default and Access key as an explicit alternative.
+2. The page-specific script intercepts submit before the legacy handler and keeps the existing local AJAX action.
+3. Account login sends `auth_method=account`, username/email, password, and nonce. Access-key login sends `auth_method=access_key`, access key, and nonce.
+4. `authenticate_the_customer()` calls `user-login` for account credentials or `user-connect` for an access key.
+5. On success, user meta is updated with connected state and available subscription data.
+6. Current JS reloads the page after success.
+
+Both methods normalize into the existing `license_data.themes` and `license_data.plugins` contract. Account/password remains the default for existing customers. Access keys are not persisted locally; only the non-secret connection method and returned account identity/entitlements are stored.
+
+Performance opportunity: replace unconditional reload with a state refresh/render step, but keep reload fallback when canonical state cannot be reconstructed safely.
+
+## Disconnected Account Recommendation
+
+When the account is not connected, preserve the current page behavior: render the Directorist account-connect form and the promo marketplace/discovery section only.
+
+- Locked as of 2026-07-22: the disconnected view is complete and should be treated as out of scope for future Themes & Extensions work unless the user explicitly asks for disconnected-view changes in that task.
+- Do not alter disconnected-view layout, account-connect copy, resource links, search/tabs/count placement, product row/card actions, CSS/responsive behavior, or page-specific JS behavior by accident while working on connected-state, badge, API, licensing, install/update, or theme-management features.
+- Do not expose installed premium product management in the disconnected page state by default.
+- Do not show Settings, Active status, Activate, Deactivate, Update, or local management actions for installed premium products while disconnected unless a future task explicitly changes this policy.
+- Already installed premium products can continue working in WordPress, but this page should not manage them while the Directorist account session is disconnected.
+- Premium install, package download, update, refresh purchase, and license validation must require connected account/subscription data.
+- The disconnected state should include a clear account-connect CTA for managing subscriptions, installs, and updates.
+- Recommended helper copy: `Already installed extensions will keep working. Connect your Directorist account to manage subscriptions, installs, and updates.`
+- This keeps the safest behavior for existing customer sites while reducing confusion about whether installed premium products stop working when the account is disconnected.
+
+## Connected Account Journey
+
+1. Statistics section renders current extension/theme availability and update status from server-side overview data.
+2. `my-themes-extensions.php` renders tabs for extension and theme management.
+3. Installed products, subscribed products, required products, active theme, and available subscription themes are all generated from live data.
+4. Refresh Purchase requests the same credential type used for connection: password for Account login, access key for Access key. It calls the existing `atbdp_refresh_purchase_status` action.
+5. Logout calls `atbdp_close_subscriptions_sassion` and clears connected account state.
+
+Do not store the resulting product or account values in docs.
+
+## Product Action Journeys
+
+### Install From Subscription
+
+- UI selector family: `.file-install-btn`
+- AJAX action: `atbdp_install_file_from_subscriptions`
+- Server path: `handle_file_install_request_from_subscriptions()` -> `install_file_from_subscriptions()`
+- Risk: filesystem download, unzip/copy, license activation, package host validation, plugin/theme destination changes.
+
+### Activate Plugin
+
+- UI selector family: `.plugin-active-btn`
+- AJAX action: `atbdp_activate_plugin`
+- Server path: `activate_plugin()`
+- Risk: activation can trigger extension code, dependencies, fatal errors, or `WP_Error`.
+
+### Update Plugin
+
+- UI selector family: `.ext-update-btn`
+- AJAX action: `atbdp_update_plugins`
+- Server path: `handle_plugins_update_request()` -> update/download helpers
+- Risk: remote version checks, download package, filesystem replacement.
+
+### Bulk Plugin Action
+
+- Form: `#atbdp-my-extensions-form`
+- AJAX action: `atbdp_plugins_bulk_action`
+- Supported tasks include activate, deactivate, and uninstall.
+- Risk: uninstall is destructive and must not be automated without explicit confirmation.
+
+### Plugin Uninstall Recommendation
+
+- Keep uninstall available for compatibility with the existing page, but make it a protected danger action in the rewrite.
+- Do not expose uninstall as an easy primary action.
+- Place uninstall under a danger/overflow menu with clear destructive styling.
+- Require an explicit confirmation modal that shows the extension name and explains that plugin files will be deleted and site features may break.
+- Do not run uninstall from Agent Browser or tests on a real/client site without explicit confirmation for that exact action.
+- After server success, re-check canonical WordPress plugin state before updating the UI; keep full reload fallback when state cannot be reconciled.
+
+### Required Extensions
+
+- Legacy compatibility destination: `#atbdp-required-extensions-form`
+- The redesigned page uses this ID on the connected filter destination, not as a separate legacy form.
+- Opening the legacy hash selects Add-ons, Extensions, and Required, then focuses the Required filter.
+- Required products use the main `#atbdp-my-extensions-form` row and bulk-action contracts.
+- UI can show install, activate, or external get-now paths depending on ownership and install state.
+- Required extension data comes from `directorist_required_extensions` and current product/install state.
+
+### Theme Activate/Update
+
+- Theme activation action: `atbdp_activate_theme`
+- Theme update action: `atbdp_update_theme`
+- Risk: `switch_theme()` changes the live site theme, and update/download touches theme files.
+- Locked recommendation: every theme activation/switch must require an explicit confirmation modal before calling `atbdp_activate_theme`.
+- The confirmation must name the theme and warn that activating it changes the live site's active theme and may affect layout, menus, widgets, headers/footers, and theme settings.
+- Theme activation should not be treated like a normal button status change. It is a live-site change and must not run from accidental click, hover, keyboard focus, or Agent Browser automation without explicit confirmation.
+- After server success, re-check canonical active theme state before updating UI; keep full reload fallback when state cannot be safely reconciled.
+
+## External Handoff Points
+
+The page can link outside wp-admin for:
+
+- Product details
+- Get Now / purchase flows
+- Demo links
+- Directorist.com dashboard/account/support
+- Official docs
+
+External links must open intentionally and should not be mistaken for local AJAX state.
+
+## Journey Safety
+
+- Inspect forms and selectors read-only by default.
+- Use mocked AJAX, local throwaway sites, or explicit approval for action testing.
+- For no-reload improvements, update the local UI only after server success and then reconcile with canonical server state.
+- Keep full reload fallback available for remote/API/filesystem failures.
diff --git a/docs/agents/directorist-themes-extensions-page/references/api-data-flow-report.md b/docs/agents/directorist-themes-extensions-page/references/api-data-flow-report.md
new file mode 100644
index 0000000000..516000afbf
--- /dev/null
+++ b/docs/agents/directorist-themes-extensions-page/references/api-data-flow-report.md
@@ -0,0 +1,603 @@
+# API And Data Flow Report
+
+This report documents durable data sources and API routes for the Directorist admin `Themes & Extensions` page. It must not contain runtime product counts, installed product lists, subscribed product lists, user names, screenshots, or one-time AJAX payloads from a specific site.
+
+## Summary
+
+The page data does not come from one API. It is assembled from:
+
+- Local Directorist product definitions in `ATBDP_Extensions`.
+- Optional remote product catalog data from `app.directorist.com`.
+- Directorist.com account/license data fetched through WordPress AJAX handlers.
+- Local WordPress plugin, theme, update, option, transient, and user-meta state.
+- Existing `admin-ajax.php` actions that connect the jQuery admin UI to PHP handlers.
+
+Frontend JavaScript should not call Directorist.com directly. Keep the browser talking to local WordPress AJAX, then let PHP own remote API calls, WordPress state reads, filesystem work, and compatibility behavior.
+
+## Step-By-Step Page Load Flow
+
+1. WordPress loads `wp-admin/edit.php?post_type=at_biz_dir&page=atbdp-extension`.
+2. `ATBDP_Extensions::__construct()` registers the admin menu, AJAX actions, menu styling, and `initial_setup()` only for this page.
+3. `setup_ajax_actions()` registers the `wp_ajax_atbdp_*` action handlers when the current admin can `manage_options`.
+4. `initial_setup()` prepares extension aliases, calls `wp_update_plugins()`, reads required extensions from `directorist_required_extensions`, and calls `setup_products_list()`.
+5. `setup_products_list()` builds `$this->extensions` and `$this->themes`.
+6. `show_extension_view()` reads connected-account state from user meta, builds extension/theme overview arrays, and loads `admin-templates/theme-extensions/theme-extension`.
+7. The root template renders the account-connect form when disconnected, or statistics plus managed products when connected, and always renders the promo marketplace section.
+
+## Product Catalog Source
+
+Default behavior is local-first:
+
+- `ATBDP_Extensions::$load_from_api` is `false`.
+- Extensions come from `ATBDP_Extensions::get_default_extensions()`.
+- Themes come from `ATBDP_Extensions::get_default_themes()`.
+- Filters can modify these lists:
+ - `atbdp_extension_list`
+ - `atbdp_theme_list`
+
+Optional remote catalog path:
+
+- `Directorist\Core\API::get_products()`
+- Base URL: `https://app.directorist.com/wp-json/directorist/`
+- Endpoint: `v1/get-remote-products`
+- Effective URL: `https://app.directorist.com/wp-json/directorist/v1/get-remote-products`
+- Cache key: `directorist_products`
+- Cache duration: `30 * DAY_IN_SECONDS`
+- Empty remote response falls back to local defaults.
+
+Use the product catalog for product names, descriptions, thumbnails, product links, demo links, active promo flags, item IDs, optional plugin base overrides, and future optional product badge/status metadata. Do not store fetched catalog output in docs.
+
+Product copy/source policy for rewrite:
+
+- Prefer product API data for product name, description, thumbnail, product link, demo link, active promo flag, item ID, and plugin base when the API provides usable values.
+- Keep local product arrays as safe fallback for product name, description, thumbnail, product link, demo link, item ID, and plugin base when the API is unavailable, empty, malformed, missing a field, or disabled.
+- Do not render blank cards only because remote copy is missing; merge API data over local defaults by product key where possible.
+- Cross-check public product claims against local `README.md`/`readme.txt` and official Directorist docs before changing visible copy.
+- Badge/status values are different: render them only from product API or explicit local filters. Do not invent hardcoded badge/status fallback from local copy, product order, slug, or name.
+
+Future badge/status support should be added to this catalog contract as optional API data. EDD product meta or a dedicated product badge setting on Directorist.com can feed the API, but the core plugin UI should consume the product API field. Do not use normal EDD/WordPress `post_status` as the badge source because it represents product availability, not display labels such as `New`.
+
+Recommended optional badge payload:
+
+```json
+{
+ "badges": [
+ {
+ "type": "new",
+ "label": "New",
+ "expires_at": "2026-09-01"
+ }
+ ]
+}
+```
+
+Render no badge when this field is absent, expired, malformed, or disabled by filters.
+
+Directorist.com API implementation sketch:
+
+```php
+function directorist_get_product_api_badges( $product_id ) {
+ $raw_badges = get_post_meta( $product_id, '_directorist_product_badges', true );
+ $raw_badges = is_array( $raw_badges ) ? $raw_badges : [];
+ $badges = [];
+ $now = time();
+
+ foreach ( $raw_badges as $raw_badge ) {
+ $type = isset( $raw_badge['type'] ) ? sanitize_key( $raw_badge['type'] ) : '';
+ $label = isset( $raw_badge['label'] ) ? sanitize_text_field( $raw_badge['label'] ) : '';
+ $expires_at = isset( $raw_badge['expires_at'] ) ? sanitize_text_field( $raw_badge['expires_at'] ) : '';
+
+ if ( '' === $type || '' === $label ) {
+ continue;
+ }
+
+ if ( '' !== $expires_at ) {
+ $expires_timestamp = strtotime( $expires_at );
+
+ if ( false !== $expires_timestamp && $expires_timestamp < $now ) {
+ continue;
+ }
+ }
+
+ $badges[] = [
+ 'type' => $type,
+ 'label' => $label,
+ 'expires_at' => '' !== $expires_at ? $expires_at : null,
+ ];
+ }
+
+ return $badges;
+}
+```
+
+When building each product item for `v1/get-remote-products`, only include `badges` when `directorist_get_product_api_badges( $product_id )` returns a non-empty array.
+
+## Account Connection API
+
+Browser action:
+
+- Form: `#atbdp-directorist-license-login-form`
+- Current submit owner: `assets/js/directorist-themes-extensions.js`
+- Legacy compatibility handler: `assets/src/js/admin/components/subscriptionManagement.js`
+- Local AJAX URL: `directorist_admin.ajaxurl`
+- AJAX action: `atbdp_authenticate_the_customer`
+- Account request fields: `auth_method=account`, `username`, `password`, `nonce`
+- Access-key request fields: `auth_method=access_key`, `access_key`, `nonce`
+
+Server handler:
+
+- `ATBDP_Extensions::authenticate_the_customer()`
+- Remote method: `ATBDP_Extensions::remote_authenticate_user()`
+- Preferred remote endpoint: `POST https://directorist.com/wp-json/directorist-license-manager/user-login`
+- Preferred request fields: `email` (accepts username or email), `pass`, `domain`
+- Access-key method: `ATBDP_Extensions::remote_authenticate_user_by_access_key()`
+- Access-key endpoint: `POST https://directorist.com/wp-json/directorist-license-manager/user-connect`
+- Access-key request fields: `access_key`, `domain`
+- Compatibility fallback endpoint: `GET https://directorist.com/wp-json/directorist/v1/licencing`
+- Legacy request body: `user`, `password`
+- Headers include a Directorist user-agent and `Accept: application/json`.
+- The POST password remains raw only for the request lifetime. The legacy fallback retains its existing encoded password contract.
+- A `422` response for an email login is treated as an invalid-credentials result. A username rejected with `422` falls back to the legacy endpoint so core remains compatible while the older email-only License Manager is still deployed.
+- Transport errors, missing routes, other non-2xx responses, or malformed successful payloads also fall back to the legacy endpoint.
+- The preferred response is accepted only when both `plan_data.downloads.templates` and `plan_data.downloads.extensions` are arrays. A partial success response falls back instead of clearing or replacing existing entitlement state.
+- Credential-bearing POST requests do not follow HTTP redirects.
+- Access-key authentication has no legacy fallback. A `422` response is an invalid-key result; transport/non-2xx failures use the safe connection error.
+- The access key is a bearer credential. Core uses it for the current HTTPS request only, whitelists non-secret identity fields from `account_data`, discards any echoed `account_data.access_key`, never stores the key in WordPress options/user meta, never returns it in local AJAX responses, and asks for it again during Refresh Purchases.
+- The Directorist.com License Manager must protect `user-connect` with transport security, rate limiting/failed-attempt controls, key rotation/revocation, request logging that redacts the key, and a cryptographically secure key generator. Its response should omit `account_data.access_key`. Do not copy PR #2440's permissive local REST route or plaintext option storage into core.
+
+The preferred API returns `account_data` and `plan_data`. Core maps `plan_data.downloads.templates` and `plan_data.downloads.extensions` to the existing `license_data.themes` and `license_data.plugins` contract before storing current user meta:
+
+- `_atbdp_subscribed_username`
+- `_atbdp_has_subscriptions_sassion`
+- `_themes_available_in_subscriptions`
+- `_plugins_available_in_subscriptions`
+- `_atbdp_account_summary` when optional authoritative summary data exists
+- `_atbdp_subscription_connection_method` with `account` or `access_key`
+
+Keep the `sassion` spelling because it is part of the existing data/action contract.
+
+## Subscription Reads
+
+After connection, the page usually reads subscription data from local user meta instead of calling Directorist.com on every page load.
+
+Extension subscription reader:
+
+- `ATBDP_Extensions::get_purchased_extension_list()`
+- Meta key: `_plugins_available_in_subscriptions`
+- Filter: `directorist_purchased_extension_list`
+
+Theme subscription reader:
+
+- `ATBDP_Extensions::get_purchased_theme_list()`
+- Meta key: `_themes_available_in_subscriptions`
+- Filter: `directorist_purchased_theme_list`
+
+The subscription arrays are keyed from product permalinks by stripping:
+
+- `http://directorist.com/product/`
+- `https://directorist.com/product/`
+- `/`
+
+## Refresh Purchase Flow
+
+Browser action:
+
+- Form/action area: purchase refresh form in connected account UI
+- AJAX action: `atbdp_refresh_purchase_status`
+- Request fields: `password`, `nonce`
+
+Server flow:
+
+1. `handle_refresh_purchase_status_request()` validates capability and nonce.
+2. `refresh_purchase_status()` reads `_atbdp_subscribed_username`.
+3. It calls `remote_authenticate_user()` again, preferring the License Manager POST endpoint and falling back to the legacy licensing GET endpoint when the new route is unavailable or malformed.
+4. It rewrites `_themes_available_in_subscriptions` and `_plugins_available_in_subscriptions`.
+5. If the username/session is missing, it deletes `_atbdp_has_subscriptions_sassion` and returns a reload-required status.
+
+Current JavaScript reloads after success. A future no-reload version should refresh canonical state through a server response or state-summary endpoint.
+
+## Logout Flow
+
+Browser action:
+
+- Button selector family: `.subscriptions-logout-btn`
+- AJAX action: `atbdp_close_subscriptions_sassion`
+- Request fields: `hard_logout`, `nonce`
+
+Server flow:
+
+- Always deletes `_atbdp_has_subscriptions_sassion`.
+- If hard logout is enabled, also deletes:
+ - `_atbdp_subscribed_username`
+ - `_themes_available_in_subscriptions`
+ - `_plugins_available_in_subscriptions`
+
+Current JavaScript reloads after success.
+
+## Installed Extension Data
+
+Installed extension state is local WordPress data:
+
+- `get_plugins()`
+- `is_plugin_active()`
+- official extension keys from `$this->extensions`
+- alias map from `directorist_extensions_aliases`
+
+`get_extensions_overview()` builds:
+
+- installed extension list
+- active extension total
+- outdated extension list
+- available-in-subscription list
+- promo extension list
+- required extension list
+
+Use this server-side overview as canonical for rendered extension sections.
+
+## Extension Update API
+
+Browser action:
+
+- Button selector family: `.ext-update-btn`
+- AJAX action: `atbdp_update_plugins`
+- Request fields: optional `plugin_key`, `nonce`
+
+Server flow:
+
+1. `handle_plugins_update_request()` validates capability and nonce.
+2. `update_plugins()` calls `get_outdated_extensions_via_api()`.
+3. `get_outdated_extensions_via_api()` reads installed plugins via `get_plugins()`.
+4. It reads purchased extensions from `_plugins_available_in_subscriptions`.
+5. It checks each eligible installed product against EDD version data.
+
+Remote EDD version check:
+
+- Method: `wp_remote_post()`
+- Endpoint: `https://directorist.com`
+- Body fields:
+ - `edd_action=get_version`
+ - `license`
+ - `item_id`
+ - `version`
+ - `slug`
+ - `author=AazzTech`
+ - `url=home_url()`
+ - `beta=false`
+- SSL verification is controlled by `edd_sl_api_request_verify_ssl`.
+- Cache key prefix: `directorist_ext_version_`
+- Cache duration: `3 * HOUR_IN_SECONDS`
+
+If update is requested, download URL is fetched through the product-data API before filesystem update.
+
+## Theme Data And Update Flow
+
+Theme state is local WordPress theme data:
+
+- `wp_get_theme()`
+- `wp_get_themes()`
+- `get_option('stylesheet')`
+- `get_site_transient('update_themes')`
+- WordPress theme screenshot/customizer APIs
+
+Browser actions:
+
+- Theme activation: `atbdp_activate_theme`
+- Theme update: `atbdp_update_theme`
+
+Theme activation calls `switch_theme()`, so it must be treated as live-site destructive behavior.
+
+Theme update flow:
+
+1. `handle_theme_update_request()` validates capability and nonce.
+2. `update_the_themes()` checks `get_site_transient('update_themes')`.
+3. It reads purchased themes from `_themes_available_in_subscriptions`.
+4. It calls `get_file_download_link()` or falls back to the WordPress theme update package when available.
+5. It downloads and installs through `download_theme()`.
+
+## Download Link API
+
+Used for plugin and theme update/install flows when a subscription item has a license and item ID.
+
+- Method: `wp_remote_get()`
+- Endpoint: `https://directorist.com/wp-json/directorist/v1/get-product-data/`
+- Request body:
+ - `product_type`
+ - `license`
+ - `item_id`
+ - `get_info=download_link`
+ - optional `beta=true`
+- Returns: download URL in `response['data']`
+- Current code sets `sslverify` to `false`.
+
+Downloaded packages are then passed to `download_plugin()` or `download_theme()`. Current host verification allows `directorist.com` only in `is_varified_host()`.
+
+## License Activation API
+
+Used before install/download flows and when adding products to `_atbdp_purchased_products`.
+
+- Method: `wp_remote_get()`
+- Endpoint: `https://directorist.com`
+- Request body:
+ - `edd_action=activate_license`
+ - `url=home_url()`
+ - `item_id`
+ - `license`
+- Current code sets `sslverify` to `false`.
+- `item_name_mismatch` can still be treated as success when returned `item_id` matches the requested item ID.
+
+Successful activation can update `_atbdp_purchased_products`.
+
+## Install From Subscription Flow
+
+Browser action:
+
+- Button selector family: `.file-install-btn`
+- AJAX action: `atbdp_install_file_from_subscriptions`
+- Request fields: `item_key`, `type`, `nonce`
+
+Server flow:
+
+1. `handle_file_install_request_from_subscriptions()` validates capability and nonce.
+2. `install_file_from_subscriptions()` validates item key and product type.
+3. It selects subscription source:
+ - `plugin` -> `_plugins_available_in_subscriptions`
+ - `theme` -> `_themes_available_in_subscriptions`
+4. It confirms the item exists in the current subscription list.
+5. It activates the license through EDD.
+6. It selects beta or normal download link.
+7. It calls `download_plugin()` or `download_theme()`.
+
+This is high-risk because it can write plugin/theme files.
+
+## Plugin Activation And Bulk Actions
+
+Plugin activation:
+
+- Browser selector family: `.plugin-active-btn`
+- AJAX action: `atbdp_activate_plugin`
+- Server action: `activate_plugin($plugin_key)`
+
+Bulk installed-extension form:
+
+- Form: `#atbdp-my-extensions-form`
+- AJAX action: `atbdp_plugins_bulk_action`
+- Tasks: `activate`, `deactivate`, `uninstall`
+- Bulk nonce field in JS: `directorist_nonce`
+
+Uninstall calls `delete_plugins()`, so it must not be automated on real/client sites without explicit confirmation.
+
+## Required Extensions
+
+Required extensions do not come directly from a remote endpoint. They are composed from:
+
+- `directorist_required_extensions` filter
+- current official extension catalog
+- extension alias map
+- purchased extension user meta
+- plugin folder existence under the plugins directory
+- active plugin option state
+
+`prepare_the_final_requred_extension_list()` outputs required products with:
+
+- recommending references
+- base plugin file
+- purchased state
+- installed state
+
+The UI then decides whether to show install, activate, or external get-now actions.
+
+## Promo Product Rendering
+
+Promo cards are rendered from `$this->extensions` and `$this->themes`, after filtering out installed/subscribed products for connected users.
+
+Promo links are passed through `ATBDP_Upgrade::promo_link()`.
+
+These links can leave wp-admin for Directorist product, purchase, demo, account, or support pages. Treat them as external handoff points, not local state transitions.
+
+## Rendered Sections
+
+Root template:
+
+- Disconnected: account connect form.
+- Connected: statistics plus managed themes/extensions.
+- Always: all themes/extensions promo marketplace.
+
+Statistics section uses:
+
+- active extension total
+- available extension total
+- available theme total
+- extension update total
+- theme update total
+
+Installed extensions section uses:
+
+- `installed_extension_list`
+- `outdated_plugin_list`
+- `extension_list`
+- `settings_url`
+
+Subscribed extensions section uses:
+
+- `extensions_available_in_subscriptions`
+- `extension_list`
+- alias map
+
+Themes tab uses:
+
+- `current_active_theme_info`
+- `themes_available_in_subscriptions`
+- installed theme data
+- update state
+
+## Known API/Data Issues
+
+- `handle_file_download_request()` has `if ( 'plugin' !== $type || 'theme' !== $type )`, which is always invalid for both valid types. Prefer `atbdp_install_file_from_subscriptions` path unless this is intentionally fixed.
+- `activate_plugin()` currently calls `activate_plugin()` without checking `WP_Error`.
+- `handle_license_activation_request()` exists but is not registered in `setup_ajax_actions()`.
+- `get_customers_purchased()` references stale/undefined variables and should not be reused blindly.
+- Many successful AJAX paths immediately call `location.reload()`.
+- Remote calls mix REST-style Directorist endpoints and EDD action endpoints.
+- Some remote calls set `sslverify` to `false`; future hardening must be planned carefully for existing customers.
+
+## Future API Improvement Feedback
+
+Use these notes when changing the Themes & Extensions API layer. They are design constraints and improvement targets, not current runtime data.
+
+### Browser Boundary
+
+- Keep browser JavaScript calling local WordPress AJAX only.
+- Do not call Directorist.com, EDD, or package download URLs directly from the browser.
+- Let PHP own remote authentication, license activation, product catalog reads, package URL resolution, filesystem work, and canonical WordPress state checks.
+- Preserve existing `wp_ajax_atbdp_*` action names and request fields; add new response fields in a backward-compatible way.
+
+### Response Contract
+
+Create one normalized response formatter for all page actions while preserving legacy fields that current JavaScript or third-party code may expect.
+
+Recommended response fields:
+
+```json
+{
+ "success": true,
+ "code": "plugin_activated",
+ "message": "Plugin activated.",
+ "action": "activate_plugin",
+ "item_key": "directorist-extension-slug",
+ "type": "plugin",
+ "next_state": "active",
+ "requires_reload": false,
+ "state": {},
+ "html": {}
+}
+```
+
+Rules:
+
+- `success` must be the canonical boolean for new code.
+- `code` must be machine-readable and stable for UI handling, logs, and tests.
+- `message` must be safe for display and translatable when generated locally.
+- `requires_reload` must be explicit, especially for filesystem, update, theme switch, and unreconciled remote failures.
+- `state` may include a fresh canonical state summary, but docs must never store example site-specific values from it.
+- `html` may include rendered row/card partials when that is safer than duplicating template logic in JavaScript.
+
+### State Summary Endpoint
+
+Add a read-only state endpoint before removing reloads from mutation flows.
+
+Recommended local action:
+
+- `atbdp_get_themes_extensions_state`
+
+Recommended state groups:
+
+- `account`: connected/disconnected capability and refresh/logout availability.
+- `statistics`: counts and update flags generated at request time only.
+- `extensions`: installed, active, inactive, outdated, subscribed, required, promo, and action availability.
+- `themes`: active, installed, inactive, subscribed, update availability, and action availability.
+- `notices`: non-sensitive connection, license, update, and remote API messages.
+- `html`: optional server-rendered partials for rows/cards/counters.
+
+Rules:
+
+- Keep it capability and nonce protected.
+- Return no passwords, raw licenses, subscription secrets, or user-identifying account data.
+- Generate state from canonical server reads each time; do not let the browser invent final install/update/activation state.
+- Use this endpoint after successful mutations and after recoverable API failures that may leave the UI stale.
+
+### Error Codes And Failure Shape
+
+Map mixed Directorist REST, EDD, WordPress, and filesystem failures into stable local error codes.
+
+Recommended code categories:
+
+- `capability_denied`
+- `nonce_invalid`
+- `account_disconnected`
+- `auth_failed`
+- `subscription_missing`
+- `license_missing`
+- `license_activation_failed`
+- `remote_unreachable`
+- `remote_invalid_response`
+- `download_unavailable`
+- `download_host_invalid`
+- `filesystem_unavailable`
+- `package_invalid`
+- `install_failed`
+- `update_failed`
+- `activation_failed`
+- `theme_switch_failed`
+- `requires_reload`
+
+Rules:
+
+- Never show false success when WordPress returns `WP_Error`.
+- Include developer-safe diagnostic context only when it does not expose passwords, raw licenses, or private account data.
+- Prefer inline recoverable errors in the UI over browser `alert()` calls.
+
+### Product Catalog API
+
+Future product catalog API changes should be versioned and optional-field friendly.
+
+Recommended additions:
+
+- `schema_version`
+- `catalog_version` or another cache-busting marker.
+- `badge` object for `new`, `beta`, `popular`, `sale`, or similar labels.
+- `status` or `availability` only when it has a clear UI meaning separate from EDD/WordPress `post_status`.
+- stable product key/slug, item ID, product type, link, thumbnail, description, plugin base, demo link, and active promo flag.
+
+Rules:
+
+- Merge API product fields over local defaults by product key.
+- Keep local fallback for non-badge fields when API data is unavailable or incomplete.
+- Render badge/status only from API data or explicit filters.
+- Do not infer badges from product name, slug, order, install date, or local hardcoded lists.
+- Account for product catalog cache delay when adding time-sensitive badge behavior.
+
+### Cache And Freshness
+
+- Keep product catalog caching separate from account/subscription state.
+- Do not cache user subscription/license state as if it were public catalog data.
+- Prefer shorter or version-busted cache behavior for badge/status metadata than for long-lived product copy when product marketing needs faster updates.
+- Add explicit cache invalidation or refresh behavior for support/debug flows if remote product data appears stale.
+- Keep EDD version-check caching scoped enough to avoid cross-product contamination.
+
+### Credential And Transport Safety
+
+- Do not store account passwords after a request finishes.
+- Do not log passwords, raw licenses, or raw subscription payloads.
+- Use the License Manager POST endpoint for account authentication and keep the legacy GET endpoint as a compatibility fallback.
+- Never include credentials in a URL or log a request payload that contains `pass` or `password`.
+- The License Manager `valid_request()` middleware currently allows every request and does not provide application-level throttling. This predates the account-summary API work. Harden the preferred and legacy authentication endpoints together so attackers cannot bypass protection through the fallback endpoint.
+- Review every `sslverify => false` path. Harden with compatibility filters and actionable error messages rather than silently breaking older customer sites.
+- Use reasonable remote timeouts and map timeout failures to recoverable UI messages.
+
+### License Manager Release Gate
+
+- Build an installable archive with one top-level `directorist-license-manager/` directory and the runtime Composer dependencies required by the plugin.
+- Do not deploy a raw repository archive. Exclude `.git`, `.env`, `node_modules`, development dependencies, test fixtures, logs, database dumps, private keys, and source maps.
+- Bump the plugin version for every live replacement so WordPress and support diagnostics can identify the deployed code.
+- Before upload, lint changed PHP files, test the archive, verify required files exist, and compare the changed source hashes with the files inside the archive.
+- After upload, confirm both `/directorist-license-manager/user-login` and `/directorist/v1/licencing` remain registered.
+- Send an invalid credential request and confirm it returns a generic failure without account, entitlement, password, or license data.
+- Run one authorized account check after upload without logging the request or full response. Confirm the response keeps the established plan/download fields and adds only the optional `account_summary`.
+
+### Mutation Safety
+
+- Add per-item action locking or request IDs for install, update, activate, uninstall, refresh, and theme switch to reduce duplicate-click and concurrent-request risk.
+- Do not mark UI state final until the server accepts the action and a canonical state recheck confirms the result.
+- For install/update, validate download URL, host, package structure, unzip result, copy result, and final plugin/theme presence before returning success.
+- For theme switch, require explicit UI confirmation before AJAX and re-check `get_option( 'stylesheet' )` after success.
+- For uninstall, require explicit UI confirmation and re-check plugin filesystem/active state after success.
+
+## Redesign Guidance
+
+For no-reload or page-speed improvements:
+
+1. Keep browser calls pointed at local `admin-ajax.php`.
+2. Keep PHP as the remote API and WordPress state boundary.
+3. Add a stable server-side state summary before removing reloads.
+4. Let expensive actions show progress in-place, then reconcile with canonical server state.
+5. Keep full page reload as fallback for install, update, activation, theme switch, remote API failure, and filesystem failure.
diff --git a/docs/agents/directorist-themes-extensions-page/references/current-implementation-change-notes.md b/docs/agents/directorist-themes-extensions-page/references/current-implementation-change-notes.md
new file mode 100644
index 0000000000..622b059522
--- /dev/null
+++ b/docs/agents/directorist-themes-extensions-page/references/current-implementation-change-notes.md
@@ -0,0 +1,228 @@
+# Current Implementation Change Notes
+
+These notes capture durable Themes & Extensions page changes made during the current redesign pass. They are implementation context for future rewrite work, not runtime truth.
+
+Do not store runtime product counts, installed product lists, update counts, account names, subscription contents, or local screenshots here. Re-check live state at task time.
+
+## Scope
+
+- Page: `edit.php?post_type=at_biz_dir&page=atbdp-extension`.
+- Template: `views/admin-templates/theme-extensions/theme-extension.php`.
+- Page JS: `assets/js/directorist-themes-extensions.js`.
+- Page CSS: `assets/css/directorist-themes-extensions.css`.
+- Product data source class: `includes/classes/class-extension.php`.
+- Disconnected view remains locked unless a future task explicitly asks to change it.
+
+## Disconnected View
+
+- Disconnected view is treated as complete and locked.
+- Header only keeps Directorist branding and resource links for Docs, Tutorials, and Support.
+- Connected-state navigation, account menu, settings, active/update controls, and local product management are not shown while disconnected.
+- Account form uses `Username or email address` with an email-style placeholder.
+- Password field has a show/hide toggle.
+- Account login remains the default connection method for backward compatibility. A compact, keyboard-accessible Access key tab uses the License Manager `user-connect` endpoint through the existing `atbdp_authenticate_the_customer` AJAX action.
+- Access-key and account-login responses normalize into the same legacy session and entitlement user meta. The access key is request-only and is not persisted; `_atbdp_subscription_connection_method` stores only the non-secret method name.
+- Access-key help links to the Directorist.com account dashboard where the current theme exposes the key. Official Directorist installation/license docs still describe account email/password only, so those docs must be updated before publicly announcing Access Key login.
+- The disconnected page does not force focus on load, so the account form and marketplace remain browse-first; switching authentication methods focuses the first field in the selected panel.
+- Messaging clarifies that already installed Directorist products keep working, but account connection is required for subscription installs, updates, and license-backed management.
+- Disconnected product rows use marketplace/details-oriented actions instead of install/update/activate/deactivate/delete controls.
+
+## Connected Header And Account Menu
+
+- The large connected account stats/action card was removed from the body.
+- Connected header navigation now uses `Dashboard` and `Add-ons` as internal page views.
+- The existing Themes & Extensions catalog is labeled `Add-ons` in the header nav, but keeps the original page slug, selectors, forms, rows, and AJAX contracts.
+- `Docs`, `Tutorials`, and `Support` remain external resource links, not dashboard tabs, and are grouped on the right side of the connected header like the disconnected view.
+- The Dashboard welcome section uses the connected Directorist licensing account owner's display name. It falls back to a non-email licensing username and then generic `Welcome back`; it does not expose the licensing email or use the local WordPress administrator identity.
+- The connected header account control uses the optional authoritative licensing-account `avatar_url`, with a Gravatar-derived URL or licensing-owner initials as safe client fallbacks. The account dropdown identifies the licensing owner instead of the local WordPress user.
+- The Dashboard consumes the optional `_atbdp_account_summary` user meta when a compatible licensing API provides authoritative plan status, expiry, All Access, and lifetime fields.
+- Active All Access plans can show the dynamic expiration/unlocked message; lifetime, expired, cancelled, limited, missing, and malformed states use dedicated safe copy or the generic connected fallback.
+- Legacy licensing responses remain compatible because absent account-summary data does not alter existing plugin/theme subscription state.
+- Account authentication now prefers the Directorist License Manager POST endpoint, maps its product response into the existing `license_data` shape, and falls back to the legacy licensing GET endpoint when the new route is unavailable or malformed.
+- The Directorist.com License Manager accepts either username or email, returns optional `plan_data.account_summary`, and leaves existing response fields intact.
+- Until the username-capable License Manager reaches Directorist.com, a username rejected by the older email-only POST route falls back to the legacy licensing endpoint instead of breaking existing connections.
+- Optional account-summary generation is fail-open on Directorist.com; an EDD summary exception returns the established licensing payload with `account_summary: null`.
+- Core accepts the new authentication response only when both theme and extension entitlement arrays have the expected shape, and credential-bearing POST requests do not follow redirects.
+- `View listings` uses the configured Directorist All Listings page and is hidden when no directory type exists.
+- `Add listing` uses the configured frontend Directorist Add Listing page, allowing the existing form to handle one or multiple directory types. With no directory type, it becomes `Create directory` and links to Directory Builder.
+- The Dashboard footer uses the runtime `ATBDP_VERSION`, the API-backed account-summary plan name/status with legacy-safe fallbacks, and a filterable official changelog URL. It must not contain a hardcoded plugin version or plan name.
+- Dashboard summary metrics now use current local Directorist data for published listings, listing views, pending listings, upcoming expirations, paid-order revenue, and paid-order count.
+- Fake metric percentages and decorative trend lines were removed because there is no historical analytics contract for those comparisons.
+- Account summary moved into the avatar dropdown so the product list starts closer to the page title/update banner.
+- Avatar dropdown is click-only for opening. Hover and focus alone do not open it.
+- Dropdown closes on outside click, Escape, and focus leaving the menu.
+- Dropdown contains a labeled connected state, compact dynamic account summary tiles, touch-sized `Refresh purchases`, and a visually separated `Disconnect account` action.
+- `Refresh purchases` keeps the existing `#purchase-refresh-form` and `atbdp_refresh_purchase_status` AJAX action. It requests a Directorist password for account-login connections and an access key for access-key connections.
+- `Disconnect` keeps `.subscriptions-logout-btn` and `atbdp_close_subscriptions_sassion` compatibility.
+- Returning-customer reconnect reuses the first validated authentication response to replace saved theme/extension entitlements and account summary. It no longer calls `refresh_purchase_status()` automatically and therefore does not issue a duplicate remote authentication request.
+- The reconnect response still preserves `has_previous_subscriptions`, and the existing page reload remains in place. Manual Refresh Purchases behavior is unchanged.
+- Refresh purchase password inputs are hidden from tab order until the user opens that panel. The revealed form uses a visible label, password visibility toggle, text submit action, live feedback region, and an explicit non-submit close button.
+- Closing the inner refresh-purchase form keeps the account dropdown open, clears the password and feedback, restores the default Refresh Purchases row, and returns focus to that row.
+- Closing the account dropdown itself still resets any open refresh form, so reopening the dropdown always starts from the default account-summary state.
+- The page-specific refresh controls suppress the legacy width/display animation handlers only inside the connected account dropdown and clear stale animation styles. Legacy refresh layouts outside this dropdown keep their established behavior.
+- Connected header search was removed. Catalog search remains in the Add-ons toolbar as the single supported search control.
+
+## Directory-Aware Dashboard Quick Actions
+
+- Quick Actions re-collect current `atbdp_listing_types` terms on each connected Dashboard request; runtime directory names, IDs, and counts are not stored as documentation truth.
+- With multiple directories, one compact Directory selector controls Add Listing, Categories, Listing Layout, and Submission Form links without reloading the Dashboard.
+- The selector starts from the valid default directory, restores a still-valid session selection, and falls back safely when a stored directory no longer exists.
+- Quick Actions `Add a listing` opens the WordPress admin editor with `post_type=at_biz_dir&directory_type={term_id}`. The admin metabox accepts that sanitized, valid directory only when the new listing has no saved directory; saved listing metadata remains authoritative. The existing admin listing loader then uses that directory's `submission_form_fields`; Builder data is not copied into the Dashboard.
+- `Manage categories` opens the Directorist category admin scoped with the selected `directory_type`.
+- `Customize listing layout` opens the selected Directory Builder at `#single_page_layout__contents`.
+- `Submission form settings` opens the selected Directory Builder at `#submission_form`.
+- `Email notifications` remains global and opens Directorist Settings at the email notification channel.
+- With one directory the selector is hidden and links are pre-bound. With no directories, the card shows Create directory and Email notifications only.
+- Directory Builder navigation resolves valid layout/submenu hashes before its saved localStorage tab state. Missing or invalid hashes keep the established saved/default fallback behavior.
+- The connected welcome CTA remains the configured frontend Add Listing journey; only the Quick Actions row uses the directory-scoped WordPress admin editor.
+
+## Connected View And Product-Type State
+
+- A connected clean page URL renders Dashboard by default. The WordPress Themes & Extensions sidebar link remains the clean entry point.
+- Header and product-type state use optional, whitelisted URL parameters: `te_view=addons` and `te_type=extension|theme`. Missing or invalid values fall back to Dashboard and All.
+- Dashboard is the implicit URL default, Add-ons uses `te_view=addons`, and All is the implicit product-type default. Default parameters are removed from the URL.
+- Selecting Extensions or Themes remains remembered while switching Dashboard and Add-ons, and the current state survives reloads and full-page action fallbacks.
+- State changes use `history.replaceState()` and do not reload the page or add tab-by-tab browser history entries.
+- The legacy `#atbdp-required-extensions-form` route remains authoritative and forces Add-ons, Extensions, and Required.
+- Disconnected rendering ignores these connected-view parameters and keeps the locked account-connect/marketplace experience.
+
+## Product Catalog And Toolbar
+
+- Disconnected users always receive the complete current extension and theme catalogs. Installed/subscription promo exclusions apply only to connected users and can no longer reduce the disconnected marketplace to a partial list.
+- Search was moved into the toolbar near the catalog count.
+- Primary tabs keep dynamic All, Extensions, and Themes counts.
+- Status segmented filter keeps All, Installed, Not installed, Required, and Updates. Required is rendered only when the current theme has outstanding Directorist requirements.
+- Installed and Not installed render compact row-derived count badges. Counts update for the selected All, Extensions, or Themes scope without a remote request.
+- Existing theme links to `#atbdp-required-extensions-form` remain compatible. The hash opens Add-ons, selects Extensions and Required, clears catalog search, and focuses the Required filter.
+- Required subscription, installed-inactive, and marketplace metadata is merged into one canonical product row. Purchased products keep Install/Activate actions, unowned products keep Get It Now, and duplicate subscription/required/promo rows are suppressed.
+- The disconnected fallback preserves the locked design and directs the legacy hash to the existing account-connect area without exposing product-management actions.
+- Updates count renders as a small warning pill.
+- Update status labels render the target version when available, for example `vX.Y.Z available`; fallback remains `Update available`.
+- Theme update metadata now carries `new_version` from the WordPress theme update transient into active theme row state.
+
+## Connected Header Notifications
+
+- The connected header bell uses existing server-rendered page state only. It does not add a polling endpoint, persistence layer, or separate update engine.
+- Current notification sources are extension updates, theme updates, and outstanding extensions declared through the existing required-extension contract.
+- With no actionable state, the dropdown renders an all-caught-up message and no count badge.
+- Clicking a notification never installs or updates a product. It opens Add-ons, selects Extensions or Themes, selects Updates or Required, scrolls to the catalog toolbar, focuses the status filter, and briefly highlights the selected controls.
+- The notification and account dropdowns are mutually exclusive, close on outside click or Escape, and restore focus to their trigger.
+- Do not add license expiry, renewal, remote API failure, or deprecated-product notifications until those states have a reliable normalized data contract and a safe destination.
+
+## Badges
+
+- Badge rendering supports scalar badges such as `New` and structured badges such as `{ type, label, expires_at }`.
+- Empty, malformed, duplicate, or expired badges are ignored.
+- Badge class names are generated from badge type, for example `directorist-te-badge--new`, `--popular`, and `--trending`.
+- Local badge fallback was added only for existing local product entries. API/filter-provided badge data remains the desired source of truth.
+- Badge terms are searchable.
+- Search highlights matching text in product titles and descriptions.
+- Badge styling was reduced to keep row height compact.
+
+## Local Product Fallback Additions
+
+- Added local fallback entries for Directorist Notifications Pro and Directorist Divi Integration because the product API can return these products while the local fallback list did not previously include them.
+- Added local images for those fallback products.
+- These entries should remain display/catalog fallbacks only; live product metadata should still come from API/filter data when available.
+
+## Active Theme Handling
+
+- The active site theme row remains visible because it explains what theme controls the live WordPress site and provides access to Customize.
+- If the active theme is a Directorist theme, type label is `Directorist theme`.
+- If the active theme is not in the Directorist theme catalog, type label is `WordPress theme`.
+- Active theme badge is `Active site theme`.
+- Default WordPress themes are not presented as Directorist products.
+- Active theme row keeps a single visible `Customize` action. Duplicate overflow `Customize` was removed.
+- Theme activation remains high risk and should require explicit confirmation before calling `atbdp_activate_theme`.
+
+## Row Actions
+
+- Product detail actions are exposed directly instead of hiding essential discovery behind overflow.
+- Demo actions are exposed directly where applicable.
+- Active plugin rows use `Settings` as the primary action when no update is available.
+- Active installed plugin overflow contains `Deactivate` only when applicable.
+- Inactive installed plugin overflow uses `Delete plugin` as the destructive file-removal action.
+- `Delete` remains protected by confirmation and should never be exposed as an easy primary action.
+- Duplicate Settings/Customize-style actions should be avoided.
+
+## Selection And Bulk Actions
+
+- Rows with no safe bulk action render a disabled checkbox instead of silently omitting the selection cell.
+- Master checkbox selects visible selectable rows only.
+- Bulk bar no longer requires every selected item to share the same action.
+- Bulk action visibility is now based on the union of eligible selected actions.
+- Each visible bulk action shows a dynamic eligible-item count.
+- Clicking a bulk action runs only the selected items that support that action; unsupported selected items are skipped.
+- Button title/ARIA text explains how many selected items will run and how many will be skipped.
+- Delete bulk action remains destructive and requires confirmation.
+- Delete is limited to items marked eligible for uninstall/delete, not active plugins.
+- Existing AJAX contracts are preserved:
+ - `atbdp_install_file_from_subscriptions`
+ - `atbdp_update_plugins`
+ - `atbdp_update_theme`
+ - `atbdp_plugins_bulk_action`
+
+## Connected View Responsive Fixes
+
+- Connected header is not sticky, preventing overlap with product rows.
+- Connected nav can wrap/scroll on smaller screens without clipping the active label.
+- Product titles and badges are allowed to wrap on narrow screens.
+- Toolbar/search/count and bulk bar were checked for no horizontal overflow.
+
+## Connected Sidebar Routing And Activity
+
+- Connected users get a `Dashboard` Directorist submenu at the top. Its registered page slug remains `atbdp-extension` and the clean URL renders Dashboard.
+- The existing `Themes & Extensions` submenu remains available and adds `te_view=addons`, so it opens Add-ons directly.
+- Header Dashboard/Add-ons switching updates the clean/add-ons URL and the matching WordPress submenu current state without reloading.
+- Disconnected users keep one `Themes & Extensions` submenu and the locked Add-ons/connect view; Dashboard is not exposed while disconnected.
+- Recent Activity no longer contains reference-design names, amounts, dates, or listings. `ATBDP_Extension_Activity` builds normalized items from current listings, Directorist reviews, paid modern table-based orders, completed legacy order posts, Directorist user registrations, and upcoming listing expirations.
+- Modern orders from `directorist_orders` are preferred. Legacy `atbdp_orders` posts remain supported, and migrated legacy IDs are excluded to prevent duplicate payment activity or revenue.
+- The Dashboard card renders at most five current items. `View all` opens an accessible side drawer and lazily calls `directorist_te_get_activity`.
+- Activity drawer filters are All, Listings, Reviews, Payments, and Users. Results load ten at a time with an explicit Load more action; no infinite scroll or background polling is used.
+- The activity endpoint requires `manage_options` and the established `atbdp_nonce_action_js` nonce. It returns display-only action URLs and never installs, updates, activates, disconnects, or modifies product state.
+- Final activity data is filterable through `directorist_themes_extensions_activity_data`. Runtime activity items and counts must not be stored in docs.
+- Dashboard summary cards now use current published/pending listing counts, listing-view meta, upcoming expirations, and paid-order totals instead of reference-design numbers.
+- The setup checklist now evaluates current directory types, categories, active gateways, and published listings. Its progress, copy, completion state, and links are generated on each request.
+- The directory setup link follows the registered mode: `atbdp-layout-builder` for single-directory sites and the `atbdp-directory-types` overview for multi-directory sites.
+- Multi-directory mode intentionally does not deep-link to a default directory or the add-new screen. The overview lets administrators see all directory types before deciding which one to edit or create.
+- The payment gateway shortcut uses the settings route `#monetization_settings__gateway`.
+- Completed setup items remain readable, clickable maintenance shortcuts; do not style them as disabled or crossed-out text.
+- Setup completion is informational only. Rendering the connected Dashboard must never create demo content, enable a gateway, publish a listing, or change settings automatically.
+
+## Directory-Aware Dashboard Recommendations
+
+- The connected Dashboard recommendation section is dynamic; the disconnected view remains locked and unchanged.
+- `ATBDP_Extension_Recommendations` owns the centralized profile registry, directory classification, API override merge, product-state resolution, and final card data.
+- The active recommendation group comes from real `atbdp_listing_types` terms and starts from the current default directory.
+- Recommendations rotate every six seconds through real directory terms. When a directory returns, its next deterministic three-card window is shown; the implementation does not use unpredictable random ordering.
+- Hover, keyboard focus, browser-tab inactivity, the explicit Pause control, and `prefers-reduced-motion` stop automatic rotation. Previous, next, and the compact native directory selector remain available without reload.
+- The recommendation directory chooser mirrors the Quick Actions control: a visible `Directory` label and the same select height, typography, border, radius, focus treatment, and responsive sizing. Previous, next, and pause remain grouped as secondary carousel controls.
+- Known directory profiles render their ordered recommendation candidates three at a time. Unknown/custom directory types use the generic candidate pool three at a time and never expose the complete extension catalog as a recommendation group.
+- Directory mappings are recommendations, not hard dependencies. The `Required` product state remains reserved for extensions declared through the existing theme-required-extension contract.
+- Recommendation cards keep installed products visible:
+ - Active extensions show `Active` with no management CTA.
+ - Installed inactive extensions show `Installed` with the existing Activate action.
+ - Entitled uninstalled extensions show `Not installed` with the existing Install action.
+ - Unowned catalog products show `Available` with an external View details action.
+- Product actions reuse `.plugin-active-btn`, `.file-install-btn`, existing data keys, AJAX actions, and nonce handling.
+- Profile data is filterable through `directorist_extension_recommendation_profiles`; final prepared data is filterable through `directorist_extension_recommendation_data`.
+- Optional per-product API `recommendations` metadata can override local profile placement. Missing metadata preserves fallback placement, an empty array removes that product from all profiles, and malformed non-empty metadata is ignored.
+- Product copy, thumbnail, link, installation state, activity state, and entitlement still come from the existing dynamic catalog and WordPress state. The template does not hardcode product cards.
+- Post Your Need is no longer a local catalog product or recommendation candidate because it was removed from the remote product API.
+- The predefined Post Your Need directory profile remains supported and recommends other available products. Legacy detection for already-installed Post Your Need extension copies remains untouched for customer compatibility.
+- Automatic rotation, manual switching, card-window changes, and pause/resume are page-local interactions and make no remote request.
+
+## Compatibility Rules Preserved
+
+- Page slug remains `atbdp-extension`.
+- Parent post type remains `at_biz_dir`.
+- Existing AJAX action names and nonce patterns are preserved.
+- Existing selectors/classes needed by legacy JS are preserved where actions still depend on them.
+- Legacy user meta key `_atbdp_has_subscriptions_sassion` and AJAX action `atbdp_close_subscriptions_sassion` are preserved, including misspellings.
+- Product filters and local fallback data remain part of the product merge path.
+
+## Known Follow-Up Notes
+
+- Many expensive actions still use full reload after server success. Future no-reload work should progressively enhance around existing AJAX responses and re-fetch canonical state.
+- API badge source should move to Directorist.com/EDD product metadata so local fallback badges can eventually be removed.
+- Build assets were not regenerated during these source edits unless explicitly stated in the task.
diff --git a/docs/agents/directorist-themes-extensions-page/references/dynamic-data-contract.md b/docs/agents/directorist-themes-extensions-page/references/dynamic-data-contract.md
new file mode 100644
index 0000000000..000f000c30
--- /dev/null
+++ b/docs/agents/directorist-themes-extensions-page/references/dynamic-data-contract.md
@@ -0,0 +1,304 @@
+# Dynamic Data Contract
+
+This page is driven by live WordPress state, remote Directorist/EDD state, user meta, filters, and current filesystem/plugin/theme state. Do not store runtime output in this docs agent.
+
+## What Not To Store
+
+Never commit these values into the skill:
+
+- Product counts
+- Installed extension lists
+- Active/inactive extension lists
+- Subscribed extension or theme lists
+- Update counts
+- Current theme name
+- Connected username/account details
+- Local screenshots or network logs
+- Site-specific AJAX responses
+
+Use those values only as temporary evidence for the current task.
+
+## Product Item Shapes
+
+Extension catalog items commonly include:
+
+- `name`
+- `description`
+- `link`
+- `thumbnail`
+- `active`
+- `item_id`
+- `base` when the plugin base differs from the catalog slug
+- optional `badge` object when remote/API support exists
+
+Theme catalog items commonly include:
+
+- `name`
+- `description`
+- `link`
+- `demo_link`
+- `thumbnail`
+- `active`
+- optional `badge` object when remote/API support exists
+
+## Product Copy Source Policy
+
+- Product API data is the preferred source for product name, description, thumbnail, product link, demo link, active promo flag, item ID, and plugin base when valid API values exist.
+- Local default product arrays remain the fallback source for product name, description, thumbnail, product link, demo link, item ID, and plugin base.
+- Merge API data over local defaults by product key when possible so missing remote fields do not create blank cards.
+- If the API is unavailable, empty, malformed, or missing a non-badge field, render the local fallback field.
+- Badge/status fields do not use hardcoded local fallback. Render badge/status only when provided by product API data or explicit filters.
+- Do not store the current merged catalog output in this skill.
+
+Optional product badge/status shape:
+
+- Preferred API field: `badges`, an array of badge objects.
+- Legacy/filter-compatible field: `badge`, a single badge object or scalar label.
+- `type`: machine-readable badge type such as `new`, `beta`, `popular`, `sale`, `trending`, or `featured`
+- `label`: display label such as `New`
+- `expires_at`: optional expiration date/time; expired badges must not render
+
+Badge/status data should come from Directorist.com product API data. EDD product meta, a dedicated product badge setting, or taxonomy can be the upstream source on Directorist.com, but the core plugin should consume the API field. Local product-list filters may add or override badge data for compatibility/testing. Do not infer badges from product order, names, slugs, or local runtime state.
+
+Purchased/subscribed items returned from the account journey may include title, slug/key, item id, license, URL, download/package data, or product type. Re-check the current handler before relying on a field.
+
+## Account Summary Availability
+
+The connected dashboard can safely resolve these values without a new remote API contract:
+
+- Current WordPress user's first name or display name
+- Directorist account connected state
+- Whether subscribed extension or theme entitlements are present
+- Installed, active, and outdated plugin/theme state
+- Local directory type count, names, and slugs
+- Configured All Listings and Add Listing page URLs
+
+The legacy licensing flow does not expose these account-level fields through a normalized, reliable contract:
+
+- Global Directorist plan name
+- Account-level subscription status such as active, expired, or cancelled
+- Global plan expiration or renewal date
+- Authoritative all-access/every-product-unlocked boolean
+- Remote account display name intended for UI greetings
+
+The newer Directorist License Manager API provides an optional normalized `plan_data.account_summary` from EDD All Access pass data. Core stores the sanitized summary in `_atbdp_account_summary` during account connection/refresh and removes it on disconnect. When EDD All Access data is unavailable, no matching pass exists, the field is malformed, or the legacy API is used, fields remain null/unknown and core renders generic connected-account copy.
+
+The current connection method is stored separately in `_atbdp_subscription_connection_method` as `account` or `access_key`. This is non-secret UI/refresh state. Never store the submitted access key as runtime truth or persistent credential; request it again when an access-key-connected customer refreshes purchases.
+
+Do not infer missing account fields from product counts, individual product licenses, installed products, or the connected username/email.
+
+The backward-compatible account summary uses nullable fields where the server cannot determine a value:
+
+```json
+{
+ "plan_data": {
+ "account_summary": {
+ "display_name": null,
+ "avatar_url": null,
+ "plan_name": null,
+ "subscription_status": "unknown",
+ "expires_at": null,
+ "all_access": false,
+ "is_lifetime": false
+ }
+ }
+}
+```
+
+`subscription_status` accepts `active`, `expired`, `cancelled`, or `unknown`. The API date is ISO 8601; core formats it using the customer site's WordPress date settings. `avatar_url` is optional, sanitized, and generated by Directorist.com for the authenticated licensing owner. Core uses licensing-owner initials when it is missing.
+
+Still unavailable when the customer has no usable EDD All Access pass:
+
+- Authoritative non-All-Access membership plan name
+- Renewal date distinct from access expiration
+- Cancellation-at-period-end state
+- Billing interval, renewal price, and payment method
+
+## Runtime State Categories
+
+The UI must handle these dynamic states:
+
+- Not connected to Directorist account
+- Not connected while Directorist premium products may already be installed locally; the page should still keep the current disconnected UI unless explicitly changed
+- Not connected messaging should clarify that already installed extensions keep working, while account connection is required to manage subscriptions, installs, and updates
+- Connected account with no purchased products
+- Connected account with subscribed plugins
+- Connected account with subscribed themes
+- Product installed and inactive
+- Product installed and active
+- Product installed and outdated
+- Product installed with expired or missing license/update entitlement
+- Product subscribed but not installed
+- Product required by another feature
+- Product required but not purchased
+- Promo-only product
+- Promo/subscribed/installed product with optional API-provided badge
+- Active theme
+- Installed inactive theme
+- Subscribed theme not installed
+- Theme update available
+- Remote API failure
+- Filesystem/download failure
+- Capability or nonce failure
+- Beta package mode
+
+These are categories, not counts. Always collect current values during the task.
+
+## Directory Recommendation Contract
+
+Connected Dashboard recommendations are derived at request time from:
+
+- Current `atbdp_listing_types` terms: term ID, name, slug, and default-directory state
+- The current extension catalog after API/local fallback/filter merging
+- Current installed plugin bases and `is_plugin_active()` state
+- Current uninstalled subscription entitlements
+- Optional product API recommendation metadata
+
+Do not store observed directory names, term counts, selected terms, recommendation cards, or installation results in this reference as truth.
+
+The local profile registry accepts ordered product slugs and remains the compatibility fallback. It is filterable through `directorist_extension_recommendation_profiles`.
+
+Optional product API metadata uses this shape:
+
+```json
+{
+ "recommendations": [
+ {
+ "profile": "restaurant",
+ "priority": 100,
+ "reason": "Accept reservations from listing pages."
+ }
+ ]
+}
+```
+
+Compatibility semantics:
+
+- Field absent: retain the local placement for that product
+- Empty array: remove the product from every recommendation profile
+- Valid array: replace local placement with validated API placements
+- Malformed non-empty data: ignore the override and retain local placement
+- Unknown profile keys: ignore that placement
+- Priority: integer clamped to `0..100`
+- Reason: optional sanitized plain text; product description remains fallback
+
+Recommendation state labels/actions are resolved locally and are never trusted from profile metadata:
+
+- Active: show `Active`, no recommendation CTA
+- Installed inactive: show `Installed`, reuse Activate
+- Entitled and uninstalled: show `Not installed`, reuse Install
+- Not entitled: show `Available`, link to product details
+- Missing catalog product: omit and continue to the next configured candidate
+
+Presentation rules:
+
+- Show no more than three recommendation cards at once.
+- Multiple real directory types rotate in stable term order; do not randomize directory order.
+- A directory with more than three candidates advances through deterministic three-card windows when it returns.
+- Unknown/custom directory types use the generic candidate pool, not the complete catalog.
+- Automatic rotation pauses on hover, focus, tab inactivity, explicit user pause, and reduced-motion preference.
+- Directory-profile mappings are `Recommended`; only the established theme requirement contract may label a product `Required`.
+
+## Current State Collection
+
+Use read-only browser checks:
+
+```bash
+agent-browser --session directorist-themes-extensions --profile Default --ignore-https-errors open "https://directorist-core.local/wp-admin/edit.php?post_type=at_biz_dir&page=atbdp-extension"
+agent-browser --session directorist-themes-extensions snapshot -c -d 4
+agent-browser --session directorist-themes-extensions eval 'JSON.stringify({connected:!!document.querySelector("#purchase-refresh-form"),auth:!!document.querySelector("#atbdp-directorist-license-login-form"),hasRequired:!!document.querySelector("#atbdp-required-extensions-form"),overflow:document.documentElement.scrollWidth>innerWidth})'
+```
+
+Use source checks:
+
+```bash
+rg -n "setup_ajax_actions|show_extension_view|setup_products_list|get_extensions_overview|get_themes_overview" includes/classes/class-extension.php
+rg -n "location.reload|atbdp_|file-install-btn|plugin-active-btn|theme-activate-btn" assets/src/js/admin/components/subscriptionManagement.js
+rg -n "theme-extensions|atbdp-extension|directorist_required_extensions|directorist_extensions_aliases" includes views assets/src/js assets/src/scss
+```
+
+Close the browser session when finished:
+
+```bash
+agent-browser --session directorist-themes-extensions close
+```
+
+## Compatibility Contracts
+
+Keep these stable unless a migration plan is explicitly approved:
+
+- Page slug: `atbdp-extension`
+- Parent post type: `at_biz_dir`
+- Capability: `manage_options`
+- AJAX actions listed in `page-architecture-map.md`
+- User meta keys for connected account and subscriptions
+- Filters for product lists, required extensions, and aliases
+- Settings links back to the settings panel
+- Existing template override expectations for admin templates
+- Existing selector/class hooks used by custom CSS or scripts
+
+New no-reload behavior must preserve the same server-side action contracts or provide a compatibility wrapper.
+
+## Connected Dashboard Activity Contract
+
+The Recent Activity card and drawer derive display data from existing local sources:
+
+- Listing activity: `at_biz_dir` posts with published, pending, or draft status
+- Review activity: WordPress comments with `comment_type=review`
+- Payment activity: modern `directorist_orders` rows with `status=paid` and a positive amount, plus unmigrated `atbdp_orders` posts with `_payment_status=completed` and a positive `_amount`
+- User activity: WordPress users with Directorist `_user_type` metadata
+- Upcoming expirations: published listings with a finite `_expiry_date`
+
+The UI must not infer remote licensing events, subscription renewals, failed payments, or product update events from these local sources.
+
+The AJAX response is normalized:
+
+```json
+{
+ "success": true,
+ "data": {
+ "items": [
+ {
+ "id": "listing-123",
+ "type": "listing",
+ "title": "Listing published",
+ "subject": "Example listing",
+ "context": "by Example owner",
+ "timestamp": 1780000000,
+ "icon": "la la-plus",
+ "tone": "blue",
+ "action_label": "Edit",
+ "action_url": "https://example.test/wp-admin/post.php?post=123&action=edit",
+ "upcoming": false,
+ "group": "today",
+ "group_label": "Today",
+ "time_label": "5 minutes ago"
+ }
+ ],
+ "has_more": false,
+ "next_page": null,
+ "page": 1,
+ "type": "all"
+ }
+}
+```
+
+The example describes shape only. Never store an observed activity item, site URL, count, title, user, amount, or timestamp as documentation truth.
+
+Connected Dashboard metric values are also collected at request time:
+
+- Published and pending counts: `at_biz_dir` post statuses
+- Listing views: `_atbdp_post_views_count` summed for published listings
+- Upcoming expiration count: finite `_expiry_date` values within the configured display window
+- Revenue and paid-order count: paid modern orders plus completed unmigrated legacy order posts
+
+Modern orders with a `legacy_id` suppress the corresponding legacy post from both activity and metric totals. No trend percentage or sparkline should be presented as real data until a time-series analytics contract exists.
+
+Connected Dashboard setup progress is also request-scoped:
+
+- Directory step: at least one current `atbdp_listing_types` term
+- Category step: at least one current `at_biz_dir-category` term
+- Gateway step: at least one value returned by `ATBDP_Gateway::get_active_gateways()`
+- Listing step: at least one published `at_biz_dir` post
+
+The setup section may link to the established admin screens, but it must not mutate any of these states. Never store observed setup progress or completion values in this document.
diff --git a/docs/agents/directorist-themes-extensions-page/references/licensing-integration-map.md b/docs/agents/directorist-themes-extensions-page/references/licensing-integration-map.md
new file mode 100644
index 0000000000..44c27292a1
--- /dev/null
+++ b/docs/agents/directorist-themes-extensions-page/references/licensing-integration-map.md
@@ -0,0 +1,199 @@
+# Licensing Integration Map
+
+This reference maps the systems connected to licensing for the Directorist admin `Themes & Extensions` page. It records durable contracts and ownership boundaries only. Never store credentials, access keys, license keys, customer names, entitlement lists, product counts, or one-time API responses in this file.
+
+## System Boundaries
+
+| System | Responsibility | Canonical data |
+| --- | --- | --- |
+| Directorist core plugin | Renders the local admin UI, sends local AJAX requests, normalizes remote account responses, caches entitlements in user meta, and performs WordPress install/update/activation work | Local WordPress plugin/theme state and the current admin user's cached connection state |
+| Directorist License Manager | Authenticates a Directorist.com account or access key and assembles EDD customer, entitlement, license, plan, and account data | Directorist.com account and EDD-backed licensing data |
+| Sovware User Dashboard | Shows the signed-in Directorist.com customer their access key with reveal and copy controls | Presentation only; key generation and storage belong to License Manager |
+| Directorist.com WordPress users | Own the account identity and License Manager-managed access credential | User ID, login/email, display name, avatar |
+| Easy Digital Downloads | Owns purchases, downloadable products, customers, and license records | Product IDs, files, purchases, licenses, customer records |
+| EDD All Access | Supplies account-level pass name, status, expiration, lifetime, and all-access state when available | All Access pass objects |
+| EDD Software Licensing | Activates licenses and supplies version/update eligibility | License activation and version responses |
+| Legacy Directorist licensing API | Compatibility fallback for older deployments | Existing `license_data` response |
+| Directorist product API | Supplies catalog copy and optional badges/recommendation metadata; it is not the entitlement authority | Remote product catalog |
+
+## Active Account Login Flow
+
+1. The browser submits the page form to local WordPress `admin-ajax.php`.
+2. Existing AJAX action `atbdp_authenticate_the_customer` calls `ATBDP_Extensions::authenticate_the_customer()`.
+3. Core sends a server-side `POST` request to:
+ - `https://directorist.com/wp-json/directorist-license-manager/user-login`
+ - Fields: `email`, `pass`, `domain`
+4. License Manager accepts either username or email through `AccountRepository::authenticate_user_login()`.
+5. License Manager's `EddRepository::get_customer_data()` assembles downloads, license state, purchase history, All Access state, and optional `account_summary`.
+6. Core accepts the preferred response only when `plan_data.downloads.templates` and `plan_data.downloads.extensions` are arrays.
+7. Core maps those arrays into the legacy-compatible `license_data.themes` and `license_data.plugins` shape.
+8. Core stores sanitized connection and entitlement state in the current local WordPress admin user's meta.
+9. The submitted password exists only for the remote request and must never be stored locally or returned to browser state.
+
+If the preferred route is unavailable, non-successful in a fallback-safe way, malformed, or incomplete, account login falls back to:
+
+- `GET https://directorist.com/wp-json/directorist/v1/licencing`
+- Legacy fields: `user`, `password`
+
+The fallback exists for the installed customer base and must not be removed until the old endpoint is formally retired with a compatibility plan.
+
+## Active Access-Key Flow
+
+### Key Ownership And Display
+
+1. License Manager owns access-key generation, lookup, rotation, and storage for the signed-in Directorist.com user.
+2. User Dashboard requests the current customer's key through the License Manager integration.
+3. Sovware User Dashboard calls that helper and renders a masked access-key field with reveal and copy controls.
+4. User Dashboard must not create an independent key, duplicate key storage, or become the authentication authority.
+
+### Connecting A Client Site
+
+1. The customer copies the key from their Directorist.com User Dashboard.
+2. The local Directorist form submits `auth_method=access_key`, `access_key`, and the existing nonce to `atbdp_authenticate_the_customer`.
+3. Core sends a server-side `POST` request to:
+ - `https://directorist.com/wp-json/directorist-license-manager/user-connect`
+ - Fields: `access_key`, `domain`
+4. License Manager resolves the Directorist.com user through `AccountRepository::get_user_id_by_access_key()`.
+5. License Manager returns the same `account_data` and `plan_data` families used by account login.
+6. Core normalizes both authentication methods into the same legacy entitlement contract.
+7. Core stores only `access_key` as the non-secret connection-method label in `_atbdp_subscription_connection_method`.
+8. Core never stores the submitted access key, never includes it in local AJAX responses, and asks for it again when Refresh Purchases needs reauthentication.
+
+Access-key authentication intentionally has no legacy endpoint fallback. Invalid keys must remain distinguishable from transport or server failures.
+
+## Remote Response Contract
+
+Preferred License Manager response:
+
+```json
+{
+ "method": "user_login",
+ "account_data": {
+ "user_id": 0,
+ "user_email": "",
+ "display_name": ""
+ },
+ "plan_data": {
+ "downloads": {
+ "templates": [],
+ "extensions": []
+ },
+ "account_summary": {
+ "display_name": null,
+ "avatar_url": null,
+ "plan_name": null,
+ "subscription_status": "unknown",
+ "expires_at": null,
+ "all_access": false,
+ "is_lifetime": false
+ }
+ }
+}
+```
+
+Rules:
+
+- `account_summary` and every field inside it are optional.
+- Login success is not enough to replace local entitlements unless both required download arrays are valid.
+- Extra remote fields are untrusted. Core whitelists account identity fields and does not copy an echoed access key into local state.
+- Missing account summary must fall back to generic connected-account copy without changing entitlement behavior.
+- Dates must be ISO 8601 from the API and formatted using the client site's WordPress date settings.
+
+## Local Client-Site Cache
+
+Core stores the following on the current WordPress admin user:
+
+| User meta key | Purpose | Secret |
+| --- | --- | --- |
+| `_atbdp_has_subscriptions_sassion` | Connected/disconnected page state | No |
+| `_atbdp_subscribed_username` | Connected account identifier | Treat as private account data |
+| `_plugins_available_in_subscriptions` | Normalized extension entitlements | Treat as private entitlement data |
+| `_themes_available_in_subscriptions` | Normalized theme entitlements | Treat as private entitlement data |
+| `_atbdp_account_summary` | Sanitized optional plan/avatar/expiry summary | Treat as private account data |
+| `_atbdp_subscription_connection_method` | `account` or `access_key` | No |
+
+Keep the `sassion` misspelling because it is a shipped compatibility contract. These values are a local cache, not permanent licensing truth and not documentation truth.
+
+## Refresh Purchases
+
+1. UI reuses `atbdp_refresh_purchase_status`.
+2. Core reads `_atbdp_subscription_connection_method`.
+3. Account connections request the Directorist.com password again.
+4. Access-key connections request the access key again.
+5. Core re-authenticates through the matching License Manager route.
+6. Only a valid complete response replaces local extension/theme entitlement meta and account summary.
+7. The credential is discarded after the request.
+
+Refresh Purchases is remote revalidation. It is not a product-catalog refresh, WordPress plugin update check, or license-key rotation.
+
+## Disconnect
+
+`atbdp_close_subscriptions_sassion` disconnects the local WordPress page session.
+
+- It clears connected state, account summary, and connection-method meta.
+- Hard disconnect may also clear the cached account identifier and entitlement arrays.
+- It does not revoke the Directorist.com access key.
+- It does not cancel a subscription.
+- It does not deactivate EDD licenses remotely.
+- It does not deactivate or uninstall already installed plugins or themes.
+- Already installed products may continue running; account connection is required for page-managed subscription installs, downloads, refreshes, and updates.
+
+## Install, Update, And License Calls
+
+The current core product-management path is not fully routed through License Manager:
+
+| Operation | Current remote contract |
+| --- | --- |
+| Account login | License Manager `user-login` |
+| Access-key login | License Manager `user-connect` |
+| License activation | `https://directorist.com` with EDD `activate_license` |
+| Extension version check | Directorist.com EDD `get_version` request |
+| Package URL | `https://directorist.com/wp-json/directorist/v1/get-product-data/` |
+| Product catalog | `https://app.directorist.com/wp-json/directorist/v1/get-remote-products` |
+
+Local WordPress state remains canonical after every install, update, activation, or theme switch. Remote success alone must not cause the UI to claim a final local state.
+
+## Account Summary And Avatar
+
+License Manager builds optional account summary data from:
+
+- Directorist.com WordPress user display name and avatar.
+- EDD customer identity.
+- EDD All Access pass objects.
+
+Core sanitizes and stores only the supported summary fields. The connected Dashboard greeting and account control use the licensing owner, not the local site name or local WordPress administrator identity. When avatar data is unavailable, the UI uses a safe fallback such as account initials.
+
+## Security Requirements
+
+The public skill records required protections, not private service implementation details or an exploit checklist:
+
+- Credential routes require verified HTTPS, redacted logs, throttling, failed-attempt controls, generic authentication errors, and monitoring.
+- Access keys require cryptographically secure generation, rotation/revocation, and storage appropriate for a bearer credential.
+- License Manager responses should omit submitted credentials and unnecessary secret fields.
+- Core must continue whitelisting accepted identity/summary fields instead of persisting arbitrary remote response data.
+- Legacy account fallback is compatibility-only and should be retired only through a separately reviewed migration.
+- New EDD activation and package-download work must use verified HTTPS and strict package-host validation.
+- Never log, document, or persist submitted passwords, access keys, raw license keys, or complete remote entitlement payloads.
+
+## Cross-Repository Change Checklist
+
+When changing licensing behavior, inspect all affected repositories:
+
+1. **Directorist core**
+ - AJAX names, nonce/capability checks, fallback behavior, user-meta compatibility, response normalization, UI states.
+2. **Directorist License Manager**
+ - Route validation, account lookup, EDD data shape, optional fields, access-key security, error status codes.
+3. **Sovware User Dashboard**
+ - Access-key visibility/copy UX only; no duplicate key generation or storage.
+4. **Directorist.com theme/site code**
+ - Legacy `/directorist/v1/licencing` endpoint and product/catalog endpoints when still active.
+5. **EDD dependencies**
+ - Core EDD, Software Licensing, and All Access behavior on the live site.
+
+For backward-compatible API additions:
+
+- Add optional fields; do not rename or remove established fields.
+- Keep old core versions able to ignore new data.
+- Keep new core versions able to fall back when new routes are absent.
+- Never replace cached entitlements from a partial or malformed success response.
+- Verify account login, access-key login, refresh, disconnect, install, update, and failed-remote states separately.
diff --git a/docs/agents/directorist-themes-extensions-page/references/licensing-system-prd.md b/docs/agents/directorist-themes-extensions-page/references/licensing-system-prd.md
new file mode 100644
index 0000000000..3ebac79e47
--- /dev/null
+++ b/docs/agents/directorist-themes-extensions-page/references/licensing-system-prd.md
@@ -0,0 +1,526 @@
+# Licensing System PRD
+
+This PRD defines how the Directorist admin `Themes & Extensions` page should handle account connection, licenses, subscriptions, extension setup, and theme setup during the full UI rewrite.
+
+It is intentionally product/system focused. It must not store runtime account data, current product counts, subscribed product lists, installed plugin lists, usernames, screenshots, or one-time API payloads.
+
+## Objective
+
+Build a safer, faster, account-connected product management system for Directorist themes and extensions while preserving existing customer compatibility.
+
+The page should let a site admin:
+
+- Connect a Directorist.com account.
+- See which extensions and themes are installed, active, available in the account, required, outdated, or marketplace-only.
+- Install entitled premium extensions/themes.
+- Activate installed extensions.
+- Update entitled premium extensions/themes.
+- Switch Directorist themes only after explicit confirmation.
+- Refresh purchase/subscription state.
+- Disconnect the Directorist account without breaking already installed products.
+
+## Non-Goals
+
+- Do not redesign the frontend `[directorist_user_dashboard]`.
+- Do not replace the whole page with Vue/React unless a future explicit architecture decision approves it.
+- Do not create a new payment/subscription platform inside the plugin.
+- Do not expose raw license keys, passwords, or private subscription payloads in browser state.
+- Do not add an old/new UI rollout toggle by default.
+- Do not store runtime product/account values in docs.
+
+## Core Product Rule
+
+The Directorist account/license connection controls product management on this page. It should not control whether already installed premium extensions keep running elsewhere in WordPress.
+
+Disconnected behavior stays as currently approved:
+
+- Show the Directorist account connection form.
+- Show marketplace/promo product discovery.
+- Do not show local premium product management actions while disconnected.
+- Already installed premium products can keep working in WordPress.
+- Show helper copy: `Already installed extensions will keep working. Connect your Directorist account to manage subscriptions, installs, and updates.`
+
+## System Actors
+
+- WordPress admin user: must have `manage_options`.
+- Local WordPress site: source of installed plugin/theme state.
+- Directorist plugin PHP: owns local AJAX, remote calls, entitlement normalization, install/update logic, and canonical state.
+- Browser JavaScript: calls local WordPress AJAX only and updates UI from server-confirmed state.
+- Directorist.com account/licensing API: verifies account credentials and returns subscription/license data.
+- Directorist.com EDD license API: activates/checks licenses for package access and updates.
+- app.directorist.com product API: optional product catalog/copy/badge source.
+
+## Main Data Sources
+
+### Product Catalog
+
+Purpose: define the official extension/theme catalog and marketplace cards.
+
+Sources:
+
+- Local fallback arrays from `ATBDP_Extensions::get_default_extensions()`.
+- Local fallback arrays from `ATBDP_Extensions::get_default_themes()`.
+- Optional remote catalog through `Directorist\Core\API::get_products()`.
+- Filters: `atbdp_extension_list`, `atbdp_theme_list`.
+
+Rules:
+
+- API product fields are preferred when valid.
+- Local product arrays remain fallback for non-badge fields.
+- Merge API over local defaults by product key.
+- Badge/status fields must come from product API data or explicit filters only.
+- Do not infer badge/status from product name, slug, order, local install date, or hardcoded lists.
+
+### Account And Subscription State
+
+Purpose: determine whether the admin account is connected and which products are entitled.
+
+Stored local user meta:
+
+- `_atbdp_subscribed_username`
+- `_atbdp_has_subscriptions_sassion`
+- `_themes_available_in_subscriptions`
+- `_plugins_available_in_subscriptions`
+
+Rules:
+
+- Preserve the `sassion` misspelling because it is a legacy contract.
+- Treat this user meta as a local cache of account state, not public catalog truth.
+- Refresh purchase should revalidate against Directorist.com before changing entitlement state.
+- Do not expose raw license keys or private subscription payloads to browser JavaScript.
+
+### Local WordPress Product State
+
+Purpose: determine what is installed, active, outdated, required, or currently active theme.
+
+Sources:
+
+- `get_plugins()`
+- `is_plugin_active()`
+- `wp_get_theme()`
+- `wp_get_themes()`
+- `get_option( 'stylesheet' )`
+- `get_site_transient( 'update_plugins' )`
+- `get_site_transient( 'update_themes' )`
+- `directorist_required_extensions`
+- `directorist_extensions_aliases`
+
+Rules:
+
+- Local WordPress state is canonical for installed/active/final action result.
+- Browser UI must not invent final install/update/activation state.
+- After high-risk actions, re-check canonical server state or use reload fallback.
+
+## Required Backend Components
+
+### ProductCatalogResolver
+
+Builds the extension/theme catalog.
+
+Responsibilities:
+
+- Load local fallback catalog.
+- Optionally merge remote API catalog.
+- Apply existing filters.
+- Normalize product keys, item IDs, links, thumbnails, descriptions, plugin base, demo links, active flags, and optional badge data.
+- Avoid blank cards when remote fields are missing.
+
+### AccountConnectionService
+
+Handles connected/disconnected account state.
+
+Responsibilities:
+
+- Authenticate Directorist.com credentials through the existing local AJAX action.
+- Store connected account state in existing user meta.
+- Refresh purchase/subscription state.
+- Logout/disconnect by clearing the same legacy meta keys.
+- Never persist account password after the request.
+
+### EntitlementResolver
+
+Converts subscription payloads into product action permissions.
+
+Responsibilities:
+
+- Read `_plugins_available_in_subscriptions` and `_themes_available_in_subscriptions`.
+- Match entitlement records to product catalog keys.
+- Determine whether install/update/download/license actions are available.
+- Hide premium management actions when the account is disconnected.
+- Return safe action availability data, not raw secrets.
+
+### ProductStateResolver
+
+Builds canonical current state for the page.
+
+Responsibilities:
+
+- Determine installed, active, inactive, outdated, required, subscribed-not-installed, promo-only, active theme, inactive theme, and update-available states.
+- Resolve alias/deprecated extension keys.
+- Create state groups for extensions, themes, required items, and statistics.
+- Produce server-rendered partials when needed for no-reload UI updates.
+
+### LicenseService
+
+Owns EDD license activation/version/package eligibility.
+
+Responsibilities:
+
+- Activate a product license before install/download when required.
+- Check EDD version data for update eligibility.
+- Normalize EDD errors into stable local error codes.
+- Treat missing, invalid, expired, or remote-failed licenses as recoverable UI states.
+- Never return raw license data to the browser unless already exposed by legacy behavior and required for compatibility.
+
+### PackageInstallService
+
+Owns plugin/theme download, validation, install, update, and cleanup.
+
+Responsibilities:
+
+- Request package URLs from Directorist.com only from PHP.
+- Validate package host and URL.
+- Validate `download_url()`, `unzip_file()`, extracted package structure, and copy result.
+- Avoid deleting/replacing existing folders before validating the new package.
+- Clean temporary files/directories.
+- Return structured errors and keep reload fallback for unreconciled filesystem state.
+
+### PageStateEndpoint
+
+New recommended read-only AJAX action:
+
+- `atbdp_get_themes_extensions_state`
+
+Responsibilities:
+
+- Return canonical account, extension, theme, required-product, statistics, notice, and optional HTML partial state.
+- Be capability and nonce protected.
+- Return no passwords, raw licenses, usernames, or private account payloads.
+- Support no-reload UI refresh after successful actions.
+
+### ResponseFormatter
+
+Normalizes all page action responses.
+
+Recommended shape:
+
+```json
+{
+ "success": true,
+ "code": "plugin_activated",
+ "message": "Plugin activated.",
+ "action": "activate_plugin",
+ "item_key": "product-key",
+ "type": "plugin",
+ "next_state": "active",
+ "requires_reload": false,
+ "state": {},
+ "html": {}
+}
+```
+
+Rules:
+
+- Preserve legacy response fields where existing JS or third-party code may depend on them.
+- New JS should use `success`, `code`, `message`, `requires_reload`, and canonical `state`.
+- Errors should be inline and recoverable where possible.
+
+## Core User Journeys
+
+### 1. Page Load
+
+1. Admin opens `edit.php?post_type=at_biz_dir&page=atbdp-extension`.
+2. PHP validates capability and prepares aliases, required extensions, product catalog, account state, and local WP state.
+3. If account is disconnected, render connect form plus marketplace.
+4. If account is connected, render statistics, installed/subscribed extension management, theme management, required products, and marketplace.
+5. Browser JS enhances interactions without replacing server-rendered fallback.
+
+### 2. Connect Account
+
+1. Admin submits Directorist.com username/password.
+2. Browser calls local `atbdp_authenticate_the_customer`.
+3. PHP calls Directorist.com account/licensing API.
+4. PHP stores connected account and subscription state in legacy user meta.
+5. UI refreshes via state endpoint or reload fallback.
+6. Password is discarded after the request.
+
+Success state:
+
+- Connected account UI becomes available.
+- Subscribed products can be shown from canonical server state.
+
+Failure state:
+
+- Show inline authentication/remote/API error.
+- Keep connect form usable.
+- Do not store partial/unsafe credential state.
+
+### 3. Refresh Purchase
+
+1. Admin confirms password for refresh.
+2. Browser calls local `atbdp_refresh_purchase_status`.
+3. PHP re-authenticates against Directorist.com and rewrites subscription meta.
+4. UI refreshes canonical state.
+
+Failure state:
+
+- Existing visible state remains stable.
+- Show inline error.
+- If session is invalid, return `requires_reload` or disconnected state.
+
+### 4. Disconnect Account
+
+1. Admin clicks logout/disconnect.
+2. Browser calls local `atbdp_close_subscriptions_sassion`.
+3. PHP clears connected session meta.
+4. UI returns to disconnected state.
+
+Rules:
+
+- Installed premium products keep running elsewhere in WordPress.
+- This page no longer shows premium management actions while disconnected.
+
+### 5. Install Extension From Subscription
+
+1. Product must be in connected account entitlement state.
+2. Browser calls local `atbdp_install_file_from_subscriptions`.
+3. PHP validates capability, nonce, entitlement, license, download URL, package, and filesystem operation.
+4. PHP installs the extension package.
+5. UI re-checks canonical plugin state.
+
+Rules:
+
+- Do not mark installed until server confirms installed state.
+- Keep full reload fallback for filesystem ambiguity.
+- Show recoverable errors for invalid license, missing entitlement, failed download, invalid package, and filesystem failure.
+
+### 6. Activate Extension
+
+1. Product must be installed and inactive.
+2. Browser calls local `atbdp_activate_plugin`.
+3. PHP calls WordPress `activate_plugin()`.
+4. PHP must check `WP_Error`.
+5. UI re-checks canonical active plugin state.
+
+Rules:
+
+- Do not show false success on `WP_Error`.
+- If activation fails, restore button and show inline error.
+
+### 7. Update Extension
+
+1. Product must be installed, entitled, and update-available.
+2. PHP checks EDD version data and download eligibility.
+3. PHP validates package and safely replaces plugin files.
+4. UI re-checks installed version/update state.
+
+Rules:
+
+- Treat update as high-risk filesystem mutation.
+- Prefer progress UI plus reload fallback over optimistic final state.
+
+### 8. Required Extension
+
+1. Required items come from `directorist_required_extensions`.
+2. PHP matches required product to catalog, aliases, subscription state, install state, and active state.
+3. UI shows the next safe action: install, activate, or get-now/external handoff.
+
+Rules:
+
+- Required status is not a remote endpoint by itself.
+- Required-product install/update still follows the same entitlement and license rules.
+
+### 9. Install Theme From Subscription
+
+1. Product must be in connected account entitlement state.
+2. Browser calls existing install action with `type=theme`.
+3. PHP validates entitlement, license, package URL, package structure, and theme install result.
+4. UI re-checks `wp_get_themes()` and theme update state.
+
+Rules:
+
+- Installing a theme is filesystem mutation but does not switch the live theme by itself.
+- Keep reload fallback if theme state cannot be reconciled.
+
+### 10. Activate Theme
+
+1. Theme must be installed and inactive.
+2. UI shows explicit confirmation modal every time.
+3. After confirmation, browser calls local `atbdp_activate_theme`.
+4. PHP calls `switch_theme()`.
+5. UI re-checks `get_option( 'stylesheet' )`.
+
+Confirmation copy must communicate:
+
+- This changes the live site's active theme.
+- Layout, menus, widgets, headers/footers, and theme settings may be affected.
+
+Rules:
+
+- Never trigger theme switch from a single accidental click.
+- Do not automate theme switch on real/client sites without explicit confirmation.
+- Do not show success before canonical active-theme state is confirmed.
+
+### 11. Update Theme
+
+1. Theme must be installed, entitled, and update-available.
+2. PHP checks theme update transient and entitlement.
+3. PHP validates package/download and updates theme files.
+4. UI re-checks canonical theme version/update state.
+
+Rules:
+
+- Handle missing or malformed `update_themes` transient defensively.
+- Keep reload fallback for filesystem or state uncertainty.
+
+### 12. Marketplace / Get Now
+
+1. Promo-only product cards are visible as product discovery.
+2. Links may go to Directorist product, pricing, account, demo, support, or docs pages.
+3. External links are handoff points, not local state changes.
+
+Rules:
+
+- Use API product copy when available and local fallback when not.
+- Product claims should be cross-checked with local readme files and official docs before copy changes.
+
+## License States
+
+The UI and API should support these states as categories:
+
+- `connected`: account session exists locally.
+- `disconnected`: no local account session.
+- `entitled`: current account has subscription/license access for product.
+- `not_entitled`: product is marketplace-only for this account.
+- `license_active`: remote license activation succeeded for the requested product.
+- `license_missing`: entitlement record lacks a usable license.
+- `license_invalid`: EDD activation/check failed.
+- `license_expired`: remote license indicates renewal required.
+- `license_remote_failed`: Directorist.com/EDD could not be reached or returned invalid response.
+- `installed`: product exists locally.
+- `active`: plugin active or theme is current stylesheet.
+- `inactive`: installed but not active.
+- `update_available`: local version is behind canonical update state.
+- `requires_reload`: server cannot safely reconcile state in-place.
+
+Already installed products should not be presented as broken only because the account is disconnected. The page should say connection is required for subscription management, installs, downloads, and updates.
+
+## API Requirements
+
+Existing local AJAX actions must remain compatible:
+
+- `atbdp_authenticate_the_customer`
+- `atbdp_install_file_from_subscriptions`
+- `atbdp_plugins_bulk_action`
+- `atbdp_activate_theme`
+- `atbdp_activate_plugin`
+- `atbdp_update_plugins`
+- `atbdp_update_theme`
+- `atbdp_refresh_purchase_status`
+- `atbdp_close_subscriptions_sassion`
+
+Recommended new local AJAX action:
+
+- `atbdp_get_themes_extensions_state`
+
+Remote API calls stay server-side:
+
+- Account/license auth: `https://directorist.com/wp-json/directorist/v1/licencing`
+- Product data/download link: `https://directorist.com/wp-json/directorist/v1/get-product-data/`
+- EDD license activation/version: `https://directorist.com`
+- Optional product catalog: `https://app.directorist.com/wp-json/directorist/v1/get-remote-products`
+
+## UI Requirements
+
+- Keep disconnected UI simple: connect form plus marketplace.
+- Connected UI should group products by clear state: installed, available in account, required, themes, marketplace.
+- Show action buttons only when the server says the action is available.
+- Use inline notices instead of browser `alert()` as the primary feedback.
+- Use button loading states and disable duplicate clicks while a request is running.
+- Require confirmation for uninstall and theme activation.
+- Keep external product/docs/account links visually distinct from local AJAX actions.
+- Mobile layout must avoid horizontal overflow.
+
+## Performance Requirements
+
+- Keep PHP-rendered templates as canonical fallback.
+- Add no-reload behavior through a small page-specific JS state adapter.
+- Use the state endpoint after safe or successful actions.
+- Avoid full reload for simple UI state changes and recoverable errors.
+- Keep reload fallback for install, update, theme switch, uninstall, remote API failure, and filesystem uncertainty.
+
+## Security And Privacy Requirements
+
+- Require `manage_options` for all local actions.
+- Verify nonce for all local actions.
+- Sanitize request fields and escape rendered output.
+- Never store passwords after request completion.
+- Never log passwords, raw licenses, or private subscription payloads.
+- Do not expose raw subscription/license data to the browser.
+- Review `sslverify => false` paths with compatibility-safe hardening and clear error handling.
+
+## Compatibility Requirements
+
+Preserve:
+
+- Page slug: `atbdp-extension`
+- Parent post type: `at_biz_dir`
+- Capability: `manage_options`
+- Legacy AJAX action names
+- Legacy user meta keys
+- `directorist_extensions_aliases`
+- `directorist_required_extensions`
+- `atbdp_extension_list`
+- `atbdp_theme_list`
+- Existing settings/product/external links
+- Existing template/selector compatibility where practical
+
+## Error Handling Requirements
+
+Use stable local error codes such as:
+
+- `capability_denied`
+- `nonce_invalid`
+- `account_disconnected`
+- `auth_failed`
+- `subscription_missing`
+- `license_missing`
+- `license_activation_failed`
+- `license_expired`
+- `remote_unreachable`
+- `remote_invalid_response`
+- `download_unavailable`
+- `download_host_invalid`
+- `filesystem_unavailable`
+- `package_invalid`
+- `install_failed`
+- `update_failed`
+- `activation_failed`
+- `theme_switch_failed`
+- `requires_reload`
+
+## Acceptance Criteria
+
+- A disconnected admin sees the account-connect form and marketplace only.
+- A connected admin sees product-management sections built from current server state.
+- Installed premium products are not implied to stop working when disconnected.
+- Premium install/update/download requires connected entitlement/license state.
+- Extension activation checks `WP_Error` and never shows false success.
+- Theme activation always requires confirmation and re-checks active theme after success.
+- Uninstall is available only as a protected danger action with confirmation.
+- Product badges render only from API/filter data.
+- Product copy uses API data with local fallback for missing non-badge fields.
+- No high-risk action shows final success before canonical server state confirms it.
+- No page-level horizontal overflow on mobile admin widths.
+- All local action failures leave controls recoverable.
+- Official Directorist docs and local readme files are checked before changing public product claims.
+
+## Documentation References
+
+- Local API map: `api-data-flow-report.md`
+- Runtime data contract: `dynamic-data-contract.md`
+- Account journey map: `account-license-journey-map.md`
+- Rewrite issue register: `rewrite-issue-register.md`
+- Official Directorist docs: `https://directorist.com/documentation/directorist/`
+- Official themes docs: `https://directorist.com/documentation/themes/`
+- Official extensions docs: `https://directorist.com/documentation/extensions/`
diff --git a/docs/agents/directorist-themes-extensions-page/references/page-architecture-map.md b/docs/agents/directorist-themes-extensions-page/references/page-architecture-map.md
new file mode 100644
index 0000000000..5ddcd1bae5
--- /dev/null
+++ b/docs/agents/directorist-themes-extensions-page/references/page-architecture-map.md
@@ -0,0 +1,141 @@
+# Themes & Extensions Page Architecture Map
+
+Use this map for source orientation. Re-check files before each task because product catalogs, active plugins, subscriptions, themes, and update availability are dynamic.
+
+## Admin Entry
+
+- Page URL: `wp-admin/edit.php?post_type=at_biz_dir&page=atbdp-extension`
+- Admin screen id: `at_biz_dir_page_atbdp-extension`
+- Main class: `ATBDP_Extensions` in `includes/classes/class-extension.php`
+- Menu registration: `admin_menu()` adds the submenu under `edit.php?post_type=at_biz_dir`
+- Capability: `manage_options`
+- Render callback: `show_extension_view()`
+
+## Server Lifecycle
+
+- Constructor registers `setup_ajax_actions()` on `admin_init`.
+- When `$_GET['page']` is `atbdp-extension`, constructor also registers `initial_setup()` on `admin_init`.
+- `initial_setup()` prepares extension aliases, calls WordPress plugin update checks, loads required extensions through `directorist_required_extensions`, and prepares products through `setup_products_list()`.
+- `show_extension_view()` builds render data and loads `admin-templates/theme-extensions/theme-extension`.
+
+## Product Sources
+
+- Default extension list: `ATBDP_Extensions::get_default_extensions_list()`
+- Default theme list: `ATBDP_Extensions::get_default_themes_list()`
+- Local filters:
+ - `atbdp_extension_list`
+ - `atbdp_theme_list`
+ - `directorist_required_extensions`
+ - `directorist_extensions_aliases`
+- Optional remote catalog: `Directorist\Core\API::get_products()` in `includes/classes/class-directorist-api.php`
+- Remote catalog endpoint: `https://app.directorist.com/wp-json/directorist/v1/get-remote-products`
+- Remote catalog cache: transient `directorist_products`
+
+Do not commit fetched product payloads into docs. Store only the source and contract.
+
+## Account And Subscription State
+
+- Connected-account flag user meta: `_atbdp_has_subscriptions_sassion`
+- Connected username user meta: `_atbdp_subscribed_username`
+- Connection method user meta: `_atbdp_subscription_connection_method` (`account` or `access_key`; never the credential)
+- Subscribed plugins user meta: `_plugins_available_in_subscriptions`
+- Subscribed themes user meta: `_themes_available_in_subscriptions`
+- Refresh and logout behavior are controlled by class methods in `class-extension.php` and jQuery handlers in `subscriptionManagement.js`.
+
+Preserve the misspelled `sassion` keys/actions because they are compatibility contracts.
+
+## Templates
+
+- Root wrapper: `views/admin-templates/theme-extensions/theme-extension.php`
+- Current logged-out account form: inline in `theme-extension.php`
+- Legacy logged-out account form: `theme-extensions/auth/license-auth-section.php`; confirm whether a task still reaches it before editing
+- Connected statistics: `theme-extensions/statistics/statistics.php`
+- Connected product area: `theme-extensions/my-themes-extensions/my-themes-extensions.php`
+- Extensions tab: `theme-extensions/my-themes-extensions/extensions-tab.php`
+- Themes tab: `theme-extensions/my-themes-extensions/themes-tab.php`
+- Promo marketplace: `theme-extensions/all-themes-extensions.php`
+
+Template output is server-rendered PHP and must remain usable without new JavaScript state management.
+
+## JavaScript And Assets
+
+- Admin entry: `assets/src/js/admin/admin.js`
+- Legacy account/product behavior: `assets/src/js/admin/components/subscriptionManagement.js`
+- Current rewritten page behavior, including the account/access-key method switch and connect-form submit owner: `assets/js/directorist-themes-extensions.js`
+- Current rewritten page styles: `assets/css/directorist-themes-extensions.css`
+- Enqueued admin script: `directorist-admin-script`
+- Enqueued admin CSS: `directorist-admin-style`
+- Localized object: `directorist_admin`, localized to `jquery` in `includes/asset-loader/localized_data.php`
+- Screen detection: `includes/asset-loader/helper.php`
+- Enqueue rules: `includes/asset-loader/init.php`
+- Script handles: `includes/asset-loader/scripts.php`
+- Main styles are currently in `assets/src/scss/layout/admin/admin-style.scss`
+
+## AJAX Actions
+
+Registered by `ATBDP_Extensions::setup_ajax_actions()`:
+
+- `atbdp_authenticate_the_customer`
+- `atbdp_download_file`
+- `atbdp_install_file_from_subscriptions`
+- `atbdp_plugins_bulk_action`
+- `atbdp_activate_theme`
+- `atbdp_activate_plugin`
+- `atbdp_update_plugins`
+- `atbdp_update_theme`
+- `atbdp_refresh_purchase_status`
+- `atbdp_close_subscriptions_sassion`
+
+Most actions use `directorist_admin.nonce`; bulk plugin actions use `directorist_admin.directorist_nonce`. Re-check each handler before changing request shape.
+
+## Remote Dependencies
+
+- Preferred Directorist account authentication: `POST https://directorist.com/wp-json/directorist-license-manager/user-login`
+- Directorist access-key authentication: `POST https://directorist.com/wp-json/directorist-license-manager/user-connect`
+- Legacy account authentication fallback: `GET https://directorist.com/wp-json/directorist/v1/licencing`
+- Product data/download links: `https://directorist.com/wp-json/directorist/v1/get-product-data/`
+- EDD software licensing version checks: `https://directorist.com` with `edd_action=get_version`
+- Optional remote product catalog: `https://app.directorist.com/wp-json/directorist/v1/get-remote-products`
+
+Network failures must leave the page usable with local defaults and clear error states.
+
+## Settings Page Connection
+
+The settings panel links admins back to this page, but it is a separate domain:
+
+- Settings URL pattern: `edit.php?post_type=at_biz_dir&page=atbdp-settings#extension_settings__extensions_general`
+- Settings-panel skill: `docs/agents/directorist-settings-panel/SKILL.md`
+- Do not document installed extension settings as core marketplace/discovery behavior.
+
+## Existing Issue Patterns
+
+- The current UI uses tables and fixed widths that can become fragile on narrow screens.
+- `subscriptionManagement.js` uses many `location.reload()` calls after AJAX success.
+- Old login continuation/download checklist code exists after an immediate reload and should be treated as stale unless revived intentionally.
+- `handle_file_download_request()` must be reviewed carefully before reuse because the type validation path is known-risk.
+- Plugin/theme install and update code touches the filesystem and must be treated as high risk.
+- The theme "What's new" modal must not ship hardcoded dummy changelog content in a redesign.
+
+For full rewrite fix priorities, read `references/rewrite-issue-register.md` before planning implementation.
+
+## Connected Dashboard Sidebar And Activity
+
+- Clean connected route: `edit.php?post_type=at_biz_dir&page=atbdp-extension` renders Dashboard.
+- Connected Add-ons route: `edit.php?post_type=at_biz_dir&page=atbdp-extension&te_view=addons` renders the existing catalog.
+- `ATBDP_Extensions::admin_menu()` registers the clean Dashboard route and adds the Themes & Extensions Add-ons submenu without changing the page slug or capability.
+- `ATBDP_Extensions::set_active_submenu()` and page JS keep WordPress sidebar state synchronized with the active internal view.
+- Disconnected requests register only the established Themes & Extensions submenu.
+
+Activity files and contracts:
+
+- Service: `includes/classes/class-extension-activity.php`
+- Template: `views/admin-templates/theme-extensions/theme-extension.php`
+- Page interaction: `assets/js/directorist-themes-extensions.js`
+- Page styling: `assets/css/directorist-themes-extensions.css`
+- AJAX action: `wp_ajax_directorist_te_get_activity`
+- Request fields: `nonce`, `activity_page`, `activity_type`
+- Allowed activity types: `all`, `listing`, `review`, `payment`, `user`
+- Response fields: `items`, `has_more`, `next_page`, `page`, `type`
+- Item fields: `id`, `type`, `title`, `subject`, `context`, `timestamp`, `icon`, `tone`, `action_label`, `action_url`, `upcoming`, `group`, `group_label`, `time_label`
+
+Activity, Dashboard metric, and setup-progress data are collected at request time from local WordPress/Directorist state. The service reads listings, review comments, Directorist users, directory/category terms, active gateways, modern `directorist_orders`, unmigrated legacy `atbdp_orders`, view-count meta, and listing expiration meta. It uses bounded queries and pagination, deduplicates migrated orders through `legacy_id`, and does not create an activity table, persist snapshots, auto-seed setup data, or call a remote product/licensing API.
diff --git a/docs/agents/directorist-themes-extensions-page/references/performance-improvement-guide.md b/docs/agents/directorist-themes-extensions-page/references/performance-improvement-guide.md
new file mode 100644
index 0000000000..6b868b38cf
--- /dev/null
+++ b/docs/agents/directorist-themes-extensions-page/references/performance-improvement-guide.md
@@ -0,0 +1,140 @@
+# Performance Improvement Guide
+
+This page currently uses PHP-rendered admin templates plus legacy jQuery in `subscriptionManagement.js`. It is not a Vue settings-manager page.
+
+## Current Tech
+
+- Server-rendered PHP templates produce the initial UI.
+- jQuery handles form submits, button clicks, bulk selection, install/update/activate calls, tabs, and feedback.
+- AJAX requests go through `admin-ajax.php` using `wp_ajax_atbdp_*` handlers.
+- The page often reloads after successful AJAX actions to force canonical state.
+- CSS is bundled through the legacy admin stylesheet.
+
+## Reload-Heavy Areas
+
+Review `assets/src/js/admin/components/subscriptionManagement.js` for `location.reload()` before planning performance work.
+
+Known reload patterns:
+
+- Account connect success
+- Product install success
+- Plugin activation success
+- Plugin update success
+- Theme activation success
+- Theme update success
+- Refresh purchase success or session reset
+- Logout success
+- Bulk action success
+- Old reload link handler
+
+Do not remove reloads blindly. They currently protect correctness by forcing a full server re-render.
+
+## Recommended Direction
+
+Use progressive enhancement:
+
+1. Preserve the existing PHP-rendered page as the canonical fallback.
+2. Add a small client-side state adapter for this page only.
+3. Normalize current AJAX response handling into success, error, loading, and stale-state branches.
+4. After successful low-risk actions, update affected buttons/cards/counters in place.
+5. After high-risk filesystem or theme/plugin actions, re-fetch canonical state or keep reload fallback.
+6. Record and compare reload count, network calls, interaction latency, and layout stability.
+
+Avoid a full Vue/React rewrite until the user explicitly approves a larger architecture change.
+
+## Framework Decision
+
+Default rewrite direction: use PHP-rendered templates plus a small page-specific JavaScript adapter, not Vue or React.
+
+The page rewrite is approved as a full UI rewrite, not a staged old/new UI rollout. Do not add a feature flag or rollout toggle by default unless a future task explicitly asks for it. Keep server-rendered compatibility behavior and reload fallbacks for safety; those are not the same as keeping two parallel UIs.
+
+Reasons:
+
+- This page is currently PHP templates plus legacy jQuery, not the Vue settings-manager app.
+- Keeping server-rendered markup preserves fallback behavior for existing customer sites.
+- A small adapter requires less code than a SPA and can be rolled back safely.
+- Existing `wp_ajax_atbdp_*` actions, selectors, filters, user meta keys, and template paths can remain stable.
+- Vue 2 is already legacy in the settings panel, so adding new Vue 2 surface area is not future-friendly.
+- React is available in WordPress admin, but using it here would require a larger state/API migration and broader compatibility QA.
+
+Preferred implementation shape:
+
+1. Add PHP service/resolver classes behind current handlers for product catalog, account/session state, installed-product state, update state, install/update/download operations, and response formatting.
+2. Keep the current PHP templates as the canonical render path and fallback.
+3. Add a small JS adapter around existing AJAX calls for loading states, inline notices, card/row replacement, counters, and state-summary refresh.
+4. Return structured responses from existing actions while preserving compatibility for older response readers.
+5. Use full reload fallback when canonical state cannot be safely reconciled.
+
+Only consider React or a new SPA-style architecture if a future task explicitly approves a full admin UI migration strategy for this page.
+
+## Safer No-Reload Candidates
+
+- Account connect feedback before final state refresh
+- Refresh Purchase status messages
+- Button loading/error states
+- Tab navigation and card visibility
+- Non-destructive validation errors
+- Promo/product detail modal interactions
+- Counter/card updates after a server-confirmed state refresh
+
+## Conservative No-Reload Candidates
+
+- Plugin install
+- Plugin update
+- Plugin activation
+- Theme update
+- Theme activation
+- Bulk activate/deactivate
+
+For these, show progress without reload, but reconcile with canonical server state before claiming completion. Keep reload fallback.
+
+## High-Risk Or Destructive
+
+- Plugin uninstall
+- Theme switch on a live/client site
+- Filesystem replacement during update/install
+- Remote package download and unzip
+- Any flow that deletes an existing plugin/theme folder
+
+Do not automate these on client sites without explicit confirmation.
+
+Theme activation rewrite policy:
+
+- Require an explicit confirmation modal every time before calling the theme activation AJAX action.
+- Confirmation must name the theme and warn that the live site's active theme will change and layout, menus, widgets, headers/footers, and theme settings may be affected.
+- Do not optimistically mark a theme active before server success and canonical active-theme recheck.
+- Keep reload fallback for theme activation because `switch_theme()` changes global site state.
+
+Uninstall rewrite policy:
+
+- Keep uninstall for backward compatibility, but never as a primary one-click action.
+- Put uninstall behind a danger/overflow menu and an explicit confirmation modal.
+- Confirmation must name the extension and warn that files will be deleted and dependent site features may break.
+- Treat uninstall as reload-fallback-first: after server acceptance, re-check canonical plugin state before updating UI.
+
+## Backend Improvement Targets
+
+Before a deeper rewrite, plan a service layer behind existing AJAX actions:
+
+- Product catalog resolver
+- Account/session resolver
+- Purchased product mapper
+- Install/update/download service
+- Action response formatter
+- State summary endpoint for client-side refresh
+
+Maintain the current AJAX action names and response compatibility while introducing safer internals.
+
+## Page Speed Acceptance
+
+Future performance work should measure:
+
+- Full page reload count per journey
+- Number of AJAX calls per action
+- Time from click/submit to visible feedback
+- Time from server success to stable UI
+- Console errors and page errors
+- Mobile horizontal overflow
+- Layout shift during loading and state changes
+
+Do not report performance success only from code review. Verify in Agent Browser.
diff --git a/docs/agents/directorist-themes-extensions-page/references/qa-checklist.md b/docs/agents/directorist-themes-extensions-page/references/qa-checklist.md
new file mode 100644
index 0000000000..591f920103
--- /dev/null
+++ b/docs/agents/directorist-themes-extensions-page/references/qa-checklist.md
@@ -0,0 +1,168 @@
+# QA Checklist
+
+Use this checklist for Themes & Extensions page work. Current runtime data must be collected fresh and not stored in this docs agent.
+
+## Static Checks
+
+- Confirm `ATBDP_Extensions::setup_ajax_actions()` still registers expected `wp_ajax_atbdp_*` actions.
+- Confirm `show_extension_view()` still passes required data to the root template.
+- Confirm `subscriptionManagement.js` action names match server handlers.
+- Confirm new code preserves existing nonce usage or provides compatibility.
+- Confirm new copy is escaped and translatable in PHP.
+- Confirm changed selectors/classes do not remove legacy hooks without shims.
+- Confirm page styles are scoped to the Themes & Extensions page root/classes and do not introduce broad global admin or shared Directorist selector changes.
+- Confirm docs/copy claims against `README.md`, `readme.txt`, and official Directorist docs.
+- Confirm product names, descriptions, thumbnails, and links use API values when present and local defaults when API fields are missing or invalid.
+- Confirm product badges/status render only from product API/filter-provided fields and do not rely on hardcoded product slugs or product order.
+- Confirm no old/new UI feature flag or rollout toggle was added unless explicitly requested.
+- Confirm disconnected-view files/selectors/styles were not changed unless the current task explicitly requested disconnected-view changes.
+- Confirm connected Dashboard recommendations show at most three cards, rotate through real directory types, and never expose the full catalog under an unknown/general type.
+- Confirm recommendation autoplay pauses on hover, keyboard focus, explicit Pause, hidden browser tabs, and reduced-motion preference.
+- Confirm previous, next, native directory selection, and pause/resume work without triggering product AJAX actions.
+- Confirm Quick Actions re-collect current directory terms and do not rely on stored documentation/runtime snapshots.
+- Confirm zero-directory state shows Create directory and Email notifications without dead directory-specific links.
+- Confirm one-directory state hides the selector and binds Add Listing, Categories, Listing Layout, and Submission Form to that directory.
+- Confirm multiple-directory selection updates all four directory-specific links, descriptions, and accessible labels without changing the global Email Notifications link.
+- Confirm the selected Quick Actions directory is selected in the new-listing admin metabox and loads that directory's `submission_form_fields`.
+- Confirm an existing listing's saved directory overrides any `directory_type` query parameter.
+- Confirm a valid remembered Quick Actions directory is restored for the browser session and a deleted/stale directory falls back to the current default.
+- Confirm `#submission_form` and `#single_page_layout__contents` override saved Builder tab state and open the requested layout/submenu.
+- Confirm a connected clean sidebar URL opens Dashboard with All as the default product type.
+- Confirm Add-ons with Extensions or Themes updates `te_view`/`te_type` without a reload and restores the same state after reload.
+- Confirm switching to Dashboard retains `te_type`, and returning to Add-ons restores the same All/Extensions/Themes selection.
+- Confirm invalid `te_view` and `te_type` values fall back to Dashboard and All and are removed from the canonical URL.
+- Confirm `#atbdp-required-extensions-form` still forces Add-ons, Extensions, and Required regardless of initial URL state.
+- Confirm disconnected rendering ignores connected `te_view`/`te_type` parameters.
+- Confirm disconnected-state username is first in tab order but is not autofocused on normal page load.
+- Confirm disconnected-state resource links are plain Docs, Tutorials, and Support links with external-link safety attributes.
+- Confirm disconnected-state account copy changes based on local official Directorist product detection: normal subscription copy when none are installed, installed-product copy when local Directorist extensions/themes are present.
+- Confirm legacy theme links to `#atbdp-required-extensions-form` select Add-ons, Extensions, and Required without submitting or triggering a product action.
+- Confirm required products render once and keep the correct Install, Activate, or Get It Now action for current ownership/install state.
+- Confirm the connected header has no search control and the Add-ons toolbar remains the only catalog search.
+- Confirm the notification count and items derive from current extension updates, theme updates, and required-extension state.
+- Confirm clicking an extension/theme update notification opens Add-ons, selects the matching product type and Updates, focuses the Updates filter, and sends no update request.
+- Confirm clicking a required-extension notification opens Add-ons, selects Extensions and Required, focuses the Required filter, and sends no install request.
+- Confirm the notification empty state, Escape close, outside-click close, account-menu mutual exclusion, keyboard focus restoration, and 390px dropdown containment.
+- Confirm connected WordPress sidebar Dashboard is the first Directorist submenu and the clean route opens Dashboard.
+- Confirm connected Themes & Extensions sidebar opens `te_view=addons` and selects Add-ons in both the header and WordPress sidebar.
+- Confirm header Dashboard/Add-ons switches update the matching WordPress submenu current state without reload.
+- Confirm disconnected users still receive one Themes & Extensions submenu and no connected Dashboard.
+- Confirm the Recent Activity card renders no more than five current dynamic items and contains no reference-design names, dates, amounts, or listing titles.
+- Confirm opening View all makes one lazy `directorist_te_get_activity` request, returns focus on close, traps Tab while open, closes on Escape/backdrop, and has no mobile overflow.
+- Confirm activity filters reset pagination, Load more appends without duplicates, and empty/error states remain usable.
+- Confirm the activity endpoint rejects missing/invalid nonce and non-admin capability requests.
+
+## Agent Browser Checks
+
+Open the local admin page:
+
+```bash
+agent-browser --session directorist-themes-extensions --profile Default --ignore-https-errors open "https://directorist-core.local/wp-admin/edit.php?post_type=at_biz_dir&page=atbdp-extension"
+```
+
+Inspect without destructive actions:
+
+```bash
+agent-browser --session directorist-themes-extensions snapshot -c -d 4
+agent-browser --session directorist-themes-extensions console --clear
+agent-browser --session directorist-themes-extensions errors --clear
+```
+
+Mobile overflow check:
+
+```bash
+agent-browser --session directorist-themes-extensions set viewport 390 844
+agent-browser --session directorist-themes-extensions eval 'JSON.stringify({scrollWidth:document.documentElement.scrollWidth,innerWidth:innerWidth,overflow:document.documentElement.scrollWidth>innerWidth})'
+```
+
+Close the session:
+
+```bash
+agent-browser --session directorist-themes-extensions close
+```
+
+## Dynamic State Matrix
+
+Verify behavior against current site state for:
+
+- Not connected account
+- Not connected account while premium products may already be installed locally; verify the page still shows the auth/connect state and does not expose installed-product management actions
+- Connected account
+- Subscribed product not installed
+- Installed product inactive
+- Installed product active
+- Installed product outdated
+- Required extension
+- Promo-only product
+- Product with API/filter-provided badge
+- Product without badge data
+- Product with expired badge data
+- Product with missing API name, description, thumbnail, or link to verify local fallback fields
+- Active theme
+- Installed inactive theme
+- Theme update available
+- Remote API failure
+- Nonce failure
+- Capability failure
+- Filesystem failure
+
+Use categories only in docs. Do not preserve observed counts or product lists.
+
+## Performance Checks
+
+- Count full page reloads before and after the change.
+- Confirm loading states appear immediately after click/submit.
+- Confirm server errors are shown without leaving disabled buttons stuck.
+- Confirm no-reload UI updates are reconciled with canonical server state.
+- Confirm full reload fallback still works.
+- Compare desktop and mobile interaction latency.
+- Check console and page errors after each tested journey.
+
+## Disconnected Accessibility Checks
+
+- Confirm the disconnected page remains browse-first: account connect form plus marketplace catalog are both reachable without forced focus.
+- Confirm Account login remains the selected default and username/password fields have visible labels.
+- Confirm Access key is an explicit secondary tab, and only the selected method's controls are enabled or reachable by keyboard.
+- Confirm Left/Right/Home/End keys change the selected authentication tab and move focus with it.
+- Confirm pressing Enter inside username/password or access key submits the account-connect form exactly once.
+- Confirm password and access-key visibility toggles change the input type, icon, `aria-label`, and `aria-pressed`.
+- Confirm connect loading state disables controls only during the active request and restores them after failure.
+- Confirm empty username, empty password, empty/invalid access key, wrong account credentials, API unavailable, nonce failure, capability failure, and unexpected errors render inline form feedback.
+- Confirm a submitted access key never appears in the URL, local/session storage, AJAX response, user meta, options, logs, or documentation.
+- Confirm an existing account-login connection with no `_atbdp_subscription_connection_method` value still refreshes with a password.
+- Confirm an access-key connection stores only `_atbdp_subscription_connection_method=access_key` and asks for the key again during Refresh Purchases.
+- Confirm disconnected search no-result state shows an inline empty state and a clear/reset affordance.
+- Confirm mobile disconnected view uses a one-column connect form, does not open the keyboard on load, and has no horizontal page overflow.
+
+## Destructive-Action Safeguards
+
+Do not execute these on a real/client site without explicit confirmation:
+
+- Install plugin/theme
+- Update plugin/theme
+- Activate plugin
+- Deactivate plugin
+- Uninstall plugin
+- Activate/switch theme
+- Logout connected account
+- Bulk action
+- Refresh purchase if it requires real credentials
+
+When these need QA, use a disposable local site, mocks, or a server-side test harness.
+
+Uninstall-specific QA for rewrite:
+
+- Confirm uninstall is in a danger/overflow area, not a primary action.
+- Confirm confirmation modal names the extension and warns that files will be deleted and site features may break.
+- Confirm cancellation makes no AJAX request.
+- Confirm success is shown only after server success and canonical plugin-state recheck or reload fallback.
+- Confirm failure restores the UI and shows inline feedback.
+
+Theme-activation-specific QA for rewrite:
+
+- Confirm clicking a theme Activate button opens a confirmation modal instead of sending AJAX immediately.
+- Confirm the modal names the theme and warns that the live site's active theme will change.
+- Confirm cancellation makes no AJAX request.
+- Confirm confirmation sends the existing `atbdp_activate_theme` action only after explicit user approval.
+- Confirm active-theme UI updates only after server success and canonical active-theme recheck or reload fallback.
+- Confirm failure restores the UI and shows inline feedback.
diff --git a/docs/agents/directorist-themes-extensions-page/references/rewrite-issue-register.md b/docs/agents/directorist-themes-extensions-page/references/rewrite-issue-register.md
new file mode 100644
index 0000000000..0a38d2c5f0
--- /dev/null
+++ b/docs/agents/directorist-themes-extensions-page/references/rewrite-issue-register.md
@@ -0,0 +1,325 @@
+# Rewrite Issue Register
+
+Use this register when planning or implementing a full rewrite of the Directorist admin `Themes & Extensions` page. These are durable issue patterns and fix priorities, not runtime data snapshots. Always re-check the current page, source, and Directorist docs before acting.
+
+## Rewrite Principle
+
+The page is an account-connected product management surface. It is not only a marketing catalog. A rewrite must preserve account connection, subscription discovery, install, activate, update, required-extension, settings-link, theme, and promo-product journeys while improving layout, speed, and failure handling.
+
+Keep existing page slug, AJAX action names, nonce contracts, filters, aliases, user meta keys, selectors needed for compatibility, and fallback server-rendered behavior unless an explicit migration plan is approved.
+
+The approved direction is a full UI rewrite of this page, not a staged old/new UI rollout. Do not plan a separate feature flag or rollout toggle by default unless a future task explicitly asks for one. Full UI rewrite still means preserving backend contracts, server-rendered compatibility behavior, reload fallbacks, and high-risk action safeguards.
+
+## Preferred Rewrite Architecture
+
+- Do not default to a Vue or React rewrite for this page.
+- Keep PHP-rendered templates as the compatibility baseline and canonical fallback.
+- Reduce complexity by adding focused PHP service/resolver classes behind the existing `wp_ajax_atbdp_*` actions.
+- Add a small page-specific JavaScript state adapter for no-reload behavior instead of a full SPA.
+- The adapter should own request lifecycle, button loading states, inline notices, row/card replacement, counters, and canonical state refresh.
+- Existing actions should gradually return structured responses with `success`, `message`, `item_key`, `next_state`, `requires_reload`, and optional rendered HTML/state-summary data while preserving backward compatibility.
+- Vue 2 should not be expanded just because the settings panel uses Vue; it is legacy surface area.
+- React should only be considered after an explicit larger admin UI migration decision, because it requires a new state/API layer and broader QA.
+- This architecture is the lowest-risk default for the 20k customer base: fewer moving parts, easier rollback, existing server-rendered fallback, and stable public contracts.
+
+## Current Styling Source Note
+
+- The current rewritten Themes & Extensions page styles are in the direct page stylesheet `assets/css/directorist-themes-extensions.css`.
+- They are not currently authored in `assets/src/scss/`.
+- The stylesheet is enqueued directly for the `atbdp-extension` admin page from the asset loader.
+- Styles for this page must remain scoped to the Themes & Extensions root/classes, such as `#directorist.directorist-te-page` and `.directorist-te-*`. Avoid broad selectors like `.wrap`, `.button`, `.notice`, `table`, or shared Directorist admin classes unless the change is intentionally global and verified across affected admin screens.
+- Keep page-only design tokens and custom properties under the Themes & Extensions page root so they do not override other Directorist admin screens.
+- Future rewrite or hardening work should decide explicitly whether to keep this page-specific direct CSS file or migrate the styles into the SCSS/build pipeline.
+- Do not commit generated `assets/build/*` changes as part of this decision unless a build output update is intentionally requested.
+
+## Locked Reference Design Behavior
+
+When implementing from the approved HTML demo design, match the behavior as well as the static visuals. Do not store demo product rows or local runtime values as truth.
+
+- Use a compact admin-app layout: resource topbar, page head, optional account/connect panel, update banner, type tabs, status segmented control, list rows, upsell, and footer.
+- Keep the core sizing rhythm from the reference: 1200px max content container, 24px desktop gutters, 62px topbar, 42px type tabs, 41px list header, and 66px dense desktop rows. Adapt only for WordPress admin chrome and responsive constraints.
+- Top resource navigation should include Dashboard, Themes & Extensions, Docs, Tutorials, and Support. Docs, Tutorials, and Support are external handoff links; the active Themes & Extensions item should not duplicate the WordPress sidebar as the primary navigation source.
+- On narrow screens, resource navigation can become a horizontal scroll area. Do not create page-level horizontal overflow.
+- Filtering is cumulative: product type tab, product status segment, and search query combine together. A search query should continue to apply when switching tabs or status.
+- Visible add-on count updates after filtering. If no rows match, show an inline empty state instead of leaving a blank list.
+- The upsell/library banner shows only in the unfiltered default catalog view. Hide it during search, type filtering, or status filtering.
+- Update banner visibility depends on update availability in the current type scope. Hide the banner while rows are selected.
+- Row selection should visually set a selected row state and reveal a sticky bulk-action bar with `N selected`, plus Clear and Escape reset behavior.
+- Preserve real safety constraints: only rows backed by safe existing bulk contracts should be selectable for bulk management. In the disconnected marketplace-only state, do not invent bulk management actions only to match the static demo.
+- Master select applies to visible selectable rows only, and should show all/some/none visual states.
+- Kebab menus open one at a time, outside click closes them, and destructive actions remain inside the overflow/danger path rather than primary row buttons.
+- Safe read-only actions such as `View Details` and `Demo` should be visible row actions instead of being hidden only inside the overflow menu. Keep the overflow menu for secondary alternatives and protected/destructive actions.
+- Do not render duplicate visible links to the same destination with different labels such as `Get It Now` and `View Details`. If disconnected marketplace rows only have a product details URL, show `View Details` as the single product link. Use `Get It Now` only when it points to a distinct purchase/checkout flow or when it is the single intended purchase CTA.
+- Do not trigger Install, Update, Activate, Deactivate, Uninstall, Logout, Refresh Purchase, or theme switch during design QA without explicit confirmation.
+
+## Disconnected Account Policy
+
+- Locked decision, 2026-07-22: disconnected view is done. Do not change it in future rewrite work unless the user explicitly requests disconnected-view changes in the current task.
+- Preserve current disconnected-account behavior by default.
+- Treat `logout` in this page context as Directorist account disconnect, not WordPress admin logout.
+- When `_atbdp_has_subscriptions_sassion` is empty, the page should render the account-connect form and the marketplace/promo discovery section.
+- Keep disconnected-view PHP markup, copy, resource links, search/tabs/count placement, product-row actions, page-scoped CSS/responsive behavior, and JS behavior stable by default.
+- Do not expose installed premium product management in the disconnected state by default.
+- Do not show Settings, Active status, Activate, Deactivate, Update, or local management actions for installed premium plugins/themes while disconnected unless a future task explicitly changes this policy.
+- Do not show disconnected users management-only UI affordances such as header account utilities, Installed/Updates status filters, master select checkboxes, row selection, or bulk-action controls. Keep browsing affordances such as add-on search, type tabs, counts, product detail links, and Get It Now links.
+- In the disconnected marketplace catalog, place add-on search with the catalog toolbar/count area instead of the hero. On desktop, keep type tabs, search, and visible count in one horizontal toolbar when space allows. Search controls the product list, so it should stay visually near the list it filters.
+- In the disconnected marketplace catalog, do not show a top page menu for Dashboard or Themes & Extensions. The WordPress admin sidebar owns page navigation. Keep only right-aligned resource links for Docs, Tutorials, and Support.
+- Existing installed/active premium extensions can continue working elsewhere in WordPress, but the Themes & Extensions page should not manage them while disconnected.
+- License-backed actions must remain gated behind connected account/subscription data: new premium install, premium package download, premium update, refresh purchase, and any server-side license validation flow.
+- Improve disconnected-state messaging without changing behavior. If no official Directorist products are installed locally, use normal subscription-management copy. If official Directorist extensions/themes are installed locally while the account is disconnected, use installed-product copy such as `We found Directorist products installed on this site. They will keep working. Connect your Directorist account to verify subscriptions, install new products, receive updates, and manage license-backed product actions.`
+- The intent is to keep a clear account-first product-management surface, avoid license/update confusion, and reduce support risk for existing customer sites.
+- Connected-state page model can still group products as:
+ - `Installed`: local WordPress plugin/theme state rendered after account connection.
+ - `Available in your account`: subscription/license data rendered after account connection.
+ - `Marketplace`: external product discovery, visible regardless of connection state.
+
+## Disconnected UX And Accessibility Recommendations
+
+- Keep the disconnected state browse-first and connect-when-needed. Do not force login as the only first action.
+- Do not autofocus the username field on normal page load. Autofocus can steal keyboard/screen-reader context and opens the mobile keyboard immediately. Username should remain first in the natural tab order.
+- Focus the username field only after explicit user intent, such as a future `Connect account` CTA or a `#connect-account` deep link.
+- Keep the account-connect form as a real form so pressing Enter inside username/password submits the connect request.
+- Keep the password visibility toggle on the account password field. It must update the input type, icon, `aria-label`, and `aria-pressed` state.
+- Show inline form feedback for empty username, empty password, wrong credentials, unavailable API, nonce/capability failure, and unexpected errors. Avoid browser-only alert feedback for normal validation failures.
+- On connect submit, disable the username/password inputs and submit button only while the request is active. Show an immediate loading state such as `Connecting...`, and restore controls on failure.
+- Keep Docs, Tutorials, and Support as simple right-aligned resource links in disconnected state. External links should use `target="_blank"` with `rel="noopener noreferrer"` and clear accessible text where needed.
+- Keep disconnected marketplace actions read-only unless they are distinct external purchase/detail actions. Prefer `View Details` and `Demo`; use `Get It Now` only when it points to a real distinct purchase/checkout destination.
+- If search/filter returns no results, show an inline empty state with a clear/reset affordance instead of a blank product list.
+- On mobile, keep the connect form one-column, avoid autofocus, keep search near the product list, and verify there is no page-level horizontal overflow.
+
+## Remote Product Badge Policy
+
+- New, beta, popular, sale, or similar product badges/status should be driven by the product API data contract, not hardcoded in plugin templates.
+- The current product catalog contract may not include badge data. Re-check `Directorist\Core\API::get_products()` and the remote `v1/get-remote-products` response before implementation.
+- Preferred remote API shape is a structured optional field:
+
+```json
+"badges": [
+ {
+ "type": "new",
+ "label": "New",
+ "expires_at": "2026-09-01"
+ }
+]
+```
+
+- `badge` as a single object or scalar label may remain supported for filters and backward compatibility.
+- `type` should be a machine-readable enum such as `new`, `beta`, `popular`, `trending`, `sale`, or `featured`.
+- `label` should be the display text from the API and escaped/translated safely where applicable.
+- `expires_at` should be optional and handled server-side or client-side so expired badges do not render.
+- The core plugin UI should read badge/status values from the product API. EDD product meta, custom taxonomy, or a dedicated badge setting can be the upstream source on Directorist.com, but the Themes & Extensions page should depend on the API field, not direct EDD assumptions.
+- Do not depend on normal WordPress/EDD `post_status`; `publish` and `draft` indicate product availability, not UI badge state.
+- Badge rendering must be optional and backward compatible. If no `badge` field exists, render no badge.
+- Local filters `atbdp_extension_list` and `atbdp_theme_list` should still be able to add or override badge data for compatibility and testing.
+- Do not infer `New` from product order, product name, local install date, or a hardcoded slug list.
+- Because the product catalog is cached, badge changes from the API must account for cache invalidation or acceptable cache delay.
+
+### Public Marketing Page Badge Scrape
+
+Last refreshed scrape: 2026-07-22.
+
+Scraped from:
+
+- `https://directorist.com/extensions/`
+- `https://directorist.com/themes/`
+
+This scrape is a temporary implementation reference only. Do not treat these product-to-badge mappings as canonical product data in the plugin. Re-scrape the marketing pages and, more importantly, re-check the product API before implementing or shipping badge behavior.
+
+Observed markup pattern:
+
+- Product cards expose badge machine values through `data-badges`, for example `data-badges="new trending"`.
+- Visible badges render as spans such as `.badge-new`, `.badge-trending`, and `.badge-popular`.
+- Observed badge labels: `New`, `Trending`, `Popular`.
+
+Observed extension badges:
+
+- Directorist Notifications Pro: `New`
+- Directorist Divi Integration: `New`
+- Directorist AI Search: `New`
+- Directorist Listing Importer: `New`, `Trending`
+- Directorist Search Alert: `New`
+- Directorist Announcement: `New`
+- AddonsKit for Bricks: `New`
+- HelpGent Integration: `New`
+- Digital Marketplace: `Trending`
+- Job Manager: `Trending`
+- Booking (Reservation & Appointment): `Popular`
+- Listings with Map: `Popular`
+- Business Hours: `Popular`
+- WooCommerce Pricing Plans: `Popular`
+- PayPal Payment Gateway: `Popular`
+- Pricing Plans: `Popular`, `Trending`
+
+Observed theme badges:
+
+- dJobs: `New`
+- dHotels: `Popular`
+- dClassified: `Trending`
+- OneListing Pro: `Popular`, `Trending`
+- dCar: `Popular`
+- dDoctors: `Popular`
+
+## Product Copy Source Policy
+
+- Product API data is preferred for product names, descriptions, thumbnails, product links, demo links, active promo flags, item IDs, and plugin bases when valid fields exist.
+- Local default product arrays must remain a safe fallback for names, descriptions, thumbnails, links, demo links, item IDs, and plugin bases.
+- Merge API product data over local defaults by product key where possible; do not allow missing remote fields to create blank cards or broken product links.
+- If the API is unavailable, empty, malformed, or missing a non-badge field, use the local fallback field.
+- Badge/status behavior remains stricter: badge/status must come from product API data or explicit filters only. Do not create hardcoded badge/status fallback from local product names, slugs, order, or descriptions.
+- Cross-check user-facing product claims, labels, and descriptions against local `README.md`/`readme.txt` and official Directorist docs before changing copy.
+
+## High Priority Issues To Fix
+
+### Responsive Layout
+
+- Current extension management UI is table-first and uses fixed/minimum widths in the extension name and action areas.
+- This can create page-level horizontal overflow on mobile and narrow admin layouts.
+- Full rewrite should use a responsive management layout: table on wide screens only if needed, card/list rows on narrow screens, stable action menus, and no page-level overflow.
+- Always re-check with Agent Browser desktop and mobile viewports.
+
+Primary areas:
+
+- `views/admin-templates/theme-extensions/my-themes-extensions/extensions-tab.php`
+- `views/admin-templates/theme-extensions/my-themes-extensions/themes-tab.php`
+- `assets/src/scss/layout/admin/admin-style.scss`
+
+### Reload-Heavy UX
+
+- `assets/src/js/admin/components/subscriptionManagement.js` relies heavily on `location.reload()` after successful account connect, install, activate, update, refresh purchase, logout, uninstall, and bulk actions.
+- Do not remove reloads blindly. They currently force canonical server-rendered state.
+- Add progressive no-reload behavior behind existing AJAX actions. Use a small page-state adapter, loading states, normalized error handling, and canonical state refresh before claiming completion.
+- Keep full reload fallback for high-risk actions and unreconciled states.
+
+### Legacy Design CSS Cleanup
+
+- A full UI rewrite should not leave old page-specific design styles in place when they are no longer used.
+- After replacing an old section or selector set, audit legacy styles in `assets/src/scss/layout/admin/admin-style.scss`, generated/admin CSS, and any new page-specific stylesheet.
+- Remove unused old design-specific rules only after confirming they are not used by:
+ - the rewritten Themes & Extensions page;
+ - compatibility selector shims kept for existing users;
+ - theme/template overrides;
+ - other Directorist admin screens that share the same classes.
+- Prefer scoped new page styles over broad global overrides. If a legacy rule must stay for compatibility, document why it stays and isolate new styles with page-specific selectors.
+- Verify the cleanup with source search, desktop/mobile Agent Browser checks, and console/page-error checks. Do not remove old classes that existing JavaScript still reads unless the JS is migrated or a shim remains.
+
+### Dead Login Continuation Flow
+
+- The account-connect success path reloads immediately, leaving older checklist/download continuation code unreachable.
+- During rewrite, either remove the dead path or intentionally rebuild it as a supported flow.
+- Do not revive old checklist behavior without checking current account, subscription, and install contracts.
+
+### Registered Download Handler Type Bug
+
+- `ATBDP_Extensions::handle_file_download_request()` currently has invalid product type validation: valid `plugin` and `theme` values cannot pass the condition.
+- Do not reuse this handler for new flows until the condition and response contract are fixed.
+- Prefer `atbdp_install_file_from_subscriptions` for current subscription installs unless this legacy handler is intentionally repaired and tested.
+
+### Plugin Activation Error Handling
+
+- Single plugin activation calls WordPress `activate_plugin()` without checking for `WP_Error`.
+- A rewrite must surface activation errors, avoid false-success UI, and keep the button recoverable on failure.
+- Bulk activation already checks `WP_Error`; align single activation response behavior with that pattern.
+
+### Filesystem Install And Update Safety
+
+- Plugin/theme download, install, and update flows write to `wp-content/plugins` and `wp-content/themes`.
+- Existing code can delete an existing destination directory before fully validating package extraction and copy success.
+- Full rewrite should introduce a safer install/update service behind existing AJAX actions:
+ - validate download host and URL before download;
+ - validate `download_url()`, `unzip_file()`, and `copy_dir()` results;
+ - verify expected package structure before replacing an existing directory;
+ - clean temp directories reliably;
+ - restore temporary error handlers;
+ - return structured failures with recovery instructions;
+ - never mark UI complete until server confirms final state.
+
+### Plugin Uninstall Safety
+
+- Keep uninstall available for compatibility with the existing page, but make it a protected danger action.
+- Do not remove the behavior unless a future migration explicitly chooses to delegate uninstall fully to the WordPress Plugins screen.
+- Do not expose uninstall as a primary one-click action.
+- Place uninstall under a danger/overflow menu.
+- Require a confirmation modal that names the extension and explains that plugin files will be deleted and dependent site features may break.
+- The server should confirm capability, nonce, plugin target validity, and `delete_plugins()` result before returning success.
+- The UI must not mark uninstall complete until canonical WordPress plugin state has been re-checked, or a reload fallback has completed.
+- Failed uninstall must restore the action UI and show inline failure feedback.
+
+### Theme Update State Defensive Checks
+
+- Theme update code assumes `get_site_transient( 'update_themes' )` is an object with a `response` property.
+- Rewrite code must handle missing, false, malformed, or empty update transient state without PHP warnings.
+- Theme activation calls `switch_theme()` and must remain a high-risk action.
+- Locked recommendation: every theme activation/switch requires an explicit confirmation modal before the AJAX request is sent.
+- The confirmation modal must name the theme and warn that activating it changes the live site's active theme and may affect layout, menus, widgets, headers/footers, and theme settings.
+- Theme activation must not be triggered by a single accidental click. Do not run it from Agent Browser or automated QA on a real/client site without explicit confirmation.
+- Do not show active-theme success UI until server success and canonical active-theme state has been re-checked, or reload fallback has completed.
+
+### Stale License/Purchase Helpers
+
+- `handle_license_activation_request()` exists but is not registered by `setup_ajax_actions()`.
+- `get_customers_purchased()` and older purchased/download helpers contain stale variable usage and duplicated fields.
+- Do not base new logic on these helpers without a focused audit. Prefer a new service layer behind existing public contracts.
+
+### JavaScript State And Error Handling
+
+- Current JS uses repeated state flags, undeclared assignments in some bulk paths, inconsistent error resets, browser `alert()` for several failures, and success paths that reload without inspecting response status.
+- Rewrite should centralize request state, disable and restore controls consistently, show inline feedback, and keep action buttons usable after errors.
+- Bulk flows should aggregate per-item results and continue safely where appropriate.
+
+### Accessibility, Semantics, And I18n
+
+- Current templates include hardcoded English strings, duplicate IDs, empty or `#` action links, external `target="_blank"` links without `rel`, placeholder-only fields, and action controls implemented as links.
+- Rewrite should use buttons for local actions, unique IDs, visible labels or accessible labels, `rel="noopener noreferrer"` for external blank-target links, translatable strings, and clear keyboard/focus behavior.
+
+### Theme Changelog Modal
+
+- The theme "What's new" modal contains dummy hardcoded version and changelog content.
+- Do not ship this content in a redesign. Either connect it to real update/changelog data or remove/hide the feature.
+
+### Remote API And Credential Handling
+
+- Browser JS should continue talking to local WordPress AJAX only. PHP owns Directorist.com and EDD remote calls.
+- Existing remote flows mix Directorist REST endpoints and EDD action endpoints; keep response normalization server-side.
+- Review `sslverify => false` usages carefully. Harden only with a compatibility plan and useful error handling for customer sites.
+- Never log or store account passwords beyond the current request. Keep account connection and refresh purchase flows careful around credentials.
+- Before changing API behavior, read `api-data-flow-report.md#future-api-improvement-feedback` and apply its guidance for normalized responses, state-summary refresh, stable error codes, product catalog versioning, cache freshness, credential handling, and mutation safety.
+
+## Rewrite Fix Order
+
+1. Preserve contracts first: page slug, capability, AJAX action names, nonces, filters, aliases, meta keys, and existing settings/product links.
+2. Preserve current disconnected behavior: account-connect form plus marketplace only, with no installed premium product management while disconnected.
+3. Implement product copy merging with API data as primary and local product arrays as fallback for non-badge fields.
+4. Add optional product badge/status support through product API contracts, with no hardcoded badge fallback.
+5. Add focused PHP service/resolver classes behind existing AJAX actions instead of introducing Vue/React as the first rewrite step.
+6. Add server-side state summary or render-partial capability before removing reloads.
+7. Build a response formatter for all page AJAX handlers: success, error, message, action, item key, canonical state needed, reload fallback.
+8. Add a small page-specific JavaScript state adapter around existing AJAX calls.
+9. Replace fragile table layout with responsive rows/cards while preserving old selectors or compatibility wrappers.
+10. Audit and remove unused old design-specific CSS after confirming compatibility selectors and other admin screens do not depend on it.
+11. Fix low-risk no-reload flows first: account-connect feedback, refresh purchase errors, tabs, filtering/search, inline errors, and button states.
+12. Harden single plugin activation and bulk result handling.
+13. Harden install/update/download services before changing their UI behavior.
+14. Keep uninstall for compatibility, but move it behind a protected danger action with confirmation and canonical plugin-state revalidation.
+15. Require confirmation modal for every theme switch, then revalidate canonical active-theme state or use reload fallback.
+16. Treat plugin/theme update and filesystem replacement as conservative flows with server revalidation and reload fallback.
+17. Remove or rebuild stale/dead code only after verifying no custom integration depends on old selectors or action names.
+
+## Regression Checklist For Full Rewrite
+
+- Disconnected account state renders and validates without page errors.
+- Disconnected account state keeps the current behavior: account-connect form plus marketplace, without installed premium product management actions.
+- Connected account state renders statistics, extensions, themes, required items, and promo items from dynamic server state.
+- Premium install/update/download actions remain gated behind connected account/subscription/license state.
+- Product names, descriptions, thumbnails, and links use API data when present and local fallback when API fields are missing.
+- Product badges render only from API/filter-provided badge data and disappear safely when absent or expired.
+- Existing installed active, installed inactive, subscribed-not-installed, outdated, required, promo-only, active theme, inactive theme, and update-available states are all represented.
+- No page-level horizontal overflow on mobile admin widths.
+- Old design-specific CSS that is no longer used by this page, compatibility shims, overrides, or shared admin screens has been removed or explicitly documented as intentionally retained.
+- No action leaves buttons permanently disabled after failure.
+- No local action uses `alert()` as the only feedback.
+- Uninstall is not exposed as a one-click primary action; it requires explicit confirmation and recovers cleanly on failure.
+- Theme activation requires explicit confirmation every time and does not show success before active-theme state is confirmed.
+- No success UI is shown before server acceptance.
+- High-risk actions re-check canonical WordPress state before final UI update.
+- Console and page errors stay clean after load, tab changes, and non-destructive interactions.
+- Official Directorist docs and local `README.md`/`readme.txt` are checked before changing public claims, product labels, or descriptions.
diff --git a/includes/asset-loader/init.php b/includes/asset-loader/init.php
index 0d4490b977..8f0c3b2947 100644
--- a/includes/asset-loader/init.php
+++ b/includes/asset-loader/init.php
@@ -281,7 +281,9 @@ public static function admin_scripts( string $hook_suffix ) {
wp_enqueue_media();
} elseif ( Helper::is_admin_page( 'extensions' ) ) {
wp_enqueue_style( 'directorist-admin-style' );
+ wp_enqueue_style( 'directorist-themes-extensions', DIRECTORIST_ASSETS . 'css/directorist-themes-extensions.css', [ 'directorist-admin-style' ], ATBDP_VERSION );
wp_enqueue_script( 'directorist-admin-script' );
+ wp_enqueue_script( 'directorist-themes-extensions', DIRECTORIST_ASSETS . 'js/directorist-themes-extensions.js', [ 'jquery', 'directorist-admin-script' ], ATBDP_VERSION, true );
wp_enqueue_script( 'directorist-tooltip' );
// Inline styles
diff --git a/includes/classes/class-extension-activity.php b/includes/classes/class-extension-activity.php
new file mode 100644
index 0000000000..38987fd95a
--- /dev/null
+++ b/includes/classes/class-extension-activity.php
@@ -0,0 +1,963 @@
+publish ) ? (int) $post_counts->publish : 0;
+ $pending_listings = isset( $post_counts->pending ) ? (int) $post_counts->pending : 0;
+ $views_meta_key = directorist_get_listing_views_count_meta_key();
+ $listing_views = (int) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COALESCE( SUM( CAST( postmeta.meta_value AS UNSIGNED ) ), 0 )
+ FROM {$wpdb->posts} AS posts
+ INNER JOIN {$wpdb->postmeta} AS postmeta
+ ON posts.ID = postmeta.post_id
+ WHERE posts.post_type = %s
+ AND posts.post_status = 'publish'
+ AND postmeta.meta_key = %s",
+ ATBDP_POST_TYPE,
+ $views_meta_key
+ )
+ );
+ $payment_stats = $this->get_payment_stats( time() - ( 30 * DAY_IN_SECONDS ) );
+
+ return [
+ 'published_listings' => $published_listings,
+ 'listing_views' => $listing_views,
+ 'pending_listings' => $pending_listings,
+ 'expiring_this_week' => $this->get_expiring_listing_count( 7 ),
+ 'revenue' => (float) $payment_stats['amount'],
+ 'paid_orders' => (int) $payment_stats['count'],
+ 'currency' => atbdp_get_payment_currency(),
+ ];
+ }
+
+ /**
+ * Get the connected dashboard's canonical setup progress.
+ *
+ * @param array $metrics Pre-collected dashboard metrics.
+ *
+ * @return array
+ */
+ public function get_dashboard_setup( $metrics = [] ) {
+ $directories = directory_types();
+ $directories = is_array( $directories ) && ! is_wp_error( $directories ) ? $directories : [];
+ $category_count = wp_count_terms(
+ [
+ 'taxonomy' => ATBDP_CATEGORY,
+ 'hide_empty' => false,
+ ]
+ );
+ $category_count = is_wp_error( $category_count ) ? 0 : (int) $category_count;
+ $active_gateways = ATBDP_Gateway::get_active_gateways();
+ $active_gateways = is_array( $active_gateways ) ? array_filter( $active_gateways ) : [];
+ $has_directories = ! empty( $directories );
+ $builder_url = admin_url( 'edit.php?post_type=at_biz_dir&page=atbdp-layout-builder' );
+
+ if ( directorist_is_multi_directory_enabled() ) {
+ $builder_url = admin_url( 'edit.php?post_type=at_biz_dir&page=atbdp-directory-types' );
+ }
+
+ $steps = [
+ [
+ 'label' => $has_directories
+ ? __( 'Manage directory type', 'directorist' )
+ : __( 'Create a directory type', 'directorist' ),
+ 'complete' => $has_directories,
+ 'url' => $builder_url,
+ ],
+ [
+ 'label' => $category_count > 0
+ ? __( 'Manage listing categories', 'directorist' )
+ : __( 'Add your real categories', 'directorist' ),
+ 'complete' => $category_count > 0,
+ 'url' => admin_url( 'edit-tags.php?taxonomy=' . ATBDP_CATEGORY . '&post_type=' . ATBDP_POST_TYPE ),
+ ],
+ [
+ 'label' => ! empty( $active_gateways )
+ ? __( 'Review payment gateways', 'directorist' )
+ : __( 'Configure a payment gateway', 'directorist' ),
+ 'complete' => ! empty( $active_gateways ),
+ 'url' => admin_url( 'edit.php?post_type=at_biz_dir&page=atbdp-settings#monetization_settings__gateway' ),
+ ],
+ [
+ 'label' => ! empty( $metrics['published_listings'] )
+ ? __( 'Manage published listings', 'directorist' )
+ : __( 'Publish your first real listing', 'directorist' ),
+ 'complete' => ! empty( $metrics['published_listings'] ),
+ 'url' => admin_url( 'edit.php?post_type=' . ATBDP_POST_TYPE ),
+ ],
+ ];
+ $completed_steps = count(
+ array_filter(
+ $steps,
+ static function( $step ) {
+ return ! empty( $step['complete'] );
+ }
+ )
+ );
+ $progress = $steps ? (int) round( ( $completed_steps / count( $steps ) ) * 100 ) : 0;
+
+ return [
+ 'progress' => $progress,
+ 'title' => 100 === $progress
+ ? __( 'Your directory foundation is ready', 'directorist' )
+ : __( 'A few steps to launch your directory', 'directorist' ),
+ 'description' => 100 === $progress
+ ? __( 'Core setup is complete. Use these links whenever you need to make changes.', 'directorist' )
+ : __( 'Complete the remaining setup tasks before accepting live submissions.', 'directorist' ),
+ 'steps' => $steps,
+ ];
+ }
+
+ /**
+ * Supported activity filters.
+ *
+ * @var array
+ */
+ private $supported_types = [ 'all', 'listing', 'review', 'payment', 'user' ];
+
+ /**
+ * Get one activity page.
+ *
+ * @param int $page Page number.
+ * @param int $per_page Items per page.
+ * @param string $type Activity type.
+ *
+ * @return array
+ */
+ public function get_page( $page = 1, $per_page = 10, $type = 'all' ) {
+ $page = max( 1, min( 10, absint( $page ) ) );
+ $per_page = max( 1, min( 20, absint( $per_page ) ) );
+ $type = sanitize_key( $type );
+ $type = in_array( $type, $this->supported_types, true ) ? $type : 'all';
+ $offset = ( $page - 1 ) * $per_page;
+ $limit = min( 101, $offset + $per_page + 1 );
+ $items = [];
+
+ if ( in_array( $type, [ 'all', 'listing' ], true ) ) {
+ $items = array_merge(
+ $items,
+ $this->get_listing_activity( $limit ),
+ $this->get_expiring_listing_activity( min( $limit, 10 ) )
+ );
+ }
+
+ if ( in_array( $type, [ 'all', 'review' ], true ) ) {
+ $items = array_merge( $items, $this->get_review_activity( $limit ) );
+ }
+
+ if ( in_array( $type, [ 'all', 'payment' ], true ) ) {
+ $items = array_merge( $items, $this->get_payment_activity( $limit ) );
+ }
+
+ if ( in_array( $type, [ 'all', 'user' ], true ) ) {
+ $items = array_merge( $items, $this->get_user_activity( $limit ) );
+ }
+
+ $unique_items = [];
+
+ foreach ( array_filter( $items ) as $item ) {
+ $unique_items[ $item['id'] ] = $item;
+ }
+
+ $items = array_values( $unique_items );
+
+ usort(
+ $items,
+ static function( $left, $right ) {
+ $left_upcoming = ! empty( $left['upcoming'] );
+ $right_upcoming = ! empty( $right['upcoming'] );
+
+ if ( $left_upcoming !== $right_upcoming ) {
+ return $left_upcoming ? 1 : -1;
+ }
+
+ if ( $left_upcoming ) {
+ return (int) $left['timestamp'] <=> (int) $right['timestamp'];
+ }
+
+ return (int) $right['timestamp'] <=> (int) $left['timestamp'];
+ }
+ );
+
+ $has_more = $page < 10 && count( $items ) > ( $offset + $per_page );
+ $items = array_slice( $items, $offset, $per_page );
+
+ foreach ( $items as &$item ) {
+ $item['group'] = $this->get_group( $item );
+ $item['group_label'] = $this->get_group_label( $item['group'] );
+ $item['time_label'] = $this->get_time_label( $item );
+ }
+ unset( $item );
+
+ $data = [
+ 'items' => $items,
+ 'has_more' => $has_more,
+ 'next_page' => $has_more ? $page + 1 : null,
+ 'page' => $page,
+ 'type' => $type,
+ ];
+
+ /**
+ * Filter connected dashboard activity data.
+ *
+ * @param array $data Prepared activity page.
+ * @param int $page Current page.
+ * @param int $per_page Items per page.
+ * @param string $type Current activity filter.
+ */
+ return apply_filters( 'directorist_themes_extensions_activity_data', $data, $page, $per_page, $type );
+ }
+
+ /**
+ * Get recent listing activity.
+ *
+ * @param int $limit Query limit.
+ *
+ * @return array
+ */
+ private function get_listing_activity( $limit ) {
+ $query = new WP_Query(
+ [
+ 'post_type' => ATBDP_POST_TYPE,
+ 'post_status' => [ 'publish', 'pending', 'draft' ],
+ 'posts_per_page' => $limit,
+ 'orderby' => 'modified',
+ 'order' => 'DESC',
+ 'no_found_rows' => true,
+ 'update_post_meta_cache' => false,
+ 'update_post_term_cache' => false,
+ ]
+ );
+ $items = [];
+
+ foreach ( $query->posts as $listing ) {
+ $author = get_userdata( $listing->post_author );
+ $status = get_post_status( $listing );
+ $title = 'pending' === $status
+ ? __( 'Listing awaiting review', 'directorist' )
+ : ( 'publish' === $status ? __( 'Listing published', 'directorist' ) : __( 'Listing draft saved', 'directorist' ) );
+ $action = 'pending' === $status ? __( 'Review', 'directorist' ) : __( 'Edit', 'directorist' );
+
+ $items[] = $this->prepare_item(
+ [
+ 'id' => 'listing-' . $listing->ID,
+ 'type' => 'listing',
+ 'title' => $title,
+ 'subject' => get_the_title( $listing ) ?: __( 'Untitled listing', 'directorist' ),
+ 'context' => $author ? sprintf(
+ /* translators: %s: Listing author display name. */
+ __( 'by %s', 'directorist' ),
+ $author->display_name
+ ) : '',
+ 'timestamp' => $this->get_post_timestamp( $listing, true ),
+ 'icon' => 'la la-plus',
+ 'tone' => 'blue',
+ 'action_label' => $action,
+ 'action_url' => get_edit_post_link( $listing->ID, 'raw' ),
+ ]
+ );
+ }
+
+ return array_filter( $items );
+ }
+
+ /**
+ * Get recent review activity.
+ *
+ * @param int $limit Query limit.
+ *
+ * @return array
+ */
+ private function get_review_activity( $limit ) {
+ $comments = get_comments(
+ [
+ 'type' => 'review',
+ 'post_type' => ATBDP_POST_TYPE,
+ 'status' => 'all',
+ 'parent' => 0,
+ 'number' => min( 202, $limit * 2 ),
+ 'orderby' => 'comment_date_gmt',
+ 'order' => 'DESC',
+ ]
+ );
+ $items = [];
+
+ foreach ( $comments as $comment ) {
+ if ( ! in_array( (string) $comment->comment_approved, [ '0', '1' ], true ) ) {
+ continue;
+ }
+
+ $rating = class_exists( '\Directorist\Review\Comment' )
+ ? \Directorist\Review\Comment::get_rating( $comment->comment_ID )
+ : (float) get_comment_meta( $comment->comment_ID, 'rating', true );
+ $context = $rating
+ ? sprintf(
+ /* translators: 1: Review rating, 2: Reviewer name. */
+ __( '%1$s-star review by %2$s', 'directorist' ),
+ number_format_i18n( $rating, 1 ),
+ $comment->comment_author
+ )
+ : sprintf(
+ /* translators: %s: Reviewer name. */
+ __( 'Review by %s', 'directorist' ),
+ $comment->comment_author
+ );
+
+ $items[] = $this->prepare_item(
+ [
+ 'id' => 'review-' . $comment->comment_ID,
+ 'type' => 'review',
+ 'title' => '0' === (string) $comment->comment_approved
+ ? __( 'Review awaiting moderation', 'directorist' )
+ : __( 'New review received', 'directorist' ),
+ 'subject' => get_the_title( $comment->comment_post_ID ) ?: __( 'Untitled listing', 'directorist' ),
+ 'context' => $context,
+ 'timestamp' => strtotime( $comment->comment_date_gmt . ' UTC' ),
+ 'icon' => 'la la-star',
+ 'tone' => 'green',
+ 'action_label' => __( 'Review', 'directorist' ),
+ 'action_url' => add_query_arg(
+ [
+ 'action' => 'editcomment',
+ 'c' => $comment->comment_ID,
+ ],
+ admin_url( 'comment.php' )
+ ),
+ ]
+ );
+
+ if ( count( $items ) >= $limit ) {
+ break;
+ }
+ }
+
+ return array_filter( $items );
+ }
+
+ /**
+ * Get completed paid-order activity.
+ *
+ * @param int $limit Query limit.
+ *
+ * @return array
+ */
+ private function get_payment_activity( $limit ) {
+ $items = $this->get_modern_payment_activity( $limit );
+ $modern_legacy_ids = array_values(
+ array_filter(
+ array_map(
+ static function( $item ) {
+ return absint( $item['legacy_id'] ?? 0 );
+ },
+ $items
+ )
+ )
+ );
+
+ foreach ( $items as &$item ) {
+ unset( $item['legacy_id'] );
+ }
+ unset( $item );
+
+ if ( count( $items ) >= $limit ) {
+ return array_slice( $items, 0, $limit );
+ }
+
+ $query = new WP_Query(
+ [
+ 'post_type' => ATBDP_ORDER_POST_TYPE,
+ 'post_status' => 'publish',
+ 'posts_per_page' => min( 202, $limit * 2 ),
+ 'orderby' => 'date',
+ 'order' => 'DESC',
+ 'no_found_rows' => true,
+ 'update_post_meta_cache' => true,
+ 'update_post_term_cache' => false,
+ 'meta_query' => [
+ [
+ 'key' => '_payment_status',
+ 'value' => 'completed',
+ ],
+ ],
+ ]
+ );
+
+ foreach ( $query->posts as $order ) {
+ if ( in_array( (int) $order->ID, $modern_legacy_ids, true ) ) {
+ continue;
+ }
+
+ $amount = (float) get_post_meta( $order->ID, '_amount', true );
+
+ if ( $amount <= 0 ) {
+ continue;
+ }
+
+ $listing_id = absint( get_post_meta( $order->ID, '_listing_id', true ) );
+ $listing_title = $listing_id ? get_the_title( $listing_id ) : '';
+ $customer = get_userdata( $order->post_author );
+ $context_parts = [];
+
+ if ( $customer ) {
+ $context_parts[] = sprintf(
+ /* translators: %s: Customer display name. */
+ __( 'from %s', 'directorist' ),
+ $customer->display_name
+ );
+ }
+
+ if ( $listing_title ) {
+ $context_parts[] = sprintf(
+ /* translators: %s: Listing title. */
+ __( 'for %s', 'directorist' ),
+ $listing_title
+ );
+ }
+
+ $items[] = $this->prepare_item(
+ [
+ 'id' => 'payment-' . $order->ID,
+ 'type' => 'payment',
+ 'title' => __( 'Payment received', 'directorist' ),
+ 'subject' => html_entity_decode( atbdp_currency_symbol( atbdp_get_payment_currency() ), ENT_QUOTES, get_bloginfo( 'charset' ) ) . number_format_i18n( $amount, 2 ),
+ 'context' => implode( ' ', $context_parts ),
+ 'timestamp' => $this->get_post_timestamp( $order ),
+ 'icon' => 'la la-dollar',
+ 'tone' => 'violet',
+ 'action_label' => __( 'View order', 'directorist' ),
+ 'action_url' => get_edit_post_link( $order->ID, 'raw' ),
+ ]
+ );
+
+ if ( count( $items ) >= $limit ) {
+ break;
+ }
+ }
+
+ return array_values( array_filter( $items ) );
+ }
+
+ /**
+ * Get completed payments from the current table-based order system.
+ *
+ * @param int $limit Query limit.
+ *
+ * @return array
+ */
+ private function get_modern_payment_activity( $limit ) {
+ global $wpdb;
+
+ $orders_table = $wpdb->prefix . 'directorist_orders';
+
+ if ( ! $this->modern_orders_table_exists() ) {
+ return [];
+ }
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The table name uses the trusted WordPress prefix.
+ $orders = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT id, legacy_id, user_id, listing_id, amount, currency, created_at,
+ UNIX_TIMESTAMP( created_at ) AS created_timestamp
+ FROM {$orders_table}
+ WHERE status = %s
+ AND amount > 0
+ ORDER BY created_at DESC, id DESC
+ LIMIT %d",
+ 'paid',
+ max( 1, $limit )
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared.
+
+ $items = [];
+
+ foreach ( $orders as $order ) {
+ $amount = (float) $order->amount;
+ $listing_title = $order->listing_id ? get_the_title( (int) $order->listing_id ) : '';
+ $customer = get_userdata( (int) $order->user_id );
+ $context_parts = [];
+ $currency = $order->currency ?: atbdp_get_payment_currency();
+
+ if ( $customer ) {
+ $context_parts[] = sprintf(
+ /* translators: %s: Customer display name. */
+ __( 'from %s', 'directorist' ),
+ $customer->display_name
+ );
+ }
+
+ if ( $listing_title ) {
+ $context_parts[] = sprintf(
+ /* translators: %s: Listing title. */
+ __( 'for %s', 'directorist' ),
+ $listing_title
+ );
+ }
+
+ $item = $this->prepare_item(
+ [
+ 'id' => 'payment-db-' . $order->id,
+ 'type' => 'payment',
+ 'title' => __( 'Payment received', 'directorist' ),
+ 'subject' => html_entity_decode( atbdp_currency_symbol( $currency ), ENT_QUOTES, get_bloginfo( 'charset' ) ) . number_format_i18n( $amount, 2 ),
+ 'context' => implode( ' ', $context_parts ),
+ 'timestamp' => absint( $order->created_timestamp ),
+ 'icon' => 'la la-dollar',
+ 'tone' => 'violet',
+ 'action_label' => __( 'View order', 'directorist' ),
+ 'action_url' => add_query_arg(
+ [
+ 'post_type' => ATBDP_POST_TYPE,
+ 'page' => 'directorist-orders',
+ ],
+ admin_url( 'edit.php' )
+ ) . '#/edit/' . absint( $order->id ),
+ ]
+ );
+
+ if ( $item ) {
+ $item['legacy_id'] = absint( $order->legacy_id );
+ $items[] = $item;
+ }
+ }
+
+ return $items;
+ }
+
+ /**
+ * Get paid-order totals from modern and unmigrated legacy storage.
+ *
+ * @param int $after_timestamp Earliest included order timestamp.
+ *
+ * @return array
+ */
+ private function get_payment_stats( $after_timestamp ) {
+ global $wpdb;
+
+ $orders_table = $wpdb->prefix . 'directorist_orders';
+ $stats = [
+ 'amount' => 0.0,
+ 'count' => 0,
+ ];
+ $table_exists = $this->modern_orders_table_exists();
+
+ if ( $table_exists ) {
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The table name uses the trusted WordPress prefix.
+ $modern_stats = $wpdb->get_row(
+ $wpdb->prepare(
+ "SELECT COALESCE( SUM( amount ), 0 ) AS amount, COUNT( id ) AS order_count
+ FROM {$orders_table}
+ WHERE status = %s
+ AND amount > 0
+ AND created_at >= FROM_UNIXTIME( %d )",
+ 'paid',
+ $after_timestamp
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared.
+
+ if ( $modern_stats ) {
+ $stats['amount'] += (float) $modern_stats->amount;
+ $stats['count'] += (int) $modern_stats->order_count;
+ }
+ }
+
+ $legacy_join = $table_exists
+ ? "LEFT JOIN {$orders_table} AS modern_orders ON modern_orders.legacy_id = posts.ID"
+ : '';
+ $legacy_where = $table_exists ? 'AND modern_orders.id IS NULL' : '';
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WordPress core table names and the checked Directorist table are trusted.
+ $legacy_stats = $wpdb->get_row(
+ $wpdb->prepare(
+ "SELECT COALESCE( SUM( CAST( amount_meta.meta_value AS DECIMAL(10,2) ) ), 0 ) AS amount,
+ COUNT( DISTINCT posts.ID ) AS order_count
+ FROM {$wpdb->posts} AS posts
+ INNER JOIN {$wpdb->postmeta} AS status_meta
+ ON posts.ID = status_meta.post_id
+ AND status_meta.meta_key = '_payment_status'
+ AND status_meta.meta_value = 'completed'
+ INNER JOIN {$wpdb->postmeta} AS amount_meta
+ ON posts.ID = amount_meta.post_id
+ AND amount_meta.meta_key = '_amount'
+ {$legacy_join}
+ WHERE posts.post_type = %s
+ AND posts.post_status = 'publish'
+ AND CAST( amount_meta.meta_value AS DECIMAL(10,2) ) > 0
+ AND posts.post_date_gmt >= %s
+ {$legacy_where}",
+ ATBDP_ORDER_POST_TYPE,
+ gmdate( 'Y-m-d H:i:s', $after_timestamp )
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared.
+
+ if ( $legacy_stats ) {
+ $stats['amount'] += (float) $legacy_stats->amount;
+ $stats['count'] += (int) $legacy_stats->order_count;
+ }
+
+ return $stats;
+ }
+
+ /**
+ * Check the modern orders table once per service instance.
+ *
+ * @return bool
+ */
+ private function modern_orders_table_exists() {
+ global $wpdb;
+
+ if ( null !== $this->modern_orders_table_exists ) {
+ return $this->modern_orders_table_exists;
+ }
+
+ $orders_table = $wpdb->prefix . 'directorist_orders';
+ $this->modern_orders_table_exists = $orders_table === $wpdb->get_var(
+ $wpdb->prepare(
+ 'SHOW TABLES LIKE %s',
+ $orders_table
+ )
+ );
+
+ return $this->modern_orders_table_exists;
+ }
+
+ /**
+ * Count published listings expiring within the requested number of days.
+ *
+ * @param int $days Number of days.
+ *
+ * @return int
+ */
+ private function get_expiring_listing_count( $days ) {
+ $query = new WP_Query(
+ [
+ 'post_type' => ATBDP_POST_TYPE,
+ 'post_status' => 'publish',
+ 'posts_per_page' => 1,
+ 'fields' => 'ids',
+ 'no_found_rows' => false,
+ 'update_post_meta_cache' => false,
+ 'update_post_term_cache' => false,
+ 'meta_query' => [
+ 'relation' => 'AND',
+ [
+ 'key' => '_never_expire',
+ 'compare' => 'NOT EXISTS',
+ ],
+ [
+ 'key' => '_expiry_date',
+ 'value' => [
+ current_time( 'mysql' ),
+ current_datetime()->modify( '+' . max( 1, absint( $days ) ) . ' days' )->format( 'Y-m-d H:i:s' ),
+ ],
+ 'compare' => 'BETWEEN',
+ 'type' => 'DATETIME',
+ ],
+ ],
+ ]
+ );
+
+ return (int) $query->found_posts;
+ }
+
+ /**
+ * Get recent Directorist user registrations.
+ *
+ * @param int $limit Query limit.
+ *
+ * @return array
+ */
+ private function get_user_activity( $limit ) {
+ $query = new WP_User_Query(
+ [
+ 'number' => min( 202, $limit * 2 ),
+ 'orderby' => 'registered',
+ 'order' => 'DESC',
+ 'meta_query' => [
+ [
+ 'key' => '_user_type',
+ 'compare' => 'EXISTS',
+ ],
+ ],
+ ]
+ );
+ $items = [];
+
+ foreach ( $query->get_results() as $user ) {
+ $user_type = (string) get_user_meta( $user->ID, '_user_type', true );
+
+ if ( '' === $user_type ) {
+ continue;
+ }
+
+ $items[] = $this->prepare_item(
+ [
+ 'id' => 'user-' . $user->ID,
+ 'type' => 'user',
+ 'title' => __( 'New user registered', 'directorist' ),
+ 'subject' => $user->display_name ?: $user->user_login,
+ 'context' => 'author' === $user_type
+ ? __( 'Registered as a listing owner', 'directorist' )
+ : __( 'Registered for the directory', 'directorist' ),
+ 'timestamp' => strtotime( $user->user_registered . ' UTC' ),
+ 'icon' => 'la la-user-plus',
+ 'tone' => 'info',
+ 'action_label' => __( 'View user', 'directorist' ),
+ 'action_url' => get_edit_user_link( $user->ID ),
+ ]
+ );
+
+ if ( count( $items ) >= $limit ) {
+ break;
+ }
+ }
+
+ return array_filter( $items );
+ }
+
+ /**
+ * Get listings that will expire soon.
+ *
+ * @param int $limit Query limit.
+ *
+ * @return array
+ */
+ private function get_expiring_listing_activity( $limit ) {
+ $now = current_time( 'mysql' );
+ $threshold = gmdate( 'Y-m-d H:i:s', strtotime( '+30 days', current_time( 'timestamp' ) ) );
+ $query = new WP_Query(
+ [
+ 'post_type' => ATBDP_POST_TYPE,
+ 'post_status' => 'publish',
+ 'posts_per_page' => $limit,
+ 'orderby' => 'meta_value',
+ 'meta_key' => '_expiry_date',
+ 'order' => 'ASC',
+ 'no_found_rows' => true,
+ 'update_post_meta_cache' => true,
+ 'update_post_term_cache' => false,
+ 'meta_query' => [
+ 'relation' => 'AND',
+ [
+ 'key' => '_never_expire',
+ 'compare' => 'NOT EXISTS',
+ ],
+ [
+ 'key' => '_expiry_date',
+ 'value' => [
+ $now,
+ $threshold,
+ ],
+ 'compare' => 'BETWEEN',
+ 'type' => 'DATETIME',
+ ],
+ ],
+ ]
+ );
+ $items = [];
+
+ foreach ( $query->posts as $listing ) {
+ $expiry_value = (string) get_post_meta( $listing->ID, '_expiry_date', true );
+ $expiry_gmt = $expiry_value ? get_gmt_from_date( $expiry_value ) : '';
+ $timestamp = $expiry_gmt ? strtotime( $expiry_gmt . ' UTC' ) : false;
+
+ if ( ! $timestamp ) {
+ continue;
+ }
+
+ $days = max( 1, (int) ceil( ( $timestamp - time() ) / DAY_IN_SECONDS ) );
+
+ $items[] = $this->prepare_item(
+ [
+ 'id' => 'expiry-' . $listing->ID,
+ 'type' => 'listing',
+ 'title' => __( 'Listing expiring soon', 'directorist' ),
+ 'subject' => get_the_title( $listing ) ?: __( 'Untitled listing', 'directorist' ),
+ 'context' => sprintf(
+ /* translators: %d: Number of days before listing expiration. */
+ _n( 'Expires in %d day', 'Expires in %d days', $days, 'directorist' ),
+ $days
+ ),
+ 'timestamp' => $timestamp,
+ 'icon' => 'la la-hourglass-half',
+ 'tone' => 'amber',
+ 'action_label' => __( 'Review', 'directorist' ),
+ 'action_url' => get_edit_post_link( $listing->ID, 'raw' ),
+ 'upcoming' => true,
+ ]
+ );
+ }
+
+ return array_filter( $items );
+ }
+
+ /**
+ * Normalize one activity item.
+ *
+ * @param array $item Activity item.
+ *
+ * @return array|null
+ */
+ private function prepare_item( $item ) {
+ $timestamp = ! empty( $item['timestamp'] ) ? absint( $item['timestamp'] ) : 0;
+ $url = ! empty( $item['action_url'] ) ? esc_url_raw( $item['action_url'] ) : '';
+
+ if ( ! $timestamp || empty( $item['id'] ) || empty( $item['type'] ) || empty( $item['title'] ) ) {
+ return null;
+ }
+
+ return [
+ 'id' => sanitize_key( $item['id'] ),
+ 'type' => sanitize_key( $item['type'] ),
+ 'title' => sanitize_text_field( $item['title'] ),
+ 'subject' => sanitize_text_field( $item['subject'] ?? '' ),
+ 'context' => sanitize_text_field( $item['context'] ?? '' ),
+ 'timestamp' => $timestamp,
+ 'icon' => sanitize_text_field( $item['icon'] ?? 'la la-history' ),
+ 'tone' => sanitize_key( $item['tone'] ?? 'blue' ),
+ 'action_label' => $url ? sanitize_text_field( $item['action_label'] ?? __( 'Open', 'directorist' ) ) : '',
+ 'action_url' => $url,
+ 'upcoming' => ! empty( $item['upcoming'] ),
+ ];
+ }
+
+ /**
+ * Get a post timestamp in UTC.
+ *
+ * @param WP_Post $post Post object.
+ * @param bool $use_modified Use the modified date.
+ *
+ * @return int
+ */
+ private function get_post_timestamp( $post, $use_modified = false ) {
+ $date = $use_modified ? $post->post_modified_gmt : $post->post_date_gmt;
+
+ if ( ! $date || '0000-00-00 00:00:00' === $date ) {
+ $date = get_gmt_from_date( $use_modified ? $post->post_modified : $post->post_date );
+ }
+
+ return absint( strtotime( $date . ' UTC' ) );
+ }
+
+ /**
+ * Get the item's date group.
+ *
+ * @param array $item Activity item.
+ *
+ * @return string
+ */
+ private function get_group( $item ) {
+ if ( ! empty( $item['upcoming'] ) ) {
+ return 'upcoming';
+ }
+
+ $timestamp = (int) $item['timestamp'];
+ $item_date = $this->format_site_date( 'Y-m-d', $timestamp );
+ $today = $this->format_site_date( 'Y-m-d', time() );
+ $yesterday = $this->format_site_date( 'Y-m-d', time() - DAY_IN_SECONDS );
+
+ if ( $item_date === $today ) {
+ return 'today';
+ }
+
+ if ( $item_date === $yesterday ) {
+ return 'yesterday';
+ }
+
+ return 'earlier';
+ }
+
+ /**
+ * Get a localized group label.
+ *
+ * @param string $group Group key.
+ *
+ * @return string
+ */
+ private function get_group_label( $group ) {
+ $labels = [
+ 'today' => __( 'Today', 'directorist' ),
+ 'yesterday' => __( 'Yesterday', 'directorist' ),
+ 'earlier' => __( 'Earlier', 'directorist' ),
+ 'upcoming' => __( 'Upcoming', 'directorist' ),
+ ];
+
+ return $labels[ $group ] ?? $labels['earlier'];
+ }
+
+ /**
+ * Get a concise localized time label.
+ *
+ * @param array $item Activity item.
+ *
+ * @return string
+ */
+ private function get_time_label( $item ) {
+ $timestamp = (int) $item['timestamp'];
+ $now = time();
+
+ if ( ! empty( $item['upcoming'] ) ) {
+ return $this->format_site_date( get_option( 'date_format' ) . ', ' . get_option( 'time_format' ), $timestamp );
+ }
+
+ if ( $timestamp <= $now && ( $now - $timestamp ) < DAY_IN_SECONDS ) {
+ return sprintf(
+ /* translators: %s: Human-readable time difference. */
+ __( '%s ago', 'directorist' ),
+ human_time_diff( $timestamp, $now )
+ );
+ }
+
+ return $this->format_site_date( get_option( 'date_format' ) . ', ' . get_option( 'time_format' ), $timestamp );
+ }
+
+ /**
+ * Format a UTC timestamp in the site timezone across supported WordPress versions.
+ *
+ * @param string $format Date format.
+ * @param int $timestamp UTC timestamp.
+ *
+ * @return string
+ */
+ private function format_site_date( $format, $timestamp ) {
+ if ( function_exists( 'wp_date' ) ) {
+ return wp_date( $format, $timestamp );
+ }
+
+ $local_date = get_date_from_gmt( gmdate( 'Y-m-d H:i:s', $timestamp ) );
+
+ return mysql2date( $format, $local_date, true );
+ }
+ }
+}
diff --git a/includes/classes/class-extension-recommendations.php b/includes/classes/class-extension-recommendations.php
new file mode 100644
index 0000000000..dea61beb20
--- /dev/null
+++ b/includes/classes/class-extension-recommendations.php
@@ -0,0 +1,700 @@
+products = $products;
+ $this->overview = $overview;
+ $this->aliases = $aliases;
+ $this->is_beta = (bool) $is_beta;
+ }
+
+ /**
+ * Build connected-dashboard recommendation data.
+ *
+ * @return array
+ */
+ public function get_dashboard_data() {
+ $profiles = $this->get_profiles();
+ $directories = directory_types();
+ $directories = is_array( $directories ) && ! is_wp_error( $directories ) ? $directories : [];
+ $default_id = (int) default_directory_type();
+ $resolved_items = [];
+
+ if ( empty( $directories ) ) {
+ $resolved_items[] = $this->prepare_directory(
+ [
+ 'id' => 'generic',
+ 'name' => __( 'your directory', 'directorist' ),
+ 'slug' => 'generic',
+ ],
+ $profiles,
+ false
+ );
+ $default_id = 'generic';
+ } else {
+ foreach ( $directories as $directory ) {
+ if ( ! $directory instanceof WP_Term ) {
+ continue;
+ }
+
+ $resolved_items[] = $this->prepare_directory(
+ [
+ 'id' => (string) $directory->term_id,
+ 'name' => $directory->name,
+ 'slug' => $directory->slug,
+ ],
+ $profiles
+ );
+ }
+ }
+
+ $resolved_items = array_values(
+ array_filter(
+ $resolved_items,
+ static function( $directory ) {
+ return ! empty( $directory['items'] );
+ }
+ )
+ );
+
+ if ( empty( $resolved_items ) ) {
+ return [];
+ }
+
+ $available_ids = wp_list_pluck( $resolved_items, 'id' );
+ $default_id = (string) $default_id;
+
+ if ( ! in_array( $default_id, $available_ids, true ) ) {
+ $default_id = (string) $resolved_items[0]['id'];
+ }
+
+ $data = [
+ 'default_id' => $default_id,
+ 'directories' => $resolved_items,
+ ];
+
+ /**
+ * Filter the final directory-aware recommendation data.
+ *
+ * @param array $data Prepared dashboard recommendation data.
+ * @param array $products Current extension catalog.
+ * @param array $overview Current extension overview.
+ */
+ return apply_filters(
+ 'directorist_extension_recommendation_data',
+ $data,
+ $this->products,
+ $this->overview
+ );
+ }
+
+ /**
+ * Central recommendation registry.
+ *
+ * Product order controls the default recommendation priority. This registry is
+ * a compatibility fallback; valid product API recommendation data may replace it.
+ *
+ * @return array
+ */
+ private function get_profiles() {
+ $profiles = [
+ 'business' => [
+ 'label' => __( 'Business', 'directorist' ),
+ 'aliases' => [ 'business', 'local business', 'company', 'companies' ],
+ 'description' => __( 'Add the essentials businesses need to publish, manage, and grow their listings.', 'directorist' ),
+ 'products' => [
+ 'directorist-business-hours',
+ 'directorist-claim-listing',
+ 'directorist-listings-with-map',
+ 'directorist-social-login',
+ 'directorist-pricing-plans',
+ 'directorist-advanced-review',
+ ],
+ ],
+ 'classified' => [
+ 'label' => __( 'Classified', 'directorist' ),
+ 'aliases' => [ 'classified', 'classified ads', 'buy and sell', 'marketplace ads' ],
+ 'description' => __( 'Help buyers and sellers communicate, track availability, and discover relevant listings.', 'directorist' ),
+ 'products' => [
+ 'directorist-mark-as-sold',
+ 'directorist-live-chat',
+ 'directorist-pricing-plans',
+ 'directorist-listings-with-map',
+ 'directorist-social-login',
+ 'directorist-gallery',
+ 'directorist-search-alert',
+ ],
+ ],
+ 'car' => [
+ 'label' => __( 'Car', 'directorist' ),
+ 'aliases' => [ 'car', 'cars', 'vehicle', 'vehicles', 'automotive', 'auto dealer', 'car sell', 'car rent' ],
+ 'description' => __( 'Give vehicle listings the availability, location, booking, and comparison tools shoppers expect.', 'directorist' ),
+ 'products' => [
+ 'directorist-mark-as-sold',
+ 'directorist-listings-with-map',
+ 'directorist-booking',
+ 'directorist-compare-listing',
+ 'directorist-gallery',
+ 'directorist-faqs',
+ 'directorist-business-hours',
+ ],
+ ],
+ 'place' => [
+ 'label' => __( 'Place', 'directorist' ),
+ 'aliases' => [ 'place', 'places', 'travel', 'tourism', 'destination', 'destinations', 'attraction', 'attractions' ],
+ 'description' => __( 'Make destinations easier to find, compare, explore, and book.', 'directorist' ),
+ 'products' => [
+ 'directorist-listings-with-map',
+ 'directorist-business-hours',
+ 'directorist-booking',
+ 'directorist-gallery',
+ 'directorist-compare-listing',
+ 'directorist-claim-listing',
+ ],
+ ],
+ 'job' => [
+ 'label' => __( 'Job', 'directorist' ),
+ 'aliases' => [ 'job', 'jobs', 'career', 'careers', 'employment', 'recruitment' ],
+ 'description' => __( 'Support job publishing, candidate access, and alerts for new opportunities.', 'directorist' ),
+ 'products' => [
+ 'directorist-job-manager',
+ 'directorist-social-login',
+ 'directorist-search-alert',
+ 'directorist-pricing-plans',
+ 'directorist-ai-search',
+ 'directorist-notifications-pro',
+ ],
+ ],
+ 'hotel' => [
+ 'label' => __( 'Hotel', 'directorist' ),
+ 'aliases' => [ 'hotel', 'hotels', 'accommodation', 'lodging', 'motel', 'resort', 'resorts' ],
+ 'description' => __( 'Add booking, comparison, opening-hour, and visual tools for accommodation listings.', 'directorist' ),
+ 'products' => [
+ 'directorist-booking',
+ 'directorist-compare-listing',
+ 'directorist-business-hours',
+ 'directorist-listings-with-map',
+ 'directorist-gallery',
+ 'directorist-faqs',
+ ],
+ ],
+ 'restaurant' => [
+ 'label' => __( 'Restaurant', 'directorist' ),
+ 'aliases' => [ 'restaurant', 'restaurants', 'food', 'dining', 'cafe', 'cafes', 'coffee shop' ],
+ 'description' => __( 'Add the opening hours, reservations, and rich photos restaurant visitors need.', 'directorist' ),
+ 'products' => [
+ 'directorist-business-hours',
+ 'directorist-booking',
+ 'directorist-gallery',
+ 'directorist-listings-with-map',
+ 'directorist-claim-listing',
+ 'directorist-faqs',
+ 'directorist-live-chat',
+ ],
+ ],
+ 'lawyer' => [
+ 'label' => __( 'Lawyer', 'directorist' ),
+ 'aliases' => [ 'lawyer', 'lawyers', 'legal', 'attorney', 'attorneys', 'law firm' ],
+ 'description' => __( 'Help legal professionals build trust, publish availability, and receive appointments.', 'directorist' ),
+ 'products' => [
+ 'directorist-booking',
+ 'directorist-business-hours',
+ 'directorist-advanced-review',
+ 'directorist-claim-listing',
+ 'directorist-listings-with-map',
+ 'directorist-faqs',
+ 'directorist-pricing-plans',
+ ],
+ ],
+ 'doctor' => [
+ 'label' => __( 'Doctor', 'directorist' ),
+ 'aliases' => [ 'doctor', 'doctors', 'medical', 'healthcare', 'clinic', 'clinics', 'physician', 'dentist' ],
+ 'description' => __( 'Support appointments, opening hours, trusted reviews, and verified provider profiles.', 'directorist' ),
+ 'products' => [
+ 'directorist-booking',
+ 'directorist-business-hours',
+ 'directorist-advanced-review',
+ 'directorist-claim-listing',
+ 'directorist-listings-with-map',
+ 'directorist-faqs',
+ ],
+ ],
+ 'real-estate' => [
+ 'label' => __( 'Real Estate', 'directorist' ),
+ 'aliases' => [ 'real estate', 'realestate', 'property', 'properties', 'realtor', 'housing', 'rental' ],
+ 'description' => __( 'Help property seekers search, compare, follow, and inspect listings.', 'directorist' ),
+ 'products' => [
+ 'directorist-listings-with-map',
+ 'directorist-compare-listing',
+ 'directorist-search-alert',
+ 'directorist-gallery',
+ 'directorist-live-chat',
+ 'directorist-business-hours',
+ 'directorist-booking',
+ 'directorist-faqs',
+ ],
+ ],
+ 'post-your-need' => [
+ 'label' => __( 'Post Your Need', 'directorist' ),
+ 'aliases' => [ 'post your need', 'request a service', 'service request', 'find a provider' ],
+ 'description' => __( 'Connect customer requests with suitable providers and keep conversations moving.', 'directorist' ),
+ 'products' => [
+ 'directorist-live-chat',
+ 'directorist-pricing-plans',
+ 'directorist-social-login',
+ 'directorist-notifications-pro',
+ 'directorist-announcement',
+ ],
+ ],
+ 'service' => [
+ 'label' => __( 'Service', 'directorist' ),
+ 'aliases' => [ 'service', 'services', 'service provider', 'professional service', 'contractor' ],
+ 'description' => __( 'Help customers find, contact, and book the right service provider.', 'directorist' ),
+ 'products' => [
+ 'directorist-booking',
+ 'directorist-live-chat',
+ 'directorist-business-hours',
+ 'directorist-claim-listing',
+ 'directorist-pricing-plans',
+ ],
+ ],
+ 'generic' => [
+ 'label' => __( 'Directory', 'directorist' ),
+ 'aliases' => [],
+ 'description' => __( 'Explore versatile add-ons that improve discovery, trust, and directory management.', 'directorist' ),
+ 'products' => [
+ 'directorist-ai-search',
+ 'directorist-analytics',
+ 'directorist-pricing-plans',
+ 'directorist-listings-with-map',
+ 'directorist-advanced-review',
+ 'directorist-business-hours',
+ 'directorist-gallery',
+ 'directorist-live-chat',
+ ],
+ ],
+ ];
+
+ /**
+ * Filter the centralized directory recommendation registry.
+ *
+ * @param array $profiles Directory profiles keyed by stable profile name.
+ */
+ $profiles = apply_filters( 'directorist_extension_recommendation_profiles', $profiles );
+ $profiles = is_array( $profiles ) ? $profiles : [];
+
+ return $this->apply_product_api_recommendations( $profiles );
+ }
+
+ /**
+ * Apply optional per-product API recommendation data over local fallbacks.
+ *
+ * A missing field preserves the local mapping. An empty array intentionally
+ * removes the product from all recommendation profiles.
+ *
+ * @param array $profiles Recommendation registry.
+ *
+ * @return array
+ */
+ private function apply_product_api_recommendations( array $profiles ) {
+ foreach ( $this->products as $product_slug => $product ) {
+ if ( ! is_array( $product ) || ! array_key_exists( 'recommendations', $product ) ) {
+ continue;
+ }
+
+ $recommendations = $product['recommendations'];
+
+ if ( ! is_array( $recommendations ) ) {
+ continue;
+ }
+
+ $valid_recommendations = [];
+ foreach ( $recommendations as $recommendation ) {
+ if ( ! is_array( $recommendation ) ) {
+ continue;
+ }
+
+ $profile_key = isset( $recommendation['profile'] ) ? sanitize_key( $recommendation['profile'] ) : '';
+ if ( ! $profile_key || ! isset( $profiles[ $profile_key ] ) ) {
+ continue;
+ }
+
+ $valid_recommendations[] = [
+ 'profile' => $profile_key,
+ 'priority' => isset( $recommendation['priority'] ) ? max( 0, min( 100, (int) $recommendation['priority'] ) ) : 100,
+ 'reason' => isset( $recommendation['reason'] ) ? sanitize_text_field( (string) $recommendation['reason'] ) : '',
+ ];
+ }
+
+ if ( ! empty( $recommendations ) && empty( $valid_recommendations ) ) {
+ continue;
+ }
+
+ foreach ( $profiles as $profile_key => $profile ) {
+ $products = isset( $profile['products'] ) && is_array( $profile['products'] ) ? $profile['products'] : [];
+
+ $profiles[ $profile_key ]['products'] = array_values(
+ array_filter(
+ $products,
+ static function( $candidate ) use ( $product_slug ) {
+ $candidate_slug = is_array( $candidate ) ? ( $candidate['slug'] ?? '' ) : $candidate;
+
+ return (string) $candidate_slug !== (string) $product_slug;
+ }
+ )
+ );
+ }
+
+ foreach ( $valid_recommendations as $recommendation ) {
+ $profiles[ $recommendation['profile'] ]['products'][] = [
+ 'slug' => (string) $product_slug,
+ 'priority' => $recommendation['priority'],
+ 'reason' => $recommendation['reason'],
+ ];
+ }
+ }
+
+ foreach ( $profiles as $profile_key => $profile ) {
+ if ( empty( $profile['products'] ) || ! is_array( $profile['products'] ) ) {
+ continue;
+ }
+
+ foreach ( $profiles[ $profile_key ]['products'] as $index => $candidate ) {
+ $candidate = is_array( $candidate ) ? $candidate : [ 'slug' => $candidate ];
+
+ if ( ! isset( $candidate['priority'] ) ) {
+ $candidate['priority'] = max( 1, 100 - ( (int) $index * 10 ) );
+ }
+
+ $candidate['_index'] = (int) $index;
+ $profiles[ $profile_key ]['products'][ $index ] = $candidate;
+ }
+
+ usort(
+ $profiles[ $profile_key ]['products'],
+ static function( $left, $right ) {
+ $left_priority = isset( $left['priority'] ) ? (int) $left['priority'] : 0;
+ $right_priority = isset( $right['priority'] ) ? (int) $right['priority'] : 0;
+
+ if ( $left_priority === $right_priority ) {
+ return (int) ( $left['_index'] ?? 0 ) <=> (int) ( $right['_index'] ?? 0 );
+ }
+
+ return $right_priority <=> $left_priority;
+ }
+ );
+ }
+
+ return $profiles;
+ }
+
+ /**
+ * Prepare one real or generic directory recommendation group.
+ *
+ * @param array $directory Directory identity.
+ * @param array $profiles Recommendation registry.
+ * @param bool $classify Whether to classify the directory name and slug.
+ *
+ * @return array
+ */
+ private function prepare_directory( array $directory, array $profiles, $classify = true ) {
+ $profile_key = $classify ? $this->classify_directory( $directory, $profiles ) : 'generic';
+ $profile = isset( $profiles[ $profile_key ] ) ? $profiles[ $profile_key ] : ( $profiles['generic'] ?? [] );
+ $items = [];
+ $seen = [];
+
+ foreach ( $profile['products'] ?? [] as $position => $candidate ) {
+ $candidate_data = is_array( $candidate ) ? $candidate : [ 'slug' => $candidate ];
+ $product_slug = isset( $candidate_data['slug'] ) ? sanitize_key( (string) $candidate_data['slug'] ) : '';
+
+ if ( ! $product_slug || isset( $seen[ $product_slug ] ) ) {
+ continue;
+ }
+
+ $item = $this->prepare_product( $product_slug, $candidate_data, $position );
+ if ( empty( $item ) ) {
+ continue;
+ }
+
+ $seen[ $product_slug ] = true;
+ $items[] = $item;
+ }
+
+ return [
+ 'id' => (string) ( $directory['id'] ?? 'generic' ),
+ 'name' => sanitize_text_field( (string) ( $directory['name'] ?? __( 'your directory', 'directorist' ) ) ),
+ 'profile' => $profile_key,
+ 'known' => 'generic' !== $profile_key,
+ 'description' => sanitize_text_field( (string) ( $profile['description'] ?? '' ) ),
+ 'items' => $items,
+ ];
+ }
+
+ /**
+ * Match a directory term to a canonical profile.
+ *
+ * @param array $directory Directory identity.
+ * @param array $profiles Recommendation registry.
+ *
+ * @return string
+ */
+ private function classify_directory( array $directory, array $profiles ) {
+ $haystack = $this->normalize_phrase(
+ (string) ( $directory['name'] ?? '' ) . ' ' . (string) ( $directory['slug'] ?? '' )
+ );
+
+ foreach ( $profiles as $profile_key => $profile ) {
+ if ( 'generic' === $profile_key || empty( $profile['aliases'] ) || ! is_array( $profile['aliases'] ) ) {
+ continue;
+ }
+
+ foreach ( $profile['aliases'] as $alias ) {
+ $normalized_alias = $this->normalize_phrase( $alias );
+
+ if ( $normalized_alias && false !== strpos( ' ' . $haystack . ' ', ' ' . $normalized_alias . ' ' ) ) {
+ return (string) $profile_key;
+ }
+ }
+ }
+
+ return 'generic';
+ }
+
+ /**
+ * Normalize a phrase for conservative whole-phrase matching.
+ *
+ * @param mixed $value Phrase.
+ *
+ * @return string
+ */
+ private function normalize_phrase( $value ) {
+ $value = strtolower( remove_accents( wp_strip_all_tags( (string) $value ) ) );
+ $value = preg_replace( '/[^a-z0-9]+/', ' ', $value );
+
+ return trim( preg_replace( '/\s+/', ' ', (string) $value ) );
+ }
+
+ /**
+ * Prepare one catalog product with its canonical local state.
+ *
+ * @param string $product_slug Product slug.
+ * @param array $candidate Recommendation metadata.
+ * @param int $position Default position.
+ *
+ * @return array
+ */
+ private function prepare_product( $product_slug, array $candidate, $position ) {
+ $resolved_slug = $this->resolve_product_slug( $product_slug );
+ $product = isset( $this->products[ $resolved_slug ] ) && is_array( $this->products[ $resolved_slug ] )
+ ? $this->products[ $resolved_slug ]
+ : [];
+
+ if ( empty( $product ) ) {
+ return [];
+ }
+
+ $installed = $this->find_installed_product( $resolved_slug );
+ $entitlement = $this->find_entitlement( $resolved_slug );
+ $status = 'marketplace';
+ $label = __( 'Available', 'directorist' );
+ $action = [];
+
+ if ( ! empty( $installed ) ) {
+ if ( ! empty( $installed['active'] ) ) {
+ $status = 'active';
+ $label = __( 'Active', 'directorist' );
+ } else {
+ $status = 'installed';
+ $label = __( 'Installed', 'directorist' );
+ $action = [
+ 'label' => __( 'Activate', 'directorist' ),
+ 'class' => 'directorist-te-btn directorist-te-btn--soft plugin-active-btn',
+ 'attrs' => [
+ 'data-type' => 'plugin',
+ 'data-key' => $installed['base'],
+ ],
+ 'icon' => 'la la-check',
+ ];
+ }
+ } elseif ( $entitlement ) {
+ $status = 'not-installed';
+ $label = __( 'Not installed', 'directorist' );
+ $action = [
+ 'label' => $this->is_beta ? __( 'Install Beta', 'directorist' ) : __( 'Install', 'directorist' ),
+ 'class' => 'directorist-te-btn directorist-te-btn--soft file-install-btn',
+ 'attrs' => [
+ 'data-type' => 'plugin',
+ 'data-key' => $entitlement,
+ ],
+ 'icon' => 'la la-download',
+ ];
+ } elseif ( ! empty( $product['link'] ) ) {
+ $action = [
+ 'label' => __( 'View details', 'directorist' ),
+ 'href' => $product['link'],
+ 'class' => 'directorist-te-btn directorist-te-btn--soft',
+ 'external' => true,
+ 'icon' => 'la la-external-link',
+ ];
+ }
+
+ $reason = ! empty( $candidate['reason'] )
+ ? sanitize_text_field( (string) $candidate['reason'] )
+ : wp_trim_words( wp_strip_all_tags( (string) ( $product['description'] ?? '' ) ), 20, '...' );
+
+ return [
+ 'slug' => $resolved_slug,
+ 'name' => sanitize_text_field( (string) ( $product['name'] ?? $resolved_slug ) ),
+ 'reason' => $reason,
+ 'image' => ! empty( $product['thumbnail'] ) ? esc_url_raw( (string) $product['thumbnail'] ) : '',
+ 'status' => $status,
+ 'label' => $label,
+ 'action' => $action,
+ 'position' => isset( $candidate['priority'] ) ? (int) $candidate['priority'] : ( 1000 - (int) $position ),
+ ];
+ }
+
+ /**
+ * Resolve a current product slug through the legacy alias map.
+ *
+ * @param string $product_slug Product slug.
+ *
+ * @return string
+ */
+ private function resolve_product_slug( $product_slug ) {
+ if ( isset( $this->products[ $product_slug ] ) ) {
+ return $product_slug;
+ }
+
+ if ( ! empty( $this->aliases[ $product_slug ] ) && isset( $this->products[ $this->aliases[ $product_slug ] ] ) ) {
+ return (string) $this->aliases[ $product_slug ];
+ }
+
+ $alias_key = array_search( $product_slug, $this->aliases, true );
+
+ return $alias_key && isset( $this->products[ $alias_key ] ) ? (string) $alias_key : $product_slug;
+ }
+
+ /**
+ * Find an installed product by folder slug or alias.
+ *
+ * @param string $product_slug Product slug.
+ *
+ * @return array
+ */
+ private function find_installed_product( $product_slug ) {
+ $installed_extensions = isset( $this->overview['installed_extension_list'] ) && is_array( $this->overview['installed_extension_list'] )
+ ? $this->overview['installed_extension_list']
+ : [];
+ $candidate_slugs = $this->get_candidate_slugs( $product_slug );
+
+ foreach ( $installed_extensions as $plugin_base => $plugin_data ) {
+ $folder_slug = preg_replace( '/\/.+/', '', (string) $plugin_base );
+
+ if ( in_array( $folder_slug, $candidate_slugs, true ) ) {
+ return [
+ 'base' => (string) $plugin_base,
+ 'active' => is_plugin_active( $plugin_base ),
+ ];
+ }
+ }
+
+ return [];
+ }
+
+ /**
+ * Find the canonical entitlement key for an uninstalled product.
+ *
+ * @param string $product_slug Product slug.
+ *
+ * @return string
+ */
+ private function find_entitlement( $product_slug ) {
+ $entitlements = isset( $this->overview['extensions_available_in_subscriptions'] ) && is_array( $this->overview['extensions_available_in_subscriptions'] )
+ ? $this->overview['extensions_available_in_subscriptions']
+ : [];
+ $candidate_slugs = $this->get_candidate_slugs( $product_slug );
+
+ foreach ( $entitlements as $entitlement_key => $entitlement ) {
+ $folder_slug = preg_replace( '/\/.+/', '', (string) $entitlement_key );
+
+ if ( in_array( $folder_slug, $candidate_slugs, true ) ) {
+ return (string) $entitlement_key;
+ }
+ }
+
+ return '';
+ }
+
+ /**
+ * Get the current and legacy slugs that may represent one product.
+ *
+ * @param string $product_slug Product slug.
+ *
+ * @return array
+ */
+ private function get_candidate_slugs( $product_slug ) {
+ $candidate_slugs = [ $product_slug ];
+
+ if ( ! empty( $this->aliases[ $product_slug ] ) ) {
+ $candidate_slugs[] = (string) $this->aliases[ $product_slug ];
+ }
+
+ $legacy_slug = array_search( $product_slug, $this->aliases, true );
+ if ( $legacy_slug ) {
+ $candidate_slugs[] = (string) $legacy_slug;
+ }
+
+ return array_values( array_unique( array_filter( $candidate_slugs ) ) );
+ }
+ }
+}
diff --git a/includes/classes/class-extension.php b/includes/classes/class-extension.php
index dfb442759b..e6701ebd3f 100644
--- a/includes/classes/class-extension.php
+++ b/includes/classes/class-extension.php
@@ -42,6 +42,7 @@ public function __construct() {
add_action( 'admin_menu', [ $this, 'admin_menu' ], 100 );
add_action( 'admin_init', [ $this, 'setup_ajax_actions' ] );
add_action( 'admin_head', [ $this, 'add_menu_separator_classes' ] );
+ add_filter( 'submenu_file', [ $this, 'set_active_submenu' ], 10, 2 );
if ( ! empty( $_GET['page'] ) && ( 'atbdp-extension' === $_GET['page'] ) ) {
add_action( 'admin_init', [ $this, 'initial_setup' ] );
@@ -64,6 +65,7 @@ public function setup_ajax_actions() {
add_action( 'wp_ajax_atbdp_update_theme', [ $this, 'handle_theme_update_request' ] );
add_action( 'wp_ajax_atbdp_refresh_purchase_status', [ $this, 'handle_refresh_purchase_status_request' ] );
add_action( 'wp_ajax_atbdp_close_subscriptions_sassion', [ $this, 'handle_close_subscriptions_sassion_request' ] );
+ add_action( 'wp_ajax_directorist_te_get_activity', [ $this, 'get_dashboard_activity' ] );
// add_action( 'wp_ajax_atbdp_download_purchased_items', array($this, 'download_purchased_items') );
}
@@ -236,8 +238,39 @@ public function setup_products_list() {
}
}
+ private static function get_product_badge( $type, $label, $expires_at = null ) {
+ $badge = [
+ 'type' => $type,
+ 'label' => $label,
+ ];
+
+ if ( null !== $expires_at ) {
+ $badge['expires_at'] = $expires_at;
+ }
+
+ return $badge;
+ }
+
public static function get_default_extensions() {
return [
+ 'directorist-notifications-pro' => [
+ 'name' => 'Directorist Notifications Pro',
+ 'description' => __( 'Send instant browser push notifications for listings, payments, reviews, renewals, and other important directory events.', 'directorist' ),
+ 'link' => 'https://directorist.com/product/directorist-notifications-pro/',
+ 'thumbnail' => ATBDP_URL . 'assets/images/extensions/directorist-notifications-pro.jpg',
+ 'active' => true,
+ 'item_id' => 371698,
+ 'badges' => [ self::get_product_badge( 'new', __( 'New', 'directorist' ) ) ],
+ ],
+ 'directorist-divi-integration' => [
+ 'name' => 'Directorist Divi Integration',
+ 'description' => __( 'Build and customize your directory visually with native Divi 5 modules.', 'directorist' ),
+ 'link' => 'https://directorist.com/product/directorist-divi-integration/',
+ 'thumbnail' => ATBDP_URL . 'assets/images/extensions/directorist-divi-integration.jpg',
+ 'active' => true,
+ 'item_id' => 371246,
+ 'badges' => [ self::get_product_badge( 'new', __( 'New', 'directorist' ) ) ],
+ ],
'directorist-ai-search' => [
'name' => 'Directorist AI Search',
'description' => __( 'AI-powered directory search that understands intent and improves listing discovery.', 'directorist' ),
@@ -245,6 +278,7 @@ public static function get_default_extensions() {
'thumbnail' => ATBDP_URL . 'assets/images/extensions/AI-Search-Preview.jpg',
'active' => true,
'item_id' => 370908,
+ 'badges' => [ self::get_product_badge( 'new', __( 'New', 'directorist' ) ) ],
],
'directorist-listing-importer' => [
'name' => 'Directorist Listing Importer',
@@ -253,6 +287,10 @@ public static function get_default_extensions() {
'thumbnail' => ATBDP_URL . 'assets/images/extensions/directorist-listing-importer.png',
'active' => true,
'item_id' => 370853,
+ 'badges' => [
+ self::get_product_badge( 'new', __( 'New', 'directorist' ) ),
+ self::get_product_badge( 'trending', __( 'Trending', 'directorist' ) ),
+ ],
],
'directorist-analytics' => [
@@ -289,6 +327,7 @@ public static function get_default_extensions() {
'thumbnail' => ATBDP_URL . 'assets/images/extensions/directorist-search-alert.png',
'active' => true,
'item_id' => 323908,
+ 'badges' => [ self::get_product_badge( 'new', __( 'New', 'directorist' ) ) ],
],
'directorist-announcement' => [
@@ -298,6 +337,7 @@ public static function get_default_extensions() {
'thumbnail' => ATBDP_URL . 'assets/images/extensions/directorist-announcement.svg',
'active' => true,
'item_id' => 308031,
+ 'badges' => [ self::get_product_badge( 'new', __( 'New', 'directorist' ) ) ],
],
'addonskit-for-bricks' => [
@@ -307,6 +347,7 @@ public static function get_default_extensions() {
'thumbnail' => ATBDP_URL . 'assets/images/extensions/addonskit-bricks.svg',
'active' => true,
'item_id' => 307581,
+ 'badges' => [ self::get_product_badge( 'new', __( 'New', 'directorist' ) ) ],
],
'directorist-coupon' => [
@@ -333,6 +374,7 @@ public static function get_default_extensions() {
'base' => 'directorist-listings-with-map/directorist-listings-map.php',
'active' => true,
'item_id' => 13794,
+ 'badges' => [ self::get_product_badge( 'popular', __( 'Popular', 'directorist' ) ) ],
],
'directorist-pricing-plans' => [
'name' => 'Pricing Plans',
@@ -341,6 +383,10 @@ public static function get_default_extensions() {
'thumbnail' => ATBDP_URL . 'assets/images/extensions/pricing-plans.png',
'active' => true,
'item_id' => 13776,
+ 'badges' => [
+ self::get_product_badge( 'popular', __( 'Popular', 'directorist' ) ),
+ self::get_product_badge( 'trending', __( 'Trending', 'directorist' ) ),
+ ],
],
'directorist-woocommerce-pricing-plans' => [
'name' => 'WooCommerce Pricing Plans',
@@ -349,6 +395,7 @@ public static function get_default_extensions() {
'thumbnail' => ATBDP_URL . 'assets/images/extensions/woo-pricing-plans.png',
'active' => true,
'item_id' => 13784,
+ 'badges' => [ self::get_product_badge( 'popular', __( 'Popular', 'directorist' ) ) ],
],
'directorist-paypal' => [
'name' => 'PayPal Payment Gateway',
@@ -357,6 +404,7 @@ public static function get_default_extensions() {
'thumbnail' => ATBDP_URL . 'assets/images/extensions/paypal-gateway.png',
'active' => true,
'item_id' => 13702,
+ 'badges' => [ self::get_product_badge( 'popular', __( 'Popular', 'directorist' ) ) ],
],
'directorist-stripe' => [
'name' => 'Stripe Payment Gateway',
@@ -414,6 +462,7 @@ public static function get_default_extensions() {
'base' => 'directorist-business-hours/bd-business-hour.php',
'active' => true,
'item_id' => 13714,
+ 'badges' => [ self::get_product_badge( 'popular', __( 'Popular', 'directorist' ) ) ],
],
'directorist-slider-carousel' => [
'name' => 'Listings Slider & Carousel',
@@ -439,6 +488,7 @@ public static function get_default_extensions() {
'thumbnail' => ATBDP_URL . 'assets/images/extensions/booking.png',
'active' => true,
'item_id' => 21718,
+ 'badges' => [ self::get_product_badge( 'popular', __( 'Popular', 'directorist' ) ) ],
],
'directorist-gallery' => [
'name' => 'Image Gallery',
@@ -504,6 +554,7 @@ public static function get_default_extensions() {
'thumbnail' => ATBDP_URL . 'assets/images/extensions/jobs-manager.svg',
'active' => true,
'item_id' => 134332,
+ 'badges' => [ self::get_product_badge( 'trending', __( 'Trending', 'directorist' ) ) ],
],
'directorist-mailchimp-integration' => [
'name' => 'Mailchimp Integration',
@@ -520,6 +571,7 @@ public static function get_default_extensions() {
'thumbnail' => ATBDP_URL . 'assets/images/extensions/helpgent.svg',
'active' => true,
'item_id' => 188735,
+ 'badges' => [ self::get_product_badge( 'new', __( 'New', 'directorist' ) ) ],
],
'directorist-wpml-integration' => [
'name' => 'WPML Integration',
@@ -536,6 +588,7 @@ public static function get_default_extensions() {
'thumbnail' => ATBDP_URL . 'assets/images/extensions/marketplace.svg',
'active' => true,
'item_id' => 148417,
+ 'badges' => [ self::get_product_badge( 'trending', __( 'Trending', 'directorist' ) ) ],
],
'directorist-gamipress-integration' => [
'name' => 'Gamipress Integration',
@@ -558,6 +611,7 @@ public static function get_default_themes() {
'demo_link' => 'https://demo.directorist.com/theme/djobs/',
'thumbnail' => ATBDP_URL . 'assets/images/themes/djobs.png',
'active' => true,
+ 'badges' => [ self::get_product_badge( 'new', __( 'New', 'directorist' ) ) ],
],
'dhotels' => [
'name' => 'dHotels',
@@ -566,6 +620,7 @@ public static function get_default_themes() {
'demo_link' => 'https://demo.directorist.com/theme/dhotels/',
'thumbnail' => ATBDP_URL . 'assets/images/themes/dhotels.png',
'active' => true,
+ 'badges' => [ self::get_product_badge( 'popular', __( 'Popular', 'directorist' ) ) ],
],
'dclassified' => [
'name' => 'dClassified',
@@ -574,6 +629,7 @@ public static function get_default_themes() {
'demo_link' => 'https://demo.directorist.com/theme/dclassified/',
'thumbnail' => ATBDP_URL . 'assets/images/themes/dclassified.png',
'active' => true,
+ 'badges' => [ self::get_product_badge( 'trending', __( 'Trending', 'directorist' ) ) ],
],
'onelisting' => [
'name' => 'OneListing',
@@ -590,6 +646,10 @@ public static function get_default_themes() {
'demo_link' => 'https://demo.directorist.com/theme/onelisting-pro/',
'thumbnail' => ATBDP_URL . 'assets/images/themes/onelisting.png',
'active' => true,
+ 'badges' => [
+ self::get_product_badge( 'popular', __( 'Popular', 'directorist' ) ),
+ self::get_product_badge( 'trending', __( 'Trending', 'directorist' ) ),
+ ],
],
'dplace' => [
'name' => 'dPlace',
@@ -622,6 +682,7 @@ public static function get_default_themes() {
'demo_link' => 'https://demo.directorist.com/theme/dcar/',
'thumbnail' => ATBDP_URL . 'assets/images/themes/dcar.png',
'active' => true,
+ 'badges' => [ self::get_product_badge( 'popular', __( 'Popular', 'directorist' ) ) ],
],
'dlist' => [
'name' => 'dList',
@@ -646,6 +707,7 @@ public static function get_default_themes() {
'demo_link' => 'https://demo.directorist.com/theme/ddoctors/',
'thumbnail' => ATBDP_URL . 'assets/images/themes/ddoctors.png',
'active' => true,
+ 'badges' => [ self::get_product_badge( 'popular', __( 'Popular', 'directorist' ) ) ],
],
'dlawyers' => [
'name' => 'dLawyers',
@@ -1378,9 +1440,19 @@ public function authenticate_the_customer() {
$status = [
'success' => true,
- 'log' => [],
+ 'log' => [],
];
+ if ( ! current_user_can( 'manage_options' ) ) {
+ $status['success'] = false;
+ $status['log']['permission_denied'] = [
+ 'type' => 'error',
+ 'message' => __( 'You do not have permission to perform this action.', 'directorist' ),
+ ];
+
+ wp_send_json( [ 'status' => $status ] );
+ }
+
if ( ! directorist_verify_nonce( 'nonce', 'atbdp_nonce_action_js' ) ) {
$status['success'] = false;
$status['log']['invalid_request'] = [
@@ -1390,34 +1462,50 @@ public function authenticate_the_customer() {
}
// Get form data
- $username = ( isset( $_POST['username'] ) ) ? sanitize_user( $_POST['username'] ) : ''; // @codingStandardsIgnoreLine.
- $password = ( isset( $_POST['password'] ) ) ? urlencode( $_POST['password'] ) : ''; // @codingStandardsIgnoreLine.
-
- // Validate username
- if ( empty( $username ) && ! empty( $password ) ) {
- $status['success'] = false;
- $status['log']['username_missing'] = [
+ $auth_method = isset( $_POST['auth_method'] ) && 'access_key' === sanitize_key( wp_unslash( $_POST['auth_method'] ) )
+ ? 'access_key'
+ : 'account';
+ $access_key = ( isset( $_POST['access_key'] ) ) ? sanitize_text_field( wp_unslash( $_POST['access_key'] ) ) : '';
+ $submitted_login = ( isset( $_POST['username'] ) ) ? wp_unslash( $_POST['username'] ) : ''; // @codingStandardsIgnoreLine.
+ $username = is_email( $submitted_login ) ? sanitize_email( $submitted_login ) : sanitize_user( $submitted_login );
+ $password_raw = ( isset( $_POST['password'] ) ) ? wp_unslash( $_POST['password'] ) : ''; // @codingStandardsIgnoreLine.
+ $password = urlencode( $password_raw );
+
+ if ( 'access_key' === $auth_method && empty( $access_key ) ) {
+ $status['success'] = false;
+ $status['log']['access_key_missing'] = [
'type' => 'error',
- 'message' => 'Username is required',
+ 'message' => __( 'Access key is required', 'directorist' ),
];
}
- // Validate password
- if ( empty( $password ) && ! empty( $username ) ) {
- $status['success'] = false;
- $status['log']['password_missing'] = [
- 'type' => 'error',
- 'message' => 'Password is required',
- ];
- }
+ if ( 'account' === $auth_method ) {
+ // Validate username
+ if ( empty( $username ) && ! empty( $password ) ) {
+ $status['success'] = false;
+ $status['log']['username_missing'] = [
+ 'type' => 'error',
+ 'message' => __( 'Username or email address is required', 'directorist' ),
+ ];
+ }
- // Validate username && password
- if ( empty( $password ) && empty( $username ) ) {
- $status['success'] = false;
- $status['log']['password_missing'] = [
- 'type' => 'error',
- 'message' => 'Username and Password is required',
- ];
+ // Validate password
+ if ( empty( $password ) && ! empty( $username ) ) {
+ $status['success'] = false;
+ $status['log']['password_missing'] = [
+ 'type' => 'error',
+ 'message' => __( 'Password is required', 'directorist' ),
+ ];
+ }
+
+ // Validate username && password
+ if ( empty( $password ) && empty( $username ) ) {
+ $status['success'] = false;
+ $status['log']['password_missing'] = [
+ 'type' => 'error',
+ 'message' => __( 'Username or email address and password are required', 'directorist' ),
+ ];
+ }
}
if ( ! $status['success'] ) {
@@ -1425,12 +1513,15 @@ public function authenticate_the_customer() {
}
// Get licencing data
- $response = self::remote_authenticate_user(
- [
- 'user' => $username,
- 'password' => $password,
- ]
- );
+ $response = 'access_key' === $auth_method
+ ? self::remote_authenticate_user_by_access_key( $access_key )
+ : self::remote_authenticate_user(
+ [
+ 'user' => $username,
+ 'password' => $password,
+ 'password_raw' => $password_raw,
+ ]
+ );
// Validate response
if ( ! $response['success'] ) {
@@ -1459,28 +1550,36 @@ public function authenticate_the_customer() {
);
}
- $previous_username = get_user_meta( get_current_user_id(), '_atbdp_subscribed_username', true );
+ $this->store_account_summary_from_response( $response );
+
+ $account_data = isset( $response['account_data'] ) && is_array( $response['account_data'] ) ? $response['account_data'] : [];
+ $account_identifier = $username;
+
+ if ( 'access_key' === $auth_method ) {
+ $account_identifier = isset( $account_data['user_email'] ) && is_scalar( $account_data['user_email'] )
+ ? sanitize_email( (string) $account_data['user_email'] )
+ : '';
+
+ if ( ! $account_identifier && isset( $account_data['display_name'] ) && is_scalar( $account_data['display_name'] ) ) {
+ $account_identifier = sanitize_text_field( (string) $account_data['display_name'] );
+ }
+ }
+
+ $previous_username = get_user_meta( get_current_user_id(), '_atbdp_subscribed_username', true );
+ $previous_auth_method = get_user_meta( get_current_user_id(), '_atbdp_subscription_connection_method', true );
+ $previous_auth_method = 'access_key' === $previous_auth_method ? 'access_key' : 'account';
// Enable Sassion
- update_user_meta( get_current_user_id(), '_atbdp_subscribed_username', $username );
+ update_user_meta( get_current_user_id(), '_atbdp_subscribed_username', $account_identifier );
update_user_meta( get_current_user_id(), '_atbdp_has_subscriptions_sassion', true );
+ update_user_meta( get_current_user_id(), '_atbdp_subscription_connection_method', $auth_method );
$plugins_available_in_subscriptions = self::get_purchased_extension_list();
$themes_available_in_subscriptions = self::get_purchased_theme_list();
$has_previous_subscriptions = ( ! empty( $plugins_available_in_subscriptions ) || ! empty( $themes_available_in_subscriptions ) ) ? true : false;
-
- if ( $previous_username === $username && $has_previous_subscriptions ) {
- // Enable Sassion
- update_user_meta( get_current_user_id(), '_atbdp_has_subscriptions_sassion', true );
- $this->refresh_purchase_status( $args = [ 'password' => $password ] );
-
- wp_send_json(
- [
- 'status' => $status,
- 'has_previous_subscriptions' => true,
- ]
- );
- }
+ $is_returning_customer = $previous_username === $account_identifier
+ && $previous_auth_method === $auth_method
+ && $has_previous_subscriptions;
delete_user_meta( get_current_user_id(), '_plugins_available_in_subscriptions' );
delete_user_meta( get_current_user_id(), '_themes_available_in_subscriptions' );
@@ -1498,6 +1597,15 @@ public function authenticate_the_customer() {
update_user_meta( get_current_user_id(), '_plugins_available_in_subscriptions', $plugins_available_in_subscriptions );
}
+ if ( $is_returning_customer ) {
+ wp_send_json(
+ [
+ 'status' => $status,
+ 'has_previous_subscriptions' => true,
+ ]
+ );
+ }
+
$status['success'] = true;
$status['log']['login_successful'] = [
'type' => 'success',
@@ -1527,9 +1635,20 @@ public function handle_refresh_purchase_status_request() {
wp_send_json( [ 'status' => $status ] );
}
- $password = ( isset( $_POST['password'] ) ) ? $_POST['password'] : ''; // @codingStandardsIgnoreLine.
+ $credential = isset( $_POST['credential'] )
+ ? wp_unslash( $_POST['credential'] ) // @codingStandardsIgnoreLine.
+ : ( ( isset( $_POST['password'] ) ) ? wp_unslash( $_POST['password'] ) : '' ); // @codingStandardsIgnoreLine.
+ $connection_method = get_user_meta( get_current_user_id(), '_atbdp_subscription_connection_method', true );
+ $connection_method = 'access_key' === $connection_method ? 'access_key' : 'account';
- $status = $this->refresh_purchase_status( [ 'password' => $password ] );
+ $status = $this->refresh_purchase_status(
+ [
+ 'credential' => $credential,
+ 'password' => $credential,
+ 'password_raw' => $credential,
+ 'connection_method' => $connection_method,
+ ]
+ );
wp_send_json( $status );
}
@@ -1537,20 +1656,28 @@ public function handle_refresh_purchase_status_request() {
// refresh_purchase_status
public function refresh_purchase_status( array $args = [] ) {
$status = [ 'success' => true ];
- $default = [ 'password' => '' ];
- $args = array_merge( $default, $args );
+ $default = [
+ 'credential' => '',
+ 'password' => '',
+ 'password_raw' => null,
+ 'connection_method' => 'account',
+ ];
+ $args = array_merge( $default, $args );
+ $connection_method = 'access_key' === $args['connection_method'] ? 'access_key' : 'account';
+ $credential = '' !== $args['credential'] ? $args['credential'] : $args['password'];
- if ( empty( $args['password'] ) ) {
+ if ( empty( $credential ) ) {
$status['success'] = false;
- $status['message'] = __( 'Password is required', 'directorist' );
+ $status['message'] = 'access_key' === $connection_method
+ ? __( 'Access key is required', 'directorist' )
+ : __( 'Password is required', 'directorist' );
return [ 'status' => $status ];
}
$username = get_user_meta( get_current_user_id(), '_atbdp_subscribed_username', true );
- $password = $args['password'];
- if ( empty( $username ) ) {
+ if ( 'account' === $connection_method && empty( $username ) ) {
$status['success'] = false;
$status['reload'] = true;
$status['message'] = __( 'Sassion is destroyed, please sign-in again', 'directorist' );
@@ -1561,12 +1688,15 @@ public function refresh_purchase_status( array $args = [] ) {
}
// Get licencing data
- $authentication = self::remote_authenticate_user(
- [
- 'user' => $username,
- 'password' => $password,
- ]
- );
+ $authentication = 'access_key' === $connection_method
+ ? self::remote_authenticate_user_by_access_key( sanitize_text_field( $credential ) )
+ : self::remote_authenticate_user(
+ [
+ 'user' => $username,
+ 'password' => $credential,
+ 'password_raw' => $args['password_raw'],
+ ]
+ );
// Validate response
if ( ! $authentication['success'] ) {
@@ -1579,6 +1709,18 @@ public function refresh_purchase_status( array $args = [] ) {
];
}
+ $this->store_account_summary_from_response( $authentication );
+
+ if ( 'access_key' === $connection_method ) {
+ $account_data = isset( $authentication['account_data'] ) && is_array( $authentication['account_data'] )
+ ? $authentication['account_data']
+ : [];
+
+ if ( isset( $account_data['user_email'] ) && is_scalar( $account_data['user_email'] ) ) {
+ update_user_meta( get_current_user_id(), '_atbdp_subscribed_username', sanitize_email( (string) $account_data['user_email'] ) );
+ }
+ }
+
$license_data = $authentication['license_data'];
// Update user meta
@@ -1625,6 +1767,8 @@ public function close_subscriptions_sassion( array $args = [] ) {
$status = [ 'success' => true ];
delete_user_meta( get_current_user_id(), '_atbdp_has_subscriptions_sassion' );
+ delete_user_meta( get_current_user_id(), '_atbdp_account_summary' );
+ delete_user_meta( get_current_user_id(), '_atbdp_subscription_connection_method' );
if ( $args['hard_logout'] ) {
delete_user_meta( get_current_user_id(), '_atbdp_subscribed_username' );
@@ -2376,14 +2520,86 @@ public function download_purchased_items() {
* It Adds menu item
*/
public function admin_menu() {
+ $parent_slug = 'edit.php?post_type=at_biz_dir';
+ $is_connected = (bool) get_user_meta( get_current_user_id(), '_atbdp_has_subscriptions_sassion', true );
+ $is_addons = isset( $_GET['te_view'] ) && is_scalar( $_GET['te_view'] ) && 'addons' === sanitize_key( wp_unslash( $_GET['te_view'] ) );
+
add_submenu_page(
- 'edit.php?post_type=at_biz_dir',
- __( 'Get Extensions', 'directorist' ),
- __( 'Themes & Extensions', 'directorist' ),
+ $parent_slug,
+ $is_connected && ! $is_addons ? __( 'Directorist Dashboard', 'directorist' ) : __( 'Themes & Extensions', 'directorist' ),
+ $is_connected ? __( 'Dashboard', 'directorist' ) : __( 'Themes & Extensions', 'directorist' ),
'manage_options',
'atbdp-extension',
[ $this, 'show_extension_view' ]
);
+
+ if ( ! $is_connected ) {
+ return;
+ }
+
+ global $submenu;
+
+ if ( ! empty( $submenu[ $parent_slug ] ) ) {
+ foreach ( $submenu[ $parent_slug ] as $index => $item ) {
+ if ( isset( $item[2] ) && 'atbdp-extension' === $item[2] ) {
+ unset( $submenu[ $parent_slug ][ $index ] );
+ array_unshift( $submenu[ $parent_slug ], $item );
+ break;
+ }
+ }
+ }
+
+ $addons_url = add_query_arg(
+ [
+ 'post_type' => ATBDP_POST_TYPE,
+ 'page' => 'atbdp-extension',
+ 'te_view' => 'addons',
+ ],
+ admin_url( 'edit.php' )
+ );
+
+ $submenu[ $parent_slug ][] = [
+ __( 'Themes & Extensions', 'directorist' ),
+ 'manage_options',
+ esc_url_raw( $addons_url ),
+ __( 'Themes & Extensions', 'directorist' ),
+ ];
+ }
+
+ /**
+ * Keep the WordPress submenu selection aligned with the current page view.
+ *
+ * @param string $submenu_file Current submenu file.
+ * @param string $parent_file Current parent file.
+ *
+ * @return string
+ */
+ public function set_active_submenu( $submenu_file, $parent_file ) {
+ $requested_page = isset( $_GET['page'] ) && is_scalar( $_GET['page'] )
+ ? sanitize_key( wp_unslash( $_GET['page'] ) )
+ : '';
+
+ if ( 'edit.php?post_type=at_biz_dir' !== $parent_file || 'atbdp-extension' !== $requested_page ) {
+ return $submenu_file;
+ }
+
+ $is_connected = (bool) get_user_meta( get_current_user_id(), '_atbdp_has_subscriptions_sassion', true );
+ $requested = isset( $_GET['te_view'] ) && is_scalar( $_GET['te_view'] )
+ ? sanitize_key( wp_unslash( $_GET['te_view'] ) )
+ : '';
+
+ if ( ! $is_connected || 'addons' !== $requested ) {
+ return 'atbdp-extension';
+ }
+
+ return add_query_arg(
+ [
+ 'post_type' => ATBDP_POST_TYPE,
+ 'page' => 'atbdp-extension',
+ 'te_view' => 'addons',
+ ],
+ admin_url( 'edit.php' )
+ );
}
/**
@@ -2646,7 +2862,7 @@ public function get_themes_overview() {
$sovware_themes = ( is_array( $this->themes ) ) ? array_keys( $this->themes ) : [];
$theme_updates = get_site_transient( 'update_themes' );
- $outdated_themes = $theme_updates->response;
+ $outdated_themes = ( is_object( $theme_updates ) && isset( $theme_updates->response ) && is_array( $theme_updates->response ) ) ? $theme_updates->response : array();
$outdated_themes_keys = ( is_array( $outdated_themes ) ) ? array_keys( $outdated_themes ) : [];
$all_themes = wp_get_themes();
@@ -2660,13 +2876,29 @@ public function get_themes_overview() {
if ( in_array( $theme_base, $sovware_themes ) ) {
$customizer_link = "customize.php?theme={$theme_data->stylesheet}&return=%2Fwp-admin%2Fthemes.php";
$customizer_link = admin_url( $customizer_link );
+ $has_theme_update = isset( $outdated_themes[ $theme_data->stylesheet ] );
+ $theme_update_info = $has_theme_update ? $outdated_themes[ $theme_data->stylesheet ] : array();
+ $theme_new_version = '';
+
+ if ( is_object( $theme_update_info ) ) {
+ $theme_update_info = get_object_vars( $theme_update_info );
+ }
+
+ if ( is_array( $theme_update_info ) ) {
+ if ( ! empty( $theme_update_info['new_version'] ) && is_scalar( $theme_update_info['new_version'] ) ) {
+ $theme_new_version = sanitize_text_field( $theme_update_info['new_version'] );
+ } elseif ( ! empty( $theme_update_info['version'] ) && is_scalar( $theme_update_info['version'] ) ) {
+ $theme_new_version = sanitize_text_field( $theme_update_info['version'] );
+ }
+ }
$installed_theme_list[ $theme_base ] = [
'name' => $theme_data->name,
'version' => $theme_data->version,
'thumbnail' => $theme_data->get_screenshot(),
'customizer_link' => $customizer_link,
- 'has_update' => ( in_array( $theme_data->stylesheet, $outdated_themes_keys ) ) ? true : false,
+ 'has_update' => $has_theme_update,
+ 'new_version' => $theme_new_version,
'stylesheet' => $theme_data->stylesheet,
];
@@ -2674,7 +2906,7 @@ public function get_themes_overview() {
$total_active_themes++;
}
- if ( in_array( $theme_base, $outdated_themes_keys ) ) {
+ if ( $has_theme_update ) {
$total_outdated_themes++;
}
}
@@ -2755,7 +2987,9 @@ public function get_current_active_theme_info( array $args = [] ) {
$customizer_link = admin_url( $customizer_link );
// Check form theme update
- $has_update = isset( $args['installed_theme_list'][ $current_active_theme->stylesheet ] ) ? $args['installed_theme_list'][ $current_active_theme->stylesheet ]['has_update'] : '';
+ $active_theme_state = isset( $args['installed_theme_list'][ $current_active_theme->stylesheet ] ) ? $args['installed_theme_list'][ $current_active_theme->stylesheet ] : array();
+ $has_update = isset( $active_theme_state['has_update'] ) ? $active_theme_state['has_update'] : '';
+ $new_version = isset( $active_theme_state['new_version'] ) ? $active_theme_state['new_version'] : '';
$active_theme_info = [
'name' => $current_active_theme->name,
@@ -2763,6 +2997,7 @@ public function get_current_active_theme_info( array $args = [] ) {
'thumbnail' => $current_active_theme->get_screenshot(),
'customizer_link' => $customizer_link,
'has_update' => $has_update,
+ 'new_version' => $new_version,
'stylesheet' => $current_active_theme->stylesheet,
];
@@ -2877,8 +3112,222 @@ public static function remote_activate_license( $license_item = [] ) {
return $status;
}
+ private static function get_remote_auth_connection_error_message() {
+ return __( 'Could not reach Directorist.com. Please try again.', 'directorist' );
+ }
+
+ private static function get_remote_auth_invalid_credentials_message() {
+ return __( 'The username, email address, or password is incorrect. Please check your details and try again.', 'directorist' );
+ }
+
+ private static function get_remote_auth_invalid_access_key_message() {
+ return __( 'The access key is invalid. Check the key in your Directorist account and try again.', 'directorist' );
+ }
+
+ /**
+ * Normalize the shared Directorist License Manager response contract.
+ *
+ * @param array $response_body Remote response body.
+ *
+ * @return array|null
+ */
+ private static function normalize_license_manager_response( $response_body ) {
+ if ( ! is_array( $response_body ) ) {
+ return null;
+ }
+
+ if ( isset( $response_body['data'] ) && is_array( $response_body['data'] ) && isset( $response_body['data']['plan_data'] ) ) {
+ $response_body = $response_body['data'];
+ }
+
+ if ( empty( $response_body['plan_data'] ) || ! is_array( $response_body['plan_data'] ) ) {
+ return null;
+ }
+
+ $plan_data = $response_body['plan_data'];
+ $raw_account_data = isset( $response_body['account_data'] ) && is_array( $response_body['account_data'] )
+ ? $response_body['account_data']
+ : [];
+ $account_data = [
+ 'user_id' => isset( $raw_account_data['user_id'] ) ? absint( $raw_account_data['user_id'] ) : 0,
+ 'user_email' => isset( $raw_account_data['user_email'] ) && is_scalar( $raw_account_data['user_email'] )
+ ? sanitize_email( (string) $raw_account_data['user_email'] )
+ : '',
+ 'display_name' => isset( $raw_account_data['display_name'] ) && is_scalar( $raw_account_data['display_name'] )
+ ? sanitize_text_field( (string) $raw_account_data['display_name'] )
+ : '',
+ ];
+
+ if (
+ empty( $plan_data['downloads'] )
+ || ! is_array( $plan_data['downloads'] )
+ || ! isset( $plan_data['downloads']['templates'], $plan_data['downloads']['extensions'] )
+ || ! is_array( $plan_data['downloads']['templates'] )
+ || ! is_array( $plan_data['downloads']['extensions'] )
+ ) {
+ return null;
+ }
+
+ $downloads = $plan_data['downloads'];
+ $account_summary = isset( $plan_data['account_summary'] ) && is_array( $plan_data['account_summary'] )
+ ? $plan_data['account_summary']
+ : [];
+
+ return [
+ 'success' => true,
+ 'connection_method' => isset( $response_body['method'] ) && is_scalar( $response_body['method'] )
+ ? sanitize_key( (string) $response_body['method'] )
+ : '',
+ 'account_data' => $account_data,
+ 'plan_data' => $plan_data,
+ 'account_summary' => $account_summary,
+ 'license_data' => [
+ 'themes' => $downloads['templates'],
+ 'plugins' => $downloads['extensions'],
+ 'account_summary' => $account_summary,
+ ],
+ ];
+ }
+
+ /**
+ * Authenticate with a Directorist account access key.
+ *
+ * The key is used for this request only and is never persisted locally.
+ *
+ * @param string $access_key Directorist account access key.
+ *
+ * @return array
+ */
+ private static function remote_authenticate_user_by_access_key( $access_key ) {
+ $url = apply_filters(
+ 'directorist_license_manager_access_key_api_url',
+ 'https://directorist.com/wp-json/directorist-license-manager/user-connect'
+ );
+
+ $response = wp_remote_post(
+ $url,
+ [
+ 'timeout' => 30,
+ 'redirection' => 0,
+ 'headers' => [
+ 'user-agent' => 'Directorist/' . md5( esc_url( home_url() ) ) . ';',
+ 'Accept' => 'application/json',
+ ],
+ 'body' => [
+ 'access_key' => $access_key,
+ 'domain' => home_url(),
+ ],
+ ]
+ );
+
+ if ( is_wp_error( $response ) ) {
+ return [
+ 'success' => false,
+ 'message' => self::get_remote_auth_connection_error_message(),
+ ];
+ }
+
+ $response_code = wp_remote_retrieve_response_code( $response );
+ $response_body = json_decode( wp_remote_retrieve_body( $response ), true );
+
+ if ( 422 === $response_code ) {
+ return [
+ 'success' => false,
+ 'message' => self::get_remote_auth_invalid_access_key_message(),
+ ];
+ }
+
+ if ( $response_code < 200 || $response_code >= 300 ) {
+ return [
+ 'success' => false,
+ 'message' => self::get_remote_auth_connection_error_message(),
+ ];
+ }
+
+ $normalized_response = self::normalize_license_manager_response( $response_body );
+
+ if ( null === $normalized_response || empty( $normalized_response['account_data']['user_id'] ) ) {
+ return [
+ 'success' => false,
+ 'message' => __( 'Directorist.com could not verify this access key. Please try again.', 'directorist' ),
+ ];
+ }
+
+ $normalized_response['connection_method'] = 'access_key';
+
+ return $normalized_response;
+ }
+
+ /**
+ * Authenticate through the current Directorist License Manager API.
+ *
+ * Returning null allows the legacy endpoint to remain the compatibility
+ * fallback when the newer route is unavailable.
+ *
+ * @param array $user_credentials User and password values.
+ *
+ * @return array|null
+ */
+ private static function remote_authenticate_user_v2( $user_credentials ) {
+ $url = apply_filters(
+ 'directorist_license_manager_api_url',
+ 'https://directorist.com/wp-json/directorist-license-manager/user-login'
+ );
+ $password = array_key_exists( 'password_raw', $user_credentials ) && is_string( $user_credentials['password_raw'] )
+ ? $user_credentials['password_raw']
+ : ( $user_credentials['password'] ?? '' );
+
+ $response = wp_remote_post(
+ $url,
+ [
+ 'timeout' => 30,
+ 'redirection' => 0,
+ 'headers' => [
+ 'user-agent' => 'Directorist/' . md5( esc_url( home_url() ) ) . ';',
+ 'Accept' => 'application/json',
+ ],
+ 'body' => [
+ 'email' => $user_credentials['user'] ?? '',
+ 'pass' => $password,
+ 'domain' => home_url(),
+ ],
+ ]
+ );
+
+ if ( is_wp_error( $response ) ) {
+ return null;
+ }
+
+ $response_code = wp_remote_retrieve_response_code( $response );
+ $response_body = json_decode( wp_remote_retrieve_body( $response ), true );
+
+ if ( 422 === $response_code ) {
+ if ( ! is_email( $user_credentials['user'] ?? '' ) ) {
+ return null;
+ }
+
+ return [
+ 'success' => false,
+ 'message' => self::get_remote_auth_invalid_credentials_message(),
+ ];
+ }
+
+ if ( $response_code < 200 || $response_code >= 300 || ! is_array( $response_body ) ) {
+ return null;
+ }
+
+ return self::normalize_license_manager_response( $response_body );
+ }
+
// remote_authenticate_user
public static function remote_authenticate_user( $user_credentials = [] ) {
+ $license_manager_response = self::remote_authenticate_user_v2( $user_credentials );
+ unset( $user_credentials['password_raw'] );
+
+ if ( null !== $license_manager_response ) {
+ return $license_manager_response;
+ }
+
$status = [ 'success' => true ];
$url = 'https://directorist.com/wp-json/directorist/v1/licencing';
@@ -2903,14 +3352,20 @@ public static function remote_authenticate_user( $user_credentials = [] ) {
$response = wp_remote_get( $url, $config );
if ( is_wp_error( $response ) ) {
- $status['success'] = false;
- $status['message'] = Directorist\Helper::get_first_wp_error_message( $response );
+ $status['success'] = false;
+ $status['error_code'] = $response->get_error_code();
+ $status['message'] = self::get_remote_auth_connection_error_message();
} else {
+ $response_code = wp_remote_retrieve_response_code( $response );
$response_body = is_string( $response['body'] ) ? json_decode( $response['body'], true ) : $response['body'];
+
+ if ( empty( $response_body ) && in_array( $response_code, [ 401, 403 ], true ) ) {
+ $status['message'] = self::get_remote_auth_invalid_credentials_message();
+ }
}
} catch ( Exception $e ) {
$status['success'] = false;
- $status['message'] = $e->getMessage();
+ $status['message'] = self::get_remote_auth_connection_error_message();
}
if ( is_array( $response_body ) ) {
@@ -2919,6 +3374,10 @@ public static function remote_authenticate_user( $user_credentials = [] ) {
if ( empty( $response_body['success'] ) ) {
$status['success'] = false;
+
+ if ( empty( $status['message'] ) && empty( $status['log'] ) ) {
+ $status['message'] = self::get_remote_auth_invalid_credentials_message();
+ }
}
$status['response'] = $response_body;
@@ -3017,6 +3476,440 @@ public static function filter_product_type( $product_type = '' ) {
return $product_type;
}
+ /**
+ * Store a normalized optional account summary from a remote response.
+ *
+ * @param array $response Remote API response.
+ *
+ * @return void
+ */
+ private function store_account_summary_from_response( $response ) {
+ $candidates = [
+ $response['account_summary'] ?? null,
+ $response['account']['summary'] ?? null,
+ $response['plan_data']['account_summary'] ?? null,
+ $response['license_data']['account_summary'] ?? null,
+ ];
+ $summary = null;
+
+ foreach ( $candidates as $candidate ) {
+ if ( is_array( $candidate ) ) {
+ $summary = $candidate;
+ break;
+ }
+ }
+
+ if ( null === $summary ) {
+ delete_user_meta( get_current_user_id(), '_atbdp_account_summary' );
+ return;
+ }
+
+ $allowed_statuses = [ 'active', 'expired', 'cancelled', 'unknown' ];
+ $status = isset( $summary['subscription_status'] ) && is_scalar( $summary['subscription_status'] )
+ ? sanitize_key( (string) $summary['subscription_status'] )
+ : 'unknown';
+ $expires_at = isset( $summary['expires_at'] ) && is_scalar( $summary['expires_at'] )
+ ? trim( (string) $summary['expires_at'] )
+ : '';
+ $expires_timestamp = $expires_at ? strtotime( $expires_at ) : false;
+ $account_data = isset( $response['account_data'] ) && is_array( $response['account_data'] )
+ ? $response['account_data']
+ : [];
+ $account_email = isset( $account_data['user_email'] ) && is_scalar( $account_data['user_email'] )
+ ? sanitize_email( (string) $account_data['user_email'] )
+ : '';
+ $avatar_url = isset( $summary['avatar_url'] ) && is_scalar( $summary['avatar_url'] )
+ ? esc_url_raw( (string) $summary['avatar_url'] )
+ : '';
+
+ if ( ! $avatar_url && is_email( $account_email ) ) {
+ $avatar_url = esc_url_raw( get_avatar_url( $account_email, [ 'size' => 64 ] ) );
+ }
+
+ $normalized = [
+ 'display_name' => isset( $summary['display_name'] ) && is_scalar( $summary['display_name'] )
+ ? sanitize_text_field( (string) $summary['display_name'] )
+ : ( isset( $account_data['display_name'] ) && is_scalar( $account_data['display_name'] )
+ ? sanitize_text_field( (string) $account_data['display_name'] )
+ : null ),
+ 'avatar_url' => $avatar_url ?: null,
+ 'plan_name' => isset( $summary['plan_name'] ) && is_scalar( $summary['plan_name'] )
+ ? sanitize_text_field( (string) $summary['plan_name'] )
+ : null,
+ 'subscription_status' => in_array( $status, $allowed_statuses, true ) ? $status : 'unknown',
+ 'expires_at' => false !== $expires_timestamp ? gmdate( DATE_ATOM, $expires_timestamp ) : null,
+ 'all_access' => array_key_exists( 'all_access', $summary )
+ ? filter_var( $summary['all_access'], FILTER_VALIDATE_BOOLEAN )
+ : null,
+ 'is_lifetime' => array_key_exists( 'is_lifetime', $summary )
+ ? filter_var( $summary['is_lifetime'], FILTER_VALIDATE_BOOLEAN )
+ : null,
+ ];
+
+ update_user_meta( get_current_user_id(), '_atbdp_account_summary', $normalized );
+ }
+
+ /**
+ * Build connected account copy from authoritative summary data.
+ *
+ * @param array $account_summary Account summary data.
+ * @param bool $has_entitlements Whether subscribed products are available.
+ *
+ * @return string
+ */
+ private function get_dashboard_account_description( $account_summary, $has_entitlements ) {
+ $status = is_array( $account_summary ) ? ( $account_summary['subscription_status'] ?? 'unknown' ) : 'unknown';
+ $plan_name = is_array( $account_summary ) ? trim( (string) ( $account_summary['plan_name'] ?? '' ) ) : '';
+ $all_access = is_array( $account_summary ) && true === ( $account_summary['all_access'] ?? null );
+ $is_lifetime = is_array( $account_summary ) && true === ( $account_summary['is_lifetime'] ?? null );
+ $expires_at = is_array( $account_summary ) ? ( $account_summary['expires_at'] ?? null ) : null;
+ $expires_timestamp = is_scalar( $expires_at ) ? strtotime( (string) $expires_at ) : false;
+ $formatted_date = false !== $expires_timestamp
+ ? ( function_exists( 'wp_date' )
+ ? wp_date( get_option( 'date_format' ), $expires_timestamp )
+ : date_i18n( get_option( 'date_format' ), $expires_timestamp ) )
+ : '';
+
+ if ( 'active' === $status ) {
+ if ( $all_access && $is_lifetime ) {
+ return __( 'Your lifetime plan is active, so every theme and extension is unlocked.', 'directorist' );
+ }
+
+ if ( $all_access && $formatted_date ) {
+ /* translators: %s: Subscription expiration date. */
+ return sprintf( __( 'Your plan is active until %s, so every theme and extension is unlocked.', 'directorist' ), $formatted_date );
+ }
+
+ if ( $plan_name && $formatted_date ) {
+ /* translators: 1: Plan name, 2: Subscription expiration date. */
+ return sprintf( __( 'Your %1$s plan is active until %2$s.', 'directorist' ), $plan_name, $formatted_date );
+ }
+
+ if ( $formatted_date ) {
+ /* translators: %s: Subscription expiration date. */
+ return sprintf( __( 'Your plan is active until %s.', 'directorist' ), $formatted_date );
+ }
+ }
+
+ if ( 'expired' === $status ) {
+ if ( $formatted_date ) {
+ /* translators: %s: Subscription expiration date. */
+ return sprintf( __( 'Your plan expired on %s. Renew it to receive subscription updates and installs.', 'directorist' ), $formatted_date );
+ }
+
+ return __( 'Your plan has expired. Renew it to receive subscription updates and installs.', 'directorist' );
+ }
+
+ if ( 'cancelled' === $status ) {
+ return $formatted_date
+ ? sprintf(
+ /* translators: %s: Subscription access end date. */
+ __( 'Your plan is cancelled. Your access remains available until %s.', 'directorist' ),
+ $formatted_date
+ )
+ : __( 'Your plan is cancelled. Check your Directorist account for current access details.', 'directorist' );
+ }
+
+ return $has_entitlements
+ ? __( 'Your Directorist account is connected. Your subscribed themes and extensions are ready to manage.', 'directorist' )
+ : __( 'Your Directorist account is connected, but no subscribed products were found. Refresh purchases to sync your account.', 'directorist' );
+ }
+
+ /**
+ * Build the connected account plan label for the dashboard footer.
+ *
+ * @param array $account_summary Account summary data.
+ *
+ * @return string
+ */
+ private function get_dashboard_plan_label( $account_summary ) {
+ $plan_name = is_array( $account_summary ) && isset( $account_summary['plan_name'] )
+ ? trim( sanitize_text_field( (string) $account_summary['plan_name'] ) )
+ : '';
+
+ if ( $plan_name ) {
+ if ( preg_match( '/\bplan$/i', $plan_name ) ) {
+ return $plan_name;
+ }
+
+ /* translators: %s: Connected Directorist subscription plan name. */
+ return sprintf( __( '%s plan', 'directorist' ), $plan_name );
+ }
+
+ if ( is_array( $account_summary ) && true === ( $account_summary['is_lifetime'] ?? null ) ) {
+ return __( 'Lifetime plan', 'directorist' );
+ }
+
+ $status = is_array( $account_summary ) ? ( $account_summary['subscription_status'] ?? 'unknown' ) : 'unknown';
+
+ if ( 'active' === $status ) {
+ return __( 'Active plan', 'directorist' );
+ }
+
+ if ( 'expired' === $status ) {
+ return __( 'Expired plan', 'directorist' );
+ }
+
+ if ( 'cancelled' === $status ) {
+ return __( 'Cancelled plan', 'directorist' );
+ }
+
+ return __( 'Connected account', 'directorist' );
+ }
+
+ /**
+ * Prepare the connected dashboard welcome section data.
+ *
+ * @param array $extensions_overview Extensions overview data.
+ * @param array $themes_overview Themes overview data.
+ *
+ * @return array
+ */
+ private function get_dashboard_welcome_data( $extensions_overview, $themes_overview ) {
+ $has_entitlements = ! empty( $extensions_overview['extensions_available_in_subscriptions'] )
+ || ! empty( $themes_overview['themes_available_in_subscriptions'] );
+ $account_summary = get_user_meta( get_current_user_id(), '_atbdp_account_summary', true );
+ $account_summary = is_array( $account_summary ) ? $account_summary : [];
+ $connection_method = get_user_meta( get_current_user_id(), '_atbdp_subscription_connection_method', true );
+ $connection_method = 'access_key' === $connection_method ? 'access_key' : 'account';
+ $account_name = isset( $account_summary['display_name'] )
+ ? trim( sanitize_text_field( (string) $account_summary['display_name'] ) )
+ : '';
+ $account_login = trim( (string) get_user_meta( get_current_user_id(), '_atbdp_subscribed_username', true ) );
+
+ if ( ! $account_name && $account_login && ! is_email( $account_login ) ) {
+ $account_name = sanitize_user( $account_login );
+ }
+
+ if ( is_email( $account_name ) ) {
+ $account_name = '';
+ }
+
+ if ( $account_name ) {
+ /* translators: %s: Connected Directorist account owner's display name. */
+ $title = sprintf( __( 'Welcome back, %s', 'directorist' ), $account_name );
+ } else {
+ $title = __( 'Welcome back', 'directorist' );
+ }
+
+ $name_parts = $account_name ? preg_split( '/\s+/', $account_name ) : [];
+ $initials = '';
+
+ if ( ! empty( $name_parts ) ) {
+ $first_part = reset( $name_parts );
+ $last_part = end( $name_parts );
+ $initials = function_exists( 'mb_substr' )
+ ? mb_substr( (string) $first_part, 0, 1 )
+ : substr( (string) $first_part, 0, 1 );
+
+ if ( count( $name_parts ) > 1 ) {
+ $initials .= function_exists( 'mb_substr' )
+ ? mb_substr( (string) $last_part, 0, 1 )
+ : substr( (string) $last_part, 0, 1 );
+ }
+ }
+
+ $description = $this->get_dashboard_account_description( $account_summary, $has_entitlements );
+ $plugin_version = defined( 'ATBDP_VERSION' ) ? sanitize_text_field( (string) ATBDP_VERSION ) : '';
+ $whats_new_url = apply_filters(
+ 'directorist_themes_extensions_whats_new_url',
+ 'https://directorist.com/changelog/',
+ $plugin_version
+ );
+
+ $directories = directory_types();
+ $directories = is_array( $directories ) && ! is_wp_error( $directories ) ? $directories : [];
+
+ return [
+ 'title' => $title,
+ 'description' => $description,
+ 'account_name' => $account_name,
+ 'account_avatar_url' => isset( $account_summary['avatar_url'] ) ? esc_url_raw( (string) $account_summary['avatar_url'] ) : '',
+ 'account_initials' => $initials
+ ? ( function_exists( 'mb_strtoupper' ) ? mb_strtoupper( $initials ) : strtoupper( $initials ) )
+ : 'D',
+ 'connection_method' => $connection_method,
+ 'plugin_version' => $plugin_version,
+ 'plan_label' => $this->get_dashboard_plan_label( $account_summary ),
+ 'whats_new_url' => esc_url_raw( (string) $whats_new_url ),
+ 'has_directories' => ! empty( $directories ),
+ 'view_listings_url' => ATBDP_Permalink::get_directorist_listings_page_link(),
+ 'primary_action_url' => ! empty( $directories )
+ ? ATBDP_Permalink::get_add_listing_page_link()
+ : admin_url( 'edit.php?post_type=at_biz_dir&page=atbdp-directory-types&action=add_new' ),
+ 'primary_action_text' => ! empty( $directories )
+ ? __( 'Add listing', 'directorist' )
+ : __( 'Create directory', 'directorist' ),
+ ];
+ }
+
+ /**
+ * Build a Directory Builder URL for the current directory mode.
+ *
+ * @param int $directory_id Directory term ID.
+ * @param string $target Optional stable Builder navigation target.
+ *
+ * @return string
+ */
+ private function get_dashboard_builder_url( $directory_id = 0, $target = '' ) {
+ $is_multi_directory = directorist_is_multi_directory_enabled();
+ $query_args = [
+ 'post_type' => ATBDP_POST_TYPE,
+ 'page' => $is_multi_directory ? 'atbdp-directory-types' : 'atbdp-layout-builder',
+ ];
+
+ if ( $is_multi_directory && $directory_id ) {
+ $query_args['listing_type_id'] = absint( $directory_id );
+ $query_args['action'] = 'edit';
+ }
+
+ $url = add_query_arg( $query_args, admin_url( 'edit.php' ) );
+ $target = sanitize_key( $target );
+
+ return $target ? $url . '#' . $target : $url;
+ }
+
+ /**
+ * Prepare directory-aware connected Dashboard quick actions.
+ *
+ * @return array
+ */
+ private function get_dashboard_quick_actions_data() {
+ $directories = directory_types();
+ $directories = is_array( $directories ) && ! is_wp_error( $directories ) ? $directories : [];
+ $default_id = (string) absint( default_directory_type() );
+ $items = [];
+
+ foreach ( $directories as $directory ) {
+ if ( ! $directory instanceof WP_Term ) {
+ continue;
+ }
+
+ $directory_id = (string) absint( $directory->term_id );
+ $directory_name = sanitize_text_field( $directory->name );
+ $builder_url = $this->get_dashboard_builder_url( $directory->term_id );
+
+ $items[] = [
+ 'id' => $directory_id,
+ 'name' => $directory_name,
+ 'actions' => [
+ 'add-listing' => [
+ 'key' => 'add-listing',
+ 'label' => __( 'Add a listing', 'directorist' ),
+ /* translators: %s: Directory type name. */
+ 'description' => sprintf( __( 'Create a new %s listing', 'directorist' ), $directory_name ),
+ /* translators: %s: Directory type name. */
+ 'aria_label' => sprintf( __( 'Add a listing for %s', 'directorist' ), $directory_name ),
+ 'icon' => 'la la-plus',
+ 'url' => add_query_arg(
+ [
+ 'post_type' => ATBDP_POST_TYPE,
+ 'directory_type' => $directory->term_id,
+ ],
+ admin_url( 'post-new.php' )
+ ),
+ ],
+ 'manage-categories' => [
+ 'key' => 'manage-categories',
+ 'label' => __( 'Manage categories', 'directorist' ),
+ 'description' => __( 'Organize how listings are grouped', 'directorist' ),
+ /* translators: %s: Directory type name. */
+ 'aria_label' => sprintf( __( 'Manage categories for %s', 'directorist' ), $directory_name ),
+ 'icon' => 'la la-tags',
+ 'url' => add_query_arg(
+ [
+ 'taxonomy' => 'at_biz_dir-category',
+ 'post_type' => ATBDP_POST_TYPE,
+ 'directory_type' => $directory->term_id,
+ ],
+ admin_url( 'edit-tags.php' )
+ ),
+ ],
+ 'listing-layout' => [
+ 'key' => 'listing-layout',
+ 'label' => __( 'Customize listing layout', 'directorist' ),
+ 'description' => __( 'Design the single listing page', 'directorist' ),
+ /* translators: %s: Directory type name. */
+ 'aria_label' => sprintf( __( 'Customize the listing layout for %s', 'directorist' ), $directory_name ),
+ 'icon' => 'la la-paint-roller',
+ 'url' => $builder_url . '#single_page_layout__contents',
+ ],
+ 'submission-form' => [
+ 'key' => 'submission-form',
+ 'label' => __( 'Submission form settings', 'directorist' ),
+ 'description' => __( 'Control what users can submit', 'directorist' ),
+ /* translators: %s: Directory type name. */
+ 'aria_label' => sprintf( __( 'Edit submission form settings for %s', 'directorist' ), $directory_name ),
+ 'icon' => 'la la-file-alt',
+ 'url' => $builder_url . '#submission_form',
+ ],
+ ],
+ ];
+ }
+
+ $available_ids = wp_list_pluck( $items, 'id' );
+
+ if ( $items && ! in_array( $default_id, $available_ids, true ) ) {
+ $default_id = (string) $items[0]['id'];
+ }
+
+ $builder_page = directorist_is_multi_directory_enabled()
+ ? add_query_arg(
+ [
+ 'post_type' => ATBDP_POST_TYPE,
+ 'page' => 'atbdp-directory-types',
+ 'action' => 'add_new',
+ ],
+ admin_url( 'edit.php' )
+ )
+ : $this->get_dashboard_builder_url();
+
+ return [
+ 'default_id' => $default_id,
+ 'directories' => $items,
+ 'create_directory' => [
+ 'key' => 'create-directory',
+ 'label' => __( 'Create directory', 'directorist' ),
+ 'description' => __( 'Set up your first directory type', 'directorist' ),
+ 'aria_label' => __( 'Create a directory', 'directorist' ),
+ 'icon' => 'la la-folder-plus',
+ 'url' => $builder_page,
+ ],
+ 'email' => [
+ 'key' => 'email-notifications',
+ 'label' => __( 'Email notifications', 'directorist' ),
+ 'description' => __( 'Set who gets notified, and when', 'directorist' ),
+ 'aria_label' => __( 'Manage Directorist email notifications', 'directorist' ),
+ 'icon' => 'la la-envelope',
+ 'url' => add_query_arg(
+ [
+ 'post_type' => ATBDP_POST_TYPE,
+ 'page' => 'atbdp-settings',
+ ],
+ admin_url( 'edit.php' )
+ ) . '#email_settings__email_general__active_channels__disable_email_notification',
+ ],
+ ];
+ }
+
+ /**
+ * Return a paginated connected-dashboard activity page.
+ */
+ public function get_dashboard_activity() {
+ if ( ! current_user_can( 'manage_options' ) || ! $this->is_verified_nonce() ) {
+ wp_send_json_error( [ 'message' => __( 'You are not allowed to load this activity.', 'directorist' ) ], 403 );
+ }
+
+ $page = isset( $_POST['activity_page'] ) ? absint( $_POST['activity_page'] ) : 1;
+ $type = isset( $_POST['activity_type'] ) && is_scalar( $_POST['activity_type'] )
+ ? sanitize_key( wp_unslash( $_POST['activity_type'] ) )
+ : 'all';
+
+ $activity = new ATBDP_Extension_Activity();
+
+ wp_send_json_success( $activity->get_page( $page, 10, $type ) );
+ }
+
/**
* It Loads Extension view
*/
@@ -3032,6 +3925,8 @@ public function show_extension_view() {
$extensions_overview = $this->get_extensions_overview();
$themes_overview = $this->get_themes_overview();
+ $dashboard_activity = $is_logged_in ? new ATBDP_Extension_Activity() : null;
+ $dashboard_metrics = $dashboard_activity ? $dashboard_activity->get_dashboard_metrics() : [];
$hard_logout = apply_filters( 'atbdp_subscriptions_hard_logout', false );
$hard_logout = ( $hard_logout ) ? 1 : 0;
@@ -3063,6 +3958,19 @@ public function show_extension_view() {
'theme_list' => $this->themes,
'settings_url' => $settings_url,
+ 'dashboard_welcome' => $is_logged_in ? $this->get_dashboard_welcome_data( $extensions_overview, $themes_overview ) : [],
+ 'dashboard_quick_actions' => $is_logged_in ? $this->get_dashboard_quick_actions_data() : [],
+ 'dashboard_metrics' => $dashboard_metrics,
+ 'dashboard_setup' => $dashboard_activity ? $dashboard_activity->get_dashboard_setup( $dashboard_metrics ) : [],
+ 'dashboard_activity' => $dashboard_activity ? $dashboard_activity->get_page( 1, 5, 'all' ) : [],
+ 'dashboard_recommendations' => $is_logged_in
+ ? ( new ATBDP_Extension_Recommendations(
+ $this->extensions,
+ $extensions_overview,
+ self::$extensions_aliases,
+ ATBDP()->beta
+ ) )->get_dashboard_data()
+ : [],
];
ATBDP()->load_template( 'admin-templates/theme-extensions/theme-extension', $data );
diff --git a/includes/classes/class-metabox.php b/includes/classes/class-metabox.php
index 09d84aa87e..bbbd8984b8 100644
--- a/includes/classes/class-metabox.php
+++ b/includes/classes/class-metabox.php
@@ -390,6 +390,12 @@ public function listing_form_info_meta( $post ) {
$all_types = directory_types();
$default = default_directory_type();
$current_type = directorist_get_listing_directory( $post->ID );
+ $requested_type = isset( $_GET['directory_type'] ) ? absint( wp_unslash( $_GET['directory_type'] ) ) : 0;
+
+ if ( ! $current_type && $requested_type && term_exists( $requested_type, ATBDP_TYPE ) ) {
+ $current_type = $requested_type;
+ }
+
$value = $current_type ? $current_type : $default;
wp_nonce_field( 'listing_info_action', 'listing_info_nonce' );
diff --git a/views/admin-templates/theme-extensions/auth/license-auth-section.php b/views/admin-templates/theme-extensions/auth/license-auth-section.php
index 9d5cd4845d..61f7075057 100644
--- a/views/admin-templates/theme-extensions/auth/license-auth-section.php
+++ b/views/admin-templates/theme-extensions/auth/license-auth-section.php
@@ -8,7 +8,7 @@
-
+
@@ -29,4 +29,4 @@
-
\ No newline at end of file
+
diff --git a/views/admin-templates/theme-extensions/theme-extension.php b/views/admin-templates/theme-extensions/theme-extension.php
index d44131f1f6..1692b99b57 100644
--- a/views/admin-templates/theme-extensions/theme-extension.php
+++ b/views/admin-templates/theme-extensions/theme-extension.php
@@ -1,16 +1,1838 @@
-