product_tours 1.1.0 → 1.3.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +2 -2
  3. data/CHANGELOG.md +36 -1
  4. data/README.md +28 -7
  5. data/SECURITY.md +2 -2
  6. data/app/controllers/product_tours/interactions_controller.rb +20 -2
  7. data/app/controllers/product_tours/tours_controller.rb +3 -0
  8. data/app/models/product_tours/tour.rb +54 -3
  9. data/app/views/product_tours/tours/_form.html.erb +18 -3
  10. data/app/views/product_tours/tours/_tour_panel.html.erb +6 -0
  11. data/config/locales/product_tours.ar.yml +1 -0
  12. data/config/locales/product_tours.bg.yml +1 -0
  13. data/config/locales/product_tours.bn.yml +1 -0
  14. data/config/locales/product_tours.de.yml +1 -0
  15. data/config/locales/product_tours.el.yml +1 -0
  16. data/config/locales/product_tours.en.yml +1 -0
  17. data/config/locales/product_tours.es.yml +1 -0
  18. data/config/locales/product_tours.fr.yml +1 -0
  19. data/config/locales/product_tours.hi.yml +1 -0
  20. data/config/locales/product_tours.hr.yml +1 -0
  21. data/config/locales/product_tours.id.yml +1 -0
  22. data/config/locales/product_tours.it.yml +1 -0
  23. data/config/locales/product_tours.ja.yml +1 -0
  24. data/config/locales/product_tours.ko.yml +1 -0
  25. data/config/locales/product_tours.lb.yml +1 -0
  26. data/config/locales/product_tours.nl.yml +1 -0
  27. data/config/locales/product_tours.pl.yml +1 -0
  28. data/config/locales/product_tours.pt.yml +1 -0
  29. data/config/locales/product_tours.ro.yml +1 -0
  30. data/config/locales/product_tours.ru.yml +1 -0
  31. data/config/locales/product_tours.th.yml +1 -0
  32. data/config/locales/product_tours.tr.yml +1 -0
  33. data/config/locales/product_tours.uk.yml +1 -0
  34. data/config/locales/product_tours.ur.yml +1 -0
  35. data/config/locales/product_tours.vi.yml +1 -0
  36. data/config/locales/product_tours.zh-CN.yml +1 -0
  37. data/lib/generators/product_tours/install/templates/create_product_tours_tours.rb.tt +2 -0
  38. data/lib/generators/product_tours/upgrade/templates/add_player_media_to_product_tours_tours.rb.tt +17 -0
  39. data/lib/generators/product_tours/upgrade/upgrade_generator.rb +29 -0
  40. data/lib/product_tours/version.rb +1 -1
  41. data/lib/product_tours/video_resolver.rb +10 -0
  42. data/lib/product_tours/widget.js +13 -1
  43. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5493d4177a606096eb7c630c0dd4fbab70a5b68ec013e3033b58217ec7de5c02
4
- data.tar.gz: f150d164554a4d4a710214b2924fb191aa28e8aee6f87f813cb5c3418e6c9efc
3
+ metadata.gz: 7c62b8b42042732f293443f7cdfc154b1679fd7b0ba0c6774aca4ade157a54fa
4
+ data.tar.gz: 88dc14f258d8e5bd14260fe40dbf77c0c58e8adc7816f26a6bb31c4cbd4122b2
5
5
  SHA512:
6
- metadata.gz: 137467ccfedd59ff4abaa2b4a8f35735b8b02bcde9a9439901905191036d419b221f810f92d57ae66737d45ed492b7b5583e0043006ec68eb7c193bac8fa60d5
7
- data.tar.gz: 91993e998b3d5fa607b35d38d20c58c3dfe6963d9a3860df6dc2dd9c8f872f496d7bc501e3ae26301bf843987fd53a532fe2eaacd1785c9b6dc7fe093df3776f
6
+ metadata.gz: 705534e0de88c34651aa56751e663f4a2d31830b935d09e65a0d8a3c86bc56026630e55e009c5ad82489a6684c693a6395a5f7b9c855c263bee33295fd6510ab
7
+ data.tar.gz: 4804cc836bac346588ccbab6c547b205fb6e17d45163b02d34cab395d6c272cabce2870793abaaca128691973845ff1ab19b0565f32cfe978ddd4e7a875a124c
data/AGENTS.md CHANGED
@@ -75,7 +75,7 @@ What a tutorial carries: a `key`, a title, an optional rich description, an opti
75
75
 
76
76
  ### Video providers
77
77
 
78
- YouTube, Vimeo, Loom, Tella, Voomly, a direct MP4/WebM URL, or an uploaded file (needs Active Storage). Uploaded videos stream through the engine at `/product_tours/media/:id` — not a public blob URL. Do not build your own blob links.
78
+ YouTube, Vimeo, Loom, Tella, Voomly, a direct MP4/WebM URL, or an uploaded file (needs Active Storage). A direct or uploaded video can also take a poster and a WebVTT captions file in the tutorial's language (`poster_url`, `captions_url`); provider embeds keep their own. An app installed before 1.2 lacks those columns until it runs `bin/rails generate product_tours:upgrade` and migrates — the model's `poster_supported?`/`captions_supported?` say whether they exist. Uploaded videos stream through the engine at `/product_tours/media/:id` — not a public blob URL. Do not build your own blob links.
79
79
 
80
80
  **The engine edits the app's Content Security Policy.** It appends the providers' embed hosts to `frame-src` (or to `default-src` when `frame-src` is unset) in an initializer that runs after the host's own. That is deliberate — an embed silently blocked by CSP is a bad first five minutes — but know it happens, and do not hand-add those `frame-src` entries yourself. If the app sets its CSP somewhere unusual (a middleware, a per-controller override), that is where a blocked embed will come from.
81
81
 
@@ -164,7 +164,7 @@ bundle exec rubocop # must be clean
164
164
  BUNDLE_GEMFILE=gemfiles/rails_7.1.gemfile bundle exec rake test # 7.1, 7.2, 8.0, 8.1 in gemfiles/
165
165
  ```
166
166
 
167
- Layout: `app/` controllers, `Tour`, dashboard views · `lib/product_tours/` config, widget JS, seeds, engine, CSP patch · `lib/generators/product_tours/install/` the one generator · `config/locales/` 26 locales · `test/` minitest with `test/dummy` as the host app.
167
+ Layout: `app/` controllers, `Tour`, dashboard views · `lib/product_tours/` config, widget JS, seeds, engine, CSP patch · `lib/generators/product_tours/` the install generator and the idempotent upgrade generator · `config/locales/` 26 locales · `test/` minitest with `test/dummy` as the host app.
168
168
 
169
169
  Conventions this codebase holds to — follow them rather than the first thing that works:
170
170
 
data/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.3.0] - 2026-11-09
6
+
7
+ - **A direct video can be a path on the host app**, such as
8
+ `/tutorials/billing.en.mp4`, like the poster and captions already could. The
9
+ tour then plays from whichever environment holds the record, rather than
10
+ pointing a copied staging or development database at production media.
11
+ Protocol-relative (`//host/…`) and bare relative paths are still rejected.
12
+ - The dashboard's video field is now a text field, since the browser's URL
13
+ field refuses a path. It costs the browser's own URL check; the model's
14
+ validation still decides.
15
+
16
+ ## [1.2.0] - 2026-11-03
17
+
18
+ - **Direct and uploaded videos can carry a poster and captions.** A tutorial
19
+ takes a `poster_url` and a WebVTT `captions_url` in its own language; the
20
+ widget sets the poster and adds a captions track labeled with the language's
21
+ own name, from a new `captions_label` key in all 26 locales. Without a
22
+ poster, the player still seeks to an early frame. Provider embeds are
23
+ unchanged, and a poster or captions on one is a validation error rather than
24
+ a silently ignored field. The modal's video also gains an accessible name.
25
+ - Both fields accept a relative path or an HTTPS URL, nothing else. A `.vtt`
26
+ on another origin needs CORS, and the gem does not touch `media-src` or
27
+ `img-src`: the host's policy decides what loads.
28
+ - A new translation copies the poster and drops the captions, since the
29
+ source's captions are in the wrong language.
30
+ - **Costs an upgrade step for existing installs.** Fresh installs get both
31
+ columns. Installs from 1.0 or 1.1 run `bin/rails generate
32
+ product_tours:upgrade` and migrate; until they do, the model reports
33
+ `captions_supported?`/`poster_supported?` as false, the dashboard hides both
34
+ fields and everything else works as before. The upgrade migration checks
35
+ each column first and is reversible.
36
+
5
37
  ## [1.1.0] - 2026-10-18
6
38
 
7
39
  - Add Rails edge CI and experimental Ruby 4.0+ Ractor request support. The host
@@ -221,7 +253,10 @@
221
253
  - Removed generic tutorial duplication now that translations provide the only
222
254
  intentional content-copying workflow.
223
255
 
224
- [Unreleased]: https://github.com/yshmarov/product_tours/compare/v1.0.0...HEAD
256
+ [Unreleased]: https://github.com/yshmarov/product_tours/compare/v1.3.0...HEAD
257
+ [1.3.0]: https://github.com/yshmarov/product_tours/compare/v1.2.0...v1.3.0
258
+ [1.2.0]: https://github.com/yshmarov/product_tours/compare/v1.1.0...v1.2.0
259
+ [1.1.0]: https://github.com/yshmarov/product_tours/compare/v1.0.0...v1.1.0
225
260
  [1.0.0]: https://github.com/yshmarov/product_tours/compare/v0.3.3...v1.0.0
226
261
  [0.3.3]: https://github.com/yshmarov/product_tours/compare/v0.3.2...v0.3.3
227
262
  [0.3.2]: https://github.com/yshmarov/product_tours/compare/v0.3.1...v0.3.2
data/README.md CHANGED
@@ -144,14 +144,21 @@ Paste any supported HTTPS URL:
144
144
  | Loom | Share and embed links |
145
145
  | Tella | Video links |
146
146
  | Voomly | Share, video, and embed links |
147
- | Direct | URLs ending in `.mp4` or `.webm` |
147
+ | Direct | HTTPS URLs or paths on your app (`/videos/intro.mp4`) ending in `.mp4` or `.webm` |
148
148
 
149
149
  YouTube, Vimeo, and Loom metadata comes from their oEmbed endpoints. Metadata is
150
150
  best-effort: a timeout never prevents you from saving a valid URL. YouTube uses
151
151
  `youtube-nocookie.com`; unsupported hosts and lookalike URLs fail closed.
152
152
 
153
- Direct videos seek to an early frame for a useful preview instead of showing an
154
- empty player.
153
+ Direct and uploaded videos play in the gem's own player, so they can carry a
154
+ **poster** and **captions**: a WebVTT file in the tutorial's language, labeled
155
+ with that language's own name in the player's captions menu. Both take a path in
156
+ your app or an HTTPS URL. Serve the `.vtt` from your app's origin; a file on
157
+ another origin needs CORS, and your `media-src`/`img-src` policy must allow
158
+ whatever origin you use. Without a poster, the player seeks to an early frame
159
+ instead of showing an empty box. Provider embeds keep their own poster and
160
+ captions. A new translation starts without captions, because the source's file
161
+ is in the wrong language.
155
162
 
156
163
  <details>
157
164
  <summary><b>Upload videos or add rich descriptions</b></summary>
@@ -344,10 +351,11 @@ The following are the model and integration contracts that 1.x will keep
344
351
  stable under semantic versioning:
345
352
 
346
353
  - `ProductTours::Tour`, including `key`, `locale`, `status`, `title`,
347
- `video_url`, `video_metadata`, `action_label`, `action_url`,
348
- `action_tour_key`, timestamps, `STATUSES`, status methods/scopes,
349
- `newest_first`, `action_tour`, `description_supported?`,
350
- `video_upload_supported?`, and optional rich-description/video behavior.
354
+ `video_url`, `video_metadata`, `poster_url`, `captions_url`, `action_label`,
355
+ `action_url`, `action_tour_key`, timestamps, `STATUSES`, status
356
+ methods/scopes, `newest_first`, `action_tour`, `description_supported?`,
357
+ `video_upload_supported?`, `poster_supported?`, `captions_supported?`, and
358
+ optional rich-description/video behavior.
351
359
  - `ProductTours.configure` and the documented configuration options.
352
360
  - `mount_product_tours`, `product_tours_tag`, and `data-product-tour="key"`.
353
361
  - The tutorial key grammar and per-locale uniqueness contract.
@@ -363,6 +371,19 @@ Dashboard controllers, view partials, CSS classes, generated HTML, and widget
363
371
  implementation objects are private. Incompatible changes to the public list
364
372
  above wait for a new major version; a deprecation normally ships first.
365
373
 
374
+ ### Upgrading from 1.0 or 1.1
375
+
376
+ Posters and captions (1.2) need two new columns. Until you add them, the
377
+ dashboard simply does not offer those fields:
378
+
379
+ ```bash
380
+ bin/rails generate product_tours:upgrade
381
+ bin/rails db:migrate
382
+ ```
383
+
384
+ The upgrade migration checks each column before adding it, so it is safe on an
385
+ install that already has them.
386
+
366
387
  ### Upgrading from 0.3.x
367
388
 
368
389
  Version 1.0.0 is the deliberate terminology break from the pre-1.0 API.
data/SECURITY.md CHANGED
@@ -25,8 +25,8 @@ which defaults to everyone because showing tutorials in production is the gem's
25
25
  purpose. The dashboard is separately protected by `authorize_admin` and fails
26
26
  closed outside development until the host explicitly grants access.
27
27
 
28
- A tour can store its key, locale, status, title, video URL and metadata, primary
29
- action label/destination, timestamps, optional Action Text description, and an
28
+ A tour can store its key, locale, status, title, video URL and metadata, poster and
29
+ captions URLs, primary action label/destination, timestamps, optional Action Text description, and an
30
30
  optional Active Storage video. Uploaded videos stream through the engine's
31
31
  authorization gate with private, non-stored responses; the route never redirects
32
32
  to a reusable signed blob URL. Hosts still own the storage service, bucket
@@ -47,8 +47,7 @@ module ProductTours
47
47
  end
48
48
 
49
49
  def tour_payload(tour)
50
- video = tour.resolved_video
51
- video[:url] = media_path(tour) if video&.dig(:kind) == 'upload'
50
+ video = player_video(tour)
52
51
  {
53
52
  key: tour.key,
54
53
  locale: tour.locale,
@@ -63,6 +62,25 @@ module ProductTours
63
62
  }
64
63
  end
65
64
 
65
+ def player_video(tour)
66
+ video = tour.resolved_video
67
+ return if video.nil?
68
+
69
+ video[:url] = media_path(tour) if video[:kind] == 'upload'
70
+ video[:poster] = video.delete(:poster_url)
71
+ if (captions_url = video.delete(:captions_url))
72
+ video[:captions] = { url: captions_url, srclang: tour.locale, label: captions_label(tour.locale) }
73
+ end
74
+ video.compact
75
+ end
76
+
77
+ # Named in its own language, as a player's captions menu lists them.
78
+ def captions_label(locale)
79
+ I18n.t(:captions_label, scope: :product_tours, locale: locale, default: locale.to_s)
80
+ rescue I18n::InvalidLocale
81
+ locale.to_s
82
+ end
83
+
66
84
  def default_action_label(tour)
67
85
  key = tour.action_tour_key.present? ? :next : :done
68
86
  I18n.t(key, scope: :product_tours, default: key.to_s.humanize)
@@ -89,6 +89,7 @@ module ProductTours
89
89
  translation = source.dup
90
90
  translation.locale = locale
91
91
  translation.status = 'draft'
92
+ translation.captions_url = nil if Tour.captions_supported?
92
93
  if translation.action_tour_key.present? &&
93
94
  !Tour.exists?(locale: locale, key: translation.action_tour_key)
94
95
  translation.action_tour_key = nil
@@ -160,6 +161,8 @@ module ProductTours
160
161
  permitted = %i[key status title video_url action_label action_url action_tour_key]
161
162
  permitted << :description if Tour.description_supported?
162
163
  permitted << :video if Tour.video_upload_supported?
164
+ permitted << :poster_url if Tour.poster_supported?
165
+ permitted << :captions_url if Tour.captions_supported?
163
166
  params.require(:tour).permit(*permitted)
164
167
  end
165
168
 
@@ -30,6 +30,8 @@ module ProductTours
30
30
  validate :action_url_is_safe
31
31
  validate :video_url_is_supported
32
32
  validate :uploaded_file_is_video
33
+ validate :player_media_urls_are_safe
34
+ validate :player_media_has_a_player
33
35
 
34
36
  before_validation :set_default_locale
35
37
  before_validation :derive_video_metadata, if: :will_save_change_to_video_url?
@@ -50,12 +52,26 @@ module ProductTours
50
52
  false
51
53
  end
52
54
 
55
+ # Added in 1.2. An install from an earlier version has neither column until
56
+ # it runs `bin/rails generate product_tours:upgrade`, and works as before.
57
+ def self.captions_supported?
58
+ column_names.include?('captions_url')
59
+ rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
60
+ false
61
+ end
62
+
63
+ def self.poster_supported?
64
+ column_names.include?('poster_url')
65
+ rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
66
+ false
67
+ end
68
+
53
69
  def uploaded_video?
54
70
  self.class.video_upload_supported? && video.attached?
55
71
  end
56
72
 
57
73
  def resolved_video
58
- return { kind: 'upload', provider: 'upload', url: nil } if uploaded_video?
74
+ return { kind: 'upload', provider: 'upload', url: nil, **player_media } if uploaded_video?
59
75
 
60
76
  resolved = VideoResolver.resolve(video_url)
61
77
  return if resolved.nil?
@@ -64,10 +80,16 @@ module ProductTours
64
80
  kind: resolved.kind,
65
81
  provider: resolved.provider,
66
82
  url: resolved.url,
67
- thumbnail_url: video_metadata.to_h['thumbnail_url']
83
+ thumbnail_url: video_metadata.to_h['thumbnail_url'],
84
+ **(resolved.direct? ? player_media : {})
68
85
  }.compact
69
86
  end
70
87
 
88
+ # The tour's own video player can show a poster and a captions track.
89
+ # Provider iframes cannot: their poster and captions are the provider's.
90
+ def captions_url = (self[:captions_url] if self.class.captions_supported?)
91
+ def poster_url = (self[:poster_url] if self.class.poster_supported?)
92
+
71
93
  def action_tour
72
94
  return if action_tour_key.blank?
73
95
 
@@ -88,7 +110,8 @@ module ProductTours
88
110
  def video_url_is_supported
89
111
  return if video_url.blank? || VideoResolver.resolve(video_url)
90
112
 
91
- errors.add(:video_url, 'must be an HTTPS YouTube, Vimeo, Loom, Tella, Voomly, MP4, or WebM URL')
113
+ errors.add(:video_url,
114
+ 'must be an HTTPS YouTube, Vimeo, Loom, Tella, Voomly, MP4, or WebM URL, or an MP4 or WebM path')
92
115
  end
93
116
 
94
117
  def action_url_is_safe
@@ -125,6 +148,34 @@ module ProductTours
125
148
  errors.add(:action_tour_key, 'must identify an existing tour in the same locale')
126
149
  end
127
150
 
151
+ def player_media
152
+ { poster_url: poster_url.presence, captions_url: captions_url.presence }.compact
153
+ end
154
+
155
+ def player_media_urls_are_safe
156
+ { captions_url: captions_url, poster_url: poster_url }.each do |attribute, value|
157
+ next if value.blank? || safe_media_url?(value)
158
+
159
+ errors.add(attribute, 'must be a relative path or an HTTPS URL')
160
+ end
161
+ end
162
+
163
+ def player_media_has_a_player
164
+ return if captions_url.blank? && poster_url.blank?
165
+ return if uploaded_video? || VideoResolver.resolve(video_url)&.direct?
166
+
167
+ errors.add(:base, 'A poster and captions need a direct MP4/WebM URL or an uploaded video')
168
+ end
169
+
170
+ def safe_media_url?(value)
171
+ return !value.start_with?('//') if value.start_with?('/')
172
+
173
+ uri = URI.parse(value)
174
+ uri.is_a?(URI::HTTPS) && uri.host.present? && uri.userinfo.nil?
175
+ rescue URI::InvalidURIError
176
+ false
177
+ end
178
+
128
179
  def uploaded_file_is_video
129
180
  return unless uploaded_video?
130
181
  return if video.blob.content_type.to_s.start_with?('video/')
@@ -89,9 +89,9 @@
89
89
  <div data-video-source-fields="url" <%= "hidden" unless video_source == "url" %>>
90
90
  <div class="field">
91
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>
92
+ <%= form.text_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 or path.") %></small>
95
95
  </div>
96
96
  <div class="form-video-preview" data-video-preview
97
97
  data-endpoint="<%= video_preview_tours_path %>"
@@ -115,6 +115,21 @@
115
115
  </div>
116
116
  </div>
117
117
  <% end %>
118
+
119
+ <% if ProductTours::Tour.poster_supported? %>
120
+ <div class="field">
121
+ <%= form.label :poster_url, t("product_tours.dashboard.poster_url", default: "Poster image URL") %>
122
+ <%= form.text_field :poster_url, placeholder: "/tutorials/billing-poster.webp" %>
123
+ <small><%= t("product_tours.dashboard.poster_hint", default: "Shown before playback. For direct and uploaded videos; providers show their own. A path in this app or an HTTPS URL.") %></small>
124
+ </div>
125
+ <% end %>
126
+ <% if ProductTours::Tour.captions_supported? %>
127
+ <div class="field">
128
+ <%= form.label :captions_url, t("product_tours.dashboard.captions_url", default: "Captions file URL (WebVTT)") %>
129
+ <%= form.text_field :captions_url, placeholder: "/tutorials/billing.en.vtt" %>
130
+ <small><%= t("product_tours.dashboard.captions_hint", default: "Captions in this tutorial's language, for direct and uploaded videos. Serve the .vtt from this app: a file on another origin needs CORS. A new translation starts without captions.") %></small>
131
+ </div>
132
+ <% end %>
118
133
  </section>
119
134
 
120
135
  <section class="form-section">
@@ -34,6 +34,12 @@
34
34
  <dt><%= t("product_tours.dashboard.publication_status", default: "Publication status") %></dt>
35
35
  <dd><span class="badge status-<%= tour.status %>"><%= tour.status.humanize %></span></dd>
36
36
  <dt><%= t("product_tours.dashboard.video_url", default: "Video URL") %></dt><dd><%= tour.video_url.presence || "—" %></dd>
37
+ <% if ProductTours::Tour.poster_supported? %>
38
+ <dt><%= t("product_tours.dashboard.poster_url", default: "Poster image URL") %></dt><dd><%= tour.poster_url.presence || "—" %></dd>
39
+ <% end %>
40
+ <% if ProductTours::Tour.captions_supported? %>
41
+ <dt><%= t("product_tours.dashboard.captions_url", default: "Captions file URL (WebVTT)") %></dt><dd><%= tour.captions_url.presence || "—" %></dd>
42
+ <% end %>
37
43
  <dt><%= t("product_tours.dashboard.action_label", default: "Action label") %></dt><dd><%= tour.action_label.presence || default_label %></dd>
38
44
  <dt><%= t("product_tours.dashboard.action_url", default: "Action URL") %></dt><dd><%= tour.action_url.presence || "—" %></dd>
39
45
  <dt><%= t("product_tours.dashboard.next_tour", default: "Next tutorial") %></dt>
@@ -6,6 +6,7 @@ ar:
6
6
  done: "تم"
7
7
  dialog: "دليل المنتج"
8
8
  video: "فيديو تعليمي"
9
+ captions_label: "العربية"
9
10
  unavailable: "هذا الدليل غير متاح."
10
11
  dashboard:
11
12
  title: "جولات المنتج"
@@ -6,6 +6,7 @@ bg:
6
6
  done: "Готово"
7
7
  dialog: "Урок за продукта"
8
8
  video: "Видео урок"
9
+ captions_label: "Български"
9
10
  unavailable: "Този урок не е наличен."
10
11
  dashboard:
11
12
  title: "Продуктови обиколки"
@@ -6,6 +6,7 @@ bn:
6
6
  done: "সম্পন্ন"
7
7
  dialog: "পণ্য টিউটোরিয়াল"
8
8
  video: "টিউটোরিয়াল ভিডিও"
9
+ captions_label: "বাংলা"
9
10
  unavailable: "এই টিউটোরিয়ালটি উপলভ্য নয়।"
10
11
  dashboard:
11
12
  title: "পণ্য পরিচিতি"
@@ -6,6 +6,7 @@ de:
6
6
  done: "Fertig"
7
7
  dialog: "Produkttutorial"
8
8
  video: "Tutorialvideo"
9
+ captions_label: "Deutsch"
9
10
  unavailable: "Dieses Tutorial ist nicht verfügbar."
10
11
  dashboard:
11
12
  title: "Produkttouren"
@@ -6,6 +6,7 @@ el:
6
6
  done: "Τέλος"
7
7
  dialog: "Οδηγός προϊόντος"
8
8
  video: "Βίντεο οδηγός"
9
+ captions_label: "Ελληνικά"
9
10
  unavailable: "Αυτός ο οδηγός δεν είναι διαθέσιμος."
10
11
  dashboard:
11
12
  title: "Περιηγήσεις προϊόντος"
@@ -6,6 +6,7 @@ en:
6
6
  done: "Done"
7
7
  dialog: "Product tutorial"
8
8
  video: "Tutorial video"
9
+ captions_label: "English"
9
10
  unavailable: "This tutorial is unavailable."
10
11
  dashboard:
11
12
  title: "Product tours"
@@ -6,6 +6,7 @@ es:
6
6
  done: "Listo"
7
7
  dialog: "Tutorial del producto"
8
8
  video: "Video tutorial"
9
+ captions_label: "Español"
9
10
  unavailable: "Este tutorial no está disponible."
10
11
  dashboard:
11
12
  title: "Recorridos del producto"
@@ -6,6 +6,7 @@ fr:
6
6
  done: "Terminé"
7
7
  dialog: "Tutoriel produit"
8
8
  video: "Vidéo du tutoriel"
9
+ captions_label: "Français"
9
10
  unavailable: "Ce tutoriel n’est pas disponible."
10
11
  dashboard:
11
12
  title: "Visites guidées"
@@ -6,6 +6,7 @@ hi:
6
6
  done: "पूरा हुआ"
7
7
  dialog: "उत्पाद ट्यूटोरियल"
8
8
  video: "ट्यूटोरियल वीडियो"
9
+ captions_label: "हिन्दी"
9
10
  unavailable: "यह ट्यूटोरियल उपलब्ध नहीं है।"
10
11
  dashboard:
11
12
  title: "उत्पाद परिचय"
@@ -6,6 +6,7 @@ hr:
6
6
  done: "Gotovo"
7
7
  dialog: "Vodič kroz proizvod"
8
8
  video: "Video vodič"
9
+ captions_label: "Hrvatski"
9
10
  unavailable: "Ovaj vodič nije dostupan."
10
11
  dashboard:
11
12
  title: "Vodiči kroz proizvod"
@@ -6,6 +6,7 @@ id:
6
6
  done: "Selesai"
7
7
  dialog: "Tutorial produk"
8
8
  video: "Video tutorial"
9
+ captions_label: "Bahasa Indonesia"
9
10
  unavailable: "Tutorial ini tidak tersedia."
10
11
  dashboard:
11
12
  title: "Tur produk"
@@ -6,6 +6,7 @@ it:
6
6
  done: "Fatto"
7
7
  dialog: "Tutorial del prodotto"
8
8
  video: "Video tutorial"
9
+ captions_label: "Italiano"
9
10
  unavailable: "Questo tutorial non è disponibile."
10
11
  dashboard:
11
12
  title: "Tour del prodotto"
@@ -6,6 +6,7 @@ ja:
6
6
  done: "完了"
7
7
  dialog: "製品チュートリアル"
8
8
  video: "チュートリアル動画"
9
+ captions_label: "日本語"
9
10
  unavailable: "このチュートリアルは利用できません。"
10
11
  dashboard:
11
12
  title: "製品ツアー"
@@ -6,6 +6,7 @@ ko:
6
6
  done: "완료"
7
7
  dialog: "제품 튜토리얼"
8
8
  video: "튜토리얼 동영상"
9
+ captions_label: "한국어"
9
10
  unavailable: "이 튜토리얼을 사용할 수 없습니다."
10
11
  dashboard:
11
12
  title: "제품 둘러보기"
@@ -6,6 +6,7 @@ lb:
6
6
  done: "Fäerdeg"
7
7
  dialog: "Produktutorial"
8
8
  video: "Tutorialvideo"
9
+ captions_label: "Lëtzebuergesch"
9
10
  unavailable: "Dësen Tutorial ass net verfügbar."
10
11
  dashboard:
11
12
  title: "Produkttouren"
@@ -6,6 +6,7 @@ nl:
6
6
  done: "Klaar"
7
7
  dialog: "Producttutorial"
8
8
  video: "Tutorialvideo"
9
+ captions_label: "Nederlands"
9
10
  unavailable: "Deze tutorial is niet beschikbaar."
10
11
  dashboard:
11
12
  title: "Productrondleidingen"
@@ -6,6 +6,7 @@ pl:
6
6
  done: "Gotowe"
7
7
  dialog: "Samouczek produktu"
8
8
  video: "Film instruktażowy"
9
+ captions_label: "Polski"
9
10
  unavailable: "Ten samouczek jest niedostępny."
10
11
  dashboard:
11
12
  title: "Prezentacje produktu"
@@ -6,6 +6,7 @@ pt:
6
6
  done: "Concluir"
7
7
  dialog: "Tutorial do produto"
8
8
  video: "Vídeo tutorial"
9
+ captions_label: "Português"
9
10
  unavailable: "Este tutorial não está disponível."
10
11
  dashboard:
11
12
  title: "Tours do produto"
@@ -6,6 +6,7 @@ ro:
6
6
  done: "Gata"
7
7
  dialog: "Tutorial de produs"
8
8
  video: "Videoclip tutorial"
9
+ captions_label: "Română"
9
10
  unavailable: "Acest tutorial nu este disponibil."
10
11
  dashboard:
11
12
  title: "Tururi de produs"
@@ -6,6 +6,7 @@ ru:
6
6
  done: "Готово"
7
7
  dialog: "Руководство по продукту"
8
8
  video: "Обучающее видео"
9
+ captions_label: "Русский"
9
10
  unavailable: "Это руководство недоступно."
10
11
  dashboard:
11
12
  title: "Обзоры продукта"
@@ -6,6 +6,7 @@ th:
6
6
  done: "เสร็จสิ้น"
7
7
  dialog: "บทแนะนำผลิตภัณฑ์"
8
8
  video: "วิดีโอบทแนะนำ"
9
+ captions_label: "ไทย"
9
10
  unavailable: "บทแนะนำนี้ไม่พร้อมใช้งาน"
10
11
  dashboard:
11
12
  title: "ทัวร์ผลิตภัณฑ์"
@@ -6,6 +6,7 @@ tr:
6
6
  done: "Bitti"
7
7
  dialog: "Ürün eğitimi"
8
8
  video: "Eğitim videosu"
9
+ captions_label: "Türkçe"
9
10
  unavailable: "Bu eğitim kullanılamıyor."
10
11
  dashboard:
11
12
  title: "Ürün turları"
@@ -6,6 +6,7 @@ uk:
6
6
  done: "Готово"
7
7
  dialog: "Посібник із продукту"
8
8
  video: "Навчальне відео"
9
+ captions_label: "Українська"
9
10
  unavailable: "Цей посібник недоступний."
10
11
  dashboard:
11
12
  title: "Огляди продукту"
@@ -6,6 +6,7 @@ ur:
6
6
  done: "مکمل"
7
7
  dialog: "پروڈکٹ ٹیوٹوریل"
8
8
  video: "ٹیوٹوریل ویڈیو"
9
+ captions_label: "اردو"
9
10
  unavailable: "یہ ٹیوٹوریل دستیاب نہیں ہے۔"
10
11
  dashboard:
11
12
  title: "پروڈکٹ ٹورز"
@@ -6,6 +6,7 @@ vi:
6
6
  done: "Xong"
7
7
  dialog: "Hướng dẫn sản phẩm"
8
8
  video: "Video hướng dẫn"
9
+ captions_label: "Tiếng Việt"
9
10
  unavailable: "Hướng dẫn này không khả dụng."
10
11
  dashboard:
11
12
  title: "Chuyến tham quan sản phẩm"
@@ -6,6 +6,7 @@
6
6
  done: "完成"
7
7
  dialog: "产品教程"
8
8
  video: "教程视频"
9
+ captions_label: "简体中文"
9
10
  unavailable: "此教程不可用。"
10
11
  dashboard:
11
12
  title: "产品导览"
@@ -9,6 +9,8 @@ class CreateProductToursTours < ActiveRecord::Migration<%= migration_version %>
9
9
  t.string :title, null: false
10
10
  t.text :video_url
11
11
  t.json :video_metadata, null: false, default: {}
12
+ t.text :poster_url
13
+ t.text :captions_url
12
14
  t.string :action_label
13
15
  t.text :action_url
14
16
  t.string :action_tour_key
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddPlayerMediaToProductToursTours < ActiveRecord::Migration<%= migration_version %>
4
+ COLUMNS = %i[poster_url captions_url].freeze
5
+
6
+ def up
7
+ COLUMNS.each do |column|
8
+ add_column :product_tours_tours, column, :text unless column_exists?(:product_tours_tours, column)
9
+ end
10
+ end
11
+
12
+ def down
13
+ COLUMNS.each do |column|
14
+ remove_column :product_tours_tours, column if column_exists?(:product_tours_tours, column)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/active_record'
5
+ require_relative '../migration_helpers'
6
+
7
+ module ProductTours
8
+ module Generators
9
+ # Brings an install created by an earlier version up to the current schema.
10
+ # Every migration it writes checks before it changes anything, so running it
11
+ # on an app that is already current is harmless.
12
+ class UpgradeGenerator < Rails::Generators::Base
13
+ include ActiveRecord::Generators::Migration
14
+ include MigrationHelpers
15
+
16
+ source_root File.expand_path('templates', __dir__)
17
+ desc 'Adds the columns newer product_tours versions use to an existing install.'
18
+
19
+ def create_player_media_migration
20
+ migration_template 'add_player_media_to_product_tours_tours.rb.tt',
21
+ 'db/migrate/add_player_media_to_product_tours_tours.rb', skip: true
22
+ end
23
+
24
+ def post_upgrade
25
+ say "\nRun `bin/rails db:migrate` to finish the product_tours upgrade.", :green
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ProductTours
4
- VERSION = '1.1.0'
4
+ VERSION = '1.3.0'
5
5
  end
@@ -33,6 +33,8 @@ module ProductTours
33
33
  end
34
34
 
35
35
  def resolve
36
+ return relative if @url.start_with?('/') && !@url.start_with?('//')
37
+
36
38
  uri = URI.parse(@url)
37
39
  return unless uri.is_a?(URI::HTTPS) && uri.host.present? && uri.userinfo.nil?
38
40
 
@@ -110,6 +112,14 @@ module ProductTours
110
112
  Result.new(kind: 'iframe', provider: 'voomly', provider_id: id, url: embed)
111
113
  end
112
114
 
115
+ # A path on the host app's own origin, so a tour plays from whichever environment holds the record.
116
+ def relative
117
+ uri = URI.parse(@url)
118
+ direct(uri) if uri.host.nil? && uri.scheme.nil?
119
+ rescue URI::InvalidURIError
120
+ nil
121
+ end
122
+
113
123
  def direct(uri)
114
124
  return unless uri.path.match?(/\.(?:mp4|webm)\z/i)
115
125
 
@@ -193,12 +193,24 @@
193
193
  player.controls = true;
194
194
  player.playsInline = true;
195
195
  player.preload = "metadata";
196
- showFirstVideoFrame(player);
196
+ player.setAttribute("aria-label", title || config.labels.video);
197
+ if (video.poster) player.poster = video.poster;
198
+ else showFirstVideoFrame(player);
199
+ if (video.captions) player.appendChild(captionsTrack(video.captions));
197
200
  frame.appendChild(player);
198
201
  }
199
202
  return frame;
200
203
  }
201
204
 
205
+ function captionsTrack(captions) {
206
+ var track = document.createElement("track");
207
+ track.kind = "captions";
208
+ track.src = captions.url;
209
+ track.srclang = captions.srclang;
210
+ track.label = captions.label;
211
+ return track;
212
+ }
213
+
202
214
  function showFirstVideoFrame(player) {
203
215
  function seek() {
204
216
  if (!Number.isFinite(player.duration) || player.duration <= 0 || player.currentTime > 0) return;
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: product_tours
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov
@@ -101,6 +101,8 @@ files:
101
101
  - lib/generators/product_tours/install/templates/create_product_tours_tours.rb.tt
102
102
  - lib/generators/product_tours/install/templates/initializer.rb
103
103
  - lib/generators/product_tours/migration_helpers.rb
104
+ - lib/generators/product_tours/upgrade/templates/add_player_media_to_product_tours_tours.rb.tt
105
+ - lib/generators/product_tours/upgrade/upgrade_generator.rb
104
106
  - lib/product_tours.rb
105
107
  - lib/product_tours/configuration.rb
106
108
  - lib/product_tours/content_security_policy.rb