diff --git a/apps/web/src/app/(cms)/faq/(root)/components/get-in-touch.tsx b/apps/web/src/app/(cms)/faq/(root)/components/get-in-touch.tsx new file mode 100644 index 0000000000..732906060b --- /dev/null +++ b/apps/web/src/app/(cms)/faq/(root)/components/get-in-touch.tsx @@ -0,0 +1,54 @@ +import { ChatBubbleOvalLeftIcon, TicketIcon } from '@heroicons/react/24/solid' +import { Button } from '@sushiswap/ui' +import React, { FC } from 'react' + +interface Block { + title: string + button: { text: string; link: string } + icon: FC> +} + +function Block({ title, button, icon: Icon }: Block) { + return ( +
+
+
{title}
+ + + +
+
+
+
+
+ +
+
+
+ ) +} + +export function GetInTouch() { + return ( +
+ + +
+ ) +} diff --git a/apps/web/src/app/(cms)/faq/(root)/components/help-by-categories.tsx b/apps/web/src/app/(cms)/faq/(root)/components/help-by-categories.tsx new file mode 100644 index 0000000000..46b3fa0f76 --- /dev/null +++ b/apps/web/src/app/(cms)/faq/(root)/components/help-by-categories.tsx @@ -0,0 +1,37 @@ +import { classNames } from '@sushiswap/ui' +import Link from 'next/link' + +import { + type FaqCategories, + getFaqCategories, +} from '@sushiswap/graph-client/strapi' + +function Block({ name, url }: FaqCategories[number]) { + return ( + + {name} + + ) +} + +export async function HelpByCategories() { + const categories = await getFaqCategories({ sort: ['id'] }) + + return ( +
+
Help By Categories
+
+ {categories.map((topic) => ( + + ))} +
+
+ ) +} diff --git a/apps/web/src/app/(cms)/faq/(root)/components/help-by-products.tsx b/apps/web/src/app/(cms)/faq/(root)/components/help-by-products.tsx new file mode 100644 index 0000000000..f50a832ab0 --- /dev/null +++ b/apps/web/src/app/(cms)/faq/(root)/components/help-by-products.tsx @@ -0,0 +1,51 @@ +import { + type FaqProducts, + getFaqProducts, +} from '@sushiswap/graph-client/strapi' +import { CloudinaryImage, classNames } from '@sushiswap/ui' +import Link from 'next/link' + +function Block({ name, description, url, image }: FaqProducts[number]) { + return ( + + {image ? ( + + ) : null} +
+
{name}
+
+ {description} +
+
+ + ) +} + +export async function HelpByProducts() { + const products = await getFaqProducts() + + return ( +
+
Help By Products
+
+ {products.map((product, i) => ( + + ))} +
+
+ ) +} diff --git a/apps/web/src/app/(cms)/faq/(root)/components/most-searched-questions.tsx b/apps/web/src/app/(cms)/faq/(root)/components/most-searched-questions.tsx new file mode 100644 index 0000000000..9cdf00358a --- /dev/null +++ b/apps/web/src/app/(cms)/faq/(root)/components/most-searched-questions.tsx @@ -0,0 +1,84 @@ +import { ChevronRightIcon } from '@heroicons/react/24/solid' +import { + FaqMostSearched, + getFaqMostSearched, +} from '@sushiswap/graph-client/strapi' +import Link from 'next/link' + +function Question({ question, url }: FaqMostSearched[number]) { + return ( +
+ +
+ {question} +
+
+ +
+ +
+ ) +} + +function MostSearchedQuestionsDesktop({ + questions, +}: { questions: FaqMostSearched }) { + const firstHalf = questions.slice(0, Math.ceil(questions.length / 2)) + const secondHalf = questions.slice(Math.ceil(questions.length / 2)) + + return ( +
+
+ {firstHalf.map((topic, i) => ( +
+ +
+ ))} +
+
+ {secondHalf.map((topic, i) => ( +
+ +
+ ))} +
+
+ ) +} + +function MostSearchedQuestionsMobile({ + questions, +}: { questions: FaqMostSearched }) { + return ( +
+ {questions.map((topic, i) => ( +
+ +
+ ))} +
+ ) +} + +export async function MostSearchedQuestions() { + const questions = await getFaqMostSearched() + + return ( +
+
Most Searched Questions
+ +
+
+ +
+
+ +
+
+
+ ) +} diff --git a/apps/web/src/app/(cms)/faq/(root)/components/search-box.tsx b/apps/web/src/app/(cms)/faq/(root)/components/search-box.tsx new file mode 100644 index 0000000000..3fd78dca57 --- /dev/null +++ b/apps/web/src/app/(cms)/faq/(root)/components/search-box.tsx @@ -0,0 +1,146 @@ +'use client' + +import { useDebounce, useOnClickOutside } from '@sushiswap/hooks' +import { LinkInternal, SkeletonText, classNames } from '@sushiswap/ui' +import { useRef, useState } from 'react' + +import { SearchIcon } from '@heroicons/react-v1/outline' +import { XIcon } from '@heroicons/react-v1/solid' +import { getFaqAnswerSearch } from '@sushiswap/graph-client/strapi' +import { useQuery } from '@tanstack/react-query' + +export function SearchBox() { + const ref = useRef(null) + const [query, setQuery] = useState('') + const debouncedQuery = useDebounce(query, 300) + const [open, setOpen] = useState(false) + + useOnClickOutside(ref, () => { + setOpen(false) + }) + + const { data, isLoading, isError } = useQuery({ + queryKey: ['faq-answers', debouncedQuery], + queryFn: () => getFaqAnswerSearch({ search: debouncedQuery }), + }) + + return ( +
+
+
setOpen(true)} + className={classNames( + 'rounded-xl w-full border', + 'border-black border-opacity-30 bg-neutral-100', + 'dark:bg-[#1F2535] dark:border-opacity-20 dark:border-slate-500', + )} + > +
+
+
+ +
+ setQuery(e.target.value)} + placeholder="Search questions, keyword, articles..." + className={classNames( + 'w-full dark:placeholder:text-slate-500 placeholder:text-neutral-950', + 'p-0 bg-transparent border-none focus:outline-none focus:ring-0 w-full truncate font-medium text-left text-base md:text-sm placeholder:font-normal', + )} + /> +
+ {query && ( + setQuery('')} + className="w-6 h-6 cursor-pointer dark:text-slate-500 text-neutral-950" + /> + )} +
+
+ {isError ? ( +
+ An unexpected error has occured. +
+ ) : ( + <> +

+ Questions +

+ +

+ Question Groups +

+ + + )} +
+
+
+
+ ) +} + +function RowGroup({ + entries, + isLoading, +}: { + entries: { name: string; slug: string }[] + isLoading: boolean +}) { + if (isLoading) { + return ( +
+ + + +
+ ) + } + + if (entries.length === 0) { + return ( +
+ No results found. +
+ ) + } + + return ( + <> + {entries.map(({ name, slug }) => ( + + ))} + + ) +} + +function Row({ name, slug }: { name: string; slug: string }) { + const content = ( +
+

{name}

+
+ ) + + return {content} +} diff --git a/apps/web/src/app/(cms)/faq/(root)/page.tsx b/apps/web/src/app/(cms)/faq/(root)/page.tsx new file mode 100644 index 0000000000..c63fc354f3 --- /dev/null +++ b/apps/web/src/app/(cms)/faq/(root)/page.tsx @@ -0,0 +1,34 @@ +import { typographyVariants } from '@sushiswap/ui' +import { GetInTouch } from './components/get-in-touch' +import { HelpByCategories } from './components/help-by-categories' +import { HelpByProducts } from './components/help-by-products' +import { MostSearchedQuestions } from './components/most-searched-questions' +import { SearchBox } from './components/search-box' + +export const revalidate = 3600 + +export default async function Page() { + return ( +
+
+
+
+

Sushi FAQ

+
{`Everything you need to know about Sushi products in the form of an FAQ knowledge base.`}
+
+ +
+
+
+
+ +
+
+
+ + + +
+
+ ) +} diff --git a/apps/web/src/app/(cms)/faq/[category-slug]/(category)/components/category-layout.tsx b/apps/web/src/app/(cms)/faq/[category-slug]/(category)/components/category-layout.tsx new file mode 100644 index 0000000000..48e3bca30b --- /dev/null +++ b/apps/web/src/app/(cms)/faq/[category-slug]/(category)/components/category-layout.tsx @@ -0,0 +1,76 @@ +import { getFaqCategories } from '@sushiswap/graph-client/strapi' +import { Container } from '@sushiswap/ui' +import { + Sidebar, + SidebarDesktop, + SidebarMobile, +} from '../../../components/sidebar' + +export const revalidate = 900 + +interface CategoryLayoutProps { + children: React.ReactNode +} + +function CategoryLayoutDesktop({ + children, + sidebar, +}: { children: React.ReactNode; sidebar: Sidebar }) { + return ( + +
+ +
+
+
{children}
+ + ) +} + +function CategoryLayoutMobile({ + children, + sidebar, +}: { children: React.ReactNode; sidebar: Sidebar }) { + return ( +
+
+ +
+ {children} +
+ ) +} + +export async function CategoryLayout({ children }: CategoryLayoutProps) { + const categories = await getFaqCategories({ sort: ['id'] }) + const sidebarEntries = categories.map((category) => { + return { + name: category.name, + slug: category.slug, + url: `/faq/${category.slug}`, + } + }) + + const sidebar = { + entries: sidebarEntries, + param: 'category-slug', + } + + return ( + <> +
+ + {children} + +
+
+ + {children} + +
+ + ) +} diff --git a/apps/web/src/app/(cms)/faq/[category-slug]/(category)/layout.tsx b/apps/web/src/app/(cms)/faq/[category-slug]/(category)/layout.tsx new file mode 100644 index 0000000000..eada3bf331 --- /dev/null +++ b/apps/web/src/app/(cms)/faq/[category-slug]/(category)/layout.tsx @@ -0,0 +1,38 @@ +import { getFaqCategory } from '@sushiswap/graph-client/strapi' +import { Breadcrumb, Container, typographyVariants } from '@sushiswap/ui' +import { notFound } from 'next/navigation' +import React from 'react' +import { CategoryLayout } from './components/category-layout' + +export const revalidate = 900 + +export default async function Layout({ + children, + params, +}: { + children: React.ReactNode + params: { 'category-slug': string } +}) { + let category + + try { + category = await getFaqCategory({ slug: params['category-slug'] }) + } catch { + return notFound() + } + + return ( +
+
+ + +

+ {category.name} +

+
+
+
+ {children} +
+ ) +} diff --git a/apps/web/src/app/(cms)/faq/[category-slug]/(category)/page.tsx b/apps/web/src/app/(cms)/faq/[category-slug]/(category)/page.tsx new file mode 100644 index 0000000000..ab2ee6059a --- /dev/null +++ b/apps/web/src/app/(cms)/faq/[category-slug]/(category)/page.tsx @@ -0,0 +1,60 @@ +import { FaqCategory, getFaqCategory } from '@sushiswap/graph-client/strapi' +import Link from 'next/link' +import { notFound } from 'next/navigation' + +export const revalidate = 900 + +function AnswerGroup({ + category, + answerGroup, +}: { + category: FaqCategory + answerGroup: FaqCategory['answerGroups'][number] +}) { + return ( +
+
+ {answerGroup.name} +
+
+ {answerGroup.answers.map((answer) => ( +
+ +
{answer.name}
+ +
+ ))} +
+
+ ) +} + +export default async function FaqCategoryPage({ + params, +}: { + params: { 'category-slug': string } +}) { + let category + + try { + category = await getFaqCategory({ slug: params['category-slug'] }) + } catch { + return notFound() + } + + return ( +
+
+ {category.answerGroups.map((answerGroup) => ( + + ))} +
+
+ ) +} diff --git a/apps/web/src/app/(cms)/faq/[category-slug]/[answer-group-slug]/[answer-slug]/loading.tsx b/apps/web/src/app/(cms)/faq/[category-slug]/[answer-group-slug]/[answer-slug]/loading.tsx new file mode 100644 index 0000000000..9267ce99cf --- /dev/null +++ b/apps/web/src/app/(cms)/faq/[category-slug]/[answer-group-slug]/[answer-slug]/loading.tsx @@ -0,0 +1,37 @@ +import { SkeletonText } from '@sushiswap/ui' + +export default function AnswerLoading() { + return ( +
+ + + + + + + + + +
+ + + + + + + + + +
+ + + + + + + + + +
+ ) +} diff --git a/apps/web/src/app/(cms)/faq/[category-slug]/[answer-group-slug]/[answer-slug]/page.tsx b/apps/web/src/app/(cms)/faq/[category-slug]/[answer-group-slug]/[answer-slug]/page.tsx new file mode 100644 index 0000000000..60d7b0a8d5 --- /dev/null +++ b/apps/web/src/app/(cms)/faq/[category-slug]/[answer-group-slug]/[answer-slug]/page.tsx @@ -0,0 +1,30 @@ +import { getFaqAnswer } from '@sushiswap/graph-client/strapi' +import { notFound } from 'next/navigation' +import { getGhostBody } from 'src/app/(cms)/lib/ghost/ghost' + +export const revalidate = 3600 + +export default async function AnswerPage({ + params, +}: { params: { 'answer-slug': string } }) { + let answer + let body + + try { + answer = await getFaqAnswer({ slug: params['answer-slug'] }) + + const { html } = await getGhostBody(answer.ghostSlug) + body = html + } catch { + return notFound() + } + + return ( +
+ ) +} diff --git a/apps/web/src/app/(cms)/faq/[category-slug]/[answer-group-slug]/components/answer-group-layout.tsx b/apps/web/src/app/(cms)/faq/[category-slug]/[answer-group-slug]/components/answer-group-layout.tsx new file mode 100644 index 0000000000..e44271aae6 --- /dev/null +++ b/apps/web/src/app/(cms)/faq/[category-slug]/[answer-group-slug]/components/answer-group-layout.tsx @@ -0,0 +1,83 @@ +import { getFaqAnswerGroup } from '@sushiswap/graph-client/strapi' +import { Container } from '@sushiswap/ui' +import { + Sidebar, + SidebarDesktop, + SidebarMobile, +} from '../../../components/sidebar' + +export const revalidate = 900 + +interface AnswerGroupLayoutProps { + children: React.ReactNode + params: { 'answer-group-slug': string } +} + +function AnswerGroupLayoutDesktop({ + children, + sidebar, +}: { children: React.ReactNode; sidebar: Sidebar }) { + return ( + +
+ +
+
+
{children}
+ + ) +} + +function AnswerGroupLayoutMobile({ + children, + sidebar, +}: { children: React.ReactNode; sidebar: Sidebar }) { + return ( +
+
+ +
+ {children} +
+ ) +} + +export async function AnswerGroupLayout({ + children, + params, +}: AnswerGroupLayoutProps) { + const answerGroup = await getFaqAnswerGroup({ + slug: params['answer-group-slug'], + }) + + const sidebarEntries = answerGroup.answers.map((answer) => { + return { + name: answer.name, + slug: answer.slug, + url: `/faq/${answerGroup.category.slug}/${answerGroup.slug}/${answer.slug}`, + } + }) + + const sidebar = { + entries: sidebarEntries, + param: 'answer-slug', + } + + return ( + <> +
+ + {children} + +
+
+ + {children} + +
+ + ) +} diff --git a/apps/web/src/app/(cms)/faq/[category-slug]/[answer-group-slug]/layout.tsx b/apps/web/src/app/(cms)/faq/[category-slug]/[answer-group-slug]/layout.tsx new file mode 100644 index 0000000000..cfd56cb721 --- /dev/null +++ b/apps/web/src/app/(cms)/faq/[category-slug]/[answer-group-slug]/layout.tsx @@ -0,0 +1,40 @@ +import { getFaqAnswerGroup } from '@sushiswap/graph-client/strapi' +import { Breadcrumb, Container, typographyVariants } from '@sushiswap/ui' +import { notFound } from 'next/navigation' +import React from 'react' +import { AnswerGroupLayout } from './components/answer-group-layout' + +export const revalidate = 900 + +export default async function Layout({ + children, + params, +}: { + children: React.ReactNode + params: { 'answer-group-slug': string } +}) { + let answerGroup + + try { + answerGroup = await getFaqAnswerGroup({ + slug: params['answer-group-slug'], + }) + } catch { + return notFound() + } + + return ( +
+
+ + +

+ {answerGroup.name} +

+
+
+
+ {children} +
+ ) +} diff --git a/apps/web/src/app/(cms)/faq/[category-slug]/[answer-group-slug]/route.ts b/apps/web/src/app/(cms)/faq/[category-slug]/[answer-group-slug]/route.ts new file mode 100644 index 0000000000..5bccdbc6ea --- /dev/null +++ b/apps/web/src/app/(cms)/faq/[category-slug]/[answer-group-slug]/route.ts @@ -0,0 +1,23 @@ +import { getFaqAnswerGroup } from '@sushiswap/graph-client/strapi' +import { notFound, redirect } from 'next/navigation' +import { NextRequest } from 'next/server' + +export const revalidate = 3600 + +export async function GET(request: NextRequest) { + const pathname = new URL(request.url).pathname + const answerGroupId = pathname.split('/').slice(-1)[0] + const answerGroup = await getFaqAnswerGroup({ slug: answerGroupId }) + + if (!answerGroup) { + return redirect(pathname.split('/').slice(0, -1).join('/')) + } + + const slug = answerGroup.defaultAnswer?.slug || answerGroup.answers?.[0].slug + + if (!slug) { + return notFound() + } + + redirect(`${pathname}/${slug}`) +} diff --git a/apps/web/src/app/(cms)/faq/components/sidebar.tsx b/apps/web/src/app/(cms)/faq/components/sidebar.tsx new file mode 100644 index 0000000000..9a1ec22b97 --- /dev/null +++ b/apps/web/src/app/(cms)/faq/components/sidebar.tsx @@ -0,0 +1,108 @@ +'use client' + +import { ChevronDownIcon } from '@heroicons/react-v1/solid' +import { classNames } from '@sushiswap/ui' +import Link from 'next/link' +import { useParams } from 'next/navigation' +import { useState } from 'react' + +interface Entry { + name: string + slug: string + url: string +} + +interface SidebarEntry { + entry: Entry + isActive: boolean +} + +function SidebarEntry({ entry, isActive }: SidebarEntry) { + return ( +
+ + {entry.name} + +
+ ) +} + +export interface Sidebar { + entries: Entry[] + param: string +} + +export function SidebarMobile({ entries, param }: Sidebar) { + const [open, setOpen] = useState(false) + + const active = useParams()[param] + const activeEntry = entries.find((entry) => entry.slug === active) + + return ( +
+
setOpen(!open)} + onKeyUp={() => setOpen(!open)} + > + {activeEntry?.name || ''} +
+ +
+
+
*]:overflow-hidden', + )} + > +
+
+ {entries.map((entry) => ( +
setOpen(!open)} + onKeyUp={() => setOpen(!open)} + > + +
+ ))} +
+
+
+ ) +} + +export function SidebarDesktop({ entries, param }: Sidebar) { + const active = useParams()[param] + + return ( +
+ {entries.map((entry) => ( + + ))} +
+ ) +} diff --git a/apps/web/src/app/(cms)/faq/layout.tsx b/apps/web/src/app/(cms)/faq/layout.tsx new file mode 100644 index 0000000000..d237b669ae --- /dev/null +++ b/apps/web/src/app/(cms)/faq/layout.tsx @@ -0,0 +1,9 @@ +import { Suspense } from 'react' + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + <> + {children} + + ) +} diff --git a/apps/web/src/app/(cms)/faq/lib/strapi/image.ts b/apps/web/src/app/(cms)/faq/lib/strapi/image.ts new file mode 100644 index 0000000000..5d3ef0d6de --- /dev/null +++ b/apps/web/src/app/(cms)/faq/lib/strapi/image.ts @@ -0,0 +1,33 @@ +import { z } from 'zod' + +export const imageSchema = z + .object({ + data: z.object({ + id: z.number(), + attributes: z.object({ + name: z.string(), + alternativeText: z + .string() + .nullable() + .transform((value) => value ?? ''), + caption: z + .string() + .nullable() + .transform((value) => value ?? ''), + width: z.number(), + height: z.number(), + formats: z.object({ + thumbnail: z.object({ + url: z.string(), + width: z.number(), + height: z.number(), + }), + }), + mime: z.string(), + url: z.string(), + hash: z.string(), + provider_metadata: z.object({ public_id: z.string() }), + }), + }), + }) + .transform((data) => data.data.attributes) diff --git a/apps/web/src/app/(cms)/faq/lib/strapi/productList.ts b/apps/web/src/app/(cms)/faq/lib/strapi/productList.ts new file mode 100644 index 0000000000..691ba80ae4 --- /dev/null +++ b/apps/web/src/app/(cms)/faq/lib/strapi/productList.ts @@ -0,0 +1,51 @@ +import { z } from 'zod' +import { imageSchema } from './image' +import { strapi } from './strapi' + +const schema = z.array( + z + .object({ + id: z.number(), + attributes: z.object({ + name: z.string(), + description: z.string(), + // createdAt: z.string().transform(Date), + // updatedAt: z.string().transform(Date), + // publishedAt: z.string().transform(Date), + image: imageSchema, + faqAnswerGroup: z.object({ + data: z.object({ + id: z.number(), + attributes: z.object({ + name: z.string(), + slug: z.string(), + faqCategory: z.object({ + data: z.object({ + attributes: z.object({ + slug: z.string(), + }), + }), + }), + }), + }), + }), + }), + }) + .transform((data) => ({ + name: data.attributes.name, + description: data.attributes.description, + url: `/faq/${data.attributes.faqAnswerGroup.data.attributes.faqCategory.data.attributes.slug}/${data.attributes.faqAnswerGroup.data.attributes.slug}`, + image: data.attributes.image, + })), +) + +export type ProductListEntry = z.infer[number] + +export async function getFaqProductList() { + const { data } = await strapi.find('faq-products', { + fields: ['id', 'name', 'description'], + populate: ['image', 'faqAnswerGroup', 'faqAnswerGroup.faqCategory.slug'], + }) + + return schema.parse(data) +} diff --git a/apps/web/src/app/(cms)/faq/lib/strapi/strapi.ts b/apps/web/src/app/(cms)/faq/lib/strapi/strapi.ts new file mode 100644 index 0000000000..d14faff034 --- /dev/null +++ b/apps/web/src/app/(cms)/faq/lib/strapi/strapi.ts @@ -0,0 +1,5 @@ +import Strapi from 'strapi-sdk-js' + +export const strapi = new Strapi({ + url: 'https://sushi-strapi-cms.herokuapp.com/', +}) diff --git a/apps/web/src/app/(cms)/header.tsx b/apps/web/src/app/(cms)/header.tsx index ffa8b887d7..08c9ac0c35 100644 --- a/apps/web/src/app/(cms)/header.tsx +++ b/apps/web/src/app/(cms)/header.tsx @@ -1,4 +1,4 @@ -import { getDifficulties, getProducts } from '@sushiswap/graph-client/strapi' +import { getDifficulties } from '@sushiswap/graph-client/strapi' import { Navigation, NavigationElement, @@ -22,20 +22,8 @@ export interface HeaderSection { className?: string } -const PRODUCTS_ORDER = ['furo', 'sushixswap', 'onsen', 'bentobox'] - export async function Header() { - const [products, difficulties] = await Promise.all([ - getProducts(), - getDifficulties(), - ]) - - const sortedProducts = products.sort((a, b) => - PRODUCTS_ORDER.indexOf(a.slug as (typeof PRODUCTS_ORDER)[number]) > - PRODUCTS_ORDER.indexOf(b.slug as (typeof PRODUCTS_ORDER)[number]) - ? 1 - : -1, - ) + const difficulties = await getDifficulties() const navData: NavigationElement[] = [ { @@ -57,14 +45,10 @@ export async function Header() { type: NavigationElementType.Single, }, { - title: 'Products', - items: sortedProducts.map(({ longName, slug }) => ({ - title: longName, - href: `/academy/products/${slug}`, - description: '', - })), - show: 'desktop', - type: NavigationElementType.Dropdown, + title: 'FAQ', + href: '/faq', + show: 'everywhere', + type: NavigationElementType.Single, }, { title: 'Learn', diff --git a/apps/web/src/app/(cms)/layout.tsx b/apps/web/src/app/(cms)/layout.tsx index 6c3f9b30b9..6d26ec0c57 100644 --- a/apps/web/src/app/(cms)/layout.tsx +++ b/apps/web/src/app/(cms)/layout.tsx @@ -7,7 +7,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { return (
-
{children}
+
{children}
) } diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 07a69dc6b7..008202471b 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -69,7 +69,7 @@ export default function RootLayout({ - + {children} diff --git a/config/tailwindcss/index.js b/config/tailwindcss/index.js index a5706744ce..320821fe3a 100644 --- a/config/tailwindcss/index.js +++ b/config/tailwindcss/index.js @@ -38,6 +38,7 @@ module.exports = { theme: { screens: { ...defaultTheme.screens, + xs: '380px', }, extend: { fontFamily: { diff --git a/packages/graph-client/src/lib/types/scalars.ts b/packages/graph-client/src/lib/types/scalars.ts index 6c7aa3558b..03317391b8 100644 --- a/packages/graph-client/src/lib/types/scalars.ts +++ b/packages/graph-client/src/lib/types/scalars.ts @@ -9,12 +9,20 @@ import type { SushiSwapV3ChainId, } from 'sushi/config' +type JSONValue = string | number | boolean | null | JSONArray | JSONObject + +export interface JSONObject { + [key: string]: JSONValue +} + +interface JSONArray extends Array {} + export type Scalars = { BigInt: string BigDecimal: string Bytes: `0x${string}` DateTime: string - JSON: object + JSON: JSONObject ChainId: ChainId diff --git a/packages/graph-client/src/subgraphs/strapi/index.ts b/packages/graph-client/src/subgraphs/strapi/index.ts index 5f94c401fa..8d0a11f8cd 100644 --- a/packages/graph-client/src/subgraphs/strapi/index.ts +++ b/packages/graph-client/src/subgraphs/strapi/index.ts @@ -2,6 +2,13 @@ export * from './queries/academy-articles' export * from './queries/banners' export * from './queries/blog-articles' export * from './queries/difficulties' +export * from './queries/faq-answer-group' +export * from './queries/faq-answer-search' +export * from './queries/faq-answers' +export * from './queries/faq-categories' +export * from './queries/faq-category' +export * from './queries/faq-most-searched' +export * from './queries/faq-products' export * from './queries/products' export * from './queries/topics' diff --git a/packages/graph-client/src/subgraphs/strapi/queries/faq-answer-group.ts b/packages/graph-client/src/subgraphs/strapi/queries/faq-answer-group.ts new file mode 100644 index 0000000000..21db1a1cc4 --- /dev/null +++ b/packages/graph-client/src/subgraphs/strapi/queries/faq-answer-group.ts @@ -0,0 +1,85 @@ +import type { VariablesOf } from 'gql.tada' + +import { request, type RequestOptions } from 'src/lib/request' +import { graphql } from '../graphql' +import { STRAPI_GRAPHQL_URL } from 'src/subgraphs/strapi/constants' + +export const StrapiFaqAnswerGroupQuery = graphql( + `query FaqAnswerGroup($slug: String!) { + faqAnswerGroups(filters: { slug: { eq: $slug } }) { + data { + attributes { + name + slug + faqAnswers(pagination: { limit: 10000 }) { + data { + attributes { + name + slug + ghostSlug + } + } + } + faqDefaultAnswer { + data { + attributes { + name + slug + } + } + } + faqCategory { + data { + attributes { + slug + } + } + } + } + } + } + }`, +) + +export type GetFaqAnswerGroup = VariablesOf + +export type FaqAnswerGroup = Awaited> + +export async function getFaqAnswerGroup( + variables: GetFaqAnswerGroup, + options?: RequestOptions, +) { + const result = await request( + { + url: STRAPI_GRAPHQL_URL, + document: StrapiFaqAnswerGroupQuery, + variables, + }, + options, + ) + + const data = result.faqAnswerGroups?.data[0]?.attributes + + if (!data) { + throw new Error('Failed to fetch AnswerGroup') + } + + const answerGroup = { + name: data.name, + slug: data.slug, + answers: data.faqAnswers.data.map((answer) => ({ + name: answer.attributes.name, + slug: answer.attributes.slug, + ghostSlug: answer.attributes.ghostSlug, + })), + defaultAnswer: data.faqDefaultAnswer?.data + ? { + name: data.faqDefaultAnswer.data.attributes.name, + slug: data.faqDefaultAnswer.data.attributes.slug, + } + : null, + category: { slug: data.faqCategory.data!.attributes.slug }, + } + + return answerGroup +} diff --git a/packages/graph-client/src/subgraphs/strapi/queries/faq-answer-search.ts b/packages/graph-client/src/subgraphs/strapi/queries/faq-answer-search.ts new file mode 100644 index 0000000000..3847b27852 --- /dev/null +++ b/packages/graph-client/src/subgraphs/strapi/queries/faq-answer-search.ts @@ -0,0 +1,95 @@ +import type { VariablesOf } from 'gql.tada' + +import { request, type RequestOptions } from 'src/lib/request' +import { graphql } from '../graphql' +import { STRAPI_GRAPHQL_URL } from 'src/subgraphs/strapi/constants' + +export const StrapiFaqAnswerSearchQuery = graphql( + `query FaqAnswerSearch($search: String!) { + faqAnswers(filters: { name: { containsi: $search } }) { + data { + attributes { + name + slug + faqAnswerGroup { + data { + attributes { + slug + faqCategory { + data { + attributes { + slug + } + } + } + } + } + } + } + } + } + + faqAnswerGroups(filters: { name: { containsi: $search } }) { + data { + attributes { + name + slug + faqDefaultAnswer { + data { + attributes { + slug + } + } + } + faqCategory { + data { + attributes { + slug + } + } + } + } + } + } + }`, +) + +export type GetFaqAnswerSearch = VariablesOf + +export type FaqAnswerSearch = Awaited> + +/** + * @brief Purposefully built for the search box + */ +export async function getFaqAnswerSearch( + variables: GetFaqAnswerSearch, + options?: RequestOptions, +) { + const result = await request( + { + url: STRAPI_GRAPHQL_URL, + document: StrapiFaqAnswerSearchQuery, + variables, + }, + options, + ) + + if (!result.faqAnswerGroups || !result.faqAnswers) { + throw new Error('Failed to fetch for search') + } + + const faqAnswerGroups = result.faqAnswerGroups.data + const faqAnswers = result.faqAnswers.data + + const answerGroups = faqAnswerGroups.map((group) => ({ + name: group.attributes.name, + slug: `${group.attributes.faqCategory.data?.attributes.slug}/${group.attributes.slug}/${group.attributes.faqDefaultAnswer?.data?.attributes.slug}`, + })) + + const answers = faqAnswers.map((answer) => ({ + name: answer.attributes.name, + slug: `${answer.attributes.faqAnswerGroup.data?.attributes.faqCategory.data?.attributes.slug}/${answer.attributes.faqAnswerGroup.data?.attributes.slug}/${answer.attributes.slug}`, + })) + + return { answers, answerGroups } +} diff --git a/packages/graph-client/src/subgraphs/strapi/queries/faq-answers.ts b/packages/graph-client/src/subgraphs/strapi/queries/faq-answers.ts new file mode 100644 index 0000000000..71bfcc03fe --- /dev/null +++ b/packages/graph-client/src/subgraphs/strapi/queries/faq-answers.ts @@ -0,0 +1,67 @@ +import type { VariablesOf } from 'gql.tada' + +import { request, type RequestOptions } from 'src/lib/request' +import { graphql } from '../graphql' +import { STRAPI_GRAPHQL_URL } from 'src/subgraphs/strapi/constants' + +export const StrapiFaqAnswersQuery = graphql( + `query FaqAnswers($filters: FaqAnswerFiltersInput, $pagination: PaginationArg, $publicationState: PublicationState = LIVE, $sort: [String] = ["publishedAt:desc"]) { + faqAnswers(filters: $filters, pagination: $pagination, publicationState: $publicationState, sort: $sort) { + data { + id + attributes { + name + slug + ghostSlug + } + } + } + }`, +) + +export type GetFaqAnswers = VariablesOf + +export type FaqAnswer = Awaited>[number] + +export async function getFaqAnswers( + variables: GetFaqAnswers = {}, + options?: RequestOptions, +) { + const result = await request( + { + url: STRAPI_GRAPHQL_URL, + document: StrapiFaqAnswersQuery, + variables, + }, + options, + ) + + if (!result.faqAnswers) { + throw new Error('Failed to fetch answers') + } + + const answers = result.faqAnswers.data.map((article) => ({ + id: article.id, + name: article.attributes.name, + slug: article.attributes.slug, + ghostSlug: article.attributes.ghostSlug, + })) + + return answers +} + +export async function getFaqAnswer({ slug }: { slug: string }) { + const answers = await getFaqAnswers({ + filters: { + slug: { eq: slug }, + }, + }) + + const answer = answers[0] + + if (!answer) { + throw new Error('Failed to fetch answer') + } + + return answer +} diff --git a/packages/graph-client/src/subgraphs/strapi/queries/faq-categories.ts b/packages/graph-client/src/subgraphs/strapi/queries/faq-categories.ts new file mode 100644 index 0000000000..c715859007 --- /dev/null +++ b/packages/graph-client/src/subgraphs/strapi/queries/faq-categories.ts @@ -0,0 +1,49 @@ +import type { VariablesOf } from 'gql.tada' + +import { request, type RequestOptions } from 'src/lib/request' +import { graphql } from '../graphql' +import { STRAPI_GRAPHQL_URL } from 'src/subgraphs/strapi/constants' + +export const StrapiFaqCategoriesQuery = graphql( + `query FaqCategories($filters: FaqCategoryFiltersInput, $pagination: PaginationArg, $publicationState: PublicationState = LIVE, $sort: [String] = ["publishedAt:desc"]) { + faqCategories(filters: $filters, pagination: $pagination, publicationState: $publicationState, sort: $sort) { + data { + id + attributes { + name + slug + } + } + } + }`, +) + +export type GetFaqCategories = VariablesOf + +export type FaqCategories = Awaited> + +export async function getFaqCategories( + variables: GetFaqCategories = {}, + options?: RequestOptions, +) { + const result = await request( + { + url: STRAPI_GRAPHQL_URL, + document: StrapiFaqCategoriesQuery, + variables, + }, + options, + ) + + if (!result.faqCategories) { + throw new Error('Failed to fetch categories') + } + + const categories = result.faqCategories.data.map((category) => ({ + name: category.attributes.name, + slug: category.attributes.slug, + url: `/faq/${category.attributes.slug}`, + })) + + return categories +} diff --git a/packages/graph-client/src/subgraphs/strapi/queries/faq-category.ts b/packages/graph-client/src/subgraphs/strapi/queries/faq-category.ts new file mode 100644 index 0000000000..b590fbd215 --- /dev/null +++ b/packages/graph-client/src/subgraphs/strapi/queries/faq-category.ts @@ -0,0 +1,88 @@ +import type { VariablesOf } from 'gql.tada' + +import { request, type RequestOptions } from 'src/lib/request' +import { graphql } from '../graphql' +import { STRAPI_GRAPHQL_URL } from 'src/subgraphs/strapi/constants' + +export const StrapiFaqCategoryQuery = graphql( + `query FaqCategory($slug: String!) { + faqCategories(filters: { slug: { eq: $slug } }) { + data { + id + attributes { + name + slug + faqAnswerGroups(publicationState: LIVE) { + data { + attributes { + name + slug + faqDefaultAnswer { + data { + attributes { + name + slug + } + } + } + faqAnswers { + data { + attributes { + name + slug + } + } + } + } + } + } + } + } + } + }`, +) + +export type GetFaqCategory = VariablesOf + +export type FaqCategory = Awaited> + +export async function getFaqCategory( + variables: GetFaqCategory, + options?: RequestOptions, +) { + const result = await request( + { + url: STRAPI_GRAPHQL_URL, + document: StrapiFaqCategoryQuery, + variables, + }, + options, + ) + + const data = result.faqCategories.data[0]?.attributes + + if (!data) { + throw new Error('Failed to fetch category') + } + + const category = { + name: data.name, + slug: data.slug, + answerGroups: data.faqAnswerGroups.data.map((group) => ({ + name: group.attributes.name, + slug: group.attributes.slug, + defaultAnswer: group.attributes.faqDefaultAnswer?.data + ? { + name: group.attributes.faqDefaultAnswer.data.attributes.name, + slug: group.attributes.faqDefaultAnswer.data.attributes.slug, + } + : null, + answers: group.attributes.faqAnswers.data.map((answer) => ({ + name: answer.attributes.name, + slug: answer.attributes.slug, + })), + })), + } + + return category +} diff --git a/packages/graph-client/src/subgraphs/strapi/queries/faq-most-searched.ts b/packages/graph-client/src/subgraphs/strapi/queries/faq-most-searched.ts new file mode 100644 index 0000000000..8096c22929 --- /dev/null +++ b/packages/graph-client/src/subgraphs/strapi/queries/faq-most-searched.ts @@ -0,0 +1,82 @@ +import type { VariablesOf } from 'gql.tada' + +import { request, type RequestOptions } from 'src/lib/request' +import { graphql } from '../graphql' +import { STRAPI_GRAPHQL_URL } from 'src/subgraphs/strapi/constants' + +export const StrapiFaqMostSearchedQuery = graphql( + `query FaqMostSearched { + faqMostSearcheds { + data { + id + attributes { + faqAnswerGroup { + data { + attributes { + name + slug + faqDefaultAnswer { + data { + attributes { + slug + } + } + } + faqCategory { + data { + attributes { + slug + } + } + } + } + } + } + } + } + } + }`, +) + +export type GetFaqMostSearched = VariablesOf + +export type FaqMostSearched = Awaited> + +export async function getFaqMostSearched( + variables: GetFaqMostSearched = {}, + options?: RequestOptions, +) { + const result = await request( + { + url: STRAPI_GRAPHQL_URL, + document: StrapiFaqMostSearchedQuery, + variables, + }, + options, + ) + + if (!result.faqMostSearcheds) { + throw new Error('Failed to fetch faq most searched') + } + + const mostSearched = result.faqMostSearcheds.data.map((mostSearched) => { + const faqAnswerGroup = + mostSearched.attributes.faqAnswerGroup!.data.attributes + const faqCategory = faqAnswerGroup.faqCategory.data!.attributes + + let url = `/faq/${faqCategory.slug}/${faqAnswerGroup.slug}` + + const faqDefaultAnswer = faqAnswerGroup?.faqDefaultAnswer?.data?.attributes + + if (faqDefaultAnswer) { + url += `/${faqDefaultAnswer.slug}` + } + + return { + question: faqAnswerGroup.name, + url, + } + }) + + return mostSearched +} diff --git a/packages/graph-client/src/subgraphs/strapi/queries/faq-products.ts b/packages/graph-client/src/subgraphs/strapi/queries/faq-products.ts new file mode 100644 index 0000000000..5c13f60a19 --- /dev/null +++ b/packages/graph-client/src/subgraphs/strapi/queries/faq-products.ts @@ -0,0 +1,77 @@ +import type { VariablesOf } from 'gql.tada' + +import { request, type RequestOptions } from 'src/lib/request' +import { graphql } from '../graphql' +import { STRAPI_GRAPHQL_URL } from 'src/subgraphs/strapi/constants' +import { ImageFieldsFragment } from '../fragments/image-fields' +import { transformImage } from '../transforms/transform-image' + +export const StrapiFaqProductsQuery = graphql( + `query FaqProducts($filters: FaqProductFiltersInput, $pagination: PaginationArg, $publicationState: PublicationState = LIVE, $sort: [String] = ["publishedAt:desc"]) { + faqProducts(filters: $filters, pagination: $pagination, publicationState: $publicationState, sort: $sort) { + data { + id + attributes { + name + description + faqAnswerGroup { + data { + attributes { + slug + faqCategory { + data { + attributes { + slug + } + } + } + } + } + } + image { + data { + ...ImageFields + } + } + } + } + } + }`, + [ImageFieldsFragment], +) + +export type GetFaqProducts = VariablesOf + +export type FaqProducts = Awaited> + +export async function getFaqProducts( + variables: GetFaqProducts = {}, + options?: RequestOptions, +) { + const result = await request( + { + url: STRAPI_GRAPHQL_URL, + document: StrapiFaqProductsQuery, + variables, + }, + options, + ) + + if (!result.faqProducts) { + throw new Error('Failed to fetch Products') + } + + const products = result.faqProducts.data.map((product) => ({ + name: product.attributes.name, + description: product.attributes.description, + url: `/faq/${ + product.attributes.faqAnswerGroup?.data?.attributes.faqCategory.data + ?.attributes.slug + }/${product.attributes.faqAnswerGroup!.data?.attributes.slug}`, + image: product.attributes.image.data + ? transformImage(product.attributes.image.data) + : null, + })) + + return products +} diff --git a/packages/ui/src/components/breadcrumb.tsx b/packages/ui/src/components/breadcrumb.tsx index ee7e4b541e..19ad6708ad 100644 --- a/packages/ui/src/components/breadcrumb.tsx +++ b/packages/ui/src/components/breadcrumb.tsx @@ -2,7 +2,7 @@ import { ChevronRightIcon } from '@heroicons/react/20/solid' import { usePathname, useSearchParams } from 'next/navigation' -import React, { Suspense } from 'react' +import React, { FC, Suspense, useCallback, useMemo } from 'react' import classNames from 'classnames' import { Button } from './button' @@ -40,9 +40,31 @@ const Params = () => { ) : null } -export const Breadcrumb = () => { +export const Breadcrumb: FC<{ + replace?: Record + truncate?: boolean +}> = ({ replace, truncate = true }) => { const pathname = usePathname() - const items = pathname.split('/').slice(2) + + const replaceFn = useCallback( + (text: string) => { + if (!replace) return text + + return Object.entries(replace).reduce((acc, [from, to]) => { + return acc.replace(new RegExp(from, 'g'), to) + }, text) + }, + [replace], + ) + + const items = useMemo(() => { + const split = pathname.split('/').slice(1) + + return split.map((url) => ({ + url: url.replace(/%3A/g, ':'), + label: replaceFn(url), + })) + }, [replaceFn, pathname]) return (
@@ -67,25 +89,26 @@ export const Breadcrumb = () => { className="text-muted-foreground" /> {items.map((segment, i) => { - const segments = [...items] - .map((s) => s.replace(/%3A/g, ':')) - .slice(0, i + 1) + if (i === 0) return null + + const segments = [...items].slice(0, i + 1) + const link = `/${segments.map(({ url }) => url).join('/')}` + return ( - + {i < items.length - 1 ? ( diff --git a/packages/ui/src/components/cloudinary-image.tsx b/packages/ui/src/components/cloudinary-image.tsx new file mode 100644 index 0000000000..0b0e69fb14 --- /dev/null +++ b/packages/ui/src/components/cloudinary-image.tsx @@ -0,0 +1,13 @@ +'use client' + +import Image, { ImageProps } from 'next/image' +import { FC } from 'react' +import { cloudinaryImageLoader } from '../cloudinary' + +type CloudinaryImage = Omit + +const CloudinaryImage: FC = (props) => { + return +} + +export { CloudinaryImage } diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index c2a3645635..c2784c0ecf 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -11,6 +11,7 @@ export * from './checkbox' export * from './chip' export * from './chip-input' export * from './clipboard-controller' +export * from './cloudinary-image' export * from './command' export * from './container' export * from './currency' diff --git a/packages/ui/src/components/navigation.tsx b/packages/ui/src/components/navigation.tsx index 13c45ad0e7..1f4bc3abe5 100644 --- a/packages/ui/src/components/navigation.tsx +++ b/packages/ui/src/components/navigation.tsx @@ -57,6 +57,11 @@ const SUPPORT_NAVIGATION_LINKS: NavigationElementDropdown['items'] = [ href: '/academy', description: 'Everything you need to get up to speed with DeFi.', }, + { + title: 'FAQ', + href: '/faq', + description: 'Answers to the most common questions about Sushi.', + }, ] const navigationContainerVariants = cva(