4657 lines
224 KiB
TypeScript
4657 lines
224 KiB
TypeScript
"use client";
|
|
|
|
import type { Route } from "next";
|
|
import Link from "next/link";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
|
|
import { browserFetch } from "../lib/api";
|
|
import { logoutByNavigation } from "../lib/logout";
|
|
|
|
export type AccountSection =
|
|
| "summary"
|
|
| "showcases"
|
|
| "stats"
|
|
| "locations"
|
|
| "profile"
|
|
| "services"
|
|
| "works"
|
|
| "inbox"
|
|
| "reviews"
|
|
| "subscription"
|
|
| "settings";
|
|
|
|
type AccountClientProps = {
|
|
section?: AccountSection;
|
|
conversationId?: string;
|
|
};
|
|
|
|
type AccountResponse = {
|
|
maker: {
|
|
id: string;
|
|
slug: string;
|
|
status: string;
|
|
business_name: string | null;
|
|
description: string | null;
|
|
province: string | null;
|
|
city: string | null;
|
|
delivery_scope: string | null;
|
|
availability: string;
|
|
public_contact_email: string | null;
|
|
public_whatsapp: string | null;
|
|
latitude: number | null;
|
|
longitude: number | null;
|
|
main_image_url: string | null;
|
|
website_url: string | null;
|
|
instagram_url: string | null;
|
|
tiktok_url: string | null;
|
|
twitter_url: string | null;
|
|
facebook_url: string | null;
|
|
youtube_url: string | null;
|
|
linkedin_url: string | null;
|
|
allow_verified_reviews: boolean | null;
|
|
show_satisfaction_score: boolean | null;
|
|
show_review_tags: boolean | null;
|
|
allow_maker_review_reply: boolean | null;
|
|
show_past_reviews: boolean | null;
|
|
};
|
|
services: Array<Record<string, unknown>>;
|
|
showcases: Array<Record<string, unknown>>;
|
|
locations: Array<Record<string, unknown>>;
|
|
works: Array<Record<string, unknown>>;
|
|
inquiries: Array<Record<string, unknown>>;
|
|
reviews: Array<Record<string, unknown>>;
|
|
checklist: {
|
|
checklist: Record<string, boolean>;
|
|
ready: boolean;
|
|
subscriptionStatus: string;
|
|
};
|
|
};
|
|
|
|
function toText(value: unknown, fallback = "") {
|
|
return typeof value === "string" && value ? value : fallback;
|
|
}
|
|
|
|
function toNumber(value: unknown, fallback = 0) {
|
|
return typeof value === "number" ? value : Number(value) || fallback;
|
|
}
|
|
|
|
function uniqueList(values: string[]) {
|
|
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
|
|
}
|
|
|
|
function cleanShowcaseDescription(value: string) {
|
|
return value
|
|
.split(/\n\s*\n/)
|
|
.filter((block) => !block.trim().toLowerCase().startsWith("incluye:"))
|
|
.join("\n\n")
|
|
.trim();
|
|
}
|
|
|
|
function locationPinStyle(location: Record<string, unknown>, index: number) {
|
|
const lat = toNumber(location.latitude, -31.42);
|
|
const lng = toNumber(location.longitude, -64.18);
|
|
const left = Math.max(16, Math.min(84, 50 + ((lng + 64.18) * 220) + (index % 3 - 1) * 9));
|
|
const top = Math.max(18, Math.min(82, 50 - ((lat + 31.42) * 220) + (Math.floor(index / 3) - 0.5) * 9));
|
|
return {
|
|
left: `${left}%`,
|
|
top: `${top}%`
|
|
};
|
|
}
|
|
|
|
const sectionCopy: Record<AccountSection, { eyebrow: string; title: string; description: string }> = {
|
|
summary: {
|
|
eyebrow: "Mi espacio maker",
|
|
title: "Dashboard operativo y prioridades reales",
|
|
description: "Resumen, rendimiento y tareas clave antes que metricas vacias."
|
|
},
|
|
showcases: {
|
|
eyebrow: "Escaparates",
|
|
title: "Colecciones editoriales de tus trabajos",
|
|
description: "Agrupan evidencia real para especializar mejor tu perfil y guiar consultas."
|
|
},
|
|
stats: {
|
|
eyebrow: "Estadisticas",
|
|
title: "Rendimiento util, no vanidad",
|
|
description: "Visitas, consultas y conversion resumidas en una lectura accionable."
|
|
},
|
|
locations: {
|
|
eyebrow: "Ubicaciones",
|
|
title: "Cobertura, privacidad y alcance",
|
|
description: "Define desde donde operas y como te descubren clientes locales o con envio."
|
|
},
|
|
profile: {
|
|
eyebrow: "Perfil publico",
|
|
title: "Identidad y confianza",
|
|
description: "Configura como te ven los clientes en el perfil publico y resultados."
|
|
},
|
|
services: {
|
|
eyebrow: "Servicios y precios",
|
|
title: "Capacidades visibles y bien acotadas",
|
|
description: "Cada servicio debe ayudar a filtrar mejor y a recibir consultas de mas calidad."
|
|
},
|
|
works: {
|
|
eyebrow: "Trabajos",
|
|
title: "Portafolio publico con contexto",
|
|
description: "Publica resultados reales para demostrar calidad y disparar consultas parecidas."
|
|
},
|
|
inbox: {
|
|
eyebrow: "Consultas",
|
|
title: "Bandeja con contexto persistente",
|
|
description: "La referencia del trabajo o servicio acompana toda la conversacion."
|
|
},
|
|
reviews: {
|
|
eyebrow: "Resenas",
|
|
title: "Reputacion basada en trabajos cerrados",
|
|
description: "Las opiniones verificadas ayudan a sostener la confianza y detectar riesgos."
|
|
},
|
|
subscription: {
|
|
eyebrow: "Plan Maker",
|
|
title: "Suscripcion visible pero no invasiva",
|
|
description: "La publicacion nunca depende de magia: depende de requisitos y estado permitido."
|
|
},
|
|
settings: {
|
|
eyebrow: "Cuenta",
|
|
title: "Acceso, sesion y preferencias",
|
|
description: "Una sola cuenta sirve para cliente y maker, con control claro de su estado."
|
|
}
|
|
};
|
|
|
|
const primaryLinks: Array<{ href: Route; label: string; section: AccountSection }> = [
|
|
{ href: "/account", label: "Inicio", section: "summary" },
|
|
{ href: "/account/inbox", label: "Consultas", section: "inbox" },
|
|
{ href: "/account/works", label: "Trabajos", section: "works" }
|
|
];
|
|
|
|
const secondaryLinks: Array<{ href: Route; label: string; section: AccountSection }> = [
|
|
{ href: "/account/showcases", label: "Escaparates", section: "showcases" },
|
|
{ href: "/account/services", label: "Servicios", section: "services" },
|
|
{ href: "/account/stats", label: "Estadisticas", section: "stats" },
|
|
{ href: "/account/locations", label: "Ubicaciones", section: "locations" },
|
|
{ href: "/account/profile", label: "Perfil publico", section: "profile" },
|
|
{ href: "/account/reviews", label: "Resenas", section: "reviews" },
|
|
{ href: "/account/subscription", label: "Suscripcion", section: "subscription" },
|
|
{ href: "/account/settings", label: "Configuracion", section: "settings" }
|
|
];
|
|
|
|
const publishSteps = [
|
|
"Fotos",
|
|
"Que hiciste",
|
|
"Problema y uso",
|
|
"Tecnicos",
|
|
"Antes / Resultado",
|
|
"Servicios",
|
|
"Precio",
|
|
"Vista previa",
|
|
"Publicar"
|
|
];
|
|
|
|
const problemOptions = [
|
|
"Reproducir algo descatalogado",
|
|
"Reemplazar una pieza rota",
|
|
"Crear algo desde cero",
|
|
"Personalizar un objeto",
|
|
"Fabricar varias unidades",
|
|
"Crear un prototipo"
|
|
];
|
|
|
|
const useOptions = [
|
|
"Pieza funcional",
|
|
"Exterior",
|
|
"Contacto con calor",
|
|
"Contacto con agua",
|
|
"Soporta esfuerzo",
|
|
"Decoracion / hobby"
|
|
];
|
|
|
|
const publishTips = [
|
|
"Usa fotos claras y bien iluminadas",
|
|
"Muestra el antes y el despues",
|
|
"Describe el problema y la solucion",
|
|
"Relaciona el trabajo con tus servicios",
|
|
"Agrega precio orientativo si ayuda a filtrar"
|
|
];
|
|
|
|
const subscriptionChecklistLabels: Record<string, { label: string; detail: string }> = {
|
|
hasBusinessName: { label: "Nombre y descripcion", detail: "Identidad publica basica completada." },
|
|
hasDescription: { label: "Descripcion clara", detail: "Explica que haces y para quien." },
|
|
hasLocation: { label: "Ubicacion configurada", detail: "Cobertura y privacidad definidas." },
|
|
hasService: { label: "Servicios configurados", detail: "Capacidades y precios orientativos." },
|
|
hasWork: { label: "Trabajos publicados", detail: "Portafolio con evidencia real." },
|
|
hasContact: { label: "Contacto publico", detail: "Email, WhatsApp o canal operativo." },
|
|
hasSubscription: { label: "Plan activo", detail: "Permite aparecer en mapa y busquedas." }
|
|
};
|
|
|
|
const planBenefits = [
|
|
"Perfil profesional completo",
|
|
"Aparicion en mapa y busquedas",
|
|
"Servicios y precios ilimitados",
|
|
"Hasta 3 escaparates publicos",
|
|
"Trabajos publicados ilimitados",
|
|
"Consultas y mensajeria",
|
|
"Resenas verificadas",
|
|
"Multiples ubicaciones",
|
|
"Soporte por email"
|
|
];
|
|
|
|
const subscriptionAddOns = [
|
|
{ title: "Escaparates adicionales", detail: "Mas colecciones para verticales o zonas.", state: "Futuro" },
|
|
{ title: "Analitica avanzada", detail: "Origen de consultas, embudo y conversion.", state: "Futuro" },
|
|
{ title: "Herramientas de negocio", detail: "Presupuestos, plantillas y seguimiento.", state: "Futuro" }
|
|
];
|
|
|
|
const supportCards = [
|
|
{ title: "Preguntas frecuentes", detail: "Resuelve dudas comunes del plan.", action: "Ver FAQ" },
|
|
{ title: "Contactar soporte", detail: "Escribenos si algo bloquea tu publicacion.", action: "Enviar mensaje" },
|
|
{ title: "Centro de ayuda", detail: "Guias y tutoriales paso a paso.", action: "Ir al centro" },
|
|
{ title: "Estado del servicio", detail: "Estado tecnico de la plataforma.", action: "Ver estado" }
|
|
];
|
|
|
|
const subscriptionBillingSteps = ["Facturacion", "Pago", "Validacion"];
|
|
|
|
const accountQuickAccess = [
|
|
["Datos personales", "Nombre, email, telefono y avatar."],
|
|
["Seguridad y acceso", "Contrasena, 2FA y sesiones activas."],
|
|
["Privacidad y ubicacion", "Permisos, historial y personalizacion."],
|
|
["Notificaciones", "Canales, frecuencia y alertas."],
|
|
["Preferencias", "Tema, idioma, moneda y accesibilidad."],
|
|
["Cuentas vinculadas", "Google, Apple y redes publicas."]
|
|
] as const;
|
|
|
|
const linkedAccounts = [
|
|
["Google", "Conectado", "Desconectar"],
|
|
["Apple", "No conectado", "Conectar"],
|
|
["Instagram", "Perfil maker", "Gestionar"],
|
|
["Sitio web", "makerlab3d.com", "Editar"],
|
|
["YouTube", "No conectado", "Conectar"],
|
|
["TikTok", "No conectado", "Conectar"]
|
|
] as const;
|
|
|
|
const privacyCenterItems = [
|
|
["Tu ubicacion", "Gestiona permisos y ubicaciones guardadas."],
|
|
["Tus datos", "Descarga o elimina informacion generada."],
|
|
["Personalizacion", "Controla recomendaciones y busquedas."],
|
|
["Comunicaciones", "Email, mensajes y marketing."],
|
|
["Usuarios bloqueados", "Personas que no pueden contactarte."],
|
|
["Cookies y seguimiento", "Gestiona tecnologias de medicion."],
|
|
["Privacidad del perfil maker", "Que informacion muestras publicamente."]
|
|
] as const;
|
|
|
|
const notificationChannelOptions = ["App", "Email", "Push", "WhatsApp"] as const;
|
|
|
|
const serviceSteps = [
|
|
"Tipo",
|
|
"Configuracion",
|
|
"Capacidades",
|
|
"Usos",
|
|
"Precio",
|
|
"Entrega",
|
|
"Visibilidad",
|
|
"Vista previa"
|
|
];
|
|
|
|
const serviceTemplates = [
|
|
{ title: "Impresion 3D FDM", category: "Impresion FDM", description: "Piezas funcionales, prototipos y repuestos resistentes para uso real.", icon: "FDM" },
|
|
{ title: "Impresion 3D Resina", category: "Impresion Resina", description: "Alta resolucion para figuras, miniaturas y piezas con mucho detalle.", icon: "RES" },
|
|
{ title: "Diseno 3D / CAD", category: "Diseno 3D", description: "Modelado, reparacion de archivos y preparacion tecnica para fabricar.", icon: "CAD" },
|
|
{ title: "Escaneo 3D", category: "Escaneo 3D", description: "Digitalizacion de objetos fisicos para replica, ajuste o rediseño.", icon: "3D" },
|
|
{ title: "Produccion en serie", category: "Produccion", description: "Fabricacion de lotes chicos y medianos con control de repetibilidad.", icon: "SER" },
|
|
{ title: "Postprocesado", category: "Acabados", description: "Lijado, pintura, armado y terminaciones para piezas listas para entregar.", icon: "FIN" }
|
|
];
|
|
|
|
const locationSteps = ["Tipo", "Direccion", "Privacidad", "Atencion", "Cobertura", "Horarios", "Vista previa", "Guardar"];
|
|
|
|
const locationTypeOptions = [
|
|
["public_workshop", "Taller abierto al publico", "Atencion con cita previa y punto de referencia visible."],
|
|
["private_workshop", "Taller con cita previa", "Zona aproximada recomendada para cuidar privacidad."],
|
|
["home_workshop", "Trabajo desde casa", "No mostramos direccion exacta publicamente."],
|
|
["pickup_point", "Punto de recogida", "Solo entregas/retiros coordinados."],
|
|
["mobile_base", "Base movil", "Te desplazas o haces entregas desde una zona base."],
|
|
["online", "Atencion online", "Consultas remotas y envio sin punto fisico visible."]
|
|
];
|
|
|
|
const privacyOptions = [
|
|
["approximate", "Zona aproximada", "Recomendado: muestra un radio sin revelar la direccion real."],
|
|
["city", "Solo localidad", "Muestra ciudad/provincia y cobertura, sin punto en el mapa."],
|
|
["exact", "Direccion exacta", "Solo si es un local o punto abierto al publico."]
|
|
];
|
|
|
|
const travelOptions = [
|
|
["none", "No me desplazo"],
|
|
["10", "Hasta 10 km"],
|
|
["25", "Hasta 25 km"],
|
|
["50", "Hasta 50 km"],
|
|
["custom", "Zona personalizada"]
|
|
];
|
|
|
|
const shippingOptions = [
|
|
["none", "No realizo envios"],
|
|
["province", "Dentro de mi provincia"],
|
|
["nationwide", "A toda Argentina"],
|
|
["custom", "Zonas seleccionadas"]
|
|
];
|
|
|
|
const serviceUseOptions = [
|
|
"Repuestos y piezas funcionales",
|
|
"Prototipos",
|
|
"Decoracion",
|
|
"Herramientas / accesorios",
|
|
"Educacion / hobby",
|
|
"Miniaturas"
|
|
];
|
|
|
|
const serviceConditionOptions = [
|
|
"Interior",
|
|
"Exterior",
|
|
"Contacto con agua",
|
|
"Calor moderado",
|
|
"Alto esfuerzo",
|
|
"Uso alimentario bajo revision"
|
|
];
|
|
|
|
const showcaseSteps = [
|
|
"Datos",
|
|
"Trabajos",
|
|
"Portada",
|
|
"Orden",
|
|
"Vista previa",
|
|
"Publicar"
|
|
];
|
|
|
|
const showcaseTypeOptions = [
|
|
"Repuestos",
|
|
"Prototipos",
|
|
"Funcionales",
|
|
"Decorativos",
|
|
"Automotor",
|
|
"Restauraciones"
|
|
];
|
|
|
|
function emptyWorkForm() {
|
|
return {
|
|
title: "",
|
|
summary: "",
|
|
story: "",
|
|
technology: "",
|
|
material: "",
|
|
imageUrl: "",
|
|
galleryUrls: "",
|
|
problem: "",
|
|
useCase: "",
|
|
machine: "",
|
|
color: "",
|
|
dimensions: "",
|
|
fabricationTime: "",
|
|
quantity: "",
|
|
includeBeforeAfter: false,
|
|
beforeImageUrl: "",
|
|
afterImageUrl: "",
|
|
serviceId: "",
|
|
placement: "",
|
|
priceMode: "",
|
|
priceFrom: "",
|
|
priceTo: ""
|
|
};
|
|
}
|
|
|
|
function emptyShowcaseForm() {
|
|
return {
|
|
title: "",
|
|
description: "",
|
|
types: [] as string[],
|
|
coverMode: "auto",
|
|
coverImageUrl: "",
|
|
selectedWorkIds: [] as string[],
|
|
featuredWorkId: "",
|
|
visibility: "published"
|
|
};
|
|
}
|
|
|
|
function emptyServiceForm() {
|
|
return {
|
|
title: "",
|
|
category: "",
|
|
description: "",
|
|
technologies: [] as string[],
|
|
materials: [] as string[],
|
|
maxX: "",
|
|
maxY: "",
|
|
maxZ: "",
|
|
resolution: "",
|
|
colors: [] as string[],
|
|
finishes: [] as string[],
|
|
uses: [] as string[],
|
|
conditions: [] as string[],
|
|
urgency: "yes",
|
|
priceMode: "from",
|
|
priceFrom: "",
|
|
smallPrice: "",
|
|
mediumPrice: "",
|
|
largePrice: "",
|
|
leadTimeDays: "4",
|
|
localPickup: true,
|
|
nationwideShipping: true,
|
|
coverageArea: "Todo el pais",
|
|
visibility: "published",
|
|
pauseMessage: "",
|
|
relatedWorks: [] as string[]
|
|
};
|
|
}
|
|
|
|
function emptyLocationForm() {
|
|
return {
|
|
label: "Taller principal",
|
|
type: "private_workshop",
|
|
address: "",
|
|
postalCode: "",
|
|
province: "Cordoba",
|
|
city: "Cordoba",
|
|
latitude: "-31.4201",
|
|
longitude: "-64.1888",
|
|
privacy: "approximate",
|
|
localPickup: true,
|
|
personalDelivery: true,
|
|
postalShipping: true,
|
|
nationwideShipping: true,
|
|
mobileService: false,
|
|
onsiteService: false,
|
|
notes: "",
|
|
travelRadius: "25",
|
|
shippingArea: "nationwide",
|
|
hoursMode: "appointment",
|
|
alertService: "Impresion 3D en resina",
|
|
alertRadius: "30",
|
|
alertEmail: "",
|
|
minimumRating: "4",
|
|
isPublished: true
|
|
};
|
|
}
|
|
|
|
function emptySubscriptionBillingForm() {
|
|
return {
|
|
invoiceType: "person",
|
|
name: "Juan Perez",
|
|
taxId: "20-12345678-9",
|
|
email: "",
|
|
address: "Av. Colon 1234, Cordoba",
|
|
paymentMethod: "visa",
|
|
cardLast4: "4242",
|
|
validationCode: "",
|
|
acceptedValidation: false
|
|
};
|
|
}
|
|
|
|
export default function AccountClient({ section = "summary", conversationId }: AccountClientProps) {
|
|
const [user, setUser] = useState<{ id: string; email: string; role: string } | null>(null);
|
|
const [authLoading, setAuthLoading] = useState(true);
|
|
const [account, setAccount] = useState<AccountResponse | null>(null);
|
|
const [conversations, setConversations] = useState<Array<Record<string, unknown>>>([]);
|
|
const [conversationDetail, setConversationDetail] = useState<{ inquiry: Record<string, unknown>; messages: Array<Record<string, unknown>> } | null>(null);
|
|
const [status, setStatus] = useState("");
|
|
const [workStep, setWorkStep] = useState(0);
|
|
const [serviceStep, setServiceStep] = useState(0);
|
|
const [showcaseStep, setShowcaseStep] = useState(0);
|
|
const [locationStep, setLocationStep] = useState(0);
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const [workListFilter, setWorkListFilter] = useState<"published" | "drafts">("published");
|
|
const [serviceListFilter, setServiceListFilter] = useState<"all" | "active" | "drafts">("all");
|
|
const [showcaseListFilter, setShowcaseListFilter] = useState<"all" | "published" | "drafts">("all");
|
|
const [subscriptionCycle, setSubscriptionCycle] = useState<"monthly" | "annual">("monthly");
|
|
const [subscriptionScenario, setSubscriptionScenario] = useState<"active" | "grace" | "suspending" | "inactive">("active");
|
|
const [subscriptionBillingOpen, setSubscriptionBillingOpen] = useState(false);
|
|
const [subscriptionBillingStep, setSubscriptionBillingStep] = useState(0);
|
|
const [editingWorkId, setEditingWorkId] = useState<string | null>(null);
|
|
const [editingServiceId, setEditingServiceId] = useState<string | null>(null);
|
|
const [editingShowcaseId, setEditingShowcaseId] = useState<string | null>(null);
|
|
const [editingLocationId, setEditingLocationId] = useState<string | null>(null);
|
|
const [workWizardOpen, setWorkWizardOpen] = useState(false);
|
|
const [serviceWizardOpen, setServiceWizardOpen] = useState(false);
|
|
const [showcaseWizardOpen, setShowcaseWizardOpen] = useState(false);
|
|
const [locationWizardOpen, setLocationWizardOpen] = useState(false);
|
|
const [toastMessage, setToastMessage] = useState("");
|
|
|
|
const [profileForm, setProfileForm] = useState({
|
|
businessName: "",
|
|
description: "",
|
|
province: "CABA",
|
|
city: "Buenos Aires",
|
|
latitude: "-34.6037",
|
|
longitude: "-58.3816",
|
|
deliveryScope: "nationwide",
|
|
availability: "available",
|
|
publicContactEmail: "",
|
|
publicWhatsapp: "",
|
|
mainImageUrl: "/demo/maker-hero-custom.svg",
|
|
websiteUrl: "",
|
|
instagramUrl: "",
|
|
tiktokUrl: "",
|
|
twitterUrl: "",
|
|
facebookUrl: "",
|
|
youtubeUrl: "",
|
|
linkedinUrl: ""
|
|
});
|
|
const [reviewSettings, setReviewSettings] = useState({
|
|
allowVerifiedReviews: true,
|
|
showSatisfactionScore: true,
|
|
showReviewTags: true,
|
|
allowMakerReviewReply: true,
|
|
showPastReviews: true
|
|
});
|
|
|
|
const [serviceForm, setServiceForm] = useState(emptyServiceForm());
|
|
const [showcaseForm, setShowcaseForm] = useState(emptyShowcaseForm());
|
|
const [locationForm, setLocationForm] = useState(emptyLocationForm());
|
|
const [subscriptionBillingForm, setSubscriptionBillingForm] = useState(emptySubscriptionBillingForm());
|
|
const [personalAccountForm, setPersonalAccountForm] = useState({
|
|
firstName: "Juan",
|
|
lastName: "Perez",
|
|
publicName: "Juan P.",
|
|
phone: "+54 9 351 123 4567",
|
|
avatarUrl: "/demo/maker-hero-custom.svg"
|
|
});
|
|
const [notificationPrefs, setNotificationPrefs] = useState({
|
|
newMessage: true,
|
|
messageReply: true,
|
|
makerInquiry: true,
|
|
acceptedInquiry: true,
|
|
closedInquiry: false,
|
|
customerWaiting: true,
|
|
workUpdates: true,
|
|
discoveryAlerts: true,
|
|
reviews: true,
|
|
security: true,
|
|
promotions: false
|
|
});
|
|
const [privacyPrefs, setPrivacyPrefs] = useState({
|
|
useLocation: true,
|
|
searchHistory: true,
|
|
personalizedResults: true,
|
|
profileVisible: true
|
|
});
|
|
const [appearancePrefs, setAppearancePrefs] = useState({
|
|
theme: "dark",
|
|
textSize: "medium",
|
|
reduceMotion: false,
|
|
highContrast: false,
|
|
language: "es-AR",
|
|
currency: "ARS"
|
|
});
|
|
const [blockedUsers, setBlockedUsers] = useState([
|
|
{ id: "maria-g", name: "Maria G.", date: "12/05/2026" },
|
|
{ id: "usuario-123", name: "Usuario123", date: "03/04/2026" },
|
|
{ id: "carlos-r", name: "Carlos R.", date: "15/03/2026" }
|
|
]);
|
|
|
|
const [workForm, setWorkForm] = useState(emptyWorkForm());
|
|
|
|
useEffect(() => {
|
|
void loadAll();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (section !== "inbox" || !conversationId || !user) {
|
|
return;
|
|
}
|
|
void openConversation(conversationId);
|
|
}, [conversationId, section, user]);
|
|
|
|
async function loadAll() {
|
|
try {
|
|
const me = await browserFetch<{ user: { id: string; email: string; role: string } | null }>("/auth/me");
|
|
setUser(me.user);
|
|
if (!me.user) {
|
|
return;
|
|
}
|
|
if (me.user.role === "admin") {
|
|
window.location.replace("/app/backoffice");
|
|
return;
|
|
}
|
|
|
|
const [accountData, conversationsData] = await Promise.all([
|
|
browserFetch<AccountResponse>("/account/maker"),
|
|
browserFetch<{ conversations: Array<Record<string, unknown>> }>("/account/conversations")
|
|
]);
|
|
|
|
setAccount(accountData);
|
|
setConversations(conversationsData.conversations);
|
|
setProfileForm({
|
|
businessName: accountData.maker.business_name || "",
|
|
description: accountData.maker.description || "",
|
|
province: accountData.maker.province || "CABA",
|
|
city: accountData.maker.city || "Buenos Aires",
|
|
latitude: String(accountData.maker.latitude || -34.6037),
|
|
longitude: String(accountData.maker.longitude || -58.3816),
|
|
deliveryScope: accountData.maker.delivery_scope || "nationwide",
|
|
availability: accountData.maker.availability || "available",
|
|
publicContactEmail: accountData.maker.public_contact_email || "",
|
|
publicWhatsapp: accountData.maker.public_whatsapp || "",
|
|
mainImageUrl: accountData.maker.main_image_url || "/demo/maker-hero-custom.svg",
|
|
websiteUrl: accountData.maker.website_url || "",
|
|
instagramUrl: accountData.maker.instagram_url || "",
|
|
tiktokUrl: accountData.maker.tiktok_url || "",
|
|
twitterUrl: accountData.maker.twitter_url || "",
|
|
facebookUrl: accountData.maker.facebook_url || "",
|
|
youtubeUrl: accountData.maker.youtube_url || "",
|
|
linkedinUrl: accountData.maker.linkedin_url || ""
|
|
});
|
|
setReviewSettings({
|
|
allowVerifiedReviews: accountData.maker.allow_verified_reviews !== false,
|
|
showSatisfactionScore: accountData.maker.show_satisfaction_score !== false,
|
|
showReviewTags: accountData.maker.show_review_tags !== false,
|
|
allowMakerReviewReply: accountData.maker.allow_maker_review_reply !== false,
|
|
showPastReviews: accountData.maker.show_past_reviews !== false
|
|
});
|
|
setLocationForm((current) => ({
|
|
...current,
|
|
label: accountData.maker.business_name ? `Taller ${accountData.maker.business_name}` : current.label,
|
|
province: accountData.maker.province || current.province,
|
|
city: accountData.maker.city || current.city,
|
|
latitude: String(accountData.maker.latitude || current.latitude),
|
|
longitude: String(accountData.maker.longitude || current.longitude),
|
|
nationwideShipping: (accountData.maker.delivery_scope || "nationwide") === "nationwide",
|
|
postalShipping: (accountData.maker.delivery_scope || "nationwide") === "nationwide",
|
|
shippingArea: (accountData.maker.delivery_scope || "nationwide") === "nationwide" ? "nationwide" : "province",
|
|
alertEmail: accountData.maker.public_contact_email || current.alertEmail
|
|
}));
|
|
} catch (error) {
|
|
setStatus(error instanceof Error ? error.message : "No se pudo cargar la cuenta");
|
|
} finally {
|
|
setAuthLoading(false);
|
|
}
|
|
}
|
|
|
|
const checklistItems = useMemo(() => {
|
|
const entries = account?.checklist?.checklist ? Object.entries(account.checklist.checklist) : [];
|
|
return entries;
|
|
}, [account]);
|
|
|
|
const reviewAverage = useMemo(() => {
|
|
if (!account?.reviews?.length) {
|
|
return 0;
|
|
}
|
|
const total = account.reviews.reduce((sum, review) => sum + toNumber(review.rating_overall, 0), 0);
|
|
return total / account.reviews.length;
|
|
}, [account?.reviews]);
|
|
const reviewRows = account?.reviews || [];
|
|
const publishedReviews = reviewRows.filter((review) => toText(review.status, "published") === "published");
|
|
const reportedReviews = reviewRows.filter((review) => toText(review.status) === "reported");
|
|
const completedConversations = conversations.filter((item) => toText(item.status) === "completed").length;
|
|
const reviewDistribution = [5, 4, 3, 2, 1].map((rating) => {
|
|
const count = reviewRows.filter((review) => Math.round(toNumber(review.rating_overall, 0)) === rating).length;
|
|
return { rating, count, percent: reviewRows.length ? Math.round((count / reviewRows.length) * 100) : 0 };
|
|
});
|
|
const aspectScores = [
|
|
["Calidad", reviewRows.length ? reviewRows.reduce((sum, review) => sum + toNumber(review.rating_quality, 0), 0) / reviewRows.length : 0],
|
|
["Comunicacion", reviewRows.length ? reviewRows.reduce((sum, review) => sum + toNumber(review.rating_communication, 0), 0) / reviewRows.length : 0],
|
|
["Calidad/precio", reviewRows.length ? reviewRows.reduce((sum, review) => sum + toNumber(review.rating_value, 0), 0) / reviewRows.length : 0],
|
|
["Cumplimiento", reviewAverage || 0]
|
|
] as const;
|
|
const reputationBadges = [
|
|
["Trabajo verificado", "Basado en consultas finalizadas dentro de la plataforma."],
|
|
["Problema resuelto", "Las incidencias pueden cerrarse con evidencia y respuesta."],
|
|
["Cliente recurrente", "Se destaca cuando hay confianza repetida."],
|
|
["Respuesta rapida", "Mejora cuando el maker responde en menos de 1 hora."]
|
|
] as const;
|
|
const planStatus = account?.checklist.subscriptionStatus || "inactive";
|
|
const isSubscriptionActive = planStatus === "active";
|
|
const checklistDoneCount = checklistItems.filter(([, value]) => Boolean(value)).length;
|
|
const checklistTotal = Math.max(checklistItems.length, 1);
|
|
const readinessPercent = Math.round((checklistDoneCount / checklistTotal) * 100);
|
|
const subscriptionPrice = subscriptionCycle === "monthly" ? "19.990" : "199.900";
|
|
const subscriptionPeriod = subscriptionCycle === "monthly" ? "mes" : "ano";
|
|
const publicProfileHref = `/makers/${account?.maker.slug || "makerlab-3d"}` as Route;
|
|
const billingEvents = [
|
|
{ date: "10 jul, 2026", event: "Renovacion", amount: "$19.990", status: "Pagado" },
|
|
{ date: "9 jul, 2026", event: "Intento de cobro", amount: "$19.990", status: "Fallido" },
|
|
{ date: "10 jun, 2026", event: "Renovacion", amount: "$19.990", status: "Pagado" }
|
|
];
|
|
const accountActivityRows = [
|
|
["Consultas", String(conversations.length)],
|
|
["Favoritos", "12"],
|
|
["Alertas activas", "2"],
|
|
["Resenas publicadas", String(account?.reviews.length || 0)],
|
|
["Mensajes", String(conversations.length)]
|
|
] as const;
|
|
const notificationRows = [
|
|
["newMessage", "Nuevo mensaje", "Mensajes y consultas"],
|
|
["messageReply", "Respuesta a mi mensaje", "Conversaciones"],
|
|
["makerInquiry", "Nueva consulta recibida", "Perfil maker"],
|
|
["acceptedInquiry", "Consulta aceptada", "Trabajos"],
|
|
["closedInquiry", "Consulta cerrada", "Historial"],
|
|
["customerWaiting", "Cliente espera respuesta", "Prioridad"]
|
|
] as const;
|
|
const notificationSummaryRows = [
|
|
["Mensajes y consultas", notificationPrefs.newMessage || notificationPrefs.messageReply],
|
|
["Trabajos", notificationPrefs.workUpdates],
|
|
["Alertas y descubrimiento", notificationPrefs.discoveryAlerts],
|
|
["Resenas y reputacion", notificationPrefs.reviews],
|
|
["Cuenta y seguridad", notificationPrefs.security],
|
|
["Novedades y promociones", notificationPrefs.promotions]
|
|
] as const;
|
|
const savedAlertRows = [
|
|
{ title: "Resina cerca de casa", detail: "25 km | Cordoba Capital", status: "Activa" },
|
|
{ title: "Repuestos con envio a Mendoza", detail: "Envio incluido | Mendoza", status: "Activa" },
|
|
{ title: "Impresion SLA profesional", detail: "30 km | Villa Allende", status: "Pausada" }
|
|
];
|
|
const activeSessionRows = [
|
|
{ device: "Chrome - Windows", place: "Cordoba, Argentina", status: "Actual" },
|
|
{ device: "Safari - iPhone", place: "Cordoba, Argentina", status: "Hace 2 dias" },
|
|
{ device: "Chrome - Android", place: "Villa Allende, Argentina", status: "Hace 5 dias" }
|
|
];
|
|
const profileDiagnosticRows = [
|
|
["Logo", Boolean(profileForm.mainImageUrl)],
|
|
["Descripcion", Boolean(profileForm.description.trim())],
|
|
["Servicios", Boolean(account?.services?.some((service) => Boolean(service.is_published)))],
|
|
["Ubicacion", Boolean(account?.locations?.some((location) => Boolean(location.is_published)))],
|
|
["Trabajos", Boolean(account?.works?.some((work) => Boolean(work.is_published)))],
|
|
["Escaparates", Boolean(account?.showcases?.some((showcase) => Boolean(showcase.is_published)))],
|
|
["Redes sociales", Boolean(profileForm.websiteUrl || profileForm.instagramUrl || profileForm.tiktokUrl)]
|
|
] as const;
|
|
const profileDiagnosticPercent = Math.round((profileDiagnosticRows.filter(([, value]) => value).length / profileDiagnosticRows.length) * 100);
|
|
const accountStateRows = [
|
|
["Cuenta", "Activa"],
|
|
["Email", "Verificado"],
|
|
["Seguridad", appearancePrefs.highContrast ? "Alta" : "Media"],
|
|
["Notificaciones", "Personalizadas"],
|
|
["Mi espacio Maker", account?.maker.status === "published" ? "Activo" : "Borrador"],
|
|
["Plan Maker", planStatus === "active" ? "Activo" : "Pendiente"]
|
|
] as const;
|
|
|
|
async function saveProfile(event: React.FormEvent) {
|
|
event.preventDefault();
|
|
setStatus("Guardando perfil...");
|
|
try {
|
|
await browserFetch("/account/maker/profile", {
|
|
method: "PUT",
|
|
body: JSON.stringify({
|
|
...profileForm,
|
|
latitude: Number(profileForm.latitude),
|
|
longitude: Number(profileForm.longitude)
|
|
})
|
|
});
|
|
setStatus("Perfil guardado.");
|
|
await loadAll();
|
|
} catch (error) {
|
|
setStatus(error instanceof Error ? error.message : "No se pudo guardar el perfil");
|
|
}
|
|
}
|
|
|
|
async function toggleReviewSetting(key: keyof typeof reviewSettings) {
|
|
const nextSettings = { ...reviewSettings, [key]: !reviewSettings[key] };
|
|
const previousSettings = reviewSettings;
|
|
setReviewSettings(nextSettings);
|
|
setStatus("Guardando configuracion de resenas...");
|
|
|
|
try {
|
|
await browserFetch("/account/maker/review-settings", {
|
|
method: "PATCH",
|
|
body: JSON.stringify(nextSettings)
|
|
});
|
|
setStatus("Configuracion de resenas guardada.");
|
|
await loadAll();
|
|
} catch (error) {
|
|
setReviewSettings(previousSettings);
|
|
setStatus(error instanceof Error ? error.message : "No se pudo guardar la configuracion.");
|
|
}
|
|
}
|
|
|
|
function updateLocationForm(field: keyof typeof locationForm, value: string | boolean) {
|
|
setLocationForm((current) => ({ ...current, [field]: value }));
|
|
}
|
|
|
|
function locationFormFromProfile() {
|
|
return {
|
|
...emptyLocationForm(),
|
|
label: profileForm.businessName ? `Taller ${profileForm.businessName}` : "Taller principal",
|
|
province: profileForm.province || "Cordoba",
|
|
city: profileForm.city || "Cordoba",
|
|
latitude: profileForm.latitude || "-31.4201",
|
|
longitude: profileForm.longitude || "-64.1888",
|
|
nationwideShipping: profileForm.deliveryScope === "nationwide",
|
|
postalShipping: profileForm.deliveryScope === "nationwide",
|
|
shippingArea: profileForm.deliveryScope === "nationwide" ? "nationwide" : "province",
|
|
alertEmail: profileForm.publicContactEmail
|
|
};
|
|
}
|
|
|
|
function locationFormFromLocation(location: Record<string, unknown>) {
|
|
const draftData = location.draft_data && typeof location.draft_data === "object" && !Array.isArray(location.draft_data)
|
|
? location.draft_data as Partial<ReturnType<typeof emptyLocationForm>>
|
|
: {};
|
|
|
|
return {
|
|
...emptyLocationForm(),
|
|
...draftData,
|
|
label: toText(location.label, toText(draftData.label, "Taller principal")),
|
|
type: toText(location.location_type, toText(draftData.type, "private_workshop")),
|
|
address: toText(location.address, toText(draftData.address)),
|
|
postalCode: toText(location.postal_code, toText(draftData.postalCode)),
|
|
province: toText(location.province, toText(draftData.province, "Cordoba")),
|
|
city: toText(location.city, toText(draftData.city, "Cordoba")),
|
|
latitude: String(location.latitude ?? draftData.latitude ?? "-31.4201"),
|
|
longitude: String(location.longitude ?? draftData.longitude ?? "-64.1888"),
|
|
privacy: toText(location.privacy, toText(draftData.privacy, "approximate")),
|
|
localPickup: Boolean(location.local_pickup ?? draftData.localPickup ?? true),
|
|
personalDelivery: Boolean(location.personal_delivery ?? draftData.personalDelivery ?? true),
|
|
postalShipping: Boolean(location.postal_shipping ?? draftData.postalShipping ?? true),
|
|
nationwideShipping: Boolean(location.nationwide_shipping ?? draftData.nationwideShipping ?? false),
|
|
mobileService: Boolean(location.mobile_service ?? draftData.mobileService ?? false),
|
|
onsiteService: Boolean(location.onsite_service ?? draftData.onsiteService ?? false),
|
|
notes: toText(location.notes, toText(draftData.notes)),
|
|
travelRadius: toText(location.travel_radius, toText(draftData.travelRadius, "25")),
|
|
shippingArea: toText(location.shipping_area, toText(draftData.shippingArea, "nationwide")),
|
|
hoursMode: toText(location.hours_mode, toText(draftData.hoursMode, "appointment")),
|
|
alertService: toText(location.alert_service, toText(draftData.alertService)),
|
|
alertRadius: toText(location.alert_radius, toText(draftData.alertRadius, "30")),
|
|
minimumRating: toText(location.minimum_rating, toText(draftData.minimumRating, "4")),
|
|
isPublished: Boolean(location.is_published)
|
|
};
|
|
}
|
|
|
|
function locationPayload(isPublished: boolean) {
|
|
const deliveryScope = locationForm.nationwideShipping || locationForm.shippingArea === "nationwide" ? "nationwide" : "local";
|
|
const draftData = {
|
|
...locationForm,
|
|
isPublished
|
|
};
|
|
|
|
return {
|
|
...locationForm,
|
|
latitude: Number(locationForm.latitude || profileForm.latitude || -31.4201),
|
|
longitude: Number(locationForm.longitude || profileForm.longitude || -64.1888),
|
|
nationwideShipping: deliveryScope === "nationwide",
|
|
postalShipping: locationForm.postalShipping || deliveryScope === "nationwide",
|
|
draftData,
|
|
isPublished
|
|
};
|
|
}
|
|
|
|
function startNewLocation() {
|
|
setEditingLocationId(null);
|
|
setLocationForm(locationFormFromProfile());
|
|
setLocationStep(0);
|
|
setLocationWizardOpen(true);
|
|
setStatus("");
|
|
}
|
|
|
|
function editLocation(location: Record<string, unknown>) {
|
|
setEditingLocationId(String(location.id));
|
|
setLocationForm(locationFormFromLocation(location));
|
|
setLocationStep(0);
|
|
setLocationWizardOpen(true);
|
|
setStatus("Ubicacion cargada para editar.");
|
|
}
|
|
|
|
function closeLocationWizard() {
|
|
setLocationWizardOpen(false);
|
|
}
|
|
|
|
function goToNextLocationStep() {
|
|
if (locationStep === 0 && !locationForm.type) {
|
|
showToast("Elige el tipo de ubicacion.");
|
|
return;
|
|
}
|
|
if (locationStep === 1 && (!locationForm.city.trim() || !locationForm.province.trim())) {
|
|
showToast("Completa ciudad y provincia.");
|
|
return;
|
|
}
|
|
setLocationStep((current) => Math.min(current + 1, locationSteps.length - 1));
|
|
}
|
|
|
|
async function saveLocation(shouldClose = false) {
|
|
setStatus(shouldClose ? "Guardando ubicacion..." : "Guardando borrador de ubicacion...");
|
|
const isPublished = shouldClose;
|
|
const payload = locationPayload(isPublished);
|
|
|
|
try {
|
|
const response = await browserFetch<{ location: Record<string, unknown> }>(
|
|
editingLocationId ? `/account/maker/locations/${editingLocationId}` : "/account/maker/locations",
|
|
{
|
|
method: editingLocationId ? "PATCH" : "POST",
|
|
body: JSON.stringify(payload)
|
|
}
|
|
);
|
|
setEditingLocationId(String(response.location.id));
|
|
|
|
if (isPublished) {
|
|
const nextProfileForm = {
|
|
...profileForm,
|
|
businessName: profileForm.businessName || account?.maker.business_name || "Maker 3D",
|
|
description: profileForm.description || account?.maker.description || "Maker con cobertura configurada para recibir consultas y coordinar trabajos de impresion 3D.",
|
|
province: locationForm.province || profileForm.province,
|
|
city: locationForm.city || profileForm.city,
|
|
latitude: String(Number(locationForm.latitude || profileForm.latitude || -31.4201)),
|
|
longitude: String(Number(locationForm.longitude || profileForm.longitude || -64.1888)),
|
|
deliveryScope: payload.nationwideShipping ? "nationwide" : "local",
|
|
publicContactEmail: profileForm.publicContactEmail || locationForm.alertEmail
|
|
};
|
|
await browserFetch("/account/maker/profile", {
|
|
method: "PUT",
|
|
body: JSON.stringify({
|
|
...nextProfileForm,
|
|
latitude: Number(nextProfileForm.latitude),
|
|
longitude: Number(nextProfileForm.longitude)
|
|
})
|
|
});
|
|
setProfileForm(nextProfileForm);
|
|
}
|
|
|
|
setStatus(isPublished ? "Ubicacion guardada." : "Borrador de ubicacion guardado.");
|
|
showToast(isPublished ? "Ubicacion guardada." : "Borrador guardado.");
|
|
await loadAll();
|
|
if (isPublished) {
|
|
setEditingLocationId(null);
|
|
setLocationWizardOpen(false);
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "No se pudo guardar la ubicacion";
|
|
setStatus(message);
|
|
showToast(message);
|
|
}
|
|
}
|
|
|
|
async function deleteLocation(location: Record<string, unknown>) {
|
|
setStatus("Eliminando ubicacion...");
|
|
try {
|
|
await browserFetch(`/account/maker/locations/${String(location.id)}`, { method: "DELETE" });
|
|
setStatus("Ubicacion eliminada.");
|
|
await loadAll();
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "No se pudo eliminar la ubicacion";
|
|
setStatus(message);
|
|
showToast(message);
|
|
}
|
|
}
|
|
|
|
async function createService(event: React.FormEvent) {
|
|
event.preventDefault();
|
|
await saveService(true);
|
|
}
|
|
|
|
async function createWork(event: React.FormEvent) {
|
|
event.preventDefault();
|
|
await saveWork(true);
|
|
}
|
|
|
|
function showToast(message: string) {
|
|
setToastMessage(message);
|
|
window.setTimeout(() => {
|
|
setToastMessage((current) => (current === message ? "" : current));
|
|
}, 3200);
|
|
}
|
|
|
|
function updateServiceForm(field: keyof typeof serviceForm, value: string | boolean | string[]) {
|
|
setServiceForm((current) => ({ ...current, [field]: value }));
|
|
}
|
|
|
|
function toggleServiceListField(field: "technologies" | "materials" | "colors" | "finishes" | "uses" | "conditions" | "relatedWorks", value: string) {
|
|
setServiceForm((current) => {
|
|
const values = current[field];
|
|
return {
|
|
...current,
|
|
[field]: values.includes(value) ? values.filter((item) => item !== value) : [...values, value]
|
|
};
|
|
});
|
|
}
|
|
|
|
function applyServiceTemplate(template: (typeof serviceTemplates)[number]) {
|
|
setServiceForm((current) => ({
|
|
...current,
|
|
title: template.title,
|
|
category: template.category,
|
|
description: template.description,
|
|
technologies: template.category.includes("Resina") ? ["Resina"] : template.category.includes("Diseno") ? ["CAD"] : ["FDM"],
|
|
materials: template.category.includes("Resina") ? ["Resina standard"] : ["PLA", "PETG"],
|
|
uses: template.category.includes("Diseno") ? ["Prototipos"] : ["Repuestos y piezas funcionales", "Prototipos"]
|
|
}));
|
|
setServiceStep(1);
|
|
}
|
|
|
|
function servicePayload(isPublished: boolean) {
|
|
const price = serviceForm.priceMode === "hidden" ? null : Number(serviceForm.priceFrom || serviceForm.smallPrice || 0) * 100;
|
|
|
|
return {
|
|
title: serviceForm.title,
|
|
category: serviceForm.category,
|
|
description: [
|
|
serviceForm.description,
|
|
serviceForm.maxX || serviceForm.maxY || serviceForm.maxZ ? `Volumen maximo: X ${serviceForm.maxX || "-"} mm, Y ${serviceForm.maxY || "-"} mm, Z ${serviceForm.maxZ || "-"} mm.` : "",
|
|
serviceForm.uses.length ? `Ideal para: ${serviceForm.uses.join(", ")}.` : "",
|
|
serviceForm.conditions.length ? `Condiciones: ${serviceForm.conditions.join(", ")}.` : "",
|
|
serviceForm.priceMode === "range" ? `Rangos: chico $${serviceForm.smallPrice || "-"}, mediano $${serviceForm.mediumPrice || "-"}, grande $${serviceForm.largePrice || "-"}.` : "",
|
|
serviceForm.pauseMessage && serviceForm.visibility === "paused" ? `Mensaje de pausa: ${serviceForm.pauseMessage}` : ""
|
|
].filter(Boolean).join("\n\n"),
|
|
technologies: serviceForm.technologies,
|
|
materials: serviceForm.materials,
|
|
localPickup: serviceForm.localPickup,
|
|
nationwideShipping: serviceForm.nationwideShipping,
|
|
leadTimeDays: Number(serviceForm.leadTimeDays || 4),
|
|
priceFromCents: price,
|
|
draftData: serviceForm,
|
|
isPublished
|
|
};
|
|
}
|
|
|
|
async function saveService(isPublished: boolean) {
|
|
const wantsPublish = isPublished && serviceForm.visibility !== "draft";
|
|
setStatus(wantsPublish ? "Publicando servicio..." : "Guardando servicio...");
|
|
try {
|
|
const response = await browserFetch<{ service: Record<string, unknown> }>(
|
|
editingServiceId ? `/account/maker/services/${editingServiceId}` : "/account/maker/services",
|
|
{
|
|
method: editingServiceId ? "PATCH" : "POST",
|
|
body: JSON.stringify(servicePayload(wantsPublish && serviceForm.visibility !== "paused"))
|
|
}
|
|
);
|
|
setEditingServiceId(String(response.service.id));
|
|
if (wantsPublish && serviceForm.visibility !== "paused") {
|
|
setServiceForm(emptyServiceForm());
|
|
setEditingServiceId(null);
|
|
setServiceWizardOpen(false);
|
|
setStatus("Servicio publicado.");
|
|
} else {
|
|
setStatus(serviceForm.visibility === "paused" ? "Servicio pausado." : "Borrador de servicio guardado.");
|
|
}
|
|
await loadAll();
|
|
} catch (error) {
|
|
setStatus(error instanceof Error ? error.message : "No se pudo guardar el servicio");
|
|
}
|
|
}
|
|
|
|
function startNewService() {
|
|
setEditingServiceId(null);
|
|
setServiceForm(emptyServiceForm());
|
|
setServiceStep(0);
|
|
setServiceWizardOpen(true);
|
|
setStatus("");
|
|
}
|
|
|
|
function closeServiceWizard() {
|
|
setServiceWizardOpen(false);
|
|
}
|
|
|
|
function serviceFormFromService(service: Record<string, unknown>) {
|
|
const draftData = service.draft_data && typeof service.draft_data === "object" && !Array.isArray(service.draft_data)
|
|
? service.draft_data as Partial<ReturnType<typeof emptyServiceForm>>
|
|
: {};
|
|
|
|
return {
|
|
...emptyServiceForm(),
|
|
...draftData,
|
|
title: !Boolean(service.is_published) && toText(service.title) === "Servicio en borrador" ? "" : toText(service.title),
|
|
category: !Boolean(service.is_published) && toText(service.category) === "Pendiente" ? "" : toText(service.category),
|
|
description: !Boolean(service.is_published) && toText(service.description) === "Servicio pendiente de completar." ? "" : toText(service.description),
|
|
technologies: Array.isArray(service.technologies) ? service.technologies.map(String) : [],
|
|
materials: Array.isArray(service.materials) ? service.materials.map(String) : [],
|
|
leadTimeDays: String(service.lead_time_days || 4),
|
|
localPickup: Boolean(service.local_pickup),
|
|
nationwideShipping: Boolean(service.nationwide_shipping),
|
|
priceFrom: service.price_from_cents ? String(Math.round(toNumber(service.price_from_cents) / 100)) : "",
|
|
visibility: Boolean(service.is_published) ? "published" : "draft"
|
|
};
|
|
}
|
|
|
|
function editService(service: Record<string, unknown>) {
|
|
setEditingServiceId(String(service.id));
|
|
setServiceForm(serviceFormFromService(service));
|
|
setServiceStep(1);
|
|
setServiceWizardOpen(true);
|
|
setStatus("Servicio cargado para editar.");
|
|
}
|
|
|
|
async function pauseService(service: Record<string, unknown>) {
|
|
const restoredForm = { ...serviceFormFromService(service), visibility: "paused" };
|
|
setStatus("Pausando servicio...");
|
|
try {
|
|
await browserFetch(`/account/maker/services/${String(service.id)}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({
|
|
...servicePayload(false),
|
|
...{
|
|
title: restoredForm.title,
|
|
category: restoredForm.category,
|
|
description: restoredForm.description,
|
|
technologies: restoredForm.technologies,
|
|
materials: restoredForm.materials,
|
|
localPickup: restoredForm.localPickup,
|
|
nationwideShipping: restoredForm.nationwideShipping,
|
|
leadTimeDays: Number(restoredForm.leadTimeDays || 4),
|
|
priceFromCents: restoredForm.priceFrom ? Number(restoredForm.priceFrom) * 100 : null,
|
|
draftData: restoredForm,
|
|
isPublished: false
|
|
}
|
|
})
|
|
});
|
|
setStatus("Servicio pausado.");
|
|
await loadAll();
|
|
} catch (error) {
|
|
setStatus(error instanceof Error ? error.message : "No se pudo pausar el servicio");
|
|
}
|
|
}
|
|
|
|
async function deleteService(service: Record<string, unknown>) {
|
|
if (typeof window !== "undefined" && !window.confirm("Esto eliminara el servicio. Quieres continuar?")) {
|
|
return;
|
|
}
|
|
setStatus("Eliminando servicio...");
|
|
try {
|
|
await browserFetch(`/account/maker/services/${String(service.id)}`, { method: "DELETE" });
|
|
setStatus("Servicio eliminado.");
|
|
await loadAll();
|
|
} catch (error) {
|
|
setStatus(error instanceof Error ? error.message : "No se pudo eliminar el servicio");
|
|
}
|
|
}
|
|
|
|
function updateShowcaseForm(field: keyof typeof showcaseForm, value: string | string[]) {
|
|
setShowcaseForm((current) => ({ ...current, [field]: value }));
|
|
}
|
|
|
|
function toggleShowcaseType(value: string) {
|
|
setShowcaseForm((current) => ({
|
|
...current,
|
|
types: current.types.includes(value) ? current.types.filter((item) => item !== value) : [...current.types, value]
|
|
}));
|
|
}
|
|
|
|
function toggleShowcaseWork(workId: string) {
|
|
setShowcaseForm((current) => {
|
|
const selectedWorkIds = current.selectedWorkIds.includes(workId)
|
|
? current.selectedWorkIds.filter((id) => id !== workId)
|
|
: [...current.selectedWorkIds, workId];
|
|
|
|
return {
|
|
...current,
|
|
selectedWorkIds,
|
|
featuredWorkId: selectedWorkIds.includes(current.featuredWorkId) ? current.featuredWorkId : selectedWorkIds[0] || "",
|
|
coverImageUrl: selectedWorkIds.length ? current.coverImageUrl : ""
|
|
};
|
|
});
|
|
}
|
|
|
|
function moveShowcaseWork(workId: string, direction: -1 | 1) {
|
|
setShowcaseForm((current) => {
|
|
const index = current.selectedWorkIds.indexOf(workId);
|
|
const nextIndex = index + direction;
|
|
if (index < 0 || nextIndex < 0 || nextIndex >= current.selectedWorkIds.length) {
|
|
return current;
|
|
}
|
|
const selectedWorkIds = [...current.selectedWorkIds];
|
|
const [item] = selectedWorkIds.splice(index, 1);
|
|
selectedWorkIds.splice(nextIndex, 0, item);
|
|
return { ...current, selectedWorkIds };
|
|
});
|
|
}
|
|
|
|
function chooseShowcaseCover(work: Record<string, unknown>) {
|
|
setShowcaseForm((current) => ({
|
|
...current,
|
|
coverMode: "existing",
|
|
featuredWorkId: String(work.id),
|
|
coverImageUrl: toText(work.image_url, "")
|
|
}));
|
|
}
|
|
|
|
function getShowcaseSelectedWorks() {
|
|
const worksById = new Map((account?.works || []).map((work) => [String(work.id), work]));
|
|
return showcaseForm.selectedWorkIds.map((id) => worksById.get(id)).filter(Boolean) as Array<Record<string, unknown>>;
|
|
}
|
|
|
|
function getShowcaseCoverImage() {
|
|
if (showcaseForm.coverMode === "existing" && showcaseForm.coverImageUrl) {
|
|
return showcaseForm.coverImageUrl;
|
|
}
|
|
const featured = (account?.works || []).find((work) => String(work.id) === showcaseForm.featuredWorkId);
|
|
return toText(featured?.image_url, toText(getShowcaseSelectedWorks()[0]?.image_url, "/demo/work-custom.svg"));
|
|
}
|
|
|
|
function showcasePayload(isPublished: boolean) {
|
|
const cleanedDescription = cleanShowcaseDescription(showcaseForm.description);
|
|
const selectedWorksCount = showcaseForm.selectedWorkIds.length;
|
|
const publishedDescription = cleanedDescription || `${showcaseForm.title || "Escaparate"} agrupa ${selectedWorksCount} trabajos seleccionados para mostrar experiencia real del maker.`;
|
|
const cleanedDraftData = {
|
|
...showcaseForm,
|
|
description: cleanedDescription
|
|
};
|
|
|
|
return {
|
|
title: showcaseForm.title,
|
|
description: isPublished ? publishedDescription : cleanedDescription,
|
|
coverImageUrl: getShowcaseCoverImage(),
|
|
selectedWorkIds: showcaseForm.selectedWorkIds,
|
|
featuredWorkId: showcaseForm.featuredWorkId || showcaseForm.selectedWorkIds[0] || null,
|
|
draftData: cleanedDraftData,
|
|
isPublished
|
|
};
|
|
}
|
|
|
|
async function saveShowcase(isPublished: boolean) {
|
|
if (isPublished && !showcaseForm.selectedWorkIds.length) {
|
|
setShowcaseStep(1);
|
|
showToast("Selecciona al menos un trabajo para publicar el escaparate.");
|
|
return;
|
|
}
|
|
setStatus(isPublished ? "Publicando escaparate..." : "Guardando escaparate...");
|
|
try {
|
|
const response = await browserFetch<{ showcase: Record<string, unknown> }>(
|
|
editingShowcaseId ? `/account/maker/showcases/${editingShowcaseId}` : "/account/maker/showcases",
|
|
{
|
|
method: editingShowcaseId ? "PATCH" : "POST",
|
|
body: JSON.stringify(showcasePayload(isPublished))
|
|
}
|
|
);
|
|
setEditingShowcaseId(String(response.showcase.id));
|
|
if (isPublished) {
|
|
setShowcaseForm(emptyShowcaseForm());
|
|
setEditingShowcaseId(null);
|
|
setShowcaseWizardOpen(false);
|
|
setStatus("Escaparate publicado.");
|
|
} else {
|
|
setStatus("Borrador de escaparate guardado.");
|
|
}
|
|
await loadAll();
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "No se pudo guardar el escaparate";
|
|
setStatus(message);
|
|
showToast(message);
|
|
}
|
|
}
|
|
|
|
function startNewShowcase() {
|
|
setEditingShowcaseId(null);
|
|
setShowcaseForm(emptyShowcaseForm());
|
|
setShowcaseStep(0);
|
|
setShowcaseWizardOpen(true);
|
|
setStatus("");
|
|
}
|
|
|
|
function closeShowcaseWizard() {
|
|
setShowcaseWizardOpen(false);
|
|
}
|
|
|
|
function showcaseFormFromShowcase(showcase: Record<string, unknown>) {
|
|
const draftData = showcase.draft_data && typeof showcase.draft_data === "object" && !Array.isArray(showcase.draft_data)
|
|
? showcase.draft_data as Partial<ReturnType<typeof emptyShowcaseForm>>
|
|
: {};
|
|
const storedDescription = !Boolean(showcase.is_published) && toText(showcase.description) === "Escaparate pendiente de completar." ? "" : toText(showcase.description);
|
|
return {
|
|
...emptyShowcaseForm(),
|
|
...draftData,
|
|
title: !Boolean(showcase.is_published) && toText(showcase.title) === "Escaparate en borrador" ? "" : toText(showcase.title),
|
|
description: cleanShowcaseDescription(storedDescription),
|
|
coverImageUrl: toText(showcase.cover_image_url),
|
|
selectedWorkIds: Array.isArray(showcase.selected_work_ids) ? showcase.selected_work_ids.map(String) : [],
|
|
featuredWorkId: toText(showcase.featured_work_id),
|
|
visibility: Boolean(showcase.is_published) ? "published" : "draft"
|
|
};
|
|
}
|
|
|
|
function editShowcase(showcase: Record<string, unknown>) {
|
|
setEditingShowcaseId(String(showcase.id));
|
|
setShowcaseForm(showcaseFormFromShowcase(showcase));
|
|
setShowcaseStep(0);
|
|
setShowcaseWizardOpen(true);
|
|
setStatus("Escaparate cargado para editar.");
|
|
}
|
|
|
|
async function unpublishShowcase(showcase: Record<string, unknown>) {
|
|
const restoredForm = { ...showcaseFormFromShowcase(showcase), visibility: "draft" };
|
|
setStatus("Pasando escaparate a borrador...");
|
|
try {
|
|
await browserFetch(`/account/maker/showcases/${String(showcase.id)}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({
|
|
title: restoredForm.title,
|
|
description: restoredForm.description,
|
|
coverImageUrl: restoredForm.coverImageUrl,
|
|
selectedWorkIds: restoredForm.selectedWorkIds,
|
|
featuredWorkId: restoredForm.featuredWorkId || restoredForm.selectedWorkIds[0] || null,
|
|
draftData: restoredForm,
|
|
isPublished: false
|
|
})
|
|
});
|
|
setStatus("Escaparate guardado como borrador.");
|
|
setShowcaseListFilter("drafts");
|
|
await loadAll();
|
|
} catch (error) {
|
|
setStatus(error instanceof Error ? error.message : "No se pudo despublicar el escaparate");
|
|
}
|
|
}
|
|
|
|
async function deleteShowcase(showcase: Record<string, unknown>) {
|
|
if (typeof window !== "undefined" && !window.confirm("Esto eliminara el escaparate. Quieres continuar?")) {
|
|
return;
|
|
}
|
|
setStatus("Eliminando escaparate...");
|
|
try {
|
|
await browserFetch(`/account/maker/showcases/${String(showcase.id)}`, { method: "DELETE" });
|
|
setStatus("Escaparate eliminado.");
|
|
await loadAll();
|
|
} catch (error) {
|
|
setStatus(error instanceof Error ? error.message : "No se pudo eliminar el escaparate");
|
|
}
|
|
}
|
|
|
|
async function saveWork(isPublished: boolean) {
|
|
setStatus(isPublished ? "Publicando trabajo..." : "Guardando borrador...");
|
|
try {
|
|
const galleryUrls = uniqueList([
|
|
workForm.imageUrl,
|
|
...workForm.galleryUrls.split("\n").map((value) => value.trim()).filter(Boolean),
|
|
...(workForm.includeBeforeAfter ? [workForm.beforeImageUrl, workForm.afterImageUrl] : [])
|
|
]);
|
|
if (!galleryUrls.length) {
|
|
setWorkStep(0);
|
|
const message = isPublished ? "Agrega al menos una foto para publicar." : "Agrega al menos una foto para guardar el borrador.";
|
|
setStatus("");
|
|
showToast(message);
|
|
return;
|
|
}
|
|
if (isPublished && (!workForm.title.trim() || !workForm.summary.trim() || !workForm.technology.trim() || !workForm.material.trim())) {
|
|
setWorkStep(1);
|
|
setStatus("Completa titulo, resumen, tecnologia y material antes de publicar.");
|
|
return;
|
|
}
|
|
const story = [
|
|
workForm.story,
|
|
workForm.problem || workForm.useCase ? `Necesidad: ${workForm.problem || "Sin especificar"}. Uso: ${workForm.useCase || "Sin especificar"}.` : "",
|
|
workForm.technology || workForm.material || workForm.machine || workForm.dimensions || workForm.fabricationTime || workForm.quantity
|
|
? `Detalle tecnico: ${[workForm.technology, workForm.material, workForm.machine, workForm.dimensions, workForm.fabricationTime, workForm.quantity].filter(Boolean).join(", ")}.`
|
|
: "",
|
|
workForm.priceMode ? (workForm.priceMode === "hidden" ? "Precio: no visible publicamente." : `Precio orientativo: $${workForm.priceFrom || "0"} a $${workForm.priceTo || workForm.priceFrom || "0"}.`) : ""
|
|
].filter(Boolean).join("\n\n");
|
|
|
|
const response = await browserFetch<{ work: Record<string, unknown> }>(editingWorkId ? `/account/maker/works/${editingWorkId}` : "/account/maker/works", {
|
|
method: editingWorkId ? "PATCH" : "POST",
|
|
body: JSON.stringify({
|
|
...workForm,
|
|
galleryUrls,
|
|
story: story || workForm.summary,
|
|
draftData: workForm,
|
|
isPublished,
|
|
serviceId: workForm.serviceId || account?.services?.[0]?.id || null
|
|
})
|
|
});
|
|
if (!isPublished) {
|
|
setEditingWorkId(String(response.work.id));
|
|
setStatus("Borrador guardado. Puedes seguir editandolo.");
|
|
await loadAll();
|
|
return;
|
|
}
|
|
setEditingWorkId(null);
|
|
setWorkForm(emptyWorkForm());
|
|
setWorkStep(publishSteps.length - 1);
|
|
setWorkWizardOpen(false);
|
|
setStatus(isPublished ? "Trabajo publicado." : "Borrador guardado.");
|
|
await loadAll();
|
|
} catch (error) {
|
|
setStatus(error instanceof Error ? error.message : "No se pudo guardar el trabajo");
|
|
}
|
|
}
|
|
|
|
async function activateDemoSubscription() {
|
|
setStatus("Activando plan demo...");
|
|
try {
|
|
await browserFetch("/account/maker/subscription/demo-activate", {
|
|
method: "POST"
|
|
});
|
|
setStatus("Plan demo activado.");
|
|
await loadAll();
|
|
} catch (error) {
|
|
setStatus(error instanceof Error ? error.message : "No se pudo activar el plan");
|
|
}
|
|
}
|
|
|
|
function updateSubscriptionBillingForm(field: keyof ReturnType<typeof emptySubscriptionBillingForm>, value: string | boolean) {
|
|
setSubscriptionBillingForm((current) => ({ ...current, [field]: value }));
|
|
}
|
|
|
|
function openSubscriptionBillingWizard(step = 0) {
|
|
setSubscriptionBillingForm((current) => ({
|
|
...current,
|
|
email: current.email || profileForm.publicContactEmail || user?.email || ""
|
|
}));
|
|
setSubscriptionBillingStep(step);
|
|
setSubscriptionBillingOpen(true);
|
|
setStatus("");
|
|
}
|
|
|
|
function closeSubscriptionBillingWizard() {
|
|
setSubscriptionBillingOpen(false);
|
|
}
|
|
|
|
function validateSubscriptionBillingStep(step: number) {
|
|
if (step === 0) {
|
|
if (!subscriptionBillingForm.name.trim() || !subscriptionBillingForm.taxId.trim() || !subscriptionBillingForm.email.includes("@")) {
|
|
showToast("Completa nombre, documento fiscal y un email valido.");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
if (step === 1) {
|
|
if (!/^\d{4}$/.test(subscriptionBillingForm.cardLast4.trim())) {
|
|
showToast("Ingresa los ultimos 4 digitos del metodo de pago.");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
if (!subscriptionBillingForm.acceptedValidation || subscriptionBillingForm.validationCode.trim() !== "1234") {
|
|
showToast("Acepta la validacion y usa el codigo demo 1234.");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function goToNextSubscriptionBillingStep() {
|
|
if (!validateSubscriptionBillingStep(subscriptionBillingStep)) {
|
|
return;
|
|
}
|
|
setSubscriptionBillingStep((current) => Math.min(current + 1, subscriptionBillingSteps.length - 1));
|
|
}
|
|
|
|
function saveSubscriptionBilling() {
|
|
if (![0, 1, 2].every((step) => validateSubscriptionBillingStep(step))) {
|
|
return;
|
|
}
|
|
setSubscriptionBillingOpen(false);
|
|
setStatus("Datos de facturacion y pago validados.");
|
|
}
|
|
|
|
function updatePersonalAccountField(field: keyof typeof personalAccountForm, value: string) {
|
|
setPersonalAccountForm((current) => ({ ...current, [field]: value }));
|
|
}
|
|
|
|
function toggleNotificationPref(key: keyof typeof notificationPrefs) {
|
|
setNotificationPrefs((current) => ({ ...current, [key]: !current[key] }));
|
|
}
|
|
|
|
function togglePrivacyPref(key: keyof typeof privacyPrefs) {
|
|
setPrivacyPrefs((current) => ({ ...current, [key]: !current[key] }));
|
|
}
|
|
|
|
function updateAppearancePref(field: keyof typeof appearancePrefs, value: string | boolean) {
|
|
setAppearancePrefs((current) => ({ ...current, [field]: value }));
|
|
}
|
|
|
|
function saveAccountSettings() {
|
|
setStatus("Configuracion de cuenta guardada.");
|
|
showToast("Configuracion guardada.");
|
|
}
|
|
|
|
function exportAccountData() {
|
|
setStatus("Exportacion demo preparada. En produccion se generara un archivo descargable.");
|
|
showToast("Exportacion demo preparada.");
|
|
}
|
|
|
|
function requestAccountDeletion() {
|
|
setStatus("Solicitud demo registrada. La eliminacion real requerira confirmacion adicional.");
|
|
showToast("Solicitud de eliminacion registrada como demo.");
|
|
}
|
|
|
|
function unblockUser(id: string) {
|
|
setBlockedUsers((current) => current.filter((blockedUser) => blockedUser.id !== id));
|
|
setStatus("Usuario desbloqueado.");
|
|
}
|
|
|
|
async function requestPublish() {
|
|
setStatus("Revisando requisitos...");
|
|
try {
|
|
await browserFetch("/account/maker/publish", { method: "POST" });
|
|
setStatus("Perfil publicado.");
|
|
await loadAll();
|
|
} catch (error) {
|
|
setStatus(error instanceof Error ? error.message : "No se pudo publicar");
|
|
}
|
|
}
|
|
|
|
async function openConversation(id: string) {
|
|
try {
|
|
const detail = await browserFetch<{ inquiry: Record<string, unknown>; messages: Array<Record<string, unknown>> }>(`/inquiries/${id}`);
|
|
setConversationDetail(detail);
|
|
} catch (error) {
|
|
setStatus(error instanceof Error ? error.message : "No se pudo abrir la conversacion");
|
|
}
|
|
}
|
|
|
|
async function sendReply(event: React.FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
if (!conversationDetail) {
|
|
return;
|
|
}
|
|
const form = new FormData(event.currentTarget);
|
|
const body = String(form.get("body") || "");
|
|
if (!body.trim()) {
|
|
return;
|
|
}
|
|
try {
|
|
await browserFetch(`/inquiries/${conversationDetail.inquiry.id as string}/messages`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ body })
|
|
});
|
|
await openConversation(conversationDetail.inquiry.id as string);
|
|
event.currentTarget.reset();
|
|
setStatus("Respuesta enviada.");
|
|
} catch (error) {
|
|
setStatus(error instanceof Error ? error.message : "No se pudo responder");
|
|
}
|
|
}
|
|
|
|
async function markCompleted() {
|
|
if (!conversationDetail) {
|
|
return;
|
|
}
|
|
try {
|
|
await browserFetch(`/inquiries/${conversationDetail.inquiry.id as string}/complete`, {
|
|
method: "POST"
|
|
});
|
|
setStatus("Trabajo marcado como finalizado.");
|
|
await Promise.all([loadAll(), openConversation(conversationDetail.inquiry.id as string)]);
|
|
} catch (error) {
|
|
setStatus(error instanceof Error ? error.message : "No se pudo marcar como finalizado");
|
|
}
|
|
}
|
|
|
|
async function rejectInquiry() {
|
|
if (!conversationDetail) {
|
|
return;
|
|
}
|
|
try {
|
|
await browserFetch(`/inquiries/${conversationDetail.inquiry.id as string}/reject`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ reason: "No puedo tomar este trabajo en este momento." })
|
|
});
|
|
setStatus("Consulta rechazada.");
|
|
await Promise.all([loadAll(), openConversation(conversationDetail.inquiry.id as string)]);
|
|
} catch (error) {
|
|
setStatus(error instanceof Error ? error.message : "No se pudo rechazar la consulta");
|
|
}
|
|
}
|
|
|
|
function logout() {
|
|
logoutByNavigation();
|
|
}
|
|
|
|
function updateWorkForm(field: keyof typeof workForm, value: string | boolean) {
|
|
setWorkForm((current) => ({ ...current, [field]: value }));
|
|
}
|
|
|
|
function startNewWork() {
|
|
setEditingWorkId(null);
|
|
setWorkForm(emptyWorkForm());
|
|
setWorkStep(0);
|
|
setWorkWizardOpen(true);
|
|
setStatus("");
|
|
}
|
|
|
|
function closeWorkWizard() {
|
|
setWorkWizardOpen(false);
|
|
}
|
|
|
|
function workFormFromWork(work: Record<string, unknown>) {
|
|
const isPublished = Boolean(work.is_published);
|
|
const imageUrl = toText(work.image_url);
|
|
const galleryUrls = Array.isArray(work.gallery_urls) ? work.gallery_urls.map((item) => String(item)) : [];
|
|
const isFallbackDraftImage = !isPublished && imageUrl === "/demo/work-custom.svg";
|
|
const draftData = work.draft_data && typeof work.draft_data === "object" && !Array.isArray(work.draft_data)
|
|
? work.draft_data as Partial<ReturnType<typeof emptyWorkForm>>
|
|
: {};
|
|
|
|
return {
|
|
...emptyWorkForm(),
|
|
...draftData,
|
|
title: !isPublished && toText(work.title) === "Borrador sin titulo" ? "" : toText(work.title),
|
|
summary: !isPublished && toText(work.summary) === "Borrador pendiente de completar." ? "" : toText(work.summary),
|
|
story: !isPublished && toText(work.story).startsWith("Borrador pendiente") ? "" : toText(work.story),
|
|
technology: !isPublished && toText(work.technology) === "Pendiente" ? "" : toText(work.technology),
|
|
material: !isPublished && toText(work.material) === "Pendiente" ? "" : toText(work.material),
|
|
imageUrl: isFallbackDraftImage ? "" : imageUrl,
|
|
galleryUrls: uniqueList(galleryUrls.filter((item) => item !== "/demo/work-custom.svg")).join("\n")
|
|
};
|
|
}
|
|
|
|
function resumeDraft(work: Record<string, unknown>) {
|
|
setEditingWorkId(String(work.id));
|
|
setWorkForm(workFormFromWork(work));
|
|
setWorkStep(0);
|
|
setWorkWizardOpen(true);
|
|
setStatus("Borrador cargado. Continua desde el paso que necesites.");
|
|
}
|
|
|
|
async function unpublishWork(work: Record<string, unknown>) {
|
|
setStatus("Despublicando trabajo...");
|
|
try {
|
|
const restoredForm = workFormFromWork(work);
|
|
await browserFetch(`/account/maker/works/${String(work.id)}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({
|
|
...restoredForm,
|
|
galleryUrls: uniqueList([restoredForm.imageUrl, ...restoredForm.galleryUrls.split("\n")]),
|
|
draftData: restoredForm,
|
|
isPublished: false,
|
|
serviceId: toText(work.service_id) || null
|
|
})
|
|
});
|
|
setStatus("Trabajo despublicado y guardado como borrador.");
|
|
setWorkListFilter("drafts");
|
|
await loadAll();
|
|
} catch (error) {
|
|
setStatus(error instanceof Error ? error.message : "No se pudo despublicar el trabajo");
|
|
}
|
|
}
|
|
|
|
async function deleteWork(work: Record<string, unknown>) {
|
|
if (typeof window !== "undefined" && !window.confirm("Esto eliminara el trabajo de forma permanente. Quieres continuar?")) {
|
|
return;
|
|
}
|
|
|
|
setStatus("Eliminando trabajo...");
|
|
try {
|
|
await browserFetch(`/account/maker/works/${String(work.id)}`, {
|
|
method: "DELETE"
|
|
});
|
|
if (editingWorkId === String(work.id)) {
|
|
setEditingWorkId(null);
|
|
setWorkForm(emptyWorkForm());
|
|
setWorkStep(0);
|
|
setWorkWizardOpen(false);
|
|
}
|
|
setStatus("Trabajo eliminado.");
|
|
await loadAll();
|
|
} catch (error) {
|
|
setStatus(error instanceof Error ? error.message : "No se pudo eliminar el trabajo");
|
|
}
|
|
}
|
|
|
|
function getWorkGalleryImages() {
|
|
return uniqueList([workForm.imageUrl, ...workForm.galleryUrls.split("\n")]);
|
|
}
|
|
|
|
function setCoverImage(image: string) {
|
|
setWorkForm((current) => ({
|
|
...current,
|
|
imageUrl: image,
|
|
galleryUrls: uniqueList([image, ...current.galleryUrls.split("\n")]).join("\n")
|
|
}));
|
|
}
|
|
|
|
function removeWorkImage(image: string) {
|
|
setWorkForm((current) => {
|
|
const galleryUrls = uniqueList(current.galleryUrls.split("\n").filter((item) => item !== image));
|
|
const nextCover = current.imageUrl === image ? (galleryUrls[0] || "") : current.imageUrl;
|
|
|
|
return {
|
|
...current,
|
|
imageUrl: nextCover,
|
|
galleryUrls: galleryUrls.join("\n"),
|
|
beforeImageUrl: current.beforeImageUrl === image ? "" : current.beforeImageUrl,
|
|
afterImageUrl: current.afterImageUrl === image ? "" : current.afterImageUrl
|
|
};
|
|
});
|
|
}
|
|
|
|
async function compressImage(file: File) {
|
|
if (!file.type.startsWith("image/")) {
|
|
return file;
|
|
}
|
|
|
|
try {
|
|
const bitmap = await createImageBitmap(file);
|
|
const maxSide = 1400;
|
|
const scale = Math.min(1, maxSide / Math.max(bitmap.width, bitmap.height));
|
|
const width = Math.max(1, Math.round(bitmap.width * scale));
|
|
const height = Math.max(1, Math.round(bitmap.height * scale));
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const context = canvas.getContext("2d");
|
|
if (!context) {
|
|
return file;
|
|
}
|
|
context.drawImage(bitmap, 0, 0, width, height);
|
|
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.82));
|
|
bitmap.close();
|
|
if (!blob) {
|
|
return file;
|
|
}
|
|
|
|
return new File([blob], file.name.replace(/\.[^.]+$/, ".jpg"), { type: "image/jpeg" });
|
|
} catch {
|
|
return file;
|
|
}
|
|
}
|
|
|
|
async function uploadWorkImage(file: File) {
|
|
const compactFile = await compressImage(file);
|
|
const formData = new FormData();
|
|
formData.append("file", compactFile);
|
|
const response = await browserFetch<{ url: string }>("/uploads/public-image", {
|
|
method: "POST",
|
|
body: formData
|
|
});
|
|
|
|
return response.url;
|
|
}
|
|
|
|
async function addWorkFiles(files: FileList | null) {
|
|
if (!files?.length) {
|
|
return;
|
|
}
|
|
|
|
const selectedFiles = Array.from(files).filter((file) => file.type.startsWith("image/")).slice(0, 8);
|
|
if (!selectedFiles.length) {
|
|
setStatus("Selecciona imagenes validas.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setStatus("Subiendo imagenes...");
|
|
const images = await Promise.all(selectedFiles.map((file) => uploadWorkImage(file)));
|
|
|
|
setWorkForm((current) => {
|
|
const galleryUrls = uniqueList([...images, ...current.galleryUrls.split("\n")]);
|
|
return {
|
|
...current,
|
|
imageUrl: current.imageUrl || images[0],
|
|
galleryUrls: galleryUrls.join("\n")
|
|
};
|
|
});
|
|
setStatus(`${images.length} imagen(es) agregada(s).`);
|
|
} catch (error) {
|
|
setStatus(error instanceof Error ? error.message : "No se pudieron subir las imagenes.");
|
|
}
|
|
}
|
|
|
|
async function updateProfileLogo(files: FileList | null) {
|
|
const file = Array.from(files || []).find((item) => item.type.startsWith("image/"));
|
|
if (!file) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setStatus("Subiendo logo...");
|
|
const imageUrl = await uploadWorkImage(file);
|
|
setProfileForm((current) => ({ ...current, mainImageUrl: imageUrl }));
|
|
setStatus("Logo cargado. Guarda el perfil para publicarlo.");
|
|
} catch (error) {
|
|
setStatus(error instanceof Error ? error.message : "No se pudo subir el logo.");
|
|
}
|
|
}
|
|
|
|
function goToNextWorkStep() {
|
|
if (workStep === 0 && !getWorkGalleryImages().length) {
|
|
setStatus("Agrega al menos una foto para continuar.");
|
|
return;
|
|
}
|
|
|
|
setWorkStep((current) => Math.min(current + 1, publishSteps.length - 1));
|
|
}
|
|
|
|
function goToWorkStep(step: number) {
|
|
if (step > 0 && !getWorkGalleryImages().length) {
|
|
setStatus("Agrega al menos una foto para continuar.");
|
|
setWorkStep(0);
|
|
return;
|
|
}
|
|
|
|
setWorkStep(step);
|
|
}
|
|
|
|
function renderChoiceGrid(field: keyof typeof workForm, options: string[]) {
|
|
return (
|
|
<div className="publish-choice-grid">
|
|
{options.map((option) => (
|
|
<button
|
|
key={option}
|
|
className={workForm[field] === option ? "is-selected" : ""}
|
|
type="button"
|
|
onClick={() => updateWorkForm(field, option)}
|
|
>
|
|
<span>{option}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function goToNextServiceStep() {
|
|
if (serviceStep === 0 && !serviceForm.title.trim()) {
|
|
showToast("Elige un tipo de servicio para continuar.");
|
|
return;
|
|
}
|
|
|
|
setServiceStep((current) => Math.min(current + 1, serviceSteps.length - 1));
|
|
}
|
|
|
|
function renderServiceMultiChoice(field: "technologies" | "materials" | "colors" | "finishes" | "uses" | "conditions" | "relatedWorks", options: string[]) {
|
|
const selected = serviceForm[field];
|
|
return (
|
|
<div className="service-chip-grid">
|
|
{options.map((option) => (
|
|
<button
|
|
key={option}
|
|
className={selected.includes(option) ? "is-selected" : ""}
|
|
type="button"
|
|
onClick={() => toggleServiceListField(field, option)}
|
|
>
|
|
{option}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderServiceStep() {
|
|
if (serviceStep === 0) {
|
|
return (
|
|
<section className="publish-step-card service-step-card">
|
|
<span className="eyebrow">Nuevo servicio</span>
|
|
<h3>Que servicio quieres ofrecer?</h3>
|
|
<div className="service-template-list">
|
|
{serviceTemplates.map((template) => (
|
|
<button key={template.title} type="button" onClick={() => applyServiceTemplate(template)}>
|
|
<span>{template.icon}</span>
|
|
<div>
|
|
<strong>{template.title}</strong>
|
|
<small>{template.description}</small>
|
|
</div>
|
|
<b>></b>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (serviceStep === 1) {
|
|
return (
|
|
<section className="publish-step-card service-step-card">
|
|
<span className="eyebrow">Datos basicos</span>
|
|
<h3>Configuracion del servicio</h3>
|
|
<input className="field" value={serviceForm.title} onChange={(event) => updateServiceForm("title", event.target.value)} placeholder="Nombre del servicio" />
|
|
<input className="field" value={serviceForm.category} onChange={(event) => updateServiceForm("category", event.target.value)} placeholder="Categoria" />
|
|
<textarea className="textarea" value={serviceForm.description} onChange={(event) => updateServiceForm("description", event.target.value)} placeholder="Descripcion corta para clientes" />
|
|
<div>
|
|
<label className="publish-field-label">Tecnologias</label>
|
|
{renderServiceMultiChoice("technologies", ["FDM", "Resina", "SLA / DLP", "SLS", "CAD", "Escaneo 3D"])}
|
|
</div>
|
|
<div>
|
|
<label className="publish-field-label">Materiales</label>
|
|
{renderServiceMultiChoice("materials", ["PLA", "PETG", "ABS", "ASA", "TPU", "Nylon", "Resina standard", "Resina 8K"])}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (serviceStep === 2) {
|
|
return (
|
|
<section className="publish-step-card service-step-card">
|
|
<span className="eyebrow">Capacidades</span>
|
|
<h3>Define lo que puedes hacer</h3>
|
|
<div className="workspace-form-grid">
|
|
<input className="field" value={serviceForm.maxX} onChange={(event) => updateServiceForm("maxX", event.target.value)} placeholder="X maximo en mm" />
|
|
<input className="field" value={serviceForm.maxY} onChange={(event) => updateServiceForm("maxY", event.target.value)} placeholder="Y maximo en mm" />
|
|
<input className="field" value={serviceForm.maxZ} onChange={(event) => updateServiceForm("maxZ", event.target.value)} placeholder="Z maximo en mm" />
|
|
<input className="field" value={serviceForm.resolution} onChange={(event) => updateServiceForm("resolution", event.target.value)} placeholder="Resolucion / altura de capa" />
|
|
</div>
|
|
<div>
|
|
<label className="publish-field-label">Colores disponibles</label>
|
|
{renderServiceMultiChoice("colors", ["Negro", "Blanco", "Gris", "Rojo", "Azul", "Verde", "Amarillo"])}
|
|
</div>
|
|
<div>
|
|
<label className="publish-field-label">Acabados</label>
|
|
{renderServiceMultiChoice("finishes", ["Estandar", "Lijado", "Pintado", "Ensamblado"])}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (serviceStep === 3) {
|
|
return (
|
|
<section className="publish-step-card service-step-card">
|
|
<span className="eyebrow">Usos y condiciones</span>
|
|
<h3>Para que sirve y limites</h3>
|
|
<div>
|
|
<label className="publish-field-label">Ideal para</label>
|
|
{renderServiceMultiChoice("uses", serviceUseOptions)}
|
|
</div>
|
|
<div>
|
|
<label className="publish-field-label">Uso final apto</label>
|
|
{renderServiceMultiChoice("conditions", serviceConditionOptions)}
|
|
</div>
|
|
<label className="publish-field-label">
|
|
Urgencias
|
|
<select className="select" value={serviceForm.urgency} onChange={(event) => updateServiceForm("urgency", event.target.value)}>
|
|
<option value="yes">Si, con costo adicional</option>
|
|
<option value="no">No ofrezco urgencias</option>
|
|
<option value="case">Depende del trabajo</option>
|
|
</select>
|
|
</label>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (serviceStep === 4) {
|
|
return (
|
|
<section className="publish-step-card service-step-card">
|
|
<span className="eyebrow">Precio orientativo</span>
|
|
<h3>Como mostrar precios?</h3>
|
|
<div className="publish-choice-grid">
|
|
{[
|
|
["hidden", "No mostrar precio"],
|
|
["from", "Desde"],
|
|
["range", "Rango por tamano"],
|
|
["custom", "Personalizado"]
|
|
].map(([value, label]) => (
|
|
<button key={value} className={serviceForm.priceMode === value ? "is-selected" : ""} type="button" onClick={() => updateServiceForm("priceMode", value)}>
|
|
<span>{label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="workspace-form-grid">
|
|
<input className="field" value={serviceForm.priceFrom} onChange={(event) => updateServiceForm("priceFrom", event.target.value)} placeholder="Desde $" />
|
|
<input className="field" value={serviceForm.smallPrice} onChange={(event) => updateServiceForm("smallPrice", event.target.value)} placeholder="Piezas pequenas $" />
|
|
<input className="field" value={serviceForm.mediumPrice} onChange={(event) => updateServiceForm("mediumPrice", event.target.value)} placeholder="Piezas medianas $" />
|
|
<input className="field" value={serviceForm.largePrice} onChange={(event) => updateServiceForm("largePrice", event.target.value)} placeholder="Piezas grandes $" />
|
|
</div>
|
|
<p className="muted">Los precios son orientativos. El valor final depende de material, cantidad, urgencia y acabado.</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (serviceStep === 5) {
|
|
return (
|
|
<section className="publish-step-card service-step-card">
|
|
<span className="eyebrow">Disponibilidad y entrega</span>
|
|
<h3>Como trabajas y entregas</h3>
|
|
<input className="field" value={serviceForm.leadTimeDays} onChange={(event) => updateServiceForm("leadTimeDays", event.target.value)} placeholder="Tiempo habitual en dias" />
|
|
<label className="publish-toggle">
|
|
<input type="checkbox" checked={serviceForm.localPickup} onChange={(event) => updateServiceForm("localPickup", event.target.checked)} />
|
|
<span>Retiro / entrega local</span>
|
|
</label>
|
|
<label className="publish-toggle">
|
|
<input type="checkbox" checked={serviceForm.nationwideShipping} onChange={(event) => updateServiceForm("nationwideShipping", event.target.checked)} />
|
|
<span>Envios a todo el pais</span>
|
|
</label>
|
|
<input className="field" value={serviceForm.coverageArea} onChange={(event) => updateServiceForm("coverageArea", event.target.value)} placeholder="Area de cobertura" />
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (serviceStep === 6) {
|
|
return (
|
|
<section className="publish-step-card service-step-card">
|
|
<span className="eyebrow">Visibilidad</span>
|
|
<h3>Controla que pueden ver</h3>
|
|
<div className="publish-choice-grid">
|
|
{[
|
|
["published", "Publico"],
|
|
["paused", "Pausado temporalmente"],
|
|
["draft", "Borrador privado"]
|
|
].map(([value, label]) => (
|
|
<button key={value} className={serviceForm.visibility === value ? "is-selected" : ""} type="button" onClick={() => updateServiceForm("visibility", value)}>
|
|
<span>{label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<textarea className="textarea" value={serviceForm.pauseMessage} onChange={(event) => updateServiceForm("pauseMessage", event.target.value)} placeholder="Mensaje opcional si esta pausado" />
|
|
<label className="publish-toggle">
|
|
<input type="checkbox" checked={serviceForm.visibility !== "draft"} onChange={(event) => updateServiceForm("visibility", event.target.checked ? "published" : "draft")} />
|
|
<span>Mostrar en mi perfil cuando este publicado</span>
|
|
</label>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section className="publish-step-card service-step-card">
|
|
<span className="eyebrow">Vista publica</span>
|
|
<h3>Asi lo veran los clientes</h3>
|
|
<article className="service-public-preview">
|
|
<div>
|
|
<span className="status-pill success">{serviceForm.category || "Servicio"}</span>
|
|
<h3>{serviceForm.title || "Nombre del servicio"}</h3>
|
|
<p>{serviceForm.description || "Descripcion breve para que el cliente entienda que puedes resolver."}</p>
|
|
<div className="publish-mini-tags">
|
|
{serviceForm.materials.slice(0, 4).map((item) => <span key={item}>{item}</span>)}
|
|
{serviceForm.technologies.slice(0, 2).map((item) => <span key={item}>{item}</span>)}
|
|
</div>
|
|
</div>
|
|
<div className="service-preview-stats">
|
|
<span>Alta calidad</span>
|
|
<span>{serviceForm.leadTimeDays || 4} dias</span>
|
|
<span>{serviceForm.nationwideShipping ? "Envios" : "Local"}</span>
|
|
</div>
|
|
<strong>{serviceForm.priceMode === "hidden" ? "Precio a consultar" : `Desde $${serviceForm.priceFrom || serviceForm.smallPrice || "0"}`}</strong>
|
|
</article>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function renderLocationStep() {
|
|
const publicModeLabel = privacyOptions.find(([value]) => value === locationForm.privacy)?.[1] || "Zona aproximada";
|
|
const shippingLabel = shippingOptions.find(([value]) => value === locationForm.shippingArea)?.[1] || "A toda Argentina";
|
|
const travelLabel = travelOptions.find(([value]) => value === locationForm.travelRadius)?.[1] || "Hasta 25 km";
|
|
const attentionItems = [
|
|
locationForm.localPickup ? "Recogida por el cliente" : "",
|
|
locationForm.personalDelivery ? "Entrega personal" : "",
|
|
locationForm.postalShipping ? "Envios por correo" : "",
|
|
locationForm.mobileService ? "Maker movil" : "",
|
|
locationForm.onsiteService ? "Servicio a domicilio" : ""
|
|
].filter(Boolean);
|
|
|
|
if (locationStep === 0) {
|
|
return (
|
|
<section className="publish-step-card location-step-card">
|
|
<span className="eyebrow">Tipo de ubicacion</span>
|
|
<h3>Como trabajas desde esta ubicacion?</h3>
|
|
<div className="location-choice-list">
|
|
{locationTypeOptions.map(([value, label, description]) => (
|
|
<button key={value} className={locationForm.type === value ? "is-selected" : ""} type="button" onClick={() => updateLocationForm("type", value)}>
|
|
<span>{label}</span>
|
|
<small>{description}</small>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (locationStep === 1) {
|
|
return (
|
|
<section className="publish-step-card location-step-card">
|
|
<span className="eyebrow">Direccion real</span>
|
|
<h3>Completa la referencia operativa</h3>
|
|
<input className="field" value={locationForm.label} onChange={(event) => updateLocationForm("label", event.target.value)} placeholder="Nombre: Taller Cordoba Centro" />
|
|
<input className="field" value={locationForm.address} onChange={(event) => updateLocationForm("address", event.target.value)} placeholder="Direccion real, solo para calcular distancias" />
|
|
<div className="workspace-form-grid">
|
|
<input className="field" value={locationForm.city} onChange={(event) => updateLocationForm("city", event.target.value)} placeholder="Localidad" />
|
|
<input className="field" value={locationForm.province} onChange={(event) => updateLocationForm("province", event.target.value)} placeholder="Provincia" />
|
|
<input className="field" value={locationForm.postalCode} onChange={(event) => updateLocationForm("postalCode", event.target.value)} placeholder="Codigo postal" />
|
|
</div>
|
|
<div className="workspace-form-grid">
|
|
<input className="field" value={locationForm.latitude} onChange={(event) => updateLocationForm("latitude", event.target.value)} placeholder="Latitud" />
|
|
<input className="field" value={locationForm.longitude} onChange={(event) => updateLocationForm("longitude", event.target.value)} placeholder="Longitud" />
|
|
</div>
|
|
<div className="location-map-card">
|
|
<span className="location-map-pin" />
|
|
<strong>{locationForm.city || "Cordoba"}</strong>
|
|
<small>Vista aproximada para validar la zona antes de guardar.</small>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (locationStep === 2) {
|
|
return (
|
|
<section className="publish-step-card location-step-card">
|
|
<span className="eyebrow">Privacidad en el mapa</span>
|
|
<h3>Que vera publicamente el cliente?</h3>
|
|
<div className="location-choice-list">
|
|
{privacyOptions.map(([value, label, description]) => (
|
|
<button key={value} className={locationForm.privacy === value ? "is-selected" : ""} type="button" onClick={() => updateLocationForm("privacy", value)}>
|
|
<span>{label}</span>
|
|
<small>{description}</small>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className={`location-privacy-preview is-${locationForm.privacy}`}>
|
|
<span />
|
|
<strong>{publicModeLabel}</strong>
|
|
<small>{locationForm.privacy === "exact" ? "Se muestra el punto marcado." : locationForm.privacy === "city" ? "Se muestra solo localidad y cobertura." : "Se muestra un radio aproximado, no tu direccion exacta."}</small>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (locationStep === 3) {
|
|
return (
|
|
<section className="publish-step-card location-step-card">
|
|
<span className="eyebrow">Atencion y entrega</span>
|
|
<h3>Como reciben o entregas trabajos?</h3>
|
|
<div className="location-toggle-list">
|
|
{[
|
|
["localPickup", "Recogida por el cliente", "Disponible en esta ubicacion."],
|
|
["personalDelivery", "Entrega personal", "Hasta la zona configurada."],
|
|
["postalShipping", "Envios por correo", "Acordado con el cliente."],
|
|
["mobileService", "Maker movil", "Te desplazas para ciertos trabajos."],
|
|
["onsiteService", "Servicio a domicilio", "Escaneo o medicion en sitio."]
|
|
].map(([field, label, description]) => (
|
|
<button key={field} className={locationForm[field as keyof typeof locationForm] ? "is-selected" : ""} type="button" onClick={() => updateLocationForm(field as keyof typeof locationForm, !locationForm[field as keyof typeof locationForm])}>
|
|
<span>{label}</span>
|
|
<small>{description}</small>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<textarea className="textarea" value={locationForm.notes} onChange={(event) => updateLocationForm("notes", event.target.value)} placeholder="Notas adicionales: atencion con cita previa, horarios especiales, punto de referencia..." />
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (locationStep === 4) {
|
|
return (
|
|
<section className="publish-step-card location-step-card">
|
|
<span className="eyebrow">Cobertura</span>
|
|
<h3>Desplazamiento y envios</h3>
|
|
<label className="publish-field-label">Hasta donde te desplazas?</label>
|
|
<div className="service-chip-grid">
|
|
{travelOptions.map(([value, label]) => (
|
|
<button key={value} className={locationForm.travelRadius === value ? "is-selected" : ""} type="button" onClick={() => updateLocationForm("travelRadius", value)}>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<label className="publish-field-label">Donde realizas envios?</label>
|
|
<div className="service-chip-grid">
|
|
{shippingOptions.map(([value, label]) => (
|
|
<button key={value} className={locationForm.shippingArea === value ? "is-selected" : ""} type="button" onClick={() => updateLocationForm("shippingArea", value)}>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="publish-tip">Separamos busqueda cerca de mi y busqueda con envio para que el ranking sea mas justo.</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (locationStep === 5) {
|
|
return (
|
|
<section className="publish-step-card location-step-card">
|
|
<span className="eyebrow">Horarios</span>
|
|
<h3>Define disponibilidad de atencion</h3>
|
|
<div className="location-choice-list">
|
|
{[
|
|
["hidden", "Sin horario publico", "Solo se muestra disponibilidad general."],
|
|
["appointment", "Solo con cita previa", "Recomendado para talleres chicos."],
|
|
["defined", "Horarios definidos", "Muestra dias y franjas de atencion."]
|
|
].map(([value, label, description]) => (
|
|
<button key={value} className={locationForm.hoursMode === value ? "is-selected" : ""} type="button" onClick={() => updateLocationForm("hoursMode", value)}>
|
|
<span>{label}</span>
|
|
<small>{description}</small>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="location-hours-card">
|
|
<span>Lunes a viernes</span><strong>{locationForm.hoursMode === "defined" ? "09:00 - 18:00" : locationForm.hoursMode === "appointment" ? "Con cita previa" : "No publico"}</strong>
|
|
<span>Sabados</span><strong>{locationForm.hoursMode === "defined" ? "10:00 - 13:00" : "Consultar"}</strong>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (locationStep === 6) {
|
|
return (
|
|
<section className="publish-step-card location-step-card">
|
|
<span className="eyebrow">Vista previa</span>
|
|
<h3>Asi aparecera en mapa y perfil</h3>
|
|
<article className="location-public-preview">
|
|
<div className="location-mini-map">
|
|
<span className="location-coverage-ring" />
|
|
<span className="location-map-pin" />
|
|
</div>
|
|
<div>
|
|
<strong>{locationForm.label || "Taller principal"}</strong>
|
|
<span>{locationForm.city}, {locationForm.province}</span>
|
|
<small>{publicModeLabel} | {shippingLabel}</small>
|
|
</div>
|
|
</article>
|
|
<div className="location-preview-grid">
|
|
<span>Retiro: <strong>{locationForm.localPickup ? "Disponible" : "No disponible"}</strong></span>
|
|
<span>Desplazamiento: <strong>{travelLabel}</strong></span>
|
|
<span>Envios: <strong>{shippingLabel}</strong></span>
|
|
<span>Horario: <strong>{locationForm.hoursMode === "appointment" ? "Con cita" : locationForm.hoursMode === "defined" ? "Definido" : "Oculto"}</strong></span>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section className="publish-step-card location-step-card">
|
|
<span className="eyebrow">Ubicacion lista</span>
|
|
<h3>Clientes podran encontrarte mejor</h3>
|
|
<div className="publish-checklist">
|
|
{["Tipo definido", "Privacidad configurada", "Atencion y entrega claras", "Cobertura separada", "Vista publica revisada"].map((item) => (
|
|
<span key={item}>ok {item}</span>
|
|
))}
|
|
</div>
|
|
<div className="location-alert-card">
|
|
<strong>Alertas inteligentes</strong>
|
|
<span>Si no hay makers compatibles en una zona, el cliente puede activar una alerta y volver cuando aparezca oferta.</span>
|
|
<div className="workspace-form-grid">
|
|
<input className="field" value={locationForm.alertService} onChange={(event) => updateLocationForm("alertService", event.target.value)} placeholder="Servicio de alerta" />
|
|
<input className="field" value={locationForm.alertRadius} onChange={(event) => updateLocationForm("alertRadius", event.target.value)} placeholder="Radio km" />
|
|
<input className="field" value={locationForm.minimumRating} onChange={(event) => updateLocationForm("minimumRating", event.target.value)} placeholder="Valoracion minima" />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function goToNextShowcaseStep() {
|
|
if (showcaseStep === 0 && !showcaseForm.title.trim()) {
|
|
showToast("Agrega un nombre para el escaparate.");
|
|
return;
|
|
}
|
|
if (showcaseStep >= 1 && !showcaseForm.selectedWorkIds.length) {
|
|
showToast("Selecciona al menos un trabajo.");
|
|
setShowcaseStep(1);
|
|
return;
|
|
}
|
|
setShowcaseStep((current) => Math.min(current + 1, showcaseSteps.length - 1));
|
|
}
|
|
|
|
function renderShowcaseStep() {
|
|
const publishedWorks = (account?.works || []).filter((work) => Boolean(work.is_published));
|
|
const selectedWorks = getShowcaseSelectedWorks();
|
|
const coverImage = getShowcaseCoverImage();
|
|
|
|
if (showcaseStep === 0) {
|
|
return (
|
|
<section className="publish-step-card showcase-step-card">
|
|
<span className="eyebrow">Nuevo escaparate</span>
|
|
<h3>Comienza tu coleccion</h3>
|
|
<input className="field" value={showcaseForm.title} onChange={(event) => updateShowcaseForm("title", event.target.value)} placeholder="Nombre del escaparate" />
|
|
<textarea className="textarea" value={showcaseForm.description} onChange={(event) => updateShowcaseForm("description", event.target.value)} placeholder="Descripcion breve: que agrupa y por que es util para clientes." />
|
|
<div>
|
|
<label className="publish-field-label">Que tipo de trabajos incluira?</label>
|
|
<div className="service-chip-grid">
|
|
{showcaseTypeOptions.map((option) => (
|
|
<button key={option} className={showcaseForm.types.includes(option) ? "is-selected" : ""} type="button" onClick={() => toggleShowcaseType(option)}>
|
|
{option}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (showcaseStep === 1) {
|
|
return (
|
|
<section className="publish-step-card showcase-step-card">
|
|
<span className="eyebrow">Seleccion de trabajos</span>
|
|
<h3>Anade trabajos a tu coleccion</h3>
|
|
<div className="showcase-work-picker-actions">
|
|
<span>{showcaseForm.selectedWorkIds.length} seleccionados</span>
|
|
<button type="button" onClick={() => updateShowcaseForm("selectedWorkIds", publishedWorks.map((work) => String(work.id)))}>Seleccionar todos</button>
|
|
</div>
|
|
<div className="showcase-work-picker">
|
|
{publishedWorks.map((work) => (
|
|
<button key={String(work.id)} className={showcaseForm.selectedWorkIds.includes(String(work.id)) ? "is-selected" : ""} type="button" onClick={() => toggleShowcaseWork(String(work.id))}>
|
|
<img src={toText(work.image_url, "/demo/work-custom.svg")} alt={toText(work.title)} />
|
|
<div>
|
|
<strong>{toText(work.title)}</strong>
|
|
<span>{toText(work.technology, "FDM")} | {toText(work.material, "PLA")}</span>
|
|
</div>
|
|
<b>{showcaseForm.selectedWorkIds.includes(String(work.id)) ? "OK" : "+"}</b>
|
|
</button>
|
|
))}
|
|
</div>
|
|
{publishedWorks.length ? null : <div className="empty-state">Publica al menos un trabajo para poder crear escaparates.</div>}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (showcaseStep === 2) {
|
|
return (
|
|
<section className="publish-step-card showcase-step-card">
|
|
<span className="eyebrow">Portada</span>
|
|
<h3>Elige como presentarlo</h3>
|
|
<div className="publish-choice-grid showcase-cover-mode-grid">
|
|
{[
|
|
["auto", "Automatico"],
|
|
["existing", "Elegir una foto"]
|
|
].map(([value, label]) => (
|
|
<button key={value} className={showcaseForm.coverMode === value ? "is-selected" : ""} type="button" onClick={() => updateShowcaseForm("coverMode", value)}>
|
|
<span>{label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
{showcaseForm.coverMode === "existing" ? (
|
|
<div className="showcase-cover-picker">
|
|
{selectedWorks.map((work) => (
|
|
<button
|
|
key={String(work.id)}
|
|
className={showcaseForm.featuredWorkId === String(work.id) ? "is-selected" : ""}
|
|
type="button"
|
|
onClick={() => chooseShowcaseCover(work)}
|
|
>
|
|
<img src={toText(work.image_url, "/demo/work-custom.svg")} alt={toText(work.title)} />
|
|
<span>{toText(work.title, "Trabajo")}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
<article className="showcase-cover-preview">
|
|
<img src={coverImage} alt="Portada del escaparate" />
|
|
<div>
|
|
<strong>{showcaseForm.title || "Tu escaparate"}</strong>
|
|
<span>{selectedWorks.length} trabajos</span>
|
|
</div>
|
|
</article>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (showcaseStep === 3) {
|
|
return (
|
|
<section className="publish-step-card showcase-step-card">
|
|
<span className="eyebrow">Orden y destacado</span>
|
|
<h3>Organiza la coleccion</h3>
|
|
<div className="showcase-order-list">
|
|
{selectedWorks.map((work, index) => (
|
|
<article key={String(work.id)} className={showcaseForm.featuredWorkId === String(work.id) ? "is-featured" : ""}>
|
|
<span>{index + 1}</span>
|
|
<img src={toText(work.image_url, "/demo/work-custom.svg")} alt={toText(work.title)} />
|
|
<div>
|
|
<strong>{toText(work.title)}</strong>
|
|
<small>{toText(work.technology, "FDM")} | {toText(work.material, "PLA")}</small>
|
|
</div>
|
|
<div className="showcase-order-actions">
|
|
<button className="showcase-feature-button" type="button" onClick={() => updateShowcaseForm("featuredWorkId", String(work.id))} aria-label="Destacar trabajo">
|
|
{showcaseForm.featuredWorkId === String(work.id) ? "Destacado" : "Destacar"}
|
|
</button>
|
|
<button type="button" onClick={() => moveShowcaseWork(String(work.id), -1)} aria-label="Subir trabajo">↑</button>
|
|
<button type="button" onClick={() => moveShowcaseWork(String(work.id), 1)} aria-label="Bajar trabajo">↓</button>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (showcaseStep === 4) {
|
|
return (
|
|
<section className="publish-step-card showcase-step-card">
|
|
<span className="eyebrow">Vista previa</span>
|
|
<h3>Asi vera el escaparate</h3>
|
|
<article className="showcase-public-preview">
|
|
<div className="showcase-public-cover" style={{ backgroundImage: `url(${coverImage})` }}>
|
|
<strong>{showcaseForm.title || "Escaparate"}</strong>
|
|
<span>{selectedWorks.length} trabajos</span>
|
|
</div>
|
|
<div className="showcase-preview-grid">
|
|
{selectedWorks.slice(0, 6).map((work) => (
|
|
<img key={String(work.id)} src={toText(work.image_url, "/demo/work-custom.svg")} alt={toText(work.title)} />
|
|
))}
|
|
</div>
|
|
</article>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section className="publish-step-card showcase-step-card">
|
|
<span className="eyebrow">Publicar</span>
|
|
<h3>Escaparate listo</h3>
|
|
<div className="publish-checklist">
|
|
{["Nombre y descripcion", "Portada definida", "Trabajos seleccionados", "Orden revisado", "Consulta guiada conectada"].map((item) => (
|
|
<span key={item}>ok {item}</span>
|
|
))}
|
|
</div>
|
|
<p className="muted">Si quieres dejarlo para despues usa Guardar borrador. Para hacerlo visible, toca Publicar escaparate.</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function renderPublishStep() {
|
|
if (workStep === 0) {
|
|
const galleryImages = getWorkGalleryImages();
|
|
|
|
return (
|
|
<section className="publish-step-card">
|
|
<span className="eyebrow">Fotos y portada</span>
|
|
<h3>Anade fotos</h3>
|
|
<p className="muted">Sube imagenes del trabajo y toca una foto para marcarla como portada.</p>
|
|
<label className="publish-upload-box">
|
|
<input type="file" accept="image/*" multiple onChange={(event) => void addWorkFiles(event.target.files)} />
|
|
<strong>Subir fotos</strong>
|
|
<span>JPG, PNG o WEBP. Puedes seleccionar varias.</span>
|
|
</label>
|
|
{galleryImages.length ? (
|
|
<div className="publish-photo-grid">
|
|
{galleryImages.map((image, index) => (
|
|
<article key={image} className={workForm.imageUrl === image ? "is-selected" : ""}>
|
|
<button type="button" onClick={() => setCoverImage(image)} aria-label={`Usar foto ${index + 1} como portada`}>
|
|
<img src={image} alt={`Foto del trabajo ${index + 1}`} />
|
|
<span>{workForm.imageUrl === image ? "Portada" : `Foto ${index + 1}`}</span>
|
|
</button>
|
|
<button className="publish-remove-photo" type="button" onClick={() => removeWorkImage(image)}>
|
|
Quitar
|
|
</button>
|
|
</article>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="publish-empty-photos">
|
|
<strong>No hay fotos cargadas todavia</strong>
|
|
<span>Sube al menos una imagen para poder publicar el trabajo.</span>
|
|
</div>
|
|
)}
|
|
<p className="muted">{galleryImages.length} imagenes cargadas. La portada sera la primera imagen del trabajo publico.</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (workStep === 1) {
|
|
return (
|
|
<section className="publish-step-card">
|
|
<span className="eyebrow">Titulo y descripcion</span>
|
|
<h3>Que hiciste?</h3>
|
|
<input className="field" value={workForm.title} onChange={(event) => updateWorkForm("title", event.target.value)} placeholder="Titulo del trabajo" />
|
|
<textarea className="textarea" value={workForm.summary} onChange={(event) => updateWorkForm("summary", event.target.value)} placeholder="Resumen corto para clientes" />
|
|
<div className="publish-ai-note">
|
|
<strong>Ayuda para escribir</strong>
|
|
<span>Describe en simple: problema, pieza, material y resultado. Luego podras editarlo.</span>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (workStep === 2) {
|
|
return (
|
|
<section className="publish-step-card">
|
|
<span className="eyebrow">Necesidad y uso</span>
|
|
<h3>Problema y uso del trabajo</h3>
|
|
<div>
|
|
<label className="publish-field-label">Que necesitaba el cliente?</label>
|
|
{renderChoiceGrid("problem", problemOptions)}
|
|
</div>
|
|
<div>
|
|
<label className="publish-field-label">Para que sirve este trabajo?</label>
|
|
{renderChoiceGrid("useCase", useOptions)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (workStep === 3) {
|
|
return (
|
|
<section className="publish-step-card">
|
|
<span className="eyebrow">Informacion tecnica</span>
|
|
<h3>Detalles tecnicos</h3>
|
|
<div className="workspace-form-grid">
|
|
<select className="select" value={workForm.technology} onChange={(event) => updateWorkForm("technology", event.target.value)}>
|
|
<option value="">Selecciona tecnologia</option>
|
|
<option value="FDM">Impresion 3D FDM</option>
|
|
<option value="Resina">Impresion 3D Resina</option>
|
|
<option value="CAD">Diseno 3D / CAD</option>
|
|
<option value="SLS">SLS</option>
|
|
</select>
|
|
<select className="select" value={workForm.material} onChange={(event) => updateWorkForm("material", event.target.value)}>
|
|
<option value="">Selecciona material</option>
|
|
<option value="PETG">PETG</option>
|
|
<option value="PLA">PLA</option>
|
|
<option value="ABS">ABS</option>
|
|
<option value="Resina">Resina</option>
|
|
<option value="ASA">ASA</option>
|
|
</select>
|
|
<input className="field" value={workForm.machine} onChange={(event) => updateWorkForm("machine", event.target.value)} placeholder="Maquina" />
|
|
<input className="field" value={workForm.color} onChange={(event) => updateWorkForm("color", event.target.value)} placeholder="Color" />
|
|
<input className="field" value={workForm.dimensions} onChange={(event) => updateWorkForm("dimensions", event.target.value)} placeholder="Dimensiones" />
|
|
<input className="field" value={workForm.fabricationTime} onChange={(event) => updateWorkForm("fabricationTime", event.target.value)} placeholder="Tiempo de fabricacion" />
|
|
</div>
|
|
<input className="field" value={workForm.quantity} onChange={(event) => updateWorkForm("quantity", event.target.value)} placeholder="Cantidad producida" />
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (workStep === 4) {
|
|
return (
|
|
<section className="publish-step-card">
|
|
<span className="eyebrow">Antes / Resultado</span>
|
|
<h3>Muestra el cambio</h3>
|
|
<label className="publish-toggle">
|
|
<input type="checkbox" checked={workForm.includeBeforeAfter} onChange={(event) => updateWorkForm("includeBeforeAfter", event.target.checked)} />
|
|
<span>Anadir Antes / Resultado</span>
|
|
</label>
|
|
<div className="workspace-form-grid">
|
|
<input className="field" value={workForm.beforeImageUrl} onChange={(event) => updateWorkForm("beforeImageUrl", event.target.value)} placeholder="Foto del antes" />
|
|
<input className="field" value={workForm.afterImageUrl} onChange={(event) => updateWorkForm("afterImageUrl", event.target.value)} placeholder="Foto del resultado" />
|
|
</div>
|
|
<div className="publish-before-preview">
|
|
{workForm.beforeImageUrl ? <img src={workForm.beforeImageUrl} alt="Antes" /> : <span>Foto del antes</span>}
|
|
{workForm.afterImageUrl ? <img src={workForm.afterImageUrl} alt="Resultado" /> : <span>Foto del resultado</span>}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (workStep === 5) {
|
|
return (
|
|
<section className="publish-step-card">
|
|
<span className="eyebrow">Servicios y escaparates</span>
|
|
<h3>Organiza tu trabajo</h3>
|
|
<select className="select" value={workForm.serviceId} onChange={(event) => updateWorkForm("serviceId", event.target.value)}>
|
|
<option value="">Usar primer servicio publicado</option>
|
|
{account?.services?.map((service) => (
|
|
<option key={String(service.id)} value={String(service.id)}>{toText(service.title, "Servicio")}</option>
|
|
))}
|
|
</select>
|
|
<select className="select" value={workForm.placement} onChange={(event) => updateWorkForm("placement", event.target.value)}>
|
|
<option value="">Selecciona donde mostrarlo</option>
|
|
<option value="Todos los trabajos">Todos los trabajos</option>
|
|
<option value="Repuestos funcionales">Repuestos funcionales</option>
|
|
<option value="Electrodomesticos">Electrodomesticos</option>
|
|
<option value="Automotor">Automotor</option>
|
|
</select>
|
|
<div className="publish-ai-note">
|
|
<strong>Tip</strong>
|
|
<span>Relacionarlo con un servicio ayuda a aparecer en busquedas y consultas mas precisas.</span>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (workStep === 6) {
|
|
return (
|
|
<section className="publish-step-card">
|
|
<span className="eyebrow">Precio orientativo</span>
|
|
<h3>Rango de referencia</h3>
|
|
{renderChoiceGrid("priceMode", ["hidden", "from", "range"])}
|
|
<div className="workspace-form-grid">
|
|
<input className="field" value={workForm.priceFrom} onChange={(event) => updateWorkForm("priceFrom", event.target.value)} placeholder="Desde" />
|
|
<input className="field" value={workForm.priceTo} onChange={(event) => updateWorkForm("priceTo", event.target.value)} placeholder="Hasta" />
|
|
</div>
|
|
<p className="muted">El precio es orientativo y puede variar segun material, cantidad y acabado.</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (workStep === 7) {
|
|
return (
|
|
<section className="publish-step-card">
|
|
<span className="eyebrow">Vista previa</span>
|
|
<h3>Asi lo vera la comunidad</h3>
|
|
<article className="publish-preview-card">
|
|
{workForm.imageUrl ? <img src={workForm.imageUrl} alt={workForm.title} /> : <span className="publish-image-placeholder">Sin portada</span>}
|
|
<div>
|
|
<strong>{workForm.title}</strong>
|
|
<span>{account?.maker.business_name || "Tu maker"} | 4,9</span>
|
|
<p>{workForm.summary}</p>
|
|
<div className="publish-mini-tags">
|
|
<span>{workForm.useCase}</span>
|
|
<span>{workForm.technology}</span>
|
|
<span>{workForm.material}</span>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section className="publish-step-card">
|
|
<span className="eyebrow">Comprobacion</span>
|
|
<h3>Todo listo para publicar</h3>
|
|
<div className="publish-checklist">
|
|
{["Fotos y portada", "Titulo y descripcion", "Problema y solucion", "Servicio relacionado", "Categoria asignada"].map((item) => (
|
|
<span key={item}>ok {item}</span>
|
|
))}
|
|
</div>
|
|
<textarea className="textarea" value={workForm.story} onChange={(event) => updateWorkForm("story", event.target.value)} />
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (authLoading) {
|
|
return (
|
|
<div className="workspace-theme">
|
|
<section className="workspace-main-card workspace-section-summary stack">
|
|
<div className="workspace-loading-state">
|
|
<div className="messages-loading">
|
|
<span />
|
|
<span />
|
|
<span />
|
|
</div>
|
|
<div>
|
|
<h2 className="section-title">Abriendo tu espacio Maker</h2>
|
|
<p className="muted">Validando sesion y cargando tus consultas, trabajos y configuracion.</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!user) {
|
|
return (
|
|
<div className="workspace-theme">
|
|
<section className="workspace-main-card workspace-section-summary stack">
|
|
<h2 className="section-title">Necesitas iniciar sesion</h2>
|
|
<p className="muted">Entra con una cuenta demo o crea una para gestionar tu espacio maker y responder consultas.</p>
|
|
<div className="split">
|
|
<Link href="/login?returnTo=%2Faccount" className="button button-primary">Entrar</Link>
|
|
<Link href="/register?returnTo=%2Faccount" className="button button-secondary">Crear cuenta</Link>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (user.role === "admin") {
|
|
return (
|
|
<div className="workspace-theme">
|
|
<section className="workspace-main-card workspace-section-summary stack">
|
|
<h2 className="section-title">Redirigiendo al backoffice</h2>
|
|
<p className="muted">La cuenta administradora gestiona la plataforma completa desde el panel operativo.</p>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const makerName = account?.maker.business_name || "Tu espacio Maker";
|
|
const publishedWorks = account?.works.filter((work) => Boolean(work.is_published)).length || 0;
|
|
const draftWorks = account?.works.filter((work) => !Boolean(work.is_published)).length || 0;
|
|
const activeServices = account?.services.filter((service) => Boolean(service.is_active)).length || 0;
|
|
const pendingChecklist = checklistItems.filter(([, value]) => !value).length;
|
|
const completedChecklist = checklistItems.length - pendingChecklist;
|
|
const checklistPercent = checklistItems.length ? Math.round((completedChecklist / checklistItems.length) * 100) : 0;
|
|
const topWorks = (account?.works || []).slice(0, 3);
|
|
const metrics = [
|
|
{ label: "Consultas", value: String(conversations.length), note: "Conversaciones vivas" },
|
|
{ label: "Trabajos", value: String(publishedWorks), note: `${draftWorks} borradores` },
|
|
{ label: "Servicios", value: String(activeServices), note: "Activos" },
|
|
{ label: "Resenas", value: String(account?.reviews.length || 0), note: "Verificadas" }
|
|
];
|
|
const copy = sectionCopy[section];
|
|
const publishedShowcasesCount = account?.showcases?.filter((showcase) => Boolean(showcase.is_published)).length || 0;
|
|
const draftShowcasesCount = account?.showcases?.filter((showcase) => !Boolean(showcase.is_published)).length || 0;
|
|
const visibleShowcases = account?.showcases?.filter((showcase) => (
|
|
showcaseListFilter === "all" ? true : showcaseListFilter === "published" ? Boolean(showcase.is_published) : !Boolean(showcase.is_published)
|
|
)) || [];
|
|
const activeServicesCount = account?.services?.filter((service) => Boolean(service.is_published)).length || 0;
|
|
const draftServicesCount = account?.services?.filter((service) => !Boolean(service.is_published)).length || 0;
|
|
const visibleServices = account?.services?.filter((service) => (
|
|
serviceListFilter === "all" ? true : serviceListFilter === "active" ? Boolean(service.is_published) : !Boolean(service.is_published)
|
|
)) || [];
|
|
const publishedWorksCount = account?.works?.filter((work) => Boolean(work.is_published)).length || 0;
|
|
const draftWorksCount = account?.works?.filter((work) => !Boolean(work.is_published)).length || 0;
|
|
const visibleWorks = account?.works?.filter((work) => (
|
|
workListFilter === "published" ? Boolean(work.is_published) : !Boolean(work.is_published)
|
|
)) || [];
|
|
const savedLocations = account?.locations || [];
|
|
const locationRows = savedLocations.length ? savedLocations : [{
|
|
id: "profile-location",
|
|
label: locationForm.label || "Taller principal",
|
|
province: profileForm.province,
|
|
city: profileForm.city,
|
|
privacy: "approximate",
|
|
shipping_area: profileForm.deliveryScope === "nationwide" ? "nationwide" : "province",
|
|
local_pickup: true,
|
|
postal_shipping: profileForm.deliveryScope === "nationwide",
|
|
nationwide_shipping: profileForm.deliveryScope === "nationwide",
|
|
hours_mode: "appointment",
|
|
is_published: true
|
|
}];
|
|
const publishedLocationsCount = savedLocations.filter((location) => Boolean(location.is_published)).length || (savedLocations.length ? 0 : 1);
|
|
const draftLocationsCount = savedLocations.filter((location) => !Boolean(location.is_published)).length;
|
|
const hasNationwideLocation = locationRows.some((location) => toText(location.shipping_area) === "nationwide" || Boolean(location.nationwide_shipping));
|
|
const socialProfileFields = [
|
|
["websiteUrl", "Web", "https://tumarca.com"],
|
|
["instagramUrl", "Instagram", "https://instagram.com/tu_marca"],
|
|
["tiktokUrl", "TikTok", "https://tiktok.com/@tu_marca"],
|
|
["twitterUrl", "X / Twitter", "https://x.com/tu_marca"],
|
|
["facebookUrl", "Facebook", "https://facebook.com/tu_marca"],
|
|
["youtubeUrl", "YouTube", "https://youtube.com/@tu_marca"],
|
|
["linkedinUrl", "LinkedIn", "https://linkedin.com/company/tu_marca"]
|
|
] as const;
|
|
|
|
return (
|
|
<div className="workspace-theme">
|
|
<div className="workspace-mobile-bar">
|
|
<button className="workspace-menu-button" type="button" onClick={() => setMenuOpen(true)} aria-label="Abrir menu maker">
|
|
<span />
|
|
<span />
|
|
<span />
|
|
</button>
|
|
<div>
|
|
<strong>{makerName}</strong>
|
|
<span>{copy.eyebrow}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
className={`workspace-menu-backdrop ${menuOpen ? "is-open" : ""}`}
|
|
type="button"
|
|
onClick={() => setMenuOpen(false)}
|
|
aria-label="Cerrar menu maker"
|
|
/>
|
|
|
|
<aside className={`workspace-sidebar stack ${menuOpen ? "is-open" : ""}`}>
|
|
<button className="workspace-menu-close" type="button" onClick={() => setMenuOpen(false)}>
|
|
Cerrar menu
|
|
</button>
|
|
<div className="workspace-brand stack">
|
|
<span className="eyebrow">Panel maker</span>
|
|
<strong>{makerName}</strong>
|
|
<span className="mini-note">{account?.maker.status || "draft"} | {account?.maker.availability || "available"}</span>
|
|
</div>
|
|
|
|
<div className="workspace-usercard stack">
|
|
<strong>{user.email}</strong>
|
|
<span className="muted">Una misma cuenta sirve para cliente, consultas y espacio maker.</span>
|
|
<div className="workspace-account-status">
|
|
<span className="workspace-status-dot" aria-hidden="true" />
|
|
<div>
|
|
<strong>{account?.checklist.subscriptionStatus === "active" ? "Plan activo" : "Plan demo"}</strong>
|
|
<span>Vence el 12/08/2026</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="workspace-nav-group stack" style={{ gap: 8 }}>
|
|
<span className="workspace-nav-label">Principal</span>
|
|
<div className="workspace-nav">
|
|
{primaryLinks.map((item) => (
|
|
<Link key={item.href} href={item.href} onClick={() => setMenuOpen(false)} className={`workspace-link ${section === item.section ? "active" : ""}`}>
|
|
<span>{item.label}</span>
|
|
<span>{item.section === "inbox" ? conversations.length : ""}</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="workspace-nav-group stack" style={{ gap: 8 }}>
|
|
<span className="workspace-nav-label">Mas opciones</span>
|
|
<div className="workspace-nav">
|
|
{secondaryLinks.map((item) => (
|
|
<Link key={item.href} href={item.href} onClick={() => setMenuOpen(false)} className={`workspace-link ${section === item.section ? "active" : ""}`}>
|
|
<span>{item.label}</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<button className="button button-secondary" type="button" onClick={logout}>
|
|
Cerrar sesion
|
|
</button>
|
|
</aside>
|
|
|
|
<section className={`workspace-main-card stack workspace-section-${section}`}>
|
|
<div className="workspace-header">
|
|
<div className="stack" style={{ gap: 6 }}>
|
|
<span className="eyebrow">{copy.eyebrow}</span>
|
|
<h2 className="section-title">{copy.title}</h2>
|
|
<p className="muted">{copy.description}</p>
|
|
</div>
|
|
<div className="workspace-header-actions">
|
|
{section === "works" ? (
|
|
<button className="button button-primary" type="button" onClick={startNewWork}>+ Nuevo trabajo</button>
|
|
) : section === "services" ? (
|
|
<button className="button button-primary" type="button" onClick={startNewService}>+ Nuevo servicio</button>
|
|
) : section === "showcases" ? (
|
|
<button className="button button-primary" type="button" onClick={startNewShowcase}>+ Nuevo escaparate</button>
|
|
) : section === "locations" ? (
|
|
<button className="button button-primary" type="button" onClick={startNewLocation}>+ Nueva ubicacion</button>
|
|
) : null}
|
|
{status ? <span className="status-pill primary">{status}</span> : null}
|
|
</div>
|
|
</div>
|
|
|
|
{toastMessage ? (
|
|
<div className="workspace-toast" role="alert">
|
|
<strong>Atencion</strong>
|
|
<span>{toastMessage}</span>
|
|
</div>
|
|
) : null}
|
|
|
|
{section === "summary" && (
|
|
<div className="maker-dashboard">
|
|
<section className="maker-dashboard-main">
|
|
<div className="maker-dashboard-greeting">
|
|
<div>
|
|
<span className="eyebrow">Mi espacio Maker</span>
|
|
<h3>Buenos dias, {makerName.split(" ")[0] || "Maker"}.</h3>
|
|
<p>Tienes {Math.max(1, pendingChecklist)} cosas que atender para vender mejor.</p>
|
|
</div>
|
|
<div className="maker-dashboard-actions">
|
|
{account?.maker.slug ? <Link className="button button-secondary" href={`/makers/${account.maker.slug}`}>Ver perfil publico</Link> : null}
|
|
<button className="button button-primary" type="button" onClick={startNewWork}>+ Publicar trabajo</button>
|
|
</div>
|
|
</div>
|
|
|
|
<article className="maker-dash-panel">
|
|
<div className="split">
|
|
<h3 className="section-title">Requiere tu atencion</h3>
|
|
<Link href="/account/inbox">Ver todo lo pendiente</Link>
|
|
</div>
|
|
<div className="maker-attention-grid">
|
|
<Link href="/account/inbox">
|
|
<span>2</span>
|
|
<strong>Nuevas consultas</strong>
|
|
<small>Recibidas en las ultimas 2 horas</small>
|
|
<b>Responder ahora</b>
|
|
</Link>
|
|
<Link href="/account/inbox">
|
|
<span>1</span>
|
|
<strong>Cliente espera</strong>
|
|
<small>Tu respuesta desde hace 3 h</small>
|
|
<b>Ver consulta</b>
|
|
</Link>
|
|
<Link href="/account/profile">
|
|
<span>{pendingChecklist}</span>
|
|
<strong>Tu perfil puede mejorar</strong>
|
|
<small>Anade contenido para destacar calidad</small>
|
|
<b>Mejorar perfil</b>
|
|
</Link>
|
|
</div>
|
|
</article>
|
|
|
|
<article className="maker-dash-panel">
|
|
<h3 className="section-title">Resumen de esta semana</h3>
|
|
<div className="maker-week-grid">
|
|
{[
|
|
["12", "Consultas recibidas", "+20% vs. semana pasada"],
|
|
["4", "Trabajos acordados", "+33% vs. semana pasada"],
|
|
["2", "Trabajos finalizados", "+100% vs. semana pasada"],
|
|
["~42 min", "Tiempo de respuesta", "Muy bueno"]
|
|
].map(([value, label, note]) => (
|
|
<div key={label}>
|
|
<strong>{value}</strong>
|
|
<span>{label}</span>
|
|
<small>{note}</small>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</article>
|
|
|
|
<div className="maker-dashboard-two">
|
|
<article className="maker-dash-panel">
|
|
<div className="split">
|
|
<h3 className="section-title">Rendimiento de tu perfil</h3>
|
|
<span className="publish-count-text">Este mes</span>
|
|
</div>
|
|
<div className="maker-performance-grid">
|
|
<div><strong>1.248</strong><span>Visitas al perfil</span><small>+18%</small></div>
|
|
<div><strong>86</strong><span>Trabajos vistos</span><small>+12%</small></div>
|
|
<div><strong>24</strong><span>Consultas recibidas</span><small>+21%</small></div>
|
|
<div><strong>19,3%</strong><span>Visita a consulta</span><small>+2,1%</small></div>
|
|
</div>
|
|
</article>
|
|
|
|
<article className="maker-dash-panel">
|
|
<div className="split">
|
|
<h3 className="section-title">Lo que mejor te funciona</h3>
|
|
<Link href="/account/works">Ver todos</Link>
|
|
</div>
|
|
<div className="maker-top-work-list">
|
|
{(topWorks.length ? topWorks : [{ id: "empty", title: "Publica tu primer trabajo", image_url: "/demo/work-custom.svg", views_count: 0 }]).map((work) => (
|
|
<Link key={String(work.id)} href="/account/works">
|
|
<img src={toText(work.image_url, "/demo/work-custom.svg")} alt={toText(work.title, "Trabajo")} />
|
|
<div>
|
|
<strong>{toText(work.title, "Trabajo destacado")}</strong>
|
|
<span>{toNumber(work.views_count, 189)} vistas | {conversations.length || 3} consultas</span>
|
|
</div>
|
|
<b>{publishedWorks ? "Activo" : "Nuevo"}</b>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</article>
|
|
</div>
|
|
|
|
<div className="maker-dashboard-two">
|
|
<article className="maker-dash-panel maker-opportunity">
|
|
<div>
|
|
<span className="eyebrow">Oportunidades cerca de ti</span>
|
|
<h3>23 busquedas de "repuestos"</h3>
|
|
<p>Tienes {publishedWorks || 1} trabajos relacionados en tu zona esta semana.</p>
|
|
<Link href="/account/works">Ver oportunidad</Link>
|
|
</div>
|
|
<div className="maker-mini-map">
|
|
<i />
|
|
<i />
|
|
<i />
|
|
</div>
|
|
</article>
|
|
|
|
<article className="maker-dash-panel">
|
|
<h3 className="section-title">Mejora tu visibilidad</h3>
|
|
<div className="maker-recommend-list">
|
|
<Link href="/account/services">Anade precios a 2 servicios</Link>
|
|
<Link href="/account/works">Publica otro trabajo de Automotor</Link>
|
|
<Link href="/account/profile">Anade una portada mas clara</Link>
|
|
<Link href="/account/showcases">Crea un escaparate especializado</Link>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</section>
|
|
|
|
<aside className="maker-dashboard-side">
|
|
<article className="maker-dash-panel">
|
|
<h3 className="section-title">Disponibilidad</h3>
|
|
<div className="maker-status-line"><span className="workspace-status-dot" />{account?.maker.availability === "unavailable" ? "No acepta trabajos" : "Aceptando trabajos"}</div>
|
|
<p className="muted">Puedes cambiar tu estado desde el perfil publico.</p>
|
|
<Link href="/account/profile">Programar cambio automatico</Link>
|
|
</article>
|
|
|
|
<article className="maker-dash-panel">
|
|
<h3 className="section-title">Plan Maker</h3>
|
|
<strong className="maker-plan-value">{account?.checklist.ready ? "Activo" : "Configuracion pendiente"}</strong>
|
|
<p className="muted">Vence el 12/08/2026</p>
|
|
<Link href="/account/subscription">Ver mi suscripcion</Link>
|
|
</article>
|
|
|
|
<article className="maker-dash-panel maker-reputation-card">
|
|
<h3 className="section-title">Tu reputacion</h3>
|
|
<strong>4,9</strong>
|
|
<span>*****</span>
|
|
<p>{account?.reviews.length || 128} opiniones</p>
|
|
<div className="maker-satisfaction-ring">96%</div>
|
|
<small>volveria a contratarte</small>
|
|
</article>
|
|
|
|
<article className="maker-dash-panel">
|
|
<div className="split">
|
|
<h3 className="section-title">Checklist</h3>
|
|
<span className={`status-pill ${account?.checklist.ready ? "success" : "warning"}`}>{completedChecklist}/{checklistItems.length || 7}</span>
|
|
</div>
|
|
<div className="maker-checklist-mini">
|
|
{checklistItems.map(([key, value]) => {
|
|
const checklistLabel = subscriptionChecklistLabels[key] || { label: key, detail: "Requisito del perfil maker." };
|
|
return (
|
|
<div key={key}>
|
|
<span className={value ? "is-done" : ""}>{value ? "OK" : ""}</span>
|
|
<strong>{checklistLabel.label}</strong>
|
|
<small>{value ? "Completado" : checklistLabel.detail}</small>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<button className="button button-primary" type="button" onClick={account?.checklist.ready ? requestPublish : activateDemoSubscription}>
|
|
{account?.checklist.ready ? "Publicar perfil" : `Continuar configuracion ${checklistPercent}%`}
|
|
</button>
|
|
</article>
|
|
</aside>
|
|
</div>
|
|
)}
|
|
|
|
{section === "showcases" && (
|
|
<div className="showcase-workspace">
|
|
<article className="showcase-list-card">
|
|
<div className="split">
|
|
<div>
|
|
<span className="eyebrow">Mis escaparates</span>
|
|
<h3 className="section-title">Colecciones tematicas</h3>
|
|
</div>
|
|
<span className="publish-count-text">{publishedShowcasesCount} publicos</span>
|
|
</div>
|
|
|
|
<div className="showcase-summary-strip">
|
|
<span><strong>{account?.showcases.length || 0}</strong> total</span>
|
|
<span><strong>{publishedShowcasesCount}</strong> publicos</span>
|
|
<span><strong>{draftShowcasesCount}</strong> borradores</span>
|
|
</div>
|
|
|
|
<div className="publish-tabs showcase-tabs">
|
|
<button className={showcaseListFilter === "all" ? "is-active" : ""} type="button" onClick={() => setShowcaseListFilter("all")}>Todos ({account?.showcases.length || 0})</button>
|
|
<button className={showcaseListFilter === "published" ? "is-active" : ""} type="button" onClick={() => setShowcaseListFilter("published")}>Publicos ({publishedShowcasesCount})</button>
|
|
<button className={showcaseListFilter === "drafts" ? "is-active" : ""} type="button" onClick={() => setShowcaseListFilter("drafts")}>Borradores ({draftShowcasesCount})</button>
|
|
</div>
|
|
|
|
<div className="showcase-list">
|
|
{visibleShowcases.length ? null : <div className="empty-state">Todavia no hay escaparates en esta vista.</div>}
|
|
{visibleShowcases.map((showcase) => {
|
|
const published = Boolean(showcase.is_published);
|
|
const selectedCount = Array.isArray(showcase.selected_work_ids) ? showcase.selected_work_ids.length : 0;
|
|
return (
|
|
<article key={String(showcase.id)} className="showcase-manage-card">
|
|
<button className="showcase-main-row" type="button" onClick={() => editShowcase(showcase)}>
|
|
<img src={toText(showcase.cover_image_url, "/demo/work-custom.svg")} alt={toText(showcase.title)} />
|
|
<div>
|
|
<strong>{toText(showcase.title, "Escaparate sin titulo")}</strong>
|
|
<small>{selectedCount} trabajos | {published ? "Visible para todos" : "Solo visible para ti"}</small>
|
|
<p>{toText(showcase.description, "Agrupa trabajos por especialidad para mejorar descubrimiento y consultas.")}</p>
|
|
</div>
|
|
<span className={`status-pill ${published ? "success" : "warning"}`}>{published ? "Publico" : "Borrador"}</span>
|
|
</button>
|
|
<div className="showcase-card-footer">
|
|
<span>{selectedCount} trabajos seleccionados</span>
|
|
<div>
|
|
{published ? <a href={`/showcases/${toText(showcase.slug)}`} className="button button-ghost">Ver publico</a> : null}
|
|
{published ? <button type="button" onClick={() => void unpublishShowcase(showcase)}>Despublicar</button> : <button type="button" onClick={() => editShowcase(showcase)}>Retomar</button>}
|
|
<button type="button" onClick={() => void deleteShowcase(showcase)}>Eliminar</button>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
</article>
|
|
|
|
<aside className="showcase-benefits-panel">
|
|
<article>
|
|
<span className="eyebrow">Como funcionan</span>
|
|
<strong>Colecciones tematicas de tus mejores trabajos</strong>
|
|
<p>Ayudan a presentar especialidades, crear enlaces publicos y disparar consultas con contexto.</p>
|
|
</article>
|
|
<article>
|
|
<span className="eyebrow">Limites MVP</span>
|
|
<div className="service-benefit-list">
|
|
<span>Hasta 3 escaparates recomendados</span>
|
|
<span>Trabajos ilimitados por coleccion</span>
|
|
<span>Portada automatica o personalizada</span>
|
|
<span>Consulta guiada desde el escaparate</span>
|
|
</div>
|
|
</article>
|
|
</aside>
|
|
|
|
{showcaseWizardOpen ? (
|
|
<div className="publish-wizard-overlay showcase-wizard-overlay" role="dialog" aria-modal="true" aria-label="Wizard para crear escaparate">
|
|
<button className="publish-wizard-backdrop" type="button" onClick={closeShowcaseWizard} aria-label="Cerrar wizard de escaparate" />
|
|
<div className="publish-wizard-modal showcase-wizard-modal">
|
|
<button className="publish-modal-close" type="button" onClick={closeShowcaseWizard}>Cerrar</button>
|
|
<form
|
|
className="publish-wizard showcase-wizard"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
}}
|
|
>
|
|
<div className="publish-wizard-head">
|
|
{showcaseStep > 0 ? (
|
|
<button className="publish-back" type="button" onClick={() => setShowcaseStep((current) => Math.max(current - 1, 0))}><</button>
|
|
) : (
|
|
<span className="publish-back-spacer" />
|
|
)}
|
|
<div>
|
|
<span className="eyebrow">{editingShowcaseId ? "Editar escaparate" : "Nuevo escaparate"}</span>
|
|
<h3>{showcaseSteps[showcaseStep]}</h3>
|
|
</div>
|
|
{showcaseStep < showcaseSteps.length - 1 ? (
|
|
<button className="publish-next-link" type="button" onClick={goToNextShowcaseStep}>Siguiente</button>
|
|
) : (
|
|
<span className="publish-next-link" aria-hidden="true">Listo</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="publish-progress showcase-progress" aria-label={`Paso ${showcaseStep + 1} de ${showcaseSteps.length}`}>
|
|
{showcaseSteps.map((step, index) => (
|
|
<button key={step} className={index === showcaseStep ? "is-active" : index < showcaseStep ? "is-done" : ""} type="button" onClick={() => setShowcaseStep(index)} aria-label={step} />
|
|
))}
|
|
</div>
|
|
|
|
{renderShowcaseStep()}
|
|
|
|
<div className="publish-actions showcase-actions">
|
|
{showcaseStep > 0 ? <button className="button button-secondary" type="button" onClick={() => setShowcaseStep((current) => Math.max(current - 1, 0))}>Anterior</button> : null}
|
|
<button className="button button-secondary" type="button" onClick={() => void saveShowcase(false)}>Guardar borrador</button>
|
|
{showcaseStep < showcaseSteps.length - 1 ? (
|
|
<button className="button button-primary" type="button" onClick={goToNextShowcaseStep}>Siguiente</button>
|
|
) : (
|
|
<button className="button button-primary" type="button" onClick={() => void saveShowcase(true)}>Publicar escaparate</button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
|
|
<aside className="publish-side-panel showcase-side-panel">
|
|
<article>
|
|
<span className="eyebrow">Preview rapido</span>
|
|
<img src={getShowcaseCoverImage()} alt={showcaseForm.title || "Escaparate"} />
|
|
<strong>{showcaseForm.title || "Tu escaparate"}</strong>
|
|
<p>{showcaseForm.description || "Agrupa trabajos relevantes para mostrar una especialidad."}</p>
|
|
</article>
|
|
<article>
|
|
<span className="eyebrow">Consejos</span>
|
|
<div className="publish-tip">Usa nombres concretos: Repuestos funcionales, Restauraciones, Automotor.</div>
|
|
<div className="publish-tip">Destaca el trabajo que mejor represente la coleccion.</div>
|
|
<div className="publish-tip">Comparte el enlace cuando quieras mostrar experiencia especifica.</div>
|
|
</article>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
{section === "stats" && (
|
|
<div className="workspace-wide-grid">
|
|
<article className="light-card stack">
|
|
<h3 className="section-title">Resumen del negocio</h3>
|
|
<div className="kpi-grid">
|
|
<div className="metric-card stack" style={{ gap: 6 }}>
|
|
<span className="muted">Consultas recibidas</span>
|
|
<strong style={{ fontSize: "2rem" }}>{conversations.length}</strong>
|
|
<span className="muted">Actividad comercial</span>
|
|
</div>
|
|
<div className="metric-card stack" style={{ gap: 6 }}>
|
|
<span className="muted">Trabajos visibles</span>
|
|
<strong style={{ fontSize: "2rem" }}>{account?.works.length || 0}</strong>
|
|
<span className="muted">Portafolio publico</span>
|
|
</div>
|
|
<div className="metric-card stack" style={{ gap: 6 }}>
|
|
<span className="muted">Servicios activos</span>
|
|
<strong style={{ fontSize: "2rem" }}>{account?.services.length || 0}</strong>
|
|
<span className="muted">Oferta publicada</span>
|
|
</div>
|
|
<div className="metric-card stack" style={{ gap: 6 }}>
|
|
<span className="muted">Conversion base</span>
|
|
<strong style={{ fontSize: "2rem" }}>{account?.works.length ? `${Math.round((conversations.length / Math.max(account.works.length, 1)) * 100)}%` : "0%"}</strong>
|
|
<span className="muted">Consulta por trabajo visible</span>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
|
|
<article className="light-card stack">
|
|
<h3 className="section-title">Lecturas accionables</h3>
|
|
<div className="timeline-list">
|
|
<div className="timeline-item">
|
|
<div>
|
|
<strong>Lo que mejor funciona</strong>
|
|
<div className="muted">Tus trabajos publicados son la principal palanca para convertir visitas en consulta.</div>
|
|
</div>
|
|
</div>
|
|
<div className="timeline-item">
|
|
<div>
|
|
<strong>Que mejorar despues</strong>
|
|
<div className="muted">Completar servicios y cobertura ayuda a filtrar mejor antes del primer mensaje.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
)}
|
|
|
|
{section === "locations" && (
|
|
<div className="location-workspace">
|
|
<article className="location-list-card">
|
|
<div className="split">
|
|
<div>
|
|
<span className="eyebrow">Mis ubicaciones</span>
|
|
<h3 className="section-title">Cobertura y privacidad</h3>
|
|
</div>
|
|
<span className="publish-count-text">{publishedLocationsCount} activas</span>
|
|
</div>
|
|
|
|
<div className="service-summary-strip location-summary-strip">
|
|
<span><strong>{locationRows.length}</strong> ubicaciones</span>
|
|
<span><strong>{hasNationwideLocation ? "Pais" : "Local"}</strong> cobertura</span>
|
|
<span><strong>{draftLocationsCount}</strong> borradores</span>
|
|
</div>
|
|
|
|
<button className="button button-primary location-new-button" type="button" onClick={startNewLocation}>+ Nueva ubicacion</button>
|
|
|
|
<div className="location-list">
|
|
{locationRows.map((location) => {
|
|
const published = Boolean(location.is_published);
|
|
const shippingArea = toText(location.shipping_area, Boolean(location.nationwide_shipping) ? "nationwide" : "province");
|
|
const privacy = toText(location.privacy, "approximate");
|
|
const canEdit = String(location.id) !== "profile-location";
|
|
return (
|
|
<article key={String(location.id)} className="location-manage-card">
|
|
<button className="location-main-row" type="button" onClick={() => canEdit ? editLocation(location) : startNewLocation()}>
|
|
<span className="location-avatar">UB</span>
|
|
<div>
|
|
<strong>{toText(location.label, "Taller principal")}</strong>
|
|
<small>{toText(location.city, "Cordoba")}, {toText(location.province, "Cordoba")} | {privacy === "exact" ? "Direccion exacta" : privacy === "city" ? "Solo localidad" : "Zona aproximada"}</small>
|
|
<p>{shippingArea === "nationwide" ? "Recogida, entrega coordinada y envios a toda Argentina." : "Atencion local con retiro o entrega coordinada."}</p>
|
|
<div className="publish-mini-tags">
|
|
{Boolean(location.local_pickup) ? <span>Recogida</span> : null}
|
|
{shippingArea === "nationwide" || Boolean(location.postal_shipping) ? <span>Envios</span> : <span>Local</span>}
|
|
<span>{toText(location.hours_mode, "appointment") === "appointment" ? "Con cita previa" : "Horario definido"}</span>
|
|
</div>
|
|
</div>
|
|
<span className={`status-pill ${published ? "success" : "warning"}`}>{published ? "Activa" : "Borrador"}</span>
|
|
</button>
|
|
{canEdit ? (
|
|
<div className="location-card-footer">
|
|
<button type="button" onClick={() => editLocation(location)}>{published ? "Editar" : "Retomar"}</button>
|
|
<button type="button" onClick={() => void deleteLocation(location)}>Eliminar</button>
|
|
</div>
|
|
) : null}
|
|
</article>
|
|
);
|
|
})}
|
|
|
|
</div>
|
|
</article>
|
|
|
|
<aside className="location-benefits-panel">
|
|
<article>
|
|
<span className="eyebrow">Vista publica</span>
|
|
<div className="location-public-preview">
|
|
<div className="location-mini-map">
|
|
<span className="location-coverage-ring" />
|
|
{locationRows.map((location, index) => (
|
|
<span
|
|
key={String(location.id)}
|
|
className={`location-map-pin ${Boolean(location.is_published) ? "" : "is-draft"}`}
|
|
style={locationPinStyle(location, index)}
|
|
title={toText(location.label, `Ubicacion ${index + 1}`)}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div>
|
|
<strong>{profileForm.businessName || "MakerLab 3D"}</strong>
|
|
<span>{profileForm.city}, {profileForm.province}</span>
|
|
<small>{locationRows.length} ubicaciones | {publishedLocationsCount} activas | {profileForm.deliveryScope === "nationwide" ? "Envios a todo el pais" : "Atencion local"}</small>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
|
|
<article>
|
|
<span className="eyebrow">Busqueda cliente</span>
|
|
<div className="location-search-modes">
|
|
<div>
|
|
<strong>Cerca de mi</strong>
|
|
<span>Ordena por compatibilidad, distancia y reputacion.</span>
|
|
</div>
|
|
<div>
|
|
<strong>Con envio</strong>
|
|
<span>No usa cercania como filtro duro: prioriza servicio y cobertura.</span>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
|
|
<article>
|
|
<span className="eyebrow">Sin resultados compatibles</span>
|
|
<p>El cliente puede ampliar busqueda, buscar con envio o activar una alerta para nuevos makers.</p>
|
|
<button className="button button-secondary" type="button" onClick={startNewLocation}>Configurar alerta y cobertura</button>
|
|
</article>
|
|
</aside>
|
|
|
|
{locationWizardOpen ? (
|
|
<div className="publish-wizard-overlay location-wizard-overlay" role="dialog" aria-modal="true" aria-label="Wizard de ubicacion y cobertura">
|
|
<button className="publish-wizard-backdrop" type="button" onClick={closeLocationWizard} aria-label="Cerrar wizard de ubicacion" />
|
|
<div className="publish-wizard-modal location-wizard-modal">
|
|
<button className="publish-modal-close" type="button" onClick={closeLocationWizard}>Cerrar</button>
|
|
<form
|
|
className="publish-wizard location-wizard"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
}}
|
|
>
|
|
<div className="publish-wizard-head">
|
|
{locationStep > 0 ? (
|
|
<button className="publish-back" type="button" onClick={() => setLocationStep((current) => Math.max(current - 1, 0))}><</button>
|
|
) : (
|
|
<span className="publish-back-spacer" />
|
|
)}
|
|
<div>
|
|
<span className="eyebrow">Ubicacion y cobertura</span>
|
|
<h3>{locationSteps[locationStep]}</h3>
|
|
</div>
|
|
{locationStep < locationSteps.length - 1 ? (
|
|
<button className="publish-next-link" type="button" onClick={goToNextLocationStep}>Siguiente</button>
|
|
) : (
|
|
<span className="publish-next-link" aria-hidden="true">Listo</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="publish-progress location-progress" aria-label={`Paso ${locationStep + 1} de ${locationSteps.length}`}>
|
|
{locationSteps.map((step, index) => (
|
|
<button key={step} className={index === locationStep ? "is-active" : index < locationStep ? "is-done" : ""} type="button" onClick={() => setLocationStep(index)} aria-label={step} />
|
|
))}
|
|
</div>
|
|
|
|
{renderLocationStep()}
|
|
|
|
<div className="publish-actions location-actions">
|
|
{locationStep > 0 ? <button className="button button-secondary" type="button" onClick={() => setLocationStep((current) => Math.max(current - 1, 0))}>Anterior</button> : null}
|
|
<button className="button button-secondary" type="button" onClick={() => void saveLocation(false)}>Guardar borrador</button>
|
|
{locationStep < locationSteps.length - 1 ? (
|
|
<button className="button button-primary" type="button" onClick={goToNextLocationStep}>Siguiente</button>
|
|
) : (
|
|
<button className="button button-primary" type="button" onClick={() => void saveLocation(true)}>Guardar ubicacion</button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
|
|
<aside className="publish-side-panel location-side-panel">
|
|
<article>
|
|
<span className="eyebrow">Resumen</span>
|
|
<strong>{locationForm.label || "Taller principal"}</strong>
|
|
<p>{locationForm.city}, {locationForm.province}. {locationForm.privacy === "exact" ? "Direccion exacta visible." : "Direccion protegida con zona aproximada."}</p>
|
|
<div className="service-benefit-list">
|
|
<span>{locationForm.localPickup ? "Retiro disponible" : "Sin retiro"}</span>
|
|
<span>{locationForm.shippingArea === "nationwide" ? "Envios a todo el pais" : "Cobertura local"}</span>
|
|
<span>{locationForm.hoursMode === "appointment" ? "Con cita previa" : "Horario configurable"}</span>
|
|
</div>
|
|
</article>
|
|
<article>
|
|
<span className="eyebrow">Tipos disponibles</span>
|
|
<div className="publish-tip">Trabajo desde casa: mostrar zona aproximada.</div>
|
|
<div className="publish-tip">Atencion online: aparecer por envio, no por cercania.</div>
|
|
<div className="publish-tip">Base movil: radio de desplazamiento configurable.</div>
|
|
</article>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
{section === "profile" && (
|
|
<form className="profile-workspace" onSubmit={saveProfile}>
|
|
<article className="profile-config-card stack">
|
|
<h3 className="section-title">Identidad publica</h3>
|
|
<div className="workspace-form-grid">
|
|
<input className="field" placeholder="Nombre comercial" value={profileForm.businessName} onChange={(event) => setProfileForm((current) => ({ ...current, businessName: event.target.value }))} />
|
|
<select className="select" value={profileForm.availability} onChange={(event) => setProfileForm((current) => ({ ...current, availability: event.target.value }))}>
|
|
<option value="available">Aceptando trabajos</option>
|
|
<option value="limited">Disponibilidad limitada</option>
|
|
<option value="unavailable">No aceptar nuevos trabajos</option>
|
|
</select>
|
|
</div>
|
|
<textarea className="textarea" placeholder="Describe que haces, para quien y por que confiar en tu trabajo." value={profileForm.description} onChange={(event) => setProfileForm((current) => ({ ...current, description: event.target.value }))} />
|
|
</article>
|
|
|
|
<article className="profile-config-card stack">
|
|
<h3 className="section-title">Canales publicos y portada</h3>
|
|
<p className="muted profile-muted-note">La ubicacion y cobertura operativa se gestionan desde Ubicaciones. Aca solo definimos contacto publico, logo y presencia digital.</p>
|
|
<div className="profile-logo-editor">
|
|
<img src={profileForm.mainImageUrl || "/demo/maker-hero-custom.svg"} alt="Logo o portada del perfil" />
|
|
<div>
|
|
<strong>Logo / portada de marca</strong>
|
|
<span>Se usa en tu perfil publico, favoritos y resultados.</span>
|
|
<label className="button button-secondary profile-upload-button">
|
|
Cambiar imagen
|
|
<input type="file" accept="image/*" onChange={(event) => void updateProfileLogo(event.target.files)} />
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div className="workspace-form-grid">
|
|
<input className="field" placeholder="WhatsApp publico" value={profileForm.publicWhatsapp} onChange={(event) => setProfileForm((current) => ({ ...current, publicWhatsapp: event.target.value }))} />
|
|
<input className="field" placeholder="Email publico" value={profileForm.publicContactEmail} onChange={(event) => setProfileForm((current) => ({ ...current, publicContactEmail: event.target.value }))} />
|
|
</div>
|
|
<div className="profile-social-grid">
|
|
{socialProfileFields.map(([key, label, placeholder]) => (
|
|
<label key={key}>
|
|
<span>{label}</span>
|
|
<input
|
|
className="field"
|
|
placeholder={placeholder}
|
|
value={profileForm[key]}
|
|
onChange={(event) => setProfileForm((current) => ({ ...current, [key]: event.target.value }))}
|
|
/>
|
|
</label>
|
|
))}
|
|
</div>
|
|
<div className="profile-save-row">
|
|
<Link className="button button-ghost" href="/account/locations">Editar ubicaciones</Link>
|
|
<button className="button button-primary" type="submit">Guardar perfil</button>
|
|
</div>
|
|
</article>
|
|
</form>
|
|
)}
|
|
|
|
{section === "services" && (
|
|
<div className="service-workspace">
|
|
<article className="service-list-card">
|
|
<div className="split">
|
|
<div>
|
|
<span className="eyebrow">Mis servicios</span>
|
|
<h3 className="section-title">Capacidades y precios</h3>
|
|
</div>
|
|
<span className="publish-count-text">{activeServicesCount} activos</span>
|
|
</div>
|
|
|
|
<div className="service-summary-strip">
|
|
<span><strong>{account?.services.length || 0}</strong> total</span>
|
|
<span><strong>{activeServicesCount}</strong> visibles</span>
|
|
<span><strong>{draftServicesCount}</strong> borradores</span>
|
|
</div>
|
|
|
|
<div className="publish-tabs service-tabs">
|
|
<button className={serviceListFilter === "all" ? "is-active" : ""} type="button" onClick={() => setServiceListFilter("all")}>
|
|
Todos ({account?.services.length || 0})
|
|
</button>
|
|
<button className={serviceListFilter === "active" ? "is-active" : ""} type="button" onClick={() => setServiceListFilter("active")}>
|
|
Activos ({activeServicesCount})
|
|
</button>
|
|
<button className={serviceListFilter === "drafts" ? "is-active" : ""} type="button" onClick={() => setServiceListFilter("drafts")}>
|
|
Borradores ({draftServicesCount})
|
|
</button>
|
|
</div>
|
|
|
|
<div className="service-list">
|
|
{visibleServices.length ? null : <div className="empty-state">Todavia no hay servicios en esta vista.</div>}
|
|
{visibleServices.map((service) => {
|
|
const published = Boolean(service.is_published);
|
|
const price = service.price_from_cents ? `$${Math.round(toNumber(service.price_from_cents) / 100).toLocaleString("es-AR")}` : "A consultar";
|
|
const relatedWorks = account?.works?.filter((work) => String(work.service_id || "") === String(service.id)).length || 0;
|
|
|
|
return (
|
|
<article key={String(service.id)} className="service-manage-card">
|
|
<button className="service-main-row" type="button" onClick={() => editService(service)}>
|
|
<span className="service-icon">{toText(service.category, "3D").slice(0, 3).toUpperCase()}</span>
|
|
<div>
|
|
<strong>{toText(service.title, "Servicio sin titulo")}</strong>
|
|
<small>{toText(service.category, "Pendiente")} | Desde {price}</small>
|
|
<p>{toText(service.description, "Completa la descripcion del servicio.")}</p>
|
|
<div className="publish-mini-tags">
|
|
{(Array.isArray(service.materials) ? service.materials.map(String) : []).slice(0, 3).map((item) => <span key={item}>{item}</span>)}
|
|
{(Array.isArray(service.technologies) ? service.technologies.map(String) : []).slice(0, 2).map((item) => <span key={item}>{item}</span>)}
|
|
</div>
|
|
</div>
|
|
<span className={`status-pill ${published ? "success" : "warning"}`}>{published ? "Activo" : "Borrador"}</span>
|
|
</button>
|
|
<div className="service-card-footer">
|
|
<span>{relatedWorks} trabajos vinculados</span>
|
|
<div>
|
|
{published ? <a href={`/services/${toText(service.slug)}`} className="button button-ghost">Ver publico</a> : null}
|
|
{published ? <button type="button" onClick={() => void pauseService(service)}>Pausar</button> : <button type="button" onClick={() => editService(service)}>Retomar</button>}
|
|
<button type="button" onClick={() => void deleteService(service)}>Eliminar</button>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
</article>
|
|
|
|
<aside className="service-benefits-panel">
|
|
<article>
|
|
<span className="eyebrow">Beneficios</span>
|
|
<strong>Consultas mas calificadas</strong>
|
|
<p>Los servicios alimentan filtros, perfil publico y consulta guiada para que el cliente llegue con mejor contexto.</p>
|
|
</article>
|
|
<article>
|
|
<span className="eyebrow">Campos clave</span>
|
|
<div className="service-benefit-list">
|
|
<span>Tecnologia y materiales</span>
|
|
<span>Usos y condiciones</span>
|
|
<span>Precio orientativo</span>
|
|
<span>Entrega y cobertura</span>
|
|
</div>
|
|
</article>
|
|
</aside>
|
|
|
|
{serviceWizardOpen ? (
|
|
<div className="publish-wizard-overlay service-wizard-overlay" role="dialog" aria-modal="true" aria-label="Wizard para crear servicio">
|
|
<button className="publish-wizard-backdrop" type="button" onClick={closeServiceWizard} aria-label="Cerrar wizard de servicio" />
|
|
<div className="publish-wizard-modal service-wizard-modal">
|
|
<button className="publish-modal-close" type="button" onClick={closeServiceWizard}>Cerrar</button>
|
|
<form className="publish-wizard service-wizard" onSubmit={createService}>
|
|
<div className="publish-wizard-head">
|
|
{serviceStep > 0 ? (
|
|
<button className="publish-back" type="button" onClick={() => setServiceStep((current) => Math.max(current - 1, 0))}><</button>
|
|
) : (
|
|
<span className="publish-back-spacer" />
|
|
)}
|
|
<div>
|
|
<span className="eyebrow">{editingServiceId ? "Editar servicio" : "Nuevo servicio"}</span>
|
|
<h3>{serviceSteps[serviceStep]}</h3>
|
|
</div>
|
|
<button className="publish-next-link" type="button" onClick={goToNextServiceStep}>Siguiente</button>
|
|
</div>
|
|
|
|
<div className="publish-progress service-progress" aria-label={`Paso ${serviceStep + 1} de ${serviceSteps.length}`}>
|
|
{serviceSteps.map((step, index) => (
|
|
<button
|
|
key={step}
|
|
className={index === serviceStep ? "is-active" : index < serviceStep ? "is-done" : ""}
|
|
type="button"
|
|
onClick={() => setServiceStep(index)}
|
|
aria-label={step}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{renderServiceStep()}
|
|
|
|
<div className="publish-actions service-actions">
|
|
{serviceStep > 0 ? (
|
|
<button className="button button-secondary" type="button" onClick={() => setServiceStep((current) => Math.max(current - 1, 0))}>Anterior</button>
|
|
) : null}
|
|
<button className="button button-secondary" type="button" onClick={() => void saveService(false)}>Guardar borrador</button>
|
|
{serviceStep < serviceSteps.length - 1 ? (
|
|
<button className="button button-primary" type="button" onClick={goToNextServiceStep}>Siguiente</button>
|
|
) : (
|
|
<button className="button button-primary" type="submit">{serviceForm.visibility === "paused" ? "Guardar pausado" : "Publicar servicio"}</button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
|
|
<aside className="publish-side-panel service-side-panel">
|
|
<article>
|
|
<span className="eyebrow">Preview publico</span>
|
|
<strong>{serviceForm.title || "Tu servicio"}</strong>
|
|
<p>{serviceForm.description || "Completa los campos para construir una ficha clara y vendible."}</p>
|
|
<div className="publish-mini-tags">
|
|
{serviceForm.technologies.slice(0, 3).map((item) => <span key={item}>{item}</span>)}
|
|
{serviceForm.materials.slice(0, 3).map((item) => <span key={item}>{item}</span>)}
|
|
</div>
|
|
</article>
|
|
<article>
|
|
<span className="eyebrow">Consejos</span>
|
|
<div className="publish-tip">Define limites para evitar consultas que no puedes tomar.</div>
|
|
<div className="publish-tip">Mostrar un precio desde filtra mejor sin comprometer presupuesto final.</div>
|
|
<div className="publish-tip">Vincula trabajos publicados para ganar confianza rapido.</div>
|
|
</article>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
{section === "works" && (
|
|
<div className={`publish-workspace ${workWizardOpen ? "is-editing" : "is-list-only"}`}>
|
|
<article className="publish-work-list">
|
|
<div className="split">
|
|
<div>
|
|
<span className="eyebrow">Mis trabajos</span>
|
|
<h3 className="section-title">Portfolio publicado</h3>
|
|
</div>
|
|
<span className="publish-count-text">{publishedWorksCount} publicados</span>
|
|
</div>
|
|
<div className="publish-tabs">
|
|
<button className={workListFilter === "published" ? "is-active" : ""} type="button" onClick={() => setWorkListFilter("published")}>
|
|
Publicados {publishedWorksCount}
|
|
</button>
|
|
<button className={workListFilter === "drafts" ? "is-active" : ""} type="button" onClick={() => setWorkListFilter("drafts")}>
|
|
Borradores {draftWorksCount}
|
|
</button>
|
|
</div>
|
|
<div className="publish-work-items">
|
|
{visibleWorks.length ? null : <div className="empty-state">{workListFilter === "published" ? "Todavia no hay trabajos publicados." : "No tienes borradores guardados."}</div>}
|
|
{visibleWorks.slice(0, 8).map((work) => (
|
|
Boolean(work.is_published) ? (
|
|
<article key={String(work.id)} className="publish-work-manage-card">
|
|
<a className="publish-work-row" href={`/works/${toText(work.slug)}`}>
|
|
<img src={toText(work.image_url, "/demo/work-custom.svg")} alt={toText(work.title)} />
|
|
<div>
|
|
<strong>{toText(work.title)}</strong>
|
|
<span>Publicado | {toText(work.technology, "Pendiente")} | {toText(work.material, "Pendiente")}</span>
|
|
</div>
|
|
<b>></b>
|
|
</a>
|
|
<div className="publish-work-row-actions">
|
|
<button type="button" onClick={() => void unpublishWork(work)}>Despublicar</button>
|
|
<button type="button" onClick={() => void deleteWork(work)}>Eliminar</button>
|
|
</div>
|
|
</article>
|
|
) : (
|
|
<article key={String(work.id)} className="publish-work-manage-card">
|
|
<button className="publish-work-row" type="button" onClick={() => resumeDraft(work)}>
|
|
<img src={toText(work.image_url, "/demo/work-custom.svg")} alt={toText(work.title, "Borrador")} />
|
|
<div>
|
|
<strong>{toText(work.title, "Borrador sin titulo")}</strong>
|
|
<span>Borrador | {toText(work.technology, "Pendiente")} | {toText(work.material, "Pendiente")}</span>
|
|
</div>
|
|
<b>Retomar</b>
|
|
</button>
|
|
<div className="publish-work-row-actions">
|
|
<button type="button" onClick={() => resumeDraft(work)}>Retomar</button>
|
|
<button type="button" onClick={() => void deleteWork(work)}>Eliminar</button>
|
|
</div>
|
|
</article>
|
|
)
|
|
))}
|
|
</div>
|
|
</article>
|
|
|
|
{workWizardOpen ? (
|
|
<div className="publish-wizard-overlay" role="dialog" aria-modal="true" aria-label="Wizard para publicar trabajo">
|
|
<button className="publish-wizard-backdrop" type="button" onClick={closeWorkWizard} aria-label="Cerrar wizard de publicacion" />
|
|
<div className="publish-wizard-modal">
|
|
<button className="publish-modal-close" type="button" onClick={closeWorkWizard}>Cerrar</button>
|
|
<form className="publish-wizard" onSubmit={createWork}>
|
|
<div className="publish-wizard-head">
|
|
{workStep > 0 ? (
|
|
<button className="publish-back" type="button" onClick={() => setWorkStep((current) => Math.max(current - 1, 0))}><</button>
|
|
) : (
|
|
<span className="publish-back-spacer" />
|
|
)}
|
|
<div>
|
|
<span className="eyebrow">Nueva publicacion</span>
|
|
<h3>{publishSteps[workStep]}</h3>
|
|
</div>
|
|
<button className="publish-next-link" type="button" onClick={goToNextWorkStep}>Siguiente</button>
|
|
</div>
|
|
|
|
<div className="publish-progress" aria-label={`Paso ${workStep + 1} de ${publishSteps.length}`}>
|
|
{publishSteps.map((step, index) => (
|
|
<button
|
|
key={step}
|
|
className={index === workStep ? "is-active" : index < workStep ? "is-done" : ""}
|
|
type="button"
|
|
onClick={() => goToWorkStep(index)}
|
|
aria-label={step}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{renderPublishStep()}
|
|
|
|
<div className={`publish-actions ${workStep === 0 ? "is-first-step" : ""}`}>
|
|
{workStep > 0 ? (
|
|
<button className="button button-secondary" type="button" onClick={() => setWorkStep((current) => Math.max(current - 1, 0))}>Anterior</button>
|
|
) : null}
|
|
<button className="button button-secondary" type="button" onClick={() => void saveWork(false)}>Guardar borrador</button>
|
|
{workStep < publishSteps.length - 1 ? (
|
|
<button className="button button-primary" type="button" onClick={goToNextWorkStep}>Siguiente</button>
|
|
) : (
|
|
<button className="button button-primary" type="submit">Publicar ahora</button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
|
|
<aside className="publish-side-panel">
|
|
<article>
|
|
<span className="eyebrow">Preview rapido</span>
|
|
{workForm.imageUrl ? <img src={workForm.imageUrl} alt={workForm.title} /> : <span className="publish-image-placeholder">Sin portada</span>}
|
|
<strong>{workForm.title}</strong>
|
|
<p>{workForm.summary}</p>
|
|
<div className="publish-mini-tags">
|
|
<span>{workForm.technology}</span>
|
|
<span>{workForm.material}</span>
|
|
<span>{workForm.useCase}</span>
|
|
</div>
|
|
</article>
|
|
<article>
|
|
<span className="eyebrow">Consejos</span>
|
|
{publishTips.map((tip) => (
|
|
<div key={tip} className="publish-tip">ok {tip}</div>
|
|
))}
|
|
</article>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
{section === "inbox" && (
|
|
<div className="maker-inbox-grid">
|
|
<article className="maker-dash-panel stack">
|
|
<div className="split">
|
|
<h3 className="section-title">Bandeja de consultas</h3>
|
|
<span className="badge">{conversations.length} activas</span>
|
|
</div>
|
|
<div className="list">
|
|
{conversations.length === 0 ? <div className="empty-state">Aun no tienes consultas. Cuando llegue la primera aparecera aqui con su contexto.</div> : null}
|
|
{conversations.map((conversation) => (
|
|
<Link key={String(conversation.id)} className="timeline-item" href={`/account/inbox/${String(conversation.id)}`}>
|
|
<div>
|
|
<strong>{toText(conversation.business_name) || toText(conversation.customer_name, "Conversacion")}</strong>
|
|
<div className="muted">{toText(conversation.status, "open")} | {toText(conversation.source_type, "consulta")}</div>
|
|
</div>
|
|
<span className="status-pill primary">Abrir</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</article>
|
|
|
|
<article className="maker-dash-panel stack">
|
|
<div className="split">
|
|
<h3 className="section-title">Conversacion</h3>
|
|
{conversationDetail ? <span className="badge">{toText(conversationDetail.inquiry.status, "open")}</span> : null}
|
|
</div>
|
|
{conversationDetail ? (
|
|
<>
|
|
<div className="checklist-list">
|
|
<div className="checklist-item">
|
|
<strong>Necesidad</strong>
|
|
<span>{toText(conversationDetail.inquiry.need_text, "Sin detalle")}</span>
|
|
</div>
|
|
<div className="checklist-item">
|
|
<strong>Entrega</strong>
|
|
<span>{toText(conversationDetail.inquiry.delivery_type, "shipping")} | {toText(conversationDetail.inquiry.urgency, "medium")}</span>
|
|
</div>
|
|
</div>
|
|
<div className="message-thread">
|
|
{conversationDetail.messages.map((message) => (
|
|
<div key={String(message.id)} className={`message-bubble ${String(message.sender_user_id) === user.id ? "mine" : ""}`}>
|
|
<strong>{toText(message.full_name, "Usuario")}</strong>
|
|
<p>{toText(message.body)}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<form className="stack" onSubmit={sendReply}>
|
|
<textarea className="textarea" name="body" placeholder="Responder con contexto y proximo paso claro" />
|
|
<div className="split">
|
|
<div className="split" style={{ gap: 10 }}>
|
|
<button className="button button-secondary" type="button" onClick={markCompleted}>Marcar finalizado</button>
|
|
<button className="button button-ghost" type="button" onClick={rejectInquiry}>Rechazar</button>
|
|
</div>
|
|
<button className="button button-primary" type="submit">Responder</button>
|
|
</div>
|
|
</form>
|
|
</>
|
|
) : (
|
|
<div className="empty-state">Abre una consulta desde la bandeja para ver el historial, responder o cerrar el caso.</div>
|
|
)}
|
|
</article>
|
|
</div>
|
|
)}
|
|
|
|
{section === "reviews" && (
|
|
<div className="reviews-workspace">
|
|
<section className="reviews-main-column">
|
|
<article className="reviews-hero-card">
|
|
<div>
|
|
<span className="eyebrow">Vista publica</span>
|
|
<h3 className="section-title">Reputacion que genera confianza</h3>
|
|
<p>Opiniones reales, verificadas y transparentes para que el cliente pueda decidir sin friccion.</p>
|
|
</div>
|
|
<div className="reviews-score-block">
|
|
<strong>{reviewAverage ? reviewAverage.toFixed(1) : "0.0"}</strong>
|
|
<span className="reviews-stars">★★★★★</span>
|
|
<small>{reviewRows.length} opiniones | {Math.round((publishedReviews.length / Math.max(reviewRows.length, 1)) * 100)}% verificadas</small>
|
|
</div>
|
|
</article>
|
|
|
|
<div className="reviews-kpi-grid">
|
|
<article>
|
|
<strong>{completedConversations}</strong>
|
|
<span>trabajos finalizados</span>
|
|
</article>
|
|
<article>
|
|
<strong>{publishedReviews.length}</strong>
|
|
<span>reseñas visibles</span>
|
|
</article>
|
|
<article>
|
|
<strong>{reportedReviews.length}</strong>
|
|
<span>en revision</span>
|
|
</article>
|
|
</div>
|
|
|
|
<article className="reviews-panel">
|
|
<div className="split">
|
|
<div>
|
|
<span className="eyebrow">Distribucion</span>
|
|
<h3 className="section-title">Detalle de puntuaciones</h3>
|
|
</div>
|
|
<span className="status-pill success">Publico</span>
|
|
</div>
|
|
<div className="reviews-breakdown">
|
|
{reviewDistribution.map((item) => (
|
|
<div key={item.rating}>
|
|
<span>{item.rating} estrellas</span>
|
|
<b><i style={{ width: `${item.percent}%` }} /></b>
|
|
<small>{item.count}</small>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="reviews-aspect-grid">
|
|
{aspectScores.map(([label, score]) => (
|
|
<article key={label}>
|
|
<span>{label}</span>
|
|
<strong>{score ? score.toFixed(1) : "0.0"}</strong>
|
|
</article>
|
|
))}
|
|
</div>
|
|
</article>
|
|
|
|
<article className="reviews-panel">
|
|
<div className="split">
|
|
<div>
|
|
<span className="eyebrow">Lista de reseñas</span>
|
|
<h3 className="section-title">Opiniones publicas</h3>
|
|
</div>
|
|
<div className="reviews-filter-pills">
|
|
<span>Todas</span>
|
|
<span>Verificadas</span>
|
|
<span>Con fotos</span>
|
|
</div>
|
|
</div>
|
|
<div className="reviews-list">
|
|
{reviewRows.length ? null : <div className="empty-state">Las resenas aparecen cuando un cliente marca un trabajo como finalizado y deja su opinion.</div>}
|
|
{reviewRows.map((review) => {
|
|
const statusValue = toText(review.status, "published");
|
|
const isReported = statusValue === "reported";
|
|
return (
|
|
<article key={String(review.id)} className={`reviews-item ${isReported ? "is-reported" : ""}`}>
|
|
<div className="reviews-item-head">
|
|
<div>
|
|
<strong>{toNumber(review.rating_overall, 0).toFixed(1)} ★</strong>
|
|
<span>{isReported ? "En revision" : "Trabajo verificado"}</span>
|
|
</div>
|
|
<small>{new Date(toText(review.created_at, new Date().toISOString())).toLocaleDateString("es-AR")}</small>
|
|
</div>
|
|
<p>{toText(review.comment, "Sin comentario publico.")}</p>
|
|
<div className="reviews-item-tags">
|
|
<span>Calidad {toNumber(review.rating_quality, 0)}</span>
|
|
<span>Comunicacion {toNumber(review.rating_communication, 0)}</span>
|
|
<span>Precio {toNumber(review.rating_value, 0)}</span>
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
</article>
|
|
</section>
|
|
|
|
<aside className="reviews-side-column">
|
|
<article className="reviews-panel">
|
|
<span className="eyebrow">Incidencias</span>
|
|
<h3 className="section-title">Problemas y revisiones</h3>
|
|
<div className="reviews-status-list">
|
|
<div><strong>Nueva incidencia</strong><span>Se resuelve antes de publicar una reseña.</span></div>
|
|
<div><strong>Reseña reportada</strong><span>El equipo revisa evidencia y contexto.</span></div>
|
|
<div><strong>Problema resuelto</strong><span>La reseña conserva transparencia.</span></div>
|
|
</div>
|
|
<Link className="button button-secondary" href="/account/inbox">Ver conversaciones</Link>
|
|
</article>
|
|
|
|
<article className="reviews-panel">
|
|
<span className="eyebrow">Configuracion maker</span>
|
|
<h3 className="section-title">Como mostrar reseñas</h3>
|
|
<div className="reviews-toggle-list">
|
|
<label>
|
|
<input type="checkbox" checked={reviewSettings.allowVerifiedReviews} onChange={() => void toggleReviewSetting("allowVerifiedReviews")} />
|
|
<span>Permitir reseñas de trabajos verificados</span>
|
|
</label>
|
|
<label>
|
|
<input type="checkbox" checked={reviewSettings.showSatisfactionScore} onChange={() => void toggleReviewSetting("showSatisfactionScore")} />
|
|
<span>Mostrar porcentaje de satisfaccion</span>
|
|
</label>
|
|
<label>
|
|
<input type="checkbox" checked={reviewSettings.showReviewTags} onChange={() => void toggleReviewSetting("showReviewTags")} />
|
|
<span>Mostrar etiquetas destacadas</span>
|
|
</label>
|
|
<label>
|
|
<input type="checkbox" checked={reviewSettings.allowMakerReviewReply} onChange={() => void toggleReviewSetting("allowMakerReviewReply")} />
|
|
<span>Permitir respuesta del maker</span>
|
|
</label>
|
|
<label>
|
|
<input type="checkbox" checked={reviewSettings.showPastReviews} onChange={() => void toggleReviewSetting("showPastReviews")} />
|
|
<span>Mostrar reseñas anteriores en mi perfil publico</span>
|
|
</label>
|
|
</div>
|
|
</article>
|
|
|
|
<article className="reviews-panel">
|
|
<span className="eyebrow">Sellos y confianza</span>
|
|
<div className="reviews-badge-list">
|
|
{reputationBadges.map(([label, text]) => (
|
|
<div key={label}>
|
|
<strong>{label}</strong>
|
|
<span>{text}</span>
|
|
<small>{reviewSettings.showReviewTags ? "Visible en perfil publico" : "Oculto por configuracion"}</small>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</article>
|
|
|
|
<article className="reviews-panel">
|
|
<span className="eyebrow">Politica</span>
|
|
<h3 className="section-title">Como usamos las reseñas</h3>
|
|
<p>Nunca eliminamos criticas legitimas por pedido del maker. Priorizamos evidencia, transparencia y resolucion de problemas.</p>
|
|
</article>
|
|
</aside>
|
|
|
|
{subscriptionBillingOpen ? (
|
|
<div className="publish-wizard-overlay subscription-billing-overlay" role="dialog" aria-modal="true" aria-label="Wizard de datos y pago">
|
|
<button className="publish-wizard-backdrop" type="button" onClick={closeSubscriptionBillingWizard} aria-label="Cerrar datos y pago" />
|
|
<div className="publish-wizard-modal subscription-billing-modal">
|
|
<button className="publish-modal-close" type="button" onClick={closeSubscriptionBillingWizard}>Cerrar</button>
|
|
<form
|
|
className="publish-wizard subscription-billing-wizard"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
if (subscriptionBillingStep < subscriptionBillingSteps.length - 1) {
|
|
goToNextSubscriptionBillingStep();
|
|
} else {
|
|
saveSubscriptionBilling();
|
|
}
|
|
}}
|
|
>
|
|
<div className="publish-wizard-head">
|
|
{subscriptionBillingStep > 0 ? (
|
|
<button className="publish-back" type="button" onClick={() => setSubscriptionBillingStep((current) => Math.max(current - 1, 0))}><</button>
|
|
) : (
|
|
<span className="publish-back-spacer" />
|
|
)}
|
|
<div>
|
|
<span className="eyebrow">Datos y pago</span>
|
|
<h3>{subscriptionBillingSteps[subscriptionBillingStep]}</h3>
|
|
</div>
|
|
<button
|
|
className="button button-secondary"
|
|
type="submit"
|
|
>
|
|
{subscriptionBillingStep < subscriptionBillingSteps.length - 1 ? "Siguiente" : "Guardar"}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="publish-progress">
|
|
{subscriptionBillingSteps.map((step, index) => (
|
|
<span key={step} className={index <= subscriptionBillingStep ? "active" : ""} />
|
|
))}
|
|
</div>
|
|
|
|
<article className="publish-step-card">
|
|
{subscriptionBillingStep === 0 ? (
|
|
<div className="subscription-form-grid">
|
|
<label>
|
|
<span>Tipo de factura</span>
|
|
<select className="select" value={subscriptionBillingForm.invoiceType} onChange={(event) => updateSubscriptionBillingForm("invoiceType", event.target.value)}>
|
|
<option value="person">Persona fisica</option>
|
|
<option value="company">Empresa</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>Nombre o razon social</span>
|
|
<input className="field" value={subscriptionBillingForm.name} onChange={(event) => updateSubscriptionBillingForm("name", event.target.value)} placeholder="Juan Perez" />
|
|
</label>
|
|
<label>
|
|
<span>CUIT / DNI</span>
|
|
<input className="field" value={subscriptionBillingForm.taxId} onChange={(event) => updateSubscriptionBillingForm("taxId", event.target.value)} placeholder="20-12345678-9" />
|
|
</label>
|
|
<label>
|
|
<span>Email de facturacion</span>
|
|
<input className="field" value={subscriptionBillingForm.email} onChange={(event) => updateSubscriptionBillingForm("email", event.target.value)} placeholder={user.email} />
|
|
</label>
|
|
<label className="subscription-wide-field">
|
|
<span>Domicilio fiscal</span>
|
|
<input className="field" value={subscriptionBillingForm.address} onChange={(event) => updateSubscriptionBillingForm("address", event.target.value)} placeholder="Calle, numero, ciudad" />
|
|
</label>
|
|
</div>
|
|
) : subscriptionBillingStep === 1 ? (
|
|
<div className="subscription-form-grid">
|
|
<label>
|
|
<span>Metodo de pago</span>
|
|
<select className="select" value={subscriptionBillingForm.paymentMethod} onChange={(event) => updateSubscriptionBillingForm("paymentMethod", event.target.value)}>
|
|
<option value="visa">VISA</option>
|
|
<option value="mastercard">Mastercard</option>
|
|
<option value="mercadopago">MercadoPago</option>
|
|
<option value="transferencia">Transferencia</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>Ultimos 4 digitos</span>
|
|
<input className="field" inputMode="numeric" maxLength={4} value={subscriptionBillingForm.cardLast4} onChange={(event) => updateSubscriptionBillingForm("cardLast4", event.target.value.replace(/\D/g, "").slice(0, 4))} placeholder="4242" />
|
|
</label>
|
|
<div className="subscription-validation-note subscription-wide-field">
|
|
<strong>Validacion requerida</strong>
|
|
<span>En produccion este paso lo confirma la pasarela. En este MVP validamos formato y confirmacion del maker.</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="subscription-validation-card">
|
|
<strong>Confirma que quieres aplicar estos datos</strong>
|
|
<span>Para probar el flujo, escribe el codigo demo <b>1234</b>. Ningun dato sensible real se procesa en esta version.</span>
|
|
<input className="field" inputMode="numeric" value={subscriptionBillingForm.validationCode} onChange={(event) => updateSubscriptionBillingForm("validationCode", event.target.value.replace(/\D/g, "").slice(0, 4))} placeholder="Codigo demo" />
|
|
<label>
|
|
<input type="checkbox" checked={subscriptionBillingForm.acceptedValidation} onChange={(event) => updateSubscriptionBillingForm("acceptedValidation", event.target.checked)} />
|
|
<span>Confirmo que los datos son correctos y autorizo usarlos para la suscripcion demo.</span>
|
|
</label>
|
|
</div>
|
|
)}
|
|
</article>
|
|
|
|
<div className="publish-actions">
|
|
{subscriptionBillingStep > 0 ? (
|
|
<button className="button button-secondary" type="button" onClick={() => setSubscriptionBillingStep((current) => Math.max(current - 1, 0))}>Anterior</button>
|
|
) : null}
|
|
<button className="button button-primary" type="submit">
|
|
{subscriptionBillingStep < subscriptionBillingSteps.length - 1 ? "Siguiente" : "Guardar datos validados"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
{section === "subscription" && (
|
|
<div className="subscription-workspace">
|
|
<section className="subscription-main-column">
|
|
<article className="subscription-hero-card">
|
|
<div>
|
|
<span className="eyebrow">Activar mi espacio maker</span>
|
|
<h3 className="section-title">{isSubscriptionActive ? "Tu perfil esta publicado" : "Prepara tu perfil para salir al mapa"}</h3>
|
|
<p className="muted">Un unico plan para presencia profesional, trabajos, servicios, escaparates, consultas y resenas verificadas.</p>
|
|
</div>
|
|
<div className="subscription-readiness">
|
|
<div className="subscription-ring" style={{ "--progress": `${readinessPercent}%` } as React.CSSProperties}>
|
|
<strong>{readinessPercent}%</strong>
|
|
<span>listo</span>
|
|
</div>
|
|
<span className={`status-pill ${isSubscriptionActive ? "success" : "warning"}`}>
|
|
{isSubscriptionActive ? "Plan activo" : "Plan pendiente"}
|
|
</span>
|
|
</div>
|
|
</article>
|
|
|
|
<article className="subscription-panel">
|
|
<div className="split">
|
|
<div>
|
|
<span className="eyebrow">Checklist de publicacion</span>
|
|
<h3 className="section-title">Crea y publica sin pasos confusos</h3>
|
|
</div>
|
|
<span className="publish-count-text">{checklistDoneCount}/{checklistTotal} completos</span>
|
|
</div>
|
|
<div className="subscription-check-grid">
|
|
{checklistItems.map(([key, value]) => {
|
|
const item = subscriptionChecklistLabels[key] || { label: key, detail: "Requisito para publicar." };
|
|
return (
|
|
<Link
|
|
key={key}
|
|
href={
|
|
key === "hasService" ? "/account/services" :
|
|
key === "hasWork" ? "/account/works" :
|
|
key === "hasLocation" ? "/account/locations" :
|
|
key === "hasSubscription" ? "/account/subscription" :
|
|
"/account/profile"
|
|
}
|
|
className={`subscription-check-item ${value ? "is-done" : ""}`}
|
|
>
|
|
<span>{value ? "OK" : "!"}</span>
|
|
<div>
|
|
<strong>{item.label}</strong>
|
|
<small>{item.detail}</small>
|
|
</div>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="subscription-action-row">
|
|
<button className="button button-primary" type="button" onClick={activateDemoSubscription}>
|
|
{isSubscriptionActive ? "Renovar plan demo" : "Activar plan demo"}
|
|
</button>
|
|
<button className="button button-secondary" type="button" onClick={requestPublish}>Publicar mi perfil</button>
|
|
</div>
|
|
</article>
|
|
|
|
<article className="subscription-panel">
|
|
<span className="eyebrow">Vista previa publica</span>
|
|
<div className="subscription-preview-card">
|
|
<img src={profileForm.mainImageUrl || "/demo/maker-hero-custom.svg"} alt={makerName} />
|
|
<div>
|
|
<strong>{makerName}</strong>
|
|
<span>{profileForm.city || "Cordoba"}, {profileForm.province || "Argentina"}</span>
|
|
<small>4.9 estrellas | {account?.works.length || 0} trabajos | {account?.services.length || 0} servicios</small>
|
|
<div className="publish-mini-tags">
|
|
<span>{profileForm.availability === "available" ? "Aceptando trabajos" : "Disponibilidad limitada"}</span>
|
|
<span>{hasNationwideLocation ? "Envios a todo el pais" : "Cobertura local"}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="subscription-action-row">
|
|
<Link className="button button-secondary" href="/account/profile">Editar perfil</Link>
|
|
<Link className="button button-primary" href={publicProfileHref}>Ver perfil publico</Link>
|
|
</div>
|
|
</article>
|
|
|
|
<article className="subscription-panel">
|
|
<div className="split">
|
|
<div>
|
|
<span className="eyebrow">Plan y facturacion</span>
|
|
<h3 className="section-title">Plan Maker</h3>
|
|
</div>
|
|
<span className={`status-pill ${isSubscriptionActive ? "success" : "warning"}`}>{planStatus}</span>
|
|
</div>
|
|
<div className="subscription-plan-card">
|
|
<div>
|
|
<strong>${subscriptionPrice} ARS</strong>
|
|
<span>por {subscriptionPeriod} | renovacion automatica demo</span>
|
|
</div>
|
|
<div className="subscription-cycle-toggle">
|
|
<button className={subscriptionCycle === "monthly" ? "is-active" : ""} type="button" onClick={() => setSubscriptionCycle("monthly")}>Mensual</button>
|
|
<button className={subscriptionCycle === "annual" ? "is-active" : ""} type="button" onClick={() => setSubscriptionCycle("annual")}>Anual -15%</button>
|
|
</div>
|
|
</div>
|
|
<div className="subscription-benefit-list">
|
|
{planBenefits.map((benefit) => (
|
|
<span key={benefit}>OK {benefit}</span>
|
|
))}
|
|
</div>
|
|
</article>
|
|
|
|
<article className="subscription-panel">
|
|
<div className="split">
|
|
<div>
|
|
<span className="eyebrow">Datos y pago</span>
|
|
<h3 className="section-title">Informacion validada</h3>
|
|
</div>
|
|
<span className="status-pill success">Verificado</span>
|
|
</div>
|
|
<div className="subscription-readonly-grid">
|
|
<div>
|
|
<span>Tipo de factura</span>
|
|
<strong>{subscriptionBillingForm.invoiceType === "company" ? "Empresa" : "Persona fisica"}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Responsable</span>
|
|
<strong>{subscriptionBillingForm.name}</strong>
|
|
</div>
|
|
<div>
|
|
<span>CUIT / DNI</span>
|
|
<strong>{subscriptionBillingForm.taxId}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Email fiscal</span>
|
|
<strong>{subscriptionBillingForm.email || profileForm.publicContactEmail || user.email}</strong>
|
|
</div>
|
|
</div>
|
|
<div className="subscription-payment-card">
|
|
<div>
|
|
<strong>{subscriptionBillingForm.paymentMethod.toUpperCase()} terminada en {subscriptionBillingForm.cardLast4}</strong>
|
|
<span>Los cambios pasan por validacion antes de aplicarse. En produccion se integra pasarela local.</span>
|
|
</div>
|
|
<button className="button button-secondary" type="button" onClick={() => openSubscriptionBillingWizard(1)}>Cambiar pago</button>
|
|
</div>
|
|
<div className="subscription-action-row">
|
|
<button className="button button-primary" type="button" onClick={() => openSubscriptionBillingWizard(0)}>Editar datos</button>
|
|
<button className="button button-secondary" type="button" onClick={() => openSubscriptionBillingWizard(2)}>Validar cambios</button>
|
|
</div>
|
|
</article>
|
|
|
|
<article className="subscription-panel">
|
|
<div className="split">
|
|
<div>
|
|
<span className="eyebrow">Historial</span>
|
|
<h3 className="section-title">Pagos, facturas y renovaciones</h3>
|
|
</div>
|
|
<button className="button button-secondary" type="button" onClick={() => setStatus("Factura demo disponible en produccion.")}>Ver facturas</button>
|
|
</div>
|
|
<div className="subscription-billing-list">
|
|
{billingEvents.map((event) => (
|
|
<div key={`${event.date}-${event.event}`}>
|
|
<span>{event.date}</span>
|
|
<strong>{event.event}</strong>
|
|
<small>{event.amount}</small>
|
|
<em className={event.status === "Pagado" ? "is-paid" : "is-failed"}>{event.status}</em>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</article>
|
|
</section>
|
|
|
|
<aside className="subscription-side-column">
|
|
<article className="subscription-panel subscription-state-panel">
|
|
<span className="eyebrow">Estados del plan</span>
|
|
<h3 className="section-title">Simular escenarios</h3>
|
|
<div className="subscription-scenario-tabs">
|
|
{[
|
|
["active", "Activo"],
|
|
["grace", "Pago fallido"],
|
|
["suspending", "Por suspender"],
|
|
["inactive", "Inactivo"]
|
|
].map(([value, label]) => (
|
|
<button
|
|
key={value}
|
|
className={subscriptionScenario === value ? "is-active" : ""}
|
|
type="button"
|
|
onClick={() => setSubscriptionScenario(value as typeof subscriptionScenario)}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className={`subscription-status-demo is-${subscriptionScenario}`}>
|
|
{subscriptionScenario === "active" ? (
|
|
<>
|
|
<strong>Perfil visible y operativo</strong>
|
|
<span>Proxima renovacion: 12 de agosto de 2026.</span>
|
|
</>
|
|
) : subscriptionScenario === "grace" ? (
|
|
<>
|
|
<strong>Problema con tu pago</strong>
|
|
<span>Tienes 7 dias de gracia para actualizar el metodo de pago.</span>
|
|
</>
|
|
) : subscriptionScenario === "suspending" ? (
|
|
<>
|
|
<strong>Ultimo aviso</strong>
|
|
<span>Tu perfil dejaria de aparecer publicamente si no se regulariza.</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<strong>Tu perfil no esta publicado</strong>
|
|
<span>Trabajos, resenas y datos se conservan para reactivar luego.</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
<button className="button button-primary" type="button" onClick={activateDemoSubscription}>Reactivar / actualizar pago</button>
|
|
</article>
|
|
|
|
<article className="subscription-panel">
|
|
<span className="eyebrow">Maker fundador</span>
|
|
<div className="subscription-founder-card">
|
|
<strong>Beneficio fundador</strong>
|
|
<span>Precio especial para primeros makers, distintivo fundador y acceso anticipado.</span>
|
|
<small>Vigencia estimada: 12 meses</small>
|
|
</div>
|
|
</article>
|
|
|
|
<article className="subscription-panel">
|
|
<span className="eyebrow">Complementos</span>
|
|
<div className="subscription-addon-list">
|
|
{subscriptionAddOns.map((addon) => (
|
|
<div key={addon.title}>
|
|
<strong>{addon.title}</strong>
|
|
<span>{addon.detail}</span>
|
|
<button className="button button-secondary" type="button" onClick={() => setStatus(`${addon.title} quedara disponible mas adelante.`)}>{addon.state}</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</article>
|
|
|
|
<article className="subscription-panel">
|
|
<span className="eyebrow">Promocionar mi perfil</span>
|
|
<p className="muted">Modulo futuro para aparecer mas cuando el maker ya tenga perfil completo y buena reputacion. No altera resenas ni posicionamiento organico.</p>
|
|
<button className="button button-secondary" type="button" onClick={() => setStatus("Promocion demo registrada para futuras pruebas.")}>Explorar promocion</button>
|
|
</article>
|
|
|
|
<article className="subscription-panel">
|
|
<span className="eyebrow">Garantias del plan</span>
|
|
<div className="subscription-warning-list">
|
|
<span>No garantizamos trabajos ni ingresos.</span>
|
|
<span>No manipulamos resenas ni ranking.</span>
|
|
<span>No cobramos comisiones ocultas en el MVP.</span>
|
|
<span>Los clientes siempre ven informacion identificable del maker.</span>
|
|
</div>
|
|
</article>
|
|
|
|
<article className="subscription-panel">
|
|
<span className="eyebrow">Soporte y ayuda</span>
|
|
<div className="subscription-support-grid">
|
|
{supportCards.map((card) => (
|
|
<button key={card.title} type="button" onClick={() => setStatus(`${card.action}: demo sin canal externo todavia.`)}>
|
|
<strong>{card.title}</strong>
|
|
<span>{card.detail}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</article>
|
|
</aside>
|
|
|
|
{subscriptionBillingOpen ? (
|
|
<div className="publish-wizard-overlay subscription-billing-overlay" role="dialog" aria-modal="true" aria-label="Wizard de datos y pago">
|
|
<button className="publish-wizard-backdrop" type="button" onClick={closeSubscriptionBillingWizard} aria-label="Cerrar datos y pago" />
|
|
<div className="publish-wizard-modal subscription-billing-modal">
|
|
<button className="publish-modal-close" type="button" onClick={closeSubscriptionBillingWizard}>Cerrar</button>
|
|
<form
|
|
className="publish-wizard subscription-billing-wizard"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
if (subscriptionBillingStep < subscriptionBillingSteps.length - 1) {
|
|
goToNextSubscriptionBillingStep();
|
|
} else {
|
|
saveSubscriptionBilling();
|
|
}
|
|
}}
|
|
>
|
|
<div className="publish-wizard-head">
|
|
{subscriptionBillingStep > 0 ? (
|
|
<button className="publish-back" type="button" onClick={() => setSubscriptionBillingStep((current) => Math.max(current - 1, 0))}><</button>
|
|
) : (
|
|
<span className="publish-back-spacer" />
|
|
)}
|
|
<div>
|
|
<span className="eyebrow">Datos y pago</span>
|
|
<h3>{subscriptionBillingSteps[subscriptionBillingStep]}</h3>
|
|
</div>
|
|
<button className="button button-secondary" type="submit">
|
|
{subscriptionBillingStep < subscriptionBillingSteps.length - 1 ? "Siguiente" : "Guardar"}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="publish-progress">
|
|
{subscriptionBillingSteps.map((step, index) => (
|
|
<span key={step} className={index <= subscriptionBillingStep ? "active" : ""} />
|
|
))}
|
|
</div>
|
|
|
|
<article className="publish-step-card">
|
|
{subscriptionBillingStep === 0 ? (
|
|
<div className="subscription-form-grid">
|
|
<label>
|
|
<span>Tipo de factura</span>
|
|
<select className="select" value={subscriptionBillingForm.invoiceType} onChange={(event) => updateSubscriptionBillingForm("invoiceType", event.target.value)}>
|
|
<option value="person">Persona fisica</option>
|
|
<option value="company">Empresa</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>Nombre o razon social</span>
|
|
<input className="field" value={subscriptionBillingForm.name} onChange={(event) => updateSubscriptionBillingForm("name", event.target.value)} placeholder="Juan Perez" />
|
|
</label>
|
|
<label>
|
|
<span>CUIT / DNI</span>
|
|
<input className="field" value={subscriptionBillingForm.taxId} onChange={(event) => updateSubscriptionBillingForm("taxId", event.target.value)} placeholder="20-12345678-9" />
|
|
</label>
|
|
<label>
|
|
<span>Email de facturacion</span>
|
|
<input className="field" value={subscriptionBillingForm.email} onChange={(event) => updateSubscriptionBillingForm("email", event.target.value)} placeholder={user.email} />
|
|
</label>
|
|
<label className="subscription-wide-field">
|
|
<span>Domicilio fiscal</span>
|
|
<input className="field" value={subscriptionBillingForm.address} onChange={(event) => updateSubscriptionBillingForm("address", event.target.value)} placeholder="Calle, numero, ciudad" />
|
|
</label>
|
|
</div>
|
|
) : subscriptionBillingStep === 1 ? (
|
|
<div className="subscription-form-grid">
|
|
<label>
|
|
<span>Metodo de pago</span>
|
|
<select className="select" value={subscriptionBillingForm.paymentMethod} onChange={(event) => updateSubscriptionBillingForm("paymentMethod", event.target.value)}>
|
|
<option value="visa">VISA</option>
|
|
<option value="mastercard">Mastercard</option>
|
|
<option value="mercadopago">MercadoPago</option>
|
|
<option value="transferencia">Transferencia</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>Ultimos 4 digitos</span>
|
|
<input className="field" inputMode="numeric" maxLength={4} value={subscriptionBillingForm.cardLast4} onChange={(event) => updateSubscriptionBillingForm("cardLast4", event.target.value.replace(/\D/g, "").slice(0, 4))} placeholder="4242" />
|
|
</label>
|
|
<div className="subscription-validation-note subscription-wide-field">
|
|
<strong>Validacion requerida</strong>
|
|
<span>En produccion este paso lo confirma la pasarela. En este MVP validamos formato y confirmacion del maker.</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="subscription-validation-card">
|
|
<strong>Confirma que quieres aplicar estos datos</strong>
|
|
<span>Para probar el flujo, escribe el codigo demo <b>1234</b>. Ningun dato sensible real se procesa en esta version.</span>
|
|
<input className="field" inputMode="numeric" value={subscriptionBillingForm.validationCode} onChange={(event) => updateSubscriptionBillingForm("validationCode", event.target.value.replace(/\D/g, "").slice(0, 4))} placeholder="Codigo demo" />
|
|
<label>
|
|
<input type="checkbox" checked={subscriptionBillingForm.acceptedValidation} onChange={(event) => updateSubscriptionBillingForm("acceptedValidation", event.target.checked)} />
|
|
<span>Confirmo que los datos son correctos y autorizo usarlos para la suscripcion demo.</span>
|
|
</label>
|
|
</div>
|
|
)}
|
|
</article>
|
|
|
|
<div className="publish-actions">
|
|
{subscriptionBillingStep > 0 ? (
|
|
<button className="button button-secondary" type="button" onClick={() => setSubscriptionBillingStep((current) => Math.max(current - 1, 0))}>Anterior</button>
|
|
) : null}
|
|
<button className="button button-primary" type="submit">
|
|
{subscriptionBillingStep < subscriptionBillingSteps.length - 1 ? "Siguiente" : "Guardar datos validados"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
{section === "settings" && (
|
|
<div className="account-settings-workspace">
|
|
<article className="account-settings-card account-settings-summary">
|
|
<div className="account-identity-row">
|
|
<img src={personalAccountForm.avatarUrl} alt="Avatar de cuenta" />
|
|
<div>
|
|
<span className="eyebrow">Mi cuenta</span>
|
|
<h3 className="section-title">{personalAccountForm.firstName} {personalAccountForm.lastName}</h3>
|
|
<p>{user.email}</p>
|
|
</div>
|
|
<span className="status-pill success">Activa</span>
|
|
</div>
|
|
<div className="account-activity-grid">
|
|
{accountActivityRows.map(([label, value]) => (
|
|
<div key={label}>
|
|
<strong>{value}</strong>
|
|
<span>{label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="account-quick-list">
|
|
{accountQuickAccess.map(([label, detail]) => (
|
|
<button key={label} type="button" onClick={() => setStatus(`${label}: seccion visible en esta pantalla.`)}>
|
|
<strong>{label}</strong>
|
|
<span>{detail}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="account-maker-mini">
|
|
<span className="service-icon">M3D</span>
|
|
<div>
|
|
<strong>{makerName}</strong>
|
|
<span>{account?.maker.status === "published" ? "Perfil publicado" : "Perfil en preparacion"}</span>
|
|
</div>
|
|
<Link className="button button-primary" href="/account">Ir a mi espacio Maker</Link>
|
|
</div>
|
|
</article>
|
|
|
|
<article className="account-settings-card">
|
|
<span className="eyebrow">Datos personales</span>
|
|
<div className="account-settings-form">
|
|
<label><span>Nombre</span><input className="field" value={personalAccountForm.firstName} onChange={(event) => updatePersonalAccountField("firstName", event.target.value)} /></label>
|
|
<label><span>Apellido</span><input className="field" value={personalAccountForm.lastName} onChange={(event) => updatePersonalAccountField("lastName", event.target.value)} /></label>
|
|
<label><span>Nombre publico</span><input className="field" value={personalAccountForm.publicName} onChange={(event) => updatePersonalAccountField("publicName", event.target.value)} /></label>
|
|
<label><span>Email</span><input className="field" value={user.email} readOnly /></label>
|
|
<label><span>Telefono</span><input className="field" value={personalAccountForm.phone} onChange={(event) => updatePersonalAccountField("phone", event.target.value)} /></label>
|
|
</div>
|
|
<div className="account-avatar-editor">
|
|
<img src={personalAccountForm.avatarUrl} alt="Avatar" />
|
|
<div>
|
|
<strong>Avatar de cuenta</strong>
|
|
<span>Se usa para reseñas, mensajes y panel maker.</span>
|
|
</div>
|
|
<button className="button button-secondary" type="button" onClick={() => setStatus("Cambio de avatar demo.")}>Cambiar</button>
|
|
</div>
|
|
<button className="button button-primary" type="button" onClick={saveAccountSettings}>Guardar cambios</button>
|
|
</article>
|
|
|
|
<article className="account-settings-card">
|
|
<span className="eyebrow">Seguridad y acceso</span>
|
|
<div className="account-security-list">
|
|
<div><strong>Contrasena</strong><span>Ultimo cambio hace 4 meses</span><button type="button" onClick={() => setStatus("Cambio de contrasena demo.")}>Cambiar</button></div>
|
|
<div><strong>Verificacion en dos pasos</strong><span>No activada</span><button type="button" onClick={() => setStatus("2FA demo activado.")}>Activar</button></div>
|
|
<div><strong>Sesiones activas</strong><span>{activeSessionRows.length} dispositivos</span><button type="button" onClick={() => setStatus("Sesiones visibles abajo.")}>Ver</button></div>
|
|
</div>
|
|
<div className="account-session-list compact">
|
|
{activeSessionRows.map((session) => (
|
|
<div key={session.device}>
|
|
<strong>{session.device}</strong>
|
|
<span>{session.place}</span>
|
|
<small>{session.status}</small>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button className="button button-secondary danger" type="button" onClick={() => setStatus("Las demas sesiones se cerrarian en produccion.")}>Cerrar todas las demas sesiones</button>
|
|
</article>
|
|
|
|
<article className="account-settings-card">
|
|
<span className="eyebrow">Cuentas vinculadas</span>
|
|
<div className="account-linked-list">
|
|
{linkedAccounts.map(([label, state, action]) => (
|
|
<div key={label}>
|
|
<div>
|
|
<strong>{label}</strong>
|
|
<span>{state}</span>
|
|
</div>
|
|
<button type="button" onClick={() => setStatus(`${label}: ${action.toLowerCase()} demo.`)}>{action}</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p className="muted">Las cuentas de acceso sirven para iniciar sesion. Las redes se muestran solo si decides publicarlas en tu perfil maker.</p>
|
|
</article>
|
|
|
|
<article className="account-settings-card">
|
|
<span className="eyebrow">Privacidad y ubicacion</span>
|
|
<div className="account-toggle-list">
|
|
<label><input type="checkbox" checked={privacyPrefs.useLocation} onChange={() => togglePrivacyPref("useLocation")} /><span>Usar mi ubicacion solo mientras uso la aplicacion</span></label>
|
|
<label><input type="checkbox" checked={privacyPrefs.searchHistory} onChange={() => togglePrivacyPref("searchHistory")} /><span>Guardar historial de busquedas</span></label>
|
|
<label><input type="checkbox" checked={privacyPrefs.personalizedResults} onChange={() => togglePrivacyPref("personalizedResults")} /><span>Personalizar resultados</span></label>
|
|
<label><input type="checkbox" checked={privacyPrefs.profileVisible} onChange={() => togglePrivacyPref("profileVisible")} /><span>Mostrar mi perfil maker publicamente</span></label>
|
|
</div>
|
|
<div className="account-saved-locations">
|
|
{locationRows.slice(0, 3).map((location) => (
|
|
<div key={String(location.id)}>
|
|
<strong>{toText(location.label, "Ubicacion")}</strong>
|
|
<span>{toText(location.city, "Ciudad")}, {toText(location.province, "Provincia")}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<Link className="button button-secondary" href="/account/locations">Administrar ubicaciones</Link>
|
|
</article>
|
|
|
|
<article className="account-settings-card">
|
|
<span className="eyebrow">Notificaciones</span>
|
|
<div className="account-notification-summary">
|
|
{notificationSummaryRows.map(([label, enabled]) => (
|
|
<div key={label}>
|
|
<strong>{label}</strong>
|
|
<span className={enabled ? "is-on" : "is-off"}>{enabled ? "Activadas" : "Desactivadas"}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="account-radio-group">
|
|
<strong>Resumen de actividad</strong>
|
|
{["Inmediato recomendado", "Resumen diario", "Resumen semanal", "Desactivado"].map((label) => (
|
|
<label key={label}><input name="activity-summary" type="radio" defaultChecked={label === "Inmediato recomendado"} /><span>{label}</span></label>
|
|
))}
|
|
</div>
|
|
</article>
|
|
|
|
<article className="account-settings-card">
|
|
<span className="eyebrow">Notificaciones detalle</span>
|
|
<div className="account-toggle-list">
|
|
{notificationRows.map(([key, label, group]) => (
|
|
<label key={key}>
|
|
<input type="checkbox" checked={notificationPrefs[key]} onChange={() => toggleNotificationPref(key)} />
|
|
<span>{label}<small>{group}</small></span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
<div className="account-channel-grid">
|
|
{notificationChannelOptions.map((channel) => (
|
|
<button key={channel} type="button" onClick={() => setStatus(`${channel}: canal demo seleccionado.`)}>{channel}</button>
|
|
))}
|
|
</div>
|
|
</article>
|
|
|
|
<article className="account-settings-card">
|
|
<span className="eyebrow">Alertas guardadas</span>
|
|
<div className="account-alert-list">
|
|
{savedAlertRows.map((alert) => (
|
|
<div key={alert.title}>
|
|
<strong>{alert.title}</strong>
|
|
<span>{alert.detail}</span>
|
|
<small className={alert.status === "Activa" ? "is-on" : "is-paused"}>{alert.status}</small>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button className="button button-secondary" type="button" onClick={() => setStatus("Historial de alertas demo.")}>Ver historial de alertas</button>
|
|
</article>
|
|
|
|
<article className="account-settings-card">
|
|
<span className="eyebrow">Apariencia y preferencias</span>
|
|
<div className="account-theme-grid">
|
|
{["system", "dark", "light"].map((theme) => (
|
|
<button key={theme} className={appearancePrefs.theme === theme ? "is-selected" : ""} type="button" onClick={() => updateAppearancePref("theme", theme)}>
|
|
<span>{theme === "system" ? "Sistema" : theme === "dark" ? "Oscuro" : "Claro"}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="account-settings-form">
|
|
<label><span>Tamano de texto</span><select className="select" value={appearancePrefs.textSize} onChange={(event) => updateAppearancePref("textSize", event.target.value)}><option value="small">Pequeno</option><option value="medium">Mediano</option><option value="large">Grande</option></select></label>
|
|
<label><span>Idioma</span><select className="select" value={appearancePrefs.language} onChange={(event) => updateAppearancePref("language", event.target.value)}><option value="es-AR">Espanol Argentina</option><option value="es-ES">Espanol Espana</option></select></label>
|
|
<label><span>Moneda</span><select className="select" value={appearancePrefs.currency} onChange={(event) => updateAppearancePref("currency", event.target.value)}><option value="ARS">ARS</option><option value="USD">USD</option></select></label>
|
|
</div>
|
|
<div className="account-toggle-list">
|
|
<label><input type="checkbox" checked={appearancePrefs.reduceMotion} onChange={() => updateAppearancePref("reduceMotion", !appearancePrefs.reduceMotion)} /><span>Reducir animaciones</span></label>
|
|
<label><input type="checkbox" checked={appearancePrefs.highContrast} onChange={() => updateAppearancePref("highContrast", !appearancePrefs.highContrast)} /><span>Alto contraste</span></label>
|
|
</div>
|
|
</article>
|
|
|
|
<article className="account-settings-card">
|
|
<span className="eyebrow">Centro de privacidad</span>
|
|
<div className="account-privacy-list">
|
|
{privacyCenterItems.map(([label, detail]) => (
|
|
<button key={label} type="button" onClick={() => setStatus(`${label}: detalle demo.`)}>
|
|
<strong>{label}</strong>
|
|
<span>{detail}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</article>
|
|
|
|
<article className="account-settings-card danger-zone">
|
|
<span className="eyebrow">Exportar o eliminar datos</span>
|
|
<div>
|
|
<h3 className="section-title">Descargar mis datos</h3>
|
|
<p>Obtiene una copia de la informacion generada en la plataforma.</p>
|
|
<button className="button button-secondary" type="button" onClick={exportAccountData}>Descargar mis datos</button>
|
|
</div>
|
|
<div className="account-delete-box">
|
|
<strong>Eliminar mi cuenta</strong>
|
|
<span>Esta accion es permanente y no puede deshacerse. Tus trabajos se ocultarian y el contenido se eliminaria tras 30 dias.</span>
|
|
<button className="button button-secondary danger" type="button" onClick={requestAccountDeletion}>Eliminar mi cuenta</button>
|
|
</div>
|
|
</article>
|
|
|
|
<article className="account-settings-card">
|
|
<span className="eyebrow">Ver como cliente</span>
|
|
<div className="account-client-preview">
|
|
<img src={profileForm.mainImageUrl || "/demo/maker-hero-custom.svg"} alt={makerName} />
|
|
<div>
|
|
<strong>{makerName}</strong>
|
|
<span>4.9 estrellas | {account?.reviews.length || 0} resenas</span>
|
|
<small>{account?.services.length || 0} servicios | {account?.works.length || 0} trabajos | {locationRows.length} ubicaciones</small>
|
|
</div>
|
|
</div>
|
|
<Link className="button button-primary" href={publicProfileHref}>Ver perfil completo</Link>
|
|
</article>
|
|
|
|
<article className="account-settings-card account-diagnostic-card">
|
|
<span className="eyebrow">Diagnostico de mi perfil maker</span>
|
|
<div className="account-diagnostic-layout">
|
|
<div className="subscription-ring" style={{ "--progress": `${profileDiagnosticPercent}%` } as React.CSSProperties}>
|
|
<strong>{profileDiagnosticPercent}%</strong>
|
|
<span>perfil</span>
|
|
</div>
|
|
<div className="account-diagnostic-list">
|
|
{profileDiagnosticRows.map(([label, value]) => (
|
|
<div key={label}><span>{label}</span><strong>{value ? "OK" : "Pendiente"}</strong></div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="account-improvement-list">
|
|
<strong>Lo que puedes mejorar</strong>
|
|
<Link href="/account/locations">Anadir horarios de atencion</Link>
|
|
<Link href="/account/showcases">Publicar un escaparate mas</Link>
|
|
<Link href="/account/profile">Completar redes sociales</Link>
|
|
<Link href="/account/subscription">Completar metodo de pago</Link>
|
|
</div>
|
|
</article>
|
|
|
|
<article className="account-settings-card">
|
|
<span className="eyebrow">Usuarios bloqueados</span>
|
|
<div className="account-blocked-list">
|
|
{blockedUsers.length ? blockedUsers.map((blockedUser) => (
|
|
<div key={blockedUser.id}>
|
|
<div><strong>{blockedUser.name}</strong><span>Bloqueado el {blockedUser.date}</span></div>
|
|
<button type="button" onClick={() => unblockUser(blockedUser.id)}>Desbloquear</button>
|
|
</div>
|
|
)) : <p className="muted">No tienes usuarios bloqueados.</p>}
|
|
</div>
|
|
</article>
|
|
|
|
<article className="account-settings-card">
|
|
<span className="eyebrow">Sesiones activas</span>
|
|
<div className="account-session-list">
|
|
{activeSessionRows.map((session) => (
|
|
<div key={`detail-${session.device}`}>
|
|
<strong>{session.device}</strong>
|
|
<span>{session.place}</span>
|
|
<small>{session.status}</small>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button className="button button-secondary danger" type="button" onClick={() => setStatus("Las demas sesiones se cerrarian en produccion.")}>Cerrar todas las demas sesiones</button>
|
|
</article>
|
|
|
|
<article className="account-settings-card">
|
|
<span className="eyebrow">Ayuda y soporte</span>
|
|
<div className="subscription-support-grid">
|
|
{supportCards.map((card) => (
|
|
<button key={card.title} type="button" onClick={() => setStatus(`${card.action}: demo sin canal externo todavia.`)}>
|
|
<strong>{card.title}</strong>
|
|
<span>{card.detail}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</article>
|
|
|
|
<article className="account-settings-card account-state-card">
|
|
<span className="eyebrow">Estado de tu cuenta</span>
|
|
{accountStateRows.map(([label, value]) => (
|
|
<div key={label}><strong>{label}</strong><span>{value}</span></div>
|
|
))}
|
|
<Link className="button button-primary" href="/account/subscription">Ver plan y facturacion</Link>
|
|
</article>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|