diff --git a/bun.lock b/bun.lock index d18abe4..9a0da3b 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@payloadcms/db-mongodb": "^3.36.0", "@payloadcms/next": "^3.36.0", "@payloadcms/payload-cloud": "^3.36.0", + "@payloadcms/plugin-multi-tenant": "3.36.0", "@payloadcms/richtext-lexical": "^3.36.0", "@radix-ui/react-accordion": "^1.2.8", "@radix-ui/react-alert-dialog": "^1.1.11", @@ -462,6 +463,8 @@ "@payloadcms/payload-cloud": ["@payloadcms/payload-cloud@3.36.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "^3.614.0", "@aws-sdk/client-s3": "^3.614.0", "@aws-sdk/credential-providers": "^3.614.0", "@aws-sdk/lib-storage": "^3.614.0", "@payloadcms/email-nodemailer": "3.36.0", "amazon-cognito-identity-js": "^6.1.2", "nodemailer": "6.9.16" }, "peerDependencies": { "payload": "3.36.0" } }, "sha512-3S8PVBosNdPxk5FnlphfeqCS9ECDkMK4KTaZgXdoNI184psDH9Mu+QKBmMX2sH4WXxNVqQ5r7BRLNtQQqDvHkw=="], + "@payloadcms/plugin-multi-tenant": ["@payloadcms/plugin-multi-tenant@3.36.0", "", { "peerDependencies": { "@payloadcms/ui": "3.36.0", "next": "^15.2.3", "payload": "3.36.0" } }, "sha512-geX5jHLG5GUF/NVhyySNg0vG9sWVfeEPSSFh1L+YL48YFxRusITKSGXIzBdgNYUcQntQFeyTrGNWhazH8H4zZQ=="], + "@payloadcms/richtext-lexical": ["@payloadcms/richtext-lexical@3.36.0", "", { "dependencies": { "@lexical/headless": "0.28.0", "@lexical/html": "0.28.0", "@lexical/link": "0.28.0", "@lexical/list": "0.28.0", "@lexical/mark": "0.28.0", "@lexical/react": "0.28.0", "@lexical/rich-text": "0.28.0", "@lexical/selection": "0.28.0", "@lexical/table": "0.28.0", "@lexical/utils": "0.28.0", "@payloadcms/translations": "3.36.0", "@payloadcms/ui": "3.36.0", "@types/uuid": "10.0.0", "acorn": "8.12.1", "bson-objectid": "2.0.4", "dequal": "2.0.3", "escape-html": "1.0.3", "jsox": "1.2.121", "lexical": "0.28.0", "mdast-util-from-markdown": "2.0.2", "mdast-util-mdx-jsx": "3.1.3", "micromark-extension-mdx-jsx": "3.0.1", "qs-esm": "7.0.2", "react-error-boundary": "4.1.2", "ts-essentials": "10.0.3", "uuid": "10.0.0" }, "peerDependencies": { "@faceless-ui/modal": "3.0.0-beta.2", "@faceless-ui/scroll-info": "2.0.0", "@payloadcms/next": "3.36.0", "payload": "3.36.0", "react": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020", "react-dom": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020" } }, "sha512-HiT4fE7vENAXeWt8jup8wEYxtvFzx4TjnPjyCssxwbX/96ROlLQheVAq35zPAEYQnFZZMCfmaobpW/lUYgewXQ=="], "@payloadcms/translations": ["@payloadcms/translations@3.36.0", "", { "dependencies": { "date-fns": "4.1.0" } }, "sha512-+TNesB9N85OAm6Cwt2wnQcrwhMv9z8S7vQYSWMyB/nS3V9cWHpsKShCTh9bLoIN2MjGTZ5QLw2Wx6p5kuvNX0w=="], diff --git a/package.json b/package.json index e52a360..48c88da 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,15 @@ "lint": "next lint", "generate:types": "payload generate:types", "db:fresh": "payload migrate:fresh", - "db:seed": "bun run src/seed.ts" + "db:seed": "bun run src/seed.ts", + "db:reset": "bun run db:fresh && bun run db:seed" }, "dependencies": { "@hookform/resolvers": "^5.0.1", "@payloadcms/db-mongodb": "^3.36.0", "@payloadcms/next": "^3.36.0", "@payloadcms/payload-cloud": "^3.36.0", + "@payloadcms/plugin-multi-tenant": "3.36.0", "@payloadcms/richtext-lexical": "^3.36.0", "@radix-ui/react-accordion": "^1.2.8", "@radix-ui/react-alert-dialog": "^1.1.11", diff --git a/src/app/(app)/(home)/[category]/[subcategory]/page.tsx b/src/app/(app)/(home)/[category]/[subcategory]/page.tsx index 765d2bb..1e2ae60 100644 --- a/src/app/(app)/(home)/[category]/[subcategory]/page.tsx +++ b/src/app/(app)/(home)/[category]/[subcategory]/page.tsx @@ -4,6 +4,7 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query'; import { getQueryClient, trpc } from '@/trpc/server'; import { loadProductFilters } from '@/modules/products/search-params'; import { ProductListView } from '@/modules/products/ui/views/product-list-view'; +import { DEFAULT_LIMIT } from '@/constants'; interface Props { params: Promise<{ @@ -17,10 +18,11 @@ const Page = async ({ params, searchParams }: Props) => { const queryClient = getQueryClient(); const filters = await loadProductFilters(searchParams); - void queryClient.prefetchQuery( - trpc.products.getMany.queryOptions({ - category: subcategory, + void queryClient.prefetchInfiniteQuery( + trpc.products.getMany.infiniteQueryOptions({ ...filters, + category: subcategory, + limit: DEFAULT_LIMIT, }) ); diff --git a/src/app/(app)/(home)/[category]/page.tsx b/src/app/(app)/(home)/[category]/page.tsx index d4fedb2..3a57624 100644 --- a/src/app/(app)/(home)/[category]/page.tsx +++ b/src/app/(app)/(home)/[category]/page.tsx @@ -4,6 +4,7 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query'; import { getQueryClient, trpc } from '@/trpc/server'; import { loadProductFilters } from '@/modules/products/search-params'; import { ProductListView } from '@/modules/products/ui/views/product-list-view'; +import { DEFAULT_LIMIT } from '@/constants'; interface Props { params: Promise<{ @@ -17,10 +18,11 @@ const Page = async ({ params, searchParams }: Props) => { const queryClient = getQueryClient(); const filters = await loadProductFilters(searchParams); - void queryClient.prefetchQuery( - trpc.products.getMany.queryOptions({ - category, + void queryClient.prefetchInfiniteQuery( + trpc.products.getMany.infiniteQueryOptions({ ...filters, + category, + limit: DEFAULT_LIMIT, }) ); diff --git a/src/app/(app)/(home)/page.tsx b/src/app/(app)/(home)/page.tsx index c68f1c1..e12f19e 100644 --- a/src/app/(app)/(home)/page.tsx +++ b/src/app/(app)/(home)/page.tsx @@ -1,12 +1,31 @@ -'use client'; +import type { SearchParams } from 'nuqs/server'; +import { dehydrate, HydrationBoundary } from '@tanstack/react-query'; -import { useQuery } from "@tanstack/react-query"; +import { getQueryClient, trpc } from '@/trpc/server'; +import { loadProductFilters } from '@/modules/products/search-params'; +import { ProductListView } from '@/modules/products/ui/views/product-list-view'; +import { DEFAULT_LIMIT } from '@/constants'; -import { useTRPC } from "@/trpc/client"; - -export default function Home() { - const trpc = useTRPC(); - const { data } = useQuery(trpc.auth.session.queryOptions()); - - return
{JSON.stringify(data?.user, null, 2)}
; +interface Props { + searchParams: Promise; } + +const Page = async ({ searchParams }: Props) => { + const filters = await loadProductFilters(searchParams); + const queryClient = getQueryClient(); + + void queryClient.prefetchInfiniteQuery( + trpc.products.getMany.infiniteQueryOptions({ + ...filters, + limit: DEFAULT_LIMIT, + }) + ); + + return ( + + + + ); +}; + +export default Page; diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index a3bc0e8..a17baac 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -1,5 +1,9 @@ - +import { TenantField as TenantField_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client' +import { TenantSelector as TenantSelector_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client' +import { TenantSelectionProvider as TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc' export const importMap = { - + "@payloadcms/plugin-multi-tenant/client#TenantField": TenantField_1d0591e3cf4f332c83a86da13a0de59a, + "@payloadcms/plugin-multi-tenant/client#TenantSelector": TenantSelector_1d0591e3cf4f332c83a86da13a0de59a, + "@payloadcms/plugin-multi-tenant/rsc#TenantSelectionProvider": TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62 } diff --git a/src/collections/Tenants.ts b/src/collections/Tenants.ts new file mode 100644 index 0000000..55a041f --- /dev/null +++ b/src/collections/Tenants.ts @@ -0,0 +1,50 @@ +import { CollectionConfig } from "payload"; + +export const Tenants: CollectionConfig = { + slug: 'tenants', + admin: { + useAsTitle: 'slug', + }, + fields: [ + { + name: 'name', + type: 'text', + required: true, + label: 'Store Name', + admin: { + description: "This is the name of the store (e.g. Nezumi's Store)", + } + }, + { + name: 'slug', + type: 'text', + index: true, + required: true, + unique: true, + admin: { + description: "This is the subdomain for the store (e.g. [slug].funroad.com)", + } + }, + { + name: 'image', + type: 'upload', + relationTo: 'media', + }, + { + name: 'stripeAccountId', + type: 'text', + required: true, + admin: { + readOnly: true, + } + }, + { + name: 'stripeDetailsSubmitted', + type: 'checkbox', + admin: { + readOnly: true, + description: 'You cannot create products until you submit your Stripe details', + } + } + ] +} \ No newline at end of file diff --git a/src/collections/Users.ts b/src/collections/Users.ts index 3d9a6cb..f23ae3a 100644 --- a/src/collections/Users.ts +++ b/src/collections/Users.ts @@ -1,4 +1,21 @@ -import type { CollectionConfig } from 'payload' +import type { CollectionConfig } from 'payload'; +import { tenantsArrayField } from '@payloadcms/plugin-multi-tenant/fields'; + +const defaultTenantArrayField = tenantsArrayField({ + tenantsArrayFieldName: 'tenants', + tenantsCollectionSlug: 'tenants', + tenantsArrayTenantFieldName: 'tenant', + arrayFieldAccess: { + read: () => true, + create: () => true, + update: () => true, + }, + tenantFieldAccess: { + read: () => true, + create: () => true, + update: () => true, + } +}) export const Users: CollectionConfig = { slug: 'users', @@ -12,6 +29,23 @@ export const Users: CollectionConfig = { required: true, unique: true, type: 'text' + }, + { + admin: { + position: 'sidebar', + }, + name: 'roles', + type: 'select', + defaultValue: ['user'], + hasMany: true, + options: ['super-admin', 'user'], + }, + { + ...defaultTenantArrayField, + admin: { + ...(defaultTenantArrayField?.admin || {}), + position: 'sidebar', + } } ], } diff --git a/src/modules/auth/server/procedures.ts b/src/modules/auth/server/procedures.ts index 6be13cf..3d47ec2 100644 --- a/src/modules/auth/server/procedures.ts +++ b/src/modules/auth/server/procedures.ts @@ -34,13 +34,27 @@ export const authRouter = createTRPCRouter({ }) } + const tenant = await ctx.db.create({ + collection: 'tenants', + data: { + name: input.username, + slug: input.username, + stripeAccountId: 'test', + } + }) + await ctx.db.create({ collection: 'users', data: { email: input.email, username: input.username, password: input.password, - } + tenants: [ + { + tenant: tenant.id, + }, + ], + }, }) const data = await ctx.db.login({ diff --git a/src/modules/home/ui/components/search-filters/category-dropdown.tsx b/src/modules/home/ui/components/search-filters/category-dropdown.tsx index 0c4477a..02a39fc 100644 --- a/src/modules/home/ui/components/search-filters/category-dropdown.tsx +++ b/src/modules/home/ui/components/search-filters/category-dropdown.tsx @@ -1,14 +1,14 @@ 'use client'; import { useRef, useState } from 'react'; +import Link from 'next/link'; import { Button } from '@/components/ui/button'; -import { cn } from '@/lib/utils'; -import useDropdownPosition from './use-dropdown-position'; +import { cn } from '@/lib/utils' +import { CategoriesGetManyOutput } from '@/modules/categories/type'; +; import SubCategoryMenu from './subcategory-menu'; -import Link from 'next/link'; -import { CategoriesGetManyOutput } from '@/modules/categories/type'; interface CategoryDropdownProps { category: CategoriesGetManyOutput[1]; @@ -23,8 +23,6 @@ const CategoryDropdown = ({ }: CategoryDropdownProps) => { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); - const { getDropdownPosition } = useDropdownPosition(dropdownRef); - const dropdownPosition = getDropdownPosition(); // TODO: Will improve in mobile screen // const toggleDropdown = () => { @@ -77,7 +75,6 @@ const CategoryDropdown = ({ ); diff --git a/src/modules/home/ui/components/search-filters/subcategory-menu.tsx b/src/modules/home/ui/components/search-filters/subcategory-menu.tsx index e62d47c..c1c7bed 100644 --- a/src/modules/home/ui/components/search-filters/subcategory-menu.tsx +++ b/src/modules/home/ui/components/search-filters/subcategory-menu.tsx @@ -6,17 +6,9 @@ import { CategoriesGetManyOutput } from '@/modules/categories/type'; interface SubCategoryMenuProps { category: CategoriesGetManyOutput[1]; isOpen: boolean; - position: { - top: number; - left: number; - }; } -const SubCategoryMenu = ({ - category, - isOpen, - position, -}: SubCategoryMenuProps) => { +const SubCategoryMenu = ({ category, isOpen }: SubCategoryMenuProps) => { if ( !isOpen || !category.subcategories || @@ -29,10 +21,10 @@ const SubCategoryMenu = ({ return (
{/* Invisible bridge to maintain hover */} diff --git a/src/payload-types.ts b/src/payload-types.ts index a994e82..0c91324 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -72,6 +72,7 @@ export interface Config { categories: Category; products: Product; tags: Tag; + tenants: Tenant; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; 'payload-migrations': PayloadMigration; @@ -87,6 +88,7 @@ export interface Config { categories: CategoriesSelect | CategoriesSelect; products: ProductsSelect | ProductsSelect; tags: TagsSelect | TagsSelect; + tenants: TenantsSelect | TenantsSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; @@ -130,6 +132,13 @@ export interface UserAuthOperations { export interface User { id: string; username: string; + roles?: ('super-admin' | 'user')[] | null; + tenants?: + | { + tenant: string | Tenant; + id?: string | null; + }[] + | null; updatedAt: string; createdAt: string; email: string; @@ -141,6 +150,29 @@ export interface User { lockUntil?: string | null; password?: string | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "tenants". + */ +export interface Tenant { + id: string; + /** + * This is the name of the store (e.g. Nezumi's Store) + */ + name: string; + /** + * This is the subdomain for the store (e.g. [slug].funroad.com) + */ + slug: string; + image?: (string | null) | Media; + stripeAccountId: string; + /** + * You cannot create products until you submit your Stripe details + */ + stripeDetailsSubmitted?: boolean | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "media". @@ -184,6 +216,7 @@ export interface Category { */ export interface Product { id: string; + tenant?: (string | null) | Tenant; name: string; description?: string | null; /** @@ -234,6 +267,10 @@ export interface PayloadLockedDocument { | ({ relationTo: 'tags'; value: string | Tag; + } | null) + | ({ + relationTo: 'tenants'; + value: string | Tenant; } | null); globalSlug?: string | null; user: { @@ -283,6 +320,13 @@ export interface PayloadMigration { */ export interface UsersSelect { username?: T; + roles?: T; + tenants?: + | T + | { + tenant?: T; + id?: T; + }; updatedAt?: T; createdAt?: T; email?: T; @@ -329,6 +373,7 @@ export interface CategoriesSelect { * via the `definition` "products_select". */ export interface ProductsSelect { + tenant?: T; name?: T; description?: T; price?: T; @@ -349,6 +394,19 @@ export interface TagsSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "tenants_select". + */ +export interface TenantsSelect { + name?: T; + slug?: T; + image?: T; + stripeAccountId?: T; + stripeDetailsSubmitted?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents_select". diff --git a/src/payload.config.ts b/src/payload.config.ts index cbe2bd0..29f8c26 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -6,12 +6,14 @@ import path from 'path' import { buildConfig } from 'payload' import { fileURLToPath } from 'url' import sharp from 'sharp' +import { multiTenantPlugin } from '@payloadcms/plugin-multi-tenant' import { Users } from './collections/Users'; import { Media } from './collections/Media' import { Categories } from './collections/Categories' import { Products } from './collections/Products' import { Tags } from './collections/Tags' +import { Tenants } from './collections/Tenants' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -24,7 +26,7 @@ export default buildConfig({ }, }, cookiePrefix: 'funroad', - collections: [Users, Media, Categories, Products, Tags], + collections: [Users, Media, Categories, Products, Tags, Tenants], editor: lexicalEditor(), secret: process.env.PAYLOAD_SECRET || '', typescript: { @@ -36,6 +38,15 @@ export default buildConfig({ sharp, plugins: [ payloadCloudPlugin(), + multiTenantPlugin({ + collections: { + products: {}, + }, + tenantsArrayField: { + includeDefaultField: false, + }, + userHasAccessToAllTenants: (user) => Boolean(user?.roles?.includes('super-admin')), + }) // storage-adapter-placeholder ], }) diff --git a/src/seed.ts b/src/seed.ts index 2c26582..d2a4e31 100644 --- a/src/seed.ts +++ b/src/seed.ts @@ -140,6 +140,30 @@ const categories = [ const seed = async () => { const payload = await getPayload({ config }); + const adminTenant = await payload.create({ + collection: 'tenants', + data: { + name: 'admin', + slug: 'admin', + stripeAccountId: 'admin', + } + }) + + await payload.create({ + collection: 'users', + data: { + email: 'admin@nezumi.pw', + password: 'demo', + roles: ['super-admin'], + username: 'admin', + tenants: [ + { + tenant: adminTenant.id, + } + ] + } + }) + for (const category of categories) { const { name, slug, color } = category; const categoryDoc = await payload.create({