feat: persistencia total Appwrite, inteligencia de mercado y estrategias de precios
This commit is contained in:
parent
d8c80091ba
commit
1fdfe88e51
82
apply_sneakers_columns.py
Normal file
82
apply_sneakers_columns.py
Normal 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!")
|
||||
@ -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}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
22
dripzone-app/src/app/api/market-intelligence/route.ts
Normal file
22
dripzone-app/src/app/api/market-intelligence/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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 });
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
127
dripzone-app/src/lib/market-intelligence.ts
Normal file
127
dripzone-app/src/lib/market-intelligence.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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);
|
||||
|
||||
665
dripzone-app/src/lib/server-appwrite.ts
Normal file
665
dripzone-app/src/lib/server-appwrite.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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
54
patch_appwrite_db.py
Normal 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()
|
||||
@ -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
290
seed_appwrite_initial.js
Normal 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
210
setup_sneakers_schema.js
Normal 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
25
ssh_exec.py
Normal 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()
|
||||
86
sync_collection_attributes.py
Normal file
86
sync_collection_attributes.py
Normal 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
37
vps_sql.py
Normal 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
36
vps_sql_runner.py
Normal 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
47
wait_and_seed.js
Normal 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);
|
||||
Loading…
Reference in New Issue
Block a user