added tons of features

This commit is contained in:
2025-02-05 08:01:14 +02:00
parent 4ae0d8c545
commit 8138da6b1d
195 changed files with 12619 additions and 415 deletions

1
lib/config/constants.ts Normal file
View File

@@ -0,0 +1 @@
export const STORE_ID: number = 1

10
lib/config/dayjs.ts Normal file
View File

@@ -0,0 +1,10 @@
import dayjs from 'dayjs'
import 'dayjs/locale/es'
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.locale('uk')
export default dayjs

60
lib/config/editor.ts Normal file
View File

@@ -0,0 +1,60 @@
export const BaseEditorConfig = {
readonly: false, // all options from https://xdsoft.net/jodit/docs/,
placeholder: 'Start typings...',
spellcheck: true,
language: 'ua',
//toolbarAdaptive: false,
//toolbarButtonSize: 'small',
saveHeightInStorage: true,
saveModeInStorage: true,
//defaultActionOnPaste: 'insert_as_text',
//defaultActionOnPaste: 'insert_only_text',
//disablePlugins: 'ai-assistant,mobile,print,speech-recognize,table,table-keyboard-navigation,powered-by-jodit,iframe',
minHeight: 240,
maxHeight: 640,
maxWidth: 890,
uploader: {
insertImageAsBase64URI: true,
imagesExtensions: ['jpg', 'png', 'jpeg', 'gif', 'svg', 'webp']
}
// cleanHTML: {
// allowTags: {
// p: true,
// h1: true,
// h2: true,
// h3: true,
// h4: true,
// h5: true,
// h6: true,
// div: true,
// strong: true,
// li: true,
// ul: true,
// ol: true,
// i: true,
// blockquote: true,
// code: true,
// img: true,
// cite: true,
// small: true,
// sub: true,
// sup: true
// }
// }
// controls: {
// paragraph: {
// list: {
// h1: 'Heading 1',
// h2: 'Heading 2',
// h3: 'Heading 3',
// h4: 'Heading 4',
// h5: 'Heading 5',
// h6: 'Heading 6',
// blockquote: 'Quote',
// div: 'Div',
// pre: 'Source code'
// }
// }
// },
// buttons: 'bold,italic,underline,strikethrough,eraser,ul,ol,font,fontsize,paragraph,lineHeight,superscript,subscript,classSpan,file,image,video,spellcheck,cut'
}

3
lib/config/http.ts Normal file
View File

@@ -0,0 +1,3 @@
export const HEADERS = {
xSiteLocale: 'x-site-locale'
}

5
lib/config/routes.ts Normal file
View File

@@ -0,0 +1,5 @@
export const ADMIN_DASHBOARD_PATH = '/admin'
export const translatableRoutesRegEx = /^\/(|ru|uk).*/
export const protectedRoutesRegEx =
/^\/admin|(\/(|ru\/|uk\/)?(cabinet|checkout))/

14
lib/constants.ts Normal file
View File

@@ -0,0 +1,14 @@
import {UserRole} from '@prisma/client'
export const TIMEZONE = process.env.TIMEZONE || 'Europe/Kyiv'
// export const APP_PUBLIC_URL = process.env.PUBLIC_URL || 'http://localhost:3000'
export const APP_NAME = process.env.NEXT_PUBLIC_APP_NAME || 'Be Well'
export const APP_SLOGAN = process.env.NEXT_PUBLIC_APP_SLOGAN || ''
export const APP_DESCRIPTION = process.env.NEXT_PUBLIC_APP_DESCRIPTION || ''
export const BRAND_COLOR_VIOLET = 'hsl(255.2,50.3%,35.5%)'
export const BRAND_COLOR_YELLOW = 'hsl(46.1,100%,61.2%)'
export const BRAND_ICON_COLOR = BRAND_COLOR_VIOLET
export const BRAND_ICON_SIZE = 24
export const DEFAULT_USER_ROLE = UserRole.CUSTOMER

152
lib/data.ts Normal file
View File

@@ -0,0 +1,152 @@
import {slug} from '@/lib/utils'
export const data = {
headerMenus: [
{
name: 'Про нас',
slug: slug('Про нас'),
href: '/search?tag='
},
{
name: "Цікаво про здоров'я",
slug: slug("Цікаво про здоров'я"),
href: '/search?tag='
},
{
name: 'Програма лояльності',
slug: slug('Програма лояльності'),
href: '/search?tag='
},
{
name: 'Доставка і повернення',
slug: slug('Доставка і повернення'),
href: '/search?tag='
},
{
name: 'Контакти',
slug: slug('Контакти'),
href: '/search?tag='
}
]
}
export const categories = [
{
name: "Жіноче здоров'я",
rus: 'Женское здоровье',
slug: slug("Жіноче здоров'я")
},
{
name: "Чоловіче здоров'я",
rus: 'Мужское здоровье',
slug: slug("Чоловіче здоров'я")
},
{
name: 'Краса (шкіра, волосся, нігті)',
rus: 'Красота (кожа, волосы, ногти)',
slug: slug('Краса (шкіра, волосся, нігті)')
},
{
name: 'Здоровий сон',
rus: 'Здоровый сон',
slug: slug('Здоровий сон')
},
{
name: 'Імунітет',
rus: 'Иммунитет',
slug: slug('Імунітет')
},
{
name: 'Для кісток та суглобів',
rus: 'Для костей и суставов',
slug: slug('Для кісток та суглобів')
},
{
name: 'Для зниження цукру в крові',
rus: 'Для снижения уровня сахара в крови',
slug: slug('Для зниження цукру в крові')
},
{
name: 'Детоксикація',
rus: 'Детоксикация',
slug: slug('Детоксикація')
},
{
name: "Здоров'я печінки",
rus: 'Здоровье печени',
slug: slug("Здоров'я печінки")
},
{
name: 'Для щитовидної залози',
rus: 'Для щитовидной железы',
slug: slug('Для щитовидної залози')
},
{
name: 'Життєвий тонус',
rus: 'Жизненный тонус',
slug: slug('Життєвий тонус')
},
{
name: 'Антистрес',
rus: 'Антистресс',
slug: slug('Антистрес')
}
]
export const carousels = [
{
title: 'The Children of the Serpent',
buttonCaption: 'Shop Now',
image: '/images/main-1.jpg',
url: '#',
isPublished: true
},
{
title: 'The Pirates of the Windows',
buttonCaption: 'Shop Now',
image: '/images/947-vistabon_mobi_1.jpg',
url: '#',
isPublished: true
},
{
title: 'The Slaves of the Healer',
buttonCaption: 'Shop Now',
image: '/images/og-abxbm8wqowl65w86drm8my4xr6aia1km.png',
url: '#',
isPublished: true
}
]
export const cards = [
{
title: 'Пінеал Тенс',
image: '/uploads/637393-1500x1500-ea2f.jpg',
href: slug('Пінеал Тенс'),
price: 720
},
{
title: 'Вістакеа Остеостронг',
image: '/uploads/1256-vistacare_osteostrong_tablets_box_livo_1.png',
href: slug('Вістакеа Остеостронг'),
price: 640
},
{
title: 'Вістакеа Детокс',
image:
'/uploads/1ca3d021-a55d-4c0a-aa7b-06ecb5f905b0-w1000-h1000-wm-frame.jpg',
href: slug('Вістакеа Детокс'),
price: 850
},
{
title: 'Тіромодал',
image: '/uploads/189238-192172-orig-1500-1500-d76a.jpg',
href: slug('Тіромодал'),
price: 535
},
{
title: 'Артросульфур С',
image: '/uploads/79282077-e324-4248-9f5d-184242ec4dd4.webp',
href: slug('Артросульфур С'),
price: 535
}
]

View File

@@ -0,0 +1,27 @@
// https://medium.com/@mokremiz/building-a-flexible-dto-mapper-for-react-and-next-js-projects-3ee77055f05d
export const mapResponseToDTO = <T, U>(
responseDTO: U,
propertyMappings?: Record<string, keyof T>
): T => {
// Create an empty object that will hold the mapped DTO
const mappedDTO: Partial<T> = {}
// Loop through each property in the responseDTO
for (const key in responseDTO) {
// Check if propertyMappings exist and if the current key is in propertyMappings
if (propertyMappings && key in propertyMappings) {
// If there is a mapping for the current key, use it to set the property in the mappedDTO
mappedDTO[propertyMappings[key] as keyof T] = responseDTO[
key
] as unknown as T[keyof T]
} else {
// If there is no mapping for the current key, use the key as is to set the property in the mappedDTO
mappedDTO[key as unknown as keyof T] = responseDTO[
key
] as unknown as T[keyof T]
}
}
// Return the mappedDTO as a type T
return mappedDTO as T
}

View File

@@ -0,0 +1,50 @@
'use server'
import {Lang, Product, ProductLocale} from '@prisma/client'
import internal from 'node:stream'
import {db, dbQueryLog} from '@/lib/db/prisma/client'
export const getProductBySlug = async (data: {
slug: string
lang: string
}): Promise<ProductLocale | null> => {
return db.productLocale.findFirst({
where: {
slug: data.slug,
lang: data.lang as Lang
}
})
}
export const getProductById = async (id: unknown): Promise<Product | null> => {
return db.product.findUnique({
where: {id: id as number},
include: {
locales: {
include: {
meta: true
}
},
toStore: true
}
})
}
export const getProducts = async (): Promise<Product[] | null> => {
return db.product.findMany({
include: {
locales: {
omit: {
description: true,
content: true,
instruction: true
},
include: {
meta: true
}
},
toStore: true
}
})
}

64
lib/db/prisma/client.ts Normal file
View File

@@ -0,0 +1,64 @@
import {PrismaClient} from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
db: PrismaClient
}
export const db =
globalForPrisma.db ||
new PrismaClient({
log: [
{
emit: 'event',
level: 'query'
},
{
emit: 'stdout',
level: 'error'
},
{
emit: 'stdout',
level: 'info'
},
{
emit: 'stdout',
level: 'warn'
}
]
})
export function dbQueryLog() {
db.$on('query' as never, async (e: any) => {
console.log(e.query)
})
}
if (process.env.NODE_ENV !== 'production') globalForPrisma.db = db
// {
// errorFormat: 'pretty',
// log: [
// {
// emit: 'event',
// level: 'query'
// }
// ],
// omit: {
// user: {
// password: true,
// emailVerified: true,
// extendedData: true,
// role: true,
// locale: true,
// status: true,
// updatedAt: true,
// createdAt: true
// },
// store: {}
// }
// }
//
// db.$on('query' as never, async (e: any) => {
// console.debug(e.query)
// })

View File

@@ -0,0 +1,40 @@
model Category {
id Int @id @default(autoincrement())
status Boolean? @default(false)
position Int? @default(0) @db.UnsignedSmallInt
image String? @db.VarChar(384)
storeId Int @map("store_id")
locales CategoryLocale[]
createdAt DateTime @default(now()) @map("created_at")
categoriesOnPruducts CategoriesOnProducts[]
@@map("categories")
}
model CategoryLocale {
id Int @id @default(autoincrement())
category Category @relation(fields: [categoryId], references: [id])
categoryId Int @map("category_id")
lang Lang @default(uk)
title String @db.VarChar(384)
slug String? @unique @db.VarChar(384)
shortTitle String? @map("short_title")
description String? @db.Text
// content String? @db.MediumText
// extendedData Json? @map("extended_data") @db.Json
@@unique([categoryId, lang])
@@map("category_locales")
}
model CategoriesOnProducts {
store Store @relation(fields: [storeId], references: [id])
storeId Int @default(1) @map("store_id")
product Product @relation(fields: [productId], references: [id])
productId Int @map("product_id")
category Category @relation(fields: [categoryId], references: [id])
categoryId Int @map("category_id")
@@id([storeId, productId, categoryId])
@@map("categories_on_pruducts")
}

View File

@@ -0,0 +1,28 @@
enum Lang {
uk
ru
}
enum Unit {
mkg
mg
MO
}
enum ResourceType {
IMAGE
VIDEO
FILE
URI
}
enum Package {
SACHET
TABLET
STICK
DROPS
}
enum ProductType {
DIETARY_SUPPLEMENT
}

View File

@@ -0,0 +1,26 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
previewFeatures = ["typedSql", "prismaSchemaFolder"]
/// relationJoins is not available for MySQL < 8.0.14 and MariaDB.
/// previewFeatures = ["relationJoins"]
}
/// Always after the prisma-client-js generator
generator json {
provider = "prisma-json-types-generator"
// clientOutput = "lib/db/prisma/generated"
}
// generator zod {
// provider = "zod-prisma-types"
// useMultipleFiles = true
// createRelationValuesTypes = true
// }
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}

View File

@@ -0,0 +1,49 @@
enum OpenGraphType {
image
audio
video
article
book
profile
website
}
enum LC {
uk_UA
ru_UA
}
// https://site-ok.com/blog/%D0%BA%D0%B0%D0%BA-%D0%BF%D1%80%D0%B0%D0%B2%D0%B8%D0%BB%D1%8C%D0%BD%D0%BE-%D0%BF%D0%B8%D1%81%D0%B0%D1%82%D1%8C-%D0%BC%D0%B5%D1%82%D0%B0-%D1%82%D0%B5%D0%B3%D0%B8-title-%D0%B8-description
model Meta {
id Int @id @default(autoincrement())
title String? @db.VarChar(255)
description String? @db.Text
keywords String? @db.VarChar(255)
author String? @db.VarChar(255)
openGraph OpenGraph?
storeLocale StoreLocale[]
productLocale ProductLocale[]
//vendorLocale VendorLocale[]
@@map("meta")
}
// https://ogp.me/#types
// https://seosetups.com/blog/open-graph/
// https://www.conductor.com/academy/open-graph/
// https://developer.x.com/en/docs/x-for-websites/cards/overview/markup
model OpenGraph {
id Int @id @default(autoincrement())
url String? @db.VarChar(1024)
title String? @db.VarChar(384)
description String? @db.Text //
image String? @db.VarChar(1024)
type OpenGraphType?
locale LC?
siteName String? @map("site_name")
video String? @db.VarChar(1024)
meta Meta @relation(fields: [metaId], references: [id], onDelete: Cascade)
metaId Int @unique @map("meta_id")
@@map("open_graph")
}

View File

@@ -0,0 +1,141 @@
model Product {
id Int @id @default(autoincrement())
type ProductType? @default(DIETARY_SUPPLEMENT)
image String? @db.VarChar(512)
// vendor Vendor @relation(fields: [vendorId], references: [id])
// vendorId Int
locales ProductLocale[]
categoriesOnProducts CategoriesOnProducts[]
resources ProductResource[]
toStore ProductToStore[]
attribute ProductAttribute[]
ingradient ProductIngradient[]
form FormsOfRelease[]
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
updatedAt DateTime? @default(dbgenerated("NULL DEFAULT NULL ON UPDATE current_timestamp(3)")) @map("updated_at") @db.Timestamp(3)
@@map("products")
}
model ProductLocale {
id Int @id @default(autoincrement())
lang Lang @default(uk)
slug String? @db.VarChar(384)
title String @db.VarChar(384)
shortTitle String? @map("short_title")
headingTitle String? @map("heading_title")
description String? @db.MediumText
content String @db.MediumText
instruction String? @db.MediumText
product Product @relation(fields: [productId], references: [id])
productId Int @map("product_id")
meta Meta? @relation(fields: [metaId], references: [id])
metaId Int? @map("meta_id")
@@unique([productId, slug, lang])
@@map("product_locale")
}
model ProductResource {
id Int @id @default(autoincrement())
type ResourceType
mimeType String? @map("mime_type")
filesize Int? @db.UnsignedInt
width Int? @db.UnsignedSmallInt
height Int? @db.UnsignedSmallInt
quality Int? @db.UnsignedTinyInt
signature String? @db.Char(64)
uri String @db.Text
title String? @db.VarChar(255)
description String? @db.Text
meta Json? @db.Json
productId Int? @map("product_id")
product Product? @relation(fields: [productId], references: [id])
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
updatedAt DateTime? @default(dbgenerated("NULL DEFAULT NULL ON UPDATE current_timestamp(3)")) @map("updated_at") @db.Timestamp(3)
@@map("product_resources")
}
model ProductIngradient {
id Int @id @default(autoincrement())
name String
altNames String? @map("alt_names")
genericName String? @map("generic_name")
activeSubstance String? @map("active_substance")
excipients String?
units Unit
amount Decimal @db.Decimal(7, 3)
product Product? @relation(fields: [productId], references: [id])
productId Int? @map("product_id")
@@map("product_ingradients")
}
model ProductAttribute {
id Int @id @default(autoincrement())
lang Lang @default(uk)
key String
text String @db.MediumText
productId Int? @map("product_id")
product Product? @relation(fields: [productId], references: [id])
@@unique([lang, key, productId])
@@map("product_attributes")
}
model ProductToStore {
id Int @id @default(autoincrement())
position Int @default(0)
published Boolean @default(false)
available Boolean @default(false)
price Decimal @default(0) @db.Decimal(7, 2)
pricePromotional Decimal @default(0) @map("price_promotional") @db.Decimal(7, 2)
productId Int @map("product_id")
storeId Int @map("store_id")
product Product @relation(fields: [productId], references: [id])
@@unique([storeId, productId])
@@map("product_to_store")
}
// model Vendor {
// id Int @id @default(autoincrement())
// slug String? @unique @default(uuid()) @db.VarChar(255)
// label String?
// origin String?
// locale VendorLocale[]
// product Product[]
// createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
// updatedAt DateTime? @default(dbgenerated("NULL DEFAULT NULL ON UPDATE current_timestamp(3)")) @map("updated_at") @db.Timestamp(3)
//
// @@map("vendors")
// }
//
// model VendorLocale {
// id Int @id @default(autoincrement())
// vendor Vendor? @relation(fields: [vendorId], references: [id])
// vendorId Int? @map("vendor_id")
// lang Lang @default(uk)
// title String @db.VarChar(384)
// slug String? @db.VarChar(384)
// shortTitle String? @map("short_title")
// description String? @db.Text
// content String? @db.MediumText
// meta Meta? @relation(fields: [metaId], references: [id])
// metaId Int? @map("meta_id")
//
// @@unique([vendorId, slug, lang])
// @@map("vendor_locale")
// }
model FormsOfRelease {
id Int @id @default(autoincrement())
quantity Int @db.UnsignedSmallInt
package Package
additionalInfo String? @map("additional_info")
product Product? @relation(fields: [productId], references: [id], onDelete: Cascade)
productId Int? @map("product_id")
@@map("forms_of_release")
}

View File

@@ -0,0 +1,38 @@
enum Currency {
UAH
}
model Store {
id Int @id @default(autoincrement())
uuid String? @unique @default(uuid()) @db.VarChar(255)
active Boolean? @default(false)
slug String? @unique @db.VarChar(255)
image String? @db.VarChar(512)
currency Currency? @default(UAH)
storeLocale StoreLocale[]
categoriesOnPruducts CategoriesOnProducts[]
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
updatedAt DateTime? @default(dbgenerated("NULL DEFAULT NULL ON UPDATE current_timestamp(3)")) @map("updated_at") @db.Timestamp(3)
@@map("stores")
}
model StoreLocale {
id Int @id @default(autoincrement())
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
storeId Int @map("store_id")
lang Lang @default(uk)
status Boolean? @default(true)
title String @db.VarChar(384)
slug String? @db.VarChar(384)
motto String? @map("motto")
shortTitle String? @map("short_title")
description String? @db.Text
content String? @db.MediumText
extendedData Json? @map("extended_data") @db.Json
meta Meta? @relation(fields: [metaId], references: [id])
metaId Int? @map("meta_id")
@@unique([storeId, slug, lang])
@@map("store_locale")
}

View File

@@ -0,0 +1,92 @@
enum UserRole {
BANNED
FROZEN
OBSERVER
CUSTOMER
AGENT
USER
POWERUSER
EDITOR
ADMIN
MODERATOR
SUPERVISOR
}
model User {
id Int @id @default(autoincrement())
locale Lang @default(uk)
active Boolean?
name String?
username String? @unique
email String? @unique
emailVerified DateTime? @map("email_verified") @db.Timestamp(3)
password String? @db.VarChar(384)
image String?
// sessions Session[]
/// [UserExtendedDataType]
extendedData Json? @map("extended_data") @db.Json
// orders Order[]
favorites UserFavouriteProduct[]
reviews UserProductReview[]
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
updatedAt DateTime? @default(dbgenerated("NULL DEFAULT NULL ON UPDATE current_timestamp(3)")) @map("updated_at") @db.Timestamp(3)
account Account[]
//authenticator Authenticator[]
//session Session[]
@@map("users")
}
// https://stackoverflow.com/questions/72606917/get-custom-attribute-depend-on-pivot-table-in-nest-js-with-prisma-orm
model UserFavouriteProduct {
id Int @id @default(autoincrement())
status Boolean?
informAvailability Boolean? @map("inform_availability")
user User @relation(fields: [userId], references: [id])
userId Int @map("user_id")
productId Int @map("product_id")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
@@unique([userId, productId])
@@map("user_favourite_products")
}
// https://stackoverflow.com/questions/67065859/modeling-a-rating-system-in-prisma
model UserProductReview {
id Int @id @default(autoincrement())
rating Decimal?
body String @db.Text
productId Int @map("product_id")
user User @relation(fields: [userId], references: [id])
userId Int @map("user_id")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
updatedAt DateTime? @default(dbgenerated("NULL DEFAULT NULL ON UPDATE current_timestamp(3)")) @map("updated_at") @db.Timestamp(3)
@@unique([userId, productId])
@@map("user_product_reviews")
}
model Account {
id Int @id @default(autoincrement())
userId Int @unique @map("user_id")
role UserRole @default(CUSTOMER)
active Boolean @default(true)
addess String?
type String
provider String
providerAccountId String @map("provider_account_id")
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
refresh_token_expires_in Int?
user User @relation(fields: [userId], references: [id])
@@unique([provider, providerAccountId])
@@index([userId])
@@map("accounts")
}

View File

@@ -0,0 +1,28 @@
import {Prisma, PrismaClient, Store} from '@prisma/client'
import {DefaultArgs} from '@prisma/client/runtime/library'
import {storeSeed} from '@/lib/db/prisma/seed/store'
export type prismaClientType = PrismaClient<
Prisma.PrismaClientOptions,
never,
DefaultArgs
>
const prisma = new PrismaClient()
async function main() {
console.log('!!!!')
console.log(await storeSeed(prisma))
}
main()
.then(async () => {
await prisma.$disconnect()
})
.catch(async e => {
console.error(e)
await prisma.$disconnect()
process.exit(1)
})

View File

@@ -0,0 +1,55 @@
import {Store} from '@prisma/client'
import {type prismaClientType} from '@/lib/db/prisma/seed/main'
import {slug} from '@/lib/utils'
const STORE_TITLE = 'Be Well'
const STORE_TITLE_RU = STORE_TITLE
export const storeSeed = async function (prisma: prismaClientType) {
const store: Store = await prisma.store.create({
data: {
slug: slug(STORE_TITLE),
active: true,
image: '/images/logo.svg',
storeLocale: {
create: [
{
title: STORE_TITLE,
lang: 'uk',
motto: 'подбай про себе',
slug: slug(STORE_TITLE, 'uk')
// meta: {
// create: {
// keywords: ['харчові добавки', 'вітаміни'],
// openGraph: {
// create: {
// locale: 'uk_UA'
// }
// }
// }
// }
},
{
title: STORE_TITLE_RU,
lang: 'ru',
motto: 'позаботься о себе',
slug: slug(STORE_TITLE_RU, 'ru')
// meta: {
// create: {
// keywords: ['пищевые добавки', 'витамины'],
// openGraph: {
// create: {
// locale: 'ru_UA'
// }
// }
// }
// }
}
]
}
}
})
return store
}

View File

@@ -0,0 +1,24 @@
import {User} from '@prisma/client'
import * as argon2 from 'argon2'
import type {prismaClientType} from '@/lib/db/prisma/seed/main'
import {hashPassword} from '@/lib/utils'
export const superUserSeed = async function (prisma: prismaClientType) {
// const hashedPassword = await hashPassword('b00st!667')
//
// console.log(hashedPassword, await argon2.verify(hashedPassword, 'b00st!667'))
const user: User = await prisma.user.create({
data: {
locale: 'uk',
active: true,
name: 'Yevhen Odynets',
username: 'sv-yeod',
email: 'yevhen.odynets@ugmail.org',
password: 'hashedPassword'
}
})
return user
}

View File

@@ -0,0 +1,14 @@
SELECT u.id,
a.id AS profileId,
IFNULL(a.role, 'CUSTOMER') AS role,
IF(a.id IS NULL, FALSE, TRUE) AS isOauth,
u.name,
u.username,
u.email,
u.image,
a.provider
FROM users u
LEFT JOIN accounts a
ON a.user_id = u.id
WHERE u.id = ?
LIMIT 1;

8
lib/db/redis/client.ts Normal file
View File

@@ -0,0 +1,8 @@
import {createClient} from 'redis'
export const redisClient = await createClient({
url: process.env.REDIS_URL || 'localhost'
})
.on('connect', () => console.error('Connected to redis...'))
.on('error', err => console.error('Redis Client Error', err))
.connect()

1
lib/db/redis/ttl.ts Normal file
View File

@@ -0,0 +1 @@
export const REDIS_USER_TTL = 60

30
lib/module/image.ts Normal file
View File

@@ -0,0 +1,30 @@
import im from 'imagemagick'
import fs from 'node:fs'
const originalImage =
'w:\\share\\work\\sb528990\\data\\ВістаменЛібідо\\IMG_1928_Crop.JPG'
if (!fs.existsSync(originalImage)) {
const sizes = [360, 640, 1024]
for (const i in sizes) {
const size = sizes[i]
const outImage = `w:\\share\\work\\sb528990\\data\\ВістаменЛібідо\\vistamen-libido-feature-${size}x${size}.jpg`
im.resize(
{
srcPath: originalImage,
dstPath: outImage,
quality: 0.84,
format: 'jpg',
width: size,
height: size,
filter: 'sinc',
strip: true
},
function (err: any) {
if (err) throw err
console.log(`resized IMG_1928_Crop.JPG to fit within ${size}x${size}px`)
}
)
}
}

52
lib/permission/index.ts Normal file
View File

@@ -0,0 +1,52 @@
import {Session} from '@auth/core/types'
import {UserRole} from '@prisma/client'
import 'server-only'
//import SUPERVISOR from '@/lib/permission/roles/supervisor'
export interface SessionUser {
id: string
name: string
email: string
image: string | null
isOauth: boolean
provider: string | null
role: UserRole
profileId: number | null
username: string | null
}
export interface SingedInSession extends Session {
user: SessionUser
}
export enum Access {
Admin = 'accessAdmin',
Cabinet = 'accessCabinet'
}
export enum Update {
Category = 'editCategory',
Product = 'editProduct'
}
export enum Delete {
Category = 'deleteCategory',
Product = 'deleteProduct'
}
export type Permission = Access | Delete | Update
export type AllRolesPermissions = {
SUPERVISOR: Permission[]
CUSTOMER: Permission[]
}
export const PERMISSIONS: AllRolesPermissions = {
SUPERVISOR: [
...Object.values(Access),
...Object.values(Update),
...Object.values(Delete)
],
CUSTOMER: [Access.Cabinet]
} as const

View File

@@ -0,0 +1 @@
export {default as SUPERVISOR} from './supervisor'

View File

@@ -0,0 +1,7 @@
import {Access, Delete, Permission, Update} from '@/lib/permission'
export default [
...Object.values(Access),
...Object.values(Update),
...Object.values(Delete)
]

View File

@@ -0,0 +1,26 @@
import {z} from 'zod'
import {db} from '@/lib/db/prisma/client'
export const categoryLocaleSchema = z.object({
lang: z.enum(['uk', 'ru']),
title: z.string().trim().min(1).max(384),
/*.refine(async current => {
const count = await db.categoryLocale.count({
where: {
title: current
}
})
return count === 0
}, "Імя категорії вже існує")*/
// slug: z.string().trim().min(5, {
// message: 'slug_required'
// }),
short_title: z.string().trim().max(384).optional(),
description: z.string().trim().max(3840).optional()
})
export const createCategoryFormSchema = z.object({
locales: z.array(categoryLocaleSchema)
})

View File

@@ -0,0 +1,44 @@
import {z} from 'zod'
import {i18nLocalesCodes} from '@/i18n-config'
import {metaFormSchema} from '@/lib/schemas/meta'
export const productLocaleSchema = z.object({
lang: z.enum(i18nLocalesCodes),
title: z.coerce.string().trim().min(1).max(384),
shortTitle: z.coerce.string().trim().max(191).optional(),
headingTitle: z.coerce.string().trim().max(191).optional(),
description: z.coerce.string().trim(),
content: z.coerce.string().trim().optional(),
instruction: z.coerce.string().trim().optional()
})
export const createProductFormSchema = z.object({
image: z.coerce.string().trim().max(512).optional(),
published: z.coerce.boolean().default(false).optional(), // ProductToStore
price: z.coerce
.string()
.min(2)
.regex(/^\d{1,7}(\.\d{1,2})?$/, {
message: 'Максимум 7 цифр до крапки та 2 після неї'
}), // ProductToStore
pricePromotional: z.coerce
.string()
.regex(/^\d{1,7}(\.\d{1,2})?$/, {
message: 'Максимум 7 цифр до крапки та 2 після неї'
})
.optional(), // ProductToStore
locales: z.array(productLocaleSchema),
meta: z.array(metaFormSchema)
})
//
// files: z
// .array(
// z.object({
// file: z.any(),
// alt: z.string().min(1, {message: 'Значення тегу Alt необхідне'}),
// title: z.string().min(1, {message: 'Значення тегу title необхідне'})
// })
// )
// .nonempty({message: 'Необхідно обрати хоча б один файл'})

40
lib/schemas/index.ts Normal file
View File

@@ -0,0 +1,40 @@
import * as z from 'zod'
export const RegisterSchema = z.object({
email: z.string().email({
message: 'Please enter a valid email address'
}),
name: z.string().min(1, {
message: 'Name is required'
}),
password: z.string().min(6, {
message: 'Password must be at least 6 characters long'
}),
passwordConfirmation: z.string().min(6, {
message: 'Password must be at least 6 characters long'
})
})
export const LoginSchema = z.object({
email: z.string().email({
message: 'Please enter a valid email address'
}),
password: z.string().min(1, {
message: 'Please enter a valid password'
})
})
export const ResetPasswordSchema = z.object({
email: z.string().email({
message: 'Please enter a valid email address'
})
})
export const NewPasswordSchema = z.object({
password: z.string().min(6, {
message: 'Password must be at least 6 characters long'
}),
passwordConfirmation: z.string().min(6, {
message: 'Password must be at least 6 characters long'
})
})

8
lib/schemas/meta.ts Normal file
View File

@@ -0,0 +1,8 @@
import {z} from 'zod'
export const metaFormSchema = z.object({
title: z.string().trim().max(255).optional(),
description: z.string().trim().optional(),
keywords: z.string().trim().max(255).optional(),
author: z.string().trim().max(255).optional()
})

5
lib/styles/jodit.css Normal file
View File

@@ -0,0 +1,5 @@
.jodit-wysiwyg {
h1, h2, h3, h4, h5, h6, p, blockquote, pre {
font-family: inherit;
}
}

144
lib/utils.ts Normal file
View File

@@ -0,0 +1,144 @@
import {Prisma} from '@prisma/client'
import bcrypt from 'bcryptjs'
import {type ClassValue, clsx} from 'clsx'
import slugify from 'slugify'
import {twMerge} from 'tailwind-merge'
/**
* Just output dump using pretty output
*
* @param variable
*/
export function dump(variable: any): [string, string] {
return [
(new Error().stack?.split('\n')[2]?.trim().split(' ')[1] as string) + ' ',
JSON.stringify(variable, null, 2)
]
}
/**
* Create fallback avatar for showing during login process or in case if empty
*
* @param name
*/
export const avatarFallback = (name: string): string =>
name
? name
.split(' ')
.slice(0, 2)
.map(w => w[0])
.join('')
.toUpperCase()
: 'U'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* Slugify the string based on locale using kebab case
*
* @param string
* @param locale
*/
export function slug(string: string, locale: string = 'uk'): string {
return slugify(string, {
lower: true,
strict: true,
locale
})
}
export const formatNumberWithDecimal = (num: number): string => {
const [int, decimal] = num.toString().split('.')
return decimal ? `$(num).${decimal.padEnd(2, '0')}` : int
}
export const toInt = (input: unknown): number | null => {
let output = NaN
if (typeof input === 'string' || typeof input === 'number') {
output = parseInt(input as string, 10)
}
if (isNaN(output)) {
console.log('input invalid type: ', typeof input)
return null
}
return output
}
export const hashPassword = async (value: string): Promise<string> => {
return bcrypt.hash(value, 10)
}
export const verifyHashedPassword = async (
value: string,
hashPassword: string
): Promise<boolean> => {
return await bcrypt.compare(value, hashPassword)
}
/**
* Remove empty properties from the object
*
* @param queryParams
*/
export const cleanEmptyParams = (queryParams: any) => {
return Object.keys(queryParams)
.filter(key => queryParams[key] != '')
.reduce((acc, key) => Object.assign(acc, {[key]: queryParams[key]}), {})
}
/**
* Replace null value with empty string for the object and array of them as well
*
* @param data
*/
export const toEmptyParams = (data: object | object[]) => {
const result = []
const isArray = Array.isArray(data)
const toProcess: object[] = isArray ? data : [data]
for (let x in toProcess) {
const norm = Object.keys(toProcess[x]).reduce(
(obj: {}, key: string) =>
Object.assign(obj, {
// @ts-ignore
[key]: toProcess[x][key] === null ? '' : toProcess[x][key]
}),
{}
)
if (!isArray) return norm
result.push(norm)
}
return result
}
export const dbErrorHandling = (e: unknown, message?: string | null) => {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
return {error: `${e.code}: ${e.message}`}
} else if ((e as {code: string}).code === 'ETIMEDOUT') {
return {
error:
'Неможливо підключитися до бази даних. Будь ласка, спробуйте згодом.'
}
} else if ((e as {code: string}).code === '503') {
return {
error: 'Сервіс тимчасово недоступний. Будь ласка, спробуйте згодом.'
}
} else {
return {error: 'Сталася несподівана помилка. Будь ласка, спробуйте згодом.'}
}
}
/**
* Check if the object is empty
*
* @param obj
*/
export const isEmptyObj = (obj: object): boolean =>
Object.keys(obj).length === 0

65
lib/validator.ts Normal file
View File

@@ -0,0 +1,65 @@
import {z} from 'zod'
import {formatNumberWithDecimal} from '@/lib/utils'
const Price = (field: string) =>
z.coerce
.number()
.refine(
value => /^\d+(.\d{2)?$/.test(formatNumberWithDecimal(value)),
`${field} must have exactly two decimal places (e.g., 42.21)`
)
export const ProductInputSchema = z.object({
name: z.string().min(3, 'Name must be at least 3 characters'),
slug: z.string().min(3, 'Slug must be at least 3 characters'),
category: z.string().min(1, 'Category is required'),
images: z.array(z.string()).min(1, 'Product must have at least one image'),
brand: z.string().min(1, 'Brand is required'),
description: z.string().min(1, 'Description is required'),
isPublished: z.boolean(),
price: Price('Price'),
listPrice: Price('List price'),
countInStock: z.coerce
.number()
.int()
.nonnegative('count in stock must be a non-negative number'),
tags: z.array(z.string()).default([]),
sizes: z.array(z.string()).default([]),
colors: z.array(z.string()).default([]),
avgRating: z.coerce
.number()
.min(0, 'Average rating must be at least 0')
.max(5, 'Average rating must be at most 5'),
numReviews: z.coerce
.number()
.int()
.nonnegative('Number of reviews must be a non-negative number'),
ratingDistribution: z
.array(z.object({rating: z.number(), count: z.number()}))
.max(5),
reviews: z.array(z.string()).default([]),
numSales: z.coerce
.number()
.int()
.nonnegative('Number of sales must be a non-negative number')
})
// export const UserInputSchema = z.object({
// name: UserName,
// email: Email,
// image: z.string().optional(),
// emailVerified: z.boolean(),
// role: UserRole,
// password: Password,
// paymentMethod: z.string().min(1, 'Payment method is required'),
// address: z.object({
// fullName: z.string().min(1, 'Full name is required'),
// street: z.string().min(1, 'Street is required'),
// city: z.string().min(1, 'City is required'),
// province: z.string().min(1, 'Province is required'),
// postalCode: z.string().min(1, 'Postal code is required'),
// country: z.string().min(1, 'Country is required'),
// phone: z.string().min(1, 'Phone number is required')
// })
// })