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
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTours
4
+ class WidgetsController < ApplicationController
5
+ skip_forgery_protection
6
+
7
+ def show
8
+ serve(Widget.javascript, Widget.fingerprint)
9
+ end
10
+
11
+ def dashboard
12
+ serve(Widget.dashboard_javascript, Widget.dashboard_fingerprint, 'text/javascript')
13
+ end
14
+
15
+ def dashboard_stylesheet
16
+ serve(Widget.dashboard_stylesheet, Widget.dashboard_stylesheet_fingerprint, 'text/css')
17
+ end
18
+
19
+ private
20
+
21
+ def serve(source, fingerprint, content_type = 'text/javascript')
22
+ expires_in 1.year, public: true if params[:v] == fingerprint
23
+ return unless stale?(etag: [ProductTours::VERSION, fingerprint])
24
+
25
+ render plain: source, content_type: content_type
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTours
4
+ module PostsHelper
5
+ LOCALE_NAMES = {
6
+ 'ar' => 'Arabic', 'bg' => 'Bulgarian', 'bn' => 'Bengali', 'de' => 'German', 'el' => 'Greek',
7
+ 'en' => 'English', 'es' => 'Spanish', 'fr' => 'French', 'hi' => 'Hindi', 'hr' => 'Croatian',
8
+ 'id' => 'Indonesian', 'it' => 'Italian', 'ja' => 'Japanese', 'ko' => 'Korean',
9
+ 'lb' => 'Luxembourgish', 'nl' => 'Dutch', 'pl' => 'Polish', 'pt' => 'Portuguese',
10
+ 'ro' => 'Romanian', 'ru' => 'Russian', 'th' => 'Thai', 'tr' => 'Turkish', 'uk' => 'Ukrainian',
11
+ 'ur' => 'Urdu', 'vi' => 'Vietnamese', 'zh-CN' => 'Chinese (Simplified)'
12
+ }.freeze
13
+
14
+ def product_tours_locale_name(locale)
15
+ code = locale.to_s
16
+ "#{LOCALE_NAMES.fetch(code, code.upcase)} (#{code})"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTours
4
+ module WidgetHelper
5
+ def product_tours_tag
6
+ return unless ProductTours.enabled?(request)
7
+
8
+ ProductTours::Widget.snippet(
9
+ locale: ProductTours.locale(request),
10
+ nonce: (content_security_policy_nonce if respond_to?(:content_security_policy_nonce))
11
+ ).html_safe
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTours
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module ProductTours
6
+ class Post < ApplicationRecord
7
+ KEY_FORMAT = /\A[a-z0-9]+(?:[._-][a-z0-9]+)*\z/
8
+ STATUSES = %w[draft published].freeze
9
+ DASHBOARD_STATUSES = %w[published draft].freeze
10
+
11
+ enum :status, STATUSES.index_by(&:itself), default: :draft
12
+
13
+ has_rich_text :description if respond_to?(:has_rich_text)
14
+
15
+ has_one_attached :video, service: ProductTours.config.storage_service if defined?(::ActiveStorage)
16
+
17
+ validates :key, presence: true, format: { with: KEY_FORMAT }, uniqueness: { scope: :locale }
18
+ validates :locale, presence: true
19
+ validates :status, inclusion: { in: STATUSES }
20
+ validates :title, presence: true
21
+ validate :action_has_one_destination
22
+ validate :action_post_is_linkable
23
+ validate :action_url_is_safe
24
+ validate :video_url_is_supported
25
+ validate :uploaded_file_is_video
26
+
27
+ before_validation :set_default_locale
28
+ before_validation :derive_video_metadata, if: :will_save_change_to_video_url?
29
+
30
+ scope :newest_first, -> { order(updated_at: :desc, id: :desc) }
31
+
32
+ def self.description_supported?
33
+ reflect_on_association(:rich_text_description).present? &&
34
+ ActionText::RichText.table_exists?
35
+ rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid, NameError
36
+ false
37
+ end
38
+
39
+ def self.video_upload_supported?
40
+ reflect_on_association(:video_attachment).present? &&
41
+ ActiveStorage::Blob.table_exists? && ActiveStorage::Attachment.table_exists?
42
+ rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid, NameError
43
+ false
44
+ end
45
+
46
+ def uploaded_video?
47
+ self.class.video_upload_supported? && video.attached?
48
+ end
49
+
50
+ def resolved_video
51
+ return { kind: 'upload', provider: 'upload', url: nil } if uploaded_video?
52
+
53
+ resolved = VideoResolver.resolve(video_url)
54
+ return if resolved.nil?
55
+
56
+ {
57
+ kind: resolved.kind,
58
+ provider: resolved.provider,
59
+ url: resolved.url,
60
+ thumbnail_url: video_metadata.to_h['thumbnail_url']
61
+ }.compact
62
+ end
63
+
64
+ def action_post
65
+ return if action_post_key.blank?
66
+
67
+ self.class.find_by(locale: locale, key: action_post_key)
68
+ end
69
+
70
+ private
71
+
72
+ def set_default_locale
73
+ self.locale = I18n.default_locale.to_s if locale.blank?
74
+ end
75
+
76
+ def derive_video_metadata
77
+ resolved = VideoResolver.resolve(video_url)
78
+ self.video_metadata = resolved ? video_metadata.to_h.merge(resolved.metadata) : {}
79
+ end
80
+
81
+ def video_url_is_supported
82
+ return if video_url.blank? || VideoResolver.resolve(video_url)
83
+
84
+ errors.add(:video_url, 'must be an HTTPS YouTube, Vimeo, Loom, Tella, Voomly, MP4, or WebM URL')
85
+ end
86
+
87
+ def action_url_is_safe
88
+ return if action_url.blank?
89
+ return if action_url.start_with?('/') && !action_url.start_with?('//')
90
+
91
+ uri = URI.parse(action_url)
92
+ return if uri.is_a?(URI::HTTP) && %w[http https].include?(uri.scheme) && uri.host.present? && uri.userinfo.nil?
93
+
94
+ errors.add(:action_url, 'must be a relative path or an HTTP(S) URL')
95
+ rescue URI::InvalidURIError
96
+ errors.add(:action_url, 'must be a relative path or an HTTP(S) URL')
97
+ end
98
+
99
+ def action_has_one_destination
100
+ return unless action_url.present? && action_post_key.present?
101
+
102
+ errors.add(:base, 'Primary action can open a URL or another post, not both')
103
+ end
104
+
105
+ def action_post_is_linkable
106
+ return if action_post_key.blank?
107
+
108
+ unless action_post_key.match?(KEY_FORMAT)
109
+ errors.add(:action_post_key, 'must use a valid post key')
110
+ return
111
+ end
112
+ if action_post_key == key
113
+ errors.add(:action_post_key, 'cannot link to the same post')
114
+ return
115
+ end
116
+ return if locale.blank? || self.class.exists?(locale: locale, key: action_post_key)
117
+
118
+ errors.add(:action_post_key, 'must identify an existing post in the same locale')
119
+ end
120
+
121
+ def uploaded_file_is_video
122
+ return unless uploaded_video?
123
+ return if video.blob.content_type.to_s.start_with?('video/')
124
+
125
+ errors.add(:video, 'must be a video file')
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,20 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title><%= t("product_tours.dashboard.title", default: "Product tours") %></title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+ <%= stylesheet_link_tag "#{dashboard_stylesheet_path}?v=#{ProductTours::Widget.dashboard_stylesheet_fingerprint}",
9
+ "data-turbo-track": "reload" %>
10
+ <%= javascript_include_tag "#{dashboard_script_path}?v=#{ProductTours::Widget.dashboard_fingerprint}",
11
+ "data-turbo-track": "reload", defer: true, nonce: true %>
12
+ </head>
13
+ <body class="<%= content_for?(:body_class) ? yield(:body_class) : '' %>">
14
+ <div class="container">
15
+ <% if notice.present? %><div class="flash notice"><%= notice %></div><% end %>
16
+ <% if alert.present? %><div class="flash alert"><%= alert %></div><% end %>
17
+ <%= yield %>
18
+ </div>
19
+ </body>
20
+ </html>
@@ -0,0 +1,177 @@
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|
4
+ { id: candidate.id, key: candidate.key, locale: candidate.locale, status: candidate.status,
5
+ label: "#{candidate.title} — #{candidate.key} (#{candidate.status})" }
6
+ end %>
7
+ <% video_source = params[:video_source].presence || (post.uploaded_video? ? "upload" : "url") %>
8
+ <% if post.errors.any? %>
9
+ <div class="errors" role="alert">
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>
12
+ </div>
13
+ <% end %>
14
+
15
+ <div class="form-card">
16
+ <section class="form-section">
17
+ <h2><%= t("product_tours.dashboard.identity", default: "Tutorial details") %></h2>
18
+ <div class="field">
19
+ <%= form.label :title %>
20
+ <%= form.text_field :title, required: true, autofocus: true %>
21
+ </div>
22
+ <div class="field">
23
+ <%= form.label :key, t("product_tours.dashboard.trigger_key", default: "Trigger key") %>
24
+ <%= form.text_field :key, required: true, disabled: @identity_locked,
25
+ pattern: "[a-z0-9]+([._-][a-z0-9]+)*",
26
+ data: { product_tour_key: true }, placeholder: "billing_setup" %>
27
+ <small>
28
+ <% if @identity_locked %>
29
+ <%= t("product_tours.dashboard.key_locked_hint", default: "Shared by this tutorial and its translations.") %>
30
+ <% else %>
31
+ <%= t("product_tours.dashboard.key_hint", default: "Used by your app to open this tutorial. Lowercase letters and numbers, separated by ., _, or -.") %>
32
+ <% end %>
33
+ </small>
34
+ </div>
35
+ <div class="field">
36
+ <%= form.label :locale, t("product_tours.dashboard.language", default: "Language") %>
37
+ <%= form.text_field :locale, disabled: true %>
38
+ <small>
39
+ <% if post.new_record? %>
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
+ <% else %>
42
+ <%= t("product_tours.dashboard.language_locked_hint", default: "Add and open other languages from the tutorial page.") %>
43
+ <% end %>
44
+ </small>
45
+ </div>
46
+ <div class="field">
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] } %>
49
+ </div>
50
+ </section>
51
+
52
+ <% if ProductTours::Post.description_supported? %>
53
+ <section class="form-section">
54
+ <h2><%= t("product_tours.dashboard.description", default: "Description") %></h2>
55
+ <div class="rich-editor" data-rich-editor>
56
+ <div class="rich-toolbar" role="toolbar" aria-label="<%= t("product_tours.dashboard.formatting", default: "Formatting") %>">
57
+ <button type="button" data-rich-command="bold" aria-label="<%= t("product_tours.dashboard.bold", default: "Bold") %>"><strong>B</strong></button>
58
+ <button type="button" data-rich-command="italic" aria-label="<%= t("product_tours.dashboard.italic", default: "Italic") %>"><em>I</em></button>
59
+ <button type="button" data-rich-command="insertUnorderedList" aria-label="<%= t("product_tours.dashboard.bullet_list", default: "Bulleted list") %>">•</button>
60
+ <button type="button" data-rich-command="insertOrderedList" aria-label="<%= t("product_tours.dashboard.numbered_list", default: "Numbered list") %>">1.</button>
61
+ <button type="button" data-rich-command="createLink" aria-label="<%= t("product_tours.dashboard.link", default: "Link") %>">↗</button>
62
+ </div>
63
+ <%= form.hidden_field :description, value: post.description.body.to_s, data: { rich_input: true } %>
64
+ <div class="rich-content" contenteditable="true" role="textbox" aria-multiline="true"
65
+ aria-label="<%= t("product_tours.dashboard.description", default: "Description") %>"
66
+ data-rich-content><%= post.description.body.to_s.html_safe %></div>
67
+ </div>
68
+ </section>
69
+ <% end %>
70
+
71
+ <section class="form-section">
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? %>">
75
+ <legend class="sr-only"><%= t("product_tours.dashboard.video_source", default: "Video source") %></legend>
76
+ <label class="action-choice">
77
+ <%= radio_button_tag :video_source, "url", video_source == "url", data: { video_source: true } %>
78
+ <span><strong><%= t("product_tours.dashboard.video_link", default: "Use a video link") %></strong>
79
+ <small><%= t("product_tours.dashboard.video_link_hint", default: "Paste a link from a supported provider or a direct video URL.") %></small></span>
80
+ </label>
81
+ <label class="action-choice">
82
+ <%= radio_button_tag :video_source, "upload", video_source == "upload", data: { video_source: true } %>
83
+ <span><strong><%= t("product_tours.dashboard.video_upload", default: "Upload a video") %></strong>
84
+ <small><%= t("product_tours.dashboard.video_upload_hint", default: "Choose a video file from your computer.") %></small></span>
85
+ </label>
86
+ </fieldset>
87
+ <% end %>
88
+
89
+ <div data-video-source-fields="url" <%= "hidden" unless video_source == "url" %>>
90
+ <div class="field">
91
+ <%= form.label :video_url, t("product_tours.dashboard.video_url", default: "Video URL") %>
92
+ <%= form.url_field :video_url, placeholder: "https://www.loom.com/share/...",
93
+ disabled: video_source != "url", data: { video_preview_input: true } %>
94
+ <small><%= t("product_tours.dashboard.video_hint", default: "YouTube, Vimeo, Loom, Tella, Voomly, or a direct MP4/WebM URL.") %></small>
95
+ </div>
96
+ <div class="form-video-preview" data-video-preview
97
+ data-endpoint="<%= video_preview_posts_path %>"
98
+ data-loading="<%= t("product_tours.dashboard.video_loading", default: "Loading preview…") %>"
99
+ data-error="<%= t("product_tours.dashboard.video_preview_failed", default: "This video could not be previewed.") %>"
100
+ hidden>
101
+ <div class="video-preview" data-video-preview-frame></div>
102
+ <small data-video-preview-status role="status"></small>
103
+ </div>
104
+ </div>
105
+
106
+ <% if ProductTours::Post.video_upload_supported? %>
107
+ <div data-video-source-fields="upload" <%= "hidden" unless video_source == "upload" %>>
108
+ <div class="field">
109
+ <%= form.label :video, t("product_tours.dashboard.video_file", default: "Video file") %>
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>
114
+ <% end %>
115
+ </div>
116
+ </div>
117
+ <% end %>
118
+ </section>
119
+
120
+ <section class="form-section">
121
+ <h2><%= t("product_tours.dashboard.action", default: "Primary action") %></h2>
122
+ <p class="section-intro"><%= t("product_tours.dashboard.action_intro", default: "Choose what happens when the visitor clicks the main button.") %></p>
123
+ <fieldset class="action-picker" data-action-picker>
124
+ <legend class="sr-only"><%= t("product_tours.dashboard.action_destination", default: "Button destination") %></legend>
125
+ <label class="action-choice">
126
+ <%= radio_button_tag :action_target, "close", action_target == "close", data: { action_target: true } %>
127
+ <span><strong><%= t("product_tours.dashboard.action_finish", default: "Finish and close") %></strong>
128
+ <small><%= t("product_tours.dashboard.action_finish_hint", default: "Marks this tutorial complete and closes the modal.") %></small></span>
129
+ </label>
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>
134
+ </label>
135
+ <label class="action-choice">
136
+ <%= radio_button_tag :action_target, "url", action_target == "url", data: { action_target: true } %>
137
+ <span><strong><%= t("product_tours.dashboard.action_url_choice", default: "Open a page") %></strong>
138
+ <small><%= t("product_tours.dashboard.action_url_hint", default: "Marks this tutorial complete, then sends the visitor to a URL.") %></small></span>
139
+ </label>
140
+ </fieldset>
141
+
142
+ <div class="field">
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"),
145
+ data: { action_label: true } %>
146
+ <small><%= t("product_tours.dashboard.action_label_hint", default: "Optional. A sensible label is used when left blank.") %></small>
147
+ </div>
148
+
149
+ <div data-action-fields="post">
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>
159
+ </div>
160
+ </div>
161
+
162
+ <div data-action-fields="url">
163
+ <div class="field">
164
+ <%= form.label :action_url %>
165
+ <%= form.text_field :action_url, placeholder: "/settings", required: true %>
166
+ <small><%= t("product_tours.dashboard.action_hint", default: "Use an app path such as /settings or a complete HTTP(S) URL.") %></small>
167
+ </div>
168
+ </div>
169
+ <script type="application/json" data-action-post-options><%= raw json_escape(linkable_posts.to_json) %></script>
170
+ </section>
171
+
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" %>
175
+ </div>
176
+ </div>
177
+ <% end %>
@@ -0,0 +1,111 @@
1
+ <section class="post-panel">
2
+ <div class="detail-scroll">
3
+ <article class="card pad preview-card">
4
+ <h2><%= post.title %></h2>
5
+ <% video = post.resolved_video %>
6
+ <% if video %>
7
+ <div class="video-preview">
8
+ <% if video[:kind] == "iframe" %>
9
+ <iframe src="<%= video[:url] %>" title="<%= post.title %>" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>
10
+ <% else %>
11
+ <video src="<%= post.uploaded_video? ? media_path(post) : video[:url] %>" controls playsinline preload="metadata"
12
+ <% if video[:thumbnail_url].present? %>poster="<%= video[:thumbnail_url] %>"<% end %>
13
+ data-video-first-frame></video>
14
+ <% end %>
15
+ </div>
16
+ <% end %>
17
+ <% if ProductTours::Post.description_supported? && post.description.present? %>
18
+ <div class="description-preview"><%= post.description %></div>
19
+ <% end %>
20
+ <div class="preview-action">
21
+ <% default_label = post.action_post_key.present? ? t("product_tours.next", default: "Next") : t("product_tours.done", default: "Done") %>
22
+ <% if post.action_post_key.present? %><span class="preview-back"><%= t("product_tours.back", default: "Back") %></span><% end %>
23
+ <span class="button primary"><%= post.action_label.presence || default_label %></span>
24
+ </div>
25
+ </article>
26
+
27
+ <article class="card pad details-card">
28
+ <dl>
29
+ <% target_post = post.action_post %>
30
+ <dt><%= t("product_tours.dashboard.key", default: "Key") %></dt>
31
+ <dd><code class="key"><%= post.key %></code></dd>
32
+ <dt><%= t("product_tours.dashboard.language", default: "Language") %></dt>
33
+ <dd><span class="badge locale"><%= post.locale %></span></dd>
34
+ <dt><%= t("product_tours.dashboard.publication_status", default: "Publication status") %></dt>
35
+ <dd><span class="badge status-<%= post.status %>"><%= post.status.humanize %></span></dd>
36
+ <dt><%= t("product_tours.dashboard.video_url", default: "Video URL") %></dt><dd><%= post.video_url.presence || "—" %></dd>
37
+ <dt><%= t("product_tours.dashboard.action_label", default: "Action label") %></dt><dd><%= post.action_label.presence || default_label %></dd>
38
+ <dt><%= t("product_tours.dashboard.action_url", default: "Action URL") %></dt><dd><%= post.action_url.presence || "—" %></dd>
39
+ <dt><%= t("product_tours.dashboard.next_post", default: "Next tutorial") %></dt>
40
+ <dd>
41
+ <% if target_post %>
42
+ <%= link_to target_post.title, post_path(target_post) %> <code class="key"><%= post.action_post_key %></code>
43
+ <% else %>
44
+
45
+ <% end %>
46
+ </dd>
47
+ </dl>
48
+ <% if post.video_url.present? %>
49
+ <%= button_to t("product_tours.dashboard.refresh_metadata", default: "Refresh video data"),
50
+ refresh_video_metadata_post_path(post), class: "button small refresh-button" %>
51
+ <% end %>
52
+ <% if post.video_metadata.present? %><pre class="metadata"><%= JSON.pretty_generate(post.video_metadata) %></pre><% end %>
53
+
54
+ <div class="actions details-actions">
55
+ <% if post.draft? %>
56
+ <%= button_to t("product_tours.dashboard.publish", default: "Publish"), publish_post_path(post), method: :patch,
57
+ class: "button primary" %>
58
+ <%= link_to t("product_tours.dashboard.edit", default: "Edit"), edit_post_path(post), class: "button" %>
59
+ <% else %>
60
+ <%= link_to t("product_tours.dashboard.edit", default: "Edit"), edit_post_path(post), class: "button primary" %>
61
+ <%= button_to t("product_tours.dashboard.unpublish", default: "Move to drafts"), unpublish_post_path(post),
62
+ method: :patch, class: "button" %>
63
+ <% end %>
64
+ <%= button_to t("product_tours.dashboard.delete", default: "Delete"), post_path(post), method: :delete,
65
+ class: "danger", form: { data: { confirm: t("product_tours.dashboard.delete_confirm", default: "Delete this tutorial?") } } %>
66
+ </div>
67
+ </article>
68
+
69
+ <article class="card pad translations-card">
70
+ <h2><%= t("product_tours.dashboard.languages", default: "Languages") %></h2>
71
+ <div class="translation-list">
72
+ <% @translations.each do |translation| %>
73
+ <div class="translation-row">
74
+ <span class="translation-name">
75
+ <span class="badge locale"><%= product_tours_locale_name(translation.locale) %></span>
76
+ <% if translation.locale == I18n.default_locale.to_s %>
77
+ <span class="muted"><%= t("product_tours.dashboard.default_language", default: "Default") %></span>
78
+ <% end %>
79
+ <span class="badge status-<%= translation.status %>"><%= translation.status.humanize %></span>
80
+ </span>
81
+ <% if translation.id == post.id %>
82
+ <span class="muted"><%= t("product_tours.dashboard.current_language", default: "Current") %></span>
83
+ <% else %>
84
+ <% translation_path = if sidebar_context
85
+ posts_path(status: @status, q: @query, page: @page,
86
+ post_id: translation.id)
87
+ else
88
+ post_path(translation)
89
+ end %>
90
+ <%= link_to t("product_tours.dashboard.open", default: "Open"), translation_path %>
91
+ <% end %>
92
+ </div>
93
+ <% end %>
94
+ </div>
95
+
96
+ <% if @missing_translation_locales.any? %>
97
+ <%= form_with url: add_translation_post_path(post), method: :post, class: "translation-form" do |form| %>
98
+ <div class="field">
99
+ <%= form.label :locale, t("product_tours.dashboard.add_language", default: "Add another language") %>
100
+ <%= form.select :locale,
101
+ options_for_select(@missing_translation_locales.map { |locale| [product_tours_locale_name(locale), locale] }) %>
102
+ </div>
103
+ <%= form.submit t("product_tours.dashboard.add_translation", default: "Add translation"), class: "button" %>
104
+ <% end %>
105
+ <% else %>
106
+ <p class="muted translation-complete"><%= t("product_tours.dashboard.all_languages_added", default: "All available languages have been added.") %></p>
107
+ <% end %>
108
+ </article>
109
+
110
+ </div>
111
+ </section>
@@ -0,0 +1,5 @@
1
+ <% content_for :body_class, "pt-form-page" %>
2
+
3
+ <div class="breadcrumb"><%= link_to t("product_tours.dashboard.title", default: "Product tours"), posts_path %> / <%= link_to @post.title, post_path(@post) %> / <%= t("product_tours.dashboard.edit", default: "Edit") %></div>
4
+ <h1><%= t("product_tours.dashboard.edit_post", default: "Edit tutorial") %></h1>
5
+ <%= render "form", post: @post %>
@@ -0,0 +1,92 @@
1
+ <% content_for :body_class, "pt-index" %>
2
+
3
+ <div class="page-head">
4
+ <h1><%= t("product_tours.dashboard.title", default: "Product tours") %></h1>
5
+ <%= link_to t("product_tours.dashboard.new_post", default: "New product tour"), new_post_path, class: "button primary" %>
6
+ </div>
7
+
8
+ <div class="dashboard-shell <%= "has-selected" if @selected_post %>">
9
+ <aside class="dashboard-sidebar" aria-label="<%= t("product_tours.dashboard.posts", default: "Tutorials") %>">
10
+ <nav class="tabs">
11
+ <% ProductTours::Post::DASHBOARD_STATUSES.each do |status| %>
12
+ <%= link_to posts_path(status: status, q: @query),
13
+ class: ("active" if status == @status) do %>
14
+ <%= status.humanize %>
15
+ <span class="count"><%= @counts.fetch(status, 0) %></span>
16
+ <% end %>
17
+ <% end %>
18
+ </nav>
19
+
20
+ <%= form_with url: posts_path, method: :get, class: "filters" do |form| %>
21
+ <%= form.hidden_field :status, value: @status %>
22
+ <%= form.search_field :q, value: @query,
23
+ list: "product-tour-key-options",
24
+ placeholder: t("product_tours.dashboard.search_key", default: "Search key"),
25
+ aria: { label: t("product_tours.dashboard.search", default: "Search") } %>
26
+ <datalist id="product-tour-key-options">
27
+ <% @keys.each do |key| %>
28
+ <%= tag.option value: key %>
29
+ <% end %>
30
+ </datalist>
31
+ <%= form.submit t("product_tours.dashboard.search", default: "Search") %>
32
+ <% if @query.present? %>
33
+ <%= link_to t("product_tours.dashboard.clear_filters", default: "Clear"), posts_path,
34
+ class: "button filters-clear" %>
35
+ <% end %>
36
+ <% end %>
37
+
38
+ <div class="record-list">
39
+ <% if @posts.any? %>
40
+ <% @posts.each do |post| %>
41
+ <% provider = post.resolved_video&.dig(:provider) %>
42
+ <%= link_to posts_path(status: @status, q: @query, page: @page, post_id: post.id),
43
+ class: ["record-row", ("active" if @selected_post&.id == post.id)].compact do %>
44
+ <span class="record-main">
45
+ <span class="record-topline">
46
+ <span class="badge status-<%= post.status %>"><%= post.status.humanize %></span>
47
+ </span>
48
+ <span class="record-copy"><%= post.title %></span>
49
+ <span class="muted record-meta">
50
+ <code class="key"><%= post.key %></code>
51
+ <% if provider %><span><%= provider.humanize %></span><% end %>
52
+ </span>
53
+ </span>
54
+ <span class="record-side" aria-hidden="true"><%= provider ? "▶" : "→" %></span>
55
+ <% end %>
56
+ <% end %>
57
+ <% else %>
58
+ <div class="empty"><%= t("product_tours.dashboard.empty", default: "Nothing here yet.") %></div>
59
+ <% end %>
60
+ </div>
61
+
62
+ <nav class="pager">
63
+ <span>
64
+ <% if @page > 1 %>
65
+ <%= link_to t("product_tours.dashboard.newer", default: "← Newer"),
66
+ posts_path(status: @status, q: @query, page: @page - 1) %>
67
+ <% end %>
68
+ </span>
69
+ <span>
70
+ <% if @more %>
71
+ <%= link_to t("product_tours.dashboard.older", default: "Older →"),
72
+ posts_path(status: @status, q: @query, page: @page + 1) %>
73
+ <% end %>
74
+ </span>
75
+ </nav>
76
+ </aside>
77
+
78
+ <main class="dashboard-detail" aria-label="<%= t("product_tours.dashboard.current_post", default: "Current tutorial") %>">
79
+ <% if @selected_post %>
80
+ <p class="mobile-back">
81
+ <%= link_to t("product_tours.dashboard.back", default: "All tutorials"),
82
+ posts_path(status: @status, q: @query, page: @page) %>
83
+ </p>
84
+ <%= render "post_panel", post: @selected_post, sidebar_context: true %>
85
+ <% else %>
86
+ <div class="empty-state">
87
+ <h2><%= t("product_tours.dashboard.select_post", default: "Select a tutorial") %></h2>
88
+ <p class="muted"><%= t("product_tours.dashboard.select_post_hint", default: "Open a tutorial to preview or edit it.") %></p>
89
+ </div>
90
+ <% end %>
91
+ </main>
92
+ </div>
@@ -0,0 +1,5 @@
1
+ <% content_for :body_class, "pt-form-page" %>
2
+
3
+ <div class="breadcrumb"><%= link_to t("product_tours.dashboard.title", default: "Product tours"), posts_path %> / <%= t("product_tours.dashboard.new_post", default: "New product tour") %></div>
4
+ <h1><%= t("product_tours.dashboard.new_post", default: "New product tour") %></h1>
5
+ <%= render "form", post: @post %>
@@ -0,0 +1,5 @@
1
+ <% content_for :body_class, "pt-show" %>
2
+
3
+ <p class="breadcrumb"><%= link_to t("product_tours.dashboard.title", default: "Product tours"), posts_path %> / <%= @post.title %></p>
4
+
5
+ <%= render "post_panel", post: @post, sidebar_context: false %>
@@ -0,0 +1,11 @@
1
+ ar:
2
+ product_tours:
3
+ close: "إغلاق"
4
+ back: "رجوع"
5
+ next: "التالي"
6
+ done: "تم"
7
+ dialog: "دليل المنتج"
8
+ video: "فيديو تعليمي"
9
+ unavailable: "هذا الدليل غير متاح."
10
+ dashboard:
11
+ title: "جولات المنتج"