Tip Jar Profile (#64)
* init profile tipjar backend * init profile tipjar frontend * fix lint * implement tip jar block * fix wrapping, hide tip block on self * add hide title, fix bug * rename vars, use null check * allow address and view key to be unset * add api tests * fix migrations
This commit is contained in:
parent
4b7d85872a
commit
d98b255378
|
@ -58,6 +58,8 @@ class UserSettings(db.Model):
|
|||
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
_email_subscriptions = db.Column("email_subscriptions", db.Integer, default=0) # bitmask
|
||||
refund_address = db.Column(db.String(255), unique=False, nullable=True)
|
||||
tip_jar_address = db.Column(db.String(255), unique=False, nullable=True)
|
||||
tip_jar_view_key = db.Column(db.String(255), unique=False, nullable=True)
|
||||
|
||||
user = db.relationship("User", back_populates="settings")
|
||||
|
||||
|
@ -356,13 +358,15 @@ class UserSchema(ma.Schema):
|
|||
"avatar",
|
||||
"display_name",
|
||||
"userid",
|
||||
"email_verified"
|
||||
"email_verified",
|
||||
"tip_jar_address"
|
||||
)
|
||||
|
||||
social_medias = ma.Nested("SocialMediaSchema", many=True)
|
||||
avatar = ma.Nested("AvatarSchema")
|
||||
userid = ma.Method("get_userid")
|
||||
email_verified = ma.Method("get_email_verified")
|
||||
tip_jar_address = ma.Method("get_tip_jar_address")
|
||||
|
||||
def get_userid(self, obj):
|
||||
return obj.id
|
||||
|
@ -370,6 +374,9 @@ class UserSchema(ma.Schema):
|
|||
def get_email_verified(self, obj):
|
||||
return obj.email_verification.has_verified
|
||||
|
||||
def get_tip_jar_address(self, obj):
|
||||
return obj.settings.tip_jar_address
|
||||
|
||||
|
||||
user_schema = UserSchema()
|
||||
users_schema = UserSchema(many=True)
|
||||
|
@ -412,6 +419,8 @@ class UserSettingsSchema(ma.Schema):
|
|||
fields = (
|
||||
"email_subscriptions",
|
||||
"refund_address",
|
||||
"tip_jar_address",
|
||||
"tip_jar_view_key"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -349,9 +349,12 @@ def get_user_settings(user_id):
|
|||
@body({
|
||||
"emailSubscriptions": fields.Dict(required=False, missing=None),
|
||||
"refundAddress": fields.Str(required=False, missing=None,
|
||||
validate=lambda r: validate_blockchain_get('/validate/address', {'address': r}))
|
||||
validate=lambda r: validate_blockchain_get('/validate/address', {'address': r})),
|
||||
"tipJarAddress": fields.Str(required=False, missing=None,
|
||||
validate=lambda r: validate_blockchain_get('/validate/address', {'address': r})),
|
||||
"tipJarViewKey": fields.Str(required=False, missing=None) # TODO: add viewkey validation here
|
||||
})
|
||||
def set_user_settings(user_id, email_subscriptions, refund_address):
|
||||
def set_user_settings(user_id, email_subscriptions, refund_address, tip_jar_address, tip_jar_view_key):
|
||||
if email_subscriptions:
|
||||
try:
|
||||
email_subscriptions = keys_to_snake_case(email_subscriptions)
|
||||
|
@ -364,6 +367,12 @@ def set_user_settings(user_id, email_subscriptions, refund_address):
|
|||
if refund_address:
|
||||
g.current_user.settings.refund_address = refund_address
|
||||
|
||||
# TODO: is additional validation needed similar to refund_address?
|
||||
if tip_jar_address is not None:
|
||||
g.current_user.settings.tip_jar_address = tip_jar_address
|
||||
if tip_jar_view_key is not None:
|
||||
g.current_user.settings.tip_jar_view_key = tip_jar_view_key
|
||||
|
||||
db.session.commit()
|
||||
return user_settings_schema.dump(g.current_user.settings)
|
||||
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: 9d2f7db5b5a6
|
||||
Revises: 0ba15ddf5053
|
||||
Create Date: 2019-11-13 17:29:46.810554
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '9d2f7db5b5a6'
|
||||
down_revision = '0ba15ddf5053'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('user_settings', sa.Column('tip_jar_address', sa.String(length=255), nullable=True))
|
||||
op.add_column('user_settings', sa.Column('tip_jar_view_key', sa.String(length=255), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('user_settings', 'tip_jar_view_key')
|
||||
op.drop_column('user_settings', 'tip_jar_address')
|
||||
# ### end Alembic commands ###
|
|
@ -8,7 +8,7 @@ from grant.user.models import User, user_schema, db
|
|||
from mock import patch
|
||||
|
||||
from ..config import BaseUserConfig
|
||||
from ..test_data import test_user
|
||||
from ..test_data import test_user, mock_blockchain_api_requests
|
||||
|
||||
|
||||
class TestUserAPI(BaseUserConfig):
|
||||
|
@ -385,3 +385,34 @@ class TestUserAPI(BaseUserConfig):
|
|||
content_type='application/json'
|
||||
)
|
||||
self.assert400(resp)
|
||||
|
||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||
def test_put_user_settings_tip_jar_address(self, mock_get):
|
||||
address = "address"
|
||||
|
||||
self.login_default_user()
|
||||
resp = self.app.put(
|
||||
"/api/v1/users/{}/settings".format(self.user.id),
|
||||
data=json.dumps({'tipJarAddress': address}),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assert200(resp)
|
||||
self.assertEqual(resp.json["tipJarAddress"], address)
|
||||
user = User.query.get(self.user.id)
|
||||
self.assertEqual(user.settings.tip_jar_address, address)
|
||||
|
||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||
def test_put_user_settings_tip_jar_view_key(self, mock_get):
|
||||
view_key = "view_key"
|
||||
|
||||
self.login_default_user()
|
||||
resp = self.app.put(
|
||||
"/api/v1/users/{}/settings".format(self.user.id),
|
||||
data=json.dumps({'tipJarViewKey': view_key}),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assert200(resp)
|
||||
self.assertEqual(resp.json["tipJarViewKey"], view_key)
|
||||
user = User.query.get(self.user.id)
|
||||
self.assertEqual(user.settings.tip_jar_view_key, view_key)
|
||||
|
||||
|
|
|
@ -155,6 +155,8 @@ export function getUserSettings(
|
|||
interface SettingsArgs {
|
||||
emailSubscriptions?: EmailSubscriptions;
|
||||
refundAddress?: string;
|
||||
tipJarAddress?: string;
|
||||
tipJarViewKey?: string;
|
||||
}
|
||||
export function updateUserSettings(
|
||||
userId: string | number,
|
||||
|
|
|
@ -2,8 +2,10 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&-avatar {
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
height: 10.5rem;
|
||||
|
@ -20,6 +22,8 @@
|
|||
&-info {
|
||||
// no overflow of flexbox
|
||||
min-width: 0;
|
||||
margin-bottom: 2rem;
|
||||
flex-grow: 1;
|
||||
|
||||
&-name {
|
||||
font-size: 1.6rem;
|
||||
|
@ -30,6 +34,8 @@
|
|||
font-size: 1rem;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 0.3rem;
|
||||
max-width: fit-content;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
&-address {
|
||||
|
|
|
@ -5,6 +5,7 @@ import { Button } from 'antd';
|
|||
import { SocialMedia } from 'types';
|
||||
import { UserState } from 'modules/users/reducers';
|
||||
import UserAvatar from 'components/UserAvatar';
|
||||
import { TipJarBlock } from 'components/TipJar';
|
||||
import { SOCIAL_INFO } from 'utils/social';
|
||||
import { AppState } from 'store/reducers';
|
||||
import './ProfileUser.less';
|
||||
|
@ -19,7 +20,15 @@ interface StateProps {
|
|||
|
||||
type Props = OwnProps & StateProps;
|
||||
|
||||
class ProfileUser extends React.Component<Props> {
|
||||
const STATE = {
|
||||
tipJarModalOpen: false,
|
||||
};
|
||||
|
||||
type State = typeof STATE;
|
||||
|
||||
class ProfileUser extends React.Component<Props, State> {
|
||||
state = STATE;
|
||||
|
||||
render() {
|
||||
const {
|
||||
authUser,
|
||||
|
@ -52,6 +61,7 @@ class ProfileUser extends React.Component<Props> {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isSelf && <TipJarBlock address={user.tipJarAddress} type="user" isCard />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,33 +1,57 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Form, Input, Button, message } from 'antd';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { updateUserSettings, getUserSettings } from 'api/api';
|
||||
import { updateUserSettings } from 'api/api';
|
||||
import { isValidAddress } from 'utils/validators';
|
||||
import { UserSettings } from 'types';
|
||||
|
||||
interface StateProps {
|
||||
interface Props {
|
||||
userSettings?: UserSettings;
|
||||
isFetching: boolean;
|
||||
errorFetching: boolean;
|
||||
userid: number;
|
||||
onAddressSet: (refundAddress: UserSettings['refundAddress']) => void;
|
||||
}
|
||||
|
||||
type Props = StateProps;
|
||||
interface State {
|
||||
isSaving: boolean
|
||||
refundAddress: string | null
|
||||
refundAddressSet: string | null
|
||||
}
|
||||
|
||||
const STATE = {
|
||||
refundAddress: '',
|
||||
isFetching: false,
|
||||
isSaving: false,
|
||||
};
|
||||
|
||||
type State = typeof STATE;
|
||||
export default class RefundAddress extends React.Component<Props, State> {
|
||||
|
||||
class RefundAddress extends React.Component<Props, State> {
|
||||
state: State = { ...STATE };
|
||||
static getDerivedStateFromProps(nextProps: Props, prevState: State) {
|
||||
const { userSettings } = nextProps;
|
||||
const { refundAddress, refundAddressSet } = prevState;
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchRefundAddress();
|
||||
const ret: Partial<State> = {};
|
||||
|
||||
if (!userSettings || !userSettings.refundAddress) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
if (userSettings.refundAddress !== refundAddressSet) {
|
||||
ret.refundAddressSet = userSettings.refundAddress;
|
||||
|
||||
if (refundAddress === null) {
|
||||
ret.refundAddress = userSettings.refundAddress;
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
state: State = {
|
||||
isSaving: false,
|
||||
refundAddress: null,
|
||||
refundAddressSet: null
|
||||
};
|
||||
|
||||
render() {
|
||||
const { refundAddress, isFetching, isSaving } = this.state;
|
||||
const { isSaving, refundAddress, refundAddressSet } = this.state;
|
||||
const { isFetching, errorFetching } = this.props;
|
||||
const addressChanged = refundAddress !== refundAddressSet;
|
||||
|
||||
let status: 'validating' | 'error' | undefined;
|
||||
let help;
|
||||
|
@ -42,10 +66,10 @@ class RefundAddress extends React.Component<Props, State> {
|
|||
<Form className="RefundAddress" layout="vertical" onSubmit={this.handleSubmit}>
|
||||
<Form.Item label="Refund address" validateStatus={status} help={help}>
|
||||
<Input
|
||||
value={refundAddress}
|
||||
value={refundAddress || ''}
|
||||
placeholder="Z or T address"
|
||||
onChange={this.handleChange}
|
||||
disabled={isFetching || isSaving}
|
||||
disabled={isFetching || isSaving || errorFetching}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
|
@ -53,7 +77,9 @@ class RefundAddress extends React.Component<Props, State> {
|
|||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
disabled={!refundAddress || isSaving || !!status}
|
||||
disabled={
|
||||
!refundAddress || isSaving || !!status || errorFetching || !addressChanged
|
||||
}
|
||||
loading={isSaving}
|
||||
block
|
||||
>
|
||||
|
@ -63,19 +89,6 @@ class RefundAddress extends React.Component<Props, State> {
|
|||
);
|
||||
}
|
||||
|
||||
private async fetchRefundAddress() {
|
||||
const { userid } = this.props;
|
||||
this.setState({ isFetching: true });
|
||||
try {
|
||||
const res = await getUserSettings(userid);
|
||||
this.setState({ refundAddress: res.data.refundAddress || '' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
message.error('Failed to get refund address');
|
||||
}
|
||||
this.setState({ isFetching: false });
|
||||
}
|
||||
|
||||
private handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ refundAddress: ev.currentTarget.value });
|
||||
};
|
||||
|
@ -92,7 +105,9 @@ class RefundAddress extends React.Component<Props, State> {
|
|||
try {
|
||||
const res = await updateUserSettings(userid, { refundAddress });
|
||||
message.success('Settings saved');
|
||||
this.setState({ refundAddress: res.data.refundAddress || '' });
|
||||
const refundAddressNew = res.data.refundAddress || '';
|
||||
this.setState({ refundAddress: refundAddressNew });
|
||||
this.props.onAddressSet(refundAddressNew);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
message.error(err.message || err.toString(), 5);
|
||||
|
@ -100,9 +115,3 @@ class RefundAddress extends React.Component<Props, State> {
|
|||
this.setState({ isSaving: false });
|
||||
};
|
||||
}
|
||||
|
||||
const withConnect = connect<StateProps, {}, {}, AppState>(state => ({
|
||||
userid: state.auth.user ? state.auth.user.userid : 0,
|
||||
}));
|
||||
|
||||
export default withConnect(RefundAddress);
|
||||
|
|
|
@ -0,0 +1,131 @@
|
|||
import React from 'react';
|
||||
import { Form, Input, Button, message } from 'antd';
|
||||
import { updateUserSettings } from 'api/api';
|
||||
import { isValidAddress } from 'utils/validators';
|
||||
import { UserSettings } from 'types';
|
||||
|
||||
interface Props {
|
||||
userSettings?: UserSettings;
|
||||
isFetching: boolean;
|
||||
errorFetching: boolean;
|
||||
userid: number;
|
||||
onAddressSet: (refundAddress: UserSettings['tipJarAddress']) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
isSaving: boolean;
|
||||
tipJarAddress: string | null;
|
||||
tipJarAddressSet: string | null;
|
||||
}
|
||||
|
||||
export default class TipJarAddress extends React.Component<Props, State> {
|
||||
static getDerivedStateFromProps(nextProps: Props, prevState: State) {
|
||||
const { userSettings } = nextProps;
|
||||
const { tipJarAddress, tipJarAddressSet } = prevState;
|
||||
|
||||
const ret: Partial<State> = {};
|
||||
|
||||
if (!userSettings || userSettings.tipJarAddress === undefined) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
if (userSettings.tipJarAddress !== tipJarAddressSet) {
|
||||
ret.tipJarAddressSet = userSettings.tipJarAddress;
|
||||
|
||||
if (tipJarAddress === null) {
|
||||
ret.tipJarAddress = userSettings.tipJarAddress;
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
state: State = {
|
||||
isSaving: false,
|
||||
tipJarAddress: null,
|
||||
tipJarAddressSet: null,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isSaving, tipJarAddress, tipJarAddressSet } = this.state;
|
||||
const { isFetching, errorFetching, userSettings } = this.props;
|
||||
const addressChanged = tipJarAddress !== tipJarAddressSet;
|
||||
const hasViewKeySet = userSettings && userSettings.tipJarViewKey
|
||||
|
||||
let addressIsValid;
|
||||
let status: 'validating' | 'error' | undefined;
|
||||
let help;
|
||||
|
||||
if (tipJarAddress !== null) {
|
||||
addressIsValid = tipJarAddress === '' || isValidAddress(tipJarAddress);
|
||||
}
|
||||
|
||||
if (isFetching) {
|
||||
status = 'validating';
|
||||
} else if (tipJarAddress !== null && !addressIsValid) {
|
||||
status = 'error';
|
||||
help = 'That doesn’t look like a valid address';
|
||||
}
|
||||
|
||||
if (tipJarAddress === '' && hasViewKeySet) {
|
||||
status = 'error';
|
||||
help = 'You must unset your view key before unsetting your address'
|
||||
}
|
||||
|
||||
return (
|
||||
<Form className="RefundAddress" layout="vertical" onSubmit={this.handleSubmit}>
|
||||
<Form.Item label="Tip jar address" validateStatus={status} help={help}>
|
||||
<Input
|
||||
value={tipJarAddress || ''}
|
||||
placeholder="Z or T address"
|
||||
onChange={this.handleChange}
|
||||
disabled={isFetching || isSaving || errorFetching}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
disabled={
|
||||
tipJarAddress === null ||
|
||||
isSaving ||
|
||||
!!status ||
|
||||
errorFetching ||
|
||||
!addressChanged
|
||||
}
|
||||
loading={isSaving}
|
||||
block
|
||||
>
|
||||
Change tip jar address
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
private handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ tipJarAddress: ev.currentTarget.value });
|
||||
};
|
||||
|
||||
private handleSubmit = async (ev: React.FormEvent<HTMLFormElement>) => {
|
||||
ev.preventDefault();
|
||||
const { userid } = this.props;
|
||||
const { tipJarAddress } = this.state;
|
||||
|
||||
if (tipJarAddress === null) return;
|
||||
|
||||
this.setState({ isSaving: true });
|
||||
try {
|
||||
const res = await updateUserSettings(userid, { tipJarAddress });
|
||||
message.success('Settings saved');
|
||||
const tipJarAddressNew =
|
||||
res.data.tipJarAddress === undefined ? null : res.data.tipJarAddress;
|
||||
this.setState({ tipJarAddress: tipJarAddressNew });
|
||||
this.props.onAddressSet(tipJarAddressNew);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
message.error(err.message || err.toString(), 5);
|
||||
}
|
||||
this.setState({ isSaving: false });
|
||||
};
|
||||
}
|
|
@ -0,0 +1,120 @@
|
|||
import React from 'react';
|
||||
import { Form, Input, Button, message } from 'antd';
|
||||
import { updateUserSettings } from 'api/api';
|
||||
import { UserSettings } from 'types';
|
||||
|
||||
interface Props {
|
||||
userSettings?: UserSettings;
|
||||
isFetching: boolean;
|
||||
errorFetching: boolean;
|
||||
userid: number;
|
||||
onViewKeySet: (viewKey: UserSettings['tipJarViewKey']) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
isSaving: boolean;
|
||||
tipJarViewKey: string | null;
|
||||
tipJarViewKeySet: string | null;
|
||||
}
|
||||
|
||||
export default class TipJarViewKey extends React.Component<Props, State> {
|
||||
static getDerivedStateFromProps(nextProps: Props, prevState: State) {
|
||||
const { userSettings } = nextProps;
|
||||
const { tipJarViewKey, tipJarViewKeySet } = prevState;
|
||||
|
||||
const ret: Partial<State> = {};
|
||||
|
||||
if (!userSettings || userSettings.tipJarViewKey === undefined) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
if (userSettings.tipJarViewKey !== tipJarViewKeySet) {
|
||||
ret.tipJarViewKeySet = userSettings.tipJarViewKey;
|
||||
|
||||
if (tipJarViewKey === null) {
|
||||
ret.tipJarViewKey = userSettings.tipJarViewKey;
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
state: State = {
|
||||
isSaving: false,
|
||||
tipJarViewKey: null,
|
||||
tipJarViewKeySet: null,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isSaving, tipJarViewKey, tipJarViewKeySet } = this.state;
|
||||
const { isFetching, errorFetching, userSettings } = this.props;
|
||||
const viewKeyChanged = tipJarViewKey !== tipJarViewKeySet;
|
||||
const viewKeyDisabled = !(userSettings && userSettings.tipJarAddress);
|
||||
|
||||
// TODO: add view key validation
|
||||
|
||||
// let status: 'validating' | 'error' | undefined;
|
||||
// let help;
|
||||
// if (isFetching) {
|
||||
// status = 'validating';
|
||||
// } else if (tipJarAddress && !isValidAddress(tipJarAddress)) {
|
||||
// status = 'error';
|
||||
// help = 'That doesn’t look like a valid address';
|
||||
// }
|
||||
|
||||
return (
|
||||
<Form className="RefundAddress" layout="vertical" onSubmit={this.handleSubmit}>
|
||||
<Form.Item label="Tip jar view key">
|
||||
<Input
|
||||
value={tipJarViewKey || ''}
|
||||
placeholder="A view key for your tip jar address (optional)"
|
||||
onChange={this.handleChange}
|
||||
disabled={viewKeyDisabled || isFetching || isSaving || errorFetching}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
disabled={
|
||||
tipJarViewKey === null ||
|
||||
isSaving ||
|
||||
!!status ||
|
||||
errorFetching ||
|
||||
!viewKeyChanged
|
||||
}
|
||||
loading={isSaving}
|
||||
block
|
||||
>
|
||||
Change tip jar view key
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
private handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ tipJarViewKey: ev.currentTarget.value });
|
||||
};
|
||||
|
||||
private handleSubmit = async (ev: React.FormEvent<HTMLFormElement>) => {
|
||||
ev.preventDefault();
|
||||
const { userid } = this.props;
|
||||
const { tipJarViewKey } = this.state;
|
||||
|
||||
if (tipJarViewKey === null) return;
|
||||
|
||||
this.setState({ isSaving: true });
|
||||
try {
|
||||
const res = await updateUserSettings(userid, { tipJarViewKey });
|
||||
message.success('Settings saved');
|
||||
const tipJarViewKeyNew = res.data.tipJarViewKey || '';
|
||||
this.setState({ tipJarViewKey: tipJarViewKeyNew });
|
||||
this.props.onViewKeySet(tipJarViewKeyNew);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
message.error(err.message || err.toString(), 5);
|
||||
}
|
||||
this.setState({ isSaving: false });
|
||||
};
|
||||
}
|
|
@ -1,16 +1,126 @@
|
|||
import React from 'react';
|
||||
import { Divider } from 'antd';
|
||||
import { Divider, message } from 'antd';
|
||||
import { connect } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { getUserSettings } from 'api/api';
|
||||
import ChangeEmail from './ChangeEmail';
|
||||
import RefundAddress from './RefundAddress';
|
||||
import TipJarAddress from './TipJarAddress';
|
||||
import ViewingKey from './TipJarViewKey';
|
||||
import { UserSettings } from 'types';
|
||||
|
||||
interface StateProps {
|
||||
userid: number;
|
||||
}
|
||||
|
||||
type Props = StateProps;
|
||||
interface State {
|
||||
userSettings: UserSettings | undefined;
|
||||
isFetching: boolean;
|
||||
errorFetching: boolean;
|
||||
};
|
||||
|
||||
const STATE: State = {
|
||||
userSettings: undefined,
|
||||
isFetching: false,
|
||||
errorFetching: false,
|
||||
};
|
||||
|
||||
class AccountSettings extends React.Component<Props, State> {
|
||||
state: State = { ...STATE };
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchUserSettings();
|
||||
}
|
||||
|
||||
export default class AccountSettings extends React.Component<{}> {
|
||||
render() {
|
||||
const { userid } = this.props;
|
||||
const { userSettings, isFetching, errorFetching } = this.state;
|
||||
|
||||
return (
|
||||
<div className="AccountSettings">
|
||||
<ChangeEmail />
|
||||
<Divider style={{ margin: '2.5rem 0' }} />
|
||||
<RefundAddress />
|
||||
<RefundAddress
|
||||
userSettings={userSettings}
|
||||
isFetching={isFetching}
|
||||
errorFetching={errorFetching}
|
||||
userid={userid}
|
||||
onAddressSet={this.handleRefundAddressSet}
|
||||
/>
|
||||
<Divider style={{ margin: '2.5rem 0' }} />
|
||||
<TipJarAddress
|
||||
userSettings={userSettings}
|
||||
isFetching={isFetching}
|
||||
errorFetching={errorFetching}
|
||||
userid={userid}
|
||||
onAddressSet={this.handleTipJarAddressSet}
|
||||
/>
|
||||
<Divider style={{ margin: '2.5rem 0' }} />
|
||||
<ViewingKey
|
||||
userSettings={userSettings}
|
||||
isFetching={isFetching}
|
||||
errorFetching={errorFetching}
|
||||
userid={userid}
|
||||
onViewKeySet={this.handleTipJarViewKeySet}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private async fetchUserSettings() {
|
||||
const { userid } = this.props;
|
||||
this.setState({ isFetching: true });
|
||||
try {
|
||||
const res = await getUserSettings(userid);
|
||||
this.setState({ userSettings: res.data || undefined });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
message.error('Failed to get user settings');
|
||||
this.setState({ errorFetching: true });
|
||||
}
|
||||
this.setState({ isFetching: false });
|
||||
}
|
||||
|
||||
private handleRefundAddressSet = (refundAddress: UserSettings['refundAddress']) => {
|
||||
const { userSettings } = this.state;
|
||||
if (!userSettings) return;
|
||||
|
||||
this.setState({
|
||||
userSettings: {
|
||||
...userSettings,
|
||||
refundAddress,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private handleTipJarAddressSet = (tipJarAddress: UserSettings['tipJarAddress']) => {
|
||||
const { userSettings } = this.state;
|
||||
if (!userSettings) return;
|
||||
|
||||
this.setState({
|
||||
userSettings: {
|
||||
...userSettings,
|
||||
tipJarAddress,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private handleTipJarViewKeySet = (tipJarViewKey: UserSettings['tipJarViewKey']) => {
|
||||
const { userSettings } = this.state;
|
||||
if (!userSettings) return;
|
||||
|
||||
this.setState({
|
||||
userSettings: {
|
||||
...userSettings,
|
||||
tipJarViewKey,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const withConnect = connect<StateProps, {}, {}, AppState>(state => ({
|
||||
userid: state.auth.user ? state.auth.user.userid : 0,
|
||||
}));
|
||||
|
||||
export default withConnect(AccountSettings);
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
@import '~styles/variables.less';
|
||||
|
||||
.TipJarBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.ant-form-item {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-radio-wrapper {
|
||||
margin-bottom: 0.5rem;
|
||||
opacity: 0.7;
|
||||
font-size: 0.8rem;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.anticon {
|
||||
margin-left: 0.2rem;
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 1.4rem;
|
||||
line-height: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
import React from 'react';
|
||||
import { Button, Form, Input, Tooltip } from 'antd';
|
||||
import { TipJarModal } from './TipJarModal';
|
||||
import { getAmountErrorFromString } from 'utils/validators';
|
||||
import './TipJarBlock.less';
|
||||
import '../Proposal/index.less';
|
||||
|
||||
interface Props {
|
||||
isCard?: boolean;
|
||||
hideTitle?: boolean;
|
||||
address?: string | null;
|
||||
type: 'user' | 'proposal';
|
||||
}
|
||||
|
||||
const STATE = {
|
||||
tipAmount: '',
|
||||
modalOpen: false,
|
||||
};
|
||||
|
||||
type State = typeof STATE;
|
||||
|
||||
export class TipJarBlock extends React.Component<Props, State> {
|
||||
state = STATE;
|
||||
|
||||
render() {
|
||||
const { isCard, address, type, hideTitle } = this.props;
|
||||
const { tipAmount, modalOpen } = this.state;
|
||||
const amountError = tipAmount ? getAmountErrorFromString(tipAmount) : '';
|
||||
|
||||
const addressNotSet = !address;
|
||||
const buttonTooltip = addressNotSet
|
||||
? `Tipping address has not been set for ${type}`
|
||||
: '';
|
||||
const isDisabled = addressNotSet || !tipAmount || !!amountError;
|
||||
|
||||
return (
|
||||
<div className={isCard ? 'Proposal-top-main-block' : undefined}>
|
||||
<Form layout="vertical" className="TipJarBlock">
|
||||
{!hideTitle && <h1 className="TipJarBlock-title">Tip</h1>}
|
||||
<Form.Item
|
||||
validateStatus={amountError ? 'error' : undefined}
|
||||
help={amountError}
|
||||
style={{ marginBottom: '0.5rem', paddingBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
size="large"
|
||||
name="amountToTip"
|
||||
type="number"
|
||||
value={tipAmount}
|
||||
placeholder="0.5"
|
||||
min={0}
|
||||
step={0.1}
|
||||
onChange={this.handleAmountChange}
|
||||
addonAfter="ZEC"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Tooltip placement={'bottomRight'} title={buttonTooltip}>
|
||||
<Button
|
||||
onClick={this.handleTipJarModalOpen}
|
||||
size="large"
|
||||
type="primary"
|
||||
disabled={isDisabled}
|
||||
block
|
||||
>
|
||||
Donate
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Form>
|
||||
|
||||
{address && tipAmount && (
|
||||
<TipJarModal
|
||||
isOpen={modalOpen}
|
||||
onClose={this.handleTipJarModalClose}
|
||||
type={type}
|
||||
address={address}
|
||||
amount={tipAmount}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
this.setState({
|
||||
tipAmount: e.currentTarget.value,
|
||||
});
|
||||
|
||||
private handleTipJarModalOpen = () =>
|
||||
this.setState({
|
||||
modalOpen: true,
|
||||
});
|
||||
|
||||
private handleTipJarModalClose = () =>
|
||||
this.setState({
|
||||
modalOpen: false,
|
||||
});
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
@import '~styles/variables.less';
|
||||
|
||||
.TipJarModal {
|
||||
|
||||
&-uri {
|
||||
display: flex;
|
||||
padding-bottom: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-bottom: 1px solid rgba(#000, 0.06);
|
||||
|
||||
&-qr {
|
||||
position: relative;
|
||||
padding: 0.5rem;
|
||||
margin-right: 1rem;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 2px rgba(#000, 0.1), 0 1px 4px rgba(#000, 0.2);
|
||||
|
||||
canvas {
|
||||
display: flex;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
&.is-loading canvas {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ant-spin {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
&-info {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&-fields {
|
||||
&-row {
|
||||
display: flex;
|
||||
|
||||
> * {
|
||||
flex: 1;
|
||||
margin-right: 0.75rem;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&-address {
|
||||
min-width: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ant overrides
|
||||
input[readonly],
|
||||
textarea[readonly] {
|
||||
background: rgba(#000, 0.05);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import React from 'react';
|
||||
import { Modal, Icon, Button, Form, Input } from 'antd';
|
||||
import classnames from 'classnames';
|
||||
import QRCode from 'qrcode.react';
|
||||
import { formatZcashCLI, formatZcashURI } from 'utils/formatters';
|
||||
import { getAmountErrorFromString } from 'utils/validators'
|
||||
import Loader from 'components/Loader';
|
||||
import './TipJarModal.less';
|
||||
import CopyInput from 'components/CopyInput';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
type: 'user' | 'proposal';
|
||||
address: string;
|
||||
amount: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
amount: string | null;
|
||||
}
|
||||
|
||||
export class TipJarModal extends React.Component<Props, State> {
|
||||
static getDerivedStateFromProps = (nextProps: Props, prevState: State) => {
|
||||
return prevState.amount === null ? { amount: nextProps.amount } : {};
|
||||
};
|
||||
|
||||
state: State = {
|
||||
amount: null,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isOpen, onClose, type, address } = this.props;
|
||||
const { amount } = this.state;
|
||||
|
||||
// should not be possible due to derived state, but makes TS happy
|
||||
if (amount === null) return;
|
||||
|
||||
const amountError = getAmountErrorFromString(amount)
|
||||
const amountIsValid = !amountError
|
||||
|
||||
const cli = amountIsValid ? formatZcashCLI(address, amount) : '';
|
||||
const uri = amountIsValid ? formatZcashURI(address, amount) : '';
|
||||
|
||||
const content = (
|
||||
<div className="TipJarModal">
|
||||
<div className="TipJarModal-uri">
|
||||
<div>
|
||||
<div className={classnames('TipJarModal-uri-qr', !uri && 'is-loading')}>
|
||||
<span style={{ opacity: uri ? 1 : 0 }}>
|
||||
<QRCode value={uri || ''} />
|
||||
</span>
|
||||
{!uri && <Loader />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="TipJarModal-uri-info">
|
||||
<Form.Item
|
||||
validateStatus={amountIsValid ? undefined : 'error'}
|
||||
label="Amount"
|
||||
className="TipJarModal-uri-info-input CopyInput"
|
||||
help={amountError}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={amount}
|
||||
placeholder="Amount to send"
|
||||
onChange={this.handleAmountChange}
|
||||
addonAfter="ZEC"
|
||||
/>
|
||||
</Form.Item>
|
||||
<CopyInput
|
||||
className="TipJarModal-uri-info-input"
|
||||
label="Payment URI"
|
||||
value={uri}
|
||||
isTextarea
|
||||
/>
|
||||
<Button type="ghost" size="large" href={uri} block>
|
||||
Open in Wallet <Icon type="link" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="TipJarModal-fields">
|
||||
<div className="TipJarModal-fields-row">
|
||||
<CopyInput
|
||||
className="TipJarModal-fields-row-address"
|
||||
label="Address"
|
||||
value={address}
|
||||
/>
|
||||
</div>
|
||||
<div className="TipJarModal-fields-row">
|
||||
<CopyInput
|
||||
label="Zcash CLI command"
|
||||
help="Make sure you replace YOUR_ADDRESS with your actual address"
|
||||
value={cli}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Modal
|
||||
title={`Tip a ${type}`}
|
||||
visible={isOpen}
|
||||
okText={'Done'}
|
||||
onCancel={onClose}
|
||||
centered
|
||||
footer={
|
||||
<Button type="primary" onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
}
|
||||
afterClose={this.handleAfterClose}
|
||||
>
|
||||
{content}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
private handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
this.setState({
|
||||
amount: e.currentTarget.value,
|
||||
});
|
||||
|
||||
private handleAfterClose = () => this.setState({ amount: null });
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export * from './TipJarBlock'
|
||||
export * from './TipJarModal'
|
|
@ -15,6 +15,18 @@ export function getAmountError(amount: number, max: number = Infinity, min?: num
|
|||
return null;
|
||||
}
|
||||
|
||||
export function getAmountErrorFromString(amount: string, max?: number, min?: number) {
|
||||
const parsedAmount = parseFloat(amount)
|
||||
if (Number.isNaN(parsedAmount)) {
|
||||
return 'Not a valid number'
|
||||
}
|
||||
// prevents "-0" from being valid...
|
||||
if (amount[0] === '-') {
|
||||
return 'Amount must be a positive number'
|
||||
}
|
||||
return getAmountError(parsedAmount, max, min)
|
||||
}
|
||||
|
||||
export function isValidEmail(email: string): boolean {
|
||||
return /\S+@\S+\.\S+/.test(email);
|
||||
}
|
||||
|
|
|
@ -10,9 +10,12 @@ export interface User {
|
|||
socialMedias: SocialMedia[];
|
||||
avatar: { imageUrl: string } | null;
|
||||
isAdmin?: boolean;
|
||||
tipJarAddress?: string;
|
||||
}
|
||||
|
||||
export interface UserSettings {
|
||||
emailSubscriptions: EmailSubscriptions;
|
||||
refundAddress?: string | null;
|
||||
tipJarAddress?: string | null;
|
||||
tipJarViewKey?: string | null;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue