FEATURE: Campaigns (#67)
Co-authored-by: Jordan Vidrine <jordan@jordanvidrine.com> See https://github.com/discourse/discourse-subscriptions/pull/67 for the full description.
This commit is contained in:
parent
dcec7703f8
commit
f596a0f78a
|
@ -13,7 +13,7 @@ module DiscourseSubscriptions
|
|||
products = []
|
||||
|
||||
if product_ids.present? && is_stripe_configured?
|
||||
products = ::Stripe::Product.list({ ids: product_ids })
|
||||
products = ::Stripe::Product.list({ ids: product_ids, limit: 100 })
|
||||
products = products[:data]
|
||||
elsif !is_stripe_configured?
|
||||
products = nil
|
||||
|
|
|
@ -5,5 +5,19 @@ module DiscourseSubscriptions
|
|||
def index
|
||||
head 200
|
||||
end
|
||||
|
||||
def refresh_campaign
|
||||
Jobs.enqueue(:manually_update_campaign_data)
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
def create_campaign
|
||||
begin
|
||||
DiscourseSubscriptions::Campaign.new.create_campaign
|
||||
render json: success_json
|
||||
rescue => e
|
||||
render_json_error e.message
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,7 +5,7 @@ module DiscourseSubscriptions
|
|||
include DiscourseSubscriptions::Stripe
|
||||
include DiscourseSubscriptions::Group
|
||||
before_action :set_api_key
|
||||
requires_login except: [:index, :show]
|
||||
requires_login except: [:index, :contributors, :show]
|
||||
|
||||
def index
|
||||
begin
|
||||
|
@ -31,6 +31,18 @@ module DiscourseSubscriptions
|
|||
end
|
||||
end
|
||||
|
||||
def contributors
|
||||
return unless SiteSetting.discourse_subscriptions_campaign_show_contributors
|
||||
contributor_ids = Set.new
|
||||
|
||||
campaign_product = SiteSetting.discourse_subscriptions_campaign_product
|
||||
campaign_product.present? ? contributor_ids.merge(Customer.where(product_id: campaign_product).last(5).pluck(:user_id)) : contributor_ids.merge(Customer.last(5).pluck(:user_id))
|
||||
|
||||
contributors = ::User.where(id: contributor_ids)
|
||||
|
||||
render_serialized(contributors, UserSerializer)
|
||||
end
|
||||
|
||||
def show
|
||||
params.require(:id)
|
||||
begin
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module ::Jobs
|
||||
class ManuallyUpdateCampaignData < ::Jobs::Base
|
||||
|
||||
def execute(args)
|
||||
return unless SiteSetting.discourse_subscriptions_campaign_enabled
|
||||
DiscourseSubscriptions::Campaign.new.refresh_data
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module ::Jobs
|
||||
class RefreshSubscriptionsCampaignData < ::Jobs::Scheduled
|
||||
every 30.minutes
|
||||
|
||||
def execute(args)
|
||||
return unless SiteSetting.discourse_subscriptions_campaign_enabled
|
||||
DiscourseSubscriptions::Campaign.new.refresh_data
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,170 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseSubscriptions
|
||||
class Campaign
|
||||
include DiscourseSubscriptions::Stripe
|
||||
def initialize
|
||||
set_api_key # instantiates Stripe API
|
||||
end
|
||||
|
||||
def refresh_data
|
||||
product_ids = Set.new(Product.all.pluck(:external_id))
|
||||
|
||||
# if a product id is set for the campaign, we only want to return those results.
|
||||
# if it's blank, return them all.
|
||||
campaign_product = SiteSetting.discourse_subscriptions_campaign_product
|
||||
if campaign_product.present?
|
||||
product_ids = product_ids.include?(campaign_product) ? [campaign_product] : []
|
||||
end
|
||||
|
||||
amount = 0
|
||||
subscriptions = get_subscription_data
|
||||
subscriptions = filter_to_subscriptions_products(subscriptions, product_ids)
|
||||
|
||||
# get number of subscribers
|
||||
SiteSetting.discourse_subscriptions_campaign_subscribers = subscriptions&.length.to_i
|
||||
|
||||
# calculate amount raised
|
||||
subscriptions&.each do |sub|
|
||||
sub_amount = calculate_monthly_amount(sub)
|
||||
amount += sub_amount
|
||||
end
|
||||
|
||||
SiteSetting.discourse_subscriptions_campaign_amount_raised = amount
|
||||
end
|
||||
|
||||
def create_campaign
|
||||
begin
|
||||
group = create_campaign_group
|
||||
product = create_campaign_product
|
||||
create_campaign_prices(product, group)
|
||||
|
||||
SiteSetting.discourse_subscriptions_campaign_enabled = true
|
||||
SiteSetting.discourse_subscriptions_campaign_product = product[:id]
|
||||
rescue ::Stripe::InvalidRequestError => e
|
||||
e
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def create_campaign_group
|
||||
campaign_group = SiteSetting.discourse_subscriptions_campaign_group
|
||||
group = ::Group.find_by_id(campaign_group) if campaign_group.present?
|
||||
|
||||
unless group
|
||||
group = ::Group.create(name: "campaign_supporters")
|
||||
|
||||
SiteSetting.discourse_subscriptions_campaign_group = group[:id]
|
||||
|
||||
params = {
|
||||
full_name: I18n.t('js.discourse_subscriptions.campaign.supporters'),
|
||||
title: I18n.t('js.discourse_subscriptions.campaign.supporter'),
|
||||
flair_icon: "donate"
|
||||
}
|
||||
|
||||
group.update(params)
|
||||
end
|
||||
|
||||
group[:name]
|
||||
end
|
||||
|
||||
def create_campaign_product
|
||||
product_params = {
|
||||
name: I18n.t('js.discourse_subscriptions.campaign.title'),
|
||||
active: true,
|
||||
metadata: {
|
||||
description: I18n.t('js.discourse_subscriptions.campaign.body'),
|
||||
}
|
||||
}
|
||||
|
||||
product = ::Stripe::Product.create(product_params)
|
||||
|
||||
Product.create(external_id: product[:id])
|
||||
|
||||
product
|
||||
end
|
||||
|
||||
def create_campaign_prices(product, group)
|
||||
# hard coded defaults to make setting this up as simple as possible
|
||||
monthly_prices = [3, 5, 10, 25]
|
||||
yearly_prices = [50, 100]
|
||||
|
||||
monthly_prices.each do |price|
|
||||
create_price(product[:id], group, price, "month")
|
||||
end
|
||||
|
||||
yearly_prices.each do |price|
|
||||
create_price(product[:id], group, price, "year")
|
||||
end
|
||||
end
|
||||
|
||||
def create_price(product_id, group_name, amount, recurrence)
|
||||
price_object = {
|
||||
nickname: "#{amount}/#{recurrence}",
|
||||
unit_amount: amount * 100,
|
||||
product: product_id,
|
||||
currency: SiteSetting.discourse_subscriptions_currency,
|
||||
active: true,
|
||||
recurring: {
|
||||
interval: recurrence
|
||||
},
|
||||
metadata: {
|
||||
group_name: group_name
|
||||
}
|
||||
}
|
||||
|
||||
plan = ::Stripe::Price.create(price_object)
|
||||
end
|
||||
|
||||
def get_subscription_data
|
||||
subscriptions = []
|
||||
current_set = {
|
||||
has_more: true,
|
||||
last_record: nil
|
||||
}
|
||||
|
||||
until current_set[:has_more] == false
|
||||
current_set = ::Stripe::Subscription.list(
|
||||
expand: ['data.plan.product'],
|
||||
limit: 100,
|
||||
starting_after: current_set[:last_record]
|
||||
)
|
||||
|
||||
current_set[:last_record] = current_set[:data].last[:id] if current_set[:data].present?
|
||||
subscriptions.concat(current_set[:data].to_a)
|
||||
end
|
||||
|
||||
subscriptions
|
||||
end
|
||||
|
||||
def filter_to_subscriptions_products(data, ids)
|
||||
valid = data.select do |sub|
|
||||
# cannot .dig stripe objects
|
||||
items = sub[:items][:data][0] if sub[:items] && sub[:items][:data]
|
||||
product = items[:price][:product] if items[:price] && items[:price][:product]
|
||||
|
||||
ids.include?(product)
|
||||
end
|
||||
valid.empty? ? nil : valid
|
||||
end
|
||||
|
||||
def calculate_monthly_amount(sub)
|
||||
items = sub[:items][:data][0] if sub[:items] && sub[:items][:data]
|
||||
price = items[:price] if items[:price]
|
||||
unit_amount = price[:unit_amount] if price[:unit_amount]
|
||||
recurrence = price[:recurring][:interval] if price[:recurring] && price[:recurring][:interval]
|
||||
|
||||
case recurrence
|
||||
when "day"
|
||||
unit_amount = unit_amount * 30
|
||||
when "week"
|
||||
unit_amount = unit_amount * 4
|
||||
when "year"
|
||||
unit_amount = unit_amount / 12
|
||||
end
|
||||
|
||||
unit_amount
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,120 @@
|
|||
import { action } from "@ember/object";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { equal } from "@ember/object/computed";
|
||||
import { setting } from "discourse/lib/computed";
|
||||
import Component from "@ember/component";
|
||||
import discourseComputed, { observes } from "discourse-common/utils/decorators";
|
||||
import { inject as service } from "@ember/service";
|
||||
|
||||
export default Component.extend({
|
||||
router: service(),
|
||||
dismissed: false,
|
||||
loading: false,
|
||||
isSidebar: equal(
|
||||
"siteSettings.discourse_subscriptions_campaign_banner_location",
|
||||
"Sidebar"
|
||||
),
|
||||
subscribers: setting("discourse_subscriptions_campaign_subscribers"),
|
||||
subscriberGoal: equal(
|
||||
"siteSettings.discourse_subscriptions_campaign_type",
|
||||
"Subscribers"
|
||||
),
|
||||
currency: setting("discourse_subscriptions_currency"),
|
||||
goalTarget: setting("discourse_subscriptions_campaign_goal"),
|
||||
product: setting("discourse_subscriptions_campaign_product"),
|
||||
showContributors: setting(
|
||||
"discourse_subscriptions_campaign_show_contributors"
|
||||
),
|
||||
classNameBindings: [
|
||||
"isSidebar:campaign-banner-sidebar",
|
||||
"shouldShow:campaign-banner",
|
||||
],
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
|
||||
this.set("contributors", []);
|
||||
|
||||
if (this.showContributors) {
|
||||
return ajax("/s/contributors", { method: "get" }).then((result) => {
|
||||
this.setProperties({
|
||||
contributors: result,
|
||||
loading: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
didInsertElement() {
|
||||
this._super(...arguments);
|
||||
if (this.isSidebar && this.shouldShow) {
|
||||
document.body.classList.add("subscription-campaign-sidebar");
|
||||
} else {
|
||||
document.body.classList.remove("subscription-campaign-sidebar");
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed(
|
||||
"router.currentRouteName",
|
||||
"currentUser",
|
||||
"siteSettings.discourse_subscriptions_campaign_enabled",
|
||||
"visible"
|
||||
)
|
||||
shouldShow(currentRoute, currentUser, enabled, visible) {
|
||||
// do not show on admin or subscriptions pages
|
||||
const showOnRoute =
|
||||
currentRoute !== "discovery.s" &&
|
||||
!currentRoute.split(".")[0].includes("admin") &&
|
||||
currentRoute.split(".")[0] !== "s";
|
||||
|
||||
return showOnRoute && currentUser && enabled && visible;
|
||||
},
|
||||
|
||||
@observes("dismissed")
|
||||
_updateBodyClasses() {
|
||||
if (this.dismissed) {
|
||||
document.body.classList.remove("subscription-campaign-sidebar");
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("dismissed")
|
||||
visible(dismissed) {
|
||||
const dismissedBannerKey = this.keyValueStore.get(
|
||||
"dismissed_campaign_banner"
|
||||
);
|
||||
const threeMonths = 2628000000 * 3;
|
||||
|
||||
const bannerDismissedTime = new Date(dismissedBannerKey);
|
||||
const now = Date.now();
|
||||
|
||||
return (
|
||||
(!dismissedBannerKey || now - bannerDismissedTime > threeMonths) &&
|
||||
!dismissed
|
||||
);
|
||||
},
|
||||
|
||||
@discourseComputed
|
||||
amountRaised() {
|
||||
return (
|
||||
this.siteSettings.discourse_subscriptions_campaign_amount_raised / 100
|
||||
);
|
||||
},
|
||||
|
||||
@discourseComputed
|
||||
isGoalMet() {
|
||||
const currentVolume = this.subscriberGoal
|
||||
? this.subscribers
|
||||
: this.amountRaised;
|
||||
|
||||
return currentVolume >= this.goalTarget;
|
||||
},
|
||||
|
||||
@action
|
||||
dismissBanner() {
|
||||
this.set("dismissed", true);
|
||||
this.keyValueStore.set({
|
||||
key: "dismissed_campaign_banner",
|
||||
value: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
|
@ -0,0 +1,69 @@
|
|||
import { action } from "@ember/object";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import Controller from "@ember/controller";
|
||||
import discourseComputed from "discourse-common/utils/decorators";
|
||||
import I18n from "I18n";
|
||||
|
||||
export default Controller.extend({
|
||||
loading: false,
|
||||
|
||||
@discourseComputed
|
||||
stripeConfigured() {
|
||||
return !!this.siteSettings.discourse_subscriptions_public_key;
|
||||
},
|
||||
|
||||
@discourseComputed
|
||||
campaignEnabled() {
|
||||
return this.siteSettings.discourse_subscriptions_campaign_enabled;
|
||||
},
|
||||
|
||||
@discourseComputed
|
||||
campaignProductSet() {
|
||||
return !!this.siteSettings.discourse_subscriptions_campaign_product;
|
||||
},
|
||||
|
||||
@action
|
||||
triggerManualRefresh() {
|
||||
ajax(`/s/admin/refresh`, {
|
||||
method: "post",
|
||||
}).then(() => {
|
||||
bootbox.alert(
|
||||
I18n.t("discourse_subscriptions.campaign.refresh_page"),
|
||||
() => {
|
||||
this.transitionToRoute(
|
||||
"adminPlugins.discourse-subscriptions.products"
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
@action
|
||||
createOneClickCampaign() {
|
||||
bootbox.confirm(
|
||||
I18n.t("discourse_subscriptions.campaign.confirm_creation"),
|
||||
(result) => {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.set("loading", true);
|
||||
|
||||
ajax(`/s/admin/create-campaign`, {
|
||||
method: "post",
|
||||
})
|
||||
.then(() => {
|
||||
this.set("loading", false);
|
||||
bootbox.alert(
|
||||
I18n.t("discourse_subscriptions.campaign.created"),
|
||||
() => {
|
||||
this.send("showSettings");
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch(popupAjaxError);
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
|
@ -14,7 +14,7 @@ export default Controller.extend({
|
|||
this._super(...arguments);
|
||||
this.set(
|
||||
"stripe",
|
||||
Stripe(Discourse.SiteSettings.discourse_subscriptions_public_key)
|
||||
Stripe(this.siteSettings.discourse_subscriptions_public_key)
|
||||
);
|
||||
const elements = this.get("stripe").elements();
|
||||
|
||||
|
@ -76,7 +76,7 @@ export default Controller.extend({
|
|||
plan.type === "recurring"
|
||||
? "user.billing.subscriptions"
|
||||
: "user.billing.payments",
|
||||
Discourse.User.current().username.toLowerCase()
|
||||
this.currentUser.username.toLowerCase()
|
||||
);
|
||||
},
|
||||
|
||||
|
|
|
@ -4,8 +4,6 @@ export default {
|
|||
|
||||
map() {
|
||||
this.route("discourse-subscriptions", function () {
|
||||
this.route("dashboard");
|
||||
|
||||
this.route("products", function () {
|
||||
this.route("show", { path: "/:product-id" }, function () {
|
||||
this.route("plans", function () {
|
||||
|
|
|
@ -1,3 +1,14 @@
|
|||
import Route from "@ember/routing/route";
|
||||
|
||||
export default Route.extend({});
|
||||
export default Route.extend({
|
||||
actions: {
|
||||
showSettings() {
|
||||
const controller = this.controllerFor("adminSiteSettings");
|
||||
this.transitionTo("adminSiteSettingsCategory", "plugins").then(() => {
|
||||
controller.set("filter", "plugin:discourse-subscriptions campaign");
|
||||
controller.set("_skipBounce", true);
|
||||
controller.filterContentNow("plugins");
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{{#if model.unconfigured}}
|
||||
<p>{{i18n 'discourse_subscriptions.admin.unconfigured'}}</p>
|
||||
<p><a href="https://meta.discourse.org/t/discourse-subscriptions/140818/">Discourse Subscriptions on Meta</a></p>
|
||||
<p><a href="https://meta.discourse.org/t/discourse-subscriptions/140818/">{{i18n 'discourse_subscriptions.admin.on_meta'}}</a></p>
|
||||
{{else}}
|
||||
{{#if model}}
|
||||
<table class="table discourse-patrons-table">
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{{#if model.unconfigured }}
|
||||
<p>{{i18n 'discourse_subscriptions.admin.unconfigured'}}</p>
|
||||
<p><a href="https://meta.discourse.org/t/discourse-subscriptions/140818/">Discourse Subscriptions on Meta</a></p>
|
||||
<p><a href="https://meta.discourse.org/t/discourse-subscriptions/140818/">{{i18n 'discourse_subscriptions.admin.on_meta'}}</a></p>
|
||||
{{else}}
|
||||
<p class="btn-right">
|
||||
{{#link-to 'adminPlugins.discourse-subscriptions.products.show' 'new' class="btn btn-primary"}}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{{#if model.unconfigured}}
|
||||
<p>{{i18n 'discourse_subscriptions.admin.unconfigured'}}</p>
|
||||
<p><a href="https://meta.discourse.org/t/discourse-subscriptions/140818/">Discourse Subscriptions on Meta</a></p>
|
||||
<p><a href="https://meta.discourse.org/t/discourse-subscriptions/140818/">{{i18n 'discourse_subscriptions.admin.on_meta'}}</a></p>
|
||||
{{else}}
|
||||
{{#load-more selector=".discourse-patrons-table tr" action=(action "loadMore")}}
|
||||
<table class="table discourse-patrons-table">
|
||||
|
|
|
@ -1,8 +1,27 @@
|
|||
|
||||
<h2>{{i18n 'discourse_subscriptions.title' site_name=siteSettings.title}}</h2>
|
||||
|
||||
{{#if stripeConfigured}}
|
||||
<div class="discourse-subscriptions-buttons">
|
||||
{{#if campaignEnabled}}
|
||||
{{d-button
|
||||
label="discourse_subscriptions.campaign.refresh_campaign"
|
||||
icon="sync-alt"
|
||||
action=(action 'triggerManualRefresh')
|
||||
}}
|
||||
{{else}}
|
||||
{{#unless campaignProductSet}}
|
||||
{{d-button
|
||||
label="discourse_subscriptions.campaign.one_click_campaign"
|
||||
icon="plus-square"
|
||||
action=(action 'createOneClickCampaign')
|
||||
isLoading=loading
|
||||
}}
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-pills">
|
||||
{{!-- {{nav-item route='adminPlugins.discourse-subscriptions.dashboard' label='discourse_subscriptions.admin.dashboard.title'}} --}}
|
||||
{{nav-item route='adminPlugins.discourse-subscriptions.products' label='discourse_subscriptions.admin.products.title'}}
|
||||
{{nav-item route='adminPlugins.discourse-subscriptions.coupons' label='discourse_subscriptions.admin.coupons.title'}}
|
||||
{{nav-item route='adminPlugins.discourse-subscriptions.subscriptions' label='discourse_subscriptions.admin.subscriptions.title'}}
|
||||
|
@ -13,3 +32,7 @@
|
|||
<div id="discourse-subscriptions-admin">
|
||||
{{outlet}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p>{{i18n 'discourse_subscriptions.admin.unconfigured'}}</p>
|
||||
<p><a href="https://meta.discourse.org/t/discourse-subscriptions/140818/">{{i18n 'discourse_subscriptions.admin.on_meta'}}</a></p>
|
||||
{{/if}}
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
{{#if shouldShow}}
|
||||
{{d-button
|
||||
icon="times"
|
||||
action="dismissBanner"
|
||||
class="close"
|
||||
}}
|
||||
<div class="campaign-banner-info">
|
||||
<h2 class="campaign-banner-info-header">{{i18n 'discourse_subscriptions.campaign.title'}}</h2>
|
||||
<p class="campaign-banner-info-description">{{i18n 'discourse_subscriptions.campaign.body'}}</p>
|
||||
{{#if product}}
|
||||
{{#link-to "s.show" product disabled=product.subscribed class="btn btn-primary campaign-banner-info-button"}}
|
||||
{{d-icon "far-heart"}} {{d-icon "heart" class="hover-heart"}} {{i18n 'discourse_subscriptions.campaign.button'}}
|
||||
{{/link-to}}
|
||||
{{else}}
|
||||
{{#link-to "s" class="btn btn-primary campaign-banner-info-button"}}
|
||||
{{d-icon "far-heart"}} {{d-icon "heart" class="hover-heart"}} {{i18n 'discourse_subscriptions.campaign.button'}}
|
||||
{{/link-to}}
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="campaign-banner-progress">
|
||||
{{#if isGoalMet}}
|
||||
<p class="campaign-banner-progress-success">{{i18n 'discourse_subscriptions.campaign.goal'}}!</p>
|
||||
{{#if subscriberGoal}}
|
||||
<p class="campaign-banner-progress-description">
|
||||
{{i18n 'discourse_subscriptions.campaign.goal_comparison' current=subscribers goal=goalTarget}}
|
||||
{{i18n 'discourse_subscriptions.campaign.subscribers'}}
|
||||
</p>
|
||||
{{else}}
|
||||
<p class="campaign-banner-progress-description">
|
||||
{{html-safe (i18n 'discourse_subscriptions.campaign.goal_comparison' current=(format-currency currency amountRaised) goal=(format-currency currency goalTarget))}}
|
||||
{{i18n 'discourse_subscriptions.campaign.raised'}}
|
||||
</p>
|
||||
{{#if showContributors}}
|
||||
{{#conditional-loading-spinner condition=loading size="small"}}
|
||||
<div class="campaign-banner-progress-users">
|
||||
<p class="campaign-banner-progress-users-title">
|
||||
<strong>{{i18n 'discourse_subscriptions.campaign.recent_contributors'}}</strong>
|
||||
</p>
|
||||
<div class="campaign-banner-progress-users-avatars">
|
||||
{{#each contributors as |contributor|}}
|
||||
{{avatar contributor avatarTemplatePath="avatar_template" usernamePath="username" namePath="name" imageSize="small"}}
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
{{/conditional-loading-spinner}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{else}}
|
||||
{{#if subscriberGoal}}
|
||||
<progress class="campaign-banner-progress-bar" value="{{subscribers}}" max="{{siteSettings.discourse_subscriptions_campaign_goal}}"/>
|
||||
<p class="campaign-banner-progress-description">
|
||||
{{i18n 'discourse_subscriptions.campaign.goal_comparison' current=subscribers goal=goalTarget}}
|
||||
{{i18n 'discourse_subscriptions.campaign.subscribers'}}
|
||||
</p>
|
||||
{{else}}
|
||||
<progress class="campaign-banner-progress-bar" value="{{amountRaised}}" max="{{siteSettings.discourse_subscriptions_campaign_goal}}"/>
|
||||
<p class="campaign-banner-progress-description">
|
||||
{{html-safe (i18n 'discourse_subscriptions.campaign.goal_comparison' current=(format-currency currency amountRaised) goal=(format-currency currency goalTarget))}}
|
||||
{{i18n 'discourse_subscriptions.campaign.raised'}}
|
||||
</p>
|
||||
{{/if}}
|
||||
{{#if showContributors}}
|
||||
{{#conditional-loading-spinner condition=loading size="small"}}
|
||||
<div class="campaign-banner-progress-users">
|
||||
<p class="campaign-banner-progress-users-title">
|
||||
<strong>{{i18n 'discourse_subscriptions.campaign.recent_contributors'}}</strong>
|
||||
</p>
|
||||
<div class="campaign-banner-progress-users-avatars">
|
||||
{{#each contributors as |contributor|}}
|
||||
{{avatar contributor avatarTemplatePath="avatar_template" usernamePath="username" namePath="name" imageSize="small"}}
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
{{/conditional-loading-spinner}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
|
@ -0,0 +1,3 @@
|
|||
{{#unless (eq siteSettings.discourse_subscriptions_campaign_banner_location "Sidebar")}}
|
||||
{{campaign-banner}}
|
||||
{{/unless}}
|
|
@ -0,0 +1,3 @@
|
|||
{{#if (eq siteSettings.discourse_subscriptions_campaign_banner_location "Sidebar")}}
|
||||
{{campaign-banner}}
|
||||
{{/if}}
|
|
@ -44,7 +44,11 @@
|
|||
label="discourse_subscriptions.plans.payment_button"
|
||||
}}
|
||||
{{/if}}
|
||||
|
||||
{{else}}
|
||||
<h2>{{i18n 'discourse_subscriptions.subscribe.already_purchased'}}</h2>
|
||||
{{#link-to "user.billing.subscriptions" currentUser.username class="btn btn-primary"}}
|
||||
{{i18n 'discourse_subscriptions.subscribe.go_to_billing'}}
|
||||
{{/link-to}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,199 @@
|
|||
.subscription-campaign-sidebar {
|
||||
#main-outlet
|
||||
.container.list-container
|
||||
.row:nth-of-type(2)
|
||||
.full-width
|
||||
#list-area
|
||||
.contents {
|
||||
display: grid;
|
||||
grid-template-columns: 78% calc(22% - 2em);
|
||||
grid-template-areas: "content sidebar";
|
||||
grid-column-gap: 2em;
|
||||
span:first-of-type {
|
||||
grid-area: sidebar;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sidebar Version
|
||||
.campaign-banner.campaign-banner-sidebar {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
.btn.close {
|
||||
position: absolute;
|
||||
top: 0.5em;
|
||||
right: 0.5em;
|
||||
font-size: 10px;
|
||||
z-index: 1;
|
||||
}
|
||||
.campaign-banner-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: calc(100% - 2em);
|
||||
padding: 1em 1em 2em 1em;
|
||||
position: relative;
|
||||
&-header {
|
||||
font-size: 16px;
|
||||
margin: 0.5em 0 !important;
|
||||
text-align: center;
|
||||
}
|
||||
&-description {
|
||||
width: 100%;
|
||||
font-size: $font-down-1;
|
||||
text-align: center;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
}
|
||||
.campaign-banner-progress {
|
||||
width: calc(100% - 2em);
|
||||
margin: 1em 0;
|
||||
padding: 1em;
|
||||
&-users {
|
||||
align-items: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
|
||||
&-title {
|
||||
font-size: $font-down-1;
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.campaign-banner-info-button {
|
||||
height: 2em;
|
||||
}
|
||||
progress[value] {
|
||||
height: 1.5em;
|
||||
}
|
||||
.campaign-banner-progress-description {
|
||||
font-size: $font-down-1;
|
||||
}
|
||||
}
|
||||
|
||||
// Non Sidebar version
|
||||
.campaign-banner {
|
||||
display: flex;
|
||||
width: calc(100%);
|
||||
height: max-content;
|
||||
margin: 1em 0 2em 0;
|
||||
border: 1px solid var(--primary-low);
|
||||
position: relative;
|
||||
align-items: stretch;
|
||||
.btn.close {
|
||||
position: absolute;
|
||||
top: 1em;
|
||||
right: 1em;
|
||||
font-size: 12px;
|
||||
background-color: transparent;
|
||||
&:hover {
|
||||
.d-icon {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
&-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
width: 40%;
|
||||
padding: 1.5em 2em 1.5em;
|
||||
background-color: var(--primary-very-low);
|
||||
&-header {
|
||||
font-size: $font-up-4;
|
||||
margin: 0;
|
||||
}
|
||||
&-success {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
&-description {
|
||||
width: 100%;
|
||||
margin: 1em 0;
|
||||
}
|
||||
&-button {
|
||||
height: 2.5em;
|
||||
padding: 0.75em 1.5em 0.75em 2.75em;
|
||||
position: relative;
|
||||
.d-icon {
|
||||
position: absolute;
|
||||
left: 1em;
|
||||
&.hover-heart {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
.d-icon.hover-heart {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&-progress {
|
||||
width: 60%;
|
||||
padding: 3em 2em 1.5em;
|
||||
&-success {
|
||||
font-size: $font-up-1;
|
||||
text-align: center;
|
||||
background-color: var(--tertiary);
|
||||
color: var(--secondary);
|
||||
padding: 0.75em;
|
||||
border-radius: 2em;
|
||||
margin: 0;
|
||||
}
|
||||
&-description {
|
||||
margin: 0.5em 0 0;
|
||||
font-weight: 300;
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
&-users {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
margin: 0 0 -2em 0;
|
||||
|
||||
&-title {
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
&-avatars {
|
||||
margin: 0 0 0 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
progress[value] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 2.5em;
|
||||
width: 100%;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 2em;
|
||||
background-color: var(--primary-very-low);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
progress[value]::-webkit-progress-bar {
|
||||
background-color: var(--primary-very-low);
|
||||
}
|
||||
|
||||
progress[value]::-webkit-progress-value {
|
||||
background-color: var(--tertiary);
|
||||
border-top-right-radius: 2em;
|
||||
border-bottom-right-radius: 2em;
|
||||
}
|
||||
|
||||
progress[value]::-moz-progress-bar {
|
||||
background-color: var(--tertiary);
|
||||
border-top-right-radius: 2em;
|
||||
border-bottom-right-radius: 2em;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,6 +10,10 @@ textarea[readonly] {
|
|||
border-color: #e9e9e9;
|
||||
}
|
||||
|
||||
.admin-plugins.discourse-subscriptions .discourse-subscriptions-buttons {
|
||||
margin: 1em 0 2.5em;
|
||||
}
|
||||
|
||||
#discourse-subscriptions-admin {
|
||||
.btn-right {
|
||||
text-align: right;
|
||||
|
|
|
@ -6,6 +6,12 @@ en:
|
|||
discourse_subscriptions_secret_key: Stripe Secret Key
|
||||
discourse_subscriptions_webhook_secret: Stripe Webhook Secret
|
||||
discourse_subscriptions_currency: Default Currency Code. This can be overridden when creating a subscription plan.
|
||||
discourse_subscriptions_campaign_enabled: Enables a campaign banner to promote supporting this community financially.
|
||||
discourse_subscriptions_campaign_goal: The numerical goal for your support campaign (subscribers or amount raised).
|
||||
discourse_subscriptions_campaign_type: Selects the type of campaign to run (subscribers or monthly amount raised).
|
||||
discourse_subscriptions_campaign_banner_location: Selects the location of the campaign banner (top or sidebar).
|
||||
discourse_subscriptions_campaign_show_contributors: Show avatars of the most recent purchases of subscription products.
|
||||
discourse_subscriptions_campaign_product: The Stripe product id to send supporters when they click the button on the campaign. If this setting is blank, supporters will be directed to the main products page.
|
||||
errors:
|
||||
discourse_patrons_amount_must_be_currency: "Currency amounts must be currencies without dollar symbol (eg 1.50)"
|
||||
js:
|
||||
|
@ -23,6 +29,33 @@ en:
|
|||
subscribe: Subscribe
|
||||
user_activity:
|
||||
payments: Payments
|
||||
campaign:
|
||||
title: Support Our Community
|
||||
body: We need your help to keep this community up and running!
|
||||
subscribers: Subscribers
|
||||
goal_comparison: "<strong>%{current}</strong> of <strong>%{goal}</strong>"
|
||||
raised: Raised
|
||||
button: Support
|
||||
recent_contributors: Recent Contributors
|
||||
goal: We've met our goal
|
||||
one_click_campaign: Auto-Create Support Campaign
|
||||
refresh_campaign: Refresh Campaign Data
|
||||
refresh_page: Campaign data is now syncing from Stripe in the background. Refresh the page in a few moments to load the updated info.
|
||||
supporter: Supporter
|
||||
supporters: Supporters
|
||||
confirm_creation: |-
|
||||
<p>Do you want to automatically create a campaign for your community?</p>
|
||||
|
||||
<p>Continuing will create:</p>
|
||||
|
||||
<ul>
|
||||
<li>A group to reward your supporters with a custom title and avatar flair</li>
|
||||
<li>A Stripe product with pre-set prices (monthly and annual) your users can purchase to support your site.</li>
|
||||
</ul>
|
||||
|
||||
<p>Do you wish to continue?</p>
|
||||
|
||||
created: Your one-click campaign is created and ready to go! Redirecting to the campaign settings.
|
||||
one_time_payment: One-Time Payment
|
||||
plans:
|
||||
purchase: Purchase a subscription
|
||||
|
@ -75,6 +108,7 @@ en:
|
|||
subscribe: Subscribe
|
||||
purchased: Purchased
|
||||
go_to_billing: Go to Billing
|
||||
already_purchased: Thanks so much for your prior purchase of this product!
|
||||
billing:
|
||||
name: Full name
|
||||
email: Email
|
||||
|
@ -88,6 +122,7 @@ en:
|
|||
success: Go back
|
||||
admin:
|
||||
unconfigured: 'Stripe is not configured correctly. Please see Discourse Meta for information.'
|
||||
on_meta: Discourse Subscriptions on Meta
|
||||
dashboard:
|
||||
title: Dashboard
|
||||
table:
|
||||
|
|
|
@ -2,9 +2,10 @@
|
|||
require_dependency "subscriptions_user_constraint"
|
||||
|
||||
DiscourseSubscriptions::Engine.routes.draw do
|
||||
# TODO: namespace this
|
||||
scope 'admin' do
|
||||
get '/' => 'admin#index'
|
||||
post '/refresh' => 'admin#refresh_campaign'
|
||||
post '/create-campaign' => 'admin#create_campaign'
|
||||
end
|
||||
|
||||
namespace :admin do
|
||||
|
@ -22,6 +23,7 @@ DiscourseSubscriptions::Engine.routes.draw do
|
|||
|
||||
get '/' => 'subscribe#index'
|
||||
get '.json' => 'subscribe#index'
|
||||
get '/contributors' => 'subscribe#contributors'
|
||||
get '/:id' => 'subscribe#show'
|
||||
post '/create' => 'subscribe#create'
|
||||
post '/finalize' => 'subscribe#finalize'
|
||||
|
|
|
@ -28,3 +28,40 @@ plugins:
|
|||
- USD
|
||||
- DKK
|
||||
- SGD
|
||||
discourse_subscriptions_campaign_enabled:
|
||||
client: true
|
||||
default: false
|
||||
discourse_subscriptions_campaign_goal:
|
||||
client: true
|
||||
default: 100
|
||||
discourse_subscriptions_campaign_type:
|
||||
client: true
|
||||
type: enum
|
||||
default: "Amount"
|
||||
choices:
|
||||
- Amount
|
||||
- Subscribers
|
||||
discourse_subscriptions_campaign_banner_location:
|
||||
client: true
|
||||
type: enum
|
||||
default: "Top"
|
||||
choices:
|
||||
- Top
|
||||
- Sidebar
|
||||
discourse_subscriptions_campaign_show_contributors:
|
||||
client: true
|
||||
default: true
|
||||
discourse_subscriptions_campaign_product:
|
||||
client: true
|
||||
default: ""
|
||||
discourse_subscriptions_campaign_amount_raised:
|
||||
client: true
|
||||
default: 0
|
||||
hidden: true
|
||||
discourse_subscriptions_campaign_subscribers:
|
||||
client: true
|
||||
default: 0
|
||||
hidden: true
|
||||
discourse_subscriptions_campaign_group:
|
||||
default: ""
|
||||
hidden: true
|
||||
|
|
|
@ -13,6 +13,7 @@ gem 'stripe', '5.29.0'
|
|||
register_asset "stylesheets/common/main.scss"
|
||||
register_asset "stylesheets/common/layout.scss"
|
||||
register_asset "stylesheets/common/subscribe.scss"
|
||||
register_asset "stylesheets/common/campaign.scss"
|
||||
register_asset "stylesheets/mobile/main.scss"
|
||||
register_svg_icon "far-credit-card" if respond_to?(:register_svg_icon)
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ require 'rails_helper'
|
|||
module DiscourseSubscriptions
|
||||
RSpec.describe SubscribeController do
|
||||
let (:user) { Fabricate(:user) }
|
||||
let (:campaign_user) { Fabricate(:user) }
|
||||
|
||||
context "showing products" do
|
||||
let(:product) do
|
||||
|
@ -73,6 +74,48 @@ module DiscourseSubscriptions
|
|||
end
|
||||
end
|
||||
|
||||
describe "#get_contributors" do
|
||||
before do
|
||||
Fabricate(:product, external_id: "prod_campaign")
|
||||
Fabricate(:customer, product_id: "prodct_23456", user_id: user.id, customer_id: 'x')
|
||||
Fabricate(:customer, product_id: "prod_campaign", user_id: campaign_user.id, customer_id: 'y')
|
||||
end
|
||||
context 'not showing contributors' do
|
||||
it 'returns nothing if not set to show contributors' do
|
||||
SiteSetting.discourse_subscriptions_campaign_show_contributors = false
|
||||
get "/s/contributors.json"
|
||||
|
||||
data = response.parsed_body
|
||||
expect(data).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'showing contributors' do
|
||||
before do
|
||||
SiteSetting.discourse_subscriptions_campaign_show_contributors = true
|
||||
end
|
||||
|
||||
it 'filters users by campaign product if set' do
|
||||
SiteSetting.discourse_subscriptions_campaign_product = "prod_campaign"
|
||||
|
||||
get "/s/contributors.json"
|
||||
|
||||
data = response.parsed_body
|
||||
expect(data.first["id"]).to eq campaign_user.id
|
||||
expect(data.length).to eq 1
|
||||
end
|
||||
|
||||
it 'shows all purchases if campaign product not set' do
|
||||
SiteSetting.discourse_subscriptions_campaign_product = nil
|
||||
|
||||
get "/s/contributors.json"
|
||||
|
||||
data = response.parsed_body
|
||||
expect(data.length).to eq 2
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#show" do
|
||||
it 'retrieves the product' do
|
||||
::Stripe::Product.expects(:retrieve).with('prod_walterwhite').returns(product)
|
||||
|
|
|
@ -0,0 +1,110 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe DiscourseSubscriptions::Campaign do
|
||||
describe 'campaign data is refreshed' do
|
||||
let (:user) { Fabricate(:user) }
|
||||
let(:subscription) do
|
||||
{
|
||||
id: "sub_1234",
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
price: {
|
||||
product: "prodct_23456",
|
||||
unit_amount: 1000,
|
||||
recurring: {
|
||||
interval: "month"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
Fabricate(:product, external_id: "prodct_23456")
|
||||
Fabricate(:customer, product_id: "prodct_23456", user_id: user.id, customer_id: 'x')
|
||||
SiteSetting.discourse_subscriptions_public_key = "public-key"
|
||||
SiteSetting.discourse_subscriptions_secret_key = "secret-key"
|
||||
end
|
||||
|
||||
describe "refresh_data" do
|
||||
context "for all subscription purchases" do
|
||||
it "refreshes the campaign data properly" do
|
||||
::Stripe::Subscription.expects(:list).returns(data: [subscription], has_more: false)
|
||||
|
||||
DiscourseSubscriptions::Campaign.new.refresh_data
|
||||
|
||||
expect(SiteSetting.discourse_subscriptions_campaign_subscribers).to eq 1
|
||||
expect(SiteSetting.discourse_subscriptions_campaign_amount_raised).to eq 1000
|
||||
end
|
||||
end
|
||||
|
||||
context "with a campaign product set" do
|
||||
let(:user2) { Fabricate(:user) }
|
||||
let(:campaign_subscription) do
|
||||
{
|
||||
id: "sub_5678",
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
price: {
|
||||
product: "prod_use",
|
||||
unit_amount: 10000,
|
||||
recurring: {
|
||||
interval: "year"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
Fabricate(:product, external_id: "prod_use")
|
||||
Fabricate(:customer, product_id: "prod_use", user_id: user2.id, customer_id: 'y')
|
||||
SiteSetting.discourse_subscriptions_campaign_product = "prod_use"
|
||||
end
|
||||
|
||||
it "refreshes campaign data with only the campaign product/subscriptions" do
|
||||
::Stripe::Subscription.expects(:list).returns(data: [subscription, campaign_subscription], has_more: false)
|
||||
|
||||
DiscourseSubscriptions::Campaign.new.refresh_data
|
||||
|
||||
expect(SiteSetting.discourse_subscriptions_campaign_subscribers).to eq 1
|
||||
expect(SiteSetting.discourse_subscriptions_campaign_amount_raised).to eq 833
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "campaign is automatically created" do
|
||||
describe "create_campaign" do
|
||||
it "successfully creates the campaign group, product, and prices" do
|
||||
::Stripe::Product.expects(:create).returns(id: "prod_campaign")
|
||||
::Stripe::Price.expects(:create)
|
||||
::Stripe::Price.expects(:create)
|
||||
::Stripe::Price.expects(:create)
|
||||
::Stripe::Price.expects(:create)
|
||||
::Stripe::Price.expects(:create)
|
||||
::Stripe::Price.expects(:create)
|
||||
|
||||
DiscourseSubscriptions::Campaign.new.create_campaign
|
||||
|
||||
group = Group.find_by(name: "campaign_supporters")
|
||||
|
||||
expect(group[:full_name]).to eq "Supporters"
|
||||
expect(SiteSetting.discourse_subscriptions_campaign_group.to_i).to eq group.id
|
||||
|
||||
expect(DiscourseSubscriptions::Product.where(external_id: "prod_campaign").length).to eq 1
|
||||
|
||||
expect(SiteSetting.discourse_subscriptions_campaign_enabled).to eq true
|
||||
expect(SiteSetting.discourse_subscriptions_campaign_product).to eq "prod_campaign"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue