From 6734d4f154667e5e3b40131b42e1a097a692cecf Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Thu, 1 Sep 2022 23:52:07 +0300 Subject: [PATCH] add more templates --- .eslintignore | 2 + .prettierignore | 2 + .vscode/settings.json | 8 +- .zshrc | 7 +- aliases.sh | 1 - scaffolds/nextjs/.editorconfig | 8 + scaffolds/nextjs/.eslintignore | 1 + scaffolds/nextjs/.eslintrc.js | 9 + scaffolds/nextjs/.gitignore | 38 + scaffolds/nextjs/.prettierrc | 15 + scaffolds/nextjs/.vscode/settings.json | 16 + .../nextjs/.vscode/snippets.code-snippets | 30 + scaffolds/nextjs/.vscode/tasks.json | 20 + scaffolds/nextjs/Dockerfile | 57 + scaffolds/nextjs/README.md | 53 + scaffolds/nextjs/components/atoms/Avatar.tsx | 47 + .../atoms/ControlledAutocomplete.tsx | 76 + .../components/atoms/ControlledSelect.tsx | 117 + .../nextjs/components/atoms/PageLoader.tsx | 30 + .../nextjs/components/atoms/PageWrapper.tsx | 62 + .../nextjs/components/hooks/flat_hooks.ts | 54 + .../nextjs/components/hooks/intl_hooks.ts | 17 + .../components/hooks/notification_hooks.ts | 10 + .../nextjs/components/hooks/place_hooks.ts | 21 + .../hooks/user_preferences_hooks.ts | 7 + scaffolds/nextjs/components/layout/Link.tsx | 11 + .../nextjs/components/layout/MainAppBar.tsx | 64 + scaffolds/nextjs/core/api/api.ts | 39 + scaffolds/nextjs/core/api/api_types.ts | 13 + scaffolds/nextjs/core/data/cities.json | 1003 +++++ scaffolds/nextjs/core/emotion.ts | 11 + scaffolds/nextjs/core/env.ts | 7 + scaffolds/nextjs/core/i18n.ts | 14 + scaffolds/nextjs/core/models/user.ts | 5 + scaffolds/nextjs/core/routes.ts | 48 + scaffolds/nextjs/core/stores/user_store.tsx | 167 + scaffolds/nextjs/core/theme.ts | 111 + scaffolds/nextjs/core/types.ts | 20 + scaffolds/nextjs/core/utils/array_utils.ts | 4 + scaffolds/nextjs/core/utils/error_helpers.ts | 14 + scaffolds/nextjs/core/utils/html_utils.ts | 57 + scaffolds/nextjs/core/utils/image_utils.ts | 16 + scaffolds/nextjs/core/utils/locale_utils.ts | 6 + scaffolds/nextjs/core/utils/object_utils.ts | 33 + scaffolds/nextjs/core/utils/promise_utils.ts | 3 + scaffolds/nextjs/core/utils/react_utils.ts | 92 + scaffolds/nextjs/mui.d.ts | 26 + scaffolds/nextjs/next-env.d.ts | 5 + scaffolds/nextjs/next-i18next.config.js | 9 + scaffolds/nextjs/next.config.js | 17 + scaffolds/nextjs/package.json | 61 + scaffolds/nextjs/pages/_app.tsx | 71 + scaffolds/nextjs/pages/_document.tsx | 91 + scaffolds/nextjs/pages/api/hello.ts | 13 + scaffolds/nextjs/pages/index.tsx | 25 + scaffolds/nextjs/public/favicon.ico | Bin 0 -> 25931 bytes scaffolds/nextjs/public/locales/he/flats.json | 77 + .../nextjs/public/locales/he/flatswap.json | 11 + .../nextjs/public/locales/he/profile.json | 8 + .../nextjs/public/locales/he/signup.json | 27 + scaffolds/nextjs/public/logo.svg | 11 + scaffolds/nextjs/public/vercel.svg | 4 + scaffolds/nextjs/scripts/.gitkeep | 0 .../component/{{pascalCase name}}.tsx | 12 + .../templates/page/{{snakeCase name}}.tsx | 33 + scaffolds/nextjs/tsconfig.json | 37 + scaffolds/nextjs/yarn.lock | 3344 +++++++++++++++++ scaffolds/tsfiles/.eslintignore | 2 + scaffolds/tsfiles/.prettierignore | 2 + scripts/tpl.sh | 15 + 70 files changed, 6340 insertions(+), 7 deletions(-) create mode 100644 .eslintignore create mode 100644 .prettierignore create mode 100644 scaffolds/nextjs/.editorconfig create mode 100644 scaffolds/nextjs/.eslintignore create mode 100644 scaffolds/nextjs/.eslintrc.js create mode 100644 scaffolds/nextjs/.gitignore create mode 100644 scaffolds/nextjs/.prettierrc create mode 100644 scaffolds/nextjs/.vscode/settings.json create mode 100644 scaffolds/nextjs/.vscode/snippets.code-snippets create mode 100644 scaffolds/nextjs/.vscode/tasks.json create mode 100644 scaffolds/nextjs/Dockerfile create mode 100644 scaffolds/nextjs/README.md create mode 100644 scaffolds/nextjs/components/atoms/Avatar.tsx create mode 100644 scaffolds/nextjs/components/atoms/ControlledAutocomplete.tsx create mode 100644 scaffolds/nextjs/components/atoms/ControlledSelect.tsx create mode 100644 scaffolds/nextjs/components/atoms/PageLoader.tsx create mode 100644 scaffolds/nextjs/components/atoms/PageWrapper.tsx create mode 100644 scaffolds/nextjs/components/hooks/flat_hooks.ts create mode 100644 scaffolds/nextjs/components/hooks/intl_hooks.ts create mode 100644 scaffolds/nextjs/components/hooks/notification_hooks.ts create mode 100644 scaffolds/nextjs/components/hooks/place_hooks.ts create mode 100644 scaffolds/nextjs/components/hooks/user_preferences_hooks.ts create mode 100644 scaffolds/nextjs/components/layout/Link.tsx create mode 100644 scaffolds/nextjs/components/layout/MainAppBar.tsx create mode 100644 scaffolds/nextjs/core/api/api.ts create mode 100644 scaffolds/nextjs/core/api/api_types.ts create mode 100644 scaffolds/nextjs/core/data/cities.json create mode 100644 scaffolds/nextjs/core/emotion.ts create mode 100644 scaffolds/nextjs/core/env.ts create mode 100644 scaffolds/nextjs/core/i18n.ts create mode 100644 scaffolds/nextjs/core/models/user.ts create mode 100644 scaffolds/nextjs/core/routes.ts create mode 100644 scaffolds/nextjs/core/stores/user_store.tsx create mode 100644 scaffolds/nextjs/core/theme.ts create mode 100644 scaffolds/nextjs/core/types.ts create mode 100644 scaffolds/nextjs/core/utils/array_utils.ts create mode 100644 scaffolds/nextjs/core/utils/error_helpers.ts create mode 100644 scaffolds/nextjs/core/utils/html_utils.ts create mode 100644 scaffolds/nextjs/core/utils/image_utils.ts create mode 100644 scaffolds/nextjs/core/utils/locale_utils.ts create mode 100644 scaffolds/nextjs/core/utils/object_utils.ts create mode 100644 scaffolds/nextjs/core/utils/promise_utils.ts create mode 100644 scaffolds/nextjs/core/utils/react_utils.ts create mode 100644 scaffolds/nextjs/mui.d.ts create mode 100644 scaffolds/nextjs/next-env.d.ts create mode 100644 scaffolds/nextjs/next-i18next.config.js create mode 100644 scaffolds/nextjs/next.config.js create mode 100644 scaffolds/nextjs/package.json create mode 100644 scaffolds/nextjs/pages/_app.tsx create mode 100644 scaffolds/nextjs/pages/_document.tsx create mode 100644 scaffolds/nextjs/pages/api/hello.ts create mode 100644 scaffolds/nextjs/pages/index.tsx create mode 100644 scaffolds/nextjs/public/favicon.ico create mode 100644 scaffolds/nextjs/public/locales/he/flats.json create mode 100644 scaffolds/nextjs/public/locales/he/flatswap.json create mode 100644 scaffolds/nextjs/public/locales/he/profile.json create mode 100644 scaffolds/nextjs/public/locales/he/signup.json create mode 100644 scaffolds/nextjs/public/logo.svg create mode 100644 scaffolds/nextjs/public/vercel.svg create mode 100644 scaffolds/nextjs/scripts/.gitkeep create mode 100644 scaffolds/nextjs/templates/component/{{pascalCase name}}.tsx create mode 100644 scaffolds/nextjs/templates/page/{{snakeCase name}}.tsx create mode 100644 scaffolds/nextjs/tsconfig.json create mode 100644 scaffolds/nextjs/yarn.lock create mode 100644 scaffolds/tsfiles/.eslintignore create mode 100644 scaffolds/tsfiles/.prettierignore create mode 100644 scripts/tpl.sh diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..a52a73ca --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +templates/ +scaffolds/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..a52a73ca --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +templates/ +scaffolds/ diff --git a/.vscode/settings.json b/.vscode/settings.json index fbf3984e..a01da527 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { - "cSpell.words": [ - "dotfiles" - ], - "python.linting.enabled": true + "cSpell.words": [ + "dotfiles" + ], + "python.linting.enabled": true } diff --git a/.zshrc b/.zshrc index 4265136e..9275c1ef 100644 --- a/.zshrc +++ b/.zshrc @@ -3,5 +3,8 @@ source $HOME/.dotfiles/zsh_init.sh source $HOME/.dotfiles/aliases.sh source $HOME/.dotfiles/sources.sh source $HOME/.dotfiles/home.sh -source $HOME/.dotfiles/scripts/java.sh -source $HOME/.dotfiles/scripts/zi.sh + +# source all files in scripts dir +for file in $HOME/.dotfiles/scripts/*.sh; do + source $file +done diff --git a/aliases.sh b/aliases.sh index 5cea3d4a..dbf204f2 100755 --- a/aliases.sh +++ b/aliases.sh @@ -29,7 +29,6 @@ alias dgi_gen="$GOBIN/gi_gen" alias ggi_gen="$DOTBIN/gi_gen" # go [i]nstall & run gi_gen alias igi_gen="go install && dgi_gen" -alias tsfiles="yes | npx simple-scaffold@latest -t '$DOTFILES/scaffolds/tsfiles' -o . -" # Functions mansect() { man -aWS ${1?man section not provided} \* | xargs basename | sed "s/\.[^.]*$//" | sort -u; } diff --git a/scaffolds/nextjs/.editorconfig b/scaffolds/nextjs/.editorconfig new file mode 100644 index 00000000..4f00475a --- /dev/null +++ b/scaffolds/nextjs/.editorconfig @@ -0,0 +1,8 @@ +[*] +tab_width = 2 +indent_size = 2 +indent_style = space +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/scaffolds/nextjs/.eslintignore b/scaffolds/nextjs/.eslintignore new file mode 100644 index 00000000..82966bbb --- /dev/null +++ b/scaffolds/nextjs/.eslintignore @@ -0,0 +1 @@ +templates/**/* diff --git a/scaffolds/nextjs/.eslintrc.js b/scaffolds/nextjs/.eslintrc.js new file mode 100644 index 00000000..1c8c32d2 --- /dev/null +++ b/scaffolds/nextjs/.eslintrc.js @@ -0,0 +1,9 @@ +module.exports = { + extends: ['next/core-web-vitals', 'plugin:@typescript-eslint/recommended'], + rules: { + 'no-console': ['warn', { allow: ['warn', 'error'] }], + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': ['warn', { varsIgnorePattern: '^_' }], + '@typescript-eslint/no-non-null-assertion': 'off', + }, +} diff --git a/scaffolds/nextjs/.gitignore b/scaffolds/nextjs/.gitignore new file mode 100644 index 00000000..f2bbe36e --- /dev/null +++ b/scaffolds/nextjs/.gitignore @@ -0,0 +1,38 @@ +# 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/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo + +# swagger definitions +/swagger.* diff --git a/scaffolds/nextjs/.prettierrc b/scaffolds/nextjs/.prettierrc new file mode 100644 index 00000000..548c8173 --- /dev/null +++ b/scaffolds/nextjs/.prettierrc @@ -0,0 +1,15 @@ +{ + "printWidth": 100, + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "overrides": [ + { + "files": "*.md", + "options": { + "printWidth": 100, + "proseWrap": "always" + } + } + ] +} diff --git a/scaffolds/nextjs/.vscode/settings.json b/scaffolds/nextjs/.vscode/settings.json new file mode 100644 index 00000000..dd49c34e --- /dev/null +++ b/scaffolds/nextjs/.vscode/settings.json @@ -0,0 +1,16 @@ +{ + "cSpell.words": [ + "{{ hyphenCase name }}", + "MAPBOX", + "mapboxgl", + "stylis", + "Unmatch" + ], + "i18n-ally.localesPaths": [ + "public/locales" + ], + "i18n-ally.sourceLanguage": "he", + "i18n-ally.displayLanguage": "he", + "i18next.i18nPaths": "/Users/chen/Dev/nestify-web/public/locales", + "i18n-ally.keystyle": "nested", +} diff --git a/scaffolds/nextjs/.vscode/snippets.code-snippets b/scaffolds/nextjs/.vscode/snippets.code-snippets new file mode 100644 index 00000000..184f2d61 --- /dev/null +++ b/scaffolds/nextjs/.vscode/snippets.code-snippets @@ -0,0 +1,30 @@ +{ + // Place your {{ hyphenCase name }}-web workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and + // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope + // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is + // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: + // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. + // Placeholders with the same ids are connected. + // Example: + // "Print to console": { + // "scope": "javascript,typescript", + // "prefix": "log", + // "body": [ + // "console.log('$1');", + // "$2" + // ], + // "description": "Log output to console" + // } + "Load Translation SSR Props": { + "scope": "javascriptreact,typescriptreact", + "prefix": "tssr", + "body": [ + "export async function getStaticProps({ locale }: NextPageContext) {", + " return {", + " props: await getI18nProps(locale ?? 'he', ['${1:{{ hyphenCase name }}}']),", + " }", + "}", + ], + "description": "Load Translation SSR Props", + } +} diff --git a/scaffolds/nextjs/.vscode/tasks.json b/scaffolds/nextjs/.vscode/tasks.json new file mode 100644 index 00000000..a646f267 --- /dev/null +++ b/scaffolds/nextjs/.vscode/tasks.json @@ -0,0 +1,20 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "dev", + "problemMatcher": [], + "label": "npm: dev", + "detail": "next dev" + }, + { + "type": "npm", + "script": "build", + "group": "build", + "problemMatcher": [], + "label": "npm: build", + "detail": "next build" + }, + ] +} diff --git a/scaffolds/nextjs/Dockerfile b/scaffolds/nextjs/Dockerfile new file mode 100644 index 00000000..2e8f5a67 --- /dev/null +++ b/scaffolds/nextjs/Dockerfile @@ -0,0 +1,57 @@ +# Install dependencies only when needed +FROM node:16-alpine AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ +RUN \ + if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ + elif [ -f package-lock.json ]; then npm ci; \ + elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i; \ + else echo "Lockfile not found." && exit 1; \ + fi + + +# Rebuild the source code only when needed +FROM node:16-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry during the build. +# ENV NEXT_TELEMETRY_DISABLED 1 + +RUN yarn build + +# Production image, copy all the files and run next +FROM node:16-alpine AS runner +WORKDIR /app + +ENV NODE_ENV production +# Uncomment the following line in case you want to disable telemetry during runtime. +# ENV NEXT_TELEMETRY_DISABLED 1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# You only need to copy next.config.js if you are NOT using the default configuration +COPY --from=builder /app/next.config.js ./ +COPY --from=builder /app/public ./public +COPY --from=builder /app/package.json ./package.json + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT 3000 + +CMD ["node", "server.js"] diff --git a/scaffolds/nextjs/README.md b/scaffolds/nextjs/README.md new file mode 100644 index 00000000..2bcd2ec3 --- /dev/null +++ b/scaffolds/nextjs/README.md @@ -0,0 +1,53 @@ +# {{ pascalCase name }} Web App + +## Dev Requirements + +1. [Node.js](https://nodejs.org/en/download/) +2. Yarn: `npm install -g yarn` + +> These requirements are for development mode only. +> You can run this project statically using Docker without having to install other dependencies. + +## Run in dev mode + +Dev mode allows hot reloading of files & components, and JIT build; as opposed to AOT build + +static serving. + +1. Install/update project dependencies: `yarn install` +2. Add `.env.local` file to the repository root folder, and fill it with the correct env variables: + + ```shell + NEXT_PUBLIC_API_BASE= + NEXT_PUBLIC_MAPBOX_API_KEY= + NEXT_PUBLIC_FACEBOOK_APP_ID= + # to use test social app login instead of prod one in backend + # (optional, automatically true in dev mode) + # NEXT_PUBLIC_API_TEST=true + ``` + +3. Run in development mode with hot reload: `yarn dev` +4. Open [http://localhost:3000](http://localhost:3000) with your browser + +## Run using Docker + +Add the `.env.local` file mentioned in "Run in dev mode" to build the Docker with the correct +environment. Then, you can build the container and run as an image: + +### Shell - Manual + +```shell +# build +docker build -t nextjs-docker . +# run +# this docker exposes port 3000, you may forward to any other port +# (in this case 3100, to avoid conflict with dev mode run) +docker run -p 3100:3000 nextjs-docker +``` + +### NPM Script - Automated + +```shell +# build & run via npm scripts if you have Node.js installed +yarn build:docker +yarn start:docker +``` diff --git a/scaffolds/nextjs/components/atoms/Avatar.tsx b/scaffolds/nextjs/components/atoms/Avatar.tsx new file mode 100644 index 00000000..a0115bb6 --- /dev/null +++ b/scaffolds/nextjs/components/atoms/Avatar.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import Box, { BoxProps } from '@mui/material/Box' +import { sxc } from '../../core/utils/object_utils' +import { CustomComponent } from '../../core/types' +import Image from 'next/image' +import { apiFallbackImageUrl } from '../../core/utils/image_utils' + +export interface AvatarProps extends CustomComponent { + src: string + size?: number + padding?: number +} + +export const Avatar: React.FC = ({ sx, size = 160, src, padding = 8 }) => { + return ( + theme.boxShadows.avatar, + padding: `${padding}px`, + }, + sx, + )} + > + + {src} + + + ) +} diff --git a/scaffolds/nextjs/components/atoms/ControlledAutocomplete.tsx b/scaffolds/nextjs/components/atoms/ControlledAutocomplete.tsx new file mode 100644 index 00000000..f4772050 --- /dev/null +++ b/scaffolds/nextjs/components/atoms/ControlledAutocomplete.tsx @@ -0,0 +1,76 @@ +import Autocomplete, { AutocompleteProps } from '@mui/material/Autocomplete' +import TextField from '@mui/material/TextField' +import React from 'react' +import { Controller, Control, Path } from 'react-hook-form' +import { CustomComponent, Option } from '../../core/types' +import { optionFrom } from '../../core/utils/object_utils' + +export interface ControlledAutocompleteProps, Multiple extends boolean = false> + extends CustomComponent>> { + control: Control + name: K + options: Option[] + helperText?: React.ReactNode + placeholder?: string + required?: boolean + multiple?: Multiple +} + +export const ControlledAutocomplete = , Multiple extends boolean = false>( + props: ControlledAutocompleteProps, +) => { + const { control, name, options, helperText, placeholder, onChange, multiple, ...rest } = props + + const _value = React.useMemo( + (): Multiple extends true ? Option[] : Option | null => + control._formValues[name] + ? multiple + ? control._formValues[name].map(optionFrom) + : optionFrom(control._formValues[name]) + : multiple + ? [] + : null, + // eslint-disable-next-line react-hooks/exhaustive-deps + [control._formValues[name]], + ) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [value, setValue] = React.useState(_value ?? (multiple ? ([] as string[]) : ('' as any))) + React.useEffect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + () => setValue(_value ?? (multiple ? ([] as string[]) : ('' as any))), + [_value, multiple], + ) + + return ( + + control={control} + name={name} + rules={{ required: props.required }} + render={({ field, formState: { errors } }) => ( + { + setValue(d as typeof _value) + field.onChange(Array.isArray(d) ? d.map((d) => d?.value) : d?.value) + onChange?.(e, d, r) + }} + value={value} + options={options} + getOptionLabel={(opt) => opt?.label} + isOptionEqualToValue={(opt, value) => opt.value === value.value} + multiple={multiple} + renderInput={(params) => ( + + )} + {...rest} + /> + )} + /> + ) +} diff --git a/scaffolds/nextjs/components/atoms/ControlledSelect.tsx b/scaffolds/nextjs/components/atoms/ControlledSelect.tsx new file mode 100644 index 00000000..ddfe450a --- /dev/null +++ b/scaffolds/nextjs/components/atoms/ControlledSelect.tsx @@ -0,0 +1,117 @@ +import Select, { SelectProps } from '@mui/material/Select' +import MenuItem from '@mui/material/MenuItem' +import React from 'react' +import { Controller, Control, Path } from 'react-hook-form' +import { CustomComponent, Option } from '../../core/types' +import { useRerender } from '../../core/utils/react_utils' + +export interface ControlledSelectProps< + R extends string, + T, + K extends Path, + Multiple extends boolean = false, +> extends CustomComponent>> { + control: Control + name: K + options: Option[] + helperText?: React.ReactNode + placeholder?: string + required?: boolean + multiple?: Multiple +} + +export const ControlledSelect = < + R extends string, + T, + K extends Path, + Multiple extends boolean = false, +>( + props: ControlledSelectProps, +) => { + const { + control, + name, + options, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + helperText, + placeholder, + onChange, + multiple = false, + ...rest + } = props + + const _value = React.useMemo( + (): typeof multiple extends true ? R[] : R | null => + (control._formValues[name] + ? multiple + ? (control._formValues[name] as R[]).map((x) => options.find((y) => y.value === x)!.value) + : options.find((x) => x.value === control._formValues[name])!.value + : multiple + ? [] + : null) as typeof multiple extends true ? R[] : R | null, + // eslint-disable-next-line react-hooks/exhaustive-deps + [control._formValues[name], multiple, name, options], + ) + const [value, setValue] = React.useState( + (Array.isArray(_value) ? _value : _value) ?? (multiple ? [] : ''), + ) + React.useEffect(() => setValue(_value ?? (multiple ? [] : '')), [_value, multiple]) + const rerender = useRerender() + + return ( + + control={control} + name={name} + rules={{ required: props.required }} + render={({ field, formState: { errors } }) => ( + + )} + /> + ) +} diff --git a/scaffolds/nextjs/components/atoms/PageLoader.tsx b/scaffolds/nextjs/components/atoms/PageLoader.tsx new file mode 100644 index 00000000..d67598ef --- /dev/null +++ b/scaffolds/nextjs/components/atoms/PageLoader.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import Box, { BoxProps } from '@mui/material/Box' +import { CustomComponent } from '../../core/types' +import CircularProgress from '@mui/material/CircularProgress' +import { TOOLBAR_HEIGHT } from '../layout/MainAppBar' + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface PageLoaderProps extends CustomComponent { + // +} + +export const PageLoader: React.FC = ({ sx }) => { + return ( + + + + ) +} diff --git a/scaffolds/nextjs/components/atoms/PageWrapper.tsx b/scaffolds/nextjs/components/atoms/PageWrapper.tsx new file mode 100644 index 00000000..b13522d2 --- /dev/null +++ b/scaffolds/nextjs/components/atoms/PageWrapper.tsx @@ -0,0 +1,62 @@ +import React from 'react' +import Box, { BoxProps } from '@mui/material/Box' +import { sxc } from '../../core/utils/object_utils' +import { CustomComponent } from '../../core/types' +import { MainAppBar, TOOLBAR_HEIGHT } from '../layout/MainAppBar' +import Toolbar from '@mui/material/Toolbar' +import { PageLoader } from './PageLoader' +import Head from 'next/head' +import Typography from '@mui/material/Typography' + +export interface PageWrapperProps extends CustomComponent { + fixedMaxWidth?: boolean + loading?: boolean + pageTitle?: React.ReactNode + htmlTitle?: string + htmlDescription?: string + render?: (props: PageWrapperProps) => React.ReactNode +} + +export const PageWrapper: React.FC = (props) => { + const { + sx, + children, + loading, + fixedMaxWidth = true, + htmlTitle, + htmlDescription, + pageTitle, + render, + } = props + return ( + theme.spacing(3, 2), + margin: '0 auto', + }, + fixedMaxWidth && { + maxWidth: 600, + }, + sx, + )} + > + {htmlTitle || htmlDescription ? ( + + {htmlTitle} + + + ) : null} + {/* App Bar */} + + {/* Empty toolbar - to save space for fixed app bar */} + + {pageTitle ? ( + + {pageTitle} + + ) : null} + {loading ? : render?.(props) ?? children} + + ) +} diff --git a/scaffolds/nextjs/components/hooks/flat_hooks.ts b/scaffolds/nextjs/components/hooks/flat_hooks.ts new file mode 100644 index 00000000..28c41c69 --- /dev/null +++ b/scaffolds/nextjs/components/hooks/flat_hooks.ts @@ -0,0 +1,54 @@ +import React from 'react' +import flatsApi from '../../core/api/flats.api' +import { Flat } from '../../core/models/flats' +import { createUseApi } from '../../core/utils/react_utils' + +export const useMyFlat = createUseApi(() => flatsApi.getMyFlat(), { + cacheKey: 'myFlat', + responseKey: 'flat', +}) + +export const usePotentialFlats = createUseApi(() => flatsApi.getPotentialFlatsList(), { + cacheKey: 'potentialFlats', + responseKey: 'potentialFlats', + innerKey: 'flat', +}) + +export const useMatchedFlats = createUseApi(() => flatsApi.getMatchedFlatsList(), { + cacheKey: 'matchedFlats', + responseKey: 'fullMatches', + innerKey: 'matchesByUser', +}) + +export const useLikedFlats = createUseApi(() => flatsApi.getLikedFlatsList(), { + cacheKey: 'likedFlats', + responseKey: 'likedFlats', + innerKey: 'flat', +}) + +export const useDislikedFlats = createUseApi(() => flatsApi.getDislikedFlatsList(), { + cacheKey: 'dislikedFlats', + responseKey: 'dislikedFlats', + innerKey: 'flat', +}) + +export function useFlatCache(list: Flat[] | undefined) { + const [cache, setCache] = React.useState(list ?? []) + + React.useEffect(() => { + setCache(list ?? []) + }, [list]) + + return { + data: cache, + add: (item: Flat) => { + setCache([...cache, item]) + }, + update: (item: Flat) => { + setCache(cache.map((flat) => (flat.userId === item.userId ? item : flat))) + }, + remove: (item: Flat) => { + setCache(cache.filter((flat) => flat.userId !== item.userId)) + }, + } +} diff --git a/scaffolds/nextjs/components/hooks/intl_hooks.ts b/scaffolds/nextjs/components/hooks/intl_hooks.ts new file mode 100644 index 00000000..c81282cb --- /dev/null +++ b/scaffolds/nextjs/components/hooks/intl_hooks.ts @@ -0,0 +1,17 @@ +import { useTranslation } from 'next-i18next' +import React from 'react' + +export function useNumberFormatter(options?: Intl.NumberFormatOptions): Intl.NumberFormat { + const { i18n } = useTranslation() + + return React.useMemo(() => { + return Intl.NumberFormat(i18n.language, options) + }, [i18n.language, options]) +} +export function useCurrencyFormatter(options?: Intl.NumberFormatOptions): Intl.NumberFormat { + return useNumberFormatter({ + style: 'currency', + currency: 'ILS', + ...options, + }) +} diff --git a/scaffolds/nextjs/components/hooks/notification_hooks.ts b/scaffolds/nextjs/components/hooks/notification_hooks.ts new file mode 100644 index 00000000..05a89bf1 --- /dev/null +++ b/scaffolds/nextjs/components/hooks/notification_hooks.ts @@ -0,0 +1,10 @@ +import notificationsApi from '../../core/api/notifications.api' +import { Notification } from '../../core/models/notification' +import { createUseApi } from '../../core/utils/react_utils' + +const getId = (notification: Notification) => notification.userId + +export const useNotifications = createUseApi(() => notificationsApi.getNotifications(), { + cacheKey: 'notifications', + responseKey: 'notifications', +}) diff --git a/scaffolds/nextjs/components/hooks/place_hooks.ts b/scaffolds/nextjs/components/hooks/place_hooks.ts new file mode 100644 index 00000000..d5873906 --- /dev/null +++ b/scaffolds/nextjs/components/hooks/place_hooks.ts @@ -0,0 +1,21 @@ +import placesApi, { GetPlacesListResult } from '../../core/api/places.api' +import uniqBy from 'lodash/uniqBy' +import React from 'react' +import { createUseApi, RequiredQueryOptions } from '../../core/utils/react_utils' + +export const usePlaces = createUseApi(() => placesApi.getPlacesList(), { + cacheKey: 'places', + responseKey: 'places', +}) + +export function useCities(options?: RequiredQueryOptions) { + const { places = [], ...rest } = usePlaces(options) + const data = React.useMemo(() => uniqBy(places, 'city'), [places]) + return { ...rest, cities: data } +} + +export function useNeighborhoods(options?: RequiredQueryOptions) { + const { places = [], ...rest } = usePlaces(options) + const data = React.useMemo(() => uniqBy(places, 'neighborhood'), [places]) + return { ...rest, neighborhoods: data } +} diff --git a/scaffolds/nextjs/components/hooks/user_preferences_hooks.ts b/scaffolds/nextjs/components/hooks/user_preferences_hooks.ts new file mode 100644 index 00000000..c44ced84 --- /dev/null +++ b/scaffolds/nextjs/components/hooks/user_preferences_hooks.ts @@ -0,0 +1,7 @@ +import userPreferencesApi from '../../core/api/user_preferences.api' +import { createUseApi } from '../../core/utils/react_utils' + +export const useUserPreferences = createUseApi(() => userPreferencesApi.getUserPreferences(), { + cacheKey: 'userPreferences', + responseKey: 'userPreference', +}) diff --git a/scaffolds/nextjs/components/layout/Link.tsx b/scaffolds/nextjs/components/layout/Link.tsx new file mode 100644 index 00000000..acc16351 --- /dev/null +++ b/scaffolds/nextjs/components/layout/Link.tsx @@ -0,0 +1,11 @@ +import NextLink, { LinkProps as NextLinkProps } from 'next/link' +import MUILink, { LinkProps as MUILinkProps } from '@mui/material/Link' + +export const Link: React.FC = (props) => { + const { href, children, ...rest } = props + return ( + + {children} + + ) +} diff --git a/scaffolds/nextjs/components/layout/MainAppBar.tsx b/scaffolds/nextjs/components/layout/MainAppBar.tsx new file mode 100644 index 00000000..077fcbd9 --- /dev/null +++ b/scaffolds/nextjs/components/layout/MainAppBar.tsx @@ -0,0 +1,64 @@ +import ChevronLeft from '@mui/icons-material/ChevronLeft' +import MenuIcon from '@mui/icons-material/Menu' +import Notifications from '@mui/icons-material/Notifications' +import Toolbar from '@mui/material/Toolbar' +import AppBar from '@mui/material/AppBar' +import React from 'react' +import IconButton from '@mui/material/IconButton' +import Box from '@mui/material/Box' +import { Routes, usePushRoute } from '../../core/routes' +import { useTranslation } from 'next-i18next' +import Menu from '@mui/material/Menu' +import MenuItem from '@mui/material/MenuItem' + +export const TOOLBAR_HEIGHT = 68 + +export const MainAppBar: React.FC = React.memo(function MainAppBar() { + const [menuRef, setMenuRef] = React.useState(null) + const goTo = usePushRoute() + const { t } = useTranslation('{{ hyphenCase name }}') + + const handleMenuClick = React.useCallback((e: React.MouseEvent) => { + setMenuRef(e.currentTarget) + }, []) + + const handleMenuClose = React.useCallback(() => { + setMenuRef(null) + }, []) + + return ( + <> + + + + + + goTo(Routes.Notifications)}> + + + + {/* eslint-disable-next-line @next/next/no-img-element */} + {{ pascalCase name }} + + + + + + + + + goTo(Routes.PotentialFlatsList)}> + {t('app_bar.menu.results')} + + goTo(Routes.MatchedFlatsList)}> + {t('app_bar.menu.matches')} + + goTo(Routes.LikedFlatsList)}>{t('app_bar.menu.liked')} + goTo(Routes.DislikedFlatsList)}> + {t('app_bar.menu.disliked')} + + goTo(Routes.Profile)}>{t('app_bar.menu.profile')} + + + ) +}) diff --git a/scaffolds/nextjs/core/api/api.ts b/scaffolds/nextjs/core/api/api.ts new file mode 100644 index 00000000..fd847ef2 --- /dev/null +++ b/scaffolds/nextjs/core/api/api.ts @@ -0,0 +1,39 @@ +import axios, { Axios, AxiosResponse } from 'axios' +import { ENV } from '../env' + +const client = axios.create({ + baseURL: ENV.API_BASE + '/api/v1', + headers: ENV.API_TEST ? { Test: 'true' } : undefined, +}) + +export class ApiCollection { + client!: Axios + + constructor() { + this.client = client + } + + public setAuthHeaders(token: string) { + this.client.defaults.headers.common['Authorization'] = `Bearer ${token}` + } + + public unsetAuthHeaders() { + delete this.client.defaults.headers.common['Authorization'] + } + + protected async parse( + // eslint-disable-next-line no-unused-vars + result: Promise>, + // eslint-disable-next-line no-unused-vars + key: K, + ): Promise + // eslint-disable-next-line no-unused-vars + protected async parse(result: Promise>): Promise + protected async parse( + result: Promise>, + key?: K, + ): Promise { + const { data } = await result + return key ? data[key] : data + } +} diff --git a/scaffolds/nextjs/core/api/api_types.ts b/scaffolds/nextjs/core/api/api_types.ts new file mode 100644 index 00000000..544c8a75 --- /dev/null +++ b/scaffolds/nextjs/core/api/api_types.ts @@ -0,0 +1,13 @@ +export type SingleResult = { + [result in K]: T +} & { + error: boolean + msg: string +} +export type ListResult = { + [result in K]?: T[] +} & { + count: number + error: boolean + msg: string +} diff --git a/scaffolds/nextjs/core/data/cities.json b/scaffolds/nextjs/core/data/cities.json new file mode 100644 index 00000000..01474b0b --- /dev/null +++ b/scaffolds/nextjs/core/data/cities.json @@ -0,0 +1,1003 @@ +[ + { + "city": "ירושלים", + "city_ascii": "Jerusalem", + "lat": "31.7833", + "lng": "35.2167", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Jerusalem", + "capital": "primary", + "population": "919438", + "id": "1376261644" + }, + { + "city": "תל אביב - יפו", + "city_ascii": "Tel Aviv-Yafo", + "lat": "32.0800", + "lng": "34.7800", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Tel Aviv", + "capital": "admin", + "population": "451523", + "id": "1376401542" + }, + { + "city": "חיפה", + "city_ascii": "Haifa", + "lat": "32.8000", + "lng": "34.9833", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Haifa", + "capital": "admin", + "population": "281087", + "id": "1376133727" + }, + { + "city": "ראשון לציון", + "city_ascii": "Rishon LeZiyyon", + "lat": "31.9711", + "lng": "34.7894", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Central", + "capital": "", + "population": "249860", + "id": "1376642268" + }, + { + "city": "פתח-תקווה", + "city_ascii": "Petah Tiqwa", + "lat": "32.0833", + "lng": "34.8833", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Central", + "capital": "", + "population": "236169", + "id": "1376362310" + }, + { + "city": "אשדוד", + "city_ascii": "Ashdod", + "lat": "31.7978", + "lng": "34.6503", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Southern", + "capital": "", + "population": "220174", + "id": "1376458766" + }, + { + "city": "נתניה", + "city_ascii": "Netanya", + "lat": "32.3328", + "lng": "34.8600", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Central", + "capital": "", + "population": "217244", + "id": "1376203187" + }, + { + "city": "Beersheba", + "city_ascii": "Beersheba", + "lat": "31.2589", + "lng": "34.7978", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Southern", + "capital": "admin", + "population": "209000", + "id": "1376023307" + }, + { + "city": "Bené Beraq", + "city_ascii": "Bene Beraq", + "lat": "32.0807", + "lng": "34.8338", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Tel Aviv", + "capital": "", + "population": "193774", + "id": "1376944837" + }, + { + "city": "Holon", + "city_ascii": "Holon", + "lat": "32.0167", + "lng": "34.7667", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Tel Aviv", + "capital": "", + "population": "188834", + "id": "1376222772" + }, + { + "city": "Ramat Gan", + "city_ascii": "Ramat Gan", + "lat": "32.0700", + "lng": "34.8235", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Tel Aviv", + "capital": "", + "population": "152596", + "id": "1376357911" + }, + { + "city": "Bat Yam", + "city_ascii": "Bat Yam", + "lat": "32.0231", + "lng": "34.7503", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Tel Aviv", + "capital": "", + "population": "128800", + "id": "1376837517" + }, + { + "city": "Ashqelon", + "city_ascii": "Ashqelon", + "lat": "31.6658", + "lng": "34.5664", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Southern", + "capital": "", + "population": "134454", + "id": "1376324059" + }, + { + "city": "Reẖovot", + "city_ascii": "Rehovot", + "lat": "31.8914", + "lng": "34.8078", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Central", + "capital": "", + "population": "132671", + "id": "1376684821" + }, + { + "city": "Bet Shemesh", + "city_ascii": "Bet Shemesh", + "lat": "31.7514", + "lng": "34.9886", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Jerusalem", + "capital": "", + "population": "114371", + "id": "1376846832" + }, + { + "city": "Kefar Sava", + "city_ascii": "Kefar Sava", + "lat": "32.1858", + "lng": "34.9077", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Central", + "capital": "", + "population": "100800", + "id": "1376008883" + }, + { + "city": "Herẕliyya", + "city_ascii": "Herzliyya", + "lat": "32.1556", + "lng": "34.8422", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Tel Aviv", + "capital": "", + "population": "93989", + "id": "1376303805" + }, + { + "city": "Nazareth", + "city_ascii": "Nazareth", + "lat": "32.7021", + "lng": "35.2978", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Northern", + "capital": "admin", + "population": "83400", + "id": "1376505625" + }, + { + "city": "Ra‘ananna", + "city_ascii": "Ra`ananna", + "lat": "32.1833", + "lng": "34.8667", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Central", + "capital": "", + "population": "74000", + "id": "1376791991" + }, + { + "city": "Ramla", + "city_ascii": "Ramla", + "lat": "31.9275", + "lng": "34.8625", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Central", + "capital": "admin", + "population": "75500", + "id": "1376321361" + }, + { + "city": "Givatayim", + "city_ascii": "Givatayim", + "lat": "32.0697", + "lng": "34.8117", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Tel Aviv", + "capital": "", + "population": "59518", + "id": "1376739486" + }, + { + "city": "Hod HaSharon", + "city_ascii": "Hod HaSharon", + "lat": "32.1500", + "lng": "34.8833", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Central", + "capital": "", + "population": "56659", + "id": "1376159880" + }, + { + "city": "Qiryat Ata", + "city_ascii": "Qiryat Ata", + "lat": "32.8000", + "lng": "35.1000", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Haifa", + "capital": "", + "population": "55464", + "id": "1376676929" + }, + { + "city": "Rosh Ha‘Ayin", + "city_ascii": "Rosh Ha`Ayin", + "lat": "32.0833", + "lng": "34.9500", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Central", + "capital": "", + "population": "56300", + "id": "1376619380" + }, + { + "city": "Umm el Faḥm", + "city_ascii": "Umm el Fahm", + "lat": "32.5158", + "lng": "35.1525", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Haifa", + "capital": "", + "population": "55300", + "id": "1376814378" + }, + { + "city": "Nes Ẕiyyona", + "city_ascii": "Nes Ziyyona", + "lat": "31.9333", + "lng": "34.8000", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Central", + "capital": "", + "population": "50200", + "id": "1376745785" + }, + { + "city": "El‘ad", + "city_ascii": "El`ad", + "lat": "32.0523", + "lng": "34.9512", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Central", + "capital": "", + "population": "46896", + "id": "1376486332" + }, + { + "city": "Ramat HaSharon", + "city_ascii": "Ramat HaSharon", + "lat": "32.1500", + "lng": "34.8333", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Tel Aviv", + "capital": "", + "population": "46700", + "id": "1376215427" + }, + { + "city": "Karmiel", + "city_ascii": "Karmiel", + "lat": "32.9000", + "lng": "35.2833", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Northern", + "capital": "", + "population": "45300", + "id": "1376615005" + }, + { + "city": "Qiryat Ono", + "city_ascii": "Qiryat Ono", + "lat": "32.0636", + "lng": "34.8553", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Tel Aviv", + "capital": "", + "population": "37791", + "id": "1376929399" + }, + { + "city": "Ben Zakkay", + "city_ascii": "Ben Zakkay", + "lat": "31.8833", + "lng": "34.7333", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Central", + "capital": "", + "population": "42314", + "id": "1376946236" + }, + { + "city": "Qiryat Bialik", + "city_ascii": "Qiryat Bialik", + "lat": "32.8331", + "lng": "35.0664", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Haifa", + "capital": "", + "population": "39900", + "id": "1376833983" + }, + { + "city": "Or Yehuda", + "city_ascii": "Or Yehuda", + "lat": "32.0333", + "lng": "34.8500", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Tel Aviv", + "capital": "", + "population": "36706", + "id": "1376356429" + }, + { + "city": "Shefar‘am", + "city_ascii": "Shefar`am", + "lat": "32.8056", + "lng": "35.1694", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Northern", + "capital": "", + "population": "41600", + "id": "1376191471" + }, + { + "city": "Yehud", + "city_ascii": "Yehud", + "lat": "32.0333", + "lng": "34.8833", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Central", + "capital": "", + "population": "29146", + "id": "1376760246" + }, + { + "city": "Giv‘at Shemu’él", + "city_ascii": "Giv`at Shemu'el", + "lat": "32.0781", + "lng": "34.8489", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Central", + "capital": "", + "population": "25298", + "id": "1376429803" + }, + { + "city": "Gedera", + "city_ascii": "Gedera", + "lat": "31.8139", + "lng": "34.7783", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Central", + "capital": "", + "population": "26217", + "id": "1376689197" + }, + { + "city": "Eṭ Ṭīra", + "city_ascii": "Et Tira", + "lat": "32.2328", + "lng": "34.9503", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Central", + "capital": "", + "population": "26200", + "id": "1376315793" + }, + { + "city": "Gan Yavne", + "city_ascii": "Gan Yavne", + "lat": "31.7886", + "lng": "34.7053", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Central", + "capital": "", + "population": "22453", + "id": "1376546526" + }, + { + "city": "Kafr Qāsim", + "city_ascii": "Kafr Qasim", + "lat": "32.1142", + "lng": "34.9772", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Central", + "capital": "", + "population": "21848", + "id": "1376925215" + }, + { + "city": "Qalansuwa", + "city_ascii": "Qalansuwa", + "lat": "32.2850", + "lng": "34.9811", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Central", + "capital": "", + "population": "21451", + "id": "1376958417" + }, + { + "city": "Hadera", + "city_ascii": "Hadera", + "lat": "32.4500", + "lng": "34.9167", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Haifa", + "capital": "", + "population": "91707", + "id": "1376761209" + }, + { + "city": "Modi‘in Makkabbim Re‘ut", + "city_ascii": "Modi`in Makkabbim Re`ut", + "lat": "31.9339", + "lng": "34.9856", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Central", + "capital": "", + "population": "90013", + "id": "1376334230" + }, + { + "city": "Lod", + "city_ascii": "Lod", + "lat": "31.9500", + "lng": "34.9000", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Central", + "capital": "", + "population": "75700", + "id": "1376929543" + }, + { + "city": "Rahat", + "city_ascii": "Rahat", + "lat": "31.3925", + "lng": "34.7544", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Southern", + "capital": "", + "population": "64462", + "id": "1376207828" + }, + { + "city": "Nahariyya", + "city_ascii": "Nahariyya", + "lat": "33.0036", + "lng": "35.0925", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Northern", + "capital": "", + "population": "60000", + "id": "1376378013" + }, + { + "city": "Eilat", + "city_ascii": "Eilat", + "lat": "29.5500", + "lng": "34.9500", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Southern", + "capital": "", + "population": "51935", + "id": "1376831370" + }, + { + "city": "‘Akko", + "city_ascii": "`Akko", + "lat": "32.9261", + "lng": "35.0839", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Northern", + "capital": "", + "population": "47675", + "id": "1376781950" + }, + { + "city": "Afula", + "city_ascii": "Afula", + "lat": "32.6078", + "lng": "35.2897", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Northern", + "capital": "", + "population": "44930", + "id": "1376077681" + }, + { + "city": "Tiberias", + "city_ascii": "Tiberias", + "lat": "32.7897", + "lng": "35.5247", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Northern", + "capital": "", + "population": "44200", + "id": "1376017086" + }, + { + "city": "Pardés H̱anna Karkur", + "city_ascii": "Pardes Hanna Karkur", + "lat": "32.4711", + "lng": "34.9675", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Haifa", + "capital": "", + "population": "42100", + "id": "1376163698" + }, + { + "city": "Eṭ Ṭaiyiba", + "city_ascii": "Et Taiyiba", + "lat": "32.2667", + "lng": "35.0000", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Central", + "capital": "", + "population": "43100", + "id": "1376597784" + }, + { + "city": "Qiryat Moẕqin", + "city_ascii": "Qiryat Mozqin", + "lat": "32.8369", + "lng": "35.0775", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Haifa", + "capital": "", + "population": "40160", + "id": "1376435231" + }, + { + "city": "Qiryat Yam", + "city_ascii": "Qiryat Yam", + "lat": "32.8331", + "lng": "35.0664", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Haifa", + "capital": "", + "population": "39416", + "id": "1376460777" + }, + { + "city": "Ma‘alot Tarshīḥā", + "city_ascii": "Ma`alot Tarshiha", + "lat": "33.0167", + "lng": "35.2708", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Northern", + "capital": "", + "population": "36000", + "id": "1376992708" + }, + { + "city": "Ẕefat", + "city_ascii": "Zefat", + "lat": "32.9658", + "lng": "35.4983", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Northern", + "capital": "", + "population": "35700", + "id": "1376611460" + }, + { + "city": "Tamra", + "city_ascii": "Tamra", + "lat": "32.8511", + "lng": "35.2071", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Northern", + "capital": "", + "population": "34000", + "id": "1376012882" + }, + { + "city": "Dimona", + "city_ascii": "Dimona", + "lat": "31.0700", + "lng": "35.0300", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Southern", + "capital": "", + "population": "34135", + "id": "1376956540" + }, + { + "city": "Sakhnīn", + "city_ascii": "Sakhnin", + "lat": "32.8667", + "lng": "35.3000", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Northern", + "capital": "", + "population": "31100", + "id": "1376646130" + }, + { + "city": "Netivot", + "city_ascii": "Netivot", + "lat": "31.4167", + "lng": "34.5833", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Southern", + "capital": "", + "population": "31314", + "id": "1376200656" + }, + { + "city": "Ofaqim", + "city_ascii": "Ofaqim", + "lat": "31.2833", + "lng": "34.6167", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Southern", + "capital": "", + "population": "27771", + "id": "1376992458" + }, + { + "city": "Migdal Ha‘Emeq", + "city_ascii": "Migdal Ha`Emeq", + "lat": "32.6786", + "lng": "35.2444", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Northern", + "capital": "", + "population": "25600", + "id": "1376279434" + }, + { + "city": "Nesher", + "city_ascii": "Nesher", + "lat": "32.7711", + "lng": "35.0394", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Haifa", + "capital": "", + "population": "23700", + "id": "1376735055" + }, + { + "city": "Arad", + "city_ascii": "Arad", + "lat": "31.2603", + "lng": "35.2147", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Southern", + "capital": "", + "population": "24436", + "id": "1376674296" + }, + { + "city": "Kefar Yona", + "city_ascii": "Kefar Yona", + "lat": "32.3150", + "lng": "34.9328", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Central", + "capital": "", + "population": "21611", + "id": "1376924544" + }, + { + "city": "Tirat Karmel", + "city_ascii": "Tirat Karmel", + "lat": "32.7667", + "lng": "34.9667", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Haifa", + "capital": "", + "population": "22200", + "id": "1376894717" + }, + { + "city": "Sederot", + "city_ascii": "Sederot", + "lat": "31.5261", + "lng": "34.5939", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Southern", + "capital": "", + "population": "23090", + "id": "1376365217" + }, + { + "city": "Qiryat Mal’akhi", + "city_ascii": "Qiryat Mal'akhi", + "lat": "31.7333", + "lng": "34.7500", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Southern", + "capital": "", + "population": "23100", + "id": "1376662881" + }, + { + "city": "Qiryat Shemona", + "city_ascii": "Qiryat Shemona", + "lat": "33.2075", + "lng": "35.5697", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Northern", + "capital": "", + "population": "23076", + "id": "1376248603" + }, + { + "city": "Yoqne‘am ‘Illit", + "city_ascii": "Yoqne`am `Illit", + "lat": "32.6594", + "lng": "35.1100", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Northern", + "capital": "", + "population": "21383", + "id": "1376262404" + }, + { + "city": "Be’er Ya‘aqov", + "city_ascii": "Be'er Ya`aqov", + "lat": "31.9436", + "lng": "34.8392", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Central", + "capital": "", + "population": "18401", + "id": "1376941719" + }, + { + "city": "Or ‘Aqiva", + "city_ascii": "Or `Aqiva", + "lat": "32.5000", + "lng": "34.9167", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Haifa", + "capital": "", + "population": "17759", + "id": "1376081727" + }, + { + "city": "Bet She’an", + "city_ascii": "Bet She'an", + "lat": "32.4961", + "lng": "35.4989", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Northern", + "capital": "", + "population": "18200", + "id": "1376100451" + }, + { + "city": "Majdal Shams", + "city_ascii": "Majdal Shams", + "lat": "33.2692", + "lng": "35.7706", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Northern", + "capital": "", + "population": "15973", + "id": "1376000007" + }, + { + "city": "Jisr ez Zarqā", + "city_ascii": "Jisr ez Zarqa", + "lat": "32.5379", + "lng": "34.9122", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Haifa", + "capital": "", + "population": "13962", + "id": "1376963985" + }, + { + "city": "Omer", + "city_ascii": "Omer", + "lat": "31.2683", + "lng": "34.8489", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Southern", + "capital": "", + "population": "7339", + "id": "1376441239" + }, + { + "city": "Buq‘ātā", + "city_ascii": "Buq`ata", + "lat": "33.2014", + "lng": "35.7797", + "country": "Israel", + "iso2": "IL", + "iso3": "ISR", + "admin_name": "Northern", + "capital": "", + "population": "6329", + "id": "1376000061" + } +] diff --git a/scaffolds/nextjs/core/emotion.ts b/scaffolds/nextjs/core/emotion.ts new file mode 100644 index 00000000..c4fb9155 --- /dev/null +++ b/scaffolds/nextjs/core/emotion.ts @@ -0,0 +1,11 @@ +import createCache from '@emotion/cache' +import stylisRTLPlugin from 'stylis-plugin-rtl' +import { prefixer } from 'stylis' + +export const createEmotionCache = () => { + return createCache({ + key: 'css', + prepend: true, + stylisPlugins: [prefixer, stylisRTLPlugin], + }) +} diff --git a/scaffolds/nextjs/core/env.ts b/scaffolds/nextjs/core/env.ts new file mode 100644 index 00000000..f9c2fd2e --- /dev/null +++ b/scaffolds/nextjs/core/env.ts @@ -0,0 +1,7 @@ +export const ENV = { + BROWSER_LOADED: typeof window !== 'undefined', + API_BASE: process.env.NEXT_PUBLIC_API_BASE!, + API_TEST: process.env.NODE_ENV === 'development' || process.env.NEXT_PUBLIC_API_TEST === 'true', + MAPBOX_API_KEY: process.env.NEXT_PUBLIC_MAPBOX_API_KEY!, + FACEBOOK_APP_ID: process.env.NEXT_PUBLIC_FACEBOOK_APP_ID!, +} diff --git a/scaffolds/nextjs/core/i18n.ts b/scaffolds/nextjs/core/i18n.ts new file mode 100644 index 00000000..f9395b8e --- /dev/null +++ b/scaffolds/nextjs/core/i18n.ts @@ -0,0 +1,14 @@ +import { serverSideTranslations } from 'next-i18next/serverSideTranslations' + +export async function getI18nProps(locale: string | undefined | null, namespaces: string[]) { + return serverSideTranslations( + locale ?? 'he', + Array.from(new Set(['{{ hyphenCase name }}', ...namespaces])), + ) +} + +// export async function getI18nPropsFn(namespaces: string[]) { +// return async ({ locale }: NextPageContext) => { +// return { props: await serverSideTranslations(locale ?? 'he', namespaces) } +// } +// } diff --git a/scaffolds/nextjs/core/models/user.ts b/scaffolds/nextjs/core/models/user.ts new file mode 100644 index 00000000..2463d4a7 --- /dev/null +++ b/scaffolds/nextjs/core/models/user.ts @@ -0,0 +1,5 @@ +export interface User { + firstName: string + lastName: string + email: string +} diff --git a/scaffolds/nextjs/core/routes.ts b/scaffolds/nextjs/core/routes.ts new file mode 100644 index 00000000..fd930561 --- /dev/null +++ b/scaffolds/nextjs/core/routes.ts @@ -0,0 +1,48 @@ +import { useRouter } from 'next/router' +import React from 'react' + +const _signup = '/signup' +const _flats = '/flats' +const _user = '/user' + +export const Routes = { + Home: '/', + + // Signup + Signup: _signup, + SignupUserDetails: `${_signup}/user-details`, + + // User + Profile: `${_user}/profile`, + UserDetails: `${_user}/details`, + Preferences: `${_user}/preferences`, + Notifications: `${_user}/notifications`, + + // Flats + MyFlat: `${_flats}/my`, + MatchedFlatsList: `${_flats}/matches`, + PotentialFlatsList: `${_flats}/potential`, + LikedFlatsList: `${_flats}/liked`, + DislikedFlatsList: `${_flats}/disliked`, + ViewFlat: `${_flats}/:id`, +} + +type RoutePush = (route: string, params?: { [key: string]: string }) => Promise + +export function usePushRoute(): RoutePush { + const router = useRouter() + const goTo = React.useCallback( + (route: string, params?: { [key: string]: string }) => + router.push(buildRoute(route, params ?? {})), + [router], + ) + return goTo +} + +export function buildRoute(route: string, params: { [key: string]: string }): string { + if (!route.includes(':')) { + console.warn('Route does not contain any params', route) + return route + } + return Object.entries(params).reduce((acc, [key, value]) => acc.replace(`:${key}`, value), route) +} diff --git a/scaffolds/nextjs/core/stores/user_store.tsx b/scaffolds/nextjs/core/stores/user_store.tsx new file mode 100644 index 00000000..a2563ac8 --- /dev/null +++ b/scaffolds/nextjs/core/stores/user_store.tsx @@ -0,0 +1,167 @@ +import { makeAutoObservable } from 'mobx' +import { User } from '../models/user' +import userApi, { UserLoginParams, UserRegisterResponse } from '../api/user.api' +import { ENV } from '../env' +import { Routes, usePushRoute } from '../routes' +import { useRouter } from 'next/router' + +export type ProviderType = 'facebook' | 'google' + +export interface ParsedProviderDetails { + provider: ProviderType + accessToken: string + email: string + firstName: string + lastName: string +} + +class UserStore { + maybeUser: User | null = null + provider: ProviderType | null = null + /** Token used to login to {{ hyphenCase name }} only - sourced from social login */ + accessToken: string | null = null + /** Token used to auth inside {{ hyphenCase name }} - sourced from `/login` endpoint */ + idToken: string | null = null + loading = true + + constructor() { + makeAutoObservable(this) + if (ENV.BROWSER_LOADED && localStorage.getItem('idToken')) { + this.getStoredIdToken() + } + if (ENV.BROWSER_LOADED && localStorage.getItem('accessToken')) { + this.getStoredAccessToken() + } + } + + public async login(params: UserLoginParams): Promise { + this.loading = true + const resp = await userApi.login(params) + const { user, id_token: idToken } = resp + this.setUser(user) + this.setIdToken(idToken) + this.setAccessToken(params.provider, params.accessToken) + this.loading = false + return resp + } + + public async silentLogin(): Promise { + this.loading = true + this.getStoredAccessToken() + if (this.provider && this.accessToken) { + const resp = await this.login({ provider: this.provider, accessToken: this.accessToken }) + this.loading = false + return resp + } + this.loading = false + } + + /** Token used to login to {{ hyphenCase name }} only - sourced from social login */ + public getStoredAccessToken() { + const provider = localStorage.getItem('tokenProvider') as ProviderType | null + const token = localStorage.getItem('accessToken') + if (provider && token) { + this.setAccessToken(provider, token) + } + } + + /** Token used to auth inside {{ hyphenCase name }} - sourced from `/login` endpoint */ + public getStoredIdToken() { + const token = localStorage.getItem('idToken') + if (token) { + this.setIdToken(token) + } + } + + public get user(): User { + return this.maybeUser! + } + + private setUser(user: User) { + this.maybeUser = user + } + + public get isLoggedIn(): boolean { + return Boolean(this.maybeUser) + } + + /** Token used to login to {{ hyphenCase name }} only - sourced from social login */ + private setAccessToken(provider: ProviderType, accessToken: string) { + if (ENV.BROWSER_LOADED) { + localStorage.setItem('accessToken', accessToken) + localStorage.setItem('tokenProvider', provider) + } + this.accessToken = accessToken + this.provider = provider + } + + /** Token used to auth inside {{ hyphenCase name }} - sourced from `/login` endpoint */ + private setIdToken(idToken: string) { + if (ENV.BROWSER_LOADED) { + localStorage.setItem('idToken', idToken) + } + this.idToken = idToken + userApi.setAuthHeaders(idToken) + } +} + +const _userStore = new UserStore() + +/** Don't forget to wrap in observer if needed */ +export function useUserStore(): UserStore { + return _userStore +} + +/** Don't forget to wrap in observer if needed */ +export function useUser(): User { + return useUserStore().user +} + +function _useLoginRedirect(force?: boolean): (registered: boolean) => void { + const goTo = usePushRoute() + const router = useRouter() + + return (registered) => { + if (!force && router.pathname !== '/') { + return + } + if (registered || force) { + // TODO use [Routes.Home] once redirect is removed + goTo(Routes.PotentialFlatsList) + } else { + goTo(Routes.SignupUserDetails) + } + } +} + +export function useLoginFlow(forceRedirect?: boolean): (details: UserLoginParams) => Promise { + const store = useUserStore() + const redirect = _useLoginRedirect(forceRedirect) + + return async (details) => { + const { registered } = await store.login(details) + redirect(registered) + } +} + +export function useSilentLoginFlow(forceRedirect?: boolean): () => Promise { + const store = useUserStore() + const redirect = _useLoginRedirect(forceRedirect) + const router = useRouter() + + return async () => { + try { + const resp = await store.silentLogin() + if (!resp) { + // TODO check public route permission instead of directly checking against [Routes.Home] + if (!store.isLoggedIn && router.pathname !== Routes.Home) { + router.push(Routes.Signup) + } + return + } + redirect(resp.registered) + } catch (e) { + router.push(Routes.Signup) + } + } +} diff --git a/scaffolds/nextjs/core/theme.ts b/scaffolds/nextjs/core/theme.ts new file mode 100644 index 00000000..6bdd09de --- /dev/null +++ b/scaffolds/nextjs/core/theme.ts @@ -0,0 +1,111 @@ +import { alpha } from '@mui/material/styles' +import createTheme, { Theme } from '@mui/material/styles/createTheme' + +const colors = { + primary: '#638DB3', + link: '#0066FF', + primaryGradientTop: '#7EACD6', + primaryGradientBottom: '#6089AF', + disabledBackground: '#bdbdbd', + disabled: '#DCDCDC', +} + +const shadows = { + input: `0 2px 10px ${alpha('#000000', 0.25)}`, + card: `0 1px 9px ${alpha('#000000', 0.25)}`, + likedCard: `0 1px 9px 1px ${alpha('#0FA639', 0.5)}`, + dislikedCard: `0 1px 9px 1px ${alpha('#E22F29', 0.5)}`, + mutualCard: `0 1px 9px 1px ${alpha('#0C77DF', 0.5)}`, + matchButton: `0 2px 9px 0 ${alpha('#000000', 0.25)}`, + avatar: `0 4px 11px ${alpha('#000000', 0.38)}`, +} + +export const lightTheme = createTheme({ + boxShadows: shadows, + typography: { + fontFamily: 'Assistant, Roboto, Helvetica, sans-serif', + }, + palette: { + mode: 'light', + primary: { + main: colors.primary, + }, + link: { + main: colors.link, + }, + disabled: { + main: colors.disabled, + }, + disabledBackground: { + main: colors.disabledBackground, + }, + }, + components: { + MuiLink: { + styleOverrides: { + root: { + color: colors.link, + textDecoration: 'none', + }, + }, + }, + MuiButton: { + styleOverrides: { + root: { + fontSize: '24px', + fontWeight: 600, + borderRadius: '2em', + minWidth: '200', + '&.Mui-disabled': { + background: colors.disabledBackground, + }, + }, + containedPrimary: { + background: `linear-gradient(180deg, ${colors.primaryGradientTop}, ${colors.primaryGradientBottom})`, + }, + }, + }, + MuiTextField: { + styleOverrides: { + root: { + fontSize: '20', + }, + }, + }, + MuiInputBase: { + styleOverrides: { + multiline: { + borderRadius: '0 !important', + }, + }, + }, + MuiSelect: { + defaultProps: { + variant: 'outlined', + }, + }, + MuiOutlinedInput: { + styleOverrides: { + root: { + borderRadius: '2em', + boxShadow: shadows.input, + outline: 'none', + }, + notchedOutline: { + border: '0', + }, + }, + }, + MuiTypography: { + styleOverrides: { + h5: { + fontSize: 22, + }, + }, + }, + }, +}) + +export function themeDir(theme: Theme, direction: 'ltr' | 'rtl'): Theme { + return { ...theme, direction } +} diff --git a/scaffolds/nextjs/core/types.ts b/scaffolds/nextjs/core/types.ts new file mode 100644 index 00000000..22eaffdb --- /dev/null +++ b/scaffolds/nextjs/core/types.ts @@ -0,0 +1,20 @@ +import React from 'react' + +export interface HTMLComponent + extends React.PropsWithChildren, T>>, + CustomComponent { + // +} + +export type CustomComponent = T & { + className?: string +} + +export interface Option { + value: T + label: string +} + +export interface ReactOptions extends Omit, 'label'> { + label: React.ReactNode +} diff --git a/scaffolds/nextjs/core/utils/array_utils.ts b/scaffolds/nextjs/core/utils/array_utils.ts new file mode 100644 index 00000000..27814fcc --- /dev/null +++ b/scaffolds/nextjs/core/utils/array_utils.ts @@ -0,0 +1,4 @@ +export function asArray(value: T | T[0]): T { + if (value === null || value === undefined) return [] as any + return Array.isArray(value) ? value : ([value] as any) +} diff --git a/scaffolds/nextjs/core/utils/error_helpers.ts b/scaffolds/nextjs/core/utils/error_helpers.ts new file mode 100644 index 00000000..d1493b4c --- /dev/null +++ b/scaffolds/nextjs/core/utils/error_helpers.ts @@ -0,0 +1,14 @@ +import { useTranslation } from 'next-i18next' +import React from 'react' +import { FieldErrors } from 'react-hook-form' + +export function useFormErrorText( + errors: FieldErrors, +): (key: keyof T, error: string) => string | undefined { + const fn = React.useCallback( + (key: keyof T, error: string) => (errors?.[key] ? error : undefined), + [errors], + ) + + return fn +} diff --git a/scaffolds/nextjs/core/utils/html_utils.ts b/scaffolds/nextjs/core/utils/html_utils.ts new file mode 100644 index 00000000..0f1caa1e --- /dev/null +++ b/scaffolds/nextjs/core/utils/html_utils.ts @@ -0,0 +1,57 @@ +import React from 'react' +import debounce from 'lodash/debounce' +import { DebouncedFunc } from 'lodash' +import { ENV } from '../env' + +export interface UseWindowSizeOptions { + type: 'inner' | 'outer' + delay?: number +} +export function useWindowSize({ + type = 'inner', + delay = 100, +}: Partial = {}): { + width: number + height: number +} { + const wKey: keyof Window = React.useMemo(() => `${type}Width` as keyof Window, [type]) + const hKey: keyof Window = React.useMemo(() => `${type}Height` as keyof Window, [type]) + + const [windowSize, setWindowSize] = React.useState({ + width: ENV.BROWSER_LOADED ? window[wKey] : 0, + height: ENV.BROWSER_LOADED ? window[hKey] : 0, + }) + + const handleResize = useDebounce( + (): void => setWindowSize({ width: window?.[wKey] ?? 0, height: window?.[hKey] ?? 0 }), + delay, + [hKey, wKey, delay], + ) + + React.useEffect(() => { + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, [hKey, handleResize, wKey]) + + return windowSize +} + +export function useDebounce unknown>( + fn: T, + delay: number | undefined = undefined, + deps: unknown[] = [], +): DebouncedFunc { + const cb = React.useMemo(() => debounce(fn, delay), [fn, delay]) + return React.useCallback(cb, [cb, ...deps]) +} + +export function preventDefault< + T extends React.SyntheticEvent, + Cb extends (e: T, ...args: unknown[]) => void = (e: T, ...args: unknown[]) => void, +>(cb: Cb): Cb { + return ((e: T, ...args: unknown[]): void => { + e.preventDefault() + e.stopPropagation() + cb(e, ...args) + }) as unknown as Cb +} diff --git a/scaffolds/nextjs/core/utils/image_utils.ts b/scaffolds/nextjs/core/utils/image_utils.ts new file mode 100644 index 00000000..8a645ce8 --- /dev/null +++ b/scaffolds/nextjs/core/utils/image_utils.ts @@ -0,0 +1,16 @@ +export function fallbackImageUrl( + text: string, + { width, height }: Record<'width' | 'height', number>, +): string { + const qs = new URLSearchParams({ + bg_color: '008000', + fg_color: 'FFFFFF', + text, + }) + + return `https://placeholder.photo/img/${width}x${height}?` + qs.toString() +} + +export function apiFallbackImageUrl(url: string): string { + return `/api/image?url=${decodeURIComponent(url)}` +} diff --git a/scaffolds/nextjs/core/utils/locale_utils.ts b/scaffolds/nextjs/core/utils/locale_utils.ts new file mode 100644 index 00000000..3ccfb6d8 --- /dev/null +++ b/scaffolds/nextjs/core/utils/locale_utils.ts @@ -0,0 +1,6 @@ +import { useTranslation } from 'next-i18next' + +export function useLocaleDirection(): 'ltr' | 'rtl' { + const { i18n } = useTranslation() + return i18n.dir(i18n.language) +} diff --git a/scaffolds/nextjs/core/utils/object_utils.ts b/scaffolds/nextjs/core/utils/object_utils.ts new file mode 100644 index 00000000..f9e5fa3b --- /dev/null +++ b/scaffolds/nextjs/core/utils/object_utils.ts @@ -0,0 +1,33 @@ +import { SxProps } from '@mui/material/styles' +import { Theme } from '@mui/material/styles/createTheme' +import { SystemCssProperties } from '@mui/system/styleFunctionSx' +import { Option } from '../types' +import { asArray } from './array_utils' + +export function uniqueKeyFromObj(object: T, keys?: Array): string { + if (!keys) { + keys = Object.keys(object) as Array + } + return keys.map((key) => String(object[key]).replaceAll(/[^a-z0-9]+/gi, '_')).join('-') +} + +export function uniqueKeyFrom(...array: Array): string { + return array.map((item) => String(item).replaceAll(/[^a-z0-9]+/gi, '_')).join('-') +} + +/** + * Combines `sx` props easily + * @param sx sx prop for this component + * @param rest passed sx props from parent + * @returns sx compatible props combined + */ +export function sxc( + sx: SxProps, + ...rest: Array | SystemCssProperties | undefined | false> +): SxProps { + return [...asArray(sx), ...asArray(rest)] +} + +export function optionFrom(value: string): Option { + return { label: value, value } +} diff --git a/scaffolds/nextjs/core/utils/promise_utils.ts b/scaffolds/nextjs/core/utils/promise_utils.ts new file mode 100644 index 00000000..3d37da86 --- /dev/null +++ b/scaffolds/nextjs/core/utils/promise_utils.ts @@ -0,0 +1,3 @@ +export async function delayedPromise(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/scaffolds/nextjs/core/utils/react_utils.ts b/scaffolds/nextjs/core/utils/react_utils.ts new file mode 100644 index 00000000..a65d6c92 --- /dev/null +++ b/scaffolds/nextjs/core/utils/react_utils.ts @@ -0,0 +1,92 @@ +import React from 'react' +import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query' +import { ListResult } from '../api/api_types' + +export function useRerender(): () => void { + const [, rerender] = React.useState(0) + return React.useCallback(() => rerender((r) => r + 1), [rerender]) +} + +type MaybeKey = `maybe${Capitalize}` + +export type EnhancedQueryResponse = UseQueryResult & { + [key in MaybeKey>]: T[K] | undefined +} & { + [key in K]: T[K] +} + +type EnhancedQueryHook = ( + options?: RequiredQueryOptions, +) => EnhancedQueryResponse + +export type RequiredQueryOptions = Omit, 'queryKey' | 'queryFn'> + +type StateUpdater = T extends unknown[] + ? { + add: (item: T[number]) => void + update: (item: T[number]) => void + remove: (item: T[number]) => void + } + : { + set: (item: T) => void + } + +type EnhancedQueryOptions< + K extends Exclude, + T, + IKey extends keyof (T[K] extends unknown[] ? T[K][number] : never), +> = { + cacheKey: string + responseKey: K + innerKey?: IKey + defaultOptions?: RequiredQueryOptions + // objectId?: (a: never) => unknown + // objectId?: (a: SingleGetter) => unknown +} + +type InnerObjTest = T[K] extends unknown[] ? T[K][number] : never + +type SingleGetter< + T, + K extends keyof T, + IKey extends keyof InnerObjTest, +> = T[K] extends unknown[] + ? IKey extends keyof T[K][number] + ? T[K][number][IKey] + : T[K][number] + : IKey extends keyof T[K] + ? T[K][IKey] + : T[K] + +export function createUseApi< + T, + K extends Exclude, + IKey extends keyof (T[K] extends unknown[] ? T[K][number] : never), +>( + api: () => Promise, + { cacheKey, responseKey, defaultOptions }: EnhancedQueryOptions, +): EnhancedQueryHook { + return (options): EnhancedQueryResponse => { + const [cache, setCache] = React.useState() + + const { data = {} as T, ...rest } = useQuery([cacheKey], api, { + ...defaultOptions, + ...options, + onSuccess: (data) => { + setCache(data[responseKey]) + options?.onSuccess?.(data) + }, + } as Omit, 'queryKey' | 'queryFn'>) + const ent = data[responseKey]! + + const response: EnhancedQueryResponse = { + ...(rest as UseQueryResult), + [responseKey]: ent, + ['maybe' + responseKey[0].toUpperCase() + responseKey.slice(1)]: ent, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: cache as never, + } + console.log(cacheKey, 'response', response.data, response) + return response + } +} diff --git a/scaffolds/nextjs/mui.d.ts b/scaffolds/nextjs/mui.d.ts new file mode 100644 index 00000000..10669c34 --- /dev/null +++ b/scaffolds/nextjs/mui.d.ts @@ -0,0 +1,26 @@ +import { PaletteOptions, PaletteColorOptions } from '@mui/material/styles/createPalette' + +declare module '@mui/material/styles/createTheme' { + type Shadows = + | 'input' + | 'card' + | 'likedCard' + | 'dislikedCard' + | 'mutualCard' + | 'matchButton' + | 'avatar' + interface ThemeOptions { + boxShadows: Record + } + + interface Theme { + boxShadows: Record + } +} +declare module '@mui/material/styles/createPalette' { + export interface PaletteOptions { + link?: PaletteColorOptions + disabled?: PaletteColorOptions + disabledBackground?: PaletteColorOptions + } +} diff --git a/scaffolds/nextjs/next-env.d.ts b/scaffolds/nextjs/next-env.d.ts new file mode 100644 index 00000000..4f11a03d --- /dev/null +++ b/scaffolds/nextjs/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/scaffolds/nextjs/next-i18next.config.js b/scaffolds/nextjs/next-i18next.config.js new file mode 100644 index 00000000..ec592b1f --- /dev/null +++ b/scaffolds/nextjs/next-i18next.config.js @@ -0,0 +1,9 @@ +module.exports = { + debug: process.env.NODE_ENV === 'development', + i18n: { + defaultLocale: 'he', + locales: ['he'], + }, + defaultNS: '{{ hyphenCase name }}', + reloadOnPrerender: process.env.NODE_ENV === 'development', +} diff --git a/scaffolds/nextjs/next.config.js b/scaffolds/nextjs/next.config.js new file mode 100644 index 00000000..28e7e372 --- /dev/null +++ b/scaffolds/nextjs/next.config.js @@ -0,0 +1,17 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const withTM = require('next-transpile-modules')(['@mui/material']) // pass the modules you would like to see transpiled +const withImages = require('next-images') +const { i18n } = require('./next-i18next.config') + +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'standalone', + reactStrictMode: true, + swcMinify: true, + i18n, + images: { + domains: ['placeholder.photo'], + }, +} + +module.exports = withImages(withTM(nextConfig)) diff --git a/scaffolds/nextjs/package.json b/scaffolds/nextjs/package.json new file mode 100644 index 00000000..67b0770a --- /dev/null +++ b/scaffolds/nextjs/package.json @@ -0,0 +1,61 @@ +{ + "name": "{{ hyphenCase name }}", + "version": "0.1.0", + "private": true, + "license": "UNLICENSED", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "gen:page": "npx -y simple-scaffold@latest -t templates/page -s false -o pages ", + "gen:component": "npx -y simple-scaffold@latest -t templates/component -s false -o components/atoms", + "build:docker": "docker build -t nextjs-docker .", + "start:docker": "docker run -p 3100:3000 nextjs-docker" + }, + "dependencies": { + "@emotion/cache": "^11.9.3", + "@emotion/react": "^11.9.3", + "@emotion/server": "^11.4.0", + "@emotion/styled": "^11.9.3", + "@mui/icons-material": "^5.8.4", + "@mui/material": "^5.9.2", + "axios": "^0.27.2", + "lodash": "^4.17.21", + "mapbox-gl": "^2.9.2", + "mobx": "^6.6.1", + "mobx-react-lite": "^3.4.0", + "next": "12.2.3", + "next-i18next": "^11.3.0", + "next-images": "^1.8.4", + "next-transpile-modules": "^9.0.0", + "node-fetch": "^3.2.10", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-facebook-login": "^4.1.1", + "react-hook-form": "^7.34.0", + "react-query": "^3.39.2", + "react-responsive-carousel": "^3.2.23", + "react-use": "^17.4.0", + "stylis": "^4.1.1", + "stylis-plugin-rtl": "^2.1.1" + }, + "devDependencies": { + "@types/lodash": "^4.14.182", + "@types/mapbox-gl": "^2.7.5", + "@types/node": "18.6.2", + "@types/react": "18.0.15", + "@types/react-dom": "18.0.6", + "@types/react-facebook-login": "^4.1.5", + "@types/stylis": "^4.0.2", + "@typescript-eslint/eslint-plugin": "^5.34.0", + "@typescript-eslint/parser": "^5.34.0", + "css-loader": "^6.7.1", + "csv-parse": "^5.3.0", + "eslint": "8.20.0", + "eslint-config-next": "12.2.3", + "style-loader": "^3.3.1", + "ts-node": "^10.9.1", + "typescript": "4.7.4" + } +} diff --git a/scaffolds/nextjs/pages/_app.tsx b/scaffolds/nextjs/pages/_app.tsx new file mode 100644 index 00000000..952bcb0b --- /dev/null +++ b/scaffolds/nextjs/pages/_app.tsx @@ -0,0 +1,71 @@ +import type { AppProps } from 'next/app' +import CssBaseline from '@mui/material/CssBaseline' +import ThemeProvider from '@mui/material/styles/ThemeProvider' +import { lightTheme, themeDir } from '../core/theme' +import { CacheProvider, EmotionCache } from '@emotion/react' +import { createEmotionCache } from '../core/emotion' +import { enableStaticRendering } from 'mobx-react-lite' +import { appWithTranslation, useTranslation } from 'next-i18next' +import nextI18nConfig from '../next-i18next.config' +import { QueryClientProvider, QueryClient } from 'react-query' +import React from 'react' +import 'mapbox-gl/dist/mapbox-gl.css' +import mapboxgl from 'mapbox-gl' +import { ENV } from '../core/env' +import { useSilentLoginFlow } from '../core/stores/user_store' + +mapboxgl.accessToken = ENV.MAPBOX_API_KEY + +export interface {{ pascalCase name }}AppProps extends AppProps { + emotionCache?: EmotionCache +} + +let windowInit = false + +const clientSideEmotionCache = createEmotionCache() +const isBrowserLoaded = ENV.BROWSER_LOADED +enableStaticRendering(!isBrowserLoaded) + +const _{{ pascalCase name }}App: React.FC<{{ pascalCase name }}AppProps> = (props) => { + const { Component, pageProps, emotionCache = clientSideEmotionCache } = props + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: Infinity, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }, + }, + }) + + const { i18n } = useTranslation() + const themeWithDirection = React.useMemo( + () => themeDir(lightTheme, i18n.dir(i18n.language)), + [i18n], + ) + const silentLogin = useSilentLoginFlow() + + React.useEffect(() => { + if (!windowInit && typeof window !== 'undefined') { + windowInit = true + silentLogin() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [typeof window]) + + return ( + + + + + + + + + ) +} + +const {{ pascalCase name }}App = appWithTranslation(_{{ pascalCase name }}App, nextI18nConfig) + +export default {{ pascalCase name }}App diff --git a/scaffolds/nextjs/pages/_document.tsx b/scaffolds/nextjs/pages/_document.tsx new file mode 100644 index 00000000..a91427cf --- /dev/null +++ b/scaffolds/nextjs/pages/_document.tsx @@ -0,0 +1,91 @@ +import * as React from 'react' +import Document, { Html, Head, Main, NextScript } from 'next/document' +import { createEmotionCache } from '../core/emotion' +import createEmotionServer from '@emotion/server/create-instance' + +export default class MyDocument extends Document { + render() { + return ( + + + + + + + {(this.props as any).emotionStyleTags} + + + {/* Main page content - deferred */} +
+ + + + ) + } +} + +// `getInitialProps` belongs to `_document` (instead of `_app`), +// it's compatible with static-site generation (SSG). +MyDocument.getInitialProps = async (ctx) => { + // Resolution order + // + // On the server: + // 1. app.getInitialProps + // 2. page.getInitialProps + // 3. document.getInitialProps + // 4. app.render + // 5. page.render + // 6. document.render + // + // On the server with error: + // 1. document.getInitialProps + // 2. app.render + // 3. page.render + // 4. document.render + // + // On the client + // 1. app.getInitialProps + // 2. page.getInitialProps + // 3. app.render + // 4. page.render + + const originalRenderPage = ctx.renderPage + + // You can consider sharing the same emotion cache between all the SSR requests to speed up performance. + // However, be aware that it can have global side effects. + const cache = createEmotionCache() + const { extractCriticalToChunks } = createEmotionServer(cache) + + /* eslint-disable */ + ctx.renderPage = () => + originalRenderPage({ + enhanceApp: (App) => + function EnhanceApp(props) { + const _injectedProps: any = { emotionCache: cache } + return + }, + }) + /* eslint-enable */ + + const initialProps = await Document.getInitialProps(ctx) + // This is important. It prevents emotion to render invalid HTML. + // See https://github.com/mui-org/material-ui/issues/26561#issuecomment-855286153 + const emotionStyles = extractCriticalToChunks(initialProps.html) + const emotionStyleTags = emotionStyles.styles.map((style) => ( +