feat: use tracking pixel for article view count (#11559)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
|
||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||
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]
|
||||
layout 'portal'
|
||||
|
||||
@@ -15,6 +15,21 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
|
||||
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
|
||||
|
||||
def limit_results
|
||||
@@ -39,7 +54,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
|
||||
def set_article
|
||||
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
|
||||
@article.increment_view_count if @article.published?
|
||||
@parsed_content = render_article_content(@article.content)
|
||||
end
|
||||
|
||||
|
||||
@@ -73,6 +73,30 @@ By default, it renders:
|
||||
<% end %>
|
||||
</main>
|
||||
</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>
|
||||
<style>
|
||||
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/: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/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'
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
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}"
|
||||
expect(response).to have_http_status(:success)
|
||||
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
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user