plan currencies WIP
parent
3923a2d61b
commit
54bab579be
|
@ -69,7 +69,7 @@ final class Plan(env: Env)(implicit system: akka.actor.ActorSystem) extends Lila
|
|||
recentIds <- env.plan.api.recentChargeUserIds
|
||||
bestIds <- env.plan.api.topPatronUserIds
|
||||
_ <- env.user.lightUserApi preloadMany { recentIds ::: bestIds }
|
||||
prices <- env.plan.priceApi.pricesFor(myGeoLocale)
|
||||
pricing <- env.plan.priceApi.pricingFor(myGeoLocale)
|
||||
} yield Ok(
|
||||
html.plan.index(
|
||||
stripePublicKey = env.plan.stripePublicKey,
|
||||
|
@ -77,7 +77,7 @@ final class Plan(env: Env)(implicit system: akka.actor.ActorSystem) extends Lila
|
|||
patron = patron,
|
||||
recentIds = recentIds,
|
||||
bestIds = bestIds,
|
||||
prices = prices
|
||||
pricing = pricing
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
@ -122,7 +122,8 @@ trait ScalatagsExtensions {
|
|||
t.setAttr(a.name, scalatags.text.Builder.GenericAttrValueSource(v.value))
|
||||
}
|
||||
|
||||
implicit val charAttr = genericAttr[Char]
|
||||
implicit val charAttr = genericAttr[Char]
|
||||
implicit val bigDecimalAttr = genericAttr[BigDecimal]
|
||||
|
||||
implicit val optionStringAttr = new AttrValue[Option[String]] {
|
||||
def apply(t: scalatags.text.Builder, a: Attr, v: Option[String]): Unit = {
|
||||
|
|
|
@ -5,6 +5,7 @@ import play.api.i18n.Lang
|
|||
import lila.api.Context
|
||||
import lila.app.templating.Environment._
|
||||
import lila.app.ui.ScalatagsTemplate._
|
||||
import lila.common.String.html.safeJsonValue
|
||||
|
||||
import controllers.routes
|
||||
|
||||
|
@ -20,7 +21,7 @@ object index {
|
|||
patron: Option[lila.plan.Patron],
|
||||
recentIds: List[String],
|
||||
bestIds: List[String],
|
||||
prices: lila.plan.PlanPrices
|
||||
pricing: lila.plan.PlanPricing
|
||||
)(implicit ctx: Context) = {
|
||||
|
||||
views.html.base.layout(
|
||||
|
@ -29,7 +30,9 @@ object index {
|
|||
moreJs = frag(
|
||||
stripeScript,
|
||||
jsModule("checkout"),
|
||||
embedJsUnsafeLoadThen(s"""checkoutStart("$stripePublicKey")""")
|
||||
embedJsUnsafeLoadThen(s"""checkoutStart("$stripePublicKey", ${safeJsonValue(
|
||||
lila.plan.PlanPriceApi.pricingWrites.writes(pricing)
|
||||
)})""")
|
||||
),
|
||||
openGraph = lila.app.ui
|
||||
.OpenGraph(
|
||||
|
@ -85,8 +88,7 @@ object index {
|
|||
div(
|
||||
cls := "plan_checkout",
|
||||
attr("data-email") := email.??(_.value),
|
||||
attr("data-lifetime-usd") := lila.plan.Cents.lifetime.usd.toString,
|
||||
attr("data-lifetime-cents") := lila.plan.Cents.lifetime.value
|
||||
attr("data-lifetime-amount") := pricing.lifetime.amount
|
||||
)(
|
||||
raw(s"""
|
||||
<form class="paypal_checkout onetime none" action="https://www.paypal.com/cgi-bin/webscr" method="post" target="_top">
|
||||
|
@ -101,8 +103,8 @@ object index {
|
|||
<input type="hidden" name="rm" value="1">
|
||||
<input type="hidden" name="return" value="https://lichess.org/patron/thanks">
|
||||
<input type="hidden" name="cancel_return" value="https://lichess.org/patron">
|
||||
<input type="hidden" name="lc" value="US">
|
||||
<input type="hidden" name="currency_code" value="USD">
|
||||
<input type="hidden" name="lc" value="${pricing.locale}">
|
||||
<input type="hidden" name="currency_code" value="${pricing.currencyCode}">
|
||||
</form>
|
||||
<form class="paypal_checkout monthly none" action="https://www.paypal.com/cgi-bin/webscr" method="post" target="_top">
|
||||
<input type="hidden" name="custom" value="${~ctx.userId}">
|
||||
|
@ -118,8 +120,8 @@ object index {
|
|||
<input type="hidden" name="src" value="1">
|
||||
<input type="hidden" name="p3" value="1">
|
||||
<input type="hidden" name="t3" value="M">
|
||||
<input type="hidden" name="lc" value="US">
|
||||
<input type="hidden" name="currency_code" value="USD">
|
||||
<input type="hidden" name="lc" value="${pricing.locale}">
|
||||
<input type="hidden" name="currency_code" value="${pricing.currencyCode}">
|
||||
</form>
|
||||
<form class="paypal_checkout lifetime none" action="https://www.paypal.com/cgi-bin/webscr" method="post" target="_top">
|
||||
<input type="hidden" name="custom" value="${~ctx.userId}">
|
||||
|
@ -187,27 +189,26 @@ object index {
|
|||
),
|
||||
div(cls := "amount_choice")(
|
||||
st.group(cls := "radio buttons amount")(
|
||||
prices.suggestions.map { money =>
|
||||
val cents = lila.plan.Cents((money.amount * 100).toInt)
|
||||
val id = s"plan_${cents.value}"
|
||||
pricing.suggestions.map { money =>
|
||||
val id = s"plan_${money.code}"
|
||||
div(
|
||||
input(
|
||||
cls := money == pricing.default option "default",
|
||||
tpe := "radio",
|
||||
name := "plan",
|
||||
st.id := id,
|
||||
cents.usd.value == 10 option checked,
|
||||
value := cents.value,
|
||||
attr("data-usd") := cents.usd.toString,
|
||||
attr("data-amount") := cents.value
|
||||
money == pricing.default option checked,
|
||||
value := money.amount,
|
||||
attr("data-amount") := money.amount
|
||||
),
|
||||
label(`for` := id)(cents.usd.toString)
|
||||
label(`for` := id)(money.display)
|
||||
)
|
||||
},
|
||||
div(cls := "other")(
|
||||
input(tpe := "radio", name := "plan", id := "plan_other", value := "other"),
|
||||
label(
|
||||
`for` := "plan_other",
|
||||
title := pleaseEnterAmount.txt(),
|
||||
title := pleaseEnterAmountInX.txt(pricing.currencyCode),
|
||||
attr("data-trans-other") := otherAmount.txt()
|
||||
)(otherAmount())
|
||||
)
|
||||
|
@ -215,10 +216,7 @@ object index {
|
|||
),
|
||||
div(cls := "amount_fixed none")(
|
||||
st.group(cls := "radio buttons amount")(
|
||||
div {
|
||||
val cents = lila.plan.Cents.lifetime
|
||||
label(`for` := s"plan_${cents.value}")(cents.usd.toString)
|
||||
}
|
||||
div(label(`for` := s"plan_${pricing.lifetime.code}")(pricing.lifetime.display))
|
||||
)
|
||||
),
|
||||
div(cls := "service")(
|
||||
|
|
|
@ -1337,7 +1337,7 @@ val `recurringBilling` = new I18nKey("patron:recurringBilling")
|
|||
val `onetime` = new I18nKey("patron:onetime")
|
||||
val `singleDonation` = new I18nKey("patron:singleDonation")
|
||||
val `otherAmount` = new I18nKey("patron:otherAmount")
|
||||
val `pleaseEnterAmount` = new I18nKey("patron:pleaseEnterAmount")
|
||||
val `pleaseEnterAmountInX` = new I18nKey("patron:pleaseEnterAmountInX")
|
||||
val `withCreditCard` = new I18nKey("patron:withCreditCard")
|
||||
val `withPaypal` = new I18nKey("patron:withPaypal")
|
||||
val `weAreSmallTeam` = new I18nKey("patron:weAreSmallTeam")
|
||||
|
@ -1601,11 +1601,11 @@ val `teamPassword` = new I18nKey("team:teamPassword")
|
|||
val `teamPasswordDescriptionForLeader` = new I18nKey("team:teamPasswordDescriptionForLeader")
|
||||
val `incorrectTeamPassword` = new I18nKey("team:incorrectTeamPassword")
|
||||
val `teamAlreadyExists` = new I18nKey("team:teamAlreadyExists")
|
||||
val `upcomingTourns` = new I18nKey("team:upcomingTourns")
|
||||
val `completedTourns` = new I18nKey("team:completedTourns")
|
||||
val `nbMembers` = new I18nKey("team:nbMembers")
|
||||
val `teamLeaders` = new I18nKey("team:teamLeaders")
|
||||
val `xJoinRequests` = new I18nKey("team:xJoinRequests")
|
||||
val `upcomingTourns` = new I18nKey("team:upcomingTourns")
|
||||
val `completedTourns` = new I18nKey("team:completedTourns")
|
||||
}
|
||||
|
||||
object perfStat {
|
||||
|
@ -1893,15 +1893,15 @@ val `yourStreakX` = new I18nKey("puzzle:yourStreakX")
|
|||
val `streakSkipExplanation` = new I18nKey("puzzle:streakSkipExplanation")
|
||||
val `continueTheStreak` = new I18nKey("puzzle:continueTheStreak")
|
||||
val `newStreak` = new I18nKey("puzzle:newStreak")
|
||||
val `playedXTimes` = new I18nKey("puzzle:playedXTimes")
|
||||
val `nbPointsBelowYourPuzzleRating` = new I18nKey("puzzle:nbPointsBelowYourPuzzleRating")
|
||||
val `nbPointsAboveYourPuzzleRating` = new I18nKey("puzzle:nbPointsAboveYourPuzzleRating")
|
||||
val `fromMyGames` = new I18nKey("puzzle:fromMyGames")
|
||||
val `lookupOfPlayer` = new I18nKey("puzzle:lookupOfPlayer")
|
||||
val `fromXGames` = new I18nKey("puzzle:fromXGames")
|
||||
val `searchPuzzles` = new I18nKey("puzzle:searchPuzzles")
|
||||
val `fromMyGamesNone` = new I18nKey("puzzle:fromMyGamesNone")
|
||||
val `fromXGamesFound` = new I18nKey("puzzle:fromXGamesFound")
|
||||
val `playedXTimes` = new I18nKey("puzzle:playedXTimes")
|
||||
val `nbPointsBelowYourPuzzleRating` = new I18nKey("puzzle:nbPointsBelowYourPuzzleRating")
|
||||
val `nbPointsAboveYourPuzzleRating` = new I18nKey("puzzle:nbPointsAboveYourPuzzleRating")
|
||||
}
|
||||
|
||||
object puzzleTheme {
|
||||
|
|
|
@ -10,6 +10,7 @@ import scala.concurrent.ExecutionContext
|
|||
import scala.util.Try
|
||||
|
||||
import lila.common.config
|
||||
import play.api.i18n.Lang
|
||||
|
||||
case class CurrencyWithRate(currency: Currency, rate: Double)
|
||||
|
||||
|
@ -43,33 +44,24 @@ final class CurrencyApi(
|
|||
def convert(money: Money, to: Locale): Fu[Option[Money]] =
|
||||
ratesCache.get {} map { rates =>
|
||||
for {
|
||||
currency <- Try(Currency getInstance to).toOption
|
||||
currency <- Try(Currency getInstance to).toOption.pp(to.toString)
|
||||
fromRate <- rates get money.currency.getCurrencyCode
|
||||
toRate <- rates get currency.getCurrencyCode
|
||||
} yield Money(money.amount * fromRate / toRate, to)
|
||||
} yield Money(money.amount / fromRate * toRate, to)
|
||||
}
|
||||
|
||||
val US = Locale.US
|
||||
val USD = Currency getInstance US
|
||||
|
||||
def hasCurrency(locale: Locale) = Try(Currency getInstance locale).isSuccess
|
||||
|
||||
def byCountryCode(countryCode: Option[String]): Fu[CurrencyWithRate] =
|
||||
def localeByCountryCodeOrLang(countryCode: Option[String], lang: Lang): Locale =
|
||||
countryCode
|
||||
.flatMap { country =>
|
||||
Try(new Locale("", country)).toOption
|
||||
}
|
||||
.?? { locale =>
|
||||
Try(Currency getInstance locale).toOption ?? { currency =>
|
||||
ratesCache.get(()) map {
|
||||
_ get currency.getCurrencyCode map {
|
||||
CurrencyWithRate(currency, _)
|
||||
}
|
||||
}
|
||||
}
|
||||
} dmap {
|
||||
_ | CurrencyWithRate(USD, 1d)
|
||||
}
|
||||
.flatMap { code => scala.util.Try(new java.util.Locale("", code)).toOption }
|
||||
.filter(hasCurrency)
|
||||
.orElse(lang.locale.some)
|
||||
.filter(hasCurrency)
|
||||
.getOrElse(US)
|
||||
|
||||
private def hasCurrency(locale: Locale) = Try(Currency getInstance locale).isSuccess
|
||||
}
|
||||
|
||||
private object CurrencyApi {
|
||||
|
|
|
@ -4,13 +4,18 @@ import cats.implicits._
|
|||
import java.util.{ Currency, Locale }
|
||||
import scala.concurrent.ExecutionContext
|
||||
|
||||
case class PlanPrices(locale: Locale, suggestions: List[Money], min: Money, max: Money, lifetime: Money)
|
||||
case class PlanPricing(locale: Locale, suggestions: List[Money], min: Money, max: Money, lifetime: Money) {
|
||||
|
||||
val default = suggestions.lift(1) orElse suggestions.headOption getOrElse min
|
||||
|
||||
def currencyCode = min.currency.getCurrencyCode
|
||||
}
|
||||
|
||||
final class PlanPriceApi(currencyApi: CurrencyApi)(implicit ec: ExecutionContext) {
|
||||
|
||||
import currencyApi.US
|
||||
|
||||
val usdPrices = PlanPrices(
|
||||
val usdPricing = PlanPricing(
|
||||
locale = US,
|
||||
suggestions = List(5, 10, 20, 50).map(usd => Money(usd, US)),
|
||||
min = Money(1, US),
|
||||
|
@ -18,16 +23,17 @@ final class PlanPriceApi(currencyApi: CurrencyApi)(implicit ec: ExecutionContext
|
|||
lifetime = Money(250, US)
|
||||
)
|
||||
|
||||
def pricesFor(locale: Locale): Fu[PlanPrices] =
|
||||
if (locale == US) fuccess(usdPrices)
|
||||
def pricingFor(locale: Locale): Fu[PlanPricing] =
|
||||
if (locale == US) fuccess(usdPricing)
|
||||
else {
|
||||
for {
|
||||
defaults <- usdPrices.suggestions.map(convertAndRound(_, locale)).sequenceFu.map(_.sequence)
|
||||
min <- convertAndRound(usdPrices.min, locale)
|
||||
max <- convertAndRound(usdPrices.max, locale)
|
||||
lifetime <- convertAndRound(usdPrices.lifetime, locale)
|
||||
} yield (locale.some, defaults, min, max, lifetime).mapN(PlanPrices.apply)
|
||||
}.dmap(_ | usdPrices)
|
||||
allSuggestions <- usdPricing.suggestions.map(convertAndRound(_, locale)).sequenceFu.map(_.sequence)
|
||||
suggestions = allSuggestions.map(_.distinct)
|
||||
min <- convertAndRound(usdPricing.min, locale)
|
||||
max <- convertAndRound(usdPricing.max, locale)
|
||||
lifetime <- convertAndRound(usdPricing.lifetime, locale)
|
||||
} yield (locale.some, suggestions, min, max, lifetime).mapN(PlanPricing.apply)
|
||||
}.dmap(_ | usdPricing)
|
||||
|
||||
private def convertAndRound(money: Money, to: Locale): Fu[Option[Money]] =
|
||||
currencyApi.convert(money, to) map2 { case Money(amount, locale) =>
|
||||
|
@ -35,10 +41,10 @@ final class PlanPriceApi(currencyApi: CurrencyApi)(implicit ec: ExecutionContext
|
|||
}
|
||||
}
|
||||
|
||||
private object PlanPriceApi {
|
||||
object PlanPriceApi {
|
||||
|
||||
// round to closest number in 1-2-5 series
|
||||
def nicelyRound(amount: BigDecimal): BigDecimal =
|
||||
private def nicelyRound(amount: BigDecimal): BigDecimal =
|
||||
if (amount <= 0) amount // ?
|
||||
else {
|
||||
val scale = math.floor(math.log10(amount.toDouble))
|
||||
|
@ -50,4 +56,16 @@ private object PlanPriceApi {
|
|||
else 10
|
||||
math.pow(10, scale) * multiplier
|
||||
}
|
||||
|
||||
import play.api.libs.json._
|
||||
val pricingWrites = OWrites[PlanPricing] { p =>
|
||||
Json.obj(
|
||||
"currency" -> p.currencyCode,
|
||||
"min" -> p.min.amount,
|
||||
"max" -> p.max.amount,
|
||||
"lifetime" -> p.lifetime.amount,
|
||||
"default" -> p.default.amount,
|
||||
"suggestions" -> p.suggestions.map(_.amount)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,6 +40,7 @@ object Cents {
|
|||
case class Money(amount: BigDecimal, locale: Locale) {
|
||||
val currency = Currency getInstance locale
|
||||
def display = NumberFormat.getCurrencyInstance(locale).format(amount)
|
||||
def code = s"${currency.getCurrencyCode}_$amount"
|
||||
}
|
||||
|
||||
case class Country(code: String) extends AnyVal
|
||||
|
|
|
@ -27,7 +27,7 @@
|
|||
<string name="onetime">One-time</string>
|
||||
<string name="singleDonation">A single donation that grants you the Patron wings for one month.</string>
|
||||
<string name="otherAmount">Other</string>
|
||||
<string name="pleaseEnterAmount">Please enter an amount in USD</string>
|
||||
<string name="pleaseEnterAmountInX">Please enter an amount in %s</string>
|
||||
<string name="withCreditCard">Credit Card</string>
|
||||
<string name="withPaypal">PayPal</string>
|
||||
<string name="weAreSmallTeam">We are a small team, so your support makes a huge difference!</string>
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
import * as xhr from 'common/xhr';
|
||||
|
||||
export default function (publicKey: string) {
|
||||
export interface Pricing {
|
||||
currency: string;
|
||||
default: number;
|
||||
min: number;
|
||||
max: number;
|
||||
lifetime: number;
|
||||
}
|
||||
|
||||
export default function (publicKey: string, pricing: Pricing) {
|
||||
const $checkout = $('div.plan_checkout');
|
||||
const lifetime = {
|
||||
cents: parseInt($checkout.data('lifetime-cents')),
|
||||
usd: $checkout.data('lifetime-usd'),
|
||||
};
|
||||
const min = 100,
|
||||
max = 100 * 100000;
|
||||
|
||||
if (location.hash === '#onetime') $('#freq_onetime').trigger('click');
|
||||
if (location.hash === '#lifetime') $('#freq_lifetime').trigger('click');
|
||||
|
@ -19,7 +21,7 @@ export default function (publicKey: string) {
|
|||
// Other is selected but no amount specified
|
||||
// happens with backward button
|
||||
if (!$checkout.find('.amount_choice group.amount input:checked').data('amount'))
|
||||
$checkout.find('#plan_monthly_1000').trigger('click');
|
||||
$checkout.find('input.default').trigger('click');
|
||||
|
||||
const selectAmountGroup = function () {
|
||||
const freq = getFreq();
|
||||
|
@ -38,25 +40,21 @@ export default function (publicKey: string) {
|
|||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
let cents = Math.round(amount * 100);
|
||||
if (!cents) {
|
||||
if (!amount) {
|
||||
$(this).text($(this).data('trans-other'));
|
||||
$checkout.find('#plan_monthly_1000').trigger('click');
|
||||
$checkout.find('input.default').trigger('click');
|
||||
return false;
|
||||
}
|
||||
if (cents < min) cents = min;
|
||||
else if (cents > max) cents = max;
|
||||
const usd = '$' + cents / 100;
|
||||
$(this).text(usd);
|
||||
$(this).siblings('input').data('amount', cents).data('usd', usd);
|
||||
amount = Math.max(pricing.min, Math.min(pricing.max, amount));
|
||||
$(this).text(`${pricing.currency} ${amount}`);
|
||||
$(this).siblings('input').data('amount', amount);
|
||||
});
|
||||
|
||||
$checkout.find('button.paypal').on('click', function () {
|
||||
const freq = getFreq(),
|
||||
cents =
|
||||
freq == 'lifetime' ? lifetime.cents : parseInt($checkout.find('group.amount input:checked').data('amount'));
|
||||
if (!cents || cents < min || cents > max) return;
|
||||
const amount = cents / 100;
|
||||
amount =
|
||||
freq == 'lifetime' ? pricing.lifetime : parseInt($checkout.find('group.amount input:checked').data('amount'));
|
||||
if (!amount || amount < pricing.min || amount > pricing.max) return;
|
||||
const $form = $checkout.find('form.paypal_checkout.' + freq);
|
||||
$form.find('input.amount').val('' + amount);
|
||||
($form[0] as HTMLFormElement).submit();
|
||||
|
@ -68,8 +66,8 @@ export default function (publicKey: string) {
|
|||
$checkout.find('button.stripe').on('click', function () {
|
||||
const freq = getFreq(),
|
||||
amount =
|
||||
freq == 'lifetime' ? lifetime.cents : parseInt($checkout.find('group.amount input:checked').data('amount'));
|
||||
if (amount < min || amount > max) return;
|
||||
freq == 'lifetime' ? pricing.lifetime : parseInt($checkout.find('group.amount input:checked').data('amount'));
|
||||
if (amount < pricing.min || amount > pricing.max) return;
|
||||
$checkout.find('.service').html(lichess.spinnerHtml);
|
||||
|
||||
xhr
|
||||
|
|
Loading…
Reference in New Issue