From 033c6a0f26a1a086e4b6c1de4f0caad476897a01 Mon Sep 17 00:00:00 2001 From: Rafael Batista Date: Thu, 16 Apr 2026 12:30:27 -0300 Subject: [PATCH] tour guiado --- app/assets/config/manifest.js | 1 + .../app/onboarding_progress_controller.rb | 8 +- .../controllers/onboarding_tour_controller.js | 138 ++++++++++++++++++ .../onboarding_welcome_controller.js | 4 +- app/models/user_onboarding_progress.rb | 5 + .../app/dashboard/_onboarding_tour.html.erb | 40 +++++ app/views/layouts/application.html.erb | 4 + app/views/layouts/partials/_scripts.html.erb | 3 +- app/views/layouts/partials/_sidebar.html.erb | 10 +- 9 files changed, 204 insertions(+), 9 deletions(-) create mode 100644 app/javascript/controllers/onboarding_tour_controller.js create mode 100644 app/views/app/dashboard/_onboarding_tour.html.erb diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js index 440fdeb7..6434b38f 100644 --- a/app/assets/config/manifest.js +++ b/app/assets/config/manifest.js @@ -33,3 +33,4 @@ //= link cliente/budget_approvals.js //= link controllers/onboarding_welcome_controller.js //= link controllers/onboarding_checklist_controller.js +//= link controllers/onboarding_tour_controller.js diff --git a/app/controllers/app/onboarding_progress_controller.rb b/app/controllers/app/onboarding_progress_controller.rb index ce817a15..a7024687 100644 --- a/app/controllers/app/onboarding_progress_controller.rb +++ b/app/controllers/app/onboarding_progress_controller.rb @@ -1,7 +1,7 @@ class App::OnboardingProgressController < ApplicationController skip_authorization_check - ALLOWED_OPERATIONS = %w[complete_step dismiss resume finish].freeze + ALLOWED_OPERATIONS = %w[complete_step dismiss resume finish set_last_seen].freeze def show render json: response_payload @@ -23,6 +23,10 @@ def update onboarding_progress.resume! when "finish" onboarding_progress.finish! + when "set_last_seen" + return render_missing_step_key if params[:step_key].blank? + + onboarding_progress.set_last_seen_step!(params[:step_key]) end render json: response_payload @@ -64,6 +68,6 @@ def render_invalid_operation end def render_missing_step_key - render json: { success: false, error: "step_key is required for operation complete_step" }, status: :unprocessable_entity + render json: { success: false, error: "step_key is required for this operation" }, status: :unprocessable_entity end end diff --git a/app/javascript/controllers/onboarding_tour_controller.js b/app/javascript/controllers/onboarding_tour_controller.js new file mode 100644 index 00000000..504e89f8 --- /dev/null +++ b/app/javascript/controllers/onboarding_tour_controller.js @@ -0,0 +1,138 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { + endpoint: String, + steps: Array, + autoStart: Boolean + } + + connect() { + this.intro = null + + if (!this.autoStartValue) return + + this.startTour().catch((error) => { + console.warn("[OnboardingTour] failed to start", error) + }) + } + + disconnect() { + if (!this.intro) return + + this.intro.exit() + this.intro = null + } + + async startTour() { + if (typeof window.introJs === "undefined") return + + const progress = await this.fetchProgress() + const builtSteps = this.buildSteps() + if (builtSteps.length === 0) return + + this.intro = window.introJs() + this.intro.setOptions({ + steps: builtSteps, + showProgress: true, + showBullets: false, + exitOnOverlayClick: false, + nextLabel: "Próximo", + prevLabel: "Voltar", + doneLabel: "Concluir", + skipLabel: "Pular" + }) + + this.intro.onchange((targetElement) => { + const stepKey = targetElement?.dataset?.onboardingTourStepKey + if (!stepKey) return + + this.persistLastSeen(stepKey) + }) + + this.intro.onexit(() => this.clearAutoStartParam()) + this.intro.oncomplete(() => this.clearAutoStartParam()) + + const resumeIndex = this.findResumeIndex(progress?.last_seen_step, builtSteps) + this.intro.start() + + if (resumeIndex > 0) { + this.intro.goToStep(resumeIndex + 1) + } + } + + async fetchProgress() { + if (!this.hasEndpointValue || !this.endpointValue) return null + + try { + const response = await fetch(this.endpointValue, { + method: "GET", + headers: { "Accept": "application/json" }, + credentials: "same-origin" + }) + + if (!response.ok) return null + + const payload = await response.json() + return payload.onboarding_progress || null + } catch (_error) { + return null + } + } + + buildSteps() { + const configured = Array.isArray(this.stepsValue) ? this.stepsValue : [] + + return configured + .map((step) => { + const element = document.querySelector(step.selector) + if (!element) return null + + element.dataset.onboardingTourStepKey = step.key + + return { + element, + title: step.title, + intro: step.description, + tooltipClass: "introjs-tooltip-onboarding" + } + }) + .filter(Boolean) + } + + findResumeIndex(lastSeenStep, steps) { + if (!lastSeenStep) return 0 + + const idx = steps.findIndex((step) => step.element?.dataset?.onboardingTourStepKey === lastSeenStep) + if (idx < 0) return 0 + + return Math.min(idx + 1, Math.max(steps.length - 1, 0)) + } + + async persistLastSeen(stepKey) { + if (!this.hasEndpointValue || !this.endpointValue || !stepKey) return + + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") + + try { + await fetch(this.endpointValue, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + "X-CSRF-Token": csrfToken || "" + }, + credentials: "same-origin", + body: JSON.stringify({ operation: "set_last_seen", step_key: stepKey }) + }) + } catch (_error) { + // noop + } + } + + clearAutoStartParam() { + const url = new URL(window.location.href) + url.searchParams.delete("onboarding_tour") + window.history.replaceState({}, "", url.toString()) + } +} diff --git a/app/javascript/controllers/onboarding_welcome_controller.js b/app/javascript/controllers/onboarding_welcome_controller.js index 3658e4db..45f4cce7 100644 --- a/app/javascript/controllers/onboarding_welcome_controller.js +++ b/app/javascript/controllers/onboarding_welcome_controller.js @@ -47,7 +47,9 @@ export default class extends Controller { this.dispatch("started") if (this.hasStartPathValue && this.startPathValue) { - window.location.href = this.startPathValue + const url = new URL(this.startPathValue, window.location.origin) + url.searchParams.set("onboarding_tour", "1") + window.location.href = url.toString() return } diff --git a/app/models/user_onboarding_progress.rb b/app/models/user_onboarding_progress.rb index 29bd0362..3597cc58 100644 --- a/app/models/user_onboarding_progress.rb +++ b/app/models/user_onboarding_progress.rb @@ -57,6 +57,11 @@ def finish! update!(finished_at: Time.current) end + def set_last_seen_step!(step_key) + key = normalize_step_key(step_key) + update!(last_seen_step: key) + end + def finished_all_steps? (STEP_KEYS - completed_step_keys).empty? end diff --git a/app/views/app/dashboard/_onboarding_tour.html.erb b/app/views/app/dashboard/_onboarding_tour.html.erb new file mode 100644 index 00000000..9718e15b --- /dev/null +++ b/app/views/app/dashboard/_onboarding_tour.html.erb @@ -0,0 +1,40 @@ +<% + tour_steps = [ + { + key: "created_technician", + selector: "#onboarding-tour-technicians-menu", + title: "Cadastre os técnicos", + description: "Aqui você cadastra os técnicos que vão receber as ordens de serviço." + }, + { + key: "created_customer", + selector: "#onboarding-tour-clients-menu", + title: "Cadastre os clientes", + description: "Nesta área você registra os clientes e mantém o histórico centralizado." + }, + { + key: "created_first_work_order", + selector: "#onboarding-tour-order-services-menu", + title: "Crie a primeira OS", + description: "Use Ordens de Serviço para abrir, acompanhar e concluir atendimentos." + }, + { + key: "moved_work_order_status", + selector: "#onboarding-tour-order-services-menu", + title: "Atualize os status", + description: "Depois de criar a OS, atualize os status para manter a operação rastreável." + }, + { + key: "viewed_reports", + selector: "#onboarding-tour-reports-menu", + title: "Acompanhe os relatórios", + description: "Aqui você visualiza indicadores de volume, produtividade e desempenho." + } + ] +%> +
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index eb2da1bf..a902eeb3 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -27,6 +27,7 @@ + <%= stylesheet_link_tag "https://cdn.jsdelivr.net/npm/intro.js/minified/introjs.min.css" %> @@ -46,6 +47,9 @@
<%= render 'layouts/partials/sidebar' %> <%= render 'layouts/partials/navbar' %> + <% if request.subdomain == "app" && current_user.present? && !current_user.tecnico? %> + <%= render "app/dashboard/onboarding_tour" %> + <% end %> <%= yield %>
diff --git a/app/views/layouts/partials/_scripts.html.erb b/app/views/layouts/partials/_scripts.html.erb index 159375e0..bb7901b1 100644 --- a/app/views/layouts/partials/_scripts.html.erb +++ b/app/views/layouts/partials/_scripts.html.erb @@ -11,6 +11,7 @@ <%= javascript_include_tag "plugins/vectormap/jquery-jvectormap-2.0.2.min.js" %> <%= javascript_include_tag "plugins/vectormap/jquery-jvectormap-world-mill-en.js" %> <%= javascript_include_tag "plugins/chartjs/js/chart.js" %> +<%= javascript_include_tag "https://cdn.jsdelivr.net/npm/intro.js/minified/intro.min.js" %> <%= javascript_include_tag "js/index.js" %> -<%= javascript_include_tag "js/app.js" %> \ No newline at end of file +<%= javascript_include_tag "js/app.js" %> diff --git a/app/views/layouts/partials/_sidebar.html.erb b/app/views/layouts/partials/_sidebar.html.erb index 07f47712..640da977 100644 --- a/app/views/layouts/partials/_sidebar.html.erb +++ b/app/views/layouts/partials/_sidebar.html.erb @@ -6,7 +6,7 @@