Initial commit
5
.formatter.exs
Normal 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
@ -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
@ -0,0 +1,2 @@
|
||||
erlang 21.1
|
||||
elixir 1.7.4-otp-21
|
34
README.md
Normal 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 you’re 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
@ -0,0 +1,5 @@
|
||||
{
|
||||
"presets": [
|
||||
"@babel/preset-env"
|
||||
]
|
||||
}
|
201
assets/css/app.css
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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);
|
13
assets/js/polyfills/closest.js
Normal 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
@ -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()
|
||||
})
|
||||
})
|
||||
}
|
242
assets/js/stats/main-graph.js
Normal 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">↑ ${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">↓ ${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">↑ ${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">↓ ${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
@ -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
@ -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);
|
||||
}
|
||||
}
|
||||
|
71
assets/js/timeframe-selector.js
Normal 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
27
assets/package.json
Normal 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
@ -0,0 +1,8 @@
|
||||
var tailwindcss = require('tailwindcss');
|
||||
|
||||
module.exports = {
|
||||
plugins: [
|
||||
tailwindcss('./tailwind.js'),
|
||||
require('autoprefixer')
|
||||
]
|
||||
}
|
BIN
assets/static/favicon.ico
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
assets/static/images/icon/plausible_favicon.png
Normal file
After Width: | Height: | Size: 2.1 KiB |
BIN
assets/static/images/icon/plausible_logo.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
assets/static/images/icon/plausible_logo_inverted.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
assets/static/images/testimonials/felipe.jpg
Normal file
After Width: | Height: | Size: 8.6 KiB |
BIN
assets/static/images/testimonials/makis.jpg
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
assets/static/images/testimonials/markus.jpg
Normal file
After Width: | Height: | Size: 5.4 KiB |
5
assets/static/robots.txt
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -0,0 +1,3 @@
|
||||
erlang_version=21.1.1
|
||||
elixir_version=1.7.4
|
||||
always_rebuild=false
|
35
lib/mix/tasks/check_overuse.ex
Normal 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
|
48
lib/mix/tasks/send_feedback_emails.ex
Normal 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
|
64
lib/mix/tasks/send_intro_emails.ex
Normal 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
|
124
lib/mix/tasks/send_trial_notifications.ex
Normal 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
@ -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
|
27
lib/plausible/application.ex
Normal 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
|
30
lib/plausible/auth/auth.ex
Normal 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
|
13
lib/plausible/auth/password.ex
Normal 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
|
23
lib/plausible/auth/token.ex
Normal 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
|
41
lib/plausible/auth/user.ex
Normal 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
|
133
lib/plausible/billing/billing.ex
Normal 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
|
46
lib/plausible/billing/paddle_api.ex
Normal 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
|
40
lib/plausible/billing/plans.ex
Normal 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
|
29
lib/plausible/billing/subscription.ex
Normal 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
|
92
lib/plausible/google/api.ex
Normal 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
@ -0,0 +1,3 @@
|
||||
defmodule Plausible.Mailer do
|
||||
use Bamboo.Mailer, otp_app: :plausible
|
||||
end
|
29
lib/plausible/pageview/schema.ex
Normal 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
@ -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
|
23
lib/plausible/site/google_auth.ex
Normal 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
|
17
lib/plausible/site/membership.ex
Normal 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
|
47
lib/plausible/site/schema.ex
Normal 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
@ -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
@ -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
|
253
lib/plausible/stats/countries.ex
Normal 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
|
112
lib/plausible/stats/query.ex
Normal 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
|
||||
|
207
lib/plausible/stats/stats.ex
Normal 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
|
92
lib/plausible/timezones.ex
Normal 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
@ -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
@ -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
|
20
lib/plausible_web/auth_plug.ex
Normal 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
|
33
lib/plausible_web/channels/user_socket.ex
Normal 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
|
139
lib/plausible_web/controllers/api/external_controller.ex
Normal 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
|
17
lib/plausible_web/controllers/api/internal_controller.ex
Normal 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
|
74
lib/plausible_web/controllers/api/paddle_controller.ex
Normal 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
|
214
lib/plausible_web/controllers/auth_controller.ex
Normal 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
|
47
lib/plausible_web/controllers/billing_controller.ex
Normal 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
|
18
lib/plausible_web/controllers/helpers.ex
Normal 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
|
86
lib/plausible_web/controllers/page_controller.ex
Normal 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
|
115
lib/plausible_web/controllers/site_controller.ex
Normal 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
|
292
lib/plausible_web/controllers/stats_controller.ex
Normal 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
|
90
lib/plausible_web/email.ex
Normal 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
|
61
lib/plausible_web/endpoint.ex
Normal 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
|
35
lib/plausible_web/last_seen_plug.ex
Normal 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
|
18
lib/plausible_web/plugs/require_account.ex
Normal 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
|
16
lib/plausible_web/plugs/require_logged_out.ex
Normal 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
@ -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
|
32
lib/plausible_web/session_timeout_plug.ex
Normal 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
|
22
lib/plausible_web/templates/auth/login_form.html.eex
Normal 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 %>
|
14
lib/plausible_web/templates/auth/password_form.html.eex
Normal 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 %>
|
@ -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 %>
|
@ -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 %>
|
@ -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>
|
20
lib/plausible_web/templates/auth/register_form.html.eex
Normal 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 %>
|
14
lib/plausible_web/templates/auth/register_success.html.eex
Normal 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>
|
82
lib/plausible_web/templates/auth/user_settings.html.eex
Normal 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>
|
@ -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>
|
56
lib/plausible_web/templates/billing/change_plan.html.eex
Normal 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>
|
59
lib/plausible_web/templates/billing/upgrade.html.eex
Normal 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>
|
@ -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.
|
17
lib/plausible_web/templates/email/feedback_survey.html.eex
Normal 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
|
13
lib/plausible_web/templates/email/help_email.html.eex
Normal 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
|
@ -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.
|