product_tours 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +41 -0
  3. data/MIT-LICENSE +19 -0
  4. data/README.md +209 -0
  5. data/Rakefile +11 -0
  6. data/app/controllers/product_tours/application_controller.rb +68 -0
  7. data/app/controllers/product_tours/media_controller.rb +14 -0
  8. data/app/controllers/product_tours/posts_controller.rb +230 -0
  9. data/app/controllers/product_tours/tours_controller.rb +69 -0
  10. data/app/controllers/product_tours/widgets_controller.rb +28 -0
  11. data/app/helpers/product_tours/posts_helper.rb +19 -0
  12. data/app/helpers/product_tours/widget_helper.rb +14 -0
  13. data/app/models/product_tours/application_record.rb +7 -0
  14. data/app/models/product_tours/post.rb +128 -0
  15. data/app/views/layouts/product_tours/application.html.erb +20 -0
  16. data/app/views/product_tours/posts/_form.html.erb +177 -0
  17. data/app/views/product_tours/posts/_post_panel.html.erb +111 -0
  18. data/app/views/product_tours/posts/edit.html.erb +5 -0
  19. data/app/views/product_tours/posts/index.html.erb +92 -0
  20. data/app/views/product_tours/posts/new.html.erb +5 -0
  21. data/app/views/product_tours/posts/show.html.erb +5 -0
  22. data/config/locales/product_tours.ar.yml +11 -0
  23. data/config/locales/product_tours.bg.yml +11 -0
  24. data/config/locales/product_tours.bn.yml +11 -0
  25. data/config/locales/product_tours.de.yml +11 -0
  26. data/config/locales/product_tours.el.yml +11 -0
  27. data/config/locales/product_tours.en.yml +11 -0
  28. data/config/locales/product_tours.es.yml +11 -0
  29. data/config/locales/product_tours.fr.yml +11 -0
  30. data/config/locales/product_tours.hi.yml +11 -0
  31. data/config/locales/product_tours.hr.yml +11 -0
  32. data/config/locales/product_tours.id.yml +11 -0
  33. data/config/locales/product_tours.it.yml +11 -0
  34. data/config/locales/product_tours.ja.yml +11 -0
  35. data/config/locales/product_tours.ko.yml +11 -0
  36. data/config/locales/product_tours.lb.yml +11 -0
  37. data/config/locales/product_tours.nl.yml +11 -0
  38. data/config/locales/product_tours.pl.yml +11 -0
  39. data/config/locales/product_tours.pt.yml +11 -0
  40. data/config/locales/product_tours.ro.yml +11 -0
  41. data/config/locales/product_tours.ru.yml +11 -0
  42. data/config/locales/product_tours.th.yml +11 -0
  43. data/config/locales/product_tours.tr.yml +11 -0
  44. data/config/locales/product_tours.uk.yml +11 -0
  45. data/config/locales/product_tours.ur.yml +11 -0
  46. data/config/locales/product_tours.vi.yml +11 -0
  47. data/config/locales/product_tours.zh-CN.yml +11 -0
  48. data/config/routes.rb +23 -0
  49. data/lib/generators/product_tours/install/install_generator.rb +44 -0
  50. data/lib/generators/product_tours/install/templates/create_product_tours_posts.rb.tt +29 -0
  51. data/lib/generators/product_tours/install/templates/initializer.rb +21 -0
  52. data/lib/product_tours/configuration.rb +18 -0
  53. data/lib/product_tours/content_security_policy.rb +24 -0
  54. data/lib/product_tours/dashboard.css +196 -0
  55. data/lib/product_tours/dashboard.js +229 -0
  56. data/lib/product_tours/engine.rb +28 -0
  57. data/lib/product_tours/errors.rb +14 -0
  58. data/lib/product_tours/seeds.rb +147 -0
  59. data/lib/product_tours/version.rb +5 -0
  60. data/lib/product_tours/video_metadata.rb +60 -0
  61. data/lib/product_tours/video_resolver.rb +129 -0
  62. data/lib/product_tours/widget.js +359 -0
  63. data/lib/product_tours/widget.rb +59 -0
  64. data/lib/product_tours.rb +35 -0
  65. data/lib/tasks/product_tours_tasks.rake +12 -0
  66. metadata +131 -0
@@ -0,0 +1,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