Add free plan (#351)

* Allow free usage

* Update features

* Update plan details

* Render footer

* Type

* Unlimited books

* Restrict repetition for non pro
This commit is contained in:
Sung Won Cho 2019-11-28 15:59:49 +08:00 committed by GitHub
commit 4adb7764ed
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 492 additions and 508 deletions

View file

@ -49,7 +49,6 @@
"react", "react-hooks", "import", "prettier", "@typescript-eslint"
],
"globals": {
// web
"__DEVELOPMENT__": true,
"__PRODUCTION__": true,
"__DISABLE_SSR__": true,
@ -64,7 +63,6 @@
"webpackIsomorphicTools": true,
"StripeCheckout": true,
// browser
"browser": true,
"chrome": true,
__WEB_URL__: true,

38
package-lock.json generated
View file

@ -132,12 +132,12 @@
"dev": true
},
"@typescript-eslint/eslint-plugin": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.8.0.tgz",
"integrity": "sha512-ohqul5s6XEB0AzPWZCuJF5Fd6qC0b4+l5BGEnrlpmvXxvyymb8yw8Bs4YMF8usNAeuCJK87eFIHy8g8GFvOtGA==",
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.9.0.tgz",
"integrity": "sha512-98rfOt3NYn5Gr9wekTB8TexxN6oM8ZRvYuphPs1Atfsy419SDLYCaE30aJkRiiTCwGEY98vOhFsEVm7Zs4toQQ==",
"dev": true,
"requires": {
"@typescript-eslint/experimental-utils": "2.8.0",
"@typescript-eslint/experimental-utils": "2.9.0",
"eslint-utils": "^1.4.3",
"functional-red-black-tree": "^1.0.1",
"regexpp": "^3.0.0",
@ -145,32 +145,32 @@
}
},
"@typescript-eslint/experimental-utils": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-2.8.0.tgz",
"integrity": "sha512-jZ05E4SxCbbXseQGXOKf3ESKcsGxT8Ucpkp1jiVp55MGhOvZB2twmWKf894PAuVQTCgbPbJz9ZbRDqtUWzP8xA==",
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-2.9.0.tgz",
"integrity": "sha512-0lOLFdpdJsCMqMSZT7l7W2ta0+GX8A3iefG3FovJjrX+QR8y6htFlFdU7aOVPL6pDvt6XcsOb8fxk5sq+girTw==",
"dev": true,
"requires": {
"@types/json-schema": "^7.0.3",
"@typescript-eslint/typescript-estree": "2.8.0",
"@typescript-eslint/typescript-estree": "2.9.0",
"eslint-scope": "^5.0.0"
}
},
"@typescript-eslint/parser": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-2.8.0.tgz",
"integrity": "sha512-NseXWzhkucq+JM2HgqAAoKEzGQMb5LuTRjFPLQzGIdLthXMNUfuiskbl7QSykvWW6mvzCtYbw1fYWGa2EIaekw==",
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-2.9.0.tgz",
"integrity": "sha512-fJ+dNs3CCvEsJK2/Vg5c2ZjuQ860ySOAsodDPwBaVlrGvRN+iCNC8kUfLFL8cT49W4GSiLPa/bHiMjYXA7EhKQ==",
"dev": true,
"requires": {
"@types/eslint-visitor-keys": "^1.0.0",
"@typescript-eslint/experimental-utils": "2.8.0",
"@typescript-eslint/typescript-estree": "2.8.0",
"@typescript-eslint/experimental-utils": "2.9.0",
"@typescript-eslint/typescript-estree": "2.9.0",
"eslint-visitor-keys": "^1.1.0"
}
},
"@typescript-eslint/typescript-estree": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-2.8.0.tgz",
"integrity": "sha512-ksvjBDTdbAQ04cR5JyFSDX113k66FxH1tAXmi+dj6hufsl/G0eMc/f1GgLjEVPkYClDbRKv+rnBFuE5EusomUw==",
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-2.9.0.tgz",
"integrity": "sha512-v6btSPXEWCP594eZbM+JCXuFoXWXyF/z8kaSBSdCb83DF+Y7+xItW29SsKtSULgLemqJBT+LpT+0ZqdfH7QVmA==",
"dev": true,
"requires": {
"debug": "^4.1.1",
@ -498,9 +498,9 @@
"dev": true
},
"eslint": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-6.7.0.tgz",
"integrity": "sha512-dQpj+PaHKHfXHQ2Imcw5d853PTvkUGbHk/MR68KQUl98EgKDCdh4vLRH1ZxhqeQjQFJeg8fgN0UwmNhN3l8dDQ==",
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-6.7.1.tgz",
"integrity": "sha512-UWzBS79pNcsDSxgxbdjkmzn/B6BhsXMfUaOHnNwyE8nD+Q6pyT96ow2MccVayUTV4yMid4qLhMiQaywctRkBLA==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.0.0",

View file

@ -5,10 +5,10 @@
"description": "Dnote monorepo",
"license": "SEE LICENSE IN LICENSE",
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^2.8.0",
"@typescript-eslint/parser": "^2.8.0",
"@typescript-eslint/eslint-plugin": "^2.9.0",
"@typescript-eslint/parser": "^2.9.0",
"babel-eslint": "^10.0.3",
"eslint": "^6.7.0",
"eslint": "^6.7.1",
"eslint-config-airbnb": "^18.0.1",
"eslint-config-prettier": "^6.7.0",
"eslint-plugin-import": "^2.18.2",

View file

@ -200,6 +200,7 @@ func (a *App) auth(next http.HandlerFunc, p *AuthMiddlewareParams) http.HandlerF
if p != nil && p.ProOnly {
if !user.Cloud {
respondForbidden(w)
return
}
}
@ -237,6 +238,7 @@ func (a *App) tokenAuth(next http.HandlerFunc, tokenType string, p *AuthMiddlewa
if p != nil && p.ProOnly {
if !user.Cloud {
respondForbidden(w)
return
}
}
@ -360,10 +362,10 @@ func NewRouter(app *App) (*mux.Router, error) {
{"GET", "/subscriptions", app.auth(app.getSub, nil), true},
{"GET", "/stripe_source", app.auth(app.getStripeSource, nil), true},
{"PATCH", "/stripe_source", app.auth(app.updateStripeSource, nil), true},
{"GET", "/notes", app.auth(app.getNotes, &proOnly), false},
{"GET", "/notes", app.auth(app.getNotes, nil), false},
{"GET", "/notes/{noteUUID}", app.getNote, true},
{"GET", "/calendar", app.auth(app.getCalendar, &proOnly), true},
{"GET", "/repetition_rules", app.auth(app.getRepetitionRules, &proOnly), true},
{"GET", "/calendar", app.auth(app.getCalendar, nil), true},
{"GET", "/repetition_rules", app.auth(app.getRepetitionRules, nil), true},
{"GET", "/repetition_rules/{repetitionRuleUUID}", app.tokenAuth(app.getRepetitionRule, database.TokenTypeRepetition, &proOnly), true},
{"POST", "/repetition_rules", app.auth(app.createRepetitionRule, &proOnly), true},
{"PATCH", "/repetition_rules/{repetitionRuleUUID}", app.tokenAuth(app.updateRepetitionRule, database.TokenTypeRepetition, &proOnly), true},
@ -377,18 +379,18 @@ func NewRouter(app *App) (*mux.Router, error) {
{"PATCH", "/classic/set-password", app.auth(app.classicSetPassword, nil), true},
// v3
{"GET", "/v3/sync/fragment", cors(app.auth(app.GetSyncFragment, &proOnly)), false},
{"GET", "/v3/sync/state", cors(app.auth(app.GetSyncState, &proOnly)), false},
{"GET", "/v3/sync/fragment", cors(app.auth(app.GetSyncFragment, nil)), false},
{"GET", "/v3/sync/state", cors(app.auth(app.GetSyncState, nil)), false},
{"OPTIONS", "/v3/books", cors(app.BooksOptions), true},
{"GET", "/v3/books", cors(app.auth(app.GetBooks, &proOnly)), true},
{"GET", "/v3/books/{bookUUID}", cors(app.auth(app.GetBook, &proOnly)), true},
{"POST", "/v3/books", cors(app.auth(app.CreateBook, &proOnly)), false},
{"PATCH", "/v3/books/{bookUUID}", cors(app.auth(app.UpdateBook, &proOnly)), false},
{"DELETE", "/v3/books/{bookUUID}", cors(app.auth(app.DeleteBook, &proOnly)), false},
{"GET", "/v3/books", cors(app.auth(app.GetBooks, nil)), true},
{"GET", "/v3/books/{bookUUID}", cors(app.auth(app.GetBook, nil)), true},
{"POST", "/v3/books", cors(app.auth(app.CreateBook, nil)), false},
{"PATCH", "/v3/books/{bookUUID}", cors(app.auth(app.UpdateBook, nil)), false},
{"DELETE", "/v3/books/{bookUUID}", cors(app.auth(app.DeleteBook, nil)), false},
{"OPTIONS", "/v3/notes", cors(app.NotesOptions), true},
{"POST", "/v3/notes", cors(app.auth(app.CreateNote, &proOnly)), false},
{"PATCH", "/v3/notes/{noteUUID}", app.auth(app.UpdateNote, &proOnly), false},
{"DELETE", "/v3/notes/{noteUUID}", app.auth(app.DeleteNote, &proOnly), false},
{"POST", "/v3/notes", cors(app.auth(app.CreateNote, nil)), false},
{"PATCH", "/v3/notes/{noteUUID}", app.auth(app.UpdateNote, nil), false},
{"DELETE", "/v3/notes/{noteUUID}", app.auth(app.DeleteNote, nil), false},
{"POST", "/v3/signin", cors(app.signin), true},
{"OPTIONS", "/v3/signout", cors(app.signoutOptions), true},
{"POST", "/v3/signout", cors(app.signout), true},

View file

@ -86,9 +86,9 @@ func createEmailPreference(user database.User, tx *gorm.DB) error {
func createDefaultRepetitionRule(user database.User, tx *gorm.DB) error {
r := database.RepetitionRule{
Title: "Default repetition - all bookx",
Title: "Default repetition - all book",
UserID: user.ID,
Enabled: true,
Enabled: false,
Hour: 20,
Minute: 30,
Frequency: 604800000,

View file

@ -106,8 +106,11 @@ func main() {
}()
if ignore != "" {
if err := w.Ignore(ignore); err != nil {
log.Fatalln(errors.Wrapf(err, "ignoring %s", ignore))
files := strings.Split(ignore, ",")
for _, file := range files {
if err := w.Ignore(file); err != nil {
log.Fatalln(errors.Wrapf(err, "ignoring %s", file))
}
}
}

View file

@ -1,71 +0,0 @@
{ "extends": ["eslint-config-airbnb", "prettier"],
"env": {
"browser": true,
"node": true,
"jest": true
},
"parser": "@typescript-eslint/parser",
"rules": {
"camelcase": 0,
"strict": 0,
"react/no-multi-comp": 0,
"import/default": 0,
"import/no-duplicates": 0,
"import/named": 0,
"import/namespace": 0,
"import/no-unresolved": 0,
"import/no-named-as-default": 2,
"import/prefer-default-export": 0,
"comma-dangle": 0, // not sure why airbnb turned this on. gross!
"indent": [2, 2, {"SwitchCase": 1}],
"no-console": 0,
"no-alert": 0,
"arrow-body-style": 0,
"react/prop-types": 0,
"react/jsx-filename-extension": 0,
"react/prefer-stateless-function": 0,
"jsx-a11y/anchor-is-valid": 0,
"jsx-a11y/tabindex-no-positive": 0,
"no-mixed-operators": 0,
"no-plusplus": 0,
"no-underscore-dangle": 0,
"prettier/prettier": "error",
"jsx-a11y/no-autofocus": 0,
"jsx-a11y/label-has-for": 0,
"prefer-destructuring": 0,
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"react/jsx-wrap-multilines": ["error", {"declaration": false, "assignment": false}],
"react/jsx-one-expression-per-line": 0,
"@typescript-eslint/no-unused-vars": 1,
"import/no-extraneous-dependencies": ["error", {"devDependencies": ["**/*_test.ts", "**/webpack/**/*"]}],
"lines-between-class-members": 0,
"react/jsx-fragments": 0,
"jsx-a11y/label-has-associated-control": 0
},
"plugins": [
"react", "react-hooks", "import", "prettier", "@typescript-eslint"
],
"settings": {
"import/parser": "babel-eslint",
"import/resolve": {
"moduleDirectory": ["node_modules", "src"]
}
},
"globals": {
"__DEVELOPMENT__": true,
"__PRODUCTION__": true,
"__DISABLE_SSR__": true,
"__DEVTOOLS__": true,
"__DOMAIN__": true,
"__BASE_URL__": true,
"__BASE_NAME__": true,
"__STRIPE_PUBLIC_KEY__": true,
"__ROOT_URL__": true,
"__CDN_URL__": true,
"__VERSION__": true,
"socket": true,
"webpackIsomorphicTools": true,
"StripeCheckout": true
}
}

24
web/package-lock.json generated
View file

@ -7722,14 +7722,12 @@
"balanced-match": {
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -7744,20 +7742,17 @@
"code-point-at": {
"version": "1.1.0",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"core-util-is": {
"version": "1.0.2",
@ -7874,8 +7869,7 @@
"inherits": {
"version": "2.0.3",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"ini": {
"version": "1.3.5",
@ -7887,7 +7881,6 @@
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@ -7902,7 +7895,6 @@
"version": "3.0.4",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@ -7916,7 +7908,6 @@
"version": "2.3.5",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
@ -8015,8 +8006,7 @@
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"object-assign": {
"version": "4.1.1",
@ -8028,7 +8018,6 @@
"version": "1.4.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@ -8150,7 +8139,6 @@
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",

View file

@ -20,7 +20,6 @@ import React, { useEffect, useState, Fragment } from 'react';
import { getBooks } from '../../store/books';
import { useDispatch } from '../../store';
import PayWall from '../Common/PayWall';
import Content from './Content';
import Flash from '../Common/Flash';
import HeadData from './HeadData';
@ -37,22 +36,20 @@ const Books: React.FunctionComponent = () => {
<Fragment>
<HeadData />
<PayWall>
<div className="page page-mobile-full">
<div className="container mobile-nopadding">
<Flash
kind="success"
when={Boolean(successMessage)}
onDismiss={() => {
setSuccessMessage('');
}}
>
{successMessage}
</Flash>
</div>
<Content setSuccessMessage={setSuccessMessage} />;
<div className="page page-mobile-full">
<div className="container mobile-nopadding">
<Flash
kind="success"
when={Boolean(successMessage)}
onDismiss={() => {
setSuccessMessage('');
}}
>
{successMessage}
</Flash>
</div>
</PayWall>
<Content setSuccessMessage={setSuccessMessage} />;
</div>
</Fragment>
);
};

View file

@ -31,6 +31,7 @@ interface Props {
isOpen: boolean;
setIsOpen: React.Dispatch<any>;
wrapperClassName?: string;
disabled?: boolean;
}
const ItemActions: React.FunctionComponent<Props> = ({
@ -41,7 +42,8 @@ const ItemActions: React.FunctionComponent<Props> = ({
optRefs,
isOpen,
setIsOpen,
wrapperClassName
wrapperClassName,
disabled
}) => {
return (
<div
@ -62,6 +64,7 @@ const ItemActions: React.FunctionComponent<Props> = ({
contentClassName={styles.content}
alignment="right"
direction="bottom"
disabled={disabled}
/>
</div>
);

View file

@ -29,7 +29,6 @@ import styles from './AdvancedPanel.scss';
interface Props {
value: string;
setValue: (string) => void;
disabled: boolean;
}
// getCurrentTerm returns the current term in the comma separated
@ -113,11 +112,7 @@ function useSetSuggestionVisibility(
}, [setIsOpen, triggerRef, inputValue, prevInputValue]);
}
const BookSearch: React.FunctionComponent<Props> = ({
value,
setValue,
disabled
}) => {
const BookSearch: React.FunctionComponent<Props> = ({ value, setValue }) => {
const [isOpen, setIsOpen] = useState(false);
const [focusedIdx, setFocusedIdx] = useState(0);
const [focusedOptEl, setFocusedOptEl] = useState(null);
@ -141,7 +136,7 @@ const BookSearch: React.FunctionComponent<Props> = ({
focusedIdx,
setFocusedIdx,
onKeydownSelect: appendBook,
disabled: !isOpen || disabled
disabled: !isOpen
});
useScrollToFocused({
shouldScroll: true,
@ -167,7 +162,6 @@ const BookSearch: React.FunctionComponent<Props> = ({
styles.input
)}
value={value}
disabled={disabled}
onChange={e => {
const val = e.target.value;

View file

@ -24,14 +24,9 @@ import styles from './AdvancedPanel.scss';
interface Props {
words: string;
setWords: (string) => void;
disabled: boolean;
}
const WordsSearch: React.FunctionComponent<Props> = ({
words,
setWords,
disabled
}) => {
const WordsSearch: React.FunctionComponent<Props> = ({ words, setWords }) => {
return (
<section className={styles.section}>
<label htmlFor="has-words" className={styles.label}>
@ -44,7 +39,6 @@ const WordsSearch: React.FunctionComponent<Props> = ({
styles.input
)}
value={words}
disabled={disabled}
onChange={e => {
const val = e.target.value;
setWords(val);

View file

@ -30,7 +30,6 @@ import styles from './AdvancedPanel.scss';
interface Props extends RouteComponentProps {
onDismiss: () => void;
disabled: boolean;
}
// quoteFilters surrounds a filter term with a pair of double quotation marks, effectively
@ -107,8 +106,7 @@ function encodeBookStr(s: string): string[] {
const AdvancedPanel: React.FunctionComponent<Props> = ({
onDismiss,
history,
location,
disabled
location
}) => {
const filters = useFilters();
const { queries } = filters;
@ -139,9 +137,9 @@ const AdvancedPanel: React.FunctionComponent<Props> = ({
closeOnOutsideClick
>
<form className={styles.form} onSubmit={handleSubmit}>
<WordsSearch words={words} setWords={setWords} disabled={disabled} />
<WordsSearch words={words} setWords={setWords} />
<BookSearch value={books} setValue={setBooks} disabled={disabled} />
<BookSearch value={books} setValue={setBooks} />
<Button
type="submit"
@ -149,7 +147,6 @@ const AdvancedPanel: React.FunctionComponent<Props> = ({
size="normal"
stretch
className={styles.submit}
disabled={disabled}
>
Search
</Button>

View file

@ -24,7 +24,7 @@ import * as filtersLib from 'jslib/helpers/filters';
import * as queriesLib from 'jslib/helpers/queries';
import { getSearchDest } from 'web/libs/search';
import { usePrevious } from 'web/libs/hooks';
import { useFilters, useSelector } from '../../../store';
import { useFilters } from '../../../store';
import SearchInput from '../../Common/SearchInput';
import AdvancedPanel from './AdvancedPanel';
import styles from './SearchBar.scss';
@ -41,23 +41,13 @@ const SearchBar: React.FunctionComponent<Props> = ({ location, history }) => {
const [value, setValue] = useState(initialValue);
const [expanded, setExpanded] = useState(false);
const { user } = useSelector(state => {
return {
user: state.auth.user.data
};
});
const handleSearch = useCallback(
(queryText: string) => {
if (!user.pro) {
return;
}
const queries = queriesLib.parse(queryText);
const dest = getSearchDest(location, queries);
history.push(dest);
},
[history, location, user]
[history, location]
);
const prevFilters = usePrevious(filters);
@ -76,8 +66,6 @@ const SearchBar: React.FunctionComponent<Props> = ({ location, history }) => {
setExpanded(false);
};
const disabled = !user.pro;
return (
<div className={styles.wrapper}>
<form
@ -93,7 +81,6 @@ const SearchBar: React.FunctionComponent<Props> = ({ location, history }) => {
wrapperClassName={styles['input-wrapper']}
inputClassName={classnames(styles.input, ' text-input-small')}
value={value}
disabled={disabled}
onChange={e => {
const val = e.target.value;
setValue(val);
@ -120,7 +107,7 @@ const SearchBar: React.FunctionComponent<Props> = ({ location, history }) => {
</button>
</form>
{expanded && <AdvancedPanel onDismiss={onDismiss} disabled={disabled} />}
{expanded && <AdvancedPanel onDismiss={onDismiss} />}
</div>
);
};

View file

@ -24,14 +24,6 @@
.wrapper {
flex-grow: 1;
&.nopro {
display: none;
@include breakpoint(lg) {
display: block;
}
}
@include breakpoint(lg) {
margin-top: rem(16px);
}

View file

@ -17,7 +17,6 @@
*/
import React from 'react';
import classnames from 'classnames';
import { Filters } from 'jslib/helpers/filters';
import { NotesGroupData } from 'web/libs/notes';
@ -59,18 +58,16 @@ function renderResult({
interface Props {
isFetched: boolean;
groups: NotesGroupData[];
pro: boolean;
filters: Filters;
}
const NoteGroupList: React.FunctionComponent<Props> = ({
groups,
pro,
filters,
isFetched
}) => {
return (
<div className={classnames(styles.wrapper, { [styles.nopro]: !pro })}>
<div className={styles.wrapper}>
{renderResult({ groups, isFetched, filters })}
</div>
);

View file

@ -32,7 +32,6 @@ import { useDispatch, useSelector } from '../../store';
import { getNotes } from '../../store/notes';
import TopActions from './Actions/Top';
import Flash from '../Common/Flash';
import PayWall from '../Common/PayWall';
interface Props extends RouteComponentProps {}
@ -47,9 +46,6 @@ function useFetchNotes(filters: Filters) {
const prevFilters = usePrevious(filters);
useEffect(() => {
if (!user.pro) {
return () => null;
}
if (prevFilters && checkFilterEqual(filters, prevFilters)) {
return () => null;
}
@ -61,9 +57,8 @@ function useFetchNotes(filters: Filters) {
}
const Home: React.FunctionComponent<Props> = ({ location }) => {
const { notes, user } = useSelector(state => {
const { notes } = useSelector(state => {
return {
user: state.auth.user.data,
notes: state.notes
};
});
@ -80,24 +75,21 @@ const Home: React.FunctionComponent<Props> = ({ location }) => {
>
<HeadData filters={filters} />
<PayWall>
<h1 className="sr-only">Notes</h1>
<h1 className="sr-only">Notes</h1>
<Flash kind="danger" when={Boolean(notes.errorMessage)}>
Error getting notes: {notes.errorMessage}
</Flash>
<Flash kind="danger" when={Boolean(notes.errorMessage)}>
Error getting notes: {notes.errorMessage}
</Flash>
<TopActions />
<TopActions />
<NoteGroupList
groups={groups}
pro={user.pro}
filters={filters}
isFetched={notes.isFetched}
/>
<NoteGroupList
groups={groups}
filters={filters}
isFetched={notes.isFetched}
/>
{notes.data.length > 10 && <TopActions position="bottom" />}
</PayWall>
{notes.data.length > 10 && <TopActions position="bottom" />}
</div>
);
};

View file

@ -41,8 +41,9 @@ SOFTWARE.
*/
import React from 'react';
import { IconProps } from './types';
const Icon = ({ fill, width, height, className }) => {
const Icon = ({ fill, width, height, className }: IconProps) => {
const h = `${height}px`;
const w = `${width}px`;

View file

@ -30,7 +30,6 @@ import { EditorSession, resetEditor } from '../../store/editor';
import { setMessage } from '../../store/ui';
import Editor from '../Common/Editor';
import Flash from '../Common/Flash';
import PayWall from '../Common/PayWall';
import styles from './New.scss';
interface Props extends RouteComponentProps {
@ -78,72 +77,70 @@ const New: React.FunctionComponent<Props> = ({
<title>New</title>
</Helmet>
<PayWall>
<div
className={classnames(
'page page-mobile-full container mobile-nopadding',
styles.container
)}
>
<Flash kind="danger" when={Boolean(errMessage)}>
Error: {errMessage}
</Flash>
<div
className={classnames(
'page page-mobile-full container mobile-nopadding',
styles.container
)}
>
<Flash kind="danger" when={Boolean(errMessage)}>
Error: {errMessage}
</Flash>
<div className={styles.wrapper}>
<div className={styles.header}>
<h2 className={styles.heading}>New notes</h2>
</div>
<Editor
isNew
editor={editor}
isBusy={submitting}
textareaRef={textareaRef}
bookSelectorTriggerRef={triggerRef}
onSubmit={async ({ draftContent, draftBookUUID }) => {
setSubmitting(true);
try {
let bookUUID;
if (!draftBookUUID) {
const book = await dispatch(createBook(editor.bookLabel));
bookUUID = book.uuid;
} else {
bookUUID = draftBookUUID;
}
const res = await operations.notes.create({
bookUUID,
content: draftContent
});
dispatch(resetEditor(editor.sessionKey));
const dest = getNotePath(res.result.uuid);
history.push(dest);
dispatch(
setMessage({
message: 'Created a note',
kind: 'info',
path: notePathDef
})
);
} catch (err) {
setErrMessage(err.message);
setSubmitting(false);
}
}}
/>
<div className={styles.wrapper}>
<div className={styles.header}>
<h2 className={styles.heading}>New notes</h2>
</div>
<Prompt
message="You have unsaved changes. Continue?"
when={!persisted}
<Editor
isNew
editor={editor}
isBusy={submitting}
textareaRef={textareaRef}
bookSelectorTriggerRef={triggerRef}
onSubmit={async ({ draftContent, draftBookUUID }) => {
setSubmitting(true);
try {
let bookUUID;
if (!draftBookUUID) {
const book = await dispatch(createBook(editor.bookLabel));
bookUUID = book.uuid;
} else {
bookUUID = draftBookUUID;
}
const res = await operations.notes.create({
bookUUID,
content: draftContent
});
dispatch(resetEditor(editor.sessionKey));
const dest = getNotePath(res.result.uuid);
history.push(dest);
dispatch(
setMessage({
message: 'Created a note',
kind: 'info',
path: notePathDef
})
);
} catch (err) {
setErrMessage(err.message);
setSubmitting(false);
}
}}
/>
</div>
</PayWall>
<Prompt
message="You have unsaved changes. Continue?"
when={!persisted}
/>
</div>
</Fragment>
);
};

View file

@ -4,6 +4,7 @@ import classnames from 'classnames';
import {
getNewRepetitionPath,
getSettingsPath,
getSubscriptionPath,
SettingSections,
repetitionsPathDef
} from 'web/libs/paths';
@ -37,29 +38,44 @@ const Content: React.FunctionComponent = () => {
<div className={classnames('page-header', styles.header)}>
<h1 className="page-heading">Repetition</h1>
<Link
id="T-new-rule-btn"
className="button button-first button-normal"
to={getNewRepetitionPath()}
>
New
</Link>
{!user.pro ? (
<button
disabled
type="button"
className="button button-first button-normal"
>
New
</button>
) : (
<Link
id="T-new-rule-btn"
className="button button-first button-normal"
to={getNewRepetitionPath()}
>
New
</Link>
)}
</div>
</div>
<div className="container mobile-nopadding">
<Flash when={!user.emailVerified} kind="warning">
<Flash when={!user.pro} kind="warning">
Repetitions are not enabled on your plan.{' '}
<Link to={getSubscriptionPath()}>Upgrade here.</Link>
</Flash>
<Flash when={user.pro && !user.emailVerified} kind="warning">
Please verify your email address in order to receive digests.{' '}
<Link to={getSettingsPath(SettingSections.account)}>
Go to settings.
</Link>
</Flash>
<RepetitionList
isFetching={repetitionRules.isFetching}
isFetched={repetitionRules.isFetched}
items={repetitionRules.data}
setRuleUUIDToDelete={setRuleUUIDToDelete}
pro={user.pro}
/>
</div>

View file

@ -16,13 +16,15 @@
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
import classnames from 'classnames';
import { RepetitionRuleData } from 'jslib/operations/types';
import React, { useEffect, useState } from 'react';
import classnames from 'classnames';
import Helmet from 'react-helmet';
import { Link, RouteComponentProps } from 'react-router-dom';
import { getRepetitionsPath } from 'web/libs/paths';
import { RepetitionRuleData } from 'jslib/operations/types';
import services from 'web/libs/services';
import { getRepetitionsPath } from 'web/libs/paths';
import PayWall from '../../Common/PayWall';
import { useDispatch } from '../../../store';
import Flash from '../../Common/Flash';
import repetitionStyles from '../Repetition.scss';
@ -57,29 +59,31 @@ const EditRepetition: React.FunctionComponent<Props> = ({ match }) => {
<title>Edit Repetition</title>
</Helmet>
<div className="container mobile-fw">
<div className={classnames('page-header', repetitionStyles.header)}>
<h1 className="page-heading">Edit Repetition</h1>
<PayWall>
<div className="container mobile-fw">
<div className={classnames('page-header', repetitionStyles.header)}>
<h1 className="page-heading">Edit Repetition</h1>
<Link to={getRepetitionsPath()}>Back</Link>
<Link to={getRepetitionsPath()}>Back</Link>
</div>
<Flash
kind="danger"
when={errMsg !== ''}
onDismiss={() => {
setErrMsg('');
}}
>
Error: {errMsg}
</Flash>
{data === null ? (
<div>loading</div>
) : (
<Content setErrMsg={setErrMsg} data={data} />
)}
</div>
<Flash
kind="danger"
when={errMsg !== ''}
onDismiss={() => {
setErrMsg('');
}}
>
Error: {errMsg}
</Flash>
{data === null ? (
<div>loading</div>
) : (
<Content setErrMsg={setErrMsg} data={data} />
)}
</div>
</PayWall>
</div>
);
};

View file

@ -22,6 +22,7 @@ import { Link, withRouter, RouteComponentProps } from 'react-router-dom';
import classnames from 'classnames';
import { getRepetitionsPath, repetitionsPathDef } from 'web/libs/paths';
import PayWall from '../../Common/PayWall';
import {
getRepetitionRules,
createRepetitionRule
@ -70,25 +71,27 @@ const NewRepetition: React.FunctionComponent<Props> = ({ history }) => {
<title>New Repetition</title>
</Helmet>
<div className="container mobile-fw">
<div className={classnames('page-header', repetitionStyles.header)}>
<h1 className="page-heading">New Repetition</h1>
<PayWall>
<div className="container mobile-fw">
<div className={classnames('page-header', repetitionStyles.header)}>
<h1 className="page-heading">New Repetition</h1>
<Link to={getRepetitionsPath()}>Back</Link>
<Link to={getRepetitionsPath()}>Back</Link>
</div>
<Flash
kind="danger"
when={errMsg !== ''}
onDismiss={() => {
setErrMsg('');
}}
>
Error creating a rule: {errMsg}
</Flash>
<Form onSubmit={handleSubmit} setErrMsg={setErrMsg} />
</div>
<Flash
kind="danger"
when={errMsg !== ''}
onDismiss={() => {
setErrMsg('');
}}
>
Error creating a rule: {errMsg}
</Flash>
<Form onSubmit={handleSubmit} setErrMsg={setErrMsg} />
</div>
</PayWall>
</div>
);
};

View file

@ -27,12 +27,14 @@ interface Props {
isActive: boolean;
onDelete: () => void;
repetitionUUID: string;
disabled?: boolean;
}
const Actions: React.FunctionComponent<Props> = ({
isActive,
onDelete,
repetitionUUID
repetitionUUID,
disabled
}) => {
const [isOpen, setIsOpen] = useState(false);
@ -83,6 +85,7 @@ const Actions: React.FunctionComponent<Props> = ({
setIsOpen={setIsOpen}
isActive={isActive}
options={options}
disabled={disabled}
optRefs={optRefs}
/>
);

View file

@ -33,6 +33,7 @@ import styles from './RepetitionItem.scss';
interface Props {
item: RepetitionRuleData;
setRuleUUIDToDelete: React.Dispatch<any>;
pro: boolean;
}
function formatLastActive(ms: number): string {
@ -48,6 +49,7 @@ function formatNextActive(ms: number): string {
const RepetitionItem: React.FunctionComponent<Props> = ({
item,
pro,
setRuleUUIDToDelete
}) => {
const [isHovered, setIsHovered] = useState(false);
@ -94,7 +96,7 @@ const RepetitionItem: React.FunctionComponent<Props> = ({
<div className={styles.right}>
<ul className={classnames('list-unstyled', styles['detail-list'])}>
<li>
{item.enabled ? (
{item.enabled && item.nextActive !== 0 ? (
<span>
Scheduled in{' '}
<Time
@ -145,6 +147,7 @@ const RepetitionItem: React.FunctionComponent<Props> = ({
setRuleUUIDToDelete(item.uuid);
}}
repetitionUUID={item.uuid}
disabled={!pro}
/>
</li>
);

View file

@ -28,13 +28,15 @@ interface Props {
isFetched: boolean;
items: RepetitionRuleData[];
setRuleUUIDToDelete: React.Dispatch<any>;
pro: boolean;
}
const ReptitionList: React.FunctionComponent<Props> = ({
isFetching,
isFetched,
items,
setRuleUUIDToDelete
setRuleUUIDToDelete,
pro
}) => {
if (isFetching && !isFetched) {
return <div>loading</div>;
@ -53,6 +55,7 @@ const ReptitionList: React.FunctionComponent<Props> = ({
key={i.uuid}
item={i}
setRuleUUIDToDelete={setRuleUUIDToDelete}
pro={pro}
/>
);
})}

View file

@ -19,7 +19,6 @@
import React from 'react';
import Helmet from 'react-helmet';
import PayWall from '../Common/PayWall';
import Content from './Content';
const Repetition: React.FunctionComponent = () => {
@ -29,9 +28,7 @@ const Repetition: React.FunctionComponent = () => {
<title>Repetition</title>
</Helmet>
<PayWall>
<Content />
</PayWall>
<Content />
</div>
);
};

View file

@ -22,11 +22,17 @@
@import '../../App/rem';
.wrapper {
display: flex;
flex-direction: column;
background: white;
padding-top: rem(52px);
flex-grow: 1;
}
.form {
margin-bottom: auto;
}
.cvc,
.expiry {
width: rem(100px);

View file

@ -32,6 +32,7 @@ import { useDispatch } from '../../../store';
import { setMessage } from '../../../store/ui';
import NameOnCardInput from '../../Common/PaymentInput/NameOnCard';
import CardInput from '../../Common/PaymentInput/Card';
import Footer from '../Footer';
import CountryInput from '../../Common/PaymentInput/Country';
import styles from './Form.scss';
@ -116,7 +117,10 @@ const Form: React.FunctionComponent<Props> = ({
return (
<div className={styles.wrapper}>
<form className={classnames('container')} onSubmit={handleSubmit}>
<form
className={classnames('container', styles.form)}
onSubmit={handleSubmit}
>
<Helmet>
<title>Subscriptions</title>
</Helmet>
@ -174,6 +178,8 @@ const Form: React.FunctionComponent<Props> = ({
</div>
</div>
</form>
<Footer />
</div>
);
};

View file

@ -29,7 +29,8 @@ const perks = [
{
id: 'hosted',
icon: <ServerIcon width={16} height={16} fill="#4d4d8b" />,
value: 'Fully hosted and managed'
value:
'Maximize your memory retention and become the best learner you can be'
},
{
id: 'support',

View file

@ -19,10 +19,13 @@
import React from 'react';
import CheckIcon from '../Icons/Check';
import styles from './FeatureItem.scss';
import styles from './FeatureItem.module.scss';
interface Props {
label: string;
}
export default ({ label }) => {
const FeatureItem: React.FunctionComponent<Props> = ({ label }) => {
return (
<li className={styles.wrapper}>
<CheckIcon width={16} height={16} fill="#0d5ec1" className="check-icon" />
@ -31,3 +34,5 @@ export default ({ label }) => {
</li>
);
};
export default FeatureItem;

View file

@ -25,5 +25,4 @@
list-style: none;
margin-bottom: 0;
padding-left: 0;
padding-top: rem(12px);
}

View file

@ -17,19 +17,27 @@
*/
import React from 'react';
import classnames from 'classnames';
import FeatureItem from './FeatureItem';
import styles from './FeatureList.scss';
import styles from './FeatureList.module.scss';
interface Props {
features: any;
wrapperClassName?: string;
}
function FeatureList({ features }) {
const FeatureList: React.FunctionComponent<Props> = ({
features,
wrapperClassName
}) => {
return (
<ul className={styles['feature-list']}>
<ul className={classnames(styles['feature-list'], wrapperClassName)}>
{features.map(feature => {
return <FeatureItem key={feature.id} label={feature.label} />;
})}
</ul>
);
}
};
export default FeatureList;

View file

@ -0,0 +1,15 @@
import React from 'react';
import styles from './Subscription.scss';
interface Props {}
const Footer: React.FunctionComponent<Props> = () => {
return (
<footer className={styles.footer}>
&copy; 2019 Monomax Software Pty Ltd
</footer>
);
};
export default Footer;

View file

@ -18,28 +18,41 @@
import React from 'react';
import BoxIcon from '../../Icons/Box';
import { UserData } from 'jslib/operations/types';
import Plan from './internal';
const selfHostedPerks = [
{
id: 'own-machine',
icon: <BoxIcon width="16" height="16" fill="#6e6e6e" />,
value: 'Host on your own machine'
}
];
const desc =
'Streamline your learnings into your own personal knowledge base. You can access any item at any time.';
function Core({ wrapperClassName, ctaContent, bottomContent }) {
interface Props {
wrapperClassName: string;
user: UserData;
bottomContent: React.ReactElement;
}
const Core: React.FunctionComponent<Props> = ({
wrapperClassName,
user,
bottomContent
}) => {
return (
<Plan
name="Core"
desc={desc}
price="Free"
perks={selfHostedPerks}
wrapperClassName={wrapperClassName}
ctaContent={ctaContent}
ctaContent={
<button
type="button"
className="button button-large button-second button-stretch"
disabled
>
{user && user.pro ? 'Already upgraded!' : 'Your current plan'}
</button>
}
bottomContent={bottomContent}
/>
);
}
};
export default Core;

View file

@ -54,7 +54,7 @@
}
}
.header-body {
margin-top: rem(20px);
margin-top: rem(32px);
}
.name {
@ -65,20 +65,23 @@
}
.price-wrapper {
display: flex;
align-items: baseline;
@include font-size('large');
font-weight: 600;
margin-top: rem(12px);
}
.price {
@include font-size('3x-large');
display: inline-block;
font-weight: 600;
}
.interval {
@include font-size('large');
text-transform: uppercase;
font-weight: 600;
margin-left: rem(4px);
display: inline-block;
}
.desc {
margin-top: rem(20px);
@include font-size('regular');
}
.cta-wrapper {
@ -92,30 +95,6 @@
padding-top: rem(12px);
}
.perks {
margin-top: rem(12px);
}
.perk-value {
@include font-size('regular');
margin-left: rem(8px);
}
.perk-item {
display: flex;
flex-shrink: 1;
&:not(:first-child) {
margin-top: rem(4px);
}
}
.perk-icon {
flex-shrink: 0;
display: flex;
justify-content: center;
align-items: flex-start;
margin-top: rem(4px);
}
.feature-bold {
font-weight: 600;
}

View file

@ -18,35 +18,35 @@
import React from 'react';
import { UserData } from 'jslib/operations/types';
import Plan from './internal';
import ServerIcon from '../../Icons/Server';
import GlobeIcon from '../../Icons/Globe';
import ProCTA from './ProCTA';
const proPerks = [
{
id: 'hosted',
icon: <ServerIcon width="16" height="16" fill="#4d4d8b" />,
value: 'Fully hosted and managed'
},
{
id: 'support',
icon: <GlobeIcon width="16" height="16" fill="#4d4d8b" />,
value: 'Support the Dnote community and development'
}
];
const desc =
'Maximize your memory retention and become the best learner you can be.';
function ProPlan({ wrapperClassName, ctaContent, bottomContent }) {
interface Props {
wrapperClassName: string;
user: UserData;
bottomContent: React.ReactElement;
}
const ProPlan: React.FunctionComponent<Props> = ({
wrapperClassName,
user,
bottomContent
}) => {
return (
<Plan
name="Pro"
price="$3"
desc={desc}
interval="month"
perks={proPerks}
wrapperClassName={wrapperClassName}
ctaContent={ctaContent}
ctaContent={<ProCTA user={user} />}
bottomContent={bottomContent}
/>
);
}
};
export default ProPlan;

View file

@ -0,0 +1,39 @@
import React from 'react';
import { Link } from 'react-router-dom';
import classnames from 'classnames';
import {
getSubscriptionCheckoutPath,
getSettingsPath,
SettingSections
} from 'web/libs/paths';
import { UserData } from 'jslib/operations/types';
interface Props {
user: UserData;
}
const ProCTA: React.FunctionComponent<Props> = ({ user }) => {
if (user && user.pro) {
return (
<Link
to={getSettingsPath(SettingSections.billing)}
className="button button-large button-third-outline button-stretch"
>
Manage Your Plan
</Link>
);
}
return (
<Link
id="T-unlock-pro-btn"
className={classnames('button button-large button-third button-stretch')}
to={getSubscriptionCheckoutPath()}
>
Upgrade
</Link>
);
};
export default ProCTA;

View file

@ -19,17 +19,33 @@
import React from 'react';
import classnames from 'classnames';
import styles from './Plan.module.scss';
import styles from './Plan.scss';
function Plan({
interface Perk {
id: string;
icon: JSX.Element;
value: string;
}
interface Props {
name: string;
desc: string;
price: string;
bottomContent: React.ReactElement;
ctaContent: React.ReactElement;
interval?: string;
wrapperClassName: string;
}
const Plan: React.FunctionComponent<Props> = ({
name,
desc,
price,
bottomContent,
ctaContent,
interval,
perks,
wrapperClassName
}) {
}) => {
return (
<div className={classnames(styles.wrapper, wrapperClassName)}>
<div
@ -39,23 +55,14 @@ function Plan({
>
<h2 className={styles.name}>{name}</h2>
<ul className={classnames('list-unstyled', styles.perks)}>
{perks.map(perk => {
return (
<li key={perk.id} className={styles['perk-item']}>
<div className={styles['perk-icon']}>{perk.icon}</div>
<div className={styles['perk-value']}>{perk.value}</div>
</li>
);
})}
</ul>
<div className={styles['price-wrapper']}>
<strong className={styles.price}>{price}</strong>{' '}
{interval && <div className={styles.interval}> / {interval}</div>}
</div>
<p className={styles.desc}>{desc}</p>
<div className={styles['header-body']}>
<div className={styles['price-wrapper']}>
<strong className={styles.price}>{price}</strong>
{interval && <div className={styles.interval}>/ {interval}</div>}
</div>
<div className={styles['cta-wrapper']}>{ctaContent}</div>
</div>
</div>
@ -63,6 +70,6 @@ function Plan({
{bottomContent}
</div>
);
}
};
export default Plan;

View file

@ -18,12 +18,34 @@
@import '../App/responsive';
@import '../App/font';
@import '../App/theme';
@import '../App/rem';
.wrapper {
// padding-bottom: rem(80px);
background: white;
height: calc(100vh - #{$header-height});
display: flex;
flex-direction: column;
justify-content: center;
@include breakpoint(md) {
height: calc(100vh - #{$header-height});
}
}
.content {
margin-bottom: rem(40px);
@include breakpoint(md) {
margin-bottom: auto;
}
}
.footer {
margin-top: auto;
padding: rem(12px) 0;
text-align: center;
@include font-size('x-small');
color: $gray;
}
.hero {
@ -94,3 +116,15 @@
.feature-bold {
font-weight: 600;
}
.bottom {
padding-top: rem(20px);
}
.pro-prelude {
padding: 0 rem(8px);
}
.pro-feature-list {
margin-top: rem(12px);
}

View file

@ -18,29 +18,19 @@
import React from 'react';
import Helmet from 'react-helmet';
import { Link } from 'react-router-dom';
import classnames from 'classnames';
import { getSubscriptionCheckoutPath } from 'web/libs/paths';
import ProPlan from './Plan/Pro';
import CorePlan from './Plan/Core';
import FeatureList from './FeatureList';
import Footer from './Footer';
import { useSelector } from '../../store';
import styles from './Subscription.scss';
const proFeatures = [
{
id: 'core',
label: <div className={styles['feature-bold']}>Everything in core</div>
},
{
id: 'host',
label: <div>Hosting</div>
},
{
id: 'auto',
label: <div>Automatic update and migration</div>
id: 'spaced-rep',
label: <div>Automated Spaced Repetition</div>
},
{
id: 'email-support',
@ -48,7 +38,19 @@ const proFeatures = [
}
];
const baseFeatures = [
const coreFeatures = [
{
id: 'oss',
label: <div>Open source</div>
},
{
id: 'num-notes',
label: <div>Unlimited notes</div>
},
{
id: 'num-books',
label: <div>Unlimited books</div>
},
{
id: 'sync',
label: <div>Multi-device sync</div>
@ -57,25 +59,13 @@ const baseFeatures = [
id: 'cli',
label: <div>Command line interface</div>
},
{
id: 'atom',
label: <div>Atom plugin</div>
},
{
id: 'web',
label: <div>Web client</div>
},
{
id: 'digest',
label: <div>Automated email digest</div>
label: <div>Web application</div>
},
{
id: 'ext',
label: <div>Firefox/Chrome extension</div>
},
{
id: 'foss',
label: <div>Free and open source</div>
label: <div>Chrome/Firefox extension</div>
},
{
id: 'forum-support',
@ -92,31 +82,6 @@ const Subscription: React.FunctionComponent<Props> = () => {
};
});
function renderPlanCta() {
if (user && user.pro) {
return (
<Link
to="/"
className="button button-normal button-third-outline button-stretch"
>
Go to your notes
</Link>
);
}
return (
<Link
id="T-unlock-pro-btn"
className={classnames(
'button button-normal button-third button-stretch'
)}
to={getSubscriptionCheckoutPath()}
>
Unlock
</Link>
);
}
return (
<div className={styles.wrapper}>
<Helmet>
@ -127,38 +92,45 @@ const Subscription: React.FunctionComponent<Props> = () => {
/>
</Helmet>
<div className={styles.hero}>
<div className={styles.content}>
<div className={styles.hero}>
<div className="container">
<h1 className={styles.heading}>Choose your Dnote plan.</h1>
</div>
</div>
<div className="container">
<h1 className={styles.heading}>
You can self-host or sign up for the hosted version.
</h1>
<div className={styles['plans-wrapper']}>
<CorePlan
wrapperClassName={styles['core-plan']}
user={user}
bottomContent={
<div className={styles.bottom}>
<FeatureList features={coreFeatures} />
</div>
}
/>
<ProPlan
wrapperClassName={styles['pro-plan']}
user={user}
bottomContent={
<div className={styles.bottom}>
<div className={styles['pro-prelude']}>
Everything from the core plan, plus:
</div>
<FeatureList
features={proFeatures}
wrapperClassName={styles['pro-feature-list']}
/>
</div>
}
/>
</div>
</div>
</div>
<div className="container">
<div className={styles['plans-wrapper']}>
<CorePlan
wrapperClassName={styles['core-plan']}
ctaContent={
<a
href="https://github.com/dnote/dnote"
target="_blank"
rel="noopener noreferrer"
className="button button-normal button-second-outline button-stretch"
>
See source code
</a>
}
bottomContent={<FeatureList features={baseFeatures} />}
/>
<ProPlan
wrapperClassName={styles['pro-plan']}
ctaContent={renderPlanCta()}
bottomContent={<FeatureList features={proFeatures} />}
/>
</div>
</div>
<Footer />
</div>
);
};