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:
Danny Skubak 2019-11-13 18:44:35 -05:00 committed by Daniel Ternyak
parent 4b7d85872a
commit d98b255378
18 changed files with 854 additions and 47 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -155,6 +155,8 @@ export function getUserSettings(
interface SettingsArgs {
emailSubscriptions?: EmailSubscriptions;
refundAddress?: string;
tipJarAddress?: string;
tipJarViewKey?: string;
}
export function updateUserSettings(
userId: string | number,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
export * from './TipJarBlock'
export * from './TipJarModal'

View File

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

View File

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