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:
Justin DiRose 2021-06-02 13:15:03 -05:00 committed by GitHub
parent dcec7703f8
commit f596a0f78a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 981 additions and 22 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(),
});
},
});

View File

@ -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);
}
);
},
});

View File

@ -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()
);
},

View File

@ -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 () {

View File

@ -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");
});
},
},
});

View File

@ -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">

View File

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

View File

@ -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">

View File

@ -1,15 +1,38 @@
<h2>{{i18n 'discourse_subscriptions.title' site_name=siteSettings.title}}</h2>
<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'}}
</ul>
{{#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>
<hr>
<ul class="nav nav-pills">
{{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'}}
</ul>
<div id="discourse-subscriptions-admin">
{{outlet}}
</div>
<hr>
<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}}

View File

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

View File

@ -0,0 +1,3 @@
{{#unless (eq siteSettings.discourse_subscriptions_campaign_banner_location "Sidebar")}}
{{campaign-banner}}
{{/unless}}

View File

@ -0,0 +1,3 @@
{{#if (eq siteSettings.discourse_subscriptions_campaign_banner_location "Sidebar")}}
{{campaign-banner}}
{{/if}}

View File

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

View File

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

View File

@ -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;

View File

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

View File

@ -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'

View File

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

View File

@ -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)

View File

@ -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)

View File

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