boutique 0.0.11 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +22 -20
- data/{boutique → bin/boutique} +3 -7
- data/config.ru +5 -4
- data/lib/boutique.rb +9 -352
- data/lib/boutique/app.rb +60 -0
- data/lib/boutique/config.rb +101 -0
- data/lib/boutique/emailer.rb +109 -0
- data/lib/boutique/model.rb +86 -0
- data/lib/boutique/version.rb +3 -0
- data/migrate/001_init_schema.rb +21 -0
- data/public/boutique/script.js +144 -0
- data/public/boutique/styles.css +123 -0
- data/test/app_test.rb +36 -15
- data/test/config_test.rb +3 -9
- data/test/emailer_test.rb +7 -5
- data/test/helper.rb +26 -29
- data/test/model_test.rb +8 -3
- metadata +69 -86
data/lib/boutique/app.rb
ADDED
@@ -0,0 +1,60 @@
|
|
1
|
+
module Boutique
|
2
|
+
class App < Sinatra::Base
|
3
|
+
set :logging, true
|
4
|
+
set :raise_errors, false
|
5
|
+
set :show_exceptions, false
|
6
|
+
|
7
|
+
error do
|
8
|
+
err = request.env['sinatra.error']
|
9
|
+
Pony.mail(
|
10
|
+
:to => Boutique.config.error_email,
|
11
|
+
:subject => "[Boutique Error] #{err.message}",
|
12
|
+
:body => [request.env.to_s, err.message, err.backtrace].compact.flatten.join("\n")
|
13
|
+
) if Boutique.config.error_email
|
14
|
+
raise err
|
15
|
+
end
|
16
|
+
|
17
|
+
get '/subscribe/:list_key' do
|
18
|
+
list = get_list(params[:list_key])
|
19
|
+
begin
|
20
|
+
subscriber = Subscriber.find_or_create(
|
21
|
+
list_key: list.key,
|
22
|
+
email: params[:email])
|
23
|
+
Emailer.new(list).deliver_zero(subscriber) rescue nil
|
24
|
+
jsonp
|
25
|
+
rescue Sequel::ValidationFailed => e
|
26
|
+
halt 400, e.message
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
get '/confirm/:list_key/:id/:secret' do
|
31
|
+
list = get_list(params[:list_key])
|
32
|
+
subscriber = get_subscriber(params[:id], list, params[:secret])
|
33
|
+
subscriber.confirm!(params[:secret])
|
34
|
+
jsonp
|
35
|
+
end
|
36
|
+
|
37
|
+
get '/unsubscribe/:list_key/:id/:secret' do
|
38
|
+
list = get_list(params[:list_key])
|
39
|
+
subscriber = get_subscriber(params[:id], list, params[:secret])
|
40
|
+
subscriber.unconfirm!(params[:secret])
|
41
|
+
jsonp
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
def jsonp
|
46
|
+
params[:jsonp].nil? ? '' : "#{params[:jsonp]}()"
|
47
|
+
end
|
48
|
+
|
49
|
+
def get_list(list_key)
|
50
|
+
List[list_key] || halt(404)
|
51
|
+
end
|
52
|
+
|
53
|
+
def get_subscriber(id, list, secret)
|
54
|
+
Subscriber.first(
|
55
|
+
id: params[:id],
|
56
|
+
list_key: list.key,
|
57
|
+
secret: secret) || halt(404)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
module Boutique
|
2
|
+
class << self
|
3
|
+
attr_accessor :database
|
4
|
+
|
5
|
+
def configure
|
6
|
+
yield config
|
7
|
+
Pony.options = config.email_options if !config.email_options.nil?
|
8
|
+
Boutique.database = Sequel.connect(config.db_options)
|
9
|
+
require_relative 'model'
|
10
|
+
end
|
11
|
+
|
12
|
+
def config
|
13
|
+
@config ||= Config.new('config')
|
14
|
+
end
|
15
|
+
|
16
|
+
def product(key)
|
17
|
+
yield Product.new(key)
|
18
|
+
end
|
19
|
+
|
20
|
+
def list(key)
|
21
|
+
yield List.new(key)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
module MemoryResource
|
26
|
+
def self.included(base)
|
27
|
+
base.extend(ClassMethods)
|
28
|
+
base.attr_resource :key
|
29
|
+
base.reset_db
|
30
|
+
end
|
31
|
+
|
32
|
+
module ClassMethods
|
33
|
+
def attr_resource(*names)
|
34
|
+
names.each do |name|
|
35
|
+
define_method(name) do |*args|
|
36
|
+
value = args[0]
|
37
|
+
instance_variable_set("@#{name}".to_sym, value) if !value.nil?
|
38
|
+
instance_variable_get("@#{name}".to_sym)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def reset_db
|
44
|
+
@db = {}
|
45
|
+
end
|
46
|
+
|
47
|
+
def [](key)
|
48
|
+
@db[key]
|
49
|
+
end
|
50
|
+
|
51
|
+
def []=(key, value)
|
52
|
+
@db[key] = value
|
53
|
+
end
|
54
|
+
|
55
|
+
def include?(key)
|
56
|
+
self.to_a.include?(key)
|
57
|
+
end
|
58
|
+
|
59
|
+
def to_a
|
60
|
+
@db.keys
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def initialize(key)
|
65
|
+
@key = key
|
66
|
+
self.class[key] = self
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
class Config
|
71
|
+
include MemoryResource
|
72
|
+
attr_resource :error_email,
|
73
|
+
:stripe_api_key,
|
74
|
+
:download_dir,
|
75
|
+
:download_path,
|
76
|
+
:db_options,
|
77
|
+
:email_options
|
78
|
+
end
|
79
|
+
|
80
|
+
class Product
|
81
|
+
include MemoryResource
|
82
|
+
attr_resource :from, :files, :price
|
83
|
+
end
|
84
|
+
|
85
|
+
class List
|
86
|
+
include MemoryResource
|
87
|
+
attr_resource :from, :emails, :url
|
88
|
+
|
89
|
+
def subscribers
|
90
|
+
Subscriber.where(list_key: self.key)
|
91
|
+
end
|
92
|
+
|
93
|
+
def subscribe_url
|
94
|
+
url = URI.parse(self.url)
|
95
|
+
params = [url.query]
|
96
|
+
params << "boutique=subscribe/#{CGI.escape(self.key)}"
|
97
|
+
url.query = params.compact.join('&')
|
98
|
+
url.to_s
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,109 @@
|
|
1
|
+
module Boutique
|
2
|
+
class Emailer
|
3
|
+
def initialize(list, directory = nil)
|
4
|
+
@list = list
|
5
|
+
@directory = directory
|
6
|
+
end
|
7
|
+
|
8
|
+
def render(path, locals = {}, pre = false)
|
9
|
+
path = @directory ?
|
10
|
+
File.join(@directory, path) :
|
11
|
+
full_path(path)
|
12
|
+
raise "File not found: #{path}" if !File.exist?(path)
|
13
|
+
|
14
|
+
yaml, body = preamble(path)
|
15
|
+
templates_for(path).each do |template|
|
16
|
+
blk = proc { body }
|
17
|
+
body = template.new(path, &blk).render(self, locals)
|
18
|
+
end
|
19
|
+
|
20
|
+
pre ? [yaml, body] : body
|
21
|
+
end
|
22
|
+
|
23
|
+
def deliver(subscriber, path, locals = {})
|
24
|
+
locals = locals.merge(
|
25
|
+
subscribe_url: @list.subscribe_url,
|
26
|
+
confirm_url: subscriber.confirm_url,
|
27
|
+
unsubscribe_url: subscriber.unsubscribe_url)
|
28
|
+
yaml, body = self.render(path, locals, true)
|
29
|
+
if yaml['day'] == 0
|
30
|
+
ymd = Date.today.strftime("%Y-%m-%d")
|
31
|
+
Email.create(email_key: "#{yaml['key']}-#{ymd}", subscriber: subscriber)
|
32
|
+
else
|
33
|
+
raise "Unconfirmed #{subscriber.email} for #{yaml['key']}" if !subscriber.confirmed
|
34
|
+
Email.create(email_key: yaml['key'], subscriber: subscriber)
|
35
|
+
end
|
36
|
+
Pony.mail(
|
37
|
+
to: subscriber.email,
|
38
|
+
from: @list.from,
|
39
|
+
subject: yaml['subject'],
|
40
|
+
headers: {'Content-Type' => 'text/html'},
|
41
|
+
body: body)
|
42
|
+
rescue DataMapper::SaveFailureError
|
43
|
+
raise "Duplicate email #{yaml['key']} to #{subscriber.email}"
|
44
|
+
end
|
45
|
+
|
46
|
+
def deliver_zero(subscriber)
|
47
|
+
self.deliver(subscriber, emails[0])
|
48
|
+
end
|
49
|
+
|
50
|
+
def blast(path, locals = {})
|
51
|
+
yaml, body = preamble(full_path(path))
|
52
|
+
email_key = yaml['key']
|
53
|
+
@list.subscribers.where(confirmed: true).each do |subscriber|
|
54
|
+
# TODO: speed up by moving filter outside of loop
|
55
|
+
if Email.first(email_key: yaml['key'], subscriber: subscriber).nil?
|
56
|
+
self.deliver(subscriber, path, locals)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def drip
|
62
|
+
today = Date.today
|
63
|
+
max_day = emails.keys.max || 0
|
64
|
+
subscribers = @list.subscribers.
|
65
|
+
where(confirmed: true).
|
66
|
+
where { (drip_on < today) & (drip_day < max_day) }
|
67
|
+
subscribers.each do |subscriber|
|
68
|
+
subscriber.drip_on = today
|
69
|
+
subscriber.drip_day += 1
|
70
|
+
subscriber.save
|
71
|
+
if (email_path = emails[subscriber.drip_day])
|
72
|
+
self.deliver(subscriber, email_path)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
private
|
78
|
+
def full_path(path)
|
79
|
+
File.join(@list.emails, path)
|
80
|
+
end
|
81
|
+
|
82
|
+
def templates_for(path)
|
83
|
+
basename = File.basename(path)
|
84
|
+
basename.split('.')[1..-1].reverse.map { |ext| Tilt[ext] }
|
85
|
+
end
|
86
|
+
|
87
|
+
def emails
|
88
|
+
@emails ||= begin
|
89
|
+
emails = {}
|
90
|
+
Dir.entries(@list.emails).each do |filename|
|
91
|
+
next if File.directory?(filename)
|
92
|
+
# TODO: stop duplicating calls to preamble, store in memory
|
93
|
+
yaml, body = preamble(full_path(filename))
|
94
|
+
if yaml && yaml['day'] && yaml['key']
|
95
|
+
emails[yaml['day']] = filename
|
96
|
+
end
|
97
|
+
end
|
98
|
+
emails
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def preamble(path)
|
103
|
+
data = Preamble.load(path)
|
104
|
+
[data.metadata, data.content]
|
105
|
+
rescue
|
106
|
+
[{}, File.read(path)]
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
module Boutique
|
2
|
+
class Migrate
|
3
|
+
def self.run
|
4
|
+
Sequel.extension :migration
|
5
|
+
Sequel::Migrator.run(Boutique.database, File.join(File.dirname(__FILE__), '..', '..', 'migrate'))
|
6
|
+
# re-parse the schema after table changes
|
7
|
+
Subscriber.dataset = Subscriber.dataset
|
8
|
+
Email.dataset = Email.dataset
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
class Subscriber < Sequel::Model
|
13
|
+
one_to_many :emails
|
14
|
+
set_allowed_columns :list_key, :email
|
15
|
+
|
16
|
+
def initialize(*args)
|
17
|
+
super
|
18
|
+
self.secret ||= SecureRandom.hex(3)
|
19
|
+
self.drip_on ||= Date.today
|
20
|
+
self.created_at ||= DateTime.now
|
21
|
+
end
|
22
|
+
|
23
|
+
def validate
|
24
|
+
super
|
25
|
+
errors.add(:list_key, 'is invalid') if !List.include?(list_key)
|
26
|
+
errors.add(:email, 'is invalid') if email !~ /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/i
|
27
|
+
|
28
|
+
count = self.class.
|
29
|
+
where(list_key: list_key, email: email).
|
30
|
+
exclude(id: id).
|
31
|
+
count
|
32
|
+
errors.add(:email, 'has already subscribed') if count > 0
|
33
|
+
end
|
34
|
+
|
35
|
+
def list
|
36
|
+
@list ||= List[self.list_key]
|
37
|
+
end
|
38
|
+
|
39
|
+
def confirm!(secret)
|
40
|
+
self.confirmed = true if self.secret == secret
|
41
|
+
self.save
|
42
|
+
end
|
43
|
+
|
44
|
+
def unconfirm!(secret)
|
45
|
+
self.confirmed = false if self.secret == secret
|
46
|
+
self.save
|
47
|
+
end
|
48
|
+
|
49
|
+
def confirm_url
|
50
|
+
secret_url("confirm")
|
51
|
+
end
|
52
|
+
|
53
|
+
def unsubscribe_url
|
54
|
+
secret_url("unsubscribe")
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
def secret_url(action)
|
59
|
+
url = URI.parse(self.list.url)
|
60
|
+
params = [url.query]
|
61
|
+
params << "boutique=#{action}/#{CGI.escape(self.list_key)}/#{self.id}/#{self.secret}"
|
62
|
+
url.query = params.compact.join('&')
|
63
|
+
url.to_s
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
class Email < Sequel::Model
|
68
|
+
many_to_one :subscriber
|
69
|
+
|
70
|
+
def initialize(*args)
|
71
|
+
super
|
72
|
+
self.created_at ||= DateTime.now
|
73
|
+
end
|
74
|
+
|
75
|
+
def validate
|
76
|
+
super
|
77
|
+
errors.add(:subscriber_id, "can't be blank") if subscriber_id.nil?
|
78
|
+
|
79
|
+
count = self.class.
|
80
|
+
where(subscriber_id: subscriber_id, email_key: email_key).
|
81
|
+
exclude(id: id).
|
82
|
+
count
|
83
|
+
errors.add(:email_key, 'has already been sent') if count > 0
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
Sequel.migration do
|
2
|
+
change do
|
3
|
+
create_table(:subscribers) do
|
4
|
+
primary_key :id
|
5
|
+
column :list_key, String, null: false
|
6
|
+
column :email, String, null: false
|
7
|
+
column :secret, String, null: false
|
8
|
+
column :confirmed, TrueClass, null: false, default: false
|
9
|
+
column :created_at, DateTime, null: false
|
10
|
+
column :drip_on, Date, null: false
|
11
|
+
column :drip_day, Integer, null: false, default: 0
|
12
|
+
end
|
13
|
+
|
14
|
+
create_table(:emails) do
|
15
|
+
primary_key :id
|
16
|
+
foreign_key :subscriber_id, :subscribers
|
17
|
+
column :email_key, String, null: false
|
18
|
+
column :created_at, DateTime, null: false
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,144 @@
|
|
1
|
+
var Boutique = {
|
2
|
+
URL: "/boutique/",
|
3
|
+
lists: {},
|
4
|
+
subscribe: function(key) {
|
5
|
+
var modal = this.buildModal(key);
|
6
|
+
modal.show();
|
7
|
+
$("body").css("overflow-y", "hidden");
|
8
|
+
if (modal.hasClass("unsubscribed")) {
|
9
|
+
modal.removeClass("unsubscribed").addClass("start");
|
10
|
+
}
|
11
|
+
return modal;
|
12
|
+
},
|
13
|
+
showModal: function(key, action) {
|
14
|
+
var modal = Boutique.buildModal(key);
|
15
|
+
var state = {confirm: "confirmed", unsubscribe: "unsubscribed"}[action];
|
16
|
+
modal.removeClass("start subscribed confirmed unsubscribed");
|
17
|
+
modal.addClass(state);
|
18
|
+
modal.show();
|
19
|
+
$("body").css("overflow-y", "hidden");
|
20
|
+
},
|
21
|
+
hideModal: function() {
|
22
|
+
$(".boutique").hide();
|
23
|
+
$("body").css("overflow-y", "auto");
|
24
|
+
},
|
25
|
+
list: function(key, configs) {
|
26
|
+
this.lists[key] = configs;
|
27
|
+
var params = this.params("boutique");
|
28
|
+
if (params) {
|
29
|
+
params = params.split("/");
|
30
|
+
var pAction = params[0];
|
31
|
+
var pKey = params[1];
|
32
|
+
var pId = params[2];
|
33
|
+
var pSecret = params[3];
|
34
|
+
if (key == pKey && pAction == "subscribe") {
|
35
|
+
Boutique.subscribe(key);
|
36
|
+
} else if (key == pKey && ($.inArray(pAction, ["subscribe", "unsubscribe"]))) {
|
37
|
+
if (document.domain == "localhost" && pId == "0") {
|
38
|
+
Boutique.showModal(key, pAction);
|
39
|
+
} else {
|
40
|
+
$.ajax(this.URL + pAction + "/" + key + "/" + pId + "/" + pSecret, {
|
41
|
+
dataType: "jsonp",
|
42
|
+
jsonp: "jsonp",
|
43
|
+
success: function() {
|
44
|
+
Boutique.showModal(key, pAction);
|
45
|
+
}
|
46
|
+
});
|
47
|
+
}
|
48
|
+
}
|
49
|
+
}
|
50
|
+
},
|
51
|
+
buildModal: function(key) {
|
52
|
+
var modal = $("#boutique-list-" + key);
|
53
|
+
if (modal.length) { return modal; }
|
54
|
+
|
55
|
+
var html = "";
|
56
|
+
html += '<div class="boutique start">';
|
57
|
+
html += ' <div class="boutique-modal">';
|
58
|
+
html += ' <div class="boutique-close">×</div>';
|
59
|
+
html += ' </div>';
|
60
|
+
html += '</div>';
|
61
|
+
|
62
|
+
modal = $(html);
|
63
|
+
modal.click(function(e) {
|
64
|
+
if (e.target != this) { return; }
|
65
|
+
e.preventDefault();
|
66
|
+
Boutique.hideModal();
|
67
|
+
});
|
68
|
+
modal.find(".boutique-close").click(function(e) {
|
69
|
+
e.preventDefault();
|
70
|
+
Boutique.hideModal();
|
71
|
+
});
|
72
|
+
modal.attr("id", "boutique-list-" + key);
|
73
|
+
|
74
|
+
var states = ["start", "subscribed", "confirmed", "unsubscribed"];
|
75
|
+
for (var i = 0; i < states.length; i++) {
|
76
|
+
var state = states[i];
|
77
|
+
var head = $('<div class="boutique-head '+ state +'"></div>');
|
78
|
+
var body = $('<div class="boutique-body '+ state +'"></div>');
|
79
|
+
head.html(this.lists[key][state]["title"]);
|
80
|
+
body.html(this.lists[key][state]["body"]);
|
81
|
+
if (state == "start") {
|
82
|
+
body.append(this.buildSubscribeForm(key));
|
83
|
+
} else if (this.lists[key][state]["button"]) {
|
84
|
+
body.append(this.buildButton(key, state));
|
85
|
+
}
|
86
|
+
modal.find(".boutique-modal").prepend(head, body);
|
87
|
+
}
|
88
|
+
$("body").append(modal);
|
89
|
+
return modal;
|
90
|
+
},
|
91
|
+
buildSubscribeForm: function(key) {
|
92
|
+
var html = "";
|
93
|
+
html += '<form>';
|
94
|
+
html += ' <p>';
|
95
|
+
html += ' <input type="email" name="email" placeholder="Email Address" /><br>';
|
96
|
+
html += ' <input type="submit" value="Subscribe Now" class="inlined" />';
|
97
|
+
html += ' </p>';
|
98
|
+
html += '</form>';
|
99
|
+
var options = this.lists[key]["start"];
|
100
|
+
|
101
|
+
var form = $(html);
|
102
|
+
form.attr("action", Boutique.URL + "subscribe/" + key);
|
103
|
+
if (options["button"]) { form.find("input[type=submit]").val(options["button"]); }
|
104
|
+
form.submit(function(e) {
|
105
|
+
e.preventDefault();
|
106
|
+
form.find("input[type=submit]").
|
107
|
+
attr("disabled", true).
|
108
|
+
val("Loading...");
|
109
|
+
if (document.domain == "localhost" &&
|
110
|
+
form.find("input[name=email]").val() == "example@example.com") {
|
111
|
+
form.parents(".boutique").removeClass("start").addClass("subscribed");
|
112
|
+
return;
|
113
|
+
}
|
114
|
+
$.ajax(form.attr("action"), {
|
115
|
+
data: form.serialize(),
|
116
|
+
dataType: "jsonp",
|
117
|
+
jsonp: "jsonp",
|
118
|
+
error: function() {
|
119
|
+
form.find("input[type=submit]").
|
120
|
+
attr("disabled", false).
|
121
|
+
val(options["button"] || "Subscribe Now");
|
122
|
+
},
|
123
|
+
success: function() {
|
124
|
+
form.parents(".boutique").
|
125
|
+
removeClass("start").
|
126
|
+
addClass("subscribed");
|
127
|
+
}
|
128
|
+
});
|
129
|
+
});
|
130
|
+
return form;
|
131
|
+
},
|
132
|
+
buildButton: function(key, state) {
|
133
|
+
var options = this.lists[key][state];
|
134
|
+
var button = $('<a class="boutique-button"></a>');
|
135
|
+
button.attr("href", options["href"]);
|
136
|
+
button.html(options["button"]);
|
137
|
+
var container = $("<p></p>");
|
138
|
+
container.append(button);
|
139
|
+
return container;
|
140
|
+
},
|
141
|
+
params: function(name) {
|
142
|
+
return decodeURIComponent((new RegExp('[?|&]' + name + '=' + '([^&;]+?)(&|#|;|$)').exec(location.search) || [,""])[1].replace(/\+/g, '%20')) || null;
|
143
|
+
}
|
144
|
+
};
|