Browse Source

feat: add faq from zendesk

fix/enable-imgix
Thomas Osmonson 5 years ago
parent
commit
1e550e7415
  1. 74
      src/common/data/faq.ts
  2. 4
      src/common/navigation.yaml
  3. 34
      src/common/utils/faqs.ts
  4. 35
      src/components/back-button.tsx
  5. 8
      src/components/custom-blocks/page-reference.tsx
  6. 118
      src/components/faq.tsx
  7. 2
      src/components/home/card.tsx
  8. 7
      src/components/mdx/md-contents.tsx
  9. 48
      src/components/side-nav.tsx
  10. 58
      src/hover-image.tsx
  11. 10
      src/pages/references/faqs.md
  12. 144
      src/pages/references/faqs/[slug].tsx

74
src/common/data/faq.ts

@ -1,26 +1,66 @@
import { convertRemoteDataToMDX } from '@common/data/mdx';
import FAQ_JSON from '../../_data/faqs.json';
import TurndownService from 'turndown';
import { slugify } from '@common/utils';
import { getBetterNames } from '@common/utils/faqs';
export const convertFaqAnswersToMDX = async () => {
const turndownService = new TurndownService();
// we convert html to markdown so we can process it with remark,
// eg external links open in new window
const md = FAQ_JSON.faqs.map(faq => ({
...faq,
answer: turndownService.turndown(faq.answer),
}));
// convert it to MDX with next-mdx-remote
const answers = await convertRemoteDataToMDX(md, 'answer');
const faqs = FAQ_JSON.faqs.map((faq, index) => ({
...faq,
answer: answers[index],
const fetchSections = async () => {
const res = await fetch('https://blockstack.zendesk.com/api/v2/help_center/en-us/sections.json');
const { sections } = await res.json();
return sections;
};
const fetchArticles = async (id: number) => {
const res = await fetch(
`https://blockstack.zendesk.com/api/v2/help_center/en-us/sections/${id}/articles.json?per_page=100`
);
const { articles } = await res.json();
return articles;
};
// This function gets called at build time
export async function getStaticPaths() {
const sections = await fetchSections();
const paths = sections.map(section => ({
params: { slug: slugify(getBetterNames(section.id).title) },
}));
return { paths, fallback: false };
}
const getSectionBySlug = (sections, slug) =>
sections.find(s => {
const { title } = getBetterNames(s.id);
const _slug = slugify(title);
return _slug === slug;
});
export async function getStaticProps(context) {
const sections = await fetchSections();
let articles = [];
if (context?.params?.slug) {
const section = getSectionBySlug(sections, context.params.slug);
const _articles = await fetchArticles(section.id);
const turndownService = new TurndownService();
// we convert html to markdown so we can process it with remark/rehype,
// eg external links open in new window
const md = _articles.map(faq => ({
...faq,
body: turndownService.turndown(faq.body),
}));
// convert it to MDX with next-mdx-remote
const body = await convertRemoteDataToMDX(md, 'body');
articles = _articles.map((faq, index) => ({
...faq,
body: body[index],
}));
}
return {
props: {
mdx: faqs,
sections,
articles,
...context,
},
revalidate: 60 * 60 * 12, // 12 hours
};
};
}

4
src/common/navigation.yaml

@ -108,9 +108,7 @@ sections:
- external:
href: 'https://blockstack.github.io/blockstack-ios/'
title: iOS SDK
- external:
href: 'https://blockstack.zendesk.com/hc/en-us'
title: FAQs
- path: /faqs
- path: /glossary
- path: /deploy-tips
- title: Ecosystem

34
src/common/utils/faqs.ts

@ -0,0 +1,34 @@
export const getBetterNames = (id: number) => {
switch (id) {
case 360007620914:
return {
title: 'General information',
description: 'General questions about Blockstack and the Stacks network',
img: '/images/pages/testnet.svg',
};
case 360007411853:
return {
title: 'Stacks Token',
description: 'Questions relating to the native token of Stacks 2.0',
img: '/images/pages/mining.svg',
};
case 360007760554:
return {
title: 'Stacks blockchain',
description: 'Learn about the blockchain and details of Stacks 2.0',
img: '/images/pages/hello-world.svg',
};
case 360007781533:
return {
title: 'Ecosystem details',
description: 'Questions related to the age of the project and the contributors',
img: '/images/pages/data-storage.svg',
};
case 360007780033:
return {
title: 'Building apps',
description: 'Learn about the platform and questions related to decentralized applications',
img: '/images/pages/connect.svg',
};
}
};

35
src/components/back-button.tsx

@ -0,0 +1,35 @@
import React from 'react';
import { Flex, Box, color, space } from '@blockstack/ui';
import ArrowLeftIcon from 'mdi-react/ArrowLeftIcon';
import { Text } from '@components/typography';
import Link from 'next/link';
const Wrapper = ({ href, children }) =>
href ? (
<Link href={href} passHref>
{children}
</Link>
) : (
children
);
export const BackButton = ({ href, ...rest }) => (
<Wrapper href={href}>
<Flex
color={color('text-caption')}
_hover={{
cursor: 'pointer',
color: color('text-title'),
}}
align="center"
as={href ? 'a' : 'div'}
display="flex !important"
{...rest}
>
<Box as="span" mr={space('extra-tight')}>
<ArrowLeftIcon size="16px" />
</Box>
<Text color={'currentColor'}>Back</Text>
</Flex>
</Wrapper>
);

8
src/components/custom-blocks/page-reference.tsx

@ -141,8 +141,8 @@ const InlineCard = ({ page }) => {
);
};
const GridCardImage: React.FC<BoxProps & { isHovered?: boolean; page: any }> = React.memo(
({ isHovered, page, ...props }) => (
const GridCardImage: React.FC<BoxProps & { isHovered?: boolean; src?: string }> = React.memo(
({ isHovered, src, ...props }) => (
<Box
bg="#9985FF"
position="relative"
@ -158,7 +158,7 @@ const GridCardImage: React.FC<BoxProps & { isHovered?: boolean; page: any }> = R
transition={transition('0.45s')}
transform={isHovered && 'scale(1.08)'}
style={{ willChange: 'transform' }}
src={page?.images?.large}
src={src}
position="absolute"
left={'-2%'}
top={'-2%'}
@ -194,7 +194,7 @@ const GridCard: React.FC<BoxProps & { page?: any }> = React.memo(({ page, ...res
{...rest}
{...bind}
>
<GridCardImage page={page} isHovered={hover || active} />
<GridCardImage src={page?.images?.large} isHovered={hover || active} />
<GridItemDetails page={page} />
</Box>
);

118
src/components/faq.tsx

@ -1,76 +1,68 @@
import React from 'react';
import { Components } from '@components/mdx';
import { Box, Flex, ChevronIcon, space, color } from '@blockstack/ui';
import hydrate from 'next-mdx-remote/hydrate';
import { Accordion, AccordionItem, AccordionButton, AccordionPanel } from '@reach/accordion';
import { border } from '@common/utils';
import { Box, space, color, Grid } from '@blockstack/ui';
import { Text } from '@components/typography';
import { slugify } from '@common/utils';
import { css } from '@styled-system/css';
import { useRouter } from 'next/router';
import { useActiveHeading } from '@common/hooks/use-active-heading';
const getSlug = (asPath: string) => {
if (asPath.includes('#')) {
const slug = asPath.split('#')[1];
return slug;
}
return;
};
const FAQItem = React.memo(({ faq, ...rest }: any) => {
const id = slugify(faq.question);
const { isActive } = useActiveHeading(id);
import { getCapsizeStyles, getHeadingStyles } from '@components/mdx/typography';
import { HoverImage } from '../hover-image';
import { useTouchable } from '@common/hooks/use-touchable';
import Link from 'next/link';
import { getBetterNames } from '@common/utils/faqs';
const FloatingLink = ({ href, ...props }: any) => (
<Link href={href} {...props} passHref>
<Box as="a" position="absolute" size="100%" zIndex={999} left={0} top={0} />
</Link>
);
const SectionCard = ({ section }) => {
const { hover, active, bind } = useTouchable({
behavior: 'button',
});
const { title, description, img } = getBetterNames(section.id);
return (
<Box as={AccordionItem} borderBottom={border()} {...rest}>
<Flex
as={AccordionButton}
_hover={{ color: color('accent') }}
css={css({
display: 'flex',
width: '100%',
outline: 'none',
alignItems: 'center',
justifyContent: 'space-between',
py: space('extra-loose'),
textAlign: 'left',
pr: space('extra-loose'),
color: isActive ? color('accent') : color('text-title'),
'& > h4': {
pl: space('extra-loose'),
},
':hover': {
color: color('accent'),
},
})}
>
<Components.h4 id={id} my="0px !important" color="currentColor">
{faq.question}
</Components.h4>
<Box color={color('text-caption')} pl={space('base-loose')}>
<ChevronIcon direction="down" size="22px" />
<Box
color={color('text-title')}
_hover={{ cursor: 'pointer', color: color('accent') }}
position="relative"
{...bind}
>
<FloatingLink href="/references/faqs/[slug]" as={`/references/faqs/${slugify(title)}`} />
<HoverImage isHovered={hover || active} src={img} />
<Box>
<Text
css={css({
color: 'currentColor',
...getHeadingStyles('h3'),
})}
>
{title}
</Text>
<Box>
<Text
css={css({
display: 'block',
color: color('text-body'),
mt: space('base-loose'),
...getCapsizeStyles(16, 26),
})}
>
{description}
</Text>
</Box>
</Flex>
<Box px={space('extra-loose')} pb={space('extra-loose')} as={AccordionPanel}>
{hydrate(faq.answer, Components)}
</Box>
</Box>
);
});
export const FAQs = React.memo(({ category, data }: any) => {
const router = useRouter();
const slug = getSlug(router.asPath);
const faqs = data.filter(faq => faq.category === category);
const slugIndex = faqs.findIndex(faq => slugify(faq.question) === slug);
const [index, setIndex] = React.useState(slugIndex !== -1 ? slugIndex : undefined);
const handleIndexChange = (value: number) => {
setIndex(value);
};
};
export const FAQs = React.memo(({ articles, sections }: any) => {
return (
<Accordion multiple collapsible defaultIndex={index} onChange={handleIndexChange}>
{faqs.map((faq, _index) => {
return <FAQItem faq={faq} key={_index} />;
<Grid
gridTemplateColumns="repeat(2, 1fr)"
gridColumnGap={space('extra-loose')}
gridRowGap="64px"
>
{sections.map(section => {
return <SectionCard key={section.id} section={section} />;
})}
</Accordion>
</Grid>
);
});

2
src/components/home/card.tsx

@ -26,7 +26,7 @@ export const Card: React.FC<CardProps> = ({ children, onClick, dark = false, hre
{...bind}
{...rest}
>
<LinkComponent href={href} />
{href && <LinkComponent href={href} />}
<Grid
width="100%"
px={space('base-loose')}

7
src/components/mdx/md-contents.tsx

@ -244,15 +244,14 @@ export const MDContents: React.FC<any> = ({ pageTop: PageTop = null, headings, c
const router = useRouter();
const isHome = router.pathname === '/';
const TOCShowing = !isHome && headings && headings?.length > 1;
return (
<>
<ContentWrapper
width={['100%', '100%', '100%', `calc(100% - ${!TOCShowing ? 0 : TOC_WIDTH}px)`]}
width={['100%', '100%', '100%', `calc(100% - ${isHome ? 0 : TOC_WIDTH}px)`]}
mx="unset"
pt="unset"
css={css(styleOverwrites as any)}
pr={TOCShowing && ['0', '0', '0', 'extra-loose']}
pr={!isHome && ['0', '0', '0', 'extra-loose']}
>
{PageTop && <PageTop />}
{children}
@ -265,7 +264,7 @@ export const MDContents: React.FC<any> = ({ pageTop: PageTop = null, headings, c
>
<Box position="sticky" top={0} pt="64px">
<Search mb={space('extra-loose')} />
<TableOfContents headings={headings} />
{headings?.length ? <TableOfContents headings={headings} /> : null}
</Box>
</Box>
) : null}

48
src/components/side-nav.tsx

@ -124,7 +124,7 @@ const ChildPages = ({ items, handleClick }: any) => {
<Box mb={space('extra-tight')} key={key}>
<Link href={routePath.path} passHref>
<PageItem
isActive={router.pathname.endsWith(path)}
isActive={router.pathname.includes(path)}
onClick={page.pages ? () => handleClick(page) : undefined}
as="a"
>
@ -187,28 +187,38 @@ const Navigation = () => {
React.useEffect(() => {
let currentSection = selected.items;
nav.sections.forEach(section => {
section.pages.forEach(page => {
if (page.pages) {
const pagesFound = page.pages.find(_page => {
return router.pathname.endsWith(`${page.path}${_page.path}`);
});
const sectionsFound = page?.sections?.find(_section => {
return _section.pages.find(_page => {
if (router.pathname === '/') {
currentSection = {
items: nav.sections,
type: 'default',
};
} else {
nav.sections.forEach(section => {
section.pages.forEach(page => {
if (page.pages) {
const pagesFound = page.pages.find(_page => {
return router.pathname.endsWith(`${page.path}${_page.path}`);
});
});
if (pagesFound || sectionsFound) {
currentSection = page;
const sectionsFound = page?.sections?.find(_section => {
return _section.pages.find(_page => {
return router.pathname.endsWith(`${page.path}${_page.path}`);
});
});
if (pagesFound || sectionsFound) {
currentSection = {
type: 'page',
items: page,
};
}
} else {
return router.pathname.endsWith(page.path);
}
} else {
return router.pathname.endsWith(page.path);
}
});
});
});
}
if (selected.items !== currentSection) {
setSelected({ type: 'page', items: currentSection });
if (currentSection.items && selected.items !== currentSection.items) {
setSelected(currentSection);
}
}, [router.pathname]);
@ -289,6 +299,8 @@ const Navigation = () => {
);
});
}
return null;
};
export const SideNav: React.FC<BoxProps & { containerProps?: BoxProps }> = ({

58
src/hover-image.tsx

@ -0,0 +1,58 @@
import React from 'react';
import { Box, BoxProps, Grid, space } from '@blockstack/ui';
import { transition } from '@common/utils';
import { Img } from '@components/mdx/image';
const Image = ({
src,
isHovered,
size,
...rest
}: BoxProps & { src?: string; isHovered?: boolean }) => (
<Box
flexShrink={0}
style={{
willChange: 'transform',
}}
width="100%"
size={size}
{...rest}
>
<Img
flexShrink={0}
borderRadius="12px"
src={src}
width="100%"
minWidth={size}
size={size}
mx="0 !important"
my="0 !important"
/>
</Box>
);
export const HoverImage: React.FC<BoxProps & { isHovered?: boolean; src?: string }> = React.memo(
({ isHovered, src, ...props }) => (
<Box
bg="#9985FF"
position="relative"
borderRadius="12px"
mb={space('loose')}
overflow="hidden"
{...props}
>
<Grid style={{ placeItems: 'center' }} height="0px" paddingTop="56.25%">
<Image
width="102%"
size="102%"
transition={transition('0.45s')}
transform={isHovered && 'scale(1.08)'}
style={{ willChange: 'transform' }}
src={src}
position="absolute"
left={'-2%'}
top={'-2%'}
/>
</Grid>
</Box>
)
);

10
src/pages/references/faqs.md

@ -0,0 +1,10 @@
---
title: FAQs
description: A knowledge base of question and answers related to the Blockstack ecosystem.
duration: ''
---
import { FAQs } from '@components/faq'
export { getStaticProps } from '@common/data/faq'
<FAQs sections={props.sections} />

144
src/pages/references/faqs/[slug].tsx

@ -0,0 +1,144 @@
import React from 'react';
import { Components } from '@components/mdx';
import { Box, Flex, ChevronIcon, space, color, Grid } from '@blockstack/ui';
import hydrate from 'next-mdx-remote/hydrate';
import { Accordion, AccordionItem, AccordionButton, AccordionPanel } from '@reach/accordion';
import { border } from '@common/utils';
import { css } from '@styled-system/css';
import { useRouter } from 'next/router';
import { useActiveHeading } from '@common/hooks/use-active-heading';
import { BackButton } from '@components/back-button';
import Head from 'next/head';
import { MDContents } from '@components/mdx/md-contents';
export { getStaticProps, getStaticPaths } from '@common/data/faq';
import { slugify } from '@common/utils';
import { PageTop } from '@components/page-top';
const getBetterNames = (id: number) => {
switch (id) {
case 360007620914:
return {
title: 'General information',
description: 'General questions about Blockstack and the Stacks network',
img: '/images/pages/testnet.svg',
};
case 360007411853:
return {
title: 'Stacks Token',
description: 'Questions relating to the native token of Stacks 2.0',
img: '/images/pages/mining.svg',
};
case 360007760554:
return {
title: 'Stacks blockchain',
description: 'Learn about the blockchain and details of Stacks 2.0',
img: '/images/pages/hello-world.svg',
};
case 360007781533:
return {
title: 'Ecosystem details',
description: 'Questions related to the age of the project and the contributors',
img: '/images/pages/data-storage.svg',
};
case 360007780033:
return {
title: 'Building apps',
description: 'Learn about the platform and questions related to decentralized applications',
img: '/images/pages/connect.svg',
};
}
};
const getSlug = (asPath: string) => {
if (asPath.includes('#')) {
const slug = asPath.split('#')[1];
return slug;
}
return;
};
const FAQItem = React.memo(({ faq, ...rest }: any) => {
const id = slugify(faq.title);
const { isActive } = useActiveHeading(id);
return (
<Box as={AccordionItem} borderBottom={border()} {...rest}>
<Flex
as={AccordionButton}
_hover={{ color: color('accent') }}
css={css({
display: 'flex',
width: '100%',
outline: 'none',
alignItems: 'center',
justifyContent: 'space-between',
py: space('extra-loose'),
textAlign: 'left',
color: isActive ? color('accent') : color('text-title'),
':hover': {
color: color('accent'),
},
})}
>
<Components.h4 my="0px !important" color="currentColor">
{faq.title}
</Components.h4>
<Box color={color('text-caption')} pl={space('base-loose')}>
<ChevronIcon direction="down" size="22px" />
</Box>
</Flex>
<Box pb={space('extra-loose')} as={AccordionPanel}>
{hydrate(faq.body, Components)}
</Box>
</Box>
);
});
const FaqItems = ({ articles }) => {
const router = useRouter();
const slug = getSlug(router.asPath);
const slugIndex = articles.findIndex(faq => slugify(faq.title) === slug);
const [index, setIndex] = React.useState(slugIndex !== -1 ? slugIndex : 0);
const handleIndexChange = (value: number) => {
setIndex(value);
};
return (
<Box pr={['0px', 0, 'base-loose']}>
<BackButton href="/references/faqs" mb={0} />
<Accordion multiple collapsible defaultIndex={index} onChange={handleIndexChange}>
{articles
// @ts-ignore
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at))
.map((faq, _index) => {
return <FAQItem faq={faq} key={_index} />;
})}
</Accordion>
</Box>
);
};
const FaqPage = props => {
const { articles, sections, params } = props;
const section = sections.find(s => {
const { title } = getBetterNames(s.id);
const slug = slugify(title);
return slug === params.slug;
});
const { title, description } = getBetterNames(section.id);
return (
<>
<Head>
<title>{title} | Blockstack</title>
<meta name="description" content={description} />
</Head>
<MDContents pageTop={() => <PageTop title={title} description={description} />} headings={[]}>
<FaqItems articles={articles} />
</MDContents>
</>
);
};
export default FaqPage;
Loading…
Cancel
Save