product_tours 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +41 -0
  3. data/MIT-LICENSE +19 -0
  4. data/README.md +209 -0
  5. data/Rakefile +11 -0
  6. data/app/controllers/product_tours/application_controller.rb +68 -0
  7. data/app/controllers/product_tours/media_controller.rb +14 -0
  8. data/app/controllers/product_tours/posts_controller.rb +230 -0
  9. data/app/controllers/product_tours/tours_controller.rb +69 -0
  10. data/app/controllers/product_tours/widgets_controller.rb +28 -0
  11. data/app/helpers/product_tours/posts_helper.rb +19 -0
  12. data/app/helpers/product_tours/widget_helper.rb +14 -0
  13. data/app/models/product_tours/application_record.rb +7 -0
  14. data/app/models/product_tours/post.rb +128 -0
  15. data/app/views/layouts/product_tours/application.html.erb +20 -0
  16. data/app/views/product_tours/posts/_form.html.erb +177 -0
  17. data/app/views/product_tours/posts/_post_panel.html.erb +111 -0
  18. data/app/views/product_tours/posts/edit.html.erb +5 -0
  19. data/app/views/product_tours/posts/index.html.erb +92 -0
  20. data/app/views/product_tours/posts/new.html.erb +5 -0
  21. data/app/views/product_tours/posts/show.html.erb +5 -0
  22. data/config/locales/product_tours.ar.yml +11 -0
  23. data/config/locales/product_tours.bg.yml +11 -0
  24. data/config/locales/product_tours.bn.yml +11 -0
  25. data/config/locales/product_tours.de.yml +11 -0
  26. data/config/locales/product_tours.el.yml +11 -0
  27. data/config/locales/product_tours.en.yml +11 -0
  28. data/config/locales/product_tours.es.yml +11 -0
  29. data/config/locales/product_tours.fr.yml +11 -0
  30. data/config/locales/product_tours.hi.yml +11 -0
  31. data/config/locales/product_tours.hr.yml +11 -0
  32. data/config/locales/product_tours.id.yml +11 -0
  33. data/config/locales/product_tours.it.yml +11 -0
  34. data/config/locales/product_tours.ja.yml +11 -0
  35. data/config/locales/product_tours.ko.yml +11 -0
  36. data/config/locales/product_tours.lb.yml +11 -0
  37. data/config/locales/product_tours.nl.yml +11 -0
  38. data/config/locales/product_tours.pl.yml +11 -0
  39. data/config/locales/product_tours.pt.yml +11 -0
  40. data/config/locales/product_tours.ro.yml +11 -0
  41. data/config/locales/product_tours.ru.yml +11 -0
  42. data/config/locales/product_tours.th.yml +11 -0
  43. data/config/locales/product_tours.tr.yml +11 -0
  44. data/config/locales/product_tours.uk.yml +11 -0
  45. data/config/locales/product_tours.ur.yml +11 -0
  46. data/config/locales/product_tours.vi.yml +11 -0
  47. data/config/locales/product_tours.zh-CN.yml +11 -0
  48. data/config/routes.rb +23 -0
  49. data/lib/generators/product_tours/install/install_generator.rb +44 -0
  50. data/lib/generators/product_tours/install/templates/create_product_tours_posts.rb.tt +29 -0
  51. data/lib/generators/product_tours/install/templates/initializer.rb +21 -0
  52. data/lib/product_tours/configuration.rb +18 -0
  53. data/lib/product_tours/content_security_policy.rb +24 -0
  54. data/lib/product_tours/dashboard.css +196 -0
  55. data/lib/product_tours/dashboard.js +229 -0
  56. data/lib/product_tours/engine.rb +28 -0
  57. data/lib/product_tours/errors.rb +14 -0
  58. data/lib/product_tours/seeds.rb +147 -0
  59. data/lib/product_tours/version.rb +5 -0
  60. data/lib/product_tours/video_metadata.rb +60 -0
  61. data/lib/product_tours/video_resolver.rb +129 -0
  62. data/lib/product_tours/widget.js +359 -0
  63. data/lib/product_tours/widget.rb +59 -0
  64. data/lib/product_tours.rb +35 -0
  65. data/lib/tasks/product_tours_tasks.rake +12 -0
  66. metadata +131 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 82d70df0c0b3463d2b4e49de5d2d8736c1ef50ab732a86bca87d5fcf2ebe0b51
4
+ data.tar.gz: 2e010ecadc7381f7176f6525cddc4ed93719dc07528fd4fb17d8c63852abcc24
5
+ SHA512:
6
+ metadata.gz: f89e331dd2c34ffb12821a5f9159abc39c8f21bd78c6577048cdccf9895bed7eab76048a165234c8dd612f9c22a959440fc5d4d644eaa36807c3c6173a12e8d0
7
+ data.tar.gz: 3a34a6f113643ba9076c69805e586ca073187124de304f51ef6ebf353b9baba42c3f8c7c0f4a59e8118e9044185adc36bc79b93f4f01334a222336b7eca2c537
data/CHANGELOG.md ADDED
@@ -0,0 +1,41 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0] - 2026-08-03
6
+
7
+ - Initial Rails engine, post dashboard, modal widget, video providers,
8
+ lifecycle notifications, installer, and demo seed task.
9
+ - Linked Post actions with same-modal navigation, an in-memory Back stack, and
10
+ self-explanatory action choices in the dashboard.
11
+ - Development installs now seed provider and multi-step demos automatically;
12
+ the installer and seed task print a copy-ready ERB trigger block.
13
+ - Clarified the permanent host/gem boundary: host apps own trigger timing,
14
+ Help menus, analytics persistence, reporting, and vendor integrations.
15
+ - Added immediate oEmbed-backed form previews, first-frame direct-video
16
+ previews, automatic CSP `frame-src` merging, language controls, key
17
+ normalization, and a simpler form layout.
18
+ - Aligned the public modal shell and mobile behavior with the shared visual
19
+ patterns used by the `testimonials` and `ideasbugs` gems.
20
+ - Added explicit Publish and Move to drafts dashboard actions, and linked the
21
+ seeded walkthrough through every supported video provider.
22
+ - Aligned the admin dashboard with the shared sibling-gem page, card, queue,
23
+ detail, action, and responsive patterns; post metadata now lives in Details.
24
+ - Added copy-ready launchers for a seeded draft post and an intentionally
25
+ missing key so developers can exercise both unresolved-trigger paths.
26
+ - Reduced configuration to host gating, layout, mount path, and optional storage;
27
+ locale resolution now follows current then default locale, production failures
28
+ report through `Rails.error`, and lifecycle payloads contain no gem-owned user,
29
+ tenant, or visitor identity.
30
+ - Renamed admin-facing records to tutorials, put Published before Draft, made
31
+ identity fields full-width, and replaced simultaneous URL/upload inputs with a
32
+ clear video-source choice.
33
+ - Simplified lifecycle notifications to tutorial, page, and source context;
34
+ removed navigation/progress metadata and inconsistent video-ended completion.
35
+ - Made the default locale the canonical admin tutorial list; new product tours now
36
+ start in that locale and translations are added and opened from each tutorial.
37
+ - Removed generic tutorial duplication now that translations provide the only
38
+ intentional content-copying workflow.
39
+
40
+ [Unreleased]: https://github.com/yshmarov/product_tours/compare/v0.1.0...HEAD
41
+ [0.1.0]: https://github.com/yshmarov/product_tours/releases/tag/v0.1.0
data/MIT-LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026 Yaroslav Shmarov
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,209 @@
1
+ # product_tours
2
+
3
+ [![CI](https://github.com/yshmarov/product_tours/actions/workflows/ci.yml/badge.svg)](https://github.com/yshmarov/product_tours/actions/workflows/ci.yml)
4
+ [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](MIT-LICENSE)
5
+
6
+ `product_tours` is a self-hosted Rails engine for in-app product tutorials and
7
+ lightweight multi-step walkthroughs. Your app owns the trigger UI; the gem owns
8
+ the tutorial editor, translations, safe video embeds, and modal navigation.
9
+
10
+ Requires Ruby 3.2+ and Rails 7.1+.
11
+
12
+ ## Install
13
+
14
+ ```ruby
15
+ gem "product_tours"
16
+ ```
17
+
18
+ ```bash
19
+ bin/rails generate product_tours:install
20
+ bin/rails db:migrate
21
+ ```
22
+
23
+ The migration automatically creates ready-to-use demo tutorials in development.
24
+ Production databases are never populated with demo content.
25
+
26
+ Add the widget before `</body>` in your application layout:
27
+
28
+ ```erb
29
+ <%= product_tours_tag %>
30
+ ```
31
+
32
+ Use any host-owned button, link, or icon as the trigger:
33
+
34
+ ```erb
35
+ <button class="btn btn-primary" data-product-tour="billing_setup">
36
+ Watch setup guide
37
+ </button>
38
+ ```
39
+
40
+ Create and publish `billing_setup` in the dashboard at `/product_tours`. Keys
41
+ are unique per locale. Draft, missing, invalid, or disabled keys open nothing
42
+ and raise in development/test; production reports them through `Rails.error`
43
+ and `product_tours.unresolved_trigger` instrumentation.
44
+
45
+ ### Host-controlled invocation
46
+
47
+ The host app decides where and when a guide opens. It can use a visible button,
48
+ compose several buttons into its own Help menu, or activate a hidden trigger
49
+ after page load. For example, Rails flash state and a host Stimulus controller
50
+ can open a guide after a redirect:
51
+
52
+ ```erb
53
+ <% if flash[:product_tour].present? %>
54
+ <button hidden
55
+ data-controller="product-tour-autoplay"
56
+ data-product-tour="<%= flash[:product_tour] %>"></button>
57
+ <% end %>
58
+ ```
59
+
60
+ ```js
61
+ // product_tour_autoplay_controller.js in the host app
62
+ connect() {
63
+ this.element.click()
64
+ }
65
+ ```
66
+
67
+ The gem deliberately has no page-rule engine, automatic display scheduler,
68
+ resource center, or guides launcher. Those are host navigation and presentation
69
+ choices built from the same `data-product-tour` attribute.
70
+
71
+ ## Tutorials
72
+
73
+ A tutorial is stored internally as a `ProductTours::Post`. It has a title, key,
74
+ locale, `draft`/`published` status,
75
+ optional video, optional rich description, and one primary action. Video URLs
76
+ support YouTube, Vimeo, Loom, Tella, Voomly, and direct MP4/WebM files.
77
+
78
+ The dashboard previews a pasted video URL immediately. YouTube, Vimeo, and Loom
79
+ metadata is fetched through oEmbed; the other supported providers still get a
80
+ safe resolved preview. Direct videos seek to an early frame so they do not look
81
+ like an empty player before playback.
82
+
83
+ The admin sidebar treats `I18n.default_locale` as the canonical tutorial list.
84
+ The New product tour button always creates that default-language record. Open a
85
+ tutorial to see its existing languages or add another available locale; each
86
+ translation remains an ordinary draft/published `Post` with the same key.
87
+
88
+ When the host has a Rails Content Security Policy, the engine preserves its
89
+ existing `frame-src` entries and automatically adds the supported embed origins.
90
+ For a direct video hosted on a custom origin, the host remains responsible for
91
+ allowing that origin in `media-src` (or can allow HTTPS media generally).
92
+
93
+ ### Link tutorials into a walkthrough
94
+
95
+ In the tutorial editor, choose what the primary button does: finish and close,
96
+ open a page, or continue to another tutorial. Continuing opens the selected
97
+ tutorial in the
98
+ same modal and automatically gives visitors a Back button.
99
+
100
+ Tutorials remain independently invokable—there is no separate course, step, or
101
+ sequence model. The Back history exists only for the open modal, so a tutorial opened
102
+ directly never shows a misleading Back button.
103
+
104
+ Video uploads require Active Storage:
105
+
106
+ ```bash
107
+ bin/rails active_storage:install
108
+ bin/rails db:migrate
109
+ ```
110
+
111
+ Rich descriptions require Action Text:
112
+
113
+ ```bash
114
+ bin/rails action_text:install
115
+ bin/rails db:migrate
116
+ ```
117
+
118
+ The post form hides either feature when its Rails framework is not installed.
119
+
120
+ ## Demo tutorials
121
+
122
+ The installer seeds the app's default locale in development. Copy the block it
123
+ prints into any ERB view to open every demo entry point immediately:
124
+
125
+ ```erb
126
+ <div class="product-tours-demo">
127
+ <button type="button" data-product-tour="demo_walkthrough_start">Try the multi-step walkthrough</button>
128
+ <button type="button" data-product-tour="demo_youtube">Open the YouTube tutorial</button>
129
+ <button type="button" data-product-tour="demo_vimeo">Open the Vimeo tutorial</button>
130
+ <button type="button" data-product-tour="demo_loom">Open the Loom tutorial</button>
131
+ <button type="button" data-product-tour="demo_tella">Open the Tella tutorial</button>
132
+ <button type="button" data-product-tour="demo_voomly">Open the Voomly tutorial</button>
133
+ <button type="button" data-product-tour="demo_direct_video">Open the direct video tutorial</button>
134
+ <button type="button" data-product-tour="demo_getting_started">Open the getting started guide</button>
135
+ <button type="button" data-product-tour="demo_draft">Try an unpublished tutorial</button>
136
+ <button type="button" data-product-tour="demo_missing_post">Try a missing tutorial</button>
137
+ </div>
138
+ <%= product_tours_tag %>
139
+ ```
140
+
141
+ `demo_walkthrough_start` introduces the collection, then continues through the
142
+ YouTube, Vimeo, Loom, Tella, Voomly, and direct-video tutorials before its final
143
+ step. Every transition demonstrates the automatic Back button. The individual
144
+ provider buttons remain available so each video can also be opened directly.
145
+ The final two buttons deliberately exercise unresolved triggers: `demo_draft`
146
+ exists but remains unpublished, while `demo_missing_post` is never seeded.
147
+
148
+ Refresh the full idempotent demo set in English, French, and Bulgarian at any
149
+ time:
150
+
151
+ ```bash
152
+ bin/rails product_tours:seed_demo
153
+ ```
154
+
155
+ Along with the provider examples, it creates `demo_walkthrough_start`,
156
+ `demo_walkthrough_features`, `demo_walkthrough_finish`, and the unpublished
157
+ `demo_draft` in the default demo locales (`en`, `fr`, and `bg`). The missing-key
158
+ button intentionally has no matching record. Running the task again refreshes
159
+ the seeded records instead of duplicating them and prints the copy-ready block
160
+ again. To seed only one locale from application code, call
161
+ `ProductTours::Seeds.load!(locale: :fr)`.
162
+
163
+ ## Configuration
164
+
165
+ The installer creates `config/initializers/product_tours.rb`. Important hooks:
166
+
167
+ ```ruby
168
+ ProductTours.configure do |config|
169
+ config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
170
+ end
171
+ ```
172
+
173
+ Tutorial lookup automatically uses the page's current `I18n.locale`, then falls
174
+ back to `I18n.default_locale` when that key has no current-locale record. An
175
+ existing draft translation is not bypassed by the fallback.
176
+
177
+ ## Lifecycle Notifications
178
+
179
+ The widget emits `product_tours.viewed`, `product_tours.dismissed`, and
180
+ `product_tours.completed` through `ActiveSupport::Notifications`. The gem does
181
+ not persist analytics and does not depend on Ahoy. `completed` means the visitor
182
+ used the tutorial's primary action; video playback does not imply completion.
183
+
184
+ ```ruby
185
+ ActiveSupport::Notifications.subscribe(/^product_tours\./) do |name, _start, _finish, _id, payload|
186
+ Ahoy.track(name.delete_prefix("product_tours."), payload.slice(:key, :locale, :source))
187
+ end
188
+ ```
189
+
190
+ The host may store these events with Ahoy and attach its own user or account
191
+ context in the subscriber. Persistence, dashboards, identity, and vendor-specific
192
+ integrations remain outside this gem.
193
+
194
+ ## Scope
195
+
196
+ `product_tours` owns tutorial persistence and editing, safe media rendering, the
197
+ modal experience, linked-tutorial navigation, and lifecycle notifications. The host
198
+ app owns trigger timing and placement, Help/resource menus, analytics storage,
199
+ reporting, and downstream integrations.
200
+
201
+ ## Development
202
+
203
+ ```bash
204
+ bundle install
205
+ bundle exec rake test
206
+ bundle exec rubocop
207
+ ```
208
+
209
+ Rails 7.1+ and Ruby 3.2+ are supported.
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rake/testtask'
5
+
6
+ Rake::TestTask.new(:test) do |task|
7
+ task.libs << 'test'
8
+ task.pattern = 'test/**/*_test.rb'
9
+ end
10
+
11
+ task default: :test
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module ProductTours
6
+ class ApplicationController < ActionController::Base
7
+ protect_from_forgery with: :exception
8
+
9
+ private
10
+
11
+ def product_tours_admin_layout
12
+ ProductTours.config.admin_layout
13
+ end
14
+
15
+ def require_admin
16
+ return if ProductTours.admin?(request)
17
+
18
+ render plain: 'Forbidden. Set ProductTours.config.authorize_admin to grant access.', status: :forbidden
19
+ end
20
+
21
+ def current_product_tours_locale
22
+ requested_locale = params[:locale].to_s
23
+ return requested_locale if I18n.available_locales.map(&:to_s).include?(requested_locale)
24
+
25
+ ProductTours.locale(request)
26
+ end
27
+
28
+ def find_post_by_locale(scope, key)
29
+ locales = [current_product_tours_locale, I18n.default_locale.to_s].uniq
30
+ posts = scope.where(locale: locales, key: key).index_by(&:locale)
31
+ locales.filter_map { |locale| posts[locale] }.first
32
+ end
33
+
34
+ def clean_page_url(value)
35
+ uri = URI.parse(value.to_s)
36
+ return unless uri.is_a?(URI::HTTP) && uri.host.present?
37
+
38
+ uri.query = nil
39
+ uri.fragment = nil
40
+ uri.to_s
41
+ rescue URI::InvalidURIError
42
+ nil
43
+ end
44
+
45
+ def unresolved_payload(key, reason)
46
+ {
47
+ key: key.to_s,
48
+ locale: current_product_tours_locale,
49
+ reason: reason.to_s,
50
+ page_url: clean_page_url(params[:page_url])
51
+ }
52
+ end
53
+
54
+ def handle_unresolved_trigger(key, reason)
55
+ payload = unresolved_payload(key, reason)
56
+ error = ProductTours::UnresolvedTriggerError.new(payload)
57
+ raise error if Rails.env.development? || Rails.env.test?
58
+
59
+ if defined?(Rails.error) && Rails.error.respond_to?(:report)
60
+ Rails.error.report(error, handled: true, context: payload)
61
+ else
62
+ Rails.logger.error("product_tours: #{error.message} #{payload.inspect}")
63
+ end
64
+ ActiveSupport::Notifications.instrument('product_tours.unresolved_trigger', payload)
65
+ render json: { error: 'unresolved_trigger', reason: reason.to_s }, status: :not_found
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTours
4
+ class MediaController < ApplicationController
5
+ def show
6
+ post = Post.find(params[:id])
7
+ allowed = ProductTours.admin?(request) || (ProductTours.enabled?(request) && post.published?)
8
+ return head :forbidden unless allowed
9
+ return head :not_found unless post.uploaded_video?
10
+
11
+ redirect_to main_app.rails_blob_path(post.video, disposition: 'inline')
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTours
4
+ class PostsController < ApplicationController
5
+ PER_PAGE = 50
6
+
7
+ layout :product_tours_admin_layout
8
+ before_action :require_admin
9
+ before_action :set_post,
10
+ only: %i[show edit update destroy refresh_video_metadata add_translation publish unpublish]
11
+ before_action :load_linkable_posts, only: %i[new create edit update]
12
+ before_action :load_translations, only: :show
13
+
14
+ def index
15
+ @status = Post::STATUSES.include?(params[:status]) ? params[:status] : 'published'
16
+ @query = params[:q].to_s.strip.presence
17
+ @counts = Post.where(locale: default_locale).group(:status).count
18
+
19
+ key_scope = Post.where(status: @status, locale: default_locale)
20
+ @keys = key_scope.distinct.order(:key).pluck(:key)
21
+
22
+ scope = posts_with_video.newest_first.where(status: @status, locale: default_locale)
23
+ scope = scope.where('LOWER(key) LIKE ?', "%#{Post.sanitize_sql_like(@query.downcase)}%") if @query
24
+ @page = [params[:page].to_i, 1].max
25
+ @posts = scope.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
26
+ @more = @posts.size > PER_PAGE
27
+ @posts = @posts.first(PER_PAGE)
28
+
29
+ @selected_post = posts_with_video.find_by(id: params[:post_id]) if params[:post_id].present?
30
+ load_translations if @selected_post
31
+ end
32
+
33
+ def show; end
34
+
35
+ def new
36
+ @post = Post.new(locale: default_locale)
37
+ end
38
+
39
+ def create
40
+ @post = Post.new(post_attributes.merge(locale: default_locale))
41
+ if @post.save
42
+ redirect_to post_path(@post), notice: t('product_tours.dashboard.created', default: 'Tutorial created.')
43
+ else
44
+ render :new, status: :unprocessable_entity
45
+ end
46
+ end
47
+
48
+ def edit; end
49
+
50
+ def update
51
+ if @post.update(post_attributes)
52
+ remove_uploaded_video if remove_uploaded_video?
53
+ redirect_to post_path(@post), notice: t('product_tours.dashboard.updated', default: 'Tutorial updated.')
54
+ else
55
+ render :edit, status: :unprocessable_entity
56
+ end
57
+ end
58
+
59
+ def destroy
60
+ if default_locale_post?(@post) && translations_for(@post).where.not(id: @post.id).exists?
61
+ redirect_to post_path(@post),
62
+ alert: t('product_tours.dashboard.delete_translations_first',
63
+ default: 'Delete this tutorial\'s translations before deleting the ' \
64
+ 'default-language tutorial.')
65
+ return
66
+ end
67
+
68
+ @post.destroy!
69
+ redirect_to posts_path,
70
+ notice: t('product_tours.dashboard.deleted', default: 'Tutorial deleted.'), status: :see_other
71
+ end
72
+
73
+ def add_translation
74
+ locale = params[:locale].to_s
75
+ unless available_locales.include?(locale) && locale != @post.locale
76
+ redirect_to post_path(@post),
77
+ alert: t('product_tours.dashboard.translation_locale_invalid',
78
+ default: 'Choose another available language.')
79
+ return
80
+ end
81
+
82
+ existing = Post.find_by(locale: locale, key: @post.key)
83
+ if existing
84
+ redirect_to post_path(existing),
85
+ notice: t('product_tours.dashboard.translation_exists',
86
+ default: 'That translation already exists.')
87
+ return
88
+ end
89
+
90
+ source = translations_for(@post).find_by(locale: default_locale) || @post
91
+ translation = source.dup
92
+ translation.locale = locale
93
+ translation.status = 'draft'
94
+ if translation.action_post_key.present? &&
95
+ !Post.exists?(locale: locale, key: translation.action_post_key)
96
+ translation.action_post_key = nil
97
+ end
98
+ translation.save!
99
+ if Post.description_supported? && source.description.present?
100
+ translation.update!(description: source.description.body)
101
+ end
102
+ translation.video.attach(source.video.blob) if source.uploaded_video?
103
+
104
+ redirect_to edit_post_path(translation),
105
+ notice: t('product_tours.dashboard.translation_added',
106
+ default: 'Translation added as a draft. Translate its content before publishing.')
107
+ end
108
+
109
+ def publish
110
+ @post.update!(status: 'published')
111
+ redirect_to post_path(@post), notice: t('product_tours.dashboard.published', default: 'Tutorial published.')
112
+ end
113
+
114
+ def unpublish
115
+ @post.update!(status: 'draft')
116
+ redirect_to post_path(@post),
117
+ notice: t('product_tours.dashboard.unpublished', default: 'Tutorial moved to drafts.')
118
+ end
119
+
120
+ def video_preview
121
+ resolved = VideoResolver.resolve(params[:url])
122
+ unless resolved
123
+ render json: { error: t('product_tours.dashboard.video_invalid', default: 'Enter a supported video URL.') },
124
+ status: :unprocessable_entity
125
+ return
126
+ end
127
+
128
+ metadata = VideoMetadata.fetch(params[:url])
129
+ render json: {
130
+ kind: resolved.kind,
131
+ provider: resolved.provider,
132
+ url: resolved.url,
133
+ title: metadata['provider_title'],
134
+ thumbnail_url: metadata['thumbnail_url']
135
+ }.compact
136
+ end
137
+
138
+ def refresh_video_metadata
139
+ metadata = VideoMetadata.fetch(@post.video_url)
140
+ if metadata.present?
141
+ @post.update!(video_metadata: @post.video_metadata.to_h.merge(metadata))
142
+ redirect_to edit_post_path(@post),
143
+ notice: t('product_tours.dashboard.metadata_updated', default: 'Video data refreshed.')
144
+ else
145
+ redirect_to edit_post_path(@post),
146
+ alert: t('product_tours.dashboard.metadata_failed',
147
+ default: 'Video data could not be fetched. The URL was not changed.')
148
+ end
149
+ end
150
+
151
+ private
152
+
153
+ def posts_with_video
154
+ Post.video_upload_supported? ? Post.with_attached_video : Post.all
155
+ end
156
+
157
+ def set_post
158
+ @post = Post.find(params[:id])
159
+ end
160
+
161
+ def post_params
162
+ permitted = %i[key status title video_url action_label action_url action_post_key]
163
+ permitted << :description if Post.description_supported?
164
+ permitted << :video if Post.video_upload_supported?
165
+ params.require(:post).permit(*permitted)
166
+ end
167
+
168
+ def post_attributes
169
+ attributes = post_params
170
+ attributes.delete(:key) if identity_locked?
171
+ case params[:video_source]
172
+ when 'url'
173
+ attributes.delete(:video)
174
+ when 'upload'
175
+ attributes[:video_url] = nil
176
+ end
177
+ case params[:action_target]
178
+ when 'close'
179
+ attributes[:action_url] = nil
180
+ attributes[:action_post_key] = nil
181
+ when 'url'
182
+ attributes[:action_post_key] = nil
183
+ when 'post'
184
+ attributes[:action_url] = nil
185
+ end
186
+ attributes
187
+ end
188
+
189
+ def load_linkable_posts
190
+ @linkable_posts = Post.where.not(id: @post&.id).order(:locale, :title, :key)
191
+ .select(:id, :key, :locale, :status, :title)
192
+ @identity_locked = identity_locked?
193
+ end
194
+
195
+ def load_translations
196
+ post = @selected_post || @post
197
+ @translations = translations_for(post).order(:locale).to_a
198
+ @missing_translation_locales = available_locales - @translations.map(&:locale)
199
+ end
200
+
201
+ def translations_for(post)
202
+ Post.where(key: post.key)
203
+ end
204
+
205
+ def available_locales
206
+ (I18n.available_locales.map(&:to_s) + [default_locale]).uniq.sort
207
+ end
208
+
209
+ def default_locale
210
+ I18n.default_locale.to_s
211
+ end
212
+
213
+ def default_locale_post?(post)
214
+ post.locale == default_locale
215
+ end
216
+
217
+ def identity_locked?
218
+ @post&.persisted? &&
219
+ (!default_locale_post?(@post) || translations_for(@post).where.not(id: @post.id).exists?)
220
+ end
221
+
222
+ def remove_uploaded_video?
223
+ params[:video_source] == 'url' && @post.uploaded_video?
224
+ end
225
+
226
+ def remove_uploaded_video
227
+ @post.video.purge if @post.uploaded_video?
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTours
4
+ class ToursController < ApplicationController
5
+ SIGNALS = %w[viewed dismissed completed].freeze
6
+
7
+ def resolve
8
+ key = params[:key].to_s
9
+ return handle_unresolved_trigger(key, :disabled) unless ProductTours.enabled?(request)
10
+ return handle_unresolved_trigger(key, :invalid_key) unless key.match?(Post::KEY_FORMAT)
11
+
12
+ post = find_post_by_locale(Post.all, key)
13
+ return handle_unresolved_trigger(key, :missing) unless post
14
+ return handle_unresolved_trigger(key, :unpublished) unless post.published?
15
+
16
+ render json: post_payload(post)
17
+ end
18
+
19
+ def signal
20
+ return head :forbidden unless ProductTours.enabled?(request)
21
+
22
+ action = params[:event_action].to_s
23
+ return head :unprocessable_entity unless SIGNALS.include?(action)
24
+
25
+ post = find_post_by_locale(Post.published, params[:key].to_s)
26
+ return head :not_found unless post
27
+
28
+ payload = {
29
+ post_id: post.id,
30
+ key: post.key,
31
+ locale: post.locale,
32
+ page_url: clean_page_url(params[:page_url]),
33
+ source: params[:source].to_s.presence
34
+ }
35
+ ActiveSupport::Notifications.instrument("product_tours.#{action}", payload)
36
+ head :no_content
37
+ end
38
+
39
+ private
40
+
41
+ def post_payload(post)
42
+ video = post.resolved_video
43
+ video[:url] = media_path(post) if video&.dig(:kind) == 'upload'
44
+ {
45
+ key: post.key,
46
+ locale: post.locale,
47
+ title: post.title,
48
+ descriptionHtml: description_html(post),
49
+ video: video,
50
+ action: {
51
+ label: post.action_label.presence || default_action_label(post),
52
+ url: post.action_url.presence,
53
+ postKey: post.action_post_key.presence
54
+ }
55
+ }
56
+ end
57
+
58
+ def default_action_label(post)
59
+ key = post.action_post_key.present? ? :next : :done
60
+ I18n.t(key, scope: :product_tours, default: key.to_s.humanize)
61
+ end
62
+
63
+ def description_html(post)
64
+ return unless Post.description_supported? && post.description.present?
65
+
66
+ post.description.body.to_s
67
+ end
68
+ end
69
+ end