Port Landing Hooks (#30)

* port & adapt landing hooks from grant-base

* fix type guard

* CSS Adjustments for Illustration and Content layout.
This commit is contained in:
Danny Skubak 2019-11-07 22:58:55 -05:00 committed by Daniel Ternyak
parent 8255f0174c
commit 67fbbae9bf
9 changed files with 270 additions and 8 deletions

View File

@ -10,7 +10,7 @@ from flask_security import SQLAlchemyUserDatastore
from flask_sslify import SSLify
from sentry_sdk.integrations.flask import FlaskIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
from grant import commands, proposal, user, comment, milestone, admin, email, blockchain, task, rfp, e2e
from grant import commands, proposal, user, comment, milestone, admin, email, blockchain, task, rfp, e2e, home
from grant.extensions import bcrypt, migrate, db, ma, security, limiter
from grant.settings import SENTRY_RELEASE, ENV, E2E_TESTING, DEBUG, CORS_DOMAINS
from grant.utils.auth import AuthException, handle_auth_error, get_authed_user
@ -138,6 +138,7 @@ def register_blueprints(app):
app.register_blueprint(blockchain.views.blueprint)
app.register_blueprint(task.views.blueprint)
app.register_blueprint(rfp.views.blueprint)
app.register_blueprint(home.views.blueprint)
if E2E_TESTING and DEBUG:
print('Warning: e2e end-points are open, this should only be the case for development or testing')
app.register_blueprint(e2e.views.blueprint)

View File

@ -0,0 +1 @@
from . import views

View File

@ -0,0 +1,34 @@
from datetime import datetime
from flask import Blueprint
from sqlalchemy import or_
from grant.proposal.models import Proposal, proposals_schema
from grant.rfp.models import RFP, rfps_schema
from grant.utils.enums import ProposalStatus, ProposalStage, RFPStatus
blueprint = Blueprint("home", __name__, url_prefix="/api/v1/home")
@blueprint.route("/latest", methods=["GET"])
def get_home_content():
latest_proposals = (
Proposal.query.filter_by(status=ProposalStatus.LIVE)
.filter(Proposal.stage != ProposalStage.CANCELED)
.filter(Proposal.stage != ProposalStage.FAILED)
.order_by(Proposal.date_created.desc())
.limit(3)
.all()
)
latest_rfps = (
RFP.query.filter_by(status=RFPStatus.LIVE)
.filter(or_(RFP.date_closes == None, RFP.date_closes > datetime.now()))
.order_by(RFP.date_opened)
.limit(3)
.all()
)
return {
"latest_proposals": proposals_schema.dump(latest_proposals),
"latest_rfps": rfps_schema.dump(latest_rfps),
}

View File

@ -385,3 +385,18 @@ export function getRFP(rfpId: number | string): Promise<{ data: RFP }> {
export function resendEmailVerification(): Promise<{ data: void }> {
return axios.put(`/api/v1/users/me/resend-verification`);
}
export function getHomeLatest(): Promise<{
data: {
latestProposals: Proposal[];
latestRfps: RFP[];
};
}> {
return axios.get('/api/v1/home/latest').then(res => {
res.data = {
latestProposals: res.data.latestProposals.map(formatProposalFromGet),
latestRfps: res.data.latestRfps.map(formatRFPFromGet),
};
return res;
});
}

View File

@ -3,11 +3,11 @@
.HomeIntro {
position: relative;
display: flex;
justify-content: space-between;
justify-content: space-around;
align-items: center;
max-width: 1440px;
padding: 0 4rem;
margin: 0 auto 6rem;
margin: 0 auto 4rem;
overflow: hidden;
@media @thin-query {
@ -19,10 +19,7 @@
}
&-content {
width: 50%;
min-width: 600px;
padding-right: 2rem;
margin: 0 auto;
&-title {
margin-bottom: 2rem;
@ -98,7 +95,7 @@
&-illustration {
position: relative;
width: 100%;
max-width: 640px;
max-width: 480px;
background-size: contain;
&:after {

View File

@ -0,0 +1,75 @@
@import '~styles/variables.less';
.HomeLatest {
&-inner {
display: flex;
flex-wrap: wrap;
max-width: @max-content-width;
margin: 0 auto 10rem;
padding: 0 2rem;
@media @thin-query {
flex-direction: column;
}
}
&-loader {
flex: 1;
position: relative;
height: 14rem;
}
&-column {
flex: 1;
margin: 0 2rem;
@media @thin-query {
margin: 0 1.25rem 2rem;
}
@media @mobile-query {
margin: 0 0 2rem;
}
&-title {
font-size: 1.6rem;
font-weight: 600;
margin-bottom: 2rem;
}
&-item {
margin-bottom: 1.5rem;
&-title {
font-size: 1.3rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
&-brief {
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
font-size: 1rem;
line-height: 1.6;
height: 3.2rem;
color: @text-color;
@media @thin-query {
height: auto;
max-height: 3.2rem;
}
}
}
.Placeholder {
padding-left: 1rem;
padding-right: 1rem;
&-title {
font-size: 1.3rem;
}
}
}
}

View File

@ -0,0 +1,130 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { withNamespaces, WithNamespaces } from 'react-i18next';
import Loader from 'components/Loader';
import Placeholder from 'components/Placeholder';
import { getHomeLatest } from 'api/api';
import { Proposal, RFP } from 'types';
import './Latest.less';
interface State {
latestProposals: Proposal[];
latestRfps: RFP[];
isLoading: boolean;
error: string | null;
}
class HomeLatest extends React.Component<WithNamespaces, State> {
state: State = {
latestProposals: [],
latestRfps: [],
isLoading: true,
error: null,
};
async componentDidMount() {
try {
const res = await getHomeLatest();
this.setState({
...res.data,
error: null,
isLoading: false,
});
} catch (err) {
// tslint:disable-next-line
console.error('Failed to load homepage content:', err);
this.setState({
error: err.message,
isLoading: false,
});
}
}
render() {
const { t } = this.props;
const { latestProposals, latestRfps, isLoading } = this.state;
const numItems = latestProposals.length + latestRfps.length;
let content;
if (isLoading) {
content = (
<div className="HomeLatest-loader">
<Loader size="large" />
</div>
);
} else if (numItems) {
const columns: ContentColumnProps[] = [
{
title: t('home.latest.proposalsTitle'),
placeholder: t('home.latest.proposalsPlaceholder'),
path: 'proposals',
items: latestProposals,
},
{
title: t('home.latest.requestsTitle'),
placeholder: t('home.latest.requestsPlaceholder'),
path: 'requests',
items: latestRfps,
},
];
content = columns.filter(c => !!c.items.length).map((col, idx) => (
<div className="HomeLatest-column" key={idx}>
<ContentColumn {...col} />
</div>
));
} else {
return null;
}
return (
<div className="HomeLatest">
<div className="HomeLatest-inner">{content}</div>
</div>
);
}
}
interface ContentColumnProps {
title: string;
placeholder: string;
path: string;
items: Array<Proposal | RFP>;
}
const ContentColumn: React.SFC<ContentColumnProps> = p => {
let content: React.ReactNode;
if (p.items.length) {
content = (
<>
{p.items.map(item => {
const isProposal = (x: Proposal | RFP): x is Proposal =>
(x as Proposal).proposalUrlId !== undefined;
const id = isProposal(item) ? item.proposalId : item.id;
const urlId = isProposal(item) ? item.proposalUrlId : item.urlId;
return (
<Link to={`/${p.path}/${urlId}`} key={id}>
<div className="HomeLatest-column-item">
<div className="HomeLatest-column-item-title">{item.title}</div>
<div className="HomeLatest-column-item-brief">{item.brief}</div>
</div>
</Link>
);
})}
<Link to={`/${p.path}`} className="HomeLatest-column-more">
See more
</Link>
</>
);
} else {
content = <Placeholder title={p.placeholder} />;
}
return (
<div className="HomeLatest-column">
<h3 className="HomeLatest-column-title">{p.title}</h3>
{content}
</div>
);
};
export default withNamespaces()(HomeLatest);

View File

@ -5,6 +5,7 @@ import Intro from './Intro';
import Requests from './Requests';
import Guide from './Guide';
import Actions from './Actions';
import Latest from './Latest';
import './style.less';
class Home extends React.Component<WithNamespaces> {
@ -14,6 +15,7 @@ class Home extends React.Component<WithNamespaces> {
<div className="Home">
<HeaderDetails title={t('home.title')} description={t('home.description')} />
<Intro />
<Latest />
<Requests />
<Guide />
<Actions />

View File

@ -14,6 +14,13 @@
"learn": "or learn more below"
},
"latest": {
"proposalsTitle": "Latest proposals",
"proposalsPlaceholder": "No proposals found",
"requestsTitle": "Latest requests",
"requestsPlaceholder": "No requests found"
},
"requests": {
"title": "Open Requests from the ZF",
"description": "The Zcash Foundation will periodically open up requests for proposals that have financial incentives attached to them in the form of fixed bounties, or pledges of contribution matching.\nProposals will be reviewed and chosen based on the ZFs confidence in the team and their plan.\nTo be eligible for funding from the Zcash Foundation, teams must provide identifying information for legal reasons.\nIf none of the RFPs catch your eye, check out the <a href=\"https://www.zfnd.org/grants/#ideas\" target=\"_blank\">list of promising ideas<\/a>!",