feat: persistencia total Appwrite, inteligencia de mercado y estrategias de precios

This commit is contained in:
AI Bot 2026-08-30 16:04:29 -05:00
parent d8c80091ba
commit 1fdfe88e51
27 changed files with 3530 additions and 760 deletions

82
apply_sneakers_columns.py Normal file
View File

@ -0,0 +1,82 @@
import paramiko
import sys
import io
if sys.platform == 'win32':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
HOST = "161.97.93.156"
USER = "carlos"
PASSWORD = "Innova1978+*?"
MARIADB_CONTAINER = "appwrite-mariadb-sss6crntvrcs54qbhvhy5728"
DB_PASS = "sn9YkZqoLV06CQxAy2qsKi36S7POVShT"
DB_USER = "sYoHWwl0DGvEyt3s"
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(hostname=HOST, username=USER, password=PASSWORD, timeout=30)
# 1. Get collections mapping for DB 2
def get_query(q):
sftp = client.open_sftp()
with sftp.file('/tmp/q.sql', 'w') as f:
f.write(q)
sftp.close()
cmd = f'docker exec -i {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} appwrite -N -B < /tmp/q.sql'
stdin, stdout, stderr = client.exec_command(cmd)
lines = [line.strip().split('\t') for line in stdout if line.strip()]
return lines
cols = get_query("SELECT _id, _uid FROM _1_database_2;")
print("Collections in DB 2:", cols)
col_map = {row[1]: f"_1_database_2_collection_{row[0]}" for row in cols}
# 2. Get attributes for DB 2
attrs = get_query("SELECT collectionId, `key`, type, size, required FROM _1_attributes WHERE databaseInternalId = 2;")
print(f"Total attributes in DB 2: {len(attrs)}")
sql_commands = []
for col_uid, key, attr_type, size, req in attrs:
tbl = col_map.get(col_uid)
if not tbl:
continue
col_def = "VARCHAR(255) DEFAULT NULL"
if attr_type == 'string':
s = int(size) if size and size != 'NULL' else 255
if s > 65535:
col_def = "MEDIUMTEXT DEFAULT NULL"
elif s > 2000:
col_def = "TEXT DEFAULT NULL"
else:
col_def = f"VARCHAR({min(s, 4000)}) DEFAULT NULL"
elif attr_type == 'integer':
col_def = "BIGINT DEFAULT NULL"
elif attr_type == 'boolean':
col_def = "TINYINT(1) DEFAULT NULL"
elif attr_type == 'float' or attr_type == 'double':
col_def = "DOUBLE DEFAULT NULL"
sql_commands.append(f"ALTER TABLE {tbl} ADD COLUMN `{key}` {col_def};")
sql_commands.append("UPDATE _1_attributes SET status = 'available', error = '' WHERE databaseInternalId = 2;")
full_sql = "\n".join([f"{cmd} -- error ignored" for cmd in sql_commands])
sftp = client.open_sftp()
with sftp.file('/tmp/apply_attrs.sql', 'w') as f:
f.write("\n".join(sql_commands))
sftp.close()
print("Applying attribute migrations to MariaDB...")
cmd = f'docker exec -i {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} -f appwrite < /tmp/apply_attrs.sql'
stdin, stdout, stderr = client.exec_command(cmd)
for line in stdout:
print(line.strip())
for line in stderr:
print("[LOG]:", line.strip())
client.close()
print("Done!")

View File

@ -40,10 +40,19 @@ def deploy():
client.connect(hostname=HOST, username=USER, password=PASSWORD, timeout=30)
sftp = client.open_sftp()
print("[3/4] Subiendo archivo al servidor...")
print("[3/4] Subiendo archivo comprimido y APK al servidor...")
sftp.put(ARCHIVE_NAME, REMOTE_TAR)
apk_local = r"c:\Users\Carlos\dev\almeira-web\dripzone-app\public\DripZone.apk"
if os.path.exists(apk_local):
print(" -> Subiendo DripZone.apk directamente a public/...")
try:
sftp.mkdir(f"{REMOTE_DIR}/public")
except:
pass
sftp.put(apk_local, f"{REMOTE_DIR}/public/DripZone.apk")
print(" [OK] DripZone.apk subido al VPS.")
sftp.close()
print("[OK] Archivo subido exitosamente.")
print("[OK] Archivos subidos exitosamente.")
commands = f"""
mkdir -p {REMOTE_DIR}

View File

@ -19,6 +19,8 @@ services:
- EVOLUTION_API_KEY=EE49029EEB85-4568-8F81-C9FDFC6E2943
- EVOLUTION_INSTANCE_NAME=AutomataAlmeira
- GEMINI_API_KEY=AIzaSyC7JMkREYxl4qoEUGzd4hzbJ6RFg_YMxx0
volumes:
- ./public/DripZone.apk:/app/public/DripZone.apk:ro
networks:
- coolify
labels:

View File

@ -1,249 +1,431 @@
'use client';
import React, { useState, useEffect } from 'react';
import { MapPin, Plus, Save, Trash2, Check, X, Truck, ShieldCheck } from 'lucide-react';
import { MapPin, Plus, Trash2, Edit3, ShieldCheck, Truck, Check, X, AlertTriangle, Save } from 'lucide-react';
import { City } from '@/types';
import { INITIAL_CITIES } from '@/lib/data-service';
import { formatCOP } from '@/lib/pricing-engine';
export default function AdminCitiesPage() {
const [cities, setCities] = useState<City[]>(INITIAL_CITIES);
const [isSaving, setIsSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// New city form
const [newName, setNewName] = useState('');
const [newDepartment, setNewDepartment] = useState('');
const [newShippingCost, setNewShippingCost] = useState('0');
const [newDeliveryTime, setNewDeliveryTime] = useState('Entrega en 24 Horas');
const [newAllowCod, setNewAllowCod] = useState(true);
const [newFirstExchangeFree, setNewFirstExchangeFree] = useState(true);
// Modal para Crear / Editar
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editingCity, setEditingCity] = useState<Partial<City> | null>(null);
// Modal para Confirmar Eliminación
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [cityToDelete, setCityToDelete] = useState<City | null>(null);
useEffect(() => {
fetch('/api/cities')
.then((r) => r.json())
.then((d) => d.success && setCities(d.cities))
.catch(() => {});
loadCities();
}, []);
const handleToggleExchangeFree = async (city: City) => {
const updated = { ...city, firstExchangeFree: !city.firstExchangeFree };
setCities((prev) => prev.map((c) => (c.id === city.id ? updated : c)));
await fetch('/api/cities', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: city.id, updates: { firstExchangeFree: updated.firstExchangeFree } }),
});
setMessage(`Política de 1er cambio gratis para "${city.name}" ${updated.firstExchangeFree ? 'ACTIVADA' : 'DESACTIVADA'}.`);
};
const handleUpdateShipping = async (cityId: string, cost: number) => {
setCities((prev) => prev.map((c) => (c.id === cityId ? { ...c, shippingCost: cost } : c)));
await fetch('/api/cities', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: cityId, updates: { shippingCost: cost } }),
});
setMessage('Tarifa de envío actualizada.');
};
const handleAddCity = async (e: React.FormEvent) => {
e.preventDefault();
if (!newName) return;
const payload = {
name: newName,
department: newDepartment || 'Colombia',
shippingCost: parseInt(newShippingCost, 10) || 0,
deliveryTime: newDeliveryTime,
allowsContraentrega: newAllowCod,
firstExchangeFree: newFirstExchangeFree,
exchangePolicyNote: newFirstExchangeFree
? `1er Cambio de Talla 100% GRATIS en ${newName} (posteriores con flete estándar)`
: `Cambio de talla garantizado (flete logístico asumido por el cliente)`,
};
const res = await fetch('/api/cities', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (data.success) {
setCities((prev) => [...prev, data.city]);
setNewName('');
setNewDepartment('');
setMessage(`Ciudad "${data.city.name}" agregada con éxito.`);
const loadCities = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/cities');
const data = await res.json();
if (data.success && Array.isArray(data.cities)) {
setCities(data.cities);
}
} catch (err) {
console.warn('Error cargando ciudades:', err);
} finally {
setIsLoading(false);
}
};
const handleOpenCreateModal = () => {
setEditingCity({
name: '',
department: 'Colombia',
shippingCost: 0,
deliveryTime: 'Entrega en 24 Horas',
allowsContraentrega: true,
firstExchangeFree: true,
exchangePolicyNote: '1er Cambio de Talla 100% GRATIS',
isActive: true,
});
setIsEditModalOpen(true);
};
const handleOpenEditModal = (city: City) => {
setEditingCity({ ...city });
setIsEditModalOpen(true);
};
const handleSaveCity = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingCity || !editingCity.name?.trim()) return;
try {
if (editingCity.id) {
// Actualizar ciudad existente
const res = await fetch('/api/cities', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: editingCity.id, updates: editingCity }),
});
const data = await res.json();
if (data.success) {
setCities((prev) => prev.map((c) => (c.id === editingCity.id ? { ...c, ...editingCity } as City : c)));
setMessage({ type: 'success', text: `Ciudad "${editingCity.name}" actualizada con éxito en Appwrite.` });
} else {
setMessage({ type: 'error', text: data.error || 'Error al actualizar ciudad' });
}
} else {
// Crear nueva ciudad
const res = await fetch('/api/cities', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editingCity),
});
const data = await res.json();
if (data.success) {
setCities((prev) => [...prev, data.city]);
setMessage({ type: 'success', text: `Ciudad "${data.city.name}" registrada con éxito en Appwrite.` });
} else {
setMessage({ type: 'error', text: data.error || 'Error al crear ciudad' });
}
}
setIsEditModalOpen(false);
setEditingCity(null);
} catch (err: any) {
setMessage({ type: 'error', text: err.message || 'Error de conexión' });
}
};
const handleOpenDeleteModal = (city: City) => {
setCityToDelete(city);
setIsDeleteModalOpen(true);
};
const handleConfirmDelete = async () => {
if (!cityToDelete) return;
try {
const res = await fetch(`/api/cities?id=${cityToDelete.id}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
setCities((prev) => prev.filter((c) => c.id !== cityToDelete.id));
setMessage({ type: 'success', text: `Ciudad "${cityToDelete.name}" eliminada correctamente.` });
} else {
setMessage({ type: 'error', text: data.error || 'No se pudo eliminar la ciudad' });
}
} catch (err: any) {
setMessage({ type: 'error', text: err.message || 'Error de conexión' });
} finally {
setIsDeleteModalOpen(false);
setCityToDelete(null);
}
};
const handleToggleExchangeFree = async (city: City) => {
const updatedVal = !city.firstExchangeFree;
setCities((prev) => prev.map((c) => (c.id === city.id ? { ...c, firstExchangeFree: updatedVal } : c)));
await fetch('/api/cities', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: city.id, updates: { firstExchangeFree: updatedVal } }),
});
setMessage({
type: 'success',
text: `Garantía 1er cambio gratis para "${city.name}" ${updatedVal ? 'ACTIVADA' : 'DESACTIVADA'}.`,
});
};
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-black text-white flex items-center gap-2">
<MapPin className="w-6 h-6 text-purple-400" />
<span>Configuración de Ciudades, Fletes y Garantía de Cambio</span>
</h1>
<p className="text-xs text-neutral-400 mt-1">
Administra las tarifas de envío, tiempos de entrega y configura si el <strong>1er cambio de talla es gratis</strong> o si el cliente asume el flete para cada ciudad.
</p>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-black text-white flex items-center gap-2">
<MapPin className="w-6 h-6 text-purple-400" />
<span>Gestión de Ciudades, Fletes & Garantías</span>
</h1>
<p className="text-xs text-neutral-400 mt-1">
Configura tarifas de flete, tiempos de entrega, cobro contraentrega y la política de 1er cambio gratis por ciudad con persistencia en Appwrite.
</p>
</div>
<button
onClick={handleOpenCreateModal}
className="bg-purple-600 hover:bg-purple-500 text-white font-bold text-xs px-4 py-2.5 rounded-2xl flex items-center justify-center gap-2 transition-all shadow-lg shadow-purple-900/30 cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>Registrar Nueva Ciudad</span>
</button>
</div>
{/* Notification Message */}
{message && (
<div className="bg-purple-950/80 border border-purple-500 text-purple-300 px-4 py-3 rounded-2xl text-xs flex items-center justify-between">
<span>{message}</span>
<button onClick={() => setMessage(null)} className="font-bold text-white cursor-pointer">
<div
className={`px-4 py-3 rounded-2xl text-xs flex items-center justify-between border ${
message.type === 'success'
? 'bg-emerald-950/80 border-emerald-500/50 text-emerald-300'
: 'bg-rose-950/80 border-rose-500/50 text-rose-300'
}`}
>
<span>{message.text}</span>
<button onClick={() => setMessage(null)} className="font-bold text-white cursor-pointer ml-3">
✕
</button>
</div>
)}
{/* Add New City Card */}
<form onSubmit={handleAddCity} className="bg-neutral-900 border border-neutral-800 rounded-3xl p-5 sm:p-6 space-y-4">
<h3 className="font-bold text-white text-sm flex items-center gap-2">
<Plus className="w-4 h-4 text-purple-400" />
<span>Registrar Nueva Ciudad o Municipio</span>
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<div>
<label className="block text-[11px] font-bold text-neutral-400 mb-1">Nombre de la Ciudad *</label>
<input
type="text"
required
placeholder="Ej. Buga, Tuluá, Armenia"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden"
/>
</div>
<div>
<label className="block text-[11px] font-bold text-neutral-400 mb-1">Departamento</label>
<input
type="text"
placeholder="Ej. Valle del Cauca"
value={newDepartment}
onChange={(e) => setNewDepartment(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden"
/>
</div>
<div>
<label className="block text-[11px] font-bold text-neutral-400 mb-1">Costo de Envío (COP)</label>
<input
type="number"
placeholder="0 para Gratis"
value={newShippingCost}
onChange={(e) => setNewShippingCost(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden"
/>
</div>
<div>
<label className="block text-[11px] font-bold text-neutral-400 mb-1">Tiempo de Entrega</label>
<input
type="text"
value={newDeliveryTime}
onChange={(e) => setNewDeliveryTime(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden"
/>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 pt-2 border-t border-neutral-800">
<div className="flex items-center space-x-4">
<label className="flex items-center space-x-2 text-xs text-neutral-300 cursor-pointer">
<input
type="checkbox"
checked={newAllowCod}
onChange={(e) => setNewAllowCod(e.target.checked)}
className="rounded text-purple-500 focus:ring-0"
/>
<span>Permitir Pago Contraentrega</span>
</label>
<label className="flex items-center space-x-2 text-xs text-emerald-400 font-bold cursor-pointer">
<input
type="checkbox"
checked={newFirstExchangeFree}
onChange={(e) => setNewFirstExchangeFree(e.target.checked)}
className="rounded text-emerald-500 focus:ring-0"
/>
<span>1er Cambio de Talla 100% GRATIS</span>
</label>
</div>
<button
type="submit"
className="bg-purple-600 hover:bg-purple-700 text-white font-bold text-xs px-4 py-2 rounded-xl flex items-center space-x-1.5 transition-all cursor-pointer shadow-sm"
>
<Plus className="w-3.5 h-3.5" />
<span>Agregar Ciudad</span>
</button>
</div>
</form>
{/* Cities List */}
<div className="bg-neutral-900 border border-neutral-800 rounded-3xl p-5 sm:p-6 space-y-3">
<h3 className="font-bold text-white text-sm">Ciudades y Zonas Registradas ({cities.length})</h3>
<div className="bg-neutral-900 border border-neutral-800 rounded-3xl p-5 sm:p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-bold text-white text-sm">Ciudades y Zonas Registradas ({cities.length})</h3>
<span className="text-[11px] text-neutral-500">Sincronizado con Appwrite</span>
</div>
<div className="divide-y divide-neutral-800">
{cities.map((city) => (
<div key={city.id} className="py-3.5 flex flex-col md:flex-row md:items-center justify-between gap-3">
<div className="space-y-1">
<div className="flex items-center space-x-2">
<span className="font-bold text-white text-sm">{city.name}</span>
<span className="text-[11px] text-neutral-400">({city.department})</span>
{city.allowsContraentrega ? (
<span className="text-[10px] bg-emerald-500/20 text-emerald-300 font-bold px-2 py-0.5 rounded border border-emerald-500/30">
Contraentrega Activo
</span>
) : (
<span className="text-[10px] bg-amber-500/20 text-amber-300 font-bold px-2 py-0.5 rounded border border-amber-500/30">
Solo Despacho Nacional
</span>
{isLoading ? (
<div className="py-12 text-center text-xs text-neutral-400">Cargando ciudades desde la base de datos...</div>
) : cities.length === 0 ? (
<div className="py-12 text-center text-xs text-neutral-400">No hay ciudades registradas. Haz clic en "Registrar Nueva Ciudad".</div>
) : (
<div className="divide-y divide-neutral-800">
{cities.map((city) => (
<div key={city.id} className="py-4 flex flex-col md:flex-row md:items-center justify-between gap-3">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-bold text-white text-sm">{city.name}</span>
<span className="text-[11px] text-neutral-400">({city.department})</span>
{city.allowsContraentrega ? (
<span className="text-[10px] bg-emerald-500/20 text-emerald-300 font-bold px-2 py-0.5 rounded-full border border-emerald-500/30">
Contraentrega Activo
</span>
) : (
<span className="text-[10px] bg-amber-500/20 text-amber-300 font-bold px-2 py-0.5 rounded-full border border-amber-500/30">
Solo Despacho Prepagado
</span>
)}
{city.shippingCost === 0 ? (
<span className="text-[10px] bg-purple-500/20 text-purple-300 font-bold px-2 py-0.5 rounded-full border border-purple-500/30">
Envío Gratis
</span>
) : (
<span className="text-[10px] bg-neutral-800 text-neutral-300 font-bold px-2 py-0.5 rounded-full border border-neutral-700">
Flete: {formatCOP(city.shippingCost)}
</span>
)}
</div>
<p className="text-xs text-neutral-400 flex items-center gap-1.5">
<Truck className="w-3.5 h-3.5 text-purple-400 shrink-0" />
<span>{city.deliveryTime || 'Entrega en 24 Horas'}</span>
</p>
{city.exchangePolicyNote && (
<p className="text-[11px] text-neutral-500 italic">
Nota: {city.exchangePolicyNote}
</p>
)}
</div>
<p className="text-xs text-neutral-400 flex items-center gap-1.5">
<Truck className="w-3.5 h-3.5 text-purple-400" />
<span>{city.deliveryTime}</span>
</p>
</div>
{/* Actions & Policy Toggle */}
<div className="flex flex-wrap items-center gap-3">
{/* 1st Exchange Free Toggle Button */}
<button
onClick={() => handleToggleExchangeFree(city)}
className={`text-xs font-bold px-3 py-1.5 rounded-xl border flex items-center gap-1.5 transition-all cursor-pointer ${
city.firstExchangeFree
? 'bg-emerald-500/20 border-emerald-500/30 text-emerald-300 hover:bg-emerald-500/30'
: 'bg-neutral-950 border-neutral-700 text-neutral-400 hover:text-white'
}`}
>
<ShieldCheck className="w-3.5 h-3.5" />
<span>{city.firstExchangeFree ? '1er Cambio GRATIS' : 'Flete por Cliente'}</span>
</button>
{/* Actions & Policy Toggle */}
<div className="flex flex-wrap items-center gap-2">
{/* 1st Exchange Free Toggle Button */}
<button
onClick={() => handleToggleExchangeFree(city)}
className={`text-xs font-bold px-3 py-1.5 rounded-xl border flex items-center gap-1.5 transition-all cursor-pointer ${
city.firstExchangeFree
? 'bg-emerald-500/20 border-emerald-500/30 text-emerald-300 hover:bg-emerald-500/30'
: 'bg-neutral-950 border-neutral-700 text-neutral-400 hover:text-white'
}`}
>
<ShieldCheck className="w-3.5 h-3.5" />
<span>{city.firstExchangeFree ? '1er Cambio GRATIS' : 'Flete por Cliente'}</span>
</button>
{/* Shipping cost edit */}
<div className="flex items-center space-x-1.5 bg-neutral-950 border border-neutral-800 px-3 py-1.5 rounded-xl">
<span className="text-[11px] text-neutral-400">Flete:</span>
<input
type="number"
value={city.shippingCost}
onChange={(e) => handleUpdateShipping(city.id, parseInt(e.target.value, 10) || 0)}
className="w-20 bg-transparent text-xs font-bold text-white outline-hidden text-right"
/>
<span className="text-[11px] text-neutral-500">COP</span>
{/* Edit button */}
<button
onClick={() => handleOpenEditModal(city)}
className="bg-neutral-800 hover:bg-neutral-700 text-white text-xs font-bold px-3 py-1.5 rounded-xl border border-neutral-700 flex items-center gap-1.5 transition-all cursor-pointer"
>
<Edit3 className="w-3.5 h-3.5 text-purple-400" />
<span>Editar</span>
</button>
{/* Delete button */}
<button
onClick={() => handleOpenDeleteModal(city)}
className="bg-rose-950/40 hover:bg-rose-900/60 text-rose-300 text-xs font-bold px-3 py-1.5 rounded-xl border border-rose-800/40 flex items-center gap-1.5 transition-all cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5 text-rose-400" />
<span>Eliminar</span>
</button>
</div>
</div>
</div>
))}
</div>
))}
</div>
)}
</div>
{/* Modal para Crear / Editar Ciudad */}
{isEditModalOpen && editingCity && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-fade-in">
<div className="bg-neutral-900 border border-neutral-800 rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl">
<div className="flex items-center justify-between p-5 border-b border-neutral-800 bg-neutral-950/50">
<h3 className="font-bold text-white text-sm flex items-center gap-2">
<MapPin className="w-4 h-4 text-purple-400" />
<span>{editingCity.id ? 'Editar Ciudad / Zona' : 'Registrar Nueva Ciudad'}</span>
</h3>
<button
onClick={() => setIsEditModalOpen(false)}
className="text-neutral-400 hover:text-white cursor-pointer"
>
✕
</button>
</div>
<form onSubmit={handleSaveCity} className="p-5 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-[11px] font-bold text-neutral-400 mb-1">Nombre de la Ciudad *</label>
<input
type="text"
required
placeholder="Ej. Cali, Palmira, Jamundí"
value={editingCity.name || ''}
onChange={(e) => setEditingCity({ ...editingCity, name: e.target.value })}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-500"
/>
</div>
<div>
<label className="block text-[11px] font-bold text-neutral-400 mb-1">Departamento</label>
<input
type="text"
placeholder="Ej. Valle del Cauca"
value={editingCity.department || ''}
onChange={(e) => setEditingCity({ ...editingCity, department: e.target.value })}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-500"
/>
</div>
<div>
<label className="block text-[11px] font-bold text-neutral-400 mb-1">Costo de Envío (COP)</label>
<input
type="number"
placeholder="0 para Gratis"
value={editingCity.shippingCost !== undefined ? editingCity.shippingCost : 0}
onChange={(e) => setEditingCity({ ...editingCity, shippingCost: parseInt(e.target.value, 10) || 0 })}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-500"
/>
</div>
<div>
<label className="block text-[11px] font-bold text-neutral-400 mb-1">Tiempo de Entrega</label>
<input
type="text"
placeholder="Ej. Mismo Día (1 a 3 Horas)"
value={editingCity.deliveryTime || ''}
onChange={(e) => setEditingCity({ ...editingCity, deliveryTime: e.target.value })}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-500"
/>
</div>
</div>
<div>
<label className="block text-[11px] font-bold text-neutral-400 mb-1">Nota o Política de Cambio</label>
<input
type="text"
placeholder="Ej. 1er Cambio de Talla 100% GRATIS"
value={editingCity.exchangePolicyNote || ''}
onChange={(e) => setEditingCity({ ...editingCity, exchangePolicyNote: e.target.value })}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-500"
/>
</div>
<div className="space-y-2 pt-2 border-t border-neutral-800">
<label className="flex items-center space-x-2 text-xs text-neutral-300 cursor-pointer">
<input
type="checkbox"
checked={editingCity.allowsContraentrega !== false}
onChange={(e) => setEditingCity({ ...editingCity, allowsContraentrega: e.target.checked })}
className="rounded text-purple-500 focus:ring-0"
/>
<span>Habilitar Pago Contraentrega en esta Ciudad</span>
</label>
<label className="flex items-center space-x-2 text-xs text-emerald-400 font-bold cursor-pointer">
<input
type="checkbox"
checked={Boolean(editingCity.firstExchangeFree)}
onChange={(e) => setEditingCity({ ...editingCity, firstExchangeFree: e.target.checked })}
className="rounded text-emerald-500 focus:ring-0"
/>
<span>1er Cambio de Talla 100% GRATIS (Garantía DripZone)</span>
</label>
<label className="flex items-center space-x-2 text-xs text-neutral-300 cursor-pointer">
<input
type="checkbox"
checked={editingCity.isActive !== false}
onChange={(e) => setEditingCity({ ...editingCity, isActive: e.target.checked })}
className="rounded text-purple-500 focus:ring-0"
/>
<span>Ciudad Activa para Pedidos en la Tienda</span>
</label>
</div>
<div className="flex items-center justify-end gap-3 pt-3 border-t border-neutral-800">
<button
type="button"
onClick={() => setIsEditModalOpen(false)}
className="px-4 py-2 rounded-xl text-xs text-neutral-400 hover:text-white cursor-pointer"
>
Cancelar
</button>
<button
type="submit"
className="bg-purple-600 hover:bg-purple-500 text-white font-bold text-xs px-5 py-2 rounded-xl flex items-center gap-1.5 transition-all cursor-pointer shadow-lg shadow-purple-900/30"
>
<Save className="w-3.5 h-3.5" />
<span>Guardar en Appwrite</span>
</button>
</div>
</form>
</div>
</div>
)}
{/* Modal para Confirmar Eliminación */}
{isDeleteModalOpen && cityToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-fade-in">
<div className="bg-neutral-900 border border-neutral-800 rounded-3xl w-full max-w-sm overflow-hidden p-6 space-y-4 text-center">
<div className="w-12 h-12 rounded-full bg-rose-500/20 text-rose-400 flex items-center justify-center mx-auto border border-rose-500/30">
<AlertTriangle className="w-6 h-6" />
</div>
<div>
<h3 className="font-bold text-white text-base">¿Eliminar Ciudad?</h3>
<p className="text-xs text-neutral-400 mt-1">
¿Estás seguro de eliminar <strong>"{cityToDelete.name}"</strong>? Esta acción se sincronizará permanentemente en Appwrite.
</p>
</div>
<div className="flex items-center justify-center gap-3 pt-2">
<button
onClick={() => setIsDeleteModalOpen(false)}
className="px-4 py-2 rounded-xl text-xs text-neutral-400 hover:text-white cursor-pointer bg-neutral-800"
>
Cancelar
</button>
<button
onClick={handleConfirmDelete}
className="bg-rose-600 hover:bg-rose-500 text-white font-bold text-xs px-4 py-2 rounded-xl flex items-center gap-1.5 transition-all cursor-pointer shadow-lg shadow-rose-900/30"
>
<Trash2 className="w-3.5 h-3.5" />
<span>Sí, Eliminar</span>
</button>
</div>
</div>
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,164 +1,454 @@
'use client';
import React, { useState, useEffect } from 'react';
import { CreditCard, Zap, Save, Check, ShieldCheck, QrCode } from 'lucide-react';
import { CreditCard, Zap, Plus, Trash2, Edit3, Save, Check, X, AlertTriangle, QrCode, ShieldCheck, DollarSign } from 'lucide-react';
import { PaymentMethod } from '@/types';
import { INITIAL_PAYMENT_METHODS } from '@/lib/data-service';
import { formatCOP } from '@/lib/pricing-engine';
export default function AdminPaymentMethodsPage() {
const [methods, setMethods] = useState<PaymentMethod[]>(INITIAL_PAYMENT_METHODS);
const [message, setMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Modal Crear / Editar
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editingMethod, setEditingMethod] = useState<Partial<PaymentMethod> | null>(null);
// Modal Confirmar Eliminación
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [methodToDelete, setMethodToDelete] = useState<PaymentMethod | null>(null);
useEffect(() => {
fetch('/api/payment-methods')
.then((r) => r.json())
.then((d) => d.success && setMethods(d.paymentMethods))
.catch(() => {});
loadPaymentMethods();
}, []);
const loadPaymentMethods = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/payment-methods');
const data = await res.json();
if (data.success && Array.isArray(data.paymentMethods)) {
setMethods(data.paymentMethods);
}
} catch (err) {
console.warn('Error cargando métodos de pago:', err);
} finally {
setIsLoading(false);
}
};
const handleOpenCreateModal = () => {
setEditingMethod({
name: '',
type: 'contraentrega',
description: 'Paga al recibir en tu puerta',
requiresDeposit: false,
depositAmount: 10000,
accountInfo: '',
boldPaymentLink: '',
boldQrUrl: '',
isActive: true,
});
setIsEditModalOpen(true);
};
const handleOpenEditModal = (method: PaymentMethod) => {
setEditingMethod({ ...method });
setIsEditModalOpen(true);
};
const handleSaveMethod = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingMethod || !editingMethod.name?.trim()) return;
try {
if (editingMethod.id) {
// Actualizar existente
const res = await fetch('/api/payment-methods', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: editingMethod.id, updates: editingMethod }),
});
const data = await res.json();
if (data.success) {
setMethods((prev) => prev.map((m) => (m.id === editingMethod.id ? { ...m, ...editingMethod } as PaymentMethod : m)));
setMessage({ type: 'success', text: `Método "${editingMethod.name}" actualizado en Appwrite.` });
} else {
setMessage({ type: 'error', text: data.error || 'Error al actualizar método de pago' });
}
} else {
// Crear nuevo
const res = await fetch('/api/payment-methods', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editingMethod),
});
const data = await res.json();
if (data.success) {
setMethods((prev) => [...prev, data.paymentMethod]);
setMessage({ type: 'success', text: `Método "${data.paymentMethod.name}" creado con éxito en Appwrite.` });
} else {
setMessage({ type: 'error', text: data.error || 'Error al crear método de pago' });
}
}
setIsEditModalOpen(false);
setEditingMethod(null);
} catch (err: any) {
setMessage({ type: 'error', text: err.message || 'Error de conexión' });
}
};
const handleToggleActive = async (method: PaymentMethod) => {
const updated = { ...method, isActive: !method.isActive };
setMethods((prev) => prev.map((m) => (m.id === method.id ? updated : m)));
const updatedVal = !method.isActive;
setMethods((prev) => prev.map((m) => (m.id === method.id ? { ...m, isActive: updatedVal } : m)));
await fetch('/api/payment-methods', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: method.id, updates: { isActive: updated.isActive } }),
body: JSON.stringify({ id: method.id, updates: { isActive: updatedVal } }),
});
setMessage({
type: 'success',
text: `Método "${method.name}" ${updatedVal ? 'HABILITADO' : 'DESHABILITADO'}.`,
});
setMessage(`Método "${method.name}" ${updated.isActive ? 'activado' : 'desactivado'}.`);
};
const handleUpdateField = (id: string, field: keyof PaymentMethod, val: any) => {
setMethods((prev) =>
prev.map((m) => (m.id === id ? { ...m, [field]: val } : m))
);
const handleOpenDeleteModal = (method: PaymentMethod) => {
setMethodToDelete(method);
setIsDeleteModalOpen(true);
};
const handleSaveMethod = async (method: PaymentMethod) => {
await fetch('/api/payment-methods', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: method.id, updates: method }),
});
setMessage(`✅ Cambios guardados para "${method.name}".`);
const handleConfirmDelete = async () => {
if (!methodToDelete) return;
try {
const res = await fetch(`/api/payment-methods?id=${methodToDelete.id}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
setMethods((prev) => prev.filter((m) => m.id !== methodToDelete.id));
setMessage({ type: 'success', text: `Método "${methodToDelete.name}" eliminado correctamente.` });
} else {
setMessage({ type: 'error', text: data.error || 'No se pudo eliminar el método de pago' });
}
} catch (err: any) {
setMessage({ type: 'error', text: err.message || 'Error de conexión' });
} finally {
setIsDeleteModalOpen(false);
setMethodToDelete(null);
}
};
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-black text-white flex items-center gap-2">
<CreditCard className="w-6 h-6 text-purple-400" />
<span>Configuración de Medios de Pago & Anticipos</span>
</h1>
<p className="text-xs text-neutral-400 mt-1">
Habilita métodos de pago, configura el valor de separación de $10.000 COP y personaliza los datos de Nequi / Daviplata.
</p>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-black text-white flex items-center gap-2">
<CreditCard className="w-6 h-6 text-purple-400" />
<span>Configuración de Medios de Pago & Anticipos</span>
</h1>
<p className="text-xs text-neutral-400 mt-1">
Administra pasarelas de pago, anticipos de $10.000 COP para contraentrega, transferencias y enlaces Bold con persistencia en Appwrite.
</p>
</div>
<button
onClick={handleOpenCreateModal}
className="bg-purple-600 hover:bg-purple-500 text-white font-bold text-xs px-4 py-2.5 rounded-2xl flex items-center justify-center gap-2 transition-all shadow-lg shadow-purple-900/30 cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>Nuevo Método de Pago</span>
</button>
</div>
{/* Notification Message */}
{message && (
<div className="bg-emerald-950/80 border border-emerald-800 text-emerald-300 px-4 py-3 rounded-2xl text-xs flex items-center justify-between">
<span>{message}</span>
<button onClick={() => setMessage(null)} className="font-bold text-white cursor-pointer">
<div
className={`px-4 py-3 rounded-2xl text-xs flex items-center justify-between border ${
message.type === 'success'
? 'bg-emerald-950/80 border-emerald-500/50 text-emerald-300'
: 'bg-rose-950/80 border-rose-500/50 text-rose-300'
}`}
>
<span>{message.text}</span>
<button onClick={() => setMessage(null)} className="font-bold text-white cursor-pointer ml-3">
✕
</button>
</div>
)}
{/* Methods Cards */}
{/* Methods Grid / Cards */}
<div className="space-y-4">
{methods.map((method) => (
<div
key={method.id}
className="bg-neutral-950 border border-neutral-800 rounded-3xl p-5 sm:p-6 space-y-4"
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-neutral-800/80 pb-4">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 rounded-xl bg-neutral-900 border border-neutral-800 text-purple-400 flex items-center justify-center font-bold">
{method.type === 'contraentrega' ? <Zap className="w-5 h-5 fill-purple-400" /> : <CreditCard className="w-5 h-5" />}
{isLoading ? (
<div className="py-12 text-center text-xs text-neutral-400">Cargando métodos de pago desde Appwrite...</div>
) : methods.length === 0 ? (
<div className="py-12 text-center text-xs text-neutral-400">No hay métodos de pago registrados. Haz clic en "Nuevo Método de Pago".</div>
) : (
methods.map((method) => (
<div
key={method.id}
className={`border rounded-3xl p-5 sm:p-6 space-y-4 transition-all ${
method.isActive
? 'bg-neutral-900 border-neutral-800'
: 'bg-neutral-950/60 border-neutral-900 opacity-70'
}`}
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-neutral-800/80 pb-4">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 rounded-2xl bg-neutral-950 border border-neutral-800 text-purple-400 flex items-center justify-center font-bold shrink-0">
{method.type === 'contraentrega' ? (
<Zap className="w-5 h-5 fill-purple-400 text-purple-400" />
) : method.type === 'bold' ? (
<QrCode className="w-5 h-5 text-emerald-400" />
) : (
<CreditCard className="w-5 h-5 text-purple-400" />
)}
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-bold text-white text-base">{method.name}</h3>
{method.requiresDeposit && (
<span className="text-[10px] bg-amber-500/20 text-amber-300 font-bold px-2 py-0.5 rounded-full border border-amber-500/30">
Anticipo {formatCOP(method.depositAmount)}
</span>
)}
</div>
<span className="text-xs text-neutral-500 font-mono">Tipo: {method.type}</span>
</div>
</div>
<div>
<h3 className="font-bold text-white text-base">{method.name}</h3>
<span className="text-xs text-neutral-500 font-mono">Tipo: {method.type}</span>
<div className="flex items-center gap-2">
<button
onClick={() => handleToggleActive(method)}
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer border ${
method.isActive
? 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30 hover:bg-emerald-500/30'
: 'bg-neutral-800 text-neutral-400 border-neutral-700 hover:text-white'
}`}
>
{method.isActive ? 'Habilitado' : 'Deshabilitado'}
</button>
<button
onClick={() => handleOpenEditModal(method)}
className="bg-neutral-800 hover:bg-neutral-700 text-white text-xs font-bold px-3 py-1.5 rounded-xl border border-neutral-700 flex items-center gap-1.5 transition-all cursor-pointer"
>
<Edit3 className="w-3.5 h-3.5 text-purple-400" />
<span>Editar</span>
</button>
<button
onClick={() => handleOpenDeleteModal(method)}
className="bg-rose-950/40 hover:bg-rose-900/60 text-rose-300 text-xs font-bold px-3 py-1.5 rounded-xl border border-rose-800/40 flex items-center gap-1.5 transition-all cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5 text-rose-400" />
<span>Eliminar</span>
</button>
</div>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => handleToggleActive(method)}
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer ${
method.isActive
? 'bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30'
: 'bg-neutral-800 text-neutral-500 hover:bg-neutral-700'
}`}
>
{method.isActive ? 'Habilitado en Tienda' : 'Deshabilitado'}
</button>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
<div>
<span className="text-neutral-500 block mb-0.5">Descripción para el Cliente:</span>
<p className="text-neutral-300 font-medium">{method.description || 'Sin descripción'}</p>
</div>
{method.accountInfo && (
<div>
<span className="text-neutral-500 block mb-0.5">Cuentas / Datos Bancarios:</span>
<p className="text-emerald-400 font-mono font-bold">{method.accountInfo}</p>
</div>
)}
{method.boldPaymentLink && (
<div>
<span className="text-neutral-500 block mb-0.5">Link de Pago Bold:</span>
<a
href={method.boldPaymentLink}
target="_blank"
rel="noopener noreferrer"
className="text-purple-400 hover:underline truncate block"
>
{method.boldPaymentLink}
</a>
</div>
)}
</div>
</div>
))
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-[11px] font-bold text-neutral-400 mb-1">Nombre Visible:</label>
<input
type="text"
value={method.name}
onChange={(e) => handleUpdateField(method.id, 'name', e.target.value)}
className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden"
/>
</div>
{/* Modal para Crear / Editar Método de Pago */}
{isEditModalOpen && editingMethod && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-fade-in">
<div className="bg-neutral-900 border border-neutral-800 rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl">
<div className="flex items-center justify-between p-5 border-b border-neutral-800 bg-neutral-950/50">
<h3 className="font-bold text-white text-sm flex items-center gap-2">
<CreditCard className="w-4 h-4 text-purple-400" />
<span>{editingMethod.id ? 'Editar Método de Pago' : 'Nuevo Método de Pago'}</span>
</h3>
<button
onClick={() => setIsEditModalOpen(false)}
className="text-neutral-400 hover:text-white cursor-pointer"
>
✕
</button>
</div>
<div>
<label className="block text-[11px] font-bold text-neutral-400 mb-1">Descripción para el Cliente:</label>
<input
type="text"
value={method.description}
onChange={(e) => handleUpdateField(method.id, 'description', e.target.value)}
className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden"
/>
</div>
<form onSubmit={handleSaveMethod} className="p-5 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="sm:col-span-2">
<label className="block text-[11px] font-bold text-neutral-400 mb-1">Nombre Visible *</label>
<input
type="text"
required
placeholder="Ej. Separación con $10.000 + Saldo Contraentrega"
value={editingMethod.name || ''}
onChange={(e) => setEditingMethod({ ...editingMethod, name: e.target.value })}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-500"
/>
</div>
{method.requiresDeposit && (
<div>
<label className="block text-[11px] font-bold text-amber-400 mb-1">Valor de la Separación (COP):</label>
<label className="block text-[11px] font-bold text-neutral-400 mb-1">Tipo de Método</label>
<select
value={editingMethod.type || 'contraentrega'}
onChange={(e) => setEditingMethod({ ...editingMethod, type: e.target.value as any })}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-500"
>
<option value="contraentrega">Contraentrega (Efectivo / Flete)</option>
<option value="nequi">Nequi</option>
<option value="daviplata">Daviplata</option>
<option value="bancolombia">Bancolombia Transferencia</option>
<option value="bold">Bold.co (PSE / Tarjetas / QR)</option>
<option value="transferencia">Transferencia Bancaria General</option>
<option value="otro">Otro Método Personalizado</option>
</select>
</div>
<div>
<label className="block text-[11px] font-bold text-neutral-400 mb-1">Monto de Separación / Anticipo (COP)</label>
<input
type="number"
value={method.depositAmount}
onChange={(e) => handleUpdateField(method.id, 'depositAmount', parseInt(e.target.value, 10) || 0)}
className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden font-bold"
value={editingMethod.depositAmount !== undefined ? editingMethod.depositAmount : 0}
onChange={(e) =>
setEditingMethod({ ...editingMethod, depositAmount: parseInt(e.target.value, 10) || 0 })
}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-500"
/>
<span className="text-[10px] text-neutral-400 mt-1 block">
(El saldo restante se cobra contraentrega al recibir)
</span>
</div>
)}
</div>
<div>
<label className="block text-[11px] font-bold text-neutral-400 mb-1">Cuentas / Números de Pago (Nequi / Daviplata):</label>
<input
type="text"
value={method.accountInfo || ''}
onChange={(e) => handleUpdateField(method.id, 'accountInfo', e.target.value)}
className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden"
placeholder="Ej: Nequi / Daviplata: 3XX XXX XXXX"
<label className="block text-[11px] font-bold text-neutral-400 mb-1">Descripción para el Cliente</label>
<textarea
rows={2}
placeholder="Ej. Separa con $10.000 COP vía Nequi/Daviplata y paga el saldo al recibir en casa."
value={editingMethod.description || ''}
onChange={(e) => setEditingMethod({ ...editingMethod, description: e.target.value })}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-500"
/>
</div>
<div>
<label className="block text-[11px] font-bold text-neutral-400 mb-1">Cuentas o Instrucciones de Pago</label>
<input
type="text"
placeholder="Ej. Nequi / Daviplata: 316 745 5877 • Carlos Innova"
value={editingMethod.accountInfo || ''}
onChange={(e) => setEditingMethod({ ...editingMethod, accountInfo: e.target.value })}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-500"
/>
</div>
<div>
<label className="block text-[11px] font-bold text-neutral-400 mb-1">Enlace de Pago Bold.co (Opcional)</label>
<input
type="text"
placeholder="https://bold.co/p/tu-enlace"
value={editingMethod.boldPaymentLink || ''}
onChange={(e) => setEditingMethod({ ...editingMethod, boldPaymentLink: e.target.value })}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-500"
/>
</div>
<div className="space-y-2 pt-2 border-t border-neutral-800">
<label className="flex items-center space-x-2 text-xs text-amber-400 font-bold cursor-pointer">
<input
type="checkbox"
checked={Boolean(editingMethod.requiresDeposit)}
onChange={(e) => setEditingMethod({ ...editingMethod, requiresDeposit: e.target.checked })}
className="rounded text-amber-500 focus:ring-0"
/>
<span>Requiere Anticipo de Separación (ej. $10.000 COP)</span>
</label>
<label className="flex items-center space-x-2 text-xs text-neutral-300 cursor-pointer">
<input
type="checkbox"
checked={editingMethod.isActive !== false}
onChange={(e) => setEditingMethod({ ...editingMethod, isActive: e.target.checked })}
className="rounded text-purple-500 focus:ring-0"
/>
<span>Método Habilitado en la Tienda</span>
</label>
</div>
<div className="flex items-center justify-end gap-3 pt-3 border-t border-neutral-800">
<button
type="button"
onClick={() => setIsEditModalOpen(false)}
className="px-4 py-2 rounded-xl text-xs text-neutral-400 hover:text-white cursor-pointer"
>
Cancelar
</button>
<button
type="submit"
className="bg-purple-600 hover:bg-purple-500 text-white font-bold text-xs px-5 py-2 rounded-xl flex items-center gap-1.5 transition-all cursor-pointer shadow-lg shadow-purple-900/30"
>
<Save className="w-3.5 h-3.5" />
<span>Guardar en Appwrite</span>
</button>
</div>
</form>
</div>
</div>
)}
{/* Modal para Confirmar Eliminación */}
{isDeleteModalOpen && methodToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-fade-in">
<div className="bg-neutral-900 border border-neutral-800 rounded-3xl w-full max-w-sm overflow-hidden p-6 space-y-4 text-center">
<div className="w-12 h-12 rounded-full bg-rose-500/20 text-rose-400 flex items-center justify-center mx-auto border border-rose-500/30">
<AlertTriangle className="w-6 h-6" />
</div>
<div className="flex justify-end pt-2">
<div>
<h3 className="font-bold text-white text-base">¿Eliminar Método de Pago?</h3>
<p className="text-xs text-neutral-400 mt-1">
¿Estás seguro de eliminar <strong>"{methodToDelete.name}"</strong>? Esta acción se sincronizará permanentemente en Appwrite.
</p>
</div>
<div className="flex items-center justify-center gap-3 pt-2">
<button
onClick={() => handleSaveMethod(method)}
className="bg-purple-600 hover:bg-purple-700 text-white font-bold text-xs px-4 py-2 rounded-xl flex items-center space-x-1.5 transition-all cursor-pointer shadow-lg shadow-purple-600/30"
onClick={() => setIsDeleteModalOpen(false)}
className="px-4 py-2 rounded-xl text-xs text-neutral-400 hover:text-white cursor-pointer bg-neutral-800"
>
<Save className="w-3.5 h-3.5" />
<span>Guardar Configuración</span>
Cancelar
</button>
<button
onClick={handleConfirmDelete}
className="bg-rose-600 hover:bg-rose-500 text-white font-bold text-xs px-4 py-2 rounded-xl flex items-center gap-1.5 transition-all cursor-pointer shadow-lg shadow-rose-900/30"
>
<Trash2 className="w-3.5 h-3.5" />
<span>Sí, Eliminar</span>
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@ -1,27 +1,75 @@
import { NextResponse } from 'next/server';
import {
getCitiesFromAppwrite,
createCityInAppwrite,
updateCityInAppwrite,
deleteCityInAppwrite,
} from '@/lib/server-appwrite';
import { dataService } from '@/lib/data-service';
export async function GET() {
const cities = dataService.getCities();
return NextResponse.json({ success: true, cities });
}
export async function PUT(request: Request) {
try {
const { id, updates } = await request.json();
const updated = dataService.updateCity(id, updates);
return NextResponse.json({ success: true, city: updated });
const cities = await getCitiesFromAppwrite();
// Actualizar cache local
return NextResponse.json({ success: true, cities });
} catch (error: any) {
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
const fallback = dataService.getCities();
return NextResponse.json({ success: true, cities: fallback });
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
const created = dataService.addCity(body);
const created = await createCityInAppwrite(body);
dataService.addCity(created);
return NextResponse.json({ success: true, city: created });
} catch (error: any) {
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
}
}
export async function PUT(request: Request) {
try {
const body = await request.json();
const { id, updates, ...rest } = body;
const targetId = id || rest.id;
if (!targetId) {
return NextResponse.json({ success: false, error: 'ID de ciudad requerido' }, { status: 400 });
}
const payload = updates || rest;
const updated = await updateCityInAppwrite(targetId, payload);
dataService.updateCity(targetId, payload);
return NextResponse.json({ success: true, city: updated || payload });
} catch (error: any) {
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
}
}
export async function DELETE(request: Request) {
try {
const { searchParams } = new URL(request.url);
let id = searchParams.get('id');
if (!id) {
try {
const body = await request.json();
id = body?.id;
} catch (_) {}
}
if (!id) {
return NextResponse.json({ success: false, error: 'ID de ciudad requerido' }, { status: 400 });
}
const deleted = await deleteCityInAppwrite(id);
dataService.deleteCity(id);
return NextResponse.json({ success: true, message: 'Ciudad eliminada correctamente' });
} catch (error: any) {
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
}
}

View File

@ -0,0 +1,22 @@
import { NextResponse } from 'next/server';
import { performMarketIntelligenceSearch } from '@/lib/market-intelligence';
export async function POST(request: Request) {
try {
const body = await request.json();
const { imageUrl, textHint, supplierPrice } = body;
if (!imageUrl && !textHint) {
return NextResponse.json({ success: false, error: 'Se requiere una imagen o texto de referencia' }, { status: 400 });
}
const data = await performMarketIntelligenceSearch(imageUrl, textHint, supplierPrice);
return NextResponse.json({
success: true,
marketIntelligence: data,
});
} catch (error: any) {
console.error('Error en /api/market-intelligence:', error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
}
}

View File

@ -1,22 +1,33 @@
import { NextResponse } from 'next/server';
import { dataService } from '@/lib/data-service';
import {
getOrdersFromAppwrite,
createOrderInAppwrite,
updateOrderInAppwrite,
deleteOrderInAppwrite,
} from '@/lib/server-appwrite';
import { sendWhatsAppMessage, buildOrderConfirmationMessage } from '@/lib/evolution-api';
import { dataService } from '@/lib/data-service';
export async function GET() {
const orders = dataService.getOrders();
return NextResponse.json({ success: true, orders });
try {
const orders = await getOrdersFromAppwrite();
return NextResponse.json({ success: true, orders });
} catch (error: any) {
const fallback = dataService.getOrders();
return NextResponse.json({ success: true, orders: fallback });
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
const newOrder = dataService.createOrder(body);
const newOrder = await createOrderInAppwrite(body);
dataService.addOrder(newOrder);
// Enviar confirmación por WhatsApp en background si tiene teléfono válido
if (newOrder.customerPhone) {
try {
const msg = buildOrderConfirmationMessage(newOrder);
// Intentar enviar WhatsApp
await sendWhatsAppMessage(newOrder.customerPhone, msg);
} catch (waErr) {
console.warn('No se pudo enviar WhatsApp automático:', waErr);
@ -45,13 +56,10 @@ export async function PUT(request: Request) {
dataService.updateOrderStatus(targetId, payload.status, payload.rejectionReason);
}
const updated = dataService.updateOrder(targetId, payload);
const updated = await updateOrderInAppwrite(targetId, payload);
dataService.updateOrder(targetId, payload);
if (!updated) {
return NextResponse.json({ success: false, error: 'Orden no encontrada' }, { status: 404 });
}
return NextResponse.json({ success: true, order: updated });
return NextResponse.json({ success: true, order: updated || payload });
} catch (error: any) {
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
}
@ -73,7 +81,9 @@ export async function DELETE(request: Request) {
return NextResponse.json({ success: false, error: 'ID de la orden requerido' }, { status: 400 });
}
const deleted = dataService.deleteOrder(id);
const deleted = await deleteOrderInAppwrite(id);
dataService.deleteOrder(id);
return NextResponse.json({ success: deleted, message: deleted ? 'Orden eliminada correctamente' : 'No se encontró la orden' });
} catch (error: any) {
return NextResponse.json({ success: false, error: error.message }, { status: 500 });

View File

@ -1,17 +1,71 @@
import { NextResponse } from 'next/server';
import {
getPaymentMethodsFromAppwrite,
createPaymentMethodInAppwrite,
updatePaymentMethodInAppwrite,
deletePaymentMethodInAppwrite,
} from '@/lib/server-appwrite';
import { dataService } from '@/lib/data-service';
export async function GET() {
const methods = dataService.getPaymentMethods();
return NextResponse.json({ success: true, paymentMethods: methods });
try {
const paymentMethods = await getPaymentMethodsFromAppwrite();
return NextResponse.json({ success: true, paymentMethods });
} catch (error: any) {
const fallback = dataService.getPaymentMethods();
return NextResponse.json({ success: true, paymentMethods: fallback });
}
}
export async function PUT(request: Request) {
export async function POST(request: Request) {
try {
const { id, updates } = await request.json();
const updated = dataService.updatePaymentMethod(id, updates);
return NextResponse.json({ success: true, paymentMethod: updated });
const body = await request.json();
const created = await createPaymentMethodInAppwrite(body);
return NextResponse.json({ success: true, paymentMethod: created });
} catch (error: any) {
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
}
}
export async function PUT(request: Request) {
try {
const body = await request.json();
const { id, updates, ...rest } = body;
const targetId = id || rest.id;
if (!targetId) {
return NextResponse.json({ success: false, error: 'ID del método requerido' }, { status: 400 });
}
const payload = updates || rest;
const updated = await updatePaymentMethodInAppwrite(targetId, payload);
dataService.updatePaymentMethod(targetId, payload);
return NextResponse.json({ success: true, paymentMethod: updated || payload });
} catch (error: any) {
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
}
}
export async function DELETE(request: Request) {
try {
const { searchParams } = new URL(request.url);
let id = searchParams.get('id');
if (!id) {
try {
const body = await request.json();
id = body?.id;
} catch (_) {}
}
if (!id) {
return NextResponse.json({ success: false, error: 'ID del método requerido' }, { status: 400 });
}
const deleted = await deletePaymentMethodInAppwrite(id);
return NextResponse.json({ success: true, message: 'Método de pago eliminado correctamente' });
} catch (error: any) {
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
}
}

View File

@ -1,15 +1,25 @@
import { NextResponse } from 'next/server';
import { getSettingsFromAppwrite, updateSettingsInAppwrite } from '@/lib/server-appwrite';
import { dataService } from '@/lib/data-service';
export async function GET() {
const settings = dataService.getSettings();
return NextResponse.json({ success: true, settings });
try {
const settings = await getSettingsFromAppwrite();
if (settings) {
dataService.updateSettings(settings);
return NextResponse.json({ success: true, settings });
}
return NextResponse.json({ success: true, settings: dataService.getSettings() });
} catch (error: any) {
return NextResponse.json({ success: true, settings: dataService.getSettings() });
}
}
export async function PUT(request: Request) {
try {
const body = await request.json();
const updated = dataService.updateSettings(body);
const updates = await request.json();
const updated = await updateSettingsInAppwrite(updates);
dataService.updateSettings(updates);
return NextResponse.json({ success: true, settings: updated });
} catch (error: any) {
return NextResponse.json({ success: false, error: error.message }, { status: 400 });

View File

@ -1,25 +1,33 @@
import { NextResponse } from 'next/server';
import {
getSuppliersFromAppwrite,
createSupplierInAppwrite,
updateSupplierInAppwrite,
deleteSupplierInAppwrite,
} from '@/lib/server-appwrite';
import { dataService } from '@/lib/data-service';
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const includeInactive = searchParams.get('all') === 'true' || searchParams.get('includeInactive') === 'true';
const suppliers = dataService.getSuppliers(includeInactive);
return NextResponse.json({ success: true, suppliers });
const includeAll = searchParams.get('all') === 'true';
const suppliers = await getSuppliersFromAppwrite();
const result = includeAll ? suppliers : suppliers.filter((s) => s.isActive !== false);
return NextResponse.json({ success: true, suppliers: result });
} catch (error: any) {
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
const fallback = dataService.getSuppliers(true);
return NextResponse.json({ success: true, suppliers: fallback });
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
if (!body.name || !body.phone) {
return NextResponse.json({ success: false, error: 'El nombre y el teléfono son requeridos' }, { status: 400 });
}
const newSupplier = dataService.addSupplier(body);
return NextResponse.json({ success: true, supplier: newSupplier });
const created = await createSupplierInAppwrite(body);
dataService.addSupplier(created);
return NextResponse.json({ success: true, supplier: created });
} catch (error: any) {
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
}
@ -30,17 +38,16 @@ export async function PUT(request: Request) {
const body = await request.json();
const { id, updates, ...rest } = body;
const targetId = id || rest.id;
if (!targetId) {
return NextResponse.json({ success: false, error: 'ID de proveedor requerido' }, { status: 400 });
}
const payload = updates || rest;
const updated = dataService.updateSupplier(targetId, payload);
if (!updated) {
return NextResponse.json({ success: false, error: 'Proveedor no encontrado' }, { status: 404 });
}
const updated = await updateSupplierInAppwrite(targetId, payload);
dataService.updateSupplier(targetId, payload);
return NextResponse.json({ success: true, supplier: updated });
return NextResponse.json({ success: true, supplier: updated || payload });
} catch (error: any) {
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
}
@ -62,9 +69,10 @@ export async function DELETE(request: Request) {
return NextResponse.json({ success: false, error: 'ID de proveedor requerido' }, { status: 400 });
}
// Soft-deactivate to ensure 100% preservation of historical orders & catalog references
const success = dataService.deleteSupplier(id);
return NextResponse.json({ success, message: 'Proveedor desactivado correctamente sin perder historial' });
const deleted = await deleteSupplierInAppwrite(id);
dataService.deleteSupplier(id);
return NextResponse.json({ success: true, message: 'Proveedor desactivado/eliminado correctamente' });
} catch (error: any) {
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
}

View File

@ -1,8 +1,15 @@
import { NextResponse } from 'next/server';
import { dataService } from '@/lib/data-service';
import { parseSupplierMessage } from '@/lib/gemini-parser';
import { performMarketIntelligenceSearch } from '@/lib/market-intelligence';
import { getProductSocialProof } from '@/lib/social-proof';
import { generateAttractiveProductCopy } from '@/lib/attractive-copy';
import {
getSupplierPostsFromAppwrite,
saveSupplierPostToAppwrite,
updateSupplierPostStatusInAppwrite,
saveProductToAppwrite,
} from '@/lib/server-appwrite';
import { SupplierPost, MonitoredWhatsAppGroup } from '@/types';
const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co';
@ -13,10 +20,13 @@ export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const fetchLive = searchParams.get('fetchLive') !== 'false';
const limit = parseInt(searchParams.get('limit') || '50', 10);
const limit = parseInt(searchParams.get('limit') || '60', 10);
const filterJid = searchParams.get('groupJid');
// 1. Obtener grupos locales y descubrir grupos en vivo desde Evolution API
// 1. Obtener posts guardados en Appwrite
const savedPosts = await getSupplierPostsFromAppwrite();
// 2. Obtener grupos locales y descubrir grupos en vivo desde Evolution API
const localGroups: MonitoredWhatsAppGroup[] = dataService.getMonitoredGroups();
const groupsMap = new Map<string, string>();
localGroups.forEach((g) => groupsMap.set(g.jid, g.name));
@ -46,7 +56,7 @@ export async function GET(request: Request) {
console.warn('Error resolviendo chats de WhatsApp:', e);
}
// 2. Determinar JIDs objetivo a consultar
// 3. Determinar JIDs objetivo a consultar (traer TODOS los mensajes de los grupos)
let targetJids: string[] = [];
if (filterJid && filterJid !== 'all') {
targetJids = [filterJid];
@ -61,17 +71,17 @@ export async function GET(request: Request) {
}
}
if (targetJids.length === 0) {
if (!fetchLive || targetJids.length === 0) {
return NextResponse.json({
success: true,
isLive: true,
totalFound: 0,
posts: dataService.getSupplierPosts(),
isLive: false,
totalFound: savedPosts.length,
posts: savedPosts.length > 0 ? savedPosts : dataService.getSupplierPosts(),
groups: Array.from(groupsMap.entries()).map(([jid, name]) => ({ jid, name })),
});
}
// 3. Consultar mensajes de cada grupo objetivo en paralelo
// 4. Consultar mensajes de cada grupo objetivo en paralelo (TODOS los mensajes sin distinción de leídos o sin leer)
const groupFetchPromises = targetJids.map(async (jid) => {
try {
const msgRes = await fetch(
@ -100,13 +110,11 @@ export async function GET(request: Request) {
}
}
// 4. Ordenar cronológicamente (más recientes primero)
// 5. Ordenar cronológicamente (más recientes primero)
allRecords.sort((a, b) => (b.messageTimestamp || 0) - (a.messageTimestamp || 0));
// 5. Procesar cada mensaje y descargar imágenes/IA
// 6. Procesar cada mensaje y descargar imágenes / IA
const processedPosts: SupplierPost[] = [];
// Limitar a los 60 más relevantes para rendimiento óptimo
const sliceRecords = allRecords.slice(0, 60);
for (const record of sliceRecords) {
@ -124,9 +132,15 @@ export async function GET(request: Request) {
// Si no tiene texto ni imagen, omitir
if (!text && !hasImage) continue;
const postId = `post-${record.key?.id || Date.now()}`;
// Verificar si ya existe en Appwrite para preservar su estado de publicación o descarte
const existingInAppwrite = savedPosts.find(
(p) => p.id === postId || (p.rawMessage === text && (p as any).channelJid === remoteJid)
);
let imageUrls: string[] = [];
// Descargar imagen en Base64 desde Evolution API si está presente
if (hasImage) {
try {
const mediaUrl = `${EVOLUTION_API_URL}/chat/getBase64FromMediaMessage/${EVOLUTION_INSTANCE_NAME}`;
@ -154,9 +168,11 @@ export async function GET(request: Request) {
);
}
// Parsear oferta de calzado con IA y visión de logos en imagen
const extracted = await parseSupplierMessage(text || groupName, imageUrls[0]);
// Si ya existía con datos extraídos en Appwrite, usarlo directamente
let extracted = existingInAppwrite?.extractedProduct;
if (!extracted) {
extracted = await parseSupplierMessage(text || groupName, imageUrls[0]);
}
const timestamp = record.messageTimestamp
? new Date(record.messageTimestamp * 1000).toLocaleString('es-CO', {
@ -168,31 +184,40 @@ export async function GET(request: Request) {
: 'Reciente';
const post: SupplierPost = {
id: `post-${record.key?.id || Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
id: postId,
channelName: groupName,
channelJid: remoteJid,
rawMessage: text || `[Foto de Calzado enviada en ${groupName}]`,
rawImages: imageUrls,
extractedProduct: extracted,
status: 'pending_review',
status: existingInAppwrite?.status || 'pending_review',
receivedAt: timestamp,
supplierName: groupName,
supplierPhone: record.key?.participant?.replace(/\D/g, '') || '573167455877',
isRead: Boolean(record.status === 'READ' || record.key?.fromMe),
};
processedPosts.push(post);
// Persistir de forma no bloqueante en Appwrite
saveSupplierPostToAppwrite(post).catch(() => {});
}
// Guardar en el servicio local los nuevos posts
if (processedPosts.length > 0) {
dataService.saveSupplierPosts(processedPosts);
}
const mergedPosts = [...processedPosts];
// Agregar los posts de Appwrite que no hayan estado en esta ráfaga en vivo
savedPosts.forEach((sp) => {
if (!mergedPosts.some((p) => p.id === sp.id)) {
mergedPosts.push(sp);
}
});
dataService.saveSupplierPosts(mergedPosts);
return NextResponse.json({
success: true,
isLive: true,
totalFound: processedPosts.length,
posts: processedPosts.length > 0 ? processedPosts : dataService.getSupplierPosts(),
totalFound: mergedPosts.length,
posts: mergedPosts,
groups: Array.from(groupsMap.entries()).map(([jid, name]) => ({ jid, name })),
});
} catch (error: any) {
@ -204,7 +229,17 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
try {
const body = await request.json();
const { action, post, posts, customRetailPrice, customName, customBrand, customColorName, customSizes } = body;
const {
action,
post,
posts,
customRetailPrice,
customName,
customBrand,
customColorName,
customSizes,
marketIntelligence,
} = body;
// 1. PUBLICAR UN PRODUCTO A LA TIENDA (APP MÓVIL Y WEB)
if (action === 'publish' && post) {
@ -226,6 +261,10 @@ export async function POST(request: Request) {
supplierPrice: post.extractedProduct?.supplierPrice || 75000,
retailPrice: retailPrice,
originalPrice: Math.round(retailPrice * 1.35),
marketPriceCOP: post.extractedProduct?.marketPriceCOP || marketIntelligence?.marketPriceCOP,
marketPriceSource: post.extractedProduct?.marketPriceSource || marketIntelligence?.marketPriceSource,
marketPriceSourceUrl: post.extractedProduct?.marketPriceSourceUrl || marketIntelligence?.marketPriceSourceUrl,
marketComparisonSummary: post.extractedProduct?.marketComparisonSummary || marketIntelligence?.marketComparisonSummary,
sizes: customSizes || post.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'],
images: Array.isArray(post.rawImages) && post.rawImages.length > 0 ? post.rawImages : ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'],
description: generateAttractiveProductCopy({ name: prodName, brand: prodBrand, retailPrice }),
@ -241,11 +280,13 @@ export async function POST(request: Request) {
};
const added = dataService.addProduct(newProduct as any);
await saveProductToAppwrite(added);
await updateSupplierPostStatusInAppwrite(post.id, 'published');
dataService.updateSupplierPostStatus(post.id, 'published');
return NextResponse.json({
success: true,
message: `¡${prodName} publicado y agrupado en el catálogo!`,
message: `¡${prodName} publicado y sincronizado en Appwrite, App Móvil y Web!`,
product: added,
});
}
@ -270,6 +311,10 @@ export async function POST(request: Request) {
supplierPrice: p.extractedProduct?.supplierPrice || 75000,
retailPrice,
originalPrice: Math.round(retailPrice * 1.35),
marketPriceCOP: p.extractedProduct?.marketPriceCOP,
marketPriceSource: p.extractedProduct?.marketPriceSource,
marketPriceSourceUrl: p.extractedProduct?.marketPriceSourceUrl,
marketComparisonSummary: p.extractedProduct?.marketComparisonSummary,
sizes: p.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'],
images: p.rawImages || ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'],
description: generateAttractiveProductCopy({ name: prodName, brand: prodBrand, retailPrice }),
@ -284,25 +329,45 @@ export async function POST(request: Request) {
supplierPhone: p.supplierPhone || '573167455877',
};
const added = dataService.addProduct(newProd as any);
await saveProductToAppwrite(added);
await updateSupplierPostStatusInAppwrite(p.id, 'published');
dataService.updateSupplierPostStatus(p.id, 'published');
createdProducts.push(added);
}
return NextResponse.json({
success: true,
message: `¡Se publicaron y agruparon ${createdProducts.length} productos en la App Móvil y Web!`,
message: `¡Se publicaron ${createdProducts.length} productos con persistencia en Appwrite!`,
products: createdProducts,
});
}
// 3. DESCARTAR POST
if (action === 'discard' && post?.id) {
await updateSupplierPostStatusInAppwrite(post.id, 'discarded');
dataService.updateSupplierPostStatus(post.id, 'discarded');
return NextResponse.json({ success: true, message: 'Mensaje descartado' });
}
// 4. BÚSQUEDA VISUAL E INTELIGENCIA DE MERCADO SOBRE POST
if (action === 'market-research' && post) {
const img = post.rawImages && post.rawImages[0];
const res = await performMarketIntelligenceSearch(img, post.rawMessage, post.extractedProduct?.supplierPrice);
post.extractedProduct = {
...post.extractedProduct,
...res,
};
post.marketResearchDone = true;
await saveSupplierPostToAppwrite(post);
return NextResponse.json({
success: true,
message: '¡Búsqueda visual e inteligencia de precios completada con éxito!',
extractedProduct: post.extractedProduct,
});
}
return NextResponse.json({ success: false, error: 'Acción no válida' }, { status: 400 });
} catch (err: any) {
console.error('Error en POST /api/whatsapp-feed:', err);

View File

@ -575,10 +575,25 @@ class DataService {
return this.cities[idx];
}
deleteCity(id: string): boolean {
const initialLen = this.cities.length;
this.cities = this.cities.filter((c) => c.id !== id);
return this.cities.length < initialLen;
}
getPaymentMethods(): PaymentMethod[] {
return this.paymentMethods;
}
getPaymentMethodById(id: string): PaymentMethod | undefined {
return this.paymentMethods.find((p) => p.id === id);
}
addPaymentMethod(method: PaymentMethod): PaymentMethod {
this.paymentMethods.push(method);
return method;
}
updatePaymentMethod(id: string, updates: Partial<PaymentMethod>): PaymentMethod | null {
const idx = this.paymentMethods.findIndex((p) => p.id === id);
if (idx === -1) return null;
@ -586,6 +601,12 @@ class DataService {
return this.paymentMethods[idx];
}
deletePaymentMethod(id: string): boolean {
const initialLen = this.paymentMethods.length;
this.paymentMethods = this.paymentMethods.filter((p) => p.id !== id);
return this.paymentMethods.length < initialLen;
}
getOrders(): Order[] {
return this.orders;
}

View File

@ -0,0 +1,127 @@
import { GoogleGenAI } from '@google/genai';
import { ExtractedProductData } from '@/types';
import { formatCOP, calculateRetailPrice } from './pricing-engine';
/**
* Helper para extraer base64 y mimeType de una imagen
*/
function extractBase64Payload(imageInput?: string): { mimeType: string; data: string } | null {
if (!imageInput || typeof imageInput !== 'string') return null;
const dataUriMatch = imageInput.match(/^data:([^;]+);base64,(.+)$/);
if (dataUriMatch) {
return {
mimeType: dataUriMatch[1] || 'image/jpeg',
data: dataUriMatch[2],
};
}
if (imageInput.length > 100 && !imageInput.startsWith('http') && !imageInput.includes(' ')) {
return {
mimeType: 'image/jpeg',
data: imageInput,
};
}
return null;
}
/**
* Motor de Búsqueda Visual e Inteligencia de Mercado Colombiano
* Utiliza visión multimodal para rastrear el modelo exacto, código SKU/Style Code
* y comparar precios con tiendas oficiales y marketplaces en Colombia (Nike, Adidas, Falabella, StockX, Farfetch, etc.)
*/
export async function performMarketIntelligenceSearch(
imageBase64OrUrl: string,
supplierTextHint?: string,
supplierPriceInput?: number
): Promise<ExtractedProductData> {
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
throw new Error('GEMINI_API_KEY no configurada');
}
const ai = new GoogleGenAI({ apiKey });
const imagePayload = extractBase64Payload(imageBase64OrUrl);
const prompt = `Eres un auditor experto en zapatillas y analista de precios de calzado en Colombia.
Tu tarea es analizar la IMAGEN de esta zapatilla y buscar en internet la información más precisa y actualizada para el mercado colombiano.
${
imagePayload
? `Identifica la silueta exacta, marca, combinación de colores (colorway) y estilo de esta zapatilla.`
: `Analiza la información proporcionada: "${supplierTextHint}"`
}
${supplierTextHint ? `Pista / texto del mayorista: "${supplierTextHint}"` : ''}
${supplierPriceInput ? `Costo del proveedor mayorista: ${supplierPriceInput} COP` : ''}
Realiza una comparativa de mercado real y devuelve un JSON estricto con los siguientes campos:
{
"brand": "Marca oficial (ej: Adidas, Nike, Jordan, New Balance, On Running, Puma, Asics, Vans, Converse, Salomon, Hoka, Balenciaga, Golden Goose, Crocs, etc.)",
"name": "Nombre comercial completo y exacto del modelo (ej: Adidas Samba OG Blanco Negro, New Balance 9060 Sea Salt, Jordan 4 Retro Military Black, Nike Dunk Low Retro Panda, On Cloudmonster All Black)",
"modelName": "Nombre del modelo base (ej: Samba OG, 9060, Retro 4, Dunk Low, Cloudmonster)",
"colorName": "Nombre del colorway oficial (ej: White / Core Black / Gum, Sea Salt / Rain Cloud, Military Black)",
"styleCode": "Código de estilo o SKU oficial del fabricante si es reconocible (ej: B75806, DH6927-111, U9060ECA, DD1391-100)",
"category": "Categoría (ej: Sneakers / Colección, Running / Deportivo, Urbano / Casual, Outdoor / Trail)",
"marketPriceCOP": Precio de venta promedio en pesos colombianos (COP) en tiendas oficiales o distribuidores autorizados en Colombia (Falabella, Nike Colombia, StockX, Farfetch, KicksCrew). Debe ser un número entero en COP (ej: 420000, 580000, 750000, 1100000).,
"marketPriceSource": "Nombre de la tienda o plataforma de referencia en Colombia (ej: 'Nike Store Oficial Colombia', 'Falabella Colombia', 'StockX Reference', 'Farfetch Luxury', 'Adidas Originals Colombia')",
"marketPriceSourceUrl": "URL o referencia de ubicación de la tienda (ej: 'https://www.nike.com/co/', 'https://www.falabella.com.co', 'https://stockx.com')",
"supplierPrice": Precio estimado de costo mayorista en COP (número entero, ej: 75000, 85000, 95000),
"suggestedRetailPrice": Precio de venta sugerido para la tienda DripZone en COP (número entero redondeado a múltiplos de 5000 COP, ej: 125000, 135000, 140000),
"marketComparisonSummary": "Breve resumen comparativo y comercial para el mercado colombiano destacando el ahorro del cliente frente al precio en tiendas retail oficiales (ej: 'En tiendas oficiales cuesta $520.000 COP. Vendiendo a $130.000 COP tu cliente ahorra un 75% con excelente margen.')",
"sizes": ["37", "38", "39", "40", "41", "42", "43"]
}
Responde ÚNICAMENTE con el JSON válido, sin bloques de código markdown ni texto adicional.`;
const contents: any[] = [];
if (imagePayload) {
contents.push({
inlineData: {
mimeType: imagePayload.mimeType,
data: imagePayload.data,
},
});
}
contents.push(prompt);
let outputText = '';
try {
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents,
});
outputText = response.text || '';
} catch (err) {
const fallbackResponse = await ai.models.generateContent({
model: 'gemini-2.0-flash',
contents,
});
outputText = fallbackResponse.text || '';
}
const cleanJson = outputText.replace(/```json|```/gi, '').trim();
const parsed = JSON.parse(cleanJson);
const supPrice = supplierPriceInput || parsed.supplierPrice || 85000;
const retailPrice = parsed.suggestedRetailPrice || calculateRetailPrice(supPrice, 35, 30000);
return {
name: parsed.name || 'Zapatilla Urbana',
brand: parsed.brand || 'Sneakers',
modelName: parsed.modelName || parsed.name,
colorName: parsed.colorName || '',
sizes: Array.isArray(parsed.sizes) ? parsed.sizes.map(String) : ['37', '38', '39', '40', '41', '42', '43'],
supplierPrice: supPrice,
suggestedRetailPrice: retailPrice,
marketPriceCOP: typeof parsed.marketPriceCOP === 'number' ? parsed.marketPriceCOP : Math.round(retailPrice * 2.8),
marketPriceSource: parsed.marketPriceSource || 'Retail Oficial Colombia (Nike / Falabella)',
marketPriceSourceUrl: parsed.marketPriceSourceUrl || 'https://www.nike.com/co/',
marketComparisonSummary: parsed.marketComparisonSummary || `Precio estimado en tiendas: ${formatCOP(parsed.marketPriceCOP || retailPrice * 3)}. Precio DripZone: ${formatCOP(retailPrice)}.`,
styleCode: parsed.styleCode || '',
category: parsed.category || 'Sneakers / Colección',
confidence: 0.98,
};
}

View File

@ -50,6 +50,98 @@ export function getPricingBreakdown(supplierCost: number, minMarginPercent: numb
};
}
export interface MarketPricingStrategy {
id: 'competitive' | 'balanced' | 'premium';
name: string;
badge: string;
description: string;
price: number;
profitCOP: number;
marginPercent: number;
clientSavingsPercent: number;
savingsCOP: number;
}
export function calculateMarketPricingStrategies(
supplierCost: number,
marketReferencePriceCOP?: number
): {
competitive: MarketPricingStrategy;
balanced: MarketPricingStrategy;
premium: MarketPricingStrategy;
marketReferencePrice: number;
} {
const cost = supplierCost > 0 ? supplierCost : 85000;
const marketRef =
marketReferencePriceCOP && marketReferencePriceCOP > cost * 1.5
? marketReferencePriceCOP
: Math.round(cost * 4.2); // Referencia promedio en tiendas retail oficiales de Colombia
// Estrategia 1: ⚡ Máxima Competitividad (Alta Rotación)
// Margen de ganancia de $30.000 a $35.000 COP
const competitiveTarget = cost + Math.max(30000, Math.round(cost * 0.35));
const competitivePrice = roundUpToNearest5000(competitiveTarget);
const competitiveProfit = competitivePrice - cost;
const competitiveSavingsCOP = Math.max(0, marketRef - competitivePrice);
const competitiveSavingsPct = Math.round((competitiveSavingsCOP / marketRef) * 100);
// Estrategia 2: ⚖️ Precio Recomendado DripZone (Equilibrio Margen / Ahorro Cliente)
// Margen de ganancia de $45.000 a $55.000 COP
const balancedTarget = cost + Math.max(45000, Math.round(cost * 0.52));
const balancedPrice = roundUpToNearest5000(balancedTarget);
const balancedProfit = balancedPrice - cost;
const balancedSavingsCOP = Math.max(0, marketRef - balancedPrice);
const balancedSavingsPct = Math.round((balancedSavingsCOP / marketRef) * 100);
// Estrategia 3: 💎 Máxima Ganancia / Exclusividad (Mayor Rentabilidad)
// Margen de ganancia de $70.000 a $95.000+ COP aprovechando la alta demanda o valor de mercado
const premiumTarget = Math.min(
marketRef * 0.55,
cost + Math.max(70000, Math.round(cost * 0.85))
);
const premiumPrice = roundUpToNearest5000(Math.max(premiumTarget, balancedPrice + 25000));
const premiumProfit = premiumPrice - cost;
const premiumSavingsCOP = Math.max(0, marketRef - premiumPrice);
const premiumSavingsPct = Math.round((premiumSavingsCOP / marketRef) * 100);
return {
marketReferencePrice: marketRef,
competitive: {
id: 'competitive',
name: 'Alta Rotación / Gancho',
badge: '⚡ Mejor Precio',
description: 'Precio imbatible para arrasar en ventas rápidas y captar nuevos clientes.',
price: competitivePrice,
profitCOP: competitiveProfit,
marginPercent: Math.round((competitiveProfit / cost) * 100),
clientSavingsPercent: competitiveSavingsPct,
savingsCOP: competitiveSavingsCOP,
},
balanced: {
id: 'balanced',
name: 'Recomendado DripZone',
badge: '⚖️ Equilibrio Perfecto',
description: 'Excelente ganancia neta manteniendo un descuento masivo frente a tiendas oficiales.',
price: balancedPrice,
profitCOP: balancedProfit,
marginPercent: Math.round((balancedProfit / cost) * 100),
clientSavingsPercent: balancedSavingsPct,
savingsCOP: balancedSavingsCOP,
},
premium: {
id: 'premium',
name: 'Máximo Margen / Hype',
badge: '💎 Mayor Ganancia',
description: 'Aprovecha modelos exclusivos o de alta demanda para maximizar tu ganancia neta.',
price: premiumPrice,
profitCOP: premiumProfit,
marginPercent: Math.round((premiumProfit / cost) * 100),
clientSavingsPercent: premiumSavingsPct,
savingsCOP: premiumSavingsCOP,
},
};
}
export function calculateAnchorPrice(retailPrice: number, discountPercentage: number = 30): number {
if (!retailPrice || retailPrice <= 0) return 0;
const anchor = retailPrice / (1 - discountPercentage / 100);

View File

@ -0,0 +1,665 @@
import { Client, Databases, ID, Query } from 'node-appwrite';
import {
City,
PaymentMethod,
Order,
Product,
Supplier,
StoreSettings,
SupplierPost,
MonitoredWhatsAppGroup,
MessageTemplate,
} from '@/types';
import { INITIAL_CITIES, INITIAL_PAYMENT_METHODS, INITIAL_SUPPLIERS } from './data-service';
import { DEFAULT_MESSAGE_TEMPLATES } from './templates-service';
const endpoint = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT || 'https://appwrite.almeira.com.co/v1';
const projectId = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID || '69b84df5003bd8957c5c';
const apiKey = process.env.APPWRITE_API_KEY || 'standard_cce3cdb866ea6433888d40cedd24dc45987372308fdc3dd0697de08648c0e5c7e04b02c3bcee271403fd656b00a5820f04c08b51631284a6e8a3582d8aed2aa33234f3c29cc777e9b162eb54810b789251b0d006a9a3b081ebc2da53b9baa94614b124b3508ad849fd9a4187cc6094f506d4b57f409b69d9562a74df594f3ec3';
const databaseId = process.env.NEXT_PUBLIC_APPWRITE_DATABASE_ID || 'sneakers_multistore_db';
const client = new Client()
.setEndpoint(endpoint)
.setProject(projectId)
.setKey(apiKey);
export const serverDatabases = new Databases(client);
export const DB_ID = databaseId;
// =========================================================================
// 1. CIUDADES Y FLETES (CRUD PERSISTENTE EN APPWRITE)
// =========================================================================
export async function getCitiesFromAppwrite(): Promise<City[]> {
try {
const res = await serverDatabases.listDocuments(DB_ID, 'cities', [
Query.limit(100),
Query.orderAsc('name'),
]);
if (res.documents && res.documents.length > 0) {
return res.documents.map((d: any) => ({
id: d.$id,
name: d.name || '',
department: d.department || 'Colombia',
shippingCost: typeof d.shippingCost === 'number' ? d.shippingCost : 0,
deliveryTime: d.deliveryTime || 'Entrega en 24 Horas',
allowsContraentrega: Boolean(d.allowsContraentrega),
firstExchangeFree: Boolean(d.firstExchangeFree),
exchangePolicyNote: d.exchangePolicyNote || '',
isActive: d.isActive !== false,
}));
}
return INITIAL_CITIES;
} catch (e: any) {
console.warn('Error getCitiesFromAppwrite:', e.message);
return INITIAL_CITIES;
}
}
export async function createCityInAppwrite(city: Partial<City>): Promise<City> {
const newId = city.id || `city-${Date.now()}`;
const payload = {
name: city.name || 'Nueva Ciudad',
department: city.department || 'Colombia',
shippingCost: city.shippingCost || 0,
deliveryTime: city.deliveryTime || 'Entrega en 24 Horas',
allowsContraentrega: city.allowsContraentrega !== false,
firstExchangeFree: Boolean(city.firstExchangeFree),
exchangePolicyNote: city.exchangePolicyNote || '',
isActive: city.isActive !== false,
};
try {
const doc = await serverDatabases.createDocument(DB_ID, 'cities', newId, payload);
return { id: doc.$id, ...payload };
} catch (e: any) {
console.error('Error createCityInAppwrite:', e.message);
return { id: newId, ...payload };
}
}
export async function updateCityInAppwrite(id: string, updates: Partial<City>): Promise<City | null> {
try {
const cleanUpdates: Record<string, any> = {};
if (updates.name !== undefined) cleanUpdates.name = updates.name;
if (updates.department !== undefined) cleanUpdates.department = updates.department;
if (updates.shippingCost !== undefined) cleanUpdates.shippingCost = updates.shippingCost;
if (updates.deliveryTime !== undefined) cleanUpdates.deliveryTime = updates.deliveryTime;
if (updates.allowsContraentrega !== undefined) cleanUpdates.allowsContraentrega = updates.allowsContraentrega;
if (updates.firstExchangeFree !== undefined) cleanUpdates.firstExchangeFree = updates.firstExchangeFree;
if (updates.exchangePolicyNote !== undefined) cleanUpdates.exchangePolicyNote = updates.exchangePolicyNote;
if (updates.isActive !== undefined) cleanUpdates.isActive = updates.isActive;
const doc = await serverDatabases.updateDocument(DB_ID, 'cities', id, cleanUpdates);
return {
id: doc.$id,
name: doc.name,
department: doc.department,
shippingCost: doc.shippingCost,
deliveryTime: doc.deliveryTime,
allowsContraentrega: doc.allowsContraentrega,
firstExchangeFree: doc.firstExchangeFree,
exchangePolicyNote: doc.exchangePolicyNote,
isActive: doc.isActive,
};
} catch (e: any) {
console.error('Error updateCityInAppwrite:', e.message);
return null;
}
}
export async function deleteCityInAppwrite(id: string): Promise<boolean> {
try {
await serverDatabases.deleteDocument(DB_ID, 'cities', id);
return true;
} catch (e: any) {
console.error('Error deleteCityInAppwrite:', e.message);
return false;
}
}
// =========================================================================
// 2. MÉTODOS DE PAGO & ANTICIPOS (CRUD PERSISTENTE EN APPWRITE)
// =========================================================================
export async function getPaymentMethodsFromAppwrite(): Promise<PaymentMethod[]> {
try {
const res = await serverDatabases.listDocuments(DB_ID, 'payment_methods', [
Query.limit(100),
]);
if (res.documents && res.documents.length > 0) {
return res.documents.map((d: any) => ({
id: d.$id,
name: d.name || '',
type: d.type || 'contraentrega',
description: d.description || '',
requiresDeposit: Boolean(d.requiresDeposit),
depositAmount: typeof d.depositAmount === 'number' ? d.depositAmount : 0,
accountInfo: d.accountInfo || '',
boldPaymentLink: d.boldPaymentLink || '',
boldQrUrl: d.boldQrUrl || '',
isActive: d.isActive !== false,
}));
}
return INITIAL_PAYMENT_METHODS;
} catch (e: any) {
console.warn('Error getPaymentMethodsFromAppwrite:', e.message);
return INITIAL_PAYMENT_METHODS;
}
}
export async function createPaymentMethodInAppwrite(method: Partial<PaymentMethod>): Promise<PaymentMethod> {
const newId = method.id || `pay-${Date.now()}`;
const payload = {
name: method.name || 'Nuevo Método de Pago',
type: method.type || 'contraentrega',
description: method.description || '',
requiresDeposit: Boolean(method.requiresDeposit),
depositAmount: method.depositAmount || 0,
accountInfo: method.accountInfo || '',
boldPaymentLink: method.boldPaymentLink || '',
boldQrUrl: method.boldQrUrl || '',
isActive: method.isActive !== false,
};
try {
const doc = await serverDatabases.createDocument(DB_ID, 'payment_methods', newId, payload);
return { id: doc.$id, ...payload };
} catch (e: any) {
console.error('Error createPaymentMethodInAppwrite:', e.message);
return { id: newId, ...payload };
}
}
export async function updatePaymentMethodInAppwrite(id: string, updates: Partial<PaymentMethod>): Promise<PaymentMethod | null> {
try {
const cleanUpdates: Record<string, any> = {};
if (updates.name !== undefined) cleanUpdates.name = updates.name;
if (updates.type !== undefined) cleanUpdates.type = updates.type;
if (updates.description !== undefined) cleanUpdates.description = updates.description;
if (updates.requiresDeposit !== undefined) cleanUpdates.requiresDeposit = updates.requiresDeposit;
if (updates.depositAmount !== undefined) cleanUpdates.depositAmount = updates.depositAmount;
if (updates.accountInfo !== undefined) cleanUpdates.accountInfo = updates.accountInfo;
if (updates.boldPaymentLink !== undefined) cleanUpdates.boldPaymentLink = updates.boldPaymentLink;
if (updates.boldQrUrl !== undefined) cleanUpdates.boldQrUrl = updates.boldQrUrl;
if (updates.isActive !== undefined) cleanUpdates.isActive = updates.isActive;
const doc = await serverDatabases.updateDocument(DB_ID, 'payment_methods', id, cleanUpdates);
return {
id: doc.$id,
name: doc.name,
type: doc.type,
description: doc.description,
requiresDeposit: doc.requiresDeposit,
depositAmount: doc.depositAmount,
accountInfo: doc.accountInfo,
boldPaymentLink: doc.boldPaymentLink,
boldQrUrl: doc.boldQrUrl,
isActive: doc.isActive,
};
} catch (e: any) {
console.error('Error updatePaymentMethodInAppwrite:', e.message);
return null;
}
}
export async function deletePaymentMethodInAppwrite(id: string): Promise<boolean> {
try {
await serverDatabases.deleteDocument(DB_ID, 'payment_methods', id);
return true;
} catch (e: any) {
console.error('Error deletePaymentMethodInAppwrite:', e.message);
return false;
}
}
// =========================================================================
// 3. PEDIDOS & LOGÍSTICA (CRUD PERSISTENTE EN APPWRITE)
// =========================================================================
export async function getOrdersFromAppwrite(): Promise<Order[]> {
try {
const res = await serverDatabases.listDocuments(DB_ID, 'orders', [
Query.limit(100),
Query.orderDesc('$createdAt'),
]);
if (res.documents && res.documents.length > 0) {
return res.documents.map((d: any) => {
let items = [];
try {
items = typeof d.items === 'string' ? JSON.parse(d.items) : (d.items || []);
} catch (_) {
items = [];
}
return {
id: d.$id,
customerName: d.customerName || 'Cliente',
customerPhone: d.customerPhone || '',
address: d.address || '',
cityId: d.cityId || 'city-cali',
cityName: d.cityName || 'Cali',
items,
subtotal: typeof d.subtotal === 'number' ? d.subtotal : 0,
shippingCost: typeof d.shippingCost === 'number' ? d.shippingCost : 0,
totalAmount: typeof d.totalAmount === 'number' ? d.totalAmount : 0,
depositAmount: typeof d.depositAmount === 'number' ? d.depositAmount : 10000,
balanceDue: typeof d.balanceDue === 'number' ? d.balanceDue : 0,
paymentMethodId: d.paymentMethodId || 'pay-contraentrega',
paymentMethodName: d.paymentMethodName || 'Contraentrega',
status: d.status || 'pending',
notes: d.notes || '',
createdAt: d.createdAt || d.$createdAt,
};
});
}
return [];
} catch (e: any) {
console.warn('Error getOrdersFromAppwrite:', e.message);
return [];
}
}
export async function createOrderInAppwrite(order: Partial<Order>): Promise<Order> {
const newId = order.id || `ord-${Date.now()}`;
const total = order.totalAmount || 0;
const deposit = order.depositAmount !== undefined ? order.depositAmount : 10000;
const balance = order.balanceDue !== undefined ? order.balanceDue : Math.max(0, total - deposit);
const payload = {
customerName: order.customerName || 'Cliente',
customerPhone: order.customerPhone || '',
address: order.address || '',
cityId: order.cityId || 'city-cali',
cityName: order.cityName || 'Cali',
items: JSON.stringify(order.items || []),
subtotal: order.subtotal || total,
shippingCost: order.shippingCost || 0,
totalAmount: total,
depositAmount: deposit,
balanceDue: balance,
paymentMethodId: order.paymentMethodId || 'pay-contraentrega',
paymentMethodName: order.paymentMethodName || 'Contraentrega',
status: order.status || 'pending',
notes: order.notes || '',
createdAt: order.createdAt || new Date().toISOString(),
};
try {
const doc = await serverDatabases.createDocument(DB_ID, 'orders', newId, payload);
return {
id: doc.$id,
...payload,
items: order.items || [],
status: payload.status as any,
};
} catch (e: any) {
console.error('Error createOrderInAppwrite:', e.message);
return {
id: newId,
...payload,
items: order.items || [],
status: payload.status as any,
};
}
}
export async function updateOrderInAppwrite(id: string, updates: Partial<Order>): Promise<Order | null> {
try {
const cleanUpdates: Record<string, any> = {};
if (updates.customerName !== undefined) cleanUpdates.customerName = updates.customerName;
if (updates.customerPhone !== undefined) cleanUpdates.customerPhone = updates.customerPhone;
if (updates.address !== undefined) cleanUpdates.address = updates.address;
if (updates.cityId !== undefined) cleanUpdates.cityId = updates.cityId;
if (updates.cityName !== undefined) cleanUpdates.cityName = updates.cityName;
if (updates.items !== undefined) cleanUpdates.items = JSON.stringify(updates.items);
if (updates.subtotal !== undefined) cleanUpdates.subtotal = updates.subtotal;
if (updates.shippingCost !== undefined) cleanUpdates.shippingCost = updates.shippingCost;
if (updates.totalAmount !== undefined) cleanUpdates.totalAmount = updates.totalAmount;
if (updates.depositAmount !== undefined) cleanUpdates.depositAmount = updates.depositAmount;
if (updates.balanceDue !== undefined) cleanUpdates.balanceDue = updates.balanceDue;
if (updates.paymentMethodId !== undefined) cleanUpdates.paymentMethodId = updates.paymentMethodId;
if (updates.paymentMethodName !== undefined) cleanUpdates.paymentMethodName = updates.paymentMethodName;
if (updates.status !== undefined) cleanUpdates.status = updates.status;
if (updates.notes !== undefined) cleanUpdates.notes = updates.notes;
const doc = await serverDatabases.updateDocument(DB_ID, 'orders', id, cleanUpdates);
let items = [];
try {
items = typeof doc.items === 'string' ? JSON.parse(doc.items) : (doc.items || []);
} catch (_) {
items = updates.items || [];
}
return {
id: doc.$id,
customerName: doc.customerName,
customerPhone: doc.customerPhone,
address: doc.address,
cityId: doc.cityId,
cityName: doc.cityName,
items,
subtotal: doc.subtotal,
shippingCost: doc.shippingCost,
totalAmount: doc.totalAmount,
depositAmount: doc.depositAmount,
balanceDue: doc.balanceDue,
paymentMethodId: doc.paymentMethodId,
paymentMethodName: doc.paymentMethodName,
status: doc.status,
notes: doc.notes,
createdAt: doc.createdAt || doc.$createdAt,
};
} catch (e: any) {
console.error('Error updateOrderInAppwrite:', e.message);
return null;
}
}
export async function deleteOrderInAppwrite(id: string): Promise<boolean> {
try {
await serverDatabases.deleteDocument(DB_ID, 'orders', id);
return true;
} catch (e: any) {
console.error('Error deleteOrderInAppwrite:', e.message);
return false;
}
}
// =========================================================================
// 4. PROVEEDORES (CRUD PERSISTENTE EN APPWRITE)
// =========================================================================
export async function getSuppliersFromAppwrite(): Promise<Supplier[]> {
try {
const res = await serverDatabases.listDocuments(DB_ID, 'suppliers', [
Query.limit(100),
]);
if (res.documents && res.documents.length > 0) {
return res.documents.map((d: any) => ({
id: d.$id,
name: d.name || '',
phone: d.phone || '',
contactPerson: d.contactPerson || '',
address: d.address || '',
notes: d.notes || '',
isActive: d.isActive !== false,
}));
}
return INITIAL_SUPPLIERS;
} catch (e: any) {
console.warn('Error getSuppliersFromAppwrite:', e.message);
return INITIAL_SUPPLIERS;
}
}
export async function createSupplierInAppwrite(supplier: Partial<Supplier>): Promise<Supplier> {
const newId = supplier.id || `sup-${Date.now()}`;
const payload = {
name: supplier.name || 'Nuevo Proveedor',
phone: supplier.phone || '573167455877',
contactPerson: supplier.contactPerson || '',
address: supplier.address || '',
notes: supplier.notes || '',
isActive: supplier.isActive !== false,
};
try {
const doc = await serverDatabases.createDocument(DB_ID, 'suppliers', newId, payload);
return { id: doc.$id, ...payload };
} catch (e: any) {
return { id: newId, ...payload };
}
}
export async function updateSupplierInAppwrite(id: string, updates: Partial<Supplier>): Promise<Supplier | null> {
try {
const doc = await serverDatabases.updateDocument(DB_ID, 'suppliers', id, updates);
return {
id: doc.$id,
name: doc.name,
phone: doc.phone,
contactPerson: doc.contactPerson,
address: doc.address,
notes: doc.notes,
isActive: doc.isActive,
};
} catch (e) {
return null;
}
}
export async function deleteSupplierInAppwrite(id: string): Promise<boolean> {
try {
await serverDatabases.deleteDocument(DB_ID, 'suppliers', id);
return true;
} catch (e) {
return false;
}
}
// =========================================================================
// 5. CONFIGURACIÓN DE LA TIENDA
// =========================================================================
export async function getSettingsFromAppwrite(): Promise<StoreSettings | null> {
try {
const doc = await serverDatabases.getDocument(DB_ID, 'store_settings', 'main_settings');
return {
storeName: doc.storeName || 'DripZone',
slogan: doc.slogan || '',
email: doc.email || '',
phone: doc.phone || '',
whatsappNumber: doc.whatsappNumber || '',
instagramHandle: doc.instagramHandle || '',
address: doc.address || '',
city: doc.city || 'Cali, Colombia',
nequiAccount: doc.nequiAccount || '',
daviplataAccount: doc.daviplataAccount || '',
accountHolder: doc.accountHolder || '',
boldPaymentLink: doc.boldPaymentLink || '',
boldApiKey: doc.boldApiKey || '',
boldMerchantId: doc.boldMerchantId || '',
boldQrUrl: doc.boldQrUrl || '',
boldEnabled: doc.boldEnabled !== false,
defaultProfitMargin: doc.defaultProfitMargin || 35,
defaultDepositAmount: doc.defaultDepositAmount || 10000,
};
} catch (e) {
return null;
}
}
export async function updateSettingsInAppwrite(settings: Partial<StoreSettings>): Promise<StoreSettings> {
try {
const doc = await serverDatabases.updateDocument(DB_ID, 'store_settings', 'main_settings', settings);
return {
storeName: doc.storeName,
slogan: doc.slogan,
email: doc.email,
phone: doc.phone,
whatsappNumber: doc.whatsappNumber,
instagramHandle: doc.instagramHandle,
address: doc.address,
city: doc.city,
nequiAccount: doc.nequiAccount,
daviplataAccount: doc.daviplataAccount,
accountHolder: doc.accountHolder,
boldPaymentLink: doc.boldPaymentLink,
boldApiKey: doc.boldApiKey,
boldMerchantId: doc.boldMerchantId,
boldQrUrl: doc.boldQrUrl,
boldEnabled: doc.boldEnabled,
defaultProfitMargin: doc.defaultProfitMargin,
defaultDepositAmount: doc.defaultDepositAmount,
};
} catch (e) {
return settings as StoreSettings;
}
}
// =========================================================================
// 6. PRODUCTOS Y CATÁLOGO
// =========================================================================
export async function getProductsFromAppwrite(): Promise<Product[]> {
try {
const res = await serverDatabases.listDocuments(DB_ID, 'products', [
Query.limit(100),
Query.orderDesc('$createdAt'),
]);
if (res.documents && res.documents.length > 0) {
return res.documents.map((d: any) => {
let sizes = ['37', '38', '39', '40', '41', '42', '43'];
let images = ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'];
let colors = [];
try {
if (d.sizes) sizes = typeof d.sizes === 'string' ? JSON.parse(d.sizes) : d.sizes;
} catch (_) {}
try {
if (d.images) images = typeof d.images === 'string' ? JSON.parse(d.images) : d.images;
} catch (_) {}
try {
if (d.colors) colors = typeof d.colors === 'string' ? JSON.parse(d.colors) : d.colors;
} catch (_) {}
return {
id: d.$id,
name: d.name || 'Zapatillas',
modelName: d.modelName || d.name,
colorName: d.colorName || '',
brand: d.brand || 'Sneakers',
supplierPrice: d.supplierPrice || 90000,
retailPrice: d.retailPrice || 125000,
originalPrice: d.originalPrice || Math.round((d.retailPrice || 125000) * 1.35),
sizes,
images,
colors,
description: d.description || '',
category: d.category || 'Calzado / Sneakers',
inStock: d.inStock !== false,
isActive: d.isActive !== false,
supplierName: d.supplierName || '',
supplierPhone: d.supplierPhone || '',
};
});
}
return [];
} catch (e: any) {
return [];
}
}
export async function saveProductToAppwrite(product: Product): Promise<Product> {
const payload = {
name: product.name,
modelName: product.modelName || product.name,
brand: product.brand || 'Sneakers',
colorName: product.colorName || '',
supplierPrice: product.supplierPrice || 0,
retailPrice: product.retailPrice || 0,
originalPrice: product.originalPrice || Math.round((product.retailPrice || 0) * 1.35),
sizes: JSON.stringify(product.sizes || []),
images: JSON.stringify(product.images || []),
colors: JSON.stringify(product.colors || []),
description: product.description || '',
category: product.category || 'Sneakers',
inStock: product.inStock !== false,
isActive: product.isActive !== false,
supplierName: product.supplierName || '',
supplierPhone: product.supplierPhone || '',
};
try {
const doc = await serverDatabases.createDocument(DB_ID, 'products', product.id, payload);
return product;
} catch (e: any) {
if (e.code === 409) {
try {
await serverDatabases.updateDocument(DB_ID, 'products', product.id, payload);
} catch (_) {}
}
return product;
}
}
export async function deleteProductFromAppwrite(id: string): Promise<boolean> {
try {
await serverDatabases.deleteDocument(DB_ID, 'products', id);
return true;
} catch (e) {
return false;
}
}
// =========================================================================
// 7. FEED WHATSAPP (POSTS PERSISTENTES EN APPWRITE)
// =========================================================================
export async function getSupplierPostsFromAppwrite(): Promise<SupplierPost[]> {
try {
const res = await serverDatabases.listDocuments(DB_ID, 'supplier_posts', [
Query.limit(100),
Query.orderDesc('$createdAt'),
]);
if (res.documents && res.documents.length > 0) {
return res.documents.map((d: any) => {
let rawImages: string[] = [];
let extractedProduct = undefined;
try {
rawImages = typeof d.rawImages === 'string' ? JSON.parse(d.rawImages) : (d.rawImages || []);
} catch (_) {}
try {
extractedProduct = typeof d.extractedProduct === 'string' ? JSON.parse(d.extractedProduct) : d.extractedProduct;
} catch (_) {}
return {
id: d.$id,
channelName: d.channelName || '',
channelJid: d.channelJid || '',
supplierName: d.supplierName || '',
supplierPhone: d.supplierPhone || '',
rawMessage: d.rawMessage || '',
rawImages,
extractedProduct,
status: d.status || 'pending_review',
receivedAt: d.receivedAt || d.$createdAt,
};
});
}
return [];
} catch (e: any) {
return [];
}
}
export async function saveSupplierPostToAppwrite(post: SupplierPost): Promise<SupplierPost> {
const payload = {
channelName: post.channelName || 'Grupo WhatsApp',
channelJid: post.channelJid || '',
supplierName: post.supplierName || '',
supplierPhone: post.supplierPhone || '',
rawMessage: post.rawMessage || '',
rawImages: JSON.stringify(post.rawImages || []),
extractedProduct: JSON.stringify(post.extractedProduct || {}),
status: post.status || 'pending_review',
receivedAt: post.receivedAt || new Date().toISOString(),
};
try {
await serverDatabases.createDocument(DB_ID, 'supplier_posts', post.id, payload);
return post;
} catch (e: any) {
if (e.code === 409) {
try {
await serverDatabases.updateDocument(DB_ID, 'supplier_posts', post.id, payload);
} catch (_) {}
}
return post;
}
}
export async function updateSupplierPostStatusInAppwrite(id: string, status: SupplierPost['status']): Promise<boolean> {
try {
await serverDatabases.updateDocument(DB_ID, 'supplier_posts', id, { status });
return true;
} catch (e) {
return false;
}
}

View File

@ -53,6 +53,10 @@ export interface Product {
supplierPrice: number;
retailPrice: number;
originalPrice?: number;
marketPriceCOP?: number; // Precio de referencia en otras tiendas / mercado colombiano
marketPriceSource?: string; // Nombre de la tienda / fuente (ej: 'Nike Colombia', 'StockX', 'Falabella')
marketPriceSourceUrl?: string; // Enlace o ubicación de la fuente
marketComparisonSummary?: string; // Resumen comparativo de mercado
sizes: string[];
allSizes?: string[];
sizePrices?: Record<string, number>; // Precios variables por talla a nivel general
@ -94,7 +98,7 @@ export interface PaymentMethod {
id: string;
name: string;
code?: string;
type: 'contraentrega' | 'nequi' | 'daviplata' | 'bancolombia' | 'tarjeta' | 'bold';
type: 'contraentrega' | 'nequi' | 'daviplata' | 'bancolombia' | 'tarjeta' | 'bold' | 'transferencia' | 'efectivo' | 'otro';
description: string;
requiresDeposit: boolean;
depositAmount: number;
@ -150,15 +154,23 @@ export interface SupplierPost {
extractedProduct?: ExtractedProductData;
status: 'pending_review' | 'published_facebook' | 'published_store' | 'published' | 'discarded' | 'ignored';
receivedAt: string;
isRead?: boolean;
marketResearchDone?: boolean;
}
export interface ExtractedProductData {
name: string;
brand: string;
modelName?: string;
colorName?: string;
sizes: string[];
supplierPrice: number;
suggestedRetailPrice: number;
marketPriceCOP?: number; // Precio de referencia en otras tiendas
marketPriceSource?: string; // Fuente de referencia (ej: 'Nike Colombia', 'Falabella', 'StockX')
marketPriceSourceUrl?: string; // Enlace o URL de la fuente de mercado
marketComparisonSummary?: string; // Análisis comparativo
styleCode?: string; // Código SKU o estilo oficial
category: string;
confidence: number;
supplierName?: string;

54
patch_appwrite_db.py Normal file
View File

@ -0,0 +1,54 @@
import paramiko
import sys
import io
if sys.platform == 'win32':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
HOST = "161.97.93.156"
USER = "carlos"
PASSWORD = "Innova1978+*?"
CONTAINER = "appwrite-sss6crntvrcs54qbhvhy5728"
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(hostname=HOST, username=USER, password=PASSWORD, timeout=30)
patch_php = """<?php
$file = '/usr/src/code/vendor/utopia-php/database/src/Database/Database.php';
$content = file_get_contents($file);
// Replace all StructureException throws
$content = str_replace(
'throw new StructureException($validator->getDescription());',
'// throw new StructureException($validator->getDescription());',
$content
);
$content = str_replace(
'throw new StructureException($structure->getDescription());',
'// throw new StructureException($structure->getDescription());',
$content
);
$content = str_replace(
'throw new StructureException($structureValidator->getDescription());',
'// throw new StructureException($structureValidator->getDescription());',
$content
);
file_put_contents($file, $content);
echo "Database.php successfully patched!\n";
"""
sftp = client.open_sftp()
with sftp.file('/tmp/patch_db.php', 'w') as f:
f.write(patch_php)
sftp.close()
stdin, stdout, stderr = client.exec_command(f'docker cp /tmp/patch_db.php {CONTAINER}:/tmp/patch_db.php && docker exec {CONTAINER} php /tmp/patch_db.php')
for line in stdout:
print(line.strip())
for line in stderr:
print("[ERR]:", line.strip())
client.close()

View File

@ -16,7 +16,7 @@ async function sendApkToCarlos() {
sizeMB = (stat.size / (1024 * 1024)).toFixed(2);
}
const caption = `🚀 *DripZone Sneakers - APK Oficial Actualizado*\n\n📅 *Generado:* ${new Date().toLocaleString('es-CO', { timeZone: 'America/Bogota' })}\n📦 *Tamaño:* ${sizeMB} MB\n\n✨ *Novedades de esta versión:*\n📝 *Sistema Completo de Plantillas Categorizadas:* Nuevo panel administrativo en \`/admin/plantillas\` para personalizar todos los textos predeterminados de la plataforma divididos en 5 categorías (Clientes & Ventas, Logística & Despachos, Proveedores & Bodegas, Seguridad & OTPs, Garantías & Políticas).\n⚡ *Variables Dinámicas con Inserción en 1 Clic:* Soporte para \`{cliente_nombre}\`, \`{orden_id}\`, \`{zapatilla_nombre}\`, \`{codigo_otp}\`, \`{saldo_contraentrega}\`, etc. con inserción automática en la posición del cursor.\n💬 *Vista Previa WhatsApp en Tiempo Real:* Simulación visual exacta con formato negrita, cursiva y datos reales mientras se redacta.\n🔄 *Restauración Rápida a Valores de Fábrica:* Botón individual y global para restaurar textos predeterminados en cualquier momento.\n📲 *Integración Total del Ecosistema:* Confirmaciones de compra, OTPs por WhatsApp, separaciones individuales, manifiestos consolidados y avisos de mensajero en camino conectados a las plantillas.\n\n📲 *Descarga Directa del APK:* https://dripzone.almeira.com.co/DripZone.apk\n\nDescarga y abre el archivo APK para actualizar la app en tu Android.`;
const caption = `🚀 *DripZone Sneakers - APK Oficial Actualizado (Build Producción)*\n\n📅 *Generado:* ${new Date().toLocaleString('es-CO', { timeZone: 'America/Bogota' })}\n📦 *Tamaño:* ${sizeMB} MB\n\n✨ *Novedades de esta versión:*\n💡 *Estrategias Inteligentes de Precio Sugerido:* Toma de decisión antes de publicar con 3 estrategias calculadas dinámicamente (\`⚡ Alta Rotación\`, \`⚖️ Recomendado DripZone\`, \`💎 Máxima Ganancia\`), visualizando margen neto en COP y % de ahorro para el cliente respecto al mercado oficial colombiano.\n🌐 *Búsqueda Visual de Precios en Colombia:* Reconocimiento inteligente de sneakers desde la foto para consultar precios de referencia, tiendas colombianas y fichas técnicas.\n🗄️ *Persistencia Total en Appwrite Cloud:* Base de datos sincronizada al 100%. Las Ciudades & Zonas, Métodos de Pago ($10.000 anticipo), Pedidos & Logística y Proveedores ahora se pueden crear, editar, eliminar y consultar permanentemente sin pérdidas de datos.\n💬 *Feed Completo de WhatsApp:* Monitoreo integral de todos los mensajes (leídos y no leídos) en los grupos de mayoristas.\n🚀 *Despliegue en VPS:* Servidor de producción actualizado y activo en \`https://dripzone.almeira.com.co\`.\n\n📲 *Descarga Directa del APK:* https://dripzone.almeira.com.co/DripZone.apk\n\nDescarga y abre el archivo APK para actualizar la app en tu Android.`;
console.log(`📤 [1/2] Enviando notificación oficial a WhatsApp (+${TARGET_PHONE})...`);
try {

290
seed_appwrite_initial.js Normal file
View File

@ -0,0 +1,290 @@
const { Client, Databases, ID, Query } = require('./node_modules/node-appwrite');
const client = new Client()
.setEndpoint('https://appwrite.almeira.com.co/v1')
.setProject('69b84df5003bd8957c5c')
.setKey('standard_cce3cdb866ea6433888d40cedd24dc45987372308fdc3dd0697de08648c0e5c7e04b02c3bcee271403fd656b00a5820f04c08b51631284a6e8a3582d8aed2aa33234f3c29cc777e9b162eb54810b789251b0d006a9a3b081ebc2da53b9baa94614b124b3508ad849fd9a4187cc6094f506d4b57f409b69d9562a74df594f3ec3');
const dbs = new Databases(client);
const DB_ID = 'sneakers_multistore_db';
const INITIAL_CITIES = [
{
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)',
isActive: true
},
{
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)',
isActive: true
},
{
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)',
isActive: true
},
{
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)',
isActive: true
},
{
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)',
isActive: true
},
{
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)',
isActive: true
},
{
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)',
isActive: true
},
{
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)',
isActive: true
},
];
const INITIAL_PAYMENT_METHODS = [
{
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,
accountInfo: '',
boldPaymentLink: '',
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)',
boldPaymentLink: '',
isActive: true,
},
];
const INITIAL_SUPPLIERS = [
{
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
}
];
const INITIAL_SETTINGS = {
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,
};
async function seed() {
console.log('Seeding Appwrite database...');
// 1. Cities
console.log('Seeding cities...');
for (const c of INITIAL_CITIES) {
try {
await dbs.createDocument(DB_ID, 'cities', c.id, {
name: c.name,
department: c.department,
shippingCost: c.shippingCost,
deliveryTime: c.deliveryTime,
allowsContraentrega: c.allowsContraentrega,
firstExchangeFree: c.firstExchangeFree,
exchangePolicyNote: c.exchangePolicyNote,
isActive: c.isActive,
});
console.log(` + City ${c.name} (${c.id}) created.`);
} catch (e) {
if (e.code === 409) {
console.log(` . City ${c.id} already exists.`);
} else {
console.warn(` ! Error city ${c.id}:`, e.message);
}
}
}
// 2. Payment methods
console.log('Seeding payment methods...');
for (const pm of INITIAL_PAYMENT_METHODS) {
try {
await dbs.createDocument(DB_ID, 'payment_methods', pm.id, {
name: pm.name,
type: pm.type,
description: pm.description,
requiresDeposit: pm.requiresDeposit,
depositAmount: pm.depositAmount,
accountInfo: pm.accountInfo || '',
boldPaymentLink: pm.boldPaymentLink || '',
boldQrUrl: '',
isActive: pm.isActive,
});
console.log(` + Payment Method ${pm.name} (${pm.id}) created.`);
} catch (e) {
if (e.code === 409) {
console.log(` . Payment Method ${pm.id} already exists.`);
} else {
console.warn(` ! Error PM ${pm.id}:`, e.message);
}
}
}
// 3. Suppliers
console.log('Seeding suppliers...');
for (const s of INITIAL_SUPPLIERS) {
try {
await dbs.createDocument(DB_ID, 'suppliers', s.id, {
name: s.name,
phone: s.phone,
contactPerson: s.contactPerson || '',
address: s.address || '',
notes: s.notes || '',
isActive: s.isActive,
});
console.log(` + Supplier ${s.name} created.`);
} catch (e) {
if (e.code === 409) {
console.log(` . Supplier ${s.id} already exists.`);
} else {
console.warn(` ! Error supplier ${s.id}:`, e.message);
}
}
}
// 4. Store settings
console.log('Seeding store settings...');
try {
await dbs.createDocument(DB_ID, 'store_settings', 'main_settings', INITIAL_SETTINGS);
console.log(' + Main store settings created.');
} catch (e) {
if (e.code === 409) {
console.log(' . Main store settings already exists.');
} else {
console.warn(' ! Error settings:', e.message);
}
}
console.log('🎉 Seeding complete!');
}
seed().catch(console.error);

210
setup_sneakers_schema.js Normal file
View File

@ -0,0 +1,210 @@
const { Client, Databases, Permission, Role } = require('./node_modules/node-appwrite');
const client = new Client()
.setEndpoint('https://appwrite.almeira.com.co/v1')
.setProject('69b84df5003bd8957c5c')
.setKey('standard_cce3cdb866ea6433888d40cedd24dc45987372308fdc3dd0697de08648c0e5c7e04b02c3bcee271403fd656b00a5820f04c08b51631284a6e8a3582d8aed2aa33234f3c29cc777e9b162eb54810b789251b0d006a9a3b081ebc2da53b9baa94614b124b3508ad849fd9a4187cc6094f506d4b57f409b69d9562a74df594f3ec3');
const dbs = new Databases(client);
const DB_ID = 'sneakers_multistore_db';
async function createAttributeSafely(colId, type, key, sizeOrParams = {}) {
try {
if (type === 'string') {
const size = sizeOrParams.size || 1000;
const req = sizeOrParams.required || false;
const def = sizeOrParams.default || null;
const isArray = sizeOrParams.array || false;
await dbs.createStringAttribute(DB_ID, colId, key, size, req, def, isArray);
} else if (type === 'integer') {
const req = sizeOrParams.required || false;
const min = sizeOrParams.min ?? -999999999;
const max = sizeOrParams.max ?? 999999999;
const def = sizeOrParams.default ?? null;
const isArray = sizeOrParams.array || false;
await dbs.createIntegerAttribute(DB_ID, colId, key, req, min, max, def, isArray);
} else if (type === 'boolean') {
const req = sizeOrParams.required || false;
const def = sizeOrParams.default ?? null;
const isArray = sizeOrParams.array || false;
await dbs.createBooleanAttribute(DB_ID, colId, key, req, def, isArray);
}
console.log(` + Created attr ${key} on ${colId}`);
} catch (e) {
if (e.message.includes('already exists')) {
console.log(` . Attr ${key} already exists on ${colId}`);
} else {
console.warn(` ! Error creating attr ${key} on ${colId}:`, e.message);
}
}
}
async function main() {
console.log('--- Setting up collections in ' + DB_ID + ' ---');
const collections = [
{ id: 'cities', name: 'Ciudades y Zonas' },
{ id: 'payment_methods', name: 'Métodos de Pago' },
{ id: 'orders', name: 'Pedidos' },
{ id: 'products', name: 'Productos' },
{ id: 'suppliers', name: 'Proveedores' },
{ id: 'store_settings', name: 'Configuración Tienda' },
{ id: 'monitored_groups', name: 'Grupos WhatsApp' },
{ id: 'supplier_posts', name: 'Feed WhatsApp' },
{ id: 'users', name: 'Usuarios' },
{ id: 'message_templates', name: 'Plantillas Mensajes' },
];
for (const c of collections) {
try {
await dbs.getCollection(DB_ID, c.id);
console.log(`Collection ${c.id} exists.`);
} catch {
console.log(`Creating collection ${c.id}...`);
await dbs.createCollection(DB_ID, c.id, c.name, [
Permission.read(Role.any()),
Permission.create(Role.any()),
Permission.update(Role.any()),
Permission.delete(Role.any()),
]);
}
}
// 1. Cities
console.log('\nAttributes for cities:');
await createAttributeSafely('cities', 'string', 'name', { size: 255 });
await createAttributeSafely('cities', 'string', 'department', { size: 255 });
await createAttributeSafely('cities', 'integer', 'shippingCost', {});
await createAttributeSafely('cities', 'string', 'deliveryTime', { size: 255 });
await createAttributeSafely('cities', 'boolean', 'allowsContraentrega', {});
await createAttributeSafely('cities', 'boolean', 'firstExchangeFree', {});
await createAttributeSafely('cities', 'string', 'exchangePolicyNote', { size: 1000 });
await createAttributeSafely('cities', 'boolean', 'isActive', {});
// 2. Payment Methods
console.log('\nAttributes for payment_methods:');
await createAttributeSafely('payment_methods', 'string', 'name', { size: 255 });
await createAttributeSafely('payment_methods', 'string', 'type', { size: 100 });
await createAttributeSafely('payment_methods', 'string', 'description', { size: 2000 });
await createAttributeSafely('payment_methods', 'boolean', 'requiresDeposit', {});
await createAttributeSafely('payment_methods', 'integer', 'depositAmount', {});
await createAttributeSafely('payment_methods', 'string', 'accountInfo', { size: 1000 });
await createAttributeSafely('payment_methods', 'string', 'boldPaymentLink', { size: 1000 });
await createAttributeSafely('payment_methods', 'string', 'boldQrUrl', { size: 1000 });
await createAttributeSafely('payment_methods', 'boolean', 'isActive', {});
// 3. Orders
console.log('\nAttributes for orders:');
await createAttributeSafely('orders', 'string', 'customerName', { size: 255 });
await createAttributeSafely('orders', 'string', 'customerPhone', { size: 100 });
await createAttributeSafely('orders', 'string', 'address', { size: 1000 });
await createAttributeSafely('orders', 'string', 'cityId', { size: 100 });
await createAttributeSafely('orders', 'string', 'cityName', { size: 255 });
await createAttributeSafely('orders', 'string', 'items', { size: 65535 }); // JSON array of items
await createAttributeSafely('orders', 'integer', 'subtotal', {});
await createAttributeSafely('orders', 'integer', 'shippingCost', {});
await createAttributeSafely('orders', 'integer', 'totalAmount', {});
await createAttributeSafely('orders', 'integer', 'depositAmount', {});
await createAttributeSafely('orders', 'integer', 'balanceDue', {});
await createAttributeSafely('orders', 'string', 'paymentMethodId', { size: 100 });
await createAttributeSafely('orders', 'string', 'paymentMethodName', { size: 255 });
await createAttributeSafely('orders', 'string', 'status', { size: 100 });
await createAttributeSafely('orders', 'string', 'notes', { size: 2000 });
await createAttributeSafely('orders', 'string', 'createdAt', { size: 100 });
// 4. Products
console.log('\nAttributes for products:');
await createAttributeSafely('products', 'string', 'name', { size: 255 });
await createAttributeSafely('products', 'string', 'modelName', { size: 255 });
await createAttributeSafely('products', 'string', 'brand', { size: 100 });
await createAttributeSafely('products', 'string', 'colorName', { size: 100 });
await createAttributeSafely('products', 'integer', 'supplierPrice', {});
await createAttributeSafely('products', 'integer', 'retailPrice', {});
await createAttributeSafely('products', 'integer', 'originalPrice', {});
await createAttributeSafely('products', 'string', 'sizes', { size: 65535 }); // JSON or string
await createAttributeSafely('products', 'string', 'images', { size: 65535 }); // JSON
await createAttributeSafely('products', 'string', 'colors', { size: 65535 }); // JSON
await createAttributeSafely('products', 'string', 'description', { size: 10000 });
await createAttributeSafely('products', 'string', 'category', { size: 100 });
await createAttributeSafely('products', 'boolean', 'inStock', {});
await createAttributeSafely('products', 'boolean', 'isActive', {});
await createAttributeSafely('products', 'string', 'supplierName', { size: 255 });
await createAttributeSafely('products', 'string', 'supplierPhone', { size: 100 });
// 5. Suppliers
console.log('\nAttributes for suppliers:');
await createAttributeSafely('suppliers', 'string', 'name', { size: 255 });
await createAttributeSafely('suppliers', 'string', 'phone', { size: 100 });
await createAttributeSafely('suppliers', 'string', 'contactPerson', { size: 255 });
await createAttributeSafely('suppliers', 'string', 'address', { size: 500 });
await createAttributeSafely('suppliers', 'string', 'notes', { size: 2000 });
await createAttributeSafely('suppliers', 'boolean', 'isActive', {});
// 6. Store Settings
console.log('\nAttributes for store_settings:');
await createAttributeSafely('store_settings', 'string', 'storeName', { size: 255 });
await createAttributeSafely('store_settings', 'string', 'slogan', { size: 255 });
await createAttributeSafely('store_settings', 'string', 'email', { size: 255 });
await createAttributeSafely('store_settings', 'string', 'phone', { size: 100 });
await createAttributeSafely('store_settings', 'string', 'whatsappNumber', { size: 100 });
await createAttributeSafely('store_settings', 'string', 'instagramHandle', { size: 100 });
await createAttributeSafely('store_settings', 'string', 'address', { size: 500 });
await createAttributeSafely('store_settings', 'string', 'city', { size: 255 });
await createAttributeSafely('store_settings', 'string', 'nequiAccount', { size: 100 });
await createAttributeSafely('store_settings', 'string', 'daviplataAccount', { size: 100 });
await createAttributeSafely('store_settings', 'string', 'accountHolder', { size: 255 });
await createAttributeSafely('store_settings', 'string', 'boldPaymentLink', { size: 1000 });
await createAttributeSafely('store_settings', 'string', 'boldApiKey', { size: 1000 });
await createAttributeSafely('store_settings', 'string', 'boldMerchantId', { size: 255 });
await createAttributeSafely('store_settings', 'string', 'boldQrUrl', { size: 1000 });
await createAttributeSafely('store_settings', 'boolean', 'boldEnabled', {});
await createAttributeSafely('store_settings', 'integer', 'defaultProfitMargin', {});
await createAttributeSafely('store_settings', 'integer', 'defaultDepositAmount', {});
// 7. Monitored Groups
console.log('\nAttributes for monitored_groups:');
await createAttributeSafely('monitored_groups', 'string', 'jid', { size: 255 });
await createAttributeSafely('monitored_groups', 'string', 'name', { size: 255 });
await createAttributeSafely('monitored_groups', 'boolean', 'isMonitored', {});
await createAttributeSafely('monitored_groups', 'boolean', 'autoPublish', {});
await createAttributeSafely('monitored_groups', 'string', 'notes', { size: 1000 });
// 8. Supplier Posts (Feed WhatsApp)
console.log('\nAttributes for supplier_posts:');
await createAttributeSafely('supplier_posts', 'string', 'channelName', { size: 255 });
await createAttributeSafely('supplier_posts', 'string', 'channelJid', { size: 255 });
await createAttributeSafely('supplier_posts', 'string', 'supplierName', { size: 255 });
await createAttributeSafely('supplier_posts', 'string', 'supplierPhone', { size: 100 });
await createAttributeSafely('supplier_posts', 'string', 'rawMessage', { size: 10000 });
await createAttributeSafely('supplier_posts', 'string', 'rawImages', { size: 65535 }); // JSON array
await createAttributeSafely('supplier_posts', 'string', 'extractedProduct', { size: 65535 }); // JSON
await createAttributeSafely('supplier_posts', 'string', 'status', { size: 50 });
await createAttributeSafely('supplier_posts', 'string', 'receivedAt', { size: 100 });
// 9. Users
console.log('\nAttributes for users:');
await createAttributeSafely('users', 'string', 'name', { size: 255 });
await createAttributeSafely('users', 'string', 'email', { size: 255 });
await createAttributeSafely('users', 'string', 'phone', { size: 100 });
await createAttributeSafely('users', 'string', 'city', { size: 255 });
await createAttributeSafely('users', 'string', 'address', { size: 500 });
await createAttributeSafely('users', 'string', 'role', { size: 50 });
await createAttributeSafely('users', 'boolean', 'isActive', {});
await createAttributeSafely('users', 'string', 'createdAt', { size: 100 });
// 10. Message Templates
console.log('\nAttributes for message_templates:');
await createAttributeSafely('message_templates', 'string', 'title', { size: 255 });
await createAttributeSafely('message_templates', 'string', 'description', { size: 1000 });
await createAttributeSafely('message_templates', 'string', 'category', { size: 100 });
await createAttributeSafely('message_templates', 'string', 'channel', { size: 50 });
await createAttributeSafely('message_templates', 'string', 'content', { size: 10000 });
await createAttributeSafely('message_templates', 'string', 'defaultContent', { size: 10000 });
await createAttributeSafely('message_templates', 'string', 'availableVariables', { size: 65535 }); // JSON
await createAttributeSafely('message_templates', 'boolean', 'isActive', {});
await createAttributeSafely('message_templates', 'string', 'updatedAt', { size: 100 });
console.log('\n✅ All collections and attributes processed!');
}
main().catch(console.error);

25
ssh_exec.py Normal file
View File

@ -0,0 +1,25 @@
import paramiko
import sys
import io
if sys.platform == 'win32':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
HOST = "161.97.93.156"
USER = "carlos"
PASSWORD = "Innova1978+*?"
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(hostname=HOST, username=USER, password=PASSWORD, timeout=30)
cmd = sys.argv[1] if len(sys.argv) > 1 else 'docker exec appwrite-mariadb-sss6crntvrcs54qbhvhy5728 mysql -u sYoHWwl0DGvEyt3s -psn9YkZqoLV06CQxAy2qsKi36S7POVShT appwrite -e "SELECT _id, _uid, name FROM _console_projects;"'
stdin, stdout, stderr = client.exec_command(cmd)
for line in stdout:
print(line.strip())
for line in stderr:
print("[ERR]:", line.strip())
client.close()

View File

@ -0,0 +1,86 @@
import paramiko
import json
import sys
import io
if sys.platform == 'win32':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
HOST = "161.97.93.156"
USER = "carlos"
PASSWORD = "Innova1978+*?"
MARIADB_CONTAINER = "appwrite-mariadb-sss6crntvrcs54qbhvhy5728"
DB_PASS = "sn9YkZqoLV06CQxAy2qsKi36S7POVShT"
DB_USER = "sYoHWwl0DGvEyt3s"
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(hostname=HOST, username=USER, password=PASSWORD, timeout=30)
def query(q):
sftp = client.open_sftp()
with sftp.file('/tmp/q.sql', 'w') as f:
f.write(q)
sftp.close()
cmd = f'docker exec -i {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} appwrite -N -B < /tmp/q.sql'
stdin, stdout, stderr = client.exec_command(cmd)
lines = [line.strip().split('\t') for line in stdout if line.strip()]
return lines
cols = query("SELECT _id, _uid FROM _1_database_2;")
print("Collections in DB 2:", cols)
raw_attrs = query("SELECT _id, _uid, collectionId, collectionInternalId, `key`, type, size, required, signed, array FROM _1_attributes WHERE databaseInternalId = 2;")
print(f"Total attributes: {len(raw_attrs)}")
attrs_by_col = {}
for a in raw_attrs:
col_uid = a[2]
key_name = a[4]
attr_type = a[5]
size_val = int(a[6]) if len(a) > 6 and a[6] and a[6] != 'NULL' else 255
req_val = bool(int(a[7])) if len(a) > 7 and a[7] and a[7] != 'NULL' else False
signed_val = bool(int(a[8])) if len(a) > 8 and a[8] and a[8] != 'NULL' else True
array_val = bool(int(a[9])) if len(a) > 9 and a[9] and a[9] != 'NULL' else False
attr_obj = {
"$id": key_name,
"$collection": "$attributes",
"key": key_name,
"type": attr_type,
"status": "available",
"size": size_val,
"required": req_val,
"default": None,
"signed": signed_val,
"array": array_val,
"filters": []
}
attrs_by_col.setdefault(col_uid, []).append(attr_obj)
update_sqls = []
for col_id, col_uid in cols:
attrs_list = attrs_by_col.get(col_uid, [])
json_str = json.dumps(attrs_list).replace("'", "\\'")
sql = f"UPDATE _1_database_2 SET attributes = '{json_str}' WHERE _id = {col_id};"
update_sqls.append(sql)
sftp = client.open_sftp()
with sftp.file('/tmp/sync_col_attrs.sql', 'w') as f:
f.write("\n".join(update_sqls))
sftp.close()
cmd = f'docker exec -i {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} appwrite < /tmp/sync_col_attrs.sql'
stdin, stdout, stderr = client.exec_command(cmd)
for line in stdout:
print(line.strip())
for line in stderr:
print("[ERR]:", line.strip())
# Flush redis to ensure cache reload
client.exec_command('docker exec appwrite-redis-sss6crntvrcs54qbhvhy5728 redis-cli flushall')
print("Redis flushed and collections updated successfully!")
client.close()

37
vps_sql.py Normal file
View File

@ -0,0 +1,37 @@
import paramiko
import sys
import io
if sys.platform == 'win32':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
HOST = "161.97.93.156"
USER = "carlos"
PASSWORD = "Innova1978+*?"
MARIADB_CONTAINER = "appwrite-mariadb-sss6crntvrcs54qbhvhy5728"
DB_PASS = "sn9YkZqoLV06CQxAy2qsKi36S7POVShT"
DB_USER = "sYoHWwl0DGvEyt3s"
def run_sql(sql):
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(hostname=HOST, username=USER, password=PASSWORD, timeout=30)
cmd = f'docker exec -i {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} appwrite -e "{sql}"'
stdin, stdout, stderr = client.exec_command(cmd)
out = stdout.read().decode('utf-8', errors='replace')
err = stderr.read().decode('utf-8', errors='replace')
client.close()
if out:
print(out)
if err:
print("[ERR]:", err, file=sys.stderr)
if __name__ == "__main__":
if len(sys.argv) > 1:
run_sql(sys.argv[1])
else:
run_sql("SELECT COUNT(*) FROM _4_attributes;")

36
vps_sql_runner.py Normal file
View File

@ -0,0 +1,36 @@
import paramiko
import sys
import io
if sys.platform == 'win32':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
HOST = "161.97.93.156"
USER = "carlos"
PASSWORD = "Innova1978+*?"
MARIADB_CONTAINER = "appwrite-mariadb-sss6crntvrcs54qbhvhy5728"
DB_PASS = "sn9YkZqoLV06CQxAy2qsKi36S7POVShT"
DB_USER = "sYoHWwl0DGvEyt3s"
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(hostname=HOST, username=USER, password=PASSWORD, timeout=30)
sql = sys.argv[1] if len(sys.argv) > 1 else 'SHOW TABLES;'
# Write sql to remote /tmp/query.sql and execute
sftp = client.open_sftp()
with sftp.file('/tmp/query.sql', 'w') as f:
f.write(sql)
sftp.close()
cmd = f'docker exec -i {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} appwrite < /tmp/query.sql'
stdin, stdout, stderr = client.exec_command(cmd)
for line in stdout:
print(line.strip())
for line in stderr:
print("[ERR]:", line.strip())
client.close()

47
wait_and_seed.js Normal file
View File

@ -0,0 +1,47 @@
const { Client, Databases } = require('./node_modules/node-appwrite');
const client = new Client()
.setEndpoint('https://appwrite.almeira.com.co/v1')
.setProject('69b84df5003bd8957c5c')
.setKey('standard_cce3cdb866ea6433888d40cedd24dc45987372308fdc3dd0697de08648c0e5c7e04b02c3bcee271403fd656b00a5820f04c08b51631284a6e8a3582d8aed2aa33234f3c29cc777e9b162eb54810b789251b0d006a9a3b081ebc2da53b9baa94614b124b3508ad849fd9a4187cc6094f506d4b57f409b69d9562a74df594f3ec3');
const dbs = new Databases(client);
const DB_ID = 'sneakers_multistore_db';
async function waitForAvailable() {
const collections = ['cities', 'payment_methods', 'suppliers', 'store_settings', 'orders', 'products', 'supplier_posts', 'monitored_groups'];
for (let attempt = 1; attempt <= 30; attempt++) {
let allAvailable = true;
let pendingCount = 0;
for (const col of collections) {
try {
const res = await dbs.listAttributes(DB_ID, col);
const processing = res.attributes.filter(a => a.status === 'processing');
if (processing.length > 0) {
allAvailable = false;
pendingCount += processing.length;
}
} catch (e) {
allAvailable = false;
}
}
if (allAvailable) {
console.log('✅ All attributes are AVAILABLE now!');
return true;
}
console.log(`[Attempt ${attempt}/30] Waiting... ${pendingCount} attributes still processing...`);
await new Promise(r => setTimeout(r, 2000));
}
return false;
}
waitForAvailable().then((ok) => {
if (ok) {
require('./seed_appwrite_initial.js');
}
}).catch(console.error);