analytics.rs 97 lignes · 3378 octets
//! Template context for the audience-measurement opt-out component.
//!
//! The framework side of audience measurement lives in
//! [`rustwarden_core::config::analytics`]: configuration, snippet rendering
//! and the CSP allowance. This module only carries what the reference UI
//! needs to render `components/analytics_opt_out.html`.

use rustwarden_core::config::analytics::AnalyticsContext;
use rustwarden_core::i18n::I18nService;

/// Everything `components/analytics_opt_out.html` needs.
///
/// Built from the [`AnalyticsContext`] extractor plus the translated labels,
/// following the convention of this crate: templates receive ready-made
/// `String`s rather than calling into i18n themselves.
///
/// # Example
///
/// ```rust,ignore
/// let analytics_opt_out =
///     AnalyticsOptOutContext::build(&analytics, &i18n_service, locale.as_str()).await;
/// ```
///
/// Then, in the template: `{% include "components/analytics_opt_out.html" %}`.
#[derive(Debug, Clone, Default)]
pub struct AnalyticsOptOutContext {
    /// `localStorage` key toggled by the component. Empty when the configured
    /// vendor exposes no opt-out, in which case nothing is rendered.
    pub storage_key: String,
    /// Sentence explaining the choice and when it takes effect.
    pub hint: String,
    /// Label of the button while measurement is active.
    pub opt_out_label: String,
    /// Label of the button once the visitor has opted out.
    pub opt_in_label: String,
    /// Badge shown next to the button while opted out.
    pub opted_out_badge: String,
}

impl AnalyticsOptOutContext {
    /// Build the context, translating the labels into `locale`.
    ///
    /// Returns an empty context — rendering nothing — when analytics is not
    /// configured or the vendor exposes no opt-out key, so a page can include
    /// the component unconditionally.
    pub async fn build(
        analytics: &AnalyticsContext,
        i18n_service: &I18nService,
        locale: &str,
    ) -> Self {
        if !analytics.has_opt_out() {
            return Self::default();
        }
        Self {
            storage_key: analytics.opt_out_storage_key.clone(),
            hint: i18n_service
                .translate("analytics.opt_out.hint", locale)
                .await,
            opt_out_label: i18n_service
                .translate("analytics.opt_out.opt_out_label", locale)
                .await,
            opt_in_label: i18n_service
                .translate("analytics.opt_out.opt_in_label", locale)
                .await,
            opted_out_badge: i18n_service
                .translate("analytics.opt_out.opted_out_badge", locale)
                .await,
        }
    }

    /// Whether the component has an opt-out key to work with.
    ///
    /// Called from the template guard; an empty key means the whole component
    /// is skipped.
    pub fn has_key(&self) -> bool {
        !self.storage_key.is_empty()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_context_renders_nothing() {
        assert!(!AnalyticsOptOutContext::default().has_key());
    }

    #[test]
    fn a_configured_key_enables_the_component() {
        let ctx = AnalyticsOptOutContext {
            storage_key: "disable-rybbit".to_string(),
            ..AnalyticsOptOutContext::default()
        };
        assert!(ctx.has_key());
    }
}