Initial commit

This commit is contained in:
Uku Taht 2019-09-02 12:29:19 +01:00
commit 779d64e19a
233 changed files with 36918 additions and 0 deletions

5
.formatter.exs Normal file
View File

@ -0,0 +1,5 @@
[
import_deps: [:ecto, :phoenix],
inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"],
subdirectories: ["priv/*/migrations"]
]

42
.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where 3rd-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Ignore package tarball (built via "mix hex.build").
plausible-*.tar
# If NPM crashes, it generates a log, let's ignore it too.
npm-debug.log
# The directory NPM downloads your dependencies sources to.
/assets/node_modules/
# Since we are building assets from assets/,
# we ignore priv/static. You may want to comment
# this depending on your deployment strategy.
/priv/static/
# Files matching config/*.secret.exs pattern contain sensitive
# data and you should not commit them into version control.
#
# Alternatively, you may comment the line below and commit the
# secrets files as long as you replace their contents by environment
# variables.
/config/*.secret.exs

2
.tool-versions Normal file
View File

@ -0,0 +1,2 @@
erlang 21.1
elixir 1.7.4-otp-21

1
Procfile Normal file
View File

@ -0,0 +1 @@
web: MIX_ENV=prod mix phx.server

34
README.md Normal file
View File

@ -0,0 +1,34 @@
# Plausible Insights
Plausible is a simple, lightweight web analytics service that provides the most
important traffic stats without intruding on your visitors' privacy. It collects
unique visitors, referrers, top pages, countries, and device information using a
lightweight script.
![](https://i.ibb.co/wzWYMYb/screenshot.png)
[View live demo of our own analytics](https://plausible.io/plausible.io)
### Why Plausible?
* **Clutter Free**: Stop digging through complex reports to find what youre looking for. Plausible presents the most important information to you on a single page.
* **Anonymous**: Measure traffic, not individuals. No personal data or IP addresses are ever stored in our database. Read more about our data policy
* **Lightweight**: Plausible works by loading a script on your website, like Google Analytics. Our script is 14x smaller, making your website quicker to load.
Interested? [Read more on our website](https://plausible.io)
### Can Plausible be self-hosted?
At the moment we don't provide support for easily self-hosting the code. Currently, the purpose of
keeping the code open-source is to be transparent with the community about how we
collect and process data.
### Technology
Plausible is a standard Elixir/Phoenix application backed by a PostgreSQL database. On the frontend we use
[TailwindCSS](https://tailwindcss.com/) for styling and some vanilla Javascript for interactive bits.
### Feedback & Roadmap
We have a [feedback board](https://feedback.plausible.io/) and a [public roadmap](https://feedback.plausible.io/roadmap).
Please let us know if you have any requests and vote on open issues so we can better prioritize.

5
assets/.babelrc Normal file
View File

@ -0,0 +1,5 @@
{
"presets": [
"@babel/preset-env"
]
}

201
assets/css/app.css Normal file
View File

@ -0,0 +1,201 @@
@tailwind preflight;
@tailwind components;
@import "modal.css";
@import "coffee_cup.css";
@import "tooltip.css";
.button {
@apply inline-block bg-indigo text-white text-center font-bold tracking-wide py-3 px-5 rounded no-underline;
}
.button:hover {
@apply shadow;
}
.button:focus {
@apply outline-none;
}
.button-outline {
@apply bg-transparent border border-indigo text-indigo;
}
.button-sm {
@apply text-sm py-2 px-4;
}
.button-md {
@apply py-2 px-4;
}
html {
@apply font-sans text-grey-darkest;
}
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a, button {
@apply no-underline;
}
blockquote {
@apply my-4 py-2 px-4 border-l-4 border-grey;
}
@screen xl {
.container {
max-width: 70rem;
}
}
@tailwind utilities;
a {
color: inherit;
}
.light-text {
color: #F0F4F8;
}
.transition {
transition: all .1s ease-in;
}
.truncated-label {
@apply no-underline text-grey-darkest;
white-space: nowrap;
width: calc(100% - 40px);
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
}
.bar {
height: 4px;
width: 100%;
background-color: #f5f6f7;
border-radius: 4px;
margin-top: 8px;
margin-bottom: 24px;
}
.bar__fill {
height: 100%;
border-radius: 4px;
}
.pulsating-circle {
position: absolute;
left: 50%;
top: 50%;
transform: translateX(-50%) translateY(-50%);
width: 20px;
height: 20px;
}
.pulsating-circle:before {
content: '';
position: relative;
display: block;
width: 300%;
height: 300%;
box-sizing: border-box;
margin-left: -100%;
margin-top: -100%;
border-radius: 45px;
background-color: #01a4e9;
animation: pulse-ring 1.25s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
@apply bg-green;
}
.pulsating-circle:after {
content: '';
position: absolute;
left: 0;
top: 0;
display: block;
width: 100%;
height: 100%;
background-color: white;
border-radius: 15px;
animation: pulse-dot 1.25s cubic-bezier(0.455, 0.03, 0.515, 0.955) -.4s infinite;
@apply bg-green;
}
@keyframes pulse-ring {
0% {
transform: scale(.33);
}
80%, 100% {
opacity: 0;
}
}
@keyframes pulse-dot {
0% {
transform: scale(.8);
}
50% {
transform: scale(1);
}
100% {
transform: scale(.8);
}
}
.just-text h1, .just-text h2, .just-text h3 {
margin-top: 1em;
margin-bottom: .5em;
}
.just-text p {
margin-top: 0;
margin-bottom: 1rem;
}
.dropdown-content::before {
top: -16px;
right: 64px;
left: auto;
}
.dropdown-content::before {
border: 8px solid transparent;
border-bottom-color: rgba(27,31,35,0.15);
}
.dropdown-content::before, .dropdown-content::after {
position: absolute;
display: inline-block;
content: "";
}
.dropdown-content::after {
border: 7px solid transparent;
border-bottom-color: #fff;
}
.dropdown-content::after {
top: -14px;
right: 65px;
left: auto;
}
.group:hover > .group-hover-fill-red > svg {
fill: #ff6978!important;
}
.group:hover > .group-hover-fill-red {
color: #ff6978;
}
.group-hover-fill-red svg {
transition: all .1 ease-in;
}
.feather {
height: 1em;
width: 1em;
transform: translateY(0.15em);
}

22
assets/css/coffee_cup.css Normal file
View File

@ -0,0 +1,22 @@
.loading {
width: 50px;
height: 50px;
}
.loading div {
display: inline-block;
width: 50px;
height: 50px;
border: 3px solid #dae1e7;
border-radius: 50%;
border-top-color: #606f7b;
animation: spin 1s ease-in-out infinite;
-webkit-animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { -webkit-transform: rotate(360deg); }
}
@-webkit-keyframes spin {
to { -webkit-transform: rotate(360deg); }
}

68
assets/css/modal.css Normal file
View File

@ -0,0 +1,68 @@
.modal {
display: none;
}
.modal.is-open {
display: block;
}
.modal[aria-hidden="false"] .modal__overlay {
animation: mmfadeIn .2s cubic-bezier(0.0, 0.0, 0.2, 1);
}
.modal[aria-hidden="true"] .modal__overlay {
animation: mmfadeOut .2s cubic-bezier(0.0, 0.0, 0.2, 1);
}
.modal__overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 99;
overflow-x: hidden;
overflow-y: auto;
}
.modal__container {
background-color: #fff;
padding: 1rem 2rem;
max-width: 860px;
border-radius: 4px;
margin: 50px auto;
box-sizing: border-box;
min-height: 509px;
}
.modal__header {
display: flex;
justify-content: space-between;
align-items: center;
}
.modal__close {
background: transparent;
border: 0;
font-size: 20px;
font-weight: bold;
}
.modal__header .modal__close:before { content: "\2715"; }
.modal__content {
margin-top: 2rem;
margin-bottom: 2rem;
line-height: 1.5;
}
@keyframes mmfadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes mmfadeOut {
from { opacity: 1; }
to { opacity: 0; }
}

43
assets/css/tooltip.css Normal file
View File

@ -0,0 +1,43 @@
[tooltip]{
position:relative;
display:inline-block;
}
[tooltip]::before {
transition: .3s;
content: "";
position: absolute;
top:-6px;
left:50%;
transform: translateX(-50%);
border-width: 4px 6px 0 6px;
border-style: solid;
border-color: rgba(0,0,0,0.8) transparent transparent transparent;
z-index: 99;
opacity:0;
}
[tooltip]::after {
transition: .3s;
white-space: nowrap;
content: attr(tooltip);
position: absolute;
left:50%;
top:-6px;
transform: translateX(-50%) translateY(-100%);
background: rgba(0,0,0,0.8);
text-align: center;
color: #fff;
font-size: .875rem;
min-width: 80px;
max-width: 320px;
border-radius: 3px;
pointer-events: none;
padding: 4px 8px;
z-index:99;
opacity:0;
}
[tooltip]:hover::after,[tooltip]:hover::before {
opacity:1
}

34
assets/js/app.js Normal file
View File

@ -0,0 +1,34 @@
import css from "../css/app.css"
import "./polyfills/closest"
import "./stats"
import "./timeframe-selector"
import "phoenix_html"
const triggers = document.querySelectorAll('[data-dropdown-trigger]')
for (const trigger of triggers) {
trigger.addEventListener('click', function(e) {
e.stopPropagation()
e.currentTarget.nextElementSibling.classList.remove('hidden')
})
}
if (triggers.length > 0) {
document.addEventListener('click', function(e) {
const clickedInDropdown = e.target.closest('[data-dropdown]')
if (!clickedInDropdown) {
for (const dropdown of document.querySelectorAll('[data-dropdown]')) {
dropdown.classList.add('hidden')
}
}
})
}
const flash = document.getElementById('flash')
if (flash) {
setTimeout(function() {
flash.style.display = 'none'
}, 2500)
}

103
assets/js/p.js Normal file
View File

@ -0,0 +1,103 @@
(function(window, plausibleHost){
'use strict';
try {
function setCookie(name,value) {
var date = new Date();
date.setTime(date.getTime() + (3*365*24*60*60*1000)); // 3 YEARS
var expires = "; expires=" + date.toUTCString();
document.cookie = name + "=" + (value || "") + expires + "; path=/";
}
function getCookie(name) {
var nameEQ = name + "=";
var ca = document.cookie.split(';');
for(var i=0;i < ca.length;i++) {
var c = ca[i];
while (c.charAt(0)==' ') c = c.substring(1,c.length);
if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length,c.length);
}
return null;
}
function pseudoUUIDv4() {
var d = new Date().getTime();
if (typeof performance !== 'undefined' && typeof performance.now === 'function'){
d += performance.now(); //use high-precision timer if available
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = (d + Math.random() * 16) % 16 | 0;
d = Math.floor(d / 16);
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
function ignore(reason) {
console.warn('[Plausible] Ignoring pageview because ' + reason);
}
function getUrl() {
return window.location.protocol + '//' + window.location.hostname + window.location.pathname + window.location.search;
}
function page() {
if (/localhost$/.test(window.location.hostname)) return ignore('website is running locally');
if (window.location.protocol === 'file:') return ignore('website is running locally');
if (window.document.visibilityState === 'prerender') return ignore('document is prerendering');
var existingUid = getCookie('nm_uid');
var request = new XMLHttpRequest();
request.open('POST', plausibleHost + '/api/page', true);
request.setRequestHeader('Content-Type', 'text/plain');
var uid = existingUid || pseudoUUIDv4()
request.send(JSON.stringify({
url: getUrl(),
new_visitor: !existingUid,
uid: uid,
user_agent: window.navigator.userAgent,
referrer: window.document.referrer,
screenWidth: window.innerWidth
}));
request.onreadystatechange = function() {
if (request.readyState == XMLHttpRequest.DONE) {
if (!existingUid) {
setCookie('nm_uid', uid)
}
}
}
}
function trackPushState() {
var his = window.history
if (his.pushState) {
var originalFn = his['pushState']
his.pushState = function() {
originalFn.apply(this, arguments)
page();
}
}
}
const functions = {
page: page,
trackPushState: trackPushState
}
const queue = window.plausible.q || []
window.plausible = function() {
var args = [].slice.call(arguments);
var funcName = args.shift();
functions[funcName].apply(this, args);
};
for (var i = 0; i < queue.length; i++) {
window.plausible.apply(this, queue[i])
}
} catch (e) {
new Image().src = plausibleHost + '/api/error?message=' + encodeURIComponent(e.message);
}
})(window, BASE_URL);

86
assets/js/plausible.js Normal file
View File

@ -0,0 +1,86 @@
(function(window, plausibleHost){
'use strict';
try {
function setCookie(name,value) {
var date = new Date();
date.setTime(date.getTime() + (3*365*24*60*60*1000)); // 3 YEARS
var expires = "; expires=" + date.toUTCString();
document.cookie = name + "=" + (value || "") + expires + "; path=/";
}
function getCookie(name) {
var nameEQ = name + "=";
var ca = document.cookie.split(';');
for(var i=0;i < ca.length;i++) {
var c = ca[i];
while (c.charAt(0)==' ') c = c.substring(1,c.length);
if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length,c.length);
}
return null;
}
function pseudoUUIDv4() {
var d = new Date().getTime();
if (typeof performance !== 'undefined' && typeof performance.now === 'function'){
d += performance.now(); //use high-precision timer if available
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = (d + Math.random() * 16) % 16 | 0;
d = Math.floor(d / 16);
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
function ignore(reason) {
console.warn('[Plausible] Ignoring pageview because ' + reason);
}
function getUrl() {
return window.location.protocol + '//' + window.location.hostname + window.location.pathname + window.location.search;
}
function page() {
if (/localhost$/.test(window.location.hostname)) return ignore('website is running locally');
if (window.location.protocol === 'file:') return ignore('website is running locally');
if (window.document.visibilityState === 'prerender') return ignore('document is prerendering');
var existingUid = getCookie('nm_uid');
var request = new XMLHttpRequest();
request.open('POST', plausibleHost + '/api/page', true);
request.setRequestHeader('Content-Type', 'text/plain');
var uid = existingUid || pseudoUUIDv4()
request.send(JSON.stringify({
url: getUrl(),
new_visitor: !existingUid,
uid: uid,
user_agent: window.navigator.userAgent,
referrer: window.document.referrer,
screenWidth: window.innerWidth
}));
request.onreadystatechange = function() {
if (request.readyState == XMLHttpRequest.DONE) {
if (!existingUid) {
setCookie('nm_uid', uid)
}
}
}
}
var his = window.history
if (his.pushState) {
var originalFn = his['pushState']
his.pushState = function() {
originalFn.apply(this, arguments)
page();
}
}
page()
} catch (e) {
new Image().src = plausibleHost + '/api/error?message=' + encodeURIComponent(e.message);
}
})(window, BASE_URL);

View File

@ -0,0 +1,13 @@
if (window.Element && !Element.prototype.closest) {
Element.prototype.closest =
function(s) {
var matches = (this.document || this.ownerDocument).querySelectorAll(s),
i,
el = this;
do {
i = matches.length;
while (--i >= 0 && matches.item(i) !== el) {};
} while ((i < 0) && (el = el.parentElement));
return el;
};
}

128
assets/js/stats/index.js Normal file
View File

@ -0,0 +1,128 @@
import Router from './router'
import * as m from './modal'
import {renderMainGraph, renderComparisons} from './main-graph'
const SPINNER = `
<div class="loading my-48 mx-auto"><div></div></div>
`
function delayAtLeast(promise, time) {
const delayed = new Promise((resolve) => setTimeout(resolve, time))
return Promise.all([promise, delayed]).then(function(val) { return val[0] })
}
function fetchModal(modal, path) {
const promise = fetch(path)
m.setModalBody(modal, SPINNER)
delayAtLeast(promise, 200)
.then(function(res) {
return res.text()
}).then(function(res) {
m.setModalBody(modal, res)
router.updateLinkHandlers()
})
}
const router = new Router()
function showModal(domain, endpoint) {
m.showModal({
onShow: function(modal) {
fetchModal(modal, endpoint + window.location.search)
},
onClose: function() {
router.navigate('/' + domain + window.location.search)
}
})
}
router
.on('/:domain/referrers/:referrer', function(params) {
showModal(params.domain, `/api/${params.domain}/referrers/${params.referrer}`)
})
.on('/:domain/referrers', function(params) {
showModal(params.domain, `/api/${params.domain}/referrers`)
})
.on('/:domain/pages', function(params) {
showModal(params.domain, `/api/${params.domain}/pages`)
})
.on('/:domain/countries', function(params) {
showModal(params.domain, `/api/${params.domain}/countries`)
})
.on('/:domain/operating-systems', function(params) {
showModal(params.domain, `/api/${params.domain}/operating-systems`)
})
.on('/:domain/browsers', function(params) {
showModal(params.domain, `/api/${params.domain}/browsers`)
})
.on('/:domain', function() {
m.ensureModalClosed()
})
.resolve();
const domainEl = document.querySelector('[data-site-domain]')
if (domainEl) {
const domain = domainEl.getAttribute('data-site-domain')
const promises = []
promises.push(
fetch(`/stats/${domain}/main-graph${location.search}`)
.then(res => res.json())
.then(res => renderMainGraph(res))
.then(graphData => fetch(`/api/${domain}/compare${location.search || '?'}&pageviews=${graphData.pageviews}&unique_visitors=${graphData.unique_visitors}`))
.then(res => res.json())
.then(res => renderComparisons(res))
)
promises.push(
fetch(`/stats/${domain}/referrers${location.search}`)
.then(res => res.text())
.then((res) => {
document.getElementById('referrer-stats').innerHTML = res
router.updateLinkHandlers()
})
)
promises.push(
fetch(`/stats/${domain}/pages${location.search}`)
.then(res => res.text())
.then((res) => {
document.getElementById('pages-stats').innerHTML = res
router.updateLinkHandlers()
})
)
promises.push(
fetch(`/stats/${domain}/countries${location.search}`)
.then(res => res.text())
.then((res) => {
document.getElementById('countries-stats').innerHTML = res
router.updateLinkHandlers()
})
)
Promise.all(promises).then(() => {
fetch(`/stats/${domain}/screen-sizes${location.search}`)
.then(res => res.text())
.then((res) => {
document.getElementById('screen-sizes-stats').innerHTML = res
router.updateLinkHandlers()
})
fetch(`/stats/${domain}/operating-systems${location.search}`)
.then(res => res.text())
.then((res) => {
document.getElementById('operating-systems-stats').innerHTML = res
router.updateLinkHandlers()
})
fetch(`/stats/${domain}/browsers${location.search}`)
.then(res => res.text())
.then((res) => {
document.getElementById('browsers-stats').innerHTML = res
router.updateLinkHandlers()
})
})
}

View File

@ -0,0 +1,242 @@
export function renderMainGraph(graphData) {
const extraClass = graphData.interval === 'hour' ? '' : 'cursor-pointer'
const TEMPLATE = `
<div class="border-b border-grey-light flex p-4">
<div class="border-r border-grey-light pl-2 pr-10">
<div class="text-grey-dark text-sm font-bold tracking-wide">UNIQUE VISITORS</div>
<div class="mt-2 flex items-center justify-between" id="visitors">
<b class="text-2xl" title="${graphData.unique_visitors.toLocaleString()}">${numberFormatter(graphData.unique_visitors)}</b>
</div>
</div>
<div class="px-10">
<div class="text-grey-dark text-sm font-bold tracking-wide">TOTAL PAGEVIEWS</div>
<div class="mt-2 flex items-center justify-between" id="pageviews">
<b class="text-2xl" title="${graphData.pageviews.toLocaleString()}">${numberFormatter(graphData.pageviews)}</b>
</div>
</div>
</div>
<div class="p-4">
<canvas id="main-graph-canvas" class="mt-4 ${extraClass}" width="1054" height="329"></canvas>
</div>
`
const mainGraphDiv = document.getElementById('main-graph')
mainGraphDiv.innerHTML = TEMPLATE
drawGraph(graphData)
return graphData
}
export function renderComparisons(comparisons) {
const visitorsDiv = document.getElementById('visitors')
const pageviewsDiv = document.getElementById('pageviews')
if (comparisons.change_pageviews && comparisons.change_visitors) {
const formattedChangeVisitors = numberFormatter(Math.abs(comparisons.change_visitors))
if (comparisons.change_visitors >= 0) {
visitorsDiv.innerHTML +=`
<span class="bg-green-lightest text-green-dark px-2 py-1 text-xs font-bold rounded">&uarr; ${formattedChangeVisitors}%</span>
`
} else if (comparisons.change_visitors < 0) {
visitorsDiv.innerHTML +=`
<span class="bg-red-lightest text-red-dark px-2 py-1 text-xs font-bold rounded">&darr; ${formattedChangeVisitors}%</span>
`
}
const formattedChangePageviews = numberFormatter(Math.abs(comparisons.change_pageviews))
if (comparisons.change_pageviews >= 0) {
pageviewsDiv.innerHTML +=`
<span class="bg-green-lightest text-green-dark px-2 py-1 text-xs font-bold rounded">&uarr; ${formattedChangePageviews}%</span>
`
} else if (comparisons.change_pageviews < 0) {
pageviewsDiv.innerHTML +=`
<span class="bg-red-lightest text-red-dark px-2 py-1 text-xs font-bold rounded">&darr; ${formattedChangePageviews}%</span>
`
}
} else {
visitorsDiv.innerHTML +=`
<span class="bg-grey-lightest text-grey-dark px-2 py-1 text-xs font-bold rounded">N/A</span>
`
pageviewsDiv.innerHTML +=`
<span class="bg-grey-lightest text-grey-dark px-2 py-1 text-xs font-bold rounded">N/A</span>
`
}
}
function dataSets(graphData, ctx) {
var gradient = ctx.createLinearGradient(0, 0, 0, 300);
gradient.addColorStop(0, 'rgba(101,116,205, 0.2)');
gradient.addColorStop(1, 'rgba(101,116,205, 0)');
if (graphData.present_index) {
var dashedPart = graphData.plot.slice(graphData.present_index - 1);
var dashedPlot = (new Array(graphData.plot.length - dashedPart.length)).concat(dashedPart)
for(var i = graphData.present_index; i < graphData.plot.length; i++) {
graphData.plot[i] = undefined
}
return [{
label: 'Visitors',
data: graphData.plot,
borderWidth: 3,
borderColor: 'rgba(101,116,205)',
pointBackgroundColor: 'rgba(101,116,205)',
backgroundColor: gradient,
},
{
label: 'Visitors',
data: dashedPlot,
borderWidth: 3,
borderDash: [5, 10],
borderColor: 'rgba(101,116,205)',
pointBackgroundColor: 'rgba(101,116,205)',
backgroundColor: gradient,
}]
} else {
return [{
label: 'Visitors',
data: graphData.plot,
borderWidth: 3,
borderColor: 'rgba(101,116,205)',
pointBackgroundColor: 'rgba(101,116,205)',
backgroundColor: gradient,
}]
}
}
function drawGraph(graphData) {
var ctx = document.getElementById("main-graph-canvas").getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: graphData.labels,
datasets: dataSets(graphData, ctx)
},
options: {
animation: false,
legend: {display: false},
responsive: true,
elements: {line: {tension: 0.1}, point: {radius: 0}},
onClick: onClick(graphData),
tooltips: {
mode: 'index',
intersect: false,
xPadding: 10,
yPadding: 10,
titleFontSize: 16,
footerFontSize: 14,
footerFontColor: '#e6e8ff',
backgroundColor: 'rgba(25, 30, 56)',
callbacks: {
title: function(dataPoints) {
var data = dataPoints[0]
if (graphData.interval === 'month') {
return data.yLabel.toLocaleString() + ' visitors in ' + data.xLabel
} else if (graphData.interval === 'date') {
return data.yLabel.toLocaleString() + ' visitors on ' + data.xLabel
} else if (graphData.interval === 'hour') {
return data.yLabel.toLocaleString() + ' visitors at ' + data.xLabel
}
},
label: function() {},
afterBody: function(dataPoints) {
if (graphData.interval === 'month') {
return 'Click to view month'
} else if (graphData.interval === 'date') {
return 'Click to view day'
}
}
}
},
scales: {
yAxes: [{
ticks: {
callback: numberFormatter,
beginAtZero: true,
autoSkip: true,
maxTicksLimit: 8,
},
gridLines: {
zeroLineColor: 'transparent',
drawBorder: false,
}
}],
xAxes: [{
gridLines: {
display: false,
},
ticks: {
autoSkip: true,
maxTicksLimit: 8,
callback: dateFormatter(graphData),
}
}]
}
}
});
}
const THOUSAND = 1000
const HUNDRED_THOUSAND = 100000
const MILLION = 1000000
const HUNDRED_MILLION = 100000000
function numberFormatter(num) {
if (num >= THOUSAND && num < MILLION) {
const thousands = num / THOUSAND
if (thousands === Math.floor(thousands) || num >= HUNDRED_THOUSAND) {
return Math.floor(thousands) + 'k'
} else {
return (Math.floor(thousands * 10) / 10) + 'k'
}
} else if (num >= MILLION && num < HUNDRED_MILLION) {
const millions = num / MILLION
if (millions === Math.floor(millions)) {
return Math.floor(millions) + 'm'
} else {
return (Math.floor(millions * 10) / 10) + 'm'
}
} else {
return num
}
}
const MONTHS = [
"January", "February", "March",
"April", "May", "June", "July",
"August", "September", "October",
"November", "December"
]
function dateFormatter(graphData) {
return function(isoDate) {
const date = new Date(isoDate)
if (graphData.interval === 'month') {
return MONTHS[date.getMonth()];
} else if (graphData.interval === 'date') {
return date.getDate() + ' ' + MONTHS[date.getMonth()];
} else if (graphData.interval === 'hour') {
var hours = date.getHours();
var ampm = hours >= 12 ? 'pm' : 'am';
hours = hours % 12;
hours = hours ? hours : 12; // the hour '0' should be '12'
return hours + ampm;
}
}
}
function onClick(graphData) {
return function(e) {
const element = this.getElementsAtEventForMode(e, 'index', {intersect: false})[0]
const date = element._chart.config.data.labels[element._index]
if (graphData.interval === 'month') {
document.location = '?period=month&date=' + date
} else if (graphData.interval === 'date') {
document.location = '?period=day&date=' + date
}
}
}

47
assets/js/stats/modal.js Normal file
View File

@ -0,0 +1,47 @@
const SPINNER = `
<div class="loading my-48 mx-auto"><div></div></div>
`
const EMPTY_MODAL = `
<div class="modal micromodal-slide" id="stats-modal" aria-hidden="true">
<div class="modal__overlay" tabindex="-1" data-micromodal-close>
<div class="modal__container"></div>
</div>
</div>
`
let el, instance;
export function showModal(callbacks) {
if (!el) {
const modal = document.createElement('div')
modal.innerHTML = EMPTY_MODAL
document.body.append(modal)
el = modal
}
MicroModal.show('stats-modal', {
disableFocus: true,
disableScroll: true,
onShow: function(modal, e) {
instance = this
callbacks.onShow(modal)
},
onClose: function(modal) {
setModalBody(modal, '')
callbacks.onClose(modal)
}
})
}
export function setModalBody(modal, body) {
modal.children[0].children[0].innerHTML = body
}
export function ensureModalClosed() {
if (instance) {
const currentOnClose = instance.onClose
instance.onClose = function() {}
MicroModal.close('stats-modal')
}
}

81
assets/js/stats/router.js Normal file
View File

@ -0,0 +1,81 @@
const PARAMETER_REGEXP = /([:*])(\w+)/g;
const REPLACE_VARIABLE_REGEXP = '([^\/]+)';
export default class Router {
constructor() {
this.routes = []
this.updateLinkHandlers()
window.addEventListener('popstate', this.resolve.bind(this));
}
on(path, handler) {
const {regex, paramNames} = this._generateRouteRegex(path)
this.routes.push({regex, paramNames, handler})
return this
}
resolve() {
const match = this._matchRoute()
if (match) {
match.route.handler(match.params)
}
return this
}
navigate(to) {
history.pushState({}, '', to)
this.resolve()
}
updateLinkHandlers() {
const self = this
const links = document.querySelectorAll('[data-pushstate]')
for (const link of links) {
if (!link.hasListenerAttached) {
link.addEventListener('click', function (e) {
if (e.ctrlKey || e.metaKey) {
return false;
}
e.preventDefault();
const location = link.getAttribute('href');;
self.navigate(location.replace(/\/+$/, '').replace(/^\/+/, '/'));
})
link.hasListenerAttached = true;
}
}
}
_matchRoute() {
const currentPath = window.location.pathname
for (const route of this.routes) {
const match = currentPath.replace(/^\/+/, '/').match(route.regex)
if (match) {
const params = this._extractParams(match, route.paramNames)
return {route, params}
}
}
}
_generateRouteRegex(path) {
const paramNames = []
const regex = path.replace(PARAMETER_REGEXP, function (full, dots, name) {
paramNames.push(name);
return REPLACE_VARIABLE_REGEXP;
})
return {regex: new RegExp(regex), paramNames}
}
_extractParams(match, paramNames) {
return match.slice(1, match.length).reduce(function(params, value, index) {
if (params === null) params = {};
params[paramNames[index]] = decodeURIComponent(value);
return params;
}, null);
}
}

View File

@ -0,0 +1,71 @@
const selector = document.querySelector('[data-timeframe-select]')
if (selector) {
selector.addEventListener('change', function(e) {
if (e.target.value === 'Day') {
document.location = '?period=day'
} else if (e.target.value === 'Week') {
document.location = '?period=week'
} else if (e.target.value === 'Month') {
document.location = '?period=month'
} else if (e.target.value === 'Last 3 Months') {
document.location = '?period=3mo'
} else if (e.target.value === 'Last 6 Months') {
document.location = '?period=6mo'
} else if (e.target.value === 'Custom') {
const parent = e.target.closest('div')
parent.classList.add('hidden')
parent.nextElementSibling.classList.remove('hidden')
}
})
}
function getQueryVariable(variable) {
var query = window.location.search.substring(1);
var vars = query.split('&');
for (var i = 0; i < vars.length; i++) {
var pair = vars[i].split('=');
if (decodeURIComponent(pair[0]) == variable) {
return decodeURIComponent(pair[1]);
}
}
}
function defaultDate() {
const from = getQueryVariable("from")
const to = getQueryVariable("to")
return [Date.parse(from), Date.parse(to)]
}
function dateToISOString(date) {
const year = date.getFullYear();
let month = date.getMonth()+1;
let dt = date.getDate();
if (dt < 10) {
dt = '0' + dt;
}
if (month < 10) {
month = '0' + month;
}
return year + '-' + month + '-'+ dt;
}
const dateRangeTrigger = document.querySelector('#custom-daterange-trigger')
if (dateRangeTrigger) {
const picker = flatpickr('#custom-daterange-trigger', {
mode: "range",
dateFormat: "M j",
maxDate: 'today',
onChange: function(selectedDates, dateStr) {
if (selectedDates.length === 2) {
dateRangeTrigger.innerHTML = dateStr
const from = dateToISOString(selectedDates[0])
const to = dateToISOString(selectedDates[1])
document.location = '?period=custom&from=' + from + '&to=' + to
}
},
})
}

9583
assets/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
assets/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"repository": {},
"license": "MIT",
"scripts": {
"deploy": "webpack --mode production",
"watch": "webpack --mode development --watch"
},
"dependencies": {
"autoprefixer": "^9.6.1",
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html"
},
"devDependencies": {
"@babel/core": "^7.5.5",
"@babel/preset-env": "^7.5.5",
"babel-loader": "^8.0.6",
"copy-webpack-plugin": "^5.0.4",
"css-loader": "^3.2.0",
"mini-css-extract-plugin": "^0.8.0",
"optimize-css-assets-webpack-plugin": "^5.0.3",
"postcss-loader": "^3.0.0",
"tailwindcss": "^0.7.4",
"uglifyjs-webpack-plugin": "^2.2.0",
"webpack": "4.39.2",
"webpack-cli": "^3.3.7"
}
}

8
assets/postcss.config.js Normal file
View File

@ -0,0 +1,8 @@
var tailwindcss = require('tailwindcss');
module.exports = {
plugins: [
tailwindcss('./tailwind.js'),
require('autoprefixer')
]
}

BIN
assets/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

5
assets/static/robots.txt Normal file
View File

@ -0,0 +1,5 @@
# See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
#
# To ban all spiders from the entire site uncomment the next two lines:
# User-agent: *
# Disallow: /

959
assets/tailwind.js Normal file
View File

@ -0,0 +1,959 @@
/*
Tailwind - The Utility-First CSS Framework
A project by Adam Wathan (@adamwathan), Jonathan Reinink (@reinink),
David Hemphill (@davidhemphill) and Steve Schoger (@steveschoger).
Welcome to the Tailwind config file. This is where you can customize
Tailwind specifically for your project. Don't be intimidated by the
length of this file. It's really just a big JavaScript object and
we've done our very best to explain each section.
View the full documentation at https://tailwindcss.com.
|-------------------------------------------------------------------------------
| The default config
|-------------------------------------------------------------------------------
|
| This variable contains the default Tailwind config. You don't have
| to use it, but it can sometimes be helpful to have available. For
| example, you may choose to merge your custom configuration
| values with some of the Tailwind defaults.
|
*/
let defaultConfig = require('tailwindcss/defaultConfig')()
/*
|-------------------------------------------------------------------------------
| Colors https://tailwindcss.com/docs/colors
|-------------------------------------------------------------------------------
|
| Here you can specify the colors used in your project. To get you started,
| we've provided a generous palette of great looking colors that are perfect
| for prototyping, but don't hesitate to change them for your project. You
| own these colors, nothing will break if you change everything about them.
|
| We've used literal color names ("red", "blue", etc.) for the default
| palette, but if you'd rather use functional names like "primary" and
| "secondary", or even a numeric scale like "100" and "200", go for it.
|
*/
let colors = {
'transparent': 'transparent',
'black': '#22292f',
'grey-darkest': '#3d4852',
'grey-darker': '#606f7b',
'grey-dark': '#8795a1',
'grey': '#b8c2cc',
'grey-light': '#dae1e7',
'grey-lighter': '#f1f5f8',
'grey-lightest': '#f8fafc',
'white': '#ffffff',
'red-darkest': '#3b0d0c',
'red-darker': '#621b18',
'red-dark': '#ff4457', // USE
'red': '#ff6978', // USE
'red-light': '#ef5753',
'red-lighter': '#f9acaa',
'red-lightest': '#fcebea',
'orange-darkest': '#462a16',
'orange-darker': '#613b1f',
'orange-dark': '#de751f',
'orange': '#f6993f',
'orange-light': '#faad63',
'orange-lighter': '#fcd9b6',
'orange-lightest': '#fff5eb',
'yellow-darkest': '#453411',
'yellow-darker': '#684f1d',
'yellow-dark': '#f2d024',
'yellow': '#ffed4a',
'yellow-light': '#fff382',
'yellow-lighter': '#fff9c2',
'yellow-lightest': '#fcfbeb',
'green-darkest': '#0f2f21',
'green-darker': '#1a4731',
'green-dark': '#1f9d55',
'green': '#38C172', // USE
'green-light': '#51d88a',
'green-lighter': '#a2f5bf',
'green-lightest': '#e3fcec',
'teal-darkest': '#0d3331',
'teal-darker': '#20504f',
'teal-dark': '#38a89d',
'teal': '#4dc0b5',
'teal-light': '#64d5ca',
'teal-lighter': '#a0f0ed',
'teal-lightest': '#e8fffe',
'blue-darkest': '#12283a',
'blue-darker': '#1c3d5a',
'blue-dark': '#2779bd',
'blue': '#3490dc',
'blue-light': '#6cb2eb',
'blue-lighter': '#bcdefa',
'blue-lightest': '#eff8ff',
'indigo-darkest': '#191e38',
'indigo-darker': '#2f365f',
'indigo-dark': '#5661b3',
'indigo': '#6574cd',
'indigo-light': '#7886d7',
'indigo-lighter': '#b2b7ff',
'indigo-lightest': '#e6e8ff',
'purple-darkest': '#21183c',
'purple-darker': '#382b5f',
'purple-dark': '#794acf',
'purple': '#9561e2',
'purple-light': '#a779e9',
'purple-lighter': '#d6bbfc',
'purple-lightest': '#f3ebff',
'pink-darkest': '#451225',
'pink-darker': '#6f213f',
'pink-dark': '#eb5286',
'pink': '#f66d9b',
'pink-light': '#fa7ea8',
'pink-lighter': '#ffbbca',
'pink-lightest': '#ffebef',
}
module.exports = {
/*
|-----------------------------------------------------------------------------
| Colors https://tailwindcss.com/docs/colors
|-----------------------------------------------------------------------------
|
| The color palette defined above is also assigned to the "colors" key of
| your Tailwind config. This makes it easy to access them in your CSS
| using Tailwind's config helper. For example:
|
| .error { color: config('colors.red') }
|
*/
colors: colors,
/*
|-----------------------------------------------------------------------------
| Screens https://tailwindcss.com/docs/responsive-design
|-----------------------------------------------------------------------------
|
| Screens in Tailwind are translated to CSS media queries. They define the
| responsive breakpoints for your project. By default Tailwind takes a
| "mobile first" approach, where each screen size represents a minimum
| viewport width. Feel free to have as few or as many screens as you
| want, naming them in whatever way you'd prefer for your project.
|
| Tailwind also allows for more complex screen definitions, which can be
| useful in certain situations. Be sure to see the full responsive
| documentation for a complete list of options.
|
| Class name: .{screen}:{utility}
|
*/
screens: {
'sm': '576px',
'md': '768px',
'lg': '992px',
'xl': '1200px',
},
/*
|-----------------------------------------------------------------------------
| Fonts https://tailwindcss.com/docs/fonts
|-----------------------------------------------------------------------------
|
| Here is where you define your project's font stack, or font families.
| Keep in mind that Tailwind doesn't actually load any fonts for you.
| If you're using custom fonts you'll need to import them prior to
| defining them here.
|
| By default we provide a native font stack that works remarkably well on
| any device or OS you're using, since it just uses the default fonts
| provided by the platform.
|
| Class name: .font-{name}
|
*/
fonts: {
'sans': [
'system-ui',
'BlinkMacSystemFont',
'-apple-system',
'Segoe UI',
'Roboto',
'Oxygen',
'Ubuntu',
'Cantarell',
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
'sans-serif',
],
'serif': [
'Constantia',
'Lucida Bright',
'Lucidabright',
'Lucida Serif',
'Lucida',
'DejaVu Serif',
'Bitstream Vera Serif',
'Liberation Serif',
'Georgia',
'serif',
],
'mono': [
'Menlo',
'Monaco',
'Consolas',
'Liberation Mono',
'Courier New',
'monospace',
],
},
/*
|-----------------------------------------------------------------------------
| Text sizes https://tailwindcss.com/docs/text-sizing
|-----------------------------------------------------------------------------
|
| Here is where you define your text sizes. Name these in whatever way
| makes the most sense to you. We use size names by default, but
| you're welcome to use a numeric scale or even something else
| entirely.
|
| By default Tailwind uses the "rem" unit type for most measurements.
| This allows you to set a root font size which all other sizes are
| then based on. That said, you are free to use whatever units you
| prefer, be it rems, ems, pixels or other.
|
| Class name: .text-{size}
|
*/
textSizes: {
'xs': '.75rem', // 12px
'sm': '.875rem', // 14px
'base': '1rem', // 16px
'lg': '1.125rem', // 18px
'xl': '1.25rem', // 20px
'2xl': '1.5rem', // 24px
'3xl': '1.875rem', // 30px
'4xl': '2.25rem', // 36px
'5xl': '3rem', // 48px
},
/*
|-----------------------------------------------------------------------------
| Font weights https://tailwindcss.com/docs/font-weight
|-----------------------------------------------------------------------------
|
| Here is where you define your font weights. We've provided a list of
| common font weight names with their respective numeric scale values
| to get you started. It's unlikely that your project will require
| all of these, so we recommend removing those you don't need.
|
| Class name: .font-{weight}
|
*/
fontWeights: {
'hairline': 100,
'thin': 200,
'light': 300,
'normal': 400,
'medium': 500,
'semibold': 600,
'bold': 700,
'extrabold': 800,
'black': 900,
},
/*
|-----------------------------------------------------------------------------
| Leading (line height) https://tailwindcss.com/docs/line-height
|-----------------------------------------------------------------------------
|
| Here is where you define your line height values, or as we call
| them in Tailwind, leadings.
|
| Class name: .leading-{size}
|
*/
leading: {
'none': 1,
'tight': 1.25,
'normal': 1.5,
'loose': 2,
},
/*
|-----------------------------------------------------------------------------
| Tracking (letter spacing) https://tailwindcss.com/docs/letter-spacing
|-----------------------------------------------------------------------------
|
| Here is where you define your letter spacing values, or as we call
| them in Tailwind, tracking.
|
| Class name: .tracking-{size}
|
*/
tracking: {
'tight': '-0.05em',
'normal': '0',
'wide': '0.05em',
},
/*
|-----------------------------------------------------------------------------
| Text colors https://tailwindcss.com/docs/text-color
|-----------------------------------------------------------------------------
|
| Here is where you define your text colors. By default these use the
| color palette we defined above, however you're welcome to set these
| independently if that makes sense for your project.
|
| Class name: .text-{color}
|
*/
textColors: colors,
/*
|-----------------------------------------------------------------------------
| Background colors https://tailwindcss.com/docs/background-color
|-----------------------------------------------------------------------------
|
| Here is where you define your background colors. By default these use
| the color palette we defined above, however you're welcome to set
| these independently if that makes sense for your project.
|
| Class name: .bg-{color}
|
*/
backgroundColors: colors,
/*
|-----------------------------------------------------------------------------
| Background sizes https://tailwindcss.com/docs/background-size
|-----------------------------------------------------------------------------
|
| Here is where you define your background sizes. We provide some common
| values that are useful in most projects, but feel free to add other sizes
| that are specific to your project here as well.
|
| Class name: .bg-{size}
|
*/
backgroundSize: {
'auto': 'auto',
'cover': 'cover',
'contain': 'contain',
},
/*
|-----------------------------------------------------------------------------
| Border widths https://tailwindcss.com/docs/border-width
|-----------------------------------------------------------------------------
|
| Here is where you define your border widths. Take note that border
| widths require a special "default" value set as well. This is the
| width that will be used when you do not specify a border width.
|
| Class name: .border{-side?}{-width?}
|
*/
borderWidths: {
default: '1px',
'0': '0',
'2': '2px',
'4': '4px',
'8': '8px',
},
/*
|-----------------------------------------------------------------------------
| Border colors https://tailwindcss.com/docs/border-color
|-----------------------------------------------------------------------------
|
| Here is where you define your border colors. By default these use the
| color palette we defined above, however you're welcome to set these
| independently if that makes sense for your project.
|
| Take note that border colors require a special "default" value set
| as well. This is the color that will be used when you do not
| specify a border color.
|
| Class name: .border-{color}
|
*/
borderColors: global.Object.assign({ default: colors['grey-light'] }, colors),
/*
|-----------------------------------------------------------------------------
| Border radius https://tailwindcss.com/docs/border-radius
|-----------------------------------------------------------------------------
|
| Here is where you define your border radius values. If a `default` radius
| is provided, it will be made available as the non-suffixed `.rounded`
| utility.
|
| If your scale includes a `0` value to reset already rounded corners, it's
| a good idea to put it first so other values are able to override it.
|
| Class name: .rounded{-side?}{-size?}
|
*/
borderRadius: {
'none': '0',
'sm': '.125rem',
default: '.25rem',
'lg': '.5rem',
'full': '9999px',
},
/*
|-----------------------------------------------------------------------------
| Width https://tailwindcss.com/docs/width
|-----------------------------------------------------------------------------
|
| Here is where you define your width utility sizes. These can be
| percentage based, pixels, rems, or any other units. By default
| we provide a sensible rem based numeric scale, a percentage
| based fraction scale, plus some other common use-cases. You
| can, of course, modify these values as needed.
|
|
| It's also worth mentioning that Tailwind automatically escapes
| invalid CSS class name characters, which allows you to have
| awesome classes like .w-2/3.
|
| Class name: .w-{size}
|
*/
width: {
'auto': 'auto',
'px': '1px',
'1': '0.25rem',
'2': '0.5rem',
'3': '0.75rem',
'4': '1rem',
'5': '1.25rem',
'6': '1.5rem',
'8': '2rem',
'10': '2.5rem',
'12': '3rem',
'16': '4rem',
'24': '6rem',
'32': '8rem',
'48': '12rem',
'64': '16rem',
'1/2': '50%',
'31percent': '31%',
'1/3': '33.33333%',
'2/3': '66.66667%',
'1/4': '25%',
'3/4': '75%',
'1/5': '20%',
'2/5': '40%',
'3/5': '60%',
'4/5': '80%',
'1/6': '16.66667%',
'5/6': '83.33333%',
'full': '100%',
'screen': '100vw',
},
/*
|-----------------------------------------------------------------------------
| Height https://tailwindcss.com/docs/height
|-----------------------------------------------------------------------------
|
| Here is where you define your height utility sizes. These can be
| percentage based, pixels, rems, or any other units. By default
| we provide a sensible rem based numeric scale plus some other
| common use-cases. You can, of course, modify these values as
| needed.
|
| Class name: .h-{size}
|
*/
height: {
'auto': 'auto',
'px': '1px',
'1': '0.25rem',
'2': '0.5rem',
'3': '0.75rem',
'4': '1rem',
'5': '1.25rem',
'6': '1.5rem',
'8': '2rem',
'10': '2.5rem',
'12': '3rem',
'16': '4rem',
'24': '6rem',
'32': '8rem',
'48': '12rem',
'64': '16rem',
'72': '18rem',
'full': '100%',
'screen': '100vh',
},
/*
|-----------------------------------------------------------------------------
| Minimum width https://tailwindcss.com/docs/min-width
|-----------------------------------------------------------------------------
|
| Here is where you define your minimum width utility sizes. These can
| be percentage based, pixels, rems, or any other units. We provide a
| couple common use-cases by default. You can, of course, modify
| these values as needed.
|
| Class name: .min-w-{size}
|
*/
minWidth: {
'0': '0',
'full': '100%',
},
/*
|-----------------------------------------------------------------------------
| Minimum height https://tailwindcss.com/docs/min-height
|-----------------------------------------------------------------------------
|
| Here is where you define your minimum height utility sizes. These can
| be percentage based, pixels, rems, or any other units. We provide a
| few common use-cases by default. You can, of course, modify these
| values as needed.
|
| Class name: .min-h-{size}
|
*/
minHeight: {
'0': '0',
'full': '100%',
'screen': '100vh',
},
/*
|-----------------------------------------------------------------------------
| Maximum width https://tailwindcss.com/docs/max-width
|-----------------------------------------------------------------------------
|
| Here is where you define your maximum width utility sizes. These can
| be percentage based, pixels, rems, or any other units. By default
| we provide a sensible rem based scale and a "full width" size,
| which is basically a reset utility. You can, of course,
| modify these values as needed.
|
| Class name: .max-w-{size}
|
*/
maxWidth: {
'xs': '20rem',
'sm': '30rem',
'md': '40rem',
'lg': '50rem',
'xl': '60rem',
'2xl': '70rem',
'3xl': '80rem',
'4xl': '90rem',
'5xl': '100rem',
'full': '100%',
},
/*
|-----------------------------------------------------------------------------
| Maximum height https://tailwindcss.com/docs/max-height
|-----------------------------------------------------------------------------
|
| Here is where you define your maximum height utility sizes. These can
| be percentage based, pixels, rems, or any other units. We provide a
| couple common use-cases by default. You can, of course, modify
| these values as needed.
|
| Class name: .max-h-{size}
|
*/
maxHeight: {
'full': '100%',
'screen': '100vh',
},
/*
|-----------------------------------------------------------------------------
| Padding https://tailwindcss.com/docs/padding
|-----------------------------------------------------------------------------
|
| Here is where you define your padding utility sizes. These can be
| percentage based, pixels, rems, or any other units. By default we
| provide a sensible rem based numeric scale plus a couple other
| common use-cases like "1px". You can, of course, modify these
| values as needed.
|
| Class name: .p{side?}-{size}
|
*/
padding: {
'px': '1px',
'0': '0',
'1': '0.25rem',
'2': '0.5rem',
'3': '0.75rem',
'4': '1rem',
'5': '1.25rem',
'6': '1.5rem',
'8': '2rem',
'10': '2.5rem',
'12': '3rem',
'16': '4rem',
'20': '5rem',
'24': '6rem',
'32': '8rem',
'48': '12rem',
},
/*
|-----------------------------------------------------------------------------
| Margin https://tailwindcss.com/docs/margin
|-----------------------------------------------------------------------------
|
| Here is where you define your margin utility sizes. These can be
| percentage based, pixels, rems, or any other units. By default we
| provide a sensible rem based numeric scale plus a couple other
| common use-cases like "1px". You can, of course, modify these
| values as needed.
|
| Class name: .m{side?}-{size}
|
*/
margin: {
'auto': 'auto',
'px': '1px',
'0': '0',
'1': '0.25rem',
'2': '0.5rem',
'3': '0.75rem',
'4': '1rem',
'5': '1.25rem',
'6': '1.5rem',
'8': '2rem',
'10': '2.5rem',
'12': '3rem',
'16': '4rem',
'20': '5rem',
'24': '6rem',
'32': '8rem',
'48': '12rem',
},
/*
|-----------------------------------------------------------------------------
| Negative margin https://tailwindcss.com/docs/negative-margin
|-----------------------------------------------------------------------------
|
| Here is where you define your negative margin utility sizes. These can
| be percentage based, pixels, rems, or any other units. By default we
| provide matching values to the padding scale since these utilities
| generally get used together. You can, of course, modify these
| values as needed.
|
| Class name: .-m{side?}-{size}
|
*/
negativeMargin: {
'px': '1px',
'0': '0',
'1': '0.25rem',
'2': '0.5rem',
'3': '0.75rem',
'4': '1rem',
'5': '1.25rem',
'6': '1.5rem',
'8': '2rem',
'10': '2.5rem',
'12': '3rem',
'16': '4rem',
'20': '5rem',
'24': '6rem',
'32': '8rem',
},
/*
|-----------------------------------------------------------------------------
| Shadows https://tailwindcss.com/docs/shadows
|-----------------------------------------------------------------------------
|
| Here is where you define your shadow utilities. As you can see from
| the defaults we provide, it's possible to apply multiple shadows
| per utility using comma separation.
|
| If a `default` shadow is provided, it will be made available as the non-
| suffixed `.shadow` utility.
|
| Class name: .shadow-{size?}
|
*/
shadows: {
default: '0 2px 4px 0 rgba(0,0,0,0.10)',
'md': '0 4px 8px 0 rgba(0,0,0,0.12), 0 2px 4px 0 rgba(0,0,0,0.08)',
'lg': '0 15px 30px 0 rgba(0,0,0,0.11), 0 5px 15px 0 rgba(0,0,0,0.08)',
'inner': 'inset 0 2px 4px 0 rgba(0,0,0,0.06)',
'outline': '0 0 0 3px rgba(52,144,220,0.5)',
'none': 'none',
},
/*
|-----------------------------------------------------------------------------
| Z-index https://tailwindcss.com/docs/z-index
|-----------------------------------------------------------------------------
|
| Here is where you define your z-index utility values. By default we
| provide a sensible numeric scale. You can, of course, modify these
| values as needed.
|
| Class name: .z-{index}
|
*/
zIndex: {
'auto': 'auto',
'0': 0,
'10': 10,
'20': 20,
'30': 30,
'40': 40,
'50': 50,
},
/*
|-----------------------------------------------------------------------------
| Opacity https://tailwindcss.com/docs/opacity
|-----------------------------------------------------------------------------
|
| Here is where you define your opacity utility values. By default we
| provide a sensible numeric scale. You can, of course, modify these
| values as needed.
|
| Class name: .opacity-{name}
|
*/
opacity: {
'0': '0',
'25': '.25',
'50': '.5',
'75': '.75',
'100': '1',
},
/*
|-----------------------------------------------------------------------------
| SVG fill https://tailwindcss.com/docs/svg
|-----------------------------------------------------------------------------
|
| Here is where you define your SVG fill colors. By default we just provide
| `fill-current` which sets the fill to the current text color. This lets you
| specify a fill color using existing text color utilities and helps keep the
| generated CSS file size down.
|
| Class name: .fill-{name}
|
*/
svgFill: {
'current': 'currentColor',
},
/*
|-----------------------------------------------------------------------------
| SVG stroke https://tailwindcss.com/docs/svg
|-----------------------------------------------------------------------------
|
| Here is where you define your SVG stroke colors. By default we just provide
| `stroke-current` which sets the stroke to the current text color. This lets
| you specify a stroke color using existing text color utilities and helps
| keep the generated CSS file size down.
|
| Class name: .stroke-{name}
|
*/
svgStroke: {
'current': 'currentColor',
},
/*
|-----------------------------------------------------------------------------
| Modules https://tailwindcss.com/docs/configuration#modules
|-----------------------------------------------------------------------------
|
| Here is where you control which modules are generated and what variants are
| generated for each of those modules.
|
| Currently supported variants:
| - responsive
| - hover
| - focus
| - focus-within
| - active
| - group-hover
|
| To disable a module completely, use `false` instead of an array.
|
*/
modules: {
appearance: ['responsive'],
backgroundAttachment: ['responsive'],
backgroundColors: ['responsive', 'hover', 'focus'],
backgroundPosition: ['responsive'],
backgroundRepeat: ['responsive'],
backgroundSize: ['responsive'],
borderCollapse: [],
borderColors: ['responsive', 'hover', 'focus'],
borderRadius: ['responsive'],
borderStyle: ['responsive'],
borderWidths: ['responsive'],
cursor: ['responsive'],
display: ['responsive'],
flexbox: ['responsive'],
float: ['responsive'],
fonts: ['responsive'],
fontWeights: ['responsive', 'hover', 'focus'],
height: ['responsive'],
leading: ['responsive'],
lists: ['responsive'],
margin: ['responsive'],
maxHeight: ['responsive'],
maxWidth: ['responsive'],
minHeight: ['responsive'],
minWidth: ['responsive'],
negativeMargin: ['responsive'],
objectFit: false,
objectPosition: false,
opacity: ['responsive', 'group-hover'],
outline: ['focus'],
overflow: ['responsive'],
padding: ['responsive'],
pointerEvents: ['responsive'],
position: ['responsive'],
resize: ['responsive'],
shadows: ['responsive', 'hover', 'focus'],
svgFill: [],
svgStroke: [],
tableLayout: ['responsive'],
textAlign: ['responsive'],
textColors: ['responsive', 'hover', 'focus', 'group-hover'],
textSizes: ['responsive'],
textStyle: ['responsive', 'hover', 'focus'],
tracking: ['responsive'],
userSelect: ['responsive'],
verticalAlign: ['responsive'],
visibility: ['responsive'],
whitespace: ['responsive'],
width: ['responsive'],
zIndex: ['responsive'],
},
/*
|-----------------------------------------------------------------------------
| Plugins https://tailwindcss.com/docs/plugins
|-----------------------------------------------------------------------------
|
| Here is where you can register any plugins you'd like to use in your
| project. Tailwind's built-in `container` plugin is enabled by default to
| give you a Bootstrap-style responsive container component out of the box.
|
| Be sure to view the complete plugin documentation to learn more about how
| the plugin system works.
|
*/
plugins: [
require('tailwindcss/plugins/container')({
center: true,
padding: '1rem',
}),
],
/*
|-----------------------------------------------------------------------------
| Advanced Options https://tailwindcss.com/docs/configuration#options
|-----------------------------------------------------------------------------
|
| Here is where you can tweak advanced configuration options. We recommend
| leaving these options alone unless you absolutely need to change them.
|
*/
options: {
prefix: '',
important: false,
separator: ':',
},
}

48
assets/webpack.config.js Normal file
View File

@ -0,0 +1,48 @@
const path = require('path');
const glob = require('glob');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const DefinePlugin = require('webpack').DefinePlugin;
module.exports = (env, options) => ({
optimization: {
minimizer: [
new UglifyJsPlugin({ cache: true, parallel: true, sourceMap: false }),
new OptimizeCSSAssetsPlugin({})
]
},
entry: {
'app': ['./js/app.js'],
'p': ['./js/p.js'],
'analytics': ['./js/plausible.js'],
'plausible': ['./js/plausible.js']
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, '../priv/static/js')
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
},
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader']
}
]
},
plugins: [
new MiniCssExtractPlugin({ filename: '../css/app.css' }),
new CopyWebpackPlugin([{ from: 'static/', to: '../' }]),
new DefinePlugin({
"BASE_URL": JSON.stringify(process.env.BASE_URL || "http://localtest.me:8000")
}),
]
});

4
compile Normal file
View File

@ -0,0 +1,4 @@
cd $phoenix_dir
npm --prefix ./assets run deploy
mix "${phoenix_ex}.digest"
mix "${phoenix_ex}.digest.clean"

53
config/config.exs Normal file
View File

@ -0,0 +1,53 @@
# This file is responsible for configuring your application
# and its dependencies with the aid of the Mix.Config module.
#
# This configuration file is loaded before any dependency and
# is restricted to this project.
# General application configuration
use Mix.Config
config :plausible,
ecto_repos: [Plausible.Repo]
# Configures the endpoint
config :plausible, PlausibleWeb.Endpoint,
url: [host: "localhost"],
secret_key_base: "/NJrhNtbyCVAsTyvtk1ZYCwfm981Vpo/0XrVwjJvemDaKC/vsvBRevLwsc6u8RCg",
render_errors: [view: PlausibleWeb.ErrorView, accepts: ~w(html json)],
pubsub: [name: Plausible.PubSub, adapter: Phoenix.PubSub.PG2]
config :sentry,
dsn: "https://0350a42aa6234a2eaf1230866788598e@sentry.io/1382353",
included_environments: [:prod, :staging],
environment_name: String.to_atom(Map.get(System.get_env(), "APP_ENV", "dev")),
enable_source_code_context: true,
root_source_code_path: File.cwd!
# Configures Elixir's Logger
config :logger, :console,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
config :ua_inspector,
database_path: "priv/ua_inspector"
config :ref_inspector,
database_path: "priv/ref_inspector"
config :plausible, PlausibleWeb.Endpoint,
instrumenters: [Appsignal.Phoenix.Instrumenter]
config :phoenix, :template_engines,
eex: Appsignal.Phoenix.Template.EExEngine,
exs: Appsignal.Phoenix.Template.ExsEngine
config :plausible,
paddle_api: Plausible.Billing.PaddleApi
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{Mix.env()}.exs"

48
config/dev.exs Normal file
View File

@ -0,0 +1,48 @@
use Mix.Config
# For development, we disable any cache and enable
# debugging and code reloading.
#
# The watchers configuration can be used to run external
# watchers to your application. For example, we use it
# with webpack to recompile .js and .css sources.
config :plausible, PlausibleWeb.Endpoint,
http: [port: 8000],
debug_errors: true,
code_reloader: true,
check_origin: false,
watchers: [
node: [
"node_modules/webpack/bin/webpack.js",
"--mode",
"development",
"--watch-stdin",
cd: Path.expand("../assets", __DIR__)
]
]
config :plausible, PlausibleWeb.Endpoint,
live_reload: [
patterns: [
~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$},
~r{priv/gettext/.*(po)$},
~r{lib/plausible_web/views/.*(ex)$},
~r{lib/plausible_web/templates/.*(eex)$}
]
]
config :logger, :console, format: "[$level] $message\n"
config :phoenix, :stacktrace_depth, 20
config :phoenix, :plug_init_mode, :runtime
config :plausible, Plausible.Repo,
username: "postgres",
password: "postgres",
database: "plausible_dev",
hostname: "localhost",
pool_size: 10
config :plausible, Plausible.Mailer,
adapter: Bamboo.LocalAdapter
import_config "dev.secret.exs"

91
config/prod.exs Normal file
View File

@ -0,0 +1,91 @@
use Mix.Config
# For production, don't forget to configure the url host
# to something meaningful, Phoenix uses this information
# when generating URLs.
#
# Note we also include the path to a cache manifest
# containing the digested version of static files. This
# manifest is generated by the `mix phx.digest` task,
# which you should run after static files are built and
# before starting your production server.
config :plausible, PlausibleWeb.Endpoint,
http: [:inet6, port: System.get_env("PORT") || 4000],
url: [host: System.get_env("HOST"), scheme: "https"],
cache_static_manifest: "priv/static/cache_manifest.json"
# Do not print debug messages in production
config :logger, level: :info
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
# to the previous section and set your `:url` port to 443:
#
# config :plausible, PlausibleWeb.Endpoint,
# ...
# url: [host: "example.com", port: 443],
# https: [
# :inet6,
# port: 443,
# cipher_suite: :strong,
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
# ]
#
# The `cipher_suite` is set to `:strong` to support only the
# latest and more secure SSL ciphers. This means old browsers
# and clients may not be supported. You can set it to
# `:compatible` for wider support.
#
# `:keyfile` and `:certfile` expect an absolute path to the key
# and cert in disk or a relative path inside priv, for example
# "priv/ssl/server.key". For all supported SSL configuration
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
#
# We also recommend setting `force_ssl` in your endpoint, ensuring
# no data is ever sent via http, always redirecting to https:
#
# config :plausible, PlausibleWeb.Endpoint,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
# ## Using releases (distillery)
#
# If you are doing OTP releases, you need to instruct Phoenix
# to start the server for all endpoints:
#
# config :phoenix, :serve_endpoints, true
#
# Alternatively, you can configure exactly which server to
# start per endpoint:
#
# config :plausible, PlausibleWeb.Endpoint, server: true
#
# Note you can't rely on `System.get_env/1` when using releases.
# See the releases documentation accordingly.
# Finally import the config/prod.secret.exs which should be versioned
# separately.
config :plausible, PlausibleWeb.Endpoint,
secret_key_base: System.get_env("SECRET_KEY_BASE")
# Configure your database
config :plausible, Plausible.Repo,
adapter: Ecto.Adapters.Postgres,
url: System.get_env("AVIEN_DATABASE_URL"),
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
timeout: 10_000,
ssl: true
config :plausible, :amplitude,
api_key: System.get_env("AMPLITUDE_API_KEY")
config :plausible, :google,
client_id: System.get_env("GOOGLE_CLIENT_ID"),
client_secret: System.get_env("GOOGLE_CLIENT_SECRET")
config :plausible, Plausible.Mailer,
adapter: Bamboo.PostmarkAdapter,
api_key: System.get_env("POSTMARK_API_KEY")

27
config/test.exs Normal file
View File

@ -0,0 +1,27 @@
use Mix.Config
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :plausible, PlausibleWeb.Endpoint,
http: [port: 4002],
server: false
# Print only warnings and errors during test
config :logger, level: :warn
# Reduce bcrypt rounds to speed up test suite
config :bcrypt_elixir, :log_rounds, 4
# Configure your database
config :plausible, Plausible.Repo,
username: "postgres",
password: "postgres",
database: "plausible_test",
hostname: "localhost",
pool: Ecto.Adapters.SQL.Sandbox
config :plausible, Plausible.Mailer,
adapter: Bamboo.TestAdapter
config :plausible,
paddle_api: Plausible.PaddleApi.Mock

3
elixir_buildpack.config Normal file
View File

@ -0,0 +1,3 @@
erlang_version=21.1.1
elixir_version=1.7.4
always_rebuild=false

View File

@ -0,0 +1,35 @@
defmodule Mix.Tasks.CheckOveruse do
use Mix.Task
use Plausible.Repo
require Logger
@doc """
This is scheduled to run every 6 hours.
"""
def run(args) do
Application.ensure_all_started(:plausible)
Logger.configure(level: :error)
execute(args)
end
def execute(_args \\ []) do
active_users = Repo.all(
from u in Plausible.Auth.User,
join: s in Plausible.Billing.Subscription, on: s.user_id == u.id,
where: s.status == "active",
select: {u, s}
)
for {user, subscription} <- active_users do
IO.puts("Checking #{user.email}...")
usage = Plausible.Billing.usage(user)
allowance = Plausible.Billing.Plans.allowance(subscription)
if usage > allowance do
IO.puts("Overuse: #{user.email}")
IO.puts("Usage: #{usage}")
IO.puts("Allowance: #{allowance}")
end
end
end
end

View File

@ -0,0 +1,48 @@
defmodule Mix.Tasks.SendFeedbackEmails do
use Mix.Task
use Plausible.Repo
require Logger
@doc """
This is scheduled to run every 6 hours.
"""
def run(args) do
Application.ensure_all_started(:plausible)
execute(args)
end
def execute(args \\ []) do
q =
from(u in Plausible.Auth.User,
left_join: fe in "feedback_emails", on: fe.user_id == u.id,
where: is_nil(fe.id),
where: u.inserted_at < fragment("(now() at time zone 'utc') - '30 days'::interval"),
where: u.last_seen > fragment("now() at time zone 'utc' - '7 days'::interval")
)
for user <- Repo.all(q) do
if Plausible.Auth.user_completed_setup?(user) do
send_feedback_email(args, user)
end
end
end
defp send_feedback_email(["--dry-run"], user) do
Logger.info("DRY RUN: feedback survey email to #{user.name}")
end
defp send_feedback_email(_, user) do
PlausibleWeb.Email.feedback_survey_email(user)
|> Plausible.Mailer.deliver_now()
feedback_email_sent(user)
end
defp feedback_email_sent(user) do
Repo.insert_all("feedback_emails", [%{
user_id: user.id,
timestamp: NaiveDateTime.utc_now()
}])
end
end

View File

@ -0,0 +1,64 @@
defmodule Mix.Tasks.SendIntroEmails do
use Mix.Task
use Plausible.Repo
require Logger
@doc """
This is scheduled to run every 6 hours.
"""
def run(args) do
Application.ensure_all_started(:plausible)
execute(args)
end
def execute(args \\ []) do
q =
from(u in Plausible.Auth.User,
left_join: ie in "intro_emails", on: ie.user_id == u.id,
where: is_nil(ie.id),
where:
u.inserted_at > fragment("(now() at time zone 'utc') - '24 hours'::interval") and
u.inserted_at < fragment("(now() at time zone 'utc') - '6 hours'::interval")
)
for user <- Repo.all(q) do
if Plausible.Auth.user_completed_setup?(user) do
Logger.info("#{user.name} has completed the setup. Sending welcome email.")
send_welcome_email(args, user)
else
Logger.info("#{user.name} has not completed the setup. Sending help email.")
send_help_email(args, user)
end
end
end
defp send_welcome_email(["--dry-run"], user) do
Logger.info("DRY RUN: welcome email to #{user.name}")
end
defp send_welcome_email(_, user) do
PlausibleWeb.Email.welcome_email(user)
|> Plausible.Mailer.deliver_now()
intro_email_sent(user)
end
defp send_help_email(["--dry-run"], user) do
Logger.info("DRY RUN: help email to #{user.name}")
end
defp send_help_email(_, user) do
PlausibleWeb.Email.help_email(user)
|> Plausible.Mailer.deliver_now()
intro_email_sent(user)
end
defp intro_email_sent(user) do
Repo.insert_all("intro_emails", [%{
user_id: user.id,
timestamp: NaiveDateTime.utc_now()
}])
end
end

View File

@ -0,0 +1,124 @@
defmodule Mix.Tasks.SendTrialNotifications do
use Mix.Task
use Plausible.Repo
require Logger
@doc """
This is scheduled to run every day.
"""
def run(args) do
Application.ensure_all_started(:plausible)
execute(args)
end
def execute(args \\ []) do
base_query =
from(u in Plausible.Auth.User,
left_join: s in Plausible.Billing.Subscription, on: s.user_id == u.id,
where: is_nil(s.id),
order_by: u.inserted_at
)
users = Repo.all(base_query)
for user <- users do
case Timex.diff(Plausible.Billing.trial_end_date(user), Timex.today(), :days) do
14 ->
if Plausible.Auth.user_completed_setup?(user) do
send_two_week_reminder(args, user)
end
1 ->
if Plausible.Auth.user_completed_setup?(user) do
send_tomorrow_reminder(args, user)
end
0 ->
if Plausible.Auth.user_completed_setup?(user) do
send_today_reminder(args, user)
end
-1 ->
if Plausible.Auth.user_completed_setup?(user) do
send_over_reminder(args, user)
end
_ ->
nil
end
end
#two_weeks_left = from(
# u in base_query,
# where: type(u.inserted_at, :date) == fragment("now()::date - '14 days'::interval")
#)
#tomorrow = from(
# u in base_query,
# where: type(u.inserted_at, :date) == fragment("now()::date - '29 days'::interval")
#)
#today = from(
# u in base_query,
# where: type(u.inserted_at, :date) == fragment("now()::date - '30 days'::interval")
#)
#yesterday = from(
# u in base_query,
# where: type(u.inserted_at, :date) == fragment("now()::date - '31 days'::interval")
#)
#for user <- Repo.all(two_weeks_left) do
# if Plausible.Auth.user_completed_setup?(user), do: send_two_week_reminder(args, user)
#end
#for user <- Repo.all(tomorrow) do
# if Plausible.Auth.user_completed_setup?(user), do: send_tomorrow_reminder(args, user)
#end
#for user <- Repo.all(today) do
# if Plausible.Auth.user_completed_setup?(user), do: send_today_reminder(args, user)
#end
#for user <- Repo.all(yesterday) do
# if Plausible.Auth.user_completed_setup?(user), do: send_over_reminder(args, user)
#end
end
defp send_two_week_reminder(["--dry-run"], user) do
Logger.info("DRY RUN: 2-week trial notification email to #{user.name} [inserted=#{user.inserted_at}]")
end
defp send_two_week_reminder(_, user) do
PlausibleWeb.Email.trial_two_week_reminder(user)
|> Plausible.Mailer.deliver_now()
end
defp send_tomorrow_reminder(["--dry-run"], user) do
Logger.info("DRY RUN: tomorrow trial upgrade email to #{user.name} [inserted=#{user.inserted_at}]")
end
defp send_tomorrow_reminder(_, user) do
usage = Plausible.Billing.usage(user)
PlausibleWeb.Email.trial_upgrade_email(user, "tomorrow", usage)
|> Plausible.Mailer.deliver_now()
end
defp send_today_reminder(["--dry-run"], user) do
Logger.info("DRY RUN: today trial upgrade email to #{user.name} [inserted=#{user.inserted_at}]")
end
defp send_today_reminder(_, user) do
usage = Plausible.Billing.usage(user)
PlausibleWeb.Email.trial_upgrade_email(user, "today", usage)
|> Plausible.Mailer.deliver_now()
end
defp send_over_reminder(["--dry-run"], user) do
Logger.info("DRY RUN: over trial notification email to #{user.name} [inserted=#{user.inserted_at}]")
end
defp send_over_reminder(_, user) do
PlausibleWeb.Email.trial_over_email(user)
|> Plausible.Mailer.deliver_now()
end
end

9
lib/plausible.ex Normal file
View File

@ -0,0 +1,9 @@
defmodule Plausible do
@moduledoc """
Plausible keeps the contexts that define your domain
and business logic.
Contexts are also responsible for managing your data, regardless
if it comes from the database, an external API or others.
"""
end

View File

@ -0,0 +1,27 @@
defmodule Plausible.Application do
@moduledoc false
use Application
def start(_type, _args) do
children = [
Plausible.Repo,
PlausibleWeb.Endpoint
]
opts = [strategy: :one_for_one, name: Plausible.Supervisor]
{:ok, _} = Logger.add_backend(Sentry.LoggerBackend)
:telemetry.attach(
"appsignal-ecto",
[:plausible, :repo, :query],
&Appsignal.Ecto.handle_event/4,
nil
)
Supervisor.start_link(children, opts)
end
def config_change(changed, _new, removed) do
PlausibleWeb.Endpoint.config_change(changed, removed)
:ok
end
end

View File

@ -0,0 +1,30 @@
defmodule Plausible.Auth do
use Plausible.Repo
alias Plausible.Auth
def create_user(name, email) do
%Auth.User{}
|> Auth.User.changeset(%{name: name, email: email})
|> Repo.insert
end
def find_user_by(opts) do
Repo.get_by(Auth.User, opts)
end
def user_completed_setup?(user) do
query =
from(
p in Plausible.Pageview,
join: s in Plausible.Site,
on: s.domain == p.hostname,
join: sm in Plausible.Site.Membership,
on: sm.site_id == s.id,
join: u in Plausible.Auth.User,
on: sm.user_id == u.id,
where: u.id == ^user.id
)
Repo.exists?(query)
end
end

View File

@ -0,0 +1,13 @@
defmodule Plausible.Auth.Password do
def hash(password) do
Bcrypt.hash_pwd_salt(password)
end
def match?(password, hash) do
Bcrypt.verify_pass(password, hash)
end
def dummy_calculation do
Bcrypt.no_user_verify()
end
end

View File

@ -0,0 +1,23 @@
defmodule Plausible.Auth.Token do
@one_day_in_seconds 30 * 60 * 24
@one_hour_in_seconds 30 * 60
def sign_password_reset(email) do
Phoenix.Token.sign(PlausibleWeb.Endpoint, "password-reset", %{email: email})
end
def verify_password_reset(token) do
Phoenix.Token.verify(PlausibleWeb.Endpoint, "password-reset", token, max_age: @one_hour_in_seconds)
end
def sign_activation(name, email) do
Phoenix.Token.sign(PlausibleWeb.Endpoint, "activation", %{
name: name,
email: email
})
end
def verify_activation(token) do
Phoenix.Token.verify(PlausibleWeb.Endpoint, "activation", token, max_age: @one_day_in_seconds)
end
end

View File

@ -0,0 +1,41 @@
defimpl Bamboo.Formatter, for: Plausible.Auth.User do
def format_email_address(user, _opts) do
{user.name, user.email}
end
end
defmodule Plausible.Auth.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :password_hash
field :password, :string, virtual: true
field :name, :string
field :last_seen, :naive_datetime
has_many :site_memberships, Plausible.Site.Membership
has_many :sites, through: [:site_memberships, :site]
has_one :google_auth, Plausible.Site.GoogleAuth
timestamps()
end
def changeset(user, attrs \\ %{}) do
user
|> cast(attrs, [:email, :name])
|> validate_required([:email, :name])
|> unique_constraint(:email)
end
def set_password(user, password) do
hash = Plausible.Auth.Password.hash(password)
user
|> cast(%{password: password}, [:password])
|> validate_required(:password)
|> validate_length(:password, min: 6)
|> cast(%{password_hash: hash}, [:password_hash])
end
end

View File

@ -0,0 +1,133 @@
defmodule Plausible.Billing do
use Plausible.Repo
alias Plausible.Billing.{Subscription, Plans, PaddleApi}
@paddle_api Application.fetch_env!(:plausible, :paddle_api)
def active_subscription_for(user_id) do
Repo.get_by(Subscription, user_id: user_id, status: "active")
end
def subscription_created(params) do
changeset = Subscription.changeset(%Subscription{}, format_subscription(params))
Repo.insert(changeset)
end
def subscription_updated(params) do
subscription = Repo.get_by!(Subscription, paddle_subscription_id: params["subscription_id"])
changeset = Subscription.changeset(subscription, format_subscription(params))
Repo.update(changeset)
end
def subscription_cancelled(params) do
subscription = Repo.get_by(Subscription, paddle_subscription_id: params["subscription_id"])
if subscription do
changeset = Subscription.changeset(subscription, %{
status: params["status"]
})
Repo.update(changeset)
else
{:ok, nil}
end
end
def subscription_payment_succeeded(params) do
subscription = Repo.get_by(Subscription, paddle_subscription_id: params["subscription_id"])
if subscription do
{:ok, api_subscription} = @paddle_api.get_subscription(subscription.paddle_subscription_id)
amount = :erlang.float_to_binary(api_subscription["next_payment"]["amount"] / 1, decimals: 2)
changeset = Subscription.changeset(subscription, %{
next_bill_amount: amount,
next_bill_date: api_subscription["next_payment"]["date"]
})
Repo.update(changeset)
else
{:ok, nil}
end
end
def change_plan(user, new_plan) do
subscription = active_subscription_for(user.id)
res = PaddleApi.update_subscription(subscription.paddle_subscription_id, %{
plan_id: Plans.paddle_id_for_plan(new_plan)
})
case res do
{:ok, response} ->
Subscription.changeset(subscription, %{
paddle_plan_id: Integer.to_string(response["plan_id"])
}) |> Repo.update
e -> e
end
end
def needs_to_upgrade?(user) do
if Timex.before?(trial_end_date(user), Timex.today()) do
!active_subscription_for(user.id)
else
false
end
end
def coupon_for(user) do
if was_beta_user(user) do
"8FE5AF26"
end
end
def was_beta_user(user) do
Timex.before?(user.inserted_at, ~D[2019-04-25])
end
def trial_days_left(user) do
if Timex.before?(user.inserted_at, ~D[2019-04-24]) do
Timex.diff(~D[2019-05-25], Timex.today, :days) + 1
else
30 - Timex.diff(Timex.today, user.inserted_at, :days)
end
end
def trial_end_date(user) do
if Timex.before?(user.inserted_at, ~D[2019-04-25]) do
~D[2019-05-25]
else
Timex.shift(user.inserted_at, days: 30) |> NaiveDateTime.to_date
end
end
def usage(user) do
user = Repo.preload(user, :sites)
Enum.reduce(user.sites, 0, fn site, total ->
total + site_usage(site)
end)
end
defp site_usage(site) do
Repo.aggregate(from(
p in Plausible.Pageview,
where: p.hostname == ^site.domain,
where: p.inserted_at >= fragment("now() - '30 days'::interval")
), :count, :id
)
end
defp format_subscription(params) do
%{
paddle_subscription_id: params["subscription_id"],
paddle_plan_id: params["subscription_plan_id"],
cancel_url: params["cancel_url"],
update_url: params["update_url"],
user_id: params["passthrough"],
status: params["status"],
next_bill_date: params["next_bill_date"],
next_bill_amount: params["unit_price"] || params["new_unit_price"]
}
end
end

View File

@ -0,0 +1,46 @@
defmodule Plausible.Billing.PaddleApi do
@update_endpoint "https://vendors.paddle.com/api/2.0/subscription/users/update"
@get_endpoint "https://vendors.paddle.com/api/2.0/subscription/users"
@vendor_id "49430"
@vendor_auth_code "00e75d18fde1457171b73723ecf54c4026f9b0047e70b6dbff"
@headers [
{"Content-type", "application/json"},
{"Accept", "application/json"}
]
def update_subscription(paddle_subscription_id, params) do
params = Map.merge(params, %{
vendor_id: @vendor_id,
vendor_auth_code: @vendor_auth_code,
subscription_id: paddle_subscription_id,
quantity: 1
})
{:ok, response} = HTTPoison.post(@update_endpoint, Poison.encode!(params), @headers)
body = Poison.decode!(response.body)
if body["success"] do
{:ok, body["response"]}
else
{:error, body["error"]}
end
end
def get_subscription(paddle_subscription_id) do
params = %{
vendor_id: @vendor_id,
vendor_auth_code: @vendor_auth_code,
subscription_id: paddle_subscription_id
}
{:ok, response} = HTTPoison.post(@get_endpoint, Poison.encode!(params), @headers)
body = Poison.decode!(response.body)
if body["success"] do
[subscription] = body["response"]
{:ok, subscription}
else
{:error, body["error"]}
end
end
end

View File

@ -0,0 +1,40 @@
defmodule Plausible.Billing.Plans do
@app_env System.get_env("APP_ENV") || "dev"
@real_plans %{
personal: "558018",
startup: "558745",
business: "558746"
}
@test_plans %{
personal: "558156",
startup: "558199",
business: "558200"
}
def paddle_id_for_plan(plan) do
if @app_env == "prod" do
@real_plans[plan]
else
@test_plans[plan]
end
end
def is?(subscription, plan) do
paddle_id_for_plan(plan) == subscription.paddle_plan_id
end
def allowance(subscription) do
cond do
is?(subscription, :personal) ->
10_000
is?(subscription, :startup) ->
100_000
is?(subscription, :business) ->
1_000_000
true ->
raise "Subscription not found for #{subscription.paddle_plan_id}"
end
end
end

View File

@ -0,0 +1,29 @@
defmodule Plausible.Billing.Subscription do
use Ecto.Schema
import Ecto.Changeset
@required_fields [:paddle_subscription_id, :paddle_plan_id, :update_url, :cancel_url, :status, :next_bill_amount, :next_bill_date, :user_id]
@valid_statuses ["active", "past_due", "deleted"]
schema "subscriptions" do
field :paddle_subscription_id, :string
field :paddle_plan_id, :string
field :update_url, :string
field :cancel_url, :string
field :status, :string
field :next_bill_amount, :string
field :next_bill_date, :date
belongs_to :user, Plausible.Auth.User
timestamps()
end
def changeset(model, attrs \\ %{}) do
model
|> cast(attrs, @required_fields)
|> validate_required(@required_fields)
|> validate_inclusion(:status, @valid_statuses)
|> unique_constraint(:paddle_subscription_id)
end
end

View File

@ -0,0 +1,92 @@
defmodule Plausible.Google.Api do
@scope URI.encode_www_form("https://www.googleapis.com/auth/webmasters.readonly email")
def authorize_url(site_id) do
if Mix.env() == :test do
""
else
"https://accounts.google.com/o/oauth2/v2/auth?client_id=#{client_id()}&redirect_uri=#{redirect_uri()}&prompt=consent&response_type=code&access_type=offline&scope=#{@scope}&state=#{site_id}"
end
end
def fetch_access_token(code) do
res = HTTPoison.post!("https://www.googleapis.com/oauth2/v4/token", "client_id=#{client_id()}&client_secret=#{client_secret()}&code=#{code}&grant_type=authorization_code&redirect_uri=#{redirect_uri()}", ["Content-Type": "application/x-www-form-urlencoded"])
Jason.decode!(res.body)
end
def fetch_site(domain, auth) do
auth = if Timex.before?(auth.expires, Timex.now() |> Timex.shift(seconds: 5)) do
refresh_token(auth)
else
auth
end
with_https = URI.encode_www_form("https://#{domain}")
res = HTTPoison.get!("https://www.googleapis.com/webmasters/v3/sites/#{with_https}",["Content-Type": "application/json", "Authorization": "Bearer #{auth.access_token}"])
Jason.decode!(res.body)
end
def fetch_stats(site, auth, query) do
if Timex.before?(auth.expires, Timex.now() |> Timex.shift(seconds: 5)) do
auth = refresh_token(auth)
fetch_queries(site.domain, auth, query)
else
fetch_queries(site.domain, auth, query)
end
end
defp fetch_queries(domain, auth, query) do
with_https = URI.encode_www_form("https://#{domain}")
res = HTTPoison.post!("https://www.googleapis.com/webmasters/v3/sites/#{with_https}/searchAnalytics/query", Jason.encode!(%{
startDate: Date.to_iso8601(query.date_range.first),
endDate: Date.to_iso8601(query.date_range.last),
dimensions: ["query"],
rowLimit: 20
}), ["Content-Type": "application/json", "Authorization": "Bearer #{auth.access_token}"])
case res.status_code do
200 ->
terms = Jason.decode!(res.body)["rows"]
|> Enum.filter(fn row -> row["clicks"] > 0 end)
|> Enum.map(fn row -> {row["keys"], round(row["clicks"])} end)
{:ok, terms}
401 ->
Sentry.capture_message("Error fetching Google queries", extra: Jason.decode!(res.body))
{:error, :invalid_credentials}
403 ->
Sentry.capture_message("Error fetching Google queries", extra: Jason.decode!(res.body))
msg = Jason.decode!(res.body)["error"]["message"]
{:error, msg}
_ ->
Sentry.capture_message("Error fetching Google queries", extra: Jason.decode!(res.body))
{:error, :unknown}
end
end
defp refresh_token(auth) do
res = HTTPoison.post!("https://www.googleapis.com/oauth2/v4/token", "client_id=#{client_id()}&client_secret=#{client_secret()}&refresh_token=#{auth.refresh_token}&grant_type=refresh_token&redirect_uri=#{redirect_uri()}", ["Content-Type": "application/x-www-form-urlencoded"])
body = Jason.decode!(res.body)
Plausible.Site.GoogleAuth.changeset(auth, %{
access_token: body["access_token"],
expires: NaiveDateTime.utc_now() |> NaiveDateTime.add(body["expires_in"]),
}) |> Plausible.Repo.update!
end
defp client_id() do
Keyword.fetch!(Application.get_env(:plausible, :google), :client_id)
end
defp client_secret() do
Keyword.fetch!(Application.get_env(:plausible, :google), :client_secret)
end
defp redirect_uri() do
PlausibleWeb.Endpoint.clean_url() <> "/auth/google/callback"
end
end

3
lib/plausible/mailer.ex Normal file
View File

@ -0,0 +1,3 @@
defmodule Plausible.Mailer do
use Bamboo.Mailer, otp_app: :plausible
end

View File

@ -0,0 +1,29 @@
defmodule Plausible.Pageview do
use Ecto.Schema
import Ecto.Changeset
schema "pageviews" do
field :hostname, :string
field :pathname, :string
field :referrer, :string
field :raw_referrer, :string
field :user_agent, :string
field :screen_width, :integer
field :screen_size, :string
field :new_visitor, :boolean
field :user_id, :binary_id
field :country_code, :string
field :operating_system, :string
field :browser, :string
field :referrer_source, :string
timestamps()
end
def changeset(pageview, attrs) do
pageview
|> cast(attrs, [:hostname, :pathname, :referrer, :raw_referrer, :user_agent, :new_visitor, :screen_width, :user_id, :operating_system, :browser, :referrer_source, :country_code, :screen_size])
|> validate_required([:hostname, :pathname, :new_visitor, :user_id])
end
end

13
lib/plausible/repo.ex Normal file
View File

@ -0,0 +1,13 @@
defmodule Plausible.Repo do
use Ecto.Repo,
otp_app: :plausible,
adapter: Ecto.Adapters.Postgres
defmacro __using__(_) do
quote do
alias Plausible.Repo
import Ecto
import Ecto.Query, only: [from: 1, from: 2]
end
end
end

View File

@ -0,0 +1,23 @@
defmodule Plausible.Site.GoogleAuth do
use Ecto.Schema
import Ecto.Changeset
schema "google_auth" do
field :email, :string
field :refresh_token, :string
field :access_token, :string
field :expires, :naive_datetime
belongs_to :user, Plausible.Auth.User
belongs_to :site, Plausible.Site
timestamps()
end
def changeset(auth, attrs \\ %{}) do
auth
|> cast(attrs, [:refresh_token, :access_token, :expires, :email, :user_id, :site_id])
|> validate_required([:refresh_token, :access_token, :expires, :email, :user_id, :site_id])
|> unique_constraint(:site)
end
end

View File

@ -0,0 +1,17 @@
defmodule Plausible.Site.Membership do
use Ecto.Schema
import Ecto.Changeset
schema "site_memberships" do
belongs_to :site, Plausible.Site
belongs_to :user, Plausible.Auth.User
timestamps()
end
def changeset(user, attrs) do
user
|> cast(attrs, [:user_id, :site_id])
|> validate_required([:user_id, :site_id])
end
end

View File

@ -0,0 +1,47 @@
defmodule Plausible.Site do
use Ecto.Schema
import Ecto.Changeset
alias Plausible.Auth.User
alias Plausible.Site.GoogleAuth
schema "sites" do
field :domain, :string
field :timezone, :string
field :public, :boolean
many_to_many :members, User, join_through: Plausible.Site.Membership
has_one :google_auth, GoogleAuth
timestamps()
end
def changeset(site, attrs \\ %{}) do
site
|> cast(attrs, [:domain, :timezone])
|> validate_required([:domain, :timezone])
|> unique_constraint(:domain)
|> clean_domain
end
def make_public(site) do
change(site, public: true)
end
def make_private(site) do
change(site, public: false)
end
defp clean_domain(changeset) do
clean_domain = (get_field(changeset, :domain) || "")
|> String.trim
|> String.replace_leading("http://", "")
|> String.replace_leading("https://", "")
|> String.replace_leading("www.", "")
|> String.replace_trailing("/", "")
|> String.downcase()
change(changeset, %{
domain: clean_domain
})
end
end

27
lib/plausible/sites.ex Normal file
View File

@ -0,0 +1,27 @@
defmodule Plausible.Sites do
use Plausible.Repo
def get_for_user!(user_id, domain) do
Repo.one!(
from s in Plausible.Site,
join: sm in Plausible.Site.Membership, on: sm.site_id == s.id,
where: sm.user_id == ^user_id,
where: s.domain == ^domain,
select: s
)
end
def has_pageviews?(site) do
Repo.exists?(
from p in Plausible.Pageview,
where: p.hostname == ^site.domain
)
end
def is_owner?(user_id, site) do
Repo.exists?(
from sm in Plausible.Site.Membership,
where: sm.user_id == ^user_id and sm.site_id == ^site.id
)
end
end

15
lib/plausible/slack.ex Normal file
View File

@ -0,0 +1,15 @@
defmodule Plausible.Slack do
@app_env System.get_env("APP_ENV") || "dev"
@feed_channel_url "https://hooks.slack.com/services/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"
def notify(text) do
Task.start(fn ->
case @app_env do
"prod" ->
HTTPoison.post!(@feed_channel_url, Poison.encode!(%{text: text}))
_ ->
nil
end
end)
end
end

View File

@ -0,0 +1,253 @@
defmodule Plausible.Stats.CountryName do
@country_codes_to_names %{
"AF" => "Afghanistan",
"AX" => "Aland Islands",
"AL" => "Albania",
"DZ" => "Algeria",
"AS" => "American Samoa",
"AD" => "Andorra",
"AO" => "Angola",
"AI" => "Anguilla",
"AQ" => "Antarctica",
"AG" => "Antigua And Barbuda",
"AR" => "Argentina",
"AM" => "Armenia",
"AW" => "Aruba",
"AU" => "Australia",
"AT" => "Austria",
"AZ" => "Azerbaijan",
"BS" => "Bahamas",
"BH" => "Bahrain",
"BD" => "Bangladesh",
"BB" => "Barbados",
"BY" => "Belarus",
"BE" => "Belgium",
"BZ" => "Belize",
"BJ" => "Benin",
"BM" => "Bermuda",
"BT" => "Bhutan",
"BO" => "Bolivia",
"BA" => "Bosnia And Herzegovina",
"BW" => "Botswana",
"BV" => "Bouvet Island",
"BR" => "Brazil",
"IO" => "British Indian Ocean Territory",
"BN" => "Brunei Darussalam",
"BG" => "Bulgaria",
"BF" => "Burkina Faso",
"BI" => "Burundi",
"KH" => "Cambodia",
"CM" => "Cameroon",
"CA" => "Canada",
"CV" => "Cape Verde",
"KY" => "Cayman Islands",
"CF" => "Central African Republic",
"TD" => "Chad",
"CL" => "Chile",
"CN" => "China",
"CX" => "Christmas Island",
"CC" => "Cocos (Keeling) Islands",
"CO" => "Colombia",
"KM" => "Comoros",
"CG" => "Congo",
"CD" => "Congo, Democratic Republic",
"CK" => "Cook Islands",
"CR" => "Costa Rica",
"CI" => "Cote D\"Ivoire",
"HR" => "Croatia",
"CU" => "Cuba",
"CY" => "Cyprus",
"CZ" => "Czech Republic",
"DK" => "Denmark",
"DJ" => "Djibouti",
"DM" => "Dominica",
"DO" => "Dominican Republic",
"EC" => "Ecuador",
"EG" => "Egypt",
"SV" => "El Salvador",
"GQ" => "Equatorial Guinea",
"ER" => "Eritrea",
"EE" => "Estonia",
"ET" => "Ethiopia",
"FK" => "Falkland Islands (Malvinas)",
"FO" => "Faroe Islands",
"FJ" => "Fiji",
"FI" => "Finland",
"FR" => "France",
"GF" => "French Guiana",
"PF" => "French Polynesia",
"TF" => "French Southern Territories",
"GA" => "Gabon",
"GM" => "Gambia",
"GE" => "Georgia",
"DE" => "Germany",
"GH" => "Ghana",
"GI" => "Gibraltar",
"GR" => "Greece",
"GL" => "Greenland",
"GD" => "Grenada",
"GP" => "Guadeloupe",
"GU" => "Guam",
"GT" => "Guatemala",
"GG" => "Guernsey",
"GN" => "Guinea",
"GW" => "Guinea-Bissau",
"GY" => "Guyana",
"HT" => "Haiti",
"HM" => "Heard Island & Mcdonald Islands",
"VA" => "Holy See (Vatican City State)",
"HN" => "Honduras",
"HK" => "Hong Kong",
"HU" => "Hungary",
"IS" => "Iceland",
"IN" => "India",
"ID" => "Indonesia",
"IR" => "Iran, Islamic Republic Of",
"IQ" => "Iraq",
"IE" => "Ireland",
"IM" => "Isle Of Man",
"IL" => "Israel",
"IT" => "Italy",
"JM" => "Jamaica",
"JP" => "Japan",
"JE" => "Jersey",
"JO" => "Jordan",
"KZ" => "Kazakhstan",
"KE" => "Kenya",
"KI" => "Kiribati",
"KR" => "Korea",
"KW" => "Kuwait",
"KG" => "Kyrgyzstan",
"LA" => "Lao People\"s Democratic Republic",
"LV" => "Latvia",
"LB" => "Lebanon",
"LS" => "Lesotho",
"LR" => "Liberia",
"LY" => "Libyan Arab Jamahiriya",
"LI" => "Liechtenstein",
"LT" => "Lithuania",
"LU" => "Luxembourg",
"MO" => "Macao",
"MK" => "Macedonia",
"MG" => "Madagascar",
"MW" => "Malawi",
"MY" => "Malaysia",
"MV" => "Maldives",
"ML" => "Mali",
"MT" => "Malta",
"MH" => "Marshall Islands",
"MQ" => "Martinique",
"MR" => "Mauritania",
"MU" => "Mauritius",
"YT" => "Mayotte",
"MX" => "Mexico",
"FM" => "Micronesia, Federated States Of",
"MD" => "Moldova",
"MC" => "Monaco",
"MN" => "Mongolia",
"ME" => "Montenegro",
"MS" => "Montserrat",
"MA" => "Morocco",
"MZ" => "Mozambique",
"MM" => "Myanmar",
"NA" => "Namibia",
"NR" => "Nauru",
"NP" => "Nepal",
"NL" => "Netherlands",
"AN" => "Netherlands Antilles",
"NC" => "New Caledonia",
"NZ" => "New Zealand",
"NI" => "Nicaragua",
"NE" => "Niger",
"NG" => "Nigeria",
"NU" => "Niue",
"NF" => "Norfolk Island",
"MP" => "Northern Mariana Islands",
"NO" => "Norway",
"OM" => "Oman",
"PK" => "Pakistan",
"PW" => "Palau",
"PS" => "Palestinian Territory, Occupied",
"PA" => "Panama",
"PG" => "Papua New Guinea",
"PY" => "Paraguay",
"PE" => "Peru",
"PH" => "Philippines",
"PN" => "Pitcairn",
"PL" => "Poland",
"PT" => "Portugal",
"PR" => "Puerto Rico",
"QA" => "Qatar",
"RE" => "Reunion",
"RO" => "Romania",
"RU" => "Russian Federation",
"RW" => "Rwanda",
"BL" => "Saint Barthelemy",
"SH" => "Saint Helena",
"KN" => "Saint Kitts And Nevis",
"LC" => "Saint Lucia",
"MF" => "Saint Martin",
"PM" => "Saint Pierre And Miquelon",
"VC" => "Saint Vincent And Grenadines",
"WS" => "Samoa",
"SM" => "San Marino",
"ST" => "Sao Tome And Principe",
"SA" => "Saudi Arabia",
"SN" => "Senegal",
"RS" => "Serbia",
"SC" => "Seychelles",
"SL" => "Sierra Leone",
"SG" => "Singapore",
"SK" => "Slovakia",
"SI" => "Slovenia",
"SB" => "Solomon Islands",
"SO" => "Somalia",
"ZA" => "South Africa",
"GS" => "South Georgia And Sandwich Isl.",
"ES" => "Spain",
"LK" => "Sri Lanka",
"SD" => "Sudan",
"SR" => "Suriname",
"SJ" => "Svalbard And Jan Mayen",
"SZ" => "Swaziland",
"SE" => "Sweden",
"CH" => "Switzerland",
"SY" => "Syrian Arab Republic",
"TW" => "Taiwan",
"TJ" => "Tajikistan",
"TZ" => "Tanzania",
"TH" => "Thailand",
"TL" => "Timor-Leste",
"TG" => "Togo",
"TK" => "Tokelau",
"TO" => "Tonga",
"TT" => "Trinidad And Tobago",
"TN" => "Tunisia",
"TR" => "Turkey",
"TM" => "Turkmenistan",
"TC" => "Turks And Caicos Islands",
"TV" => "Tuvalu",
"UG" => "Uganda",
"UA" => "Ukraine",
"AE" => "United Arab Emirates",
"GB" => "United Kingdom",
"US" => "United States",
"UM" => "United States Outlying Islands",
"UY" => "Uruguay",
"UZ" => "Uzbekistan",
"VU" => "Vanuatu",
"VE" => "Venezuela",
"VN" => "Viet Nam",
"VG" => "Virgin Islands, British",
"VI" => "Virgin Islands, U.S.",
"WF" => "Wallis And Futuna",
"EH" => "Western Sahara",
"YE" => "Yemen",
"ZM" => "Zambia",
"ZW" => "Zimbabwe"
}
def from_iso3166(code) do
Map.get(@country_codes_to_names, code, code)
end
end

View File

@ -0,0 +1,112 @@
defmodule Plausible.Stats.Query do
defstruct [date_range: nil, step_type: nil, period: nil, steps: nil]
def new(attrs) do
attrs
|> Enum.into(%{})
|> Map.put(:__struct__, __MODULE__)
end
def month(date) do
%__MODULE__{
date_range: Date.range(Timex.beginning_of_month(date), Timex.end_of_month(date))
}
end
def day(date) do
%__MODULE__{
date_range: Date.range(date, date)
}
end
def shift_back(%__MODULE__{period: "day"} = query) do
new_date = query.date_range.first |> Timex.shift(days: -1)
Map.put(query, :date_range, Date.range(new_date, new_date))
end
def shift_back(query) do
diff = Timex.diff(query.date_range.first, query.date_range.last, :days)
new_first = query.date_range.first |> Timex.shift(days: diff)
new_last = query.date_range.last |> Timex.shift(days: diff)
Map.put(query, :date_range, Date.range(new_first, new_last))
end
def from(_tz, %{"period" => "day", "date" => date}) do
date = Date.from_iso8601!(date)
%__MODULE__{
period: "day",
date_range: Date.range(date, date),
step_type: "hour"
}
end
def from(_tz, %{"period" => "month", "date" => month_start}) do
start_date = Date.from_iso8601!(month_start) |> Timex.beginning_of_month
end_date = Timex.end_of_month(start_date)
%__MODULE__{
period: "month",
date_range: Date.range(start_date, end_date),
step_type: "date",
steps: Timex.diff(start_date, end_date, :days)
}
end
def from(tz, %{"period" => "month"}) do
start_date = today(tz) |> Timex.beginning_of_month
end_date = Timex.end_of_month(start_date)
%__MODULE__{
period: "month",
date_range: Date.range(start_date, end_date),
step_type: "date",
steps: Timex.diff(start_date, end_date, :days)
}
end
def from(tz, %{"period" => "3mo"}) do
start_date = Timex.shift(today(tz), months: -2)
|> Timex.beginning_of_month()
%__MODULE__{
period: "3mo",
date_range: Date.range(start_date, today(tz)),
step_type: "month",
steps: 3
}
end
def from(tz, %{"period" => "6mo"}) do
start_date = Timex.shift(today(tz), months: -5)
|> Timex.beginning_of_month()
%__MODULE__{
period: "6mo",
date_range: Date.range(start_date, today(tz)),
step_type: "month",
steps: 6
}
end
def from(_tz, %{"period" => "custom", "from" => from, "to" => to}) do
start_date = Date.from_iso8601!(from)
end_date = Date.from_iso8601!(to)
date_range = Date.range(start_date, end_date)
%__MODULE__{
period: "custom",
date_range: date_range,
step_type: "date"
}
end
def from(tz, _) do
__MODULE__.from(tz, %{"period" => "6mo"})
end
defp today(tz) do
Timex.now(tz) |> Timex.to_date
end
end

View File

@ -0,0 +1,207 @@
defmodule Plausible.Stats do
use Plausible.Repo
alias Plausible.Stats.Query
def compare_pageviews_and_visitors(site, query, {pageviews, visitors}) do
query = Query.shift_back(query)
{old_pageviews, old_visitors} = pageviews_and_visitors(site, query)
if old_visitors > 0 do
{
round((pageviews - old_pageviews) / old_pageviews * 100),
round((visitors - old_visitors) / old_visitors * 100),
}
else
{nil, nil}
end
end
def calculate_plot(site, %Query{step_type: "month"} = query) do
steps = Enum.map((query.steps - 1)..0, fn shift ->
Timex.now(site.timezone)
|> Timex.beginning_of_month
|> Timex.shift(months: -shift)
|> DateTime.to_date
end)
groups = Repo.all(
from p in base_query(site, query),
group_by: 1,
order_by: 1,
select: {fragment("date_trunc('month', ? at time zone 'utc' at time zone ?)", p.inserted_at, ^site.timezone), count(p.user_id, :distinct)}
) |> Enum.into(%{})
|> transform_keys(fn dt -> NaiveDateTime.to_date(dt) end)
plot = Enum.map(steps, fn step -> groups[step] || 0 end)
labels = Enum.map(steps, fn step -> Timex.format!(step, "{ISOdate}") end)
present_index = Enum.find_index(steps, fn step -> step == Timex.now(site.timezone) |> Timex.to_date |> Timex.beginning_of_month end)
{plot, labels, present_index}
end
def calculate_plot(site, %Query{step_type: "date"} = query) do
steps = Enum.into(query.date_range, [])
groups = Repo.all(
from p in base_query(site, query),
group_by: 1,
order_by: 1,
select: {fragment("date_trunc('day', ? at time zone 'utc' at time zone ?)", p.inserted_at, ^site.timezone), count(p.user_id, :distinct)}
) |> Enum.into(%{})
|> transform_keys(fn dt -> NaiveDateTime.to_date(dt) end)
plot = Enum.map(steps, fn step -> groups[step] || 0 end)
labels = Enum.map(steps, fn step -> Timex.format!(step, "{ISOdate}") end)
present_index = Enum.find_index(steps, fn step -> step == Timex.now(site.timezone) |> Timex.to_date end)
{plot, labels, present_index}
end
def calculate_plot(site, %Query{step_type: "hour"} = query) do
{:ok, beginning_of_day} = NaiveDateTime.new(query.date_range.first, ~T[00:00:00])
steps = Enum.map(0..23, fn shift ->
beginning_of_day
|> Timex.shift(hours: shift)
|> truncate_to_hour
|> NaiveDateTime.truncate(:second)
end)
groups = Repo.all(
from p in base_query(site, query),
group_by: 1,
order_by: 1,
select: {fragment("date_trunc('hour', ? at time zone 'utc' at time zone ?)", p.inserted_at, ^site.timezone), count(p.user_id, :distinct)}
)
|> Enum.into(%{})
|> transform_keys(fn dt -> NaiveDateTime.truncate(dt, :second) end)
plot = Enum.map(steps, fn step -> groups[step] || 0 end)
labels = Enum.map(steps, fn step -> NaiveDateTime.to_iso8601(step) end)
present_index = Enum.find_index(steps, fn step -> step == Timex.now(site.timezone) |> truncate_to_hour |> NaiveDateTime.truncate(:second) end)
{plot, labels, present_index}
end
def pageviews_and_visitors(site, query) do
Repo.one(from(
p in base_query(site, query),
select: {count(p.id), count(p.user_id, :distinct)}
))
end
def total_pageviews(site, query) do
Repo.aggregate(base_query(site, query), :count, :id)
end
def unique_visitors(site, query) do
Repo.one(from(
p in base_query(site, query),
select: count(p.user_id, :distinct)
))
end
def top_referrers(site, query, limit \\ 5) do
Repo.all(from p in base_query(site, query),
select: {p.referrer_source, count(p.referrer_source)},
group_by: p.referrer_source,
where: p.new_visitor == true and not is_nil(p.referrer_source),
order_by: [desc: 2],
limit: ^limit
)
end
def visitors_from_referrer(site, query, referrer) do
Repo.one(from p in base_query(site, query),
select: count(p),
where: p.new_visitor == true and p.referrer_source == ^referrer
)
end
def referrer_drilldown(site, query, referrer) do
Repo.all(from p in base_query(site, query),
select: {p.referrer, count(p)},
group_by: p.referrer,
where: p.new_visitor == true and p.referrer_source == ^referrer,
order_by: [desc: 2],
limit: 100
)
end
def top_pages(site, query, limit \\ 5) do
Repo.all(from p in base_query(site, query),
select: {p.pathname, count(p.pathname)},
group_by: p.pathname,
order_by: [desc: count(p.pathname)],
limit: ^limit
)
end
@available_screen_sizes ["Desktop", "Laptop", "Tablet", "Mobile"]
def top_screen_sizes(site, query) do
Repo.all(from p in base_query(site, query),
select: {p.screen_size, count(p.screen_size)},
group_by: p.screen_size,
where: p.new_visitor == true and not is_nil(p.screen_size)
) |> Enum.sort(fn {screen_size1, _}, {screen_size2, _} ->
index1 = Enum.find_index(@available_screen_sizes, fn s -> s == screen_size1 end)
index2 = Enum.find_index(@available_screen_sizes, fn s -> s == screen_size2 end)
index2 > index1
end)
end
def countries(site, query, limit \\ 5) do
Repo.all(from p in base_query(site, query),
select: {p.country_code, count(p.country_code)},
group_by: p.country_code,
where: p.new_visitor == true and not is_nil(p.country_code),
order_by: [desc: count(p.country_code)],
limit: ^limit
) |> Enum.map(fn {country_code, count} ->
{Plausible.Stats.CountryName.from_iso3166(country_code), count}
end)
end
def browsers(site, query, limit \\ 5) do
Repo.all(from p in base_query(site, query),
select: {p.browser, count(p.browser)},
group_by: p.browser,
where: p.new_visitor == true and not is_nil(p.browser),
order_by: [desc: count(p.browser)],
limit: ^limit
)
end
def operating_systems(site, query, limit \\ 5) do
Repo.all(from p in base_query(site, query),
select: {p.operating_system, count(p.operating_system)},
group_by: p.operating_system,
where: p.new_visitor == true and not is_nil(p.operating_system),
order_by: [desc: count(p.operating_system)],
limit: ^limit
)
end
defp base_query(site, query) do
{:ok, first} = NaiveDateTime.new(query.date_range.first, ~T[00:00:00])
first_datetime = Timex.to_datetime(first, site.timezone)
{:ok, last} = NaiveDateTime.new(query.date_range.last |> Timex.shift(days: 1), ~T[00:00:00])
last_datetime = Timex.to_datetime(last, site.timezone)
from(p in Plausible.Pageview,
where: p.hostname == ^site.domain,
where: p.inserted_at >= ^first_datetime and p.inserted_at < ^last_datetime
)
end
defp transform_keys(map, fun) do
for {key, val} <- map, into: %{} do
{fun.(key), val}
end
end
defp truncate_to_hour(datetime) do
{:ok, datetime} = NaiveDateTime.new(datetime.year, datetime.month, datetime.day, datetime.hour, 0, 0, 0)
datetime
end
end

View File

@ -0,0 +1,92 @@
defmodule Plausible.Timezones do
@moduledoc "https://stackoverflow.com/a/52265733"
@options [
[key: "(GMT-12:00) International Date Line West", value: "Etc/GMT+12", offset: "720"],
[key: "(GMT-11:00) Midway Island, Samoa",value: "Pacific/Midway", offset: "660"],
[key: "(GMT-10:00) Hawaii",value: "Pacific/Honolulu", offset: "600"],
[key: "(GMT-09:00) Alaska",value: "US/Alaska", offset: "540"],
[key: "(GMT-08:00) Pacific Time (US & Canada)",value: "America/Los_Angeles", offset: "480"],
[key: "(GMT-08:00) Tijuana, Baja California",value: "America/Tijuana"],
[key: "(GMT-07:00) Arizona",value: "US/Arizona"],
[key: "(GMT-07:00) Chihuahua, La Paz, Mazatlan",value: "America/Chihuahua"],
[key: "(GMT-07:00) Mountain Time (US & Canada)",value: "US/Mountain", offset: "420"],
[key: "(GMT-06:00) Central America",value: "America/Managua"],
[key: "(GMT-06:00) Central Time (US & Canada)",value: "US/Central", offset: "360"],
[key: "(GMT-06:00) Guadalajara, Mexico City, Monterrey",value: "America/Mexico_City"],
[key: "(GMT-06:00) Saskatchewan",value: "Canada/Saskatchewan"],
[key: "(GMT-05:00) Bogota, Lima, Quito, Rio Branco",value: "America/Bogota"],
[key: "(GMT-05:00) Eastern Time (US & Canada)",value: "US/Eastern", offset: "300"],
[key: "(GMT-05:00) Indiana (East)",value: "US/East-Indiana"],
[key: "(GMT-04:00) Atlantic Time (Canada)",value: "Canada/Atlantic", offset: "240"],
[key: "(GMT-04:00) Caracas, La Paz",value: "America/Caracas"],
[key: "(GMT-04:00) Manaus",value: "America/Manaus"],
[key: "(GMT-04:00) Santiago",value: "America/Santiago"],
[key: "(GMT-03:30) Newfoundland",value: "Canada/Newfoundland"],
[key: "(GMT-03:00) Brasilia",value: "America/Sao_Paulo", offset: "180"],
[key: "(GMT-03:00) Buenos Aires, Georgetown",value: "America/Argentina/Buenos_Aires"],
[key: "(GMT-03:00) Greenland",value: "America/Godthab"],
[key: "(GMT-03:00) Montevideo",value: "America/Montevideo"],
[key: "(GMT-02:00) Mid-Atlantic",value: "America/Noronha", offset: "120"],
[key: "(GMT-01:00) Cape Verde Is.",value: "Atlantic/Cape_Verde", offset: "60"],
[key: "(GMT-01:00) Azores",value: "Atlantic/Azores"],
[key: "(GMT+00:00) Casablanca, Monrovia, Reykjavik",value: "Africa/Casablanca"],
[key: "(GMT+00:00) Greenwich Mean Time : Dublin, Edinburgh, Lisbon, London",value: "Etc/Greenwich", offset: "0"],
[key: "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",value: "Europe/Amsterdam", offset: "-60"],
[key: "(GMT+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague",value: "Europe/Belgrade"],
[key: "(GMT+01:00) Brussels, Copenhagen, Madrid, Paris",value: "Europe/Brussels"],
[key: "(GMT+01:00) Sarajevo, Skopje, Warsaw, Zagreb",value: "Europe/Sarajevo"],
[key: "(GMT+01:00) West Central Africa",value: "Africa/Lagos"],
[key: "(GMT+02:00) Amman",value: "Asia/Amman"],
[key: "(GMT+02:00) Athens, Bucharest, Istanbul",value: "Europe/Athens"],
[key: "(GMT+02:00) Beirut",value: "Asia/Beirut"],
[key: "(GMT+02:00) Cairo",value: "Africa/Cairo"],
[key: "(GMT+02:00) Harare, Pretoria",value: "Africa/Harare"],
[key: "(GMT+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius",value: "Europe/Helsinki", offset: "-120"],
[key: "(GMT+02:00) Jerusalem",value: "Asia/Jerusalem"],
[key: "(GMT+02:00) Minsk",value: "Europe/Minsk"],
[key: "(GMT+02:00) Windhoek",value: "Africa/Windhoek"],
[key: "(GMT+03:00) Kuwait, Riyadh, Baghdad",value: "Asia/Kuwait"],
[key: "(GMT+03:00) Moscow, St. Petersburg, Volgograd",value: "Europe/Moscow", offset: "-180"],
[key: "(GMT+03:00) Nairobi",value: "Africa/Nairobi"],
[key: "(GMT+03:00) Tbilisi",value: "Asia/Tbilisi"],
[key: "(GMT+03:30) Tehran",value: "Asia/Tehran", offset: "-210"],
[key: "(GMT+04:00) Abu Dhabi, Muscat",value: "Asia/Muscat", offset: "-240"],
[key: "(GMT+04:00) Baku",value: "Asia/Baku"],
[key: "(GMT+04:00) Yerevan",value: "Asia/Yerevan"],
[key: "(GMT+04:30) Kabul",value: "Asia/Kabul", offset: "-270"],
[key: "(GMT+05:00) Yekaterinburg",value: "Asia/Yekaterinburg"],
[key: "(GMT+05:00) Islamabad, Karachi, Tashkent",value: "Asia/Karachi", offset: "-300"],
[key: "(GMT+05:30) Chennai, Kolkata, Mumbai, New Delhi",value: "Asia/Calcutta", offset: "-330"],
[key: "(GMT+05:30) Sri Jayawardenapura",value: "Asia/Calcutta"],
[key: "(GMT+05:45) Kathmandu",value: "Asia/Katmandu", offset: "-345"],
[key: "(GMT+06:00) Almaty, Novosibirsk",value: "Asia/Almaty", offset: "-360"],
[key: "(GMT+06:00) Astana, Dhaka",value: "Asia/Dhaka"],
[key: "(GMT+06:30) Yangon (Rangoon)",value: "Asia/Rangoon", offset: "-390"],
[key: "(GMT+07:00) Bangkok, Hanoi, Jakarta",value: "Asia/Bangkok", offset: "-420"],
[key: "(GMT+07:00) Krasnoyarsk",value: "Asia/Krasnoyarsk"],
[key: "(GMT+08:00) Beijing, Chongqing, Hong Kong, Urumqi",value: "Asia/Hong_Kong", offset: "-480"],
[key: "(GMT+08:00) Kuala Lumpur, Singapore",value: "Asia/Kuala_Lumpur"],
[key: "(GMT+08:00) Irkutsk, Ulaan Bataar",value: "Asia/Irkutsk"],
[key: "(GMT+08:00) Perth",value: "Australia/Perth"],
[key: "(GMT+08:00) Taipei",value: "Asia/Taipei"],
[key: "(GMT+09:00) Osaka, Sapporo, Tokyo",value: "Asia/Tokyo", offset: "-540"],
[key: "(GMT+09:00) Seoul",value: "Asia/Seoul"],
[key: "(GMT+09:00) Yakutsk",value: "Asia/Yakutsk"],
[key: "(GMT+09:30) Adelaide",value: "Australia/Adelaide", offset: "-570"],
[key: "(GMT+09:30) Darwin",value: "Australia/Darwin"],
[key: "(GMT+10:00) Brisbane",value: "Australia/Brisbane", offset: "-600"],
[key: "(GMT+10:00) Canberra, Melbourne, Sydney",value: "Australia/Canberra"],
[key: "(GMT+10:00) Hobart",value: "Australia/Hobart"],
[key: "(GMT+10:00) Guam, Port Moresby",value: "Pacific/Guam"],
[key: "(GMT+10:00) Vladivostok",value: "Asia/Vladivostok"],
[key: "(GMT+11:00) Magadan, Solomon Is., New Caledonia",value: "Asia/Magadan", offset: "-660"],
[key: "(GMT+12:00) Auckland, Wellington",value: "Pacific/Auckland", offset: "-720"],
[key: "(GMT+12:00) Fiji, Kamchatka, Marshall Is.",value: "Pacific/Fiji"],
[key: "(GMT+13:00) Nuku'alofa",value: "Pacific/Tongatapu", offset: "-780"],
]
def options() do
@options
end
end

51
lib/plausible/tracking.ex Normal file
View File

@ -0,0 +1,51 @@
defmodule Plausible.Tracking do
@api_host "https://api.amplitude.com"
def event(conn, event, properties \\ %{}) do
Task.start(fn ->
track(Mix.env(), %{
event_type: event,
user_id: extract_user_id(conn),
device_id: extract_device_id(conn),
event_properties: properties,
time: Timex.now() |> DateTime.to_unix
})
end)
end
def identify(conn, user_id, props \\ %{}) do
Task.start(fn ->
track(Mix.env(), %{
event_type: "$identify",
user_id: user_id,
device_id: extract_device_id(conn),
user_properties: props,
time: Timex.now() |> DateTime.to_unix
})
end)
end
defp track(:test, _params) do
# /dev/null
end
defp track(:dev, _params) do
# /dev/null
end
defp track(_, params) do
HTTPoison.get!(@api_host <> "/httpapi", [], params: [api_key: api_key(), event: Jason.encode!(params)])
end
defp extract_user_id(conn) do
if conn.assigns[:current_user] do
conn.assigns[:current_user].id
end
end
defp extract_device_id(conn) do
Plug.Conn.get_session(conn, :device_id)
end
defp api_key, do: Keyword.fetch!(Application.get_env(:plausible, :amplitude), :api_key)
end

68
lib/plausible_web.ex Normal file
View File

@ -0,0 +1,68 @@
defmodule PlausibleWeb do
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, views, channels and so on.
This can be used in your application as:
use PlausibleWeb, :controller
use PlausibleWeb, :view
The definitions below will be executed for every view,
controller, etc, so keep them short and clean, focused
on imports, uses and aliases.
Do NOT define functions inside the quoted expressions
below. Instead, define any helper function in modules
and import those modules here.
"""
def controller do
quote do
use Phoenix.Controller, namespace: PlausibleWeb
import Plug.Conn
import PlausibleWeb.ControllerHelpers
alias PlausibleWeb.Router.Helpers, as: Routes
end
end
def view do
quote do
use Phoenix.View,
root: "lib/plausible_web/templates",
namespace: PlausibleWeb
# Import convenience functions from controllers
import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1]
# Use all HTML functionality (forms, tags, etc)
use Phoenix.HTML
import PlausibleWeb.ErrorHelpers
import PhoenixActiveLink
alias PlausibleWeb.Router.Helpers, as: Routes
end
end
def router do
quote do
use Phoenix.Router
import Plug.Conn
import Phoenix.Controller
end
end
def channel do
quote do
use Phoenix.Channel
end
end
@doc """
When used, dispatch to the appropriate controller/view/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end

View File

@ -0,0 +1,20 @@
defmodule PlausibleWeb.AuthPlug do
import Plug.Conn
def init(options) do
options
end
def call(conn, _opts) do
case get_session(conn, :current_user_id) do
nil -> conn
id ->
user = Plausible.Auth.find_user_by(id: id)
if user do
assign(conn, :current_user, user)
else
conn
end
end
end
end

View File

@ -0,0 +1,33 @@
defmodule PlausibleWeb.UserSocket do
use Phoenix.Socket
## Channels
# channel "room:*", PlausibleWeb.RoomChannel
# Socket params are passed from the client and can
# be used to verify and authenticate a user. After
# verification, you can put default assigns into
# the socket that will be set for all channels, ie
#
# {:ok, assign(socket, :user_id, verified_user_id)}
#
# To deny connection, return `:error`.
#
# See `Phoenix.Token` documentation for examples in
# performing token verification on connect.
def connect(_params, socket, _connect_info) do
{:ok, socket}
end
# Socket id's are topics that allow you to identify all sockets for a given user:
#
# def id(socket), do: "user_socket:#{socket.assigns.user_id}"
#
# Would allow you to broadcast a "disconnect" event and terminate
# all active sockets and channels for a given user:
#
# PlausibleWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
#
# Returning `nil` makes this socket anonymous.
def id(_socket), do: nil
end

View File

@ -0,0 +1,139 @@
defmodule PlausibleWeb.Api.ExternalController do
use PlausibleWeb, :controller
require Logger
@blacklist_user_ids [
"e8150466-7ddb-4771-bcf5-7c58f232e8a6"
]
def page(conn, _params) do
params = parse_body(conn)
case create_pageview(conn, params) do
{:ok, _pageview} ->
conn |> send_resp(202, "")
{:error, changeset} ->
request = Sentry.Plug.build_request_interface_data(conn, [])
Sentry.capture_message("Error processing pageview", extra: %{errors: inspect(changeset.errors), params: params, request: request})
Logger.error("Error processing pageview: #{inspect(changeset)}")
conn |> send_resp(400, "")
end
end
def error(conn, _params) do
request = Sentry.Plug.build_request_interface_data(conn, [])
Sentry.capture_message("JS snippet error", request: request)
send_resp(conn, 200, "")
end
defp create_pageview(conn, params) do
uri = URI.parse(params["url"])
country_code = Plug.Conn.get_req_header(conn, "cf-ipcountry") |> List.first
user_agent = Plug.Conn.get_req_header(conn, "user-agent") |> List.first
if UAInspector.bot?(user_agent) || params["uid"] in @blacklist_user_ids do
{:ok, nil}
else
ua = if user_agent do
UAInspector.Parser.parse(user_agent)
end
ref = params["referrer"]
ref = if ref && strip_www(URI.parse(ref).host) !== strip_www(uri.host) && URI.parse(ref).host !== "localhost" do
RefInspector.parse(ref)
end
pageview_attrs = %{
hostname: strip_www(uri.host),
pathname: uri.path,
user_agent: user_agent,
new_visitor: params["new_visitor"],
screen_width: params["screen_width"],
country_code: country_code,
user_id: params["uid"],
operating_system: ua && os_name(ua),
browser: ua && browser_name(ua),
raw_referrer: params["referrer"],
referrer_source: ref && referrer_source(uri, ref),
referrer: ref && clean_referrer(params["referrer"]),
screen_size: calculate_screen_size(params["screen_width"])
}
Plausible.Pageview.changeset(%Plausible.Pageview{}, pageview_attrs)
|> Plausible.Repo.insert
end
end
defp calculate_screen_size(width) when width < 576, do: "Mobile"
defp calculate_screen_size(width) when width < 992, do: "Tablet"
defp calculate_screen_size(width) when width < 1440, do: "Laptop"
defp calculate_screen_size(width) when width >= 1440, do: "Desktop"
defp calculate_screen_size(_) , do: nil
defp clean_referrer(referrer) do
uri = if referrer do
URI.parse(referrer)
end
if uri && uri.scheme in ["http", "https"] do
host = String.replace_prefix(uri.host, "www.", "")
host <> (uri.path || "")
end
end
defp parse_body(conn) do
{:ok, body, _conn} = Plug.Conn.read_body(conn)
Jason.decode!(body)
end
defp strip_www(nil), do: nil
defp strip_www(hostname) do
String.replace_prefix(hostname, "www.", "")
end
defp browser_name(ua) do
case ua.client do
%UAInspector.Result.Client{name: "Mobile Safari"} -> "Safari"
%UAInspector.Result.Client{name: "Chrome Mobile"} -> "Chrome"
%UAInspector.Result.Client{name: "Chrome Mobile iOS"} -> "Chrome"
%UAInspector.Result.Client{type: "mobile app"} -> "Mobile App"
:unknown -> nil
client -> client.name
end
end
defp os_name(ua) do
case ua.os do
:unknown -> nil
os -> os.name
end
end
defp referrer_source(uri, ref) do
case ref.source do
:unknown ->
query_param_source(uri) || clean_uri(ref.referer)
source ->
source
end
end
defp clean_uri(uri) do
uri = URI.parse(String.trim(uri))
if uri.scheme in ["http", "https"] do
String.replace_leading(uri.host, "www.", "")
end
end
@source_query_params ["ref", "utm_source", "source"]
defp query_param_source(uri) do
if uri && uri.query do
Enum.find_value(URI.query_decoder(uri.query), fn {key, val} ->
if Enum.member?(@source_query_params, key) do
val
end
end)
end
end
end

View File

@ -0,0 +1,17 @@
defmodule PlausibleWeb.Api.InternalController do
use PlausibleWeb, :controller
use Plausible.Repo
def domain_status(conn, %{"domain" => domain}) do
has_pageviews = Repo.exists?(
from p in Plausible.Pageview,
where: p.hostname == ^domain
)
if has_pageviews do
json(conn, "READY")
else
json(conn, "WAITING")
end
end
end

View File

@ -0,0 +1,74 @@
defmodule PlausibleWeb.Api.PaddleController do
use PlausibleWeb, :controller
use Plausible.Repo
require Logger
plug :verify_signature
def webhook(conn, %{"alert_name" => "subscription_created"} = params) do
Plausible.Billing.subscription_created(params)
|> webhook_response(conn, params)
end
def webhook(conn, %{"alert_name" => "subscription_updated"} = params) do
Plausible.Billing.subscription_updated(params)
|> webhook_response(conn, params)
end
def webhook(conn, %{"alert_name" => "subscription_cancelled"} = params) do
Plausible.Billing.subscription_cancelled(params)
|> webhook_response(conn, params)
end
def webhook(conn, %{"alert_name" => "subscription_payment_succeeded"} = params) do
Plausible.Billing.subscription_payment_succeeded(params)
|> webhook_response(conn, params)
end
def webhook(conn, _params) do
send_resp(conn, 404, "") |> halt
end
@paddle_key File.read!("priv/paddle.pem")
def verify_signature(conn, _opts) do
signature = Base.decode64!(conn.params["p_signature"])
msg = Map.delete(conn.params, "p_signature")
|> Enum.map(fn {key, val} -> {key, "#{val}"} end)
|> List.keysort(0)
|> PhpSerializer.serialize()
[key_entry] = :public_key.pem_decode(@paddle_key)
public_key = :public_key.pem_entry_decode(key_entry)
if :public_key.verify(msg, :sha, signature, public_key) do
conn
else
send_resp(conn, 400, "") |> halt
end
end
def verified_signature?(params) do
signature = Base.decode64!(params["p_signature"])
msg = Map.delete(params, "p_signature")
|> Enum.map(fn {key, val} -> {key, "#{val}"} end)
|> List.keysort(0)
|> PhpSerializer.serialize()
[key_entry] = :public_key.pem_decode(@paddle_key)
public_key = :public_key.pem_entry_decode(key_entry)
:public_key.verify(msg, :sha, signature, public_key)
end
defp webhook_response({:ok, _}, conn, _params) do
json(conn, "")
end
defp webhook_response({:error, changeset}, conn, params) do
request = Sentry.Plug.build_request_interface_data(conn, [])
Sentry.capture_message("Error processing Paddle webhook", extra: %{errors: inspect(changeset.errors), params: params, request: request})
Logger.error("Error processing Paddle webhook: #{inspect(changeset)}")
conn |> send_resp(400, "") |> halt
end
end

View File

@ -0,0 +1,214 @@
defmodule PlausibleWeb.AuthController do
use PlausibleWeb, :controller
use Plausible.Repo
alias Plausible.Auth
require Logger
plug PlausibleWeb.RequireLoggedOutPlug when action in [:register_form, :register, :login_form, :login]
plug PlausibleWeb.RequireAccountPlug when action in [:user_settings, :save_settings, :delete_me, :password_form, :set_password]
def register_form(conn, _params) do
changeset = Plausible.Auth.User.changeset(%Plausible.Auth.User{})
Plausible.Tracking.event(conn, "Register: View Form")
render(conn, "register_form.html", changeset: changeset, layout: {PlausibleWeb.LayoutView, "focus.html"})
end
def register(conn, %{"user" => params}) do
user = Plausible.Auth.User.changeset(%Plausible.Auth.User{}, params)
case Ecto.Changeset.apply_action(user, :insert) do
{:ok, user} ->
token = Auth.Token.sign_activation(user.name, user.email)
url = PlausibleWeb.Endpoint.clean_url() <> "/claim-activation?token=#{token}"
Logger.info(url)
email_template = PlausibleWeb.Email.activation_email(user, url)
Plausible.Mailer.deliver_now(email_template)
Plausible.Tracking.event(conn, "Register: Submit Form")
conn |> render("register_success.html", email: user.email, layout: {PlausibleWeb.LayoutView, "focus.html"})
{:error, changeset} ->
render(conn, "register_form.html", changeset: changeset, layout: {PlausibleWeb.LayoutView, "focus.html"})
end
end
def claim_activation_link(conn, %{"token" => token}) do
case Auth.Token.verify_activation(token) do
{:ok, %{name: name, email: email}} ->
case Auth.create_user(name, email) do
{:ok, user} ->
Plausible.Tracking.event(conn, "Register: Activate Account")
Plausible.Tracking.identify(conn, user.id, %{name: user.name})
conn
|> put_session(:current_user_id, user.id)
|> redirect(to: "/password")
{:error, changeset} ->
send_resp(conn, 400, inspect(changeset.errors))
end
{:error, :expired} ->
render_error(conn, 401, "Your token has expired. Please request another activation link.")
{:error, _} ->
render_error(conn, 400, "Your token is invalid. Please request another activation link.")
end
end
def password_reset_request_form(conn, _) do
render(conn, "password_reset_request_form.html", layout: {PlausibleWeb.LayoutView, "focus.html"})
end
def password_reset_request(conn, %{"email" => ""}) do
render(conn, "password_reset_request_form.html", error: "Please enter an email address", layout: {PlausibleWeb.LayoutView, "focus.html"})
end
def password_reset_request(conn, %{"email" => email}) do
user = Repo.get_by(Plausible.Auth.User, email: email)
if user do
token = Auth.Token.sign_password_reset(email)
url = PlausibleWeb.Endpoint.clean_url() <> "/password/reset?token=#{token}"
Logger.debug("PASSWORD RESET LINK: " <> url)
email_template = PlausibleWeb.Email.password_reset_email(email, url)
Plausible.Mailer.deliver_now(email_template)
render(conn, "password_reset_request_success.html", email: email, layout: {PlausibleWeb.LayoutView, "focus.html"})
else
render(conn, "password_reset_request_success.html", email: email, layout: {PlausibleWeb.LayoutView, "focus.html"})
end
end
def password_reset_form(conn, %{"token" => token}) do
case Auth.Token.verify_password_reset(token) do
{:ok, _} ->
render(conn, "password_reset_form.html", token: token, layout: {PlausibleWeb.LayoutView, "focus.html"})
{:error, :expired} ->
render_error(conn, 401, "Your token has expired. Please request another password reset link.")
{:error, _} ->
render_error(conn, 401, "Your token is invalid. Please request another password reset link.")
end
end
def password_reset(conn, %{"token" => token, "password" => pw}) do
case Auth.Token.verify_password_reset(token) do
{:ok, %{email: email}} ->
user = Repo.get_by(Auth.User, email: email)
changeset = Auth.User.set_password(user, pw)
case Repo.update(changeset) do
{:ok, _updated} ->
conn
|> put_flash(:login_title, "Password updated successfully")
|> put_flash(:login_instructions, "Please log in with your new credentials")
|> put_session(:current_user_id, nil)
|> redirect(to: "/login")
{:error, changeset} ->
render(conn, "password_reset_form.html", changeset: changeset, token: token, layout: {PlausibleWeb.LayoutView, "focus.html"})
end
{:error, :expired} ->
render_error(conn, 401, "Your token has expired. Please request another password reset link.")
{:error, _} ->
render_error(conn, 401, "Your token is invalid. Please request another password reset link.")
end
end
def login(conn, %{"email" => email, "password" => password}) do
alias Plausible.Auth.Password
user = Repo.one(
from u in Plausible.Auth.User,
where: u.email == ^email
)
if user do
if Password.match?(password, user.password_hash || "") do
login_dest = get_session(conn, :login_dest) || "/"
conn
|> put_session(:current_user_id, user.id)
|> put_session(:login_dest, nil)
|> redirect(to: login_dest)
else
conn |> render("login_form.html", error: "Wrong email or password. Please try again.", layout: {PlausibleWeb.LayoutView, "focus.html"})
end
else
Password.dummy_calculation()
conn |> render("login_form.html", error: "Wrong email or password. Please try again.", layout: {PlausibleWeb.LayoutView, "focus.html"})
end
end
def login_form(conn, _params) do
render(conn, "login_form.html", layout: {PlausibleWeb.LayoutView, "focus.html"})
end
def password_form(conn, _params) do
render(conn, "password_form.html", layout: {PlausibleWeb.LayoutView, "focus.html"}, skip_plausible_tracking: true)
end
def set_password(conn, %{"password" => pw}) do
changeset = Auth.User.set_password(conn.assigns[:current_user], pw)
case Repo.update(changeset) do
{:ok, _user} ->
redirect(conn, to: "/sites/new")
{:error, changeset} ->
render(conn, "password_form.html", changeset: changeset, layout: {PlausibleWeb.LayoutView, "focus.html"})
end
end
def user_settings(conn, _params) do
changeset = Auth.User.changeset(conn.assigns[:current_user])
subscription = Plausible.Billing.active_subscription_for(conn.assigns[:current_user].id)
render(conn, "user_settings.html", changeset: changeset, subscription: subscription)
end
def save_settings(conn, %{"user" => user_params}) do
changes = Auth.User.changeset(conn.assigns[:current_user], user_params)
case Repo.update(changes) do
{:ok, _user} ->
conn
|> put_flash(:success, "Account settings saved succesfully")
|> redirect(to: "/settings")
{:error, changeset} ->
render(conn, "user_settings.html", changeset: changeset)
end
end
def delete_me(conn, _params) do
user = conn.assigns[:current_user] |> Repo.preload(:sites)
for site_membership <- user.site_memberships do
Repo.delete!(site_membership)
end
for site <- user.sites do
Repo.delete!(site)
end
Repo.delete!(user)
conn
|> configure_session(drop: true)
|> redirect(to: "/")
end
def logout(conn, _params) do
conn
|> configure_session(drop: true)
|> redirect(to: "/")
end
def google_auth_callback(conn, %{"code" => code, "state" => site_id}) do
res = Plausible.Google.Api.fetch_access_token(code)
id_token = res["id_token"]
[_, body, _] = String.split(id_token, ".")
id = body |> Base.decode64!(padding: false) |> Jason.decode!
Plausible.Site.GoogleAuth.changeset(%Plausible.Site.GoogleAuth{}, %{
email: id["email"],
refresh_token: res["refresh_token"],
access_token: res["access_token"],
expires: NaiveDateTime.utc_now() |> NaiveDateTime.add(res["expires_in"]),
user_id: conn.assigns[:current_user].id,
site_id: site_id
}) |> Repo.insert!
site = Repo.get(Plausible.Site, site_id)
redirect(conn, to: "/#{site.domain}/settings#google-auth")
end
end

View File

@ -0,0 +1,47 @@
defmodule PlausibleWeb.BillingController do
use PlausibleWeb, :controller
use Plausible.Repo
alias Plausible.Billing
require Logger
plug PlausibleWeb.RequireAccountPlug
def change_plan_form(conn, _params) do
subscription = Billing.active_subscription_for(conn.assigns[:current_user].id)
if subscription do
render(conn, "change_plan.html", subscription: subscription, layout: {PlausibleWeb.LayoutView, "focus.html"})
else
redirect(conn, to: "/billing/upgrade")
end
end
def change_plan(conn, %{"plan_name" => plan}) when plan in ["personal", "startup", "business"] do
new_plan = String.to_existing_atom(plan)
case Billing.change_plan(conn.assigns[:current_user], new_plan) do
{:ok, _subscription} ->
conn
|> put_flash(:success, "Plan changed successfully")
|> redirect(to: "/settings")
{:error, e} ->
Sentry.capture_message("Error changing plans", extra: %{errors: inspect(e), new_plan: new_plan, user_id: conn.assigns[:current_user].id})
conn
|> put_flash(:error, "Something went wrong. Please try again or contact support at uku@plausible.io")
|> redirect(to: "/settings")
end
end
def upgrade(conn, _params) do
usage = Plausible.Billing.usage(conn.assigns[:current_user])
trial_end_date = Plausible.Billing.trial_end_date(conn.assigns[:current_user])
today = Timex.today()
render(conn, "upgrade.html", usage: usage, trial_end_date: trial_end_date, today: today, layout: {PlausibleWeb.LayoutView, "focus.html"})
end
def success(conn, _params) do
conn
|> put_flash(:success, "Subscription created successfully")
|> redirect(to: "/")
end
end

View File

@ -0,0 +1,18 @@
defmodule PlausibleWeb.ControllerHelpers do
import Plug.Conn
import Phoenix.Controller
def render_error(conn, status, message) do
conn
|> put_status(status)
|> put_view(PlausibleWeb.ErrorView)
|> render("#{status}.html", layout: false, message: message)
end
def render_error(conn, status) do
conn
|> put_status(status)
|> put_view(PlausibleWeb.ErrorView)
|> render("#{status}.html", layout: false)
end
end

View File

@ -0,0 +1,86 @@
defmodule PlausibleWeb.PageController do
use PlausibleWeb, :controller
use Plausible.Repo
@demo_referrers [
{"indiehackers.com", 30},
{"Twitter", 17},
{"Google", 6},
{"DuckDuckGo", 4},
{"Bing", 2},
]
@demo_countries [
{"United Kingdom", 41},
{"United States", 38},
{"France", 13},
{"India", 7},
{"Netherlands", 6},
]
def index(conn, _params) do
if conn.assigns[:current_user] do
Plausible.Tracking.event(conn, "Sites: View Page")
user = conn.assigns[:current_user] |> Repo.preload(:sites)
render(conn, "sites.html", sites: user.sites)
else
Plausible.Tracking.event(conn, "Landing: View Page")
render(conn, "index.html", demo_referrers: @demo_referrers, demo_countries: @demo_countries, landing_nav: true)
end
end
defmodule Token do
use Joken.Config
end
defp sign_token!(user) do
claims = %{
id: user.id,
email: user.email,
name: user.name,
}
signer = Joken.Signer.create("HS256", "4d1d2ae6-4595-4d0b-b98a-8ca5b1f2095a")
{:ok, token, _} = Token.generate_and_sign(claims, signer)
token
end
def feedback(conn, _params) do
if conn.assigns[:current_user] do
token = sign_token!(conn.assigns[:current_user])
redirect(conn, external: "https://feedback.plausible.io/sso/#{token}")
else
redirect(conn, external: "https://feedback.plausible.io")
end
end
def roadmap(conn, _params) do
if conn.assigns[:current_user] do
token = sign_token!(conn.assigns[:current_user])
redirect(conn, external: "https://feedback.plausible.io/sso/#{token}?returnUrl=https://feedback.plausible.io/roadmap")
else
redirect(conn, external: "https://feedback.plausible.io/roadmap")
end
end
def contact_form(conn, _params) do
render(conn, "contact_form.html")
end
def submit_contact_form(conn, %{"text" => text, "email" => email}) do
PlausibleWeb.Email.feedback(email, text) |> Plausible.Mailer.deliver_now
render(conn, "contact_thanks.html")
end
def privacy(conn, _params) do
render(conn, "privacy.html")
end
def data_policy(conn, _params) do
render(conn, "data_policy.html")
end
def terms(conn, _params) do
render(conn, "terms.html")
end
end

View File

@ -0,0 +1,115 @@
defmodule PlausibleWeb.SiteController do
use PlausibleWeb, :controller
use Plausible.Repo
alias Plausible.Sites
plug PlausibleWeb.RequireAccountPlug
def new(conn, _params) do
changeset = Plausible.Site.changeset(%Plausible.Site{})
render(conn, "new.html", changeset: changeset, layout: {PlausibleWeb.LayoutView, "focus.html"})
end
def create_site(conn, %{"site" => site_params}) do
user = conn.assigns[:current_user]
case insert_site(user.id, site_params) do
{:ok, %{site: site}} ->
Plausible.Tracking.event(conn, "New Site: Create", %{domain: site.domain})
Plausible.Slack.notify("#{user.name} created #{site.domain}")
redirect(conn, to: "/#{site.domain}/snippet")
{:error, :site, changeset, _} ->
render(conn, "new.html", changeset: changeset)
end
end
def add_snippet(conn, %{"website" => website}) do
site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
conn
|> assign(:skip_plausible_tracking, true)
|> render("snippet.html", site: site, layout: {PlausibleWeb.LayoutView, "focus.html"})
end
def settings(conn, %{"website" => website}) do
site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
|> Repo.preload(:google_auth)
google_search_console_verified = if site.google_auth do
google_site = Plausible.Google.Api.fetch_site(site.domain, site.google_auth)
!google_site["error"]
end
changeset = Plausible.Site.changeset(site, %{})
conn
|> assign(:skip_plausible_tracking, true)
|> render("settings.html", site: site, google_search_console_verified: google_search_console_verified, changeset: changeset)
end
def update_settings(conn, %{"website" => website, "site" => site_params}) do
site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
changeset = site |> Plausible.Site.changeset(site_params)
res = changeset |> Repo.update
case res do
{:ok, site} ->
conn
|> put_flash(:success, "Site settings saved succesfully")
|> redirect(to: "/#{site.domain}/settings")
{:error, changeset} ->
render("settings.html", site: site, changeset: changeset)
end
end
def delete_site(conn, %{"website" => website}) do
site =
Sites.get_for_user!(conn.assigns[:current_user].id, website)
|> Repo.preload(:google_auth)
Repo.delete_all(from sm in "site_memberships", where: sm.site_id == ^site.id)
Repo.delete_all(from p in "pageviews", where: p.hostname == ^site.domain)
Repo.delete!(site.google_auth)
Repo.delete!(site)
conn
|> put_flash(:success, "Site deleted succesfully along with all pageviews")
|> redirect(to: "/")
end
def make_public(conn, %{"website" => website}) do
site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
|> Plausible.Site.make_public
|> Repo.update!
conn
|> put_flash(:success, "Congrats! Stats for #{site.domain} are now public.")
|> redirect(to: "/" <> site.domain <> "/settings")
end
def make_private(conn, %{"website" => website}) do
site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
|> Plausible.Site.make_private
|> Repo.update!
conn
|> put_flash(:success, "Stats for #{site.domain} are now private.")
|> redirect(to: "/" <> site.domain <> "/settings")
end
defp insert_site(user_id, params) do
site_changeset = Plausible.Site.changeset(%Plausible.Site{}, params)
Ecto.Multi.new()
|> Ecto.Multi.insert(:site, site_changeset)
|> Ecto.Multi.run(:site_membership, fn repo, %{site: site} ->
membership_changeset = Plausible.Site.Membership.changeset(%Plausible.Site.Membership{}, %{
site_id: site.id,
user_id: user_id
})
repo.insert(membership_changeset)
end)
|> Repo.transaction
end
end

View File

@ -0,0 +1,292 @@
defmodule PlausibleWeb.StatsController do
use PlausibleWeb, :controller
use Plausible.Repo
alias Plausible.Stats
defp referrer_link(site, {name, count}, query) do
link = "/#{site.domain}/referrers/#{name}" <> PlausibleWeb.StatsView.query_params(query)
{{:link, name, link}, count}
end
def stats(conn, %{"website" => website}) do
site = Repo.get_by(Plausible.Site, domain: website)
if site && current_user_can_access?(conn, site) do
user = conn.assigns[:current_user]
if user && Plausible.Billing.needs_to_upgrade?(conn.assigns[:current_user]) do
redirect(conn, to: "/billing/upgrade")
else
if Plausible.Sites.has_pageviews?(site) do
demo = site.domain == "plausible.io"
Plausible.Tracking.event(conn, "Site Analytics: Open", %{demo: demo})
query = Stats.Query.from(site.timezone, conn.params)
conn
|> assign(:skip_plausible_tracking, !demo)
|> render("stats.html",
site: site,
query: query,
title: "Plausible · " <> site.domain
)
else
conn
|> assign(:skip_plausible_tracking, true)
|> render("waiting_first_pageview.html", site: site)
end
end
else
render_error(conn, 404)
end
end
def browsers_preview(conn, %{"domain" => domain}) do
site = Repo.get_by(Plausible.Site, domain: domain)
query = Stats.Query.from(site.timezone, conn.params)
if site && current_user_can_access?(conn, site) do
render(conn,
"browsers_preview.html",
browsers: Stats.browsers(site, query),
site: site,
query: query,
layout: false
)
else
render_error(conn, 404)
end
end
def operating_systems_preview(conn, %{"domain" => domain}) do
site = Repo.get_by(Plausible.Site, domain: domain)
query = Stats.Query.from(site.timezone, conn.params)
if site && current_user_can_access?(conn, site) do
render(conn,
"operating_systems_preview.html",
operating_systems: Stats.operating_systems(site, query),
site: site,
query: query,
layout: false
)
else
render_error(conn, 404)
end
end
def screen_sizes_preview(conn, %{"domain" => domain}) do
site = Repo.get_by(Plausible.Site, domain: domain)
query = Stats.Query.from(site.timezone, conn.params)
if site && current_user_can_access?(conn, site) do
render(conn,
"screen_sizes_preview.html",
top_screen_sizes: Stats.top_screen_sizes(site, query),
site: site,
query: query,
layout: false
)
else
render_error(conn, 404)
end
end
def referrers_preview(conn, %{"domain" => domain}) do
site = Repo.get_by(Plausible.Site, domain: domain)
query = Stats.Query.from(site.timezone, conn.params)
if site && current_user_can_access?(conn, site) do
render(conn,
"referrers_preview.html",
top_referrers: Stats.top_referrers(site, query) |> Enum.map(&(referrer_link(site, &1, query))),
site: site,
query: query,
layout: false
)
else
render_error(conn, 404)
end
end
def pages_preview(conn, %{"domain" => domain}) do
site = Repo.get_by(Plausible.Site, domain: domain)
query = Stats.Query.from(site.timezone, conn.params)
if site && current_user_can_access?(conn, site) do
render(conn,
"pages_preview.html",
top_pages: Stats.top_pages(site, query),
site: site,
query: query,
layout: false
)
else
render_error(conn, 404)
end
end
def countries_preview(conn, %{"domain" => domain}) do
site = Repo.get_by(Plausible.Site, domain: domain)
query = Stats.Query.from(site.timezone, conn.params)
if site && current_user_can_access?(conn, site) do
render(conn,
"countries_preview.html",
top_countries: Stats.countries(site, query),
site: site,
query: query,
layout: false
)
else
render_error(conn, 404)
end
end
def main_graph(conn, %{"domain" => domain}) do
site = Repo.get_by(Plausible.Site, domain: domain)
query = Stats.Query.from(site.timezone, conn.params)
plot_task = Task.async(fn -> Stats.calculate_plot(site, query) end)
{pageviews, visitors} = Stats.pageviews_and_visitors(site, query)
{plot, labels, present_index} = Task.await(plot_task)
json(conn, %{
plot: plot,
labels: labels,
present_index: present_index,
pageviews: pageviews,
unique_visitors: visitors,
interval: query.step_type
})
end
def compare(conn, %{"domain" => domain}) do
site = Repo.get_by(Plausible.Site, domain: domain)
query = Stats.Query.from(site.timezone, conn.params)
{pageviews, ""} = Integer.parse(conn.params["pageviews"])
{unique_visitors, ""} = Integer.parse(conn.params["unique_visitors"])
if site && current_user_can_access?(conn, site) do
{change_pageviews, change_visitors} = Stats.compare_pageviews_and_visitors(site, query, {pageviews, unique_visitors})
json(conn, %{
change_pageviews: change_pageviews,
change_visitors: change_visitors
})
end
end
def referrers(conn, %{"domain" => domain}) do
site = Repo.get_by(Plausible.Site, domain: domain)
if site && current_user_can_access?(conn, site) do
query = Stats.Query.from(site.timezone, conn.params)
referrers = Stats.top_referrers(site, query, 100)
render(conn, "referrers.html", layout: false, site: site, top_referrers: referrers, query: query)
else
render_error(conn, 404)
end
end
def referrer_drilldown(conn, %{"domain" => domain, "referrer" => "Google"}) do
site = Repo.get_by(Plausible.Site, domain: domain)
|> Repo.preload(:google_auth)
if site && current_user_can_access?(conn, site) do
query = Stats.Query.from(site.timezone, conn.params)
total_visitors = Stats.visitors_from_referrer(site, query, "Google")
search_terms = if site.google_auth do
Plausible.Google.Api.fetch_stats(site, site.google_auth, query)
end
render(conn, "google_referrer.html",
layout: false,
site: site,
search_terms: search_terms,
total_visitors: total_visitors,
query: query
)
else
render_error(conn, 404)
end
end
def referrer_drilldown(conn, %{"domain" => domain, "referrer" => referrer}) do
site = Repo.get_by(Plausible.Site, domain: domain)
if site && current_user_can_access?(conn, site) do
query = Stats.Query.from(site.timezone, conn.params)
referrers = Stats.referrer_drilldown(site, query, referrer)
total_visitors = Stats.visitors_from_referrer(site, query, referrer)
render(conn, "referrer_drilldown.html", layout: false, site: site, referrers: referrers, referrer: referrer, total_visitors: total_visitors, query: query)
else
render_error(conn, 404)
end
end
def pages(conn, %{"domain" => domain}) do
site = Repo.get_by(Plausible.Site, domain: domain)
if site && current_user_can_access?(conn, site) do
query = Stats.Query.from(site.timezone, conn.params)
pages = Stats.top_pages(site, query, 100)
render(conn, "pages.html", layout: false, site: site, top_pages: pages)
else
render_error(conn, 404)
end
end
def countries(conn, %{"domain" => domain}) do
site = Repo.get_by(Plausible.Site, domain: domain)
if site && current_user_can_access?(conn, site) do
query = Stats.Query.from(site.timezone, conn.params)
countries = Stats.countries(site, query, 100)
render(conn, "countries.html", layout: false, site: site, countries: countries)
else
render_error(conn, 404)
end
end
def operating_systems(conn, %{"domain" => domain}) do
site = Repo.get_by(Plausible.Site, domain: domain)
if site && current_user_can_access?(conn, site) do
query = Stats.Query.from(site.timezone, conn.params)
operating_systems = Stats.operating_systems(site, query, 100)
render(conn, "operating_systems.html", layout: false, site: site, operating_systems: operating_systems)
else
render_error(conn, 404)
end
end
def browsers(conn, %{"domain" => domain}) do
site = Repo.get_by(Plausible.Site, domain: domain)
if site && current_user_can_access?(conn, site) do
query = Stats.Query.from(site.timezone, conn.params)
browsers = Stats.browsers(site, query, 100)
render(conn, "browsers.html", layout: false, site: site, browsers: browsers)
else
render_error(conn, 404)
end
end
defp current_user_can_access?(_conn, %Plausible.Site{public: true}) do
true
end
defp current_user_can_access?(conn, site) do
case conn.assigns[:current_user] do
nil -> false
user -> Plausible.Sites.is_owner?(user.id, site)
end
end
end

View File

@ -0,0 +1,90 @@
defmodule PlausibleWeb.Email do
use Bamboo.Phoenix, view: PlausibleWeb.EmailView
import Bamboo.PostmarkHelper
def welcome_email(user) do
new_email()
|> to(user)
|> from("Uku Taht <uku@plausible.io>")
|> tag("welcome-email")
|> subject("Welcome to Plausible :) Plus, a quick question...")
|> render("welcome_email.html", user: user)
end
def help_email(user) do
new_email()
|> to(user)
|> from("Uku Taht <uku@plausible.io>")
|> tag("help-email")
|> subject("Your Plausible setup")
|> render("help_email.html", user: user)
end
def password_reset_email(email, reset_link) do
new_email()
|> to(email)
|> from("Uku Taht <uku@plausible.io>")
|> tag("password-reset-email")
|> subject("Plausible password reset")
|> render("password_reset_email.html", reset_link: reset_link)
end
def activation_email(user, link) do
new_email()
|> to(user.email)
|> from("Uku Taht <uku@plausible.io>")
|> tag("activation-email")
|> subject("Plausible activation link")
|> render("activation_email.html", name: user.name, link: link)
end
def trial_two_week_reminder(user) do
new_email()
|> to(user)
|> bcc("uku@plausible.io")
|> from("Uku Taht <uku@plausible.io>")
|> tag("trial-two-week-reminder")
|> subject("14 days left on your Plausible trial")
|> render("trial_two_week_reminder.html", user: user)
end
def trial_upgrade_email(user, day, pageviews) do
new_email()
|> to(user)
|> bcc("uku@plausible.io")
|> from("Uku Taht <uku@plausible.io>")
|> tag("trial-upgrade-email")
|> subject("Your Plausible trial ends #{day}")
|> render("trial_upgrade_email.html", user: user, day: day, pageviews: pageviews)
end
def trial_over_email(user) do
new_email()
|> to(user)
|> from("Uku Taht <uku@plausible.io>")
|> tag("trial-over-email")
|> subject("Your Plausible trial has ended")
|> render("trial_over_email.html", user: user)
end
def feedback_survey_email(user) do
new_email()
|> to(user)
|> from("Uku Taht <uku@plausible.io>")
|> tag("feedback-survey-email")
|> subject("Plausible feedback")
|> render("feedback_survey.html", user: user)
end
def feedback(from, text) do
from = if from == "", do: "anonymous@plausible.io", else: from
new_email()
|> to("uku@plausible.io")
|> from("feedback@plausible.io")
|> put_param("ReplyTo", from)
|> tag("feedback")
|> subject("New feedback submission")
|> text_body(text)
end
end

View File

@ -0,0 +1,61 @@
defmodule PlausibleWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :plausible
use Sentry.Phoenix.Endpoint
use Appsignal.Phoenix
socket "/socket", PlausibleWeb.UserSocket,
websocket: true,
longpoll: false
# Serve at "/" the static files from "priv/static" directory.
#
# You should set gzip to true if you are running phx.digest
# when deploying your static files in production.
plug Plug.Static,
at: "/",
from: :plausible,
gzip: false,
only: ~w(css fonts images js favicon.ico robots.txt)
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
plug Phoenix.LiveReloader
plug Phoenix.CodeReloader
end
plug Plug.RequestId
plug Plug.Logger
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
# The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it.
plug Plug.Session,
store: :cookie,
key: "_plausible_key",
signing_salt: "3IL0ob4k",
max_age: 60*60*24*365*5 # 5 years, this is super long but the SlidingSessionTimeout will log people out if they don't return for 2 weeks
plug CORSPlug
plug PlausibleWeb.Router
def clean_url() do
url = PlausibleWeb.Endpoint.url
if Mix.env() == :prod do
URI.parse(url) |> Map.put(:port, nil) |> URI.to_string()
else
url
end
end
end

View File

@ -0,0 +1,35 @@
defmodule PlausibleWeb.LastSeenPlug do
import Plug.Conn
use Plausible.Repo
@one_hour 60 * 60
def init(opts) do
opts
end
def call(conn, _opts) do
last_seen = get_session(conn, :last_seen)
user = conn.assigns[:current_user]
cond do
user && last_seen && last_seen < (unix_now() - @one_hour) ->
persist_last_seen(user)
put_session(conn, :last_seen, unix_now())
user && !last_seen ->
put_session(conn, :last_seen, unix_now())
true ->
conn
end
end
defp persist_last_seen(user) do
q = from(u in Plausible.Auth.User, where: u.id == ^user.id)
Repo.update_all(q, [set: [last_seen: DateTime.utc_now()]])
end
defp unix_now do
DateTime.utc_now() |> DateTime.to_unix
end
end

View File

@ -0,0 +1,18 @@
defmodule PlausibleWeb.RequireAccountPlug do
import Plug.Conn
def init(options) do
options
end
def call(conn, _opts) do
case conn.assigns[:current_user] do
nil ->
Plug.Conn.put_session(conn, :login_dest, conn.request_path)
|> Phoenix.Controller.redirect(to: "/login")
|> halt
_email ->
conn
end
end
end

View File

@ -0,0 +1,16 @@
defmodule PlausibleWeb.RequireLoggedOutPlug do
def init(options) do
options
end
def call(conn, _opts) do
cond do
conn.assigns[:current_user] ->
conn
|> Phoenix.Controller.redirect(to: "/")
|> Plug.Conn.halt
:else ->
conn
end
end
end

109
lib/plausible_web/router.ex Normal file
View File

@ -0,0 +1,109 @@
defmodule PlausibleWeb.Router do
use PlausibleWeb, :router
use Plug.ErrorHandler
use Sentry.Plug
@two_weeks_in_seconds 60 * 60 * 24 * 14
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_flash
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :assign_device_id
plug PlausibleWeb.SessionTimeoutPlug, timeout_after_seconds: @two_weeks_in_seconds
plug PlausibleWeb.AuthPlug
plug PlausibleWeb.LastSeenPlug
end
pipeline :api do
plug :accepts, ["application/json"]
plug :fetch_session
plug PlausibleWeb.AuthPlug
end
if Mix.env == :dev do
forward "/sent-emails", Bamboo.SentEmailViewerPlug
end
scope "/api", PlausibleWeb do
pipe_through :api
post "/page", Api.ExternalController, :page
get "/error", Api.ExternalController, :error
post "/paddle/webhook", Api.PaddleController, :webhook
get "/:domain/status", Api.InternalController, :domain_status
get "/:domain/referrers", StatsController, :referrers
get "/:domain/referrers/:referrer", StatsController, :referrer_drilldown
get "/:domain/pages", StatsController, :pages
get "/:domain/countries", StatsController, :countries
get "/:domain/operating-systems", StatsController, :operating_systems
get "/:domain/browsers", StatsController, :browsers
get "/:domain/compare", StatsController, :compare
end
scope "/", PlausibleWeb do
pipe_through :browser
get "/register", AuthController, :register_form
post "/register", AuthController, :register
get "/claim-activation", AuthController, :claim_activation_link
get "/login", AuthController, :login_form
post "/login", AuthController, :login
get "/claim-login", AuthController, :claim_login_link
get "/password/request-reset", AuthController, :password_reset_request_form
post "/password/request-reset", AuthController, :password_reset_request
get "/password/reset", AuthController, :password_reset_form
post "/password/reset", AuthController, :password_reset
get "/password", AuthController, :password_form
post "/password", AuthController, :set_password
post "/logout", AuthController, :logout
get "/settings", AuthController, :user_settings
put "/settings", AuthController, :save_settings
delete "/me", AuthController, :delete_me
get "/auth/google/callback", AuthController, :google_auth_callback
get "/", PageController, :index
get "/privacy", PageController, :privacy
get "/terms", PageController, :terms
get "/data-policy", PageController, :data_policy
get "/feedback", PageController, :feedback
get "/roadmap", PageController, :roadmap
get "/contact", PageController, :contact_form
post "/contact", PageController, :submit_contact_form
get "/billing/change-plan", BillingController, :change_plan_form
post "/billing/change-plan/:plan_name", BillingController, :change_plan
get "/billing/upgrade", BillingController, :upgrade
get "/billing/success", BillingController, :success
get "/sites/new", SiteController, :new
post "/sites", SiteController, :create_site
post "/sites/:website/make-public", SiteController, :make_public
post "/sites/:website/make-private", SiteController, :make_private
get "/:website/snippet", SiteController, :add_snippet
get "/:website/settings", SiteController, :settings
put "/:website/settings", SiteController, :update_settings
delete "/:website", SiteController, :delete_site
get "/stats/:domain/referrers", StatsController, :referrers_preview
get "/stats/:domain/pages", StatsController, :pages_preview
get "/stats/:domain/countries", StatsController, :countries_preview
get "/stats/:domain/screen-sizes", StatsController, :screen_sizes_preview
get "/stats/:domain/operating-systems", StatsController, :operating_systems_preview
get "/stats/:domain/browsers", StatsController, :browsers_preview
get "/stats/:domain/main-graph", StatsController, :main_graph
get "/:website/*path", StatsController, :stats
end
def assign_device_id(conn, _opts) do
if is_nil(Plug.Conn.get_session(conn, :device_id)) do
Plug.Conn.put_session(conn, :device_id, UUID.uuid4())
else
conn
end
end
end

View File

@ -0,0 +1,32 @@
defmodule PlausibleWeb.SessionTimeoutPlug do
import Plug.Conn
def init(opts \\ []) do
opts
end
def call(conn, opts) do
timeout_at = get_session(conn, :session_timeout_at)
user_id = get_session(conn, :current_user_id)
if user_id && timeout_at && now() > timeout_at do
logout_user(conn)
else
put_session(conn, :session_timeout_at, new_session_timeout_at(opts[:timeout_after_seconds]))
end
end
defp logout_user(conn) do
conn
|> put_session(:current_user_id, nil) # Leave `device_id` in the session for accurate tracking
|> assign(:session_timeout, true)
end
defp now do
DateTime.utc_now() |> DateTime.to_unix
end
defp new_session_timeout_at(timeout_after_seconds) do
now() + timeout_after_seconds
end
end

View File

@ -0,0 +1,22 @@
<%= form_for @conn, "/login", [class: "w-full max-w-sm mx-auto bg-white shadow-md rounded px-8 py-6 mt-8"], fn f -> %>
<h2><%= get_flash(@conn, :login_title) || "Enter your email and password" %></h2>
<%= if get_flash(@conn, :login_instructions) do %>
<p class="text-grey-dark text-sm mt-1 mb-2"><%= get_flash(@conn, :login_instructions) %></p>
<% end %>
<%= if @conn.assigns[:error] do %>
<div class="text-red text-xs italic mt-4"><%= @conn.assigns[:error] %></div>
<% end %>
<div class="my-4 mt-8">
<%= label f, :email, class: "block text-grey-darker text-sm font-bold mb-2" %>
<%= email_input f, :email, class: "bg-grey-lighter appearance-none border border-transparent rounded w-full p-2 text-grey-darker leading-normal appearance-none focus:outline-none focus:bg-white focus:border-grey-light", placeholder: "user@example.com" %>
</div>
<div class="my-4">
<%= label f, :password, class: "block text-grey-darker text-sm font-bold mb-2" %>
<%= password_input f, :password, class: "transition bg-grey-lighter appearance-none border border-transparent rounded w-full p-2 text-grey-darker leading-normal appearance-none focus:outline-none focus:bg-white focus:border-grey-light" %>
<p class="text-grey-dark text-xs my-2">Forgot password? <a href="/password/request-reset">Click here</a> to reset it.</p>
</div>
<%= submit "Login →", class: "button mt-4 w-full" %>
<p class="text-center text-grey-dark text-xs mt-4">
Don't have an account? <%= link("Register", to: "/register") %> instead.
</p>
<% end %>

View File

@ -0,0 +1,14 @@
<%= form_for @conn, "/password", [class: "bg-white max-w-sm w-full mx-auto shadow-md rounded px-8 py-6 mt-8"], fn f -> %>
<h2>Set your password</h2>
<p class="text-grey-dark text-sm mt-1 mb-2">Min 6 characters</p>
<div class="my-4">
<%= password_input f, :password, class: "transition bg-grey-lighter appearance-none border border-transparent rounded w-full p-2 text-grey-darker leading-normal appearance-none focus:outline-none focus:bg-white focus:border-grey-light" %>
<%= if @conn.assigns[:changeset] do %>
<%= error_tag @changeset, :password %>
<% end %>
</div>
<%= submit "Set password →", class: "button mt-4 w-full" %>
<p class="text-center text-grey-dark text-xs mt-4">
Don't have an account? <%= link("Register", to: "/register") %> instead.
</p>
<% end %>

View File

@ -0,0 +1,15 @@
<%= form_for @conn, "/password/reset", [class: "bg-white max-w-sm w-full mx-auto shadow-md rounded px-8 py-6 mt-8"], fn f -> %>
<h2>Reset your password</h2>
<p class="text-grey-dark text-sm mt-1 mb-2">Min 6 characters</p>
<div class="my-4 mt-8">
<%= password_input f, :password, class: "transition bg-grey-lighter appearance-none border border-transparent rounded w-full p-2 text-grey-darker leading-normal appearance-none focus:outline-none focus:bg-white focus:border-grey-light" %>
<%= if @conn.assigns[:changeset] do %>
<%= error_tag @changeset, :password %>
<% end %>
</div>
<%= hidden_input f, :token, value: @token %>
<%= submit "Set password →", class: "button mt-4 w-full" %>
<p class="text-center text-grey-dark text-xs mt-4">
Don't have an account? <%= link("Register", to: "/register") %> instead.
</p>
<% end %>

View File

@ -0,0 +1,11 @@
<%= form_for @conn, "/password/request-reset", [class: "max-w-sm w-full mx-auto bg-white shadow-md rounded px-8 py-6 mt-8"], fn f -> %>
<h2>Reset your password</h2>
<div class="mt-4">Enter your email so we can send a password reset link</div>
<div class="my-4 mt-8">
<%= email_input f, :email, class: "transition bg-grey-lighter appearance-none border border-transparent rounded w-full p-2 text-grey-darker leading-normal appearance-none focus:outline-none focus:bg-white focus:border-grey-light", placeholder: "user@example.com" %>
</div>
<%= if @conn.assigns[:error] do %>
<div class="text-red text-xs italic my-2"><%= @conn.assigns[:error] %></div>
<% end %>
<%= submit "Send reset link →", class: "button mt-4 w-full" %>
<% end %>

View File

@ -0,0 +1,14 @@
<div class="bg-white max-w-sm w-full mx-auto shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8">
<div class="flex items-center justify-between">
<h2>Success!</h2>
</div>
<div class="my-4 leading-tight">
We have sent an email with password reset instructions to <b><%= @email %></b> if it exists in our database.
</div>
<div class="mt-8 text-sm">
Didn't recieve an email?
</div>
<div class="mt-2 text-sm text-grey-dark leading-tight">
Please check your spam folder and contact <a href="mailto:uku@plausible.io">uku@plausible.io</a> if the problem persists
</div>
</div>

View File

@ -0,0 +1,20 @@
<%= form_for @changeset, "/register", [class: "max-w-sm w-full mx-auto bg-white shadow-md rounded px-8 py-6 mb-4 mt-8"], fn f -> %>
<div class="flex items-center justify-between">
<h2>Enter your details to get started</h2>
</div>
<div class="my-4 mt-8">
<%= label f, :name, "Full name", class: "block text-grey-darker text-sm font-bold mb-2" %>
<%= text_input f, :name, class: "transition bg-grey-lighter appearance-none border border-transparent rounded w-full p-2 text-grey-darker leading-normal appearance-none focus:outline-none focus:bg-white focus:border-grey-light", placeholder: "Jane Doe" %>
<%= error_tag f, :name %>
</div>
<div class="my-4">
<%= label f, :email, class: "block text-grey-darker text-sm font-bold mb-2" %>
<p class="text-grey-dark text-xs mt-1 mb-2">No spam, guaranteed.</p>
<%= email_input f, :email, class: "transition bg-grey-lighter appearance-none border border-transparent rounded w-full p-2 text-grey-darker leading-normal appearance-none focus:outline-none focus:bg-white focus:border-grey-light", placeholder: "example@email.com" %>
<%= error_tag f, :email %>
</div>
<%= submit "Send activation link →", class: "button mt-4 w-full" %>
<p class="text-center text-grey-dark text-xs mt-4">
Already have an account? <%= link("Log in", to: "/login") %> instead.
</p>
<% end %>

View File

@ -0,0 +1,14 @@
<div class="bg-white max-w-sm w-full mx-auto shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8">
<div class="flex items-center justify-between">
<h2>Success!</h2>
</div>
<div class="my-4 leading-tight">
We've sent an activation link to <b><%= @email %></b>. Please click on the link to activate your account.
</div>
<div class="mt-8 text-sm">
Didn't recieve an email?
</div>
<div class="mt-2 text-sm text-grey-dark leading-tight">
Please check your spam folder and contact <a href="mailto:uku@plausible.io">uku@plausible.io</a> if the problem persists
</div>
</div>

View File

@ -0,0 +1,82 @@
<div class="max-w-md mx-auto bg-white shadow-md rounded rounded-t-none border-t-2 border-orange-lighter px-8 pt-6 pb-8 mt-24">
<h2 class="font-black">Subscription Plan</h2>
<div class="mt-4 border-b border-grey-light"></div>
<div class="flex flex-col items-center sm:flex-row sm:items-start justify-between mt-8">
<div class="text-center bg-grey-lighter py-8 px-6 rounded h-32 my-4" style="width: 11.75rem;">
<h4 class="font-black">Current plan</h4>
<%= if @subscription do %>
<div class="text-xl py-2 font-medium"><%= subscription_name(@subscription) %></div>
<%= link("Change plan", to: "/billing/change-plan", class: "text-sm text-indigo font-medium") %>
<% else %>
<div class="text-xl py-2 font-medium">Free trial</div>
<%= link("Upgrade", to: "/billing/upgrade", class: "text-sm text-indigo font-medium") %>
<% end %>
</div>
<div class="text-center bg-grey-lighter py-8 px-6 rounded h-32 my-4" style="width: 11.75rem;">
<h4 class="font-black">Next bill amount</h4>
<%= if @subscription do %>
<div class="text-xl py-2 font-medium">$<%= @subscription.next_bill_amount %></div>
<%= link("Update billing info", to: @subscription.update_url, class: "text-sm text-indigo font-medium") %>
<% else %>
<div class="text-xl py-2 font-medium">$0</div>
<% end %>
</div>
<div class="text-center bg-grey-lighter py-8 px-6 rounded h-32 my-4" style="width: 11.75rem;">
<h4 class="font-black">Next bill date</h4>
<%= if @subscription do %>
<div class="text-xl py-2 font-medium"><%= Timex.format!(@subscription.next_bill_date, "{Mshort} {D}, {YYYY}") %></div>
<% else %>
<div class="text-xl py-2 font-medium"></div>
<% end %>
</div>
</div>
<h3 class="mt-8">Your usage</h3>
<div class="py-2">
<b><%= delimit_integer(Plausible.Billing.usage(@conn.assigns[:current_user])) %></b>
pageviews in the last 30 days
</div>
<%= if @subscription do %>
<div class="mt-8">
<%= link("Cancel my subscription", to: @subscription.cancel_url, class: "text-indigo text-sm") %>
</div>
<% end %>
</div>
<div class="max-w-md mx-auto bg-white shadow-md rounded rounded-t-none border-t-2 border-indigo-lightest px-8 pt-6 pb-8 mt-16">
<div class="flex items-center justify-between">
<h2 class="font-black">Account settings</h2>
</div>
<div class="my-4 border-b border-grey-light"></div>
<%= form_for @changeset, "/settings", [class: "max-w-xs"], fn f -> %>
<div class="my-4">
<%= label f, :name, class: "block text-grey-darker text-sm font-bold mb-2" %>
<%= text_input f, :name, class: "transition bg-grey-lighter appearance-none border border-transparent rounded w-full p-2 text-grey-darker leading-normal appearance-none focus:outline-none focus:border-grey-light" %>
<%= error_tag f, :name %>
</div>
<div class="my-4">
<%= label f, :email, class: "block text-grey-darker text-sm font-bold mb-2" %>
<%= email_input f, :email, class: "transition bg-grey-lighter appearance-none border border-transparent rounded w-full p-2 text-grey-darker leading-normal appearance-none focus:outline-none focus:border-grey-light" %>
<%= error_tag f, :email %>
</div>
<%= submit "Save changes", class: "button mt-4" %>
<% end %>
</div>
<div class="max-w-md mx-auto bg-white shadow-md rounded rounded-t-none border-t-2 border-red-dark px-8 pt-6 pb-8 mt-16 mb-24">
<div class="flex items-center justify-between">
<h2 class="font-black">Delete account</h2>
</div>
<div class="my-4 border-b border-grey-light"></div>
<p class="text-lg">Deleting your account removes all sites and stats you've collected</p>
<%= link "Delete my account ", to: "/me", method: :delete, class: "button bg-red-dark mt-4", data: [confirm: "Deleting your account cannot be reversed. Are you sure?"] %>
</div>

View File

@ -0,0 +1 @@
<button class="paddle_button button button-sm" data-theme="none" data-product="<%= Plausible.Billing.Plans.paddle_id_for_plan(@plan) %>" data-email="<%= @conn.assigns[:current_user].email %>" data-disable-logout="true" data-coupon="<%= Plausible.Billing.coupon_for(@conn.assigns[:current_user]) %>" data-passthrough="<%= @conn.assigns[:current_user].id %>" data-success="/billing/success">Select</button>

View File

@ -0,0 +1,56 @@
<div class="max-w-sm w-full mx-auto bg-white shadow-md rounded px-8 py-6 mb-4 mt-8">
<h2 class="font-black">Change subscription plan</h2>
<div class="py-8">
If you choose to change your plan, your current subscription will
be prorated.
</div>
<div class="w-full flex justify-between items-center py-4 border-t">
<span><b>10,000</b> / mo</span>
<b class="bg-orange-lighter text-orange-darkest p-1 rounded-sm">$6 / mo</b>
<%= if Plausible.Billing.Plans.is?(@subscription, :personal) do %>
<div>
<svg class="feather text-green"><use xlink:href="#feather-check-circle" /></svg>
Selected
</div>
<% else %>
<%= button("Select", class: "button button-sm", method: :post, to: "/billing/change-plan/personal") %>
<% end %>
</div>
<div class="w-full flex justify-between items-center py-4 border-t">
<span><b>100,000</b> / mo</span>
<b class="bg-orange-lighter text-orange-darkest p-1 rounded-sm">$12 / mo</b>
<%= if Plausible.Billing.Plans.is?(@subscription, :startup) do %>
<div>
<svg class="feather text-green"><use xlink:href="#feather-check-circle" /></svg>
Selected
</div>
<% else %>
<%= button("Select", class: "button button-sm", method: :post, to: "/billing/change-plan/startup") %>
<% end %>
</div>
<div class="w-full flex justify-between items-center py-4 border-t">
<span><b>1,000,000</b> / mo</span>
<b class="bg-orange-lighter text-orange-darkest p-1 rounded-sm">$36 / mo</b>
<%= if Plausible.Billing.Plans.is?(@subscription, :business) do %>
<div>
<svg class="feather text-green"><use xlink:href="#feather-check-circle" /></svg>
Selected
</div>
<% else %>
<%= button("Select", class: "button button-sm", method: :post, to: "/billing/change-plan/business") %>
<% end %>
</div>
<script type="text/javascript" src="https://cdn.paddle.com/paddle/paddle.js"></script>
<script type="text/javascript" src="<%= Routes.static_path(@conn, "/js/checkout.js") %>"></script>
<script type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
<script>
window.Checkout.init(
<%= @conn.assigns[:current_user].id %>,
'<%= @conn.assigns[:current_user].email %>'
)
</script>
</div>

View File

@ -0,0 +1,59 @@
<div class="max-w-sm w-full mx-auto bg-white shadow-md rounded px-8 py-6 mb-4 mt-8">
<h2 class="font-black">Upgrade your free trial</h2>
<div class="mt-4">
<%= cond do %>
<% Timex.equal?(@trial_end_date, @today) -> %>
Today is the last day of your trial. Please select a plan and enter
your billing info to access your stats after the trial ends.
<% Timex.after?(@trial_end_date, @today) -> %>
<p class="py-2">
Your free trial ends on <b><%= Timex.format!(@trial_end_date, "{WDshort} {D} {Mshort}") %></b>. Please select a plan and enter
your billing info to access your stats after the trial ends.
</p>
<% Timex.before?(@trial_end_date, @today) -> %>
<p class="py-2">
Your free trial ended on <%= Timex.format!(@trial_end_date, "{Mshort} {D}")%>. To access your stats going forward, please select a plan and enter your billing info.
</p>
<% end %>
<p class="py-2">
If you go over your limit in the future, we will contact you about upgrading.
Don't worry, we will never bill you unexpectedly or stop recording without telling you first.
</p>
<p class="py-2">
All plans are on a monthly basis, cancel any time.
<%= if Plausible.Billing.was_beta_user(@conn.assigns[:current_user]) do %>
As a beta user, you will get 33% off on all plans. The coupon will be applied at checkout.
<% end %>
</p>
</div>
<div class="pt-8"></div>
<div class="w-full flex justify-between items-center py-4 border-t">
<span><b>10,000</b> / mo</span>
<b class="bg-orange-lighter text-orange-darkest p-1 rounded-sm">$6 / mo</b>
<%= render("_checkout_button.html", conn: @conn, plan: :personal) %>
</div>
<div class="w-full flex justify-between items-center py-4 border-t">
<span><b>100,000</b> / mo</span>
<b class="bg-orange-lighter text-orange-darkest p-1 rounded-sm">$12 / mo</b>
<%= render("_checkout_button.html", conn: @conn, plan: :startup) %>
</div>
<div class="w-full flex justify-between items-center py-4 border-t">
<span><b>1,000,000</b> / mo</span>
<b class="bg-orange-lighter text-orange-darkest p-1 rounded-sm">$36 / mo</b>
<%= render("_checkout_button.html", conn: @conn, plan: :business) %>
</div>
<div class="w-full flex justify-between items-center py-4 border-t">
<span>Your usage: <b><%= PlausibleWeb.AuthView.delimit_integer(@usage) %></b> pageviews in the last 30 days</span>
</div>
<div class="text-center mt-8">
Questions? Contact <%= link("uku@plausibile.io", to: "mailto: uku@plausibile.io", class: "text-indigo") %>
</div>
<script type="text/javascript" src="https://cdn.paddle.com/paddle/paddle.js"></script>
<script>Paddle.Setup({vendor: 49430})</script>
</div>

View File

@ -0,0 +1,5 @@
Hi <%= @name %>,
<br /><br />
Thank you for signing up to Plausible. <a href="<%= @link %>">Click here</a> to activate your account.
<br /><br />
This link will expire in 24 hours. If you don't use it by then, you can request another activation link.

View File

@ -0,0 +1,17 @@
Hey <%= user_salutation(@user) %>,
<br /><br />
Big thanks for giving Plausible a try at such an early stage.
<br /><br />
Now that you've had some time to use the product, I'd love to know how it's working for you. I've created a Typeform with 4 quick questions about your experience; it would help a lot if you could fill it out.
<br /><br />
<%= link("Take the survey", to: "https://plausible.typeform.com/to/szHj56") %>
<br /><br />
I will use your response to help prioritise the roadmap for the next months. You can request features and let me know about any annoyances/bugs you've encountered. Be completely honest, I can take it :)
<br /><br />
Thanks,<br />
Uku Taht<br />
Founder, Plausible Insights
<br /><br />
--
<br /><br />
https://plausible.io

View File

@ -0,0 +1,13 @@
Hey <%= user_salutation(@user) %>,
<br /><br />
I saw that you signed up for Plausible but the setup isn't working completely. Is there anything I can do to help?
<br /><br />
If you have any questions let me know.
<br /><br />
Thanks,<br />
Uku Taht<br />
Founder, Plausible Insights
<br /><br />
--
<br /><br />
https://plausible.io

View File

@ -0,0 +1,2 @@
<a href="<%= @reset_link %>">Click here</a> to reset your Plausible password.<br /><br />
This link will expire in 1 hour. If you don't use it by then, you can request another login link.

Some files were not shown because too many files have changed in this diff Show More