diff --git a/frontend/backend/OCR/client.ts b/frontend/backend/OCR/client.ts new file mode 100644 index 00000000..f5d4ae5e --- /dev/null +++ b/frontend/backend/OCR/client.ts @@ -0,0 +1,5 @@ +import * as genai from '@google/generative-ai' + +export function createGeminiClient(apiKey: string) { + return new genai.GoogleGenerativeAI(apiKey) +} diff --git a/frontend/backend/OCR/handlers.ts b/frontend/backend/OCR/handlers.ts new file mode 100644 index 00000000..cc52a60f --- /dev/null +++ b/frontend/backend/OCR/handlers.ts @@ -0,0 +1,113 @@ +import { createGeminiClient } from './client'; + +const RATE_LIMIT_SECONDS = 300; +const userLastRequest: Map = new Map(); + +export type OCRResponse = { + text: string; + confidence?: number; + language?: string; + products?: Array>; +}; + +export function parseAuthHeader(authHeader: string | null | undefined) { + if (!authHeader) return 'anonymous'; + return authHeader.startsWith('Bearer ') + ? authHeader.replace('Bearer ', '') + : authHeader; +} + +export const ALLOWED_TYPES = [ + 'image/png', + 'image/jpg', + 'image/jpeg', + 'image/webp', + 'application/pdf', +]; + +export async function processImageFile(params: { + file: File; + authHeader: string | null | undefined; + env: any; +}): Promise { + const { file, authHeader, env } = params; + const userId = parseAuthHeader(authHeader); + const now = Date.now() / 1000; + const last = userLastRequest.get(userId) ?? 0; + if (now - last < RATE_LIMIT_SECONDS) + throw { status: 429, message: 'rate_limited' }; + userLastRequest.set(userId, now); + + if (!ALLOWED_TYPES.includes(file.type)) + throw { status: 400, message: 'invalid_type' }; + + const apiKey = env.GEMINI_API_KEY; + if (!apiKey) throw { status: 500, message: 'GEMINI_API_KEY not configured' }; + + const client = createGeminiClient(apiKey); + + const arrayBuffer = await file.arrayBuffer(); + const uint8 = new Uint8Array(arrayBuffer); + const base64Data = Buffer.from(uint8).toString('base64'); + + const prompt = env.OCR_PROMPT || ''; + + const parts = [ + { text: prompt }, + { inlineData: { mimeType: file.type, data: base64Data } }, + ]; + + const model = client.getGenerativeModel({ model: 'gemini-1.5-flash' }); + const result = await model.generateContent(parts); + const response = result.response; + + let extractedText = response.text(); + + extractedText = extractedText.trim(); + if (extractedText.startsWith('```json')) + extractedText = extractedText.slice(7); + if (extractedText.endsWith('```')) extractedText = extractedText.slice(0, -3); + extractedText = extractedText.trim(); + + try { + const parsed = JSON.parse(extractedText); + let products = null; + if (Array.isArray(parsed)) products = parsed; + else if (parsed && typeof parsed === 'object') { + if ('productos' in parsed) products = (parsed as any).productos; + else products = [parsed]; + } + + return { text: extractedText, confidence: 0.95, language: 'es', products }; + } catch (e) { + return { text: extractedText, confidence: 0.95, language: 'es' }; + } +} + +export async function sendBulk(params: { + data: any; + authHeader: string | null | undefined; + env: any; +}) { + const { data, authHeader, env } = params; + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (authHeader) { + const token = authHeader.startsWith('Bearer ') + ? authHeader.replace('Bearer ', '') + : authHeader; + headers['Authorization'] = `Bearer ${token}`; + } + const res = await fetch(`/api/products/bulk`, { + method: 'POST', + headers, + body: JSON.stringify(data), + }); + const text = await res.text(); + try { + return { status: res.status, ok: res.ok, body: JSON.parse(text) }; + } catch { + return { status: res.status, ok: res.ok, body: text }; + } +} diff --git a/frontend/backend/auth/models.ts b/frontend/backend/auth/models.ts new file mode 100644 index 00000000..1e79ff0e --- /dev/null +++ b/frontend/backend/auth/models.ts @@ -0,0 +1,36 @@ +export type RoleUser = 'admin' | 'manager' | 'user'; + +export interface UserItem { + id: string; + full_name: string; + email: string; + password?: string | null; + role?: RoleUser; + phone?: string | null; + profile_image?: string | null; + polar_api_key?: string | null; + polar_organization_id?: string | null; +} + +export interface LoginRequest { + email: string; + password: string; +} + +export interface RegisterRequest { + full_name: string; + email: string; + password: string; + phone?: string | null; +} + +export interface UserInfo { + id: string; + email: string; + full_name: string; + phone?: string | null; + address?: string | null; + role: RoleUser; + created_at: string; + last_updated: string; +} diff --git a/frontend/backend/auth/repository.ts b/frontend/backend/auth/repository.ts new file mode 100644 index 00000000..a0f567ff --- /dev/null +++ b/frontend/backend/auth/repository.ts @@ -0,0 +1,109 @@ +import { createClient } from '@/utils/supabase/server'; +import type { UserItem } from './models'; +import { encryptString, decryptString } from '@/utils/crypto'; +import { getCurrentTimeWithTimezone } from '@/utils/timezone'; + +export class UserRepository { + table = 'users'; + + async findAllUsers(): Promise { + const supabase = await createClient(); + const { data } = await supabase.from(this.table).select('*'); + return (data || []) as UserItem[]; + } + + async findByEmail(email: string): Promise { + if (!email) return null; + const supabase = await createClient(); + const { data } = await supabase + .from(this.table) + .select('*') + .eq('email', email) + .limit(1); + if (!data || data.length === 0) return null; + return data[0] as UserItem; + } + + async findByUserId(user_id: string): Promise { + if (!user_id) return null; + const supabase = await createClient(); + const { data } = await supabase + .from(this.table) + .select('*') + .eq('id', user_id) + .limit(1); + if (!data || data.length === 0) return null; + return data[0] as UserItem; + } + + async createUser(payload: Record): Promise { + const supabase = await createClient(); + const now = getCurrentTimeWithTimezone('UTC'); + const insertPayload = { + full_name: payload.full_name, + email: payload.email, + password: payload.password, + phone: payload.phone || null, + role: payload.role || 'user', + profile_image: payload.profile_image || null, + polar_api_key: payload.polar_api_key || null, + polar_organization_id: payload.polar_organization_id || null, + created_at: now, + last_updated: now, + }; + const { data, error } = await supabase + .from(this.table) + .insert(insertPayload) + .select() + .single(); + if (error) throw error; + return (data && data.id) || ''; + } + + async updateProfile( + user_id: string, + updates: Record + ): Promise { + const supabase = await createClient(); + const now = getCurrentTimeWithTimezone('UTC'); + updates.last_updated = now; + if (typeof updates.polar_api_key !== 'undefined') { + updates.polar_api_key = updates.polar_api_key || null; + } + if (typeof updates.polar_organization_id !== 'undefined') { + updates.polar_organization_id = updates.polar_organization_id || null; + } + const { data, error } = await supabase + .from(this.table) + .update(updates) + .eq('id', user_id) + .select() + .single(); + if (error) throw error; + return data as UserItem; + } + + async getDecryptedPolarKey(user_id: string): Promise { + const supabase = await createClient(); + const { data } = await supabase + .from(this.table) + .select('polar_api_key') + .eq('id', user_id) + .limit(1) + .single(); + if (!data || !data.polar_api_key) return null; + return data.polar_api_key as string; + } + + async deleteUser(user_id: string): Promise { + const supabase = await createClient(); + const { data, error } = await supabase + .from(this.table) + .delete() + .eq('id', user_id) + .select() + .single(); + if (error) throw error; + return (data as UserItem) || null; + } +} diff --git a/frontend/backend/auth/service.ts b/frontend/backend/auth/service.ts new file mode 100644 index 00000000..4ccbd97a --- /dev/null +++ b/frontend/backend/auth/service.ts @@ -0,0 +1,227 @@ +import jwt, { SignOptions, Secret } from 'jsonwebtoken'; +import { UserRepository } from './repository'; +import type { UserItem } from './models'; +import { hashPassword, verifyPassword } from '@/utils/password'; +import { uploadProfileImage } from '@/utils/cloudinary'; + +const SECRET_KEY = process.env.SECRET_KEY; +const ALGORITHM = 'HS256'; +const ACCESS_TOKEN_EXPIRE_MINUTES = Number( + process.env.ACCESS_TOKEN_EXPIRE_MINUTES || 60 * 24 * 7 +); + +export class AuthService { + repo: UserRepository; + blacklisted: Set; + constructor(repo?: UserRepository) { + this.repo = repo || new UserRepository(); + this.blacklisted = new Set(); + } + + createAccessToken( + data: Record, + expiresMinutes?: number + ): string { + const payload = { ...data }; + const expiresIn = `${expiresMinutes || ACCESS_TOKEN_EXPIRE_MINUTES}m`; + const options: SignOptions = { + algorithm: ALGORITHM as unknown as SignOptions['algorithm'], + expiresIn: expiresIn as any, + }; + return jwt.sign(payload, SECRET_KEY as unknown as Secret, options); + } + + async login( + email: string, + password: string, + expiresMinutes?: number + ): Promise<{ user: UserItem; token: string }> { + if (!email || !password) throw new Error('Email and password are required'); + let user = await this.repo.findByEmail(email); + + if (user && (user as any).password) { + try { + const hashed = (user as any).password as string; + if (!verifyPassword(password, hashed)) + throw new Error('Invalid credentials'); + const userData: UserItem = { ...user }; + delete (userData as any).password; + const token = this.createAccessToken( + { user_id: user.id }, + expiresMinutes + ); + return { user: userData, token }; + } catch (e) { + throw new Error('Invalid credentials'); + } + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const serviceKey = + process.env.SUPABASE_SERVICE_KEY || + process.env.SUPABASE_SERVICE_ROLE_KEY || + process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY; + if (!supabaseUrl || !serviceKey) throw new Error('Authentication error'); + try { + const res = await fetch( + `${supabaseUrl}/auth/v1/token?grant_type=password`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + apikey: serviceKey, + }, + body: JSON.stringify({ email, password }), + } + ); + const data = await res.json(); + if (!res.ok || data.error) throw new Error('Invalid credentials'); + const supaUser = data.user; + if (!supaUser) throw new Error('Invalid credentials'); + + user = await this.repo.findByEmail(email); + if (!user) { + const fullName = + supaUser.user_metadata?.full_name || + supaUser.user_metadata?.name || + email; + const newId = await this.repo.createUser({ + full_name: fullName, + email, + password: null, + }); + user = await this.repo.findByUserId(String(newId)); + } + + if (!user) throw new Error('Authentication error'); + const userData: UserItem = { ...user }; + delete (userData as any).password; + const token = this.createAccessToken( + { user_id: user.id }, + expiresMinutes + ); + return { user: userData, token }; + } catch (e) { + throw new Error('Invalid credentials'); + } + } + + async register( + full_name: string, + email: string, + password: string, + phone?: string, + expiresMinutes?: number + ): Promise<{ user: UserItem; token: string }> { + if (!email || !password || !full_name) + throw new Error('Email, password and fullname are required'); + const existing = await this.repo.findByEmail(email); + if (existing) throw new Error('Email already exists'); + const hashed = hashPassword(password); + const user_id = await this.repo.createUser({ + full_name, + email, + password: hashed, + phone, + }); + return this.login(email, password, expiresMinutes); + } + + logout(token: string) { + this.blacklisted.add(token); + return { success: true, message: 'Successful logout' }; + } + + async verifyToken(token: string): Promise { + try { + const payload: any = jwt.verify(token, SECRET_KEY as unknown as Secret); + const user_id = payload.user_id; + if (!user_id) throw new Error('Invalid Token'); + const user = await this.repo.findByUserId(String(user_id)); + if (!user) throw new Error('User not found'); + return user; + } catch (e) { + throw new Error('Invalid Token'); + } + } + + isTokenBlacklisted(token: string) { + return this.blacklisted.has(token); + } + + async getListUsers(): Promise<{ success: true; users: UserItem[] }> { + const users = await this.repo.findAllUsers(); + return { success: true, users }; + } + + async getProfileUser( + user_id: string + ): Promise<{ success: true; user: UserItem | null }> { + const user = await this.repo.findByUserId(user_id); + return { success: true, user }; + } + + async updateProfile( + user_id: string, + updates: Record + ): Promise<{ success: true; user: UserItem }> { + const user = await this.repo.updateProfile(user_id, updates); + return { success: true, user }; + } + + async setPolarApiKey(user_id: string, apiKey: string | null) { + await this.repo.updateProfile(user_id, { polar_api_key: apiKey }); + return { success: true }; + } + + async getPolarApiKey(user_id: string): Promise { + const key = await this.repo.getDecryptedPolarKey(user_id); + return key; + } + + async uploadProfileImage( + user_id: string, + file: File + ): Promise<{ success: true; user: UserItem }> { + try { + const url = await uploadProfileImage(file as any); + const user = await this.repo.updateProfile(user_id, { + profile_image: url, + }); + return { success: true, user }; + } catch (e: any) { + throw new Error(String(e?.message ?? e)); + } + } + + async deleteProfileImage( + user_id: string + ): Promise<{ success: true; user: UserItem }> { + const user = await this.repo.updateProfile(user_id, { + profile_image: null, + }); + return { success: true, user }; + } + + async updateProfileImage( + user_id: string, + file: File + ): Promise<{ success: true; user: UserItem }> { + try { + const url = await uploadProfileImage(file as any); + const user = await this.repo.updateProfile(user_id, { + profile_image: url, + }); + return { success: true, user }; + } catch (e: any) { + throw new Error(String(e?.message ?? e)); + } + } + + async deleteUser( + user_id: string + ): Promise<{ success: true; user: UserItem | null }> { + const user = await this.repo.deleteUser(user_id); + return { success: true, user }; + } +} diff --git a/frontend/backend/back/category/models.ts b/frontend/backend/back/category/models.ts new file mode 100644 index 00000000..a5de36c2 --- /dev/null +++ b/frontend/backend/back/category/models.ts @@ -0,0 +1,16 @@ +export interface Category { + id: string; + name: string; + created_at?: string | null; + last_updated?: string | null; +} + +export interface CategoryResponse { + success: boolean; + category: Category; +} + +export interface CategoriesResponse { + success: boolean; + categories: Category[]; +} diff --git a/frontend/backend/back/category/repository.ts b/frontend/backend/back/category/repository.ts new file mode 100644 index 00000000..de6d489f --- /dev/null +++ b/frontend/backend/back/category/repository.ts @@ -0,0 +1,23 @@ +import { createClient } from '@/utils/supabase/server'; + +export class CategoryRepository { + table = 'categories'; + + async list() { + const supabase = await createClient(); + const { data, error } = await supabase.from(this.table).select('*'); + if (error) throw error; + return data || []; + } + + async get(category_id: string) { + const supabase = await createClient(); + const { data, error } = await supabase + .from(this.table) + .select('*') + .eq('id', category_id) + .single(); + if (error) return null; + return data; + } +} diff --git a/frontend/backend/back/category/service.ts b/frontend/backend/back/category/service.ts new file mode 100644 index 00000000..d131813e --- /dev/null +++ b/frontend/backend/back/category/service.ts @@ -0,0 +1,18 @@ +import { CategoryRepository } from './repository'; + +export class CategoryService { + repo: CategoryRepository; + constructor(repo?: CategoryRepository) { + this.repo = repo || new CategoryRepository(); + } + + async listCategories() { + return this.repo.list(); + } + + async getCategory(category_id: string) { + const category = await this.repo.get(category_id); + if (!category) throw new Error('Category not found'); + return category; + } +} diff --git a/frontend/backend/back/inventory/models.ts b/frontend/backend/back/inventory/models.ts new file mode 100644 index 00000000..0e116769 --- /dev/null +++ b/frontend/backend/back/inventory/models.ts @@ -0,0 +1,21 @@ +export interface Product { + id: string; + name?: string; + status?: string; +} + +export interface Inventory { + id?: string | null; + inventory_owner?: string | null; + products: Product[]; + created_at?: string | null; + last_updated?: string | null; +} + +export interface InventoryResponse { + success: boolean; + inventory?: Inventory | null; + total_products?: number; + total_stock?: number | null; + low_stock_count?: number | null; +} diff --git a/frontend/backend/back/inventory/repository.ts b/frontend/backend/back/inventory/repository.ts new file mode 100644 index 00000000..6e2c4024 --- /dev/null +++ b/frontend/backend/back/inventory/repository.ts @@ -0,0 +1,165 @@ +import { createClient } from '@/utils/supabase/server'; +import type { Inventory, Product } from './models'; + +export class InventoryRepository { + table = 'inventory'; + + async getInventoryByOwner(owner_id: string): Promise { + const supabase = await createClient(); + const resp = await supabase + .from(this.table) + .select('*') + .eq('inventory_owner', owner_id) + .limit(1); + const data: any = + resp.data && resp.data.length ? resp.data[0] : resp.data || null; + if (!data) return null; + let products = data.products || []; + if (products && typeof products === 'object' && !Array.isArray(products)) { + try { + products = Object.values(products as Record); + } catch { + products = []; + } + } + data.products = products; + return data as Inventory; + } + + async createInventory(owner_id: string): Promise { + const supabase = await createClient(); + const payload = { inventory_owner: owner_id, products: [] }; + const res = await supabase + .from(this.table) + .insert(payload) + .select() + .single(); + const data: any = res.data; + if (!data) throw new Error('Error creating inventory'); + return data as Inventory; + } + + async getOrCreateInventory(owner_id: string): Promise { + const inv = await this.getInventoryByOwner(owner_id); + if (inv) return inv; + return this.createInventory(owner_id); + } + + async getInventoryItems(owner_id: string): Promise { + const inv = await this.getInventoryByOwner(owner_id); + if (!inv || !inv.products) return []; + return inv.products as Product[]; + } + + async getInventoryItem( + owner_id: string, + product_id: string + ): Promise { + const inv = await this.getInventoryByOwner(owner_id); + if (!inv || !inv.products) return null; + for (const p of inv.products) { + const pid = + typeof p === 'object' && 'id' in p ? (p as any).id : undefined; + if (String(pid) === String(product_id)) return p as Product; + } + return null; + } + + async updateInventoryItem( + owner_id: string, + product_id: string, + updates: Record + ): Promise { + const supabase = await createClient(); + const inv = await this.getOrCreateInventory(owner_id); + const products = inv.products || []; + let foundIndex = -1; + for (let i = 0; i < products.length; i++) { + const p = products[i]; + const pid = + typeof p === 'object' && 'id' in p ? (p as any).id : undefined; + if (String(pid) === String(product_id)) { + foundIndex = i; + break; + } + } + let currentProduct: any = null; + if (foundIndex === -1) { + const productResp = await supabase + .from('products') + .select('id,name,status') + .eq('id', product_id) + .limit(1); + const productData: any = + productResp.data && productResp.data.length + ? productResp.data[0] + : null; + if (!productData) throw new Error('Product not found'); + const newProduct = { + id: productData.id, + name: productData.name, + status: productData.status || 'active', + }; + products.push(newProduct); + currentProduct = newProduct; + } else { + const existing = products[foundIndex]; + currentProduct = typeof existing === 'object' ? existing : existing; + for (const [k, v] of Object.entries(updates)) { + if (k === 'name' || k === 'status') currentProduct[k] = v; + } + products[foundIndex] = currentProduct; + } + const serializable = products.map((p) => (typeof p === 'object' ? p : p)); + const resp = await supabase + .from(this.table) + .update({ products: serializable }) + .eq('inventory_owner', owner_id) + .select(); + const data: any = resp.data; + if (!data) throw new Error('Error updating inventory'); + return currentProduct as Product; + } + + async removeInventoryItem( + owner_id: string, + product_id: string + ): Promise { + const supabase = await createClient(); + const inv = await this.getInventoryByOwner(owner_id); + if (!inv || !inv.products) return false; + const products = inv.products; + const newProducts = products.filter( + (p) => String((p as any).id) !== String(product_id) + ); + const resp = await supabase + .from(this.table) + .update({ products: newProducts }) + .eq('inventory_owner', owner_id) + .select(); + const data: any = resp.data; + return data != null; + } + + async getInventorySummary( + owner_id: string + ): Promise<{ + total_products: number; + total_stock: null; + low_stock_count: null; + }> { + const items = await this.getInventoryItems(owner_id); + return { + total_products: items.length, + total_stock: null, + low_stock_count: null, + }; + } + + async checkLowStock( + owner_id: string, + threshold: number = 0 + ): Promise { + return []; + } +} diff --git a/frontend/backend/back/inventory/service.ts b/frontend/backend/back/inventory/service.ts new file mode 100644 index 00000000..bb088917 --- /dev/null +++ b/frontend/backend/back/inventory/service.ts @@ -0,0 +1,58 @@ +import { InventoryRepository } from './repository'; +import type { Product, InventoryResponse } from './models'; + +export class InventoryService { + repo: InventoryRepository; + + constructor() { + this.repo = new InventoryRepository(); + } + + async getInventory(user_id: string): Promise { + return this.getInventoryByUser(user_id); + } + + async getInventoryByUser(user_id: string): Promise { + return this.repo.getInventoryItems(user_id); + } + + async updateStock(): Promise { + throw new Error('Updating stock on inventory.jsonb is not supported'); + } + + async getInventoryItem( + user_id: string, + product_id: string + ): Promise { + return this.repo.getInventoryItem(user_id, product_id); + } + + async checkLowStock( + user_id: string, + min_threshold: number = 0 + ): Promise { + return this.repo.checkLowStock(user_id, min_threshold); + } + + async getInventorySummary(user_id: string) { + return this.repo.getInventorySummary(user_id); + } + + async getInventoryResponse(user_id: string): Promise { + const items = await this.getInventoryByUser(user_id); + const summary = await this.getInventorySummary(user_id); + return { + success: true, + inventory: { + id: null, + inventory_owner: user_id, + products: items, + created_at: null, + last_updated: null, + }, + total_products: summary.total_products, + total_stock: summary.total_stock, + low_stock_count: summary.low_stock_count, + }; + } +} diff --git a/frontend/backend/back/layout/models.ts b/frontend/backend/back/layout/models.ts new file mode 100644 index 00000000..a5b7cd7f --- /dev/null +++ b/frontend/backend/back/layout/models.ts @@ -0,0 +1,24 @@ +export interface CreateLayoutRequest { + slug: string; + inventory_id: string; + hero_title?: string | null; + web_description?: string | null; + social_links?: Record | null; +} + +export interface UpdateLayoutRequest { + hero_title?: string | null; + web_description?: string | null; + social_links?: Record | null; +} + +export interface LayoutResponse { + slug: string; + owner_id: string; + inventory_id: string; + hero_title?: string | null; + web_description?: string | null; + social_links?: Record | null; + created_at?: string | null; + last_updated?: string | null; +} diff --git a/frontend/backend/back/layout/repository.ts b/frontend/backend/back/layout/repository.ts new file mode 100644 index 00000000..d220d71f --- /dev/null +++ b/frontend/backend/back/layout/repository.ts @@ -0,0 +1,78 @@ +import { createClient } from '@/utils/supabase/server'; + +export class LayoutRepository { + table = 'layouts'; + + async createLayout(payload: any) { + const supabase = await createClient(); + const { data, error } = await supabase + .from(this.table) + .insert(payload) + .select() + .single(); + if (error) throw error; + return data; + } + + async updateLayout(slug: string, updates: any) { + const supabase = await createClient(); + const { data, error } = await supabase + .from(this.table) + .update(updates) + .eq('slug', slug) + .select() + .single(); + if (error) throw error; + return data; + } + + async findAll() { + const supabase = await createClient(); + const { data, error } = await supabase.from(this.table).select('*'); + if (error) throw error; + return data || []; + } + + async findBySlug(slug: string) { + const supabase = await createClient(); + const { data, error } = await supabase + .from(this.table) + .select('*') + .eq('slug', slug) + .single(); + if (error) return null; + return data; + } + + async findByOwner(owner_id: string) { + const supabase = await createClient(); + const { data, error } = await supabase + .from(this.table) + .select('*') + .eq('owner_id', owner_id); + if (error) throw error; + return data || []; + } + + async findByInventory(inventory_id: string) { + const supabase = await createClient(); + const { data, error } = await supabase + .from(this.table) + .select('*') + .eq('inventory_id', inventory_id); + if (error) throw error; + return data || []; + } + + async deleteLayout(slug: string) { + const supabase = await createClient(); + const { data, error } = await supabase + .from(this.table) + .delete() + .eq('slug', slug) + .select() + .single(); + if (error) throw error; + return data; + } +} diff --git a/frontend/backend/back/layout/service.ts b/frontend/backend/back/layout/service.ts new file mode 100644 index 00000000..d486fad7 --- /dev/null +++ b/frontend/backend/back/layout/service.ts @@ -0,0 +1,48 @@ +import { LayoutRepository } from './repository'; + +export class LayoutService { + repo: LayoutRepository; + constructor(repo?: LayoutRepository) { + this.repo = repo || new LayoutRepository(); + } + + async createLayout(layoutData: any, owner_id: string) { + return this.repo.createLayout({ ...layoutData, owner_id }); + } + + async listLayouts() { + return this.repo.findAll(); + } + + async getLayout(layout_slug: string) { + if (!layout_slug || typeof layout_slug !== 'string') + throw new Error('Invalid layout slug'); + const layout = await this.repo.findBySlug(layout_slug); + if (!layout) throw new Error('Layout not found'); + return layout; + } + + async updateLayout(layout_slug: string, updates: any) { + const allowed = ['hero_title', 'web_description', 'social_links']; + for (const k of Object.keys(updates)) { + if (!allowed.includes(k)) throw new Error(`Invalid field: ${k}`); + } + for (const fld of ['hero_title', 'web_description', 'social_links']) { + const val = updates[fld]; + if (val === '' || val == null) delete updates[fld]; + } + return this.repo.updateLayout(layout_slug, updates); + } + + async deleteLayout(layout_slug: string) { + return this.repo.deleteLayout(layout_slug); + } + + async listLayoutsByOwner(owner_id: string) { + return this.repo.findByOwner(owner_id); + } + + async listLayoutsByInventory(inventory_id: string) { + return this.repo.findByInventory(inventory_id); + } +} diff --git a/frontend/backend/back/product/models.ts b/frontend/backend/back/product/models.ts new file mode 100644 index 00000000..4934188f --- /dev/null +++ b/frontend/backend/back/product/models.ts @@ -0,0 +1,59 @@ +export interface CreateProductRequest { + name: string; + description?: string | null; + price: number; + stock: number; + unit?: string | null; + product_type?: string | null; + category_ids?: string[] | null; + sku?: string | null; + min_stock?: number | null; + status?: string | null; + weight?: number | null; + localization?: string | null; +} + +export interface UpdateProductRequest { + name?: string; + description?: string | null; + price?: number; + stock?: number; + unit?: string | null; + product_type?: string | null; + category_ids?: string[] | null; + sku?: string | null; + min_stock?: number | null; + status?: string | null; + weight?: number | null; + localization?: string | null; +} + +export interface Product { + id: string; + name: string; + description?: string | null; + price: number; + stock: number; + unit?: string | null; + product_type?: string | null; + category_ids?: string[] | null; + sku?: string | null; + min_stock?: number | null; + status?: string | null; + weight?: number | null; + localization?: string | null; + images?: string[]; + creator_id?: string | null; + created_at?: string | null; + last_updated?: string | null; +} + +export interface ProductResponse { + success: boolean; + product?: Product | null; +} + +export interface ProductsResponse { + success: boolean; + products: Product[]; +} diff --git a/frontend/backend/back/product/repository.ts b/frontend/backend/back/product/repository.ts new file mode 100644 index 00000000..191c25d5 --- /dev/null +++ b/frontend/backend/back/product/repository.ts @@ -0,0 +1,117 @@ +import { createClient } from '@/utils/supabase/server'; +import type { Product } from './models'; + +export class ProductRepository { + table = 'products'; + + async createProduct(payload: Record): Promise { + const supabase = await createClient(); + const res = await supabase + .from(this.table) + .insert(payload) + .select() + .single(); + const data: any = res.data; + if (!data) throw new Error('Error creating product'); + return data as Product; + } + + async updateProduct( + product_id: string, + updates: Record + ): Promise { + const supabase = await createClient(); + const resp = await supabase + .from(this.table) + .update(updates) + .eq('id', product_id) + .select() + .single(); + const data: any = resp.data; + if (!data) throw new Error('Product not found'); + return data as Product; + } + + async findAll(): Promise { + const supabase = await createClient(); + const { data } = await supabase.from(this.table).select('*'); + return (data || []) as Product[]; + } + + async findById(product_id: string): Promise { + const supabase = await createClient(); + const { data, error } = await supabase + .from(this.table) + .select('*') + .eq('id', product_id) + .limit(1); + if (error) return null; + if (!data || data.length === 0) return null; + return data[0] as Product; + } + + async deleteProduct(product_id: string): Promise { + const supabase = await createClient(); + const { data } = await supabase + .from(this.table) + .delete() + .eq('id', product_id) + .select() + .single(); + if (!data) throw new Error('Product not found'); + return data as Product; + } + + async findByCreator(creator_id: string): Promise { + const supabase = await createClient(); + const { data } = await supabase + .from(this.table) + .select('*') + .eq('creator_id', creator_id); + return (data || []) as Product[]; + } + + async findByName(name: string): Promise { + const supabase = await createClient(); + const { data } = await supabase + .from(this.table) + .select('*') + .ilike('name', name); + return (data || []) as Product[]; + } + + async addImages(product_id: string, new_images: string[]): Promise { + const prod = await this.findById(product_id); + if (!prod) throw new Error('Product not found'); + const current = prod.images || []; + const updated = current.concat(new_images); + if (updated.length > 4) throw new Error('Maximum 4 images allowed'); + return this.updateProduct(product_id, { images: updated }); + } + + async getImages(product_id: string): Promise { + const prod = await this.findById(product_id); + if (!prod) throw new Error('Product not found'); + return prod.images || []; + } + + async deleteImage(product_id: string, image_index: number): Promise { + const prod = await this.findById(product_id); + if (!prod) throw new Error('Product not found'); + const current = prod.images || []; + if (image_index < 0 || image_index >= current.length) + throw new Error('Invalid image index'); + const updated = current + .slice(0, image_index) + .concat(current.slice(image_index + 1)); + return this.updateProduct(product_id, { images: updated }); + } + + async updateImages( + product_id: string, + new_images: string[] + ): Promise { + if (new_images.length > 4) throw new Error('Maximum 4 images allowed'); + return this.updateProduct(product_id, { images: new_images }); + } +} diff --git a/frontend/backend/back/product/service.ts b/frontend/backend/back/product/service.ts new file mode 100644 index 00000000..910777a1 --- /dev/null +++ b/frontend/backend/back/product/service.ts @@ -0,0 +1,175 @@ +import { ProductRepository } from './repository'; +import type { + CreateProductRequest, + UpdateProductRequest, + Product, +} from './models'; + +const MAX_IMAGES = 4; + +export class ProductService { + repo: ProductRepository; + constructor(repo?: ProductRepository) { + this.repo = repo || new ProductRepository(); + } + + async createProduct( + product_data: CreateProductRequest, + creator_id: string + ): Promise { + const unit_val = (product_data as any).unit ?? product_data.unit; + const type_val = + (product_data as any).product_type ?? product_data.product_type; + const category_ids_val = (product_data as any).category_ids || []; + if (!Array.isArray(category_ids_val)) + throw new Error('category_ids must be a list'); + for (const cid of category_ids_val) { + if (typeof cid !== 'string' || !cid) + throw new Error('Invalid category id in category_ids'); + } + const initial_images: string[] = []; + if (initial_images.length > MAX_IMAGES) + throw new Error('Maximum 4 images allowed'); + return this.repo.createProduct({ + name: product_data.name, + description: product_data.description ?? null, + price: Number(product_data.price), + stock: Number(product_data.stock), + unit: unit_val, + product_type: type_val, + category_ids: category_ids_val, + sku: product_data.sku ?? null, + min_stock: Number((product_data as any).min_stock ?? 0), + status: (product_data as any).status ?? 'active', + weight: (product_data as any).weight ?? null, + localization: (product_data as any).localization ?? null, + creator_id: String(creator_id), + images: initial_images, + }); + } + + async listProducts(creator_id: string) { + return this.repo.findByCreator(creator_id); + } + + async searchByCategory(category: string) { + return this.repo.findByName(category); + } + + async searchByName(name: string) { + return this.repo.findByName(name); + } + + async getProduct(product_id: string) { + if (!product_id) throw new Error('Invalid product ID'); + const product = await this.repo.findById(product_id); + if (!product) throw new Error('Product not found'); + return product; + } + + async updateProduct( + product_id: string, + updates: Record, + user_timezone: string = 'UTC' + ) { + if (!product_id) throw new Error('Invalid product ID'); + const allowed_fields = [ + 'name', + 'description', + 'price', + 'stock', + 'category_ids', + 'images', + 'sku', + 'weight', + 'localization', + 'min_stock', + 'status', + 'product_type', + 'unit', + ]; + for (const field of Object.keys(updates)) { + if (!allowed_fields.includes(field)) + throw new Error(`Invalid field: ${field}`); + } + for (const fld of [ + 'product_type', + 'unit', + 'category_ids', + 'description', + 'localization', + 'sku', + 'status', + ]) { + const val = updates[fld]; + if (val === '' || val === null || val === undefined) delete updates[fld]; + } + if ('category_ids' in updates) { + if (!Array.isArray(updates['category_ids'])) + throw new Error('category_ids must be a list'); + const cleaned: string[] = []; + for (const cid of updates['category_ids']) { + if (typeof cid !== 'string' || !cid) + throw new Error('Invalid category id in category_ids'); + cleaned.push(cid); + } + updates['category_ids'] = cleaned; + } + if ('price' in updates) { + const v = Number(updates['price']); + if (Number.isNaN(v) || v <= 0) throw new Error('Invalid price value'); + updates['price'] = v; + } + if ('stock' in updates) { + const v = Number(updates['stock']); + if (!Number.isInteger(v) || v < 0) throw new Error('Invalid stock value'); + updates['stock'] = v; + } + if ('min_stock' in updates) { + const v = Number(updates['min_stock']); + if (!Number.isInteger(v) || v < 0) + throw new Error('Invalid min_stock value'); + updates['min_stock'] = v; + } + if ('weight' in updates) { + const v = Number(updates['weight']); + if (Number.isNaN(v) || v < 0) throw new Error('Invalid weight value'); + updates['weight'] = v; + } + return this.repo.updateProduct(product_id, updates); + } + + async addImages(product_id: string, new_images: string[]) { + if (!product_id) throw new Error('Invalid product ID'); + if (new_images.length > MAX_IMAGES) + throw new Error('Maximum 4 images allowed'); + const product = await this.repo.findById(product_id); + if (!product) throw new Error('Product not found'); + const current = product.images || []; + if (current.length + new_images.length > MAX_IMAGES) + throw new Error('Maximum 4 images allowed'); + return this.repo.addImages(product_id, new_images); + } + + async getImages(product_id: string) { + if (!product_id) throw new Error('Invalid product ID'); + return this.repo.getImages(product_id); + } + + async deleteImage(product_id: string, image_index: number) { + if (!product_id) throw new Error('Invalid product ID'); + return this.repo.deleteImage(product_id, image_index); + } + + async updateImages(product_id: string, new_images: string[]) { + if (!product_id) throw new Error('Invalid product ID'); + if (new_images.length > MAX_IMAGES) + throw new Error('Maximum 4 images allowed'); + return this.repo.updateImages(product_id, new_images); + } + + async deleteProduct(product_id: string) { + if (!product_id) throw new Error('Invalid product ID'); + return this.repo.deleteProduct(product_id); + } +} diff --git a/frontend/package.json b/frontend/package.json index aed25987..bf959b8f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "@google/generative-ai": "^0.24.1", "@polar-sh/nextjs": "^0.4.6", "@polar-sh/sdk": "^0.34.16", "@radix-ui/react-accordion": "^1.2.12", @@ -41,7 +42,9 @@ "@supabase/ssr": "^0.7.0", "@supabase/supabase-js": "^2.56.0", "axios": "^1.7.7", + "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", + "cloudinary": "^2.7.0", "clsx": "^2.1.1", "cmdk": "^1.1.1", "embla-carousel-react": "^8.6.0", @@ -49,6 +52,7 @@ "geist": "^1.3.1", "input-otp": "^1.4.2", "js-cookie": "^3.0.5", + "jsonwebtoken": "^9.0.2", "lucide-react": "^0.344.0", "next": "15.5.0", "next-themes": "^0.4.6", @@ -73,6 +77,7 @@ "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", "@types/js-cookie": "^3.0.6", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 5daae71b..58f286a9 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@google/generative-ai': + specifier: ^0.24.1 + version: 0.24.1 '@polar-sh/nextjs': specifier: ^0.4.6 version: 0.4.6(next@15.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) @@ -104,9 +107,15 @@ importers: axios: specifier: ^1.7.7 version: 1.11.0 + bcryptjs: + specifier: ^3.0.2 + version: 3.0.2 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 + cloudinary: + specifier: ^2.7.0 + version: 2.7.0 clsx: specifier: ^2.1.1 version: 2.1.1 @@ -128,6 +137,9 @@ importers: js-cookie: specifier: ^3.0.5 version: 3.0.5 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 lucide-react: specifier: ^0.344.0 version: 0.344.0(react@19.1.0) @@ -195,6 +207,9 @@ importers: '@types/js-cookie': specifier: ^3.0.6 version: 3.0.6 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 '@types/node': specifier: ^20 version: 20.19.11 @@ -302,6 +317,10 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@google/generative-ai@0.24.1': + resolution: {integrity: sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==} + engines: {node: '>=18.0.0'} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1716,6 +1735,12 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@20.19.11': resolution: {integrity: sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==} @@ -2008,6 +2033,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + bcryptjs@3.0.2: + resolution: {integrity: sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==} + hasBin: true + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -2018,6 +2047,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -2066,6 +2098,10 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cloudinary@2.7.0: + resolution: {integrity: sha512-qrqDn31+qkMCzKu1GfRpzPNAO86jchcNwEHCUiqvPHNSFqu7FTNF9FuAkBUyvM1CFFgFPu64NT0DyeREwLwK0w==} + engines: {node: '>=9'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -2254,6 +2290,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + embla-carousel-react@8.6.0: resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} peerDependencies: @@ -2822,10 +2861,20 @@ packages: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2914,9 +2963,30 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -3182,6 +3252,14 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + q@1.5.1: + resolution: {integrity: sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==} + engines: {node: '>=0.6.0', teleport: '>=0.2.0'} + deprecated: |- + You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. + + (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) + querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} @@ -3934,6 +4012,8 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@google/generative-ai@0.24.1': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -5542,6 +5622,13 @@ snapshots: '@types/json5@0.0.29': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 20.19.11 + + '@types/ms@2.1.0': {} + '@types/node@20.19.11': dependencies: undici-types: 6.21.0 @@ -5866,6 +5953,8 @@ snapshots: base64-js@1.5.1: {} + bcryptjs@3.0.2: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -5879,6 +5968,8 @@ snapshots: dependencies: fill-range: 7.1.1 + buffer-equal-constant-time@1.0.1: {} + buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -5926,6 +6017,11 @@ snapshots: client-only@0.0.1: {} + cloudinary@2.7.0: + dependencies: + lodash: 4.17.21 + q: 1.5.1 + clsx@2.1.1: {} cmdk@1.1.1(@types/react-dom@19.1.7(@types/react@19.1.10))(@types/react@19.1.10)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): @@ -6098,6 +6194,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + embla-carousel-react@8.6.0(react@19.1.0): dependencies: embla-carousel: 8.6.0 @@ -6813,6 +6913,19 @@ snapshots: dependencies: minimist: 1.2.8 + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.2 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -6820,6 +6933,17 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jwa@1.4.2: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -6888,8 +7012,22 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash@4.17.21: {} loose-envify@1.4.0: @@ -7139,6 +7277,8 @@ snapshots: punycode@2.3.1: {} + q@1.5.1: {} + querystringify@2.2.0: {} queue-microtask@1.2.3: {} diff --git a/frontend/src/app/api/auth/login/route.ts b/frontend/src/app/api/auth/login/route.ts new file mode 100644 index 00000000..947300d4 --- /dev/null +++ b/frontend/src/app/api/auth/login/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { AuthService } from '@/../backend/auth/service'; +import { UserRepository } from '@/../backend/auth/repository'; + +const service = new AuthService(new UserRepository()); + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const res = await service.login(body.email, body.password); + return NextResponse.json(res); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} diff --git a/frontend/src/app/api/auth/register/route.ts b/frontend/src/app/api/auth/register/route.ts new file mode 100644 index 00000000..7bf13d59 --- /dev/null +++ b/frontend/src/app/api/auth/register/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { AuthService } from '@/../backend/auth/service'; +import { UserRepository } from '@/../backend/auth/repository'; + +const service = new AuthService(new UserRepository()); + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const res = await service.register( + body.full_name, + body.email, + body.password, + body.phone + ); + return NextResponse.json(res); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} diff --git a/frontend/src/app/api/categories/route.ts b/frontend/src/app/api/categories/route.ts new file mode 100644 index 00000000..41eef479 --- /dev/null +++ b/frontend/src/app/api/categories/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { CategoryService } from '@/../backend/back/category/service'; + +const service = new CategoryService(); + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const pathname = url.pathname.replace('/api/categories', '') || '/'; + try { + if (pathname === '/' || pathname === '') { + const categories = await service.listCategories(); + return NextResponse.json({ success: true, categories }); + } + const parts = pathname.split('/').filter(Boolean); + if (parts[0]) { + const category = await service.getCategory(parts[0]); + return NextResponse.json({ success: true, category }); + } + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} diff --git a/frontend/src/app/api/checkout/polar/route.ts b/frontend/src/app/api/checkout/polar/route.ts new file mode 100644 index 00000000..23decf11 --- /dev/null +++ b/frontend/src/app/api/checkout/polar/route.ts @@ -0,0 +1,134 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { polarAPI } from '@/utils/polar.utils'; +import { AuthService } from '@/../backend/auth/service'; +import { cookies } from 'next/headers'; + +function getBearerFromHeader(req: NextRequest): string | undefined { + const authHeader = req.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) return undefined; + return authHeader.split(' ')[1]; +} + +async function getCurrentUser(req: NextRequest) { + const cookieStore = await cookies(); + const cookieToken = cookieStore.get('zatobox_token')?.value; + const headerToken = getBearerFromHeader(req); + const authToken = cookieToken || headerToken; + if (!authToken) throw new Error('Missing authentication token'); + + const authService = new AuthService(); + const user = await authService.verifyToken(authToken); + const profile = await authService.getProfileUser(String(user.id)); + const polarApiKey = profile.user?.polar_api_key || ''; + if (!polarApiKey) throw new Error('Missing Polar API key for user'); + return { + userId: user.id, + userEmail: user.email, + polarApiKey, + polarOrganizationId: profile.user?.polar_organization_id || '', + }; +} + +export async function POST(req: NextRequest) { + try { + const { polarApiKey, polarOrganizationId } = await getCurrentUser(req); + const { userId, items, successUrl, metadata } = await req.json(); + + if (!userId || !items || !Array.isArray(items) || items.length === 0) { + return NextResponse.json( + { success: false, message: 'Invalid cart data' }, + { status: 400 } + ); + } + + const totalPrice = items.reduce((sum: number, item: any) => { + const itemPrice = item.productData?.prices?.[0]?.price_amount || 0; + return sum + itemPrice * item.quantity; + }, 0); + + const cartDescription = items + .map( + (item: any) => + `${item.productData?.name || 'Producto'} (x${item.quantity})` + ) + .join(', '); + + const allImages = items.reduce((images: any[], item: any) => { + if (item.productData?.medias) { + images.push(...item.productData.medias); + } + return images; + }, []); + + const combinedMetadata = { + user_id: userId, + cart_total: totalPrice.toString(), + item_count: items.length.toString(), + items: JSON.stringify( + items.map((item: any) => ({ + name: item.productData?.name, + quantity: item.quantity, + price: item.productData?.prices?.[0]?.price_amount, + metadata: item.productData?.metadata, + })) + ), + ...metadata, + }; + + const cartProduct = await polarAPI.createProduct(polarApiKey, { + name: `Order #${Math.floor(Math.random() * 1000000)}`, + description: `Products: ${cartDescription}`, + prices: [ + { + amount_type: 'fixed', + price_amount: totalPrice, + price_currency: 'usd', + }, + ], + metadata: combinedMetadata, + medias: allImages.slice(0, 5), + }); + + const checkoutData = { + product_id: cartProduct.id, + success_url: + successUrl || + `${process.env.NEXT_PUBLIC_URL || 'http://localhost:3000'}/success`, + metadata: { + user_id: userId, + cart_product_id: cartProduct.id, + ...metadata, + }, + }; + + const checkout = await fetch('https://api.polar.sh/v1/checkouts', { + method: 'POST', + headers: { + Authorization: `Bearer ${polarApiKey}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(checkoutData), + }); + + if (!checkout.ok) { + const errorText = await checkout.text(); + throw new Error(`Polar checkout error: ${errorText}`); + } + + const checkoutResponse = await checkout.json(); + + return NextResponse.json({ + success: true, + checkout_url: checkoutResponse.url, + checkout_id: checkoutResponse.id, + cart_product_id: cartProduct.id, + }); + } catch (error: any) { + console.error('Polar checkout error:', error); + return NextResponse.json( + { success: false, message: error.message || 'Failed to create checkout' }, + { status: 500 } + ); + } +} diff --git a/frontend/src/app/api/inventory/route.ts b/frontend/src/app/api/inventory/route.ts new file mode 100644 index 00000000..a3523a3a --- /dev/null +++ b/frontend/src/app/api/inventory/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { InventoryService } from '@/../backend/back/inventory/service'; + +const service = new InventoryService(); + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const pathname = url.pathname.replace('/api/inventory', '') || '/'; + try { + if (pathname === '/' || pathname === '') { + const userId = req.headers.get('x-user-id') || 'anonymous'; + const inventory = await service.getInventoryResponse(userId); + return NextResponse.json(inventory); + } + const parts = pathname.split('/').filter(Boolean); + if (parts[0] === 'user') { + const userId = req.headers.get('x-user-id') || 'anonymous'; + const inventory = await service.getInventoryResponse(userId); + return NextResponse.json(inventory); + } + if (parts[0] === 'summary') { + const userId = req.headers.get('x-user-id') || 'anonymous'; + const summary = await service.getInventorySummary(userId); + return NextResponse.json({ success: true, summary }); + } + if (parts[0] === 'low-stock') { + const userId = req.headers.get('x-user-id') || 'anonymous'; + const q = url.searchParams.get('threshold') || '0'; + const threshold = parseInt(q, 10) || 0; + const items = await service.checkLowStock(userId, threshold); + return NextResponse.json({ success: true, low_stock_products: items }); + } + if (parts[0] === 'user' || parts[0] === '') { + const userId = req.headers.get('x-user-id') || 'anonymous'; + const inventory = await service.getInventoryResponse(userId); + return NextResponse.json(inventory); + } + if (parts[0]) { + const userId = req.headers.get('x-user-id') || 'anonymous'; + const item = await service.getInventoryItem(userId, parts[0]); + if (!item) + return NextResponse.json( + { success: false, message: 'Product not found in inventory' }, + { status: 404 } + ); + return NextResponse.json({ success: true, product: item }); + } + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} + +export async function PUT(req: NextRequest) { + try { + const url = new URL(req.url); + const parts = url.pathname + .replace('/api/inventory', '') + .split('/') + .filter(Boolean); + if (!parts[0]) + return NextResponse.json( + { success: false, message: 'Missing product id' }, + { status: 400 } + ); + const productId = parts[0]; + const body = await req.json(); + const quantity = body.quantity as number; + const reason = body.reason as string | undefined; + const userId = req.headers.get('x-user-id') || 'anonymous'; + await service.updateStock(); + return NextResponse.json( + { success: false, message: 'Updating stock not supported' }, + { status: 400 } + ); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} diff --git a/frontend/src/app/api/layouts/route.ts b/frontend/src/app/api/layouts/route.ts new file mode 100644 index 00000000..963c3cb2 --- /dev/null +++ b/frontend/src/app/api/layouts/route.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { LayoutService } from '@/../backend/back/layout/service'; + +const service = new LayoutService(); + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const owner = req.headers.get('x-user-id') || 'anonymous'; + const layout = await service.createLayout(body, owner); + return NextResponse.json({ + success: true, + message: 'Layout created successfully', + layout, + }); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const pathname = url.pathname.replace('/api/layouts', '') || '/'; + try { + if (pathname === '/' || pathname === '') { + const layouts = await service.listLayouts(); + return NextResponse.json({ success: true, layouts }); + } + const parts = pathname.split('/').filter(Boolean); + if (parts[0] === 'owner' && parts[1]) { + const layouts = await service.listLayoutsByOwner(parts[1]); + return NextResponse.json({ success: true, layouts }); + } + if (parts[0] === 'inventory' && parts[1]) { + const layouts = await service.listLayoutsByInventory(parts[1]); + return NextResponse.json({ success: true, layouts }); + } + if (parts[0]) { + const layout = await service.getLayout(parts[0]); + return NextResponse.json({ + success: true, + message: 'Layout found', + layout, + }); + } + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} + +export async function PUT(req: NextRequest) { + const url = new URL(req.url); + const pathname = url.pathname.replace('/api/layouts', '') || '/'; + const parts = pathname.split('/').filter(Boolean); + if (!parts[0]) + return NextResponse.json( + { success: false, message: 'Missing layout slug' }, + { status: 400 } + ); + try { + const updates = await req.json(); + const updated = await service.updateLayout(parts[0], updates); + return NextResponse.json({ + success: true, + message: 'Layout updated successfully', + layout: updated, + }); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} + +export async function DELETE(req: NextRequest) { + const url = new URL(req.url); + const pathname = url.pathname.replace('/api/layouts', '') || '/'; + const parts = pathname.split('/').filter(Boolean); + if (!parts[0]) + return NextResponse.json( + { success: false, message: 'Missing layout slug' }, + { status: 400 } + ); + try { + const deleted = await service.deleteLayout(parts[0]); + return NextResponse.json({ + success: true, + message: 'Layout deleted successfully', + layout: deleted, + }); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} diff --git a/frontend/src/app/api/ocr/route.ts b/frontend/src/app/api/ocr/route.ts new file mode 100644 index 00000000..57c85c15 --- /dev/null +++ b/frontend/src/app/api/ocr/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { processImageFile, sendBulk } from '@/../backend/OCR/handlers'; + +export async function GET() { + return NextResponse.json({ message: 'ZatoBox OCR API con Gemini' }); +} + +export async function POST(req: NextRequest) { + const url = new URL(req.url); + const pathname = url.pathname.replace('/api/ocr', '') || '/'; + + try { + if (pathname === '/' || pathname === '' || pathname === '/ocr') { + const contentType = req.headers.get('content-type') || ''; + + if (!contentType.includes('multipart/form-data')) { + return NextResponse.json({ message: 'ZatoBox OCR API with Gemini' }); + } + + const form = await req.formData(); + const file = form.get('file') as File | null; + if (!file) + return NextResponse.json( + { detail: 'No file found in form-data' }, + { status: 400 } + ); + const authHeader = req.headers.get('authorization'); + const result = await processImageFile({ + file, + authHeader, + env: process.env, + }); + return NextResponse.json(result); + } + + if (pathname === '/bulk') { + const body = await req.json(); + const authHeader = req.headers.get('authorization'); + const out = await sendBulk({ data: body, authHeader, env: process.env }); + return NextResponse.json(out, { status: out.status }); + } + + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } catch (err: any) { + if (err?.status === 429) + return NextResponse.json( + { detail: 'Too many requests. Please wait a moment and try again.' }, + { status: 429 } + ); + if (err?.status === 400) + return NextResponse.json( + { detail: 'Invalid file type' }, + { status: 400 } + ); + return NextResponse.json( + { detail: `Error: ${err?.message ?? String(err)}` }, + { status: 500 } + ); + } +} diff --git a/frontend/src/app/api/products/bulk/route.ts b/frontend/src/app/api/products/bulk/route.ts new file mode 100644 index 00000000..42932500 --- /dev/null +++ b/frontend/src/app/api/products/bulk/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { polarAPI } from '@/utils/polar.utils'; +import { AuthService } from '@/../backend/auth/service'; +import { cookies } from 'next/headers'; + +function getBearerFromHeader(req: NextRequest): string | undefined { + const authHeader = req.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) return undefined; + return authHeader.split(' ')[1]; +} + +async function getCurrentUser(req: NextRequest) { + const cookieStore = await cookies(); + const cookieToken = cookieStore.get('zatobox_token')?.value; + const headerToken = getBearerFromHeader(req); + const authToken = cookieToken || headerToken; + if (!authToken) throw new Error('Missing authentication token'); + + const authService = new AuthService(); + const user = await authService.verifyToken(authToken); + const profile = await authService.getProfileUser(String(user.id)); + const polarApiKey = profile.user?.polar_api_key || ''; + if (!polarApiKey) throw new Error('Missing Polar API key for user'); + + return { + userId: user.id, + userEmail: user.email, + polarApiKey, + polarOrganizationId: profile.user?.polar_organization_id || '', + }; +} + +export async function POST(req: NextRequest) { + try { + const { polarApiKey } = await getCurrentUser(req); + const { products } = await req.json(); + + if (!products || !Array.isArray(products) || products.length === 0) { + return NextResponse.json( + { success: false, message: 'No products provided' }, + { status: 400 } + ); + } + + const createdProducts = []; + const errors = []; + + for (const productData of products) { + try { + if (!productData.name) { + errors.push({ error: 'Product name is required', data: productData }); + continue; + } + + const product = await polarAPI.createProduct(polarApiKey, { + name: productData.name, + description: productData.description || '', + recurring_interval: null, + prices: productData.prices || [{ + amount_type: 'fixed', + price_currency: 'usd', + price_amount: productData.price_amount || 0 + }], + metadata: productData.metadata || {} + }); + + createdProducts.push(product); + } catch (error: any) { + errors.push({ + error: error.message || 'Failed to create product', + data: productData + }); + } + } + + return NextResponse.json({ + success: true, + created: createdProducts.length, + errors: errors.length, + products: createdProducts, + failed: errors + }); + + } catch (error: any) { + return NextResponse.json( + { success: false, message: error.message || 'Failed to create products' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/frontend/src/app/api/products/route.ts b/frontend/src/app/api/products/route.ts new file mode 100644 index 00000000..161e00c4 --- /dev/null +++ b/frontend/src/app/api/products/route.ts @@ -0,0 +1,194 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { polarAPI } from '@/utils/polar.utils'; +import { AuthService } from '@/../backend/auth/service'; + +async function getCurrentUser(req: NextRequest) { + const authHeader = req.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new Error('Missing authentication token'); + } + + const token = authHeader.split(' ')[1]; + const authService = new AuthService(); + + try { + const user = await authService.verifyToken(token); + const profile = await authService.getProfileUser(String(user.id)); + + const polarApiKey = profile.user?.polar_api_key || ''; + if (!polarApiKey) { + throw new Error('Missing Polar API key for user'); + } + + return { + userId: user.id, + userEmail: user.email, + polarApiKey: polarApiKey, + polarOrganizationId: profile.user?.polar_organization_id || '', + }; + } catch (error) { + throw new Error('Invalid authentication'); + } +} + +export async function GET(req: NextRequest) { + try { + const { polarApiKey } = await getCurrentUser(req); + const url = new URL(req.url); + const productId = url.searchParams.get('id'); + const organizationId = url.searchParams.get('organization_id') || undefined; + + if (productId) { + const product = await polarAPI.getProduct(polarApiKey, productId); + return NextResponse.json({ success: true, product }); + } else { + const products = await polarAPI.listProducts(polarApiKey, organizationId); + const filteredProducts = products.filter( + (product) => !product.name || !product.name.startsWith('Order #') + ); + return NextResponse.json({ success: true, products: filteredProducts }); + } + } catch (error: any) { + return NextResponse.json( + { success: false, message: error.message || 'Failed to fetch products' }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest) { + try { + const { polarApiKey, polarOrganizationId } = await getCurrentUser(req); + const contentType = req.headers.get('content-type') || ''; + + if (contentType.includes('multipart/form-data')) { + const form = await req.formData(); + const name = String(form.get('name') || ''); + const description = String(form.get('description') || ''); + const organizationId = String(polarOrganizationId || ''); + const billing = String(form.get('billing_interval') || 'once'); + const priceStr = String(form.get('price') || '0'); + const stockStr = String(form.get('stock') || '0'); + const file = form.get('image') as File | null; + + if (!name) { + return NextResponse.json( + { success: false, message: 'name is required' }, + { status: 400 } + ); + } + + if (file && file.size > 0) { + try { + await polarAPI.uploadFile( + polarApiKey, + file, + organizationId, + 'product_media' + ); + } catch {} + } + + const priceAmount = Math.round(Number(priceStr) * 100); + const isOnce = billing === 'once'; + const prices = [ + { + amount_type: 'fixed', + price_currency: 'usd', + price_amount: priceAmount, + type: isOnce ? 'one_time' : 'recurring', + recurring_interval: isOnce ? undefined : billing, + legacy: false, + is_archived: false, + }, + ]; + + const product = await polarAPI.createProduct(polarApiKey, { + name, + description: description || undefined, + recurring_interval: isOnce ? null : billing, + prices, + metadata: { quantity: Number(stockStr || '0') }, + }); + return NextResponse.json({ success: true, product }); + } else { + const body: any = await req.json(); + if (!body.name) { + return NextResponse.json( + { success: false, message: 'name is required' }, + { status: 400 } + ); + } + const prices = Array.isArray(body.prices) ? body.prices : []; + const product = await polarAPI.createProduct(polarApiKey, { + name: body.name, + description: body.description, + recurring_interval: + typeof body.recurring_interval === 'string' + ? body.recurring_interval + : null, + prices, + metadata: body.metadata || {}, + }); + return NextResponse.json({ success: true, product }); + } + } catch (error: any) { + return NextResponse.json( + { success: false, message: error.message || 'Failed to create product' }, + { status: 500 } + ); + } +} + +export async function PATCH(req: NextRequest) { + try { + const { polarApiKey } = await getCurrentUser(req); + const url = new URL(req.url); + const productId = url.searchParams.get('id'); + + if (!productId) { + return NextResponse.json( + { success: false, message: 'Product ID is required' }, + { status: 400 } + ); + } + + const body: any = await req.json(); + const product = await polarAPI.updateProduct(polarApiKey, productId, body); + return NextResponse.json({ success: true, product }); + } catch (error: any) { + return NextResponse.json( + { success: false, message: error.message || 'Failed to update product' }, + { status: 500 } + ); + } +} + +export async function DELETE(req: NextRequest) { + try { + const { polarApiKey } = await getCurrentUser(req); + const url = new URL(req.url); + const productId = url.searchParams.get('id'); + + if (!productId) { + return NextResponse.json( + { success: false, message: 'Product ID is required' }, + { status: 400 } + ); + } + + const product = await polarAPI.updateProduct(polarApiKey, productId, { + is_archived: true, + }); + return NextResponse.json({ + success: true, + message: 'Product archived successfully', + product, + }); + } catch (error: any) { + return NextResponse.json( + { success: false, message: error.message || 'Failed to archive product' }, + { status: 500 } + ); + } +} diff --git a/frontend/src/app/api/profile/image/route.ts b/frontend/src/app/api/profile/image/route.ts new file mode 100644 index 00000000..fbb7992b --- /dev/null +++ b/frontend/src/app/api/profile/image/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { AuthService } from '@/../backend/auth/service'; + +const service = new AuthService(); + +async function getBearerToken(req: NextRequest) { + const header = req.headers.get('authorization') || ''; + if (!header) return null; + const parts = header.split(' '); + if (parts.length === 2 && parts[0].toLowerCase() === 'bearer') + return parts[1]; + return header; +} + +export async function POST(req: NextRequest) { + try { + const token = await getBearerToken(req); + if (!token) + return NextResponse.json( + { success: false, message: 'Missing token' }, + { status: 401 } + ); + const user = await service.verifyToken(token); + const form = await req.formData(); + const file = form.get('file') as File | null; + if (!file) + return NextResponse.json( + { success: false, message: 'Missing file' }, + { status: 400 } + ); + const res = await service.uploadProfileImage(String(user.id), file); + return NextResponse.json(res); + } catch (e: any) { + return NextResponse.json( + { success: false, message: String(e?.message ?? e) }, + { status: 500 } + ); + } +} + +export async function PATCH(req: NextRequest) { + try { + const token = await getBearerToken(req); + if (!token) + return NextResponse.json( + { success: false, message: 'Missing token' }, + { status: 401 } + ); + const user = await service.verifyToken(token); + const form = await req.formData(); + const file = form.get('file') as File | null; + if (!file) + return NextResponse.json( + { success: false, message: 'Missing file' }, + { status: 400 } + ); + const res = await service.updateProfileImage(String(user.id), file); + return NextResponse.json(res); + } catch (e: any) { + return NextResponse.json( + { success: false, message: String(e?.message ?? e) }, + { status: 500 } + ); + } +} + +export async function DELETE(req: NextRequest) { + try { + const token = await getBearerToken(req); + if (!token) + return NextResponse.json( + { success: false, message: 'Missing token' }, + { status: 401 } + ); + const user = await service.verifyToken(token); + const res = await service.deleteProfileImage(String(user.id)); + return NextResponse.json(res); + } catch (e: any) { + return NextResponse.json( + { success: false, message: String(e?.message ?? e) }, + { status: 500 } + ); + } +} + +export const runtime = 'nodejs'; diff --git a/frontend/src/app/api/profile/route.ts b/frontend/src/app/api/profile/route.ts new file mode 100644 index 00000000..68ec837c --- /dev/null +++ b/frontend/src/app/api/profile/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { AuthService } from '@/../backend/auth/service'; + +const service = new AuthService(); + +async function getBearerToken(req: NextRequest) { + const header = req.headers.get('authorization') || ''; + if (!header) return null; + const parts = header.split(' '); + if (parts.length === 2 && parts[0].toLowerCase() === 'bearer') + return parts[1]; + return header; +} + +export async function GET(req: NextRequest) { + try { + const token = await getBearerToken(req); + if (!token) + return NextResponse.json( + { success: false, message: 'Missing token' }, + { status: 401 } + ); + const user = await service.verifyToken(token); + const out = await service.getProfileUser(String(user.id)); + return NextResponse.json(out); + } catch (e: any) { + return NextResponse.json( + { success: false, message: String(e?.message ?? e) }, + { status: 401 } + ); + } +} + +export async function PATCH(req: NextRequest) { + try { + const token = await getBearerToken(req); + if (!token) + return NextResponse.json( + { success: false, message: 'Missing token' }, + { status: 401 } + ); + const user = await service.verifyToken(token); + const body = await req.json(); + const res = await service.updateProfile(String(user.id), body || {}); + return NextResponse.json(res); + } catch (e: any) { + return NextResponse.json( + { success: false, message: String(e?.message ?? e) }, + { status: 500 } + ); + } +} + +export async function DELETE(req: NextRequest) { + try { + const token = await getBearerToken(req); + if (!token) + return NextResponse.json( + { success: false, message: 'Missing token' }, + { status: 401 } + ); + const user = await service.verifyToken(token); + const res = await service.deleteUser(String(user.id)); + return NextResponse.json(res); + } catch (e: any) { + return NextResponse.json( + { success: false, message: String(e?.message ?? e) }, + { status: 500 } + ); + } +} + +export const runtime = 'nodejs'; diff --git a/frontend/src/app/api/webhooks/polar/route.ts b/frontend/src/app/api/webhooks/polar/route.ts index 17d6a13a..856d9902 100644 --- a/frontend/src/app/api/webhooks/polar/route.ts +++ b/frontend/src/app/api/webhooks/polar/route.ts @@ -1,5 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/utils/supabase/server'; +import { AuthService } from '@/../backend/auth/service'; +import { polarAPI } from '@/utils/polar.utils'; import crypto from 'crypto'; function verifyPolarSignature(payload: string, signature: string): boolean { @@ -22,6 +24,8 @@ export async function POST(request: NextRequest) { try { const payload = await request.text(); const signature = request.headers.get('x-polar-signature') || ''; + const url = new URL(request.url); + const userId = url.searchParams.get('user_id'); if (!verifyPolarSignature(payload, signature)) { return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }); @@ -31,6 +35,12 @@ export async function POST(request: NextRequest) { const eventType = data.type; const eventData = data.data || {}; + console.log(`Webhook received${userId ? ` for user ${userId}` : ''}:`, { + type: eventType, + id: eventData.id, + status: eventData.status, + }); + if ( ![ 'subscription.created', @@ -38,78 +48,200 @@ export async function POST(request: NextRequest) { 'subscription.active', 'subscription.canceled', 'subscription.revoked', + 'checkout.created', + 'checkout.updated', + 'order.created', + 'order.paid', ].includes(eventType) ) { return NextResponse.json({ status: 'ignored' }); } const supabase = await createClient(); - const polarSubId = eventData.id; - if (!polarSubId) { - return NextResponse.json( - { error: 'Missing subscription ID' }, - { status: 400 } - ); + if (eventType.startsWith('subscription.')) { + await handleSubscriptionEvent(supabase, eventType, eventData); + } else if (eventType.startsWith('checkout.')) { + await handleCheckoutEvent(supabase, userId, eventType, eventData); + } else if (eventType.startsWith('order.')) { + await handleOrderEvent(supabase, userId, eventType, eventData); } - if (eventType === 'subscription.created') { - const subscriptionData = { - user_id: eventData.customer_id || '', - plan: eventData.product?.name || '', - cycle: eventData.recurring_interval || 'monthly', - status: 'created', - start_date: new Date(eventData.created_at).toISOString(), - polar_subscription_id: polarSubId, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }; - - await supabase.from('subscriptions').insert(subscriptionData); - } else if ( - [ - 'subscription.updated', - 'subscription.active', - 'subscription.canceled', - 'subscription.revoked', - ].includes(eventType) - ) { - const statusMap: Record = { - 'subscription.active': 'active', - 'subscription.canceled': 'canceled', - 'subscription.revoked': 'revoked', - 'subscription.updated': eventData.status || 'active', - }; - - const updateData: any = { - status: statusMap[eventType], - updated_at: new Date().toISOString(), - }; - - if (eventData.product?.name) { - updateData.plan = eventData.product.name; - } + return NextResponse.json({ status: 'success' }); + } catch (error) { + console.error('Webhook error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +async function handleSubscriptionEvent( + supabase: any, + eventType: string, + eventData: any +) { + const polarSubId = eventData.id; + if (!polarSubId) return; + + if (eventType === 'subscription.created') { + const subscriptionData = { + user_id: eventData.customer_id || '', + plan: eventData.product?.name || '', + cycle: eventData.recurring_interval || 'monthly', + status: 'created', + start_date: new Date(eventData.created_at).toISOString(), + polar_subscription_id: polarSubId, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + await supabase.from('subscriptions').insert(subscriptionData); + } else { + const statusMap: Record = { + 'subscription.active': 'active', + 'subscription.canceled': 'canceled', + 'subscription.revoked': 'revoked', + 'subscription.updated': eventData.status || 'active', + }; + + const updateData: any = { + status: statusMap[eventType], + updated_at: new Date().toISOString(), + }; + + if (eventData.product?.name) updateData.plan = eventData.product.name; + if (eventData.recurring_interval) + updateData.cycle = eventData.recurring_interval; + if (eventData.canceled_at) + updateData.end_date = new Date(eventData.canceled_at).toISOString(); + + await supabase + .from('subscriptions') + .update(updateData) + .eq('polar_subscription_id', polarSubId); + } +} + +async function handleCheckoutEvent( + supabase: any, + userId: string | null, + eventType: string, + eventData: any +) { + const status = eventData.status; + + if (eventType === 'checkout.created') { + console.log( + `Checkout created${userId ? ` for user ${userId}` : ''}:`, + eventData.id + ); + } else if (eventType === 'checkout.updated') { + if (status === 'succeeded') { + console.log( + `Checkout succeeded${userId ? ` for user ${userId}` : ''}:`, + eventData.id + ); - if (eventData.recurring_interval) { - updateData.cycle = eventData.recurring_interval; + if (userId) { + await updateProductStock(userId, eventData); } + } else if (status === 'failed') { + console.log( + `Checkout failed${userId ? ` for user ${userId}` : ''}:`, + eventData.id + ); + } + } +} + +async function handleOrderEvent( + supabase: any, + userId: string | null, + eventType: string, + eventData: any +) { + if (eventType === 'order.created') { + console.log( + `Order created${userId ? ` for user ${userId}` : ''}:`, + eventData.id + ); + } else if (eventType === 'order.paid') { + console.log( + `Order paid${userId ? ` for user ${userId}` : ''}:`, + eventData.id + ); - if (eventData.canceled_at) { - updateData.end_date = new Date(eventData.canceled_at).toISOString(); + if (userId) { + try { + const authService = new AuthService(); + await authService.updateProfile(userId, { + last_successful_order: eventData.id, + last_order_date: new Date().toISOString(), + }); + + console.log( + `Updated user profile for successful order: ${eventData.id}` + ); + } catch (error) { + console.error('Failed to update user profile:', error); } + } + } +} - await supabase - .from('subscriptions') - .update(updateData) - .eq('polar_subscription_id', polarSubId); +async function updateProductStock(userId: string, checkoutData: any) { + try { + const authService = new AuthService(); + const profile = await authService.getProfileUser(userId); + const polarApiKey = profile.user?.polar_api_key; + + if (!polarApiKey) { + console.error('No Polar API key found for user:', userId); + return; } - return NextResponse.json({ status: 'success' }); + const product = checkoutData.product; + if (!product || !product.metadata) { + console.error('No product or metadata found in checkout data'); + return; + } + + const cartItems = JSON.parse(product.metadata.items || '[]'); + + for (const item of cartItems) { + if (!item.metadata || !item.metadata.product_id) continue; + + const productId = item.metadata.product_id; + const quantityPurchased = item.quantity || 1; + + try { + const currentProduct = await polarAPI.getProduct( + polarApiKey, + productId + ); + const currentStock = currentProduct.metadata?.quantity || 0; + const newStock = Math.max(0, currentStock - quantityPurchased); + + await polarAPI.updateProduct(polarApiKey, productId, { + metadata: { + ...currentProduct.metadata, + quantity: newStock, + }, + }); + + console.log( + `Updated stock for product ${productId}: ${currentStock} -> ${newStock}` + ); + } catch (error) { + console.error( + `Failed to update stock for product ${productId}:`, + error + ); + } + } } catch (error) { - console.error('Webhook error:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); + console.error('Error updating product stock:', error); } } diff --git a/frontend/src/app/api/webhooks/setup/route.ts b/frontend/src/app/api/webhooks/setup/route.ts new file mode 100644 index 00000000..ae42e7f5 --- /dev/null +++ b/frontend/src/app/api/webhooks/setup/route.ts @@ -0,0 +1,183 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { AuthService } from '@/../backend/auth/service'; +import { cookies } from 'next/headers'; + +function getBearerFromHeader(req: NextRequest): string | undefined { + const authHeader = req.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) return undefined; + return authHeader.split(' ')[1]; +} + +async function getCurrentUser(req: NextRequest) { + const cookieStore = await cookies(); + const cookieToken = cookieStore.get('zatobox_token')?.value; + const headerToken = getBearerFromHeader(req); + const authToken = cookieToken || headerToken; + if (!authToken) throw new Error('Missing authentication token'); + + const authService = new AuthService(); + const user = await authService.verifyToken(authToken); + const profile = await authService.getProfileUser(String(user.id)); + const polarApiKey = profile.user?.polar_api_key || ''; + const polarOrganizationId = profile.user?.polar_organization_id || ''; + if (!polarApiKey) throw new Error('Missing Polar API key for user'); + if (!polarOrganizationId) + throw new Error('Missing Polar organization ID for user'); + + return { + userId: user.id, + userEmail: user.email, + polarApiKey, + polarOrganizationId, + }; +} + +export async function POST(req: NextRequest) { + try { + const { userId, polarApiKey, polarOrganizationId } = await getCurrentUser( + req + ); + + const webhookUrl = `https://zatobox.io/api/webhooks/polar?user_id=${userId}`; + const webhookSecret = `zatobox_${userId}_${Date.now()}`; + + const webhookData = { + url: webhookUrl, + secret: webhookSecret, + format: 'raw', + events: [ + 'checkout.created', + 'checkout.updated', + 'order.created', + 'order.paid', + 'subscription.created', + 'subscription.updated', + ], + organization_id: polarOrganizationId, + }; + + const response = await fetch('https://api.polar.sh/v1/webhooks/endpoints', { + method: 'POST', + headers: { + Authorization: `Bearer ${polarApiKey}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(webhookData), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to create webhook: ${errorText}`); + } + + const webhook = await response.json(); + + const authService = new AuthService(); + await authService.updateProfile(String(userId), { + webhook_endpoint_id: webhook.id, + webhook_secret: webhookSecret, + webhook_url: webhookUrl, + }); + + return NextResponse.json({ + success: true, + webhook: { + id: webhook.id, + url: webhookUrl, + events: webhookData.events, + organization_id: polarOrganizationId, + }, + message: 'Webhook configured successfully', + }); + } catch (error: any) { + console.error('Webhook setup error:', error); + return NextResponse.json( + { success: false, message: error.message || 'Failed to setup webhook' }, + { status: 500 } + ); + } +} + +export async function GET(req: NextRequest) { + try { + const { polarApiKey, polarOrganizationId } = await getCurrentUser(req); + + const response = await fetch( + `https://api.polar.sh/v1/webhooks/endpoints?organization_id=${polarOrganizationId}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${polarApiKey}`, + Accept: 'application/json', + }, + } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to list webhooks: ${errorText}`); + } + + const webhooks = await response.json(); + + return NextResponse.json({ + success: true, + webhooks: webhooks.items || webhooks, + }); + } catch (error: any) { + console.error('Webhook list error:', error); + return NextResponse.json( + { success: false, message: error.message || 'Failed to list webhooks' }, + { status: 500 } + ); + } +} + +export async function DELETE(req: NextRequest) { + try { + const { userId, polarApiKey } = await getCurrentUser(req); + const { endpointId } = await req.json(); + + if (!endpointId) { + return NextResponse.json( + { success: false, message: 'Endpoint ID is required' }, + { status: 400 } + ); + } + + const response = await fetch( + `https://api.polar.sh/v1/webhooks/endpoints/${endpointId}`, + { + method: 'DELETE', + headers: { + Authorization: `Bearer ${polarApiKey}`, + Accept: 'application/json', + }, + } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to delete webhook: ${errorText}`); + } + + const authService = new AuthService(); + await authService.updateProfile(String(userId), { + webhook_endpoint_id: null, + webhook_secret: null, + webhook_url: null, + }); + + return NextResponse.json({ + success: true, + message: 'Webhook deleted successfully', + }); + } catch (error: any) { + console.error('Webhook delete error:', error); + return NextResponse.json( + { success: false, message: error.message || 'Failed to delete webhook' }, + { status: 500 } + ); + } +} diff --git a/frontend/src/app/edit-product/[id]/page.tsx b/frontend/src/app/edit-product/[id]/page.tsx index eef3030a..77fe2e5c 100644 --- a/frontend/src/app/edit-product/[id]/page.tsx +++ b/frontend/src/app/edit-product/[id]/page.tsx @@ -1,16 +1,14 @@ 'use client'; -import React, { useEffect, useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useRouter, useParams } from 'next/navigation'; import { productsAPI, categoriesAPI } from '@/services/api.service'; -import { Product } from '@/types/index'; import { useAuth } from '@/context/auth-store'; -import EditHeader from '@/components/edit-product/EditHeader'; -import ProductForm from '@/components/edit-product/ProductForm'; -import ImagesUploader from '@/components/edit-product/ImagesUploader'; -import Categorization from '@/components/edit-product/Categorization'; -import InventoryPanel from '@/components/edit-product/InventoryPanel'; -import DeleteConfirmModal from '@/components/inventory/DeleteConfirmModal'; +import { Formik, Form } from 'formik'; +import * as Yup from 'yup'; +import { FaRegFolder } from 'react-icons/fa6'; +import { IoMdArrowRoundBack } from 'react-icons/io'; +import ImagesUploader from '@/components/new-product/ImagesUploader'; const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp']; const MAX_FILES = 4; @@ -19,32 +17,15 @@ const MAX_SIZE = 5 * 1024 * 1024; const EditProductPage: React.FC = () => { const router = useRouter(); const params = useParams(); - const { isAuthenticated } = useAuth(); + const { isAuthenticated, user } = useAuth(); const id = (params as any)?.id as string | undefined; const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [formData, setFormData] = useState({ - productType: '', - name: '', - description: '', - location: '', - weight: '', - price: '', - inventoryQuantity: '', - lowStockAlert: '', - sku: '', - }); - const [originalProduct, setOriginalProduct] = useState(null); - const [selectedCategories, setSelectedCategories] = useState([]); const [saving, setSaving] = useState(false); - const [togglingStatus, setTogglingStatus] = useState(false); - const [status, setStatus] = useState<'active' | 'inactive' | ''>(''); + const [error, setError] = useState(null); + const [files, setFiles] = useState([]); const [existingImages, setExistingImages] = useState([]); - const [newFiles, setNewFiles] = useState([]); - const [uploadingImages, setUploadingImages] = useState(false); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); + const [productData, setProductData] = useState(null); const [categories, setCategories] = useState<{ id: string; name: string }[]>( [] @@ -57,8 +38,8 @@ const EditProductPage: React.FC = () => { setLoadingCategories(true); try { const res = await categoriesAPI.list(); - if (active && (res as any).success) - setCategories((res as any).categories); + if (active && res.success) setCategories(res.categories); + } catch { } finally { if (active) setLoadingCategories(false); } @@ -70,13 +51,12 @@ const EditProductPage: React.FC = () => { }, []); useEffect(() => { - if (!id || typeof id !== 'string') { - setError('Invalid product id'); + if (!id) { + setError('Product ID is required'); setLoading(false); return; } fetchProduct(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]); const fetchProduct = async () => { @@ -84,31 +64,19 @@ const EditProductPage: React.FC = () => { setError(null); try { const res = await productsAPI.getById(id!); - const prod = (res as any).product as Product; - if (!prod) { + if (res && res.success && res.product) { + const product = res.product; + setProductData(product); + setExistingImages( + product.medias + ?.filter( + (m: any) => m.public_url && m.mime_type?.startsWith('image/') + ) + .map((m: any) => m.public_url) || [] + ); + } else { setError('Product not found'); - setLoading(false); - return; } - setOriginalProduct(prod); - setStatus((prod.status as 'active' | 'inactive') ?? ''); - setFormData({ - productType: prod.product_type ?? '', - name: prod.name ?? '', - description: prod.description ?? '', - location: prod.localization ?? '', - weight: prod.weight != null ? String(prod.weight) : '', - price: prod.price != null ? String(prod.price) : '', - inventoryQuantity: prod.stock != null ? String(prod.stock) : '', - lowStockAlert: prod.min_stock != null ? String(prod.min_stock) : '', - sku: prod.sku ?? '', - }); - setSelectedCategories( - Array.isArray((prod as any).category_ids) - ? (prod as any).category_ids - : [] - ); - setExistingImages((prod.images || []).slice(0, MAX_FILES)); } catch (err) { setError(err instanceof Error ? err.message : 'Error loading product'); } finally { @@ -116,227 +84,310 @@ const EditProductPage: React.FC = () => { } }; - const handleInputChange = (field: string, value: string) => { - setFormData((prev) => ({ ...prev, [field]: value })); - }; + const billingOptions = [ + { label: 'One-time', value: 'once' }, + { label: 'Monthly', value: 'month' }, + { label: 'Yearly', value: 'year' }, + ]; - const handleCategoryToggle = (category: string) => { - setSelectedCategories((prev) => - prev.includes(category) - ? prev.filter((c) => c !== category) - : [...prev, category] - ); + const handleAddFiles = (f: FileList | null) => { + if (!f) return; + let current = [...files]; + for (const file of Array.from(f)) { + if (current.length >= MAX_FILES) break; + if (!ALLOWED_TYPES.includes(file.type)) continue; + if (file.size > MAX_SIZE) continue; + current.push(file); + } + setFiles(current.slice(0, MAX_FILES)); }; - const handleSave = async () => { - if (!id) return; + const handleRemoveFile = (index: number) => + setFiles((prev) => prev.filter((_, i) => i !== index)); + + const onSubmit = async (values: any) => { setSaving(true); setError(null); if (!isAuthenticated) { - setError('You must be logged in to update products'); + setError('You must log in to update products'); setSaving(false); return; } - if (!formData.name || !formData.price) { - setError('Name and price are required'); - setSaving(false); - return; - } + try { + const prices = [ + { + amount_type: 'fixed', + price_currency: 'usd', + price_amount: Math.round(Number(values.price) * 100), + type: values.billingInterval === 'once' ? 'one_time' : 'recurring', + recurring_interval: + values.billingInterval === 'once' + ? undefined + : values.billingInterval, + legacy: false, + is_archived: false, + }, + ]; - const priceNum = Number(formData.price); - if (!Number.isFinite(priceNum) || priceNum <= 0) { - setError('Price must be greater than 0'); - setSaving(false); - return; - } + const metadata: any = { quantity: Number(values.stock || 0) }; + if (values.category && String(values.category).trim() !== '') + metadata.category = String(values.category).trim(); + if (values.subcategory && String(values.subcategory).trim() !== '') + metadata.subcategory = String(values.subcategory).trim(); + if (values.tags && String(values.tags).trim() !== '') { + const arr = String(values.tags) + .split(',') + .map((t) => t.trim()) + .filter((t) => t.length > 0); + if (arr.length > 0) metadata.tags = arr.join(','); + } - const stockNum = parseInt(formData.inventoryQuantity || '0', 10); - if (!Number.isFinite(stockNum) || Number.isNaN(stockNum) || stockNum < 0) { - setError('Inventory quantity must be 0 or more'); - setSaving(false); - return; - } - try { const payload: Record = { - name: formData.name, - description: formData.description || null, - price: priceNum, - stock: stockNum, - product_type: formData.productType || undefined, - weight: formData.weight ? Number(formData.weight) : undefined, - sku: formData.sku || undefined, - min_stock: formData.lowStockAlert - ? Number(formData.lowStockAlert) - : undefined, - category_ids: - selectedCategories.length > 0 ? selectedCategories : undefined, + name: values.name, + description: + values.description && values.description.trim() !== '' + ? values.description + : undefined, + recurring_interval: + values.billingInterval === 'once' ? null : values.billingInterval, + prices, + metadata, }; - // eliminar claves undefined para no provocar 422 - Object.keys(payload).forEach((k) => { - if (payload[k] === undefined) delete payload[k]; - }); - - await productsAPI.update(id, payload as any); - if (newFiles.length > 0) { - await uploadNewImages(); - } + await productsAPI.update(id!, payload as any); router.push('/inventory'); - } catch (err) { + } catch (err: unknown) { setError(err instanceof Error ? err.message : 'Error updating product'); } finally { setSaving(false); } }; - const uploadNewImages = async () => { - if (!id || newFiles.length === 0) return; - setUploadingImages(true); - try { - await productsAPI.addImages(id, newFiles); - setNewFiles([]); - await fetchProduct(); - } catch (e) { - setError(e instanceof Error ? e.message : 'Error uploading images'); - } finally { - setUploadingImages(false); - } - }; - - const handleDelete = () => { - setDeleteConfirmOpen(true); - }; - - const handleDeleteConfirm = async () => { - if (!id) return; - try { - setIsDeleting(true); - await productsAPI.delete(id); - router.push('/inventory'); - } catch (err) { - setError(err instanceof Error ? err.message : 'Error deleting product'); - } finally { - setIsDeleting(false); - setDeleteConfirmOpen(false); - } - }; + const validationSchema = Yup.object().shape({ + name: Yup.string().required('Name is required'), + price: Yup.number() + .typeError('Price must be a number') + .positive('Price must be greater than 0') + .required('Price is required'), + stock: Yup.number() + .typeError('Stock must be a number') + .integer('Stock must be an integer') + .min(0, 'Stock must be 0 or more') + .required('Stock is required'), + billingInterval: Yup.string() + .oneOf(['once', 'month', 'year']) + .required('Billing interval is required'), + }); - const handleDeleteCancel = () => { - setDeleteConfirmOpen(false); - setIsDeleting(false); - }; + if (loading) { + return
Loading product...
; + } - const handleToggleStatus = async () => { - if (!id) return; - setTogglingStatus(true); - try { - const newStatus = status === 'active' ? 'inactive' : 'active'; - await productsAPI.update(id, { status: newStatus } as any); - setStatus(newStatus as 'active' | 'inactive'); - } catch (err) { - setError(err instanceof Error ? err.message : 'Error toggling status'); - } finally { - setTogglingStatus(false); - } - }; + if (!productData) { + return
Product not found
; + } - const handleAddFiles = (files: FileList | null) => { - if (!files) return; - let current = [...newFiles]; - for (const f of Array.from(files)) { - const total = existingImages.length + current.length; - if (total >= MAX_FILES) break; - if (!ALLOWED_TYPES.includes(f.type)) continue; - if (f.size > MAX_SIZE) continue; - current.push(f); - } - setNewFiles(current.slice(0, MAX_FILES - existingImages.length)); - }; - const handleRemoveExisting = async (index: number) => { - if (!id) return; - try { - await productsAPI.deleteImage(id, index); - setExistingImages((prev) => prev.filter((_, i) => i !== index)); - } catch (e) { - setError(e instanceof Error ? e.message : 'Error deleting image'); - } - }; - const handleRemoveNew = (index: number) => { - setNewFiles((prev) => prev.filter((_, i) => i !== index)); - }; - const handleReplaceAll = async (files: FileList | null) => { - if (!id || !files) return; - try { - const valid = Array.from(files) - .filter((f) => ALLOWED_TYPES.includes(f.type) && f.size <= MAX_SIZE) - .slice(0, MAX_FILES); - if (valid.length === 0) return; - await productsAPI.updateImages(id, valid as any); - setNewFiles([]); - await fetchProduct(); - } catch (e) { - setError(e instanceof Error ? e.message : 'Error replacing images'); - } - }; + const primaryPrice = productData.prices?.[0]; + const currentPrice = primaryPrice ? primaryPrice.price_amount / 100 : 0; + const currentInterval = productData.recurring_interval || 'once'; + const currentStock = productData.metadata?.quantity || 0; + const currentCategory = productData.metadata?.category || ''; + const currentSubcategory = productData.metadata?.subcategory || ''; + const currentTags = productData.metadata?.tags || ''; - if (loading) - return
Loading product...
; + const initialValues = { + name: productData.name || '', + description: productData.description || '', + price: String(currentPrice), + stock: String(currentStock), + billingInterval: currentInterval, + category: currentCategory, + subcategory: currentSubcategory, + tags: currentTags, + } as any; return ( -
- router.push('/inventory')} - onDelete={handleDelete} - onToggleStatus={handleToggleStatus} - onSave={handleSave} - status={status} - saving={saving || uploadingImages} - togglingStatus={togglingStatus} - /> - -
- {error && ( -
- {error} -
+
+ + {(formik) => ( + <> +
+
+ +

+ Edit Product +

+
+
+
+ Status: + Active +
+ {error &&
{error}
} + +
+
+
+
+
+
+ +
+
+
+ +