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,196 @@
1
+ :root {
2
+ color-scheme: light dark;
3
+ --bg: #f6f7f9;
4
+ --surface: #ffffff;
5
+ --text: #1c2024;
6
+ --muted: #6b7280;
7
+ --border: #e5e7eb;
8
+ --accent: #2563eb;
9
+ --accent-text: #ffffff;
10
+ --draft: #6b7280;
11
+ --published: #16a34a;
12
+ --danger: #dc2626;
13
+ --code: #f0f1f3;
14
+ }
15
+
16
+ @media (prefers-color-scheme: dark) {
17
+ :root {
18
+ --bg: #111418;
19
+ --surface: #1a1f26;
20
+ --text: #e6e8ea;
21
+ --muted: #9aa2ab;
22
+ --border: #2a313a;
23
+ --accent: #3b82f6;
24
+ --code: #232a33;
25
+ }
26
+ }
27
+
28
+ * { box-sizing: border-box; }
29
+ html { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; letter-spacing: 0; }
30
+ body { margin: 0; min-height: 100vh; background: var(--bg); color: var(--text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
31
+ a { color: var(--accent); text-decoration: none; }
32
+ a:hover { text-decoration: underline; }
33
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
34
+ .container { max-width: 1020px; margin: 0 auto; padding: 24px 16px 64px; }
35
+ h1 { margin: 0 0 16px; font-size: 22px; }
36
+ h2 { margin: 0 0 12px; font-size: 16px; }
37
+ .page-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
38
+ .page-head h1 { margin: 0; }
39
+ .muted { color: var(--muted); font-size: 13px; }
40
+
41
+ button, .button {
42
+ width: auto;
43
+ display: inline-flex; align-items: center; justify-content: center; min-height: 38px; padding: 8px 14px;
44
+ border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text);
45
+ font: inherit; cursor: pointer;
46
+ }
47
+ button:hover, .button:hover { border-color: var(--muted); text-decoration: none; }
48
+ button.primary, .button.primary { border-color: var(--accent); background: var(--accent); color: var(--accent-text); }
49
+ button.danger, .button.danger { color: var(--danger); }
50
+ button.small, .button.small { min-height: 32px; padding: 4px 10px; font-size: 13px; }
51
+
52
+ .tabs { display: flex; gap: 6px; }
53
+ .tabs a {
54
+ position: relative; display: flex; flex: 1 1 0; align-items: center; justify-content: center; gap: 6px;
55
+ min-height: 34px; padding: 5px 10px 8px; border-radius: 8px; color: var(--muted); font-weight: 600;
56
+ }
57
+ .tabs a:hover { background: var(--bg); color: var(--text); text-decoration: none; }
58
+ .tabs a.active { color: var(--text); }
59
+ .tabs a.active::after { content: ""; position: absolute; right: 20px; bottom: 0; left: 20px; height: 2px; border-radius: 999px; background: var(--accent); }
60
+ .count { min-width: 20px; padding: 0 6px; border-radius: 999px; background: color-mix(in srgb, var(--muted) 14%, transparent); font-size: 12px; text-align: center; }
61
+
62
+ .filters { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 8px; padding: 12px; border-bottom: 1px solid var(--border); }
63
+ .filters input[type="search"] { min-width: 0; }
64
+ input, select, textarea {
65
+ width: 100%; min-height: 38px; padding: 7px 9px; border: 1px solid var(--border); border-radius: 8px;
66
+ background: var(--surface); color: var(--text); font: inherit; font-size: 16px; letter-spacing: 0;
67
+ }
68
+ textarea { resize: vertical; }
69
+ input:focus, select:focus, textarea:focus { border-color: var(--accent); outline: 2px solid color-mix(in srgb, var(--accent) 22%, transparent); outline-offset: 0; }
70
+
71
+ .card { overflow: hidden; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); }
72
+ .card.pad { padding: 16px; overflow: visible; }
73
+ .badge { display: inline-flex; padding: 2px 9px; border-radius: 999px; color: #ffffff; font-size: 12px; font-weight: 600; white-space: nowrap; }
74
+ .badge.status-draft { background: var(--draft); }
75
+ .badge.status-published { background: var(--published); }
76
+ .badge.locale { border: 1px solid var(--border); background: transparent; color: var(--muted); }
77
+ code.key { max-width: 100%; padding: 2px 7px; border-radius: 6px; background: var(--code); font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
78
+ .flash, .errors { margin-bottom: 16px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); }
79
+ .flash.notice { border-color: var(--published); }
80
+ .flash.alert, .errors { border-color: var(--danger); }
81
+ .errors ul { margin-bottom: 0; }
82
+ .empty { padding: 48px 16px; color: var(--muted); text-align: center; }
83
+ .empty-state { max-width: 320px; margin: auto; padding: 24px; text-align: center; }
84
+ .empty-state h2 { margin: 0 0 6px; font-size: 18px; }
85
+ .empty-state p { margin: 0; }
86
+ .breadcrumb { margin: 0 0 12px; font-size: 13px; }
87
+
88
+ .pt-index, .pt-index .container { height: 100vh; height: 100dvh; }
89
+ .pt-index { overflow: hidden; }
90
+ .pt-index .container { display: flex; max-width: 1280px; flex-direction: column; padding-bottom: 16px; }
91
+ .pt-index .page-head { flex: 0 0 auto; }
92
+ .dashboard-shell { display: grid; flex: 1 1 auto; grid-template-columns: minmax(330px, 410px) 1fr; gap: 16px; min-height: 0; }
93
+ .dashboard-sidebar { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); }
94
+ .dashboard-sidebar .tabs { flex: 0 0 auto; margin: 12px 12px 0; }
95
+ .record-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
96
+ .record-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 13px 14px; border-bottom: 1px solid var(--border); color: var(--text); }
97
+ .record-row:hover { background: var(--bg); text-decoration: none; }
98
+ .record-row.active { background: color-mix(in srgb, var(--accent) 9%, var(--surface)); box-shadow: inset 3px 0 0 var(--accent); }
99
+ .record-main { display: flex; min-width: 0; flex-direction: column; gap: 5px; }
100
+ .record-topline, .record-meta { display: flex; align-items: center; gap: 8px; min-width: 0; }
101
+ .record-copy { display: block; overflow: hidden; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
102
+ .record-meta span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
103
+ .record-side { align-self: center; color: var(--muted); }
104
+ .pager { display: flex; flex: 0 0 auto; justify-content: space-between; min-height: 42px; padding: 10px 12px; border-top: 1px solid var(--border); }
105
+ .dashboard-detail { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; }
106
+ .dashboard-detail .post-panel { flex: 1 1 auto; min-height: 0; }
107
+ .dashboard-detail .detail-scroll { flex: 1 1 auto; overflow-y: auto; padding-right: 2px; }
108
+ .mobile-back { display: none; }
109
+
110
+ .post-panel { display: flex; min-width: 0; flex-direction: column; }
111
+ .detail-scroll { display: flex; min-height: 0; flex-direction: column; gap: 12px; }
112
+ .preview-card { width: 100%; }
113
+ .video-preview { width: 100%; aspect-ratio: 16 / 9; margin-bottom: 16px; overflow: hidden; border-radius: 6px; background: #05070a; }
114
+ .video-preview iframe, .video-preview video { display: block; width: 100%; height: 100%; border: 0; object-fit: contain; }
115
+ .description-preview { color: var(--muted); overflow-wrap: anywhere; }
116
+ .preview-action { display: flex; justify-content: flex-end; margin-top: 16px; }
117
+ .details-card { width: 100%; }
118
+ dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 16px; margin: 0; }
119
+ dt { color: var(--muted); }
120
+ dd { margin: 0; overflow-wrap: anywhere; }
121
+ .refresh-button { margin-top: 14px; }
122
+ .metadata { max-width: 100%; overflow-x: auto; margin: 14px 0 0; padding: 12px; border-radius: 6px; background: var(--code); font-size: 12px; }
123
+ .translation-list { border-top: 1px solid var(--border); }
124
+ .translation-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); }
125
+ .translation-name { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 8px; }
126
+ .translation-form { display: flex; align-items: flex-end; gap: 8px; margin-top: 16px; }
127
+ .translation-form .field { flex: 1 1 auto; margin: 0; }
128
+ .translation-complete { margin: 14px 0 0; }
129
+ .actions, .form-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
130
+ .actions { margin: 0; }
131
+ .details-actions { margin-top: 16px; }
132
+ .actions form { display: inline; }
133
+ .form-actions input[type="submit"] { width: auto; }
134
+
135
+ .pt-show { min-height: 100vh; overflow: auto; }
136
+ .pt-show .post-panel { width: 100%; }
137
+ .pt-show .detail-scroll { overflow: visible; }
138
+ .pt-form-page .container { max-width: 820px; }
139
+ .post-form { width: 100%; }
140
+ .form-card { padding: 0; }
141
+ .form-section { padding: 12px 0 4px; }
142
+ .form-section + .form-section { margin-top: 4px; }
143
+ .form-section h2 { margin-bottom: 12px; }
144
+ .field { margin-bottom: 16px; }
145
+ .field label { display: block; margin-bottom: 5px; font-weight: 600; }
146
+ .field small { display: block; margin-top: 5px; color: var(--muted); }
147
+ .section-intro { margin: -4px 0 12px; color: var(--muted); }
148
+ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
149
+ .action-picker, .video-source-picker { display: grid; gap: 10px; min-width: 0; margin: 0 0 18px; padding: 0; border: 0; }
150
+ .action-picker { grid-template-columns: repeat(3, 1fr); }
151
+ .video-source-picker { grid-template-columns: repeat(2, 1fr); }
152
+ .action-choice { display: flex; align-items: flex-start; gap: 10px; min-width: 0; margin: 0; padding: 12px; border: 1px solid var(--border); border-radius: 8px; cursor: pointer; }
153
+ .action-choice:hover { border-color: var(--muted); }
154
+ .action-choice.selected { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 7%, var(--surface)); box-shadow: 0 0 0 1px var(--accent); }
155
+ .action-choice input { width: 18px; min-width: 18px; min-height: 18px; margin: 2px 0 0; }
156
+ .action-choice span { min-width: 0; }
157
+ .action-choice strong, .action-choice small { display: block; }
158
+ .action-choice small { margin-top: 3px; color: var(--muted); font-weight: 400; line-height: 1.35; }
159
+ .preview-action { align-items: center; gap: 12px; }
160
+ .preview-back { margin-right: auto; color: var(--muted); }
161
+ .checkbox { display: flex !important; align-items: center; gap: 8px; margin-top: 8px; font-weight: 400 !important; }
162
+ .checkbox input { width: 18px; min-height: 18px; }
163
+ .form-actions { padding-top: 12px; }
164
+ .form-video-preview { margin: 2px 0 16px; }
165
+ .form-video-preview .video-preview { margin-bottom: 6px; }
166
+ .form-video-preview > small { color: var(--muted); }
167
+ .rich-editor { overflow: hidden; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); }
168
+ .rich-toolbar { display: flex; gap: 4px; padding: 7px; border-bottom: 1px solid var(--border); background: var(--bg); }
169
+ .rich-toolbar button { width: 36px; height: 34px; min-height: 34px; padding: 0; border-color: transparent; background: transparent; font-weight: 600; }
170
+ .rich-content { min-height: 160px; padding: 12px; outline: none; overflow-wrap: anywhere; }
171
+ .rich-content:focus { box-shadow: inset 0 0 0 2px var(--accent); }
172
+
173
+ @media (max-width: 760px) {
174
+ body { overflow-x: hidden; font-size: 14px; }
175
+ .container { padding: 14px 10px 24px; }
176
+ .page-head { align-items: center; }
177
+ .page-head .button { min-height: 36px; }
178
+ .pt-index { overflow-y: auto; overflow-x: hidden; }
179
+ .pt-index, .pt-index .container { min-height: 100vh; height: auto; }
180
+ .pt-index .container { padding-bottom: 10px; }
181
+ .dashboard-shell { display: block; min-height: calc(100vh - 58px); }
182
+ .dashboard-sidebar, .dashboard-detail { min-height: calc(100vh - 58px); }
183
+ .dashboard-shell.has-selected .dashboard-sidebar { display: none; }
184
+ .dashboard-shell:not(.has-selected) .dashboard-detail { display: none; }
185
+ .record-list { overflow: visible; }
186
+ .record-row { padding: 12px; }
187
+ .record-meta { display: none; }
188
+ .dashboard-detail .post-panel { min-height: calc(100vh - 64px); }
189
+ .mobile-back { display: block; flex: 0 0 auto; margin: 4px 0 10px; font-size: 13px; }
190
+ .post-panel, .detail-scroll, .card, dl, dd { min-width: 0; max-width: 100%; }
191
+ .dashboard-detail .card { width: 100%; }
192
+ dl { grid-template-columns: 1fr; gap: 3px; }
193
+ dd { margin-bottom: 8px; }
194
+ .action-picker, .video-source-picker { grid-template-columns: 1fr; }
195
+ .form-card { padding: 0; }
196
+ }
@@ -0,0 +1,229 @@
1
+ /* product_tours dashboard helpers: strict-CSP-safe confirmations and fields. */
2
+ (function () {
3
+ "use strict";
4
+
5
+ document.addEventListener("click", function (event) {
6
+ var control = event.target.closest && event.target.closest("[data-confirm]");
7
+ if (control && !window.confirm(control.getAttribute("data-confirm"))) event.preventDefault();
8
+ });
9
+
10
+ document.addEventListener("change", function (event) {
11
+ if (event.target.matches("[data-autosubmit]")) event.target.form.requestSubmit();
12
+ if (event.target.matches("[data-action-target]")) syncActionPicker(event.target.closest("[data-action-picker]") || event.target.form);
13
+ if (event.target.matches("[data-video-source]")) syncVideoSourcePicker(event.target.closest("[data-video-source-picker]"));
14
+ if (event.target.matches("[name='post[locale]']")) refreshPostOptions(event.target.form);
15
+ if (event.target.matches("[data-action-post-select]")) event.target.dataset.selectedKey = event.target.value;
16
+ if (event.target.matches("[data-product-tour-key]")) normalizeKey(event.target, true);
17
+ if (event.target.matches("[data-video-preview-input]")) scheduleVideoPreview(event.target, 0);
18
+ });
19
+
20
+ document.addEventListener("input", function (event) {
21
+ if (event.target.matches("[data-product-tour-key]")) {
22
+ normalizeKey(event.target, false);
23
+ var valid = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/.test(event.target.value);
24
+ event.target.setCustomValidity(valid || !event.target.value ? "" : "Use lowercase letters and numbers separated by ., _, or -.");
25
+ }
26
+ if (event.target.matches("[data-video-preview-input]")) scheduleVideoPreview(event.target, 400);
27
+ if (event.target.matches("[data-rich-content]")) syncRichEditor(event.target.closest("[data-rich-editor]"));
28
+ });
29
+
30
+ document.addEventListener("focusout", function (event) {
31
+ if (!event.target.matches("[data-product-tour-key]")) return;
32
+ normalizeKey(event.target, true);
33
+ event.target.setCustomValidity("");
34
+ });
35
+
36
+ document.addEventListener("click", function (event) {
37
+ var button = event.target.closest && event.target.closest("[data-rich-command]");
38
+ if (!button) return;
39
+ var editor = button.closest("[data-rich-editor]");
40
+ var content = editor && editor.querySelector("[data-rich-content]");
41
+ if (!content) return;
42
+
43
+ event.preventDefault();
44
+ content.focus();
45
+ var command = button.getAttribute("data-rich-command");
46
+ var value = null;
47
+ if (command === "createLink") {
48
+ value = window.prompt("Link URL", "https://");
49
+ if (!value) return;
50
+ }
51
+ document.execCommand(command, false, value);
52
+ syncRichEditor(editor);
53
+ });
54
+
55
+ document.addEventListener("submit", function (event) {
56
+ var editors = event.target.querySelectorAll && event.target.querySelectorAll("[data-rich-editor]");
57
+ if (editors) editors.forEach(syncRichEditor);
58
+ });
59
+
60
+ function syncRichEditor(editor) {
61
+ if (!editor) return;
62
+ var input = editor.querySelector("[data-rich-input]");
63
+ var content = editor.querySelector("[data-rich-content]");
64
+ if (input && content) input.value = content.innerHTML;
65
+ }
66
+
67
+ function normalizeKey(input, final) {
68
+ var value = input.value.toLowerCase()
69
+ .replace(/[^a-z0-9._-]+/g, "_")
70
+ .replace(/([._-])[._-]+/g, "$1")
71
+ .replace(/^[._-]+/, "");
72
+ if (final) value = value.replace(/[._-]+$/, "");
73
+ input.value = value;
74
+ }
75
+
76
+ function showFirstVideoFrame(video) {
77
+ function seek() {
78
+ if (!Number.isFinite(video.duration) || video.duration <= 0 || video.currentTime > 0) return;
79
+ try { video.currentTime = Math.min(0.1, video.duration / 2); } catch (_error) {}
80
+ }
81
+ video.addEventListener("loadedmetadata", seek, { once: true });
82
+ if (video.readyState >= 1) seek();
83
+ }
84
+
85
+ function scheduleVideoPreview(input, delay) {
86
+ if (input.disabled) return;
87
+ window.clearTimeout(input._productToursPreviewTimer);
88
+ input._productToursPreviewTimer = window.setTimeout(function () { loadVideoPreview(input); }, delay);
89
+ }
90
+
91
+ function loadVideoPreview(input) {
92
+ var form = input.closest("form");
93
+ var preview = form && form.querySelector("[data-video-preview]");
94
+ if (!preview) return;
95
+
96
+ var frame = preview.querySelector("[data-video-preview-frame]");
97
+ var status = preview.querySelector("[data-video-preview-status]");
98
+ var value = input.value.trim();
99
+ var requestId = String(Date.now()) + Math.random();
100
+ preview.dataset.requestId = requestId;
101
+ frame.replaceChildren();
102
+ status.textContent = "";
103
+ preview.hidden = !value;
104
+ if (!value) return;
105
+
106
+ status.textContent = preview.dataset.loading;
107
+ var endpoint = new URL(preview.dataset.endpoint, window.location.origin);
108
+ endpoint.searchParams.set("url", value);
109
+ fetch(endpoint.toString(), { credentials: "same-origin", headers: { Accept: "application/json" } })
110
+ .then(function (response) {
111
+ return response.json().catch(function () { return {}; }).then(function (payload) {
112
+ if (!response.ok) throw new Error(payload.error || preview.dataset.error);
113
+ return payload;
114
+ });
115
+ })
116
+ .then(function (video) {
117
+ if (preview.dataset.requestId !== requestId) return;
118
+ renderVideoPreview(frame, video, form);
119
+ status.textContent = "";
120
+ })
121
+ .catch(function (error) {
122
+ if (preview.dataset.requestId !== requestId) return;
123
+ status.textContent = error.message || preview.dataset.error;
124
+ });
125
+ }
126
+
127
+ function renderVideoPreview(frame, video, form) {
128
+ var player;
129
+ if (video.kind === "iframe") {
130
+ player = document.createElement("iframe");
131
+ player.src = video.url;
132
+ player.title = video.title || "Video preview";
133
+ player.allow = "autoplay; fullscreen; picture-in-picture";
134
+ player.setAttribute("allowfullscreen", "");
135
+ player.referrerPolicy = "strict-origin-when-cross-origin";
136
+ } else {
137
+ player = document.createElement("video");
138
+ player.src = video.url;
139
+ player.controls = true;
140
+ player.playsInline = true;
141
+ player.preload = "metadata";
142
+ if (video.thumbnail_url) player.poster = video.thumbnail_url;
143
+ showFirstVideoFrame(player);
144
+ }
145
+ frame.appendChild(player);
146
+
147
+ var title = form.querySelector("[name='post[title]']");
148
+ if (title && !title.value.trim() && video.title) title.value = video.title;
149
+ }
150
+
151
+ function syncActionPicker(scope) {
152
+ if (!scope) return;
153
+ var picker = scope.matches && scope.matches("[data-action-picker]") ? scope : scope.querySelector("[data-action-picker]");
154
+ if (!picker) return;
155
+ var selected = picker.querySelector("[data-action-target]:checked");
156
+ var target = selected ? selected.value : "close";
157
+ picker.querySelectorAll(".action-choice").forEach(function (choice) {
158
+ choice.classList.toggle("selected", choice.contains(selected));
159
+ });
160
+
161
+ picker.parentElement.querySelectorAll("[data-action-fields]").forEach(function (fields) {
162
+ var active = fields.getAttribute("data-action-fields") === target;
163
+ fields.hidden = !active;
164
+ fields.querySelectorAll("input, select, textarea").forEach(function (input) { input.disabled = !active; });
165
+ });
166
+
167
+ var label = picker.parentElement.querySelector("[data-action-label]");
168
+ if (label) label.placeholder = target === "post" ? "Next" : "Done";
169
+ }
170
+
171
+ function syncVideoSourcePicker(picker) {
172
+ if (!picker) return;
173
+ var selected = picker.querySelector("[data-video-source]:checked");
174
+ var source = selected ? selected.value : "url";
175
+ picker.querySelectorAll(".action-choice").forEach(function (choice) {
176
+ choice.classList.toggle("selected", choice.contains(selected));
177
+ });
178
+
179
+ var section = picker.parentElement;
180
+ section.querySelectorAll("[data-video-source-fields]").forEach(function (fields) {
181
+ var active = fields.getAttribute("data-video-source-fields") === source;
182
+ fields.hidden = !active;
183
+ fields.querySelectorAll("input, select, textarea").forEach(function (input) {
184
+ input.disabled = !active;
185
+ if (input.type === "file") input.required = active && picker.dataset.hasUpload !== "true";
186
+ });
187
+ });
188
+
189
+ var urlInput = section.querySelector("[data-video-preview-input]");
190
+ if (source === "url" && urlInput && urlInput.value.trim()) scheduleVideoPreview(urlInput, 0);
191
+ }
192
+
193
+ function refreshPostOptions(form) {
194
+ if (!form) return;
195
+ var select = form.querySelector("[data-action-post-select]");
196
+ var data = form.querySelector("[data-action-post-options]");
197
+ var locale = form.querySelector("[name='post[locale]']");
198
+ if (!select || !data || !locale) return;
199
+
200
+ var posts;
201
+ try { posts = JSON.parse(data.textContent); } catch (_error) { return; }
202
+ var selectedKey = select.dataset.selectedKey || select.value;
203
+ var currentPostId = select.dataset.currentPostId;
204
+ var placeholder = select.options.length ? select.options[0].textContent : "Select the next post…";
205
+ select.replaceChildren(new Option(placeholder, ""));
206
+
207
+ posts.forEach(function (post) {
208
+ if (post.locale !== locale.value || String(post.id) === currentPostId) return;
209
+ select.add(new Option(post.label, post.key));
210
+ });
211
+ select.value = selectedKey;
212
+ }
213
+
214
+ function initializeDashboard() {
215
+ document.querySelectorAll("[data-video-source-picker]").forEach(syncVideoSourcePicker);
216
+ document.querySelectorAll("[data-action-picker]").forEach(function (picker) {
217
+ var form = picker.closest("form");
218
+ refreshPostOptions(form);
219
+ syncActionPicker(form);
220
+ });
221
+ document.querySelectorAll("[data-video-first-frame]").forEach(showFirstVideoFrame);
222
+ document.querySelectorAll("[data-video-preview-input]").forEach(function (input) {
223
+ if (!input.disabled && input.value.trim()) scheduleVideoPreview(input, 0);
224
+ });
225
+ }
226
+
227
+ document.addEventListener("turbo:load", initializeDashboard);
228
+ initializeDashboard();
229
+ })();
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails'
4
+
5
+ module ProductTours
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace ProductTours
8
+
9
+ initializer 'product_tours.helpers' do
10
+ ActiveSupport.on_load(:action_view) do
11
+ include ProductTours::WidgetHelper
12
+ end
13
+ end
14
+
15
+ initializer 'product_tours.routing' do
16
+ ActionDispatch::Routing::Mapper.include(Module.new do
17
+ def mount_product_tours(at: ProductTours.config.mount_path, **options)
18
+ ProductTours.config.mount_path = at
19
+ mount ProductTours::Engine, at:, **options
20
+ end
21
+ end)
22
+ end
23
+
24
+ initializer 'product_tours.content_security_policy', after: :load_config_initializers do |app|
25
+ ProductTours::ContentSecurityPolicy.apply!(app.config.content_security_policy)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTours
4
+ class Error < StandardError; end
5
+
6
+ class UnresolvedTriggerError < Error
7
+ attr_reader :payload
8
+
9
+ def initialize(payload)
10
+ @payload = payload
11
+ super("Product tour #{payload[:key].inspect} could not be resolved (#{payload[:reason]})")
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTours
4
+ module Seeds
5
+ DEMO_LOCALES = %w[en fr bg].freeze
6
+
7
+ POSTS = [
8
+ {
9
+ key: 'demo_walkthrough_finish',
10
+ title: 'You are ready',
11
+ action_label: 'Get started'
12
+ },
13
+ {
14
+ key: 'demo_direct_video',
15
+ title: 'Direct MP4 tutorial',
16
+ video_url: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
17
+ action_post_key: 'demo_walkthrough_finish'
18
+ },
19
+ {
20
+ key: 'demo_voomly',
21
+ title: 'Voomly tutorial',
22
+ video_url: 'https://share.voomly.com/v/CxfDyNYKE0SBEBV-zVAUHo8rNwS2eLBr418z81gd6GlkKlLfJ',
23
+ action_post_key: 'demo_direct_video'
24
+ },
25
+ {
26
+ key: 'demo_tella',
27
+ title: 'Tella tutorial',
28
+ video_url: 'https://www.tella.tv/video/add-your-logo-to-videos-39y0',
29
+ action_post_key: 'demo_voomly'
30
+ },
31
+ {
32
+ key: 'demo_loom',
33
+ title: 'Loom tutorial',
34
+ video_url: 'https://www.loom.com/share/e5b8c04bca094dd8a5507925ab887002',
35
+ action_post_key: 'demo_tella'
36
+ },
37
+ {
38
+ key: 'demo_vimeo',
39
+ title: 'Vimeo tutorial',
40
+ video_url: 'https://vimeo.com/76979871',
41
+ action_post_key: 'demo_loom'
42
+ },
43
+ {
44
+ key: 'demo_youtube',
45
+ title: 'YouTube tutorial',
46
+ video_url: 'https://www.youtube.com/watch?v=M7lc1UVf-VE',
47
+ action_post_key: 'demo_vimeo'
48
+ },
49
+ {
50
+ key: 'demo_walkthrough_features',
51
+ title: 'Compare every supported video provider',
52
+ action_post_key: 'demo_youtube'
53
+ },
54
+ {
55
+ key: 'demo_walkthrough_start',
56
+ title: 'Video provider walkthrough',
57
+ action_post_key: 'demo_walkthrough_features'
58
+ },
59
+ {
60
+ key: 'demo_getting_started',
61
+ title: 'Getting started',
62
+ action_label: 'Open settings',
63
+ action_url: '/settings'
64
+ },
65
+ {
66
+ key: 'demo_draft',
67
+ title: 'Draft tutorial',
68
+ status: 'draft'
69
+ }
70
+ ].freeze
71
+
72
+ BUTTONS = [
73
+ ['demo_walkthrough_start', 'Try the multi-step walkthrough'],
74
+ ['demo_youtube', 'Open the YouTube tutorial'],
75
+ ['demo_vimeo', 'Open the Vimeo tutorial'],
76
+ ['demo_loom', 'Open the Loom tutorial'],
77
+ ['demo_tella', 'Open the Tella tutorial'],
78
+ ['demo_voomly', 'Open the Voomly tutorial'],
79
+ ['demo_direct_video', 'Open the direct video tutorial'],
80
+ ['demo_getting_started', 'Open the getting started guide'],
81
+ ['demo_draft', 'Try an unpublished tutorial'],
82
+ ['demo_missing_post', 'Try a missing tutorial']
83
+ ].freeze
84
+
85
+ TRANSLATIONS = {
86
+ 'fr' => {
87
+ 'demo_walkthrough_finish' => { title: 'Vous êtes prêt', action_label: 'Commencer' },
88
+ 'demo_walkthrough_features' => { title: 'Comparez tous les fournisseurs vidéo pris en charge' },
89
+ 'demo_walkthrough_start' => { title: 'Présentation des fournisseurs vidéo' },
90
+ 'demo_youtube' => { title: 'Tutoriel YouTube' },
91
+ 'demo_vimeo' => { title: 'Tutoriel Vimeo' },
92
+ 'demo_loom' => { title: 'Tutoriel Loom' },
93
+ 'demo_tella' => { title: 'Tutoriel Tella' },
94
+ 'demo_voomly' => { title: 'Tutoriel Voomly' },
95
+ 'demo_direct_video' => { title: 'Tutoriel vidéo MP4' },
96
+ 'demo_getting_started' => { title: 'Bien démarrer', action_label: 'Ouvrir les paramètres' },
97
+ 'demo_draft' => { title: 'Tutoriel brouillon' }
98
+ },
99
+ 'bg' => {
100
+ 'demo_walkthrough_finish' => { title: 'Готови сте', action_label: 'Започнете' },
101
+ 'demo_walkthrough_features' => { title: 'Сравнете всички поддържани видео доставчици' },
102
+ 'demo_walkthrough_start' => { title: 'Обиколка на видео доставчиците' },
103
+ 'demo_youtube' => { title: 'Видео урок в YouTube' },
104
+ 'demo_vimeo' => { title: 'Видео урок във Vimeo' },
105
+ 'demo_loom' => { title: 'Видео урок в Loom' },
106
+ 'demo_tella' => { title: 'Видео урок в Tella' },
107
+ 'demo_voomly' => { title: 'Видео урок във Voomly' },
108
+ 'demo_direct_video' => { title: 'MP4 видео урок' },
109
+ 'demo_getting_started' => { title: 'Първи стъпки', action_label: 'Отвори настройките' },
110
+ 'demo_draft' => { title: 'Чернова на урок' }
111
+ }
112
+ }.freeze
113
+
114
+ def self.load!(locale: I18n.default_locale, status: 'published')
115
+ POSTS.map do |attributes|
116
+ locale = locale.to_s
117
+ key = attributes.fetch(:key)
118
+ localized = TRANSLATIONS.fetch(locale, {}).fetch(key, {})
119
+ post = ProductTours::Post.find_or_initialize_by(locale: locale, key: key)
120
+ post.assign_attributes(attributes.merge(localized).merge(status: attributes.fetch(:status, status)))
121
+ post.save!
122
+ post
123
+ end
124
+ end
125
+
126
+ def self.load_all!(locales: DEMO_LOCALES, status: 'published')
127
+ locales.flat_map { |locale| load!(locale: locale, status: status) }
128
+ end
129
+
130
+ def self.load_for_install!(environment: defined?(Rails) && Rails.env)
131
+ return [] unless environment&.development?
132
+
133
+ ProductTours::Post.reset_column_information
134
+ load!
135
+ end
136
+
137
+ def self.trigger_html
138
+ lines = ['<div class="product-tours-demo">']
139
+ BUTTONS.each do |key, label|
140
+ lines << %( <button type="button" data-product-tour="#{key}">#{label}</button>)
141
+ end
142
+ lines << '</div>'
143
+ lines << '<%= product_tours_tag %>'
144
+ lines.join("\n")
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTours
4
+ VERSION = '0.1.0'
5
+ end