almeira-web/sneaky-app/src/lib/data-service.ts

607 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Product, City, PaymentMethod, Order, SupplierPost, StoreSettings, Supplier, MonitoredWhatsAppGroup, User, ProductColorVariant } from '@/types';
import { normalizeModelKey, groupProductsByModel } from './model-grouper';
import { generateAttractiveProductCopy } from './attractive-copy';
import { getProductSocialProof } from './social-proof';
export const INITIAL_SUPPLIERS: Supplier[] = [
{
"id": "sup-jovic",
"name": "💎 2️⃣ DIFUSIÓN JOVIC STORE COLOMBIA🍀",
"phone": "573167455877",
"contactPerson": "Asesor Jovic Store",
"address": "Centro / Bodega Cali",
"notes": "Proveedor oficial Nike, Jordan, Nocta y Vomero calidades Top.",
"isActive": true
},
{
"id": "sup-ccmio",
"name": "EXCLUSIVE CC MÍO 3B",
"phone": "573159876543",
"contactPerson": "Despachos CC MÍO 3B",
"address": "CC MÍO Local 3B, Cali",
"notes": "Especialista en Adidas, Coach, Timberland y Skatewear.",
"isActive": true
},
{
"id": "sup-central",
"name": "BODEGA CENTRAL MAYORISTA",
"phone": "573101234567",
"contactPerson": "Logística Despachos",
"address": "San Andresito / Cali",
"notes": "Despacho rápido en 24 horas para separación inmediata.",
"isActive": true
}
];
export const INITIAL_CITIES: City[] = [
{
id: 'city-cali',
name: 'Cali',
department: 'Valle del Cauca',
shippingCost: 0,
deliveryTime: 'Entrega en 24 Horas (Domicilio Local)',
allowsContraentrega: true,
firstExchangeFree: true,
exchangePolicyNote: '1er Cambio de Talla 100% GRATIS en Cali (posteriores con flete estándar)',
},
{
id: 'city-jamundi',
name: 'Jamundí',
department: 'Valle del Cauca',
shippingCost: 5000,
deliveryTime: 'Entrega en 24 Horas',
allowsContraentrega: true,
firstExchangeFree: true,
exchangePolicyNote: '1er Cambio de Talla 100% GRATIS en Jamundí (posteriores con flete estándar)',
},
{
id: 'city-palmira',
name: 'Palmira',
department: 'Valle del Cauca',
shippingCost: 7000,
deliveryTime: 'Entrega en 24 Horas',
allowsContraentrega: true,
firstExchangeFree: true,
exchangePolicyNote: '1er Cambio de Talla 100% GRATIS en Palmira (posteriores con flete estándar)',
},
{
id: 'city-yumbo',
name: 'Yumbo',
department: 'Valle del Cauca',
shippingCost: 5000,
deliveryTime: 'Entrega en 24 Horas',
allowsContraentrega: true,
firstExchangeFree: true,
exchangePolicyNote: '1er Cambio de Talla 100% GRATIS en Yumbo (posteriores con flete estándar)',
},
{
id: 'city-bogota',
name: 'Bogotá D.C.',
department: 'Cundinamarca',
shippingCost: 12000,
deliveryTime: '2 a 3 días hábiles',
allowsContraentrega: true,
firstExchangeFree: false,
exchangePolicyNote: 'Cambio de talla garantizado (flete logístico asumido por el cliente)',
},
{
id: 'city-medellin',
name: 'Medellín',
department: 'Antioquia',
shippingCost: 12000,
deliveryTime: '2 a 3 días hábiles',
allowsContraentrega: true,
firstExchangeFree: false,
exchangePolicyNote: 'Cambio de talla garantizado (flete logístico asumido por el cliente)',
},
{
id: 'city-barranquilla',
name: 'Barranquilla',
department: 'Atlántico',
shippingCost: 14000,
deliveryTime: '3 a 4 días hábiles',
allowsContraentrega: true,
firstExchangeFree: false,
exchangePolicyNote: 'Cambio de talla garantizado (flete logístico asumido por el cliente)',
},
{
id: 'city-nacional',
name: 'Otras Ciudades / Resto del País',
department: 'Colombia',
shippingCost: 15000,
deliveryTime: '2 a 5 días hábiles',
allowsContraentrega: false,
firstExchangeFree: false,
exchangePolicyNote: 'Cambio de talla garantizado (flete nacional asumido por el cliente)',
},
];
// Métodos de Pago configurables por el Admin
export const INITIAL_PAYMENT_METHODS: PaymentMethod[] = [
{
id: 'pay-bold-link',
name: 'Bold.co • PSE, Tarjetas Débito/Crédito, Nequi o QR (Oficial)',
type: 'bold',
description: 'Paga de forma rápida y 100% segura con tu cuenta Bold. Acepta PSE, Tarjetas Visa/Mastercard y QR de cualquier banco.',
requiresDeposit: false,
depositAmount: 0,
boldPaymentLink: 'https://bold.co/p/carlos-lasso',
accountInfo: 'Pasarela Oficial Bold.co • PSE / Tarjetas / QR',
isActive: true,
},
{
id: 'pay-deposit-10k',
name: 'Separación con $10.000 + Saldo Contraentrega',
type: 'contraentrega',
description: 'Asegura tu talla con solo $10.000 COP vía Bold/Nequi/Daviplata y cancelas el saldo restante en efectivo o transferencia cuando el mensajero llegue a tu casa.',
requiresDeposit: true,
depositAmount: 10000,
accountInfo: 'Nequi / Daviplata Oficial: 316 745 5877 (Carlos Andrés Lasso)',
boldPaymentLink: 'https://bold.co/p/carlos-lasso',
isActive: true,
},
{
id: 'pay-cod-full',
name: 'Pago 100% Contraentrega (Cali y Alrededores)',
type: 'contraentrega',
description: 'Pagas el 100% del valor total al recibir en tu puerta (válido para entregas urbanas en 24 horas).',
requiresDeposit: false,
depositAmount: 0,
isActive: true,
},
{
id: 'pay-nequi-full',
name: 'Transferencia Total Anticipada Nequi / Daviplata',
type: 'nequi',
description: 'Paga el 100% anticipado y recibe prioridad de despacho express en 24 horas.',
requiresDeposit: false,
depositAmount: 0,
accountInfo: 'Nequi / Daviplata: 316 745 5877 (Carlos Andrés Lasso)',
isActive: true,
},
];
export const INITIAL_PRODUCTS: Product[] = [];
export const INITIAL_SUPPLIER_POSTS: SupplierPost[] = [];
export const INITIAL_ORDERS: Order[] = [];
export const INITIAL_USERS: User[] = [
{
id: 'usr-admin-carlos',
name: 'Carlos Lasso',
email: 'carlos.andres.lasso@gmail.com',
phone: '573167455877',
city: 'Cali',
address: 'Sede Principal / Cali',
role: 'admin',
twoFactorEnabled: false,
isActive: true,
createdAt: '2026-01-01',
totalOrders: 0,
},
];
export const INITIAL_MONITORED_GROUPS: MonitoredWhatsAppGroup[] = [];
class DataService {
private products: Product[] = [...INITIAL_PRODUCTS];
private cities: City[] = [...INITIAL_CITIES];
private paymentMethods: PaymentMethod[] = [...INITIAL_PAYMENT_METHODS];
private suppliers: Supplier[] = [...INITIAL_SUPPLIERS];
private orders: Order[] = [...INITIAL_ORDERS];
private supplierPosts: SupplierPost[] = [...INITIAL_SUPPLIER_POSTS];
getProducts(): Product[] {
return groupProductsByModel(this.products);
}
getProductById(id: string): Product | undefined {
const allGrouped = groupProductsByModel(this.products);
return allGrouped.find((p) => p.id === id || p.colors?.some((c) => c.id === id));
}
addProduct(product: Product): Product {
const brand = product.brand || 'Sneakers';
const baseModelName = product.modelName || product.name;
const modelKey = normalizeModelKey(baseModelName, brand);
// Buscar si ya existe un producto del mismo modelo
const existing = this.products.find(
(p) => normalizeModelKey(p.modelName || p.name, p.brand) === modelKey
);
if (existing) {
const existingColors: ProductColorVariant[] =
Array.isArray(existing.colors) && existing.colors.length > 0
? existing.colors
: [
{
id: `color-${existing.id}-1`,
colorName: existing.colorName || 'Estilo #1',
image: existing.images[0],
images: existing.images,
sizes: existing.sizes,
inStock: existing.inStock !== false,
},
];
const newImage = product.images[0] || (product.colors && product.colors[0]?.image);
const isDuplicate = existingColors.some(
(c) => c.image === newImage || (product.colorName && c.colorName.toLowerCase() === product.colorName.toLowerCase())
);
if (!isDuplicate && newImage) {
existingColors.push({
id: `color-${Date.now()}-${existingColors.length + 1}`,
colorName: product.colorName || `Estilo #${existingColors.length + 1}`,
image: newImage,
images: product.images.length > 0 ? product.images : [newImage],
sizes: product.sizes && product.sizes.length > 0 ? product.sizes : existing.sizes,
inStock: product.inStock !== false,
});
}
existing.colors = existingColors;
existing.images = Array.from(new Set([...existing.images, ...product.images]));
existing.sizes = Array.from(new Set([...existing.sizes, ...(product.sizes || [])])).sort(
(a, b) => parseInt(a, 10) - parseInt(b, 10)
);
if (!existing.description || existing.description.length < 50) {
existing.description = generateAttractiveProductCopy(existing);
}
return existing;
}
// Si es un modelo nuevo
const proof = getProductSocialProof(product);
const initialColors: ProductColorVariant[] =
Array.isArray(product.colors) && product.colors.length > 0
? product.colors
: [
{
id: `color-${Date.now()}-1`,
colorName: product.colorName || 'Estilo Principal',
image: product.images[0],
images: product.images,
sizes: product.sizes || ['37', '38', '39', '40', '41', '42', '43'],
inStock: product.inStock !== false,
},
];
const formattedProduct: Product = {
...product,
modelName: baseModelName,
colors: initialColors,
description: product.description || generateAttractiveProductCopy(product),
rating: product.rating || proof.rating,
reviewsCount: product.reviewsCount || proof.reviewsCount,
salesCount: product.salesCount || proof.salesCount,
originBadge: product.originBadge || proof.badge,
};
this.products.unshift(formattedProduct);
return formattedProduct;
}
updateProduct(id: string, updates: Partial<Product>): Product | null {
const idx = this.products.findIndex((p) => p.id === id);
if (idx === -1) return null;
this.products[idx] = { ...this.products[idx], ...updates };
return this.products[idx];
}
deleteProduct(id: string): boolean {
const initialLen = this.products.length;
this.products = this.products.filter((p) => p.id !== id);
return this.products.length < initialLen;
}
getSuppliers(includeInactive: boolean = true): Supplier[] {
if (includeInactive) return this.suppliers;
return this.suppliers.filter((s) => s.isActive !== false);
}
getSupplierById(id: string): Supplier | undefined {
return this.suppliers.find((s) => s.id === id);
}
addSupplier(supplier: Partial<Supplier>): Supplier {
const newSupplier: Supplier = {
id: supplier.id || `sup-${Date.now()}`,
name: supplier.name || 'Nuevo Proveedor',
phone: supplier.phone || '573167455877',
contactPerson: supplier.contactPerson || '',
address: supplier.address || '',
notes: supplier.notes || '',
isActive: supplier.isActive !== undefined ? supplier.isActive : true,
};
this.suppliers.push(newSupplier);
return newSupplier;
}
updateSupplier(id: string, updates: Partial<Supplier>): Supplier | null {
const idx = this.suppliers.findIndex((s) => s.id === id);
if (idx === -1) return null;
this.suppliers[idx] = { ...this.suppliers[idx], ...updates };
return this.suppliers[idx];
}
deleteSupplier(id: string): boolean {
// Soft delete (desactivación segura para preservar 100% el historial de pedidos y productos)
const supplier = this.suppliers.find((s) => s.id === id);
if (!supplier) return false;
supplier.isActive = false;
return true;
}
getCities(): City[] {
return this.cities;
}
getCityById(id: string): City | undefined {
return this.cities.find((c) => c.id === id);
}
addCity(city: City): City {
this.cities.push(city);
return city;
}
updateCity(id: string, updates: Partial<City>): City | null {
const idx = this.cities.findIndex((c) => c.id === id);
if (idx === -1) return null;
this.cities[idx] = { ...this.cities[idx], ...updates };
return this.cities[idx];
}
getPaymentMethods(): PaymentMethod[] {
return this.paymentMethods;
}
updatePaymentMethod(id: string, updates: Partial<PaymentMethod>): PaymentMethod | null {
const idx = this.paymentMethods.findIndex((p) => p.id === id);
if (idx === -1) return null;
this.paymentMethods[idx] = { ...this.paymentMethods[idx], ...updates };
return this.paymentMethods[idx];
}
getOrders(): Order[] {
return this.orders;
}
getOrderById(id: string): Order | undefined {
return this.orders.find((o) => o.id === id);
}
addOrder(order: Order): Order {
this.orders.unshift(order);
return order;
}
createOrder(orderData: Partial<Order>): Order {
const newOrder: Order = {
id: orderData.id || `ord-${Date.now()}`,
customerName: orderData.customerName || 'Cliente',
customerPhone: orderData.customerPhone || '',
address: orderData.address || '',
cityId: orderData.cityId || 'city-cali',
cityName: orderData.cityName || 'Cali',
items: orderData.items || [],
subtotal: orderData.subtotal || 0,
shippingCost: orderData.shippingCost || 0,
totalAmount: orderData.totalAmount || 0,
depositAmount: orderData.depositAmount !== undefined ? orderData.depositAmount : 10000,
balanceDue: orderData.balanceDue !== undefined ? orderData.balanceDue : ((orderData.totalAmount || 0) - (orderData.depositAmount !== undefined ? orderData.depositAmount : 10000)),
paymentMethodId: orderData.paymentMethodId || 'pay-contraentrega',
paymentMethodName: orderData.paymentMethodName || 'Contraentrega',
status: orderData.status || 'pending',
notes: orderData.notes,
createdAt: orderData.createdAt || new Date().toISOString(),
};
this.orders.unshift(newOrder);
return newOrder;
}
updateOrder(id: string, updates: Partial<Order>): Order | null {
const idx = this.orders.findIndex((o) => o.id === id);
if (idx === -1) return null;
const current = this.orders[idx];
const total = updates.totalAmount !== undefined ? updates.totalAmount : current.totalAmount;
const deposit = updates.depositAmount !== undefined ? updates.depositAmount : current.depositAmount;
const balanceDue = updates.balanceDue !== undefined ? updates.balanceDue : (total - deposit);
this.orders[idx] = {
...current,
...updates,
totalAmount: total,
depositAmount: deposit,
balanceDue,
};
return this.orders[idx];
}
deleteOrder(id: string): boolean {
const initialLen = this.orders.length;
this.orders = this.orders.filter((o) => o.id !== id);
return this.orders.length < initialLen;
}
updateOrderStatus(orderId: string, status: Order['status'], rejectionReason?: string): Order | null {
const order = this.orders.find((o) => o.id === orderId);
if (!order) return null;
order.status = status;
if (rejectionReason) {
order.rejectionReason = rejectionReason;
}
// Si el pedido es rechazado por el cliente, activar obligatoriedad de cobro de flete/garantía para futuros pedidos
if (status === 'rejected') {
const cleanPhone = (order.customerPhone || '').replace(/\D/g, '');
let user = this.users.find(
(u) => (order.userId && u.id === order.userId) || u.phone.replace(/\D/g, '') === cleanPhone
);
if (user) {
user.requiresShippingDeposit = true;
user.rejectedOrdersCount = (user.rejectedOrdersCount || 0) + 1;
user.depositReason = `Garantía activada por pedido #${order.id} rechazado en entrega (${rejectionReason || 'No recibido en domicilio'})`;
} else if (cleanPhone) {
this.addUser({
id: `usr-risk-${Date.now()}`,
name: order.customerName,
email: `${cleanPhone}@cliente.dripzone.co`,
phone: cleanPhone,
city: order.cityName,
address: order.address,
role: 'customer',
phoneVerified: false,
requiresShippingDeposit: true,
depositReason: `Garantía activada por pedido #${order.id} rechazado en entrega`,
rejectedOrdersCount: 1,
isActive: true,
createdAt: new Date().toISOString().split('T')[0],
totalOrders: 1,
});
}
}
return order;
}
setUserShippingDeposit(userIdOrPhone: string, requiresDeposit: boolean, reason?: string): User | null {
const clean = userIdOrPhone.trim();
const cleanPhone = clean.replace(/\D/g, '');
const user = this.users.find(
(u) => u.id === clean || u.phone.replace(/\D/g, '') === cleanPhone || u.email.toLowerCase() === clean.toLowerCase()
);
if (!user) return null;
user.requiresShippingDeposit = requiresDeposit;
if (reason !== undefined) {
user.depositReason = reason;
}
return user;
}
getSupplierPosts(): SupplierPost[] {
return this.supplierPosts;
}
addSupplierPost(post: SupplierPost): SupplierPost {
this.supplierPosts.unshift(post);
return post;
}
saveSupplierPosts(posts: SupplierPost[]): SupplierPost[] {
posts.forEach((newP) => {
const exists = this.supplierPosts.some(
(p) => p.id === newP.id || (p.rawMessage && p.rawMessage === newP.rawMessage && (p as any).channelJid === (newP as any).channelJid)
);
if (!exists) {
this.supplierPosts.unshift(newP);
}
});
return this.supplierPosts;
}
updateSupplierPostStatus(id: string, status: SupplierPost['status']): boolean {
const post = this.supplierPosts.find((p) => p.id === id);
if (!post) return false;
post.status = status;
return true;
}
// Configuración de la Tienda
private settings: StoreSettings = {
storeName: 'DripZone',
slogan: 'Luxury Streetwear & Collectibles',
email: 'carlos.andres.lasso@gmail.com',
phone: '3167455877',
whatsappNumber: '573167455877',
instagramHandle: '@dripzone.co',
address: 'Bodega Central / Cali',
city: 'Cali, Colombia',
nequiAccount: '316 745 5877',
daviplataAccount: '316 745 5877',
accountHolder: 'Carlos Andrés Lasso',
boldPaymentLink: 'https://bold.co/p/carlos-lasso',
boldApiKey: '',
boldMerchantId: '',
boldQrUrl: '',
boldEnabled: true,
defaultProfitMargin: 35,
defaultDepositAmount: 10000,
};
getSettings(): StoreSettings {
return this.settings;
}
updateSettings(updates: Partial<StoreSettings>): StoreSettings {
this.settings = { ...this.settings, ...updates };
return this.settings;
}
// Control de Usuarios
private users: User[] = [...INITIAL_USERS];
getUsers(): User[] {
return this.users;
}
getUserById(id: string): User | undefined {
return this.users.find((u) => u.id === id);
}
addUser(user: User): User {
this.users.unshift(user);
return user;
}
updateUser(id: string, updates: Partial<User>): User | null {
const idx = this.users.findIndex((u) => u.id === id);
if (idx === -1) return null;
this.users[idx] = { ...this.users[idx], ...updates };
return this.users[idx];
}
deleteUser(id: string): boolean {
const idx = this.users.findIndex((u) => u.id === id);
if (idx === -1) return false;
this.users.splice(idx, 1);
return true;
}
authenticate(emailOrPhone: string): User | null {
const q = emailOrPhone.trim().toLowerCase();
const user = this.users.find(
(u) => u.email.toLowerCase() === q || u.phone === q || u.phone.includes(q)
);
return user || null;
}
private monitoredGroups: MonitoredWhatsAppGroup[] = [...INITIAL_MONITORED_GROUPS];
getMonitoredGroups(): MonitoredWhatsAppGroup[] {
return this.monitoredGroups;
}
saveMonitoredGroups(groups: MonitoredWhatsAppGroup[]): MonitoredWhatsAppGroup[] {
this.monitoredGroups = groups;
return this.monitoredGroups;
}
toggleGroupMonitoring(idOrJid: string, isMonitored: boolean): MonitoredWhatsAppGroup | null {
const grp = this.monitoredGroups.find((g) => g.id === idOrJid || g.jid === idOrJid);
if (grp) {
grp.isMonitored = isMonitored;
return grp;
}
return null;
}
}
export const dataService = new DataService();