Templated structure

This commit is contained in:
Vince 2021-01-22 13:43:42 +11:00
commit e3ecea5135
111 changed files with 16574 additions and 0 deletions

19
.babelrc Normal file
View File

@ -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 }]
]
}

55
.eslintrc.js Normal file
View File

@ -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',
},
},
};

7
.firebaserc Normal file
View File

@ -0,0 +1,7 @@
{
"projects": {
"default": "oxen-io",
"staging": "oxen-io",
"production": "oxen-io"
}
}

33
.gitignore vendored Normal file
View File

@ -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

1
.nowignore Normal file
View File

@ -0,0 +1 @@
functions

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
v14.15.0

4
.prettierignore Normal file
View File

@ -0,0 +1,4 @@
# Ignore .next
.next
.git
node_modules

9
.prettierrc.js Normal file
View File

@ -0,0 +1,9 @@
module.exports = {
semi: true,
trailingComma: 'all',
singleQuote: true,
arrowParens: 'avoid',
printWidth: 80,
tabWidth: 2,
useTabs: false,
};

7
@types/index.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
declare module '*.svg' {
import * as React from 'react';
const ReactComponent: React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
export { ReactComponent };
export default string;
}

30
README.md Normal file
View File

@ -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.

148
assets/style.scss Normal file
View File

@ -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;
}

13
client.js Normal file
View File

@ -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
});

View File

@ -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>
);
}

131
components/Button.tsx Normal file
View File

@ -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>
);
}

View File

@ -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>
);
}

33
components/Contained.tsx Normal file
View File

@ -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>
);
}

72
components/Dropdown.tsx Normal file
View File

@ -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>
);
}

View File

@ -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>
);
};

29
components/Footer.tsx Normal file
View File

@ -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>
);
}

238
components/Input.tsx Normal file
View File

@ -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>
);
}

52
components/InputGroup.tsx Normal file
View File

@ -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>
);
}

80
components/Modal.tsx Normal file
View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

86
components/Title.tsx Normal file
View File

@ -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>
)}
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
)}
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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 />}</>;
}

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

30
constants/firebase.ts Normal file
View File

@ -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;

6
constants/index.ts Normal file
View File

@ -0,0 +1,6 @@
// import FIREBASE from './firebase';
import METADATA from './metadata';
import SEARCH from './search';
import UI from './ui';
export { UI, METADATA, SEARCH };

6
constants/metadata.tsx Normal file
View File

@ -0,0 +1,6 @@
const METADATA = {
OXEN_HOST_URL: 'https://oxen.io',
TITLE_SUFFIX: 'Oxen | Privacy made simple.',
};
export default METADATA;

6
constants/search.ts Normal file
View File

@ -0,0 +1,6 @@
const SEARCH = {
SEARCH_ITEMS_PER_PAGE: 10,
SOFT_LIMIT_SEARCH_RESULTS_OVERLAY: 4,
};
export default SEARCH;

21
constants/ui.ts Normal file
View File

@ -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;

30
contexts/screen.tsx Normal file
View File

@ -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;

43
hooks/screen.ts Normal file
View File

@ -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 };
}

66
hooks/search.ts Normal file
View File

@ -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 };
}

14
lib/mapbox.ts Normal file
View File

@ -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
next-env.d.ts vendored Normal file
View File

29
next.config.js Normal file
View File

@ -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);

93
package.json Normal file
View File

@ -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"
}
}
}

150
pages/404.tsx Normal file
View File

@ -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;

60
pages/_app.tsx Normal file
View File

@ -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;

14
pages/about.tsx Normal file
View File

@ -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>
);
}

50
pages/blog/[slug].tsx Normal file
View File

@ -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;

52
pages/blog/index.tsx Normal file
View File

@ -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>
);
}

28
pages/design.tsx Normal file
View File

@ -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>
);
}

60
pages/index.tsx Normal file
View File

@ -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;

204
pages/search.tsx Normal file
View File

@ -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;

8
postcss.config.js Normal file
View File

@ -0,0 +1,8 @@
module.exports = {
plugins: [
// ...
require('tailwindcss'),
// require('autoprefixer'),
// ...
],
};

36
public/_document.tsx Normal file
View File

@ -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.

23
server.js Normal file
View File

@ -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');
});
});

40
state/navigation.ts Normal file
View File

@ -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,
});

34
state/reducers/article.ts Normal file
View File

@ -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;
}
};

28
state/reducers/index.ts Normal file
View File

@ -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
});

View File

@ -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;
}
};

29
state/reducers/search.ts Normal file
View File

@ -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;
}
};

37
state/search.ts Normal file
View File

@ -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,
});

58
tailwind.config.js Normal file
View File

@ -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