analytic 0.3.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dc45200aa3d02b6b5b0762abc37f93924ea4d025c25ddd671933512b35a2a795
4
- data.tar.gz: 925779556b411685139ff5d8c167bee45b4aed9ec793c523f90a26fcc95685df
3
+ metadata.gz: e283c032e4bc20c3b16244bee1b0a4b65e236626bdd9d75c05ef44116c6aa679
4
+ data.tar.gz: 8f9e592c750579ed57665364b2b9f0fd5c7cf6455e68e134e1b7817eda7c2713
5
5
  SHA512:
6
- metadata.gz: 5e1b269cd08c02ae021f491b3b5dd6076e7be3fe049b1bf50ea4b4ef09ac50ce32dd50c73f16cdd549ed416a1f59b602b8ed4b9994ed6690623559ab14aca8a9
7
- data.tar.gz: 34eea881684f27d927c48c7b3c76240a946cca6551747342017c5f5e9f53c558f391c5c66592ceb8f701af86c0639742fb6d592270a55680a87b6b6684084e22
6
+ metadata.gz: b88a20a9fcdebaa18d0702a0dc22ceaf02cf88d383d0f4d3de59558c6073b0d1b85993cc49ef1a19d39d98fed32c321aa82241df42a32440e7bdb8138eab4af6
7
+ data.tar.gz: 9c61764e8b904c336551099e1155c4bcfdcda88f32cd622bb7f38d57adc157bfd8c9a1583cbb9e09e7866a1103c127758239efd0431e00dd4b5aca25160be5f8
data/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  # Analytic
6
6
 
7
+ ![Demo](https://raw.githubusercontent.com/ksylvest/analytic/refs/heads/main/demo.png)
8
+
7
9
  Analytic provides visitor / session / view tracking without the need for any third-party service.
8
10
 
9
11
  ## Installation
@@ -21,15 +23,6 @@ Rails.application.routes.draw do
21
23
  end
22
24
  ```
23
25
 
24
- ```ruby
25
- # config/initializer/analytic.rb
26
- Analytic.configure do |config|
27
- config.timezone = Time.find_zone('Canada/Pacific') # default is `Time.zone`
28
- config.ip_v4_mask = 24 # '255.255.255.255' becomes '255.255.255.0'
29
- config.ip_v6_mask = 48 # 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff' becomes 'ffff:ffff:ffff:0000:0000:0000:0000:0000'
30
- end
31
- ```
32
-
33
26
  ## Usage
34
27
 
35
28
  Default inline tracking is configured with:
@@ -38,7 +31,7 @@ Default inline tracking is configured with:
38
31
  class ApplicationController
39
32
  include Analytic::Trackable
40
33
 
41
- before_action { analytic_track! }
34
+ before_action :analytic_track!, if: -> { request.format.html? }
42
35
  end
43
36
  ```
44
37
 
@@ -48,11 +41,105 @@ Alternative job tracking is configured with:
48
41
  class ApplicationController
49
42
  include Analytic::Trackable
50
43
 
51
- before_action { analytic_enqueue_track_job! }
44
+ before_action :analytic_enqueue_track_job!, if: -> { request.format.html? }
45
+ end
52
46
  ```
53
47
 
54
48
  _note: a queue such as sidekiq, rescue, etc must be running to see tracking_
55
49
 
50
+ ## Configuration
51
+
52
+ ### Authentication the Dashboard
53
+
54
+ By default **Analytic** is not authenticated. To authenticate using `Rack::Auth` generate a random username and password:
55
+
56
+ ```ruby
57
+ $ bin/rails secret # generate a secret
58
+ $ bin/rails credentials:edit
59
+ ```
60
+
61
+ ```yaml
62
+ analytic:
63
+ username: abc...
64
+ password: def...
65
+ ```
66
+
67
+ ```ruby
68
+ # config/initializers/analytic.rb
69
+
70
+ def same?(src, dst)
71
+ # https://api.rubyonrails.org/classes/ActiveSupport/SecurityUtils.html
72
+ ActiveSupport::SecurityUtils.secure_compare(src, dst)
73
+ end
74
+
75
+ Analytic.configure do |config|
76
+ unless Rails.env.local?
77
+ config.use Rack::Auth::Basic do |username, password|
78
+ credentials = Rails.application.credentials.analytic
79
+ same?(username, credentials.username) && same?(password, credentials.password)
80
+ end
81
+ end
82
+ end
83
+ ```
84
+
85
+ ### Capturing Extra Parameters
86
+
87
+ By default **Analytic** tracks `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`, `ref` and `source` parameters for each request. This list can be customized using:
88
+
89
+ ```ruby
90
+ # config/initializer/analytic.rb
91
+ Analytic.configure do |config|
92
+ config.params << :gclid # e.g. Google
93
+ config.params << :msclkid # e.g. Bing
94
+ end
95
+ ```
96
+
97
+ ### Changing the Time Zone
98
+
99
+ By default **Analytic** uses `Time.zone`. The time zone can be changed using:
100
+
101
+ ```ruby
102
+ # config/application.rb
103
+ class Application < Rails::Application
104
+ config.time_zone = 'Canada/Pacific'
105
+ end
106
+ ```
107
+
108
+ The time zone may also be specified for **Analytic** using any `ActiveSupport::TimeZone`:
109
+
110
+ ```ruby
111
+ # config/initializer/analytic.rb
112
+ Analytic.configure do |config|
113
+ config.time_zone = Time.find_zone('Canada/Pacific')
114
+ end
115
+ ```
116
+
117
+ ### Changing the Database
118
+
119
+ By default **Analytic** uses your apps main database. The database can be changed using:
120
+
121
+ ```ruby
122
+ # config/initializer/analytic.rb
123
+ Analytic.configure do |config|
124
+ config.connects_to = { database: { writing: :primary, reading: :replica } }
125
+ end
126
+ ```
127
+
128
+ ### Overriding IP Masking Rules
129
+
130
+ By default IP addresses are masked as follows:
131
+
132
+ - IPv4: limit to leading 24-bits (e.g. '255.255.255.255' becomes '255.255.255.0')
133
+ - IPv6: limit to leading 48-bits (e.g. 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff' becomes 'ffff:ffff:ffff:0000:0000:0000:0000:0000')
134
+
135
+ To override both an `ip_v4_mask` and `ip_v6_mask` are assignable:
136
+
137
+ ```ruby
138
+ Analytic.configure do |config|
139
+ config.ip_v4_mask = 24 # nil skips masking
140
+ config.ip_v6_mask = 48 # nil skips masking
141
+ ```
142
+
56
143
  ## License
57
144
 
58
145
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile CHANGED
@@ -10,4 +10,9 @@ RSpec::Core::RakeTask.new(:spec)
10
10
 
11
11
  RuboCop::RakeTask.new
12
12
 
13
+ APP_RAKEFILE = File.expand_path('spec/dummy/Rakefile', __dir__)
14
+
15
+ load 'rails/tasks/engine.rake'
16
+ load 'rails/tasks/statistics.rake'
17
+
13
18
  task default: %i[spec rubocop]
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512"><defs><linearGradient id="a" x1="0%" x2="100%" y1="100%" y2="0%"><stop offset="0%" stop-color="#06B6D4"/><stop offset="49.9%" stop-color="#6366F1"/><stop offset="100%" stop-color="#D946EF"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><rect width="512" height="512" fill="url(#a)" rx="256"/><circle cx="256" cy="256" r="184" stroke="#FFF" stroke-width="48"/><rect width="48" height="128" x="232" y="210" fill="#FFF" rx="16"/><rect width="48" height="96" x="168" y="242" fill="#FFF" rx="16"/><rect width="48" height="164" x="296" y="174" fill="#FFF" rx="16"/></g></svg>
@@ -3,7 +3,8 @@
3
3
  module Analytic
4
4
  class DashboardController < ApplicationController
5
5
  include Analytic::Trackable
6
- before_action { analytic_track! }
6
+
7
+ before_action :analytic_track!, if: -> { request.format.html? }
7
8
 
8
9
  # GET /
9
10
  def show
@@ -4,7 +4,7 @@ module Analytic
4
4
  module Trackable
5
5
  # @param params [Hash]
6
6
  def analytic_track!
7
- Analytic::View.create!(
7
+ Analytic::Event.create!(
8
8
  timestamp: Time.current,
9
9
  session_id: analytic_session_id,
10
10
  visitor_id: analytic_visitor_id,
@@ -73,7 +73,7 @@ module Analytic
73
73
 
74
74
  # @return [Hash]
75
75
  def analytic_params
76
- params.permit(:utm_source, :utm_medium, :utm_campaign, :utm_term, :utm_content)
76
+ params.slice(*Analytic.config.params)
77
77
  end
78
78
  end
79
79
  end
@@ -2,5 +2,47 @@
2
2
 
3
3
  module Analytic
4
4
  module ApplicationHelper
5
+ def react(component:)
6
+ tag.div(data: { react: component })
7
+ end
8
+
9
+ # @param icon [String] e.g. fa fa-user
10
+ def fa_icon_tag(icon)
11
+ tag.i(class: icon)
12
+ end
13
+
14
+ # @param delta [Float]
15
+ # @return [String]
16
+ def delta_icon_tag(delta)
17
+ if delta.zero?
18
+ fa_icon_tag('fa-solid fa-xmark')
19
+ elsif delta.positive?
20
+ fa_icon_tag('fa-solid fa-arrow-up')
21
+ elsif delta.negative?
22
+ fa_icon_tag('fa-solid fa-arrow-down')
23
+ end
24
+ end
25
+
26
+ # @param delta [Float]
27
+ # @return [String]
28
+ def delta_percentage_tag(delta)
29
+ tag.span(number_to_percentage(delta.abs * 100.0, precision: 2), class: 'delta__value')
30
+ end
31
+
32
+ # @param delta [Float, nil]
33
+ def delta_tag(delta)
34
+ return if delta.nil?
35
+
36
+ modifier =
37
+ if delta.zero? then 'delta--neutral'
38
+ elsif delta.positive? then 'delta--positive'
39
+ elsif delta.negative? then 'delta--negative'
40
+ end
41
+
42
+ tag.div(class: "delta #{modifier}") do
43
+ concat(delta_icon_tag(delta))
44
+ concat(delta_percentage_tag(delta))
45
+ end
46
+ end
5
47
  end
6
48
  end
@@ -11,7 +11,7 @@ module Analytic
11
11
  # @param path [String]
12
12
  # @param params [Hash]
13
13
  def perform(session_id:, visitor_id:, host:, path:, ip:, timestamp: Time.current, params: {})
14
- Analytic::View.create!(
14
+ Analytic::Event.create!(
15
15
  timestamp:,
16
16
  session_id:,
17
17
  visitor_id:,
@@ -3,5 +3,7 @@
3
3
  module Analytic
4
4
  class ApplicationRecord < ActiveRecord::Base
5
5
  self.abstract_class = true
6
+
7
+ connects_to(**Analytic.config.connects_to) if Analytic.config.connects_to?
6
8
  end
7
9
  end
@@ -4,19 +4,40 @@ module Analytic
4
4
  class Dashboard
5
5
  PAGES_LIMIT = 8
6
6
 
7
+ # @return [String]
8
+ attr_reader :period
9
+
7
10
  # @param period [String] today, yesterday, week, month, year
8
11
  def initialize(period:)
9
12
  @period = period
10
13
  end
11
14
 
12
- # @return [Integer]
13
- delegate :count, to: :views
15
+ # @return [Stat]
16
+ def views
17
+ Stat.new(
18
+ current: current.count,
19
+ prior: prior&.count,
20
+ name: 'Views'
21
+ )
22
+ end
14
23
 
15
- # @return [Integer]
16
- delegate :distinct_visitors_count, to: :views
24
+ # @return [Stat]
25
+ def visitors
26
+ Stat.new(
27
+ current: current.distinct_visitors_count,
28
+ prior: prior&.distinct_visitors_count,
29
+ name: 'Visitors'
30
+ )
31
+ end
17
32
 
18
- # @return [Integer]
19
- delegate :distinct_sessions_count, to: :views
33
+ # @return [Stat]
34
+ def sessions
35
+ Stat.new(
36
+ current: current.distinct_sessions_count,
37
+ prior: prior&.distinct_sessions_count,
38
+ name: 'Sessions'
39
+ )
40
+ end
20
41
 
21
42
  # @return [String]
22
43
  def name
@@ -27,39 +48,50 @@ module Analytic
27
48
  end
28
49
  end
29
50
 
30
- # @return [Array<Array(String, String, Integer)>]
31
- def pages
32
- views
33
- .group(:host)
34
- .group(:path)
35
- .order(count: :desc)
36
- .limit(PAGES_LIMIT)
37
- .pluck(:host, :path, Arel.sql('COUNT(*) AS count'))
51
+ # @return [Period]
52
+ def current
53
+ @current ||= Period.new(range:)
38
54
  end
39
55
 
40
- protected
56
+ # @return [Period]
57
+ def prior
58
+ return unless range?
59
+ return @prior if defined?(@prior)
41
60
 
42
- # @return [Analytic::View::ActiveRecord_Relation]
43
- def views
44
- @views ||= Analytic::View.within(range)
61
+ @prior ||= Period.new(range: ((range.min - duration)..(range.max - duration)))
62
+ end
63
+
64
+ # @return [Boolean]
65
+ def range?
66
+ @period.present?
45
67
  end
46
68
 
47
69
  # @return [Range<Time>]
48
70
  def range
49
- return unless @period
50
-
51
- case @period
52
- when 'today' then timezone.today.all_day
53
- when 'yesterday' then timezone.yesterday.all_day
54
- when 'week' then timezone.now.all_week
55
- when 'month' then timezone.now.all_month
56
- when 'year' then timezone.now.all_year
57
- end
71
+ (now - duration)..now if range?
72
+ end
73
+
74
+ # @return [Interval]
75
+ def duration
76
+ @duration ||=
77
+ case @period
78
+ when '24h' then 24.hours
79
+ when '7d' then 7.days
80
+ when '4w' then 4.weeks
81
+ when '12m' then 12.months
82
+ end
83
+ end
84
+
85
+ protected
86
+
87
+ # @return [Time]
88
+ def now
89
+ @now ||= time_zone.now
58
90
  end
59
91
 
60
92
  # @return [ActiveSupport::TimeZone]
61
- def timezone
62
- Analytic.config.timezone || Time.zone
93
+ def time_zone
94
+ @time_zone ||= Analytic.config.time_zone || Time.zone
63
95
  end
64
96
  end
65
97
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Analytic
4
- class View < ApplicationRecord
4
+ class Event < ApplicationRecord
5
5
  validates :timestamp, presence: true
6
6
  validates :visitor_id, presence: true
7
7
  validates :session_id, presence: true
@@ -13,12 +13,12 @@ module Analytic
13
13
 
14
14
  # @return [Integer]
15
15
  def self.distinct_visitors_count
16
- count('DISTINCT "analytic_views"."visitor_id"')
16
+ count('DISTINCT "analytic_events"."visitor_id"')
17
17
  end
18
18
 
19
19
  # @return [Integer]
20
20
  def self.distinct_sessions_count
21
- count('DISTINCT "analytic_views"."session_id"')
21
+ count('DISTINCT "analytic_events"."session_id"')
22
22
  end
23
23
  end
24
24
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Analytic
4
+ class Period
5
+ PAGES_LIMIT = 8
6
+
7
+ # @return [Range <Time>]
8
+ attr_accessor :range
9
+
10
+ # @param dates [Range<Time>]
11
+ def initialize(range:)
12
+ @range = range
13
+ end
14
+
15
+ # @return [Integer]
16
+ delegate :count, to: :views
17
+
18
+ # @return [Integer]
19
+ delegate :distinct_visitors_count, to: :views
20
+
21
+ # @return [Integer]
22
+ delegate :distinct_sessions_count, to: :views
23
+
24
+ # @return [Array<Array(String, String, Integer)>]
25
+ def pages
26
+ views
27
+ .group(:host)
28
+ .group(:path)
29
+ .order(count: :desc)
30
+ .limit(PAGES_LIMIT)
31
+ .pluck(:host, :path, Arel.sql('COUNT(*) AS count'))
32
+ end
33
+
34
+ protected
35
+
36
+ # @return [Analytic::Event::ActiveRecord_Relation]
37
+ def views
38
+ @views ||= Analytic::Event.within(@range)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Analytic
4
+ class Stat
5
+ # @param current [Integer]
6
+ # @param prior [Integer]
7
+ # @param name [String]
8
+ def initialize(current:, prior:, name:)
9
+ @current = current
10
+ @prior = prior
11
+ @name = name
12
+ end
13
+
14
+ # @return [Integer]
15
+ def count
16
+ @current
17
+ end
18
+
19
+ # e.g. prior = 4 / current = 5 / delta = + 0.25
20
+ # e.g. prior = 4 / current = 3 / delta = - 0.25
21
+ #
22
+ # @return [Float, nil]
23
+ def delta
24
+ return if @prior.nil?
25
+ return if @prior.zero?
26
+
27
+ (@current - @prior).fdiv(@prior)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,148 @@
1
+ import { useMemo, type FC } from "react";
2
+
3
+ import { Group } from "@visx/group";
4
+ import { Bar } from "@visx/shape";
5
+ import { scaleBand, scaleLinear } from "@visx/scale";
6
+ import { AxisBottom, AxisLeft } from "@visx/axis";
7
+ import { GridColumns, GridRows } from "@visx/grid";
8
+ import { ParentSize } from "@visx/responsive";
9
+
10
+ const BAR_BG = "#6366f1"; // indigo-500
11
+ const CHART_BG = "#f8fafc"; // slate-50
12
+ const GRID_COLOR = "#e2e8f0"; // slate-200
13
+ const AXIS_TICK_COLOR = GRID_COLOR;
14
+ const AXIS_LINE_COLOR = GRID_COLOR;
15
+
16
+ const CHART_ML = 60; // px
17
+ const CHART_MB = 60; // px
18
+ const CHART_MT = 40; // px
19
+ const CHART_MR = 40; // px
20
+
21
+ const TICK_LABEL_PROPS = {
22
+ fill: "#718096", // slate-600
23
+ fontSize: 10,
24
+ fontFamily: `"ui-sans-serif", "system-ui", "sans-serif"`,
25
+ } as const;
26
+
27
+ type Entry = {
28
+ label: string;
29
+ value: number;
30
+ };
31
+
32
+ const ENTRIES: Entry[] = [
33
+ { label: "Jan, 2024", value: 404 },
34
+ { label: "Feb, 2024", value: 303 },
35
+ { label: "Mar, 2024", value: 250 },
36
+ { label: "Apr, 2024", value: 304 },
37
+ { label: "May, 2024", value: 404 },
38
+ { label: "Jun, 2024", value: 604 },
39
+ ];
40
+
41
+ const Bars: FC<{
42
+ width: number;
43
+ height: number;
44
+ entries: Entry[];
45
+ }> = ({ width, height, entries }) => {
46
+ const xScale = useMemo(
47
+ () =>
48
+ scaleBand<string>({
49
+ range: [0, width],
50
+ round: true,
51
+ domain: entries.map(({ label }) => label),
52
+ padding: 0.4,
53
+ }),
54
+ [width]
55
+ );
56
+ const yScale = useMemo(
57
+ () =>
58
+ scaleLinear<number>({
59
+ range: [height, 0],
60
+ round: true,
61
+ domain: [0, Math.max(...entries.map(({ value }) => value))],
62
+ }),
63
+ [height]
64
+ );
65
+
66
+ return (
67
+ <>
68
+ <GridRows
69
+ scale={yScale}
70
+ width={width}
71
+ height={height}
72
+ stroke={GRID_COLOR}
73
+ />
74
+
75
+ <GridColumns
76
+ scale={xScale}
77
+ width={width}
78
+ height={height}
79
+ stroke={GRID_COLOR}
80
+ />
81
+
82
+ {entries.map((entry, index) => {
83
+ const barWidth = xScale.bandwidth();
84
+ const barHeight = height - yScale(entry.value);
85
+ const barX = xScale(entry.label);
86
+ const barY = height - barHeight;
87
+
88
+ return (
89
+ <Bar
90
+ key={index}
91
+ x={barX}
92
+ y={barY}
93
+ rx={8}
94
+ ry={8}
95
+ width={barWidth}
96
+ height={barHeight}
97
+ fill={BAR_BG}
98
+ />
99
+ );
100
+ })}
101
+
102
+ <AxisBottom
103
+ top={height}
104
+ scale={xScale}
105
+ stroke={AXIS_LINE_COLOR}
106
+ tickStroke={AXIS_TICK_COLOR}
107
+ tickLabelProps={TICK_LABEL_PROPS}
108
+ />
109
+
110
+ <AxisLeft
111
+ left={0}
112
+ scale={yScale}
113
+ stroke={AXIS_LINE_COLOR}
114
+ tickStroke={AXIS_TICK_COLOR}
115
+ tickLabelProps={TICK_LABEL_PROPS}
116
+ />
117
+ </>
118
+ );
119
+ };
120
+
121
+ export const Chart: FC<{
122
+ height?: number;
123
+ entries?: Entry[];
124
+ }> = ({ height = 300, entries = ENTRIES }) => (
125
+ <ParentSize>
126
+ {({ width }) => (
127
+ <svg width={width} height={height}>
128
+ <rect
129
+ x={0}
130
+ y={0}
131
+ rx={4}
132
+ ry={4}
133
+ width={width}
134
+ height={height}
135
+ fill={CHART_BG}
136
+ />
137
+
138
+ <Group top={CHART_MT} left={CHART_ML}>
139
+ <Bars
140
+ width={width - CHART_MR - CHART_ML}
141
+ height={height - CHART_MT - CHART_MB}
142
+ entries={entries}
143
+ />
144
+ </Group>
145
+ </svg>
146
+ )}
147
+ </ParentSize>
148
+ );
@@ -0,0 +1,21 @@
1
+ import { type FC } from "react";
2
+
3
+ import { Chart } from "./chart";
4
+ import { createRoot } from "react-dom/client";
5
+
6
+ const COMPONENTS: Record<string, FC> = {
7
+ Chart,
8
+ };
9
+
10
+ document.addEventListener("DOMContentLoaded", () => {
11
+ const elements = document.querySelectorAll("[data-react]");
12
+ for (const element of elements) {
13
+ const root = createRoot(element);
14
+
15
+ const name = element.getAttribute("data-react");
16
+
17
+ const Component = COMPONENTS[name!];
18
+
19
+ root.render(<Component />);
20
+ }
21
+ });
@@ -1 +1,2 @@
1
1
  import "./initializers";
2
+ import "./components";
@@ -10,14 +10,20 @@ import { faGlobe } from "@fortawesome/free-solid-svg-icons/faGlobe";
10
10
  import { faHouse } from "@fortawesome/free-solid-svg-icons/faHouse";
11
11
  import { faSliders } from "@fortawesome/free-solid-svg-icons/faSliders";
12
12
  import { faUsers } from "@fortawesome/free-solid-svg-icons/faUsers";
13
+ import { faArrowUp } from "@fortawesome/free-solid-svg-icons/faArrowUp";
14
+ import { faArrowDown } from "@fortawesome/free-solid-svg-icons/faArrowDown";
15
+ import { faXmark } from "@fortawesome/free-solid-svg-icons/faXmark";
13
16
 
14
17
  library.add(
18
+ faArrowDown,
19
+ faArrowUp,
15
20
  faClock,
16
21
  faEye,
22
+ faFileCode,
17
23
  faGauge,
18
24
  faGlobe,
19
25
  faHouse,
20
26
  faSliders,
21
27
  faUsers,
22
- faFileCode
28
+ faXmark
23
29
  );