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.
- checksums.yaml +4 -4
- data/AGENTS.md +2 -2
- data/CHANGELOG.md +36 -1
- data/README.md +28 -7
- data/SECURITY.md +2 -2
- data/app/controllers/product_tours/interactions_controller.rb +20 -2
- data/app/controllers/product_tours/tours_controller.rb +3 -0
- data/app/models/product_tours/tour.rb +54 -3
- data/app/views/product_tours/tours/_form.html.erb +18 -3
- data/app/views/product_tours/tours/_tour_panel.html.erb +6 -0
- data/config/locales/product_tours.ar.yml +1 -0
- data/config/locales/product_tours.bg.yml +1 -0
- data/config/locales/product_tours.bn.yml +1 -0
- data/config/locales/product_tours.de.yml +1 -0
- data/config/locales/product_tours.el.yml +1 -0
- data/config/locales/product_tours.en.yml +1 -0
- data/config/locales/product_tours.es.yml +1 -0
- data/config/locales/product_tours.fr.yml +1 -0
- data/config/locales/product_tours.hi.yml +1 -0
- data/config/locales/product_tours.hr.yml +1 -0
- data/config/locales/product_tours.id.yml +1 -0
- data/config/locales/product_tours.it.yml +1 -0
- data/config/locales/product_tours.ja.yml +1 -0
- data/config/locales/product_tours.ko.yml +1 -0
- data/config/locales/product_tours.lb.yml +1 -0
- data/config/locales/product_tours.nl.yml +1 -0
- data/config/locales/product_tours.pl.yml +1 -0
- data/config/locales/product_tours.pt.yml +1 -0
- data/config/locales/product_tours.ro.yml +1 -0
- data/config/locales/product_tours.ru.yml +1 -0
- data/config/locales/product_tours.th.yml +1 -0
- data/config/locales/product_tours.tr.yml +1 -0
- data/config/locales/product_tours.uk.yml +1 -0
- data/config/locales/product_tours.ur.yml +1 -0
- data/config/locales/product_tours.vi.yml +1 -0
- data/config/locales/product_tours.zh-CN.yml +1 -0
- data/lib/generators/product_tours/install/templates/create_product_tours_tours.rb.tt +2 -0
- data/lib/generators/product_tours/upgrade/templates/add_player_media_to_product_tours_tours.rb.tt +17 -0
- data/lib/generators/product_tours/upgrade/upgrade_generator.rb +29 -0
- data/lib/product_tours/version.rb +1 -1
- data/lib/product_tours/video_resolver.rb +10 -0
- data/lib/product_tours/widget.js +13 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7c62b8b42042732f293443f7cdfc154b1679fd7b0ba0c6774aca4ade157a54fa
|
|
4
|
+
data.tar.gz: 88dc14f258d8e5bd14260fe40dbf77c0c58e8adc7816f26a6bb31c4cbd4122b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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.
|
|
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
|
|
154
|
-
|
|
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`, `
|
|
348
|
-
`action_tour_key`, timestamps, `STATUSES`, status
|
|
349
|
-
`newest_first`, `action_tour`, `description_supported?`,
|
|
350
|
-
`video_upload_supported?`,
|
|
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,
|
|
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
|
|
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,
|
|
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.
|
|
93
|
-
|
|
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>
|
|
@@ -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
|
data/lib/generators/product_tours/upgrade/templates/add_player_media_to_product_tours_tours.rb.tt
ADDED
|
@@ -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
|
|
@@ -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
|
|
data/lib/product_tours/widget.js
CHANGED
|
@@ -193,12 +193,24 @@
|
|
|
193
193
|
player.controls = true;
|
|
194
194
|
player.playsInline = true;
|
|
195
195
|
player.preload = "metadata";
|
|
196
|
-
|
|
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.
|
|
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
|