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,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ProductTours
|
|
4
|
+
class WidgetsController < ApplicationController
|
|
5
|
+
skip_forgery_protection
|
|
6
|
+
|
|
7
|
+
def show
|
|
8
|
+
serve(Widget.javascript, Widget.fingerprint)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def dashboard
|
|
12
|
+
serve(Widget.dashboard_javascript, Widget.dashboard_fingerprint, 'text/javascript')
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def dashboard_stylesheet
|
|
16
|
+
serve(Widget.dashboard_stylesheet, Widget.dashboard_stylesheet_fingerprint, 'text/css')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def serve(source, fingerprint, content_type = 'text/javascript')
|
|
22
|
+
expires_in 1.year, public: true if params[:v] == fingerprint
|
|
23
|
+
return unless stale?(etag: [ProductTours::VERSION, fingerprint])
|
|
24
|
+
|
|
25
|
+
render plain: source, content_type: content_type
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ProductTours
|
|
4
|
+
module PostsHelper
|
|
5
|
+
LOCALE_NAMES = {
|
|
6
|
+
'ar' => 'Arabic', 'bg' => 'Bulgarian', 'bn' => 'Bengali', 'de' => 'German', 'el' => 'Greek',
|
|
7
|
+
'en' => 'English', 'es' => 'Spanish', 'fr' => 'French', 'hi' => 'Hindi', 'hr' => 'Croatian',
|
|
8
|
+
'id' => 'Indonesian', 'it' => 'Italian', 'ja' => 'Japanese', 'ko' => 'Korean',
|
|
9
|
+
'lb' => 'Luxembourgish', 'nl' => 'Dutch', 'pl' => 'Polish', 'pt' => 'Portuguese',
|
|
10
|
+
'ro' => 'Romanian', 'ru' => 'Russian', 'th' => 'Thai', 'tr' => 'Turkish', 'uk' => 'Ukrainian',
|
|
11
|
+
'ur' => 'Urdu', 'vi' => 'Vietnamese', 'zh-CN' => 'Chinese (Simplified)'
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def product_tours_locale_name(locale)
|
|
15
|
+
code = locale.to_s
|
|
16
|
+
"#{LOCALE_NAMES.fetch(code, code.upcase)} (#{code})"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ProductTours
|
|
4
|
+
module WidgetHelper
|
|
5
|
+
def product_tours_tag
|
|
6
|
+
return unless ProductTours.enabled?(request)
|
|
7
|
+
|
|
8
|
+
ProductTours::Widget.snippet(
|
|
9
|
+
locale: ProductTours.locale(request),
|
|
10
|
+
nonce: (content_security_policy_nonce if respond_to?(:content_security_policy_nonce))
|
|
11
|
+
).html_safe
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module ProductTours
|
|
6
|
+
class Post < ApplicationRecord
|
|
7
|
+
KEY_FORMAT = /\A[a-z0-9]+(?:[._-][a-z0-9]+)*\z/
|
|
8
|
+
STATUSES = %w[draft published].freeze
|
|
9
|
+
DASHBOARD_STATUSES = %w[published draft].freeze
|
|
10
|
+
|
|
11
|
+
enum :status, STATUSES.index_by(&:itself), default: :draft
|
|
12
|
+
|
|
13
|
+
has_rich_text :description if respond_to?(:has_rich_text)
|
|
14
|
+
|
|
15
|
+
has_one_attached :video, service: ProductTours.config.storage_service if defined?(::ActiveStorage)
|
|
16
|
+
|
|
17
|
+
validates :key, presence: true, format: { with: KEY_FORMAT }, uniqueness: { scope: :locale }
|
|
18
|
+
validates :locale, presence: true
|
|
19
|
+
validates :status, inclusion: { in: STATUSES }
|
|
20
|
+
validates :title, presence: true
|
|
21
|
+
validate :action_has_one_destination
|
|
22
|
+
validate :action_post_is_linkable
|
|
23
|
+
validate :action_url_is_safe
|
|
24
|
+
validate :video_url_is_supported
|
|
25
|
+
validate :uploaded_file_is_video
|
|
26
|
+
|
|
27
|
+
before_validation :set_default_locale
|
|
28
|
+
before_validation :derive_video_metadata, if: :will_save_change_to_video_url?
|
|
29
|
+
|
|
30
|
+
scope :newest_first, -> { order(updated_at: :desc, id: :desc) }
|
|
31
|
+
|
|
32
|
+
def self.description_supported?
|
|
33
|
+
reflect_on_association(:rich_text_description).present? &&
|
|
34
|
+
ActionText::RichText.table_exists?
|
|
35
|
+
rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid, NameError
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.video_upload_supported?
|
|
40
|
+
reflect_on_association(:video_attachment).present? &&
|
|
41
|
+
ActiveStorage::Blob.table_exists? && ActiveStorage::Attachment.table_exists?
|
|
42
|
+
rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid, NameError
|
|
43
|
+
false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def uploaded_video?
|
|
47
|
+
self.class.video_upload_supported? && video.attached?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def resolved_video
|
|
51
|
+
return { kind: 'upload', provider: 'upload', url: nil } if uploaded_video?
|
|
52
|
+
|
|
53
|
+
resolved = VideoResolver.resolve(video_url)
|
|
54
|
+
return if resolved.nil?
|
|
55
|
+
|
|
56
|
+
{
|
|
57
|
+
kind: resolved.kind,
|
|
58
|
+
provider: resolved.provider,
|
|
59
|
+
url: resolved.url,
|
|
60
|
+
thumbnail_url: video_metadata.to_h['thumbnail_url']
|
|
61
|
+
}.compact
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def action_post
|
|
65
|
+
return if action_post_key.blank?
|
|
66
|
+
|
|
67
|
+
self.class.find_by(locale: locale, key: action_post_key)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def set_default_locale
|
|
73
|
+
self.locale = I18n.default_locale.to_s if locale.blank?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def derive_video_metadata
|
|
77
|
+
resolved = VideoResolver.resolve(video_url)
|
|
78
|
+
self.video_metadata = resolved ? video_metadata.to_h.merge(resolved.metadata) : {}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def video_url_is_supported
|
|
82
|
+
return if video_url.blank? || VideoResolver.resolve(video_url)
|
|
83
|
+
|
|
84
|
+
errors.add(:video_url, 'must be an HTTPS YouTube, Vimeo, Loom, Tella, Voomly, MP4, or WebM URL')
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def action_url_is_safe
|
|
88
|
+
return if action_url.blank?
|
|
89
|
+
return if action_url.start_with?('/') && !action_url.start_with?('//')
|
|
90
|
+
|
|
91
|
+
uri = URI.parse(action_url)
|
|
92
|
+
return if uri.is_a?(URI::HTTP) && %w[http https].include?(uri.scheme) && uri.host.present? && uri.userinfo.nil?
|
|
93
|
+
|
|
94
|
+
errors.add(:action_url, 'must be a relative path or an HTTP(S) URL')
|
|
95
|
+
rescue URI::InvalidURIError
|
|
96
|
+
errors.add(:action_url, 'must be a relative path or an HTTP(S) URL')
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def action_has_one_destination
|
|
100
|
+
return unless action_url.present? && action_post_key.present?
|
|
101
|
+
|
|
102
|
+
errors.add(:base, 'Primary action can open a URL or another post, not both')
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def action_post_is_linkable
|
|
106
|
+
return if action_post_key.blank?
|
|
107
|
+
|
|
108
|
+
unless action_post_key.match?(KEY_FORMAT)
|
|
109
|
+
errors.add(:action_post_key, 'must use a valid post key')
|
|
110
|
+
return
|
|
111
|
+
end
|
|
112
|
+
if action_post_key == key
|
|
113
|
+
errors.add(:action_post_key, 'cannot link to the same post')
|
|
114
|
+
return
|
|
115
|
+
end
|
|
116
|
+
return if locale.blank? || self.class.exists?(locale: locale, key: action_post_key)
|
|
117
|
+
|
|
118
|
+
errors.add(:action_post_key, 'must identify an existing post in the same locale')
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def uploaded_file_is_video
|
|
122
|
+
return unless uploaded_video?
|
|
123
|
+
return if video.blob.content_type.to_s.start_with?('video/')
|
|
124
|
+
|
|
125
|
+
errors.add(:video, 'must be a video file')
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title><%= t("product_tours.dashboard.title", default: "Product tours") %></title>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<%= csrf_meta_tags %>
|
|
7
|
+
<%= csp_meta_tag %>
|
|
8
|
+
<%= stylesheet_link_tag "#{dashboard_stylesheet_path}?v=#{ProductTours::Widget.dashboard_stylesheet_fingerprint}",
|
|
9
|
+
"data-turbo-track": "reload" %>
|
|
10
|
+
<%= javascript_include_tag "#{dashboard_script_path}?v=#{ProductTours::Widget.dashboard_fingerprint}",
|
|
11
|
+
"data-turbo-track": "reload", defer: true, nonce: true %>
|
|
12
|
+
</head>
|
|
13
|
+
<body class="<%= content_for?(:body_class) ? yield(:body_class) : '' %>">
|
|
14
|
+
<div class="container">
|
|
15
|
+
<% if notice.present? %><div class="flash notice"><%= notice %></div><% end %>
|
|
16
|
+
<% if alert.present? %><div class="flash alert"><%= alert %></div><% end %>
|
|
17
|
+
<%= yield %>
|
|
18
|
+
</div>
|
|
19
|
+
</body>
|
|
20
|
+
</html>
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
<%= form_with model: post, class: "post-form" do |form| %>
|
|
2
|
+
<% action_target = post.action_post_key.present? ? "post" : (post.action_url.present? ? "url" : "close") %>
|
|
3
|
+
<% linkable_posts = @linkable_posts.map do |candidate|
|
|
4
|
+
{ id: candidate.id, key: candidate.key, locale: candidate.locale, status: candidate.status,
|
|
5
|
+
label: "#{candidate.title} — #{candidate.key} (#{candidate.status})" }
|
|
6
|
+
end %>
|
|
7
|
+
<% video_source = params[:video_source].presence || (post.uploaded_video? ? "upload" : "url") %>
|
|
8
|
+
<% if post.errors.any? %>
|
|
9
|
+
<div class="errors" role="alert">
|
|
10
|
+
<strong><%= t("product_tours.dashboard.fix_errors", default: "Fix the following fields:") %></strong>
|
|
11
|
+
<ul><% post.errors.full_messages.each do |message| %><li><%= message %></li><% end %></ul>
|
|
12
|
+
</div>
|
|
13
|
+
<% end %>
|
|
14
|
+
|
|
15
|
+
<div class="form-card">
|
|
16
|
+
<section class="form-section">
|
|
17
|
+
<h2><%= t("product_tours.dashboard.identity", default: "Tutorial details") %></h2>
|
|
18
|
+
<div class="field">
|
|
19
|
+
<%= form.label :title %>
|
|
20
|
+
<%= form.text_field :title, required: true, autofocus: true %>
|
|
21
|
+
</div>
|
|
22
|
+
<div class="field">
|
|
23
|
+
<%= form.label :key, t("product_tours.dashboard.trigger_key", default: "Trigger key") %>
|
|
24
|
+
<%= form.text_field :key, required: true, disabled: @identity_locked,
|
|
25
|
+
pattern: "[a-z0-9]+([._-][a-z0-9]+)*",
|
|
26
|
+
data: { product_tour_key: true }, placeholder: "billing_setup" %>
|
|
27
|
+
<small>
|
|
28
|
+
<% if @identity_locked %>
|
|
29
|
+
<%= t("product_tours.dashboard.key_locked_hint", default: "Shared by this tutorial and its translations.") %>
|
|
30
|
+
<% else %>
|
|
31
|
+
<%= t("product_tours.dashboard.key_hint", default: "Used by your app to open this tutorial. Lowercase letters and numbers, separated by ., _, or -.") %>
|
|
32
|
+
<% end %>
|
|
33
|
+
</small>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="field">
|
|
36
|
+
<%= form.label :locale, t("product_tours.dashboard.language", default: "Language") %>
|
|
37
|
+
<%= form.text_field :locale, disabled: true %>
|
|
38
|
+
<small>
|
|
39
|
+
<% if post.new_record? %>
|
|
40
|
+
<%= t("product_tours.dashboard.default_language_hint", default: "New tutorials start in your app's default language. Add translations from the tutorial page.") %>
|
|
41
|
+
<% else %>
|
|
42
|
+
<%= t("product_tours.dashboard.language_locked_hint", default: "Add and open other languages from the tutorial page.") %>
|
|
43
|
+
<% end %>
|
|
44
|
+
</small>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="field">
|
|
47
|
+
<%= form.label :status, t("product_tours.dashboard.publication_status", default: "Publication status") %>
|
|
48
|
+
<%= form.select :status, ProductTours::Post::STATUSES.map { |status| [status.humanize, status] } %>
|
|
49
|
+
</div>
|
|
50
|
+
</section>
|
|
51
|
+
|
|
52
|
+
<% if ProductTours::Post.description_supported? %>
|
|
53
|
+
<section class="form-section">
|
|
54
|
+
<h2><%= t("product_tours.dashboard.description", default: "Description") %></h2>
|
|
55
|
+
<div class="rich-editor" data-rich-editor>
|
|
56
|
+
<div class="rich-toolbar" role="toolbar" aria-label="<%= t("product_tours.dashboard.formatting", default: "Formatting") %>">
|
|
57
|
+
<button type="button" data-rich-command="bold" aria-label="<%= t("product_tours.dashboard.bold", default: "Bold") %>"><strong>B</strong></button>
|
|
58
|
+
<button type="button" data-rich-command="italic" aria-label="<%= t("product_tours.dashboard.italic", default: "Italic") %>"><em>I</em></button>
|
|
59
|
+
<button type="button" data-rich-command="insertUnorderedList" aria-label="<%= t("product_tours.dashboard.bullet_list", default: "Bulleted list") %>">•</button>
|
|
60
|
+
<button type="button" data-rich-command="insertOrderedList" aria-label="<%= t("product_tours.dashboard.numbered_list", default: "Numbered list") %>">1.</button>
|
|
61
|
+
<button type="button" data-rich-command="createLink" aria-label="<%= t("product_tours.dashboard.link", default: "Link") %>">↗</button>
|
|
62
|
+
</div>
|
|
63
|
+
<%= form.hidden_field :description, value: post.description.body.to_s, data: { rich_input: true } %>
|
|
64
|
+
<div class="rich-content" contenteditable="true" role="textbox" aria-multiline="true"
|
|
65
|
+
aria-label="<%= t("product_tours.dashboard.description", default: "Description") %>"
|
|
66
|
+
data-rich-content><%= post.description.body.to_s.html_safe %></div>
|
|
67
|
+
</div>
|
|
68
|
+
</section>
|
|
69
|
+
<% end %>
|
|
70
|
+
|
|
71
|
+
<section class="form-section">
|
|
72
|
+
<h2><%= t("product_tours.dashboard.video", default: "Video") %></h2>
|
|
73
|
+
<% if ProductTours::Post.video_upload_supported? %>
|
|
74
|
+
<fieldset class="video-source-picker" data-video-source-picker data-has-upload="<%= post.uploaded_video? %>">
|
|
75
|
+
<legend class="sr-only"><%= t("product_tours.dashboard.video_source", default: "Video source") %></legend>
|
|
76
|
+
<label class="action-choice">
|
|
77
|
+
<%= radio_button_tag :video_source, "url", video_source == "url", data: { video_source: true } %>
|
|
78
|
+
<span><strong><%= t("product_tours.dashboard.video_link", default: "Use a video link") %></strong>
|
|
79
|
+
<small><%= t("product_tours.dashboard.video_link_hint", default: "Paste a link from a supported provider or a direct video URL.") %></small></span>
|
|
80
|
+
</label>
|
|
81
|
+
<label class="action-choice">
|
|
82
|
+
<%= radio_button_tag :video_source, "upload", video_source == "upload", data: { video_source: true } %>
|
|
83
|
+
<span><strong><%= t("product_tours.dashboard.video_upload", default: "Upload a video") %></strong>
|
|
84
|
+
<small><%= t("product_tours.dashboard.video_upload_hint", default: "Choose a video file from your computer.") %></small></span>
|
|
85
|
+
</label>
|
|
86
|
+
</fieldset>
|
|
87
|
+
<% end %>
|
|
88
|
+
|
|
89
|
+
<div data-video-source-fields="url" <%= "hidden" unless video_source == "url" %>>
|
|
90
|
+
<div class="field">
|
|
91
|
+
<%= form.label :video_url, t("product_tours.dashboard.video_url", default: "Video URL") %>
|
|
92
|
+
<%= form.url_field :video_url, placeholder: "https://www.loom.com/share/...",
|
|
93
|
+
disabled: video_source != "url", data: { video_preview_input: true } %>
|
|
94
|
+
<small><%= t("product_tours.dashboard.video_hint", default: "YouTube, Vimeo, Loom, Tella, Voomly, or a direct MP4/WebM URL.") %></small>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="form-video-preview" data-video-preview
|
|
97
|
+
data-endpoint="<%= video_preview_posts_path %>"
|
|
98
|
+
data-loading="<%= t("product_tours.dashboard.video_loading", default: "Loading preview…") %>"
|
|
99
|
+
data-error="<%= t("product_tours.dashboard.video_preview_failed", default: "This video could not be previewed.") %>"
|
|
100
|
+
hidden>
|
|
101
|
+
<div class="video-preview" data-video-preview-frame></div>
|
|
102
|
+
<small data-video-preview-status role="status"></small>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<% if ProductTours::Post.video_upload_supported? %>
|
|
107
|
+
<div data-video-source-fields="upload" <%= "hidden" unless video_source == "upload" %>>
|
|
108
|
+
<div class="field">
|
|
109
|
+
<%= form.label :video, t("product_tours.dashboard.video_file", default: "Video file") %>
|
|
110
|
+
<%= form.file_field :video, accept: "video/*", disabled: video_source != "upload",
|
|
111
|
+
required: video_source == "upload" && !post.uploaded_video? %>
|
|
112
|
+
<% if post.uploaded_video? %>
|
|
113
|
+
<small><%= t("product_tours.dashboard.current_video", default: "Current file: %{filename}", filename: post.video.filename) %></small>
|
|
114
|
+
<% end %>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
<% end %>
|
|
118
|
+
</section>
|
|
119
|
+
|
|
120
|
+
<section class="form-section">
|
|
121
|
+
<h2><%= t("product_tours.dashboard.action", default: "Primary action") %></h2>
|
|
122
|
+
<p class="section-intro"><%= t("product_tours.dashboard.action_intro", default: "Choose what happens when the visitor clicks the main button.") %></p>
|
|
123
|
+
<fieldset class="action-picker" data-action-picker>
|
|
124
|
+
<legend class="sr-only"><%= t("product_tours.dashboard.action_destination", default: "Button destination") %></legend>
|
|
125
|
+
<label class="action-choice">
|
|
126
|
+
<%= radio_button_tag :action_target, "close", action_target == "close", data: { action_target: true } %>
|
|
127
|
+
<span><strong><%= t("product_tours.dashboard.action_finish", default: "Finish and close") %></strong>
|
|
128
|
+
<small><%= t("product_tours.dashboard.action_finish_hint", default: "Marks this tutorial complete and closes the modal.") %></small></span>
|
|
129
|
+
</label>
|
|
130
|
+
<label class="action-choice">
|
|
131
|
+
<%= radio_button_tag :action_target, "post", action_target == "post", data: { action_target: true } %>
|
|
132
|
+
<span><strong><%= t("product_tours.dashboard.action_post", default: "Continue to another tutorial") %></strong>
|
|
133
|
+
<small><%= t("product_tours.dashboard.action_post_hint", default: "Opens it in the same modal and automatically adds a Back button.") %></small></span>
|
|
134
|
+
</label>
|
|
135
|
+
<label class="action-choice">
|
|
136
|
+
<%= radio_button_tag :action_target, "url", action_target == "url", data: { action_target: true } %>
|
|
137
|
+
<span><strong><%= t("product_tours.dashboard.action_url_choice", default: "Open a page") %></strong>
|
|
138
|
+
<small><%= t("product_tours.dashboard.action_url_hint", default: "Marks this tutorial complete, then sends the visitor to a URL.") %></small></span>
|
|
139
|
+
</label>
|
|
140
|
+
</fieldset>
|
|
141
|
+
|
|
142
|
+
<div class="field">
|
|
143
|
+
<%= form.label :action_label, t("product_tours.dashboard.action_label", default: "Button label") %>
|
|
144
|
+
<%= form.text_field :action_label, placeholder: action_target == "post" ? t("product_tours.next", default: "Next") : t("product_tours.done", default: "Done"),
|
|
145
|
+
data: { action_label: true } %>
|
|
146
|
+
<small><%= t("product_tours.dashboard.action_label_hint", default: "Optional. A sensible label is used when left blank.") %></small>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<div data-action-fields="post">
|
|
150
|
+
<div class="field">
|
|
151
|
+
<%= form.label :action_post_key, t("product_tours.dashboard.next_post", default: "Next tutorial") %>
|
|
152
|
+
<% same_locale_posts = @linkable_posts.select { |candidate| candidate.locale == post.locale } %>
|
|
153
|
+
<% post_options = same_locale_posts.map { |candidate| ["#{candidate.title} — #{candidate.key} (#{candidate.status})", candidate.key] } %>
|
|
154
|
+
<%= form.select :action_post_key, options_for_select(post_options, post.action_post_key),
|
|
155
|
+
{ include_blank: t("product_tours.dashboard.select_next_post", default: "Select the next tutorial…") },
|
|
156
|
+
required: true, data: { action_post_select: true, selected_key: post.action_post_key,
|
|
157
|
+
current_post_id: post.id } %>
|
|
158
|
+
<small><%= t("product_tours.dashboard.next_post_hint", default: "Tutorials must use the same language. Draft tutorials can be linked while you prepare the sequence.") %></small>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div data-action-fields="url">
|
|
163
|
+
<div class="field">
|
|
164
|
+
<%= form.label :action_url %>
|
|
165
|
+
<%= form.text_field :action_url, placeholder: "/settings", required: true %>
|
|
166
|
+
<small><%= t("product_tours.dashboard.action_hint", default: "Use an app path such as /settings or a complete HTTP(S) URL.") %></small>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
<script type="application/json" data-action-post-options><%= raw json_escape(linkable_posts.to_json) %></script>
|
|
170
|
+
</section>
|
|
171
|
+
|
|
172
|
+
<div class="form-actions">
|
|
173
|
+
<%= form.submit(post.persisted? ? t("product_tours.dashboard.save", default: "Save changes") : t("product_tours.dashboard.create", default: "Create tutorial"), class: "button primary") %>
|
|
174
|
+
<%= link_to t("product_tours.dashboard.cancel", default: "Cancel"), post.persisted? ? post_path(post) : posts_path, class: "button" %>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
<% end %>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<section class="post-panel">
|
|
2
|
+
<div class="detail-scroll">
|
|
3
|
+
<article class="card pad preview-card">
|
|
4
|
+
<h2><%= post.title %></h2>
|
|
5
|
+
<% video = post.resolved_video %>
|
|
6
|
+
<% if video %>
|
|
7
|
+
<div class="video-preview">
|
|
8
|
+
<% if video[:kind] == "iframe" %>
|
|
9
|
+
<iframe src="<%= video[:url] %>" title="<%= post.title %>" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>
|
|
10
|
+
<% else %>
|
|
11
|
+
<video src="<%= post.uploaded_video? ? media_path(post) : video[:url] %>" controls playsinline preload="metadata"
|
|
12
|
+
<% if video[:thumbnail_url].present? %>poster="<%= video[:thumbnail_url] %>"<% end %>
|
|
13
|
+
data-video-first-frame></video>
|
|
14
|
+
<% end %>
|
|
15
|
+
</div>
|
|
16
|
+
<% end %>
|
|
17
|
+
<% if ProductTours::Post.description_supported? && post.description.present? %>
|
|
18
|
+
<div class="description-preview"><%= post.description %></div>
|
|
19
|
+
<% end %>
|
|
20
|
+
<div class="preview-action">
|
|
21
|
+
<% default_label = post.action_post_key.present? ? t("product_tours.next", default: "Next") : t("product_tours.done", default: "Done") %>
|
|
22
|
+
<% if post.action_post_key.present? %><span class="preview-back"><%= t("product_tours.back", default: "Back") %></span><% end %>
|
|
23
|
+
<span class="button primary"><%= post.action_label.presence || default_label %></span>
|
|
24
|
+
</div>
|
|
25
|
+
</article>
|
|
26
|
+
|
|
27
|
+
<article class="card pad details-card">
|
|
28
|
+
<dl>
|
|
29
|
+
<% target_post = post.action_post %>
|
|
30
|
+
<dt><%= t("product_tours.dashboard.key", default: "Key") %></dt>
|
|
31
|
+
<dd><code class="key"><%= post.key %></code></dd>
|
|
32
|
+
<dt><%= t("product_tours.dashboard.language", default: "Language") %></dt>
|
|
33
|
+
<dd><span class="badge locale"><%= post.locale %></span></dd>
|
|
34
|
+
<dt><%= t("product_tours.dashboard.publication_status", default: "Publication status") %></dt>
|
|
35
|
+
<dd><span class="badge status-<%= post.status %>"><%= post.status.humanize %></span></dd>
|
|
36
|
+
<dt><%= t("product_tours.dashboard.video_url", default: "Video URL") %></dt><dd><%= post.video_url.presence || "—" %></dd>
|
|
37
|
+
<dt><%= t("product_tours.dashboard.action_label", default: "Action label") %></dt><dd><%= post.action_label.presence || default_label %></dd>
|
|
38
|
+
<dt><%= t("product_tours.dashboard.action_url", default: "Action URL") %></dt><dd><%= post.action_url.presence || "—" %></dd>
|
|
39
|
+
<dt><%= t("product_tours.dashboard.next_post", default: "Next tutorial") %></dt>
|
|
40
|
+
<dd>
|
|
41
|
+
<% if target_post %>
|
|
42
|
+
<%= link_to target_post.title, post_path(target_post) %> <code class="key"><%= post.action_post_key %></code>
|
|
43
|
+
<% else %>
|
|
44
|
+
—
|
|
45
|
+
<% end %>
|
|
46
|
+
</dd>
|
|
47
|
+
</dl>
|
|
48
|
+
<% if post.video_url.present? %>
|
|
49
|
+
<%= button_to t("product_tours.dashboard.refresh_metadata", default: "Refresh video data"),
|
|
50
|
+
refresh_video_metadata_post_path(post), class: "button small refresh-button" %>
|
|
51
|
+
<% end %>
|
|
52
|
+
<% if post.video_metadata.present? %><pre class="metadata"><%= JSON.pretty_generate(post.video_metadata) %></pre><% end %>
|
|
53
|
+
|
|
54
|
+
<div class="actions details-actions">
|
|
55
|
+
<% if post.draft? %>
|
|
56
|
+
<%= button_to t("product_tours.dashboard.publish", default: "Publish"), publish_post_path(post), method: :patch,
|
|
57
|
+
class: "button primary" %>
|
|
58
|
+
<%= link_to t("product_tours.dashboard.edit", default: "Edit"), edit_post_path(post), class: "button" %>
|
|
59
|
+
<% else %>
|
|
60
|
+
<%= link_to t("product_tours.dashboard.edit", default: "Edit"), edit_post_path(post), class: "button primary" %>
|
|
61
|
+
<%= button_to t("product_tours.dashboard.unpublish", default: "Move to drafts"), unpublish_post_path(post),
|
|
62
|
+
method: :patch, class: "button" %>
|
|
63
|
+
<% end %>
|
|
64
|
+
<%= button_to t("product_tours.dashboard.delete", default: "Delete"), post_path(post), method: :delete,
|
|
65
|
+
class: "danger", form: { data: { confirm: t("product_tours.dashboard.delete_confirm", default: "Delete this tutorial?") } } %>
|
|
66
|
+
</div>
|
|
67
|
+
</article>
|
|
68
|
+
|
|
69
|
+
<article class="card pad translations-card">
|
|
70
|
+
<h2><%= t("product_tours.dashboard.languages", default: "Languages") %></h2>
|
|
71
|
+
<div class="translation-list">
|
|
72
|
+
<% @translations.each do |translation| %>
|
|
73
|
+
<div class="translation-row">
|
|
74
|
+
<span class="translation-name">
|
|
75
|
+
<span class="badge locale"><%= product_tours_locale_name(translation.locale) %></span>
|
|
76
|
+
<% if translation.locale == I18n.default_locale.to_s %>
|
|
77
|
+
<span class="muted"><%= t("product_tours.dashboard.default_language", default: "Default") %></span>
|
|
78
|
+
<% end %>
|
|
79
|
+
<span class="badge status-<%= translation.status %>"><%= translation.status.humanize %></span>
|
|
80
|
+
</span>
|
|
81
|
+
<% if translation.id == post.id %>
|
|
82
|
+
<span class="muted"><%= t("product_tours.dashboard.current_language", default: "Current") %></span>
|
|
83
|
+
<% else %>
|
|
84
|
+
<% translation_path = if sidebar_context
|
|
85
|
+
posts_path(status: @status, q: @query, page: @page,
|
|
86
|
+
post_id: translation.id)
|
|
87
|
+
else
|
|
88
|
+
post_path(translation)
|
|
89
|
+
end %>
|
|
90
|
+
<%= link_to t("product_tours.dashboard.open", default: "Open"), translation_path %>
|
|
91
|
+
<% end %>
|
|
92
|
+
</div>
|
|
93
|
+
<% end %>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<% if @missing_translation_locales.any? %>
|
|
97
|
+
<%= form_with url: add_translation_post_path(post), method: :post, class: "translation-form" do |form| %>
|
|
98
|
+
<div class="field">
|
|
99
|
+
<%= form.label :locale, t("product_tours.dashboard.add_language", default: "Add another language") %>
|
|
100
|
+
<%= form.select :locale,
|
|
101
|
+
options_for_select(@missing_translation_locales.map { |locale| [product_tours_locale_name(locale), locale] }) %>
|
|
102
|
+
</div>
|
|
103
|
+
<%= form.submit t("product_tours.dashboard.add_translation", default: "Add translation"), class: "button" %>
|
|
104
|
+
<% end %>
|
|
105
|
+
<% else %>
|
|
106
|
+
<p class="muted translation-complete"><%= t("product_tours.dashboard.all_languages_added", default: "All available languages have been added.") %></p>
|
|
107
|
+
<% end %>
|
|
108
|
+
</article>
|
|
109
|
+
|
|
110
|
+
</div>
|
|
111
|
+
</section>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<% content_for :body_class, "pt-form-page" %>
|
|
2
|
+
|
|
3
|
+
<div class="breadcrumb"><%= link_to t("product_tours.dashboard.title", default: "Product tours"), posts_path %> / <%= link_to @post.title, post_path(@post) %> / <%= t("product_tours.dashboard.edit", default: "Edit") %></div>
|
|
4
|
+
<h1><%= t("product_tours.dashboard.edit_post", default: "Edit tutorial") %></h1>
|
|
5
|
+
<%= render "form", post: @post %>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<% content_for :body_class, "pt-index" %>
|
|
2
|
+
|
|
3
|
+
<div class="page-head">
|
|
4
|
+
<h1><%= t("product_tours.dashboard.title", default: "Product tours") %></h1>
|
|
5
|
+
<%= link_to t("product_tours.dashboard.new_post", default: "New product tour"), new_post_path, class: "button primary" %>
|
|
6
|
+
</div>
|
|
7
|
+
|
|
8
|
+
<div class="dashboard-shell <%= "has-selected" if @selected_post %>">
|
|
9
|
+
<aside class="dashboard-sidebar" aria-label="<%= t("product_tours.dashboard.posts", default: "Tutorials") %>">
|
|
10
|
+
<nav class="tabs">
|
|
11
|
+
<% ProductTours::Post::DASHBOARD_STATUSES.each do |status| %>
|
|
12
|
+
<%= link_to posts_path(status: status, q: @query),
|
|
13
|
+
class: ("active" if status == @status) do %>
|
|
14
|
+
<%= status.humanize %>
|
|
15
|
+
<span class="count"><%= @counts.fetch(status, 0) %></span>
|
|
16
|
+
<% end %>
|
|
17
|
+
<% end %>
|
|
18
|
+
</nav>
|
|
19
|
+
|
|
20
|
+
<%= form_with url: posts_path, method: :get, class: "filters" do |form| %>
|
|
21
|
+
<%= form.hidden_field :status, value: @status %>
|
|
22
|
+
<%= form.search_field :q, value: @query,
|
|
23
|
+
list: "product-tour-key-options",
|
|
24
|
+
placeholder: t("product_tours.dashboard.search_key", default: "Search key"),
|
|
25
|
+
aria: { label: t("product_tours.dashboard.search", default: "Search") } %>
|
|
26
|
+
<datalist id="product-tour-key-options">
|
|
27
|
+
<% @keys.each do |key| %>
|
|
28
|
+
<%= tag.option value: key %>
|
|
29
|
+
<% end %>
|
|
30
|
+
</datalist>
|
|
31
|
+
<%= form.submit t("product_tours.dashboard.search", default: "Search") %>
|
|
32
|
+
<% if @query.present? %>
|
|
33
|
+
<%= link_to t("product_tours.dashboard.clear_filters", default: "Clear"), posts_path,
|
|
34
|
+
class: "button filters-clear" %>
|
|
35
|
+
<% end %>
|
|
36
|
+
<% end %>
|
|
37
|
+
|
|
38
|
+
<div class="record-list">
|
|
39
|
+
<% if @posts.any? %>
|
|
40
|
+
<% @posts.each do |post| %>
|
|
41
|
+
<% provider = post.resolved_video&.dig(:provider) %>
|
|
42
|
+
<%= link_to posts_path(status: @status, q: @query, page: @page, post_id: post.id),
|
|
43
|
+
class: ["record-row", ("active" if @selected_post&.id == post.id)].compact do %>
|
|
44
|
+
<span class="record-main">
|
|
45
|
+
<span class="record-topline">
|
|
46
|
+
<span class="badge status-<%= post.status %>"><%= post.status.humanize %></span>
|
|
47
|
+
</span>
|
|
48
|
+
<span class="record-copy"><%= post.title %></span>
|
|
49
|
+
<span class="muted record-meta">
|
|
50
|
+
<code class="key"><%= post.key %></code>
|
|
51
|
+
<% if provider %><span><%= provider.humanize %></span><% end %>
|
|
52
|
+
</span>
|
|
53
|
+
</span>
|
|
54
|
+
<span class="record-side" aria-hidden="true"><%= provider ? "▶" : "→" %></span>
|
|
55
|
+
<% end %>
|
|
56
|
+
<% end %>
|
|
57
|
+
<% else %>
|
|
58
|
+
<div class="empty"><%= t("product_tours.dashboard.empty", default: "Nothing here yet.") %></div>
|
|
59
|
+
<% end %>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<nav class="pager">
|
|
63
|
+
<span>
|
|
64
|
+
<% if @page > 1 %>
|
|
65
|
+
<%= link_to t("product_tours.dashboard.newer", default: "← Newer"),
|
|
66
|
+
posts_path(status: @status, q: @query, page: @page - 1) %>
|
|
67
|
+
<% end %>
|
|
68
|
+
</span>
|
|
69
|
+
<span>
|
|
70
|
+
<% if @more %>
|
|
71
|
+
<%= link_to t("product_tours.dashboard.older", default: "Older →"),
|
|
72
|
+
posts_path(status: @status, q: @query, page: @page + 1) %>
|
|
73
|
+
<% end %>
|
|
74
|
+
</span>
|
|
75
|
+
</nav>
|
|
76
|
+
</aside>
|
|
77
|
+
|
|
78
|
+
<main class="dashboard-detail" aria-label="<%= t("product_tours.dashboard.current_post", default: "Current tutorial") %>">
|
|
79
|
+
<% if @selected_post %>
|
|
80
|
+
<p class="mobile-back">
|
|
81
|
+
<%= link_to t("product_tours.dashboard.back", default: "All tutorials"),
|
|
82
|
+
posts_path(status: @status, q: @query, page: @page) %>
|
|
83
|
+
</p>
|
|
84
|
+
<%= render "post_panel", post: @selected_post, sidebar_context: true %>
|
|
85
|
+
<% else %>
|
|
86
|
+
<div class="empty-state">
|
|
87
|
+
<h2><%= t("product_tours.dashboard.select_post", default: "Select a tutorial") %></h2>
|
|
88
|
+
<p class="muted"><%= t("product_tours.dashboard.select_post_hint", default: "Open a tutorial to preview or edit it.") %></p>
|
|
89
|
+
</div>
|
|
90
|
+
<% end %>
|
|
91
|
+
</main>
|
|
92
|
+
</div>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<% content_for :body_class, "pt-form-page" %>
|
|
2
|
+
|
|
3
|
+
<div class="breadcrumb"><%= link_to t("product_tours.dashboard.title", default: "Product tours"), posts_path %> / <%= t("product_tours.dashboard.new_post", default: "New product tour") %></div>
|
|
4
|
+
<h1><%= t("product_tours.dashboard.new_post", default: "New product tour") %></h1>
|
|
5
|
+
<%= render "form", post: @post %>
|