feat: use tracking pixel for article view count (#11559)
This commit is contained in:
parent
873cfa08d8
commit
b3a76289cc
@ -1,7 +1,7 @@
|
|||||||
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
|
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
|
||||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||||
before_action :portal
|
before_action :portal
|
||||||
before_action :set_category, except: [:index, :show]
|
before_action :set_category, except: [:index, :show, :tracking_pixel]
|
||||||
before_action :set_article, only: [:show]
|
before_action :set_article, only: [:show]
|
||||||
layout 'portal'
|
layout 'portal'
|
||||||
|
|
||||||
@ -15,6 +15,21 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
|||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
|
|
||||||
|
def tracking_pixel
|
||||||
|
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
|
||||||
|
return head :not_found unless @article
|
||||||
|
|
||||||
|
@article.increment_view_count if @article.published?
|
||||||
|
|
||||||
|
# Serve the 1x1 tracking pixel with 24-hour private cache
|
||||||
|
# Private cache bypasses CDN but allows browser caching to prevent duplicate views from same user
|
||||||
|
expires_in 24.hours, public: false
|
||||||
|
response.headers['Content-Type'] = 'image/png'
|
||||||
|
|
||||||
|
pixel_path = Rails.public_path.join('assets/images/tracking-pixel.png')
|
||||||
|
send_file pixel_path, type: 'image/png', disposition: 'inline'
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def limit_results
|
def limit_results
|
||||||
@ -39,7 +54,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
|||||||
|
|
||||||
def set_article
|
def set_article
|
||||||
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
|
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
|
||||||
@article.increment_view_count if @article.published?
|
|
||||||
@parsed_content = render_article_content(@article.content)
|
@parsed_content = render_article_content(@article.content)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -73,6 +73,30 @@ By default, it renders:
|
|||||||
<% end %>
|
<% end %>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<% if @article.present? %>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
let viewTracked = false;
|
||||||
|
const trackView = function() {
|
||||||
|
if (!viewTracked) {
|
||||||
|
viewTracked = true;
|
||||||
|
const img = new Image();
|
||||||
|
img.src = '<%= request.base_url %>/hc/<%= @portal.slug %>/articles/<%= @article.slug %>.png';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTrackingListeners = function() {
|
||||||
|
const events = ['mouseenter', 'touchstart', 'focus'];
|
||||||
|
events.forEach(event => {
|
||||||
|
document.body.addEventListener(event, function() {
|
||||||
|
setTimeout(trackView, 5000);
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
addTrackingListeners();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<% end %>
|
||||||
</body>
|
</body>
|
||||||
<style>
|
<style>
|
||||||
html.dark {
|
html.dark {
|
||||||
|
|||||||
@ -449,6 +449,7 @@ Rails.application.routes.draw do
|
|||||||
get 'hc/:slug/:locale/categories', to: 'public/api/v1/portals/categories#index'
|
get 'hc/:slug/:locale/categories', to: 'public/api/v1/portals/categories#index'
|
||||||
get 'hc/:slug/:locale/categories/:category_slug', to: 'public/api/v1/portals/categories#show'
|
get 'hc/:slug/:locale/categories/:category_slug', to: 'public/api/v1/portals/categories#show'
|
||||||
get 'hc/:slug/:locale/categories/:category_slug/articles', to: 'public/api/v1/portals/articles#index'
|
get 'hc/:slug/:locale/categories/:category_slug/articles', to: 'public/api/v1/portals/articles#index'
|
||||||
|
get 'hc/:slug/articles/:article_slug.png', to: 'public/api/v1/portals/articles#tracking_pixel'
|
||||||
get 'hc/:slug/articles/:article_slug', to: 'public/api/v1/portals/articles#show'
|
get 'hc/:slug/articles/:article_slug', to: 'public/api/v1/portals/articles#show'
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
|
|||||||
BIN
public/assets/images/tracking-pixel.png
Normal file
BIN
public/assets/images/tracking-pixel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 123 B |
@ -82,7 +82,7 @@ RSpec.describe 'Public Articles API', type: :request do
|
|||||||
get "/hc/#{portal.slug}/articles/#{article.slug}"
|
get "/hc/#{portal.slug}/articles/#{article.slug}"
|
||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
expect(response.body).to include(ChatwootMarkdownRenderer.new(article.content).render_article)
|
expect(response.body).to include(ChatwootMarkdownRenderer.new(article.content).render_article)
|
||||||
expect(article.reload.views).to eq 1
|
expect(article.reload.views).to eq 0 # View count should not increment on show
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not increment the view count if the article is not published' do
|
it 'does not increment the view count if the article is not published' do
|
||||||
@ -98,4 +98,42 @@ RSpec.describe 'Public Articles API', type: :request do
|
|||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'GET /public/api/v1/portals/:slug/articles/:slug.png (tracking pixel)' do
|
||||||
|
it 'serves a PNG image and increments view count for published article' do
|
||||||
|
get "/hc/#{portal.slug}/articles/#{article.slug}.png"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(response.headers['Content-Type']).to eq('image/png')
|
||||||
|
expect(response.headers['Cache-Control']).to include('max-age=86400')
|
||||||
|
expect(response.headers['Cache-Control']).to include('private')
|
||||||
|
expect(article.reload.views).to eq 1
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'serves a PNG image but does not increment view count for draft article' do
|
||||||
|
draft_article = create(:article, category: category, status: :draft, portal: portal, account_id: account.id, author_id: agent.id, views: 0)
|
||||||
|
|
||||||
|
get "/hc/#{portal.slug}/articles/#{draft_article.slug}.png"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(response.headers['Content-Type']).to eq('image/png')
|
||||||
|
expect(response.headers['Cache-Control']).to include('max-age=86400')
|
||||||
|
expect(response.headers['Cache-Control']).to include('private')
|
||||||
|
expect(draft_article.reload.views).to eq 0
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 404 if article does not exist' do
|
||||||
|
get "/hc/#{portal.slug}/articles/non-existent-article.png"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets proper cache headers for performance' do
|
||||||
|
get "/hc/#{portal.slug}/articles/#{article.slug}.png"
|
||||||
|
|
||||||
|
expect(response.headers['Cache-Control']).to include('max-age=86400')
|
||||||
|
expect(response.headers['Cache-Control']).to include('private')
|
||||||
|
expect(response.headers['Content-Type']).to eq('image/png')
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user