commit 0721b7dc8fa8ac6d0420210beb526bb0eb511644 Author: vespo92 Date: Wed Aug 9 21:34:23 2023 +0000 Initial commit Created from https://vercel.com/new diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1ab48b3 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +NEXT_PUBLIC_DRUPAL_BASE_URL=http://localhost:8080 +NEXT_IMAGE_DOMAIN=localhost +DRUPAL_CLIENT_ID=52ce1a10-bf5c-4c81-8edf-eea3af95da84 +DRUPAL_CLIENT_SECRET=SA9AGbHnx6pOamaAus2f9LG9XudHFjKs +DRUPAL_SITE_ID=example_marketing +DRUPAL_PREVIEW_SECRET=secret +DRUPAL_FRONT_PAGE=/home \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3aa970b --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# 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/ +.next + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +/certificates/* + +cypress/screenshots +cypress/videos \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..455bd9c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,531 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [1.5.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@1.4.4...example-marketing@1.5.0) (2022-12-06) + +**Note:** Version bump only for package example-marketing + + + + + +## [1.4.4](https://github.com/chapter-three/next-drupal/compare/example-marketing@1.4.3...example-marketing@1.4.4) (2022-12-06) + +**Note:** Version bump only for package example-marketing + + + + + +## [1.4.3](https://github.com/chapter-three/next-drupal/compare/example-marketing@1.4.2...example-marketing@1.4.3) (2022-09-07) + +**Note:** Version bump only for package example-marketing + + + + + +## [1.4.2](https://github.com/chapter-three/next-drupal/compare/example-marketing@1.4.1...example-marketing@1.4.2) (2022-08-09) + + +### Bug Fixes + +* Warning title element received an array with more than 1 element ([910ca41](https://github.com/chapter-three/next-drupal/commit/910ca41ca230e5948fbe9d880bed04232d004306)) + + + + + +## [1.4.1](https://github.com/chapter-three/next-drupal/compare/example-marketing@1.4.0...example-marketing@1.4.1) (2022-07-29) + +**Note:** Version bump only for package example-marketing + + + + + +# [1.4.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@1.4.0-rc.0...example-marketing@1.4.0) (2022-06-14) + +**Note:** Version bump only for package example-marketing + + + + + +# [1.4.0-rc.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@1.3.3-rc.1...example-marketing@1.4.0-rc.0) (2022-06-14) + + +### Features + +* rename Experimental_DrupalClient to DrupalClient ([fc549ec](https://github.com/chapter-three/next-drupal/commit/fc549ecab94a5a1e67f38b4e951351365adbb1f5)) + + + + + +## [1.3.3-rc.1](https://github.com/chapter-three/next-drupal/compare/example-marketing@1.3.3-rc.0...example-marketing@1.3.3-rc.1) (2022-06-10) + +**Note:** Version bump only for package example-marketing + + + + + +## [1.3.3-rc.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@1.3.3-alpha.0...example-marketing@1.3.3-rc.0) (2022-06-06) + +**Note:** Version bump only for package example-marketing + + + + + +## [1.3.3-alpha.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@1.3.2...example-marketing@1.3.3-alpha.0) (2022-06-02) + +**Note:** Version bump only for package example-marketing + + + + + +## [1.3.2](https://github.com/chapter-three/next-drupal/compare/example-marketing@1.3.1...example-marketing@1.3.2) (2022-05-02) + +**Note:** Version bump only for package example-marketing + + + + + +## [1.3.1](https://github.com/chapter-three/next-drupal/compare/example-marketing@1.3.0...example-marketing@1.3.1) (2022-04-25) + +**Note:** Version bump only for package example-marketing + + + + + +# [1.3.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@1.3.0-rc.0...example-marketing@1.3.0) (2022-04-19) + +**Note:** Version bump only for package example-marketing + + + + + +# [1.3.0-rc.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@1.3.0-alpha.0...example-marketing@1.3.0-rc.0) (2022-04-19) + +**Note:** Version bump only for package example-marketing + + + + + +# [1.3.0-alpha.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@1.2.2...example-marketing@1.3.0-alpha.0) (2022-04-18) + + +### Bug Fixes + +* **example-marketing:** update slug ([cd6f9cc](https://github.com/chapter-three/next-drupal/commit/cd6f9ccdb783f95bc95d5dc7a551edcd5b2601b2)) +* update examples ([c00cafb](https://github.com/chapter-three/next-drupal/commit/c00cafbf3c667265fd6f0478164808664f778433)) + + +### Features + +* add example-cache ([2b29d66](https://github.com/chapter-three/next-drupal/commit/2b29d66c8cddb66a331e3b588da8140a4e4ba61e)) +* **next-drupal:** rename DrupalClient to Experimental_DrupalClient ([0d5cf2f](https://github.com/chapter-three/next-drupal/commit/0d5cf2f44b503a2d8e61eee19146fd5b797356ab)) + + +### Reverts + +* Revert "chore: revert example-marketing" ([88a9505](https://github.com/chapter-three/next-drupal/commit/88a950508617e3e94a2b6504bb0ea95d7574c3b9)) + + + + + +## [1.2.2](https://github.com/chapter-three/next-drupal/compare/example-marketing@1.2.1...example-marketing@1.2.2) (2022-04-11) + +**Note:** Version bump only for package example-marketing + + + + + +## [1.2.1](https://github.com/chapter-three/next-drupal/compare/example-marketing@1.2.0...example-marketing@1.2.1) (2022-03-28) + + +### Bug Fixes + +* **example-blog:** switch to manual revalidation ([933ca86](https://github.com/chapter-three/next-drupal/commit/933ca86d47a35dc3bf2a120466f1fec197ca8b61)) +* **example-marketing:** turn off revalidate for pages ([22e8bf1](https://github.com/chapter-three/next-drupal/commit/22e8bf1f333e08aa6f13b6038e362bf826133acb)) + + + + + +# [1.2.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@1.1.1...example-marketing@1.2.0) (2022-02-24) + + +### Bug Fixes + +* **example-marketing:** add next-transpile-modules ([b574e30](https://github.com/chapter-three/next-drupal/commit/b574e3077ee90d742b9ec49713597bb4b8d8005e)) +* **example-marketing:** add tailwindcss forms ([e9b583f](https://github.com/chapter-three/next-drupal/commit/e9b583f645e2a8aa9a5bf70cc132fc31b2ebb6b3)) +* **example-marketing:** debug code for translatePath in getStaticProps ([c14b690](https://github.com/chapter-three/next-drupal/commit/c14b690fdc3bd2b1125ba080f0343293426f9e6b)) +* **example-marketing:** remove next-transpile-modules ([5b248c2](https://github.com/chapter-three/next-drupal/commit/5b248c2e4d44c9e2acf363743904e4078c5ec610)) +* **example-marketing:** remove tailwind forms ([630fc24](https://github.com/chapter-three/next-drupal/commit/630fc24e126560f1dccc6f495079e895fa4f70ca)) +* **example-marketing:** switch to next/link ([b39da07](https://github.com/chapter-three/next-drupal/commit/b39da077b2bb4c6cbbbd51a533aa7d061842c532)) +* **example-marketing:** update meta component ([4d4e99b](https://github.com/chapter-three/next-drupal/commit/4d4e99b7c9206bc05117ac10868c12e696f53dae)) +* **example-marketing:** update tailwindcss ([943d7f1](https://github.com/chapter-three/next-drupal/commit/943d7f1c9a37418125053e897b67b070065479c4)) +* **example-marketing:** use local version for next-drupal ([8903fc9](https://github.com/chapter-three/next-drupal/commit/8903fc9e79d29c0e4a87acd7357229c5fe1bc61b)) +* update gitignore ([0e05896](https://github.com/chapter-three/next-drupal/commit/0e05896f06a6a48bf82db4830c085e6f9c5e7b84)) + + +### Features + +* **example-marketing:** add revalidate api ([bf402b1](https://github.com/chapter-three/next-drupal/commit/bf402b1a8ae4937cb91cd636c1256161da7d612e)) +* bump all examples to next 12.1.0 ([00b15f2](https://github.com/chapter-three/next-drupal/commit/00b15f2b308a0a9fcb298789a9ca712f4efa7eff)) +* **example-marketing:** add meta og image ([116e29f](https://github.com/chapter-three/next-drupal/commit/116e29f3171b40b274435f1ac064567770ed8f32)) + + + + + +## [1.1.1](https://github.com/chapter-three/next-drupal/compare/example-marketing@1.1.0...example-marketing@1.1.1) (2022-01-17) + +**Note:** Version bump only for package example-marketing + + + + + +# [1.1.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@1.0.1...example-marketing@1.1.0) (2022-01-12) + + +### Features + +* **example-marketing:** handle redirects ([ba12711](https://github.com/chapter-three/next-drupal/commit/ba1271155ebb6dda80dac77e29683b07021c8e73)) + + + + + +## [1.0.1](https://github.com/chapter-three/next-drupal/compare/example-marketing@1.0.0...example-marketing@1.0.1) (2021-12-21) + +**Note:** Version bump only for package example-marketing + + + + + +# [1.0.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.17.0...example-marketing@1.0.0) (2021-12-03) + +**Note:** Version bump only for package example-marketing + + + + + +# [0.17.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.16.0...example-marketing@0.17.0) (2021-11-24) + + +### Bug Fixes + +* update .env files ([8c3207b](https://github.com/chapter-three/next-drupal/commit/8c3207b79bc641b605c11cec3fd556d0f71f1e72)) +* update page for marketing ([e527b36](https://github.com/chapter-three/next-drupal/commit/e527b360da861d3c69ca9a01954e851231b3ac51)) + + +### Features + +* **drupal:** update example modules ([3f2b578](https://github.com/chapter-three/next-drupal/commit/3f2b57822226e587e590fdcc5f760cae0b11d97f)) + + + + + +# [0.16.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.16.0-alpha.0...example-marketing@0.16.0) (2021-11-01) + +**Note:** Version bump only for package example-marketing + + + + + +# [0.16.0-alpha.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.15.2...example-marketing@0.16.0-alpha.0) (2021-11-01) + +**Note:** Version bump only for package example-marketing + + + + + +## [0.15.2](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.15.1...example-marketing@0.15.2) (2021-10-14) + +**Note:** Version bump only for package example-marketing + + + + + +## [0.15.1](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.15.0...example-marketing@0.15.1) (2021-10-14) + +**Note:** Version bump only for package example-marketing + + + + + +# [0.15.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.14.0...example-marketing@0.15.0) (2021-10-13) + + +### Bug Fixes + +* rename repo links ([48d52dd](https://github.com/chapter-three/next-drupal/commit/48d52dde79f69396ef706d152c03670117b6a480)) + + +### Features + +* **example-marketing:** add drupal jsonapi params example ([e663466](https://github.com/chapter-three/next-drupal/commit/e6634662139c614b36fb76370020298f9f89352a)) + + + + + +# [0.14.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.13.2...example-marketing@0.14.0) (2021-08-11) + + +### Features + +* **example-marketing:** implement route sync ([b49e531](https://github.com/chapter-three/next-drupal/commit/b49e5314692a037b6e420167609dfec216551b77)) + + + + + +## [0.13.2](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.13.1...example-marketing@0.13.2) (2021-08-07) + +**Note:** Version bump only for package example-marketing + + + + + +## [0.13.1](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.13.0...example-marketing@0.13.1) (2021-06-22) + + +### Bug Fixes + +* **example-marketing:** add missing dependency ([7a182f6](https://github.com/chapter-three/next-drupal/commit/7a182f68f0cf86b03977009c07660e886c9635eb)) +* **example-marketing:** use fallback blocking ([be5a2d3](https://github.com/chapter-three/next-drupal/commit/be5a2d32764d1c510ffb335cb5076cc927c65e0e)) + + + + + +# [0.13.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.12.0...example-marketing@0.13.0) (2021-06-16) + + +### Features + +* update to nextjs 11 ([1e46e44](https://github.com/chapter-three/next-drupal/commit/1e46e44ab5eb9d961e95dcc87d51282178f02bb2)) + + + + + +# [0.12.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.11.0...example-marketing@0.12.0) (2021-06-16) + + +### Features + +* **example-marketing:** add pagination to view ([feb6543](https://github.com/chapter-three/next-drupal/commit/feb6543be9a201f02b7afee38a3fed5c170de85b)) + + + + + +# [0.11.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.10.0...example-marketing@0.11.0) (2021-06-15) + + +### Bug Fixes + +* **example-marketing:** remove unused locale ([412e39d](https://github.com/chapter-three/next-drupal/commit/412e39d7ce9103f845fafaff8172c1e07bddb442)) + + +### Features + +* **example-marketing:** add properties view ([2b2804f](https://github.com/chapter-three/next-drupal/commit/2b2804f835a375bdc5076cbd4148604c964dbd06)) + + + + + +# [0.10.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.9.2...example-marketing@0.10.0) (2021-06-14) + + +### Features + +* **example-marketing:** update site to work with latest next-drupal ([9f27e7a](https://github.com/chapter-three/next-drupal/commit/9f27e7ae2b9c775ba4fe7d99fdfa0b8240a3dd90)) + + + + + +## [0.9.2](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.9.1...example-marketing@0.9.2) (2021-06-13) + +**Note:** Version bump only for package example-marketing + + + + + +## [0.9.1](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.9.0...example-marketing@0.9.1) (2021-06-13) + + +### Bug Fixes + +* update sites to handle unpublished entities ([93dd678](https://github.com/chapter-three/next-drupal/commit/93dd6786caff73398dd291c84b41d45c5bc50645)) + + + + + +# [0.9.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.8.0...example-marketing@0.9.0) (2021-06-13) + + +### Bug Fixes + +* update marketing site ([08863e6](https://github.com/chapter-three/next-drupal/commit/08863e6e8ab273799f1b1fcab793fdf51ad1d04b)) +* update preview ([f01ca6b](https://github.com/chapter-three/next-drupal/commit/f01ca6b9f68ac92c587b11c6e05f1145a57e8995)) + + +### Features + +* add support for basic page to example-marketing ([f9c5d1b](https://github.com/chapter-three/next-drupal/commit/f9c5d1b81697c59bc15b0372700fa2e979de9a00)) + + + + + +# [0.8.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.7.0...example-marketing@0.8.0) (2021-06-11) + + +### Features + +* update all sites ([ca9b2e9](https://github.com/chapter-three/next-drupal/commit/ca9b2e964c5a7fe591602465f2c2516eb4a54a1b)) + + + + + +# [0.7.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.6.0...example-marketing@0.7.0) (2021-06-10) + + +### Features + +* **examples:** update examples to use the new menu hook ([f9169e3](https://github.com/chapter-three/next-drupal/commit/f9169e34ab76584db855f1a69df027024156afff)) + + + + + +# [0.6.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.5.0...example-marketing@0.6.0) (2021-05-17) + + +### Features + +* add getEntityByPath ([072ead7](https://github.com/chapter-three/next-drupal/commit/072ead7ecc3b7f158e4b81e03d17f0bf1a5b511c)) + + + + + +# [0.5.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.4.1...example-marketing@0.5.0) (2021-05-17) + + +### Features + +* deserialize entities by default ([8b53ae2](https://github.com/chapter-three/next-drupal/commit/8b53ae222717b8983568194373be04903944a032)) + + + + + +## [0.4.1](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.4.0...example-marketing@0.4.1) (2021-05-07) + +**Note:** Version bump only for package example-marketing + + + + + +# [0.4.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.3.0...example-marketing@0.4.0) (2021-05-07) + + +### Features + +* add a no-preview example ([db48c6e](https://github.com/chapter-three/next-drupal/commit/db48c6e90ae5100eafb25d3b5688b5ef8131c477)) + + + + + +# [0.3.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.2.1...example-marketing@0.3.0) (2021-04-29) + + +### Bug Fixes + +* update .env.example ([467047b](https://github.com/chapter-three/next-drupal/commit/467047b010f54394c52760b9db960b06ee48db61)) + + +### Features + +* **www:** update to reflexjs 2.0.0-alpha-5 ([3b28c84](https://github.com/chapter-three/next-drupal/commit/3b28c84e9b7eefd4892aaf22dea0dd2512091b93)) +* update examples to reflexjs 2.0.0-alpha-4 ([49816ee](https://github.com/chapter-three/next-drupal/commit/49816ee6ba0f669d45cee6930b449547200ce1c7)) + + + + + +## [0.2.1](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.2.0...example-marketing@0.2.1) (2021-02-02) + +**Note:** Version bump only for package example-marketing + + + + + +# [0.2.0](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.1.1...example-marketing@0.2.0) (2021-02-01) + + +### Bug Fixes + +* make image domain configurable via env ([9b66159](https://github.com/chapter-three/next-drupal/commit/9b66159561a5e0bf17b4e73c4cde318e06fe938d)) +* update examples ([e70ed45](https://github.com/chapter-three/next-drupal/commit/e70ed459294cb8945f42d04cf7bd20a54ab9fe77)) + + +### Features + +* implement filter by entity reference ([eeade94](https://github.com/chapter-three/next-drupal/commit/eeade9485caaff587735d5d8211a86a88ca8847f)) + + + + + +## [0.1.1](https://github.com/chapter-three/next-drupal/compare/example-marketing@0.1.0...example-marketing@0.1.1) (2021-02-01) + + +### Bug Fixes + +* update example sites ([33143d0](https://github.com/chapter-three/next-drupal/commit/33143d0d5229be6424c41ace2ad846c0d85447d9)) + + + + + +# 0.1.0 (2021-01-31) + + +### Features + +* add marketing site ([8462d7c](https://github.com/chapter-three/next-drupal/commit/8462d7cfcf623a9e8ca03456ebed0bb6ab838e11)) diff --git a/README.md b/README.md new file mode 100644 index 0000000..b63cb32 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# example-marketing + +An example marketing site built using Drupal + JSON:API. + +Pages are built from the Landing page node type and paragraphs sourced from `/drupal`. + +See https://demo.next-drupal.org + +## License + +Licensed under the [MIT license](https://github.com/chapter-three/next-drupal/blob/master/LICENSE). diff --git a/components/footer.tsx b/components/footer.tsx new file mode 100644 index 0000000..567ed21 --- /dev/null +++ b/components/footer.tsx @@ -0,0 +1,31 @@ +import Link from "next/link" +import { DrupalMenuLinkContent } from "next-drupal" + +interface FooterProps { + links: DrupalMenuLinkContent[] +} + +export function Footer({ links }: FooterProps) { + return ( + + ) +} diff --git a/components/form-item.tsx b/components/form-item.tsx new file mode 100644 index 0000000..06fdd98 --- /dev/null +++ b/components/form-item.tsx @@ -0,0 +1,16 @@ +interface FormItemProps { + label: string + name: string + children?: React.ReactNode +} + +export function FormItem({ label, name, children, ...props }: FormItemProps) { + return ( +
+ + {children} +
+ ) +} diff --git a/components/formatted-text.tsx b/components/formatted-text.tsx new file mode 100644 index 0000000..cdb224e --- /dev/null +++ b/components/formatted-text.tsx @@ -0,0 +1,64 @@ +import Image from "next/image" +import { HTMLReactParserOptions, domToReact } from "html-react-parser" +import { Element } from "domhandler/lib/node" +import parse from "html-react-parser" + +import { isRelative } from "lib/utils/is-relative" +import Link from "next/link" + +const options: HTMLReactParserOptions = { + replace: (domNode) => { + if (domNode instanceof Element) { + if (domNode.name === "img") { + const { src, alt, width = "100px", height = "100px" } = domNode.attribs + + if (isRelative(src)) { + return ( + {alt} + ) + } + } + + if (domNode.name === "a") { + const { href, class: className } = domNode.attribs + + if (href && isRelative(href)) { + return ( + + {domToReact(domNode.children)} + + ) + } + } + + if (domNode.name === "input") { + if (domNode.attribs.value === "") { + delete domNode.attribs.value + } + + return domNode + } + } + }, +} + +interface FormattedTextProps extends React.HTMLAttributes { + format?: string + processed: string + value?: string +} + +export function FormattedText({ processed, ...props }: FormattedTextProps) { + return ( +
+ {parse(processed, options)} +
+ ) +} diff --git a/components/layout.tsx b/components/layout.tsx new file mode 100644 index 0000000..35dc96c --- /dev/null +++ b/components/layout.tsx @@ -0,0 +1,23 @@ +import * as React from "react" +import { DrupalMenuLinkContent } from "next-drupal" + +import { Navbar } from "components/navbar" +import { Footer } from "components/footer" + +export interface LayoutProps { + menus: { + main: DrupalMenuLinkContent[] + footer: DrupalMenuLinkContent[] + } + children?: React.ReactNode +} + +export function Layout({ menus, children }: LayoutProps) { + return ( +
+ +
{children}
+
+
+ ) +} diff --git a/components/link.tsx b/components/link.tsx new file mode 100644 index 0000000..0ae7fea --- /dev/null +++ b/components/link.tsx @@ -0,0 +1,38 @@ +import * as React from "react" +import NextLink from "next/link" +import type { LinkProps as NextLinkProps } from "next/link" +import { useRouter } from "next/router" + +import { isRelative } from "lib/utils/is-relative" + +interface LinkProps extends NextLinkProps { + href: string + children?: React.ReactElement +} + +export function Link({ href, passHref, as, children, ...props }: LinkProps) { + const router = useRouter() + + if (!href) { + return null + } + + // Use Next Link for internal links, and for others. + if (isRelative(href)) { + // Disable prefetching in preview mode. + // We do this here inside of inline `prefetch={!router.isPreview}` + // because `prefetch={true}` is not allowed. + // See https://nextjs.org/docs/messages/prefetch-true-deprecated + const linkProps = router.isPreview ? { prefetch: false, ...props } : props + + return ( + + {children} + + ) + } + + return React.cloneElement(children, { + href, + }) +} diff --git a/components/links.tsx b/components/links.tsx new file mode 100644 index 0000000..eba8cfb --- /dev/null +++ b/components/links.tsx @@ -0,0 +1,33 @@ +import classNames from "classnames" +import Link from "next/link" + +export interface LinksProps { + links: { + title: string + uri: string + options?: [] + }[] +} + +export function Links({ links }: LinksProps) { + if (!links.length) return null + + return ( +
+ {links.map((link, index) => ( + + + {link.title} + + + ))} +
+ ) +} diff --git a/components/locale-switcher.tsx b/components/locale-switcher.tsx new file mode 100644 index 0000000..6eebbfc --- /dev/null +++ b/components/locale-switcher.tsx @@ -0,0 +1,25 @@ +import classNames from "classnames" +import Link from "next/link" +import { useRouter } from "next/router" + +export function LocaleSwitcher() { + const { locales, asPath, locale: currentLocale } = useRouter() + + return ( +
+ {locales.map((locale) => ( + + + {locale} + + + ))} +
+ ) +} diff --git a/components/media--image.tsx b/components/media--image.tsx new file mode 100644 index 0000000..671442e --- /dev/null +++ b/components/media--image.tsx @@ -0,0 +1,34 @@ +import Image, { ImageProps } from "next/image" + +import { absoluteURL } from "lib/utils/absolute-url" +import { MediaProps } from "components/media" + +interface MediaImageProps extends MediaProps, Partial {} + +export function MediaImage({ + media, + layout = "responsive", + objectFit, + width, + height, + ...props +}: MediaImageProps) { + const image = media?.field_media_image + + if (!image) { + return null + } + + return ( + {image.resourceIdObjMeta.alt + ) +} diff --git a/components/media.tsx b/components/media.tsx new file mode 100644 index 0000000..71c324d --- /dev/null +++ b/components/media.tsx @@ -0,0 +1,25 @@ +import { DrupalMedia } from "next-drupal" + +import { MediaImage } from "components/media--image" + +const mediaTypes = { + "media--image": MediaImage, +} + +export interface MediaProps { + media: DrupalMedia +} + +export function Media({ media, ...props }: MediaProps) { + if (!media) { + return null + } + + const Component = mediaTypes[media.type] + + if (!Component) { + return null + } + + return +} diff --git a/components/meta.tsx b/components/meta.tsx new file mode 100644 index 0000000..71ca3ff --- /dev/null +++ b/components/meta.tsx @@ -0,0 +1,53 @@ +import Head from "next/head" +import { useRouter } from "next/router" +import { DrupalMetatag } from "types/drupal" + +interface MetaProps { + title?: string + path?: string + tags?: DrupalMetatag[] +} + +export function Meta({ title, tags }: MetaProps) { + const router = useRouter() + + return ( + + + {tags?.length ? ( + tags.map((tag, index) => { + if (tag.attributes.rel === "canonical") { + return null + } + + if (tag.attributes.name === "title") { + return ( + {tag.attributes.content} + ) + } + const Tag = tag.tag as keyof JSX.IntrinsicElements + return + }) + ) : ( + <> + {`${title} | Next.js for Drupal`} + + + + + + )} + + ) +} diff --git a/components/navbar.tsx b/components/navbar.tsx new file mode 100644 index 0000000..038569e --- /dev/null +++ b/components/navbar.tsx @@ -0,0 +1,64 @@ +import React from "react" +import { useRouter } from "next/router" +import { DrupalMenuLinkContent } from "next-drupal" +import classNames from "classnames" + +import { LocaleSwitcher } from "components/locale-switcher" +import Link from "next/link" + +interface NavbarProps { + links: DrupalMenuLinkContent[] +} + +export function Navbar({ links, ...props }: NavbarProps) { + const { locale } = useRouter() + + return ( +
+
+ + Marketing + + {links ? : null} +
+ +
+
+
+ ) +} + +function Menu({ items }: { items: DrupalMenuLinkContent[] }) { + return ( +
    + {items.map((item) => ( + + ))} +
+ ) +} + +function MenuLink({ link }: { link: DrupalMenuLinkContent }) { + const { asPath } = useRouter() + + return ( +
  • + + + {link.title} + + +
  • + ) +} diff --git a/components/node--article.tsx b/components/node--article.tsx new file mode 100644 index 0000000..6019b14 --- /dev/null +++ b/components/node--article.tsx @@ -0,0 +1,108 @@ +import Link from "next/link" +import Image from "next/image" + +import { formatDate } from "lib/utils/format-date" +import { absoluteURL } from "lib/utils/absolute-url" +import { FormattedText } from "components/formatted-text" +import { NodeProps } from "components/node" + +export function NodeArticle({ node, viewMode, ...props }: NodeProps) { + if (viewMode === "teaser") { + return + } + + if (viewMode === "full") { + return + } + + return null +} + +export function NodeArticleFull({ node, ...props }) { + return ( +
    +
    +

    + {node.title} +

    +
    +
    + {node.uid?.field_name ? ( + + Posted by {node.uid?.field_name} + + ) : null} + - {formatDate(node.created)} +
    + {node.body?.summary ?

    {node.body.summary}

    : null} + {node.field_image?.uri && ( + + )} + {node.body?.processed && ( + + )} +
    +
    +
    + ) +} + +export function NodeArticleTeaser({ node, ...props }) { + return ( +
    + {node.field_image?.uri && ( +
    + +
    + )} +

    + + {node.title} + +

    +
    + {node.uid?.field_name ? ( + + Posted by {node.uid?.field_name} + + ) : null} + - {formatDate(node.created)} +
    + {node.body?.summary ? ( +

    + {node.body.summary} +

    + ) : null} + + + Read more + + + + + +
    + ) +} diff --git a/components/node--landing-page.tsx b/components/node--landing-page.tsx new file mode 100644 index 0000000..ed368d0 --- /dev/null +++ b/components/node--landing-page.tsx @@ -0,0 +1,20 @@ +import { DrupalNode } from "next-drupal" + +import { Paragraph } from "components/paragraph" + +interface NodeLandingPage { + node: DrupalNode + viewMode?: string +} + +export function NodeLandingPage({ node }: NodeLandingPage) { + if (!node.field_sections?.length) return null + + return node.field_sections.map((paragraph) => { + if (paragraph.type === "paragraph--from_library") { + paragraph = paragraph.field_reusable_paragraph.paragraphs + } + + return + }) +} diff --git a/components/node--page.tsx b/components/node--page.tsx new file mode 100644 index 0000000..a928ffc --- /dev/null +++ b/components/node--page.tsx @@ -0,0 +1,17 @@ +import { FormattedText } from "components/formatted-text" +import { NodeProps } from "components/node" + +export function NodePage({ node, ...props }: NodeProps) { + delete props.viewMode + + return ( +
    +

    + {node.title} +

    +
    + {node.body && } +
    +
    + ) +} diff --git a/components/node--property.tsx b/components/node--property.tsx new file mode 100644 index 0000000..764d750 --- /dev/null +++ b/components/node--property.tsx @@ -0,0 +1,127 @@ +import Image from "next/image" + +import { NodeProps } from "components/node" + +export function NodeProperty({ node, viewMode, ...props }: NodeProps) { + if (viewMode === "list") { + return + } + + return +} + +export function NodePropertyGrid({ node }) { + const thumbnail = node.field_images?.[0].field_media_image + + return ( +
    + {node.field_status ? ( +

    + {node.field_status === "rent" ? "For Rent" : "For Sale"} +

    + ) : null} + {thumbnail && ( + + )} +
    +
    +

    {node.field_location.name}

    + {node.field_size && ( +

    + {node.field_size} + sqft +

    + )} +
    +

    {node.title}

    + {node.field_teaser && ( +

    {node.field_teaser}

    + )} +
    +
    +
    + {node.field_beds && ( +

    + {node.field_beds} + beds +

    + )} + {node.field_baths && ( +

    + {node.field_baths} + baths +

    + )} +
    +
    +
    +
    + ) +} + +export function NodePropertyList({ node }) { + const thumbnail = node.field_images?.[0].field_media_image + + return ( +
    + {node.field_status ? ( +

    + {node.field_status === "rent" ? "For Rent" : "For Sale"} +

    + ) : null} +
    + {thumbnail && ( + + )} +
    +
    +

    {node.field_location.name}

    + {node.field_size && ( +

    + {node.field_size} + sqft +

    + )} +
    +

    {node.title}

    + {node.field_teaser && ( +

    {node.field_teaser}

    + )} +
    +
    +
    + {node.field_beds && ( +

    + {node.field_beds} + beds +

    + )} + {node.field_baths && ( +

    + {node.field_baths} + baths +

    + )} +
    +
    +
    +
    +
    + ) +} diff --git a/components/node.tsx b/components/node.tsx new file mode 100644 index 0000000..d7b2664 --- /dev/null +++ b/components/node.tsx @@ -0,0 +1,32 @@ +import { DrupalNode } from "next-drupal" + +import { NodePage } from "components/node--page" +import { NodeArticle } from "components/node--article" +import { NodeLandingPage } from "components/node--landing-page" +import { NodeProperty } from "components/node--property" + +const nodeTypes = { + "node--page": NodePage, + "node--article": NodeArticle, + "node--landing_page": NodeLandingPage, + "node--property_listing": NodeProperty, +} + +export interface NodeProps { + node: DrupalNode + viewMode?: string +} + +export function Node({ node, viewMode = "full", ...props }: NodeProps) { + if (!node) { + return null + } + + const Component = nodeTypes[node.type] + + if (!Component) { + return null + } + + return +} diff --git a/components/pager.tsx b/components/pager.tsx new file mode 100644 index 0000000..4ae1a9a --- /dev/null +++ b/components/pager.tsx @@ -0,0 +1,80 @@ +import classNames from "classnames" + +import { usePagination, usePaginationProps } from "hooks/use-pagination" +import Link from "next/link" + +export interface PagerProps extends React.HTMLAttributes { + current: number + total: number + href: usePaginationProps["href"] +} + +export function Pager({ current, total, href, ...props }: PagerProps) { + const items = usePagination({ + current, + total, + href, + }) + + return ( + + ) +} diff --git a/components/paragraph--cards.tsx b/components/paragraph--cards.tsx new file mode 100644 index 0000000..4e9e712 --- /dev/null +++ b/components/paragraph--cards.tsx @@ -0,0 +1,38 @@ +import { ParagraphProps } from "components/paragraph" +import { SectionHeader } from "components/section-header" +import { FormattedText } from "components/formatted-text" +import { Section } from "components/section" + +export function ParagraphCards({ paragraph, ...props }: ParagraphProps) { + return ( +
    + +
    + {paragraph.field_items?.length && ( +
    + {paragraph.field_items.map((card) => ( +
    +

    {card.field_heading}

    + {card.field_text?.processed && ( + + )} +
    + ))} +
    + )} +
    +
    + ) +} diff --git a/components/paragraph--faq.tsx b/components/paragraph--faq.tsx new file mode 100644 index 0000000..b9b1714 --- /dev/null +++ b/components/paragraph--faq.tsx @@ -0,0 +1,41 @@ +import { ParagraphProps } from "components/paragraph" +import { Section } from "components/section" +import { SectionHeader } from "components/section-header" +import { FormattedText } from "./formatted-text" + +export function ParagraphFAQ({ paragraph, ...props }: ParagraphProps) { + return ( +
    + +
    + {paragraph.field_items && ( +
    + {paragraph.field_items.map((card) => ( +
    +

    + {card.field_heading} +

    + {card.field_text?.processed && ( + + )} +
    + ))} +
    + )} +
    +
    + ) +} diff --git a/components/paragraph--feature.tsx b/components/paragraph--feature.tsx new file mode 100644 index 0000000..5682a7c --- /dev/null +++ b/components/paragraph--feature.tsx @@ -0,0 +1,60 @@ +import Image from "next/image" +import classNames from "classnames" + +import { absoluteURL } from "lib/utils/absolute-url" +import { Links } from "components/links" +import { FormattedText } from "components/formatted-text" +import { ParagraphProps } from "components/paragraph" +import { Section } from "components/section" + +export function ParagraphFeature({ paragraph, ...props }: ParagraphProps) { + return ( +
    +
    +
    + {paragraph.field_media?.field_media_image && ( +
    + { +
    + )} +
    + {paragraph.field_heading && ( +

    + {paragraph.field_heading} +

    + )} + + {paragraph.field_link && } +
    +
    +
    +
    + ) +} diff --git a/components/paragraph--hero.tsx b/components/paragraph--hero.tsx new file mode 100644 index 0000000..442e933 --- /dev/null +++ b/components/paragraph--hero.tsx @@ -0,0 +1,38 @@ +import classNames from "classnames" + +import { ParagraphProps } from "components/paragraph" +import { MediaImage } from "components/media--image" +import { SectionHeader } from "components/section-header" + +export function ParagraphHero({ paragraph, ...props }: ParagraphProps) { + return ( +
    + +
    + {paragraph.field_media && ( +
    + +
    + )} +
    +
    + ) +} diff --git a/components/paragraph--view.tsx b/components/paragraph--view.tsx new file mode 100644 index 0000000..df03229 --- /dev/null +++ b/components/paragraph--view.tsx @@ -0,0 +1,9 @@ +import { ViewPropertiesListing } from "components/view--properties_listing" + +export function ParagraphView({ paragraph, ...props }) { + if (paragraph.field_view.name === "properties--listing") { + return + } + + return null +} diff --git a/components/paragraph.tsx b/components/paragraph.tsx new file mode 100644 index 0000000..92dd785 --- /dev/null +++ b/components/paragraph.tsx @@ -0,0 +1,33 @@ +import { DrupalParagraph } from "next-drupal" + +import { ParagraphCards } from "components/paragraph--cards" +import { ParagraphFAQ } from "components/paragraph--faq" +import { ParagraphFeature } from "components/paragraph--feature" +import { ParagraphHero } from "components/paragraph--hero" +import { ParagraphView } from "components/paragraph--view" + +const paragraphTypes = { + "paragraph--cards": ParagraphCards, + "paragraph--faq": ParagraphFAQ, + "paragraph--feature": ParagraphFeature, + "paragraph--hero": ParagraphHero, + "paragraph--view": ParagraphView, +} + +export interface ParagraphProps { + paragraph: DrupalParagraph +} + +export function Paragraph({ paragraph, ...props }: ParagraphProps) { + if (!paragraph) { + return null + } + + const Component = paragraphTypes[paragraph.type] + + if (!Component) { + return null + } + + return +} diff --git a/components/section-header.tsx b/components/section-header.tsx new file mode 100644 index 0000000..3595dd3 --- /dev/null +++ b/components/section-header.tsx @@ -0,0 +1,35 @@ +import { LinksProps, Links } from "components/links" +import { FormattedText } from "components/formatted-text" + +interface SectionHeaderProps { + level?: number + heading: string + text: string + links?: LinksProps["links"] +} + +export function SectionHeader({ + level = 2, + heading, + text, + links, + ...props +}: SectionHeaderProps) { + const HeadingLevel = `h${level}` as keyof JSX.IntrinsicElements + return ( +
    + {heading && ( + + {heading} + + )} + {text && ( + + )} + {links?.length ? : null} +
    + ) +} diff --git a/components/section.tsx b/components/section.tsx new file mode 100644 index 0000000..17dc6c3 --- /dev/null +++ b/components/section.tsx @@ -0,0 +1,17 @@ +import classNames from "classnames" + +interface SectionProps extends React.HTMLAttributes { + backgroundColor?: string + children?: React.ReactNode +} + +export function Section({ backgroundColor, children, ...props }: SectionProps) { + return ( +
    + {children} +
    + ) +} diff --git a/components/view--properties_listing.tsx b/components/view--properties_listing.tsx new file mode 100644 index 0000000..9b8ce8d --- /dev/null +++ b/components/view--properties_listing.tsx @@ -0,0 +1,290 @@ +import React from "react" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { useForm } from "react-hook-form" +import { deserialize } from "next-drupal" +import classNames from "classnames" + +import { FormItem } from "components/form-item" +import { Section } from "components/section" +import { Node } from "components/node" + +const filters = { location: "All", status: "All", beds: "1", baths: "1" } + +async function fetchView(url, params) { + const _url = new URL(url) + _url.search = new URLSearchParams(params).toString() + + const result = await fetch(_url.toString()) + + if (!result.ok) { + throw new Error(result.statusText) + } + + const data = await result.json() + + return { + results: deserialize(data), + meta: data.meta, + links: data.links, + } +} + +export function ViewPropertiesListing({ view: initialView, ...props }) { + const [page, setPage] = React.useState(0) + const queryClient = useQueryClient() + const [display, setDisplay] = React.useState<"grid" | "list">("grid") + const { register, handleSubmit, getValues, formState, reset } = useForm({ + defaultValues: filters, + }) + const [locations, setLocations] = React.useState([]) + const { + data: view, + isLoading, + isPreviousData, + } = useQuery( + [initialView.name, page], + async () => { + // Build params from form values. + const values = getValues() + const params = { + page: page + "", + include: "field_location,field_images.field_media_image", + } + for (const filter of Object.keys(filters)) { + if (values[filter]) { + params[`views-filter[${filter}]`] = values[filter] + } + } + + return fetchView(initialView.links.self.href.split("?")[0], params) + }, + { + initialData: initialView, + keepPreviousData: true, + } + ) + + // Build locations dropdown from view results. + React.useEffect(() => { + const allLocations: { + name: string + id: string + }[] = view.results.map((result) => ({ + name: result.field_location.name, + id: result.field_location.drupal_internal__tid, + })) + + setLocations( + Array.from(new Map(allLocations.map((item) => [item.id, item])).values()) + ) + }, []) + + async function submitForm() { + setPage(0) + await queryClient.invalidateQueries(view.name) + } + + async function resetForm() { + reset() + submitForm() + } + + return ( +
    +
    +
    +
    +

    Find your place

    +
    +
    +
    +
    + + + + + + +
    + + + + + + +
    +
    +
    +
    + + +
    +
    +
    +
    + {view.results.length ? ( + <> +
    +

    + Found {view.meta?.count} properties. +

    +
    + + +
    +
    +
    + {view.results.map((node) => ( + + ))} +
    +
    +
    + + +
    + + ) : ( +

    No properties found.

    + )} +
    +
    +
    + ) +} diff --git a/cypress.json b/cypress.json new file mode 100644 index 0000000..399eea2 --- /dev/null +++ b/cypress.json @@ -0,0 +1,3 @@ +{ + "baseUrl": "http://localhost:3001" +} diff --git a/cypress/fixtures/example.json b/cypress/fixtures/example.json new file mode 100644 index 0000000..02e4254 --- /dev/null +++ b/cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/cypress/integration/about.spec.tsx b/cypress/integration/about.spec.tsx new file mode 100644 index 0000000..3e869d0 --- /dev/null +++ b/cypress/integration/about.spec.tsx @@ -0,0 +1,14 @@ +/// + +context("About", () => { + beforeEach(() => { + cy.visit("/about") + }) + + it("should convert inline images to Next.js images", () => { + cy.get("img[data-nimg='intrinsic']") + cy.get("figcaption").contains("This is the caption") + }) +}) + +export {} diff --git a/cypress/integration/blog.spec.tsx b/cypress/integration/blog.spec.tsx new file mode 100644 index 0000000..dbd43a5 --- /dev/null +++ b/cypress/integration/blog.spec.tsx @@ -0,0 +1,66 @@ +/// + +context("Blog", () => { + beforeEach(() => { + cy.visit("/blog") + }) + + it("should render articles", () => { + cy.get("h1").contains("Latest Articles.") + cy.get("[data-cy=node--article]").should("have.length.gt", 1) + + cy.get("[data-cy=node--article] h2").contains( + "Dynamic Routing and Static Generation" + ) + cy.get("[data-cy=node--article] [data-cy=node--meta]").contains( + "Posted by Arshad" + ) + cy.get("[data-cy=node--article] [data-cy=node--meta]").contains( + "June 13, 2021" + ) + }) +}) + +context("Translation", () => { + beforeEach(() => { + cy.visit("/es/blog") + }) + + it("should render articles", () => { + cy.get("h1").contains("Últimas Publicaciones.") + cy.get("[data-cy=node--article]").should("have.length.gt", 1) + + cy.get("[data-cy=node--article] h2").contains( + "Enrutamiento dinámico y Generación Estática" + ) + cy.get("[data-cy=node--article] [data-cy=node--meta]").contains( + "Posted by Arshad" + ) + cy.get("[data-cy=node--article] [data-cy=node--meta]").contains( + "June 14, 2021" + ) + }) +}) + +context("Blog node", () => { + beforeEach(() => { + cy.visit("/blog/learn-how-pre-render-pages-using-static-generation-nextjs") + }) + + it("should render article node", () => { + cy.get("h1").contains( + "Learn How to Pre-render Pages Using Static Generation with Next.js" + ) + cy.get("[data-cy=node--article] [data-cy=node--body]").should( + "not.be.empty" + ) + cy.get("[data-cy=node--article] [data-cy=node--meta]").contains( + "Posted by Arshad" + ) + cy.get("[data-cy=node--article] [data-cy=node--meta]").contains( + "June 13, 2021" + ) + }) +}) + +export {} diff --git a/cypress/integration/home.spec.tsx b/cypress/integration/home.spec.tsx new file mode 100644 index 0000000..6506bea --- /dev/null +++ b/cypress/integration/home.spec.tsx @@ -0,0 +1,63 @@ +/// + +context("Home", () => { + beforeEach(() => { + cy.visit("") + }) + + it("should render paragraphs", () => { + cy.get("[data-cy=paragraph-hero] h1").contains("Build Something Amazing") + cy.get("[data-cy=paragraph-hero] p").contains( + "Must today firm from bag. Investment try cold a when capital. Everything wait person service." + ) + cy.get("[data-cy=paragraph-hero] [alt=Hero]").should("be.visible") + + cy.get("[data-cy=paragraph-feature] h2").contains("Marketing Strategy") + cy.get("[data-cy=paragraph-feature] img[alt=Feature]").should("be.visible") + + cy.get("[data-cy=paragraph-faq] h2").contains("Frequently Asked Questions") + cy.get("[data-cy=paragraph-faq] h3").contains( + "Move weight here just either attorney?" + ) + }) + + it("should render menu items", () => { + cy.get("[data-cy=navbar-menu] a").contains("Home") + cy.get("[data-cy=navbar-menu] a").contains("Blog") + cy.get("[data-cy=navbar-menu] a").contains("Properties") + cy.get("[data-cy=navbar-menu] a").contains("About") + cy.get("[data-cy=navbar-menu] a").contains("GitHub") + }) +}) + +context("Translation", () => { + beforeEach(() => { + cy.visit("/es") + }) + + it("should render paragraphs", () => { + cy.get("[data-cy=paragraph-hero] h1").contains("Construye Algo Asombroso") + cy.get("[data-cy=paragraph-hero] p").contains( + "Dicta laboriosam magnam possimus ad. Ratione rem nihil nostrum dolore reiciendis enim." + ) + cy.get("[data-cy=paragraph-hero] img[alt=Hero]").should("be.visible") + + cy.get("[data-cy=paragraph-feature] h2").contains("Marketing Strategy") + cy.get("[data-cy=paragraph-feature] img[alt=Feature]").should("be.visible") + + cy.get("[data-cy=paragraph-faq] h2").contains("Frequently Asked Questions") + cy.get("[data-cy=paragraph-faq] h3").contains( + "Move weight here just either attorney?" + ) + }) + + it("should render menu items localized", () => { + cy.get("[data-cy=navbar-menu] a").contains("Inicio") + cy.get("[data-cy=navbar-menu] a").contains("Blog") + cy.get("[data-cy=navbar-menu] a").contains("Propiedades") + cy.get("[data-cy=navbar-menu] a").contains("Acerca") + cy.get("[data-cy=navbar-menu] a").contains("GitHub") + }) +}) + +export {} diff --git a/cypress/integration/locale.spec.tsx b/cypress/integration/locale.spec.tsx new file mode 100644 index 0000000..2bba1b6 --- /dev/null +++ b/cypress/integration/locale.spec.tsx @@ -0,0 +1,19 @@ +/// + +context("Home", () => { + beforeEach(() => { + cy.visit("") + }) + + it("should switch locale when clicking on the locale switcher", () => { + cy.get("[data-cy=local-switcher-en]").contains("en") + cy.get("[data-cy=local-switcher-es]").contains("es") + + cy.get("[data-cy=paragraph-hero] h1").contains("Build Something Amazing") + cy.get("[data-cy=local-switcher-es]").click() + cy.url().should("include", "/es") + cy.get("[data-cy=paragraph-hero] h1").contains("Construye Algo Asombroso") + }) +}) + +export {} diff --git a/cypress/integration/properties.spec.tsx b/cypress/integration/properties.spec.tsx new file mode 100644 index 0000000..6acae2f --- /dev/null +++ b/cypress/integration/properties.spec.tsx @@ -0,0 +1,32 @@ +/// + +context("Properties", () => { + beforeEach(() => { + cy.visit("/properties") + }) + + it("should render view with meta and pager", () => { + cy.get("[data-cy=view--results]").contains("Found 12 properties.") + cy.get("[data-cy=node--property]").should("have.length", 4) + cy.get("[data-cy=node--property]") + .find("h4") + .contains("484 Robert Crest Apt. 875") + cy.get("[data-cy=pager-next]").should("not.be.disabled") + cy.get("[data-cy=pager-previous]").should("be.disabled") + + cy.get("[data-cy=pager-next]").click() + cy.get("[data-cy=node--property]").should("have.length", 4) + cy.get("[data-cy=pager-next]").should("not.be.disabled") + cy.get("[data-cy=pager-previous]").should("not.be.disabled") + }) + + it("should allow view to be filtered", () => { + cy.get("select[name='location']").select("San Francisco, CA") + cy.get("select[name='status']").select("For Sale") + cy.get("select[name='beds']").select("4") + cy.get("[data-cy=submit]").contains("Search").click() + cy.get("[data-cy=node--property]").should("have.length", 1) + }) +}) + +export {} diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js new file mode 100644 index 0000000..59b2bab --- /dev/null +++ b/cypress/plugins/index.js @@ -0,0 +1,22 @@ +/// +// *********************************************************** +// This example plugins/index.js can be used to load plugins +// +// You can change the location of this file or turn off loading +// the plugins file with the 'pluginsFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/plugins-guide +// *********************************************************** + +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) + +/** + * @type {Cypress.PluginConfig} + */ +// eslint-disable-next-line no-unused-vars +module.exports = (on, config) => { + // `on` is used to hook into various events Cypress emits + // `config` is the resolved Cypress config +} diff --git a/cypress/support/commands.js b/cypress/support/commands.js new file mode 100644 index 0000000..119ab03 --- /dev/null +++ b/cypress/support/commands.js @@ -0,0 +1,25 @@ +// *********************************************** +// This example commands.js shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add('login', (email, password) => { ... }) +// +// +// -- This is a child command -- +// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This will overwrite an existing command -- +// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) diff --git a/cypress/support/index.js b/cypress/support/index.js new file mode 100644 index 0000000..d68db96 --- /dev/null +++ b/cypress/support/index.js @@ -0,0 +1,20 @@ +// *********************************************************** +// This example support/index.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands' + +// Alternatively you can use CommonJS syntax: +// require('./commands') diff --git a/hooks/use-pagination.tsx b/hooks/use-pagination.tsx new file mode 100644 index 0000000..984dfc0 --- /dev/null +++ b/hooks/use-pagination.tsx @@ -0,0 +1,83 @@ +import * as React from "react" +import { LinkProps } from "next/link" + +type PagerItemType = "page" | "previous" | "next" + +export interface PagerItem { + type: PagerItemType + page: number + display: string + href: LinkProps["href"] + isCurrent?: boolean +} + +export interface usePaginationProps { + total: number + current: number + href: (page: PagerItem["page"]) => LinkProps["href"] + show?: number +} + +export const usePagination = ({ + total, + current, + href, + show = 9, +}: usePaginationProps): PagerItem[] => { + return React.useMemo((): PagerItem[] => { + const pagerMiddle = Math.ceil(show / 2) + const pagerCurrent = current + 1 + let pagerFirst = pagerCurrent - pagerMiddle + 1 + let pagerLast = pagerCurrent + show - pagerMiddle + + show = total < show ? total : show + + // Adjust start end end based on position. + if (pagerLast > total) { + pagerFirst = pagerFirst + (total - pagerLast) + pagerLast = total + } + + if (pagerFirst <= 0) { + pagerFirst = 1 + pagerLast = pagerLast + (1 - pagerFirst) + } + + const items: PagerItem[] = [] + + if (current !== 0) { + items.push({ + type: "previous", + display: "Previous", + page: current - 1, + href: href(current - 1), + }) + } + + items.push( + ...Array.from(Array(show).keys()).map( + (pageNumber: number): PagerItem | null => { + const page = pageNumber + pagerFirst - 1 + return { + type: "page", + page, + display: `${page + 1}`, + isCurrent: page === current, + href: href(page), + } + } + ) + ) + + if (current !== total - 1) { + items.push({ + type: "next", + display: "Next", + page: current + 1, + href: href(current + 1), + }) + } + + return items + }, [total, current]) +} diff --git a/lib/drupal.ts b/lib/drupal.ts new file mode 100644 index 0000000..0afd727 --- /dev/null +++ b/lib/drupal.ts @@ -0,0 +1,13 @@ +import { DrupalClient } from "next-drupal" + +export const drupal = new DrupalClient( + process.env.NEXT_PUBLIC_DRUPAL_BASE_URL, + { + frontPage: "/home", + previewSecret: "secret", + auth: { + clientId: process.env.DRUPAL_CLIENT_ID, + clientSecret: process.env.DRUPAL_CLIENT_SECRET, + }, + } +) diff --git a/lib/get-menus.ts b/lib/get-menus.ts new file mode 100644 index 0000000..3fa58d1 --- /dev/null +++ b/lib/get-menus.ts @@ -0,0 +1,22 @@ +import { GetStaticPropsContext } from "next" +import { DrupalMenuLinkContent } from "next-drupal" +import { drupal } from "lib/drupal" + +export async function getMenus(context: GetStaticPropsContext): Promise<{ + main: DrupalMenuLinkContent[] + footer: DrupalMenuLinkContent[] +}> { + const { tree: main } = await drupal.getMenu("main", { + locale: context.locale, + defaultLocale: context.defaultLocale, + }) + const { tree: footer } = await drupal.getMenu("footer", { + locale: context.locale, + defaultLocale: context.defaultLocale, + }) + + return { + main, + footer, + } +} diff --git a/lib/get-params.ts b/lib/get-params.ts new file mode 100644 index 0000000..a40d5ea --- /dev/null +++ b/lib/get-params.ts @@ -0,0 +1,39 @@ +import { DrupalJsonApiParams } from "drupal-jsonapi-params" + +export function getParams(resourceType: string) { + const apiParams = new DrupalJsonApiParams().addFilter( + "field_site.meta.drupal_internal__target_id", + process.env.DRUPAL_SITE_ID + ) + + if (resourceType === "node--landing_page") { + apiParams + .addInclude([ + "field_sections", + "field_sections.field_media.field_media_image", + "field_sections.field_items", + "field_sections.field_reusable_paragraph.paragraphs.field_items", + ]) + .addFields("node--landing_page", [ + "title", + "field_sections", + "path", + "status", + ]) + } + + if (resourceType === "node--article") { + apiParams.addInclude(["field_image", "uid"]) + apiParams.addFields(resourceType, [ + "title", + "body", + "uid", + "created", + "field_image", + "status", + "metatag", + ]) + } + + return apiParams.getQueryObject() +} diff --git a/lib/utils/absolute-url.ts b/lib/utils/absolute-url.ts new file mode 100644 index 0000000..8974f14 --- /dev/null +++ b/lib/utils/absolute-url.ts @@ -0,0 +1,3 @@ +export function absoluteURL(uri: string) { + return `${process.env.NEXT_PUBLIC_DRUPAL_BASE_URL}${uri}` +} diff --git a/lib/utils/format-date.ts b/lib/utils/format-date.ts new file mode 100644 index 0000000..fc6bdfa --- /dev/null +++ b/lib/utils/format-date.ts @@ -0,0 +1,8 @@ +export function formatDate(input: string): string { + const date = new Date(input) + return date.toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + }) +} diff --git a/lib/utils/is-relative.ts b/lib/utils/is-relative.ts new file mode 100644 index 0000000..8ec6e5d --- /dev/null +++ b/lib/utils/is-relative.ts @@ -0,0 +1,3 @@ +export function isRelative(url: string) { + return !new RegExp("^(?:[a-z]+:)?//", "i").test(url) +} diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..600c711 --- /dev/null +++ b/next.config.js @@ -0,0 +1,28 @@ +module.exports = { + swcMinify: true, + i18n: { + locales: ["en", "es"], + defaultLocale: "en", + }, + images: { + domains: [process.env.NEXT_IMAGE_DOMAIN], + }, + async rewrites() { + return [ + { + source: "/blog", + destination: "/blog/page/0", + }, + { + source: "/es", + destination: "/es/home", + locale: false, + }, + { + source: "/en/principal", + destination: "/", + locale: false, + }, + ] + }, +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1d771b6 --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "example-marketing", + "version": "1.5.0", + "private": true, + "license": "MIT", + "scripts": { + "dev": "next dev -p 3001", + "build": "next build", + "preview": "next build && next start -p 3001", + "lint": "next lint", + "cy:open": "cypress open", + "cy:run": "cypress run", + "test:e2e": "start-server-and-test 'yarn preview' http://localhost:3001 cy:open", + "test:e2e:ci": "start-server-and-test 'yarn preview' http://localhost:3001 cy:run" + }, + "dependencies": { + "@tailwindcss/forms": "^0.4.0", + "@tailwindcss/typography": "^0.5.1", + "@tanstack/react-query": "^4.0.10", + "classnames": "^2.3.1", + "drupal-jsonapi-params": "^1.2.2", + "html-react-parser": "^1.2.7", + "next": "^12.2.3", + "next-drupal": "^1.6.0", + "nprogress": "^0.2.0", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-hook-form": "^7.8.6" + }, + "devDependencies": { + "@babel/core": "^7.12.9", + "@types/node": "^17.0.21", + "@types/react": "^17.0.0", + "autoprefixer": "^10.4.2", + "eslint-config-next": "^12.0.10", + "postcss": "^8.4.5", + "tailwindcss": "^3.0.15", + "typescript": "^4.5.5" + } +} diff --git a/pages/[[...slug]].tsx b/pages/[[...slug]].tsx new file mode 100644 index 0000000..38e7d37 --- /dev/null +++ b/pages/[[...slug]].tsx @@ -0,0 +1,112 @@ +import * as React from "react" +import { + GetStaticPathsContext, + GetStaticPathsResult, + GetStaticPropsContext, + GetStaticPropsResult, +} from "next" +import Head from "next/head" +import { useRouter } from "next/router" +import { DrupalNode } from "next-drupal" + +import { drupal } from "lib/drupal" +import { getMenus } from "lib/get-menus" +import { absoluteURL } from "lib/utils/absolute-url" +import { getParams } from "lib/get-params" +import { Node } from "components/node" +import { Layout, LayoutProps } from "components/layout" +import { Meta } from "components/meta" + +const RESOURCE_TYPES = ["node--page", "node--landing_page", "node--article"] + +interface NodePageProps extends LayoutProps { + node: DrupalNode +} + +export default function NodePage({ node, menus }: NodePageProps) { + const router = useRouter() + + return ( + + + + {node.content_translations?.map((translation, index) => + translation.langcode !== router.locale ? ( + + ) : null + )} + + + + ) +} + +export async function getStaticPaths( + context: GetStaticPathsContext +): Promise { + return { + paths: await drupal.getStaticPathsFromContext(RESOURCE_TYPES, context, { + params: { + filter: { + "field_site.meta.drupal_internal__target_id": + process.env.DRUPAL_SITE_ID, + }, + }, + }), + fallback: "blocking", + } +} + +export async function getStaticProps( + context: GetStaticPropsContext +): Promise> { + const path = await drupal.translatePathFromContext(context) + + if (!path || !RESOURCE_TYPES.includes(path.jsonapi.resourceName)) { + return { + notFound: true, + } + } + + const type = path.jsonapi.resourceName + + const node = await drupal.getResourceFromContext(path, context, { + params: getParams(type), + }) + + if (!node || (!context.preview && node?.status === false)) { + return { + notFound: true, + } + } + + // Load initial view data. + if (type === "node--landing_page") { + for (const section of node.field_sections) { + if (section.type === "paragraph--view" && section.field_view) { + const view = await drupal.getView(section.field_view, { + params: { + include: "field_location,field_images.field_media_image", + }, + }) + + section.field_view = { + name: section.field_view, + ...view, + } + } + } + } + + return { + props: { + node, + menus: await getMenus(context), + }, + } +} diff --git a/pages/_app.tsx b/pages/_app.tsx new file mode 100644 index 0000000..c4b28f3 --- /dev/null +++ b/pages/_app.tsx @@ -0,0 +1,31 @@ +import * as React from "react" +import Router from "next/router" +import { QueryClient, QueryClientProvider, Hydrate } from "@tanstack/react-query" +import NProgress from "nprogress" +import { syncDrupalPreviewRoutes } from "next-drupal" +import "nprogress/nprogress.css" + +import "styles/globals.css" + +NProgress.configure({ showSpinner: false }) + +Router.events.on("routeChangeStart", function (path) { + syncDrupalPreviewRoutes(path) + NProgress.start() +}) +Router.events.on("routeChangeComplete", () => NProgress.done()) +Router.events.on("routeChangeError", () => NProgress.done()) + +export default function App({ Component, pageProps }) { + const queryClientRef = React.useRef() + if (!queryClientRef.current) { + queryClientRef.current = new QueryClient() + } + return ( + + + + + + ) +} diff --git a/pages/_document.tsx b/pages/_document.tsx new file mode 100644 index 0000000..1b6ab3e --- /dev/null +++ b/pages/_document.tsx @@ -0,0 +1,17 @@ +import NextDocument, { Html, Main, NextScript, Head } from "next/document" + +export default class Document extends NextDocument { + render() { + return ( + + + + + +
    + + + + ) + } +} diff --git a/pages/api/exit-preview.ts b/pages/api/exit-preview.ts new file mode 100644 index 0000000..e08051e --- /dev/null +++ b/pages/api/exit-preview.ts @@ -0,0 +1,8 @@ +import { NextApiResponse } from "next" + +export default async function exit(_, response: NextApiResponse) { + response.clearPreviewData() + + response.writeHead(307, { Location: "/" }) + response.end() +} diff --git a/pages/api/preview.ts b/pages/api/preview.ts new file mode 100644 index 0000000..6100e38 --- /dev/null +++ b/pages/api/preview.ts @@ -0,0 +1,5 @@ +import { drupal } from "lib/drupal" + +export default async function (request, response) { + return drupal.preview(request, response) +} diff --git a/pages/api/revalidate.ts b/pages/api/revalidate.ts new file mode 100644 index 0000000..3e7d8da --- /dev/null +++ b/pages/api/revalidate.ts @@ -0,0 +1,34 @@ +import { NextApiRequest, NextApiResponse } from "next" + +export default async function handler( + request: NextApiRequest, + response: NextApiResponse +) { + let slug = request.query.slug as string + const secret = request.query.secret as string + + // Validate secret. + if (secret !== process.env.DRUPAL_PREVIEW_SECRET) { + return response.status(401).json({ message: "Invalid secret." }) + } + + // Validate slug. + if (!slug) { + return response.status(400).json({ message: "Invalid slug." }) + } + + // Fix for home slug. + if (slug === process.env.DRUPAL_FRONT_PAGE) { + slug = "/" + } + + try { + await response.revalidate(slug) + + return response.json({}) + } catch (error) { + return response.status(404).json({ + message: error.message, + }) + } +} diff --git a/pages/blog/page/[page].tsx b/pages/blog/page/[page].tsx new file mode 100644 index 0000000..292e571 --- /dev/null +++ b/pages/blog/page/[page].tsx @@ -0,0 +1,126 @@ +import * as React from "react" +import { GetStaticPathsResult, GetStaticPropsResult } from "next" +import { useRouter } from "next/router" +import { DrupalNode, JsonApiResponse } from "next-drupal" +import { DrupalJsonApiParams } from "drupal-jsonapi-params" + +import { drupal } from "lib/drupal" +import { getMenus } from "lib/get-menus" +import { Layout, LayoutProps } from "components/layout" +import { Pager, PagerProps } from "components/pager" +import { Node } from "components/node" +import { Meta } from "components/meta" + +export const NUMBER_OF_POSTS_PER_PAGE = 2 + +export interface BlogPageProps extends LayoutProps { + page: Pick + nodes: DrupalNode[] +} + +export default function BlogPage({ nodes, menus, page }: BlogPageProps) { + const { locale } = useRouter() + const title = locale === "en" ? "Latest Articles." : "Últimas Publicaciones." + + return ( + + +
    +

    + {title} +

    + {nodes.length ? ( +
    + {nodes.map((article) => ( + + ))} +
    + ) : ( +

    No posts found

    + )} + {page ? ( + (page === 0 ? `/blog` : `/blog/page/${page}`)} + className="py-8 mt-8" + /> + ) : null} +
    +
    + ) +} + +export async function getStaticPaths(): Promise { + // Use SSG for the first pages, then fallback to SSR for other pages. + const paths = Array(5) + .fill(0) + .map((_, page) => ({ + params: { + page: `${page + 1}`, + }, + })) + + return { + paths, + fallback: "blocking", + } +} + +export async function getStaticProps( + context +): Promise> { + const current = parseInt(context.params.page) + + const params = new DrupalJsonApiParams() + .addFilter( + "field_site.meta.drupal_internal__target_id", + process.env.DRUPAL_SITE_ID + ) + .addInclude(["uid", "field_image"]) + .addFields("node--article", [ + "title", + "path", + "body", + "uid", + "created", + "field_image", + ]) + .addFields("user--user", ["field_name"]) + .addFilter("status", "1") + .addSort("created", "DESC") + + const result = await drupal.getResourceCollectionFromContext( + "node--article", + context, + { + deserialize: false, + params: { + ...params.getQueryObject(), + page: { + limit: NUMBER_OF_POSTS_PER_PAGE, + offset: context.params.page ? NUMBER_OF_POSTS_PER_PAGE * current : 0, + }, + }, + } + ) + + if (!result.data?.length) { + return { + notFound: true, + } + } + + const nodes = drupal.deserialize(result) as DrupalNode[] + + return { + props: { + nodes, + page: { + current, + total: Math.ceil(result.meta.count / NUMBER_OF_POSTS_PER_PAGE), + }, + menus: await getMenus(context), + }, + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..ea2f437 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/meta.jpg b/public/meta.jpg new file mode 100644 index 0000000..ab6299e Binary files /dev/null and b/public/meta.jpg differ diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..14267e9 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / \ No newline at end of file diff --git a/styles/globals.css b/styles/globals.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/styles/globals.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..8752b4d --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,14 @@ +module.exports = { + mode: "jit", + content: [ + "./pages/**/*.{js,ts,jsx,tsx}", + "./components/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + variants: { + extend: {}, + }, + plugins: [require("@tailwindcss/forms"), require("@tailwindcss/typography")], +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..740eff3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "baseUrl": "./", + "paths": { + "@/components/*": ["src/components/*"], + "@/nodes/*": ["src/nodes/*"], + "@/paragraphs/*": ["src/paragraphs/*"], + "@/views/*": ["src/views/*"], + "@utils/*": ["src/utils/*"], + "@/config": ["src/config"] + }, + "incremental": true + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/types/.gitkeep b/types/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/types/drupal.d.ts b/types/drupal.d.ts new file mode 100644 index 0000000..8b9dd2b --- /dev/null +++ b/types/drupal.d.ts @@ -0,0 +1,17 @@ +interface NodeProperty { + id: string + title: string + field_location: { + name: string + drupal_internal__tid: string + } +} + +export interface DrupalMetatag { + tag: string + attributes: { + content: string + name?: string + rel?: string + } +}