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:
parent
8255f0174c
commit
67fbbae9bf
|
@ -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)
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
from . import views
|
|
@ -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),
|
||||
}
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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 />
|
||||
|
|
|
@ -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 ZF’s 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>!",
|
||||
|
|
Loading…
Reference in New Issue