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({