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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +41 -0
- data/MIT-LICENSE +19 -0
- data/README.md +209 -0
- data/Rakefile +11 -0
- data/app/controllers/product_tours/application_controller.rb +68 -0
- data/app/controllers/product_tours/media_controller.rb +14 -0
- data/app/controllers/product_tours/posts_controller.rb +230 -0
- data/app/controllers/product_tours/tours_controller.rb +69 -0
- data/app/controllers/product_tours/widgets_controller.rb +28 -0
- data/app/helpers/product_tours/posts_helper.rb +19 -0
- data/app/helpers/product_tours/widget_helper.rb +14 -0
- data/app/models/product_tours/application_record.rb +7 -0
- data/app/models/product_tours/post.rb +128 -0
- data/app/views/layouts/product_tours/application.html.erb +20 -0
- data/app/views/product_tours/posts/_form.html.erb +177 -0
- data/app/views/product_tours/posts/_post_panel.html.erb +111 -0
- data/app/views/product_tours/posts/edit.html.erb +5 -0
- data/app/views/product_tours/posts/index.html.erb +92 -0
- data/app/views/product_tours/posts/new.html.erb +5 -0
- data/app/views/product_tours/posts/show.html.erb +5 -0
- data/config/locales/product_tours.ar.yml +11 -0
- data/config/locales/product_tours.bg.yml +11 -0
- data/config/locales/product_tours.bn.yml +11 -0
- data/config/locales/product_tours.de.yml +11 -0
- data/config/locales/product_tours.el.yml +11 -0
- data/config/locales/product_tours.en.yml +11 -0
- data/config/locales/product_tours.es.yml +11 -0
- data/config/locales/product_tours.fr.yml +11 -0
- data/config/locales/product_tours.hi.yml +11 -0
- data/config/locales/product_tours.hr.yml +11 -0
- data/config/locales/product_tours.id.yml +11 -0
- data/config/locales/product_tours.it.yml +11 -0
- data/config/locales/product_tours.ja.yml +11 -0
- data/config/locales/product_tours.ko.yml +11 -0
- data/config/locales/product_tours.lb.yml +11 -0
- data/config/locales/product_tours.nl.yml +11 -0
- data/config/locales/product_tours.pl.yml +11 -0
- data/config/locales/product_tours.pt.yml +11 -0
- data/config/locales/product_tours.ro.yml +11 -0
- data/config/locales/product_tours.ru.yml +11 -0
- data/config/locales/product_tours.th.yml +11 -0
- data/config/locales/product_tours.tr.yml +11 -0
- data/config/locales/product_tours.uk.yml +11 -0
- data/config/locales/product_tours.ur.yml +11 -0
- data/config/locales/product_tours.vi.yml +11 -0
- data/config/locales/product_tours.zh-CN.yml +11 -0
- data/config/routes.rb +23 -0
- data/lib/generators/product_tours/install/install_generator.rb +44 -0
- data/lib/generators/product_tours/install/templates/create_product_tours_posts.rb.tt +29 -0
- data/lib/generators/product_tours/install/templates/initializer.rb +21 -0
- data/lib/product_tours/configuration.rb +18 -0
- data/lib/product_tours/content_security_policy.rb +24 -0
- data/lib/product_tours/dashboard.css +196 -0
- data/lib/product_tours/dashboard.js +229 -0
- data/lib/product_tours/engine.rb +28 -0
- data/lib/product_tours/errors.rb +14 -0
- data/lib/product_tours/seeds.rb +147 -0
- data/lib/product_tours/version.rb +5 -0
- data/lib/product_tours/video_metadata.rb +60 -0
- data/lib/product_tours/video_resolver.rb +129 -0
- data/lib/product_tours/widget.js +359 -0
- data/lib/product_tours/widget.rb +59 -0
- data/lib/product_tours.rb +35 -0
- data/lib/tasks/product_tours_tasks.rake +12 -0
- metadata +131 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module ProductTours
|
|
8
|
+
class VideoMetadata
|
|
9
|
+
ENDPOINTS = {
|
|
10
|
+
'youtube' => 'https://www.youtube.com/oembed',
|
|
11
|
+
'vimeo' => 'https://vimeo.com/api/oembed.json',
|
|
12
|
+
'loom' => 'https://www.loom.com/v1/oembed'
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def self.fetch(url)
|
|
16
|
+
new(url).fetch
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(url)
|
|
20
|
+
@url = url.to_s
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def fetch
|
|
24
|
+
resolved = VideoResolver.resolve(@url)
|
|
25
|
+
return {} unless resolved
|
|
26
|
+
|
|
27
|
+
metadata = resolved.metadata
|
|
28
|
+
endpoint = ENDPOINTS[resolved.provider]
|
|
29
|
+
return metadata unless endpoint
|
|
30
|
+
|
|
31
|
+
response = request(endpoint)
|
|
32
|
+
return metadata unless response.is_a?(Net::HTTPSuccess)
|
|
33
|
+
|
|
34
|
+
body = JSON.parse(response.body)
|
|
35
|
+
metadata.merge(
|
|
36
|
+
'provider_title' => body['title'].presence,
|
|
37
|
+
'thumbnail_url' => safe_https_url(body['thumbnail_url'])
|
|
38
|
+
).compact
|
|
39
|
+
rescue JSON::ParserError, IOError, SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError
|
|
40
|
+
metadata || {}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def request(endpoint)
|
|
46
|
+
uri = URI(endpoint)
|
|
47
|
+
uri.query = URI.encode_www_form(url: @url, format: 'json')
|
|
48
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 3, read_timeout: 4) do |http|
|
|
49
|
+
http.get(uri.request_uri, { 'User-Agent' => "product_tours/#{ProductTours::VERSION}" })
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def safe_https_url(value)
|
|
54
|
+
uri = URI.parse(value.to_s)
|
|
55
|
+
uri.to_s if uri.is_a?(URI::HTTPS) && uri.host.present? && uri.userinfo.nil?
|
|
56
|
+
rescue URI::InvalidURIError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module ProductTours
|
|
6
|
+
class VideoResolver
|
|
7
|
+
Result = Data.define(:kind, :provider, :provider_id, :url) do
|
|
8
|
+
def iframe? = kind == 'iframe'
|
|
9
|
+
def direct? = kind == 'direct'
|
|
10
|
+
|
|
11
|
+
def metadata
|
|
12
|
+
{
|
|
13
|
+
'provider' => provider,
|
|
14
|
+
'provider_id' => provider_id,
|
|
15
|
+
'embed_url' => iframe? ? url : nil
|
|
16
|
+
}.compact
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
YOUTUBE_HOSTS = %w[youtube.com www.youtube.com m.youtube.com youtu.be youtube-nocookie.com
|
|
21
|
+
www.youtube-nocookie.com].freeze
|
|
22
|
+
VIMEO_HOSTS = %w[vimeo.com www.vimeo.com player.vimeo.com].freeze
|
|
23
|
+
LOOM_HOSTS = %w[loom.com www.loom.com].freeze
|
|
24
|
+
TELLA_HOSTS = %w[tella.tv www.tella.tv].freeze
|
|
25
|
+
VOOMLY_HOSTS = %w[share.voomly.com embed.voomly.com app.voomly.com voomly.com www.voomly.com].freeze
|
|
26
|
+
|
|
27
|
+
def self.resolve(url)
|
|
28
|
+
new(url).resolve
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(url)
|
|
32
|
+
@url = url.to_s.strip
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def resolve
|
|
36
|
+
uri = URI.parse(@url)
|
|
37
|
+
return unless uri.is_a?(URI::HTTPS) && uri.host.present? && uri.userinfo.nil?
|
|
38
|
+
|
|
39
|
+
case uri.host.downcase
|
|
40
|
+
when *YOUTUBE_HOSTS then youtube(uri)
|
|
41
|
+
when *VIMEO_HOSTS then vimeo(uri)
|
|
42
|
+
when *LOOM_HOSTS then loom(uri)
|
|
43
|
+
when *TELLA_HOSTS then tella(uri)
|
|
44
|
+
when *VOOMLY_HOSTS then voomly(uri)
|
|
45
|
+
else direct(uri)
|
|
46
|
+
end
|
|
47
|
+
rescue URI::InvalidURIError, ArgumentError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def youtube(uri)
|
|
54
|
+
id = if uri.host.downcase == 'youtu.be'
|
|
55
|
+
segments(uri).first
|
|
56
|
+
elsif uri.path == '/watch'
|
|
57
|
+
query(uri)['v']
|
|
58
|
+
else
|
|
59
|
+
match = uri.path.match(%r{\A/(?:embed|shorts|live)/([A-Za-z0-9_-]+)})
|
|
60
|
+
match && match[1]
|
|
61
|
+
end
|
|
62
|
+
return unless id&.match?(/\A[A-Za-z0-9_-]{6,20}\z/)
|
|
63
|
+
|
|
64
|
+
Result.new(kind: 'iframe', provider: 'youtube', provider_id: id,
|
|
65
|
+
url: "https://www.youtube-nocookie.com/embed/#{id}?rel=0&modestbranding=1")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def vimeo(uri)
|
|
69
|
+
parts = segments(uri)
|
|
70
|
+
parts.shift if parts.first == 'video'
|
|
71
|
+
id = parts.shift
|
|
72
|
+
return unless id&.match?(/\A\d+\z/)
|
|
73
|
+
|
|
74
|
+
privacy_hash = query(uri)['h'].presence || parts.first.to_s.presence
|
|
75
|
+
return if privacy_hash && !privacy_hash.match?(/\A[A-Za-z0-9]+\z/)
|
|
76
|
+
|
|
77
|
+
embed_url = "https://player.vimeo.com/video/#{id}"
|
|
78
|
+
embed_url += "?h=#{URI.encode_www_form_component(privacy_hash)}" if privacy_hash
|
|
79
|
+
Result.new(kind: 'iframe', provider: 'vimeo', provider_id: id, url: embed_url)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def loom(uri)
|
|
83
|
+
match = uri.path.match(%r{\A/(?:share|embed)/([A-Za-z0-9_-]{8,64})/?\z})
|
|
84
|
+
return unless match
|
|
85
|
+
|
|
86
|
+
id = match[1]
|
|
87
|
+
Result.new(kind: 'iframe', provider: 'loom', provider_id: id,
|
|
88
|
+
url: "https://www.loom.com/embed/#{id}")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def tella(uri)
|
|
92
|
+
match = uri.path.match(%r{\A/video/([A-Za-z0-9_-]{3,100})(?:/(?:view|embed))?/?\z})
|
|
93
|
+
return unless match
|
|
94
|
+
|
|
95
|
+
id = match[1]
|
|
96
|
+
Result.new(kind: 'iframe', provider: 'tella', provider_id: id,
|
|
97
|
+
url: "https://www.tella.tv/video/#{id}/embed")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def voomly(uri)
|
|
101
|
+
id = if uri.host.downcase == 'embed.voomly.com' && uri.path == '/embed/assets/embed.html'
|
|
102
|
+
query(uri)['videoId']
|
|
103
|
+
else
|
|
104
|
+
uri.path.match(%r{\A/(?:v|video)/([A-Za-z0-9_-]{6,160})/?\z})&.[](1)
|
|
105
|
+
end
|
|
106
|
+
return unless id&.match?(/\A[A-Za-z0-9_-]{6,160}\z/)
|
|
107
|
+
|
|
108
|
+
embed = 'https://embed.voomly.com/embed/assets/embed.html?' \
|
|
109
|
+
"videoId=#{URI.encode_www_form_component(id)}&videoRatio=1.777778&type=v"
|
|
110
|
+
Result.new(kind: 'iframe', provider: 'voomly', provider_id: id, url: embed)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def direct(uri)
|
|
114
|
+
return unless uri.path.match?(/\.(?:mp4|webm)\z/i)
|
|
115
|
+
|
|
116
|
+
Result.new(kind: 'direct', provider: 'direct', provider_id: nil, url: uri.to_s)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def segments(uri)
|
|
120
|
+
uri.path.split('/').reject(&:blank?)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def query(uri)
|
|
124
|
+
URI.decode_www_form(uri.query.to_s).to_h
|
|
125
|
+
rescue ArgumentError
|
|
126
|
+
{}
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/* product_tours widget: delegated modal tutorials, no framework or build step. */
|
|
2
|
+
(function () {
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
if (window.__productToursLoaded) return;
|
|
6
|
+
window.__productToursLoaded = true;
|
|
7
|
+
|
|
8
|
+
var config = readConfig();
|
|
9
|
+
var overlay = null;
|
|
10
|
+
var dialog = null;
|
|
11
|
+
var closeButton = null;
|
|
12
|
+
var session = null;
|
|
13
|
+
var currentPost = null;
|
|
14
|
+
var history = [];
|
|
15
|
+
var lastFocused = null;
|
|
16
|
+
var savedOverflow = null;
|
|
17
|
+
var viewportHandler = null;
|
|
18
|
+
|
|
19
|
+
document.addEventListener("click", handleTrigger, true);
|
|
20
|
+
document.addEventListener("keydown", handleKeydown);
|
|
21
|
+
document.addEventListener("turbo:load", function () { config = readConfig() || config; });
|
|
22
|
+
document.addEventListener("turbo:before-render", function () { close(false); });
|
|
23
|
+
|
|
24
|
+
function readConfig() {
|
|
25
|
+
var node = document.querySelector("script[data-product-tours-config]");
|
|
26
|
+
if (!node) return null;
|
|
27
|
+
try { return JSON.parse(node.textContent); } catch (_error) { return null; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function handleTrigger(event) {
|
|
31
|
+
var trigger = event.target && event.target.closest
|
|
32
|
+
? event.target.closest("[data-product-tour]")
|
|
33
|
+
: null;
|
|
34
|
+
if (!trigger) return;
|
|
35
|
+
|
|
36
|
+
config = readConfig() || config;
|
|
37
|
+
if (!config || !config.endpoint) return;
|
|
38
|
+
event.preventDefault();
|
|
39
|
+
event.stopPropagation();
|
|
40
|
+
resolve(trigger.getAttribute("data-product-tour"), trigger);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolve(key, trigger) {
|
|
44
|
+
if (!key || overlay) return;
|
|
45
|
+
fetchPost(key, config.locale)
|
|
46
|
+
.then(function (post) { open(post, trigger); })
|
|
47
|
+
.catch(reportError);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function fetchPost(key, locale) {
|
|
51
|
+
var url = config.endpoint + "/post?" + params({ key: key, locale: locale, page_url: window.location.href });
|
|
52
|
+
return fetch(url, { headers: { Accept: "application/json" }, credentials: "same-origin" })
|
|
53
|
+
.then(function (response) {
|
|
54
|
+
if (!response.ok) throw new Error("Product tour " + JSON.stringify(key) + " could not be resolved (HTTP " + response.status + ")");
|
|
55
|
+
return response.json();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function reportError(error) {
|
|
60
|
+
if (window.console && console.error) console.error("product_tours:", error.message);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function open(post, trigger) {
|
|
64
|
+
if (overlay) return;
|
|
65
|
+
injectStyles();
|
|
66
|
+
lastFocused = trigger || document.activeElement;
|
|
67
|
+
history = [];
|
|
68
|
+
|
|
69
|
+
overlay = element("div", "pt-overlay");
|
|
70
|
+
overlay.id = "product-tours-overlay";
|
|
71
|
+
overlay.addEventListener("mousedown", function (event) {
|
|
72
|
+
if (event.target === overlay) close(true);
|
|
73
|
+
});
|
|
74
|
+
overlay.addEventListener("keydown", trapFocus);
|
|
75
|
+
|
|
76
|
+
dialog = element("div", "pt-dialog");
|
|
77
|
+
dialog.setAttribute("role", "dialog");
|
|
78
|
+
dialog.setAttribute("aria-modal", "true");
|
|
79
|
+
dialog.setAttribute("aria-labelledby", "product-tours-title");
|
|
80
|
+
dialog.setAttribute("aria-label", config.labels.dialog);
|
|
81
|
+
dialog.tabIndex = -1;
|
|
82
|
+
if (config.rtl) dialog.setAttribute("dir", "rtl");
|
|
83
|
+
|
|
84
|
+
overlay.appendChild(dialog);
|
|
85
|
+
document.body.appendChild(overlay);
|
|
86
|
+
lockScroll();
|
|
87
|
+
startViewportTracking();
|
|
88
|
+
renderPost(post, "modal");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function renderPost(post, source) {
|
|
92
|
+
if (!dialog) return;
|
|
93
|
+
var existing = dialog.querySelector(".pt-content");
|
|
94
|
+
if (existing) existing.remove();
|
|
95
|
+
currentPost = post;
|
|
96
|
+
session = { key: post.key, locale: post.locale, completed: false, viewed: false };
|
|
97
|
+
|
|
98
|
+
var content = element("div", "pt-content");
|
|
99
|
+
var head = element("div", "pt-head");
|
|
100
|
+
var title = element("h2", "pt-title", post.title);
|
|
101
|
+
title.id = "product-tours-title";
|
|
102
|
+
head.appendChild(title);
|
|
103
|
+
|
|
104
|
+
closeButton = element("button", "pt-close", "\u00d7");
|
|
105
|
+
closeButton.type = "button";
|
|
106
|
+
closeButton.setAttribute("aria-label", config.labels.close);
|
|
107
|
+
closeButton.addEventListener("click", function () { close(true); });
|
|
108
|
+
head.appendChild(closeButton);
|
|
109
|
+
content.appendChild(head);
|
|
110
|
+
|
|
111
|
+
if (post.video && post.video.url) content.appendChild(videoNode(post.video, post.title));
|
|
112
|
+
|
|
113
|
+
if (post.descriptionHtml) {
|
|
114
|
+
var description = element("div", "pt-description");
|
|
115
|
+
description.innerHTML = post.descriptionHtml;
|
|
116
|
+
content.appendChild(description);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
var error = element("p", "pt-error");
|
|
120
|
+
error.setAttribute("role", "alert");
|
|
121
|
+
error.hidden = true;
|
|
122
|
+
content.appendChild(error);
|
|
123
|
+
|
|
124
|
+
var actions = element("div", "pt-actions");
|
|
125
|
+
if (history.length) {
|
|
126
|
+
var back = element("button", "pt-back", config.labels.back);
|
|
127
|
+
back.type = "button";
|
|
128
|
+
back.addEventListener("click", function () {
|
|
129
|
+
renderPost(history.pop(), "back");
|
|
130
|
+
});
|
|
131
|
+
actions.appendChild(back);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
var action = element("button", "pt-primary", (post.action && post.action.label) || config.labels.done);
|
|
135
|
+
action.type = "button";
|
|
136
|
+
action.addEventListener("click", function () {
|
|
137
|
+
var nextKey = post.action && post.action.postKey;
|
|
138
|
+
if (nextKey) {
|
|
139
|
+
action.disabled = true;
|
|
140
|
+
action.setAttribute("aria-busy", "true");
|
|
141
|
+
fetchPost(nextKey, post.locale).then(function (nextPost) {
|
|
142
|
+
var repeated = currentPost && currentPost.key === nextPost.key;
|
|
143
|
+
repeated = repeated || history.some(function (visited) { return visited.key === nextPost.key; });
|
|
144
|
+
if (repeated) throw new Error("Product tour link would create a navigation cycle");
|
|
145
|
+
complete("linked_post");
|
|
146
|
+
history.push(post);
|
|
147
|
+
renderPost(nextPost, "linked_post");
|
|
148
|
+
}).catch(function (fetchError) {
|
|
149
|
+
reportError(fetchError);
|
|
150
|
+
error.textContent = config.labels.unavailable;
|
|
151
|
+
error.hidden = false;
|
|
152
|
+
action.disabled = false;
|
|
153
|
+
action.removeAttribute("aria-busy");
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
complete("action");
|
|
159
|
+
var target = post.action && post.action.url;
|
|
160
|
+
close(false);
|
|
161
|
+
if (target) window.location.assign(target);
|
|
162
|
+
});
|
|
163
|
+
actions.appendChild(action);
|
|
164
|
+
content.appendChild(actions);
|
|
165
|
+
dialog.appendChild(content);
|
|
166
|
+
|
|
167
|
+
window.requestAnimationFrame(function () {
|
|
168
|
+
if (!overlay || !dialog.getClientRects().length) return;
|
|
169
|
+
closeButton.focus();
|
|
170
|
+
if (document.activeElement && overlay.contains(document.activeElement)) {
|
|
171
|
+
session.viewed = true;
|
|
172
|
+
sendSignal("viewed", source || "modal");
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function videoNode(video, title) {
|
|
178
|
+
var frame = element("div", "pt-video");
|
|
179
|
+
if (video.kind === "iframe") {
|
|
180
|
+
var iframe = document.createElement("iframe");
|
|
181
|
+
iframe.src = video.url;
|
|
182
|
+
iframe.title = title || config.labels.video;
|
|
183
|
+
iframe.loading = "eager";
|
|
184
|
+
iframe.allow = "accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; gyroscope; picture-in-picture";
|
|
185
|
+
iframe.setAttribute("allowfullscreen", "");
|
|
186
|
+
iframe.referrerPolicy = "strict-origin-when-cross-origin";
|
|
187
|
+
frame.appendChild(iframe);
|
|
188
|
+
} else {
|
|
189
|
+
var player = document.createElement("video");
|
|
190
|
+
player.src = video.url;
|
|
191
|
+
player.controls = true;
|
|
192
|
+
player.playsInline = true;
|
|
193
|
+
player.preload = "metadata";
|
|
194
|
+
showFirstVideoFrame(player);
|
|
195
|
+
frame.appendChild(player);
|
|
196
|
+
}
|
|
197
|
+
return frame;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function showFirstVideoFrame(player) {
|
|
201
|
+
function seek() {
|
|
202
|
+
if (!Number.isFinite(player.duration) || player.duration <= 0 || player.currentTime > 0) return;
|
|
203
|
+
try { player.currentTime = Math.min(0.1, player.duration / 2); } catch (_error) {}
|
|
204
|
+
}
|
|
205
|
+
player.addEventListener("loadedmetadata", seek, { once: true });
|
|
206
|
+
if (player.readyState >= 1) seek();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function handleKeydown(event) {
|
|
210
|
+
if (event.key === "Escape" && overlay) close(true);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function trapFocus(event) {
|
|
214
|
+
if (event.key !== "Tab" || !overlay) return;
|
|
215
|
+
var focusable = overlay.querySelectorAll("button, a[href], video[controls], [tabindex]:not([tabindex='-1'])");
|
|
216
|
+
if (!focusable.length) return;
|
|
217
|
+
var first = focusable[0];
|
|
218
|
+
var last = focusable[focusable.length - 1];
|
|
219
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
220
|
+
event.preventDefault();
|
|
221
|
+
last.focus();
|
|
222
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
223
|
+
event.preventDefault();
|
|
224
|
+
first.focus();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function close(reportDismissal) {
|
|
229
|
+
if (!overlay) return;
|
|
230
|
+
if (reportDismissal && session && session.viewed && !session.completed) sendSignal("dismissed", "modal");
|
|
231
|
+
stopViewportTracking();
|
|
232
|
+
overlay.remove();
|
|
233
|
+
overlay = null;
|
|
234
|
+
dialog = null;
|
|
235
|
+
closeButton = null;
|
|
236
|
+
session = null;
|
|
237
|
+
currentPost = null;
|
|
238
|
+
history = [];
|
|
239
|
+
unlockScroll();
|
|
240
|
+
if (lastFocused && lastFocused.focus) lastFocused.focus();
|
|
241
|
+
lastFocused = null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function sendSignal(action, source) {
|
|
245
|
+
if (!session || !config) return;
|
|
246
|
+
var body = {
|
|
247
|
+
key: session.key,
|
|
248
|
+
locale: session.locale,
|
|
249
|
+
event_action: action,
|
|
250
|
+
source: source,
|
|
251
|
+
page_url: window.location.href
|
|
252
|
+
};
|
|
253
|
+
fetch(config.endpoint + "/signal", {
|
|
254
|
+
method: "POST",
|
|
255
|
+
credentials: "same-origin",
|
|
256
|
+
keepalive: true,
|
|
257
|
+
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken(), Accept: "application/json" },
|
|
258
|
+
body: JSON.stringify(body)
|
|
259
|
+
}).catch(function () {});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function complete(source) {
|
|
263
|
+
if (!session || session.completed) return;
|
|
264
|
+
session.completed = true;
|
|
265
|
+
sendSignal("completed", source);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function csrfToken() {
|
|
269
|
+
var meta = document.querySelector("meta[name='csrf-token']");
|
|
270
|
+
return meta ? meta.content : "";
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function params(values) {
|
|
274
|
+
return Object.keys(values).map(function (key) {
|
|
275
|
+
return encodeURIComponent(key) + "=" + encodeURIComponent(values[key] || "");
|
|
276
|
+
}).join("&");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function element(tag, className, text) {
|
|
280
|
+
var node = document.createElement(tag);
|
|
281
|
+
node.className = className;
|
|
282
|
+
if (text !== undefined) node.textContent = text;
|
|
283
|
+
return node;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function lockScroll() {
|
|
287
|
+
if (savedOverflow === null) savedOverflow = document.documentElement.style.overflow;
|
|
288
|
+
document.documentElement.style.overflow = "hidden";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function unlockScroll() {
|
|
292
|
+
if (savedOverflow === null) return;
|
|
293
|
+
document.documentElement.style.overflow = savedOverflow;
|
|
294
|
+
savedOverflow = null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function applyViewport() {
|
|
298
|
+
if (!overlay) return;
|
|
299
|
+
var viewport = window.visualViewport;
|
|
300
|
+
var dialog = overlay.querySelector(".pt-dialog");
|
|
301
|
+
if (viewport && window.matchMedia("(max-width: 480px)").matches) {
|
|
302
|
+
overlay.style.top = viewport.offsetTop + "px";
|
|
303
|
+
overlay.style.height = viewport.height + "px";
|
|
304
|
+
overlay.style.bottom = "auto";
|
|
305
|
+
if (dialog) dialog.style.height = viewport.height + "px";
|
|
306
|
+
} else {
|
|
307
|
+
overlay.style.top = "";
|
|
308
|
+
overlay.style.height = "";
|
|
309
|
+
overlay.style.bottom = "";
|
|
310
|
+
if (dialog) dialog.style.height = "";
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function startViewportTracking() {
|
|
315
|
+
if (!window.visualViewport) return;
|
|
316
|
+
viewportHandler = applyViewport;
|
|
317
|
+
window.visualViewport.addEventListener("resize", viewportHandler);
|
|
318
|
+
window.visualViewport.addEventListener("scroll", viewportHandler);
|
|
319
|
+
applyViewport();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function stopViewportTracking() {
|
|
323
|
+
if (viewportHandler && window.visualViewport) {
|
|
324
|
+
window.visualViewport.removeEventListener("resize", viewportHandler);
|
|
325
|
+
window.visualViewport.removeEventListener("scroll", viewportHandler);
|
|
326
|
+
}
|
|
327
|
+
viewportHandler = null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function injectStyles() {
|
|
331
|
+
var css = [
|
|
332
|
+
".pt-overlay{position:fixed;inset:0;z-index:2147482000;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;padding:16px}",
|
|
333
|
+
".pt-overlay,.pt-overlay *{box-sizing:border-box}",
|
|
334
|
+
".pt-dialog{width:100%;max-width:440px;max-height:92vh;overflow:auto;overscroll-behavior:contain;background:#fff;color:#1c2024;border-radius:14px;padding:20px;font:14px/1.5 system-ui,-apple-system,sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.35);outline:none}",
|
|
335
|
+
".pt-head{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin:0 0 12px}",
|
|
336
|
+
".pt-title{margin:0;font-size:17px;line-height:1.5;font-weight:700;overflow-wrap:anywhere}",
|
|
337
|
+
".pt-close{border:0;background:none;font-size:22px;line-height:1;cursor:pointer;color:inherit;padding:2px 6px}",
|
|
338
|
+
".pt-close:focus-visible,.pt-primary:focus-visible,.pt-back:focus-visible{outline:2px solid #2563eb;outline-offset:2px}",
|
|
339
|
+
".pt-video{position:relative;width:100%;aspect-ratio:16/9;margin:0 0 12px;overflow:hidden;border-radius:10px;background:#000}",
|
|
340
|
+
".pt-video iframe,.pt-video video{display:block;width:100%;height:100%;border:0;object-fit:contain}",
|
|
341
|
+
".pt-description{color:inherit;overflow-wrap:anywhere}.pt-description>*:first-child{margin-top:0}.pt-description>*:last-child{margin-bottom:0}.pt-description a{color:#2563eb}",
|
|
342
|
+
".pt-error{color:#dc2626;margin:0 0 12px}.pt-error[hidden]{display:none}",
|
|
343
|
+
".pt-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;margin-top:12px}",
|
|
344
|
+
".pt-primary,.pt-back{padding:8px 14px;border-radius:8px;cursor:pointer;font:inherit}",
|
|
345
|
+
".pt-primary{border:0;background:#2563eb;color:#fff;font-weight:600}.pt-primary:hover{background:#1d4ed8}.pt-primary:disabled{opacity:.6;cursor:default}",
|
|
346
|
+
".pt-back{margin-inline-end:auto;border:1px solid #d1d5db;background:none;color:inherit}.pt-back:hover{background:rgba(37,99,235,.07)}",
|
|
347
|
+
"@media (prefers-color-scheme:dark){.pt-dialog{background:#1a1f26;color:#e6e8ea}.pt-back{border-color:#2a313a}.pt-description{color:#e6e8ea}}",
|
|
348
|
+
"@media (max-width:480px){.pt-overlay{padding:0;align-items:stretch;justify-content:stretch}.pt-dialog{left:0;right:0;top:0;bottom:0;width:100%;max-width:none;height:100vh;height:100dvh;max-height:100dvh;border-radius:0;margin:0}.pt-actions{padding-bottom:calc(0px + env(safe-area-inset-bottom))}.pt-primary,.pt-back{font-size:16px}}"
|
|
349
|
+
].join("");
|
|
350
|
+
var existing = document.getElementById("product-tours-styles");
|
|
351
|
+
if (existing && existing.textContent === css) return;
|
|
352
|
+
if (existing) existing.remove();
|
|
353
|
+
|
|
354
|
+
var style = document.createElement("style");
|
|
355
|
+
style.id = "product-tours-styles";
|
|
356
|
+
style.textContent = css;
|
|
357
|
+
document.head.appendChild(style);
|
|
358
|
+
}
|
|
359
|
+
})();
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module ProductTours
|
|
7
|
+
module Widget
|
|
8
|
+
SOURCE = File.expand_path('widget.js', __dir__)
|
|
9
|
+
DASHBOARD_SOURCE = File.expand_path('dashboard.js', __dir__)
|
|
10
|
+
DASHBOARD_STYLESHEET_SOURCE = File.expand_path('dashboard.css', __dir__)
|
|
11
|
+
RTL_LANGUAGES = %w[ar arc ckb dv fa ha he ks ku ps sd ug ur yi].freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def javascript = @javascript ||= File.read(SOURCE)
|
|
15
|
+
def dashboard_javascript = @dashboard_javascript ||= File.read(DASHBOARD_SOURCE)
|
|
16
|
+
def dashboard_stylesheet = @dashboard_stylesheet ||= File.read(DASHBOARD_STYLESHEET_SOURCE)
|
|
17
|
+
def fingerprint = @fingerprint ||= Digest::MD5.hexdigest(javascript)
|
|
18
|
+
def dashboard_fingerprint = @dashboard_fingerprint ||= Digest::MD5.hexdigest(dashboard_javascript)
|
|
19
|
+
def dashboard_stylesheet_fingerprint = @dashboard_stylesheet_fingerprint ||= Digest::MD5.hexdigest(dashboard_stylesheet)
|
|
20
|
+
|
|
21
|
+
def snippet(locale:, nonce: nil)
|
|
22
|
+
nonce_attr = nonce ? %( nonce="#{ERB::Util.html_escape(nonce)}") : ''
|
|
23
|
+
source = "#{ProductTours.config.mount_path.to_s.chomp('/')}/widget.js?v=#{fingerprint}"
|
|
24
|
+
%(<script type="application/json" data-product-tours-config>#{config_json(locale:)}</script>) +
|
|
25
|
+
%(<script src="#{ERB::Util.html_escape(source)}" defer#{nonce_attr} data-product-tours-widget></script>)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def config_json(locale:)
|
|
29
|
+
{
|
|
30
|
+
endpoint: ProductTours.config.widget_endpoint,
|
|
31
|
+
locale: locale.to_s,
|
|
32
|
+
rtl: rtl?(locale),
|
|
33
|
+
labels: labels
|
|
34
|
+
}.to_json.gsub('</', '<\\/')
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def labels
|
|
40
|
+
{
|
|
41
|
+
close: t(:close, 'Close'),
|
|
42
|
+
back: t(:back, 'Back'),
|
|
43
|
+
done: t(:done, 'Done'),
|
|
44
|
+
dialog: t(:dialog, 'Product tutorial'),
|
|
45
|
+
video: t(:video, 'Tutorial video'),
|
|
46
|
+
unavailable: t(:unavailable, 'This tutorial is unavailable.')
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def t(key, default)
|
|
51
|
+
I18n.t(key, scope: :product_tours, default: default)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def rtl?(locale)
|
|
55
|
+
RTL_LANGUAGES.include?(locale.to_s.downcase.split(/[-_]/).first)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'product_tours/version'
|
|
4
|
+
require 'product_tours/configuration'
|
|
5
|
+
require 'product_tours/errors'
|
|
6
|
+
require 'product_tours/video_resolver'
|
|
7
|
+
require 'product_tours/video_metadata'
|
|
8
|
+
require 'product_tours/content_security_policy'
|
|
9
|
+
require 'product_tours/widget'
|
|
10
|
+
require 'product_tours/seeds'
|
|
11
|
+
require 'product_tours/engine'
|
|
12
|
+
|
|
13
|
+
module ProductTours
|
|
14
|
+
class << self
|
|
15
|
+
def config
|
|
16
|
+
@config ||= Configuration.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def configure
|
|
20
|
+
yield config
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def enabled?(request)
|
|
24
|
+
!!config.enabled.call(request)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def admin?(request)
|
|
28
|
+
!!config.authorize_admin.call(request)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def locale(_request = nil)
|
|
32
|
+
I18n.locale.to_s.presence || I18n.default_locale.to_s
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :product_tours do
|
|
4
|
+
desc 'Create or refresh the multilingual product_tours demo posts'
|
|
5
|
+
task seed_demo: :environment do
|
|
6
|
+
posts = ProductTours::Seeds.load_all!
|
|
7
|
+
locales = ProductTours::Seeds::DEMO_LOCALES.join(', ')
|
|
8
|
+
puts "Seeded #{posts.size} product tour demo posts for #{locales}."
|
|
9
|
+
puts "\nCopy this into any ERB view to try them:\n\n"
|
|
10
|
+
puts ProductTours::Seeds.trigger_html
|
|
11
|
+
end
|
|
12
|
+
end
|