Merge branch 'develop' into redux-alien-auth-events
This commit is contained in:
commit
be259af0cd
|
@ -46,7 +46,7 @@ class ArbiterControlNaked extends React.Component<Props, State> {
|
|||
{/* CONTROL */}
|
||||
<Button
|
||||
className="ArbiterControl-control"
|
||||
loading={store.proposalDetailApproving}
|
||||
loading={store.arbiterSaving}
|
||||
icon="crown"
|
||||
type="primary"
|
||||
onClick={this.handleShowSearch}
|
||||
|
@ -146,15 +146,13 @@ class ArbiterControlNaked extends React.Component<Props, State> {
|
|||
private handleSelect = async (user: User) => {
|
||||
this.setState({ showSearch: false });
|
||||
store.searchArbitersClear();
|
||||
try {
|
||||
await store.setArbiter(this.props.proposalId, user.userid);
|
||||
await store.setArbiter(this.props.proposalId, user.userid);
|
||||
if (store.arbiterSaved) {
|
||||
message.success(
|
||||
<>
|
||||
Arbiter nominated for <b>{this.props.title}</b>
|
||||
<b>{user.displayName}</b> nominated as arbiter of <b>{this.props.title}</b>
|
||||
</>,
|
||||
);
|
||||
} catch (e) {
|
||||
message.error(`Could not set arbiter: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -17,8 +17,13 @@ import {
|
|||
} from 'antd';
|
||||
import TextArea from 'antd/lib/input/TextArea';
|
||||
import store from 'src/store';
|
||||
import { formatDateSeconds } from 'util/time';
|
||||
import { PROPOSAL_STATUS, PROPOSAL_ARBITER_STATUS, MILESTONE_STAGE } from 'src/types';
|
||||
import { formatDateSeconds, formatDurationSeconds } from 'util/time';
|
||||
import {
|
||||
PROPOSAL_STATUS,
|
||||
PROPOSAL_ARBITER_STATUS,
|
||||
MILESTONE_STAGE,
|
||||
PROPOSAL_STAGE,
|
||||
} from 'src/types';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Back from 'components/Back';
|
||||
import Info from 'components/Info';
|
||||
|
@ -52,6 +57,11 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
return 'loading proposal...';
|
||||
}
|
||||
|
||||
const needsArbiter =
|
||||
PROPOSAL_ARBITER_STATUS.MISSING === p.arbiter.status &&
|
||||
p.status === PROPOSAL_STATUS.LIVE &&
|
||||
!p.isFailed;
|
||||
|
||||
const renderDeleteControl = () => (
|
||||
<Popconfirm
|
||||
onConfirm={this.handleDelete}
|
||||
|
@ -72,7 +82,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
type: 'default',
|
||||
className: 'ProposalDetail-controls-control',
|
||||
block: true,
|
||||
disabled: p.status !== PROPOSAL_STATUS.LIVE,
|
||||
disabled: p.status !== PROPOSAL_STATUS.LIVE || p.isFailed,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
@ -98,7 +108,14 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
okText="ok"
|
||||
cancelText="cancel"
|
||||
>
|
||||
<Switch checked={p.contributionMatching === 1} loading={false} />{' '}
|
||||
<Switch
|
||||
checked={p.contributionMatching === 1}
|
||||
loading={false}
|
||||
disabled={
|
||||
p.isFailed ||
|
||||
[PROPOSAL_STAGE.WIP, PROPOSAL_STAGE.COMPLETED].includes(p.stage)
|
||||
}
|
||||
/>{' '}
|
||||
</Popconfirm>
|
||||
<span>
|
||||
matching{' '}
|
||||
|
@ -108,6 +125,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
<span>
|
||||
<b>Contribution matching</b>
|
||||
<br /> Funded amount will be multiplied by 2.
|
||||
<br /> <i>Disabled after proposal is fully-funded.</i>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
@ -215,8 +233,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
);
|
||||
|
||||
const renderNominateArbiter = () =>
|
||||
PROPOSAL_ARBITER_STATUS.MISSING === p.arbiter.status &&
|
||||
p.status === PROPOSAL_STATUS.LIVE && (
|
||||
needsArbiter && (
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
|
@ -297,6 +314,21 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
);
|
||||
};
|
||||
|
||||
const renderFailed = () =>
|
||||
p.isFailed && (
|
||||
<Alert
|
||||
showIcon
|
||||
type="error"
|
||||
message="Funding failed"
|
||||
description={
|
||||
<>
|
||||
This proposal failed to reach its funding goal of <b>{p.target} ZEC</b> by{' '}
|
||||
<b>{formatDateSeconds(p.datePublished + p.deadlineDuration)}</b>.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderDeetItem = (name: string, val: any) => (
|
||||
<div className="ProposalDetail-deet">
|
||||
<span>{name}</span>
|
||||
|
@ -317,6 +349,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
{renderNominateArbiter()}
|
||||
{renderNominatedArbiter()}
|
||||
{renderMilestoneAccepted()}
|
||||
{renderFailed()}
|
||||
<Collapse defaultActiveKey={['brief', 'content']}>
|
||||
<Collapse.Panel key="brief" header="brief">
|
||||
{p.brief}
|
||||
|
@ -347,6 +380,17 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
<Card title="Details" size="small">
|
||||
{renderDeetItem('id', p.proposalId)}
|
||||
{renderDeetItem('created', formatDateSeconds(p.dateCreated))}
|
||||
{renderDeetItem('published', formatDateSeconds(p.datePublished))}
|
||||
{renderDeetItem(
|
||||
'deadlineDuration',
|
||||
formatDurationSeconds(p.deadlineDuration),
|
||||
)}
|
||||
{p.datePublished &&
|
||||
renderDeetItem(
|
||||
'(deadline)',
|
||||
formatDateSeconds(p.datePublished + p.deadlineDuration),
|
||||
)}
|
||||
{renderDeetItem('isFailed', JSON.stringify(p.isFailed))}
|
||||
{renderDeetItem('status', p.status)}
|
||||
{renderDeetItem('stage', p.stage)}
|
||||
{renderDeetItem('category', p.category)}
|
||||
|
|
|
@ -181,6 +181,9 @@ const app = store({
|
|||
userDeleting: false,
|
||||
userDeleted: false,
|
||||
|
||||
arbiterSaving: false,
|
||||
arbiterSaved: false,
|
||||
|
||||
arbitersSearch: {
|
||||
search: '',
|
||||
results: [] as User[],
|
||||
|
@ -385,10 +388,17 @@ const app = store({
|
|||
},
|
||||
|
||||
async setArbiter(proposalId: number, userId: number) {
|
||||
// let component handle errors for this one
|
||||
const { proposal, user } = await setArbiter(proposalId, userId);
|
||||
this.updateProposalInStore(proposal);
|
||||
this.updateUserInStore(user);
|
||||
app.arbiterSaving = true;
|
||||
app.arbiterSaved = false;
|
||||
try {
|
||||
const { proposal, user } = await setArbiter(proposalId, userId);
|
||||
this.updateProposalInStore(proposal);
|
||||
this.updateUserInStore(user);
|
||||
app.arbiterSaved = true;
|
||||
} catch (e) {
|
||||
handleApiError(e);
|
||||
}
|
||||
app.arbiterSaving = false;
|
||||
},
|
||||
|
||||
// Proposals
|
||||
|
|
|
@ -79,6 +79,13 @@ export enum PROPOSAL_STATUS {
|
|||
DELETED = 'DELETED',
|
||||
STAKING = 'STAKING',
|
||||
}
|
||||
// NOTE: sync with backend/grant/utils/enums.py ProposalStage
|
||||
export enum PROPOSAL_STAGE {
|
||||
PREVIEW = 'PREVIEW',
|
||||
FUNDING_REQUIRED = 'FUNDING_REQUIRED',
|
||||
WIP = 'WIP',
|
||||
COMPLETED = 'COMPLETED',
|
||||
}
|
||||
export interface Proposal {
|
||||
proposalId: number;
|
||||
brief: string;
|
||||
|
@ -87,9 +94,11 @@ export interface Proposal {
|
|||
dateCreated: number;
|
||||
dateApproved: number;
|
||||
datePublished: number;
|
||||
deadlineDuration: number;
|
||||
isFailed: boolean;
|
||||
title: string;
|
||||
content: string;
|
||||
stage: string;
|
||||
stage: PROPOSAL_STAGE;
|
||||
category: string;
|
||||
milestones: Milestone[];
|
||||
currentMilestone?: Milestone;
|
||||
|
|
|
@ -6,6 +6,14 @@ export const formatDateSeconds = (s: number) => {
|
|||
return moment(s * 1000).format(DATE_FMT_STRING);
|
||||
};
|
||||
|
||||
export const formatDateSecondsFromNow = (s: number) => {
|
||||
return moment(s * 1000).fromNow();
|
||||
};
|
||||
|
||||
export const formatDateMs = (s: number) => {
|
||||
return moment(s).format(DATE_FMT_STRING);
|
||||
};
|
||||
|
||||
export const formatDurationSeconds = (s: number) => {
|
||||
return moment.duration(s, 'seconds').humanize();
|
||||
};
|
||||
|
|
|
@ -209,6 +209,13 @@ def set_arbiter(proposal_id, user_id):
|
|||
if not proposal:
|
||||
return {"message": "Proposal not found"}, 404
|
||||
|
||||
for member in proposal.team:
|
||||
if member.id == user_id:
|
||||
return {"message": "Cannot set proposal team member as arbiter"}, 400
|
||||
|
||||
if proposal.is_failed:
|
||||
return {"message": "Cannot set arbiter on failed proposal"}, 400
|
||||
|
||||
user = User.query.filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return {"message": "User not found"}, 404
|
||||
|
@ -281,13 +288,7 @@ def update_proposal(id, contribution_matching):
|
|||
proposal = Proposal.query.filter(Proposal.id == id).first()
|
||||
if proposal:
|
||||
if contribution_matching is not None:
|
||||
# enforce 1 or 0 for now
|
||||
if contribution_matching == 0.0 or contribution_matching == 1.0:
|
||||
proposal.contribution_matching = contribution_matching
|
||||
# TODO: trigger check if funding target reached OR make sure
|
||||
# job schedule checks for funding completion include matching funds
|
||||
else:
|
||||
return {"message": f"Bad value for contributionMatching: {contribution_matching}"}, 400
|
||||
proposal.set_contribution_matching(contribution_matching)
|
||||
|
||||
db.session.commit()
|
||||
return proposal_schema.dump(proposal)
|
||||
|
@ -499,6 +500,11 @@ def create_contribution(proposal_id, user_id, status, amount, tx_id):
|
|||
contribution.tx_id = tx_id
|
||||
|
||||
db.session.add(contribution)
|
||||
db.session.flush()
|
||||
|
||||
contribution.proposal.set_pending_when_ready()
|
||||
contribution.proposal.set_funded_when_ready()
|
||||
|
||||
db.session.commit()
|
||||
return proposal_contribution_schema.dump(contribution), 200
|
||||
|
||||
|
@ -528,6 +534,10 @@ def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_
|
|||
if not contribution:
|
||||
return {"message": "No contribution matching that id"}, 404
|
||||
|
||||
# do not allow editing contributions once a proposal has become funded
|
||||
if contribution.proposal.is_funded:
|
||||
return {"message": "Cannot edit contributions to fully-funded proposals"}, 400
|
||||
|
||||
print((contribution_id, proposal_id, user_id, status, amount, tx_id))
|
||||
|
||||
# Proposal ID (must belong to an existing proposal)
|
||||
|
@ -558,5 +568,10 @@ def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_
|
|||
contribution.tx_id = tx_id
|
||||
|
||||
db.session.add(contribution)
|
||||
db.session.flush()
|
||||
|
||||
contribution.proposal.set_pending_when_ready()
|
||||
contribution.proposal.set_funded_when_ready()
|
||||
|
||||
db.session.commit()
|
||||
return proposal_contribution_schema.dump(contribution), 200
|
||||
|
|
|
@ -82,6 +82,14 @@ class Milestone(db.Model):
|
|||
self.reject_reason = reason
|
||||
self.reject_arbiter_id = arbiter_id
|
||||
|
||||
def accept_immediate(self):
|
||||
if self.immediate_payout and self.index == 0:
|
||||
self.date_requested = datetime.datetime.now()
|
||||
self.stage = MilestoneStage.ACCEPTED
|
||||
self.date_accepted = datetime.datetime.now()
|
||||
db.session.add(self)
|
||||
db.session.flush()
|
||||
|
||||
def accept_request(self, arbiter_id: int):
|
||||
if self.stage != MilestoneStage.REQUESTED:
|
||||
raise MilestoneException(f'Cannot accept payout request for milestone at {self.stage} stage')
|
||||
|
|
|
@ -353,6 +353,7 @@ class Proposal(db.Model):
|
|||
|
||||
return contribution
|
||||
|
||||
# state: status (DRAFT || REJECTED) -> (PENDING || STAKING)
|
||||
def submit_for_approval(self):
|
||||
self.validate_publishable()
|
||||
allowed_statuses = [ProposalStatus.DRAFT, ProposalStatus.REJECTED]
|
||||
|
@ -365,6 +366,21 @@ class Proposal(db.Model):
|
|||
else:
|
||||
self.status = ProposalStatus.STAKING
|
||||
|
||||
def set_pending_when_ready(self):
|
||||
if self.status == ProposalStatus.STAKING and self.is_staked:
|
||||
self.set_pending()
|
||||
|
||||
# state: status STAKING -> PENDING
|
||||
def set_pending(self):
|
||||
if self.status != ProposalStatus.STAKING:
|
||||
raise ValidationException(f"Proposal status must be staking in order to be set to pending")
|
||||
if not self.is_staked:
|
||||
raise ValidationException(f"Proposal is not fully staked, cannot set to pending")
|
||||
self.status = ProposalStatus.PENDING
|
||||
db.session.add(self)
|
||||
db.session.flush()
|
||||
|
||||
# state: status PENDING -> (APPROVED || REJECTED)
|
||||
def approve_pending(self, is_approve, reject_reason=None):
|
||||
self.validate_publishable()
|
||||
# specific validation
|
||||
|
@ -394,16 +410,47 @@ class Proposal(db.Model):
|
|||
'admin_note': reject_reason
|
||||
})
|
||||
|
||||
# state: status APPROVE -> LIVE, stage PREVIEW -> FUNDING_REQUIRED
|
||||
def publish(self):
|
||||
self.validate_publishable()
|
||||
# specific validation
|
||||
if not self.status == ProposalStatus.APPROVED:
|
||||
raise ValidationException(f"Proposal status must be approved")
|
||||
|
||||
self.date_published = datetime.datetime.now()
|
||||
self.status = ProposalStatus.LIVE
|
||||
self.stage = ProposalStage.FUNDING_REQUIRED
|
||||
|
||||
def set_funded_when_ready(self):
|
||||
if self.status == ProposalStatus.LIVE and self.is_funded:
|
||||
self.set_funded()
|
||||
|
||||
# state: stage FUNDING_REQUIRED -> WIP
|
||||
def set_funded(self):
|
||||
if self.status != ProposalStatus.LIVE:
|
||||
raise ValidationException(f"Proposal status must be live in order transition to funded state")
|
||||
if self.stage != ProposalStage.FUNDING_REQUIRED:
|
||||
raise ValidationException(f"Proposal stage must be funding_required in order transition to funded state")
|
||||
if not self.is_funded:
|
||||
raise ValidationException(f"Proposal is not fully funded, cannot set to funded state")
|
||||
self.stage = ProposalStage.WIP
|
||||
db.session.add(self)
|
||||
db.session.flush()
|
||||
# check the first step, if immediate payout bump it to accepted
|
||||
self.current_milestone.accept_immediate()
|
||||
|
||||
def set_contribution_matching(self, matching: float):
|
||||
# do not allow on funded/WIP proposals
|
||||
if self.is_funded:
|
||||
raise ValidationException("Cannot set contribution matching on fully-funded proposal")
|
||||
# enforce 1 or 0 for now
|
||||
if matching == 0.0 or matching == 1.0:
|
||||
self.contribution_matching = matching
|
||||
db.session.add(self)
|
||||
db.session.flush()
|
||||
self.set_funded_when_ready()
|
||||
else:
|
||||
raise ValidationException("Bad value for contribution_matching, must be 1 or 0")
|
||||
|
||||
@hybrid_property
|
||||
def contributed(self):
|
||||
contributions = ProposalContribution.query \
|
||||
|
@ -429,7 +476,15 @@ class Proposal(db.Model):
|
|||
|
||||
@hybrid_property
|
||||
def is_funded(self):
|
||||
return Decimal(self.contributed) >= Decimal(self.target)
|
||||
return Decimal(self.funded) >= Decimal(self.target)
|
||||
|
||||
@hybrid_property
|
||||
def is_failed(self):
|
||||
if not self.status == ProposalStatus.LIVE or not self.date_published:
|
||||
return False
|
||||
deadline = self.date_published + datetime.timedelta(seconds=self.deadline_duration)
|
||||
passed = deadline < datetime.datetime.now()
|
||||
return passed and not self.is_funded
|
||||
|
||||
@hybrid_property
|
||||
def current_milestone(self):
|
||||
|
@ -458,6 +513,7 @@ class ProposalSchema(ma.Schema):
|
|||
"target",
|
||||
"contributed",
|
||||
"is_staked",
|
||||
"is_failed",
|
||||
"funded",
|
||||
"content",
|
||||
"comments",
|
||||
|
|
|
@ -489,11 +489,7 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
|
|||
db.session.flush()
|
||||
|
||||
if contribution.proposal.status == ProposalStatus.STAKING:
|
||||
# fully staked, set status PENDING
|
||||
if contribution.proposal.is_staked: # Decimal(contribution.proposal.contributed) >= PROPOSAL_STAKING_AMOUNT:
|
||||
contribution.proposal.status = ProposalStatus.PENDING
|
||||
db.session.add(contribution.proposal)
|
||||
db.session.flush()
|
||||
contribution.proposal.set_pending_when_ready()
|
||||
|
||||
# email progress of staking, partial or complete
|
||||
send_email(contribution.user.email_address, 'staking_contribution_confirmed', {
|
||||
|
@ -524,12 +520,9 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
|
|||
})
|
||||
|
||||
# TODO: Once we have a task queuer in place, queue emails to everyone
|
||||
|
||||
# on funding target reached.
|
||||
if contribution.proposal.status == ProposalStatus.LIVE:
|
||||
if contribution.proposal.is_funded:
|
||||
contribution.proposal.stage = ProposalStage.WIP
|
||||
db.session.add(contribution.proposal)
|
||||
db.session.flush()
|
||||
contribution.proposal.set_funded_when_ready()
|
||||
|
||||
db.session.commit()
|
||||
return None, 200
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<p style="margin: 0 0 20px;">
|
||||
The proposal milestone
|
||||
<a href="{{ args.proposal_milestones_url }}" target="_blank">
|
||||
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}
|
||||
</a>
|
||||
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}</a
|
||||
>
|
||||
payout of <b>{{ args.amount }} ZEC</b> has been approved.
|
||||
</p>
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<p style="margin: 0 0 20px;">
|
||||
Hooray! <b>{{ args.amount }} ZEC</b> has been paid out for
|
||||
<a href="{{ args.proposal_milestones_url }}" target="_blank">
|
||||
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }} </a
|
||||
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}</a
|
||||
>! You can view the transaction below:
|
||||
</p>
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<p style="margin: 0 0 20px;">
|
||||
The payout request for proposal milestone
|
||||
<a href="{{ args.proposal_milestones_url }}" target="_blank">
|
||||
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}
|
||||
</a>
|
||||
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}</a
|
||||
>
|
||||
has been rejected.
|
||||
</p>
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<p style="margin: 0 0 20px;">
|
||||
A payout request for the proposal milestone
|
||||
<a href="{{ args.proposal_milestones_url }}" target="_blank">
|
||||
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}
|
||||
</a>
|
||||
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}</a
|
||||
>
|
||||
has been made. As arbiter, you are responsible for reviewing this request.
|
||||
</p>
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<p style="margin: 0 0 20px;">
|
||||
You have been nominated for arbiter of
|
||||
<a href="{{ args.proposal_url }}" target="_blank">
|
||||
{{ args.proposal.title }} </a
|
||||
{{ args.proposal.title }}</a
|
||||
>. You will be responsible for reviewing milestone payout requests should you
|
||||
choose to accept.
|
||||
</p>
|
||||
|
|
|
@ -97,7 +97,8 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
|||
def test_update_proposal_bad_matching(self):
|
||||
self.login_admin()
|
||||
resp = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}", data={"contributionMatching": 2})
|
||||
self.assert400(resp)
|
||||
self.assert500(resp)
|
||||
self.assertIn('Bad value', resp.json['data'])
|
||||
|
||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||
def test_approve_proposal(self, mock_get):
|
||||
|
@ -140,7 +141,7 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
|||
"/api/v1/admin/arbiters",
|
||||
data={
|
||||
'proposalId': self.proposal.id,
|
||||
'userId': self.user.id
|
||||
'userId': self.other_user.id
|
||||
}
|
||||
)
|
||||
self.assert200(resp)
|
||||
|
|
|
@ -546,20 +546,25 @@ const MilestoneAction: React.SFC<MilestoneProps> = p => {
|
|||
}
|
||||
|
||||
// special warning if no arbiter is set for team members
|
||||
if (!p.hasArbiter && p.isTeamMember) {
|
||||
if (!p.hasArbiter && p.isTeamMember && p.stage === MILESTONE_STAGE.IDLE) {
|
||||
content = (
|
||||
<Alert
|
||||
type="error"
|
||||
type="info"
|
||||
message="Arbiter not assigned"
|
||||
description={
|
||||
<p>
|
||||
We are sorry for the inconvenience, but in order to have milestone payouts
|
||||
reviewed an arbiter must be assigned. Please{' '}
|
||||
<Link target="_blank" to="/contact">
|
||||
contact support
|
||||
</Link>{' '}
|
||||
for help.
|
||||
</p>
|
||||
<>
|
||||
<p>
|
||||
Arbiters are users who review requests for payment. When they have approved
|
||||
a payment the grant administrators are then notified to make payment.
|
||||
</p>
|
||||
<p>
|
||||
It typically takes a couple of days to have an arbiter assigned. Please{' '}
|
||||
<Link target="_blank" to="/contact">
|
||||
contact support
|
||||
</Link>{' '}
|
||||
if you have waited longer than three days.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
|
Loading…
Reference in New Issue