connection base prisma + postgres + login ok
This commit is contained in:
166
src/lib/api.ts
Normal file
166
src/lib/api.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { BookProject, UserProfile } from './types';
|
||||
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
// --- API CLIENT ---
|
||||
|
||||
const api = {
|
||||
async request<T = any>(endpoint: string, options: RequestInit = {}) {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers as Record<string, string>,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMsg = `Error ${response.status}: ${response.statusText}`;
|
||||
try {
|
||||
const errorJson = await response.json();
|
||||
if (errorJson.error) errorMsg = errorJson.error;
|
||||
if (errorJson.message) errorMsg = errorJson.message;
|
||||
} catch {
|
||||
// Ignore json parse error
|
||||
}
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
if (response.status === 204) return null;
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// --- AUTH ---
|
||||
auth: {
|
||||
async register(email: string, password: string, name: string) {
|
||||
return api.request('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, name }),
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// --- PROJECTS ---
|
||||
projects: {
|
||||
async list() {
|
||||
return api.request<any[]>('/projects');
|
||||
},
|
||||
|
||||
async get(id: string) {
|
||||
return api.request<any>(`/projects/${id}`);
|
||||
},
|
||||
|
||||
async create(data: { title: string; author: string; settings?: any }) {
|
||||
return api.request<any>('/projects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async update(id: string, data: any) {
|
||||
return api.request<any>(`/projects/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
return api.request(`/projects/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// --- CHAPTERS ---
|
||||
chapters: {
|
||||
async create(data: { projectId: string; title?: string; content?: string; summary?: string; orderIndex?: number }) {
|
||||
return api.request<any>('/chapters', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async update(id: string, data: any) {
|
||||
return api.request<any>(`/chapters/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
return api.request(`/chapters/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// --- ENTITIES ---
|
||||
entities: {
|
||||
async create(data: { projectId: string; type: string; name?: string; description?: string; details?: string; attributes?: any; customValues?: any }) {
|
||||
return api.request<any>('/entities', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async update(id: string, data: any) {
|
||||
return api.request<any>(`/entities/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
return api.request(`/entities/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// --- IDEAS ---
|
||||
ideas: {
|
||||
async create(data: { projectId: string; title?: string; description?: string; status?: string; category?: string }) {
|
||||
return api.request<any>('/ideas', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async update(id: string, data: any) {
|
||||
return api.request<any>(`/ideas/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
return api.request(`/ideas/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// --- AI (server-side via API routes) ---
|
||||
ai: {
|
||||
async generate(project: BookProject, chapterId: string, prompt: string, user: UserProfile) {
|
||||
return api.request<{ text: string; type: 'draft' | 'reflection' }>('/ai/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ project, chapterId, prompt, user }),
|
||||
});
|
||||
},
|
||||
|
||||
async transform(text: string, mode: string, context: string, user: UserProfile) {
|
||||
const res = await api.request<{ text: string }>('/ai/transform', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text, mode, context, user }),
|
||||
});
|
||||
return res.text;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
61
src/lib/auth.ts
Normal file
61
src/lib/auth.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import NextAuth from 'next-auth';
|
||||
import Credentials from 'next-auth/providers/credentials';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
providers: [
|
||||
Credentials({
|
||||
name: 'credentials',
|
||||
credentials: {
|
||||
email: { label: 'Email', type: 'email' },
|
||||
password: { label: 'Password', type: 'password' },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) return null;
|
||||
|
||||
// Lazy import to avoid PrismaClient initialization during build
|
||||
const { default: getDB } = await import('./prisma');
|
||||
const prisma = getDB();
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: credentials.email as string },
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const isValid = await bcrypt.compare(
|
||||
credentials.password as string,
|
||||
user.hashedPassword
|
||||
);
|
||||
|
||||
if (!isValid) return null;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: 'jwt',
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user && token.id) {
|
||||
session.user.id = token.id as string;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: '/',
|
||||
},
|
||||
});
|
||||
67
src/lib/constants.ts
Normal file
67
src/lib/constants.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
|
||||
import { EntityType } from "./types";
|
||||
|
||||
export const DEFAULT_BOOK_TITLE = "Nouveau Roman";
|
||||
export const DEFAULT_AUTHOR = "Auteur Inconnu";
|
||||
|
||||
export const INITIAL_CHAPTER = {
|
||||
id: 'chap-1',
|
||||
title: 'Chapitre 1',
|
||||
content: '<p>Il était une fois...</p>',
|
||||
summary: 'Début de l\'histoire.'
|
||||
};
|
||||
|
||||
export const ENTITY_ICONS: Record<EntityType, string> = {
|
||||
[EntityType.CHARACTER]: '👤',
|
||||
[EntityType.LOCATION]: '🏰',
|
||||
[EntityType.OBJECT]: '🗝️',
|
||||
[EntityType.NOTE]: '📝',
|
||||
};
|
||||
|
||||
// Colors for tags
|
||||
export const ENTITY_COLORS: Record<EntityType, string> = {
|
||||
[EntityType.CHARACTER]: 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
[EntityType.LOCATION]: 'bg-green-100 text-green-800 border-green-200',
|
||||
[EntityType.OBJECT]: 'bg-amber-100 text-amber-800 border-amber-200',
|
||||
[EntityType.NOTE]: 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
};
|
||||
|
||||
// --- Character Creation Lists ---
|
||||
|
||||
export const HAIR_COLORS = [
|
||||
"Brun", "Noir", "Blond", "Roux", "Auburn", "Gris", "Blanc", "Châtain", "Chauve", "Teinture (Bleu/Rose/Etc)"
|
||||
];
|
||||
|
||||
export const EYE_COLORS = [
|
||||
"Marron", "Bleu", "Vert", "Noisette", "Gris", "Noir", "Vairons", "Ambre"
|
||||
];
|
||||
|
||||
export const ARCHETYPES = [
|
||||
"Le Héros", "L'Ombre / Le Méchant", "Le Mentor", "Le Gardien du Seuil",
|
||||
"Le Shapeshifter (Changeforme)", "Le Trickster (Farceur)", "L'Allié", "L'Élu",
|
||||
"Le Rebelle", "Le Séducteur", "Le Sage", "Le Guerrier", "L'Innocent"
|
||||
];
|
||||
|
||||
// --- Book Settings Lists ---
|
||||
|
||||
export const GENRES = [
|
||||
"Fantasy", "Science-Fiction", "Thriller / Polar", "Romance", "Historique",
|
||||
"Horreur", "Aventure", "Contemporain", "Jeunesse / Young Adult", "Dystopie"
|
||||
];
|
||||
|
||||
export const TONES = [
|
||||
"Sombre & Sérieux", "Léger & Humoristique", "Épique & Grandiose",
|
||||
"Mélancolique", "Mystérieux", "Optimiste", "Cynique", "Romantique"
|
||||
];
|
||||
|
||||
export const POV_OPTIONS = [
|
||||
"1ère personne (Je)",
|
||||
"3ème personne (Limitée au protagoniste)",
|
||||
"3ème personne (Omnisciente)",
|
||||
"Multi-points de vue (Alterné)"
|
||||
];
|
||||
|
||||
export const TENSE_OPTIONS = [
|
||||
"Passé (Passé simple / Imparfait)",
|
||||
"Présent de narration"
|
||||
];
|
||||
144
src/lib/gemini.ts
Normal file
144
src/lib/gemini.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
// Server-only: Gemini AI service
|
||||
// This file is only imported by API routes, never by client code
|
||||
|
||||
import { GoogleGenAI, Type } from "@google/genai";
|
||||
import { BookProject, UserProfile } from "./types";
|
||||
|
||||
const truncate = (str: string, length: number) => {
|
||||
if (!str) return "";
|
||||
return str.length > length ? str.substring(0, length) + "..." : str;
|
||||
};
|
||||
|
||||
const checkUsage = (user: UserProfile) => {
|
||||
if (user.subscription.plan === 'master') return true;
|
||||
return user.usage.aiActionsCurrent < user.usage.aiActionsLimit;
|
||||
};
|
||||
|
||||
const buildContextPrompt = (project: BookProject, currentChapterId: string, instruction: string) => {
|
||||
const currentChapterIndex = project.chapters.findIndex(c => c.id === currentChapterId);
|
||||
const previousSummaries = project.chapters
|
||||
.slice(0, currentChapterIndex)
|
||||
.map((c, i) => `Chapitre ${i + 1} (${c.title}): ${c.summary || truncate(c.content.replace(/<[^>]*>?/gm, ''), 200)}`)
|
||||
.join('\n');
|
||||
|
||||
const entitiesContext = project.entities
|
||||
.map(e => {
|
||||
const base = `[${e.type}] ${e.name}: ${truncate(e.description, 150)}`;
|
||||
const context = e.storyContext ? `\n - VÉCU/ÉVOLUTION DANS L'HISTOIRE: ${truncate(e.storyContext, 500)}` : '';
|
||||
return base + context;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const ideasContext = (project.ideas || [])
|
||||
.map(i => {
|
||||
const statusMap: Record<string, string> = { todo: 'À FAIRE', progress: 'EN COURS', done: 'TERMINÉ' };
|
||||
return `[IDÉE - ${statusMap[i.status]}] ${i.title}: ${truncate(i.description, 100)}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const currentContent = project.chapters[currentChapterIndex]?.content.replace(/<[^>]*>?/gm, '') || "";
|
||||
const s = project.settings;
|
||||
const settingsPrompt = s ? `
|
||||
PARAMÈTRES DU ROMAN:
|
||||
- Genre: ${s.genre} ${s.subGenre ? `(${s.subGenre})` : ''}
|
||||
- Public: ${s.targetAudience}
|
||||
- Ton: ${s.tone}
|
||||
- Narration: ${s.pov}
|
||||
- Temps: ${s.tense}
|
||||
- Thèmes: ${s.themes}
|
||||
- Synopsis Global: ${truncate(s.synopsis || '', 500)}
|
||||
` : "";
|
||||
|
||||
return `
|
||||
Tu es un assistant éditorial expert et un co-auteur créatif.
|
||||
L'utilisateur écrit un livre intitulé "${project.title}".
|
||||
|
||||
${settingsPrompt}
|
||||
|
||||
CONTEXTE DE L'HISTOIRE (Résumé des chapitres précédents):
|
||||
${previousSummaries || "Aucun chapitre précédent."}
|
||||
|
||||
BIBLE DU MONDE (Personnages et Lieux):
|
||||
${entitiesContext || "Aucune fiche créée."}
|
||||
|
||||
BOÎTE À IDÉES & NOTES (Pistes de l'auteur):
|
||||
${ideasContext || "Aucune note."}
|
||||
|
||||
CHAPITRE ACTUEL (Texte brut):
|
||||
${truncate(currentContent, 3000)}
|
||||
|
||||
STYLE D'ÉCRITURE SPÉCIFIQUE (Instruction de l'auteur):
|
||||
${project.styleGuide || "Standard, neutre."}
|
||||
|
||||
TA MISSION:
|
||||
${instruction}
|
||||
`;
|
||||
};
|
||||
|
||||
export const generateStoryContent = async (
|
||||
project: BookProject,
|
||||
currentChapterId: string,
|
||||
userPrompt: string,
|
||||
user: UserProfile,
|
||||
): Promise<{ text: string; type: 'draft' | 'reflection' }> => {
|
||||
if (!checkUsage(user)) {
|
||||
return { text: "Limite d'actions IA atteinte pour ce mois. Passez au plan Pro !", type: 'reflection' };
|
||||
}
|
||||
|
||||
try {
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
|
||||
const finalPrompt = buildContextPrompt(project, currentChapterId, userPrompt);
|
||||
|
||||
const modelName = user.subscription.plan === 'master' ? 'gemini-3-pro-preview' : 'gemini-3-flash-preview';
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: modelName,
|
||||
contents: finalPrompt,
|
||||
config: {
|
||||
temperature: 0.7,
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
responseType: {
|
||||
type: Type.STRING,
|
||||
enum: ["draft", "reflection"]
|
||||
},
|
||||
content: {
|
||||
type: Type.STRING
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const result = JSON.parse(response.text || "{}");
|
||||
return {
|
||||
text: result.content || "Erreur de génération.",
|
||||
type: result.responseType || "reflection"
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("AI Generation Error:", error);
|
||||
return { text: "Erreur lors de la communication avec l'IA.", type: 'reflection' };
|
||||
}
|
||||
};
|
||||
|
||||
export const transformTextServer = async (
|
||||
text: string,
|
||||
mode: 'correct' | 'rewrite' | 'expand' | 'continue',
|
||||
context: string,
|
||||
user: UserProfile,
|
||||
): Promise<string> => {
|
||||
if (!checkUsage(user)) return "Limite d'actions IA atteinte.";
|
||||
try {
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
|
||||
const prompt = `Action: ${mode}. Texte: ${text}. Contexte: ${truncate(context, 1000)}. Renvoie juste le texte transformé.`;
|
||||
const response = await ai.models.generateContent({ model: 'gemini-3-flash-preview', contents: prompt });
|
||||
return response.text?.trim() || text;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
export const analyzeStyle = async (_text: string) => "Style analysé";
|
||||
export const summarizeText = async (_text: string) => "Résumé généré";
|
||||
24
src/lib/prisma.ts
Normal file
24
src/lib/prisma.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a singleton PrismaClient instance using the Prisma v7 adapter pattern.
|
||||
* Uses @prisma/adapter-pg with a pg Pool for direct PostgreSQL connections.
|
||||
*/
|
||||
export function getDB(): PrismaClient {
|
||||
if (!globalForPrisma.prisma) {
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
const pool = new Pool({ connectionString });
|
||||
const adapter = new PrismaPg(pool);
|
||||
|
||||
globalForPrisma.prisma = new PrismaClient({ adapter });
|
||||
}
|
||||
return globalForPrisma.prisma;
|
||||
}
|
||||
|
||||
export default getDB;
|
||||
170
src/lib/types.ts
Normal file
170
src/lib/types.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
|
||||
export enum EntityType {
|
||||
CHARACTER = 'Personnage',
|
||||
LOCATION = 'Lieu',
|
||||
OBJECT = 'Objet',
|
||||
NOTE = 'Note'
|
||||
}
|
||||
|
||||
export interface CharacterAttributes {
|
||||
age: number;
|
||||
height: number;
|
||||
hair: string;
|
||||
eyes: string;
|
||||
archetype: string;
|
||||
role: 'protagonist' | 'antagonist' | 'support' | 'extra';
|
||||
personality: {
|
||||
spectrumIntrovertExtravert: number;
|
||||
spectrumEmotionalRational: number;
|
||||
spectrumChaoticLawful: number;
|
||||
};
|
||||
physicalQuirk: string;
|
||||
behavioralQuirk: string;
|
||||
}
|
||||
|
||||
export type CustomFieldType = 'text' | 'textarea' | 'number' | 'boolean' | 'select';
|
||||
|
||||
export interface CustomFieldDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
type: CustomFieldType;
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface EntityTemplate {
|
||||
entityType: EntityType;
|
||||
fields: CustomFieldDefinition[];
|
||||
}
|
||||
|
||||
export interface Entity {
|
||||
id: string;
|
||||
type: EntityType;
|
||||
name: string;
|
||||
description: string;
|
||||
details: string;
|
||||
storyContext?: string;
|
||||
attributes?: CharacterAttributes;
|
||||
customValues?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface Chapter {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export type PlotNodeType = 'story' | 'dialogue' | 'action';
|
||||
|
||||
export interface PlotNode {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
title: string;
|
||||
description: string;
|
||||
color: string;
|
||||
type?: PlotNodeType;
|
||||
}
|
||||
|
||||
export interface PlotConnection {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface WorkflowData {
|
||||
nodes: PlotNode[];
|
||||
connections: PlotConnection[];
|
||||
}
|
||||
|
||||
export type IdeaStatus = 'todo' | 'progress' | 'done';
|
||||
export type IdeaCategory = 'plot' | 'character' | 'research' | 'todo';
|
||||
|
||||
export interface Idea {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: IdeaStatus;
|
||||
category: IdeaCategory;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface BookSettings {
|
||||
genre: string;
|
||||
subGenre?: string;
|
||||
targetAudience: string;
|
||||
tone: string;
|
||||
pov: string;
|
||||
tense: string;
|
||||
synopsis: string;
|
||||
themes: string;
|
||||
}
|
||||
|
||||
export interface BookProject {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
lastModified: number;
|
||||
settings?: BookSettings;
|
||||
// Direct fields sometimes used in creation/updates before settings normalization
|
||||
genre?: string;
|
||||
pov?: string;
|
||||
tense?: string;
|
||||
chapters: Chapter[];
|
||||
entities: Entity[];
|
||||
workflow?: WorkflowData;
|
||||
templates?: EntityTemplate[];
|
||||
styleGuide?: string;
|
||||
ideas?: Idea[];
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'model';
|
||||
text: string;
|
||||
responseType?: 'draft' | 'reflection';
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
// --- SAAS TYPES ---
|
||||
|
||||
export type PlanType = 'free' | 'pro' | 'master';
|
||||
|
||||
export interface Subscription {
|
||||
plan: PlanType;
|
||||
startDate: number;
|
||||
status: 'active' | 'canceled' | 'past_due';
|
||||
}
|
||||
|
||||
export interface UserUsage {
|
||||
aiActionsCurrent: number;
|
||||
aiActionsLimit: number;
|
||||
projectsLimit: number;
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
theme: 'light' | 'dark' | 'sepia';
|
||||
dailyWordGoal: number;
|
||||
language: 'fr' | 'en';
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
totalWordsWritten: number;
|
||||
writingStreak: number;
|
||||
lastWriteDate: number;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
bio?: string;
|
||||
subscription: Subscription;
|
||||
usage: UserUsage;
|
||||
preferences: UserPreferences;
|
||||
stats: UserStats;
|
||||
}
|
||||
|
||||
export type ViewMode = 'write' | 'world_building' | 'workflow' | 'settings' | 'preview' | 'ideas' | 'landing' | 'features' | 'pricing' | 'checkout' | 'dashboard' | 'auth' | 'signup' | 'profile';
|
||||
Reference in New Issue
Block a user