diff --git a/assets/css/admin/onboarding-wizard.css b/assets/css/admin/onboarding-wizard.css index bb66cd93..e2e367da 100644 --- a/assets/css/admin/onboarding-wizard.css +++ b/assets/css/admin/onboarding-wizard.css @@ -5,12 +5,88 @@ * Enqueued via includes/Admin/Menu.php (handle: subscrpt-onboarding-wizard). */ #subscrpt-onboarding-wizard { - max-width: 680px; -} -#subscrpt-onboarding-wizard:has(#subscrpt-section-1.active), -#subscrpt-onboarding-wizard:has(#subscrpt-section-2.active) { max-width: 900px; } + +/* Two columns: the steps on the left, a persistent live-preview rail on the + right that stays put while the steps swap. */ +/* One card: steps top-left, its nav footer bottom-left, and the preview + spanning the full height of the right column. */ +.wpsubs-wizard-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 420px; + grid-template-rows: 1fr auto; + grid-template-areas: + "steps preview" + "footer preview"; + background: var(--wpsubs-surface, #fff); + border: 1px solid var(--wpsubs-border, #e5e7eb); + border-radius: var(--wpsubs-radius, 8px); + box-shadow: var(--wpsubs-shadow, 0 1px 3px rgba(0, 0, 0, 0.08)); +} +.wpsubs-wizard-steps { + grid-area: steps; + min-width: 0; + padding: 28px 32px; +} +.wpsubs-wizard-aside { + grid-area: preview; + display: flex; + flex-direction: column; + justify-content: center; + padding: 28px 32px; + border-left: 1px solid var(--wpsubs-border, #e5e7eb); +} +.wpsubs-preview { + width: 100%; +} +/* Footer carries the step's Back / Continue buttons, under the steps only. */ +.wpsubs-wizard-footer { + grid-area: footer; + border-top: 1px solid var(--wpsubs-border, #e5e7eb); + padding: 16px 32px; +} +.wpsubs-wizard-nav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +/* `display: flex` outweighs the bare `hidden` attribute, so restate it. */ +.wpsubs-wizard-nav[hidden] { + display: none; +} +.wpsubs-preview__label { + margin: 0 0 2px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--wpsubs-text-muted, #6b7280); + text-align: center; +} +.wpsubs-preview__caption { + margin: 12px 2px 0; + font-size: 12px; + line-height: 1.5; + color: var(--wpsubs-text-muted, #6b7280); + text-align: center; +} +@media (max-width: 900px) { + .wpsubs-wizard-layout { + grid-template-columns: minmax(0, 1fr); + grid-template-areas: + "steps" + "preview" + "footer"; + } + .wpsubs-wizard-aside { + justify-content: flex-start; + border-left: none; + border-top: 1px solid var(--wpsubs-border, #e5e7eb); + } +} + /* Page sections — all rendered, JS toggles visibility */ .wpsubs-wizard-section { display: none; @@ -88,11 +164,9 @@ margin: 0 8px; } /* Card */ +/* No longer its own box — it is the left half of the shared card. */ .wpsubs-wizard-card { - background: var(--wpsubs-surface); - border: 1px solid var(--wpsubs-border); - border-radius: var(--wpsubs-radius); - padding: 28px 32px; + padding: 0; margin-bottom: 16px; } /* Form rows (shared) */ @@ -163,24 +237,6 @@ border-color: var(--wpsubs-brand); background: var(--wpsubs-brand-light); } -.wpsubs-p2-option-card__check { - position: absolute; - top: 10px; - right: 10px; - width: 20px; - height: 20px; - border-radius: 50%; - background: var(--wpsubs-brand); - display: none; - align-items: center; - justify-content: center; - color: var(--wpsubs-surface); - font-size: 11px; - font-weight: 700; -} -.wpsubs-p2-option-card.active .wpsubs-p2-option-card__check { - display: flex; -} .wpsubs-p2-option-card__icon { width: 32px; height: 32px; @@ -805,16 +861,6 @@ box-sizing: border-box; } /* Page 1 specific — two-column hero layout */ -#subscrpt-section-1 { - max-width: 860px; -} -#subscrpt-section-1 .wpsubs-wizard-card { - padding: 56px 48px; - display: grid; - grid-template-columns: 1fr 1fr; - gap: 64px; - align-items: center; -} .wpsubs-p1-left { display: flex; flex-direction: column; @@ -1014,11 +1060,230 @@ justify-content: flex-end; gap: 4px; } -@media (max-width: 680px) { - #subscrpt-section-1 .wpsubs-wizard-card { +@media (max-width: 782px) { + /* Stack the two-column body (form + preview/summary) on narrow screens. */ + .wpsubs-p2-body { grid-template-columns: 1fr; + gap: 20px; + } + .wpsubs-p2-preview-col { + position: static; + } +} + +/* Page 1 animated plan-structure preview (right column): + one Plan -> three Durations -> two Products, joined by flowing lines. */ +.wpsubs-p1-graph { + position: relative; + /* Fluid: fill the preview column instead of a fixed pixel box, so the nodes + scale with the rail rather than needing a wider container. */ + width: 100%; + aspect-ratio: 300 / 420; + margin: 10px auto 0; +} +.wpsubs-p1-graph__lines { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; +} +.wpsubs-p1-flow { + fill: none; + stroke: var(--wpsubs-brand, #ff4d00); + stroke-width: 1.5; + stroke-linecap: round; + stroke-dasharray: 3 6; + opacity: 0.5; + animation: wpsubs-p1-flow 0.9s linear infinite; +} +@keyframes wpsubs-p1-flow { + to { + stroke-dashoffset: -9; + } +} + +.wpsubs-p1-node { + position: absolute; + z-index: 1; + display: flex; + align-items: center; + gap: 8px; + box-sizing: border-box; + padding: 10px 12px; + background: var(--wpsubs-surface, #fff); + border: 1px solid var(--wpsubs-border, #e5e7eb); + border-radius: 9px; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08); + font-size: 12px; + font-weight: 600; + color: var(--wpsubs-text, #374151); + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + transform-origin: center; + transition: + transform 0.2s ease, + box-shadow 0.2s ease, + border-color 0.12s ease; +} +.wpsubs-p1-node__icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 24px; + height: 24px; + border-radius: 6px; + background: var(--wpsubs-surface-muted, #f3f4f6); + color: var(--wpsubs-text-muted, #6b7280); +} +.wpsubs-p1-node__icon svg { + width: 14px; + height: 14px; +} +.wpsubs-p1-node__label { + overflow: hidden; + text-overflow: ellipsis; +} +/* Two-line node body: title over a muted subtitle. */ +.wpsubs-p1-node__text { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} +.wpsubs-p1-node__title { + font-size: 12px; + font-weight: 600; + line-height: 1.25; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.wpsubs-p1-node__sub { + font-size: 10px; + font-weight: 500; + line-height: 1.3; + color: var(--wpsubs-text-muted, #6b7280); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.wpsubs-p1-node--plan .wpsubs-p1-node__sub { + color: var(--wpsubs-brand, #ff4d00); + opacity: 0.85; +} + +/* Plan node — brand accent, top of the fan-out. Widths are a % of the graph so + they scale with the column. */ +.wpsubs-p1-node--plan { + width: 58%; + border-color: var(--wpsubs-brand, #ff4d00); + background: var(--wpsubs-brand-light, #fff1eb); + color: var(--wpsubs-brand-dark, #d93f00); +} +.wpsubs-p1-node--plan .wpsubs-p1-node__icon { + background: var(--wpsubs-brand, #ff4d00); + color: #fff; +} +/* Durations (x3) — narrower, so tighten padding/icon to keep text room. */ +.wpsubs-p1-node--dur { + width: 30%; + padding: 8px 9px; + gap: 6px; +} +.wpsubs-p1-node--dur .wpsubs-p1-node__icon { + width: 20px; + height: 20px; +} +/* Products (x2). */ +.wpsubs-p1-node--prod { + width: 44%; +} + +/* Ghost nodes — the "you can add more later" placeholders. */ +.wpsubs-p1-node--ghost { + opacity: 0.45; + border-style: dashed; + box-shadow: none; +} + +/* The group belonging to the current step gets a brand ring. Ghosts never + light up — only the node the step actually fills in. */ +.wpsubs-p1-graph[data-active="plan"] .wpsubs-p1-node--plan, +.wpsubs-p1-graph[data-active="dur"] .wpsubs-p1-node--dur:not(.wpsubs-p1-node--ghost), +.wpsubs-p1-graph[data-active="prod"] .wpsubs-p1-node--prod:not(.wpsubs-p1-node--ghost) { + border-color: var(--wpsubs-brand, #ff4d00); + box-shadow: 0 0 0 3px var(--wpsubs-brand-ring, rgba(255, 77, 0, 0.22)); + animation: wpsubs-p1-pulse 1.6s ease-in-out infinite; +} +@keyframes wpsubs-p1-pulse { + 0%, + 100% { + box-shadow: 0 0 0 3px var(--wpsubs-brand-ring, rgba(255, 77, 0, 0.22)); } - .wpsubs-p1-right { - display: none; + 50% { + box-shadow: 0 0 0 5px rgba(255, 77, 0, 0.1); + } +} + +/* Zoom the card whose field is currently focused (wins over the pulse). */ +.wpsubs-p1-graph[data-focus="plan"] .wpsubs-p1-node--plan, +.wpsubs-p1-graph[data-focus="dur"] .wpsubs-p1-node--dur:not(.wpsubs-p1-node--ghost), +.wpsubs-p1-graph[data-focus="prod"] .wpsubs-p1-node--prod:not(.wpsubs-p1-node--ghost) { + transform: scale(1.14); + z-index: 3; + border-color: var(--wpsubs-brand, #ff4d00); + box-shadow: 0 10px 22px rgba(255, 77, 0, 0.2); + animation: none; +} + +@media (prefers-reduced-motion: reduce) { + .wpsubs-p1-flow, + .wpsubs-p1-graph .wpsubs-p1-node { + animation: none; } } + +/* Button loading state (plan create / connect requests in flight). */ +.wpsubs-btn.is-loading { + opacity: 0.6; + cursor: progress; + pointer-events: none; +} + +/* Free-trial line in the step-2 plan preview. */ +.wpsubs-p1-preview-trial { + margin: 6px 0 0; + font-size: 12px; + font-weight: 600; + color: #15803d; +} + +/* Pro-locked plan-type cards on the create-plan step. */ +.wpsubs-p2-option-card:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +/* List layout for the plan-type cards (create-plan step): each row is + icon + text, full width, with the check centered on the right. */ +.wpsubs-p2-option-cards--list { + display: flex; + flex-direction: column; + gap: 8px; +} +.wpsubs-p2-option-cards--list .wpsubs-p2-option-card { + flex-direction: row; + align-items: center; + gap: 12px; + padding: 12px 44px 12px 16px; +} +.wpsubs-p2-option-cards--list .wpsubs-p2-option-card__icon { + margin-bottom: 0; + flex-shrink: 0; +} +.wpsubs-p2-option-card__text { + min-width: 0; +} diff --git a/assets/js/admin/onboarding-wizard.js b/assets/js/admin/onboarding-wizard.js index 583818dc..64a7980d 100644 --- a/assets/js/admin/onboarding-wizard.js +++ b/assets/js/admin/onboarding-wizard.js @@ -1,146 +1,323 @@ /** - * Onboarding Wizard — SPA-style - * All pages rendered at once; JS controls section visibility - * PHP values passed via localized script data (see Menu::enqueue_admin_assets) + * Onboarding Wizard — SPA-style. + * + * Flow: + * 1. Create Plan — pick a plan type + name (name auto-fills from the type). + * 2. Set Frequency — billing duration (frequency, interval, trial, fee). + * 3. Connect — attach the plan to a new or existing product. + * 4. Finish — summary. + * + * The plan (group), duration (term) and product relation are created through + * the Plans REST API (wpsubscription/v1/plans) once both the plan details and + * the frequency are known (leaving page 2). Creating a brand-new product uses + * an admin-ajax handler. All PHP values arrive via `subscrpt_wizard`. */ (function ($) { "use strict"; + var INTERVAL_TO_INT = { day: 1, week: 2, month: 3, year: 4 }; + var Wizard = { - currentPage: 1, - ajaxUrl: "", - subscriptionsUrl: "", + cfg: {}, + autoName: "", + // Created leaving page 2, used on page 3. + groupId: 0, + termId: 0, + planTitle: "", + billingText: "", init: function () { - this.ajaxUrl = subscrpt_wizard.ajax_url; - this.subscriptionsUrl = subscrpt_wizard.subscriptions_url; + this.cfg = window.subscrpt_wizard || {}; + this.hasProducts = $("#subscrpt-has-products").val() === "1"; + this.autoName = $.trim($("#subscrpt_plan_title").val()); this.bindEvents(); - this.showRelevantProductSection(); - this.initPageIndicator(); this.initLivePreview(); - this.initStepperState(); + this.initFocusZoom(); + this.updatePreview(); + $("#subscrpt-link-plans").attr("href", this.cfg.plans_url || "#"); + $("#subscrpt-link-products").attr("href", this.cfg.products_url || "#"); }, - initPageIndicator: function () { - this.currentPage = parseInt($("#subscrpt-current-page").text(), 10) || 1; - }, + // ----- REST helper ----- - initStepperState: function () { - var page = parseInt($("#subscrpt-wizard-page").val(), 10) || 1; - if (page > 1) { - $("#subscrpt-stepper").show(); - $(".wpsubs-wizard-stepper__step").removeClass("active done"); - $(".wpsubs-wizard-stepper__step").each(function () { - var step = parseInt($(this).data("step"), 10); - if (step < page) { - $(this).addClass("done"); - } else if (step === page) { - $(this).addClass("active"); + api: function (method, path, body) { + return fetch(this.cfg.rest_url + path, { + method: method, + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "X-WP-Nonce": this.cfg.rest_nonce || "", + }, + body: body ? JSON.stringify(body) : undefined, + }).then(function (res) { + return res.json().then(function (data) { + if (!res.ok) { + throw new Error((data && data.message) || "Request failed."); } + return data; }); - } + }); }, + // ----- Events ----- + bindEvents: function () { - // Page 1 - $(document).on("click", "#subscrpt-btn-start", $.proxy(this.goToPage2, this)); + // Page 1 (plan). + $(document).on("click", ".wpsubs-plan-type-card", $.proxy(this.selectPlanType, this)); $(document).on("click", "#subscrpt-btn-skip", $.proxy(this.skip, this)); - - // Page 2: product toggle - $(document).on("click", "#subscrpt-btn-create-new", $.proxy(this.showNewProduct, this)); - $(document).on("click", "#subscrpt-btn-use-existing", $.proxy(this.showExistingProduct, this)); - - // Page 2: product search - $(document).on("focus", "#subscrpt-product-search-input", $.proxy(this.openProductSearch, this)); - $(document).on("input", "#subscrpt-product-search-input", $.proxy(this.filterProducts, this)); - $(document).on("click", ".wpsubs-p2-product-search__item", $.proxy(this.onProductItemClick, this)); + $(document).on("click", "#subscrpt-btn-next-1", $.proxy(this.nextFromPlan, this)); + + // Page 2 (frequency). + $(document).on("click", "#subscrpt-btn-back-1", $.proxy(this.goToPage, this, 1)); + $(document).on("click", "#subscrpt-btn-create-plan", $.proxy(this.createPlan, this)); + + // Page 3 (product). + $(document).on("click", "#subscrpt-btn-back-2", $.proxy(this.goToPage, this, 2)); + $(document).on("click", ".wpsubs-connect-mode-card", $.proxy(this.selectConnectMode, this)); + $(document).on("click", "#subscrpt-btn-connect", $.proxy(this.connect, this)); + $(document).on("focus", "#subscrpt-product-search-input", this.openProductSearch); + $(document).on("input", "#subscrpt-product-search-input", this.filterProducts); + $(document).on("click", ".wpsubs-p2-product-search__item", $.proxy(this.onProductPick, this)); + $(document).on("click", "#subscrpt-btn-clear-product", $.proxy(this.clearProduct, this)); $(document).on("click", function (e) { if (!$(e.target).closest(".wpsubs-p2-product-search").length) { $("#subscrpt-product-search-dropdown").hide(); } }); - $(document).on("click", "#subscrpt-btn-clear-product", $.proxy(this.clearProductSelection, this)); - $(document).on("click", ".wpsubs-p2-variation-item", $.proxy(this.onVariationSelect, this)); - // Page 2: navigation - $(document).on("click", "#subscrpt-btn-back", $.proxy(this.goToPage1, this)); - $(document).on("click", "#subscrpt-btn-save", $.proxy(this.savePage2, this)); - - // Page 3 + // Page 4 (finish). $(document).on("click", "#subscrpt-btn-add-another", $.proxy(this.restart, this)); - $(document).on("click", "#subscrpt-btn-start-over", $.proxy(this.restart, this)); - }, - - // ----- Page transitions ----- - - goToPage1: function () { - this.switchSection(1); }, - goToPage2: function () { - this.switchSection(2); - }, + // ----- Navigation ----- - goToPage3: function (productId) { - $("#subscrpt-product-id").val(productId || 0); - this.switchSection(3); + goToPage: function (pageNum, e) { + if (e && e.preventDefault) { + e.preventDefault(); + } + this.switchSection(pageNum); }, switchSection: function (pageNum) { - this.currentPage = pageNum; - $("#subscrpt-wizard-page").val(pageNum); - if (pageNum === 1) { - $("#subscrpt-stepper").hide(); - } else { - $("#subscrpt-stepper").show(); - $(".wpsubs-wizard-stepper__step").removeClass("active done"); - $(".wpsubs-wizard-stepper__step").each(function () { - var step = parseInt($(this).data("step"), 10); - if (step < pageNum) { - $(this).addClass("done"); - } - }); - $('.wpsubs-wizard-stepper__step[data-step="' + pageNum + '"]').addClass("active"); - } + $(".wpsubs-wizard-stepper__step").removeClass("active done"); + $(".wpsubs-wizard-stepper__step").each(function () { + var step = parseInt($(this).data("step"), 10); + if (step < pageNum) { + $(this).addClass("done"); + } else if (step === pageNum) { + $(this).addClass("active"); + } + }); $(".wpsubs-wizard-section").removeClass("active"); $("#subscrpt-section-" + pageNum).addClass("active"); - }, - // ----- Page 1 actions ----- + // Swap the footer nav to this step's buttons. + $(".wpsubs-wizard-nav").attr("hidden", "hidden"); + $('.wpsubs-wizard-nav[data-nav="' + pageNum + '"]').removeAttr("hidden"); + + // Light up the part of the preview this step fills in. + var groups = { 1: "plan", 2: "dur", 3: "prod", 4: "done" }; + $("#subscrpt-preview-graph").attr("data-active", groups[pageNum] || "plan"); + this.updatePreview(); + + $("html, body").animate({ scrollTop: 0 }, 150); + }, skip: function (e) { e.preventDefault(); - window.location.href = this.subscriptionsUrl; + window.location.href = this.cfg.subscriptions_url; }, - // ----- Page 2 actions ----- + // ----- Page 1: plan type + name ----- + + selectPlanType: function (e) { + var card = $(e.currentTarget); + $(".wpsubs-plan-type-card").removeClass("active"); + card.addClass("active"); + $("#subscrpt-plan-type").val(card.data("type")); + + // Auto-fill the name, but never clobber a name the user has edited. + var suggested = card.data("name") ? String(card.data("name")) : ""; + var current = $.trim($("#subscrpt_plan_title").val()); + if (!current || current === this.autoName) { + $("#subscrpt_plan_title").val(suggested); + } + this.autoName = suggested; - showNewProduct: function (e) { - e.preventDefault(); - $(".wpsubs-p2-option-card, .product-toggle-btn").removeClass("active"); - $(e.currentTarget).addClass("active"); - $("#subscrpt-existing-product-fields").hide(); - // Restore name field editability - $("#subscrpt_product_name").prop("readonly", false).val(""); - $("#subscrpt_product_price").val(""); - $("#subscrpt-btn-save").html("Create product ›"); this.updatePreview(); }, - showExistingProduct: function (e) { + nextFromPlan: function (e) { + e.preventDefault(); + if (!$.trim($("#subscrpt_plan_title").val())) { + window.alert("Please enter a plan name."); + return; + } + this.switchSection(2); + }, + + // ----- Page 2: live preview + create plan ----- + + initLivePreview: function () { + var self = this; + var update = function () { + self.updatePreview(); + }; + // All the fields whose typing should reflect into the preview graph. + $(document).on( + "input", + "#subscrpt_plan_title, #subscrpt_billing_frequency, #subscrpt_free_trial, #subscrpt_new_product_name", + update, + ); + $(document).on("wpsubs:select", "#subscrpt-billing-interval-select, #subscrpt-trial-interval-select", update); + }, + + // Zoom the matching preview card while its field is focused. + initFocusZoom: function () { + var graph = $("#subscrpt-preview-graph"); + var groups = [ + { sel: "#subscrpt_plan_title", group: "plan" }, + { + sel: "#subscrpt_billing_frequency, #subscrpt_free_trial, #subscrpt_signup_fee, #subscrpt-billing-interval-select, #subscrpt-trial-interval-select", + group: "dur", + }, + { sel: "#subscrpt-product-search-input, #subscrpt_new_product_name, #subscrpt_connect_price", group: "prod" }, + ]; + groups.forEach(function (g) { + $(document).on("focusin", g.sel, function () { + graph.attr("data-focus", g.group); + }); + $(document).on("focusout", g.sel, function () { + graph.attr("data-focus", ""); + }); + }); + }, + + unitLabel: function (unit, count) { + var n = parseInt(count, 10) || 1; + return n > 1 ? n + " " + unit + "s" : unit; + }, + + // The name shown on the preview's product node, from whichever connect mode + // is active. + previewProductName: function () { + if (this.hasProducts && this.currentConnectMode() === "existing") { + return this.selectedProductName || ""; + } + return $.trim($("#subscrpt_new_product_name").val()); + }, + + // Fill the persistent preview graph from the current form state. Each step + // updates its own node; empty fields keep the placeholder label. + updatePreview: function () { + $("#subscrpt-preview-plan").text($.trim($("#subscrpt_plan_title").val()) || "Your plan"); + $("#subscrpt-preview-plan-type").text($(".wpsubs-plan-type-card.active").data("label") || "Recurring"); + + var freq = $("#subscrpt_billing_frequency").val() || "1"; + var interval = $("input[name='subscrpt_billing_interval']").val() || "month"; + $("#subscrpt-preview-dur").text("Every " + this.unitLabel(interval, freq)); + + var prod = this.previewProductName(); + if (prod) { + $("#subscrpt-preview-prod").text(prod); + } + }, + + createPlan: function (e) { e.preventDefault(); - $(".wpsubs-p2-option-card, .product-toggle-btn").removeClass("active"); - $(e.currentTarget).addClass("active"); - $("#subscrpt-existing-product-fields").show(); - $("#subscrpt-btn-save").html("Update product ›"); - // If already has a selection, re-show chip - var selectedVal = $("#subscrpt_existing_product").val(); - if (selectedVal) { - this.showProductChip($("#subscrpt_existing_product option:selected")); + + var self = this; + var $btn = $("#subscrpt-btn-create-plan"); + var title = $.trim($("#subscrpt_plan_title").val()); + var type = $("#subscrpt-plan-type").val() || "recurring"; + + if (!title) { + window.alert("Please enter a plan name."); + return; } + + var freq = parseInt($("#subscrpt_billing_frequency").val(), 10) || 1; + var interval = $("input[name='subscrpt_billing_interval']").val() || "month"; + var trial = $.trim($("#subscrpt_free_trial").val()); + var trialInterval = $("input[name='subscrpt_trial_interval']").val() || "day"; + var signupFee = this.cfg.is_pro ? $.trim($("#subscrpt_signup_fee").val()) : ""; + + if ($btn.hasClass("is-loading")) { + return; + } + $btn.addClass("is-loading").prop("disabled", true); + + var termBody = { + type: type, + title: title, + billing_frequency: freq, + billing_interval: INTERVAL_TO_INT[interval] || 3, + billing_length: 0, + free_trial: trial || "", + signup_fee: { amount: signupFee || "" }, + status: "active", + data: { free_trial_interval: trialInterval }, + }; + + // 1) Create the plan group. 2) Reuse the auto-seeded draft term (or create + // one) with the chosen duration. + this.api("POST", "/groups", { + title: title, + type: type, + product_type: 1, + status: "active", + }) + .then(function (group) { + self.groupId = group.id; + self.planTitle = title; + self.billingText = "every " + self.unitLabel(interval, freq); + termBody.plan_group_id = group.id; + + var seeded = group.plans && group.plans.length ? group.plans[0] : null; + if (seeded && seeded.id) { + return self.api("PUT", "/terms/" + seeded.id, termBody).then(function () { + return seeded.id; + }); + } + return self.api("POST", "/terms", termBody).then(function (term) { + return term.id; + }); + }) + .then(function (termId) { + self.termId = termId; + $btn.removeClass("is-loading").prop("disabled", false); + self.switchSection(3); + }) + .catch(function (err) { + $btn.removeClass("is-loading").prop("disabled", false); + window.alert(err.message || "Could not create the plan. Please try again."); + }); + }, + + // ----- Page 3: connect to a product ----- + + // Which connect mode is active. Without existing products the only option + // is creating a new one. + currentConnectMode: function () { + if (!this.hasProducts) { + return "new"; + } + var active = $(".wpsubs-connect-mode-card.active"); + return active.length ? active.data("mode") : "existing"; + }, + + selectConnectMode: function (e) { + var card = $(e.currentTarget); + var mode = card.data("mode"); + $(".wpsubs-connect-mode-card").removeClass("active"); + card.addClass("active"); + $("#subscrpt-connect-existing").toggle(mode === "existing"); + $("#subscrpt-connect-new").toggle(mode === "new"); + $("#subscrpt-btn-connect").html((mode === "new" ? "Create & connect" : "Connect plan") + " ›"); + this.updatePreview(); }, openProductSearch: function () { @@ -154,7 +331,7 @@ $("#subscrpt-product-search-dropdown").show(); var visible = 0; $(".wpsubs-p2-product-search__item").each(function () { - var name = $(this).data("name") ? $(this).data("name").toLowerCase() : ""; + var name = $(this).data("name") ? String($(this).data("name")).toLowerCase() : ""; var sku = $(this).data("sku") ? String($(this).data("sku")).toLowerCase() : ""; if (!q || name.indexOf(q) >= 0 || sku.indexOf(q) >= 0) { $(this).show(); @@ -166,200 +343,17 @@ $(".wpsubs-p2-product-search__empty").toggle(visible === 0); }, - onProductItemClick: function (e) { + onProductPick: function (e) { var item = $(e.currentTarget); - if (item.hasClass("wpsubs-p2-product-search__item--locked")) { - return; - } var id = String(item.data("id")); var name = item.data("name") || ""; var price = item.data("price") != null ? String(item.data("price")) : ""; var type = item.data("type") || ""; var sku = item.data("sku") ? String(item.data("sku")) : ""; - var productType = item.data("product-type") || ""; $("#subscrpt-existing-product-hidden").val(id); $("#subscrpt-product-search-dropdown").hide(); - this.showProductChip(name, price, type, sku); - $("#subscrpt_product_name").val(name); - $("#subscrpt_product_price").val(price); - - // Reset variation picker state - $("#subscrpt-variation-id-hidden").val(""); - - if (productType === "variable") { - // Variation picker handles subscription field autofill - this.fetchAndShowVariations(id); - } else { - $("#subscrpt-variation-picker-wrap").hide(); - - // Autofill subscription fields from product meta - var billingPeriod = item.data("billing-period"); - var billingPer = item.data("billing-per"); - var trialPer = item.data("trial-per"); - var signupFee = item.data("signup-fee"); - - if (billingPeriod) { - this.setAdvSelectValue("#subscrpt-billing-period-select", String(billingPeriod)); - } - if (billingPer) { - $("#subscrpt_billing_per").val(billingPer); - $("#subscrpt_billing_per_visible").val(billingPer); - } - if (trialPer !== undefined && trialPer !== "") { - $("#subscrpt_trial_timing_per").val(trialPer); - } - if (signupFee !== undefined && signupFee !== "") { - $("#subscrpt_signup_fee").val(signupFee); - } - } - - this.updatePreview(); - }, - - fetchAndShowVariations: function (productId) { - var self = this; - var wrap = $("#subscrpt-variation-picker-wrap"); - var list = $("#subscrpt-variation-picker-list"); - - wrap.show(); - list.html( - '

' + - (window.subscrpt_wizard_i18n ? subscrpt_wizard_i18n.loading : "Loading variations…") + - "

", - ); - - $.post( - this.ajaxUrl, - { - action: "subscrpt_get_product_variations", - nonce: $("#subscrpt_wizard_nonce").val(), - product_id: productId, - }, - function (response) { - if (response.success && response.data && response.data.length) { - self.renderVariationPicker(response.data); - } else { - wrap.hide(); - } - }, - ).fail(function () { - wrap.hide(); - }); - }, - renderVariationPicker: function (variations) { - var self = this; - var symbol = (window.subscrpt_wizard && subscrpt_wizard.currency_symbol) || "$"; - var html = '

Select a variation

'; - html += '
'; - - variations.forEach(function (v) { - var meta = v.sku ? "SKU " + v.sku : ""; - var priceDisplay = - v.price !== "" && v.price !== null && v.price !== undefined ? symbol + parseFloat(v.price).toFixed(2) : ""; - - html += - '
' + - '
' + - '
' + - '

' + - self.escHtml(v.label) + - "

" + - (meta ? '

' + self.escHtml(meta) + "

" : "") + - "
" + - (priceDisplay - ? '' + self.escHtml(priceDisplay) + "" - : "") + - "
"; - }); - - html += "
"; - $("#subscrpt-variation-picker-list").html(html); - $("#subscrpt-variation-picker-wrap").show(); - }, - - onVariationSelect: function (e) { - var item = $(e.currentTarget); - $(".wpsubs-p2-variation-item").removeClass("selected"); - item.addClass("selected"); - - $("#subscrpt-variation-id-hidden").val(String(item.data("id"))); - - // Autofill subscription fields and price from variation meta - var price = item.data("price"); - var billingPeriod = item.data("billing-period"); - var billingPer = item.data("billing-per"); - var trialPer = item.data("trial-per"); - var signupFee = item.data("signup-fee"); - - if (price !== undefined && price !== "") { - $("#subscrpt_product_price").val(price); - } - if (billingPeriod) { - this.setAdvSelectValue("#subscrpt-billing-period-select", String(billingPeriod)); - } - if (billingPer) { - $("#subscrpt_billing_per").val(billingPer); - $("#subscrpt_billing_per_visible").val(billingPer); - } - if (trialPer !== undefined && trialPer !== "") { - $("#subscrpt_trial_timing_per").val(trialPer); - } - if (signupFee !== undefined && signupFee !== "") { - $("#subscrpt_signup_fee").val(signupFee); - } - - this.updatePreview(); - }, - - escHtml: function (str) { - return String(str).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); - }, - - escAttr: function (str) { - return String(str) - .replace(/&/g, "&") - .replace(/"/g, """) - .replace(/'/g, "'") - .replace(//g, ">"); - }, - - setAdvSelectValue: function (selector, value) { - var el = $(selector); - if (!el.length) return; - var menuItem = el.find('.wpsubs-adv-select__item[data-value="' + value + '"]'); - var label = menuItem.length ? menuItem.find(".wpsubs-adv-select__item-label").text().trim() : value; - el.find('input[type="hidden"]').val(value); - el.find(".wpsubs-adv-select__label").text(label); - }, - - showProductChip: function (name, price, type, sku) { var initials = name .split(" ") .filter(Boolean) @@ -368,206 +362,137 @@ return w[0].toUpperCase(); }) .join(""); + var meta = [sku ? "SKU " + sku : null, type].filter(Boolean).join(" · "); - var meta = [sku ? "SKU " + sku : null, type, price ? "$" + parseFloat(price).toFixed(2) : null] - .filter(Boolean) - .join(" · "); - - $("#p2-chip-avatar").text(initials); - $("#p2-chip-name").text(name); - $("#p2-chip-meta").text(meta); + $("#p3-chip-avatar").text(initials || "?"); + $("#p3-chip-name").text(name); + $("#p3-chip-meta").text(meta); $("#subscrpt-product-select-wrap").hide(); $("#subscrpt-selected-product-chip").show(); + + if (price && !$.trim($("#subscrpt_connect_price").val())) { + $("#subscrpt_connect_price").val(parseFloat(price).toFixed(2)); + } + + this.selectedProductName = name; + this.updatePreview(); }, - clearProductSelection: function () { + clearProduct: function () { $("#subscrpt-existing-product-hidden").val(""); $("#subscrpt-product-search-input").val(""); $(".wpsubs-p2-product-search__item").show(); - $(".wpsubs-p2-product-search__empty").hide(); - $("#subscrpt-product-search-dropdown").hide(); $("#subscrpt-selected-product-chip").hide(); $("#subscrpt-product-select-wrap").show(); - $("#subscrpt_product_name").val(""); - $("#subscrpt_product_price").val(""); - // Reset variation picker - $("#subscrpt-variation-picker-wrap").hide(); - $("#subscrpt-variation-picker-list").empty(); - $("#subscrpt-variation-id-hidden").val(""); - this.updatePreview(); - }, - - showRelevantProductSection: function () { - var activeBtn = $(".product-toggle-btn.active"); - var mode = activeBtn.length ? activeBtn.data("mode") : "new"; - - if (mode === "existing") { - $("#subscrpt-existing-product-fields").show(); - var selectedId = $("#subscrpt-existing-product-hidden").val(); - if (selectedId) { - var item = $(".wpsubs-p2-product-search__item[data-id='" + selectedId + "']"); - if (item.length) { - var name = item.data("name") || ""; - var price = item.data("price") != null ? String(item.data("price")) : ""; - var type = item.data("type") || ""; - var sku = item.data("sku") ? String(item.data("sku")) : ""; - this.showProductChip(name, price, type, sku); - } - } - } else { - $("#subscrpt-existing-product-fields").hide(); - } + this.selectedProductName = ""; }, - // ----- Live preview ----- + connect: function (e) { + e.preventDefault(); - initLivePreview: function () { var self = this; - $(document).on("input", "#subscrpt_product_name", function () { - self.updatePreview(); - }); - $(document).on("input", "#subscrpt_product_price", function () { - self.updatePreview(); - }); - $(document).on("wpsubs:select", "#subscrpt-billing-period-select", function () { - self.updatePreview(); - }); - }, - - updatePreview: function () { - var name = $("#subscrpt_product_name").val() || "Your product"; - var price = $("#subscrpt_product_price").val() || "0.00"; - var period = $("input[name='subscrpt_billing_period']").val() || "months"; - - $("#p2-preview-name").text(name); - $("#p2-preview-price").text(parseFloat(price).toFixed(2)); - $("#p2-preview-period").text(period); - }, - - // ----- Save & validate ----- + var $btn = $("#subscrpt-btn-connect"); + var price = $.trim($("#subscrpt_connect_price").val()); - savePage2: function (e) { - e.preventDefault(); - - if (!this.validatePage2()) { + if (price && (isNaN(parseFloat(price)) || parseFloat(price) < 0)) { + window.alert("Please enter a valid price."); return; } - var productMode = $(".product-toggle-btn.active").data("mode") || "new"; - var confirmMsg = - productMode === "new" - ? "Create this subscription product?" - : "Apply these subscription settings to the selected product?"; - - if (!confirm(confirmMsg)) { + if ($btn.hasClass("is-loading")) { return; } - var data = { - action: "subscrpt_save_wizard_page2", - nonce: $("#subscrpt_wizard_nonce").val(), - product_mode: $(".product-toggle-btn.active").data("mode") || "new", - product_name: $("#subscrpt_product_name").val(), - product_price: $("#subscrpt_product_price").val(), - existing_product_id: $("#subscrpt-existing-product-hidden").val(), - variation_id: $("#subscrpt-variation-id-hidden").val(), - timing_option: $("#subscrpt_timing_option").val(), - billing_per: $("#subscrpt_billing_per").val(), - billing_period: $("input[name='subscrpt_billing_period']").val(), - trial_timing_per: $("#subscrpt_trial_timing_per").val(), - signup_fee: $("#subscrpt_signup_fee").val(), - // hidden compat fields - trial_enabled: 0, - trial_timing_option: $("input[name='subscrpt_trial_timing_option']").val() || "days", - length_enabled: 0, - length_per: "", - length_option: "months", - }; - - var self = this; - - $.post(this.ajaxUrl, data, function (response) { - if (response.success) { - window.location.reload(); - } else { - alert(response.data.message || "Something went wrong. Please try again."); - } - }).fail(function () { - alert("Server error. Please try again."); - }); - }, - - validatePage2: function () { - var isValid = true; - var messages = []; - var productMode = $(".product-toggle-btn.active").data("mode") || "new"; - var productName = $("#subscrpt_product_name").val().trim(); - var price = $("#subscrpt_product_price").val().trim(); - var existing = $("#subscrpt-existing-product-hidden").val(); - var billing = $("input[name='subscrpt_billing_period']").val(); - - if (productMode === "new") { - if (!productName) { - messages.push("Product name is required."); - isValid = false; + if (this.currentConnectMode() === "existing") { + var productId = $("#subscrpt-existing-product-hidden").val(); + if (!productId) { + window.alert("Please select a product."); + return; } + $btn.addClass("is-loading").prop("disabled", true); + this.createRelation(productId, this.selectedProductName || "Product", price, $btn); } else { - if (!existing) { - messages.push("Please select an existing product."); - isValid = false; - } - if ( - existing && - $("#subscrpt-variation-picker-wrap").is(":visible") && - !$("#subscrpt-variation-id-hidden").val() - ) { - messages.push("Please select a variation."); - isValid = false; + var name = $.trim($("#subscrpt_new_product_name").val()); + if (!name) { + window.alert("Please enter a product name."); + return; } + $btn.addClass("is-loading").prop("disabled", true); + // Create the product first, then connect the plan to it. + $.post( + this.cfg.ajax_url, + { + action: "subscrpt_create_wizard_product", + nonce: $("#subscrpt_wizard_nonce").val(), + product_name: name, + product_price: price, + }, + function (response) { + if (response && response.success) { + self.createRelation(response.data.product_id, name, price, $btn); + } else { + $btn.removeClass("is-loading").prop("disabled", false); + window.alert((response && response.data && response.data.message) || "Could not create the product."); + } + }, + ).fail(function () { + $btn.removeClass("is-loading").prop("disabled", false); + window.alert("Server error. Please try again."); + }); } + }, - if (!price || isNaN(parseFloat(price)) || parseFloat(price) < 0) { - messages.push("Please enter a valid price."); - isValid = false; - } + createRelation: function (productId, productName, price, $btn) { + var self = this; + this.api("POST", "/relations", { + plan_id: this.termId, + oid: parseInt(productId, 10), + vid: 0, + type: 1, + status: "active", + exclude: false, + data: { regular_price: price, sale_price: "", discount_value: 0 }, + }) + .then(function () { + $btn.removeClass("is-loading").prop("disabled", false); + self.showDone(productName, price); + }) + .catch(function (err) { + $btn.removeClass("is-loading").prop("disabled", false); + window.alert(err.message || "Could not connect the plan. Please try again."); + }); + }, - if (!billing) { - messages.push("Please select a billing period."); - isValid = false; - } + // ----- Page 4: finish ----- - if (!isValid) { - alert(messages.join("\n")); + showDone: function (productName) { + // The finished flow is shown by the persistent preview graph. + if (productName) { + $("#subscrpt-preview-prod").text(productName); } - - return isValid; + this.switchSection(4); }, - // ----- Page 3 actions ----- - restart: function (e) { e.preventDefault(); var self = this; $.post( - this.ajaxUrl, + this.cfg.ajax_url, { action: "subscrpt_reset_wizard", nonce: $("#subscrpt_wizard_nonce").val(), }, function () { + self.groupId = 0; + self.termId = 0; + self.planTitle = ""; + self.billingText = ""; + $("#subscrpt_free_trial, #subscrpt_signup_fee, #subscrpt_connect_price, #subscrpt_new_product_name").val(""); + $("#subscrpt_billing_frequency").val("1"); + self.clearProduct(); + // Reset the preview product node back to its placeholder. + $("#subscrpt-preview-prod").text("Product"); self.switchSection(1); - $("#subscrpt-current-page").text("1"); - $("#subscrpt_product_name").val(""); - $("#subscrpt_product_price").val(""); - $("#subscrpt_timing_option").val("never"); - $("#subscrpt_billing_per").val("1"); - $("input[name='subscrpt_billing_period']").val("months"); - $("#subscrpt_trial_timing_per").val("0"); - $("#subscrpt_signup_fee").val(""); - // Reset chip - $("#subscrpt-selected-product-chip").hide(); - $("#subscrpt-product-select-wrap").show(); - $("#subscrpt_existing_product").val(""); }, ); }, diff --git a/includes/Admin/Links.php b/includes/Admin/Links.php index a0445293..9527492d 100644 --- a/includes/Admin/Links.php +++ b/includes/Admin/Links.php @@ -24,9 +24,8 @@ public function __construct() { * @param array $links Plugin Links. */ public function plugin_action_links( $links ) { - // Getting Started link hidden for now — re-enable later. - // $getting_started_url = admin_url( 'admin.php?page=wp-subscription-onboarding' ); - // array_unshift( $links, '' . __( 'Getting Started', 'subscription' ) . '' ); + $getting_started_url = admin_url( 'admin.php?page=wp-subscription-onboarding' ); + array_unshift( $links, '' . __( 'Getting Started', 'subscription' ) . '' ); if ( ! subscrpt_pro_activated() ) { $links[] = '' . __( 'Upgrade to premium', 'subscription' ) . ''; } diff --git a/includes/Admin/Menu.php b/includes/Admin/Menu.php index 4b9ad6fa..916752db 100644 --- a/includes/Admin/Menu.php +++ b/includes/Admin/Menu.php @@ -62,13 +62,27 @@ public function enqueue_admin_assets() { SUBSCRPT_VERSION, true ); + $subscrpt_wizard_has_products = (bool) wc_get_products( + array( + 'status' => array( 'publish', 'draft', 'pending', 'private' ), + 'limit' => 1, + 'return' => 'ids', + ) + ); + wp_localize_script( 'subscrpt-onboarding-wizard', 'subscrpt_wizard', array( 'ajax_url' => admin_url( 'admin-ajax.php' ), 'subscriptions_url' => admin_url( 'admin.php?page=wp-subscription' ), + 'products_url' => admin_url( 'edit.php?post_type=product' ), + 'plans_url' => admin_url( 'admin.php?page=wp-subscription-plans' ), + 'rest_url' => rest_url( 'wpsubscription/v1/plans' ), + 'rest_nonce' => wp_create_nonce( 'wp_rest' ), 'currency_symbol' => get_woocommerce_currency_symbol(), + 'is_pro' => subscrpt_pro_activated(), + 'has_products' => $subscrpt_wizard_has_products, ) ); @@ -826,15 +840,12 @@ public function render_support_page() { * Initial load always shows page 1 (JS handles transitions from there) */ public function render_onboarding_wizard() { - // Start session if not already started + // Start session if not already started (used by the wizard reset handler). if ( ! session_id() && ! headers_sent() ) { session_start(); } - // Always start at page 1 on direct load (SPA behavior — JS drives page transitions) - $GLOBALS['wizard_page'] = 1; - - $this->render_admin_header( __( 'Setup Wizard', 'subscription' ), __( 'Create your first subscription product', 'subscription' ) ); + $this->render_admin_header( __( 'Setup Wizard', 'subscription' ), __( 'Create your first subscription plan', 'subscription' ) ); include __DIR__ . '/views/onboarding-wizard.php'; $this->render_admin_footer(); } diff --git a/includes/Admin/OnboardingAjax.php b/includes/Admin/OnboardingAjax.php index 3377b595..b8013c29 100644 --- a/includes/Admin/OnboardingAjax.php +++ b/includes/Admin/OnboardingAjax.php @@ -5,7 +5,10 @@ /** * AJAX handlers for the onboarding wizard. * - * Handles page-2 save (product create/update), variation fetch, and wizard reset. + * The wizard creates a plan + duration and connects it to a product entirely + * through the Plans REST API (wpsubscription/v1/plans). The one thing REST does + * not offer is creating a WooCommerce product, so that single step lives here; + * everything else — group, term, relation — is done client-side against REST. * * @package SpringDevs\Subscription\Admin */ @@ -15,200 +18,54 @@ class OnboardingAjax { * Initialize the class. */ public function __construct() { - add_action( 'wp_ajax_subscrpt_save_wizard_page2', array( $this, 'save_wizard_page2' ) ); + add_action( 'wp_ajax_subscrpt_create_wizard_product', array( $this, 'create_wizard_product' ) ); add_action( 'wp_ajax_subscrpt_reset_wizard', array( $this, 'reset_wizard' ) ); - add_action( 'wp_ajax_subscrpt_get_product_variations', array( $this, 'get_product_variations' ) ); } /** - * Save wizard Page 2 data to session and create/update product. + * Create a bare simple product (name + price) for the wizard to connect a + * plan to. No subscription meta is written here — connecting the plan (the + * REST relation) is what makes the product subscribable. * * @return void Sends JSON. */ - public function save_wizard_page2() { + public function create_wizard_product() { check_ajax_referer( 'subscrpt_onboarding_wizard', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'subscription' ) ), 403 ); } - if ( ! session_id() ) { - session_start(); - } - - $product_mode = isset( $_POST['product_mode'] ) ? sanitize_text_field( wp_unslash( $_POST['product_mode'] ) ) : 'new'; - $product_name = isset( $_POST['product_name'] ) ? sanitize_text_field( wp_unslash( $_POST['product_name'] ) ) : ''; - $product_price = isset( $_POST['product_price'] ) ? sanitize_text_field( wp_unslash( $_POST['product_price'] ) ) : ''; - $existing_product = isset( $_POST['existing_product_id'] ) ? absint( $_POST['existing_product_id'] ) : 0; - $variation_id = isset( $_POST['variation_id'] ) ? absint( $_POST['variation_id'] ) : 0; - $timing_option = isset( $_POST['timing_option'] ) ? sanitize_text_field( wp_unslash( $_POST['timing_option'] ) ) : 'never'; - $billing_per = isset( $_POST['billing_per'] ) ? absint( $_POST['billing_per'] ) : 1; - $billing_period = isset( $_POST['billing_period'] ) ? sanitize_text_field( wp_unslash( $_POST['billing_period'] ) ) : 'months'; - $trial_timing_per = isset( $_POST['trial_timing_per'] ) ? absint( $_POST['trial_timing_per'] ) : 0; - $signup_fee = isset( $_POST['signup_fee'] ) ? sanitize_text_field( wp_unslash( $_POST['signup_fee'] ) ) : ''; - $trial_enabled = isset( $_POST['trial_enabled'] ) ? (int) $_POST['trial_enabled'] : 0; - $trial_timing_opt = isset( $_POST['trial_timing_option'] ) ? sanitize_text_field( wp_unslash( $_POST['trial_timing_option'] ) ) : 'days'; - $length_enabled = isset( $_POST['length_enabled'] ) ? (int) $_POST['length_enabled'] : 0; - $length_per = isset( $_POST['length_per'] ) ? absint( $_POST['length_per'] ) : 0; - $length_option = isset( $_POST['length_option'] ) ? sanitize_text_field( wp_unslash( $_POST['length_option'] ) ) : 'months'; - - $result_product_id = 0; - - if ( 'new' === $product_mode ) { - $wc_product = new \WC_Product_Simple(); - $wc_product->set_name( $product_name ); - $wc_product->set_regular_price( wc_format_decimal( $product_price ) ); - $wc_product->set_status( 'publish' ); - $this->apply_subscription_meta( $wc_product, $billing_period, $billing_per, $trial_timing_per, $trial_timing_opt, $signup_fee ); - $result_product_id = $wc_product->save(); - - if ( ! $result_product_id ) { - wp_send_json_error( array( 'message' => __( 'Failed to create product. Please try again.', 'subscription' ) ) ); - } - } elseif ( 'existing' === $product_mode && $existing_product > 0 ) { - $parent = wc_get_product( $existing_product ); - - if ( ! $parent ) { - wp_send_json_error( array( 'message' => __( 'Product not found.', 'subscription' ) ) ); - } - - if ( $variation_id > 0 ) { - // Variable product: update parent name only; apply meta + price on the variation. - $parent->set_name( $product_name ); - $parent->save(); - - $variation = wc_get_product( $variation_id ); - if ( $variation ) { - $variation->set_regular_price( wc_format_decimal( $product_price ) ); - $this->apply_subscription_meta( $variation, $billing_period, $billing_per, $trial_timing_per, $trial_timing_opt, $signup_fee ); - $variation->save(); - } - } else { - // Simple product: update name, price, and subscription meta in one save. - $parent->set_name( $product_name ); - $parent->set_regular_price( wc_format_decimal( $product_price ) ); - $this->apply_subscription_meta( $parent, $billing_period, $billing_per, $trial_timing_per, $trial_timing_opt, $signup_fee ); - $parent->save(); - } - - $result_product_id = $existing_product; - } - - $_SESSION['subscrpt_onboarding_wizard'] = array( - 'page' => 3, - 'product_id' => $result_product_id, - 'product_mode' => $product_mode, - 'product_name' => $product_name, - 'product_price' => $product_price, - 'existing_product' => $existing_product, - 'variation_id' => $variation_id, - 'timing_option' => $timing_option, - 'billing_per' => $billing_per, - 'billing_period' => $billing_period, - 'trial_timing_per' => $trial_timing_per, - 'signup_fee' => $signup_fee, - 'trial_enabled' => $trial_enabled, - 'trial_timing_option' => $trial_timing_opt, - 'length_enabled' => $length_enabled, - 'length_per' => $length_per, - 'length_option' => $length_option, - ); - - wp_send_json_success( array( 'product_id' => $result_product_id ) ); - } - - /** - * Stage subscription meta on a WC product object via the WC meta layer. - * Does NOT call save() — the caller must save the object after calling this method. - * - * @param \WC_Product $product Product or variation object. - * @param string $billing_period Billing period unit (days/weeks/months/years). - * @param int $billing_per Billing interval count. - * @param int $trial_per Free trial length. - * @param string $trial_option Free trial period unit. - * @param string $signup_fee One-time sign-up fee (pro only). - * - * @return void - */ - private function apply_subscription_meta( $product, $billing_period, $billing_per, $trial_per, $trial_option, $signup_fee ) { - $product->update_meta_data( '_subscrpt_enabled', true ); - $product->update_meta_data( '_subscrpt_timing_option', $billing_period ); - $product->update_meta_data( '_subscrpt_timing_per', $billing_per ); - $product->update_meta_data( '_subscrpt_trial_timing_per', $trial_per ); - $product->update_meta_data( '_subscrpt_trial_timing_option', $trial_option ); - $product->update_meta_data( '_subscrpt_payment_type', 'recurring' ); + $product_name = isset( $_POST['product_name'] ) ? sanitize_text_field( wp_unslash( $_POST['product_name'] ) ) : ''; + $product_price = isset( $_POST['product_price'] ) ? sanitize_text_field( wp_unslash( $_POST['product_price'] ) ) : ''; - if ( function_exists( 'subscrpt_pro_activated' ) && subscrpt_pro_activated() && '' !== $signup_fee ) { - $product->update_meta_data( '_subscrpt_signup_fee', wc_format_decimal( $signup_fee ) ); + if ( '' === trim( $product_name ) ) { + wp_send_json_error( array( 'message' => __( 'Product name is required.', 'subscription' ) ) ); } - } - - /** - * Return available variations for a variable product (wizard use). - * - * @return void Sends JSON. - */ - public function get_product_variations() { - check_ajax_referer( 'subscrpt_onboarding_wizard', 'nonce' ); - if ( ! current_user_can( 'manage_options' ) ) { - wp_send_json_error( array( 'message' => __( 'Permission denied.', 'subscription' ) ), 403 ); + $product = new \WC_Product_Simple(); + $product->set_name( $product_name ); + if ( '' !== trim( $product_price ) ) { + $product->set_regular_price( wc_format_decimal( $product_price ) ); } + $product->set_status( 'publish' ); + $product_id = $product->save(); - $product_id = isset( $_POST['product_id'] ) ? absint( $_POST['product_id'] ) : 0; if ( ! $product_id ) { - wp_send_json_error( array( 'message' => __( 'Invalid product.', 'subscription' ) ) ); - } - - $product = wc_get_product( $product_id ); - if ( ! $product || 'variable' !== $product->get_type() ) { - wp_send_json_error( array( 'message' => __( 'Not a variable product.', 'subscription' ) ) ); + wp_send_json_error( array( 'message' => __( 'Failed to create product. Please try again.', 'subscription' ) ) ); } - $available = $product->get_available_variations(); - $result = array(); - - foreach ( $available as $variation ) { - $v_id = $variation['variation_id']; - - // Build human-readable attribute label. - $attr_labels = array(); - foreach ( $variation['attributes'] as $attr_key => $attr_value ) { - if ( '' === $attr_value ) { - $attr_labels[] = __( 'Any', 'subscription' ); - continue; - } - $taxonomy = str_replace( 'attribute_', '', $attr_key ); - if ( taxonomy_exists( $taxonomy ) ) { - $term = get_term_by( 'slug', $attr_value, $taxonomy ); - $attr_labels[] = $term ? $term->name : ucfirst( str_replace( '-', ' ', $attr_value ) ); - } else { - $attr_labels[] = ucfirst( str_replace( '-', ' ', $attr_value ) ); - } - } - $label = implode( ' / ', $attr_labels ); - if ( ! $label ) { - /* translators: %d: variation ID */ - $label = sprintf( __( 'Variation #%d', 'subscription' ), $v_id ); - } - - $result[] = array( - 'id' => $v_id, - 'label' => $label, - 'price' => $variation['display_price'], - 'sku' => $variation['sku'], - 'billing_period' => get_post_meta( $v_id, '_subscrpt_timing_option', true ), - 'billing_per' => get_post_meta( $v_id, '_subscrpt_timing_per', true ) ?: 1, - 'trial_per' => get_post_meta( $v_id, '_subscrpt_trial_timing_per', true ), - 'signup_fee' => get_post_meta( $v_id, '_subscrpt_signup_fee', true ), - ); - } - - wp_send_json_success( $result ); + wp_send_json_success( + array( + 'product_id' => $product_id, + 'product_name' => $product_name, + 'product_price' => $product_price, + ) + ); } /** - * Reset wizard session (for "Add another" action). + * Reset wizard session (for "Add another" / "Start over" actions). * * @return void Sends JSON. */ diff --git a/includes/Admin/views/onboarding-wizard.php b/includes/Admin/views/onboarding-wizard.php index 039a04fe..dc332c3b 100644 --- a/includes/Admin/views/onboarding-wizard.php +++ b/includes/Admin/views/onboarding-wizard.php @@ -1,7 +1,13 @@ Set Frequency (duration) -> + * Connect to a Product -> Finish. A persistent right-rail preview shows the + * plan → durations → products flow and fills in live as each step is filled. + * The plan (group), duration (term) and product relation are created through + * the Plans REST API; only product creation uses admin-ajax. * * @package SpringDevs\Subscription\Admin */ @@ -10,561 +16,500 @@ exit; } -// Get wizard data from session -$session_data = isset( $_SESSION['subscrpt_onboarding_wizard'] ) ? $_SESSION['subscrpt_onboarding_wizard'] : array(); -$wizard_page = isset( $session_data['page'] ) ? (int) $session_data['page'] : 1; -$product_id = isset( $session_data['product_id'] ) ? (int) $session_data['product_id'] : 0; -$subscriptions_url = admin_url( 'admin.php?page=wp-subscription' ); -$is_pro = subscrpt_pro_activated(); - -// Get existing products for dropdown -$args = array( - 'post_type' => 'product', - 'posts_per_page' => -1, - 'post_status' => 'any', - 'orderby' => 'title', - 'order' => 'ASC', +$is_pro = subscrpt_pro_activated(); +$currency_symbol = get_woocommerce_currency_symbol(); + +// Whether the store has any product at all — decides the connect step's shape. +$has_products = (bool) wc_get_products( + array( + 'status' => array( 'publish', 'draft', 'pending', 'private' ), + 'limit' => 1, + 'return' => 'ids', + ) ); -$products = get_posts( $args ); -// Load product if exists -$product = null; -if ( $product_id > 0 ) { - $product = wc_get_product( $product_id ); -} +// Plan types. Only "recurring" is available on the free plugin; the others are +// shown Pro-locked so the choice is visible but not selectable without Pro. +$plan_types = array( + array( + 'key' => 'recurring', + 'label' => __( 'Recurring', 'subscription' ), + 'desc' => __( 'Charge on a repeating schedule.', 'subscription' ), + 'name' => __( 'Recurring Subscription', 'subscription' ), + 'pro' => false, + 'icon' => '', + ), + array( + 'key' => 'subscribe_save', + 'label' => __( 'Subscribe & Save', 'subscription' ), + 'desc' => __( 'Recurring with a discount.', 'subscription' ), + 'name' => __( 'Subscribe & Save', 'subscription' ), + 'pro' => true, + 'icon' => '', + ), + array( + 'key' => 'installments', + 'label' => __( 'Installments', 'subscription' ), + 'desc' => __( 'Split a price into payments.', 'subscription' ), + 'name' => __( 'Installment Plan', 'subscription' ), + 'pro' => true, + 'icon' => '', + ), +); + +$interval_options = array( + array( + 'value' => 'day', + 'label' => __( 'Day', 'subscription' ), + ), + array( + 'value' => 'week', + 'label' => __( 'Week', 'subscription' ), + ), + array( + 'value' => 'month', + 'label' => __( 'Month', 'subscription' ), + ), + array( + 'value' => 'year', + 'label' => __( 'Year', 'subscription' ), + ), +); + +$avatar_palette = array( + array( + 'bg' => '#fde8d8', + 'fg' => '#b85c20', + ), + array( + 'bg' => '#dbeafe', + 'fg' => '#1d4ed8', + ), + array( + 'bg' => '#ede9fe', + 'fg' => '#6d28d9', + ), + array( + 'bg' => '#d1fae5', + 'fg' => '#065f46', + ), + array( + 'bg' => '#fce7f3', + 'fg' => '#9d174d', + ), +); + +// Small SVG icons reused inside the preview graph nodes. +$subscrpt_icon_cal = ''; +$subscrpt_icon_box = ''; +$subscrpt_icon_plan = ''; ?>
- -