diff --git a/app/controllers/discourse_subscriptions/admin/products_controller.rb b/app/controllers/discourse_subscriptions/admin/products_controller.rb
index 5962637..4fede95 100644
--- a/app/controllers/discourse_subscriptions/admin/products_controller.rb
+++ b/app/controllers/discourse_subscriptions/admin/products_controller.rb
@@ -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
diff --git a/app/controllers/discourse_subscriptions/admin_controller.rb b/app/controllers/discourse_subscriptions/admin_controller.rb
index c0cdb2d..62e4508 100644
--- a/app/controllers/discourse_subscriptions/admin_controller.rb
+++ b/app/controllers/discourse_subscriptions/admin_controller.rb
@@ -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
diff --git a/app/controllers/discourse_subscriptions/subscribe_controller.rb b/app/controllers/discourse_subscriptions/subscribe_controller.rb
index eb88e71..34a7467 100644
--- a/app/controllers/discourse_subscriptions/subscribe_controller.rb
+++ b/app/controllers/discourse_subscriptions/subscribe_controller.rb
@@ -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
diff --git a/app/jobs/regular/manually_update_campaign_data.rb b/app/jobs/regular/manually_update_campaign_data.rb
new file mode 100644
index 0000000..954b90a
--- /dev/null
+++ b/app/jobs/regular/manually_update_campaign_data.rb
@@ -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
diff --git a/app/jobs/scheduled/refresh_subscriptions_campaign_data.rb b/app/jobs/scheduled/refresh_subscriptions_campaign_data.rb
new file mode 100644
index 0000000..d691b60
--- /dev/null
+++ b/app/jobs/scheduled/refresh_subscriptions_campaign_data.rb
@@ -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
diff --git a/app/services/discourse_subscriptions/campaign.rb b/app/services/discourse_subscriptions/campaign.rb
new file mode 100644
index 0000000..955b15a
--- /dev/null
+++ b/app/services/discourse_subscriptions/campaign.rb
@@ -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
diff --git a/assets/javascripts/discourse/components/campaign-banner.js.es6 b/assets/javascripts/discourse/components/campaign-banner.js.es6
new file mode 100644
index 0000000..00e753c
--- /dev/null
+++ b/assets/javascripts/discourse/components/campaign-banner.js.es6
@@ -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(),
+ });
+ },
+});
diff --git a/assets/javascripts/discourse/controllers/admin-plugins-discourse-subscriptions.js.es6 b/assets/javascripts/discourse/controllers/admin-plugins-discourse-subscriptions.js.es6
new file mode 100644
index 0000000..e2415bf
--- /dev/null
+++ b/assets/javascripts/discourse/controllers/admin-plugins-discourse-subscriptions.js.es6
@@ -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);
+ }
+ );
+ },
+});
diff --git a/assets/javascripts/discourse/controllers/s-show.js.es6 b/assets/javascripts/discourse/controllers/s-show.js.es6
index 79a56bc..32cf5dc 100644
--- a/assets/javascripts/discourse/controllers/s-show.js.es6
+++ b/assets/javascripts/discourse/controllers/s-show.js.es6
@@ -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()
);
},
diff --git a/assets/javascripts/discourse/discourse-subscriptions-route-map.js.es6 b/assets/javascripts/discourse/discourse-subscriptions-route-map.js.es6
index be24a39..3995510 100644
--- a/assets/javascripts/discourse/discourse-subscriptions-route-map.js.es6
+++ b/assets/javascripts/discourse/discourse-subscriptions-route-map.js.es6
@@ -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 () {
diff --git a/assets/javascripts/discourse/routes/admin-plugins-discourse-subscriptions.js.es6 b/assets/javascripts/discourse/routes/admin-plugins-discourse-subscriptions.js.es6
index f7f5da2..30cfb7c 100644
--- a/assets/javascripts/discourse/routes/admin-plugins-discourse-subscriptions.js.es6
+++ b/assets/javascripts/discourse/routes/admin-plugins-discourse-subscriptions.js.es6
@@ -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");
+ });
+ },
+ },
+});
diff --git a/assets/javascripts/discourse/templates/admin/plugins-discourse-subscriptions-coupons.hbs b/assets/javascripts/discourse/templates/admin/plugins-discourse-subscriptions-coupons.hbs
index 6083eb6..c0cb065 100644
--- a/assets/javascripts/discourse/templates/admin/plugins-discourse-subscriptions-coupons.hbs
+++ b/assets/javascripts/discourse/templates/admin/plugins-discourse-subscriptions-coupons.hbs
@@ -1,6 +1,6 @@
{{#if model.unconfigured}}
{{i18n 'discourse_subscriptions.admin.unconfigured'}}
- Discourse Subscriptions on Meta
+ {{i18n 'discourse_subscriptions.admin.on_meta'}}
{{else}}
{{#if model}}
diff --git a/assets/javascripts/discourse/templates/admin/plugins-discourse-subscriptions-products-index.hbs b/assets/javascripts/discourse/templates/admin/plugins-discourse-subscriptions-products-index.hbs
index 68a2536..44c9aab 100644
--- a/assets/javascripts/discourse/templates/admin/plugins-discourse-subscriptions-products-index.hbs
+++ b/assets/javascripts/discourse/templates/admin/plugins-discourse-subscriptions-products-index.hbs
@@ -1,6 +1,6 @@
{{#if model.unconfigured }}
{{i18n 'discourse_subscriptions.admin.unconfigured'}}
- Discourse Subscriptions on Meta
+ {{i18n 'discourse_subscriptions.admin.on_meta'}}
{{else}}
{{#link-to 'adminPlugins.discourse-subscriptions.products.show' 'new' class="btn btn-primary"}}
diff --git a/assets/javascripts/discourse/templates/admin/plugins-discourse-subscriptions-subscriptions.hbs b/assets/javascripts/discourse/templates/admin/plugins-discourse-subscriptions-subscriptions.hbs
index 151a9c2..23d5c28 100644
--- a/assets/javascripts/discourse/templates/admin/plugins-discourse-subscriptions-subscriptions.hbs
+++ b/assets/javascripts/discourse/templates/admin/plugins-discourse-subscriptions-subscriptions.hbs
@@ -1,6 +1,6 @@
{{#if model.unconfigured}}
{{i18n 'discourse_subscriptions.admin.unconfigured'}}
- Discourse Subscriptions on Meta
+ {{i18n 'discourse_subscriptions.admin.on_meta'}}
{{else}}
{{#load-more selector=".discourse-patrons-table tr" action=(action "loadMore")}}
diff --git a/assets/javascripts/discourse/templates/admin/plugins-discourse-subscriptions.hbs b/assets/javascripts/discourse/templates/admin/plugins-discourse-subscriptions.hbs
index a32c2b6..c5a04ee 100644
--- a/assets/javascripts/discourse/templates/admin/plugins-discourse-subscriptions.hbs
+++ b/assets/javascripts/discourse/templates/admin/plugins-discourse-subscriptions.hbs
@@ -1,15 +1,38 @@
{{i18n 'discourse_subscriptions.title' site_name=siteSettings.title}}
-
- {{!-- {{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'}}
-
+{{#if stripeConfigured}}
+
+ {{#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}}
+
-
+
+ {{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'}}
+
-
- {{outlet}}
-
+
+
+
+ {{outlet}}
+
+{{else}}
+ {{i18n 'discourse_subscriptions.admin.unconfigured'}}
+ {{i18n 'discourse_subscriptions.admin.on_meta'}}
+{{/if}}
diff --git a/assets/javascripts/discourse/templates/components/campaign-banner.hbs b/assets/javascripts/discourse/templates/components/campaign-banner.hbs
new file mode 100644
index 0000000..02fdab1
--- /dev/null
+++ b/assets/javascripts/discourse/templates/components/campaign-banner.hbs
@@ -0,0 +1,78 @@
+{{#if shouldShow}}
+ {{d-button
+ icon="times"
+ action="dismissBanner"
+ class="close"
+ }}
+
+
+
{{i18n 'discourse_subscriptions.campaign.body'}}
+ {{#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}}
+
+
+ {{#if isGoalMet}}
+
{{i18n 'discourse_subscriptions.campaign.goal'}}!
+ {{#if subscriberGoal}}
+
+ {{i18n 'discourse_subscriptions.campaign.goal_comparison' current=subscribers goal=goalTarget}}
+ {{i18n 'discourse_subscriptions.campaign.subscribers'}}
+
+ {{else}}
+
+ {{html-safe (i18n 'discourse_subscriptions.campaign.goal_comparison' current=(format-currency currency amountRaised) goal=(format-currency currency goalTarget))}}
+ {{i18n 'discourse_subscriptions.campaign.raised'}}
+
+ {{#if showContributors}}
+ {{#conditional-loading-spinner condition=loading size="small"}}
+
+
+ {{i18n 'discourse_subscriptions.campaign.recent_contributors'}}
+
+
+ {{#each contributors as |contributor|}}
+ {{avatar contributor avatarTemplatePath="avatar_template" usernamePath="username" namePath="name" imageSize="small"}}
+ {{/each}}
+
+
+ {{/conditional-loading-spinner}}
+ {{/if}}
+ {{/if}}
+ {{else}}
+ {{#if subscriberGoal}}
+
+
+ {{i18n 'discourse_subscriptions.campaign.goal_comparison' current=subscribers goal=goalTarget}}
+ {{i18n 'discourse_subscriptions.campaign.subscribers'}}
+
+ {{else}}
+
+
+ {{html-safe (i18n 'discourse_subscriptions.campaign.goal_comparison' current=(format-currency currency amountRaised) goal=(format-currency currency goalTarget))}}
+ {{i18n 'discourse_subscriptions.campaign.raised'}}
+
+ {{/if}}
+ {{#if showContributors}}
+ {{#conditional-loading-spinner condition=loading size="small"}}
+
+
+ {{i18n 'discourse_subscriptions.campaign.recent_contributors'}}
+
+
+ {{#each contributors as |contributor|}}
+ {{avatar contributor avatarTemplatePath="avatar_template" usernamePath="username" namePath="name" imageSize="small"}}
+ {{/each}}
+
+
+ {{/conditional-loading-spinner}}
+ {{/if}}
+ {{/if}}
+
+{{/if}}
diff --git a/assets/javascripts/discourse/templates/connectors/above-main-container/subscriptions-campaign.hbs b/assets/javascripts/discourse/templates/connectors/above-main-container/subscriptions-campaign.hbs
new file mode 100644
index 0000000..bf210b2
--- /dev/null
+++ b/assets/javascripts/discourse/templates/connectors/above-main-container/subscriptions-campaign.hbs
@@ -0,0 +1,3 @@
+{{#unless (eq siteSettings.discourse_subscriptions_campaign_banner_location "Sidebar")}}
+ {{campaign-banner}}
+{{/unless}}
\ No newline at end of file
diff --git a/assets/javascripts/discourse/templates/connectors/before-topic-list/subscriptions-campaign-sidebar.hbs b/assets/javascripts/discourse/templates/connectors/before-topic-list/subscriptions-campaign-sidebar.hbs
new file mode 100644
index 0000000..68316a0
--- /dev/null
+++ b/assets/javascripts/discourse/templates/connectors/before-topic-list/subscriptions-campaign-sidebar.hbs
@@ -0,0 +1,3 @@
+{{#if (eq siteSettings.discourse_subscriptions_campaign_banner_location "Sidebar")}}
+ {{campaign-banner}}
+{{/if}}
\ No newline at end of file
diff --git a/assets/javascripts/discourse/templates/s/show.hbs b/assets/javascripts/discourse/templates/s/show.hbs
index 9de2ee7..dedefed 100644
--- a/assets/javascripts/discourse/templates/s/show.hbs
+++ b/assets/javascripts/discourse/templates/s/show.hbs
@@ -44,7 +44,11 @@
label="discourse_subscriptions.plans.payment_button"
}}
{{/if}}
-
+ {{else}}
+ {{i18n 'discourse_subscriptions.subscribe.already_purchased'}}
+ {{#link-to "user.billing.subscriptions" currentUser.username class="btn btn-primary"}}
+ {{i18n 'discourse_subscriptions.subscribe.go_to_billing'}}
+ {{/link-to}}
{{/if}}
diff --git a/assets/stylesheets/common/campaign.scss b/assets/stylesheets/common/campaign.scss
new file mode 100644
index 0000000..5bd2b83
--- /dev/null
+++ b/assets/stylesheets/common/campaign.scss
@@ -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;
+ }
+ }
+}
diff --git a/assets/stylesheets/common/main.scss b/assets/stylesheets/common/main.scss
index 74934c0..9d983bd 100644
--- a/assets/stylesheets/common/main.scss
+++ b/assets/stylesheets/common/main.scss
@@ -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;
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index dff6705..9e778b2 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -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: "%{current} of %{goal}"
+ 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: |-
+ Do you want to automatically create a campaign for your community?
+
+ Continuing will create:
+
+
+ - A group to reward your supporters with a custom title and avatar flair
+ - A Stripe product with pre-set prices (monthly and annual) your users can purchase to support your site.
+
+
+ Do you wish to continue?
+
+ 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:
diff --git a/config/routes.rb b/config/routes.rb
index 02115fc..4f88419 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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'
diff --git a/config/settings.yml b/config/settings.yml
index 227c071..94ef8af 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -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
diff --git a/plugin.rb b/plugin.rb
index bcd4807..b9c2e76 100644
--- a/plugin.rb
+++ b/plugin.rb
@@ -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)
diff --git a/spec/requests/subscribe_controller_spec.rb b/spec/requests/subscribe_controller_spec.rb
index f90cfe8..ffd23aa 100644
--- a/spec/requests/subscribe_controller_spec.rb
+++ b/spec/requests/subscribe_controller_spec.rb
@@ -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)
diff --git a/spec/services/campaign_spec.rb b/spec/services/campaign_spec.rb
new file mode 100644
index 0000000..d966057
--- /dev/null
+++ b/spec/services/campaign_spec.rb
@@ -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