mirror of
https://github.com/dnote/dnote
synced 2026-03-16 07:25:49 +01:00
Implement payment method update (#245)
* Implement payment method update * Add license
This commit is contained in:
parent
2c80340e19
commit
7e81c2cde6
16 changed files with 760 additions and 208 deletions
|
|
@ -346,73 +346,74 @@ func NewRouter(app *App) *mux.Router {
|
|||
|
||||
var routes = []Route{
|
||||
// internal
|
||||
Route{"GET", "/health", app.checkHealth, false},
|
||||
Route{"GET", "/me", auth(app.getMe, nil), true},
|
||||
Route{"POST", "/verification-token", auth(app.createVerificationToken, nil), true},
|
||||
Route{"PATCH", "/verify-email", app.verifyEmail, true},
|
||||
Route{"GET", "/auth/{provider}", gothic.BeginAuthHandler, true},
|
||||
Route{"GET", "/auth/{provider}/callback", app.oauthCallbackHandler, true},
|
||||
Route{"PATCH", "/account/profile", auth(app.updateProfile, nil), true},
|
||||
Route{"PATCH", "/account/email", auth(app.updateEmail, nil), true},
|
||||
Route{"PATCH", "/account/password", auth(app.updatePassword, nil), true},
|
||||
Route{"GET", "/account/email-preference", tokenAuth(app.getEmailPreference, database.TokenTypeEmailPreference), true},
|
||||
Route{"PATCH", "/account/email-preference", tokenAuth(app.updateEmailPreference, database.TokenTypeEmailPreference), true},
|
||||
Route{"POST", "/subscriptions", auth(app.createSub, nil), true},
|
||||
Route{"PATCH", "/subscriptions", auth(app.updateSub, nil), true},
|
||||
Route{"POST", "/webhooks/stripe", app.stripeWebhook, true},
|
||||
Route{"GET", "/subscriptions", auth(app.getSub, nil), true},
|
||||
Route{"GET", "/stripe_source", auth(app.getStripeSource, nil), true},
|
||||
Route{"GET", "/notes", auth(app.getNotes, &proOnly), false},
|
||||
Route{"GET", "/demo/notes", app.getDemoNotes, true},
|
||||
Route{"GET", "/notes/{noteUUID}", auth(app.getNote, &proOnly), true},
|
||||
Route{"GET", "/demo/notes/{noteUUID}", app.getDemoNote, true},
|
||||
Route{"GET", "/calendar", auth(app.getCalendar, &proOnly), true},
|
||||
Route{"GET", "/demo/calendar", app.getDemoCalendar, true},
|
||||
Route{"GET", "/digests/{digestUUID}", auth(app.getDigest, &proOnly), true},
|
||||
Route{"GET", "/demo/digests/{digestUUID}", app.getDemoDigest, true},
|
||||
Route{"GET", "/digests", auth(app.getDigests, &proOnly), true},
|
||||
Route{"GET", "/demo/digests", app.getDemoDigests, true},
|
||||
{"GET", "/health", app.checkHealth, false},
|
||||
{"GET", "/me", auth(app.getMe, nil), true},
|
||||
{"POST", "/verification-token", auth(app.createVerificationToken, nil), true},
|
||||
{"PATCH", "/verify-email", app.verifyEmail, true},
|
||||
{"GET", "/auth/{provider}", gothic.BeginAuthHandler, true},
|
||||
{"GET", "/auth/{provider}/callback", app.oauthCallbackHandler, true},
|
||||
{"PATCH", "/account/profile", auth(app.updateProfile, nil), true},
|
||||
{"PATCH", "/account/email", auth(app.updateEmail, nil), true},
|
||||
{"PATCH", "/account/password", auth(app.updatePassword, nil), true},
|
||||
{"GET", "/account/email-preference", tokenAuth(app.getEmailPreference, database.TokenTypeEmailPreference), true},
|
||||
{"PATCH", "/account/email-preference", tokenAuth(app.updateEmailPreference, database.TokenTypeEmailPreference), true},
|
||||
{"POST", "/subscriptions", auth(app.createSub, nil), true},
|
||||
{"PATCH", "/subscriptions", auth(app.updateSub, nil), true},
|
||||
{"POST", "/webhooks/stripe", app.stripeWebhook, true},
|
||||
{"GET", "/subscriptions", auth(app.getSub, nil), true},
|
||||
{"GET", "/stripe_source", auth(app.getStripeSource, nil), true},
|
||||
{"PATCH", "/stripe_source", auth(app.updateStripeSource, nil), true},
|
||||
{"GET", "/notes", auth(app.getNotes, &proOnly), false},
|
||||
{"GET", "/demo/notes", app.getDemoNotes, true},
|
||||
{"GET", "/notes/{noteUUID}", auth(app.getNote, &proOnly), true},
|
||||
{"GET", "/demo/notes/{noteUUID}", app.getDemoNote, true},
|
||||
{"GET", "/calendar", auth(app.getCalendar, &proOnly), true},
|
||||
{"GET", "/demo/calendar", app.getDemoCalendar, true},
|
||||
{"GET", "/digests/{digestUUID}", auth(app.getDigest, &proOnly), true},
|
||||
{"GET", "/demo/digests/{digestUUID}", app.getDemoDigest, true},
|
||||
{"GET", "/digests", auth(app.getDigests, &proOnly), true},
|
||||
{"GET", "/demo/digests", app.getDemoDigests, true},
|
||||
//Route{"GET", "/books/{bookUUID}", cors(auth(app.getBook)), true},
|
||||
|
||||
// routes for user migration to use encryption
|
||||
Route{"POST", "/legacy/signin", app.legacyPasswordLogin, true},
|
||||
Route{"POST", "/legacy/register", legacyAuth(app.legacyRegister), true},
|
||||
Route{"GET", "/legacy/me", legacyAuth(app.getMe), true},
|
||||
Route{"GET", "/legacy/notes", auth(app.legacyGetNotes, &proOnly), false},
|
||||
Route{"PATCH", "/legacy/migrate", auth(app.legacyMigrate, &proOnly), false},
|
||||
Route{"GET", "/auth/{provider}", gothic.BeginAuthHandler, true},
|
||||
Route{"GET", "/auth/{provider}/callback", app.oauthCallbackHandler, true},
|
||||
{"POST", "/legacy/signin", app.legacyPasswordLogin, true},
|
||||
{"POST", "/legacy/register", legacyAuth(app.legacyRegister), true},
|
||||
{"GET", "/legacy/me", legacyAuth(app.getMe), true},
|
||||
{"GET", "/legacy/notes", auth(app.legacyGetNotes, &proOnly), false},
|
||||
{"PATCH", "/legacy/migrate", auth(app.legacyMigrate, &proOnly), false},
|
||||
{"GET", "/auth/{provider}", gothic.BeginAuthHandler, true},
|
||||
{"GET", "/auth/{provider}/callback", app.oauthCallbackHandler, true},
|
||||
|
||||
// v1
|
||||
Route{"POST", "/v1/sync", cors(app.Sync), true},
|
||||
Route{"GET", "/v1/sync/fragment", cors(auth(app.GetSyncFragment, &proOnly)), true},
|
||||
Route{"GET", "/v1/sync/state", cors(auth(app.GetSyncState, &proOnly)), true},
|
||||
{"POST", "/v1/sync", cors(app.Sync), true},
|
||||
{"GET", "/v1/sync/fragment", cors(auth(app.GetSyncFragment, &proOnly)), true},
|
||||
{"GET", "/v1/sync/state", cors(auth(app.GetSyncState, &proOnly)), true},
|
||||
|
||||
Route{"OPTIONS", "/v1/books", cors(app.BooksOptions), false},
|
||||
Route{"GET", "/v1/demo/books", app.GetDemoBooks, true},
|
||||
Route{"GET", "/v1/books", cors(auth(app.GetBooks, &proOnly)), true},
|
||||
Route{"GET", "/v1/books/{bookUUID}", cors(auth(app.GetBook, &proOnly)), true},
|
||||
Route{"POST", "/v1/books", cors(app.CreateBook), false},
|
||||
Route{"PATCH", "/v1/books/{bookUUID}", cors(auth(app.UpdateBook, &proOnly)), false},
|
||||
Route{"DELETE", "/v1/books/{bookUUID}", cors(auth(app.DeleteBook, &proOnly)), false},
|
||||
{"OPTIONS", "/v1/books", cors(app.BooksOptions), false},
|
||||
{"GET", "/v1/demo/books", app.GetDemoBooks, true},
|
||||
{"GET", "/v1/books", cors(auth(app.GetBooks, &proOnly)), true},
|
||||
{"GET", "/v1/books/{bookUUID}", cors(auth(app.GetBook, &proOnly)), true},
|
||||
{"POST", "/v1/books", cors(app.CreateBook), false},
|
||||
{"PATCH", "/v1/books/{bookUUID}", cors(auth(app.UpdateBook, &proOnly)), false},
|
||||
{"DELETE", "/v1/books/{bookUUID}", cors(auth(app.DeleteBook, &proOnly)), false},
|
||||
|
||||
Route{"OPTIONS", "/v1/notes", cors(app.NotesOptions), true},
|
||||
Route{"POST", "/v1/notes", cors(app.CreateNote), false},
|
||||
Route{"PATCH", "/v1/notes/{noteUUID}", auth(app.UpdateNote, &proOnly), false},
|
||||
Route{"DELETE", "/v1/notes/{noteUUID}", auth(app.DeleteNote, &proOnly), false},
|
||||
{"OPTIONS", "/v1/notes", cors(app.NotesOptions), true},
|
||||
{"POST", "/v1/notes", cors(app.CreateNote), false},
|
||||
{"PATCH", "/v1/notes/{noteUUID}", auth(app.UpdateNote, &proOnly), false},
|
||||
{"DELETE", "/v1/notes/{noteUUID}", auth(app.DeleteNote, &proOnly), false},
|
||||
|
||||
Route{"POST", "/v1/register", app.register, true},
|
||||
Route{"GET", "/v1/presignin", cors(app.presignin), true},
|
||||
Route{"POST", "/v1/signin", cors(app.signin), true},
|
||||
Route{"OPTIONS", "/v1/signout", cors(app.signoutOptions), true},
|
||||
Route{"POST", "/v1/signout", cors(app.signout), true},
|
||||
{"POST", "/v1/register", app.register, true},
|
||||
{"GET", "/v1/presignin", cors(app.presignin), true},
|
||||
{"POST", "/v1/signin", cors(app.signin), true},
|
||||
{"OPTIONS", "/v1/signout", cors(app.signoutOptions), true},
|
||||
{"POST", "/v1/signout", cors(app.signout), true},
|
||||
|
||||
// v2
|
||||
Route{"OPTIONS", "/v2/notes", cors(app.NotesOptionsV2), true},
|
||||
Route{"POST", "/v2/notes", cors(auth(app.CreateNoteV2, &proOnly)), true},
|
||||
{"OPTIONS", "/v2/notes", cors(app.NotesOptionsV2), true},
|
||||
{"POST", "/v2/notes", cors(auth(app.CreateNoteV2, &proOnly)), true},
|
||||
|
||||
Route{"OPTIONS", "/v2/books", cors(app.BooksOptionsV2), true},
|
||||
Route{"POST", "/v2/books", cors(auth(app.CreateBookV2, &proOnly)), true},
|
||||
{"OPTIONS", "/v2/books", cors(app.BooksOptionsV2), true},
|
||||
{"POST", "/v2/books", cors(auth(app.CreateBookV2, &proOnly)), true},
|
||||
}
|
||||
|
||||
router := mux.NewRouter().StrictSlash(true)
|
||||
|
|
|
|||
|
|
@ -89,6 +89,18 @@ func addCustomerSource(customerID, sourceID string) (*stripe.PaymentSource, erro
|
|||
return src, nil
|
||||
}
|
||||
|
||||
func removeCustomerSource(customerID, sourceID string) (*stripe.Source, error) {
|
||||
params := &stripe.SourceObjectDetachParams{
|
||||
Customer: stripe.String(customerID),
|
||||
}
|
||||
s, err := source.Detach(sourceID, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func createCustomerSubscription(customerID, planID string) (*stripe.Subscription, error) {
|
||||
subParams := &stripe.SubscriptionParams{
|
||||
Customer: stripe.String(customerID),
|
||||
|
|
@ -386,6 +398,79 @@ func getStripeCard(stripeCustomerID, sourceID string) (*stripe.Card, error) {
|
|||
return nil, errors.Errorf("malformed sourceID %s", sourceID)
|
||||
}
|
||||
|
||||
type updateStripeSourcePayload struct {
|
||||
Source stripe.Source `json:"source"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
|
||||
func validateUpdateStripeSourcePayload(p updateStripeSourcePayload) error {
|
||||
if p.Source.ID == "" {
|
||||
return errors.New("empty source id")
|
||||
}
|
||||
if p.Country == "" {
|
||||
return errors.New("empty country")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) updateStripeSource(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
handleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var payload updateStripeSourcePayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
handleError(w, "decoding params", err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validateUpdateStripeSourcePayload(payload); err != nil {
|
||||
http.Error(w, errors.Wrap(err, "validating payload").Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DBConn
|
||||
tx := db.Begin()
|
||||
|
||||
if err := tx.Model(&user).
|
||||
Update(map[string]interface{}{
|
||||
"billing_country": payload.Country,
|
||||
}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
handleError(w, "updating user", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
c, err := customer.Get(user.StripeCustomerID, nil)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
handleError(w, "retriving customer", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := removeCustomerSource(user.StripeCustomerID, c.DefaultSource.ID); err != nil {
|
||||
tx.Rollback()
|
||||
handleError(w, "removing source", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := addCustomerSource(user.StripeCustomerID, payload.Source.ID); err != nil {
|
||||
tx.Rollback()
|
||||
handleError(w, "attaching source", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
tx.Rollback()
|
||||
handleError(w, "committing transaction", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *App) getStripeSource(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
|
|
|
|||
88
web/src/components/Common/PaymentInput/Card.js
Normal file
88
web/src/components/Common/PaymentInput/Card.js
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { CardElement } from 'react-stripe-elements';
|
||||
|
||||
import classnames from 'classnames';
|
||||
|
||||
import styles from './PaymentInput.module.scss';
|
||||
|
||||
const elementStyles = {
|
||||
base: {
|
||||
color: '#32325D',
|
||||
fontFamily: 'Source Code Pro, Consolas, Menlo, monospace',
|
||||
fontSize: '16px',
|
||||
fontSmoothing: 'antialiased',
|
||||
|
||||
'::placeholder': {
|
||||
color: '#CFD7DF'
|
||||
},
|
||||
':-webkit-autofill': {
|
||||
color: '#e39f48'
|
||||
}
|
||||
},
|
||||
invalid: {
|
||||
color: '#E25950',
|
||||
|
||||
'::placeholder': {
|
||||
color: '#FFCCA5'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function Card({
|
||||
cardElementRef,
|
||||
setCardElementLoaded,
|
||||
containerClassName,
|
||||
labelClassName
|
||||
}) {
|
||||
const [cardElementFocused, setCardElementFocused] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={classnames(styles['card-row'], containerClassName)}>
|
||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||
<label htmlFor="card-number" className={styles.number}>
|
||||
<span className={classnames(labelClassName)}>Card Number</span>
|
||||
|
||||
<CardElement
|
||||
id="card"
|
||||
className={classnames(styles['card-number'], styles.input, {
|
||||
[styles['card-number-active']]: cardElementFocused
|
||||
})}
|
||||
onFocus={() => {
|
||||
setCardElementFocused(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setCardElementFocused(false);
|
||||
}}
|
||||
onReady={el => {
|
||||
if (cardElementRef) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
cardElementRef.current = el;
|
||||
}
|
||||
setCardElementLoaded(true);
|
||||
}}
|
||||
style={elementStyles}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Card;
|
||||
46
web/src/components/Common/PaymentInput/Country.js
Normal file
46
web/src/components/Common/PaymentInput/Country.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import classnames from 'classnames';
|
||||
|
||||
import CountrySelect from './CountrySelect';
|
||||
import styles from './PaymentInput.module.scss';
|
||||
|
||||
function NameOnCard({ value, onUpdate, containerClassName, labelClassName }) {
|
||||
return (
|
||||
<div className={classnames(containerClassName)}>
|
||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||
<label htmlFor="billing-country" className="label-full">
|
||||
<span className={classnames(labelClassName)}>Country</span>
|
||||
<CountrySelect
|
||||
id="billing-country"
|
||||
className={classnames(styles['countries-select'], styles.input)}
|
||||
value={value}
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
onUpdate(val);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NameOnCard;
|
||||
49
web/src/components/Common/PaymentInput/NameOnCard.js
Normal file
49
web/src/components/Common/PaymentInput/NameOnCard.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import classnames from 'classnames';
|
||||
|
||||
import styles from './PaymentInput.module.scss';
|
||||
|
||||
function NameOnCard({ value, onUpdate, containerClassName, labelClassName }) {
|
||||
return (
|
||||
<div className={classnames(containerClassName)}>
|
||||
<label htmlFor="name-on-card" className="label-full">
|
||||
<span className={classnames(labelClassName)}>Name on Card</span>
|
||||
<input
|
||||
autoFocus
|
||||
id="name-on-card"
|
||||
className={classnames(
|
||||
'text-input text-input-stretch text-input-medium',
|
||||
styles.input
|
||||
)}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
onUpdate(val);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NameOnCard;
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@import '../../App/responsive';
|
||||
@import '../../App/theme';
|
||||
@import '../../App/font';
|
||||
@import '../../App/rem';
|
||||
|
||||
.input {
|
||||
margin-top: rem(8px);
|
||||
}
|
||||
|
||||
.card-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.number {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.card-number {
|
||||
// match the height of the stripe element with other inputs
|
||||
padding: rem(10.4px) rem(12px);
|
||||
border: 2px solid $border-color;
|
||||
|
||||
&.card-number-active {
|
||||
border: 2px solid $third;
|
||||
}
|
||||
}
|
||||
|
||||
.countries-select {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
135
web/src/components/Settings/Billing/PaymentMethodModal/Form.js
Normal file
135
web/src/components/Settings/Billing/PaymentMethodModal/Form.js
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { injectStripe } from 'react-stripe-elements';
|
||||
|
||||
import Button from '../../../Common/Button';
|
||||
import NameOnCardInput from '../../../Common/PaymentInput/NameOnCard';
|
||||
import CardInput from '../../../Common/PaymentInput/Card';
|
||||
import CountryInput from '../../../Common/PaymentInput/Country';
|
||||
|
||||
import settingsStyles from '../../Settings.module.scss';
|
||||
import * as paymentService from '../../../../services/payment';
|
||||
import styles from './Form.module.scss';
|
||||
|
||||
function Form({
|
||||
stripe,
|
||||
nameOnCard,
|
||||
setNameOnCard,
|
||||
billingCountry,
|
||||
setBillingCountry,
|
||||
inProgress,
|
||||
onDismiss,
|
||||
setSuccessMsg,
|
||||
setInProgress,
|
||||
doGetSource,
|
||||
setErrMessage
|
||||
}) {
|
||||
const [cardElementLoaded, setCardElementLoaded] = useState(false);
|
||||
const cardElementRef = useRef(null);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!cardElementLoaded) {
|
||||
return;
|
||||
}
|
||||
if (!nameOnCard) {
|
||||
setErrMessage('Please enter the name on card');
|
||||
return;
|
||||
}
|
||||
if (!billingCountry) {
|
||||
setErrMessage('Please enter the country');
|
||||
return;
|
||||
}
|
||||
|
||||
setSuccessMsg('');
|
||||
setErrMessage('');
|
||||
setInProgress(true);
|
||||
|
||||
try {
|
||||
const { source, error } = await stripe.createSource({
|
||||
type: 'card',
|
||||
currency: 'usd',
|
||||
owner: {
|
||||
name: nameOnCard
|
||||
}
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await paymentService.updateSource({ source, country: billingCountry });
|
||||
await doGetSource();
|
||||
|
||||
setSuccessMsg('Your payment method was successfully updated.');
|
||||
setInProgress(false);
|
||||
onDismiss();
|
||||
} catch (err) {
|
||||
setErrMessage(`An error occurred: ${err.message}`);
|
||||
setInProgress(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} id="T-payment-method-form">
|
||||
<div>
|
||||
<NameOnCardInput
|
||||
value={nameOnCard}
|
||||
onUpdate={setNameOnCard}
|
||||
containerClassName={styles['input-row']}
|
||||
/>
|
||||
|
||||
<CardInput
|
||||
cardElementRef={cardElementRef}
|
||||
setCardElementLoaded={setCardElementLoaded}
|
||||
containerClassName={styles['input-row']}
|
||||
/>
|
||||
|
||||
<CountryInput
|
||||
value={billingCountry}
|
||||
onUpdate={setBillingCountry}
|
||||
containerClassName={styles['input-row']}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={settingsStyles.actions}>
|
||||
<Button
|
||||
type="submit"
|
||||
kind="first"
|
||||
isBusy={!cardElementLoaded || inProgress}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
kind="second"
|
||||
disabled={inProgress}
|
||||
onClick={onDismiss}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default injectStripe(Form);
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@import '../../../App/rem';
|
||||
@import '../../../App/font';
|
||||
@import '../../../App/theme';
|
||||
|
||||
.input-row {
|
||||
&:not(:first-child) {
|
||||
margin-top: rem(10px);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
/* Copyright (C) 2019 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { StripeProvider, Elements } from 'react-stripe-elements';
|
||||
|
||||
import Modal, { Header, Body } from '../../../Common/Modal';
|
||||
import Flash from '../../../Common/Flash';
|
||||
import Form from './Form';
|
||||
|
||||
function PaymentMethodModal({
|
||||
isOpen,
|
||||
onDismiss,
|
||||
setSuccessMsg,
|
||||
doGetSource,
|
||||
stripe
|
||||
}) {
|
||||
const [nameOnCard, setNameOnCard] = useState('');
|
||||
const [billingCountry, setBillingCountry] = useState('');
|
||||
const [inProgress, setInProgress] = useState(false);
|
||||
const [errMessage, setErrMessage] = useState('');
|
||||
|
||||
const labelId = 'payment-method-modal';
|
||||
|
||||
function handleDismiss() {
|
||||
setNameOnCard('');
|
||||
setBillingCountry('');
|
||||
onDismiss();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onDismiss={handleDismiss} ariaLabelledBy={labelId}>
|
||||
<Header
|
||||
labelId={labelId}
|
||||
heading="Update payment method"
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
|
||||
{errMessage && (
|
||||
<Flash
|
||||
type="danger"
|
||||
onDismiss={() => {
|
||||
setErrMessage('');
|
||||
}}
|
||||
>
|
||||
{errMessage}
|
||||
</Flash>
|
||||
)}
|
||||
|
||||
<Body>
|
||||
<StripeProvider stripe={stripe}>
|
||||
<Elements>
|
||||
<Form
|
||||
nameOnCard={nameOnCard}
|
||||
setNameOnCard={setNameOnCard}
|
||||
billingCountry={billingCountry}
|
||||
setBillingCountry={setBillingCountry}
|
||||
inProgress={inProgress}
|
||||
onDismiss={handleDismiss}
|
||||
setSuccessMsg={setSuccessMsg}
|
||||
setInProgress={setInProgress}
|
||||
doGetSource={doGetSource}
|
||||
setErrMessage={setErrMessage}
|
||||
/>
|
||||
</Elements>
|
||||
</StripeProvider>
|
||||
</Body>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {};
|
||||
|
||||
export default connect(
|
||||
null,
|
||||
mapDispatchToProps
|
||||
)(PaymentMethodModal);
|
||||
|
|
@ -25,6 +25,7 @@ import Header from '../../Common/Page/Header';
|
|||
import Body from '../../Common/Page/Body';
|
||||
import Flash from '../../Common/Flash';
|
||||
import CancelPlanModal from './CancelPlanModal';
|
||||
import PaymentMethodModal from './PaymentMethodModal';
|
||||
import {
|
||||
getSubscription,
|
||||
clearSubscription,
|
||||
|
|
@ -35,6 +36,7 @@ import SettingRow from '../SettingRow';
|
|||
import PlanRow from './PlanRow';
|
||||
import Placeholder from './Placeholder';
|
||||
import * as paymentService from '../../../services/payment';
|
||||
import { useScript } from '../../../libs/hooks';
|
||||
|
||||
import settingsStyles from '../Settings.module.scss';
|
||||
|
||||
|
|
@ -104,7 +106,11 @@ function CancelRow({ setIsPlanModalOpen }) {
|
|||
);
|
||||
}
|
||||
|
||||
function PaymentMethodRow({ source }) {
|
||||
function PaymentMethodRow({
|
||||
stripeLoaded,
|
||||
source,
|
||||
setIsPaymentMethodModalOpen
|
||||
}) {
|
||||
let value;
|
||||
if (source.brand) {
|
||||
value = `${source.brand} ending in ${source.last4}. expiry ${
|
||||
|
|
@ -114,18 +120,39 @@ function PaymentMethodRow({ source }) {
|
|||
value = 'No payment method';
|
||||
}
|
||||
|
||||
return <SettingRow name="Payment method" value={value} />;
|
||||
return (
|
||||
<SettingRow
|
||||
id="T-payment-method-row"
|
||||
name="Payment method"
|
||||
value={value}
|
||||
actionContent={
|
||||
<button
|
||||
id="T-update-payment-method-button"
|
||||
className={classnames('button-no-ui', settingsStyles.edit)}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsPaymentMethodModalOpen(true);
|
||||
}}
|
||||
disabled={!stripeLoaded}
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Content({
|
||||
subscription,
|
||||
source,
|
||||
setIsPlanModalOpen,
|
||||
setIsPaymentMethodModalOpen,
|
||||
successMsg,
|
||||
failureMsg,
|
||||
setSuccessMsg,
|
||||
setFailureMsg,
|
||||
doGetSubscription
|
||||
doGetSubscription,
|
||||
stripeLoaded
|
||||
}) {
|
||||
return (
|
||||
<div className="container-wide">
|
||||
|
|
@ -177,7 +204,11 @@ function Content({
|
|||
<section className={settingsStyles.section}>
|
||||
<h2 className={settingsStyles['section-heading']}>Payment</h2>
|
||||
|
||||
<PaymentMethodRow source={source} />
|
||||
<PaymentMethodRow
|
||||
source={source}
|
||||
setIsPaymentMethodModalOpen={setIsPaymentMethodModalOpen}
|
||||
stripeLoaded={stripeLoaded}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -185,6 +216,21 @@ function Content({
|
|||
);
|
||||
}
|
||||
|
||||
function ErrorMessage({ desc, message }) {
|
||||
return (
|
||||
<div className="container-wide">
|
||||
<div className="row">
|
||||
<div className="col-12 col-md-12 col-lg-10">
|
||||
<Flash type="danger" wrapperClassName={settingsStyles.flash}>
|
||||
<div>{desc}</div>
|
||||
{message}
|
||||
</Flash>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Billing({
|
||||
doGetSubscription,
|
||||
doClearSubscription,
|
||||
|
|
@ -194,9 +240,14 @@ function Billing({
|
|||
doClearSource
|
||||
}) {
|
||||
const [isPlanModalOpen, setIsPlanModalOpen] = useState(false);
|
||||
const [isPaymentMethodModalOpen, setIsPaymentMethodModalOpen] = useState(
|
||||
false
|
||||
);
|
||||
const [successMsg, setSuccessMsg] = useState('');
|
||||
const [failureMsg, setFailureMsg] = useState('');
|
||||
|
||||
const [stripeLoaded, stripeLoadError] = useScript('https://js.stripe.com/v3');
|
||||
|
||||
useEffect(() => {
|
||||
doGetSubscription();
|
||||
doGetSource();
|
||||
|
|
@ -210,6 +261,13 @@ function Billing({
|
|||
const subscription = subscriptionData.data;
|
||||
const source = sourceData.data;
|
||||
|
||||
const key = `${__STRIPE_PUBLIC_KEY__}`;
|
||||
|
||||
let stripe = null;
|
||||
if (stripeLoaded) {
|
||||
stripe = window.Stripe(key);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
|
|
@ -220,28 +278,22 @@ function Billing({
|
|||
|
||||
<Body>
|
||||
{subscriptionData.errorMessage && (
|
||||
<div className="container-wide">
|
||||
<div className="row">
|
||||
<div className="col-12 col-md-12 col-lg-10">
|
||||
<Flash type="danger" wrapperClassName={settingsStyles.flash}>
|
||||
<div>Failed to fetch the billing information</div>
|
||||
{subscriptionData.errorMessage}
|
||||
</Flash>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage
|
||||
desc="Failed to fetch the billing information"
|
||||
message={subscriptionData.errorMessage}
|
||||
/>
|
||||
)}
|
||||
{sourceData.errorMessage && (
|
||||
<div className="container-wide">
|
||||
<div className="row">
|
||||
<div className="col-12 col-md-12 col-lg-10">
|
||||
<Flash type="danger" wrapperClassName={settingsStyles.flash}>
|
||||
<div>Failed to fetch the payment source</div>
|
||||
{sourceData.errorMessage}
|
||||
</Flash>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage
|
||||
desc="Failed to fetch the payment source"
|
||||
message={sourceData.errorMessage}
|
||||
/>
|
||||
)}
|
||||
{stripeLoadError && (
|
||||
<ErrorMessage
|
||||
desc="Failed to load Stripe"
|
||||
message={stripeLoadError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!subscriptionData.isFetched || !sourceData.isFetched ? (
|
||||
|
|
@ -256,6 +308,8 @@ function Billing({
|
|||
setSuccessMsg={setSuccessMsg}
|
||||
setFailureMsg={setFailureMsg}
|
||||
doGetSubscription={doGetSubscription}
|
||||
setIsPaymentMethodModalOpen={setIsPaymentMethodModalOpen}
|
||||
stripeLoaded={stripeLoaded}
|
||||
/>
|
||||
)}
|
||||
</Body>
|
||||
|
|
@ -270,6 +324,16 @@ function Billing({
|
|||
setFailureMsg={setFailureMsg}
|
||||
doGetSubscription={doGetSubscription}
|
||||
/>
|
||||
|
||||
<PaymentMethodModal
|
||||
isOpen={isPaymentMethodModalOpen}
|
||||
onDismiss={() => {
|
||||
setIsPaymentMethodModalOpen(false);
|
||||
}}
|
||||
setSuccessMsg={setSuccessMsg}
|
||||
doGetSource={doGetSource}
|
||||
stripe={stripe}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ import classnames from 'classnames';
|
|||
|
||||
import styles from './SettingRow.module.scss';
|
||||
|
||||
function SettingRow({ name, desc, value, actionContent }) {
|
||||
function SettingRow({ name, desc, value, actionContent, id }) {
|
||||
return (
|
||||
<div className={classnames(styles.wrapper, styles.row)}>
|
||||
<div className={classnames(styles.wrapper, styles.row)} id={id}>
|
||||
<div>
|
||||
<h3 className={styles.name}>{name}</h3>
|
||||
<p className={styles.desc}>{desc}</p>
|
||||
|
|
|
|||
|
|
@ -21,41 +21,20 @@ import { withRouter } from 'react-router-dom';
|
|||
import { connect } from 'react-redux';
|
||||
import classnames from 'classnames';
|
||||
import Helmet from 'react-helmet';
|
||||
import { injectStripe, CardElement } from 'react-stripe-elements';
|
||||
import { injectStripe } from 'react-stripe-elements';
|
||||
|
||||
import Sidebar from './Sidebar';
|
||||
import CountrySelect from './CountrySelect';
|
||||
import Flash from '../../Common/Flash';
|
||||
import * as paymentService from '../../../services/payment';
|
||||
import { getCurrentUser } from '../../../actions/auth';
|
||||
import { updateMessage } from '../../../actions/ui';
|
||||
import { getHomePath } from '../../../libs/paths';
|
||||
import NameOnCardInput from '../../Common/PaymentInput/NameOnCard';
|
||||
import CardInput from '../../Common/PaymentInput/Card';
|
||||
import CountryInput from '../../Common/PaymentInput/Country';
|
||||
|
||||
import styles from './Form.module.scss';
|
||||
|
||||
const elementStyles = {
|
||||
base: {
|
||||
color: '#32325D',
|
||||
fontFamily: 'Source Code Pro, Consolas, Menlo, monospace',
|
||||
fontSize: '16px',
|
||||
fontSmoothing: 'antialiased',
|
||||
|
||||
'::placeholder': {
|
||||
color: '#CFD7DF'
|
||||
},
|
||||
':-webkit-autofill': {
|
||||
color: '#e39f48'
|
||||
}
|
||||
},
|
||||
invalid: {
|
||||
color: '#E25950',
|
||||
|
||||
'::placeholder': {
|
||||
color: '#FFCCA5'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function Form({
|
||||
stripe,
|
||||
stripeLoadError,
|
||||
|
|
@ -65,7 +44,6 @@ function Form({
|
|||
}) {
|
||||
const [nameOnCard, setNameOnCard] = useState('');
|
||||
const cardElementRef = useRef(null);
|
||||
const [cardElementFocused, setCardElementFocused] = useState(false);
|
||||
const [cardElementLoaded, setCardElementLoaded] = useState(false);
|
||||
const [billingCountry, setBillingCountry] = useState('');
|
||||
const [transacting, setTransacting] = useState(false);
|
||||
|
|
@ -154,71 +132,26 @@ function Form({
|
|||
<h1 className={styles.heading}>You are almost there.</h1>
|
||||
|
||||
<div className={styles.content}>
|
||||
<div className={styles['input-row']}>
|
||||
<label htmlFor="name-on-card" className="label-full">
|
||||
<span className={styles.label}>Name on Card</span>
|
||||
<input
|
||||
autoFocus
|
||||
id="name-on-card"
|
||||
className={classnames(
|
||||
'text-input text-input-stretch text-input-medium',
|
||||
styles.input
|
||||
)}
|
||||
type="text"
|
||||
value={nameOnCard}
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
setNameOnCard(val);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<NameOnCardInput
|
||||
value={nameOnCard}
|
||||
onUpdate={setNameOnCard}
|
||||
containerClassName={styles['input-row']}
|
||||
labelClassName={styles.label}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={classnames(styles['input-row'], styles['card-row'])}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||
<label htmlFor="card-number" className={styles.number}>
|
||||
<span className={styles.label}>Card Number</span>
|
||||
<CardInput
|
||||
cardElementRef={cardElementRef}
|
||||
setCardElementLoaded={setCardElementLoaded}
|
||||
containerClassName={styles['input-row']}
|
||||
labelClassName={styles.label}
|
||||
/>
|
||||
|
||||
<CardElement
|
||||
id="card"
|
||||
className={classnames(styles['card-number'], styles.input, {
|
||||
[styles['card-number-active']]: cardElementFocused
|
||||
})}
|
||||
onFocus={() => {
|
||||
setCardElementFocused(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setCardElementFocused(false);
|
||||
}}
|
||||
onReady={el => {
|
||||
cardElementRef.current = el;
|
||||
setCardElementLoaded(true);
|
||||
}}
|
||||
style={elementStyles}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={styles['input-row']}>
|
||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||
<label htmlFor="billing-country" className="label-full">
|
||||
<span className={styles.label}>Country</span>
|
||||
<CountrySelect
|
||||
id="billing-country"
|
||||
className={classnames(
|
||||
styles['countries-select'],
|
||||
styles.input
|
||||
)}
|
||||
value={billingCountry}
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
setBillingCountry(val);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<CountryInput
|
||||
value={billingCountry}
|
||||
onUpdate={setBillingCountry}
|
||||
containerClassName={styles['input-row']}
|
||||
labelClassName={styles.label}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -25,10 +25,6 @@
|
|||
margin-top: rem(52px);
|
||||
}
|
||||
|
||||
.number {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.cvc,
|
||||
.expiry {
|
||||
width: rem(100px);
|
||||
|
|
@ -38,24 +34,6 @@
|
|||
margin-bottom: rem(24px);
|
||||
}
|
||||
|
||||
.label {
|
||||
@include font-size('medium');
|
||||
}
|
||||
|
||||
.input {
|
||||
margin-top: rem(8px);
|
||||
}
|
||||
|
||||
.input-row {
|
||||
&:not(:first-child) {
|
||||
margin-top: rem(20px);
|
||||
}
|
||||
}
|
||||
|
||||
.card-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.heading {
|
||||
@include font-size('3x-large');
|
||||
font-weight: 400;
|
||||
|
|
@ -71,17 +49,12 @@
|
|||
margin-top: rem(32px);
|
||||
}
|
||||
|
||||
.card-number {
|
||||
// match the height of the stripe element with other inputs
|
||||
padding: rem(10.4px) rem(12px);
|
||||
border: 2px solid $border-color;
|
||||
|
||||
&.card-number-active {
|
||||
border: 2px solid $third;
|
||||
.input-row {
|
||||
&:not(:first-child) {
|
||||
margin-top: rem(20px);
|
||||
}
|
||||
}
|
||||
|
||||
.countries-select {
|
||||
width: 100%;
|
||||
display: block;
|
||||
.label {
|
||||
@include font-size('medium');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,3 +52,12 @@ export function reactivateSubscription({ subscriptionId }) {
|
|||
export function getSource() {
|
||||
return apiClient.get('/stripe_source');
|
||||
}
|
||||
|
||||
export function updateSource({ source, country }) {
|
||||
const payload = {
|
||||
source,
|
||||
country
|
||||
};
|
||||
|
||||
return apiClient.patch('/stripe_source', payload);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue