Merge remote-tracking branch 'origin/main' into cloudflare

This commit is contained in:
javayhu 2025-05-11 22:19:58 +08:00
commit 9b68e3095e
61 changed files with 1600 additions and 794 deletions

View File

@ -1,6 +1,6 @@
---
description: Best practices for using Vercel AI SDK
globs: **/*.{ts,tsx}
globs: *.tsx,*.ts
alwaysApply: false
---

View File

@ -0,0 +1,43 @@
---
description:
globs: *.tsx,*.ts
alwaysApply: false
---
# Database and State Management Guide
## Database (Drizzle ORM)
- Schema definitions in `src/db/schema.ts`
- Migrations in `drizzle/`
- Use `db:generate` to create new migration files based on schema changes
- Use `db:migrate` to apply pending migrations to the database
- Use `db:push` to sync schema changes directly to the database (development only)
- Use `db:studio` to view and manage database data through the Drizzle Studio UI
- Follow naming conventions for tables and columns
- Use proper data types and constraints
- Implement proper indexes
- Handle relationships properly
- Use transactions when needed
## State Management (Zustand)
- Store definitions in `src/stores/`
- Keep stores modular and focused
- Use TypeScript for store types
- Implement proper state updates
- Handle async operations properly
- Use selectors for derived state
- Implement proper error handling
- Use middleware when needed
- Keep store logic pure
- Document complex state logic
## Data Flow
1. Server-side data fetching in server components
2. Client-side state in Zustand stores
3. Form state in React Hook Form
4. API calls through server actions
5. Database operations through Drizzle
6. File storage through AWS S3
7. Proper error handling at each layer
8. Type safety throughout
9. Proper validation with Zod
10. Proper caching strategies

View File

@ -1,10 +1,10 @@
---
description: Best practices for date and time manipulation with date-fns
globs: **/*.{ts,tsx,js,jsx}
globs: *.ts,*.tsx
alwaysApply: false
---
- Use the `format` function for consistent date formatting across your application.
- Implement proper timezone handling using the `utcToZonedTime` function.
- Utilize the `intervalToDuration` function for calculating time differences.
- Leverage the `isWithinInterval` function for date range checks.
- Leverage the `isWithinInterval` function for date range checks.

View File

@ -0,0 +1,39 @@
---
description:
globs: *.tsx,*.ts
alwaysApply: false
---
# Development Workflow Guide
## Available Scripts
- `pnpm dev`: Start development server with content collections
- `pnpm build`: Build the application and content collections
- `pnpm start`: Start production server
- `pnpm lint`: Run Biome linter
- `pnpm format`: Format code with Biome
- `pnpm db:generate`: Generate new migration files based on schema changes
- `pnpm db:migrate`: Apply pending migrations to the database
- `pnpm db:push`: Sync schema changes directly to the database (development only)
- `pnpm db:studio`: Open Drizzle Studio for database inspection and management
- `pnpm email`: Start email template development server
## Development Process
1. Use TypeScript for all new code
2. Follow Biome formatting rules
3. Write server actions in `src/actions/`
4. Use Zustand for client-side state
5. Implement database changes through Drizzle migrations
6. Use Radix UI components for consistent UI
7. Follow the established directory structure
8. Write tests for new features
9. Update content collections when adding new content
10. Use environment variables from `env.example`
## Code Style
- Use functional components with hooks
- Implement proper error handling
- Follow TypeScript best practices
- Use proper type definitions
- Document complex logic
- Keep components small and focused
- Use proper naming conventions

View File

@ -1,6 +1,7 @@
---
description: Best practices for using Drizzle ORM with database
globs: **/*.{ts}
globs: *.tsx,*.ts
alwaysApply: false
---
- Use Drizzle's type-safe query builder for better code completion and safety.

View File

@ -1,6 +1,6 @@
---
description: Best practices for Next.js applications and routing
globs: **/*.{ts,tsx}
globs: *.tsx,*.ts
alwaysApply: false
---

View File

@ -0,0 +1,36 @@
---
description:
globs: **/*.{ts,tsx}
alwaysApply: false
---
# Project Structure Guide
## Core Directories
- `src/app/`: Next.js app router pages and layouts
- `src/components/`: Reusable React components
- `src/lib/`: Utility functions and shared code
- `src/db/`: Database schema and migrations using Drizzle ORM
- `src/stores/`: Zustand state management
- `src/actions/`: Server actions and API routes
- `src/hooks/`: Custom React hooks
- `src/types/`: TypeScript type definitions
- `src/i18n/`: Internationalization setup
- `src/mail/`: Email templates and mail functionality
- `src/payment/`: Payment integration
- `src/analytics/`: Analytics and tracking
- `src/storage/`: File storage integration
## Configuration Files
- `next.config.ts`: Next.js configuration
- `drizzle.config.ts`: Database configuration
- `biome.json`: Code formatting and linting rules
- `tsconfig.json`: TypeScript configuration
- `components.json`: UI components configuration
## Content Management
- `content/`: MDX content files
- `content-collections.ts`: Content collection configuration
## Environment
- `env.example`: Environment variables template
- `global.d.ts`: Global TypeScript declarations

View File

@ -1,6 +1,6 @@
---
description: Best practices for using Radix UI components
globs: **/*.{ts,tsx}
globs: *.tsx,*.ts
alwaysApply: false
---

View File

@ -1,6 +1,6 @@
---
description: Best practices for React component development
globs: **/*.{ts,tsx,js,jsx}
globs: *.tsx,*.ts
alwaysApply: false
---

View File

@ -1,10 +1,10 @@
---
description: Best practices for form handling with React Hook Form
globs: **/*.{ts,tsx}
globs: *.tsx,*.ts
alwaysApply: false
---
- Use the `useForm` hook for efficient form state management.
- Implement validation using Zod with `@hookform/resolvers` for type-safe form validation.
- Utilize the `Controller` component for integrating with custom inputs.
- Leverage the `useFormContext` hook for sharing form state across components.
- Leverage the `useFormContext` hook for sharing form state across components.

View File

@ -1,6 +1,6 @@
---
description: Best practices for integrating Stripe payments
globs: **/*.{ts,tsx}
globs: *.tsx,*.ts
alwaysApply: false
---

View File

@ -1,6 +1,6 @@
---
description: Best practices for styling with Tailwind CSS
globs: **/*.{ts,tsx,css}
globs: *.tsx,*.ts
alwaysApply: false
---

View File

@ -1,6 +1,6 @@
---
description: TypeScript coding standards and type safety guidelines
globs: **/*.{ts,tsx}
globs: *.tsx,*.ts
alwaysApply: false
---
@ -11,4 +11,4 @@ alwaysApply: false
- Use strict null checks to prevent null and undefined errors
- Implement proper type inference using generics for reusable components.
- Utilize type guards and assertions for runtime type checking.
- Use `pnpm` as default package manager if run Command in Terminal.
- Use `pnpm` as default package manager if run Command in Terminal.

View File

@ -0,0 +1,54 @@
---
description:
globs: **/*.{ts,tsx}
alwaysApply: false
---
# UI and Components Guide
## Component Structure
- Components in `src/components/`
- Follow atomic design principles
- Use Radix UI primitives
- Implement proper accessibility
- Use Tailwind CSS for styling
- Follow consistent naming
- Keep components focused
- Implement proper error states
- Handle loading states
- Use proper TypeScript types
## UI Libraries
- Radix UI for primitives
- Tailwind CSS for styling
- Framer Motion for animations
- React Hook Form for forms
- Zod for validation
- Lucide React for icons
- Tabler Icons for additional icons
- Sonner for toasts
- Vaul for drawers
- Embla Carousel for carousels
## Styling Guidelines
- Use Tailwind CSS classes
- Follow design system tokens
- Implement dark mode support
- Use proper spacing scale
- Follow color palette
- Implement responsive design
- Use proper typography
- Handle hover/focus states
- Implement proper transitions
- Use proper z-index scale
## Accessibility
- Use semantic HTML
- Implement proper ARIA labels
- Handle keyboard navigation
- Support screen readers
- Use proper color contrast
- Implement focus management
- Handle dynamic content
- Support reduced motion
- Test with assistive tools
- Follow WCAG guidelines

View File

@ -1,6 +1,7 @@
---
description: Best practices for schema validation with Zod
globs: **/*.{ts,tsx}
globs: *.tsx,*.ts
alwaysApply: false
---
- Define clear and reusable schemas for data validation

View File

@ -1,10 +1,10 @@
---
description: Best practices for state management with Zustand
globs: **/*.{ts,tsx}
globs: *.tsx,*.ts
alwaysApply: false
---
- Use the `create` function to define your store for simplicity and performance.
- Implement middleware like `persist` for persisting state across sessions.
- Utilize the `useStore` hook for accessing store state in components.
- Leverage the `immer` middleware for easier state updates with mutable syntax.
- Leverage the `immer` middleware for easier state updates with mutable syntax.

View File

@ -244,6 +244,10 @@ will replace the `/docs` page with your `page.tsx`.
</Accordions>
## Video Tutorials
<YoutubeVideo url="https://www.youtube.com/embed/BPnK-YbISHQ?si=TH_tI3e4MCgMHzGr" />
## Learn More
New to here? Don't worry, we are welcome for your questions.

View File

@ -244,6 +244,10 @@ export const source = loader({
</Accordions>
## 视频教程
<YoutubeVideo url="https://www.youtube.com/embed/BPnK-YbISHQ?si=TH_tI3e4MCgMHzGr" />
## 了解更多
刚来这里?别担心,我们欢迎您的问题。
刚来这里?别担心,我们欢迎您的问题。

View File

@ -337,6 +337,8 @@ Images are automatically optimized for `next/image`.
![Image](/image.png)
```
![Image](/images/docs/notebook.png)
## Optional
Some optional plugins you can enable.

View File

@ -246,7 +246,7 @@ console.log('Hello World');
```
````
### 高亮行
### 高亮行
````md
```tsx
@ -335,6 +335,8 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
![Image](/image.png)
```
![Image](/images/docs/notebook.png)
## 可选功能
一些您可以启用的可选插件。

View File

@ -76,7 +76,15 @@ NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY=""
NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=""
# -----------------------------------------------------------------------------
# Analytics
# Configurations
# -----------------------------------------------------------------------------
# Disable image optimization, check out next.config.ts for more details
DISABLE_IMAGE_OPTIMIZATION="false"
# Run this website as demo website, in most cases, you should set this to false
NEXT_PUBLIC_DEMO_WEBSITE="false"
# -----------------------------------------------------------------------------
# Analytics
# https://mksaas.com/docs/analytics#setup
# -----------------------------------------------------------------------------
# Google Analytics (https://analytics.google.com)
@ -101,6 +109,11 @@ NEXT_PUBLIC_OPENPANEL_CLIENT_ID=""
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=""
NEXT_PUBLIC_PLAUSIBLE_SCRIPT="https://plausible.io/js/script.js"
# -----------------------------------------------------------------------------
# Ahrefs Analytics (https://ahrefs.com)
# https://mksaas.com/docs/analytics#ahrefs
# -----------------------------------------------------------------------------
NEXT_PUBLIC_AHREFS_WEBSITE_ID=""
# -----------------------------------------------------------------------------
# Seline Analytics
# https://mksaas.com/docs/analytics#seline
# -----------------------------------------------------------------------------

View File

@ -440,7 +440,57 @@
"admin": {
"title": "Admin",
"users": {
"title": "Users"
"title": "Users",
"fakeData": "Note: Faked data for demonstration, some features are disabled",
"error": "Failed to get users",
"search": "Search users...",
"columns": {
"columns": "Columns",
"name": "Name",
"email": "Email",
"role": "Role",
"createdAt": "Created At",
"customerId": "Customer ID",
"status": "Status",
"banReason": "Ban Reason",
"banExpires": "Ban Expires"
},
"noResults": "No results",
"firstPage": "First Page",
"lastPage": "Last Page",
"nextPage": "Next Page",
"previousPage": "Previous Page",
"rowsPerPage": "Rows per page",
"page": "Page",
"loading": "Loading...",
"admin": "Admin",
"user": "User",
"email": {
"verified": "Email Verified",
"unverified": "Email Unverified"
},
"emailCopied": "Email copied to clipboard",
"banned": "Banned",
"active": "Active",
"joined": "Joined",
"updated": "Updated",
"ban": {
"reason": "Ban Reason",
"reasonPlaceholder": "Enter the reason for banning this user",
"defaultReason": "Spamming",
"never": "Never",
"expires": "Ban Expires",
"selectDate": "Select Date",
"button": "Ban User",
"success": "User has been banned",
"error": "Failed to ban user"
},
"unban": {
"button": "Unban User",
"success": "User has been unbanned",
"error": "Failed to unban user"
},
"close": "Close"
}
},
"settings": {

View File

@ -441,7 +441,57 @@
"admin": {
"title": "系统管理",
"users": {
"title": "用户管理"
"title": "用户管理",
"fakeData": "注:只为演示功能,数据为假数据,封禁功能不可用",
"error": "获取用户失败",
"search": "搜索用户...",
"columns": {
"columns": "显示列",
"name": "姓名",
"email": "邮箱",
"role": "角色",
"createdAt": "创建时间",
"customerId": "客户ID",
"status": "状态",
"banReason": "封禁原因",
"banExpires": "封禁到期时间"
},
"noResults": "没有结果",
"firstPage": "第一页",
"lastPage": "最后一页",
"nextPage": "下一页",
"previousPage": "上一页",
"rowsPerPage": "每页行数",
"page": "页",
"loading": "加载中...",
"admin": "管理员",
"user": "用户",
"email": {
"verified": "邮箱已验证",
"unverified": "邮箱未验证"
},
"emailCopied": "邮箱已复制到剪贴板",
"banned": "账号被封禁",
"active": "账号正常",
"joined": "加入时间",
"updated": "更新时间",
"ban": {
"reason": "封禁原因",
"reasonPlaceholder": "请输入封禁该用户的原因",
"defaultReason": "垃圾信息",
"never": "永不解禁",
"expires": "封禁到期时间",
"selectDate": "选择日期",
"button": "封禁用户",
"success": "用户已被封禁",
"error": "封禁用户失败"
},
"unban": {
"button": "解除封禁",
"success": "用户已被解除封禁",
"error": "解除封禁失败"
},
"close": "关闭"
}
},
"settings": {

View File

@ -16,6 +16,10 @@ const nextConfig: NextConfig = {
},
images: {
// https://vercel.com/docs/image-optimization/managing-image-optimization-costs#minimizing-image-optimization-costs
// https://nextjs.org/docs/app/api-reference/components/image#unoptimized
// vercel has limits on image optimization, 1000 images per month
unoptimized: process.env.DISABLE_IMAGE_OPTIMIZATION === 'true',
remotePatterns: [
{
protocol: 'https',

View File

@ -102,7 +102,7 @@
"next-themes": "^0.4.4",
"postgres": "^3.4.5",
"react": "^19.0.0",
"react-day-picker": "9.6.3",
"react-day-picker": "8.10.1",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-remove-scroll": "^2.6.3",

26
pnpm-lock.yaml generated
View File

@ -252,8 +252,8 @@ importers:
specifier: ^19.0.0
version: 19.0.0
react-day-picker:
specifier: 9.6.3
version: 9.6.3(react@19.0.0)
specifier: 8.10.1
version: 8.10.1(date-fns@4.1.0)(react@19.0.0)
react-dom:
specifier: ^19.0.0
version: 19.0.0(react@19.0.0)
@ -1114,9 +1114,6 @@ packages:
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@date-fns/tz@1.2.0':
resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
@ -4578,9 +4575,6 @@ packages:
data-uri-to-buffer@2.0.2:
resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==}
date-fns-jalali@4.1.0-0:
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
@ -6194,11 +6188,11 @@ packages:
resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==}
engines: {node: '>= 0.8'}
react-day-picker@9.6.3:
resolution: {integrity: sha512-rDqCSKAl5MLX0z1fLkYcBenQK4ANlYaAhUR0ruVSVAhAa7/ZmKQqgDpXPoS7bYEkgBRH06LO1qNFP1Ki8uiZpw==}
engines: {node: '>=18'}
react-day-picker@8.10.1:
resolution: {integrity: sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==}
peerDependencies:
react: '>=16.8.0'
date-fns: ^2.28.0 || ^3.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom@19.0.0:
resolution: {integrity: sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==}
@ -8616,8 +8610,6 @@ snapshots:
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@date-fns/tz@1.2.0': {}
'@dnd-kit/accessibility@3.1.1(react@19.0.0)':
dependencies:
react: 19.0.0
@ -11888,8 +11880,6 @@ snapshots:
data-uri-to-buffer@2.0.2: {}
date-fns-jalali@4.1.0-0: {}
date-fns@4.1.0: {}
debounce@2.0.0: {}
@ -13930,11 +13920,9 @@ snapshots:
iconv-lite: 0.6.3
unpipe: 1.0.0
react-day-picker@9.6.3(react@19.0.0):
react-day-picker@8.10.1(date-fns@4.1.0)(react@19.0.0):
dependencies:
'@date-fns/tz': 1.2.0
date-fns: 4.1.0
date-fns-jalali: 4.1.0-0
react: 19.0.0
react-dom@19.0.0(react@19.0.0):

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

85
src/actions/get-users.ts Normal file
View File

@ -0,0 +1,85 @@
'use server';
import db from '@/db';
import { user } from '@/db/schema';
import { asc, desc, ilike, or, sql } from 'drizzle-orm';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Define the schema for getUsers parameters
const getUsersSchema = z.object({
pageIndex: z.number().min(0).default(0),
pageSize: z.number().min(1).max(100).default(10),
search: z.string().optional().default(''),
sorting: z
.array(
z.object({
id: z.string(),
desc: z.boolean(),
})
)
.optional()
.default([]),
});
// Define sort field mapping
const sortFieldMap = {
name: user.name,
email: user.email,
createdAt: user.createdAt,
role: user.role,
banned: user.banned,
customerId: user.customerId,
banReason: user.banReason,
banExpires: user.banExpires,
} as const;
// Create a safe action for getting users
export const getUsersAction = actionClient
.schema(getUsersSchema)
.action(async ({ parsedInput }) => {
try {
const { pageIndex, pageSize, search, sorting } = parsedInput;
const where = search
? or(ilike(user.name, `%${search}%`), ilike(user.email, `%${search}%`))
: undefined;
const offset = pageIndex * pageSize;
// Get the sort configuration
const sortConfig = sorting[0];
const sortField = sortConfig?.id
? sortFieldMap[sortConfig.id as keyof typeof sortFieldMap]
: user.createdAt;
const sortDirection = sortConfig?.desc ? desc : asc;
const [items, [{ count }]] = await Promise.all([
db
.select()
.from(user)
.where(where)
.orderBy(sortDirection(sortField))
.limit(pageSize)
.offset(offset),
db.select({ count: sql`count(*)` }).from(user).where(where),
]);
return {
success: true,
data: {
items,
total: Number(count),
},
};
} catch (error) {
console.error('get users error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch users',
};
}
});

View File

@ -0,0 +1,29 @@
'use client';
import Script from 'next/script';
/**
* Ahrefs Analytics
*
* https://ahrefs.com/
* https://mksaas.com/docs/analytics#ahrefs
*/
export function AhrefsAnalytics() {
if (process.env.NODE_ENV !== 'production') {
return null;
}
const websiteId = process.env.NEXT_PUBLIC_AHREFS_WEBSITE_ID as string;
if (!websiteId) {
return null;
}
return (
<Script
async
type="text/javascript"
data-key={websiteId}
src="https://analytics.ahrefs.com/analytics.js"
/>
);
}

View File

@ -1,12 +1,13 @@
import { websiteConfig } from '@/config/website';
import { Analytics as VercelAnalytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/next';
import { AhrefsAnalytics } from './ahrefs-analytics';
import DataFastAnalytics from './data-fast-analytics';
import GoogleAnalytics from './google-analytics';
import OpenPanelAnalytics from './open-panel-analytics';
import { PlausibleAnalytics } from './plausible-analytics';
import { SelineAnalytics } from './seline-analytics';
import { UmamiAnalytics } from './umami-analytics';
import { Analytics as VercelAnalytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/next';
/**
* Analytics Components all in one
@ -33,6 +34,9 @@ export function Analytics() {
{/* plausible analytics */}
<PlausibleAnalytics />
{/* ahrefs analytics */}
<AhrefsAnalytics />
{/* datafast analytics */}
<DataFastAnalytics />

View File

@ -6,6 +6,7 @@ import Script from 'next/script';
* DataFast Analytics
*
* https://datafa.st
* https://mksaas.com/docs/analytics#datafast
*/
export default function DataFastAnalytics() {
if (process.env.NODE_ENV !== 'production') {

View File

@ -6,6 +6,7 @@ import { GoogleAnalytics as NextGoogleAnalytics } from '@next/third-parties/goog
* Google Analytics
*
* https://analytics.google.com
* https://mksaas.com/docs/analytics#google
* https://nextjs.org/docs/app/building-your-application/optimizing/third-party-libraries#google-analytics
*/
export default function GoogleAnalytics() {

View File

@ -3,6 +3,8 @@ import { OpenPanelComponent } from '@openpanel/nextjs';
/**
* OpenPanel Analytics (https://openpanel.dev)
*
* https://openpanel.dev
* https://mksaas.com/docs/analytics#openpanel
* https://docs.openpanel.dev/docs/sdks/nextjs#options
*/
export default function OpenPanelAnalytics() {

View File

@ -10,6 +10,7 @@ import Script from 'next/script';
* you do not need to add new script to this component.
*
* https://plausible.io
* https://mksaas.com/docs/analytics#plausible
*/
export function PlausibleAnalytics() {
if (process.env.NODE_ENV !== 'production') {

View File

@ -6,6 +6,7 @@ import Script from 'next/script';
* Seline Analytics
*
* https://seline.com
* https://mksaas.com/docs/analytics#seline
* https://seline.com/docs/install-seline
* https://seline.com/docs/stripe
*/

View File

@ -6,6 +6,7 @@ import Script from 'next/script';
* Umami Analytics
*
* https://umami.is
* https://mksaas.com/docs/analytics#umami
*/
export function UmamiAnalytics() {
if (process.env.NODE_ENV !== 'production') {

View File

@ -166,10 +166,7 @@ export default async function BlogPostPage(props: NextPageProps) {
{/* in order to make the mdx.css work, we need to add the className prose to the div */}
{/* https://github.com/tailwindlabs/tailwindcss-typography */}
<div className="mt-8 max-w-none prose prose-neutral dark:prose-invert prose-img:rounded-lg">
<CustomMDXContent
code={post.body}
includeFumadocsComponents={true}
/>
<CustomMDXContent code={post.body} />
</div>
<div className="flex items-center justify-start my-16">

View File

@ -1,614 +0,0 @@
[
{
"id": 1,
"header": "Cover page",
"type": "Cover page",
"status": "In Process",
"target": "18",
"limit": "5",
"reviewer": "Eddie Lake"
},
{
"id": 2,
"header": "Table of contents",
"type": "Table of contents",
"status": "Done",
"target": "29",
"limit": "24",
"reviewer": "Eddie Lake"
},
{
"id": 3,
"header": "Executive summary",
"type": "Narrative",
"status": "Done",
"target": "10",
"limit": "13",
"reviewer": "Eddie Lake"
},
{
"id": 4,
"header": "Technical approach",
"type": "Narrative",
"status": "Done",
"target": "27",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 5,
"header": "Design",
"type": "Narrative",
"status": "In Process",
"target": "2",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 6,
"header": "Capabilities",
"type": "Narrative",
"status": "In Process",
"target": "20",
"limit": "8",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 7,
"header": "Integration with existing systems",
"type": "Narrative",
"status": "In Process",
"target": "19",
"limit": "21",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 8,
"header": "Innovation and Advantages",
"type": "Narrative",
"status": "Done",
"target": "25",
"limit": "26",
"reviewer": "Assign reviewer"
},
{
"id": 9,
"header": "Overview of EMR's Innovative Solutions",
"type": "Technical content",
"status": "Done",
"target": "7",
"limit": "23",
"reviewer": "Assign reviewer"
},
{
"id": 10,
"header": "Advanced Algorithms and Machine Learning",
"type": "Narrative",
"status": "Done",
"target": "30",
"limit": "28",
"reviewer": "Assign reviewer"
},
{
"id": 11,
"header": "Adaptive Communication Protocols",
"type": "Narrative",
"status": "Done",
"target": "9",
"limit": "31",
"reviewer": "Assign reviewer"
},
{
"id": 12,
"header": "Advantages Over Current Technologies",
"type": "Narrative",
"status": "Done",
"target": "12",
"limit": "0",
"reviewer": "Assign reviewer"
},
{
"id": 13,
"header": "Past Performance",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "33",
"reviewer": "Assign reviewer"
},
{
"id": 14,
"header": "Customer Feedback and Satisfaction Levels",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "34",
"reviewer": "Assign reviewer"
},
{
"id": 15,
"header": "Implementation Challenges and Solutions",
"type": "Narrative",
"status": "Done",
"target": "3",
"limit": "35",
"reviewer": "Assign reviewer"
},
{
"id": 16,
"header": "Security Measures and Data Protection Policies",
"type": "Narrative",
"status": "In Process",
"target": "6",
"limit": "36",
"reviewer": "Assign reviewer"
},
{
"id": 17,
"header": "Scalability and Future Proofing",
"type": "Narrative",
"status": "Done",
"target": "4",
"limit": "37",
"reviewer": "Assign reviewer"
},
{
"id": 18,
"header": "Cost-Benefit Analysis",
"type": "Plain language",
"status": "Done",
"target": "14",
"limit": "38",
"reviewer": "Assign reviewer"
},
{
"id": 19,
"header": "User Training and Onboarding Experience",
"type": "Narrative",
"status": "Done",
"target": "17",
"limit": "39",
"reviewer": "Assign reviewer"
},
{
"id": 20,
"header": "Future Development Roadmap",
"type": "Narrative",
"status": "Done",
"target": "11",
"limit": "40",
"reviewer": "Assign reviewer"
},
{
"id": 21,
"header": "System Architecture Overview",
"type": "Technical content",
"status": "In Process",
"target": "24",
"limit": "18",
"reviewer": "Maya Johnson"
},
{
"id": 22,
"header": "Risk Management Plan",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "22",
"reviewer": "Carlos Rodriguez"
},
{
"id": 23,
"header": "Compliance Documentation",
"type": "Legal",
"status": "In Process",
"target": "31",
"limit": "27",
"reviewer": "Sarah Chen"
},
{
"id": 24,
"header": "API Documentation",
"type": "Technical content",
"status": "Done",
"target": "8",
"limit": "12",
"reviewer": "Raj Patel"
},
{
"id": 25,
"header": "User Interface Mockups",
"type": "Visual",
"status": "In Process",
"target": "19",
"limit": "25",
"reviewer": "Leila Ahmadi"
},
{
"id": 26,
"header": "Database Schema",
"type": "Technical content",
"status": "Done",
"target": "22",
"limit": "20",
"reviewer": "Thomas Wilson"
},
{
"id": 27,
"header": "Testing Methodology",
"type": "Technical content",
"status": "In Process",
"target": "17",
"limit": "14",
"reviewer": "Assign reviewer"
},
{
"id": 28,
"header": "Deployment Strategy",
"type": "Narrative",
"status": "Done",
"target": "26",
"limit": "30",
"reviewer": "Eddie Lake"
},
{
"id": 29,
"header": "Budget Breakdown",
"type": "Financial",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 30,
"header": "Market Analysis",
"type": "Research",
"status": "Done",
"target": "29",
"limit": "32",
"reviewer": "Sophia Martinez"
},
{
"id": 31,
"header": "Competitor Comparison",
"type": "Research",
"status": "In Process",
"target": "21",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 32,
"header": "Maintenance Plan",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "23",
"reviewer": "Alex Thompson"
},
{
"id": 33,
"header": "User Personas",
"type": "Research",
"status": "In Process",
"target": "27",
"limit": "24",
"reviewer": "Nina Patel"
},
{
"id": 34,
"header": "Accessibility Compliance",
"type": "Legal",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 35,
"header": "Performance Metrics",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "David Kim"
},
{
"id": 36,
"header": "Disaster Recovery Plan",
"type": "Technical content",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 37,
"header": "Third-party Integrations",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Eddie Lake"
},
{
"id": 38,
"header": "User Feedback Summary",
"type": "Research",
"status": "Done",
"target": "20",
"limit": "15",
"reviewer": "Assign reviewer"
},
{
"id": 39,
"header": "Localization Strategy",
"type": "Narrative",
"status": "In Process",
"target": "12",
"limit": "19",
"reviewer": "Maria Garcia"
},
{
"id": 40,
"header": "Mobile Compatibility",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "James Wilson"
},
{
"id": 41,
"header": "Data Migration Plan",
"type": "Technical content",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Assign reviewer"
},
{
"id": 42,
"header": "Quality Assurance Protocols",
"type": "Technical content",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Priya Singh"
},
{
"id": 43,
"header": "Stakeholder Analysis",
"type": "Research",
"status": "In Process",
"target": "11",
"limit": "14",
"reviewer": "Eddie Lake"
},
{
"id": 44,
"header": "Environmental Impact Assessment",
"type": "Research",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Assign reviewer"
},
{
"id": 45,
"header": "Intellectual Property Rights",
"type": "Legal",
"status": "In Process",
"target": "17",
"limit": "20",
"reviewer": "Sarah Johnson"
},
{
"id": 46,
"header": "Customer Support Framework",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 47,
"header": "Version Control Strategy",
"type": "Technical content",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 48,
"header": "Continuous Integration Pipeline",
"type": "Technical content",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Michael Chen"
},
{
"id": 49,
"header": "Regulatory Compliance",
"type": "Legal",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Assign reviewer"
},
{
"id": 50,
"header": "User Authentication System",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "Eddie Lake"
},
{
"id": 51,
"header": "Data Analytics Framework",
"type": "Technical content",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 52,
"header": "Cloud Infrastructure",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 53,
"header": "Network Security Measures",
"type": "Technical content",
"status": "In Process",
"target": "29",
"limit": "32",
"reviewer": "Lisa Wong"
},
{
"id": 54,
"header": "Project Timeline",
"type": "Planning",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Eddie Lake"
},
{
"id": 55,
"header": "Resource Allocation",
"type": "Planning",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Assign reviewer"
},
{
"id": 56,
"header": "Team Structure and Roles",
"type": "Planning",
"status": "Done",
"target": "20",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 57,
"header": "Communication Protocols",
"type": "Planning",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 58,
"header": "Success Metrics",
"type": "Planning",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Eddie Lake"
},
{
"id": 59,
"header": "Internationalization Support",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 60,
"header": "Backup and Recovery Procedures",
"type": "Technical content",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 61,
"header": "Monitoring and Alerting System",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Daniel Park"
},
{
"id": 62,
"header": "Code Review Guidelines",
"type": "Technical content",
"status": "Done",
"target": "12",
"limit": "15",
"reviewer": "Eddie Lake"
},
{
"id": 63,
"header": "Documentation Standards",
"type": "Technical content",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 64,
"header": "Release Management Process",
"type": "Planning",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Assign reviewer"
},
{
"id": 65,
"header": "Feature Prioritization Matrix",
"type": "Planning",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Emma Davis"
},
{
"id": 66,
"header": "Technical Debt Assessment",
"type": "Technical content",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Eddie Lake"
},
{
"id": 67,
"header": "Capacity Planning",
"type": "Planning",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 68,
"header": "Service Level Agreements",
"type": "Legal",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Assign reviewer"
}
]

View File

@ -0,0 +1,35 @@
import { DashboardHeader } from '@/components/dashboard/dashboard-header';
import { getTranslations } from 'next-intl/server';
interface UsersLayoutProps {
children: React.ReactNode;
}
export default async function UsersLayout({ children }: UsersLayoutProps) {
const t = await getTranslations('Dashboard.admin');
const breadcrumbs = [
{
label: t('title'),
isCurrentPage: false,
},
{
label: t('users.title'),
isCurrentPage: true,
},
];
return (
<>
<DashboardHeader breadcrumbs={breadcrumbs} />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
{children}
</div>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,5 @@
import { Loader2Icon } from 'lucide-react';
export default function Loading() {
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
}

View File

@ -1,46 +1,11 @@
import { ChartAreaInteractive } from '@/components/dashboard/chart-area-interactive';
import { DashboardHeader } from '@/components/dashboard/dashboard-header';
import { DataTable } from '@/components/dashboard/data-table';
import { SectionCards } from '@/components/dashboard/section-cards';
import { useTranslations } from 'next-intl';
import data from './data.json';
import { UsersPageClient } from '@/components/admin/users-page';
/**
* Admin users page
* Users page
*
* NOTICE: This is a demo page for the admin, no real data is used,
* we will show real data in the future
* This page is used to manage users for the admin,
* it is protected and only accessible to the admin role
*/
export default function AdminUsersPage() {
const t = useTranslations();
const breadcrumbs = [
{
label: t('Dashboard.admin.title'),
isCurrentPage: false,
},
{
label: t('Dashboard.admin.users.title'),
isCurrentPage: true,
},
];
return (
<>
<DashboardHeader breadcrumbs={breadcrumbs} />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<SectionCards />
<div className="px-4 lg:px-6">
<ChartAreaInteractive />
</div>
<DataTable data={data} />
</div>
</div>
</div>
</>
);
export default function UsersPage() {
return <UsersPageClient />;
}

View File

@ -136,7 +136,6 @@ export default async function DocPage({ params }: DocPageProps) {
);
},
}}
includeFumadocsComponents={true}
/>
</DocsBody>
</DocsPage>

View File

@ -0,0 +1,298 @@
import { UserAvatar } from '@/components/layout/user-avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from '@/components/ui/drawer';
import { Label } from '@/components/ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { useIsMobile } from '@/hooks/use-mobile';
import { authClient } from '@/lib/auth-client';
import type { User } from '@/lib/auth-types';
import { formatDate } from '@/lib/formatter';
import { cn } from '@/lib/utils';
import { useUsersStore } from '@/stores/users-store';
import {
CalendarIcon,
Loader2Icon,
MailCheckIcon,
MailQuestionIcon,
UserRoundCheckIcon,
UserRoundXIcon,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { toast } from 'sonner';
interface UserDetailViewerProps {
user: User;
}
export function UserDetailViewer({ user }: UserDetailViewerProps) {
const t = useTranslations('Dashboard.admin.users');
const isMobile = useIsMobile();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | undefined>();
const [banReason, setBanReason] = useState(t('ban.defaultReason'));
const [banExpiresAt, setBanExpiresAt] = useState<Date | undefined>();
const triggerRefresh = useUsersStore((state) => state.triggerRefresh);
// show fake data in demo website
const isDemo = process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true';
const handleBan = async () => {
if (!banReason) {
setError(t('ban.error'));
return;
}
if (!user.id) {
setError('User ID is required');
return;
}
setIsLoading(true);
setError('');
try {
await authClient.admin.banUser({
userId: user.id,
banReason,
banExpiresIn: banExpiresAt
? Math.floor((banExpiresAt.getTime() - Date.now()) / 1000)
: undefined,
});
toast.success(t('ban.success'));
// Reset form
setBanReason('');
setBanExpiresAt(undefined);
// Trigger refresh
triggerRefresh();
} catch (err) {
const error = err as Error;
console.error('Failed to ban user:', error);
setError(error.message || t('ban.error'));
toast.error(error.message || t('ban.error'));
} finally {
setIsLoading(false);
}
};
const handleUnban = async () => {
if (!user.id) {
setError('User ID is required');
return;
}
setIsLoading(true);
setError('');
try {
await authClient.admin.unbanUser({
userId: user.id,
});
toast.success(t('unban.success'));
// Trigger refresh
triggerRefresh();
} catch (err) {
const error = err as Error;
console.error('Failed to unban user:', error);
setError(error.message || t('unban.error'));
toast.error(error.message || t('unban.error'));
} finally {
setIsLoading(false);
}
};
return (
<Drawer direction={isMobile ? 'bottom' : 'right'}>
<DrawerTrigger asChild>
<Button
variant="link"
className="cursor-pointer text-foreground w-fit px-0 text-left"
>
<div className="flex items-center gap-2 pl-3">
<UserAvatar
name={user.name}
image={user.image}
className="size-8 border"
/>
<span className="hover:underline hover:underline-offset-4">
{isDemo ? 'MkSaaS User' : user.name}
</span>
</div>
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="gap-1">
<div className="flex items-center gap-4">
<UserAvatar
name={user.name}
image={user.image}
className="size-12 border"
/>
<div>
<DrawerTitle>{isDemo ? 'MkSaaS User' : user.name}</DrawerTitle>
<DrawerDescription>
{isDemo ? 'example@mksaas.com' : user.email}
</DrawerDescription>
</div>
</div>
</DrawerHeader>
<div className="flex flex-col gap-4 overflow-y-auto px-4 text-sm">
<div className="grid gap-4">
<div className="flex items-center gap-2">
{/* role */}
<Badge
variant={user.role === 'admin' ? 'default' : 'outline'}
className="px-1.5"
>
{user.role === 'admin' ? t('admin') : t('user')}
</Badge>
{/* email verified */}
<Badge variant="outline" className="px-1.5 hover:bg-accent">
{user.emailVerified ? (
<MailCheckIcon className="stroke-green-500 dark:stroke-green-400" />
) : (
<MailQuestionIcon className="stroke-red-500 dark:stroke-red-400" />
)}
{user.emailVerified
? t('email.verified')
: t('email.unverified')}
</Badge>
{/* user banned */}
<div className="flex items-center gap-2">
<Badge variant="outline" className="px-1.5 hover:bg-accent">
{user.banned ? (
<UserRoundXIcon className="stroke-red-500 dark:stroke-red-400" />
) : (
<UserRoundCheckIcon className="stroke-green-500 dark:stroke-green-400" />
)}
{user.banned ? t('banned') : t('active')}
</Badge>
</div>
</div>
{/* information */}
<div className="text-muted-foreground">
{t('joined')}: {formatDate(user.createdAt)}
</div>
<div className="text-muted-foreground">
{t('updated')}: {formatDate(user.updatedAt)}
</div>
</div>
<Separator />
{/* error */}
{error && <div className="text-sm text-destructive">{error}</div>}
{/* ban or unban user */}
{user.banned ? (
<div className="grid gap-4">
<div className="">
{t('ban.reason')}: {user.banReason}
</div>
<div className="">
{t('ban.expires')}:{' '}
{(user.banExpires && formatDate(user.banExpires)) ||
t('ban.never')}
</div>
<Button
variant="destructive"
onClick={handleUnban}
disabled={isLoading || isDemo}
className="mt-4 cursor-pointer"
>
{isLoading && (
<Loader2Icon className="mr-2 size-4 animate-spin" />
)}
{t('unban.button')}
</Button>
</div>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
handleBan();
}}
className="grid gap-4"
>
<div className="grid gap-2">
<Label htmlFor="ban-reason">{t('ban.reason')}</Label>
<Textarea
id="ban-reason"
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
placeholder={t('ban.reasonPlaceholder')}
required
/>
</div>
<div className="grid gap-2">
<Label>{t('ban.expires')}</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
'justify-start text-left font-normal cursor-pointer',
!banExpiresAt && 'text-muted-foreground'
)}
>
<CalendarIcon />
{banExpiresAt ? (
formatDate(banExpiresAt)
) : (
<span>{t('ban.selectDate')}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={banExpiresAt}
onSelect={setBanExpiresAt}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<Button
type="submit"
variant="destructive"
disabled={isLoading || !banReason || isDemo}
className="mt-4 cursor-pointer"
>
{isLoading && (
<Loader2Icon className="mr-2 size-4 animate-spin" />
)}
{t('ban.button')}
</Button>
</form>
)}
</div>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline">{t('close')}</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}

View File

@ -0,0 +1,72 @@
'use client';
import { getUsersAction } from '@/actions/get-users';
import { UsersTable } from '@/components/admin/users-table';
import type { User } from '@/lib/auth-types';
import { useUsersStore } from '@/stores/users-store';
import type { SortingState } from '@tanstack/react-table';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
export function UsersPageClient() {
const t = useTranslations('Dashboard.admin.users');
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState('');
const [data, setData] = useState<User[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]);
const refreshTrigger = useUsersStore((state) => state.refreshTrigger);
useEffect(() => {
const fetchUsers = async () => {
try {
setLoading(true);
const result = await getUsersAction({
pageIndex,
pageSize,
search,
sorting,
});
if (result?.data?.success) {
setData(result.data.data?.items || []);
setTotal(result.data.data?.total || 0);
} else {
const errorMessage = result?.data?.error || t('error');
toast.error(errorMessage);
setData([]);
setTotal(0);
}
} catch (error) {
console.error('Failed to fetch users:', error);
toast.error(t('error'));
setData([]);
setTotal(0);
} finally {
setLoading(false);
}
};
fetchUsers();
}, [pageIndex, pageSize, search, sorting, refreshTrigger]);
return (
<>
<UsersTable
data={data}
total={total}
pageIndex={pageIndex}
pageSize={pageSize}
search={search}
loading={loading}
onSearch={setSearch}
onPageChange={setPageIndex}
onPageSizeChange={setPageSize}
onSortingChange={setSorting}
/>
</>
);
}

View File

@ -0,0 +1,520 @@
'use client';
import { UserDetailViewer } from '@/components/admin/user-detail-viewer';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { User } from '@/lib/auth-types';
import { formatDate } from '@/lib/formatter';
import { getStripeDashboardCustomerUrl } from '@/lib/urls/urls';
import {
type ColumnDef,
type ColumnFiltersState,
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import {
ArrowUpDownIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
MailCheckIcon,
MailQuestionIcon,
UserRoundCheckIcon,
UserRoundXIcon,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { toast } from 'sonner';
import { Badge } from '../ui/badge';
import { Label } from '../ui/label';
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
column: any;
title: string;
}
function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className={className}>{title}</div>;
}
return (
<div className={className}>
<Button
variant="ghost"
className="cursor-pointer flex items-center gap-2"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
>
{title}
<ArrowUpDownIcon className="h-4 w-4" />
</Button>
</div>
);
}
interface UsersTableProps {
data: User[];
total: number;
pageIndex: number;
pageSize: number;
search: string;
loading?: boolean;
onSearch: (search: string) => void;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
onSortingChange?: (sorting: SortingState) => void;
}
/**
* https://ui.shadcn.com/docs/components/data-table
*/
export function UsersTable({
data,
total,
pageIndex,
pageSize,
search,
loading,
onSearch,
onPageChange,
onPageSizeChange,
onSortingChange,
}: UsersTableProps) {
const t = useTranslations('Dashboard.admin.users');
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
// show fake data in demo website
const isDemo = process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true';
// Map column IDs to translation keys
const columnIdToTranslationKey = {
name: 'columns.name' as const,
email: 'columns.email' as const,
role: 'columns.role' as const,
createdAt: 'columns.createdAt' as const,
customerId: 'columns.customerId' as const,
banned: 'columns.status' as const,
banReason: 'columns.banReason' as const,
banExpires: 'columns.banExpires' as const,
} as const;
// Table columns definition
const columns: ColumnDef<User>[] = [
{
accessorKey: 'name',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('columns.name')} />
),
cell: ({ row }) => {
const user = row.original;
return <UserDetailViewer user={user} />;
},
},
{
accessorKey: 'email',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('columns.email')} />
),
cell: ({ row }) => {
const user = row.original;
return (
<div className="flex items-center gap-2 pl-3">
<Badge
variant="outline"
className="text-sm px-1.5 cursor-pointer hover:bg-accent"
onClick={() => {
navigator.clipboard.writeText(user.email);
toast.success(t('emailCopied'));
}}
>
{user.emailVerified ? (
<MailCheckIcon className="stroke-green-500 dark:stroke-green-400" />
) : (
<MailQuestionIcon className="stroke-red-500 dark:stroke-red-400" />
)}
{isDemo ? 'example@mksaas.com' : user.email}
</Badge>
</div>
);
},
},
{
accessorKey: 'role',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('columns.role')} />
),
cell: ({ row }) => {
const user = row.original;
const role = user.role || 'user';
return (
<div className="flex items-center gap-2 pl-3">
<Badge
variant={role === 'admin' ? 'default' : 'outline'}
className="px-1.5"
>
{role === 'admin' ? t('admin') : t('user')}
</Badge>
</div>
);
},
},
{
accessorKey: 'createdAt',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('columns.createdAt')} />
),
cell: ({ row }) => {
const user = row.original;
return (
<div className="flex items-center gap-2 pl-3">
{formatDate(user.createdAt)}
</div>
);
},
},
{
accessorKey: 'customerId',
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t('columns.customerId')}
/>
),
cell: ({ row }) => {
const user = row.original;
return (
<div className="flex items-center gap-2 pl-3">
{user.customerId ? (
<a
href={getStripeDashboardCustomerUrl(user.customerId)}
target="_blank"
rel="noopener noreferrer"
className="hover:underline hover:underline-offset-4"
>
{!isDemo ? user.customerId : 'cus_abcdef123456'}
</a>
) : (
'-'
)}
</div>
);
},
},
{
accessorKey: 'banned',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('columns.status')} />
),
cell: ({ row }) => {
const user = row.original;
return (
<div className="flex items-center gap-2 pl-3">
<Badge variant="outline" className="px-1.5 hover:bg-accent">
{user.banned ? (
<UserRoundXIcon className="stroke-red-500 dark:stroke-red-400" />
) : (
<UserRoundCheckIcon className="stroke-green-500 dark:stroke-green-400" />
)}
{user.banned ? t('banned') : t('active')}
</Badge>
</div>
);
},
},
{
accessorKey: 'banReason',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('columns.banReason')} />
),
cell: ({ row }) => {
const user = row.original;
return (
<div className="flex items-center gap-2 pl-3">
{user.banReason || '-'}
</div>
);
},
},
{
accessorKey: 'banExpires',
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t('columns.banExpires')}
/>
),
cell: ({ row }) => {
const user = row.original;
return (
<div className="flex items-center gap-2 pl-3">
{user.banExpires ? formatDate(user.banExpires) : '-'}
</div>
);
},
},
];
const table = useReactTable({
data,
columns,
pageCount: Math.ceil(total / pageSize),
state: {
sorting,
columnFilters,
columnVisibility,
pagination: { pageIndex, pageSize },
},
onSortingChange: (updater) => {
const next = typeof updater === 'function' ? updater(sorting) : updater;
setSorting(next);
onSortingChange?.(next);
},
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: (updater) => {
const next =
typeof updater === 'function'
? updater({ pageIndex, pageSize })
: updater;
if (next.pageIndex !== pageIndex) onPageChange(next.pageIndex);
if (next.pageSize !== pageSize) onPageSizeChange(next.pageSize);
},
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
manualPagination: true,
manualSorting: true,
});
return (
<div className="w-full flex-col justify-start gap-6 space-y-4">
<div className="flex items-center justify-between px-4 lg:px-6 gap-4">
<div className="flex flex-1 items-center gap-4">
<Input
placeholder={t('search')}
value={search}
onChange={(event) => {
onSearch(event.target.value);
onPageChange(0);
}}
className="max-w-sm"
/>
{isDemo && (
<span className="text-sm text-primary">{t('fakeData')}</span>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="cursor-pointer">
{/* <IconLayoutColumns /> */}
<span className="inline">{t('columns.columns')}</span>
<ChevronDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize cursor-pointer"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{t(
columnIdToTranslationKey[
column.id as keyof typeof columnIdToTranslationKey
] || 'columns.columns'
)}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6">
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader className="bg-muted sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{t('loading')}
</TableCell>
</TableRow>
) : table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-4">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{t('noResults')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between px-4">
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
{/* empty here for now */}
</div>
<div className="flex w-full items-center gap-8 lg:w-fit">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
{t('rowsPerPage')}
</Label>
<Select
value={`${pageSize}`}
onValueChange={(value) => {
onPageSizeChange(Number(value));
onPageChange(0);
}}
>
<SelectTrigger
size="sm"
className="w-20 cursor-pointer"
id="rows-per-page"
>
<SelectValue placeholder={pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
{t('page')} {pageIndex + 1} {' / '}
{Math.max(1, Math.ceil(total / pageSize))}
</div>
<div className="ml-auto flex items-center gap-2 lg:ml-0">
<Button
variant="outline"
className="cursor-pointer hidden h-8 w-8 p-0 lg:flex"
onClick={() => onPageChange(0)}
disabled={pageIndex === 0}
>
<span className="sr-only">{t('firstPage')}</span>
<ChevronsLeftIcon />
</Button>
<Button
variant="outline"
className="cursor-pointer size-8"
size="icon"
onClick={() => onPageChange(pageIndex - 1)}
disabled={pageIndex === 0}
>
<span className="sr-only">{t('previousPage')}</span>
<ChevronLeftIcon />
</Button>
<Button
variant="outline"
className="cursor-pointer size-8"
size="icon"
onClick={() => onPageChange(pageIndex + 1)}
disabled={pageIndex + 1 >= Math.ceil(total / pageSize)}
>
<span className="sr-only">{t('nextPage')}</span>
<ChevronRightIcon />
</Button>
<Button
variant="outline"
className="cursor-pointer hidden size-8 lg:flex"
size="icon"
onClick={() =>
onPageChange(Math.max(0, Math.ceil(total / pageSize) - 1))
}
disabled={pageIndex + 1 >= Math.ceil(total / pageSize)}
>
<span className="sr-only">{t('lastPage')}</span>
<ChevronsRightIcon />
</Button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -10,72 +10,79 @@ export default function LogoCloudSection() {
<div className="mx-auto mt-20 flex max-w-4xl flex-wrap items-center justify-center gap-x-12 gap-y-8 sm:gap-x-16 sm:gap-y-12">
<img
className="h-4 w-fit dark:invert"
src="/svg/nextjs_logo_light.svg"
alt="Nextjs Logo"
className="h-5 w-fit dark:invert"
src="https://html.tailus.io/blocks/customers/nvidia.svg"
alt="Nvidia Logo"
height="20"
width="auto"
/>
<img
className="h-4 w-fit dark:invert"
src="/svg/tailwindcss.svg"
alt="Tailwind CSS Logo"
src="https://html.tailus.io/blocks/customers/column.svg"
alt="Column Logo"
height="16"
width="auto"
/>
<img
className="h-6 w-fit dark:invert"
src="/svg/resend-wordmark-black.svg"
alt="Resend Logo"
height="28"
width="auto"
/>
<img
className="h-5 w-fit dark:invert"
src="/svg/vercel.svg"
alt="Vercel Logo"
height="20"
width="auto"
/>
<img
className="h-4 w-fit dark:invert"
src="/svg/github.svg"
src="https://html.tailus.io/blocks/customers/github.svg"
alt="GitHub Logo"
height="16"
width="auto"
/>
<img
className="h-5 w-fit dark:invert"
src="/svg/cursor_wordmark_light.svg"
alt="Cursor Logo"
src="https://html.tailus.io/blocks/customers/nike.svg"
alt="Nike Logo"
height="20"
width="auto"
/>
<img
className="h-5 w-fit dark:invert"
src="/svg/lemonsqueezy.svg"
alt="Lemon Squeezy Logo"
className="h-4 w-fit dark:invert"
src="https://html.tailus.io/blocks/customers/laravel.svg"
alt="Laravel Logo"
height="16"
width="auto"
/>
<img
className="h-7 w-fit dark:invert"
src="https://html.tailus.io/blocks/customers/lilly.svg"
alt="Lilly Logo"
height="28"
width="auto"
/>
<img
className="h-5 w-fit dark:invert"
src="https://html.tailus.io/blocks/customers/lemonsqueezy.svg"
alt="Lemon Squeezy Logo"
height="20"
width="auto"
/>
<img
className="h-6 w-fit dark:invert"
src="/svg/openai.svg"
src="https://html.tailus.io/blocks/customers/openai.svg"
alt="OpenAI Logo"
height="24"
width="auto"
/>
<img
className="h-4 w-fit dark:invert"
src="/svg/zapier.svg"
alt="Zapier Logo"
src="https://html.tailus.io/blocks/customers/tailwindcss.svg"
alt="Tailwind CSS Logo"
height="16"
width="auto"
/>
<img
className="h-5 w-fit dark:invert"
src="https://html.tailus.io/blocks/customers/vercel.svg"
alt="Vercel Logo"
height="20"
width="auto"
/>
<img
className="h-4 w-fit dark:invert"
src="/svg/nvidia.svg"
alt="NVIDIA Logo"
className="h-5 w-fit dark:invert"
src="https://html.tailus.io/blocks/customers/zapier.svg"
alt="Zapier Logo"
height="20"
width="auto"
/>

View File

@ -0,0 +1,28 @@
import { ImageZoom } from 'fumadocs-ui/components/image-zoom';
import type { ComponentProps, FC } from 'react';
interface ImageWrapperProps extends ComponentProps<'img'> {
src: string;
alt?: string;
}
export const ImageWrapper = ({ src, alt }: ImageWrapperProps) => {
if (!src) {
return null;
}
return (
<ImageZoom
src={src}
alt={alt || 'image'}
width={1400}
height={787}
style={{
width: '100%',
height: 'auto',
objectFit: 'contain',
}}
priority
/>
);
};

View File

@ -0,0 +1,39 @@
interface YoutubeVideoProps {
url: string;
width?: number;
height?: number;
}
/**
* YoutubeVideo component
*
* How to get the URL of the YouTube video?
* 1. Go to the YouTube video you want to embed
* 2. Click on the share button and copy the embed URL
* 3. Paste the URL into the url prop
*
* @param {string} url - The URL of the YouTube video
* @param {number} width - The width of the video
* @param {number} height - The height of the video
*/
export const YoutubeVideo = ({
url,
width = 560,
height = 315,
}: YoutubeVideoProps) => {
return (
<div className="my-4">
<iframe
width={width}
height={height}
src={url}
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
className="w-full aspect-video"
/>
</div>
);
};

View File

@ -0,0 +1,22 @@
import React from 'react';
import type { SVGProps } from 'react';
/**
* https://icon-sets.iconify.design/fa6-brands/telegram/
*/
export function TelegramIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={496}
height={512}
viewBox="0 0 496 512"
{...props}
>
<path
fill="currentColor"
d="M248 8C111.033 8 0 119.033 0 256s111.033 248 248 248s248-111.033 248-248S384.967 8 248 8m114.952 168.66c-3.732 39.215-19.881 134.378-28.1 178.3c-3.476 18.584-10.322 24.816-16.948 25.425c-14.4 1.326-25.338-9.517-39.287-18.661c-21.827-14.308-34.158-23.215-55.346-37.177c-24.485-16.135-8.612-25 5.342-39.5c3.652-3.793 67.107-61.51 68.335-66.746c.153-.655.3-3.1-1.154-4.384s-3.59-.849-5.135-.5q-3.283.746-104.608 69.142q-14.845 10.194-26.894 9.934c-8.855-.191-25.888-5.006-38.551-9.123c-15.531-5.048-27.875-7.717-26.8-16.291q.84-6.7 18.45-13.7q108.446-47.248 144.628-62.3c68.872-28.647 83.183-33.623 92.511-33.789c2.052-.034 6.639.474 9.61 2.885a10.45 10.45 0 0 1 3.53 6.716a43.8 43.8 0 0 1 .417 9.769"
/>
</svg>
);
}

View File

@ -27,7 +27,7 @@ export function ModeSwitcherHorizontal() {
if (!mounted) {
return (
<div className="flex items-center gap-2 rounded-full border bg-background p-1">
<div className="flex items-center gap-2 rounded-full border p-1">
<div className="size-6 px-0 rounded-full" />
<div className="size-6 px-0 rounded-full" />
<div className="size-6 px-0 rounded-full" />
@ -36,7 +36,7 @@ export function ModeSwitcherHorizontal() {
}
return (
<div className="flex items-center gap-2 rounded-full border bg-background p-1">
<div className="flex items-center gap-2 rounded-full border p-1">
<Button
variant="ghost"
size="icon"

View File

@ -38,10 +38,7 @@ export function CustomPage({
<Card className="mb-8">
<CardContent>
<div className="max-w-none prose prose-neutral dark:prose-invert prose-img:rounded-lg">
<CustomMDXContent
code={content}
includeFumadocsComponents={false}
/>
<CustomMDXContent code={content} />
</div>
</CardContent>
</Card>

View File

@ -41,7 +41,7 @@ export function ReleaseCard({
</CardHeader>
<CardContent>
<div className="max-w-none prose prose-neutral dark:prose-invert prose-img:rounded-lg">
<CustomMDXContent code={content} includeFumadocsComponents={false} />
<CustomMDXContent code={content} />
</div>
</CardContent>
</Card>

View File

@ -1,9 +1,10 @@
import { ImageWrapper } from '@/components/docs/image-wrapper';
import { Wrapper } from '@/components/docs/wrapper';
import { YoutubeVideo } from '@/components/docs/youtube-video';
import { MDXContent } from '@content-collections/mdx/react';
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
import { Callout } from 'fumadocs-ui/components/callout';
import { File, Files, Folder } from 'fumadocs-ui/components/files';
import { ImageZoom } from 'fumadocs-ui/components/image-zoom';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
import { TypeTable } from 'fumadocs-ui/components/type-table';
@ -15,7 +16,6 @@ import type { ComponentProps, FC } from 'react';
interface CustomMDXContentProps {
code: string;
customComponents?: Record<string, any>;
includeFumadocsComponents?: boolean;
}
/**
@ -25,54 +25,29 @@ interface CustomMDXContentProps {
export async function CustomMDXContent({
code,
customComponents = {},
includeFumadocsComponents = true,
}: CustomMDXContentProps) {
// Start with default components
const baseComponents: Record<string, any> = {
...defaultMdxComponents,
...LucideIcons,
...((await import('lucide-react')) as unknown as MDXComponents),
YoutubeVideo,
Tabs,
Tab,
TypeTable,
Accordion,
Accordions,
Steps,
Step,
Wrapper,
File,
Folder,
Files,
blockquote: Callout as unknown as FC<ComponentProps<'blockquote'>>,
img: ImageWrapper,
...customComponents,
};
// Add Fumadocs UI components if requested
if (includeFumadocsComponents) {
Object.assign(baseComponents, {
Tabs,
Tab,
TypeTable,
Accordion,
Accordions,
Steps,
Step,
Wrapper,
File,
Folder,
Files,
blockquote: Callout as unknown as FC<ComponentProps<'blockquote'>>,
img: (props: ComponentProps<'img'>) => {
if (!props.src) {
return null;
}
return (
<ImageZoom
src={props.src}
alt={props.alt || 'image'}
width={1400}
height={787}
style={{
width: '100%',
height: 'auto',
objectFit: 'contain',
}}
priority
/>
);
},
});
}
return (
<MDXContent code={code} components={baseComponents as MDXComponents} />
);

View File

@ -60,15 +60,11 @@ function Calendar({
...classNames,
}}
components={{
PreviousMonthButton: (props) => (
<button {...props}>
<ChevronLeft className={cn("size-4", props.className)} />
</button>
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn("size-4", className)} {...props} />
),
NextMonthButton: (props) => (
<button {...props}>
<ChevronRight className={cn("size-4", props.className)} />
</button>
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("size-4", className)} {...props} />
),
}}
{...props}

View File

@ -27,6 +27,9 @@ import { useTranslations } from 'next-intl';
export function getSidebarLinks(): NestedMenuItem[] {
const t = useTranslations('Dashboard');
// if is demo website, allow user to access admin and user pages, but data is fake
const isDemo = process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true';
return [
{
title: t('dashboard.title'),
@ -37,7 +40,7 @@ export function getSidebarLinks(): NestedMenuItem[] {
{
title: t('admin.title'),
icon: <SettingsIcon className="size-4 shrink-0" />,
authorizeOnly: ['admin'],
authorizeOnly: isDemo ? ['admin', 'user'] : ['admin'],
items: [
{
title: t('admin.users.title'),

View File

@ -7,6 +7,7 @@ import { GitHubIcon } from '@/components/icons/github';
import { InstagramIcon } from '@/components/icons/instagram';
import { LinkedInIcon } from '@/components/icons/linkedin';
import { MastodonIcon } from '@/components/icons/mastodon';
import { TelegramIcon } from '@/components/icons/telegram';
import { TikTokIcon } from '@/components/icons/tiktok';
import { XTwitterIcon } from '@/components/icons/x';
import { YouTubeIcon } from '@/components/icons/youtube';
@ -106,5 +107,13 @@ export function getSocialLinks(): MenuItem[] {
});
}
if (websiteConfig.metadata.social?.telegram) {
socialLinks.push({
title: 'Telegram',
href: websiteConfig.metadata.social.telegram,
icon: <TelegramIcon className="size-4 shrink-0" />,
});
}
return socialLinks;
}

View File

@ -2,3 +2,5 @@ import type { auth } from './auth';
// https://www.better-auth.com/docs/concepts/typescript#additional-fields
export type Session = typeof auth.$Infer.Session;
export type User = typeof auth.$Infer.Session.user;

View File

@ -108,8 +108,12 @@ export const auth = betterAuth({
},
user: {
// https://www.better-auth.com/docs/concepts/database#extending-core-schema
// additionalFields: {
// },
additionalFields: {
customerId: {
type: 'string',
required: false,
},
},
// https://www.better-auth.com/docs/concepts/users-accounts#delete-user
deleteUser: {
enabled: true,
@ -142,7 +146,13 @@ export const auth = betterAuth({
plugins: [
// https://www.better-auth.com/docs/plugins/admin
// support user management, ban/unban user, manage user roles, etc.
admin(),
admin({
// https://www.better-auth.com/docs/plugins/admin#default-ban-reason
// defaultBanReason: 'Spamming',
defaultBanExpiresIn: undefined,
bannedUserMessage:
'You have been banned from this application. Please contact support if you believe this is an error.',
}),
],
onAPIError: {
// https://www.better-auth.com/docs/reference/options#onapierror

View File

@ -78,3 +78,15 @@ export function getUrlWithLocaleInCallbackUrl(
return url;
}
}
/**
* Get the Stripe dashboard customer URL
* @param customerId - The Stripe customer ID
* @returns The Stripe dashboard customer URL
*/
export function getStripeDashboardCustomerUrl(customerId: string): string {
if (process.env.NODE_ENV === 'development') {
return `https://dashboard.stripe.com/test/customers/${customerId}`;
}
return `https://dashboard.stripe.com/customers/${customerId}`;
}

12
src/stores/users-store.ts Normal file
View File

@ -0,0 +1,12 @@
import { create } from 'zustand';
interface UsersState {
refreshTrigger: number;
triggerRefresh: () => void;
}
export const useUsersStore = create<UsersState>((set) => ({
refreshTrigger: 0,
triggerRefresh: () =>
set((state) => ({ refreshTrigger: state.refreshTrigger + 1 })),
}));

View File

@ -57,6 +57,7 @@ export interface SocialConfig {
facebook?: string;
instagram?: string;
tiktok?: string;
telegram?: string;
}
/**