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}}

- +{{#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}} +
-
+ -
- {{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.title'}}

+

{{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:

+ + + +

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