Templated structure
This commit is contained in:
commit
e3ecea5135
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"env": {
|
||||
"development": {
|
||||
"plugins": [
|
||||
["tailwind", { "ssr": true, "displayName": true, "preprocess": false }]
|
||||
],
|
||||
"presets": ["next/babel"]
|
||||
},
|
||||
"production": {
|
||||
"plugins": [
|
||||
["tailwind", { "ssr": true, "displayName": true, "preprocess": false }]
|
||||
],
|
||||
"presets": ["next/babel"]
|
||||
}
|
||||
},
|
||||
"plugins": [
|
||||
["tailwind", { "ssr": true, "displayName": true, "preprocess": false }]
|
||||
]
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
module.exports = {
|
||||
// root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020, // Allows for ES8+
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/eslint-recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:jsx-a11y/recommended',
|
||||
// Prettier plugin and recommended rules
|
||||
'prettier/@typescript-eslint',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
rules: {
|
||||
// Include .prettierrc.js rules
|
||||
'prettier/prettier': ['error', {}, { usePrettierrc: true }],
|
||||
'react/prop-types': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/no-unescaped-entities': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/ban-ts-ignore': 'off',
|
||||
'jsx-a11y/anchor-is-valid': 'off',
|
||||
'jsx-a11y/anchor-has-content': 'off',
|
||||
'jsx-a11y/iframe-has-title': 'off',
|
||||
'jsx-a11y/click-events-have-key-events': 'off',
|
||||
'jsx-a11y/alt-text': 'off',
|
||||
'jsx-a11y/img-redundant-alt': 'off',
|
||||
'jsx-a11y/no-static-element-interactions': 'off',
|
||||
'jsx-a11y/label-has-associated-control': [
|
||||
'error',
|
||||
{
|
||||
labelComponents: [],
|
||||
labelAttributes: [],
|
||||
controlComponents: [],
|
||||
assert: 'either',
|
||||
depth: 25,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"projects": {
|
||||
"default": "oxen-io",
|
||||
"staging": "oxen-io",
|
||||
"production": "oxen-io"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
!public/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env*
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Environment Variables
|
||||
.env
|
||||
.env.build
|
||||
|
||||
.now
|
||||
.vercel
|
|
@ -0,0 +1 @@
|
|||
functions
|
|
@ -0,0 +1,4 @@
|
|||
# Ignore .next
|
||||
.next
|
||||
.git
|
||||
node_modules
|
|
@ -0,0 +1,9 @@
|
|||
module.exports = {
|
||||
semi: true,
|
||||
trailingComma: 'all',
|
||||
singleQuote: true,
|
||||
arrowParens: 'avoid',
|
||||
printWidth: 80,
|
||||
tabWidth: 2,
|
||||
useTabs: false,
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
declare module '*.svg' {
|
||||
import * as React from 'react';
|
||||
|
||||
const ReactComponent: React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
|
||||
export { ReactComponent };
|
||||
export default string;
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/zeit/next.js/tree/canary/packages/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/zeit/next.js/) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on ZEIT Now
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [ZEIT Now Platform](https://zeit.co/import?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
|
@ -0,0 +1,148 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ********************* */
|
||||
/* ***** CONSTANTS ***** */
|
||||
/* ********************* */
|
||||
$theme-primary: #fe4c00;
|
||||
$theme-primary-2: #eb5929;
|
||||
$theme-secondary: #ffd618;
|
||||
$theme-secondary-1: #ffd400;
|
||||
|
||||
/* **************************** */
|
||||
/* ******** RESPONSIVE ******** */
|
||||
/* **************************** */
|
||||
|
||||
$breakpoint-mobile: 500px;
|
||||
$breakpoint-tablet: 768px;
|
||||
$breakpoint-desktop: 992px;
|
||||
$breakpoint-huge: 1280px;
|
||||
|
||||
/* ********************* */
|
||||
/* ******* FONTS ******* */
|
||||
/* ********************* */
|
||||
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
src: url(/fonts/Roboto/Roboto-Thin.ttf) format('truetype');
|
||||
font-weight: 200;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
src: url(/fonts/Roboto/Roboto-ThinItalic.ttf) format('truetype');
|
||||
font-weight: 200;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
src: url(/fonts/Roboto/Roboto-Regular.ttf) format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
src: url(/fonts/Roboto/Roboto-MediumItalic.ttf) format('truetype');
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
src: url(/fonts/Roboto/Roboto-Medium.ttf) format('truetype');
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
src: url(/fonts/Roboto/Roboto-LightItalic.ttf) format('truetype');
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
src: url(/fonts/Roboto/Roboto-Light.ttf) format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
src: url(/fonts/Roboto/Roboto-Italic.ttf) format('truetype');
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
src: url(/fonts/Roboto/Roboto-BoldItalic.ttf) format('truetype');
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
src: url(/fonts/Roboto/Roboto-Bold.ttf) format('truetype');
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
src: url(/fonts/Roboto/Roboto-BlackItalic.ttf) format('truetype');
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
src: url(/fonts/Roboto/Roboto-Black.ttf) format('truetype');
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: RobotoSlab;
|
||||
src: url(/fonts/RobotoSlab/RobotoSlab-ExtraLight.ttf) format('truetype');
|
||||
font-weight: 100;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: RobotoSlab;
|
||||
src: url(/fonts/RobotoSlab/RobotoSlab-Thin.ttf) format('truetype');
|
||||
font-weight: 200;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: RobotoSlab;
|
||||
src: url(/fonts/RobotoSlab/RobotoSlab-Light.ttf) format('truetype');
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: RobotoSlab;
|
||||
src: url(/fonts/RobotoSlab/RobotoSlab-Regular.ttf) format('truetype');
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: RobotoSlab;
|
||||
src: url(/fonts/RobotoSlab/RobotoSlab-SemiBold.ttf) format('truetype');
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: RobotoSlab;
|
||||
src: url(/fonts/RobotoSlab/RobotoSlab-Bold.ttf) format('truetype');
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: RobotoSlab;
|
||||
src: url(/fonts/RobotoSlab/RobotoSlab-ExtraBold.ttf) format('truetype');
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 350px;
|
||||
}
|
||||
|
||||
.__next {
|
||||
overflow-x: hidden;
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
// client.js
|
||||
import sanityClient from '@sanity/client';
|
||||
|
||||
export const SANITY_CONSTATNTS = {
|
||||
PROJECT_ID: 'q2qmxra4',
|
||||
DATASET: 'production',
|
||||
};
|
||||
|
||||
export default sanityClient({
|
||||
projectId: SANITY_CONSTATNTS.PROJECT_ID, // you can find this in sanity.json
|
||||
dataset: SANITY_CONSTATNTS.DATASET, // or the name you chose in step 1
|
||||
useCdn: true, // `false` if you want to ensure fresh data
|
||||
});
|
|
@ -0,0 +1,27 @@
|
|||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React from 'react';
|
||||
// import HomeSVG from '../assets/svgs/home-primary.svg';
|
||||
|
||||
export function Breadcrumbs() {
|
||||
const router = useRouter();
|
||||
const path = router.asPath.split('/').filter(i => Boolean(i));
|
||||
|
||||
console.log('Breadcrumbs ➡️ path:', path);
|
||||
|
||||
return (
|
||||
<div className="flex items-center font-roboto">
|
||||
{/* <HomeSVG className="h-4 w-4 mr-1 text-primary fill-current" /> */}
|
||||
<span className="children:last:font-medium">
|
||||
{path.map((item, index) => (
|
||||
<Link key={item} href={`/${path.slice(0, index + 1)?.join('/')}`}>
|
||||
<a>
|
||||
<span className="font-normal opacity-75"> {'>'} </span>
|
||||
<span className="text-primary">{item}</span>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,131 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { useContext } from 'react';
|
||||
import { ScreenContext } from '../contexts/screen';
|
||||
|
||||
export interface Props {
|
||||
color?: 'primary' | 'secondary' | 'danger';
|
||||
type?: 'text' | 'ghost' | 'solid' | 'outline';
|
||||
size?: 'tiny' | 'small' | 'medium' | 'large';
|
||||
|
||||
disabled?: boolean;
|
||||
selected?: boolean;
|
||||
onClick?(): any;
|
||||
children?: string;
|
||||
className?: string;
|
||||
|
||||
// Icons
|
||||
prefix?: JSX.Element;
|
||||
suffix?: JSX.Element;
|
||||
|
||||
wide?: boolean;
|
||||
}
|
||||
|
||||
export function Button(props: Props) {
|
||||
const {
|
||||
color = 'primary',
|
||||
size = 'medium',
|
||||
type = 'solid',
|
||||
disabled = false,
|
||||
selected = false,
|
||||
onClick,
|
||||
children,
|
||||
className,
|
||||
prefix,
|
||||
suffix,
|
||||
wide = false,
|
||||
} = props;
|
||||
|
||||
const { isDesktop } = useContext(ScreenContext);
|
||||
|
||||
const clickHandler = (e: React.MouseEvent) => {
|
||||
if (onClick) {
|
||||
e?.stopPropagation && e?.stopPropagation();
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
const onClickFn = disabled ? () => null : clickHandler;
|
||||
|
||||
const ghostClassNames = [
|
||||
'bg-white',
|
||||
`hover:bg-${color}`,
|
||||
'bg-transparent',
|
||||
selected && `bg-white`,
|
||||
selected ? 'text-white' : `text-${color}`,
|
||||
];
|
||||
|
||||
const solidClassNames = [
|
||||
'text-white',
|
||||
`bg-${color}`,
|
||||
'hover:opacity-75',
|
||||
selected && 'bg-opacity-75',
|
||||
];
|
||||
|
||||
const outlineClassNames = ['rounded-xl py-2'];
|
||||
|
||||
const textTypeClassNames = [`text-${color}`, 'hover:opacity-75'];
|
||||
|
||||
const off = disabled
|
||||
? ['cursor-not-allowed', 'opacity-50']
|
||||
: ['cursor-pointer'];
|
||||
|
||||
// Conditional isDesktop makes buttons more touch friendly
|
||||
// with more padding
|
||||
const sizeStyles = [
|
||||
size === 'large' && 'text-lg py-2',
|
||||
size === 'medium' && 'text-base py-1',
|
||||
size === 'small' && ['text-sm', isDesktop ? 'py-0' : 'py-1'],
|
||||
size === 'tiny' && 'text-xs py-0',
|
||||
];
|
||||
|
||||
// Make bg crop to text with tailwind on gradient
|
||||
// https://tailwindcss.com/docs/background-clip#class-reference
|
||||
|
||||
// prettier-ignore
|
||||
const typeStyles =
|
||||
type === 'ghost' ? ghostClassNames :
|
||||
type === 'solid' ? solidClassNames :
|
||||
type === 'text' ? textTypeClassNames :
|
||||
type === 'outline' ? outlineClassNames :
|
||||
'';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'flex',
|
||||
'justify-center',
|
||||
'items-center',
|
||||
'px-4',
|
||||
'outline-none',
|
||||
'duration-300',
|
||||
'ease-in-out',
|
||||
'text-center',
|
||||
'rounded-lg',
|
||||
'font-raleway',
|
||||
'font-semibold',
|
||||
off,
|
||||
sizeStyles,
|
||||
typeStyles,
|
||||
wide && 'tracking-widest',
|
||||
!disabled && type !== 'text' && 'hover:text-white',
|
||||
type !== 'text' && ['border-2', 'border-solid', `border-${color}`],
|
||||
className,
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
onClick={onClickFn}
|
||||
>
|
||||
{prefix && (
|
||||
<div className={classNames('flex', 'items-center', children && 'pr-2')}>
|
||||
{prefix}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
{suffix && (
|
||||
<div className={classNames('flex', 'items-center', children && 'pl-2')}>
|
||||
{suffix}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
children: JSX.Element[];
|
||||
equalWidth?: boolean;
|
||||
}
|
||||
|
||||
export function ButtonGroup(props: Props) {
|
||||
// Attach props to children following this guide
|
||||
// https://stackoverflow.com/a/35102287
|
||||
const { className, children, equalWidth } = props;
|
||||
const numChildren = children.length;
|
||||
const hasChildren = Boolean(numChildren);
|
||||
|
||||
const firstChild = children[0];
|
||||
const lastChild = children[numChildren - 1];
|
||||
|
||||
return (
|
||||
<div className={classNames('flex', className)}>
|
||||
{hasChildren &&
|
||||
React.cloneElement(firstChild, {
|
||||
className: classNames(
|
||||
'rounded-r-none border-r-0',
|
||||
equalWidth && 'flex-1',
|
||||
),
|
||||
})}
|
||||
{numChildren > 2 &&
|
||||
children.slice(1, numChildren - 1).map(child =>
|
||||
React.cloneElement(child, {
|
||||
className: classNames(
|
||||
'rounded-r-none rounded-l-none border-r-0',
|
||||
equalWidth && 'flex-1',
|
||||
),
|
||||
}),
|
||||
)}
|
||||
{hasChildren &&
|
||||
React.cloneElement(lastChild, {
|
||||
className: classNames('rounded-l-none', equalWidth && 'flex-1'),
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { UI } from '../constants';
|
||||
|
||||
interface Props {
|
||||
backgroundColor?: 'primary' | 'secondary' | 'secondary-1';
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Contained(props: Props) {
|
||||
const { backgroundColor, children } = props;
|
||||
|
||||
const containerStyle = {
|
||||
paddingLeft: `${UI.PAGE_CONTAINED_PADDING_VW}vw`,
|
||||
paddingRight: `${UI.PAGE_CONTAINED_PADDING_VW}vw`,
|
||||
width: '100%',
|
||||
maxWidth: `${UI.MAX_CONTENT_WIDTH}px`,
|
||||
margin: '0 auto',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'w-full',
|
||||
backgroundColor && `bg-${backgroundColor}`,
|
||||
)}
|
||||
>
|
||||
<div className="relative" style={containerStyle}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { useRef } from 'react';
|
||||
import { useClickAway } from 'react-use';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
pull?: 'left' | 'right' | 'center';
|
||||
style?: 'default' | 'outline';
|
||||
onClickAway: () => void;
|
||||
center?: boolean;
|
||||
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
|
||||
// Use DropdownItem
|
||||
children?: JSX.Element | JSX.Element[];
|
||||
}
|
||||
|
||||
export function Dropdown(props: Props) {
|
||||
// Ensure children are all DropdownItems
|
||||
const {
|
||||
isOpen,
|
||||
pull = 'right',
|
||||
style = 'default',
|
||||
center = false,
|
||||
offsetX,
|
||||
offsetY,
|
||||
onClickAway,
|
||||
children,
|
||||
} = props;
|
||||
|
||||
const ref = useRef(null);
|
||||
useClickAway(ref, onClickAway);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-0 z-50">
|
||||
<div
|
||||
style={{
|
||||
width: 'max-content',
|
||||
marginLeft: offsetX ? `${offsetX}px` : 'unset',
|
||||
marginTop: offsetY ? `${offsetY}px` : '0.5rem',
|
||||
}}
|
||||
className={classNames(
|
||||
'absolute',
|
||||
'top-0',
|
||||
'z-50',
|
||||
isOpen ? 'block' : 'hidden',
|
||||
pull === 'right' && 'left-0',
|
||||
pull === 'left' && 'right-0',
|
||||
pull === 'center' && 'left-0 right-0',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
'bg-white',
|
||||
'duration-300',
|
||||
'rounded-lg',
|
||||
'transform',
|
||||
'shadow-lg',
|
||||
'overflow-hidden',
|
||||
'children:last:border-b-0',
|
||||
style === 'default' && ['pt-2'],
|
||||
style === 'outline' && ['py-2', 'border-2', 'border-secondary'],
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
import classNames from 'classnames';
|
||||
|
||||
// Simplest use is to define your options and map over them to product JSX and your desired onSelect; eg:
|
||||
// options?.map(option => <DropdownItem onSelect={() => setDropdownItem(option.key)})}</Dropdown>
|
||||
export interface DropdownItemProps {
|
||||
id: string;
|
||||
selected?: boolean;
|
||||
onSelect?(): void;
|
||||
style?: 'default' | 'outline';
|
||||
children: JSX.Element | JSX.Element[] | string;
|
||||
}
|
||||
|
||||
export const DropdownItem = (props: DropdownItemProps) => {
|
||||
const { id, children, onSelect, selected = false, style = 'default' } = props;
|
||||
|
||||
const handleOnSelect = () => {
|
||||
if (onSelect) {
|
||||
onSelect();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => handleOnSelect()}
|
||||
className={classNames(
|
||||
'flex items-center',
|
||||
'block',
|
||||
'font-roboto text-sm text-primary',
|
||||
'hover:text-opacity-100 text-opacity-75',
|
||||
'select-none',
|
||||
'cursor-pointer',
|
||||
style === 'default' &&
|
||||
'border-b border-secondary font-semibold py-2 mx-3',
|
||||
style === 'outline' && ['pl-4', 'pr-6', 'py-1'],
|
||||
selected && 'bold text-opacity-100',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,29 @@
|
|||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import { Contained } from './Contained';
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<div className="bg-primary py-6 text-center mt-10">
|
||||
<Contained>
|
||||
<div className="font-roboto text-base text-white font-medium desktop:text-sm">
|
||||
<div className="flex justify-center mt-2 mb-6 cursor-pointer">
|
||||
<Link href="/"></Link>
|
||||
</div>
|
||||
<div className="flex flex-col mt-3">
|
||||
<Link href="/">
|
||||
<a className="mb-1 mt-0">Home</a>
|
||||
</Link>
|
||||
|
||||
<Link href="/about">
|
||||
<a className="mb-1 mt-0">About Us</a>
|
||||
</Link>
|
||||
<Link href="/privacy">
|
||||
<a className="mb-1 mt-0">Privacy</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Contained>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,238 @@
|
|||
import classNames from 'classnames';
|
||||
import {
|
||||
ChangeEvent,
|
||||
CSSProperties,
|
||||
FocusEvent,
|
||||
RefObject,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
export interface InputProps {
|
||||
id?: string;
|
||||
ref?: RefObject<HTMLInputElement>;
|
||||
|
||||
// Applied to parent only
|
||||
className?: string;
|
||||
inputClassName?: string;
|
||||
|
||||
// If value is not given in props, the component will manage it through state (default)
|
||||
value?: string | number;
|
||||
type?: 'text' | 'number' | 'search' | 'email' | 'password';
|
||||
name?: string;
|
||||
size?: 'large' | 'medium' | 'small';
|
||||
border?: 'primary' | 'secondary' | 'none';
|
||||
|
||||
inputMode?:
|
||||
| 'none'
|
||||
| 'text'
|
||||
| 'decimal'
|
||||
| 'numeric'
|
||||
| 'tel'
|
||||
| 'search'
|
||||
| 'url';
|
||||
|
||||
// Content
|
||||
prefix?: JSX.Element;
|
||||
suffix?: JSX.Element;
|
||||
placeholder?: string;
|
||||
|
||||
// Styling
|
||||
style?: CSSProperties;
|
||||
fitHeight?: boolean;
|
||||
readonly?: boolean;
|
||||
center?: boolean;
|
||||
duration?: boolean;
|
||||
|
||||
// Callbacks
|
||||
onKeyDown?(): any;
|
||||
onMouseUp?(): any;
|
||||
onBlur?(event: FocusEvent<HTMLInputElement>): void;
|
||||
onFocus?(event: FocusEvent<HTMLInputElement>): void;
|
||||
onChange?(event: ChangeEvent<HTMLInputElement>): any;
|
||||
onValueChange?(value: string): any;
|
||||
|
||||
// HTMLInputElement Props
|
||||
|
||||
autofocus?: boolean;
|
||||
// required?: boolean;
|
||||
// validity?: ValidityState;
|
||||
// validationMessage?: string;
|
||||
// willValidate?: boolean;
|
||||
// autocomplete?: string;
|
||||
|
||||
// Validation
|
||||
max?: number | string;
|
||||
maxLength?: number | string;
|
||||
min?: number | string;
|
||||
minLength?: number | string;
|
||||
disabled?: boolean;
|
||||
step?: number | string | undefined;
|
||||
}
|
||||
|
||||
export function Input(props: InputProps) {
|
||||
const {
|
||||
className,
|
||||
inputClassName,
|
||||
type = 'text',
|
||||
center = false,
|
||||
readonly = false,
|
||||
size = 'medium',
|
||||
border = 'secondary',
|
||||
style,
|
||||
prefix,
|
||||
duration = true,
|
||||
suffix,
|
||||
autofocus,
|
||||
disabled,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
placeholder = '',
|
||||
inputMode = 'text',
|
||||
fitHeight = false,
|
||||
onKeyDown,
|
||||
onMouseUp,
|
||||
} = props;
|
||||
|
||||
// Focus
|
||||
const inputRef = props.ref ?? useRef<HTMLInputElement>(null);
|
||||
const setInputFocus = () => {
|
||||
if (typeof inputRef !== 'string') {
|
||||
inputRef?.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// Value
|
||||
const [value, setValue] = useState('' as string | number);
|
||||
const [hasFocus, setHasFocus] = useState(false);
|
||||
|
||||
// Styles
|
||||
const fontSize =
|
||||
size !== 'medium' && size === 'large' ? 'text-lg' : 'text-sm';
|
||||
|
||||
// Functions
|
||||
const handleOnChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const element = event?.target as HTMLInputElement;
|
||||
if (element.value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Emails don't support selectionStart
|
||||
if (type !== 'email') {
|
||||
const caret = element.selectionStart;
|
||||
window.requestAnimationFrame(() => {
|
||||
element.selectionStart = caret;
|
||||
element.selectionEnd = caret;
|
||||
});
|
||||
}
|
||||
|
||||
if (props.onValueChange) {
|
||||
props.onValueChange(element.value);
|
||||
}
|
||||
|
||||
setValue(element.value);
|
||||
};
|
||||
|
||||
const handleOnBlur = (event: FocusEvent<HTMLInputElement>) => {
|
||||
setHasFocus(false);
|
||||
|
||||
if (props.onBlur) {
|
||||
props.onBlur(event);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnFocus = (event: FocusEvent<HTMLInputElement>) => {
|
||||
if (!readonly) {
|
||||
setHasFocus(true);
|
||||
}
|
||||
|
||||
if (props.onFocus) {
|
||||
props.onFocus(event);
|
||||
}
|
||||
};
|
||||
|
||||
// // Effects
|
||||
// useEffect(() => {
|
||||
// if (autofocus) {
|
||||
// setInputFocus();
|
||||
// }
|
||||
// }, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style ?? {}}
|
||||
className={classNames(
|
||||
'flex',
|
||||
'items-center',
|
||||
'appearance-none',
|
||||
'rounded-lg',
|
||||
'w-full',
|
||||
// 'bg-white',
|
||||
'text-gray-700',
|
||||
'leading-tight',
|
||||
'outline-black',
|
||||
'outline-secondary',
|
||||
'focus:outline-black',
|
||||
border !== 'none' && 'border-2',
|
||||
size === 'small' ? 'px-2' : 'px-4',
|
||||
duration && 'duration-300',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
border === 'primary' && 'border-primary',
|
||||
border === 'secondary' && hasFocus
|
||||
? `border-primary`
|
||||
: 'border-secondary',
|
||||
className,
|
||||
)}
|
||||
onClick={setInputFocus}
|
||||
>
|
||||
{prefix && (
|
||||
<span
|
||||
className={classNames(`text-black`, 'flex', 'items-center', 'pr-4')}
|
||||
>
|
||||
{prefix}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<input
|
||||
className={classNames(
|
||||
'bg-transparent',
|
||||
'outline-none',
|
||||
'flex-1',
|
||||
'w-0',
|
||||
size === 'large' && 'py-2',
|
||||
disabled && 'cursor-not-allowed',
|
||||
center && 'text-center',
|
||||
fontSize,
|
||||
inputClassName,
|
||||
)}
|
||||
readOnly={readonly}
|
||||
type={type}
|
||||
ref={inputRef}
|
||||
spellCheck={false}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
value={props.value ?? value}
|
||||
step={step}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={handleOnChange}
|
||||
onFocus={handleOnFocus}
|
||||
onBlur={handleOnBlur}
|
||||
inputMode={inputMode}
|
||||
onKeyDown={onKeyDown}
|
||||
onMouseUp={onMouseUp}
|
||||
></input>
|
||||
|
||||
{type === 'number' && <div className="h-full bg-green-200"></div>}
|
||||
|
||||
{suffix && (
|
||||
<span
|
||||
className={classNames(`text-primary`, 'flex', 'items-center', 'pl-4')}
|
||||
>
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
children: JSX.Element[];
|
||||
className?: string;
|
||||
hasOpenDropdown?: boolean;
|
||||
}
|
||||
|
||||
export function InputGroup(props: Props) {
|
||||
// Attach props to children following this guide
|
||||
// https://stackoverflow.com/a/35102287
|
||||
const { children, className, hasOpenDropdown = false } = props;
|
||||
const numChildren = children.length;
|
||||
const hasChildren = Boolean(numChildren);
|
||||
|
||||
const firstChild = children[0];
|
||||
const lastChild = children[numChildren - 1];
|
||||
|
||||
return (
|
||||
<div className={classNames('flex', 'w-full', className)}>
|
||||
{hasChildren &&
|
||||
React.cloneElement(firstChild, {
|
||||
className: classNames(
|
||||
'rounded-r-none',
|
||||
'border-r-0',
|
||||
'duration-300',
|
||||
hasOpenDropdown && 'rounded-b-none',
|
||||
),
|
||||
})}
|
||||
{numChildren > 2 &&
|
||||
children.slice(1, numChildren - 1).map(child =>
|
||||
React.cloneElement(child, {
|
||||
className: classNames(
|
||||
'rounded-r-none',
|
||||
'border-l-none',
|
||||
'duration-300',
|
||||
hasOpenDropdown && 'rounded-b-none',
|
||||
),
|
||||
}),
|
||||
)}
|
||||
{hasChildren &&
|
||||
React.cloneElement(lastChild, {
|
||||
className: classNames(
|
||||
'rounded-l-none',
|
||||
'duration-300',
|
||||
hasOpenDropdown && 'rounded-b-none',
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useClickAway } from 'react-use';
|
||||
// import ExitSVG from '../assets/svgs/exit-primary.svg';
|
||||
import { UI } from '../constants';
|
||||
import { collapseSearchOverlay } from '../state/navigation';
|
||||
import { IState } from '../state/reducers';
|
||||
|
||||
interface Props {
|
||||
modalId: string;
|
||||
isOpen: boolean;
|
||||
children: ReactNode;
|
||||
isMobileFullscreen?: boolean;
|
||||
className?: string;
|
||||
close?: () => void;
|
||||
}
|
||||
|
||||
export function Modal(props: Props) {
|
||||
const { modalId, isOpen, close, className, children } = props;
|
||||
const { searchOverlayExpanded, openedModal } = useSelector(
|
||||
(state: IState) => state.navigation,
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [shouldRender, setShouldRender] = useState(false);
|
||||
|
||||
const ref = useRef(null);
|
||||
useClickAway(ref, close);
|
||||
|
||||
useEffect(() => {
|
||||
// If modal is open, close search overlay
|
||||
if (isOpen && searchOverlayExpanded) {
|
||||
dispatch(collapseSearchOverlay());
|
||||
}
|
||||
|
||||
// Refuse to open if another modal is currently open
|
||||
if (modalId !== openedModal) {
|
||||
console.log(
|
||||
`Cannot open modal ${modalId}, ${openedModal} is already open.`,
|
||||
);
|
||||
|
||||
setShouldRender(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!isOpen || !shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
zIndex: UI.Z_INDEX_MODAL_OVERLAY,
|
||||
paddingLeft: `${UI.PAGE_CONTAINED_PADDING_VW}vw`,
|
||||
paddingRight: `${UI.PAGE_CONTAINED_PADDING_VW}vw`,
|
||||
}}
|
||||
className="fixed inset-0 flex justify-center items-center bg-black bg-opacity-25"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
minWidth: '200px',
|
||||
maxWidth: '100%',
|
||||
minHeight: '150px',
|
||||
maxHeight: '80vh',
|
||||
}}
|
||||
className={classNames(
|
||||
'relative border-2 border-gray px-6 pb-4 pt-12 bg-white',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 flex justify-end pt-3 pr-3">
|
||||
{/* <ExitSVG onClick={close} className="h-8 cursor-pointer" /> */}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
import classNames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
size?: 'tiny' | 'small' | 'medium' | 'large';
|
||||
theme?: 'default' | 'alt';
|
||||
bold?: boolean;
|
||||
children: string;
|
||||
className?: string;
|
||||
onClick?(): void;
|
||||
}
|
||||
|
||||
export function OutlineBlock(props: Props) {
|
||||
const {
|
||||
children,
|
||||
size = 'medium',
|
||||
theme = 'default',
|
||||
bold,
|
||||
className,
|
||||
onClick,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
theme === 'alt' && 'border-2 border-white rounded-md',
|
||||
theme === 'default' && 'border-2 border-secondary rounded-lg',
|
||||
size === 'tiny' && 'px-2 text-xs',
|
||||
size === 'small' && 'py-1 px-3 text-sm',
|
||||
size === 'medium' && 'py-2 px-3',
|
||||
size === 'large' && 'py-3 px-3 text-lg',
|
||||
onClick && 'cursor-pointer',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={classNames('text-primary', bold && 'font-medium', className)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
interface Props {
|
||||
children: string;
|
||||
}
|
||||
|
||||
export function SectionTitle(props: Props) {
|
||||
const { children } = props;
|
||||
|
||||
return (
|
||||
<div className="flex justify-center font-roboto text-xl text-primary mb-3">
|
||||
{children}
|
||||
<div className="absolute w-10 h-1 mt-8 rounded-full bg-secondary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
import classNames from 'classnames';
|
||||
interface Props {
|
||||
level: 1 | 2 | 3 | 4;
|
||||
small?: boolean;
|
||||
bold?: boolean;
|
||||
soft?: boolean;
|
||||
disabled?: boolean;
|
||||
margin?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Title(props: Props) {
|
||||
const {
|
||||
level,
|
||||
children,
|
||||
className = '',
|
||||
bold = false,
|
||||
small = false,
|
||||
soft = false,
|
||||
disabled = false,
|
||||
margin = true,
|
||||
} = props;
|
||||
|
||||
const opacity = disabled ? 'opacity-25' : soft ? 'opacity-75' : undefined;
|
||||
|
||||
const commonClassNames = classNames(
|
||||
disabled && 'select-none',
|
||||
margin && 'mb-2',
|
||||
opacity,
|
||||
className,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{level === 1 && (
|
||||
<h1
|
||||
className={classNames(
|
||||
'font-raleway',
|
||||
small ? 'text-xl' : 'text-twoxl',
|
||||
bold ? 'font-bold' : 'font-semibold',
|
||||
commonClassNames,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
)}
|
||||
{level === 2 && (
|
||||
<h2
|
||||
className={classNames(
|
||||
'font-raleway',
|
||||
small ? 'text-lg' : 'text-xl',
|
||||
bold ? 'font-bold' : 'font-light',
|
||||
commonClassNames,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
)}
|
||||
{level === 3 && (
|
||||
<h3
|
||||
className={classNames(
|
||||
'font-raleway',
|
||||
small ? 'text-base' : 'text-lg',
|
||||
bold ? 'font-bold' : 'font-light',
|
||||
commonClassNames,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
)}
|
||||
{level === 4 && (
|
||||
<h4
|
||||
className={classNames(
|
||||
'font-raleway',
|
||||
small ? 'text-sm' : 'text-base',
|
||||
bold ? 'font-bold' : 'font-light',
|
||||
commonClassNames,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import React, { useContext } from 'react';
|
||||
import { ScreenContext } from '../../contexts/screen';
|
||||
import { IArticle } from '../../types/article';
|
||||
import { ArticleSectionAbstract } from './sections/ArticleSectionAbstract';
|
||||
import { ArticleSectionContent } from './sections/ArticleSectionContent';
|
||||
import { ArticleSectionTitle } from './sections/ArticleSectionTitle';
|
||||
import { ArticleSubtitleSection } from './sections/ArticleSubtitleSection';
|
||||
|
||||
export function Article(props: IArticle) {
|
||||
const { isMobile } = useContext(ScreenContext);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isMobile ? <ArticleMobile {...props} /> : <ArticleDesktop {...props} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArticleMobile(props: IArticle) {
|
||||
const { id, title, slug, subtitle, author, date, city, location } = props;
|
||||
|
||||
return (
|
||||
<article>
|
||||
<ArticleSectionTitle title={title} author={author} date={date} />
|
||||
<ArticleSubtitleSection subtitle={subtitle} />
|
||||
|
||||
<ArticleSectionAbstract
|
||||
city={city}
|
||||
location={location}
|
||||
></ArticleSectionAbstract>
|
||||
<ArticleSectionContent {...props} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function ArticleDesktop(props: IArticle) {
|
||||
const { id, title, subtitle, author, date, slug, city, location } = props;
|
||||
|
||||
return (
|
||||
<article>
|
||||
<ArticleSectionTitle title={title} author={author} date={date} />
|
||||
<ArticleSectionAbstract city={city} location={location}>
|
||||
<ArticleSubtitleSection subtitle={subtitle} />
|
||||
</ArticleSectionAbstract>
|
||||
<ArticleSectionContent {...props} />
|
||||
</article>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import { ILocation } from '../../../types/article';
|
||||
import { Contained } from '../../Contained';
|
||||
|
||||
interface Props {
|
||||
city: string;
|
||||
location: ILocation;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function ArticleSectionAbstract(props: Props) {
|
||||
const { city, location, children } = props;
|
||||
|
||||
return (
|
||||
<Contained backgroundColor="secondary-1">
|
||||
<div className="flex flex-col items-center desktop:pt-6 mb-16 space-y-10">
|
||||
{children}
|
||||
</div>
|
||||
</Contained>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
import BlockContent from '@sanity/block-content-to-react';
|
||||
import React, { useContext } from 'react';
|
||||
import { SANITY_CONSTATNTS } from '../../../client';
|
||||
import { ScreenContext } from '../../../contexts/screen';
|
||||
import { IArticle } from '../../../types/article';
|
||||
import { Contained } from '../../Contained';
|
||||
import { ArticleSectionFeatureImage } from './ArticleSectionFeatureImage';
|
||||
|
||||
const paragraphs = [
|
||||
`Lorem ipsum, dolor sit amet consectetur adipisicing elit. Hic, labore consequatur. Architecto, aperiam impedit. Corrupti inventore eius, exercitationem odit est fugit, iste aspernatur incidunt quod iure aliquid fugiat. Earum, voluptate. Lorem ipsum, dolor sit amet consectetur adipisicing elit. Hic, labore consequatur. Architecto, aperiam impedit. Corrupti inventore eius, exercitationem odit est fugit, iste aspernatur incidunt quod iure aliquid fugiat. Earum, voluptate. Lorem ipsum, dolor sit amet consectetur adipisicing elit. Hic, labore consequatur. Architecto, aperiam impedit. Corrupti inventore eius, exercitationem odit est fugit, iste aspernatur incidunt quod iure aliquid fugiat. Earum, voluptate. Lorem ipsum, dolor sit amet consectetur adipisicing elit. Hic, labore consequatur. Architecto, aperiam impedit.', 'Corrupti inventore eius, exercitationem
|
||||
odit est fugit, iste aspernatur incidunt quod iure aliquid fugiat.
|
||||
Earum, voluptate. Lorem ipsum, dolor sit amet consectetur adipisicing
|
||||
elit. Hic, labore consequatur. Architecto, aperiam impedit. Corrupti
|
||||
inventore eius, exercitationem odit est fugit, iste aspernatur
|
||||
incidunt quod iure aliquid fugiat. Earum, voluptate. Lorem ipsum,
|
||||
dolor sit amet consectetur adipisicing elit. Hic, labore consequatur.
|
||||
Architecto, aperiam impedit. Corrupti inventore eius, exercitationem
|
||||
odit est fugit, iste aspernatur incidunt quod iure aliquid fugiat.
|
||||
Earum, voluptate. Lorem ipsum, dolor sit amet consectetur adipisicing
|
||||
elit. Hic, labore consequatur. Architecto, aperiam impedit. Corrupti
|
||||
inventore eius, exercitationem odit est fugit, iste aspernatur
|
||||
incidunt quod iure aliquid fugiat. Earum, voluptate.`,
|
||||
`Lorem ipsum, dolor sit amet consectetur adipisicing elit. Hic, labore
|
||||
consequatur. Architecto, aperiam impedit. Corrupti inventore eius,
|
||||
exercitationem odit est fugit, iste aspernatur incidunt quod iure
|
||||
aliquid fugiat. Earum, voluptate. Lorem ipsum, dolor sit amet
|
||||
consectetur adipisicing elit. Hic, labore consequatur. Architecto,
|
||||
aperiam impedit. Corrupti inventore eius, exercitationem odit est
|
||||
fugit, iste aspernatur incidunt quod iure aliquid fugiat. Earum,
|
||||
voluptate. Lorem ipsum, dolor sit amet consectetur adipisicing elit.
|
||||
Hic, labore consequatur. Architecto, aperiam impedit. Corrupti
|
||||
inventore eius, exercitationem odit est fugit, iste aspernatur
|
||||
incidunt quod iure aliquid fugiat.`,
|
||||
`Earum, voluptate. Lorem ipsum,
|
||||
dolor sit amet consectetur adipisicing elit. Hic, labore consequatur.
|
||||
Architecto, aperiam impedit. Corrupti inventore eius, exercitationem
|
||||
odit est fugit, iste aspernatur incidunt quod iure aliquid fugiat.
|
||||
Earum, voluptate. Lorem ipsum, dolor sit amet consectetur adipisicing
|
||||
elit. Hic, labore consequatur. Architecto, aperiam impedit. Corrupti
|
||||
inventore eius, exercitationem odit est fugit, iste aspernatur
|
||||
incidunt quod iure aliquid fugiat. Earum, voluptate. Lorem ipsum,
|
||||
dolor sit amet consectetur adipisicing elit. Hic, labore consequatur.
|
||||
Architecto, aperiam impedit. Corrupti inventore eius, exercitationem
|
||||
odit est fugit, iste aspernatur incidunt quod iure aliquid fugiat.`,
|
||||
];
|
||||
|
||||
export function ArticleSectionContent(post: IArticle) {
|
||||
const { isDesktop } = useContext(ScreenContext);
|
||||
|
||||
return (
|
||||
<Contained>
|
||||
{isDesktop ? <DesktopContent {...post} /> : <MobileContent {...post} />}
|
||||
</Contained>
|
||||
);
|
||||
}
|
||||
|
||||
const MobileContent = (post: IArticle) => (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div>{paragraphs[0]}</div>
|
||||
|
||||
<BlockContent
|
||||
blocks={post.body}
|
||||
projectId={SANITY_CONSTATNTS.PROJECT_ID}
|
||||
dataset={SANITY_CONSTATNTS.DATASET}
|
||||
/>
|
||||
|
||||
<ArticleSectionFeatureImage featureImage={post.featureImage} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const DesktopContent = (post: IArticle) => (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex space-x-10">
|
||||
<div className="w-8/12 mt-16">
|
||||
<BlockContent
|
||||
blocks={post.body}
|
||||
projectId={SANITY_CONSTATNTS.PROJECT_ID}
|
||||
dataset={SANITY_CONSTATNTS.DATASET}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ArticleSectionFeatureImage featureImage={post.featureImage} />
|
||||
</div>
|
||||
);
|
|
@ -0,0 +1,27 @@
|
|||
import { IFigureImage } from '../../../types/article';
|
||||
|
||||
interface Props {
|
||||
featureImage: IFigureImage;
|
||||
}
|
||||
|
||||
export function ArticleSectionFeatureImage({ featureImage }: Props) {
|
||||
return (
|
||||
<div className="my-10 pb-4 desktop:pb-0">
|
||||
<div
|
||||
style={{ paddingBottom: '40%' }}
|
||||
className="relative w-full h-0 mb-4 bg-gray-300 rounded-md overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-0">
|
||||
<img
|
||||
src={featureImage.source}
|
||||
alt={featureImage.altText}
|
||||
style={{ objectFit: 'cover' }}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-8/12 italic text-sm">{featureImage.description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import React from 'react';
|
||||
import { UI } from '../../../constants';
|
||||
import { IAuthor } from '../../../types/article';
|
||||
import { Contained } from '../../Contained';
|
||||
import { ArticleWidgetAuthor } from '../widgets/ArticleWidgetAuthor';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
author: IAuthor;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export function ArticleSectionTitle(props: Props) {
|
||||
const { title, author, date } = props;
|
||||
|
||||
return (
|
||||
<Contained>
|
||||
<div className="flex flex-col items-center space-y-4 mt-16 mb-4">
|
||||
<div
|
||||
className="flex items-center"
|
||||
style={{
|
||||
minHeight: '7rem',
|
||||
maxWidth: `${UI.ARTICLE.TITLE_MAX_WIDTH_REM}rem`,
|
||||
}}
|
||||
>
|
||||
<h1 className="font-roboto text-primary text-fourxl desktop:text-fivexl tablet:text-fixexl leading-none text-center">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<ArticleWidgetAuthor author={author} date={date} />
|
||||
</div>
|
||||
</Contained>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import { useContext } from 'react';
|
||||
import { ScreenContext } from '../../../contexts/screen';
|
||||
import { Contained } from '../../Contained';
|
||||
|
||||
interface Props {
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export function ArticleSubtitleSection({ subtitle }: Props) {
|
||||
const { isMobile, isDesktop } = useContext(ScreenContext);
|
||||
|
||||
return (
|
||||
<Contained>
|
||||
<div className="flex justify-center w-full">
|
||||
<span
|
||||
style={{
|
||||
maxWidth: isDesktop ? '700px' : 'unset',
|
||||
}}
|
||||
className="text-lg w-full desktop:text-xl text-center font-medium font-roboto text-gray-900"
|
||||
>
|
||||
{subtitle}
|
||||
</span>
|
||||
</div>
|
||||
</Contained>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import React from 'react';
|
||||
import YouTube from 'react-youtube';
|
||||
|
||||
interface Props {
|
||||
video: string;
|
||||
}
|
||||
|
||||
export function ArticleFeatureVideoWidget({ video }: Props) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div
|
||||
style={{
|
||||
// Padding bottom of 56.25% corresponds to 16/9 aspect ratio
|
||||
paddingBottom: '56.25%',
|
||||
}}
|
||||
className="relative h-0 w-full rounded-lg overflow-hidden"
|
||||
>
|
||||
<YouTube
|
||||
videoId={video}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
// Tracking -> user started video
|
||||
// onPlay={() => }
|
||||
/>
|
||||
|
||||
{/* <div className="absolute inset-0 flex justify-center items-center">
|
||||
<div className="flex justify-center items-center rounded-full bg-primary h-20 w-20">
|
||||
<div
|
||||
style={{
|
||||
clipPath: 'polygon(100% 50%, 0 0, 0 100%)',
|
||||
}}
|
||||
className="bg-white ml-2 w-8 h-10"
|
||||
></div>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
<div className="w-10/12 desktop:w-64 text-sm desktop:text-base italic mt-4 pl-2">
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Soluta labore
|
||||
eius iure aliquid asperiores cum, quibusdam sapiente!
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import React, { useState } from 'react';
|
||||
// import ShareSVG from '../../../assets/svgs/share.svg';
|
||||
import {
|
||||
shareToFacebook,
|
||||
shareToReddit,
|
||||
shareToTwitter,
|
||||
shareToWhatsApp,
|
||||
} from '../../../utils/share';
|
||||
import { Button } from '../../Button';
|
||||
import { Dropdown } from '../../Dropdown';
|
||||
import { DropdownItem } from '../../DropdownItem';
|
||||
import { Input } from '../../Input';
|
||||
import { InputGroup } from '../../InputGroup';
|
||||
|
||||
interface IShareDropdownItems {
|
||||
id: string;
|
||||
name: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export function ArticleShareWidget(props: Props) {
|
||||
const { id, title, slug } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const articleUrl = `oxen.io${router.asPath}`;
|
||||
|
||||
const dropdownItems: Array<IShareDropdownItems> = [
|
||||
{
|
||||
id: 'share-to-facebook',
|
||||
name: 'Facebook',
|
||||
onClick: () => shareToFacebook(title, slug),
|
||||
},
|
||||
{
|
||||
id: 'share-to-twitter',
|
||||
name: 'Twitter',
|
||||
onClick: () => shareToTwitter(title, slug),
|
||||
},
|
||||
{
|
||||
id: 'share-to-whatsapp',
|
||||
name: 'WhatsApp',
|
||||
onClick: () => shareToWhatsApp(title, slug),
|
||||
},
|
||||
{
|
||||
id: 'share-to-reddit',
|
||||
name: 'Reddit',
|
||||
onClick: () => shareToReddit(title, slug),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex justify-center w-full z-10">
|
||||
<div
|
||||
style={{ width: 'fit-content' }}
|
||||
className="flex bg-soft cursor-pointer rounded-md text-primary my-4"
|
||||
>
|
||||
<div
|
||||
className="flex flex-1 items-center cursor-pointer px-2 py-1 space-x-1 hover:bg-subtle-2 font-medium rounded-r-md"
|
||||
onClick={() => setIsDropdownOpen(true)}
|
||||
>
|
||||
{/* <ShareSVG className="h-8" /> */}
|
||||
<span>Share</span>
|
||||
</div>
|
||||
|
||||
<Dropdown
|
||||
isOpen={isDropdownOpen}
|
||||
onClickAway={() => setIsDropdownOpen(false)}
|
||||
pull="center"
|
||||
offsetX={-50}
|
||||
offsetY={25}
|
||||
>
|
||||
<>
|
||||
<div className="px-3 pt-1 pb-2">
|
||||
<InputGroup className="w-full rounded-sm bg-secondary bg-opacity-25">
|
||||
<Input
|
||||
style={{ minWidth: '9rem' }}
|
||||
border="none"
|
||||
readonly
|
||||
value={articleUrl}
|
||||
/>
|
||||
|
||||
<Button type="text" size="small" color="primary">
|
||||
COPY
|
||||
</Button>
|
||||
</InputGroup>
|
||||
</div>
|
||||
|
||||
{dropdownItems.map(item => (
|
||||
<DropdownItem key={item.id} id={item.id} onSelect={item.onClick}>
|
||||
<div className="w-full text-center">{item.name}</div>
|
||||
</DropdownItem>
|
||||
))}
|
||||
</>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
import { IAuthor } from '../../../types/article';
|
||||
// import { Avatar } from '../../Avatar';
|
||||
|
||||
interface Props {
|
||||
author: IAuthor;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export function ArticleWidgetAuthor({ author, date }: Props) {
|
||||
return (
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* <Avatar size={10} imageSrc={author?.imageSrc} /> */}
|
||||
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-roboto tracking-wider text-sm font-bold">
|
||||
By: {author.name}
|
||||
</span>
|
||||
<span>{date}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
import classNames from 'classnames';
|
||||
import router from 'next/dist/client/router';
|
||||
import { SyntheticEvent } from 'react';
|
||||
import { useMeasure } from 'react-use';
|
||||
import { ISanityArticle } from '../../types/article';
|
||||
import { generateURL } from '../../utils/routing';
|
||||
import { titleCase } from '../../utils/text';
|
||||
import { OutlineBlock } from '../OutlineBlock';
|
||||
|
||||
export function ArticleCard(props: ISanityArticle): JSX.Element {
|
||||
const {
|
||||
featureImage,
|
||||
title,
|
||||
paragraph,
|
||||
/*tags*/ slug,
|
||||
city,
|
||||
category,
|
||||
} = props;
|
||||
|
||||
const [ref, { width }] = useMeasure();
|
||||
const isSmall = width < 130;
|
||||
|
||||
const handleClick = (e: SyntheticEvent) => {
|
||||
const { href, as } = generateURL(slug);
|
||||
|
||||
e.preventDefault();
|
||||
router.push(href, as);
|
||||
};
|
||||
|
||||
const tags = ['crepes', 'sweet'];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
'overflow-hidden w-full bg-secondary bg-opacity-75',
|
||||
isSmall ? 'rounded-lg' : 'rounded-xl',
|
||||
isSmall ? 'pb-3' : 'pb-1',
|
||||
)}
|
||||
onClick={e => handleClick(e)}
|
||||
>
|
||||
<div
|
||||
style={{ paddingBottom: '60%' }}
|
||||
className="relative w-full h-0 overflow-hidden bg-white bg-opacity-25"
|
||||
>
|
||||
{featureImage.source && (
|
||||
<div className="absolute inset-0">
|
||||
<img
|
||||
className="object-cover w-full h-full"
|
||||
src={featureImage?.source}
|
||||
alt={featureImage?.altText}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={isSmall ? 'px-3' : 'px-4'}>
|
||||
<div className={isSmall ? 'py-1' : 'py-3'}>
|
||||
<div
|
||||
style={{
|
||||
lineHeight: '1em',
|
||||
height: '2em',
|
||||
paddingBottom: '2.1em',
|
||||
}}
|
||||
className={classNames(
|
||||
isSmall ? 'text-base' : 'text-xl',
|
||||
'font-roboto overflow-hidden cursor-pointer',
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<p className="text-gray-700 text-base">{paragraph}</p>
|
||||
</div>
|
||||
|
||||
<div className={classNames('flex space-x-1 mt-1', !isSmall && 'mb-2')}>
|
||||
{tags
|
||||
.filter(tag => Boolean(tag))
|
||||
// Maximum of three tags
|
||||
.slice(0, 3)
|
||||
.map(tag => (
|
||||
<div key={tag.toLowerCase()}>
|
||||
{isSmall ? (
|
||||
<span className="text-xs font-medium text-primary hover:underline">
|
||||
{titleCase(tag)}
|
||||
</span>
|
||||
) : (
|
||||
<OutlineBlock size="tiny" theme="alt" bold key={tag}>
|
||||
{titleCase(tag)}
|
||||
</OutlineBlock>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
import classNames from 'classnames';
|
||||
import router from 'next/dist/client/router';
|
||||
import Link from 'next/link';
|
||||
import React, { SyntheticEvent, useContext } from 'react';
|
||||
import { useMeasure } from 'react-use';
|
||||
// import ShareSVG from '../../assets/svgs/share.svg';
|
||||
import { ScreenContext } from '../../contexts/screen';
|
||||
import { ISanityArticle } from '../../types/article';
|
||||
import { generateURL } from '../../utils/routing';
|
||||
|
||||
interface Props extends Partial<ISanityArticle> {
|
||||
isFavourite: boolean;
|
||||
}
|
||||
|
||||
export function ArticleCardFavourite(props: Props): JSX.Element {
|
||||
const { id, featureImage, title, city, slug, category, isFavourite } = props;
|
||||
const { isMobile, isTablet, isDesktop, isHuge } = useContext(ScreenContext);
|
||||
|
||||
const [ref, { width }] = useMeasure();
|
||||
const isSmall = width < 130;
|
||||
|
||||
const { href, as } = generateURL(slug);
|
||||
const handleClick = (e: SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
router.push(href, as);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
'overflow-hidden w-full bg-opacity-75',
|
||||
isSmall ? 'pb-3' : 'pb-1',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
onClick={e => handleClick(e)}
|
||||
style={{ paddingBottom: '80%' }}
|
||||
className={classNames(
|
||||
'relative w-full h-0 overflow-hidden bg-primary bg-opacity-10',
|
||||
isSmall ? 'rounded-lg' : 'rounded-xl',
|
||||
)}
|
||||
>
|
||||
{featureImage.source && (
|
||||
<div className="absolute inset-0 ">
|
||||
<img
|
||||
className="object-cover w-full h-full"
|
||||
src={featureImage?.source}
|
||||
alt={featureImage?.altText}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={isSmall ? 'py-1' : 'py-3'}>
|
||||
<div
|
||||
style={{
|
||||
lineHeight: '1em',
|
||||
height: '2em',
|
||||
paddingBottom: '2.1em',
|
||||
}}
|
||||
className={classNames(
|
||||
isMobile ? 'text-base' : isSmall ? 'text-lg' : 'text-twoxl',
|
||||
'font-roboto text-primary overflow-hidden cursor-pointer',
|
||||
)}
|
||||
>
|
||||
<Link href={href} as={as}>
|
||||
<a>{title}</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-primary mt-2 pr-2">
|
||||
<div className="flex items-center cursor-pointer">
|
||||
{/* <ShareSVG className={isMobile ? 'h-8' : 'h-8'} /> */}
|
||||
{!isMobile && 'Share'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
import Link from 'next/link';
|
||||
import React, { useContext } from 'react';
|
||||
import { ScreenContext } from '../../contexts/screen';
|
||||
import { IArticle } from '../../types/article';
|
||||
import { generateURL } from '../../utils/routing';
|
||||
|
||||
export function ArticleCardRow(post: IArticle) {
|
||||
const { isMobile } = useContext(ScreenContext);
|
||||
const { city, slug, category } = post;
|
||||
const { href, as } = generateURL(slug);
|
||||
|
||||
const ArticlePreviewContent = () => (
|
||||
<p
|
||||
style={{
|
||||
lineHeight: '1.33em',
|
||||
height: '4em',
|
||||
}}
|
||||
className="text-base overflow-hidden"
|
||||
>
|
||||
{post.subtitle}
|
||||
</p>
|
||||
);
|
||||
|
||||
const ArticlePreviewImage = () => (
|
||||
<div
|
||||
style={{
|
||||
width: isMobile ? '33%' : '10rem',
|
||||
height: isMobile ? '66%' : '6rem',
|
||||
}}
|
||||
className="relative rounded-lg bg-primary bg-opacity-10 overflow-hidden"
|
||||
>
|
||||
{post?.featureImage?.source && (
|
||||
<img
|
||||
src={post.featureImage.source}
|
||||
alt={post.featureImage.altText}
|
||||
className="w-full h-full rounded-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMobile ? (
|
||||
<div className="flex flex-col w-full space-y-4 mb-6">
|
||||
<div className="flex w-full space-x-6">
|
||||
<ArticlePreviewImage />
|
||||
<div className="w-2/3">
|
||||
<h3 className="font-roboto text-twoxl text-primary">
|
||||
{post.title}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArticlePreviewContent />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full space-x-6">
|
||||
<ArticlePreviewImage />
|
||||
<div
|
||||
style={{ width: 'min-content' }}
|
||||
className="flex flex-col flex-grow"
|
||||
>
|
||||
<Link href={href} as={as}>
|
||||
<a className="font-roboto text-xl text-primary">{post.title}</a>
|
||||
</Link>
|
||||
|
||||
<ArticlePreviewContent />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import classNames from 'classnames';
|
||||
import _ from 'lodash';
|
||||
import React, { useContext } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { ScreenContext } from '../../contexts/screen';
|
||||
|
||||
interface Props {
|
||||
children: JSX.Element[];
|
||||
}
|
||||
|
||||
export function CardGrid({ children }: Props) {
|
||||
const { isMobile, isTablet, isDesktop, isHuge } = useContext(ScreenContext);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
{_.chunk(children, isHuge ? 4 : isDesktop || isTablet ? 3 : 2).map(
|
||||
group => (
|
||||
<div key={uuid()} className="flex">
|
||||
{group.map((item, index) => (
|
||||
<div
|
||||
key={uuid()}
|
||||
className={classNames(
|
||||
index === 0 && 'pr-2',
|
||||
index === 1 && 'pl-2 pr-2',
|
||||
index === 2 && 'pl-2',
|
||||
isHuge ? 'w-1/4' : isDesktop || isTablet ? 'w-1/3' : 'w-1/2',
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
import React from 'react';
|
||||
import { SectionTitle } from '../SectionTitle';
|
||||
|
||||
export function ColorPalette() {
|
||||
return (
|
||||
<>
|
||||
<div className="pt-10 pb-4">
|
||||
<SectionTitle>Color Palette</SectionTitle>
|
||||
</div>
|
||||
<div className="flex pb-10 space-x-4">
|
||||
<div className="flex flex-col space-y-3 font-roboto">
|
||||
<div className="flex justify-center items-center w-8 h-8 text-threexl">
|
||||
0
|
||||
</div>
|
||||
<div className="flex justify-center items-center w-8 h-8 text-threexl">
|
||||
1
|
||||
</div>
|
||||
<div className="flex justify-center items-center w-8 h-8 text-threexl">
|
||||
2
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary"></div>
|
||||
<div className="w-8 h-8 rounded-lg bg-primary-1"></div>
|
||||
<div className="w-8 h-8 rounded-lg bg-primary-2"></div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-secondary"></div>
|
||||
<div className="w-8 h-8 rounded-lg bg-secondary-1"></div>
|
||||
<div className="w-8 h-8 rounded-lg bg-secondary-2"></div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-alt"></div>
|
||||
<div className="w-8 h-8 rounded-lg bg-alt-1"></div>
|
||||
<div className="w-8 h-8 rounded-lg bg-alt-2"></div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-subtle"></div>
|
||||
<div className="w-8 h-8 rounded-lg bg-subtle-1"></div>
|
||||
<div className="w-8 h-8 rounded-lg bg-subtle-2"></div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-3 justify-end">
|
||||
<div className="w-8 h-8 rounded-lg bg-soft"></div>
|
||||
<div className="w-8 h-8 rounded-lg bg-soft-1"></div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col w-8"></div>
|
||||
|
||||
<div className="flex flex-col space-y-3 justify-end">
|
||||
<div className="w-8 h-8 rounded-lg bg-aux-orange"></div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-3 justify-end">
|
||||
<div className="w-8 h-8 rounded-lg bg-aux-brown"></div>
|
||||
<div className="w-8 h-8 rounded-lg bg-aux-green"></div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-3 justify-end">
|
||||
<div className="w-8 h-8 rounded-lg bg-aux-beige"></div>
|
||||
<div className="w-8 h-8 rounded-lg bg-aux-blue"></div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
import Link from 'next/link';
|
||||
import React, { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
// import SearchPrimarySVG from '../../assets/svgs/search-primary.svg';
|
||||
import { UI } from '../../constants';
|
||||
import { ScreenContext } from '../../contexts/screen';
|
||||
import { expandSearchOverlay } from '../../state/navigation';
|
||||
import { IState } from '../../state/reducers';
|
||||
import { Contained } from '../Contained';
|
||||
import { HeaderSearch } from './HeaderSearch';
|
||||
|
||||
export function Header() {
|
||||
const { isMobile } = useContext(ScreenContext);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<div>{isMobile ? <MobileHeader /> : <DesktopHeader />}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileHeader() {
|
||||
const navigationState = useSelector((state: IState) => state.navigation);
|
||||
const searchState = useSelector((state: IState) => state.search);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleExpandSearch = (e: React.MouseEvent) => {
|
||||
// Timeout to prevent action immediately firing on the elemnt under with onMouseUp
|
||||
setTimeout(() => dispatch(expandSearchOverlay()), 50);
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
zIndex: UI.Z_INDEX_HEADER,
|
||||
paddingLeft: '5vw',
|
||||
paddingRight: '5vw',
|
||||
}}
|
||||
className="fixed left-0 right-0 top-0 w-full h-24 bg-white"
|
||||
>
|
||||
<div className="w-full h-full flex items-center justify-between">
|
||||
<div className="flex flex-shrink-0" onMouseDown={handleExpandSearch}>
|
||||
{/* <SearchPrimarySVG className="h-10 cursor-pointer" /> */}
|
||||
</div>
|
||||
|
||||
<div className="antialiased">
|
||||
<Link href="/">
|
||||
<a className="oxen-logo-link flex items-center flex-shrink-0 text-secondary">
|
||||
{/* <OxenLogo className="fill-current h-8" /> */}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopHeader() {
|
||||
const { searchOverlayExpanded } = useSelector(
|
||||
(state: IState) => state.navigation,
|
||||
);
|
||||
const { searchBarPinnedToHeader } = useSelector(
|
||||
(state: IState) => state.search,
|
||||
);
|
||||
|
||||
// We only wnat the searchbar to be invisible on the home page
|
||||
// and given that they have not scrolled past the main home search
|
||||
const [searchIsShown, setSearchIsShown] = useState(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
(location.pathname === '/' && searchBarPinnedToHeader) ||
|
||||
location.pathname !== '/'
|
||||
) {
|
||||
if (!searchIsShown) {
|
||||
setSearchIsShown(true);
|
||||
}
|
||||
} else {
|
||||
if (searchIsShown) {
|
||||
setSearchIsShown(false);
|
||||
}
|
||||
}
|
||||
}, [location.pathname, searchBarPinnedToHeader]);
|
||||
|
||||
const navBarRef = useRef(null);
|
||||
console.log('Header ➡️ location.pathname:', location.pathname);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={navBarRef}
|
||||
style={{
|
||||
zIndex:
|
||||
searchOverlayExpanded && searchIsShown
|
||||
? UI.Z_INDEX_HEADER_SEARCH
|
||||
: UI.Z_INDEX_HEADER,
|
||||
}}
|
||||
className="fixed left-0 right-0 top-0 w-full h-20 bg-white flex items-center"
|
||||
>
|
||||
<Contained>
|
||||
<div className="w-full h-full flex items-center">
|
||||
<div className="antialiased flex w-full items-center justify-between">
|
||||
<div className="flex flex-grow">
|
||||
<Link href="/">
|
||||
<a className="oxen-logo-link flex items-center flex-shrink-0 text-secondary">
|
||||
{/* <OxenLogo className="fill-current h-8" /> */}
|
||||
</a>
|
||||
</Link>
|
||||
<HeaderSearch
|
||||
isShown={searchIsShown}
|
||||
innerOverlayStyle={{
|
||||
// When pinned to header, limit height to vh and lock body scroll
|
||||
maxHeight: searchIsShown ? '80vh' : 'unset',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Contained>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { CSSProperties, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { UI } from '../../constants';
|
||||
import { expandSearchOverlay } from '../../state/navigation';
|
||||
import { IState } from '../../state/reducers';
|
||||
import { SearchDropdown } from '../search/SearchDropdown';
|
||||
import { SearchInput } from '../search/SearchInput';
|
||||
|
||||
interface Props {
|
||||
isShown: boolean;
|
||||
innerOverlayStyle: CSSProperties;
|
||||
}
|
||||
|
||||
export function HeaderSearch({ isShown, innerOverlayStyle = {} }: Props) {
|
||||
const navigationState = useSelector((state: IState) => state.navigation);
|
||||
const searchState = useSelector((state: IState) => state.search);
|
||||
const { searchOverlayExpanded } = navigationState;
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const searchRef = useRef(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
zIndex: isShown
|
||||
? searchOverlayExpanded
|
||||
? UI.Z_INDEX_HEADER_SEARCH
|
||||
: 1
|
||||
: -1,
|
||||
maxWidth: '650px',
|
||||
}}
|
||||
className={classNames(
|
||||
'mx-8 flex-grow',
|
||||
isShown ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={searchRef}
|
||||
className={classNames(
|
||||
'flex items-center w-full justify-between h-10 pl-3 bg-white px-2',
|
||||
'border-primary border-t-2 border-l-2 border-r-2',
|
||||
searchOverlayExpanded ? 'rounded-t-lg' : 'rounded-lg',
|
||||
searchOverlayExpanded ? 'border-b-0' : 'border-b-2',
|
||||
)}
|
||||
>
|
||||
<SearchInput
|
||||
searchIcon="primary"
|
||||
placeholder="Search..."
|
||||
onFocus={() => dispatch(expandSearchOverlay())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-0">
|
||||
<SearchDropdown
|
||||
isShown={isShown}
|
||||
innerOverlayStyle={innerOverlayStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import { Footer } from '../Footer';
|
||||
import { Header } from '../header/Header';
|
||||
import { SearchOverlay } from '../search/SearchOverlay';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function Layout({ children }: Props) {
|
||||
return (
|
||||
<div style={{ height: '100vh' }} className="flex flex-col justify-between">
|
||||
<div className="relative flex-grow">
|
||||
<SearchOverlay />
|
||||
<Header />
|
||||
|
||||
<div className="flex-grow">{children}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
import React from 'react';
|
||||
// import OxenLogo from '../../assets/svgs/brand.svg';
|
||||
// import EmailLogoSVG from '../../assets/svgs/hot.svg';
|
||||
import { ModalInstance } from '../../state/navigation';
|
||||
import { Button } from '../Button';
|
||||
import { Modal } from '../Modal';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
close?: () => void;
|
||||
}
|
||||
|
||||
export function LoginModal(props: Props) {
|
||||
return (
|
||||
<Modal modalId={ModalInstance.LOGIN} {...props}>
|
||||
<div className="">
|
||||
<div className="flex flex-col items-center space-y-6 mb-32">
|
||||
{/* <OxenLogo className="fill-current h-6" /> */}
|
||||
|
||||
<h1 className="font-roboto text-threexl mb-2">Hello!</h1>
|
||||
|
||||
<Button
|
||||
type="outline"
|
||||
color="secondary"
|
||||
// prefix={<EmailLogoSVG className="h-6 w-8" />}
|
||||
suffix={<div className="w-6"></div>}
|
||||
onClick={() => alert('sdf')}
|
||||
>
|
||||
Continue with email
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
By proceeding, you agree to our{' '}
|
||||
<a href="#" className="underline font-semibold">
|
||||
Terms of Use
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { CSSProperties, useRef } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { UI } from '../../constants';
|
||||
import { IState } from '../../state/reducers';
|
||||
import { SearchOverlayInner } from './SearchOverlayInner';
|
||||
|
||||
interface Props {
|
||||
isShown: boolean;
|
||||
innerOverlayStyle?: CSSProperties;
|
||||
}
|
||||
|
||||
export function SearchDropdown({ isShown, innerOverlayStyle = {} }: Props) {
|
||||
const { searchOverlayExpanded } = useSelector(
|
||||
(state: IState) => state.navigation,
|
||||
);
|
||||
const { searchBarPinnedToHeader } = useSelector(
|
||||
(state: IState) => state.search,
|
||||
);
|
||||
|
||||
const overlayContentRef = useRef(null);
|
||||
|
||||
// Desktop overlay styles depend on wether or not the search bar is
|
||||
// in the navbar or not
|
||||
const desktopOverlayStyles = {
|
||||
zIndex: searchOverlayExpanded && isShown ? UI.Z_INDEX_SEARCH_DROPDOWN : -1,
|
||||
display: searchOverlayExpanded && isShown ? 'block' : 'none',
|
||||
minHeight: '600px',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={overlayContentRef} style={desktopOverlayStyles}>
|
||||
<div
|
||||
className={classNames(
|
||||
'relative',
|
||||
'flex',
|
||||
// Allows shadow to overflow
|
||||
'bg-white',
|
||||
'border-primary',
|
||||
'border-t-none',
|
||||
'border-l-2',
|
||||
'border-r-2',
|
||||
'border-b-2',
|
||||
'rounded-b-lg',
|
||||
'pb-4',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
style={innerOverlayStyle}
|
||||
className="relative overflow-y-auto w-full"
|
||||
>
|
||||
<SearchOverlayInner />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,184 @@
|
|||
import classNames from 'classnames';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { ChangeEvent, ReactNode, useEffect, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useKey, useStartTyping } from 'react-use';
|
||||
import {
|
||||
collapseSearchOverlay,
|
||||
expandSearchOverlay,
|
||||
} from '../../state/navigation';
|
||||
import { IState } from '../../state/reducers';
|
||||
import { setSearchQuery } from '../../state/search';
|
||||
|
||||
interface Props {
|
||||
placeholder?: string;
|
||||
autofocus?: boolean;
|
||||
className?: string;
|
||||
inputClassName?: string;
|
||||
|
||||
prefix?: ReactNode;
|
||||
searchIcon?: 'primary' | 'secondary';
|
||||
|
||||
// A dummy input is purely visual.
|
||||
dummy?: boolean;
|
||||
dummyOnClick?(): void;
|
||||
onFocus?(): void;
|
||||
onChange?(value: string): void;
|
||||
}
|
||||
|
||||
// Pure functionality. This component is not intended to be used as a standalone.
|
||||
// Its parent should provide styling
|
||||
export function SearchInput(props: Props) {
|
||||
const {
|
||||
autofocus = false,
|
||||
placeholder,
|
||||
className,
|
||||
inputClassName,
|
||||
searchIcon,
|
||||
prefix,
|
||||
dummy,
|
||||
dummyOnClick,
|
||||
onFocus,
|
||||
onChange,
|
||||
} = props;
|
||||
|
||||
// State
|
||||
// const navigationState = useSelector((state: IState) => state.navigation);
|
||||
const searchState = useSelector((state: IState) => state.search);
|
||||
// const { searchOverlayExpanded } = navigationState;
|
||||
const searchOverlayExpanded = true;
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// References
|
||||
const inputRef = useRef(null);
|
||||
|
||||
// Hooks
|
||||
const router = useRouter();
|
||||
|
||||
// Force focus when user starts typing
|
||||
useStartTyping(() => inputRef?.current?.focus());
|
||||
const inputValue = searchState.searchQuery;
|
||||
|
||||
// Internal functions
|
||||
const pushToSearchPage = () => {
|
||||
const input = inputRef.current as HTMLInputElement;
|
||||
if (input?.value?.length && input?.focus) {
|
||||
router.push({
|
||||
pathname: '/search',
|
||||
query: { s: encodeURI(input?.value) },
|
||||
});
|
||||
}
|
||||
|
||||
dispatch(collapseSearchOverlay());
|
||||
};
|
||||
|
||||
// Handler Functions
|
||||
const handleFocus = () => {
|
||||
if (onFocus) {
|
||||
onFocus();
|
||||
}
|
||||
|
||||
inputRef?.current?.focus();
|
||||
if (!searchOverlayExpanded) {
|
||||
dispatch(expandSearchOverlay());
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = String(event.target.value);
|
||||
dispatch(setSearchQuery(String(value)));
|
||||
|
||||
if (onChange) {
|
||||
onChange(value);
|
||||
}
|
||||
|
||||
// Bring up overlay when they start typing
|
||||
if (String(value).length > 0) {
|
||||
dispatch(expandSearchOverlay());
|
||||
}
|
||||
|
||||
inputRef?.current?.focus();
|
||||
};
|
||||
|
||||
// Search on enter
|
||||
useKey('Enter', pushToSearchPage);
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
// search(String(inputValue));
|
||||
}, [inputValue]);
|
||||
|
||||
// Autofocus
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (autofocus) {
|
||||
inputRef?.current?.focus();
|
||||
}
|
||||
}, 50);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('flex items-center w-full space-x-3', className)}
|
||||
>
|
||||
{prefix && <div>{prefix}</div>}
|
||||
|
||||
{dummy ? (
|
||||
<div
|
||||
onClick={() => dummyOnClick && dummyOnClick()}
|
||||
className={classNames(
|
||||
'flex',
|
||||
'flex-grow',
|
||||
'border-none',
|
||||
'cursor-text',
|
||||
'outline-none',
|
||||
'opacity-50',
|
||||
'w-0',
|
||||
inputClassName,
|
||||
)}
|
||||
>
|
||||
{placeholder}
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
ref={inputRef}
|
||||
spellCheck={false}
|
||||
className={classNames(
|
||||
'flex',
|
||||
'flex-grow',
|
||||
'border-none',
|
||||
'outline-none',
|
||||
'w-0',
|
||||
inputClassName,
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
value={inputValue}
|
||||
onKeyDown={() => inputRef?.current?.focus()}
|
||||
onFocus={handleFocus}
|
||||
onChange={handleOnChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{searchIcon && (
|
||||
// Internal elements
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
if (searchOverlayExpanded) {
|
||||
pushToSearchPage();
|
||||
} else {
|
||||
dispatch(expandSearchOverlay());
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* {searchIcon === 'primary' && (
|
||||
<SearchPrimarySVG className="h-8 fill-current" />
|
||||
)}
|
||||
{searchIcon === 'secondary' && (
|
||||
<SearchSecondarySVG className="h-8 fill-current" />
|
||||
)} */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
import classNames from 'classnames';
|
||||
import { useRouter } from 'next/dist/client/router';
|
||||
import React, { SyntheticEvent, useContext } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { ScreenContext } from '../../contexts/screen';
|
||||
import { ISanityArticle } from '../../types/article';
|
||||
import { generateURL } from '../../utils/routing';
|
||||
|
||||
export function SearchItem(props: ISanityArticle) {
|
||||
const dispatch = useDispatch();
|
||||
const router = useRouter();
|
||||
const { title, featureImage, city, category, slug } = props;
|
||||
|
||||
const { isMobile } = useContext(ScreenContext);
|
||||
|
||||
const handleClick = (e: SyntheticEvent) => {
|
||||
const { href, as } = generateURL(slug);
|
||||
e.preventDefault();
|
||||
router.push(href, as);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'cursor-pointer',
|
||||
'search-item',
|
||||
'flex',
|
||||
'flex-col',
|
||||
'rounded-lg',
|
||||
'overflow-hidden',
|
||||
'shadow-lg',
|
||||
'm-4',
|
||||
'w-full',
|
||||
)}
|
||||
onClick={e => handleClick(e)}
|
||||
>
|
||||
<div className={classNames('w-full', isMobile && 'h-64')}>
|
||||
<img
|
||||
className="w-full h-full"
|
||||
src={featureImage.source}
|
||||
alt={featureImage.altText}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 w-full">
|
||||
<div className="font-bold text-xl">{title}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import React, { useContext } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useKey } from 'react-use';
|
||||
import { ScreenContext } from '../../contexts/screen';
|
||||
import { collapseSearchOverlay } from '../../state/navigation';
|
||||
import { SearchOverlayBackdrop } from './SearchOverlayBackdrop';
|
||||
import { SearchOverlayMobile } from './SearchOverlayMobile';
|
||||
|
||||
// Search overlay includes the backdrop and the mobile overlay.
|
||||
// Search dropdown is desktop only and is rendered per component
|
||||
export function SearchOverlay() {
|
||||
const { isMobile } = useContext(ScreenContext);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Close on escape
|
||||
useKey('Escape', () => dispatch(collapseSearchOverlay()));
|
||||
|
||||
return <>{isMobile ? <SearchOverlayMobile /> : <SearchOverlayBackdrop />}</>;
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { UI } from '../../constants';
|
||||
import { collapseSearchOverlay } from '../../state/navigation';
|
||||
import { IState } from '../../state/reducers';
|
||||
|
||||
export function SearchOverlayBackdrop() {
|
||||
const navigationState = useSelector((state: IState) => state.navigation);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { searchOverlayExpanded } = navigationState;
|
||||
const onClickAway = () => {
|
||||
if (searchOverlayExpanded) {
|
||||
dispatch(collapseSearchOverlay());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClickAway}
|
||||
style={{ zIndex: searchOverlayExpanded ? UI.Z_INDEX_SEARCH_OVERLAY : -1 }}
|
||||
className={classNames(
|
||||
'fixed',
|
||||
'bottom-0',
|
||||
'left-0',
|
||||
'right-0',
|
||||
'h-full',
|
||||
'w-full',
|
||||
'bg-white',
|
||||
'bg-opacity-75',
|
||||
'transition-opacity',
|
||||
searchOverlayExpanded ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
></div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,131 @@
|
|||
import classNames from 'classnames';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useContext } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { ScreenContext } from '../../contexts/screen';
|
||||
import { useSearch } from '../../hooks/search';
|
||||
import { IState } from '../../state/reducers';
|
||||
import { SVG } from '../../types';
|
||||
import { Button } from '../Button';
|
||||
import { ArticleCard } from '../cards/ArticleCard';
|
||||
|
||||
interface IDynamicOptions {
|
||||
name: string;
|
||||
href: string;
|
||||
svg: SVG;
|
||||
}
|
||||
|
||||
const dynamicCategories: Array<IDynamicOptions> = [
|
||||
{ name: 'Trending', href: '/search/trending', svg: null as SVG },
|
||||
{ name: 'New!', href: '/search/new', svg: null as SVG },
|
||||
];
|
||||
|
||||
export function SearchOverlayInner() {
|
||||
const searchState = useSelector((state: IState) => state.search);
|
||||
|
||||
const renderSearchResults =
|
||||
searchState.searchQuery.length > 0 &&
|
||||
searchState.searchResultItems?.length > 0;
|
||||
|
||||
const renderSearchDefaltTemplate =
|
||||
searchState?.searchQuery?.length === 0 ||
|
||||
(searchState?.searchQuery?.length > 0 &&
|
||||
searchState.searchResultItems?.length === 0);
|
||||
|
||||
const renderSearchNoResults =
|
||||
searchState.searchQuery.length > 0 &&
|
||||
searchState?.searchResultItems?.length === 0;
|
||||
|
||||
const { isMobile } = useContext(ScreenContext);
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classNames('w-full', isMobile ? 'px-0' : 'px-4')}>
|
||||
<div className="border-secondary border-opacity-50 border-t-2"></div>
|
||||
</div>
|
||||
|
||||
{renderSearchResults && <SearchOverlayInnerResults />}
|
||||
{renderSearchDefaltTemplate && <SearchOverlayInnerDefault />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchOverlayInnerDefault() {
|
||||
const { searchBarPinnedToHeader } = useSelector(
|
||||
(state: IState) => state.search,
|
||||
);
|
||||
|
||||
const { isMobile } = useContext(ScreenContext);
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'w-full px-4',
|
||||
isMobile && 'flex flex-col h-full justify-between',
|
||||
)}
|
||||
>
|
||||
{/* FEATURED DYNAMIC CATEGORIES */}
|
||||
<div className="flex flex-col space-y-1 mt-4">
|
||||
{dynamicCategories.map(category => (
|
||||
<div
|
||||
key={category.name.toLowerCase()}
|
||||
onClick={() => router.push(category.href)}
|
||||
className="flex items-center text-lg cursor-pointer text-primary font-roboto font-medium rounded-lg hover:bg-primary hover:bg-opacity-10"
|
||||
>
|
||||
<category.svg className="h-8 mr-2" />
|
||||
{category.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchOverlayInnerResults() {
|
||||
const { results: allResults, query: searchQuery } = useSearch();
|
||||
const { isMobile } = useContext(ScreenContext);
|
||||
const router = useRouter();
|
||||
|
||||
// Sort results by popularity and filter down to four results
|
||||
console.log('Results', allResults);
|
||||
const results = allResults?.slice(0, 4);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={classNames('flex flex-wrap', [
|
||||
isMobile ? 'mt-10 px-0' : 'mt-6 px-4',
|
||||
`children:odd:${isMobile ? 'pr-4' : 'pr-2'}`,
|
||||
`children:even:${isMobile ? 'pl-4' : 'pl-2'}`,
|
||||
])}
|
||||
>
|
||||
{results?.map(card => (
|
||||
<div key={card.id.toLowerCase()} className={classNames('w-1/2 mb-8')}>
|
||||
<ArticleCard {...card} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'flex w-full justify-center px-6',
|
||||
isMobile ? 'mb-6' : 'mb-0',
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
color="primary"
|
||||
size={isMobile ? 'medium' : 'small'}
|
||||
onClick={() =>
|
||||
router.push({
|
||||
pathname: '/search',
|
||||
query: { s: searchQuery },
|
||||
})
|
||||
}
|
||||
>
|
||||
See all results
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
// import BackSVG from '../../assets/svgs/back.svg';
|
||||
import { UI } from '../../constants';
|
||||
import { collapseSearchOverlay } from '../../state/navigation';
|
||||
import { IState } from '../../state/reducers';
|
||||
import { Contained } from '../Contained';
|
||||
import { SearchInput } from './SearchInput';
|
||||
import { SearchOverlayInner } from './SearchOverlayInner';
|
||||
|
||||
export function SearchOverlayMobile() {
|
||||
const navigationState = useSelector((state: IState) => state.navigation);
|
||||
const { searchOverlayExpanded } = navigationState;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Internal functions
|
||||
const handleExit = (e: React.MouseEvent) => {
|
||||
// Timeout to prevent action immediately firing on the elemnt under with onMouseUp
|
||||
setTimeout(() => dispatch(collapseSearchOverlay()), 50);
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
// Internal elements
|
||||
const searchInputPrefix = (
|
||||
<span onMouseDown={handleExit} className="text-secondary">
|
||||
{/* <BackSVG className={classNames('h-8 w-8 fill-current')} /> */}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
zIndex: searchOverlayExpanded ? UI.Z_INDEX_SEARCH_OVERLAY : -1,
|
||||
}}
|
||||
className={classNames(
|
||||
'fixed top-0 bottom-0 left-0 right-0 bg-white',
|
||||
searchOverlayExpanded ? 'block' : 'hidden',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col h-full flex-grow overflow-y-auto">
|
||||
<Contained>
|
||||
<SearchInput
|
||||
autofocus
|
||||
className=""
|
||||
inputClassName="h-24 pl-2 text-xl"
|
||||
placeholder="Search"
|
||||
prefix={searchInputPrefix}
|
||||
/>
|
||||
|
||||
<SearchOverlayInner />
|
||||
</Contained>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
export interface IFirestore {
|
||||
data: any;
|
||||
}
|
||||
|
||||
// const CERT = {
|
||||
// privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'),
|
||||
// clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
|
||||
// projectId: process.env.FIREBASE_PROJECT_ID,
|
||||
// };
|
||||
|
||||
// const FIREBASE = {
|
||||
// CLIENT_CONFIG: {
|
||||
// apiKey: process.env.FIREBASE_API_KEY,
|
||||
// authDomain: process.env.FIREBASE_AUTH_DOMAIN,
|
||||
// databaseURL: process.env.FIREBASE_DATABASE_URL,
|
||||
// projectId: process.env.FIREBASE_PROJECT_ID,
|
||||
// storageBucket: process.env.FIREBASE_STORAGE_BUCKET,
|
||||
// messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID,
|
||||
// appId: process.env.FIREBASE_APP_ID,
|
||||
// measurementId: process.env.FIREBASE_MEASUREMENT_ID,
|
||||
// },
|
||||
// // ADMIN_CONFIG: {
|
||||
// // credential: firebaseAdmin.credential.cert(CERT),
|
||||
// // databaseURL: process.env.FIREBASE_DATABASE_URL,
|
||||
// // },
|
||||
// // react-redux-firebase config
|
||||
// RRF_CONFIG: {},
|
||||
// };
|
||||
|
||||
// export default FIREBASE;
|
|
@ -0,0 +1,6 @@
|
|||
// import FIREBASE from './firebase';
|
||||
import METADATA from './metadata';
|
||||
import SEARCH from './search';
|
||||
import UI from './ui';
|
||||
|
||||
export { UI, METADATA, SEARCH };
|
|
@ -0,0 +1,6 @@
|
|||
const METADATA = {
|
||||
OXEN_HOST_URL: 'https://oxen.io',
|
||||
TITLE_SUFFIX: 'Oxen | Privacy made simple.',
|
||||
};
|
||||
|
||||
export default METADATA;
|
|
@ -0,0 +1,6 @@
|
|||
const SEARCH = {
|
||||
SEARCH_ITEMS_PER_PAGE: 10,
|
||||
SOFT_LIMIT_SEARCH_RESULTS_OVERLAY: 4,
|
||||
};
|
||||
|
||||
export default SEARCH;
|
|
@ -0,0 +1,21 @@
|
|||
const UI = {
|
||||
MOBILE_BREAKPOINT: 500,
|
||||
TABLET_BREAKPOINT: 715,
|
||||
DESKTOP_BREAKPOINT: 1100,
|
||||
MAX_CONTENT_WIDTH: 1300,
|
||||
PAGE_CONTAINED_PADDING_VW: 5,
|
||||
|
||||
USER_QUERY_404_MAX_LEN: 500,
|
||||
|
||||
Z_INDEX_HEADER: 1000,
|
||||
Z_INDEX_SEARCH_OVERLAY: 20000,
|
||||
Z_INDEX_HEADER_SEARCH: 20001,
|
||||
Z_INDEX_SEARCH_DROPDOWN: 20002,
|
||||
Z_INDEX_MODAL_OVERLAY: 33333,
|
||||
|
||||
ARTICLE: {
|
||||
TITLE_MAX_WIDTH_REM: 29,
|
||||
},
|
||||
};
|
||||
|
||||
export default UI;
|
|
@ -0,0 +1,30 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useScreenSize } from '../hooks/screen';
|
||||
|
||||
interface IScreen {
|
||||
isMobile: boolean;
|
||||
isTablet: boolean;
|
||||
isDesktop: boolean;
|
||||
isHuge: boolean;
|
||||
}
|
||||
|
||||
export const ScreenContext = React.createContext(undefined);
|
||||
|
||||
const ScreenProvider = ({ children }) => {
|
||||
const screenParams: IScreen = useScreenSize();
|
||||
|
||||
useEffect(() => {
|
||||
console.log('screen ➡️ screenParams.isMobile:', screenParams.isMobile);
|
||||
console.log('screen ➡️ screenParams.isTablet:', screenParams.isTablet);
|
||||
console.log('screen ➡️ screenParams.isDesktop', screenParams.isDesktop);
|
||||
console.log('screen ➡️ screenParams.isHuge', screenParams.isHuge);
|
||||
}, [screenParams]);
|
||||
|
||||
return (
|
||||
<ScreenContext.Provider value={screenParams}>
|
||||
{children}
|
||||
</ScreenContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScreenProvider;
|
|
@ -0,0 +1,43 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useWindowSize } from 'react-use';
|
||||
import { UI } from '../constants';
|
||||
|
||||
export function useScreenSize() {
|
||||
// Default to mobile view
|
||||
// const isMobile = useMedia(`(max-width: ${UI.TABLET_BREAKPOINT}px)`, true);
|
||||
// const isTablet = useMedia(
|
||||
// `(min-width: ${UI.MOBILE_BREAKPOINT}px) and (max-width: ${UI.TABLET_BREAKPOINT}px)`,
|
||||
// false,
|
||||
// );
|
||||
// const isDesktop = useMedia(`(min-width: ${UI.TABLET_BREAKPOINT}px)`, false);
|
||||
// const isHuge = useMedia(`(min-width: ${UI.DESKTOP_BREAKPOINT}px)`, false);
|
||||
|
||||
// console.log('screen ➡️ isMobile:', isMobile);
|
||||
// console.log('screen ➡️ isTablet:', isTablet);
|
||||
// console.log('screen ➡️ isDesktop:', isDesktop);
|
||||
// console.log('screen ➡️ isHuge:', isHuge);
|
||||
|
||||
// Use width rather than media queries for improved performance.
|
||||
const { width } = useWindowSize();
|
||||
|
||||
// Default to mobile view
|
||||
const [isMobile, setIsMobile] = useState(true);
|
||||
const [isTablet, setIsTablet] = useState(false);
|
||||
const [isDesktop, setIsDesktop] = useState(false);
|
||||
const [isHuge, setIsHuge] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const _isMobile = width <= UI.TABLET_BREAKPOINT;
|
||||
const _isTablet =
|
||||
width > UI.MOBILE_BREAKPOINT && width <= UI.TABLET_BREAKPOINT;
|
||||
const _isDesktop = width > UI.TABLET_BREAKPOINT;
|
||||
const _isHuge = width > UI.DESKTOP_BREAKPOINT;
|
||||
|
||||
if (isMobile !== _isMobile) setIsMobile(_isMobile);
|
||||
if (isTablet !== _isTablet) setIsTablet(_isTablet);
|
||||
if (isDesktop !== _isDesktop) setIsDesktop(_isDesktop);
|
||||
if (isHuge !== _isHuge) setIsHuge(_isHuge);
|
||||
}, [width]);
|
||||
|
||||
return { isMobile, isTablet, isDesktop, isHuge };
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
import groq from 'groq';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import client from '../client';
|
||||
import { IState } from '../state/reducers';
|
||||
import { setSearchResultItems } from '../state/search';
|
||||
import { ISanityArticle } from '../types/article';
|
||||
|
||||
export const sanityPostQuery = `
|
||||
"id": _id,
|
||||
"updatedAt": _updatedAt,
|
||||
title,
|
||||
subtitle,
|
||||
body,
|
||||
publishedAt,
|
||||
"backdropSVG": backdropSVG.asset->url,
|
||||
"video": {"link": video.link, "description": video.description},
|
||||
"location": {"lat": location.lat, "lng": location.lng},
|
||||
"author": {"name": author->name, "imageSrc": author->image.asset->url },
|
||||
"featureImage": {"source": featureImage.image.asset->url, "altText": featureImage.altText, "description": featureImage.description },
|
||||
"category": category->title,
|
||||
"city": city->title,
|
||||
"tags": tags[]->title,
|
||||
"slug": slug.current,
|
||||
`;
|
||||
|
||||
export function useSearch() {
|
||||
const searchState = useSelector((state: IState) => state.search);
|
||||
const results = searchState?.searchResultItems ?? [];
|
||||
const query = searchState.searchQuery ?? '';
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// const throttledSetSearchResultItems = _.debounce(
|
||||
// (items: ISanityArticle[]) => dispatch(setSearchResultItems(items)),
|
||||
// 100,
|
||||
// );
|
||||
|
||||
const search = async (query: string): Promise<Array<ISanityArticle>> => {
|
||||
const sanityQuery = groq`*[_type == "post" && title match "${query}*"][0..5] {
|
||||
${sanityPostQuery}
|
||||
}`;
|
||||
|
||||
let posts: ISanityArticle[];
|
||||
try {
|
||||
posts = await client.fetch(sanityQuery);
|
||||
} catch (error) {
|
||||
console.warn('Error: ', error);
|
||||
}
|
||||
|
||||
const results = posts?.filter(post =>
|
||||
// Ensure all values are present in each post
|
||||
Object.values(post).every(value => Boolean(value)),
|
||||
);
|
||||
// .map(post => ({
|
||||
// ...post,
|
||||
// }));
|
||||
|
||||
console.log('search ➡️ posts:', posts);
|
||||
console.log('search ➡️ results:', results);
|
||||
|
||||
// throttledSetSearchResultItems(results);
|
||||
dispatch(setSearchResultItems(results));
|
||||
return results;
|
||||
};
|
||||
|
||||
return { search, query, results };
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
const MAP_BOX_USERNAME = 'oxenvince';
|
||||
const MAP_BOX_STYLE_ID = 'ckj6mv0zb04uz1amskq1bpi3u';
|
||||
|
||||
export const getMapBoxStaticSource = (
|
||||
lat: number,
|
||||
lng: number,
|
||||
width?: number,
|
||||
height?: number,
|
||||
) =>
|
||||
`https://api.mapbox.com/styles/v1/${MAP_BOX_USERNAME}/${MAP_BOX_STYLE_ID}/static/${lat},${lng},8.5,0,60/${
|
||||
width ?? 1200
|
||||
}x${
|
||||
height ?? 300
|
||||
}?access_token=pk.eyJ1IjoidGFzdGllc3R2aW5jZSIsImEiOiJja2VnaXp0bzkwZWM0MzJxYng3OW9qZnY5In0.xA1wKv2WJEZUU9XvdlolLg`;
|
|
@ -0,0 +1,29 @@
|
|||
const withPlugins = require('next-compose-plugins');
|
||||
const withSass = require('@zeit/next-sass');
|
||||
const withFonts = require('next-fonts');
|
||||
const withSvgr = require('next-svgr');
|
||||
|
||||
const nextConfig = {
|
||||
webpack(config) {
|
||||
const rules = [{}];
|
||||
|
||||
return {
|
||||
...config,
|
||||
module: {
|
||||
...config.module,
|
||||
rules: [...config.module.rules, ...rules],
|
||||
},
|
||||
node: {
|
||||
fs: 'empty',
|
||||
},
|
||||
};
|
||||
},
|
||||
env: {
|
||||
NEXT_PUBLIC_CONTENTFUL_ACCESS_TOKEN:
|
||||
process.env.NEXT_PUBLIC_CONTENTFUL_ACCESS_TOKEN,
|
||||
NEXT_PUBLIC_CONTENTFUL_SPACE_ID:
|
||||
process.env.NEXT_PUBLIC_CONTENTFUL_SPACE_ID,
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = withPlugins([withSass, withFonts, withSvgr], nextConfig);
|
|
@ -0,0 +1,93 @@
|
|||
{
|
||||
"name": "oxen.io",
|
||||
"version": "0.1.0",
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"now": "npm run now",
|
||||
"dev": "NODE_ENV=development next dev",
|
||||
"dev:https": "NODE_ENV=development node server.js",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"staging": "func() { git add . && git commit -m \"$1\" && git push -u origin staging; }; func",
|
||||
"lint": "eslint '*/**/*.{js,ts,tsx}' --quiet --fix",
|
||||
"lint:fix": "npm run lint -- --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^4.2.2",
|
||||
"@sanity/block-content-to-react": "^2.0.7",
|
||||
"@sanity/client": "^1.149.18",
|
||||
"@sanity/image-url": "^0.140.19",
|
||||
"@tailwindcss/aspect-ratio": "^0.2.0",
|
||||
"@types/lodash": "^4.14.161",
|
||||
"@types/moment": "^2.13.0",
|
||||
"@types/react-redux": "^7.1.9",
|
||||
"@types/redux": "^3.6.0",
|
||||
"@types/styled-components": "^5.1.3",
|
||||
"@zeit/next-css": "^1.0.1",
|
||||
"@zeit/next-sass": "^1.0.1",
|
||||
"D": "^1.0.0",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"babel-plugin-tailwind": "^0.1.10",
|
||||
"babel-preset-env": "^1.7.0",
|
||||
"classnames": "^2.2.6",
|
||||
"contentful": "^8.1.7",
|
||||
"dotenv": "^8.2.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.3.1",
|
||||
"firebase": "^8.2.1",
|
||||
"firebase-admin": "^9.4.2",
|
||||
"firebase-functions": "^3.13.0",
|
||||
"firestore-pagination-hook": "^1.0.0",
|
||||
"global": "^4.4.0",
|
||||
"groq": "^1.149.16",
|
||||
"moment": "^2.27.0",
|
||||
"next": "^9.3.3",
|
||||
"next-compose-plugins": "^2.2.0",
|
||||
"next-fonts": "^1.4.0",
|
||||
"next-images": "^1.4.0",
|
||||
"next-session": "^3.3.2",
|
||||
"next-svgr": "^0.0.2",
|
||||
"node-sass": "^4.14.1",
|
||||
"nookies": "^2.5.0",
|
||||
"now": "^20.1.2",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^17.0.0",
|
||||
"react-dom": "^17.0.0",
|
||||
"react-paginate": "^6.5.0",
|
||||
"react-redux": "^7.2.1",
|
||||
"react-redux-firebase": "^3.9.0",
|
||||
"react-use": "^15.3.4",
|
||||
"react-youtube": "^7.13.0",
|
||||
"redux": "^4.0.5",
|
||||
"redux-firestore": "^0.14.0",
|
||||
"swr": "^0.3.9",
|
||||
"tailwindcss": "^1.4.6",
|
||||
"tailwindcss-children": "^2.1.0",
|
||||
"uuid": "^8.3.2",
|
||||
"vercel": "^20.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash.get": "^4.4.6",
|
||||
"@types/lodash.set": "^4.3.6",
|
||||
"@types/node": "^14.0.27",
|
||||
"@types/react": "^16.9.53",
|
||||
"@typescript-eslint/eslint-plugin": "^3.9.0",
|
||||
"@typescript-eslint/parser": "^3.9.0",
|
||||
"eslint": "^7.7.0",
|
||||
"eslint-config-prettier": "^6.11.0",
|
||||
"eslint-plugin-prettier": "^3.1.4",
|
||||
"eslint-plugin-react": "^7.20.6",
|
||||
"husky": "^4.2.5",
|
||||
"lint-staged": "^10.2.11",
|
||||
"prettier": "^2.0.5",
|
||||
"typescript": "^3.9.7",
|
||||
"webpack-cli": "^3.3.11"
|
||||
},
|
||||
"lint-staged": {
|
||||
"./**/*.{ts,tsx}": "npm run lint:fix"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "lint-staged"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,150 @@
|
|||
import classNames from 'classnames';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import React, { useContext } from 'react';
|
||||
// import _404 from '../assets/svgs/404.svg';
|
||||
import { UI } from '../constants';
|
||||
import { ScreenContext } from '../contexts/screen';
|
||||
import { generateTitle } from '../utils/metadata';
|
||||
|
||||
function oxen404() {
|
||||
const { isMobile, isTablet, isDesktop, isHuge } = useContext(ScreenContext);
|
||||
|
||||
const wrapperStyles = {
|
||||
width: '100%',
|
||||
maxWidth: '760px',
|
||||
margin: isDesktop ? '50px auto 100px' : '-10px auto',
|
||||
paddingLeft: isHuge ? '0' : '5vw',
|
||||
paddingRight: isHuge ? '0' : '5vw',
|
||||
paddingBottom: !isDesktop ? '33px' : '0px',
|
||||
};
|
||||
|
||||
const svgStyles = {
|
||||
top: isDesktop ? '125px' : isTablet ? '20px' : '85px',
|
||||
left: isDesktop ? '-50px' : isTablet ? '30vw' : '-65px',
|
||||
width: isDesktop ? '810px' : isTablet ? '833px' : '933px',
|
||||
};
|
||||
|
||||
const _404SectionStyles = {
|
||||
top: isDesktop ? '45px' : isTablet ? '35px' : '25px',
|
||||
};
|
||||
|
||||
const _404TitleStyles = {
|
||||
lineHeight: '0px',
|
||||
paddingTop: '2.3rem',
|
||||
paddingBottom: '3.3rem',
|
||||
};
|
||||
|
||||
const _404TextStyles = {
|
||||
lineHeight: '1.15em',
|
||||
};
|
||||
|
||||
const absoluteBoxStyles = {
|
||||
marginTop: isTablet ? '20px' : '0px',
|
||||
minHeight: isTablet ? '330px' : '450px',
|
||||
};
|
||||
|
||||
const goBackHomeStyles = {
|
||||
width: '9rem',
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Head>
|
||||
<title>{generateTitle('404')}</title>
|
||||
</Head>
|
||||
|
||||
<div style={wrapperStyles} className="flex items-center">
|
||||
<div
|
||||
className={classNames(
|
||||
'flex w-full justify-between',
|
||||
!isDesktop && 'flex-col',
|
||||
)}
|
||||
>
|
||||
<div style={absoluteBoxStyles} className="relative w-full flex">
|
||||
{/* <_404 style={svgStyles} className="absolute top-0 z-0" /> */}
|
||||
<div style={_404SectionStyles} className="absolute left-0 z-50">
|
||||
<h1
|
||||
style={_404TitleStyles}
|
||||
className="font-roboto text-primary text-sevenxl text-opacity-25 -mb-4"
|
||||
>
|
||||
404
|
||||
</h1>
|
||||
<p
|
||||
style={_404TextStyles}
|
||||
className="font-roboto text-primary text-fourxl tracking-tight"
|
||||
>
|
||||
Nothing found here.
|
||||
</p>
|
||||
|
||||
<Link href="/">
|
||||
<div
|
||||
role="button"
|
||||
style={goBackHomeStyles}
|
||||
className={classNames(
|
||||
'bg-secondary',
|
||||
'cursor-pointer',
|
||||
'mt-3',
|
||||
'text-white',
|
||||
'font-roboto',
|
||||
'px-3',
|
||||
'py-1',
|
||||
'w-32',
|
||||
'select-none',
|
||||
'rounded-lg',
|
||||
'text-center',
|
||||
'tracking-tight',
|
||||
isMobile ? 'text-lg' : 'text-sm',
|
||||
)}
|
||||
>
|
||||
Discover food
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
'z-10 flex items-start',
|
||||
isMobile ? '-mt-10' : 'mt-0',
|
||||
)}
|
||||
>
|
||||
<div className="flex-col flex-grow z-50 my-4">
|
||||
<h2
|
||||
className={classNames(
|
||||
'text-primary font-roboto font-semibold ml-1 font-roboto mt-6 text-twoxl whitespace-no-wrap',
|
||||
)}
|
||||
>
|
||||
Something went wrong?
|
||||
</h2>
|
||||
|
||||
<textarea
|
||||
maxLength={UI.USER_QUERY_404_MAX_LEN}
|
||||
placeholder="Let us know what you were looking for and we'll get back to you soon."
|
||||
className="border-secondary border-2 rounded-xl focus:outline-none focus:border-primary placeholder-primary placeholder-opacity-50 w-full h-48 px-3 py-3 resize-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Email address..."
|
||||
className="mt-2 border-secondary border-2 rounded-xl focus:outline-none focus:border-primary py-2 placeholder-primary placeholder-opacity-50 w-full pl-3 pt-3 pr-1"
|
||||
/>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
className={classNames(
|
||||
'bg-primary cursor-pointer mt-4 text-white font-roboto px-4 py-2 select-none rounded-lg text-center',
|
||||
isMobile ? 'text-lg' : 'text-sm',
|
||||
isMobile ? 'w-full' : 'w-4/12',
|
||||
)}
|
||||
>
|
||||
Send
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default oxen404;
|
|
@ -0,0 +1,60 @@
|
|||
import 'firebase/auth';
|
||||
import 'firebase/firestore'; // <- needed if using firestore
|
||||
import type { AppProps } from 'next/app';
|
||||
import Head from 'next/head';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Provider as StoreProvider } from 'react-redux';
|
||||
import { useLocation } from 'react-use';
|
||||
import { createStore } from 'redux';
|
||||
import '../assets/style.scss';
|
||||
import Layout from '../components/layout';
|
||||
import { METADATA } from '../constants';
|
||||
import ScreenProvider from '../contexts/screen';
|
||||
import { collapseSearchOverlay } from '../state/navigation';
|
||||
import { rootReducer } from '../state/reducers';
|
||||
|
||||
// if (!firebase.apps.length) {
|
||||
// // Initialize firebase instance
|
||||
// firebase.initializeApp(FIREBASE.CLIENT_CONFIG);
|
||||
|
||||
// // Initialize other services on firebase instance
|
||||
// firebase.firestore();
|
||||
// firebase.auth().setPersistence(firebase.auth.Auth.Persistence.SESSION);
|
||||
// }
|
||||
|
||||
const store = createStore(rootReducer);
|
||||
|
||||
// const rrfProps = {
|
||||
// firebase,
|
||||
// config: FIREBASE.RRF_CONFIG,
|
||||
// dispatch: store.dispatch,
|
||||
// createFirestoreInstance,
|
||||
// };
|
||||
|
||||
function App({ Component, pageProps }: AppProps) {
|
||||
// Close search overlay on page changed
|
||||
const location = useLocation();
|
||||
useEffect(() => {
|
||||
store.dispatch(collapseSearchOverlay());
|
||||
}, [location.pathname, location.search]);
|
||||
|
||||
// const { info } = useGetInfo();
|
||||
|
||||
return (
|
||||
<StoreProvider store={store}>
|
||||
{/* <ReactReduxFirebaseProvider {...rrfProps}> */}
|
||||
<ScreenProvider>
|
||||
<Head>
|
||||
<title>{METADATA.TITLE_SUFFIX}</title>
|
||||
</Head>
|
||||
|
||||
<Layout>
|
||||
<Component {...pageProps} />
|
||||
</Layout>
|
||||
</ScreenProvider>
|
||||
{/* </ReactReduxFirebaseProvider> */}
|
||||
</StoreProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
|
@ -0,0 +1,14 @@
|
|||
import Head from 'next/head';
|
||||
import { generateTitle } from '../utils/metadata';
|
||||
|
||||
export default function About(): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<Head>
|
||||
<title>{generateTitle('About')}</title>
|
||||
</Head>
|
||||
|
||||
<h1 className="text-center text-xl m-6">About</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
// [slug].js
|
||||
import { GetServerSideProps } from 'next';
|
||||
import Head from 'next/head';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Article } from '../../components/article/Article';
|
||||
import { setArticle } from '../../state/reducers/article';
|
||||
import { IArticle } from '../../types/article';
|
||||
import { getArticleBy } from '../../utils/article';
|
||||
import { generateTitle } from '../../utils/metadata';
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async context => {
|
||||
const article = await getArticleBy('slug', String(context.query.slug) ?? '');
|
||||
|
||||
// Redirect to 404 for nonexistent page
|
||||
if (!article) {
|
||||
return {
|
||||
props: undefined,
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: article,
|
||||
};
|
||||
};
|
||||
|
||||
function Post(props: IArticle) {
|
||||
const dispatch = useDispatch();
|
||||
dispatch(setArticle(props));
|
||||
|
||||
// Scroll to top on load
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{generateTitle(props.title)}</title>
|
||||
</Head>
|
||||
|
||||
<Article {...props} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Post;
|
|
@ -0,0 +1,52 @@
|
|||
import * as contentful from 'contentful';
|
||||
import Head from 'next/head';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Article } from '../../components/article/Article';
|
||||
import { Contained } from '../../components/Contained';
|
||||
import { generateTitle } from '../../utils/metadata';
|
||||
|
||||
const client = contentful.createClient({
|
||||
space: process.env.NEXT_PUBLIC_CONTENTFUL_SPACE_ID,
|
||||
accessToken: process.env.NEXT_PUBLIC_CONTENTFUL_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
export default function Blog() {
|
||||
async function fetchEntries() {
|
||||
const entries = await client.getEntries();
|
||||
if (entries.items) {
|
||||
return entries.items;
|
||||
}
|
||||
|
||||
console.log('Error getting entries');
|
||||
|
||||
// console.log(`Error getting Entries for ${contentType.name}.`);
|
||||
}
|
||||
|
||||
const [posts, setPosts] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
async function getPosts() {
|
||||
const allPosts = await fetchEntries();
|
||||
setPosts([...allPosts]);
|
||||
}
|
||||
getPosts();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Head>
|
||||
<title>{generateTitle('Blog')}</title>
|
||||
</Head>
|
||||
|
||||
<div className="flex flex-col w-full space-y-10">
|
||||
<Contained>
|
||||
<div className="flex justify-center items-baseline space-x-6 space-y-4 mt-6 mb-16">
|
||||
{posts.map(post => (
|
||||
<Article key={post.id} {...post} />
|
||||
))}
|
||||
</div>
|
||||
</Contained>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import React from 'react';
|
||||
import { Contained } from '../components/Contained';
|
||||
import { ColorPalette } from '../components/design/DesignColorPalette';
|
||||
import { generateTitle } from '../utils/metadata';
|
||||
|
||||
export default function Design(): JSX.Element {
|
||||
const router = useRouter();
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
if (!isDev) {
|
||||
router.push('/');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Head>
|
||||
<title>{generateTitle('Design')}</title>
|
||||
</Head>
|
||||
|
||||
<Contained>
|
||||
<ColorPalette />
|
||||
</Contained>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
import groq from 'groq';
|
||||
import { NextPage } from 'next';
|
||||
import Head from 'next/head';
|
||||
import React from 'react';
|
||||
import client from '../client';
|
||||
import { ArticleCard } from '../components/cards/ArticleCard';
|
||||
import { METADATA } from '../constants';
|
||||
import { sanityPostQuery } from '../hooks/search';
|
||||
import { ISanityArticle } from '../types/article';
|
||||
|
||||
interface Props {
|
||||
posts: Array<ISanityArticle>;
|
||||
// AuthUserInfo: any;
|
||||
}
|
||||
|
||||
const Index: NextPage<Props> = ({ posts = [] }) => {
|
||||
const cards = posts
|
||||
? posts.slice(0, 4).map(post => <ArticleCard key={post.id} {...post} />)
|
||||
: [];
|
||||
|
||||
console.log('posts', posts);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{METADATA.TITLE_SUFFIX}</title>
|
||||
<meta
|
||||
property="og:title"
|
||||
content="oxen food no matter where you are"
|
||||
key="title"
|
||||
/>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1"
|
||||
></meta>
|
||||
</Head>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Index.getInitialProps = async () => {
|
||||
const query = groq`
|
||||
*[_type == "post"]|order(publishedAt desc) {
|
||||
${sanityPostQuery}
|
||||
}
|
||||
`;
|
||||
|
||||
let posts: Array<ISanityArticle>;
|
||||
try {
|
||||
posts = await client.fetch(query);
|
||||
console.log('Posts', posts);
|
||||
} catch (error) {
|
||||
console.warn('Error:', error);
|
||||
}
|
||||
|
||||
return { posts };
|
||||
};
|
||||
|
||||
// export default withAuthUser(withAuthUserInfo(Index));
|
||||
export default Index;
|
|
@ -0,0 +1,204 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import ReactPaginate from 'react-paginate';
|
||||
// import SearchBackdropDesktopSVG from '../assets/svgs/page/search_desktop.svg';
|
||||
// import SearchBackdropMobileSVG from '../assets/svgs/page/search_mobile.svg';
|
||||
import client from '../client';
|
||||
import { ArticleCardRow } from '../components/cards/ArticleCardRow';
|
||||
import { Contained } from '../components/Contained';
|
||||
import { SectionTitle } from '../components/SectionTitle';
|
||||
import { Title } from '../components/Title';
|
||||
import { METADATA, SEARCH } from '../constants';
|
||||
import { ScreenContext } from '../contexts/screen';
|
||||
import { sanityPostQuery } from '../hooks/search';
|
||||
import { ISanityArticle } from '../types/article';
|
||||
import { buildArticleInfo } from '../utils/article';
|
||||
import { getTopPosts } from '../utils/posts';
|
||||
|
||||
interface Props {
|
||||
sanityQuery: string;
|
||||
posts: ISanityArticle[];
|
||||
totalCount: number;
|
||||
currentPage: number;
|
||||
}
|
||||
|
||||
interface ISanityPageResults {
|
||||
posts: ISanityArticle[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
function Search(props: Props) {
|
||||
const posts = props.posts.map(p => buildArticleInfo(p));
|
||||
|
||||
const { isMobile } = useContext(ScreenContext);
|
||||
|
||||
const router = useRouter();
|
||||
const [isLoading, setLoading] = useState(false); //State for loading indicator
|
||||
const startLoading = () => setLoading(true);
|
||||
const stopLoading = () => setLoading(false);
|
||||
|
||||
const [topPosts, setTopPosts] = useState([] as ISanityArticle[]);
|
||||
|
||||
useEffect(() => {
|
||||
const getPosts = async () => {
|
||||
const posts = await getTopPosts(4);
|
||||
setTopPosts(posts);
|
||||
};
|
||||
|
||||
getPosts();
|
||||
}, []);
|
||||
|
||||
// Since requests happens after chaning routes url ?page={n} we need to bind loading events
|
||||
// on the router change event.
|
||||
useEffect(() => {
|
||||
//Setting router event handlers after component is located
|
||||
router.events.on('routeChangeStart', startLoading);
|
||||
router.events.on('routeChangeComplete', stopLoading);
|
||||
|
||||
return () => {
|
||||
router.events.off('routeChangeStart', startLoading);
|
||||
router.events.off('routeChangeComplete', stopLoading);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const paginationHandler = page => {
|
||||
const currentPath = router.pathname;
|
||||
|
||||
//Copy current query to avoid its removing
|
||||
const currentQuery = { ...router.query };
|
||||
currentQuery.page = page.selected + 1;
|
||||
|
||||
router.push({
|
||||
pathname: currentPath,
|
||||
query: currentQuery,
|
||||
});
|
||||
};
|
||||
|
||||
//
|
||||
//
|
||||
//
|
||||
//
|
||||
// USING QUERY ?s=
|
||||
// OR USING CITY ?city=
|
||||
// OR USING CATEGORY` ?category=
|
||||
//
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
const pageCount = Math.ceil(props.totalCount / SEARCH.SEARCH_ITEMS_PER_PAGE);
|
||||
const showPagination = posts.length > 0 && pageCount > 1;
|
||||
|
||||
console.log('search ➡️ pageCount:', pageCount);
|
||||
console.log('search ➡️ props.totalCount:', props.totalCount);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<title>{METADATA.TITLE_SUFFIX}</title>
|
||||
|
||||
<div className="relative w-full mt-6 mb-12">
|
||||
{isMobile ? (
|
||||
<>
|
||||
{/* <SearchBackdropMobileSVG
|
||||
style={{
|
||||
width: '150%',
|
||||
transform: 'translateX(-18%)',
|
||||
}}
|
||||
/> */}
|
||||
<div className="absolute inset-0 flex justify-center items-center">
|
||||
<Title level={1} className="font-roboto text-primary">
|
||||
{posts.length > 0 ? 'Search Results' : 'Nothing Found'}
|
||||
</Title>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Contained>
|
||||
{/* <SearchBackdropDesktopSVG className="w-full" /> */}
|
||||
<div className="absolute inset-0 flex justify-center items-center">
|
||||
<Title level={1} className="font-roboto text-primary">
|
||||
{posts.length > 0 ? 'Search Results' : 'Nothing Found'}
|
||||
</Title>
|
||||
</div>
|
||||
</Contained>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Contained>
|
||||
<div className="flex flex-col space-y-8">
|
||||
{posts.map(post => (
|
||||
<ArticleCardRow key={post.slug} {...post} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showPagination && (
|
||||
<div className="mobile:mt-12 mt-8">
|
||||
<ReactPaginate
|
||||
previousLabel={'<'}
|
||||
nextLabel={'>'}
|
||||
breakLabel={'...'}
|
||||
breakClassName={'break-me'}
|
||||
activeClassName={'active'}
|
||||
containerClassName={'pagination'}
|
||||
subContainerClassName={''}
|
||||
initialPage={props.currentPage - 1}
|
||||
pageCount={pageCount}
|
||||
marginPagesDisplayed={2}
|
||||
pageRangeDisplayed={5}
|
||||
onPageChange={paginationHandler}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Contained>
|
||||
|
||||
<div className="mt-12 mb-12">
|
||||
<Contained>
|
||||
<SectionTitle>Didn't find what you were looking for?</SectionTitle>
|
||||
</Contained>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Search.getInitialProps = async ({ query }): Promise<Props> => {
|
||||
const page = query.page ?? 1;
|
||||
const { s: encodedSearchQuery } = query;
|
||||
const searchQuery = decodeURI(encodedSearchQuery);
|
||||
|
||||
let posts: ISanityArticle[] = [];
|
||||
let totalCount = 0;
|
||||
|
||||
const resultsStart = SEARCH.SEARCH_ITEMS_PER_PAGE * (page - 1);
|
||||
const resultsEnd = resultsStart + SEARCH.SEARCH_ITEMS_PER_PAGE;
|
||||
|
||||
const specifier = `*[_type == "post" && (title match "*${searchQuery}*" || description match "${searchQuery}*" || "${searchQuery}*" || category match "${searchQuery}*")]`;
|
||||
const sanityQuery = `
|
||||
*[][0]{
|
||||
"posts": ${specifier}[${resultsStart}..${resultsEnd}]{
|
||||
${sanityPostQuery}
|
||||
},
|
||||
"count": count(${specifier})
|
||||
}
|
||||
`;
|
||||
|
||||
if (searchQuery) {
|
||||
try {
|
||||
const results: ISanityPageResults = await client.fetch(sanityQuery);
|
||||
|
||||
if (results?.posts?.length) {
|
||||
posts = results.posts;
|
||||
totalCount = results.count;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error: ', error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
posts,
|
||||
sanityQuery,
|
||||
totalCount,
|
||||
currentPage: page,
|
||||
};
|
||||
};
|
||||
|
||||
export default Search;
|
|
@ -0,0 +1,8 @@
|
|||
module.exports = {
|
||||
plugins: [
|
||||
// ...
|
||||
require('tailwindcss'),
|
||||
// require('autoprefixer'),
|
||||
// ...
|
||||
],
|
||||
};
|
|
@ -0,0 +1,36 @@
|
|||
import Document, { Head, Main, NextScript } from 'next/document';
|
||||
import React from 'react';
|
||||
|
||||
export default class CustomDocument extends Document<any> {
|
||||
render() {
|
||||
return (
|
||||
<html lang="en">
|
||||
<Head>
|
||||
<link rel="shortcut icon" href="/favicon.ico"></link>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon.png"
|
||||
></link>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/favicon-32x32.png"
|
||||
></link>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/favicon-16x16.png"
|
||||
></link>
|
||||
{this.props?.styleTags}
|
||||
</Head>
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
}
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,23 @@
|
|||
const { createServer } = require('https');
|
||||
const { parse } = require('url');
|
||||
const next = require('next');
|
||||
const fs = require('graceful-fs');
|
||||
|
||||
const dev = process.env.NODE_ENV !== 'production';
|
||||
const app = next({ dev });
|
||||
const handle = app.getRequestHandler();
|
||||
|
||||
const httpsOptions = dev && {
|
||||
key: fs.readFileSync('./certificates/localhost.key'),
|
||||
cert: fs.readFileSync('./certificates/localhost.crt'),
|
||||
};
|
||||
|
||||
app.prepare().then(() => {
|
||||
createServer(httpsOptions, (req, res) => {
|
||||
const parsedUrl = parse(req.url, true);
|
||||
handle(req, res, parsedUrl);
|
||||
}).listen(3000, err => {
|
||||
if (err) throw err;
|
||||
console.log('🐙 Ready on https://localhost:3000');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,40 @@
|
|||
export enum ModalInstance {
|
||||
LOGIN = 'LOGIN',
|
||||
}
|
||||
|
||||
export interface INavigation {
|
||||
searchOverlayExpanded: boolean;
|
||||
openedModal: ModalInstance | null;
|
||||
}
|
||||
|
||||
export const initialNavigationState: INavigation = {
|
||||
searchOverlayExpanded: false,
|
||||
openedModal: null,
|
||||
};
|
||||
|
||||
export enum NavigationActions {
|
||||
EXPAND_SEARCH_OVERLAY = 'EXPAND_SEARCH_OVERLAY',
|
||||
COLLAPSE_SEARCH_OVERLAY = 'COLLAPSE_SEARCH_OVERLAY',
|
||||
TOGGLE_SEARCH_OVERLAY = 'TOGGLE_SEARCH_OVERLAY',
|
||||
SET_MODAL_IS_OPEN = 'SET_MODAL_IS_OPEN',
|
||||
}
|
||||
|
||||
// ////////////////////////////// //
|
||||
// Action Creators //
|
||||
// ////////////////////////////// //
|
||||
export const expandSearchOverlay = () => ({
|
||||
type: NavigationActions.EXPAND_SEARCH_OVERLAY,
|
||||
});
|
||||
|
||||
export const collapseSearchOverlay = () => ({
|
||||
type: NavigationActions.COLLAPSE_SEARCH_OVERLAY,
|
||||
});
|
||||
|
||||
export const toggleSearchOverlay = () => ({
|
||||
type: NavigationActions.TOGGLE_SEARCH_OVERLAY,
|
||||
});
|
||||
|
||||
export const setCurrentOpenModal = (isOpen: boolean) => ({
|
||||
type: NavigationActions.SET_MODAL_IS_OPEN,
|
||||
payload: isOpen,
|
||||
});
|
|
@ -0,0 +1,34 @@
|
|||
import { IArticle } from '../../types/article';
|
||||
|
||||
export const initialArticleState: IArticle | Record<string, unknown> = {};
|
||||
|
||||
export enum ArticleActions {
|
||||
SET_ARTICLE = 'SET_ARTICLE',
|
||||
}
|
||||
|
||||
// ////////////////////////////// //
|
||||
// Action Creators //
|
||||
// ////////////////////////////// //
|
||||
|
||||
export const setArticle = (article: IArticle) => ({
|
||||
type: ArticleActions.SET_ARTICLE,
|
||||
payload: article,
|
||||
});
|
||||
|
||||
export interface ArticleAction {
|
||||
type: ArticleActions;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
export const articleReducer = (
|
||||
state: IArticle | Record<string, unknown> = initialArticleState,
|
||||
action: ArticleAction,
|
||||
): IArticle | Record<string, unknown> => {
|
||||
switch (action.type) {
|
||||
case ArticleActions.SET_ARTICLE: {
|
||||
return { ...action.payload };
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
|
@ -0,0 +1,28 @@
|
|||
import 'firebase/auth';
|
||||
import 'firebase/firestore'; // <- needed if using firestore
|
||||
import { firebaseReducer } from 'react-redux-firebase';
|
||||
import { combineReducers } from 'redux';
|
||||
import { firestoreReducer } from 'redux-firestore'; // <- needed if using firestore
|
||||
import { IFirestore } from '../../constants/firebase';
|
||||
import { IArticle } from '../../types/article';
|
||||
import { INavigation } from '../navigation';
|
||||
import { ISearch } from '../search';
|
||||
import { articleReducer } from './article';
|
||||
import { navigationReducer } from './navigation';
|
||||
import { searchReducer } from './search';
|
||||
|
||||
export interface IState {
|
||||
navigation: INavigation;
|
||||
search: ISearch;
|
||||
article: IArticle;
|
||||
firestore: IFirestore;
|
||||
}
|
||||
|
||||
export const rootReducer = combineReducers({
|
||||
navigation: navigationReducer,
|
||||
search: searchReducer,
|
||||
article: articleReducer,
|
||||
|
||||
firebase: firebaseReducer,
|
||||
firestore: firestoreReducer, // <- needed if using firestore
|
||||
});
|
|
@ -0,0 +1,32 @@
|
|||
import {
|
||||
INavigation,
|
||||
initialNavigationState,
|
||||
NavigationActions,
|
||||
} from '../navigation';
|
||||
|
||||
export interface NavigationAction {
|
||||
type: NavigationActions;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
export const navigationReducer = (
|
||||
state: INavigation = initialNavigationState,
|
||||
action: NavigationAction,
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case NavigationActions.EXPAND_SEARCH_OVERLAY: {
|
||||
return { ...state, searchOverlayExpanded: true };
|
||||
}
|
||||
case NavigationActions.COLLAPSE_SEARCH_OVERLAY: {
|
||||
return { ...state, searchOverlayExpanded: false };
|
||||
}
|
||||
case NavigationActions.TOGGLE_SEARCH_OVERLAY: {
|
||||
return { ...state, searchOverlayExpanded: !state.searchOverlayExpanded };
|
||||
}
|
||||
case NavigationActions.SET_MODAL_IS_OPEN: {
|
||||
return { ...state, modalIsOpen: action.payload };
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
|
@ -0,0 +1,29 @@
|
|||
import { initialSearchState, ISearch, SearchActions } from '../search';
|
||||
|
||||
export interface SearchAction {
|
||||
type: SearchActions;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
export const searchReducer = (
|
||||
state: ISearch = initialSearchState,
|
||||
action: SearchAction,
|
||||
): ISearch => {
|
||||
switch (action.type) {
|
||||
case SearchActions.SET_SEARCH_RESULT_ITEMS: {
|
||||
return { ...state, searchResultItems: action.payload };
|
||||
}
|
||||
case SearchActions.SET_SEARCH_QUERY: {
|
||||
return { ...state, searchQuery: action.payload };
|
||||
}
|
||||
case SearchActions.SET_SEARCH_BAR_PINNED_TO_HEADER: {
|
||||
if (action.payload === state.searchBarPinnedToHeader) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return { ...state, searchBarPinnedToHeader: action.payload };
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
|
@ -0,0 +1,37 @@
|
|||
import { ISanityArticle } from '../types/article';
|
||||
|
||||
export interface ISearch {
|
||||
searchQuery: string;
|
||||
searchBarPinnedToHeader: boolean;
|
||||
searchResultItems: Array<ISanityArticle>;
|
||||
}
|
||||
|
||||
export const initialSearchState: ISearch = {
|
||||
searchQuery: '',
|
||||
searchResultItems: [],
|
||||
searchBarPinnedToHeader: false,
|
||||
};
|
||||
|
||||
export enum SearchActions {
|
||||
SET_SEARCH_QUERY = 'SET_SEARCH_QUERY',
|
||||
SET_SEARCH_RESULT_ITEMS = 'SET_SEARCH_RESULT_ITEMS',
|
||||
SET_SEARCH_BAR_PINNED_TO_HEADER = 'SET_SEARCH_BAR_PINNED_TO_HEADER',
|
||||
}
|
||||
|
||||
// ////////////////////////////// //
|
||||
// Action Creators //
|
||||
// ////////////////////////////// //
|
||||
export const setSearchResultItems = (payload: Array<ISanityArticle>) => ({
|
||||
type: SearchActions.SET_SEARCH_RESULT_ITEMS,
|
||||
payload,
|
||||
});
|
||||
|
||||
export const setSearchQuery = (payload: string) => ({
|
||||
type: SearchActions.SET_SEARCH_QUERY,
|
||||
payload,
|
||||
});
|
||||
|
||||
export const setSearchBarPinnedToHeader = (payload: boolean) => ({
|
||||
type: SearchActions.SET_SEARCH_BAR_PINNED_TO_HEADER,
|
||||
payload,
|
||||
});
|
|
@ -0,0 +1,58 @@
|
|||
module.exports = {
|
||||
theme: {
|
||||
screens: {
|
||||
// Constants taken from UI constants.
|
||||
// Think of them as 'beyond this breakpoint' when using
|
||||
// mobile:my-class, for example.
|
||||
// -> @media (min-width: {}px) { ... }
|
||||
mobile: '500px',
|
||||
tablet: '715px',
|
||||
desktop: '1100px',
|
||||
},
|
||||
fontFamily: {
|
||||
roboto: ['Roboto'],
|
||||
robotoslab: ['RobotoSlab'],
|
||||
},
|
||||
fontSize: {
|
||||
xs: ['.75rem'],
|
||||
sm: ['.875rem'],
|
||||
tiny: ['.875rem'],
|
||||
base: ['1rem'],
|
||||
lg: ['1.125rem'],
|
||||
xl: ['1.25rem'],
|
||||
'2xl': ['1.5rem'],
|
||||
'3xl': ['1.875rem'],
|
||||
'4xl': ['2.25rem'],
|
||||
'5xl': ['3rem'],
|
||||
'6xl': ['4rem'],
|
||||
'7xl': ['5rem'],
|
||||
'8xl': ['6rem'],
|
||||
'9xl': ['7rem'],
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#1F1C47',
|
||||
secondary: '#12C7BA',
|
||||
alt: '#DBF7F5',
|
||||
hyper: '#C3F53A',
|
||||
blush: '#FF7A87',
|
||||
blue: '#3F4BF5',
|
||||
purple: '#654192',
|
||||
},
|
||||
display: ['huge', 'desktop', 'tablet', 'mobile'],
|
||||
backgroundOpacity: {
|
||||
'10': '0.1',
|
||||
},
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
borderWidth: ['children', 'children-last'],
|
||||
padding: ['children-odd', 'children-even'],
|
||||
margin: ['children-last'],
|
||||
fontWeight: ['children-last'],
|
||||
},
|
||||
plugins: [
|
||||
require('tailwindcss-children'),
|
||||
require('@tailwindcss/aspect-ratio'),
|
||||
],
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue