added tons of features
This commit is contained in:
88
.gitignore
vendored
88
.gitignore
vendored
@@ -23,7 +23,7 @@
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
*.bak
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
@@ -39,3 +39,89 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
.idea/**/aws.xml
|
||||
.idea/**/contentModel.xml
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
cmake-build-*/
|
||||
.idea/**/mongoSettings.xml
|
||||
*.iws
|
||||
out/
|
||||
.idea_modules/
|
||||
atlassian-ide-plugin.xml
|
||||
.idea/replstate.xml
|
||||
.idea/sonarlint/
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
.idea/httpRequests
|
||||
.idea/caches/build_file_checksums.ser
|
||||
logs
|
||||
*.log
|
||||
lerna-debug.log*
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
lib-cov
|
||||
coverage
|
||||
*.lcov
|
||||
.nyc_output
|
||||
.grunt
|
||||
bower_components
|
||||
.lock-wscript
|
||||
build/Release
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
web_modules/
|
||||
.npm
|
||||
.eslintcache
|
||||
.stylelintcache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
.node_repl_history
|
||||
*.tgz
|
||||
.yarn-integrity
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
.cache
|
||||
.parcel-cache
|
||||
.next
|
||||
out
|
||||
.nuxt
|
||||
dist
|
||||
.cache/
|
||||
.vuepress/dist
|
||||
.temp
|
||||
.docusaurus
|
||||
.serverless/
|
||||
.fusebox/
|
||||
.dynamodb/
|
||||
.tern-port
|
||||
.vscode-test
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.js
|
||||
.env*.local
|
||||
/messages/*.d.json.ts
|
||||
|
||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
12
.idea/bewell.iml
generated
Normal file
12
.idea/bewell.iml
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
99
.idea/codeStyles/Project.xml
generated
Normal file
99
.idea/codeStyles/Project.xml
generated
Normal file
@@ -0,0 +1,99 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<HTMLCodeStyleSettings>
|
||||
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
|
||||
</HTMLCodeStyleSettings>
|
||||
<JSCodeStyleSettings version="0">
|
||||
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
|
||||
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||
<option name="SPACE_BEFORE_GENERATOR_MULT" value="true" />
|
||||
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
|
||||
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
|
||||
<option name="CHAINED_CALL_DOT_ON_NEW_LINE" value="false" />
|
||||
<option name="SPACES_WITHIN_OBJECT_TYPE_BRACES" value="false" />
|
||||
</JSCodeStyleSettings>
|
||||
<TypeScriptCodeStyleSettings version="0">
|
||||
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
|
||||
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||
<option name="SPACE_BEFORE_GENERATOR_MULT" value="true" />
|
||||
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
|
||||
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
|
||||
<option name="SPACES_WITHIN_OBJECT_TYPE_BRACES" value="false" />
|
||||
</TypeScriptCodeStyleSettings>
|
||||
<VueCodeStyleSettings>
|
||||
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
|
||||
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
|
||||
</VueCodeStyleSettings>
|
||||
<codeStyleSettings language="HTML">
|
||||
<option name="SOFT_MARGINS" value="80" />
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
<option name="TAB_SIZE" value="2" />
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="JavaScript">
|
||||
<option name="RIGHT_MARGIN" value="92" />
|
||||
<option name="KEEP_BLANK_LINES_IN_CODE" value="1" />
|
||||
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
|
||||
<option name="ALIGN_MULTILINE_FOR" value="false" />
|
||||
<option name="SPACE_BEFORE_METHOD_PARENTHESES" value="true" />
|
||||
<option name="CALL_PARAMETERS_WRAP" value="1" />
|
||||
<option name="METHOD_PARAMETERS_WRAP" value="1" />
|
||||
<option name="METHOD_PARAMETERS_LPAREN_ON_NEXT_LINE" value="true" />
|
||||
<option name="METHOD_CALL_CHAIN_WRAP" value="5" />
|
||||
<option name="BINARY_OPERATION_WRAP" value="1" />
|
||||
<option name="TERNARY_OPERATION_WRAP" value="5" />
|
||||
<option name="TERNARY_OPERATION_SIGNS_ON_NEXT_LINE" value="true" />
|
||||
<option name="KEEP_SIMPLE_BLOCKS_IN_ONE_LINE" value="true" />
|
||||
<option name="KEEP_SIMPLE_METHODS_IN_ONE_LINE" value="true" />
|
||||
<option name="ARRAY_INITIALIZER_WRAP" value="5" />
|
||||
<option name="ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE" value="true" />
|
||||
<option name="SOFT_MARGINS" value="92" />
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
<option name="TAB_SIZE" value="2" />
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="TypeScript">
|
||||
<option name="RIGHT_MARGIN" value="127" />
|
||||
<option name="KEEP_BLANK_LINES_IN_CODE" value="1" />
|
||||
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
|
||||
<option name="ALIGN_MULTILINE_FOR" value="false" />
|
||||
<option name="SPACE_BEFORE_METHOD_PARENTHESES" value="true" />
|
||||
<option name="CALL_PARAMETERS_WRAP" value="1" />
|
||||
<option name="METHOD_PARAMETERS_WRAP" value="1" />
|
||||
<option name="METHOD_PARAMETERS_LPAREN_ON_NEXT_LINE" value="true" />
|
||||
<option name="METHOD_PARAMETERS_RPAREN_ON_NEXT_LINE" value="true" />
|
||||
<option name="METHOD_CALL_CHAIN_WRAP" value="5" />
|
||||
<option name="BINARY_OPERATION_WRAP" value="1" />
|
||||
<option name="TERNARY_OPERATION_WRAP" value="5" />
|
||||
<option name="TERNARY_OPERATION_SIGNS_ON_NEXT_LINE" value="true" />
|
||||
<option name="KEEP_SIMPLE_BLOCKS_IN_ONE_LINE" value="true" />
|
||||
<option name="KEEP_SIMPLE_METHODS_IN_ONE_LINE" value="true" />
|
||||
<option name="ARRAY_INITIALIZER_WRAP" value="5" />
|
||||
<option name="ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE" value="true" />
|
||||
<option name="SOFT_MARGINS" value="80" />
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
<option name="TAB_SIZE" value="2" />
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="Vue">
|
||||
<option name="SOFT_MARGINS" value="80" />
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
12
.idea/dataSources.xml
generated
Normal file
12
.idea/dataSources.xml
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="@10.14.88.14" uuid="44d6c739-7506-4f97-b907-615219cb4f21">
|
||||
<driver-ref>mysql.8</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:mysql://10.14.88.14:3306</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
8
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="JSHint" enabled="true" level="ERROR" enabled_by_default="true" />
|
||||
<inspection_tool class="TsLint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
</component>
|
||||
6
.idea/jsLinters/eslint.xml
generated
Normal file
6
.idea/jsLinters/eslint.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="EslintConfiguration">
|
||||
<option name="fix-on-save" value="true" />
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/bewell.iml" filepath="$PROJECT_DIR$/.idea/bewell.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
7
.idea/prettier.xml
generated
Normal file
7
.idea/prettier.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="PrettierConfiguration">
|
||||
<option name="myConfigurationMode" value="AUTOMATIC" />
|
||||
<option name="myRunOnSave" value="true" />
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/sqldialects.xml
generated
Normal file
8
.idea/sqldialects.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="SqlDialectMappings">
|
||||
<file url="file://$PROJECT_DIR$/app/[locale]/(root)/(cabinet)/cabinet/[[...slug]]/page.tsx" dialect="GenericSQL" />
|
||||
<file url="file://$PROJECT_DIR$/lib/db/prisma/sql/getUserWithAccount.sql" dialect="MySQL" />
|
||||
<file url="PROJECT" dialect="MySQL" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
50
.prettierrc.json
Normal file
50
.prettierrc.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 2,
|
||||
"useTabs": true,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"arrowParens": "avoid",
|
||||
"bracketSpacing": false,
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true,
|
||||
"importOrder": [
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^@api/(.*)$",
|
||||
"^@app/(.*)$",
|
||||
"^@components/(.*)$",
|
||||
"^@config/(.*)$",
|
||||
"^@constants/(.*)$",
|
||||
"^@hooks/(.*)$",
|
||||
"^@services/(.*)$",
|
||||
"^@shared/(.*)$",
|
||||
"^@store/(.*)$",
|
||||
"^@utils/(.*)$",
|
||||
"^@ui/(.*)$",
|
||||
"^../(.*)$",
|
||||
"^./(.*)$",
|
||||
"^[./]"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.test.js",
|
||||
"options": {
|
||||
"semi": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"*.html",
|
||||
"legacy/**/*.js"
|
||||
],
|
||||
"options": {
|
||||
"tabWidth": 4
|
||||
}
|
||||
}
|
||||
],
|
||||
"plugins": [
|
||||
"@trivago/prettier-plugin-sort-imports",
|
||||
"prettier-plugin-tailwindcss"
|
||||
]
|
||||
}
|
||||
59
actions/admin/category.ts
Normal file
59
actions/admin/category.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
'use server'
|
||||
|
||||
import {CategoryLocale} from '@prisma/client'
|
||||
import {z} from 'zod'
|
||||
|
||||
import {getCategoryBySlug} from '@/actions/model/category'
|
||||
import {i18nLocalesCodes} from '@/i18n-config'
|
||||
import {STORE_ID} from '@/lib/config/constants'
|
||||
import {db} from '@/lib/db/prisma/client'
|
||||
import {createCategoryFormSchema} from '@/lib/schemas/admin/category'
|
||||
import {cleanEmptyParams, dbErrorHandling, slug as slugger} from '@/lib/utils'
|
||||
|
||||
export const onCategoryCreateAction = async (
|
||||
formData: z.infer<typeof createCategoryFormSchema>
|
||||
) => {
|
||||
const validatedData = createCategoryFormSchema.parse(formData)
|
||||
|
||||
if (!validatedData) {
|
||||
return {error: 'Недійсні вхідні дані'}
|
||||
}
|
||||
|
||||
if (validatedData.locales.length < i18nLocalesCodes.length) {
|
||||
return {error: 'Заповніть всі мови'}
|
||||
}
|
||||
|
||||
const locales: CategoryLocale[] = []
|
||||
|
||||
for (const i in validatedData.locales) {
|
||||
const locale = validatedData.locales[i]
|
||||
|
||||
const {title, lang} = locale
|
||||
const slug = slugger(title, lang)
|
||||
const result = await getCategoryBySlug({slug, lang})
|
||||
|
||||
if (!result) {
|
||||
const normalized: any = cleanEmptyParams({slug, ...locale})
|
||||
locales.push(normalized)
|
||||
} else {
|
||||
return {error: `Категорія ${title} вже існує`}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const newCategory = await db.category.create({
|
||||
data: {
|
||||
storeId: STORE_ID,
|
||||
status: true,
|
||||
// position: 0,
|
||||
locales: {
|
||||
create: locales
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {success: JSON.stringify(newCategory, null, 2)}
|
||||
} catch (error) {
|
||||
return dbErrorHandling(error)
|
||||
}
|
||||
}
|
||||
129
actions/admin/product.ts
Normal file
129
actions/admin/product.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
'use server'
|
||||
|
||||
import {Meta, ProductLocale, ProductToStore} from '@prisma/client'
|
||||
import sanitizeHtml from 'sanitize-html'
|
||||
import {z} from 'zod'
|
||||
|
||||
import {i18nLocalesCodes} from '@/i18n-config'
|
||||
import {STORE_ID} from '@/lib/config/constants'
|
||||
import {getProductBySlug} from '@/lib/data/models/product'
|
||||
import {db} from '@/lib/db/prisma/client'
|
||||
import {createProductFormSchema} from '@/lib/schemas/admin/product'
|
||||
import {
|
||||
cleanEmptyParams,
|
||||
dbErrorHandling,
|
||||
isEmptyObj,
|
||||
slug as slugger
|
||||
} from '@/lib/utils'
|
||||
|
||||
export const onProductCreateAction = async (
|
||||
formData: z.infer<typeof createProductFormSchema>
|
||||
) => {
|
||||
const validatedData = createProductFormSchema.parse(formData)
|
||||
|
||||
if (!validatedData) return {error: 'Недійсні вхідні дані'}
|
||||
|
||||
if (validatedData.locales.length < i18nLocalesCodes.length) {
|
||||
return {error: 'Заповніть всі мови'}
|
||||
}
|
||||
|
||||
const {published, image} = validatedData
|
||||
const price = parseFloat(validatedData.price).toFixed(2)
|
||||
const price_promotional = parseFloat(
|
||||
validatedData.pricePromotional as string
|
||||
).toFixed(2)
|
||||
|
||||
if (parseFloat(price) < 1) {
|
||||
return {error: 'Ціна не може бути нижчою за одну гривню'}
|
||||
}
|
||||
|
||||
const meta: Meta[] = []
|
||||
|
||||
for (const i in validatedData.meta) {
|
||||
const normalizedMeta: any = cleanEmptyParams(validatedData.meta[i])
|
||||
|
||||
//if (!isEmptyObj(normalizedMeta)) {}
|
||||
meta.push(normalizedMeta)
|
||||
}
|
||||
|
||||
const locales: ProductLocale[] = []
|
||||
|
||||
for (const i in validatedData.locales) {
|
||||
const locale = validatedData.locales[i]
|
||||
const {title, lang} = locale
|
||||
const slug = slugger(title, lang)
|
||||
|
||||
const result = await getProductBySlug({slug, lang})
|
||||
|
||||
if (!result) {
|
||||
const normalized: any = cleanEmptyParams({slug, ...locale})
|
||||
|
||||
//locales.push({...normalized, meta: {create: meta[i]}})
|
||||
locales.push(normalized)
|
||||
} else {
|
||||
return {error: `Продукт з такою назвою ${title} вже існує`}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const newProduct = await db.product.create({
|
||||
data: {
|
||||
image,
|
||||
toStore: {
|
||||
create: {
|
||||
published,
|
||||
price,
|
||||
pricePromotional: price_promotional,
|
||||
storeId: STORE_ID
|
||||
}
|
||||
},
|
||||
locales: {
|
||||
create: locales
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {success: 'JSON.stringify(newProduct, null, 2)'}
|
||||
} catch (error) {
|
||||
return dbErrorHandling(error)
|
||||
}
|
||||
}
|
||||
|
||||
// const result = sanitizeHtml(description, {
|
||||
// allowedTags: [
|
||||
// 'p',
|
||||
// 'b',
|
||||
// 'i',
|
||||
// 'h1',
|
||||
// 'h2',
|
||||
// 'h3',
|
||||
// 'h4',
|
||||
// 'h5',
|
||||
// 'h6',
|
||||
// 'em',
|
||||
// 'strong',
|
||||
// 'a',
|
||||
// 'blockquote',
|
||||
// 'div',
|
||||
// 'li',
|
||||
// 'ol',
|
||||
// 'ul',
|
||||
// 'cite',
|
||||
// 'code',
|
||||
// 'small',
|
||||
// 'sub',
|
||||
// 'sup'
|
||||
// ],
|
||||
// nonBooleanAttributes: [],
|
||||
// allowedAttributes: {
|
||||
// a: ['href', 'name', 'target'],
|
||||
// img: ['src', 'srcset', 'alt', 'title', 'width', 'height', 'loading'],
|
||||
// selfClosing: ['img', 'hr']
|
||||
// },
|
||||
// allowedIframeHostnames: [],
|
||||
// parser: {
|
||||
// lowerCaseTags: true
|
||||
// }
|
||||
// })
|
||||
//
|
||||
// console.log(result)
|
||||
21
actions/auth/common.ts
Normal file
21
actions/auth/common.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
'use server'
|
||||
|
||||
import {getUserWithAccount} from '@prisma/client/sql'
|
||||
|
||||
import {getAccountByUserId} from '@/data/accout'
|
||||
import {getUserById} from '@/data/user'
|
||||
import {db} from '@/lib/db/prisma/client'
|
||||
|
||||
export const exisingUser = async (id: string | number) => {
|
||||
return await getUserById(id)
|
||||
}
|
||||
|
||||
export const exisingUserAccount = async (userId: string | number) => {
|
||||
return await getAccountByUserId(userId)
|
||||
}
|
||||
|
||||
export const getUserAccountByUserId = async (userId: string | number) => {
|
||||
const user = await db.$queryRawTyped(getUserWithAccount(userId))
|
||||
|
||||
return user.length > 0 ? user[0] : null
|
||||
}
|
||||
16
actions/auth/google-login.ts
Normal file
16
actions/auth/google-login.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
'use server'
|
||||
|
||||
import {AuthError} from 'next-auth'
|
||||
|
||||
import {signIn} from '@/auth'
|
||||
|
||||
export async function googleAuthenticate() {
|
||||
try {
|
||||
await signIn('google')
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return 'google log in failed'
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
87
actions/auth/login.ts
Normal file
87
actions/auth/login.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
'use server'
|
||||
|
||||
import {User} from '@prisma/client'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import {AuthError} from 'next-auth'
|
||||
import {getTranslations} from 'next-intl/server'
|
||||
import * as z from 'zod'
|
||||
|
||||
import {signIn} from '@/auth'
|
||||
import {db} from '@/lib/db/prisma/client'
|
||||
import {LoginSchema} from '@/lib/schemas'
|
||||
|
||||
export const login = async (data: z.infer<typeof LoginSchema>) => {
|
||||
const t = await getTranslations('Auth')
|
||||
// Validate the input data
|
||||
const validatedData = LoginSchema.parse(data)
|
||||
|
||||
// If the data is invalid, return an error
|
||||
if (!validatedData) {
|
||||
return {error: t('Invalid input data')}
|
||||
}
|
||||
|
||||
// Destructure the validated data
|
||||
const {email, password} = validatedData
|
||||
|
||||
const userExists = await db.user.findFirst({
|
||||
where: {email}
|
||||
})
|
||||
|
||||
if (!userExists || !userExists.password || !userExists.email) {
|
||||
return {error: t('Error.user-not-found')}
|
||||
}
|
||||
|
||||
try {
|
||||
await signIn('credentials', {
|
||||
email: userExists.email as string,
|
||||
password: password as string,
|
||||
redirectTo: '/'
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
switch (error.type) {
|
||||
case 'CredentialsSignin':
|
||||
return {error: 'Invalid credentials'}
|
||||
default:
|
||||
return {error: error.type}
|
||||
}
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
return {success: 'User successfully logged in!'}
|
||||
}
|
||||
|
||||
export const authorizeCallback = async (
|
||||
credentials: Partial<Record<'email' | 'password', unknown>>
|
||||
): Promise<User | null> => {
|
||||
const validatedData = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6)
|
||||
})
|
||||
.safeParse(credentials)
|
||||
|
||||
if (!validatedData.success) return null
|
||||
|
||||
const {email, password} = validatedData.data
|
||||
|
||||
const user = await db.user.findFirst({
|
||||
where: {email}
|
||||
})
|
||||
|
||||
if (!user || !user.password || !user.email) return null
|
||||
|
||||
try {
|
||||
if (await bcrypt.compare(password, user.password)) {
|
||||
return user
|
||||
} else {
|
||||
console.log('Invalid credentials', user.email)
|
||||
return null
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Verifying password error', err)
|
||||
}
|
||||
return null
|
||||
}
|
||||
79
actions/auth/register.ts
Normal file
79
actions/auth/register.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
'use server'
|
||||
|
||||
import bcrypt from 'bcryptjs'
|
||||
import * as z from 'zod'
|
||||
|
||||
import {db} from '@/lib/db/prisma/client'
|
||||
import {RegisterSchema} from '@/lib/schemas'
|
||||
import {hashPassword} from '@/lib/utils'
|
||||
|
||||
// import { generateVerificationToken } from "@/lib/token";
|
||||
// import { sendVerificationEmail } from "@/lib/mail";
|
||||
|
||||
export const register = async (data: z.infer<typeof RegisterSchema>) => {
|
||||
try {
|
||||
// Validate the input data
|
||||
const validatedData = RegisterSchema.parse(data)
|
||||
|
||||
// If the data is invalid, return an error
|
||||
if (!validatedData) {
|
||||
return {error: 'Invalid input data'}
|
||||
}
|
||||
|
||||
// Destructure the validated data
|
||||
const {email, name, password, passwordConfirmation} = validatedData
|
||||
|
||||
// Check if passwords match
|
||||
if (password !== passwordConfirmation) {
|
||||
return {error: 'Passwords do not match'}
|
||||
}
|
||||
|
||||
// Hash the password
|
||||
const hashedPassword = await hashPassword(password)
|
||||
|
||||
// Check to see if user already exists
|
||||
const userExists = await db.user.findFirst({
|
||||
where: {
|
||||
email
|
||||
}
|
||||
})
|
||||
|
||||
// If the user exists, return an error
|
||||
if (userExists) {
|
||||
return {error: 'Email already is in use. Please try another one.'}
|
||||
}
|
||||
|
||||
const lowerCaseEmail = email.toLowerCase()
|
||||
|
||||
// Create the user
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
email: lowerCaseEmail,
|
||||
name,
|
||||
password: hashedPassword
|
||||
}
|
||||
})
|
||||
|
||||
// Generate Verification Token
|
||||
// const verificationToken = await generateVerificationToken(email);
|
||||
|
||||
// await sendVerificationEmail(lowerCaseEmail, verificationToken.token);
|
||||
|
||||
return {success: 'Email Verification was sent'}
|
||||
} catch (error) {
|
||||
// Handle the error, specifically check for a 503 error
|
||||
console.error('Database error:', error)
|
||||
|
||||
if ((error as {code: string}).code === 'ETIMEDOUT') {
|
||||
return {
|
||||
error: 'Unable to connect to the database. Please try again later.'
|
||||
}
|
||||
} else if ((error as {code: string}).code === '503') {
|
||||
return {
|
||||
error: 'Service temporarily unavailable. Please try again later.'
|
||||
}
|
||||
} else {
|
||||
return {error: 'An unexpected error occurred. Please try again later.'}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
actions/execute-action.helper.ts
Normal file
31
actions/execute-action.helper.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import {isRedirectError} from 'next/dist/client/components/redirect-error'
|
||||
|
||||
type Options<T> = {
|
||||
actionFn: () => Promise<T>
|
||||
successMessage?: string
|
||||
}
|
||||
|
||||
const executeAction = async <T>({
|
||||
actionFn,
|
||||
successMessage = 'The actions was successful'
|
||||
}: Options<T>): Promise<{success: boolean; message: string}> => {
|
||||
try {
|
||||
await actionFn()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: successMessage
|
||||
}
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'An error has occurred during executing the action'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {executeAction}
|
||||
17
actions/model/category.ts
Normal file
17
actions/model/category.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
'use server'
|
||||
|
||||
import {CategoryLocale, Lang} from '@prisma/client'
|
||||
|
||||
import {db} from '@/lib/db/prisma/client'
|
||||
|
||||
export const getCategoryBySlug = async (data: {
|
||||
slug: string
|
||||
lang: string
|
||||
}): Promise<CategoryLocale | null> => {
|
||||
return db.categoryLocale.findFirst({
|
||||
where: {
|
||||
slug: data.slug,
|
||||
lang: data.lang as Lang
|
||||
}
|
||||
})
|
||||
}
|
||||
30
actions/permission/index.ts
Normal file
30
actions/permission/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
'use server'
|
||||
|
||||
import {auth} from '@/auth'
|
||||
import {
|
||||
Access,
|
||||
AllRolesPermissions,
|
||||
PERMISSIONS,
|
||||
type Permission,
|
||||
type SingedInSession
|
||||
} from '@/lib/permission'
|
||||
|
||||
export type CanAccessResponse = {can: boolean; session: SingedInSession | null}
|
||||
export type CanResponse = boolean | CanAccessResponse
|
||||
|
||||
const can = async (permission: Permission): Promise<CanResponse> => {
|
||||
const session: SingedInSession = (await auth()) as SingedInSession
|
||||
|
||||
if (!session) return false
|
||||
|
||||
const able =
|
||||
PERMISSIONS[session.user.role as keyof AllRolesPermissions].includes(
|
||||
permission
|
||||
)
|
||||
|
||||
return !Object.values(Access).includes(permission as Access)
|
||||
? able
|
||||
: {can: able, session}
|
||||
}
|
||||
|
||||
export default can
|
||||
19
app/(protected)/admin/category/[...slug]/page.tsx
Normal file
19
app/(protected)/admin/category/[...slug]/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import {auth} from '@/auth'
|
||||
import {CreateForm} from '@/components/(protected)/admin/category/create-form'
|
||||
import {dump} from '@/lib/utils'
|
||||
|
||||
export default async function Page({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{slug?: string[]}>
|
||||
}) {
|
||||
const session = await auth()
|
||||
const {slug} = await params
|
||||
|
||||
switch ((slug || [])[0]) {
|
||||
case 'create':
|
||||
return <CreateForm />
|
||||
}
|
||||
|
||||
return <div>{dump(slug)}</div>
|
||||
}
|
||||
14
app/(protected)/admin/category/page.tsx
Normal file
14
app/(protected)/admin/category/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
import AdminPermission from '@/components/(protected)/admin/auth/permission'
|
||||
|
||||
export default function AdminCategoryPage() {
|
||||
return (
|
||||
<div>
|
||||
<AdminPermission />
|
||||
<p>
|
||||
<Link href='/admin/category/create'>Створити</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
app/(protected)/admin/layout.tsx
Normal file
65
app/(protected)/admin/layout.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import {cookies} from 'next/headers'
|
||||
import {ReactNode} from 'react'
|
||||
|
||||
import {auth} from '@/auth'
|
||||
import AdminPermission from '@/components/(protected)/admin/auth/permission'
|
||||
import {AdminSidebar} from '@/components/(protected)/admin/sidebar'
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarTrigger
|
||||
} from '@/components/ui/sidebar'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator
|
||||
} from '@/ui/breadcrumb'
|
||||
import {Separator} from '@/ui/separator'
|
||||
|
||||
export default async function AdminLayout({children}: {children: ReactNode}) {
|
||||
//const session = await auth()
|
||||
if (!(await auth())) return <AdminPermission />
|
||||
|
||||
const cookieStore = await cookies()
|
||||
const defaultOpen = cookieStore.get('sidebar:state')?.value === 'true'
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
defaultOpen={defaultOpen}
|
||||
style={{
|
||||
// @ts-ignore
|
||||
'--sidebar-width': '16rem',
|
||||
'--sidebar-width-mobile': '18rem'
|
||||
}}
|
||||
>
|
||||
<AdminSidebar />
|
||||
<SidebarInset>
|
||||
<header className='flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12'>
|
||||
<div className='flex items-center gap-2 px-4'>
|
||||
<SidebarTrigger className='-ml-1' />
|
||||
<Separator orientation='vertical' className='mr-2 h-4' />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem className='hidden md:block'>
|
||||
<BreadcrumbLink href='#'>
|
||||
Building Your Application
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className='hidden md:block' />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Data Fetching</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</header>
|
||||
<main id='admin-bw-panel' className='container'>
|
||||
{children}
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
5
app/(protected)/admin/page.tsx
Normal file
5
app/(protected)/admin/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminPermission from '@/components/(protected)/admin/auth/permission'
|
||||
|
||||
export default async function AdminPage() {
|
||||
return <AdminPermission />
|
||||
}
|
||||
30
app/(protected)/admin/product/[...slug]/page.tsx
Normal file
30
app/(protected)/admin/product/[...slug]/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import ProductCreateEditForm from '@/components/(protected)/admin/product/create-edit-form'
|
||||
import {getProductById} from '@/lib/data/models/product'
|
||||
import {dump} from '@/lib/utils'
|
||||
|
||||
export default async function Page({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{slug?: string[]}>
|
||||
}) {
|
||||
const {slug} = await params
|
||||
const [method, id] = slug || []
|
||||
|
||||
let data = null
|
||||
|
||||
if (id) {
|
||||
data = await getProductById(parseInt(id))
|
||||
if (data) {
|
||||
data = JSON.parse(JSON.stringify(data))
|
||||
}
|
||||
}
|
||||
|
||||
switch (method) {
|
||||
case 'create':
|
||||
return <ProductCreateEditForm />
|
||||
case 'update':
|
||||
return <ProductCreateEditForm data={data} />
|
||||
default:
|
||||
return <div>{dump(slug)}</div>
|
||||
}
|
||||
}
|
||||
34
app/(protected)/admin/product/page.tsx
Normal file
34
app/(protected)/admin/product/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import {Product} from '@prisma/client'
|
||||
import {LayoutList} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import AdminPermission from '@/components/(protected)/admin/auth/permission'
|
||||
import dayjs from '@/lib/config/dayjs'
|
||||
import {getProducts} from '@/lib/data/models/product'
|
||||
import {dump} from '@/lib/utils'
|
||||
|
||||
//const products = await getProducts()
|
||||
|
||||
export default async function AdminProductPage() {
|
||||
return (
|
||||
<>
|
||||
<AdminPermission />
|
||||
<p>
|
||||
<Link href='/admin/product/create'>Створити</Link>
|
||||
</p>
|
||||
{/*<section className={'mt-12'}>
|
||||
{products
|
||||
? products.map((product: Product) => (
|
||||
<article
|
||||
key={product.id}
|
||||
className={'flex flex-row items-center justify-evenly'}
|
||||
>
|
||||
<LayoutList />
|
||||
{product.locales[0].headingTitle || product.locales[0].title}
|
||||
</article>
|
||||
))
|
||||
: null}
|
||||
</section>*/}
|
||||
</>
|
||||
)
|
||||
}
|
||||
16
app/[locale]/(auth)/layout.tsx
Normal file
16
app/[locale]/(auth)/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
|
||||
export default async function AuthLayout({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section className='relative w-full'>
|
||||
<div className='flex h-screen items-center justify-center bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-brand-violet-400 to-brand-yellow-200'>
|
||||
{/**/}
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
5
app/[locale]/(auth)/login/page.tsx
Normal file
5
app/[locale]/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import LoginForm from '@/components/auth/forms/login-form'
|
||||
|
||||
export default function LoginPage() {
|
||||
return <LoginForm />
|
||||
}
|
||||
5
app/[locale]/(auth)/register/page.tsx
Normal file
5
app/[locale]/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import RegisterForm from '@/components/auth/forms/register-form'
|
||||
|
||||
export default function RegisterPage() {
|
||||
return <RegisterForm />
|
||||
}
|
||||
26
app/[locale]/(root)/(cabinet)/cabinet/[[...slug]]/page.tsx
Normal file
26
app/[locale]/(root)/(cabinet)/cabinet/[[...slug]]/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import can, {CanAccessResponse} from '@/actions/permission'
|
||||
import LoginForm from '@/components/auth/forms/login-form'
|
||||
import CabinetIndex from '@/components/cabinet'
|
||||
import {Access} from '@/lib/permission'
|
||||
|
||||
export default async function CabinetPage({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{slug?: string[]}>
|
||||
}) {
|
||||
const user = (await can(Access.Cabinet)) as CanAccessResponse
|
||||
|
||||
if (!user.can || !user.session) {
|
||||
return (
|
||||
<div className='my-8'>
|
||||
<div className='container flex flex-col sm:flex-row'>
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
const {slug} = await params
|
||||
|
||||
return <CabinetIndex slug={slug} session={user.session} />
|
||||
}
|
||||
}
|
||||
12
app/[locale]/(root)/(shop)/checkout/page.tsx
Normal file
12
app/[locale]/(root)/(shop)/checkout/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import {Metadata} from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Checkout'
|
||||
}
|
||||
|
||||
export default function CheckoutPage() {
|
||||
//throw new Error('NOT IMPLEMENTED')
|
||||
|
||||
//const session = await auth()
|
||||
return <div>CheckoutPage</div>
|
||||
}
|
||||
17
app/[locale]/(root)/layout.tsx
Normal file
17
app/[locale]/(root)/layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import {ReactNode} from 'react'
|
||||
|
||||
import Above from '@/components/shared/above'
|
||||
import Footer from '@/components/shared/footer'
|
||||
import Header from '@/components/shared/header'
|
||||
|
||||
export default async function HomeLayout({children}: {children: ReactNode}) {
|
||||
return (
|
||||
<>
|
||||
<Above />
|
||||
<Header />
|
||||
{/*<Above />*/}
|
||||
{children}
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
69
app/[locale]/(root)/page.tsx
Normal file
69
app/[locale]/(root)/page.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import Image from 'next/image'
|
||||
|
||||
import FeatureCards from '@/components/shared/home/feature-cards'
|
||||
import {HomeCarousel} from '@/components/shared/home/home-carousel'
|
||||
import AppCatalog from '@/components/shared/sidebar/app-catalog'
|
||||
import {carousels} from '@/lib/data'
|
||||
import {db} from '@/lib/db/prisma/client'
|
||||
import {dump} from '@/lib/utils'
|
||||
import image from '@/public/uploads/products/IMG_6572.jpg'
|
||||
|
||||
// const storeModel = async (id: any) => {
|
||||
// return db.store.findFirst({
|
||||
// where: {id},
|
||||
// include: {
|
||||
// storeLocale: {
|
||||
// include: {
|
||||
// meta: {
|
||||
// include: {
|
||||
// openGraph: true
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
export default async function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<div className='mt-1'>
|
||||
<div className='container flex flex-col sm:flex-row'>
|
||||
<section className='bw-layout-col-left pt-3'>
|
||||
<AppCatalog />
|
||||
</section>
|
||||
<div className='bw-layout-col-right pt-3'>
|
||||
{/*<pre>{dump(await storeModel(1))}</pre>*/}
|
||||
<section className='w-full'>
|
||||
<HomeCarousel items={carousels}></HomeCarousel>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/*<pre>{JSON.stringify(session)}</pre>*/}
|
||||
|
||||
<section className='relative mx-auto mt-8 h-[640px] w-[840px] bg-brand-violet-200'>
|
||||
<Image
|
||||
src={'/uploads/products/IMG_6572.jpg'}
|
||||
//fill
|
||||
//sizes='(min-width: 808px) 50vw, 100vw'
|
||||
width={1280}
|
||||
height={1280}
|
||||
alt=''
|
||||
title=''
|
||||
priority
|
||||
style={{
|
||||
objectFit: 'contain' // cover, contain, none
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className='mb-4 mt-[128px]'>
|
||||
<div className='container'>
|
||||
<FeatureCards />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
34
app/[locale]/error.tsx
Normal file
34
app/[locale]/error.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import {useTranslations} from 'next-intl'
|
||||
import React from 'react'
|
||||
|
||||
import {Button} from '@/components/ui/button'
|
||||
|
||||
export default function ErrorPage({
|
||||
error,
|
||||
reset
|
||||
}: {
|
||||
error: Error
|
||||
reset: () => void
|
||||
}) {
|
||||
const t = useTranslations('Error')
|
||||
return (
|
||||
<div className='flex min-h-screen flex-col items-center justify-center'>
|
||||
<div className='w-1/3 rounded-lg p-6 text-center shadow-md'>
|
||||
<h1 className='mb-4 text-3xl font-bold'>{t('title')}</h1>
|
||||
<p className='text-destructive'>{error.message}</p>
|
||||
<Button variant='outline' className='mt-4' onClick={() => reset()}>
|
||||
{t('try-again')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='ml-2 mt-4'
|
||||
onClick={() => (window.location.href = '/')}
|
||||
>
|
||||
{t('back-to-home')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
app/[locale]/layout.tsx
Normal file
33
app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import {NextIntlClientProvider} from 'next-intl'
|
||||
import {getMessages} from 'next-intl/server'
|
||||
import {notFound} from 'next/navigation'
|
||||
import {ReactNode} from 'react'
|
||||
|
||||
import {routing} from '@/i18n/routing'
|
||||
import {TIMEZONE} from '@/lib/constants'
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
params
|
||||
}: Readonly<{
|
||||
children: ReactNode
|
||||
params: Promise<{locale: string}>
|
||||
}>) {
|
||||
const {locale} = await params
|
||||
if (!routing.locales.includes(locale as any)) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const messages = await getMessages()
|
||||
//const queryClient = new QueryClient()
|
||||
|
||||
return (
|
||||
<NextIntlClientProvider
|
||||
messages={messages}
|
||||
timeZone={TIMEZONE}
|
||||
now={new Date()}
|
||||
>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
}
|
||||
1
app/api/auth/[...nextauth]/route.ts
Normal file
1
app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {GET, POST} from '@/auth'
|
||||
0
app/api/uploads/[...filestore]/route.ts
Normal file
0
app/api/uploads/[...filestore]/route.ts
Normal file
BIN
app/favicon.ico
BIN
app/favicon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 11 KiB |
215
app/globals.css
215
app/globals.css
@@ -2,20 +2,215 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@layer base {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 4.615% 12.75%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 20 14.3% 4.1%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 20 14.3% 4.1%;
|
||||
--primary: 47.9 95.8% 53.1%;
|
||||
--primary-foreground: 26 83.3% 14.1%;
|
||||
--secondary: 60 4.8% 95.9%;
|
||||
--secondary-foreground: 24 9.8% 10%;
|
||||
--muted: 60 4.8% 95.9%;
|
||||
--muted-foreground: 25 5.3% 44.7%;
|
||||
--accent: 60 4.8% 95.9%;
|
||||
--accent-foreground: 24 9.8% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
--border: 20 5.9% 90%;
|
||||
--input: 20 5.9% 90%;
|
||||
--ring: 20 14.3% 4.1%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 20 14.3% 4.1%;
|
||||
--foreground: 60 9.1% 97.8%;
|
||||
--card: 20 14.3% 4.1%;
|
||||
--card-foreground: 60 9.1% 97.8%;
|
||||
--popover: 20 14.3% 4.1%;
|
||||
--popover-foreground: 60 9.1% 97.8%;
|
||||
--primary: 47.9 95.8% 53.1%;
|
||||
--primary-foreground: 26 83.3% 14.1%;
|
||||
--secondary: 12 6.5% 15.1%;
|
||||
--secondary-foreground: 60 9.1% 97.8%;
|
||||
--muted: 12 6.5% 15.1%;
|
||||
--muted-foreground: 24 5.4% 63.9%;
|
||||
--accent: 12 6.5% 15.1%;
|
||||
--accent-foreground: 60 9.1% 97.8%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
--border: 12 6.5% 15.1%;
|
||||
--input: 12 6.5% 15.1%;
|
||||
--ring: 35.5 91.7% 32.9%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@layer utilities {
|
||||
/* .no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; !* IE and Edge *!
|
||||
scrollbar-width: none; !* Firefox *!
|
||||
}*/
|
||||
|
||||
.bw-app-catalog-collapse {
|
||||
@apply absolute scale-0 z-20
|
||||
}
|
||||
|
||||
.header-button {
|
||||
@apply cursor-pointer p-1 border border-transparent rounded-[2px];
|
||||
}
|
||||
.h1-bold {
|
||||
@apply font-bold text-2xl lg:text-3xl;
|
||||
}
|
||||
|
||||
.bw-layout-col-left {
|
||||
@apply flex-1 sm:w-7/12 md:w-5/12 xl:w-4/12 lg:flex-col
|
||||
}
|
||||
|
||||
.bw-layout-col-right {
|
||||
@apply sm:w-5/12 md:w-7/12 xl:w-8/12 flex-1 sm:flex-auto sm:pl-4 md:pl-7 xl:pl-9
|
||||
}
|
||||
|
||||
.bw-header-col-left {
|
||||
@apply w-[9/12] flex-auto
|
||||
}
|
||||
|
||||
.bw-header-col-right {
|
||||
@apply flex-grow-0 flex-shrink-0 md:basis-[272px]
|
||||
}
|
||||
|
||||
.bw-border-color {
|
||||
@apply border-brand-violet-100;
|
||||
}
|
||||
|
||||
.bw-separator-color {
|
||||
@apply bg-brand-violet-100;
|
||||
}
|
||||
}
|
||||
|
||||
.bw-dd-menu {
|
||||
/* since nested groupes are not supported we have to use
|
||||
regular css for the nested dropdowns
|
||||
*/
|
||||
li>ul { transform: translatex(100%) scale(0) }
|
||||
li:hover>ul { transform: translatex(101%) scale(1) }
|
||||
li > button svg { transform: rotate(-90deg) }
|
||||
li:hover > button svg { transform: rotate(-270deg) }
|
||||
|
||||
/* Below styles fake what can be achieved with the tailwind config
|
||||
you need to add the group-hover variant to scale and define your custom
|
||||
min width style.
|
||||
See https://codesandbox.io/s/tailwindcss-multilevel-dropdown-y91j7?file=/index.html
|
||||
for implementation with config file
|
||||
*/
|
||||
.group:hover .group-hover\:scale-100 { transform: scale(1) }
|
||||
.group:hover .group-hover\:-rotate-180 { transform: rotate(180deg) }
|
||||
}
|
||||
|
||||
|
||||
.jodit-wysiwyg > * {
|
||||
all: revert;
|
||||
color: #262626 !important;
|
||||
}
|
||||
|
||||
.jodit-wysiwyg {
|
||||
padding: 0 16px !important;
|
||||
/*font-family: Consolas, Monaco, sans-serif;*/
|
||||
|
||||
/*p {
|
||||
font-size: 17px !important;
|
||||
}*/
|
||||
}
|
||||
|
||||
#admin-bw-panel form {
|
||||
input {
|
||||
@apply bg-white outline-0 text-[16px] leading-none;
|
||||
}
|
||||
|
||||
[role="tablist"] {
|
||||
@apply bg-brand-violet ;
|
||||
|
||||
[data-state=active] {
|
||||
@apply bg-brand-yellow text-brand-violet;
|
||||
}
|
||||
[data-state=inactive] {
|
||||
@apply text-brand-yellow bg-brand-violet;
|
||||
}
|
||||
|
||||
/*@apply bg-brand-yellow-100;*/
|
||||
|
||||
/*[id$='trigger-uk' i][data-state=active] {
|
||||
@apply bg-brand-yellow-100;
|
||||
}
|
||||
[id$='trigger-ru' i][data-state=active] {
|
||||
@apply bg-brand-violet-100;
|
||||
}*/
|
||||
}
|
||||
|
||||
label{
|
||||
@apply uppercase text-brand-violet-950 flex items-center justify-between leading-none ml-1 mt-4;
|
||||
|
||||
}
|
||||
|
||||
#form-tab-uk label {
|
||||
&:after {
|
||||
content: ' ';
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 0;
|
||||
border-top: solid #0066cc 6px;
|
||||
border-bottom: solid #ffcc00 6px;
|
||||
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,32 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import type {Metadata} from 'next'
|
||||
import {headers} from 'next/headers'
|
||||
import {ReactNode} from 'react'
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
import './globals.css'
|
||||
import {Toaster} from '@/components/ui/toaster'
|
||||
import {routing} from '@/i18n/routing'
|
||||
import {APP_DESCRIPTION, APP_NAME, APP_SLOGAN} from '@/lib/constants'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
title: {
|
||||
template: `%s | ${APP_NAME}`,
|
||||
default: `${APP_NAME}. ${APP_SLOGAN}`
|
||||
},
|
||||
description: APP_DESCRIPTION
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children
|
||||
}: Readonly<{children: ReactNode}>) {
|
||||
const headersList = await headers()
|
||||
const locale = headersList.get('x-site-locale') ?? routing.defaultLocale
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<body className='min-h-screen antialiased'>
|
||||
{children}
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
101
app/page.tsx
101
app/page.tsx
@@ -1,101 +0,0 @@
|
||||
import Image from "next/image";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
||||
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
||||
<li className="mb-2">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
|
||||
app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li>Save and see your changes instantly.</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
auth.config.ts
Normal file
20
auth.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type {NextAuthConfig} from 'next-auth'
|
||||
|
||||
//import 'server-only'
|
||||
|
||||
export default {
|
||||
// pages: {
|
||||
// signIn: '/auth/login',
|
||||
// //signOut: '/auth/register',
|
||||
// newUser: '/auth/register',
|
||||
// error: '/auth/error',
|
||||
// verifyRequest: '/auth/verify-request'
|
||||
// },
|
||||
|
||||
callbacks: {
|
||||
authorized({auth}) {
|
||||
return !!auth?.user
|
||||
}
|
||||
},
|
||||
providers: []
|
||||
} satisfies NextAuthConfig
|
||||
101
auth.ts
Normal file
101
auth.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import {PrismaAdapter} from '@auth/prisma-adapter'
|
||||
import NextAuth from 'next-auth'
|
||||
import Credentials from 'next-auth/providers/credentials'
|
||||
import Github from 'next-auth/providers/github'
|
||||
import Google from 'next-auth/providers/google'
|
||||
|
||||
//import 'server-only'
|
||||
import authConfig from './auth.config'
|
||||
import {
|
||||
getUserAccountByUserId,
|
||||
exisingUser as getUserById
|
||||
} from '@/actions/auth/common'
|
||||
import {authorizeCallback} from '@/actions/auth/login'
|
||||
import {db} from '@/lib/db/prisma/client'
|
||||
|
||||
export const {
|
||||
auth,
|
||||
handlers: {GET, POST},
|
||||
signIn,
|
||||
signOut
|
||||
} = NextAuth({
|
||||
pages: {
|
||||
signIn: '/login',
|
||||
//signOut: '/auth/signout',
|
||||
//error: '/auth/error', // Error code passed in query string as ?error=
|
||||
//verifyRequest: '/auth/verify-request', // (used for check email message)
|
||||
newUser: '/register'
|
||||
},
|
||||
trustHost: true,
|
||||
logger:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? {
|
||||
// debug: console.log,
|
||||
error: console.error,
|
||||
warn: console.warn
|
||||
}
|
||||
: {error: console.error},
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
session: {strategy: 'jwt', maxAge: 48 * 60 * 60},
|
||||
...authConfig,
|
||||
adapter: PrismaAdapter(db),
|
||||
providers: [
|
||||
Google,
|
||||
Github,
|
||||
Credentials({
|
||||
name: 'credentials',
|
||||
credentials: {
|
||||
email: {label: 'Email', type: 'text'},
|
||||
password: {label: 'Пароль', type: 'password'}
|
||||
},
|
||||
async authorize(credentials): Promise<any | null> {
|
||||
return await authorizeCallback(credentials)
|
||||
}
|
||||
})
|
||||
],
|
||||
callbacks: {
|
||||
async signIn({user, account}) {
|
||||
if (account?.provider !== 'credentials') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!user?.id) return false
|
||||
|
||||
const exisingUser = await getUserById(user.id)
|
||||
|
||||
return !Boolean(exisingUser?.emailVerified)
|
||||
},
|
||||
async jwt({token}) {
|
||||
if (!token?.sub) return token
|
||||
|
||||
const exisingUser = await getUserAccountByUserId(token.sub)
|
||||
|
||||
if (!exisingUser) return token
|
||||
|
||||
token.isOauth = Boolean(exisingUser.isOauth)
|
||||
token.provider = exisingUser.provider
|
||||
token.role = exisingUser.role
|
||||
token.profileId = exisingUser.profileId
|
||||
token.name = exisingUser.name
|
||||
token.username = exisingUser.username
|
||||
token.email = exisingUser.email
|
||||
token.image = exisingUser.image
|
||||
|
||||
return token
|
||||
},
|
||||
async session({token, session}) {
|
||||
return {
|
||||
...session,
|
||||
user: {
|
||||
...session.user,
|
||||
id: token.sub,
|
||||
isOauth: token.isOauth,
|
||||
provider: token.provider,
|
||||
role: token.role,
|
||||
profileId: token.profileId,
|
||||
username: token.username
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
21
components.json
Normal file
21
components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
26
components/(protected)/admin/auth/permission.tsx
Normal file
26
components/(protected)/admin/auth/permission.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import {UserRole} from '@prisma/client'
|
||||
import {notFound} from 'next/navigation'
|
||||
|
||||
import {auth} from '@/auth'
|
||||
import LoginForm from '@/components/auth/forms/login-form'
|
||||
import {SessionUser} from '@/types/auth'
|
||||
|
||||
export default async function AdminPermission() {
|
||||
const session = await auth()
|
||||
const user: SessionUser = session?.user as unknown as SessionUser
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className='my-8'>
|
||||
<div className='container flex flex-col sm:flex-row'>
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
//if (![UserRole.CUSTOMER].includes(user.role as 'CUSTOMER')) {
|
||||
if (user.role !== UserRole.SUPERVISOR) {
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
171
components/(protected)/admin/category/create-form.tsx
Normal file
171
components/(protected)/admin/category/create-form.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
'use client'
|
||||
|
||||
//https://codesandbox.io/p/sandbox/react-hook-form-zod-with-array-of-objects-field-array-usefieldarray-8xh3ry?file=%2Fsrc%2FApp.tsx%3A11%2C53
|
||||
// https://stackoverflow.com/questions/78004655/how-to-dynamically-add-array-of-objects-to-react-hook-form
|
||||
import {zodResolver} from '@hookform/resolvers/zod'
|
||||
import {useState} from 'react'
|
||||
import {useFieldArray, useForm} from 'react-hook-form'
|
||||
import {z} from 'zod'
|
||||
|
||||
import {onCategoryCreateAction} from '@/actions/admin/category'
|
||||
import FormError from '@/components/auth/form-error'
|
||||
import {FormSuccess} from '@/components/auth/form-success'
|
||||
import {createCategoryFormSchema as validationSchema} from '@/lib/schemas/admin/category'
|
||||
import {dump} from '@/lib/utils'
|
||||
import {ResourceMessages} from '@/types'
|
||||
import {Button} from '@/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/ui/form'
|
||||
import {Input} from '@/ui/input'
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/ui/tabs'
|
||||
|
||||
export const CreateForm = () => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
const localesValues = {
|
||||
title: '',
|
||||
short_title: '',
|
||||
description: ''
|
||||
}
|
||||
|
||||
const form = useForm<z.infer<typeof validationSchema>>({
|
||||
resolver: zodResolver(validationSchema),
|
||||
mode: 'onBlur',
|
||||
defaultValues: {
|
||||
locales: [
|
||||
{lang: 'uk', ...localesValues},
|
||||
{lang: 'ru', ...localesValues}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const {fields, append} = useFieldArray({
|
||||
name: 'locales',
|
||||
control: form.control
|
||||
})
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof validationSchema>) => {
|
||||
setLoading(true)
|
||||
onCategoryCreateAction(data).then((res: ResourceMessages) => {
|
||||
if (res?.error) {
|
||||
setError(res?.error)
|
||||
setSuccess('')
|
||||
setLoading(false)
|
||||
} else {
|
||||
setSuccess(res?.success as string)
|
||||
setError('')
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form action='' className='my-8' onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className='mx-auto w-[400px]'>
|
||||
<Tabs defaultValue='uk'>
|
||||
<TabsList className='grid w-full grid-cols-2'>
|
||||
<TabsTrigger value='uk'>Українська</TabsTrigger>
|
||||
<TabsTrigger value='ru'>російська</TabsTrigger>
|
||||
</TabsList>
|
||||
{fields.map((_, index) => (
|
||||
<TabsContent
|
||||
value={form.getValues(`locales.${index}.lang`)}
|
||||
key={index}
|
||||
className='space-y-8'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={index}
|
||||
name={`locales.${index}.lang`}
|
||||
render={({field}) => (
|
||||
<FormItem className={'w-full'}>
|
||||
{/*<FormLabel>Мова</FormLabel>*/}
|
||||
<FormControl>
|
||||
<Input type='hidden' placeholder='' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={index + 1}
|
||||
name={`locales.${index}.title`}
|
||||
render={({field}) => (
|
||||
<FormItem className={'w-full'}>
|
||||
<FormLabel>Назва категорії</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
lang={form.getValues(`locales.${index}.lang`)}
|
||||
placeholder=''
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
{/*<FormDescription>
|
||||
Select a language between uk or ru
|
||||
</FormDescription>*/}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={index + 2}
|
||||
name={`locales.${index}.short_title`}
|
||||
render={({field}) => (
|
||||
<FormItem className={'w-full'}>
|
||||
<FormLabel>Скорочена назва категорії</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
lang={form.getValues(`locales.${index}.lang`)}
|
||||
placeholder=''
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={index + 3}
|
||||
name={`locales.${index}.description`}
|
||||
render={({field}) => (
|
||||
<FormItem className={'w-full'}>
|
||||
<FormLabel>Опис категорії</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
lang={form.getValues(`locales.${index}.lang`)}
|
||||
placeholder=''
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
<div className='my-8'>
|
||||
<FormError message={error} />
|
||||
<FormSuccess message={success} />
|
||||
<Button type='submit'>
|
||||
{loading ? 'Додаємо до бази...' : 'Створити'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
47
components/(protected)/admin/footer.tsx
Normal file
47
components/(protected)/admin/footer.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import {DropdownMenuTrigger} from '@radix-ui/react-dropdown-menu'
|
||||
import {ChevronUp, User2} from 'lucide-react'
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem
|
||||
} from '@/ui/dropdown-menu'
|
||||
import {
|
||||
SidebarFooter,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem
|
||||
} from '@/ui/sidebar'
|
||||
|
||||
export default function AdminSidebarFooter() {
|
||||
return (
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton>
|
||||
<User2 /> Username
|
||||
<ChevronUp className='ml-auto' />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side='top'
|
||||
className='w-[--radix-popper-anchor-width]'
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
<span>Account</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<span>Billing</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<span>Sign out</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
)
|
||||
}
|
||||
41
components/(protected)/admin/header.tsx
Normal file
41
components/(protected)/admin/header.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import {ChevronDown} from 'lucide-react'
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/ui/dropdown-menu'
|
||||
import {
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem
|
||||
} from '@/ui/sidebar'
|
||||
|
||||
export default function AdminSidebarHeader() {
|
||||
return (
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton>
|
||||
Select Workspace
|
||||
<ChevronDown className='ml-auto' />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className='w-[--radix-popper-anchor-width]'>
|
||||
<DropdownMenuItem>
|
||||
<span>Acme Inc</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<span>Acme Corp.</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
)
|
||||
}
|
||||
442
components/(protected)/admin/product/create-edit-form.tsx
Normal file
442
components/(protected)/admin/product/create-edit-form.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
'use client'
|
||||
|
||||
import {zodResolver} from '@hookform/resolvers/zod'
|
||||
import dynamic from 'next/dynamic'
|
||||
import React, {useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {useFieldArray, useForm} from 'react-hook-form'
|
||||
import {z} from 'zod'
|
||||
|
||||
import {onProductCreateAction} from '@/actions/admin/product'
|
||||
import {useToast} from '@/hooks/use-toast'
|
||||
import {i18nDefaultLocale, i18nLocales} from '@/i18n-config'
|
||||
import {BaseEditorConfig} from '@/lib/config/editor'
|
||||
import {createProductFormSchema} from '@/lib/schemas/admin/product'
|
||||
import {toEmptyParams} from '@/lib/utils'
|
||||
import {Button} from '@/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/ui/form'
|
||||
import {Input} from '@/ui/input'
|
||||
import {Switch} from '@/ui/switch'
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/ui/tabs'
|
||||
|
||||
const JoditEditor = dynamic(() => import('jodit-react'), {ssr: false})
|
||||
|
||||
let localesValues = {
|
||||
title: '',
|
||||
shortTitle: '',
|
||||
headingTitle: '',
|
||||
description: '',
|
||||
content: '',
|
||||
instruction: ''
|
||||
}
|
||||
|
||||
let metaValues = {
|
||||
title: '',
|
||||
description: '',
|
||||
keywords: '',
|
||||
author: ''
|
||||
}
|
||||
|
||||
export default function ProductCreateEditForm({data}: {data?: any}) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [description0, setDescription0] = useState(
|
||||
data?.locales[0].description || ''
|
||||
)
|
||||
const [description1, setDescription1] = useState(
|
||||
data?.locales[1].description || ''
|
||||
)
|
||||
const [content0, setContent0] = useState(data?.locales[0].content || '')
|
||||
const [content1, setContent1] = useState(data?.locales[1].content || '')
|
||||
const [instruction0, setInstruction0] = useState(
|
||||
data?.locales[0].instruction || ''
|
||||
)
|
||||
const [instruction1, setInstruction1] = useState(
|
||||
data?.locales[1].instruction || ''
|
||||
)
|
||||
const editor = useRef(null) //declared a null value
|
||||
const {toast} = useToast()
|
||||
|
||||
const config = useMemo(() => BaseEditorConfig, [])
|
||||
|
||||
const form = useForm<z.infer<typeof createProductFormSchema>>({
|
||||
resolver: zodResolver(createProductFormSchema),
|
||||
mode: 'onBlur',
|
||||
defaultValues: data
|
||||
? (data => {
|
||||
const {locales, meta} = data
|
||||
|
||||
return {
|
||||
published: data.toStore[0].published,
|
||||
price: data.toStore[0].price,
|
||||
pricePromotional: data.toStore[0].pricePromotional,
|
||||
image: data.image,
|
||||
locales: toEmptyParams(locales) as any,
|
||||
meta: meta
|
||||
? (toEmptyParams(meta) as any)
|
||||
: [{...metaValues}, {...metaValues}]
|
||||
}
|
||||
})(data)
|
||||
: {
|
||||
published: false,
|
||||
price: '0',
|
||||
pricePromotional: '0',
|
||||
image: '',
|
||||
locales: [
|
||||
{lang: 'uk', ...localesValues},
|
||||
{lang: 'ru', ...localesValues}
|
||||
],
|
||||
meta: [{...metaValues}, {...metaValues}]
|
||||
}
|
||||
})
|
||||
|
||||
const {register, setValue} = form
|
||||
|
||||
useEffect(() => {
|
||||
register('locales.0.description')
|
||||
register('locales.0.content')
|
||||
register('locales.0.instruction')
|
||||
register('locales.1.description')
|
||||
register('locales.1.content')
|
||||
register('locales.1.instruction')
|
||||
}, [register])
|
||||
|
||||
const {fields: localeFields} = useFieldArray({
|
||||
name: 'locales',
|
||||
control: form.control
|
||||
})
|
||||
|
||||
const {fields: metaFields} = useFieldArray({
|
||||
name: 'meta',
|
||||
control: form.control
|
||||
})
|
||||
|
||||
console.log(form.formState.errors)
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof createProductFormSchema>) => {
|
||||
setLoading(true)
|
||||
onProductCreateAction(values).then((res: any) => {
|
||||
if (res?.error) {
|
||||
setError(res?.error)
|
||||
setSuccess('')
|
||||
setLoading(false)
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: res?.error,
|
||||
description: res?.message
|
||||
})
|
||||
} else {
|
||||
setSuccess(res?.success as string)
|
||||
setError('')
|
||||
setLoading(false)
|
||||
toast({
|
||||
variant: 'success',
|
||||
title: res?.success,
|
||||
description: res?.message
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
action=''
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='bgs-grasy-50/50 mx-auto mb-8 min-w-[640px] max-w-[992px] flex-1 space-y-5 rounded-lg border p-4'
|
||||
>
|
||||
<div className='mx-auto my-4 w-full space-y-4'>
|
||||
<h1 className='mb-6 text-center text-2xl font-bold text-brand-violet'>
|
||||
ДОДАВАННЯ ТОВАРУ ДО БАЗИ
|
||||
</h1>
|
||||
<div className='my-4 space-y-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='published'
|
||||
render={({field}) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border bg-gray-50 p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>Опублікувати</FormLabel>
|
||||
<FormDescription>
|
||||
Відразу після збереження буде розміщено на сайті
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className='flex flex-row items-center justify-between gap-4 rounded-lg border bg-gray-50 p-4'>
|
||||
<div className='w-1/2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='price'
|
||||
render={({field}) => (
|
||||
<FormItem className={'w-full'}>
|
||||
<FormLabel>Ціна за одиницю товару</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='text' placeholder='' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='w-1/2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='pricePromotional'
|
||||
render={({field}) => (
|
||||
<FormItem className={'w-full'}>
|
||||
<FormLabel>Акційна Ціна</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='text' placeholder='' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset className='flex gap-4 rounded-lg border bg-gray-50 p-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='image'
|
||||
render={({field}) => (
|
||||
<FormItem className={'w-full'}>
|
||||
<FormLabel>Головне зображення</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='text' placeholder='' {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Вкажіть шліх до зображення відносно публічної папки
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
<Tabs
|
||||
defaultValue={i18nDefaultLocale}
|
||||
className='min-h-[560px] rounded-lg border p-4'
|
||||
>
|
||||
<TabsList className='grid w-full grid-cols-2'>
|
||||
{i18nLocales.map(locale => (
|
||||
<TabsTrigger key={locale.icon} value={locale.code}>
|
||||
{locale.nameUkr}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{localeFields.map((_, index) => (
|
||||
<TabsContent
|
||||
id={`form-tab-${form.getValues(`locales.${index}.lang`)}`}
|
||||
value={form.getValues(`locales.${index}.lang`)}
|
||||
key={index}
|
||||
className='space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={index}
|
||||
name={`locales.${index}.lang`}
|
||||
render={({field}) => (
|
||||
<FormItem className={'w-full'}>
|
||||
{/*<FormLabel>Мова</FormLabel>*/}
|
||||
<FormControl>
|
||||
<Input type='hidden' placeholder='' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<fieldset className='flex gap-4 rounded-lg border bg-gray-50 p-4'>
|
||||
<div className='w-1/2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={index + 1}
|
||||
name={`locales.${index}.title`}
|
||||
render={({field}) => (
|
||||
<FormItem className={'w-full'}>
|
||||
<FormLabel>Назва товару</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='w-1/2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={index + 2}
|
||||
name={`locales.${index}.shortTitle`}
|
||||
render={({field}) => (
|
||||
<FormItem className={'w-full'}>
|
||||
<FormLabel>Скорочена назва товару</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className='rounded-lg border bg-gray-50 p-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={index + 3}
|
||||
name={`locales.${index}.headingTitle`}
|
||||
render={({field}) => (
|
||||
<FormItem className={'w-full'}>
|
||||
<FormLabel>
|
||||
Назва товару у описі та коротка анотація
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<JoditEditor
|
||||
key={index + 4}
|
||||
ref={editor}
|
||||
config={config}
|
||||
value={index === 0 ? description0 : description1}
|
||||
className='mt-4 w-full'
|
||||
onBlur={value => {
|
||||
index === 0
|
||||
? setDescription0(value)
|
||||
: setDescription1(value)
|
||||
setValue(`locales.${index}.description`, value)
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset className='rounded-lg border bg-gray-50 p-4'>
|
||||
<FormLabel>Опис товару</FormLabel>
|
||||
<JoditEditor
|
||||
key={index + 5}
|
||||
ref={editor}
|
||||
config={config}
|
||||
value={index === 0 ? content0 : content1}
|
||||
className='mt-4 w-full'
|
||||
onBlur={value => {
|
||||
index === 0 ? setContent0(value) : setContent1(value)
|
||||
setValue(`locales.${index}.content`, value)
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset className='rounded-lg border bg-gray-50 p-4'>
|
||||
<FormLabel>Інструкція до товару</FormLabel>
|
||||
<JoditEditor
|
||||
key={index + 2125}
|
||||
ref={editor}
|
||||
config={config}
|
||||
value={index === 0 ? instruction0 : instruction1}
|
||||
className='mt-4 w-full'
|
||||
onBlur={value => {
|
||||
index === 0
|
||||
? setInstruction0(value)
|
||||
: setInstruction1(value)
|
||||
setValue(`locales.${index}.instruction`, value)
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
</TabsContent>
|
||||
))}
|
||||
{metaFields.map((_, index) => (
|
||||
<TabsContent
|
||||
id={`form-tab-${form.getValues(`locales.${index}.lang`)}`}
|
||||
value={form.getValues(`locales.${index}.lang`)}
|
||||
key={index}
|
||||
className='space-y-4'
|
||||
>
|
||||
<fieldset className='rounded-lg border bg-gray-50 p-4'>
|
||||
<legend className='rounded-lg border bg-gray-200 px-16 py-1 text-xl font-bold'>
|
||||
META ДАНІ
|
||||
</legend>
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={index + 'meta.title'}
|
||||
name={`meta.${index}.title`}
|
||||
render={({field}) => (
|
||||
<FormItem className={'w-full'}>
|
||||
<FormLabel>Назва</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='text' placeholder='' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={index + 'meta.description'}
|
||||
name={`meta.${index}.description`}
|
||||
render={({field}) => (
|
||||
<FormItem className={'w-full'}>
|
||||
<FormLabel>Опис</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='text' placeholder='' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={index + 'meta.keywords'}
|
||||
name={`meta.${index}.keywords`}
|
||||
render={({field}) => (
|
||||
<FormItem className={'w-full'}>
|
||||
<FormLabel>Ключові слова</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='text' placeholder='' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={index + 'meta.author'}
|
||||
name={`meta.${index}.author`}
|
||||
render={({field}) => (
|
||||
<FormItem className={'w-full'}>
|
||||
<FormLabel>Автор</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='text' placeholder='' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<Button type='submit' className='!mt-0 w-full'>
|
||||
Створити
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
169
components/(protected)/admin/product/create-form.tsx.back
Normal file
169
components/(protected)/admin/product/create-form.tsx.back
Normal file
@@ -0,0 +1,169 @@
|
||||
'use client'
|
||||
|
||||
import {zodResolver} from '@hookform/resolvers/zod'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import {useCallback, useState} from 'react'
|
||||
import Dropzone from 'react-dropzone'
|
||||
import {useFieldArray, useForm} from 'react-hook-form'
|
||||
import {z} from 'zod'
|
||||
|
||||
import {onProductCreateAction} from '@/actions/admin/product'
|
||||
import {createCategoryFormSchema} from '@/lib/schemas/admin/category'
|
||||
import {createProductFormSchema} from '@/lib/schemas/admin/product'
|
||||
import {cn, dump} from '@/lib/utils'
|
||||
import {ResourceMessages} from '@/types'
|
||||
import {Button} from '@/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/ui/form'
|
||||
import {Input} from '@/ui/input'
|
||||
|
||||
export default function CreateForm() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
const form = useForm<z.infer<typeof createProductFormSchema>>({
|
||||
resolver: zodResolver(createProductFormSchema),
|
||||
mode: 'onBlur',
|
||||
defaultValues: {
|
||||
files: []
|
||||
}
|
||||
})
|
||||
|
||||
const {fields, append} = useFieldArray({
|
||||
name: 'files',
|
||||
control: form.control
|
||||
})
|
||||
|
||||
console.log(form.formState.errors)
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof createProductFormSchema>) => {
|
||||
setLoading(true)
|
||||
onProductCreateAction(values).then((res: any) => {
|
||||
if (res?.error) {
|
||||
setError(res?.error)
|
||||
setSuccess('')
|
||||
setLoading(false)
|
||||
} else {
|
||||
setSuccess(res?.success as string)
|
||||
setError('')
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-screen items-center justify-center'>
|
||||
<Form {...form}>
|
||||
<form
|
||||
action=''
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='max-w-md flex-1 space-y-5'
|
||||
>
|
||||
<div className='products_name_price_desc relative'>
|
||||
{fields.map((_, index) => {
|
||||
return (
|
||||
<div key={index}>
|
||||
<div className='mb-2 mt-7 text-xl font-bold'>
|
||||
{/*{form.getValues(`files.${index}.file.name`)}*/}
|
||||
{dump(form.getValues(`files.${index}`))}
|
||||
</div>
|
||||
<div className='flex gap-x-3'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={index}
|
||||
name={`files.${index}.alt`}
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Файл Альт Ім'я</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage className='capitalize text-red-500' />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={index + 1}
|
||||
name={`files.${index}.title`}
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Назва файлу</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage className='capitalize text-red-500' />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className='products relative'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='files'
|
||||
render={() => (
|
||||
<Dropzone
|
||||
accept={{
|
||||
'image/*': ['.jpg', '.jpeg', '.png']
|
||||
}}
|
||||
onDropAccepted={acceptedFiles => {
|
||||
acceptedFiles.map(acceptedFile => {
|
||||
console.log('acceptedFile', acceptedFile)
|
||||
|
||||
return append({
|
||||
file: acceptedFile,
|
||||
alt: '',
|
||||
title: ''
|
||||
})
|
||||
})
|
||||
}}
|
||||
multiple={true}
|
||||
maxSize={5000000}
|
||||
>
|
||||
{({getRootProps, getInputProps}) => (
|
||||
<section>
|
||||
<div {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
<p>
|
||||
Перетягніть тут, киньте кілька файлів або натисніть,
|
||||
щоб вибрати файли
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</Dropzone>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button type='submit' className='!mt-0 w-full'>
|
||||
Створити
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
|
||||
// return (
|
||||
// <div className='flex flex-col bg-zinc-200 py-10'>
|
||||
// <h1 className='text-center text-3xl font-bold capitalize'>
|
||||
// Створити галерею
|
||||
// </h1>
|
||||
// <div className='mx-auto mb-10 mt-6 flex min-h-[320px] w-[80%] flex-wrap gap-1 rounded-md bg-white p-5 shadow-sm'></div>
|
||||
// <div className='flex justify-center'>
|
||||
// <Button>Завантажити зображення</Button>
|
||||
// </div>
|
||||
// </div>
|
||||
// )
|
||||
}
|
||||
106
components/(protected)/admin/sidebar.tsx
Normal file
106
components/(protected)/admin/sidebar.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
Home,
|
||||
Inbox,
|
||||
LayoutList,
|
||||
Plus,
|
||||
ScanBarcode,
|
||||
Search,
|
||||
Settings
|
||||
} from 'lucide-react'
|
||||
|
||||
import AdminSidebarFooter from '@/components/(protected)/admin/footer'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem
|
||||
} from '@/components/ui/sidebar'
|
||||
import {ADMIN_DASHBOARD_PATH} from '@/lib/config/routes'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger
|
||||
} from '@/ui/collapsible'
|
||||
|
||||
const items = [
|
||||
{
|
||||
title: 'Головна',
|
||||
url: `${ADMIN_DASHBOARD_PATH}`,
|
||||
icon: Home
|
||||
},
|
||||
{
|
||||
title: 'Категорії',
|
||||
url: `${ADMIN_DASHBOARD_PATH}/category`,
|
||||
icon: LayoutList
|
||||
},
|
||||
{
|
||||
title: 'Товари',
|
||||
url: `${ADMIN_DASHBOARD_PATH}/product`,
|
||||
icon: ScanBarcode
|
||||
}
|
||||
// {
|
||||
// title: 'Search',
|
||||
// url: '#',
|
||||
// icon: Search
|
||||
// },
|
||||
// {
|
||||
// title: 'Settings',
|
||||
// url: '#',
|
||||
// icon: Settings
|
||||
// }
|
||||
]
|
||||
|
||||
export function AdminSidebar() {
|
||||
return (
|
||||
<Sidebar collapsible='icon' variant='sidebar'>
|
||||
{/*<AdminSidebarHeader />*/}
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel asChild>Projects</SidebarGroupLabel>
|
||||
<SidebarGroupAction title='Add Project'>
|
||||
<Plus /> <span className='sr-only'>Add Project</span>
|
||||
</SidebarGroupAction>
|
||||
<SidebarGroupContent>SidebarGroupAction</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Application</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map(item => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<Collapsible defaultOpen className='group/collapsible'>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel asChild>
|
||||
<CollapsibleTrigger>
|
||||
Help
|
||||
<ChevronDown className='ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180' />
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>SidebarGroupContent</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</SidebarGroup>
|
||||
</Collapsible>
|
||||
</SidebarContent>
|
||||
<AdminSidebarFooter />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
18
components/auth/auth-header.tsx
Normal file
18
components/auth/auth-header.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import Logo from '@/components/shared/home/logo'
|
||||
|
||||
interface HeaderProps {
|
||||
label: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const AuthHeader = ({title, label}: HeaderProps) => {
|
||||
return (
|
||||
<div className='flex w-full flex-col items-center justify-center gap-y-4'>
|
||||
<Logo />
|
||||
<h1 className='text-3xl font-semibold'>{title}</h1>
|
||||
<p className='text-sm text-muted-foreground'>{label}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthHeader
|
||||
16
components/auth/back-button.tsx
Normal file
16
components/auth/back-button.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
import {Button} from '@/components/ui/button'
|
||||
|
||||
interface BackButtonProps {
|
||||
label: string
|
||||
href: string
|
||||
}
|
||||
|
||||
export const BackButton = ({label, href}: BackButtonProps) => {
|
||||
return (
|
||||
<Button variant='link' className='w-full font-normal' size='sm' asChild>
|
||||
<Link href={href}>{label}</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
37
components/auth/card-wrapper.tsx
Normal file
37
components/auth/card-wrapper.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import {ReactNode} from 'react'
|
||||
|
||||
import AuthHeader from './auth-header'
|
||||
import {BackButton} from './back-button'
|
||||
import {Card, CardContent, CardFooter, CardHeader} from '@/components/ui/card'
|
||||
|
||||
interface CardWrapperProps {
|
||||
children: ReactNode
|
||||
headerLabel: string
|
||||
backButtonLabel: string
|
||||
title: string
|
||||
showSocial?: boolean
|
||||
backButtonHref: string
|
||||
}
|
||||
|
||||
const CardWrapper = ({
|
||||
children,
|
||||
headerLabel,
|
||||
backButtonLabel,
|
||||
backButtonHref,
|
||||
title,
|
||||
showSocial
|
||||
}: CardWrapperProps) => {
|
||||
return (
|
||||
<Card className='m-auto shadow-md md:w-1/2 xl:w-1/4'>
|
||||
<CardHeader>
|
||||
<AuthHeader label={headerLabel} title={title} />
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
<CardFooter>
|
||||
<BackButton label={backButtonLabel} href={backButtonHref} />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default CardWrapper
|
||||
17
components/auth/form-error.tsx
Normal file
17
components/auth/form-error.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import {ShieldAlert} from 'lucide-react'
|
||||
|
||||
interface FormSuccessProps {
|
||||
message?: string
|
||||
}
|
||||
|
||||
export const FormError = ({message}: FormSuccessProps) => {
|
||||
if (!message) return null
|
||||
return (
|
||||
<div className='flex items-center space-x-4 rounded-lg bg-red-500/30 p-2 text-red-500'>
|
||||
<ShieldAlert size={16} />
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FormError
|
||||
15
components/auth/form-success.tsx
Normal file
15
components/auth/form-success.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import {CheckCheckIcon} from 'lucide-react'
|
||||
|
||||
interface FormSuccessProps {
|
||||
message?: string
|
||||
}
|
||||
|
||||
export const FormSuccess = ({message}: FormSuccessProps) => {
|
||||
if (!message) return null
|
||||
return (
|
||||
<div className='flex items-center space-x-4 rounded-lg bg-emerald-500/30 p-2 text-emerald-500'>
|
||||
<CheckCheckIcon className='h-4 w-4' />
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
104
components/auth/forms/login-form.tsx
Normal file
104
components/auth/forms/login-form.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
'use client'
|
||||
|
||||
import {zodResolver} from '@hookform/resolvers/zod'
|
||||
import {useState} from 'react'
|
||||
import {useForm} from 'react-hook-form'
|
||||
import {z} from 'zod'
|
||||
|
||||
import {login} from '@/actions/auth/login'
|
||||
import CardWrapper from '@/components/auth/card-wrapper'
|
||||
import {FormError} from '@/components/auth/form-error'
|
||||
import GoogleLogin from '@/components/auth/google-login'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form'
|
||||
import {LoginSchema} from '@/lib/schemas'
|
||||
import {Button} from '@/ui/button'
|
||||
import {Input} from '@/ui/input'
|
||||
|
||||
export default function LoginForm() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const form = useForm<z.infer<typeof LoginSchema>>({
|
||||
resolver: zodResolver(LoginSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: ''
|
||||
}
|
||||
})
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof LoginSchema>) => {
|
||||
setLoading(true)
|
||||
login(data).then(res => {
|
||||
if (res?.error) {
|
||||
setError(res?.error)
|
||||
setLoading(false)
|
||||
} else {
|
||||
setError('')
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<CardWrapper
|
||||
headerLabel='Create an account'
|
||||
title='Register'
|
||||
backButtonHref='/auth/login'
|
||||
backButtonLabel='Already have an account'
|
||||
showSocial
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='m-auto space-y-6'
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder='johndoe@email.com'
|
||||
type='email'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder='******' type='password' />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormError message={error} />
|
||||
<Button type='submit' className='w-full' disabled={loading}>
|
||||
{loading ? 'Loading...' : 'Login'}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<GoogleLogin />
|
||||
</CardWrapper>
|
||||
)
|
||||
}
|
||||
133
components/auth/forms/register-form.tsx
Normal file
133
components/auth/forms/register-form.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import {zodResolver} from '@hookform/resolvers/zod'
|
||||
import {useState} from 'react'
|
||||
import {useForm} from 'react-hook-form'
|
||||
import {z} from 'zod'
|
||||
|
||||
import {register} from '@/actions/auth/register'
|
||||
import CardWrapper from '@/components/auth/card-wrapper'
|
||||
import {FormError} from '@/components/auth/form-error'
|
||||
import {FormSuccess} from '@/components/auth/form-success'
|
||||
import GoogleLogin from '@/components/auth/google-login'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form'
|
||||
import {RegisterSchema} from '@/lib/schemas'
|
||||
import {Button} from '@/ui/button'
|
||||
import {Input} from '@/ui/input'
|
||||
|
||||
export default function RegisterForm() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
const form = useForm<z.infer<typeof RegisterSchema>>({
|
||||
resolver: zodResolver(RegisterSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
name: '',
|
||||
password: '',
|
||||
passwordConfirmation: ''
|
||||
}
|
||||
})
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof RegisterSchema>) => {
|
||||
setLoading(true)
|
||||
register(data).then(res => {
|
||||
if (res.error) {
|
||||
setError(res.error)
|
||||
setLoading(false)
|
||||
}
|
||||
if (res.success) {
|
||||
setError('')
|
||||
setSuccess(res.success)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<CardWrapper
|
||||
headerLabel='Create an account'
|
||||
title='Register'
|
||||
backButtonHref='/auth/login'
|
||||
backButtonLabel='Already have an account'
|
||||
showSocial
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
|
||||
<div className='space-y-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder='johndoe@email.com'
|
||||
type='email'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder='John Doe' />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder='******' type='password' />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='passwordConfirmation'
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder='******' type='password' />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormSuccess message={success} />
|
||||
<FormError message={error} />
|
||||
<Button type='submit' className='w-full' disabled={loading}>
|
||||
{loading ? 'Loading...' : 'Register'}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
{/*<GoogleLogin />*/}
|
||||
</CardWrapper>
|
||||
)
|
||||
}
|
||||
14
components/auth/forms/sign-out-button.tsx
Normal file
14
components/auth/forms/sign-out-button.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import {signOut} from '@/auth'
|
||||
|
||||
export function SignOutButton() {
|
||||
return (
|
||||
<form
|
||||
action={async () => {
|
||||
'use server'
|
||||
await signOut()
|
||||
}}
|
||||
>
|
||||
<button type='submit'>Sign Out</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
29
components/auth/google-login.tsx
Normal file
29
components/auth/google-login.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import {useActionState} from 'react'
|
||||
|
||||
import {googleAuthenticate} from '@/actions/auth/google-login'
|
||||
import GoogleIcon from '@/components/shared/icons/google'
|
||||
import {Button} from '@/ui/button'
|
||||
|
||||
const GoogleLogin = () => {
|
||||
const [errorMsgGoogle, dispatchGoogle] = useActionState(
|
||||
googleAuthenticate,
|
||||
undefined
|
||||
) //googleAuthenticate hook
|
||||
|
||||
return (
|
||||
<form className='mt-4 flex' action={dispatchGoogle}>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
className='flex w-full flex-row items-center gap-3'
|
||||
>
|
||||
<GoogleIcon />
|
||||
Google Sign In
|
||||
</Button>
|
||||
<p>{errorMsgGoogle}</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default GoogleLogin
|
||||
60
components/cabinet/index.tsx
Normal file
60
components/cabinet/index.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import {useTranslations} from 'next-intl'
|
||||
|
||||
import {SignOutButton} from '@/components/auth/forms/sign-out-button'
|
||||
import CabinetButton from '@/components/shared/header/cabinet-button'
|
||||
import {type SingedInSession} from '@/lib/permission'
|
||||
import {dump} from '@/lib/utils'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger
|
||||
} from '@/ui/collapsible'
|
||||
import {Separator} from '@/ui/separator'
|
||||
|
||||
export default function CabinetIndex({
|
||||
slug,
|
||||
session
|
||||
}: {
|
||||
slug: string[] | undefined
|
||||
session: SingedInSession | null
|
||||
}) {
|
||||
const t = useTranslations('cabinet')
|
||||
|
||||
return (
|
||||
<div className='my-8'>
|
||||
<div className='container flex flex-col sm:flex-row'>
|
||||
<section className='bw-layout-col-left bw-border-color border-r pt-3'>
|
||||
<div className='flex items-center justify-between pr-4'>
|
||||
<CabinetButton />
|
||||
<div>
|
||||
<p className='text-sm'>{session?.user?.name}</p>
|
||||
<p className='text-xs'>{session?.user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className='bw-separator-color my-4' />
|
||||
<SignOutButton />
|
||||
</section>
|
||||
<div className='bw-layout-col-right pt-3'>
|
||||
<section className='w-full'>
|
||||
<h1 className='text-3xl font-extrabold'>
|
||||
{t('personal-information.title')}
|
||||
</h1>
|
||||
<Separator className='my-4' />
|
||||
{/*<BasicEditor placeholder={'type something'} />*/}
|
||||
{/*<Separator className='my-4' />*/}
|
||||
{slug ? <code>{slug[0]}</code> : <pre>{dump(session)}</pre>}
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger>
|
||||
Can I use this in my project?
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
Yes. Free to use for personal and commercial projects. No
|
||||
attribution required.
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
components/shared/above/css/fig-one.module.css
Normal file
19
components/shared/above/css/fig-one.module.css
Normal file
@@ -0,0 +1,19 @@
|
||||
.cls-1 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #f05539;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #b9c2e2;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
fill: #a42f23;
|
||||
}
|
||||
|
||||
.cls-5 {
|
||||
fill: #ea5151;
|
||||
}
|
||||
115
components/shared/above/fig-one.tsx
Normal file
115
components/shared/above/fig-one.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import styles from './css/fig-one.module.css'
|
||||
|
||||
const ar = 72.79 / 38.95
|
||||
|
||||
export default function FigOne() {
|
||||
return (
|
||||
<div>
|
||||
<svg
|
||||
id='fig-1'
|
||||
data-name='fig 1'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 72.79 38.95'
|
||||
>
|
||||
<g id='Layer_1' data-name='Layer 1'>
|
||||
<g>
|
||||
<path d='M63.83,21.76c-.23,0-.47-.02-.71-.06-1.25-.2-2.3-.93-3.08-1.54-1.96-1.55-3.6-3.55-4.72-5.79-.06-.11-.01-.25,.1-.31,.11-.06,.25-.01,.31,.1,1.09,2.18,2.68,4.13,4.59,5.64,1.09,.86,1.97,1.31,2.87,1.45,1.07,.17,2.29-.12,3.46-.81,1.07-.63,1.98-1.52,2.87-2.37,.09-.09,.24-.09,.32,0,.09,.09,.09,.24,0,.32-.9,.87-1.84,1.77-2.95,2.43-.75,.44-1.83,.92-3.05,.92Z' />
|
||||
<g>
|
||||
<path
|
||||
className='cls-1'
|
||||
d='M71.22,16.24c-1.18-.57-2.6-.08-3.17,1.11-.57,1.18-.08,2.6,1.11,3.17,1.18,.57,2.6,.08,3.17-1.11,.57-1.18,.08-2.6-1.11-3.17Z'
|
||||
/>
|
||||
<path d='M70.18,20.99c-.38,0-.77-.08-1.13-.26-1.29-.63-1.84-2.19-1.21-3.48,.3-.63,.83-1.1,1.49-1.33,.66-.23,1.36-.19,1.99,.11,.63,.3,1.1,.83,1.33,1.49,.23,.66,.19,1.36-.11,1.99-.45,.93-1.38,1.47-2.35,1.47Zm0-4.75c-.24,0-.48,.04-.71,.12-.54,.19-.98,.58-1.23,1.09-.52,1.07-.07,2.35,1,2.87,1.07,.52,2.35,.07,2.87-1,.25-.52,.28-1.1,.09-1.64-.19-.54-.58-.98-1.09-1.23h0c-.3-.14-.61-.22-.93-.22Z' />
|
||||
</g>
|
||||
<path d='M6.35,23.78c-1.23,0-2.55-.78-3.91-2.33-.08-.09-.07-.24,.02-.32,.09-.08,.24-.07,.32,.02,1.55,1.76,2.99,2.45,4.27,2.07,1.97-.59,3.5-3.72,4.2-8.6,.02-.13,.13-.21,.26-.19,.12,.02,.21,.13,.19,.26-.73,5.06-2.38,8.33-4.53,8.97-.27,.08-.55,.12-.83,.12Z' />
|
||||
<g>
|
||||
<path
|
||||
className='cls-1'
|
||||
d='M3.64,19.16c-1.18-.57-2.6-.08-3.17,1.11-.57,1.18-.08,2.6,1.11,3.17,1.18,.57,2.6,.08,3.17-1.11,.57-1.18,.08-2.6-1.11-3.17Z'
|
||||
/>
|
||||
<path d='M2.6,23.91c-.39,0-.77-.09-1.13-.26-.63-.3-1.1-.83-1.33-1.49-.23-.66-.19-1.36,.11-1.99,.62-1.29,2.19-1.84,3.48-1.21h0c.63,.3,1.1,.83,1.33,1.49,.23,.66,.19,1.36-.11,1.99-.3,.63-.83,1.1-1.49,1.33-.28,.1-.57,.15-.86,.15Zm0-4.75c-.8,0-1.57,.45-1.94,1.21-.25,.52-.28,1.1-.09,1.64,.19,.54,.58,.98,1.09,1.23,.52,.25,1.1,.28,1.64,.09,.54-.19,.98-.58,1.23-1.09,.25-.52,.28-1.1,.09-1.64-.19-.54-.58-.98-1.09-1.23h0c-.3-.15-.62-.21-.93-.21Z' />
|
||||
</g>
|
||||
<g>
|
||||
<path d='M28.61,36.94s0,0,0,0c-.13,0-.23-.11-.22-.24,.14-4.32,.09-8.7-.16-13.01,0-.13,.09-.23,.21-.24,.12,0,.23,.09,.24,.21,.25,4.33,.31,8.72,.16,13.05,0,.12-.11,.22-.23,.22Z' />
|
||||
<path d='M28.61,36.94s0,0,0,0c-.13,0-.23-.11-.22-.24,.14-4.32,.09-8.7-.16-13.01,0-.13,.09-.23,.21-.24,.12,0,.23,.09,.24,.21,.25,4.33,.31,8.72,.16,13.05,0,.12-.11,.22-.23,.22Z' />
|
||||
<path d='M42.12,36.94c-.12,0-.22-.1-.23-.22-.14-4.33-.09-8.72,.16-13.05,0-.13,.12-.22,.24-.21,.13,0,.22,.12,.21,.24-.25,4.31-.31,8.69-.16,13.01,0,.13-.09,.23-.22,.24,0,0,0,0,0,0Z' />
|
||||
<g>
|
||||
<path
|
||||
className='cls-1'
|
||||
d='M26.88,35.49c-.94,.1-1.94,.22-2.59,1-.32,.39-.44,.93-.24,1.39,.31,.72,1.12,.82,1.83,.83,1.07,.02,2.07,.02,3.12-.21,.64-.14,1.02-.52,1.09-2,.02-.53-.39-.99-.93-1.01-.78-.03-1.83-.06-2.29,0Z'
|
||||
/>
|
||||
<path d='M26.6,38.95c-.24,0-.48,0-.72,0-.81-.02-1.68-.14-2.04-.97-.23-.52-.12-1.15,.27-1.63,.72-.87,1.84-.99,2.74-1.09h0c.47-.05,1.52-.02,2.32,0,.32,.01,.62,.15,.83,.39,.22,.24,.33,.54,.31,.86-.06,1.43-.41,2.03-1.27,2.22-.84,.18-1.64,.22-2.45,.22Zm.3-3.24c-.86,.09-1.83,.19-2.43,.92-.29,.35-.36,.79-.21,1.16,.25,.59,.93,.68,1.63,.69,1.02,.02,2.03,.02,3.07-.21,.46-.1,.84-.3,.91-1.79,0-.2-.06-.38-.19-.53-.13-.15-.32-.23-.51-.24-1.11-.04-1.91-.05-2.25,0h0Zm-.02-.23h0Z' />
|
||||
</g>
|
||||
<g>
|
||||
<path
|
||||
className='cls-1'
|
||||
d='M43.85,35.49c.94,.1,1.94,.22,2.59,1,.32,.39,.44,.93,.24,1.39-.31,.72-1.12,.82-1.83,.83-1.07,.02-2.07,.02-3.12-.21-.64-.14-1.02-.52-1.09-2-.02-.53,.39-.99,.93-1.01,.78-.03,1.83-.06,2.29,0Z'
|
||||
/>
|
||||
<path d='M44.12,38.95c-.81,0-1.61-.04-2.45-.22-.86-.19-1.2-.79-1.27-2.22-.01-.32,.1-.62,.31-.86,.22-.24,.51-.37,.83-.39,.8-.03,1.85-.06,2.32,0h0c.9,.09,2.02,.21,2.74,1.09,.39,.48,.5,1.1,.27,1.63-.36,.83-1.23,.95-2.04,.97-.24,0-.48,0-.72,0Zm-1.05-3.26c-.4,0-.91,.01-1.5,.03-.2,0-.38,.09-.51,.24-.13,.15-.2,.33-.19,.53,.07,1.49,.45,1.69,.91,1.79,1.04,.23,2.04,.23,3.07,.21,.7-.01,1.37-.11,1.63-.69,.16-.37,.08-.81-.21-1.16-.6-.73-1.57-.83-2.43-.92h0c-.16-.02-.41-.03-.75-.03Z' />
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path d='M58.22,11.86c-.12-1.22-.31-2.86-.87-3.96-.88-1.74-2.08-2.55-3.92-3.21-1.84-.67-4.34-.86-6.28-1.05-2.7-.26-9.2-.48-12.34-.48s-9.64,.22-12.34,.48c-1.95,.19-4.44,.39-6.28,1.05-1.84,.67-3.03,1.47-3.92,3.21-.56,1.1-.75,2.74-.87,3.96-.12,1.22-.16,2.45-.13,3.67-.03,1.22,.01,2.45,.13,3.67,.12,1.22,.31,2.86,.87,3.96,.88,1.74,2.08,2.55,3.92,3.21,1.84,.67,4.34,.86,6.28,1.05,2.7,.26,8.18,.48,12.34,.48s9.64-.22,12.34-.48c1.95-.19,4.44-.39,6.28-1.05,1.84-.67,3.03-1.47,3.92-3.21,.56-1.1,.75-2.73,.87-3.96,.12-1.22,.16-2.45,.13-3.67,.03-1.22-.01-2.45-.13-3.67Z' />
|
||||
<path d='M34.81,28.15c-4.1,0-9.65-.22-12.36-.48l-.49-.05c-1.84-.18-4.14-.4-5.85-1.02-2.4-.87-3.35-1.96-4.05-3.33-.55-1.09-.76-2.66-.9-4.05-.12-1.21-.16-2.45-.13-3.7-.03-1.24,.02-2.48,.13-3.69,.13-1.39,.34-2.96,.9-4.05,.96-1.9,2.32-2.7,4.05-3.33,1.72-.62,4.01-.84,5.85-1.02l.49-.05c2.6-.25,9.08-.48,12.36-.48s9.76,.23,12.36,.48l.49,.05c1.84,.18,4.14,.4,5.85,1.02,2.4,.87,3.35,1.96,4.05,3.33,.55,1.09,.76,2.66,.9,4.05,.12,1.21,.16,2.45,.13,3.7,.03,1.24-.02,2.48-.13,3.69-.13,1.39-.34,2.96-.9,4.05-.96,1.9-2.32,2.7-4.05,3.33-1.72,.62-4.01,.84-5.85,1.02l-.49,.05c-2.71,.27-8.26,.48-12.36,.48Zm0-24.75c-3.27,0-9.72,.23-12.32,.48l-.49,.05c-1.82,.17-4.08,.39-5.73,.99-1.67,.61-2.89,1.34-3.78,3.1-.52,1.02-.72,2.53-.84,3.87-.11,1.19-.16,2.41-.13,3.64-.03,1.24,.02,2.46,.13,3.65,.13,1.34,.33,2.86,.84,3.87,.89,1.76,2.11,2.49,3.78,3.1,1.66,.6,3.92,.82,5.73,.99l.49,.05c2.7,.26,8.23,.48,12.32,.48s9.61-.21,12.32-.48l.49-.05c1.82-.17,4.08-.39,5.73-.99,1.67-.61,2.89-1.34,3.78-3.1,.52-1.02,.72-2.53,.84-3.87,.11-1.19,.16-2.41,.13-3.64,.03-1.24-.02-2.46-.13-3.65h0c-.13-1.34-.33-2.86-.84-3.87-.89-1.76-2.11-2.49-3.78-3.1-1.66-.6-3.92-.82-5.73-.99l-.49-.05c-2.59-.25-9.05-.48-12.32-.48Z' />
|
||||
</g>
|
||||
<g>
|
||||
<path
|
||||
className='cls-3'
|
||||
d='M58.22,8.95c-.12-1.22-.31-2.86-.87-3.96-.88-1.74-2.08-2.55-3.92-3.21-1.84-.67-4.34-.86-6.28-1.05-2.7-.26-9.2-.48-12.34-.48s-9.64,.22-12.34,.48c-1.95,.19-4.44,.39-6.28,1.05-1.84,.67-3.03,1.47-3.92,3.21-.56,1.1-.75,2.74-.87,3.96-.12,1.22-.16,2.45-.13,3.67-.03,1.22,.01,2.45,.13,3.67,.12,1.22,.31,2.86,.87,3.96,.88,1.74,2.08,2.55,3.92,3.21,1.84,.67,4.34,.86,6.28,1.05,2.7,.26,8.18,.48,12.34,.48s9.64-.22,12.34-.48c1.95-.19,4.44-.39,6.28-1.05,1.84-.67,3.03-1.47,3.92-3.21,.56-1.1,.75-2.74,.87-3.96,.12-1.22,.16-2.45,.13-3.67,.03-1.22-.01-2.45-.13-3.67Z'
|
||||
/>
|
||||
<path d='M34.81,25.25c-4.1,0-9.65-.22-12.36-.48l-.49-.05c-1.84-.18-4.14-.4-5.85-1.02-2.4-.87-3.35-1.96-4.05-3.33-.55-1.09-.76-2.66-.9-4.05-.12-1.21-.16-2.45-.13-3.7-.03-1.24,.02-2.48,.13-3.69,.13-1.39,.34-2.96,.9-4.05,.96-1.9,2.32-2.7,4.05-3.33,1.72-.62,4.01-.84,5.85-1.02l.49-.05c2.6-.25,9.08-.48,12.36-.48s9.76,.23,12.36,.48l.49,.05c1.84,.18,4.14,.4,5.85,1.02,2.4,.87,3.35,1.96,4.05,3.33,.55,1.09,.76,2.66,.9,4.05,.12,1.21,.16,2.45,.13,3.7,.03,1.24-.02,2.48-.13,3.69-.13,1.39-.34,2.96-.9,4.05-.96,1.9-2.32,2.7-4.05,3.33-1.72,.62-4.01,.84-5.86,1.02l-.49,.05c-2.71,.27-8.26,.48-12.36,.48ZM34.81,.49c-3.27,0-9.72,.23-12.32,.48l-.49,.05c-1.82,.17-4.07,.39-5.73,.99-1.67,.61-2.89,1.34-3.78,3.1-.52,1.02-.72,2.53-.84,3.87-.11,1.19-.16,2.41-.13,3.64-.03,1.24,.02,2.46,.13,3.65,.13,1.34,.33,2.86,.84,3.87,.89,1.76,2.11,2.49,3.78,3.1,1.66,.6,3.92,.82,5.73,.99l.49,.05c2.7,.26,8.23,.48,12.32,.48s9.61-.21,12.32-.48l.49-.05c1.82-.17,4.08-.39,5.73-.99,1.67-.61,2.89-1.34,3.78-3.1,.52-1.02,.72-2.53,.84-3.87,.11-1.19,.16-2.41,.13-3.64,.03-1.24-.02-2.46-.13-3.65h0c-.13-1.34-.33-2.86-.84-3.87-.89-1.76-2.11-2.49-3.78-3.1-1.66-.6-3.92-.82-5.73-.99l-.49-.05c-2.59-.25-9.05-.48-12.32-.48Z' />
|
||||
</g>
|
||||
<path d='M34.81,25.25c-.14,0-.25-.11-.25-.25V.25c0-.14,.11-.25,.25-.25s.25,.11,.25,.25V25c0,.14-.11,.25-.25,.25Z' />
|
||||
<g>
|
||||
<path
|
||||
className='cls-4'
|
||||
d='M30.17,12.96c-1.08,0-1.65,1.28-.91,2.07,.5,.54,1.05,1.04,1.66,1.49,1.34,.99,3.05,1.59,4.7,1.3,1.11-.2,2.12-.8,3.02-1.47,.51-.38,.99-.79,1.45-1.23,.81-.77,.26-2.13-.85-2.13-2.69,.01-6.52-.01-9.07-.03Z'
|
||||
/>
|
||||
<path d='M34.76,18.12c-1.32,0-2.73-.49-3.98-1.42-.6-.45-1.16-.94-1.7-1.52-.4-.43-.51-1.04-.27-1.58,.24-.55,.75-.88,1.35-.88h0c3.24,.02,6.59,.04,9.07,.03h0c.61,0,1.13,.35,1.35,.92,.23,.57,.1,1.18-.35,1.61-.47,.44-.96,.86-1.47,1.25-1.14,.85-2.13,1.34-3.12,1.52-.29,.05-.59,.08-.9,.08Zm-4.6-4.93c-.48,0-.79,.31-.92,.6-.13,.29-.15,.73,.18,1.08,.52,.55,1.05,1.03,1.63,1.46,1.43,1.06,3.08,1.52,4.52,1.25,.92-.17,1.85-.62,2.93-1.43,.5-.37,.98-.78,1.43-1.21,.36-.35,.36-.8,.24-1.1-.12-.3-.43-.63-.93-.63h0c-2.49,.01-5.83,0-9.07-.03h0Z' />
|
||||
</g>
|
||||
<g>
|
||||
<path
|
||||
className='cls-5'
|
||||
d='M36.02,16.31c-.64-.17-1.31-.24-1.97-.2-.66,.04-1.32,.2-1.92,.47-.21,.1-.42,.21-.61,.33,1.24,.75,2.7,1.16,4.11,.91,.71-.13,1.38-.42,2.01-.79-.5-.33-1.04-.57-1.61-.72Z'
|
||||
/>
|
||||
<path d='M34.76,18.12c-1.11,0-2.28-.35-3.37-1.01-.07-.04-.11-.11-.11-.19,0-.08,.04-.15,.11-.2,.21-.13,.42-.25,.64-.35,.62-.28,1.29-.45,2-.49,.69-.05,1.38,.02,2.05,.21h0c.6,.16,1.17,.42,1.68,.75,.07,.04,.11,.12,.1,.2,0,.08-.04,.15-.11,.19-.72,.43-1.41,.69-2.08,.82-.29,.05-.59,.08-.9,.08Zm-2.8-1.21c1.2,.65,2.47,.89,3.62,.68,.52-.09,1.04-.28,1.6-.57-.38-.21-.79-.38-1.22-.49h0c-.62-.17-1.26-.23-1.9-.19-.66,.04-1.28,.2-1.84,.45-.08,.04-.17,.08-.25,.12Z' />
|
||||
</g>
|
||||
<g>
|
||||
<path
|
||||
className='cls-1'
|
||||
d='M30.76,14.09c.33,.14,2.82,.36,3.93,.36,.7,0,3.37-.09,3.97-.25,.32-.08,1.11-.83,1.2-1.04-.18-.1-.38-.16-.61-.16-2.69,.01-6.52-.01-9.07-.03-.12,0-.24,.02-.35,.05,.05,.16,.64,.95,.94,1.08Z'
|
||||
/>
|
||||
<path d='M34.66,14.67c-1.06,0-3.6-.2-3.99-.37h0c-.38-.17-1-1.01-1.07-1.22-.02-.06-.01-.12,.02-.18s.08-.09,.14-.11c.13-.04,.28-.05,.41-.06,3.24,.02,6.59,.04,9.07,.03,.26,0,.51,.06,.73,.19,.1,.06,.14,.18,.1,.28-.11,.27-.96,1.08-1.35,1.18-.64,.17-3.39,.26-4.03,.26-.01,0-.02,0-.03,0Zm-4.46-1.48c.19,.26,.5,.62,.65,.68h0c.26,.11,2.67,.34,3.84,.34,.73,0,3.36-.1,3.91-.24,.18-.05,.65-.46,.9-.72-.08-.02-.17-.03-.25-.03-2.48,.01-5.81,0-9.05-.03Z' />
|
||||
</g>
|
||||
<path
|
||||
className='cls-1'
|
||||
d='M55.06,10.09c-.39,0-.75-.25-.87-.64-.6-1.93-2.15-3.59-4.03-4.33-.47-.18-.7-.72-.52-1.19,.18-.47,.71-.7,1.19-.52,2.43,.95,4.34,3,5.11,5.49,.15,.48-.12,1-.6,1.15-.09,.03-.18,.04-.27,.04Z'
|
||||
/>
|
||||
<g>
|
||||
<path d='M40.98,10.04c0-1.04,.53-1.56,1.18-1.56s1.18,.52,1.18,1.56-.53,1.56-1.18,1.56-1.18-.52-1.18-1.56' />
|
||||
<path d='M26.28,10.04c0-1.04,.53-1.56,1.18-1.56s1.18,.52,1.18,1.56-.53,1.56-1.18,1.56-1.18-.52-1.18-1.56' />
|
||||
</g>
|
||||
<g>
|
||||
<path
|
||||
className='cls-2'
|
||||
d='M24.91,13.48c0,1.16-.94,2.11-2.11,2.11s-2.11-.94-2.11-2.11,.94-2.11,2.11-2.11,2.11,.94,2.11,2.11Z'
|
||||
/>
|
||||
<path
|
||||
className='cls-2'
|
||||
d='M48.92,13.48c0,1.16-.94,2.11-2.11,2.11s-2.11-.94-2.11-2.11,.94-2.11,2.11-2.11,2.11,.94,2.11,2.11Z'
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
className='cls-1'
|
||||
d='M15.38,20.59c-.33,0-.65-.18-.81-.49-.48-.92-.78-1.96-.85-2.99-.04-.5,.34-.94,.85-.98,.5-.04,.94,.34,.98,.85,.06,.79,.28,1.58,.65,2.28,.23,.45,.06,1-.39,1.23-.14,.07-.28,.1-.42,.1Z'
|
||||
/>
|
||||
<path
|
||||
className='cls-1'
|
||||
d='M18.01,22.51c-.14,0-.28-.03-.41-.1l-.48-.24c-.45-.23-.64-.78-.41-1.23,.23-.45,.78-.64,1.23-.41l.48,.24c.45,.23,.64,.78,.41,1.23-.16,.32-.48,.51-.82,.51Z'
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3
components/shared/above/fig-three.tsx
Normal file
3
components/shared/above/fig-three.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function FigThree() {
|
||||
return <></>
|
||||
}
|
||||
3
components/shared/above/fig-two.tsx
Normal file
3
components/shared/above/fig-two.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function FigTwo() {
|
||||
return <div>Fig1</div>
|
||||
}
|
||||
47
components/shared/above/index.tsx
Normal file
47
components/shared/above/index.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import {useTranslations} from 'next-intl'
|
||||
import Image from 'next/image'
|
||||
|
||||
import Fig1 from '@/public/images/above/fig-1.svg'
|
||||
import Fig2 from '@/public/images/above/fig-2.svg'
|
||||
import Fig3 from '@/public/images/above/fig-3.svg'
|
||||
|
||||
export default function Above() {
|
||||
const t = useTranslations('Banner.Above')
|
||||
|
||||
return (
|
||||
<div className='flex w-full items-end justify-center bg-brand-violet md:min-h-[43px] xl:min-h-[51px]'>
|
||||
<div className='mx-0 mb-0.5'>
|
||||
<Image
|
||||
width={72.79}
|
||||
height={38.95}
|
||||
src={Fig1}
|
||||
alt=''
|
||||
className='max-h-[35px]'
|
||||
/>
|
||||
</div>
|
||||
<div className='mx-0'>
|
||||
<Image
|
||||
width={80.21}
|
||||
height={43.78}
|
||||
src={Fig2}
|
||||
alt=''
|
||||
className='max-h-[41px]'
|
||||
/>
|
||||
</div>
|
||||
<div className='self-center font-medium text-brand-yellow'>
|
||||
{t('title')}
|
||||
</div>
|
||||
<div className='mx-1'>
|
||||
<Image
|
||||
width={98.89}
|
||||
height={47.27}
|
||||
src={Fig3}
|
||||
alt=''
|
||||
className='max-h-[37px]'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
175
components/shared/app-sidebar.tsx~
Normal file
175
components/shared/app-sidebar.tsx~
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
AudioWaveform,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Command,
|
||||
Frame,
|
||||
GalleryVerticalEnd,
|
||||
Map,
|
||||
PieChart,
|
||||
Settings2,
|
||||
SquareTerminal
|
||||
} from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
|
||||
import {NavMain} from '@/components/shared/nav-main'
|
||||
import {NavProjects} from '@/components/shared/nav-projects'
|
||||
import {NavUser} from '@/components/shared/nav-user'
|
||||
import {TeamSwitcher} from '@/components/shared/team-switcher'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarRail
|
||||
} from '@/components/ui/sidebar'
|
||||
|
||||
// This is sample data.
|
||||
const data = {
|
||||
user: {
|
||||
name: 'shadcn',
|
||||
email: 'm@example.com',
|
||||
avatar: '/avatars/shadcn.jpg'
|
||||
},
|
||||
teams: [
|
||||
{
|
||||
name: 'Acme Inc',
|
||||
logo: GalleryVerticalEnd,
|
||||
plan: 'Enterprise'
|
||||
},
|
||||
{
|
||||
name: 'Acme Corp.',
|
||||
logo: AudioWaveform,
|
||||
plan: 'Startup'
|
||||
},
|
||||
{
|
||||
name: 'Evil Corp.',
|
||||
logo: Command,
|
||||
plan: 'Free'
|
||||
}
|
||||
],
|
||||
navMain: [
|
||||
{
|
||||
title: 'Playground',
|
||||
url: '#',
|
||||
icon: SquareTerminal,
|
||||
isActive: true,
|
||||
items: [
|
||||
{
|
||||
title: 'History',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Starred',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
url: '#'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Models',
|
||||
url: '#',
|
||||
icon: Bot,
|
||||
items: [
|
||||
{
|
||||
title: 'Genesis',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Explorer',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Quantum',
|
||||
url: '#'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Documentation',
|
||||
url: '#',
|
||||
icon: BookOpen,
|
||||
items: [
|
||||
{
|
||||
title: 'Introduction',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Get Started',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Tutorials',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Changelog',
|
||||
url: '#'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
url: '#',
|
||||
icon: Settings2,
|
||||
items: [
|
||||
{
|
||||
title: 'General',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Team',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Billing',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Limits',
|
||||
url: '#'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
name: 'Design Engineering',
|
||||
url: '#',
|
||||
icon: Frame
|
||||
},
|
||||
{
|
||||
name: 'Sales & Marketing',
|
||||
url: '#',
|
||||
icon: PieChart
|
||||
},
|
||||
{
|
||||
name: 'Travel',
|
||||
url: '#',
|
||||
icon: Map
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function AppSidebar({...props}: React.ComponentProps<typeof Sidebar>) {
|
||||
return (
|
||||
<Sidebar {...props}>
|
||||
<SidebarHeader>
|
||||
<TeamSwitcher teams={data.teams} />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain items={data.navMain} />
|
||||
<NavProjects projects={data.projects} />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser user={data.user} />
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
56
components/shared/editor/jodit.tsx
Normal file
56
components/shared/editor/jodit.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
// https://xdsoft.net/jodit/play.html?spellcheck=true&language=ua&direction=ltr&saveHeightInStorage=true&saveModeInStorage=true&defaultActionOnPaste=insert_as_text&disablePlugins=ai-assistant%2Cmobile%2Cprint%2Cspeech-recognize%2Ctable%2Ctable-keyboard-navigation%2Cpowered-by-jodit%2Ciframe&minHeight=360&maxHeight=NaN&maxWidth=NaN&buttons=bold%2Citalic%2Cunderline%2Cstrikethrough%2Ceraser%2Cul%2Col%2Cfont%2Cfontsize%2Cparagraph%2ClineHeight%2Csuperscript%2Csubscript%2CclassSpan%2Cfile%2Cimage%2Cvideo%2Cspellcheck%2Ccut
|
||||
//import JoditEditor from 'jodit-react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import React, {useMemo, useRef, useState} from 'react'
|
||||
|
||||
const JoditEditor = dynamic(() => import('jodit-react'), {ssr: false})
|
||||
|
||||
export default function BasicEditor({
|
||||
placeholder,
|
||||
maxHeight,
|
||||
handleChange
|
||||
}: {
|
||||
placeholder: string
|
||||
maxHeight: number
|
||||
handleChange: any
|
||||
}) {
|
||||
const editor = useRef(null)
|
||||
const [content, setContent] = useState('')
|
||||
|
||||
const config = useMemo(
|
||||
() => ({
|
||||
readonly: false, // all options from https://xdsoft.net/jodit/docs/,
|
||||
placeholder: placeholder || 'Start typings...',
|
||||
spellcheck: true,
|
||||
language: 'ua',
|
||||
saveHeightInStorage: true,
|
||||
saveModeInStorage: true,
|
||||
//defaultActionOnPaste: 'insert_as_text',
|
||||
disablePlugins:
|
||||
'ai-assistant,mobile,print,speech-recognize,table,table-keyboard-navigation,powered-by-jodit,iframe',
|
||||
minHeight: maxHeight || 320,
|
||||
maxHeight: 1100,
|
||||
// maxWidth: 992,
|
||||
uploader: {
|
||||
insertImageAsBase64URI: true,
|
||||
imagesExtensions: ['jpg', 'png', 'jpeg', 'gif', 'svg', 'webp']
|
||||
},
|
||||
buttons:
|
||||
'bold,italic,underline,strikethrough,eraser,ul,ol,font,fontsize,paragraph,lineHeight,superscript,subscript,classSpan,file,image,video,spellcheck,cut'
|
||||
}),
|
||||
[placeholder, maxHeight]
|
||||
)
|
||||
|
||||
return (
|
||||
<JoditEditor
|
||||
ref={editor}
|
||||
value={content}
|
||||
config={config}
|
||||
tabIndex={1} // tabIndex of textarea
|
||||
onBlur={newContent => setContent(newContent)} // preferred to use only this option to update the content for performance reasons
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
28
components/shared/footer.tsx
Normal file
28
components/shared/footer.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
// 31:47
|
||||
import {ChevronUp} from 'lucide-react'
|
||||
|
||||
import SocialMediaPanel from '@/components/shared/social-media-panel'
|
||||
import {Button} from '@/ui/button'
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className='bg-brand-violet w-full py-3 text-white'>
|
||||
<div className='container flex items-center justify-between'>
|
||||
<div>Політика конфіденційності</div>
|
||||
<div>Договір оферти</div>
|
||||
<div>Доставка і повернення</div>
|
||||
<div>Контакти</div>
|
||||
<SocialMediaPanel color='#fff' />
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='bg-brand-violet rounded-none'
|
||||
onClick={() => window.scrollTo({top: 0, behavior: 'smooth'})}
|
||||
>
|
||||
<ChevronUp className='mr-2 h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
33
components/shared/header/cabinet-button.tsx
Normal file
33
components/shared/header/cabinet-button.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import {CircleUserRound} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import {auth} from '@/auth'
|
||||
import {avatarFallback} from '@/lib/utils'
|
||||
import {Avatar, AvatarFallback, AvatarImage} from '@/ui/avatar'
|
||||
|
||||
export default async function CabinetButton() {
|
||||
const session = await auth()
|
||||
|
||||
return (
|
||||
<Link href='/cabinet' className='header-button' aria-label='Кабінет'>
|
||||
<button className='flex flex-col items-center' role='button'>
|
||||
{session ? (
|
||||
<>
|
||||
<Avatar className='hs1-[21px] w1-[21px] border2-2 border2-brand-violet'>
|
||||
<AvatarImage src={session.user?.image as string} alt='avatar' />
|
||||
<AvatarFallback className='text-xs'>
|
||||
{avatarFallback(session.user?.name as string)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CircleUserRound className='h-[21px] w-[21px]' />
|
||||
GA4_Ecommerce_View_Item_List_Trigger
|
||||
</>
|
||||
)}
|
||||
{/*<span className='text-sm'>Кабінет</span>*/}
|
||||
</button>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
34
components/shared/header/controls.tsx
Normal file
34
components/shared/header/controls.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import {Heart, ShoppingCartIcon, UserIcon} from 'lucide-react'
|
||||
import {useTranslations} from 'next-intl'
|
||||
|
||||
import CabinetButton from '@/components/shared/header/cabinet-button'
|
||||
import {Link} from '@/i18n/routing'
|
||||
|
||||
export default function HeaderControls() {
|
||||
const t = useTranslations('cart')
|
||||
|
||||
return (
|
||||
<div className='flex w-full justify-end gap-x-9 text-brand-violet'>
|
||||
<CabinetButton />
|
||||
|
||||
<Link href={'#' as never} className='header-button' aria-label='Вибране'>
|
||||
<button className='flex flex-col items-center' role='button'>
|
||||
<Heart className='h-[21px] w-[21px]' />
|
||||
<span className='font1-bold text-sm'>{t('favorites')}</span>
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={'/checkout' as never}
|
||||
className='header-button'
|
||||
aria-label='Кошик'
|
||||
>
|
||||
<button className='flex flex-col items-center' role='button'>
|
||||
<ShoppingCartIcon className='h-[21px] w-[21px]' />
|
||||
|
||||
<span className='font1-bold text-sm'>{t('basket')}</span>
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
54
components/shared/header/index.tsx
Normal file
54
components/shared/header/index.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import {Menu} from '@radix-ui/react-menu'
|
||||
|
||||
import HeaderControls from './controls'
|
||||
import Logo from '@/components/shared/home/logo'
|
||||
import LocaleSwitcher from '@/components/shared/locale-switcher'
|
||||
import Navbar from '@/components/shared/navbar'
|
||||
import SearchForm from '@/components/shared/search/form'
|
||||
import SocialMediaPanel from '@/components/shared/social-media-panel'
|
||||
import {Switch} from '@/ui/switch'
|
||||
|
||||
type MenuProps = {
|
||||
name: string
|
||||
slug: string
|
||||
href: string
|
||||
}
|
||||
|
||||
export default async function Header() {
|
||||
/*{
|
||||
searchParams
|
||||
}: {
|
||||
searchParams: Promise<{query?: string}>
|
||||
}*/
|
||||
//const query = (await searchParams).query
|
||||
|
||||
return (
|
||||
<header className='w-full border-none bg-background text-white'>
|
||||
<div className='container flex'>
|
||||
<div className='bw-layout-col-left'>
|
||||
<Logo />
|
||||
</div>
|
||||
<div className='bw-layout-col-right flex-col'>
|
||||
<div className='mt-1.5 flex h-10 items-center'>
|
||||
<div className='bw-header-col-left flex justify-between gap-x-10'>
|
||||
<Navbar />
|
||||
<SocialMediaPanel size={16} className='gap-x-3 pr-1' />
|
||||
</div>
|
||||
<div className='bw-header-col-right flex justify-end text-stone'>
|
||||
<LocaleSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center'>
|
||||
<div className='bw-header-col-left'>
|
||||
<SearchForm />
|
||||
</div>
|
||||
<div className='bw-header-col-right'>
|
||||
<HeaderControls />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
44
components/shared/home/feature-cards.tsx
Normal file
44
components/shared/home/feature-cards.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import Image from 'next/image'
|
||||
import * as React from 'react'
|
||||
|
||||
import {cards} from '@/lib/data'
|
||||
import {Card, CardContent} from '@/ui/card'
|
||||
import {Carousel, CarouselContent, CarouselItem} from '@/ui/carousel'
|
||||
|
||||
export default function FeatureCards() {
|
||||
return (
|
||||
<Carousel className='mx-auto w-full'>
|
||||
<CarouselContent className='-ml-2 md:-ml-4'>
|
||||
{cards.map((card: any) => (
|
||||
<CarouselItem
|
||||
key={card.title}
|
||||
className='pl-3 md:basis-1/3 lg:basis-1/4 xl:basis-1/5'
|
||||
>
|
||||
<div className='p-1'>
|
||||
<Card className='border-[2px] border-brand-violet'>
|
||||
<CardContent className='aspect-card flex items-center justify-center p-1'>
|
||||
<CarouselItem>
|
||||
<Image
|
||||
src={card.image}
|
||||
width={256}
|
||||
height={256}
|
||||
className='object-scale-down'
|
||||
priority
|
||||
alt={''}
|
||||
/>
|
||||
</CarouselItem>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
|
||||
/*<div className='container grid grid-cols-1 md:grid-cols-2 md:gap-44 lg:grid-cols-4'>
|
||||
{cards.map((card: any) => (
|
||||
<ProductCard card={card} key={card.title} />
|
||||
))}
|
||||
</div>*/
|
||||
)
|
||||
}
|
||||
74
components/shared/home/home-carousel.tsx
Normal file
74
components/shared/home/home-carousel.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import Autoplay from 'embla-carousel-autoplay'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import * as React from 'react'
|
||||
|
||||
import {Button} from '@/components/ui/button'
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious
|
||||
} from '@/components/ui/carousel'
|
||||
import {cn} from '@/lib/utils'
|
||||
|
||||
export function HomeCarousel({
|
||||
items
|
||||
}: {
|
||||
items: {
|
||||
image: string
|
||||
url: string
|
||||
title: string
|
||||
buttonCaption: string
|
||||
}[]
|
||||
}) {
|
||||
const plugin = React.useRef(Autoplay({delay: 5000, stopOnInteraction: true}))
|
||||
|
||||
return (
|
||||
<Carousel
|
||||
dir='ltr'
|
||||
plugins={[plugin.current]}
|
||||
className='mx-auto w-full'
|
||||
onMouseEnter={plugin.current.stop}
|
||||
onMouseLeave={plugin.current.reset}
|
||||
>
|
||||
<CarouselContent>
|
||||
{items.map(item => (
|
||||
<CarouselItem key={item.title}>
|
||||
<Link href={item.url}>
|
||||
<div className='relative -m-0.5 flex aspect-univisium items-center justify-center'>
|
||||
<Image
|
||||
src={item.image}
|
||||
alt={item.title}
|
||||
fill
|
||||
className='object-cover'
|
||||
priority
|
||||
/>
|
||||
<div className='absolute left-16 top-1/2 hidden w-1/3 -translate-y-1/2 transform md:left-32'>
|
||||
<h2
|
||||
className={cn(
|
||||
'mb-4 text-lg font-bold text-primary shadow-brand-violet drop-shadow-xl md:text-6xl'
|
||||
)}
|
||||
>
|
||||
{`${item.title}`}
|
||||
</h2>
|
||||
<Button className='hidden md:block'>
|
||||
{`${item.buttonCaption}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
{/*<CarouselPrevious className='left-0 h-[78px] w-[78px] text-6xl md:left-12' />
|
||||
<CarouselNext className='right-0 h-[78px] w-[78px] md:right-12' />*/}
|
||||
|
||||
<CarouselPrevious className='absolute left-[1rem] top-1/2 z-10 h-[78px] w-[78px] -translate-y-1/2 transform' />
|
||||
<CarouselNext className='absolute right-[1rem] top-1/2 z-10 h-[78px] w-[78px] -translate-y-1/2 transform' />
|
||||
</Carousel>
|
||||
)
|
||||
}
|
||||
29
components/shared/home/logo.tsx
Normal file
29
components/shared/home/logo.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
import {APP_NAME} from '@/lib/constants'
|
||||
import logoImg from '@/public/images/logo.svg'
|
||||
|
||||
export default function Logo() {
|
||||
const ar = 121 / 192
|
||||
const w = 112
|
||||
|
||||
return (
|
||||
<div className='mt-0.5 flex items-center justify-center'>
|
||||
<Link
|
||||
href='/'
|
||||
className='m-1 flex cursor-pointer items-center pt-[7px] text-2xl font-extrabold outline-0'
|
||||
>
|
||||
<Image
|
||||
src={logoImg}
|
||||
width={w}
|
||||
height={w * ar}
|
||||
alt={`${APP_NAME} logo`}
|
||||
className='w-[131]'
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
components/shared/icons/google.tsx
Normal file
34
components/shared/icons/google.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react'
|
||||
|
||||
import IconProps from '@/components/shared/icons/props'
|
||||
import {cn} from '@/lib/utils'
|
||||
|
||||
export default function GoogleIcon({className, size, color}: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className={cn('bw-icon', className)}
|
||||
>
|
||||
<path
|
||||
d='M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z'
|
||||
fill='#4285F4'
|
||||
/>
|
||||
<path
|
||||
d='M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z'
|
||||
fill='#34A853'
|
||||
/>
|
||||
<path
|
||||
d='M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z'
|
||||
fill='#FBBC05'
|
||||
/>
|
||||
<path
|
||||
d='M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z'
|
||||
fill='#EA4335'
|
||||
/>
|
||||
<path d='M1 1h22v22H1z' fill='none' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
5
components/shared/icons/props.ts
Normal file
5
components/shared/icons/props.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default interface IconProps {
|
||||
className?: string
|
||||
size?: string | number
|
||||
color?: string
|
||||
}
|
||||
22
components/shared/icons/whatsapp.tsx
Normal file
22
components/shared/icons/whatsapp.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import IconProps from '@/components/shared/icons/props'
|
||||
import {BRAND_ICON_COLOR} from '@/lib/constants'
|
||||
import {cn} from '@/lib/utils'
|
||||
|
||||
export default function WhatsappIcon({className, size, color}: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
{/*https://lineicons.com/icons?q=goo*/}
|
||||
<path
|
||||
className={cn('bw-icon', className)}
|
||||
d='M19.074 4.89389C17.2091 3.02894 14.6689 2 12.0644 2C6.59814 2 2.12869 6.4373 2.12869 11.9035C2.12869 13.672 2.57885 15.3441 3.44702 16.8875L2.03223 22L7.33769 20.6495C8.78464 21.4212 10.4245 21.8714 12.0965 21.8714C17.5306 21.8392 21.9679 17.4019 21.9679 11.9035C21.9679 9.26688 20.939 6.791 19.074 4.89389ZM12.0322 20.1672C10.5853 20.1672 9.07403 19.7492 7.82001 18.9775L7.49846 18.7846L4.37949 19.5884L5.24766 16.5659L5.05473 16.2444C4.25088 14.926 3.80072 13.3826 3.80072 11.8392C3.80072 7.30547 7.46631 3.63987 12.0322 3.63987C14.2187 3.63987 16.2766 4.50804 17.82 6.05145C19.3634 7.59486 20.2316 9.68489 20.2316 11.9035C20.2959 16.5016 16.566 20.1672 12.0322 20.1672ZM16.566 13.9936C16.3088 13.865 15.119 13.254 14.8297 13.2219C14.6046 13.1254 14.4116 13.0932 14.283 13.3505C14.1544 13.6077 13.6399 14.1222 13.5113 14.3151C13.3827 14.4437 13.2541 14.508 12.9647 14.3473C12.7075 14.2187 11.9358 13.9936 10.9711 13.0932C10.2316 12.4502 9.71711 11.6463 9.62065 11.3569C9.49203 11.0997 9.5885 11.0032 9.74927 10.8424C9.87788 10.7138 10.0065 10.5852 10.103 10.3923C10.2316 10.2637 10.2316 10.135 10.3602 9.97428C10.4888 9.84566 10.3924 9.65274 10.328 9.52412C10.2316 9.3955 9.78142 8.17364 9.55634 7.65917C9.36342 7.1447 9.13834 7.24116 9.00972 7.24116C8.8811 7.24116 8.68817 7.24116 8.55956 7.24116C8.43094 7.24116 8.1094 7.27331 7.91647 7.5627C7.69139 7.81994 7.0483 8.43087 7.0483 9.65273C7.0483 10.8746 7.91647 12 8.07724 12.2251C8.20586 12.3537 9.84573 14.8939 12.2895 15.9871C12.8682 16.2444 13.3184 16.4051 13.7043 16.5338C14.283 16.7267 14.8297 16.6624 15.2477 16.6302C15.73 16.5981 16.6946 16.0514 16.9197 15.4405C17.1126 14.8939 17.1126 14.3473 17.0483 14.2508C16.984 14.1865 16.7911 14.09 16.566 13.9936Z'
|
||||
fill={color || BRAND_ICON_COLOR}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
41
components/shared/locale-switcher.tsx
Normal file
41
components/shared/locale-switcher.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import {useLocale} from 'next-intl'
|
||||
import {redirect} from 'next/navigation'
|
||||
|
||||
import {Link, usePathname, useRouter} from '@/i18n/routing'
|
||||
import {Label} from '@/ui/label'
|
||||
import {Switch} from '@/ui/switch'
|
||||
|
||||
export default function LocaleSwitcher() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const locale = useLocale()
|
||||
const initialState = locale !== 'uk'
|
||||
|
||||
const handler = (state: boolean) => {
|
||||
const newPath = `/${locale}${pathname}`
|
||||
//window.history.replaceState(null, '', newPath)
|
||||
const link = document.getElementById('lang-switch')
|
||||
if (link) {
|
||||
link.innerText = `${state ? '/ru' : ''}${pathname}`
|
||||
link.setAttribute('href', `${state ? '/ru' : ''}${pathname}`)
|
||||
link.click()
|
||||
}
|
||||
}
|
||||
// router.replace('/cabinet', {locale: checked ? 'ru' : 'uk'}
|
||||
return (
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Link id='lang-switch' href='/' locale='uk'>
|
||||
LA
|
||||
</Link>
|
||||
<Label htmlFor='locale-switcher'>Укр</Label>
|
||||
<Switch
|
||||
id='locale-switcher'
|
||||
defaultChecked={initialState}
|
||||
onCheckedChange={handler}
|
||||
/>
|
||||
<Label htmlFor='locale-switcher'>Рус</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
9
components/shared/navbar/index.tsx
Normal file
9
components/shared/navbar/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import NavbarMenu from '@/components/shared/navbar/navbar-menu'
|
||||
|
||||
export default function Navbar() {
|
||||
return (
|
||||
<nav className='text-min flex w-full items-center justify-between text-sm font-medium leading-none'>
|
||||
<NavbarMenu />
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
44
components/shared/navbar/navbar-menu.tsx
Normal file
44
components/shared/navbar/navbar-menu.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import {Menu as MenuIcon, X} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import {useState} from 'react'
|
||||
|
||||
import {data} from '@/lib/data'
|
||||
import {Button} from '@/ui/button'
|
||||
|
||||
export default function NavbarMenu() {
|
||||
const bp = 'md'
|
||||
|
||||
const [menuOpened, setMenuOpened] = useState(false)
|
||||
|
||||
function ToggleNavbar() {
|
||||
setMenuOpened(!menuOpened)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`hidden ${bp}:block w-full`}>
|
||||
<div className='flex items-center justify-between'>
|
||||
{data.headerMenus.map(item => (
|
||||
<Link href={item.href + item.slug} className='' key={item.name}>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`flex items-center ${bp}:hidden`}>
|
||||
<Button variant='ghost' onClick={ToggleNavbar}>
|
||||
{menuOpened ? <X /> : <MenuIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
{menuOpened && (
|
||||
<div className={`${bp}:hidden`}>
|
||||
<div className='space-y-1 px-2 pb-3 pt-2'>
|
||||
<Link href={'#'}>Hidden Menu</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
33
components/shared/search/form.tsx
Normal file
33
components/shared/search/form.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import {SearchIcon} from 'lucide-react'
|
||||
import {getTranslations} from 'next-intl/server'
|
||||
import Form from 'next/form'
|
||||
|
||||
import {Button} from '@/ui/button'
|
||||
import {Input} from '@/ui/input'
|
||||
|
||||
export default async function SearchForm({query}: {query?: string}) {
|
||||
const t = await getTranslations('UI')
|
||||
|
||||
return (
|
||||
<Form
|
||||
action='/search'
|
||||
scroll={false}
|
||||
className='border-stone flex h-10 w-full overflow-hidden rounded-[10px] border-2'
|
||||
>
|
||||
<Input
|
||||
className='h-full flex-1 rounded-none border-0 bg-white text-base text-stone-600 outline-0 dark:border-gray-200'
|
||||
placeholder={t('search-placeholder')}
|
||||
name='query'
|
||||
defaultValue={query}
|
||||
type='text'
|
||||
/>
|
||||
<Button
|
||||
type='submit'
|
||||
className='h-full rounded-none border-none bg-neutral-200 px-3 py-2 text-stone-600'
|
||||
>
|
||||
{/*<SearchIcon className='size-5' />*/}
|
||||
{t('search-button')}
|
||||
</Button>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
55
components/shared/sidebar/app-catalog-render.tsx
Normal file
55
components/shared/sidebar/app-catalog-render.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import {useLocale} from 'next-intl'
|
||||
|
||||
import {Link} from '@/i18n/routing'
|
||||
import {Button} from '@/ui/button'
|
||||
|
||||
export default function AppCatalogRender(items: Array<object>) {
|
||||
const locale = useLocale()
|
||||
|
||||
return (
|
||||
<div className='flex w-full justify-center'>
|
||||
<div className='bw-dd-menu group inline-block w-full'>
|
||||
<Button className='py-13 flex h-10 w-full items-center rounded-sm border-none bg-brand-yellow-300 px-3 outline-none focus:outline-none'>
|
||||
<span className='flex-1 pr-1 font-semibold'>Каталог</span>
|
||||
<span>
|
||||
<svg
|
||||
className='h-4 w-4 transform fill-current transition duration-300 ease-in-out group-hover:-rotate-180'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path d='M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z' />
|
||||
</svg>
|
||||
</span>
|
||||
</Button>
|
||||
<ul className='-bw-app-catalog-collapse mt-2 w-full min-w-32 origin-top transform rounded-sm border bg-white shadow-xl transition duration-300 ease-in-out group-hover:scale-100 hover:shadow-2xl'>
|
||||
{items?.items.map((item: any) => (
|
||||
<li
|
||||
className='cursor-pointer rounded-none py-2.5 pl-3 pr-1.5 text-sm font-medium hover:bg-[#442d88]/10 xl:py-3'
|
||||
key={item.id}
|
||||
>
|
||||
<button className='flex w-full items-center text-left outline-none focus:outline-none'>
|
||||
<Link
|
||||
className='flex-1 pr-1 leading-none xl:leading-[1.3]'
|
||||
href={`/category/${item.id}-${item.locales[locale === 'uk' ? 0 : 1].slug}`}
|
||||
>
|
||||
{item.locales[locale === 'uk' ? 0 : 1].title}
|
||||
</Link>
|
||||
<span className='mr-auto'>
|
||||
<svg
|
||||
className='h-4 w-4 fill-current transition duration-150 ease-in-out'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path d='M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z' />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
components/shared/sidebar/app-catalog.tsx
Normal file
16
components/shared/sidebar/app-catalog.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use server'
|
||||
|
||||
import AppCatalogRender from '@/components/shared/sidebar/app-catalog-render'
|
||||
import {db} from '@/lib/db/prisma/client'
|
||||
|
||||
const appCatalog = async () => {
|
||||
return db.category.findMany({
|
||||
include: {
|
||||
locales: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default async function AppCatalog() {
|
||||
return <AppCatalogRender items={await appCatalog()} />
|
||||
}
|
||||
82
components/shared/social-media-panel.tsx
Normal file
82
components/shared/social-media-panel.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import IconProps from '@/components/shared/icons/props'
|
||||
import WhatsappIcon from '@/components/shared/icons/whatsapp'
|
||||
import {BRAND_ICON_COLOR, BRAND_ICON_SIZE} from '@/lib/constants'
|
||||
import {cn} from '@/lib/utils'
|
||||
|
||||
export default function SocialMediaPanel({className, size, color}: IconProps) {
|
||||
return (
|
||||
<aside className={cn('flex items-center gap-x-4', className)}>
|
||||
{/*facebook*/}
|
||||
<div>
|
||||
<svg
|
||||
width={size || BRAND_ICON_SIZE}
|
||||
height={size || BRAND_ICON_SIZE * 1.041666666666667}
|
||||
viewBox='0 0 24 25'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M12 2.53906C17.5229 2.53906 22 7.01621 22 12.5391C22 17.5304 18.3431 21.6674 13.5625 22.4176V15.4297H15.8926L16.3359 12.5391L13.5625 12.5387V10.6632C13.5625 10.657 13.5625 10.6509 13.5626 10.6447C13.5626 10.6354 13.5628 10.6262 13.5629 10.6169C13.578 9.84259 13.9742 9.10156 15.1921 9.10156H16.4531V6.64062C16.4531 6.64062 15.3087 6.44492 14.2146 6.44492C11.966 6.44492 10.4842 7.78652 10.4386 10.2193C10.4379 10.2578 10.4375 10.2965 10.4375 10.3355V12.5387H7.89844V15.4293L10.4375 15.4297V22.4172C5.65686 21.667 2 17.5304 2 12.5391C2 7.01621 6.47715 2.53906 12 2.53906Z'
|
||||
fill={color || BRAND_ICON_COLOR}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<WhatsappIcon size={size} color={color} />
|
||||
</div>
|
||||
|
||||
{/*Telegram*/}
|
||||
<div>
|
||||
<svg
|
||||
width={size || BRAND_ICON_SIZE}
|
||||
height={size || BRAND_ICON_SIZE}
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M21.936 5.17077L18.9059 19.3546C18.6802 20.3539 18.1 20.5795 17.2618 20.1282L12.7166 16.7757L10.4923 18.9033C10.2666 19.1289 10.041 19.3546 9.5252 19.3546L9.8798 14.6804L18.3578 6.97598C18.7124 6.62138 18.2611 6.49244 17.8098 6.78256L7.26869 13.4232L2.72343 12.037C1.72412 11.7147 1.72412 11.0377 2.94908 10.5864L20.6144 3.72015C21.4847 3.46227 22.2262 3.91357 21.936 5.17077Z'
|
||||
fill={color || BRAND_ICON_COLOR}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/*Instagram*/}
|
||||
<div>
|
||||
<svg
|
||||
width={size || BRAND_ICON_SIZE}
|
||||
height={size || BRAND_ICON_SIZE}
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M8.6672 12C8.6672 10.1591 10.1591 8.6664 12 8.6664C13.8409 8.6664 15.3336 10.1591 15.3336 12C15.3336 13.8409 13.8409 15.3336 12 15.3336C10.1591 15.3336 8.6672 13.8409 8.6672 12ZM6.86512 12C6.86512 14.836 9.164 17.1349 12 17.1349C14.836 17.1349 17.1349 14.836 17.1349 12C17.1349 9.164 14.836 6.86512 12 6.86512C9.164 6.86512 6.86512 9.164 6.86512 12ZM16.1382 6.66152C16.1381 6.89886 16.2084 7.13089 16.3401 7.32829C16.4719 7.52568 16.6593 7.67956 16.8785 7.77047C17.0977 7.86138 17.339 7.88525 17.5718 7.83904C17.8046 7.79283 18.0185 7.67862 18.1863 7.51087C18.3542 7.34311 18.4686 7.12934 18.515 6.89658C18.5614 6.66382 18.5377 6.42253 18.447 6.20322C18.3563 5.98392 18.2025 5.79644 18.0052 5.6645C17.808 5.53257 17.576 5.4621 17.3386 5.462H17.3382C17.02 5.46215 16.715 5.58856 16.49 5.81347C16.265 6.03837 16.1384 6.34339 16.1382 6.66152ZM7.96 20.1398C6.98504 20.0954 6.45512 19.933 6.10296 19.7958C5.63608 19.614 5.30296 19.3975 4.95272 19.0478C4.60248 18.698 4.38568 18.3652 4.20472 17.8983C4.06744 17.5463 3.90504 17.0162 3.86072 16.0413C3.81224 14.9872 3.80256 14.6706 3.80256 12.0001C3.80256 9.3296 3.81304 9.01384 3.86072 7.95888C3.90512 6.98392 4.06872 6.45488 4.20472 6.10184C4.38648 5.63496 4.60296 5.30184 4.95272 4.9516C5.30248 4.60136 5.63528 4.38456 6.10296 4.2036C6.45496 4.06632 6.98504 3.90392 7.96 3.8596C9.01408 3.81112 9.33072 3.80144 12 3.80144C14.6693 3.80144 14.9862 3.81192 16.0412 3.8596C17.0162 3.904 17.5452 4.0676 17.8982 4.2036C18.3651 4.38456 18.6982 4.60184 19.0485 4.9516C19.3987 5.30136 19.6147 5.63496 19.7965 6.10184C19.9338 6.45384 20.0962 6.98392 20.1405 7.95888C20.189 9.01384 20.1986 9.3296 20.1986 12.0001C20.1986 14.6706 20.189 14.9863 20.1405 16.0413C20.0961 17.0162 19.9329 17.5462 19.7965 17.8983C19.6147 18.3652 19.3982 18.6983 19.0485 19.0478C18.6987 19.3972 18.3651 19.614 17.8982 19.7958C17.5462 19.933 17.0162 20.0954 16.0412 20.1398C14.9871 20.1882 14.6705 20.1979 12 20.1979C9.32952 20.1979 9.01376 20.1882 7.96 20.1398ZM7.8772 2.06056C6.81264 2.10904 6.0852 2.27784 5.44992 2.52504C4.792 2.78032 4.23504 3.1228 3.67848 3.67848C3.12192 4.23416 2.78032 4.792 2.52504 5.44992C2.27784 6.0856 2.10904 6.81264 2.06056 7.8772C2.01128 8.94344 2 9.28432 2 12C2 14.7157 2.01128 15.0566 2.06056 16.1228C2.10904 17.1874 2.27784 17.9144 2.52504 18.5501C2.78032 19.2076 3.122 19.7661 3.67848 20.3215C4.23496 20.877 4.792 21.219 5.44992 21.475C6.0864 21.7222 6.81264 21.891 7.8772 21.9394C8.944 21.9879 9.28432 22 12 22C14.7157 22 15.0566 21.9887 16.1228 21.9394C17.1874 21.891 17.9144 21.7222 18.5501 21.475C19.2076 21.219 19.765 20.8772 20.3215 20.3215C20.8781 19.7658 21.219 19.2076 21.475 18.5501C21.7222 17.9144 21.8918 17.1874 21.9394 16.1228C21.9879 15.0558 21.9992 14.7157 21.9992 12C21.9992 9.28432 21.9879 8.94344 21.9394 7.8772C21.891 6.81256 21.7222 6.0852 21.475 5.44992C21.219 4.7924 20.8772 4.23504 20.3215 3.67848C19.7658 3.12192 19.2076 2.78032 18.5509 2.52504C17.9144 2.27784 17.1874 2.10824 16.1236 2.06056C15.0574 2.01208 14.7165 2 12.0008 2C9.28512 2 8.944 2.01128 7.8772 2.06056Z'
|
||||
fill={color || BRAND_ICON_COLOR}
|
||||
/>
|
||||
<path
|
||||
d='M8.6672 12C8.6672 10.1591 10.1591 8.6664 12 8.6664C13.8409 8.6664 15.3336 10.1591 15.3336 12C15.3336 13.8409 13.8409 15.3336 12 15.3336C10.1591 15.3336 8.6672 13.8409 8.6672 12ZM6.86512 12C6.86512 14.836 9.164 17.1349 12 17.1349C14.836 17.1349 17.1349 14.836 17.1349 12C17.1349 9.164 14.836 6.86512 12 6.86512C9.164 6.86512 6.86512 9.164 6.86512 12ZM16.1382 6.66152C16.1381 6.89886 16.2084 7.13089 16.3401 7.32829C16.4719 7.52568 16.6593 7.67956 16.8785 7.77047C17.0977 7.86138 17.339 7.88525 17.5718 7.83904C17.8046 7.79283 18.0185 7.67862 18.1863 7.51087C18.3542 7.34311 18.4686 7.12934 18.515 6.89658C18.5614 6.66382 18.5377 6.42253 18.447 6.20322C18.3563 5.98392 18.2025 5.79644 18.0052 5.6645C17.808 5.53257 17.576 5.4621 17.3386 5.462H17.3382C17.02 5.46215 16.715 5.58856 16.49 5.81347C16.265 6.03837 16.1384 6.34339 16.1382 6.66152ZM7.96 20.1398C6.98504 20.0954 6.45512 19.933 6.10296 19.7958C5.63608 19.614 5.30296 19.3975 4.95272 19.0478C4.60248 18.698 4.38568 18.3652 4.20472 17.8983C4.06744 17.5463 3.90504 17.0162 3.86072 16.0413C3.81224 14.9872 3.80256 14.6706 3.80256 12.0001C3.80256 9.3296 3.81304 9.01384 3.86072 7.95888C3.90512 6.98392 4.06872 6.45488 4.20472 6.10184C4.38648 5.63496 4.60296 5.30184 4.95272 4.9516C5.30248 4.60136 5.63528 4.38456 6.10296 4.2036C6.45496 4.06632 6.98504 3.90392 7.96 3.8596C9.01408 3.81112 9.33072 3.80144 12 3.80144C14.6693 3.80144 14.9862 3.81192 16.0412 3.8596C17.0162 3.904 17.5452 4.0676 17.8982 4.2036C18.3651 4.38456 18.6982 4.60184 19.0485 4.9516C19.3987 5.30136 19.6147 5.63496 19.7965 6.10184C19.9338 6.45384 20.0962 6.98392 20.1405 7.95888C20.189 9.01384 20.1986 9.3296 20.1986 12.0001C20.1986 14.6706 20.189 14.9863 20.1405 16.0413C20.0961 17.0162 19.9329 17.5462 19.7965 17.8983C19.6147 18.3652 19.3982 18.6983 19.0485 19.0478C18.6987 19.3972 18.3651 19.614 17.8982 19.7958C17.5462 19.933 17.0162 20.0954 16.0412 20.1398C14.9871 20.1882 14.6705 20.1979 12 20.1979C9.32952 20.1979 9.01376 20.1882 7.96 20.1398ZM7.8772 2.06056C6.81264 2.10904 6.0852 2.27784 5.44992 2.52504C4.792 2.78032 4.23504 3.1228 3.67848 3.67848C3.12192 4.23416 2.78032 4.792 2.52504 5.44992C2.27784 6.0856 2.10904 6.81264 2.06056 7.8772C2.01128 8.94344 2 9.28432 2 12C2 14.7157 2.01128 15.0566 2.06056 16.1228C2.10904 17.1874 2.27784 17.9144 2.52504 18.5501C2.78032 19.2076 3.122 19.7661 3.67848 20.3215C4.23496 20.877 4.792 21.219 5.44992 21.475C6.0864 21.7222 6.81264 21.891 7.8772 21.9394C8.944 21.9879 9.28432 22 12 22C14.7157 22 15.0566 21.9887 16.1228 21.9394C17.1874 21.891 17.9144 21.7222 18.5501 21.475C19.2076 21.219 19.765 20.8772 20.3215 20.3215C20.8781 19.7658 21.219 19.2076 21.475 18.5501C21.7222 17.9144 21.8918 17.1874 21.9394 16.1228C21.9879 15.0558 21.9992 14.7157 21.9992 12C21.9992 9.28432 21.9879 8.94344 21.9394 7.8772C21.891 6.81256 21.7222 6.0852 21.475 5.44992C21.219 4.7924 20.8772 4.23504 20.3215 3.67848C19.7658 3.12192 19.2076 2.78032 18.5509 2.52504C17.9144 2.27784 17.1874 2.10824 16.1236 2.06056C15.0574 2.01208 14.7165 2 12.0008 2C9.28512 2 8.944 2.01128 7.8772 2.06056Z'
|
||||
fill={color || BRAND_ICON_COLOR}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/*Youtube*/}
|
||||
<div>
|
||||
<svg
|
||||
width={((size || BRAND_ICON_SIZE) as number) * 1.5}
|
||||
height={((size || BRAND_ICON_SIZE) as number) * 1.5}
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M21.5806 7.19355C21.3548 6.32258 20.6774 5.64516 19.8065 5.41935C18.2581 5 12 5 12 5C12 5 5.74194 5 4.19355 5.41935C3.32258 5.64516 2.64516 6.32258 2.41935 7.19355C2 8.77419 2 12 2 12C2 12 2 15.2581 2.41935 16.8065C2.64516 17.6774 3.32258 18.3548 4.19355 18.5806C5.74194 19 12 19 12 19C12 19 18.2581 19 19.8065 18.5806C20.6774 18.3548 21.3548 17.6774 21.5806 16.8065C22 15.2581 22 12 22 12C22 12 22 8.77419 21.5806 7.19355ZM10 15V9L15.1935 12L10 15Z'
|
||||
fill={color || BRAND_ICON_COLOR}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
30
components/shared/store/product-card.tsx
Normal file
30
components/shared/store/product-card.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
import {Card, CardContent, CardFooter} from '@/ui/card'
|
||||
|
||||
export type CardItem = {
|
||||
title: string
|
||||
image: string
|
||||
href: string
|
||||
price: number
|
||||
}
|
||||
|
||||
export default function ProductCard({card}: {card: CardItem}) {
|
||||
return (
|
||||
<Card key={card.title} className='flex flex-col'>
|
||||
<Link key={card.title} href={card.href} className='flex flex-col'>
|
||||
<CardContent className='flex-1 p-4'>
|
||||
<Image
|
||||
src={card.image}
|
||||
alt={card.title}
|
||||
width={120}
|
||||
height={120}
|
||||
className='aspect-card mx-auto h-auto max-w-full object-cover'
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter>{card.title}</CardFooter>
|
||||
</Link>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
31
components/temp-component.tsx
Normal file
31
components/temp-component.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import {useLocale} from 'next-intl'
|
||||
|
||||
import {Link} from '@/i18n/routing'
|
||||
|
||||
export default function TempComponent(data: object[]) {
|
||||
const locale = useLocale()
|
||||
/*console.log(data)*/
|
||||
|
||||
const items = data?.data as object[]
|
||||
|
||||
return (
|
||||
<>
|
||||
<code>{locale}</code>
|
||||
{/*<pre>{JSON.stringify(data, null, 2)}</pre>*/}
|
||||
|
||||
<pre>
|
||||
{items.map((item: any) => (
|
||||
<div key={item.id}>
|
||||
<Link
|
||||
href={`/category/${item.id}-${item.locales[locale === 'uk' ? 0 : 1].slug}`}
|
||||
>
|
||||
{item.locales[locale === 'uk' ? 0 : 1].title}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
</>
|
||||
)
|
||||
}
|
||||
50
components/ui/avatar.tsx
Normal file
50
components/ui/avatar.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
115
components/ui/breadcrumb.tsx
Normal file
115
components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
React.ComponentPropsWithoutRef<"nav"> & {
|
||||
separator?: React.ReactNode
|
||||
}
|
||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
|
||||
Breadcrumb.displayName = "Breadcrumb"
|
||||
|
||||
const BreadcrumbList = React.forwardRef<
|
||||
HTMLOListElement,
|
||||
React.ComponentPropsWithoutRef<"ol">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbList.displayName = "BreadcrumbList"
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentPropsWithoutRef<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbItem.displayName = "BreadcrumbItem"
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentPropsWithoutRef<"a"> & {
|
||||
asChild?: boolean
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn("transition-colors hover:text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
BreadcrumbLink.displayName = "BreadcrumbLink"
|
||||
|
||||
const BreadcrumbPage = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
React.ComponentPropsWithoutRef<"span">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("font-normal text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbPage.displayName = "BreadcrumbPage"
|
||||
|
||||
const BreadcrumbSeparator = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) => (
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
|
||||
|
||||
const BreadcrumbEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
59
components/ui/button.tsx
Normal file
59
components/ui/button.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import {Slot} from '@radix-ui/react-slot'
|
||||
import {type VariantProps, cva} from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
|
||||
import {cn} from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
brand:
|
||||
'bg-brand-yellow text-primary-foreground shadow hover:bg-primary/90',
|
||||
default:
|
||||
'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({className, variant, size, asChild = false, ...props}, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({variant, size, className}))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export {Button, buttonVariants}
|
||||
76
components/ui/card.tsx
Normal file
76
components/ui/card.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
266
components/ui/carousel.tsx
Normal file
266
components/ui/carousel.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
'use client'
|
||||
|
||||
import useEmblaCarousel, {type UseEmblaCarouselType} from 'embla-carousel-react'
|
||||
import {ArrowLeft, ArrowRight, ChevronLeft, ChevronRight} from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
|
||||
import {Button} from '@/components/ui/button'
|
||||
import {cn} from '@/lib/utils'
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useCarousel must be used within a <Carousel />')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const Carousel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
orientation = 'horizontal',
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === 'horizontal' ? 'x' : 'y'
|
||||
},
|
||||
plugins
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) {
|
||||
return
|
||||
}
|
||||
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
onSelect(api)
|
||||
api.on('reInit', onSelect)
|
||||
api.on('select', onSelect)
|
||||
|
||||
return () => {
|
||||
api?.off('select', onSelect)
|
||||
}
|
||||
}, [api, onSelect])
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn('relative', className)}
|
||||
role='region'
|
||||
aria-roledescription='carousel'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
Carousel.displayName = 'Carousel'
|
||||
|
||||
const CarouselContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({className, ...props}, ref) => {
|
||||
const {carouselRef, orientation} = useCarousel()
|
||||
|
||||
return (
|
||||
<div ref={carouselRef} className='overflow-hidden'>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex',
|
||||
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
CarouselContent.displayName = 'CarouselContent'
|
||||
|
||||
const CarouselItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({className, ...props}, ref) => {
|
||||
const {orientation} = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role='group'
|
||||
aria-roledescription='slide'
|
||||
className={cn(
|
||||
'min-w-0 shrink-0 grow-0 basis-full',
|
||||
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
CarouselItem.displayName = 'CarouselItem'
|
||||
|
||||
const CarouselPrevious = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({className, variant = 'outline', size = 'icon', ...props}, ref) => {
|
||||
const {orientation, scrollPrev, canScrollPrev} = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
'absolute h-8 w-8 rounded-full',
|
||||
orientation === 'horizontal'
|
||||
? '-left-12 top-1/2 -translate-y-1/2'
|
||||
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
{/*<ArrowLeft className='h-4 w-4' />*/}
|
||||
<ChevronLeft
|
||||
size={72}
|
||||
className='h-[78px] w-[78px]'
|
||||
absoluteStrokeWidth
|
||||
/>
|
||||
<span className='sr-only'>Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
CarouselPrevious.displayName = 'CarouselPrevious'
|
||||
|
||||
const CarouselNext = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({className, variant = 'outline', size = 'icon', ...props}, ref) => {
|
||||
const {orientation, scrollNext, canScrollNext} = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
'absolute h-8 w-8 rounded-full',
|
||||
orientation === 'horizontal'
|
||||
? '-right-12 top-1/2 -translate-y-1/2'
|
||||
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
{/*<ArrowRight className='h-4 w-4' />*/}
|
||||
<ChevronRight size={48} absoluteStrokeWidth />
|
||||
<span className='sr-only'>Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
CarouselNext.displayName = 'CarouselNext'
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext
|
||||
}
|
||||
11
components/ui/collapsible.tsx
Normal file
11
components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
122
components/ui/dialog.tsx
Normal file
122
components/ui/dialog.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
201
components/ui/dropdown-menu.tsx
Normal file
201
components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
'use client'
|
||||
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||
import {Check, ChevronRight, Circle} from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
|
||||
import {cn} from '@/lib/utils'
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({className, inset, children, ...props}, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open1]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className='ml-auto' />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({className, ...props}, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({className, sideOffset = 4, ...props}, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({className, inset, ...props}, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({className, children, checked, ...props}, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className='h-4 w-4' />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({className, children, ...props}, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className='h-2 w-2 fill-current' />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({className, inset, ...props}, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-semibold',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({className, ...props}, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup
|
||||
}
|
||||
178
components/ui/form.tsx
Normal file
178
components/ui/form.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message) : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-[0.8rem] font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
23
components/ui/input.tsx
Normal file
23
components/ui/input.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import {cn} from '@/lib/utils'
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
({className, type, ...props}, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
/*focus-visible:ring-1 focus-visible:ring-ring*/
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export {Input}
|
||||
26
components/ui/label.tsx
Normal file
26
components/ui/label.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user