add more templates

This commit is contained in:
Chen Asraf
2022-09-01 23:52:07 +03:00
parent 8815b820da
commit 6734d4f154
70 changed files with 6340 additions and 7 deletions

2
.eslintignore Normal file
View File

@@ -0,0 +1,2 @@
templates/
scaffolds/

2
.prettierignore Normal file
View File

@@ -0,0 +1,2 @@
templates/
scaffolds/

View File

@@ -1,6 +1,6 @@
{
"cSpell.words": [
"dotfiles"
],
"python.linting.enabled": true
"cSpell.words": [
"dotfiles"
],
"python.linting.enabled": true
}

7
.zshrc
View File

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

View File

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

View File

@@ -0,0 +1,8 @@
[*]
tab_width = 2
indent_size = 2
indent_style = space
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

View File

@@ -0,0 +1 @@
templates/**/*

View File

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

38
scaffolds/nextjs/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,15 @@
{
"printWidth": 100,
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"overrides": [
{
"files": "*.md",
"options": {
"printWidth": 100,
"proseWrap": "always"
}
}
]
}

16
scaffolds/nextjs/.vscode/settings.json vendored Normal file
View File

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

View File

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

20
scaffolds/nextjs/.vscode/tasks.json vendored Normal file
View File

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

View File

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

View File

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

View File

@@ -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<BoxProps> {
src: string
size?: number
padding?: number
}
export const Avatar: React.FC<AvatarProps> = ({ sx, size = 160, src, padding = 8 }) => {
return (
<Box
sx={sxc(
{
width: size,
height: size,
borderRadius: '50%',
overflow: 'hidden',
boxShadow: (theme) => theme.boxShadows.avatar,
padding: `${padding}px`,
},
sx,
)}
>
<Box
sx={{
width: size - padding * 2,
height: size - padding * 2,
borderRadius: '50%',
overflow: 'hidden',
}}
>
<Image
src={apiFallbackImageUrl(src)}
objectFit="cover"
width={size}
height={size}
alt={src}
/>
</Box>
</Box>
)
}

View File

@@ -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<T, K extends Path<T>, Multiple extends boolean = false>
extends CustomComponent<Partial<AutocompleteProps<Option, Multiple, false, false>>> {
control: Control<T>
name: K
options: Option[]
helperText?: React.ReactNode
placeholder?: string
required?: boolean
multiple?: Multiple
}
export const ControlledAutocomplete = <T, K extends Path<T>, Multiple extends boolean = false>(
props: ControlledAutocompleteProps<T, K, Multiple>,
) => {
const { control, name, options, helperText, placeholder, onChange, multiple, ...rest } = props
const _value = React.useMemo(
(): Multiple extends true ? Option<string>[] : Option<string> | 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 (
<Controller<T, K>
control={control}
name={name}
rules={{ required: props.required }}
render={({ field, formState: { errors } }) => (
<Autocomplete
onChange={(e, d, r) => {
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) => (
<TextField
{...params}
fullWidth
placeholder={placeholder}
helperText={helperText}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error={Boolean((errors as any)[name])}
/>
)}
{...rest}
/>
)}
/>
)
}

View File

@@ -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<T>,
Multiple extends boolean = false,
> extends CustomComponent<Partial<SelectProps<R>>> {
control: Control<T>
name: K
options: Option<R>[]
helperText?: React.ReactNode
placeholder?: string
required?: boolean
multiple?: Multiple
}
export const ControlledSelect = <
R extends string,
T,
K extends Path<T>,
Multiple extends boolean = false,
>(
props: ControlledSelectProps<R, T, K, Multiple>,
) => {
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<string | string[]>(
(Array.isArray(_value) ? _value : _value) ?? (multiple ? [] : ''),
)
React.useEffect(() => setValue(_value ?? (multiple ? [] : '')), [_value, multiple])
const rerender = useRerender()
return (
<Controller<T, K>
control={control}
name={name}
rules={{ required: props.required }}
render={({ field, formState: { errors } }) => (
<Select
onChange={(e, d) => {
const { value: changeValue } = e.target!
if (!multiple) {
field.onChange(changeValue)
setValue(changeValue)
} else {
const valueArr = value as R[]
const fieldValue = valueArr.includes(changeValue as unknown as R)
? valueArr.filter((x) => x !== changeValue)
: [...valueArr, changeValue]
field.onChange(fieldValue)
setValue(fieldValue)
}
onChange?.(e, d)
rerender()
}}
value={value as R}
renderValue={
((v) =>
Array.isArray(v)
? placeholder
: options.find((x) => x.value === v)?.label ?? placeholder) as (
value: string | string[],
) => React.ReactNode
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error={Boolean((errors as any)[name])}
{...rest}
>
{options.map((opt) => (
<MenuItem
key={opt.value}
value={opt.value}
sx={
Boolean(Array.isArray(value) ? value.includes(opt.value) : value === opt.value)
? {
backgroundColor: (theme) => theme.palette.grey[200],
}
: undefined
}
>
{opt.label}
</MenuItem>
))}
</Select>
)}
/>
)
}

View File

@@ -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<BoxProps> {
//
}
export const PageLoader: React.FC<PageLoaderProps> = ({ sx }) => {
return (
<Box
position="absolute"
top={TOOLBAR_HEIGHT}
bottom={0}
left={0}
right={0}
display="flex"
alignItems="center"
justifyContent="center"
width="100%"
height="calc(100vh - 90px)"
sx={sx}
>
<CircularProgress size={64} />
</Box>
)
}

View File

@@ -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<BoxProps> {
fixedMaxWidth?: boolean
loading?: boolean
pageTitle?: React.ReactNode
htmlTitle?: string
htmlDescription?: string
render?: (props: PageWrapperProps) => React.ReactNode
}
export const PageWrapper: React.FC<PageWrapperProps> = (props) => {
const {
sx,
children,
loading,
fixedMaxWidth = true,
htmlTitle,
htmlDescription,
pageTitle,
render,
} = props
return (
<Box
sx={sxc(
{
padding: (theme) => theme.spacing(3, 2),
margin: '0 auto',
},
fixedMaxWidth && {
maxWidth: 600,
},
sx,
)}
>
{htmlTitle || htmlDescription ? (
<Head>
<title>{htmlTitle}</title>
<meta name="description" content={htmlDescription} />
</Head>
) : null}
{/* App Bar */}
<MainAppBar />
{/* Empty toolbar - to save space for fixed app bar */}
<Toolbar sx={{ height: `${TOOLBAR_HEIGHT}px` }} />
{pageTitle ? (
<Typography fontWeight={600} variant="h5">
{pageTitle}
</Typography>
) : null}
{loading ? <PageLoader /> : render?.(props) ?? children}
</Box>
)
}

View File

@@ -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<Flat[]>(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))
},
}
}

View File

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

View File

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

View File

@@ -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<GetPlacesListResult>) {
const { places = [], ...rest } = usePlaces(options)
const data = React.useMemo(() => uniqBy(places, 'city'), [places])
return { ...rest, cities: data }
}
export function useNeighborhoods(options?: RequiredQueryOptions<GetPlacesListResult>) {
const { places = [], ...rest } = usePlaces(options)
const data = React.useMemo(() => uniqBy(places, 'neighborhood'), [places])
return { ...rest, neighborhoods: data }
}

View File

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

View File

@@ -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<MUILinkProps> = (props) => {
const { href, children, ...rest } = props
return (
<NextLink passHref href={href!}>
<MUILink {...rest}>{children}</MUILink>
</NextLink>
)
}

View File

@@ -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<HTMLButtonElement | null>(null)
const goTo = usePushRoute()
const { t } = useTranslation('{{ hyphenCase name }}')
const handleMenuClick = React.useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
setMenuRef(e.currentTarget)
}, [])
const handleMenuClose = React.useCallback(() => {
setMenuRef(null)
}, [])
return (
<>
<AppBar color="inherit">
<Toolbar sx={{ height: `${TOOLBAR_HEIGHT}px` }}>
<IconButton onClick={handleMenuClick} color="inherit">
<MenuIcon />
</IconButton>
<IconButton color="inherit" onClick={() => goTo(Routes.Notifications)}>
<Notifications />
</IconButton>
<Box display="flex" flexGrow={1} alignItems="center" justifyContent="center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/logo.svg" alt="{{ pascalCase name }}" width={115} height={25} />
</Box>
<IconButton color="inherit">
<ChevronLeft />
</IconButton>
</Toolbar>
</AppBar>
<Menu anchorEl={menuRef} open={!!menuRef} onClose={handleMenuClose}>
<MenuItem onClick={() => goTo(Routes.PotentialFlatsList)}>
{t('app_bar.menu.results')}
</MenuItem>
<MenuItem onClick={() => goTo(Routes.MatchedFlatsList)}>
{t('app_bar.menu.matches')}
</MenuItem>
<MenuItem onClick={() => goTo(Routes.LikedFlatsList)}>{t('app_bar.menu.liked')}</MenuItem>
<MenuItem onClick={() => goTo(Routes.DislikedFlatsList)}>
{t('app_bar.menu.disliked')}
</MenuItem>
<MenuItem onClick={() => goTo(Routes.Profile)}>{t('app_bar.menu.profile')}</MenuItem>
</Menu>
</>
)
})

View File

@@ -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<T, K extends keyof T>(
// eslint-disable-next-line no-unused-vars
result: Promise<AxiosResponse<T>>,
// eslint-disable-next-line no-unused-vars
key: K,
): Promise<T[K]>
// eslint-disable-next-line no-unused-vars
protected async parse<T, K extends keyof T>(result: Promise<AxiosResponse<T>>): Promise<T>
protected async parse<T, K extends keyof T>(
result: Promise<AxiosResponse<T>>,
key?: K,
): Promise<T | T[K]> {
const { data } = await result
return key ? data[key] : data
}
}

View File

@@ -0,0 +1,13 @@
export type SingleResult<T, K extends string> = {
[result in K]: T
} & {
error: boolean
msg: string
}
export type ListResult<T, K extends string> = {
[result in K]?: T[]
} & {
count: number
error: boolean
msg: string
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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!,
}

View File

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

View File

@@ -0,0 +1,5 @@
export interface User {
firstName: string
lastName: string
email: string
}

View File

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

View File

@@ -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<UserRegisterResponse> {
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<UserRegisterResponse | undefined> {
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<void> {
const store = useUserStore()
const redirect = _useLoginRedirect(forceRedirect)
return async (details) => {
const { registered } = await store.login(details)
redirect(registered)
}
}
export function useSilentLoginFlow(forceRedirect?: boolean): () => Promise<void> {
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)
}
}
}

View File

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

View File

@@ -0,0 +1,20 @@
import React from 'react'
export interface HTMLComponent<T extends HTMLElement>
extends React.PropsWithChildren<React.DetailedHTMLProps<React.HTMLAttributes<T>, T>>,
CustomComponent {
//
}
export type CustomComponent<T = unknown> = T & {
className?: string
}
export interface Option<T = unknown> {
value: T
label: string
}
export interface ReactOptions<T = unknown> extends Omit<Option<T>, 'label'> {
label: React.ReactNode
}

View File

@@ -0,0 +1,4 @@
export function asArray<T extends any[]>(value: T | T[0]): T {
if (value === null || value === undefined) return [] as any
return Array.isArray(value) ? value : ([value] as any)
}

View File

@@ -0,0 +1,14 @@
import { useTranslation } from 'next-i18next'
import React from 'react'
import { FieldErrors } from 'react-hook-form'
export function useFormErrorText<T>(
errors: FieldErrors<T>,
): (key: keyof T, error: string) => string | undefined {
const fn = React.useCallback(
(key: keyof T, error: string) => (errors?.[key] ? error : undefined),
[errors],
)
return fn
}

View File

@@ -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<UseWindowSizeOptions> = {}): {
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<T extends (...any: unknown[]) => unknown>(
fn: T,
delay: number | undefined = undefined,
deps: unknown[] = [],
): DebouncedFunc<T> {
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
}

View File

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

View File

@@ -0,0 +1,6 @@
import { useTranslation } from 'next-i18next'
export function useLocaleDirection(): 'ltr' | 'rtl' {
const { i18n } = useTranslation()
return i18n.dir(i18n.language)
}

View File

@@ -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<T>(object: T, keys?: Array<keyof T>): string {
if (!keys) {
keys = Object.keys(object) as Array<keyof T>
}
return keys.map((key) => String(object[key]).replaceAll(/[^a-z0-9]+/gi, '_')).join('-')
}
export function uniqueKeyFrom(...array: Array<any>): 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<Theme>,
...rest: Array<SxProps<Theme> | SystemCssProperties<Theme> | undefined | false>
): SxProps<Theme> {
return [...asArray(sx), ...asArray(rest)]
}
export function optionFrom(value: string): Option<string> {
return { label: value, value }
}

View File

@@ -0,0 +1,3 @@
export async function delayedPromise(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View File

@@ -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<K extends string> = `maybe${Capitalize<K>}`
export type EnhancedQueryResponse<T, K extends keyof T> = UseQueryResult<T> & {
[key in MaybeKey<Exclude<K, number | symbol>>]: T[K] | undefined
} & {
[key in K]: T[K]
}
type EnhancedQueryHook<T, K extends keyof T> = (
options?: RequiredQueryOptions<T>,
) => EnhancedQueryResponse<T, K>
export type RequiredQueryOptions<T> = Omit<UseQueryOptions<T>, 'queryKey' | 'queryFn'>
type StateUpdater<T> = 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<keyof T, number | symbol>,
T,
IKey extends keyof (T[K] extends unknown[] ? T[K][number] : never),
> = {
cacheKey: string
responseKey: K
innerKey?: IKey
defaultOptions?: RequiredQueryOptions<T>
// objectId?: (a: never) => unknown
// objectId?: (a: SingleGetter<T, K, IKey>) => unknown
}
type InnerObjTest<T, K extends keyof T> = T[K] extends unknown[] ? T[K][number] : never
type SingleGetter<
T,
K extends keyof T,
IKey extends keyof InnerObjTest<T, K>,
> = 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<keyof T, number | symbol>,
IKey extends keyof (T[K] extends unknown[] ? T[K][number] : never),
>(
api: () => Promise<T>,
{ cacheKey, responseKey, defaultOptions }: EnhancedQueryOptions<K, T, IKey>,
): EnhancedQueryHook<T, K> {
return (options): EnhancedQueryResponse<T, K> => {
const [cache, setCache] = React.useState<T[K]>()
const { data = {} as T, ...rest } = useQuery([cacheKey], api, {
...defaultOptions,
...options,
onSuccess: (data) => {
setCache(data[responseKey])
options?.onSuccess?.(data)
},
} as Omit<UseQueryOptions<T>, 'queryKey' | 'queryFn'>)
const ent = data[responseKey]!
const response: EnhancedQueryResponse<T, K> = {
...(rest as UseQueryResult<T>),
[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
}
}

26
scaffolds/nextjs/mui.d.ts vendored Normal file
View File

@@ -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<Shadows, string>
}
interface Theme {
boxShadows: Record<Shadows, string>
}
}
declare module '@mui/material/styles/createPalette' {
export interface PaletteOptions {
link?: PaletteColorOptions
disabled?: PaletteColorOptions
disabledBackground?: PaletteColorOptions
}
}

5
scaffolds/nextjs/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

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

View File

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

View File

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

View File

@@ -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 (
<QueryClientProvider client={queryClient}>
<CacheProvider value={emotionCache}>
<ThemeProvider theme={themeWithDirection}>
<CssBaseline />
<Component {...pageProps} />
</ThemeProvider>
</CacheProvider>
</QueryClientProvider>
)
}
const {{ pascalCase name }}App = appWithTranslation(_{{ pascalCase name }}App, nextI18nConfig)
export default {{ pascalCase name }}App

View File

@@ -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 (
<Html lang="he" dir="rtl">
<Head>
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="*" />
<link
href="https://fonts.googleapis.com/css2?family=Assistant:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
<link rel="icon" href="/favicon.ico" />
<meta name="emotion-insertion-point" content="" />
{(this.props as any).emotionStyleTags}
</Head>
<body>
{/* Main page content - deferred */}
<Main />
<NextScript />
</body>
</Html>
)
}
}
// `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 <App {..._injectedProps} {...props} />
},
})
/* 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) => (
<style
data-emotion={`${style.key} ${style.ids.join(' ')}`}
key={style.key}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: style.css }}
/>
))
return {
...initialProps,
// Styles fragment is rendered after the app and page rendering finish.
styles: [...React.Children.toArray(initialProps.styles), ...emotionStyleTags],
}
}

View File

@@ -0,0 +1,13 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next'
type Data = {
name: string
}
export default function handler(
req: NextApiRequest,
res: NextApiResponse<Data>
) {
res.status(200).json({ name: 'John Doe' })
}

View File

@@ -0,0 +1,25 @@
import React from 'react'
import type { NextPage } from 'next'
import Head from 'next/head'
import { Routes, usePushRoute } from '../core/routes'
const Home: NextPage = () => {
const goTo = usePushRoute()
React.useEffect(() => {
goTo(Routes.Signup)
}, [goTo])
return (
<div>
<Head>
{/* TODO update */}
<title>{{ pascalCase name }}</title>
<meta name="description" content="{{ pascalCase name }} App" />
<link rel="icon" href="/favicon.ico" />
</Head>
</div>
)
}
export default Home

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,77 @@
{
"floor": "קומה",
"rooms": "חדרים",
"rooms_one": "חדר",
"rooms_other": "חדרים",
"sqm": "מ\"ר",
"city": "עיר",
"neighborhood": "שכונה",
"street": "רחוב",
"apt_number": "מספר דירה",
"apt_type": "סוג הדירה",
"freetext_placeholder": "הכנס תיאור של הנכס",
"apt_type_values": {
"apartment": "דירה",
"condo": "קונדורה",
"house": "בית",
"studio": "סטודיו",
"townhouse": "דירת עיר",
"villa": "בית וילה",
"penthouse": "פנטהאוס"
},
"price": "מחיר",
"extras": {
"parking": "חניה",
"elevator": "מעלית",
"balcony": "מרפסת",
"renovated": "משופץ",
"pets": "חיות",
"roommates": "שותפים",
"handicappedAccessibility": "גישה לנכים",
"furnished": "מרוהטת",
"secureRoom": "ממ\"ד"
},
"my": {
"title": "פרטי הדירה שלי",
"extras_title": "מה עוד יש לדירה להציע",
"photos_title": "העלו עד 6 תמונות",
"description_title": "ספר לנו על הדירה שלך",
"next": "הבא",
"save": "שמור",
"required_error": "שדה חובה"
},
"view": {
"freetext_title": "תיאור הנכס"
},
"view_switcher": {
"card": "הצג ככרטיסיות",
"list": "הצג כרשימה"
},
"matched": {
"title": "התאמות"
},
"potential": {
"title": "תוצאות"
},
"liked": {
"title": "דירות שאהבתי"
},
"disliked": {
"title": "דירות שלא אהבתי"
},
"search_preferences": {
"title": "מה אני מחפש/ת",
"neighborhoods": "שכונות",
"selected_neighborhoods": "שכונות שנבחרו",
"property_types": "סוגי הדירה",
"selected_property_types": "סוגי הדירה שנבחרו",
"minPrice": "מחיר מינימלי",
"maxPrice": "מחיר מקסימלי",
"minRooms": "מס' חדרים מינימלי",
"maxRooms": "מס' חדרים מקסימלי",
"minSqm": "מגודל (מ\"ר)",
"maxSqm": "עד גודל (מ\"ר)",
"minFloor": "מקומה",
"maxFloor": "עד קומה"
}
}

View File

@@ -0,0 +1,11 @@
{
"app_bar": {
"menu": {
"results": "תוצאות",
"matches": "התאמות",
"liked": "דירות שאהבתי",
"disliked": "דירות שלא אהבתי",
"profile": "פרופיל"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"title": "הפרופיל שלי",
"buttons": {
"details": "פרטים אישיים",
"search_preferences": "העדפות חיפוש",
"my_apt_details": "פרטי הדירה שלי"
}
}

View File

@@ -0,0 +1,27 @@
{
"select_login": {
"title": "קליק אחד ואת/ה בפנים",
"subtitle": "אנחנו כבר נדאג לכל השאר...",
"accept_terms": "בהצטרפותי אני מסכים ל<a>תנאי השימוש</a>",
"buttons": {
"facebook": "המשך עם פייסבוק",
"google": "המשך עם גוגל"
}
},
"user_details": {
"title": "פרטים אישיים",
"signup_title": "היי, איזה כיף שאת/ה פה",
"subtitle": "כמה פרטים קטנים ונתחיל לחפש לך את הדירה הבאה שלך...",
"accept_updates": "אני מעוניין לקבל עדכונים על דירות רלוונטיות והתאמות חדשות באמצעות הדואר האלקטרוני",
"submit": "הרשמה",
"save": "שמור שינויים",
"inputs": {
"first_name": "שם פרטי",
"last_name": "שם משפחה",
"phone": "טלפון",
"email": "כתובת מייל",
"required_error": "שדה חובה",
"phone_error": "לפחות 9 ספרות"
}
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -0,0 +1,4 @@
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

View File

@@ -0,0 +1,12 @@
import React from 'react'
import Box, { BoxProps } from '@mui/material/Box'
import { sxc } from '../../core/utils/object_utils'
import { CustomComponent } from '../../core/types'
export interface {{pascalCase name}}Props extends CustomComponent<BoxProps> {
//
}
export const {{pascalCase name}}: React.FC<{{pascalCase name}}Props> = ({ sx, children }) => {
return <Box sx={sxc({}, sx)}>{children}</Box>
}

View File

@@ -0,0 +1,33 @@
import React from 'react'
import { NextPage, NextPageContext } from 'next'
import { useRouter } from 'next/router'
import { getI18nProps } from '../../core/i18n'
import { useTranslation } from 'next-i18next'
import Box from '@mui/material/Box'
import { PageWrapper } from '../../components/atoms/PageWrapper'
// NOTE
// This page mights need to be SSRed using `getStaticProps`. If that's the case,
// we will need to use `getStaticPaths` to generate the routes.
export const {{pascalCase name}}: NextPage = () => {
const router = useRouter()
const params = router.query
const { t } = useTranslation('{{snakeCase name}}')
return (
<PageWrapper pageTitle="{{ startCase name }}" htmlTitle="{{ pascalCase name }}" htmlDescription="{{ pascalCase name }} App">
<Box>
{{ pascalCase name }} Page
</Box>
</PageWrapper>
)
}
export async function getServerSideProps({ locale }: NextPageContext) {
return {
props: await getI18nProps(locale, ['{{snakeCase name}}']),
}
}
export default {{pascalCase name}}

View File

@@ -0,0 +1,37 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
},
"ts-node": {
"compilerOptions": {
"module": "commonjs"
}
},
"include": [
"next-env.d.ts",
"mui.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules",
"templates/**/*"
]
}

3344
scaffolds/nextjs/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
templates/
scaffolds/

View File

@@ -0,0 +1,2 @@
templates/
scaffolds/

15
scripts/tpl.sh Normal file
View File

@@ -0,0 +1,15 @@
tpl() {
case $1 in
nextjs)
shift
npx -y simple-scaffold@latest -t "$DOTFILES/scaffolds/nextjs" -o . $@
;;
tsfiles)
shift
npx -y simple-scaffold@latest -t "$DOTFILES/scaffolds/tsfiles" -o . - $@
;;
*)
echo_red "Usage: tpl [nextjs|tsfiles]"
;;
esac
}