product_tours 0.3.3 → 1.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +11 -5
  3. data/CHANGELOG.md +75 -3
  4. data/README.md +66 -4
  5. data/Rakefile +17 -1
  6. data/SECURITY.md +50 -0
  7. data/app/controllers/concerns/product_tours/request_context.rb +3 -3
  8. data/app/controllers/product_tours/dashboard_controller.rb +1 -1
  9. data/app/controllers/product_tours/interactions_controller.rb +77 -0
  10. data/app/controllers/product_tours/media_controller.rb +23 -4
  11. data/app/controllers/product_tours/tours_controller.rb +203 -52
  12. data/app/helpers/product_tours/{posts_helper.rb → tours_helper.rb} +1 -1
  13. data/app/models/product_tours/{post.rb → tour.rb} +22 -15
  14. data/app/views/product_tours/{posts → tours}/_form.html.erb +34 -34
  15. data/app/views/product_tours/{posts/_post_panel.html.erb → tours/_tour_panel.html.erb} +33 -33
  16. data/app/views/product_tours/{posts → tours}/edit.html.erb +3 -3
  17. data/app/views/product_tours/{posts → tours}/index.html.erb +23 -23
  18. data/app/views/product_tours/tours/new.html.erb +7 -0
  19. data/app/views/product_tours/{posts → tours}/show.html.erb +2 -2
  20. data/config/routes.rb +4 -4
  21. data/docs/ractors.md +138 -0
  22. data/lib/generators/product_tours/install/install_generator.rb +3 -3
  23. data/lib/generators/product_tours/install/templates/{create_product_tours_posts.rb.tt → create_product_tours_tours.rb.tt} +5 -5
  24. data/lib/product_tours/dashboard.css +1 -1
  25. data/lib/product_tours/dashboard.js +16 -16
  26. data/lib/product_tours/engine.rb +12 -0
  27. data/lib/product_tours/ractor_support.rb +519 -0
  28. data/lib/product_tours/seeds.rb +22 -22
  29. data/lib/product_tours/version.rb +1 -1
  30. data/lib/product_tours/widget.js +26 -26
  31. data/lib/product_tours/widget.rb +3 -3
  32. data/{app/helpers → lib}/product_tours/widget_helper.rb +4 -0
  33. data/lib/product_tours.rb +2 -0
  34. data/lib/tasks/product_tours_tasks.rake +3 -3
  35. metadata +22 -13
  36. data/app/controllers/product_tours/posts_controller.rb +0 -228
  37. data/app/views/product_tours/posts/new.html.erb +0 -7
@@ -1,77 +1,228 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ProductTours
4
- class ToursController < ApplicationController
5
- SIGNALS = %w[viewed dismissed completed].freeze
4
+ class ToursController < DashboardController
5
+ PER_PAGE = 50
6
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)
7
+ before_action :set_tour,
8
+ only: %i[show edit update destroy refresh_video_metadata add_translation publish unpublish]
9
+ before_action :load_linkable_tours, only: %i[new create edit update]
10
+ before_action :load_translations, only: :show
11
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?
12
+ def index
13
+ @status = Tour::STATUSES.include?(params[:status]) ? params[:status] : 'published'
14
+ @query = params[:q].to_s.strip.presence
15
+ @counts = Tour.where(locale: default_locale).group(:status).count
15
16
 
16
- render json: post_payload(post)
17
+ key_scope = Tour.where(status: @status, locale: default_locale)
18
+ @keys = key_scope.distinct.order(:key).pluck(:key)
19
+
20
+ scope = tours_with_video.newest_first.where(status: @status, locale: default_locale)
21
+ scope = scope.where('LOWER(key) LIKE ?', "%#{Tour.sanitize_sql_like(@query.downcase)}%") if @query
22
+ @page = [params[:page].to_i, 1].max
23
+ @tours = scope.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
24
+ @more = @tours.size > PER_PAGE
25
+ @tours = @tours.first(PER_PAGE)
26
+
27
+ @selected_tour = tours_with_video.find_by(id: params[:tour_id]) if params[:tour_id].present?
28
+ load_translations if @selected_tour
29
+ end
30
+
31
+ def show; end
32
+
33
+ def new
34
+ @tour = Tour.new(locale: default_locale)
35
+ end
36
+
37
+ def create
38
+ @tour = Tour.new(tour_attributes.merge(locale: default_locale))
39
+ if @tour.save
40
+ redirect_to tour_path(@tour), notice: t('product_tours.dashboard.created', default: 'Tutorial created.')
41
+ else
42
+ render :new, status: :unprocessable_entity
43
+ end
44
+ end
45
+
46
+ def edit; end
47
+
48
+ def update
49
+ if @tour.update(tour_attributes)
50
+ remove_uploaded_video if remove_uploaded_video?
51
+ redirect_to tour_path(@tour), notice: t('product_tours.dashboard.updated', default: 'Tutorial updated.')
52
+ else
53
+ render :edit, status: :unprocessable_entity
54
+ end
17
55
  end
18
56
 
19
- def signal
20
- return head :forbidden unless ProductTours.enabled?(request)
57
+ def destroy
58
+ if default_locale_tour?(@tour) && translations_for(@tour).where.not(id: @tour.id).exists?
59
+ redirect_to tour_path(@tour),
60
+ alert: t('product_tours.dashboard.delete_translations_first',
61
+ default: 'Delete this tutorial\'s translations before deleting the ' \
62
+ 'default-language tutorial.')
63
+ return
64
+ end
21
65
 
22
- action = params[:event_action].to_s
23
- return head :unprocessable_entity unless SIGNALS.include?(action)
66
+ @tour.destroy!
67
+ redirect_to tours_path,
68
+ notice: t('product_tours.dashboard.deleted', default: 'Tutorial deleted.'), status: :see_other
69
+ end
70
+
71
+ def add_translation
72
+ locale = params[:locale].to_s
73
+ unless available_locales.include?(locale) && locale != @tour.locale
74
+ redirect_to tour_path(@tour),
75
+ alert: t('product_tours.dashboard.translation_locale_invalid',
76
+ default: 'Choose another available language.')
77
+ return
78
+ end
79
+
80
+ existing = Tour.find_by(locale: locale, key: @tour.key)
81
+ if existing
82
+ redirect_to tour_path(existing),
83
+ notice: t('product_tours.dashboard.translation_exists',
84
+ default: 'That translation already exists.')
85
+ return
86
+ end
87
+
88
+ source = translations_for(@tour).find_by(locale: default_locale) || @tour
89
+ translation = source.dup
90
+ translation.locale = locale
91
+ translation.status = 'draft'
92
+ if translation.action_tour_key.present? &&
93
+ !Tour.exists?(locale: locale, key: translation.action_tour_key)
94
+ translation.action_tour_key = nil
95
+ end
96
+ translation.save!
97
+ if Tour.description_supported? && source.description.present?
98
+ translation.update!(description: source.description.body)
99
+ end
100
+ translation.video.attach(source.video.blob) if source.uploaded_video?
101
+
102
+ redirect_to edit_tour_path(translation),
103
+ notice: t('product_tours.dashboard.translation_added',
104
+ default: 'Translation added as a draft. Translate its content before publishing.')
105
+ end
106
+
107
+ def publish
108
+ @tour.update!(status: 'published')
109
+ redirect_to tour_path(@tour), notice: t('product_tours.dashboard.published', default: 'Tutorial published.')
110
+ end
111
+
112
+ def unpublish
113
+ @tour.update!(status: 'draft')
114
+ redirect_to tour_path(@tour),
115
+ notice: t('product_tours.dashboard.unpublished', default: 'Tutorial moved to drafts.')
116
+ end
24
117
 
25
- post = find_post_by_locale(Post.published, params[:key].to_s)
26
- return head :not_found unless post
118
+ def video_preview
119
+ resolved = VideoResolver.resolve(params[:url])
120
+ unless resolved
121
+ render json: { error: t('product_tours.dashboard.video_invalid', default: 'Enter a supported video URL.') },
122
+ status: :unprocessable_entity
123
+ return
124
+ end
27
125
 
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
- event_name = "product_tours.#{action}"
36
- ActiveSupport::Notifications.instrument(event_name, payload)
37
- notify_host(event_name, payload)
38
- head :no_content
126
+ metadata = VideoMetadata.fetch(params[:url])
127
+ render json: {
128
+ kind: resolved.kind,
129
+ provider: resolved.provider,
130
+ url: resolved.url,
131
+ title: metadata['provider_title'],
132
+ thumbnail_url: metadata['thumbnail_url']
133
+ }.compact
134
+ end
135
+
136
+ def refresh_video_metadata
137
+ metadata = VideoMetadata.fetch(@tour.video_url)
138
+ if metadata.present?
139
+ @tour.update!(video_metadata: @tour.video_metadata.to_h.merge(metadata))
140
+ redirect_to edit_tour_path(@tour),
141
+ notice: t('product_tours.dashboard.metadata_updated', default: 'Video data refreshed.')
142
+ else
143
+ redirect_to edit_tour_path(@tour),
144
+ alert: t('product_tours.dashboard.metadata_failed',
145
+ default: 'Video data could not be fetched. The URL was not changed.')
146
+ end
39
147
  end
40
148
 
41
149
  private
42
150
 
43
- def notify_host(event_name, payload)
44
- ProductTours.config.on_event.call(event_name, payload, request)
45
- rescue StandardError => e
46
- Rails.logger.error("product_tours: on_event hook raised #{e.class}: #{e.message}")
151
+ def tours_with_video
152
+ Tour.video_upload_supported? ? Tour.with_attached_video : Tour.all
153
+ end
154
+
155
+ def set_tour
156
+ @tour = Tour.find(params[:id])
157
+ end
158
+
159
+ def tour_params
160
+ permitted = %i[key status title video_url action_label action_url action_tour_key]
161
+ permitted << :description if Tour.description_supported?
162
+ permitted << :video if Tour.video_upload_supported?
163
+ params.require(:tour).permit(*permitted)
164
+ end
165
+
166
+ def tour_attributes
167
+ attributes = tour_params
168
+ attributes.delete(:key) if identity_locked?
169
+ case params[:video_source]
170
+ when 'url'
171
+ attributes.delete(:video)
172
+ when 'upload'
173
+ attributes[:video_url] = nil
174
+ end
175
+ case params[:action_target]
176
+ when 'close'
177
+ attributes[:action_url] = nil
178
+ attributes[:action_tour_key] = nil
179
+ when 'url'
180
+ attributes[:action_tour_key] = nil
181
+ when 'tour'
182
+ attributes[:action_url] = nil
183
+ end
184
+ attributes
185
+ end
186
+
187
+ def load_linkable_tours
188
+ @linkable_tours = Tour.where.not(id: @tour&.id).order(:locale, :title, :key)
189
+ .select(:id, :key, :locale, :status, :title)
190
+ @identity_locked = identity_locked?
47
191
  end
48
192
 
49
- def post_payload(post)
50
- video = post.resolved_video
51
- video[:url] = media_path(post) if video&.dig(:kind) == 'upload'
52
- {
53
- key: post.key,
54
- locale: post.locale,
55
- title: post.title,
56
- descriptionHtml: description_html(post),
57
- video: video,
58
- action: {
59
- label: post.action_label.presence || default_action_label(post),
60
- url: post.action_url.presence,
61
- postKey: post.action_post_key.presence
62
- }
63
- }
193
+ def load_translations
194
+ tour = @selected_tour || @tour
195
+ @translations = translations_for(tour).order(:locale).to_a
196
+ @missing_translation_locales = available_locales - @translations.map(&:locale)
64
197
  end
65
198
 
66
- def default_action_label(post)
67
- key = post.action_post_key.present? ? :next : :done
68
- I18n.t(key, scope: :product_tours, default: key.to_s.humanize)
199
+ def translations_for(tour)
200
+ Tour.where(key: tour.key)
69
201
  end
70
202
 
71
- def description_html(post)
72
- return unless Post.description_supported? && post.description.present?
203
+ def available_locales
204
+ (I18n.available_locales.map(&:to_s) + [default_locale]).uniq.sort
205
+ end
206
+
207
+ def default_locale
208
+ I18n.default_locale.to_s
209
+ end
210
+
211
+ def default_locale_tour?(tour)
212
+ tour.locale == default_locale
213
+ end
214
+
215
+ def identity_locked?
216
+ @tour&.persisted? &&
217
+ (!default_locale_tour?(@tour) || translations_for(@tour).where.not(id: @tour.id).exists?)
218
+ end
219
+
220
+ def remove_uploaded_video?
221
+ params[:video_source] == 'url' && @tour.uploaded_video?
222
+ end
73
223
 
74
- post.description.body.to_s
224
+ def remove_uploaded_video
225
+ @tour.video.purge if @tour.uploaded_video?
75
226
  end
76
227
  end
77
228
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ProductTours
4
- module PostsHelper
4
+ module ToursHelper
5
5
  LOCALE_NAMES = {
6
6
  'ar' => 'Arabic', 'bg' => 'Bulgarian', 'bn' => 'Bengali', 'de' => 'German', 'el' => 'Greek',
7
7
  'en' => 'English', 'es' => 'Spanish', 'fr' => 'French', 'hi' => 'Hindi', 'hr' => 'Croatian',
@@ -3,13 +3,20 @@
3
3
  require 'uri'
4
4
 
5
5
  module ProductTours
6
- class Post < ApplicationRecord
6
+ class Tour < ApplicationRecord
7
7
  KEY_FORMAT = /\A[a-z0-9]+(?:[._-][a-z0-9]+)*\z/
8
8
  STATUSES = %w[draft published].freeze
9
9
  DASHBOARD_STATUSES = %w[published draft].freeze
10
10
 
11
11
  enum :status, STATUSES.index_by(&:itself), default: :draft
12
12
 
13
+ # Explicit methods work in non-main Ractors; Rails' enum-generated
14
+ # predicates and bang methods are currently unisolated closures.
15
+ def draft? = status_for_database == 'draft'
16
+ def draft! = update!(status: 'draft')
17
+ def published? = status_for_database == 'published'
18
+ def published! = update!(status: 'published')
19
+
13
20
  has_rich_text :description if respond_to?(:has_rich_text)
14
21
 
15
22
  has_one_attached :video, service: ProductTours.config.storage_service if defined?(::ActiveStorage)
@@ -19,7 +26,7 @@ module ProductTours
19
26
  validates :status, inclusion: { in: STATUSES }
20
27
  validates :title, presence: true
21
28
  validate :action_has_one_destination
22
- validate :action_post_is_linkable
29
+ validate :action_tour_is_linkable
23
30
  validate :action_url_is_safe
24
31
  validate :video_url_is_supported
25
32
  validate :uploaded_file_is_video
@@ -61,10 +68,10 @@ module ProductTours
61
68
  }.compact
62
69
  end
63
70
 
64
- def action_post
65
- return if action_post_key.blank?
71
+ def action_tour
72
+ return if action_tour_key.blank?
66
73
 
67
- self.class.find_by(locale: locale, key: action_post_key)
74
+ self.class.find_by(locale: locale, key: action_tour_key)
68
75
  end
69
76
 
70
77
  private
@@ -97,25 +104,25 @@ module ProductTours
97
104
  end
98
105
 
99
106
  def action_has_one_destination
100
- return unless action_url.present? && action_post_key.present?
107
+ return unless action_url.present? && action_tour_key.present?
101
108
 
102
- errors.add(:base, 'Primary action can open a URL or another post, not both')
109
+ errors.add(:base, 'Primary action can open a URL or another tour, not both')
103
110
  end
104
111
 
105
- def action_post_is_linkable
106
- return if action_post_key.blank?
112
+ def action_tour_is_linkable
113
+ return if action_tour_key.blank?
107
114
 
108
- unless action_post_key.match?(KEY_FORMAT)
109
- errors.add(:action_post_key, 'must use a valid post key')
115
+ unless action_tour_key.match?(KEY_FORMAT)
116
+ errors.add(:action_tour_key, 'must use a valid tour key')
110
117
  return
111
118
  end
112
- if action_post_key == key
113
- errors.add(:action_post_key, 'cannot link to the same post')
119
+ if action_tour_key == key
120
+ errors.add(:action_tour_key, 'cannot link to the same tour')
114
121
  return
115
122
  end
116
- return if locale.blank? || self.class.exists?(locale: locale, key: action_post_key)
123
+ return if locale.blank? || self.class.exists?(locale: locale, key: action_tour_key)
117
124
 
118
- errors.add(:action_post_key, 'must identify an existing post in the same locale')
125
+ errors.add(:action_tour_key, 'must identify an existing tour in the same locale')
119
126
  end
120
127
 
121
128
  def uploaded_file_is_video
@@ -1,14 +1,14 @@
1
- <%= form_with model: post, class: "post-form" do |form| %>
2
- <% action_target = post.action_post_key.present? ? "post" : (post.action_url.present? ? "url" : "close") %>
3
- <% linkable_posts = @linkable_posts.map do |candidate|
1
+ <%= form_with model: tour, class: "tour-form" do |form| %>
2
+ <% action_target = tour.action_tour_key.present? ? "tour" : (tour.action_url.present? ? "url" : "close") %>
3
+ <% linkable_tours = @linkable_tours.map do |candidate|
4
4
  { id: candidate.id, key: candidate.key, locale: candidate.locale, status: candidate.status,
5
5
  label: "#{candidate.title} — #{candidate.key} (#{candidate.status})" }
6
6
  end %>
7
- <% video_source = params[:video_source].presence || (post.uploaded_video? ? "upload" : "url") %>
8
- <% if post.errors.any? %>
7
+ <% video_source = params[:video_source].presence || (tour.uploaded_video? ? "upload" : "url") %>
8
+ <% if tour.errors.any? %>
9
9
  <div class="errors" role="alert">
10
10
  <strong><%= t("product_tours.dashboard.fix_errors", default: "Fix the following fields:") %></strong>
11
- <ul><% post.errors.full_messages.each do |message| %><li><%= message %></li><% end %></ul>
11
+ <ul><% tour.errors.full_messages.each do |message| %><li><%= message %></li><% end %></ul>
12
12
  </div>
13
13
  <% end %>
14
14
 
@@ -36,7 +36,7 @@
36
36
  <%= form.label :locale, t("product_tours.dashboard.language", default: "Language") %>
37
37
  <%= form.text_field :locale, disabled: true %>
38
38
  <small>
39
- <% if post.new_record? %>
39
+ <% if tour.new_record? %>
40
40
  <%= t("product_tours.dashboard.default_language_hint", default: "New tutorials start in your app's default language. Add translations from the tutorial page.") %>
41
41
  <% else %>
42
42
  <%= t("product_tours.dashboard.language_locked_hint", default: "Add and open other languages from the tutorial page.") %>
@@ -45,11 +45,11 @@
45
45
  </div>
46
46
  <div class="field">
47
47
  <%= form.label :status, t("product_tours.dashboard.publication_status", default: "Publication status") %>
48
- <%= form.select :status, ProductTours::Post::STATUSES.map { |status| [status.humanize, status] } %>
48
+ <%= form.select :status, ProductTours::Tour::STATUSES.map { |status| [status.humanize, status] } %>
49
49
  </div>
50
50
  </section>
51
51
 
52
- <% if ProductTours::Post.description_supported? %>
52
+ <% if ProductTours::Tour.description_supported? %>
53
53
  <section class="form-section">
54
54
  <h2><%= t("product_tours.dashboard.description", default: "Description") %></h2>
55
55
  <div class="rich-editor" data-rich-editor>
@@ -60,18 +60,18 @@
60
60
  <button type="button" data-rich-command="insertOrderedList" aria-label="<%= t("product_tours.dashboard.numbered_list", default: "Numbered list") %>">1.</button>
61
61
  <button type="button" data-rich-command="createLink" aria-label="<%= t("product_tours.dashboard.link", default: "Link") %>">↗</button>
62
62
  </div>
63
- <%= form.hidden_field :description, value: post.description.body.to_s, data: { rich_input: true } %>
63
+ <%= form.hidden_field :description, value: tour.description.body.to_s, data: { rich_input: true } %>
64
64
  <div class="rich-content" contenteditable="true" role="textbox" aria-multiline="true"
65
65
  aria-label="<%= t("product_tours.dashboard.description", default: "Description") %>"
66
- data-rich-content><%= post.description.body.to_s.html_safe %></div>
66
+ data-rich-content><%= tour.description.body.to_s.html_safe %></div>
67
67
  </div>
68
68
  </section>
69
69
  <% end %>
70
70
 
71
71
  <section class="form-section">
72
72
  <h2><%= t("product_tours.dashboard.video", default: "Video") %></h2>
73
- <% if ProductTours::Post.video_upload_supported? %>
74
- <fieldset class="video-source-picker" data-video-source-picker data-has-upload="<%= post.uploaded_video? %>">
73
+ <% if ProductTours::Tour.video_upload_supported? %>
74
+ <fieldset class="video-source-picker" data-video-source-picker data-has-upload="<%= tour.uploaded_video? %>">
75
75
  <legend class="sr-only"><%= t("product_tours.dashboard.video_source", default: "Video source") %></legend>
76
76
  <label class="action-choice">
77
77
  <%= radio_button_tag :video_source, "url", video_source == "url", data: { video_source: true } %>
@@ -94,7 +94,7 @@
94
94
  <small><%= t("product_tours.dashboard.video_hint", default: "YouTube, Vimeo, Loom, Tella, Voomly, or a direct MP4/WebM URL.") %></small>
95
95
  </div>
96
96
  <div class="form-video-preview" data-video-preview
97
- data-endpoint="<%= video_preview_posts_path %>"
97
+ data-endpoint="<%= video_preview_tours_path %>"
98
98
  data-loading="<%= t("product_tours.dashboard.video_loading", default: "Loading preview…") %>"
99
99
  data-error="<%= t("product_tours.dashboard.video_preview_failed", default: "This video could not be previewed.") %>"
100
100
  hidden>
@@ -103,14 +103,14 @@
103
103
  </div>
104
104
  </div>
105
105
 
106
- <% if ProductTours::Post.video_upload_supported? %>
106
+ <% if ProductTours::Tour.video_upload_supported? %>
107
107
  <div data-video-source-fields="upload" <%= "hidden" unless video_source == "upload" %>>
108
108
  <div class="field">
109
109
  <%= form.label :video, t("product_tours.dashboard.video_file", default: "Video file") %>
110
110
  <%= form.file_field :video, accept: "video/*", disabled: video_source != "upload",
111
- required: video_source == "upload" && !post.uploaded_video? %>
112
- <% if post.uploaded_video? %>
113
- <small><%= t("product_tours.dashboard.current_video", default: "Current file: %{filename}", filename: post.video.filename) %></small>
111
+ required: video_source == "upload" && !tour.uploaded_video? %>
112
+ <% if tour.uploaded_video? %>
113
+ <small><%= t("product_tours.dashboard.current_video", default: "Current file: %{filename}", filename: tour.video.filename) %></small>
114
114
  <% end %>
115
115
  </div>
116
116
  </div>
@@ -128,9 +128,9 @@
128
128
  <small><%= t("product_tours.dashboard.action_finish_hint", default: "Marks this tutorial complete and closes the modal.") %></small></span>
129
129
  </label>
130
130
  <label class="action-choice">
131
- <%= radio_button_tag :action_target, "post", action_target == "post", data: { action_target: true } %>
132
- <span><strong><%= t("product_tours.dashboard.action_post", default: "Continue to another tutorial") %></strong>
133
- <small><%= t("product_tours.dashboard.action_post_hint", default: "Opens it in the same modal and automatically adds a Back button.") %></small></span>
131
+ <%= radio_button_tag :action_target, "tour", action_target == "tour", data: { action_target: true } %>
132
+ <span><strong><%= t("product_tours.dashboard.action_tour", default: "Continue to another tutorial") %></strong>
133
+ <small><%= t("product_tours.dashboard.action_tour_hint", default: "Opens it in the same modal and automatically adds a Back button.") %></small></span>
134
134
  </label>
135
135
  <label class="action-choice">
136
136
  <%= radio_button_tag :action_target, "url", action_target == "url", data: { action_target: true } %>
@@ -141,21 +141,21 @@
141
141
 
142
142
  <div class="field">
143
143
  <%= form.label :action_label, t("product_tours.dashboard.action_label", default: "Button label") %>
144
- <%= form.text_field :action_label, placeholder: action_target == "post" ? t("product_tours.next", default: "Next") : t("product_tours.done", default: "Done"),
144
+ <%= form.text_field :action_label, placeholder: action_target == "tour" ? t("product_tours.next", default: "Next") : t("product_tours.done", default: "Done"),
145
145
  data: { action_label: true } %>
146
146
  <small><%= t("product_tours.dashboard.action_label_hint", default: "Optional. A sensible label is used when left blank.") %></small>
147
147
  </div>
148
148
 
149
- <div data-action-fields="post">
149
+ <div data-action-fields="tour">
150
150
  <div class="field">
151
- <%= form.label :action_post_key, t("product_tours.dashboard.next_post", default: "Next tutorial") %>
152
- <% same_locale_posts = @linkable_posts.select { |candidate| candidate.locale == post.locale } %>
153
- <% post_options = same_locale_posts.map { |candidate| ["#{candidate.title} — #{candidate.key} (#{candidate.status})", candidate.key] } %>
154
- <%= form.select :action_post_key, options_for_select(post_options, post.action_post_key),
155
- { include_blank: t("product_tours.dashboard.select_next_post", default: "Select the next tutorial…") },
156
- required: true, data: { action_post_select: true, selected_key: post.action_post_key,
157
- current_post_id: post.id } %>
158
- <small><%= t("product_tours.dashboard.next_post_hint", default: "Tutorials must use the same language. Draft tutorials can be linked while you prepare the sequence.") %></small>
151
+ <%= form.label :action_tour_key, t("product_tours.dashboard.next_tour", default: "Next tutorial") %>
152
+ <% same_locale_tours = @linkable_tours.select { |candidate| candidate.locale == tour.locale } %>
153
+ <% tour_options = same_locale_tours.map { |candidate| ["#{candidate.title} — #{candidate.key} (#{candidate.status})", candidate.key] } %>
154
+ <%= form.select :action_tour_key, options_for_select(tour_options, tour.action_tour_key),
155
+ { include_blank: t("product_tours.dashboard.select_next_tour", default: "Select the next tutorial…") },
156
+ required: true, data: { action_tour_select: true, selected_key: tour.action_tour_key,
157
+ current_tour_id: tour.id } %>
158
+ <small><%= t("product_tours.dashboard.next_tour_hint", default: "Tutorials must use the same language. Draft tutorials can be linked while you prepare the sequence.") %></small>
159
159
  </div>
160
160
  </div>
161
161
 
@@ -166,12 +166,12 @@
166
166
  <small><%= t("product_tours.dashboard.action_hint", default: "Use an app path such as /settings or a complete HTTP(S) URL.") %></small>
167
167
  </div>
168
168
  </div>
169
- <script type="application/json" data-action-post-options><%= raw json_escape(linkable_posts.to_json) %></script>
169
+ <script type="application/json" data-action-tour-options><%= raw json_escape(linkable_tours.to_json) %></script>
170
170
  </section>
171
171
 
172
172
  <div class="form-actions">
173
- <%= form.submit(post.persisted? ? t("product_tours.dashboard.save", default: "Save changes") : t("product_tours.dashboard.create", default: "Create tutorial"), class: "button primary") %>
174
- <%= link_to t("product_tours.dashboard.cancel", default: "Cancel"), post.persisted? ? post_path(post) : posts_path, class: "button" %>
173
+ <%= form.submit(tour.persisted? ? t("product_tours.dashboard.save", default: "Save changes") : t("product_tours.dashboard.create", default: "Create tutorial"), class: "button primary") %>
174
+ <%= link_to t("product_tours.dashboard.cancel", default: "Cancel"), tour.persisted? ? tour_path(tour) : tours_path, class: "button" %>
175
175
  </div>
176
176
  </div>
177
177
  <% end %>