Files
SIB/frontend/src/views/PromoterDashboard.vue
Hanzo_dev bdfcd55370 feat: business template/mold redesign
- Supabase: Add schedule, whatsapp, instagram, facebook, gallery_images
  columns to businesses table
- types/index.ts: Add 5 new fields to Business interface
- businessService.ts: Update all SELECT queries to include new fields,
  add _parseFormData helper, handle gallery_images as JSON array
- BusinessDetailsView.vue: Full redesign matching the approved mockup:
  * Hero 300px with centered name + yellow category badge
  * Horizontal scrollable quick-info pills (area, schedule, phone, web)
  * Image carousel (main image + gallery_images) with arrows + dots
  * About section with yellow left-accent bar
  * 2x2 social grid (WhatsApp, Instagram, Facebook, Maps) with brand colors
  * Coupon section preserved
  * Sticky CTA bar: Ver en el Mapa + Llamar buttons
- PromoterDashboard.vue: Updated business modal with grouped sections
  (Info Basica / Portada / Descripcion / Redes / Galeria) + new fields
2026-03-03 21:31:35 -05:00

1425 lines
40 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

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

<script setup lang="ts">
import { ref, onMounted, watch, computed } from 'vue'
import { useRoute } from 'vue-router'
import { businessService } from '@/services/businessService'
import { couponsService } from '@/services/couponsService'
import { shuttlesService } from '@/services/shuttlesService'
import { useAuthStore } from '@/stores/auth'
import { supabase } from '@/supabase'
import type { Coupon, Business, Shuttle } from '@/types'
const route = useRoute()
const authStore = useAuthStore()
// State
const activeTab = ref<'promotions' | 'businesses' | 'shuttles'>('promotions')
const coupons = ref<Coupon[]>([])
const businesses = ref<Business[]>([])
const shuttles = ref<Shuttle[]>([])
const isLoading = ref(true)
const searchQuery = ref('')
const categoryFilter = ref('Todas')
const categories = ['Todas', 'Restaurante', 'Turismo', 'Bebidas', 'Comercio']
// Modals
const showModal = ref(false)
const showBusinessModal = ref(false)
const isEditing = ref(false)
const isEditingBusiness = ref(false)
const businessImageFile = ref<File | null>(null)
const businessImagePreview = ref<string | null>(null)
// Current data
const currentCoupon = ref<Partial<Coupon>>({
title: '',
business_id: null,
description: '',
image_url: '',
social_media: '',
terms: '',
discount_percentage: null,
discount_amount: null,
category: 'Restaurante',
valid_from: '',
valid_until: '',
is_active: true
})
const currentBusiness = ref<Partial<Business>>({
name: '',
address: '',
phone: '',
image_url: '',
social_media: '',
category: 'Restaurante',
area: 'Boquete',
description: '',
website: '',
// Template fields
schedule: '',
whatsapp: '',
instagram: '',
facebook: '',
gallery_images: []
})
const userName = localStorage.getItem('user_name') || 'Promotor'
onMounted(async () => {
await Promise.all([loadCoupons(), loadBusinesses(), loadShuttles()])
checkHash()
})
watch(() => route.hash, () => checkHash())
function checkHash() {
if (route.hash === '#businesses') {
activeTab.value = 'businesses'
} else if (route.hash === '#shuttles') {
activeTab.value = 'shuttles'
} else {
activeTab.value = 'promotions'
}
}
// Search Computed
const filteredCoupons = computed(() => {
return coupons.value.filter(c => {
const matchesSearch = c.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
(c.business?.name?.toLowerCase().includes(searchQuery.value.toLowerCase()))
const matchesCategory = categoryFilter.value === 'Todas' || c.category === categoryFilter.value
return matchesSearch && matchesCategory
})
})
const filteredShuttles = computed(() => {
return shuttles.value.filter(s => {
const matchesSearch = s.route_name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
(s.company_name?.toLowerCase().includes(searchQuery.value.toLowerCase()) ?? false) ||
s.origin.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
s.destination.toLowerCase().includes(searchQuery.value.toLowerCase())
return matchesSearch
})
})
const filteredBusinesses = computed(() => {
return businesses.value.filter(b => {
const matchesSearch = b.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
(b.address?.toLowerCase().includes(searchQuery.value.toLowerCase()) ?? false)
const matchesCategory = categoryFilter.value === 'Todas' || b.category === categoryFilter.value
return matchesSearch && matchesCategory
})
})
async function loadCoupons() {
isLoading.value = true
try {
coupons.value = await couponsService.getAllCoupons({ active_only: false })
} catch (e) {
console.error('Failed to load coupons', e)
} finally {
isLoading.value = false
}
}
async function loadBusinesses() {
try {
businesses.value = await businessService.getAllBusinesses()
} catch (e) {
console.error('Failed to load businesses', e)
}
}
async function loadShuttles() {
try {
shuttles.value = await shuttlesService.getAllShuttles()
} catch (e) {
console.error('Failed to load shuttles', e)
}
}
// Shuttle Methods
async function toggleShuttleStatus(shuttle: Shuttle) {
try {
const { error } = await supabase
.from('shuttles')
.update({ is_active: !shuttle.is_active })
.eq('id', shuttle.id);
if (error) throw error;
await loadShuttles();
} catch (e) {
alert('Error al actualizar estado del shuttle')
}
}
async function deleteShuttle(id: string) {
if (confirm('¿Estás seguro de eliminar este transporte turístico?')) {
try {
const { error } = await supabase
.from('shuttles')
.delete()
.eq('id', id);
if (error) throw error;
await loadShuttles();
} catch (e) {
alert('Error al eliminar shuttle')
}
}
}
// Business Methods
function openCreateBusinessModal() {
isEditingBusiness.value = false
currentBusiness.value = {
name: '',
address: '',
phone: '',
image_url: '',
social_media: '',
category: 'Restaurante',
area: 'Boquete',
description: '',
website: '',
schedule: '',
whatsapp: '',
instagram: '',
facebook: '',
gallery_images: []
}
showBusinessModal.value = true
businessImageFile.value = null
businessImagePreview.value = null
}
function handleBusinessImage(event: any) {
const file = event.target.files[0]
if (file) {
businessImageFile.value = file
businessImagePreview.value = URL.createObjectURL(file)
}
}
function openEditBusinessModal(biz: Business) {
isEditingBusiness.value = true
currentBusiness.value = { ...biz }
showBusinessModal.value = true
businessImageFile.value = null
businessImagePreview.value = getImageUrl(biz.image_url)
}
async function saveBusiness() {
try {
const formData = new FormData()
formData.append('name', currentBusiness.value.name || '')
formData.append('category', currentBusiness.value.category || 'Restaurante')
formData.append('address', currentBusiness.value.address || '')
formData.append('phone', currentBusiness.value.phone || '')
formData.append('social_media', currentBusiness.value.social_media || '')
formData.append('area', currentBusiness.value.area || 'Boquete')
formData.append('description', currentBusiness.value.description || '')
formData.append('website', currentBusiness.value.website || '')
// Template fields
formData.append('schedule', currentBusiness.value.schedule || '')
formData.append('whatsapp', currentBusiness.value.whatsapp || '')
formData.append('instagram', currentBusiness.value.instagram || '')
formData.append('facebook', currentBusiness.value.facebook || '')
// gallery_images handled separately via JSON
if (currentBusiness.value.gallery_images?.length) {
formData.append('gallery_images', JSON.stringify(currentBusiness.value.gallery_images))
}
if (businessImageFile.value) {
formData.append('image', businessImageFile.value)
}
if (isEditingBusiness.value && currentBusiness.value.id) {
await businessService.updateBusiness(currentBusiness.value.id, formData)
} else {
await businessService.createBusiness(formData)
}
showBusinessModal.value = false
await loadBusinesses()
} catch (e) {
console.error('Error saving business:', e)
alert('Error al guardar el negocio')
}
}
async function deleteBusiness(id: string) {
if (confirm('¿Estás seguro de eliminar este negocio? Los cupones asociados podrían verse afectados.')) {
try {
await businessService.deleteBusiness(id)
await loadBusinesses()
} catch (e) {
alert('Error al eliminar')
}
}
}
// Coupon Methods
function openCreateModal() {
isEditing.value = false
currentCoupon.value = {
title: '',
business_id: null,
description: '',
image_url: '',
social_media: '',
terms: '',
discount_percentage: null,
discount_amount: null,
category: 'Restaurante',
valid_from: '',
valid_until: '',
is_active: true
}
showModal.value = true
}
function handleBusinessChange() {
const selectedBiz = businesses.value.find(b => b.id === currentCoupon.value.business_id)
if (selectedBiz) {
currentCoupon.value.image_url = selectedBiz.image_url
currentCoupon.value.social_media = selectedBiz.social_media
currentCoupon.value.category = selectedBiz.category
}
}
function openEditModal(coupon: Coupon) {
isEditing.value = true
currentCoupon.value = { ...coupon }
showModal.value = true
}
function getImageUrl(path: string | null | undefined) {
if (!path) return '/default-coupon.png'
return path
}
async function saveCoupon() {
try {
if (!currentCoupon.value.title?.trim()) {
alert('El título es obligatorio')
return
}
const data: any = { ...currentCoupon.value }
if (!isEditing.value) delete data.id
const fieldsToClean = [
'description', 'image_url', 'social_media', 'terms',
'discount_percentage', 'discount_amount', 'valid_from', 'valid_until'
]
fieldsToClean.forEach(field => {
if (data[field] === '' || data[field] === undefined) data[field] = null
})
if (data.discount_percentage !== null) data.discount_percentage = Number(data.discount_percentage)
if (data.discount_amount !== null) data.discount_amount = Number(data.discount_amount)
if (isEditing.value && data.id) {
await couponsService.updateCoupon(data.id, data)
} else {
await couponsService.createCoupon(data)
}
showModal.value = false
await loadCoupons()
} catch (e: any) {
console.error('Error saving coupon:', e)
alert('Error al guardar el cupón.')
}
}
async function deleteCoupon(id: string) {
if (confirm('¿Estás seguro de eliminar este cupón?')) {
try {
await couponsService.deleteCoupon(id)
await loadCoupons()
} catch (e) {
alert('Error al eliminar')
}
}
}
async function toggleCouponStatus(coupon: Coupon) {
try {
await couponsService.updateCoupon(coupon.id, { is_active: !coupon.is_active })
await loadCoupons()
} catch (e) {
alert('Error al actualizar estado')
}
}
</script>
<template>
<div class="promoter-dashboard">
<div class="dashboard-header">
<div class="welcome-section">
<button v-if="authStore.isAdmin" class="back-link" @click="$router.push('/admin')">
&larr; Volver al Panel
</button>
<button v-if="authStore.isAdmin" class="back-analytics" @click="$router.push('/admin/analytics')">
&larr; Ver Análisis y Datos
</button>
<h1>{{ authStore.isAdmin ? 'Gestión de Promociones' : 'Panel de Control' }}</h1>
<p>Bienvenido, {{ userName }}. Gestiona tus negocios y promociones aquí.</p>
</div>
</div>
<!-- Tabs -->
<div class="tabs-container">
<div class="tabs-buttons">
<button :class="['tab-btn', { active: activeTab === 'promotions' }]" @click="activeTab = 'promotions'">
Promociones
</button>
<button :class="['tab-btn', { active: activeTab === 'businesses' }]" @click="activeTab = 'businesses'">
Mis Negocios
</button>
<button :class="['tab-btn', { active: activeTab === 'shuttles' }]" @click="activeTab = 'shuttles'">
Viajes Turísticos
</button>
</div>
<div class="tabs-actions">
<div class="stats-header">
<div v-if="activeTab === 'promotions'" class="stats-group">
<div class="stat-mini">
<div class="stat-value">{{ coupons.length }}</div>
<div class="stat-label">Total Cupones</div>
</div>
<div class="stat-mini">
<div class="stat-value active">{{ coupons.filter(c => c.is_active).length }}</div>
<div class="stat-label">Activos</div>
</div>
</div>
<div v-else-if="activeTab === 'shuttles'" class="stats-group">
<div class="stat-mini">
<div class="stat-value">{{ shuttles.length }}</div>
<div class="stat-label">Total Shuttles</div>
</div>
<div class="stat-mini">
<div class="stat-value active">{{ shuttles.filter(s => s.is_active).length }}</div>
<div class="stat-label">Activos</div>
</div>
</div>
</div>
<button v-if="activeTab === 'promotions'" class="primary-btn" @click="openCreateModal">
<span class="material-icons">add</span>
Nuevo Cupón
</button>
<button v-if="activeTab === 'businesses'" class="primary-btn" @click="openCreateBusinessModal">
<span class="material-icons">add</span>
Nuevo Negocio
</button>
<button v-if="activeTab === 'shuttles'" class="primary-btn" @click="$router.push('/admin/shuttles')">
<span class="material-icons">rocket_launch</span>
Nuevo Shuttle
</button>
</div>
</div>
<!-- Global Search Bar (Visible in Promos/Businesses) -->
<div class="search-filter-bar">
<div class="search-box">
<span class="material-icons">search</span>
<input v-model="searchQuery" type="text" :placeholder="activeTab === 'promotions' ? 'Buscar promoción...' : (activeTab === 'businesses' ? 'Buscar negocio...' : 'Buscar shuttle...')">
</div>
<div class="filter-box">
<span class="material-icons">filter_alt</span>
<select v-model="categoryFilter">
<option v-for="cat in categories" :key="cat" :value="cat">{{ cat }}</option>
</select>
</div>
</div>
<div v-if="activeTab === 'promotions'">
<div class="coupons-section">
<div v-if="isLoading" class="loading-state">
<span class="material-icons spin">refresh</span>
Cargando promociones...
</div>
<div v-else-if="coupons.length === 0" class="empty-state">
<span class="material-icons">sentiment_dissatisfied</span>
<p>No hay cupones creados aún.</p>
</div>
<div v-else class="table-card">
<table class="coupons-table">
<thead>
<tr>
<th>Promoción / Local</th>
<th class="text-center">Categoría</th>
<th class="text-center">Descuento</th>
<th class="text-center">Estado</th>
<th class="text-center">Acciones</th>
</tr>
</thead>
<tbody>
<tr v-for="coupon in filteredCoupons" :key="coupon.id">
<td>
<div class="title-cell">
<div class="coupon-header-cell">
<img :src="getImageUrl(coupon.image_url)" class="coupon-mini-img" />
<div>
<strong>{{ coupon.title }}</strong>
<div class="business-tag">
<span class="material-icons">store</span>
{{ coupon.business?.name || 'Comercio Local' }}
</div>
</div>
</div>
</div>
</td>
<td class="text-center"><span class="badge">{{ coupon.category }}</span></td>
<td class="text-center">
<span class="discount-label">
{{ coupon.discount_percentage ? `${coupon.discount_percentage}%` : `$${coupon.discount_amount}` }}
</span>
</td>
<td class="text-center">
<button
:class="['status-toggle', { active: coupon.is_active }]"
@click="toggleCouponStatus(coupon)"
>
{{ coupon.is_active ? 'Activo' : 'Inactivo' }}
</button>
</td>
<td class="text-center">
<div class="action-buttons justify-center">
<button class="icon-btn edit" @click="openEditModal(coupon)">
<span class="material-icons">edit</span>
</button>
<button class="icon-btn delete" @click="deleteCoupon(coupon.id)">
<span class="material-icons">delete</span>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Businesses Tab -->
<div v-if="activeTab === 'businesses'">
<div v-if="businesses.length === 0" class="empty-state">
<span class="material-icons">store_front</span>
<p>Aún no has registrado ningún negocio o local.</p>
</div>
<div v-else class="table-card">
<table class="coupons-table">
<thead>
<tr>
<th>Negocio / Local</th>
<th class="text-center">Categoría</th>
<th class="text-center">Área</th>
<th class="text-center">Contacto</th>
<th class="text-center">Dirección</th>
<th class="text-center">Acciones</th>
</tr>
</thead>
<tbody>
<tr v-for="biz in filteredBusinesses" :key="biz.id">
<td>
<div class="title-cell">
<div class="coupon-header-cell">
<img :src="getImageUrl(biz.image_url)" class="coupon-mini-img" />
<strong>{{ biz.name }}</strong>
</div>
</div>
</td>
<td class="text-center"><span class="badge">{{ biz.category }}</span></td>
<td class="text-center"><span class="badge area-badge">{{ biz.area }}</span></td>
<td class="text-center">
<div class="contact-info align-center">
<span v-if="biz.phone"><span class="material-icons">phone</span> {{ biz.phone }}</span>
<span v-if="biz.social_media" class="social-tag"><span class="material-icons">share</span> {{ biz.social_media }}</span>
</div>
</td>
<td class="address-cell text-center">{{ biz.address }}</td>
<td class="text-center">
<div class="action-buttons justify-center">
<button class="icon-btn edit" @click="openEditBusinessModal(biz)">
<span class="material-icons">edit</span>
</button>
<button class="icon-btn delete" @click="deleteBusiness(biz.id)">
<span class="material-icons">delete</span>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Shuttles Tab -->
<div v-if="activeTab === 'shuttles'">
<div v-if="shuttles.length === 0" class="empty-state">
<span class="material-icons">directions_bus</span>
<p>No hay shuttles turísticos registrados.</p>
</div>
<div v-else class="table-card">
<table class="coupons-table">
<thead>
<tr>
<th>Ruta / Empresa</th>
<th class="text-center">Tipo Vehículo</th>
<th class="text-center">Precio (Persona)</th>
<th class="text-center">Estado</th>
<th class="text-center">Acciones</th>
</tr>
</thead>
<tbody>
<tr v-for="shuttle in filteredShuttles" :key="shuttle.id">
<td>
<div class="title-cell">
<div class="coupon-header-cell">
<img :src="getImageUrl(shuttle.image_url)" class="coupon-mini-img" />
<div>
<strong>{{ shuttle.route_name }}</strong>
<div class="business-tag">
<span class="material-icons">business</span>
{{ shuttle.company_name }}
</div>
</div>
</div>
</div>
</td>
<td class="text-center"><span class="badge">{{ shuttle.vehicle_type }}</span></td>
<td class="text-center">
<span class="discount-label">${{ shuttle.price_per_person }}</span>
</td>
<td class="text-center">
<button
:class="['status-toggle', { active: shuttle.is_active }]"
@click="toggleShuttleStatus(shuttle)"
>
{{ shuttle.is_active ? 'Activo' : 'Inactivo' }}
</button>
</td>
<td class="text-center">
<div class="action-buttons justify-center">
<button class="icon-btn delete" @click="deleteShuttle(shuttle.id)">
<span class="material-icons">delete</span>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Coupon Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
<div class="modal-content">
<div class="modal-header">
<h2>{{ isEditing ? 'Editar Cupón' : 'Nuevo Cupón' }}</h2>
<button class="close-btn" @click="showModal = false">
<span class="material-icons">close</span>
</button>
</div>
<form @submit.prevent="saveCoupon" class="coupon-form">
<div class="form-row">
<div class="form-group full">
<label>Título de la promoción</label>
<input v-model="currentCoupon.title" type="text" placeholder="Ej: 2x1 en Pizzas" required>
</div>
</div>
<div class="form-group">
<label>Seleccionar Negocio (Auto-completa la info)</label>
<select v-model="currentCoupon.business_id" @change="handleBusinessChange">
<option :value="null">-- Ingresar datos manualmente --</option>
<option v-for="biz in businesses" :key="biz.id" :value="biz.id">{{ biz.name }}</option>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label>Nombre del Local</label>
<input :value="businesses.find(b => b.id === currentCoupon.business_id)?.name || ''" type="text" readonly disabled class="readonly-input">
</div>
<div class="form-group">
<label>Categoría</label>
<select v-model="currentCoupon.category">
<option value="Restaurante">Restaurante</option>
<option value="Area Turistica">Área Turística</option>
<option value="Bebidas">Bar / Bebidas</option>
<option value="Viajes de Turismo">Viajes de Turismo</option>
</select>
</div>
</div>
<div class="form-group">
<label>Descripción</label>
<textarea v-model="currentCoupon.description" placeholder="¿En qué consiste la promo?"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>URL Imagen</label>
<input v-model="currentCoupon.image_url" type="text">
</div>
<div class="form-group">
<label>Redes Sociales</label>
<input v-model="currentCoupon.social_media" type="text">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Vence el...</label>
<input v-model="currentCoupon.valid_until" type="date">
</div>
<div class="form-group">
<label>Estado</label>
<select v-model="currentCoupon.is_active">
<option :value="true">Activo</option>
<option :value="false">Pausado</option>
</select>
</div>
</div>
<div class="form-actions">
<button type="submit" class="submit-btn">{{ isEditing ? 'Guardar Cambios' : 'Lanzar Promo' }}</button>
</div>
</form>
</div>
</div>
<!-- Business Modal -->
<div v-if="showBusinessModal" class="modal-overlay" @click.self="showBusinessModal = false">
<div class="modal-content">
<div class="modal-header">
<h2>{{ isEditingBusiness ? 'Editar Negocio' : 'Registrar Negocio' }}</h2>
<button class="close-btn" @click="showBusinessModal = false"><span class="material-icons">close</span></button>
</div>
<form @submit.prevent="saveBusiness" class="coupon-form">
<!-- Info básica -->
<div class="form-section-label">📋 Información Básica</div>
<div class="form-group">
<label>Nombre del Negocio *</label>
<input v-model="currentBusiness.name" type="text" required placeholder="Ej: Restaurante La Casona">
</div>
<div class="form-row">
<div class="form-group">
<label>Categoría</label>
<select v-model="currentBusiness.category">
<option value="Restaurante">Restaurante</option>
<option value="Hotel">Hotel</option>
<option value="Café">Café</option>
<option value="Bebidas">Bar / Bebidas</option>
<option value="Comercio">Comercio</option>
<option value="Turismo">Turismo</option>
</select>
</div>
<div class="form-group">
<label>Área / Región</label>
<select v-model="currentBusiness.area">
<option value="Boquete">Boquete</option>
<option value="Alto Boquete">Alto Boquete</option>
<option value="Dolega">Dolega</option>
<option value="David">David</option>
<option value="Caldera">Caldera</option>
<option value="Chiriquí">Chiriquí</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Teléfono</label>
<input v-model="currentBusiness.phone" type="text" placeholder="+507 6000-0000">
</div>
<div class="form-group">
<label>🕐 Horario de Atención</label>
<input v-model="currentBusiness.schedule" type="text" placeholder="Ej: Lun-Sáb 8am-10pm">
</div>
</div>
<div class="form-group">
<label>Dirección Física</label>
<input v-model="currentBusiness.address" type="text" placeholder="Ej: Calle Principal #123, Frente al Parque">
</div>
<!-- Imagen principal -->
<div class="form-section-label">🖼 Imagen de Portada</div>
<div class="form-group">
<label>Foto Principal (Logo o Fachada)</label>
<div class="file-upload-wrapper">
<input type="file" @change="handleBusinessImage" accept="image/*" class="file-input">
<div class="file-preview" v-if="businessImagePreview">
<img :src="businessImagePreview" alt="Vista previa">
</div>
</div>
</div>
<!-- Descripción -->
<div class="form-section-label">📝 Descripción</div>
<div class="form-group">
<label>Sobre el Negocio (Texto de marketing)</label>
<textarea v-model="currentBusiness.description" placeholder="Describe la experiencia del lugar, su especialidad y ambiente para atraer clientes..." rows="4"></textarea>
</div>
<!-- Redes Sociales y Contacto -->
<div class="form-section-label">🌐 Contacto y Redes Sociales</div>
<div class="form-row">
<div class="form-group">
<label>💬 WhatsApp</label>
<input v-model="currentBusiness.whatsapp" type="text" placeholder="50760000000 (con código de país)">
</div>
<div class="form-group">
<label>📸 Instagram</label>
<input v-model="currentBusiness.instagram" type="text" placeholder="@usuario">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>👍 Facebook</label>
<input v-model="currentBusiness.facebook" type="text" placeholder="/nombre-de-pagina">
</div>
<div class="form-group">
<label>🌐 Página Web</label>
<input v-model="currentBusiness.website" type="url" placeholder="https://www.ejemplo.com">
</div>
</div>
<!-- Galería -->
<div class="form-section-label">📸 Galería de Imágenes (Carrusel)</div>
<div class="form-group">
<label>URLs de fotos adicionales (una por línea)</label>
<textarea
:value="currentBusiness.gallery_images?.join('\n') || ''"
@input="(e: any) => currentBusiness.gallery_images = e.target.value.split('\n').map((s: string) => s.trim()).filter((s: string) => s.length > 0)"
placeholder="https://url-foto1.jpg&#10;https://url-foto2.jpg&#10;https://url-foto3.jpg"
rows="3"
></textarea>
<small class="form-hint">Agrega URLs de imágenes para el carrusel (menú, ambiente, experiencias). Una URL por línea.</small>
</div>
<div class="form-actions">
<button type="submit" class="submit-btn">
<span class="material-icons">{{ isEditingBusiness ? 'save' : 'store' }}</span>
{{ isEditingBusiness ? 'Guardar Cambios' : 'Publicar Negocio' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<style scoped>
/* Tabs */
.dashboard-header {
display: flex;
justify-content: center;
align-items: center;
text-align: center;
margin-bottom: 2.5rem;
padding-bottom: 1rem;
}
.welcome-section {
max-width: 800px;
}
.welcome-section h1 {
font-size: 2.5rem;
font-weight: 800;
margin: 0;
color: var(--text-primary);
}
.welcome-section p {
color: var(--text-secondary);
margin-top: 0.5rem;
font-size: 1.1rem;
}
.back-analytics {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
margin-bottom: 12px;
}
.back-analytics:hover {
background: var(--bg-secondary);
color: var(--active-color);
}
.logout-btn {
background: #fdeaea;
color: #e74c3c;
border: 1px solid #fabebb;
padding: 0.6rem 1.2rem;
border-radius: 8px;
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.logout-btn:hover {
background: #fcd5d5;
transform: translateY(-2px);
}
/* Header Stats */
.tabs-actions {
display: flex;
align-items: center;
gap: 2rem;
}
.stats-header {
display: flex;
gap: 1.5rem;
}
.stat-mini {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.stat-value {
font-size: 1.8rem;
font-weight: 800;
color: var(--text-primary);
line-height: 1;
}
.stat-value.active {
color: #4caf50;
}
.stat-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.25rem;
}
/* Tabs */
.tabs-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
border-bottom: 1px solid var(--border-color);
}
.tabs-buttons {
display: flex;
gap: 0.5rem;
}
.primary-btn {
display: flex;
align-items: center;
gap: 8px;
background: var(--text-primary);
color: var(--bg-primary);
padding: 0.6rem 1.2rem;
border-radius: 8px;
border: none;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.primary-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px var(--shadow);
}
.tab-btn {
background: none;
border: none;
padding: 0.75rem 1.5rem;
font-weight: 600;
color: var(--text-secondary);
cursor: pointer;
border-bottom: 3px solid transparent;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.tab-btn.active {
color: var(--active-color);
border-bottom-color: var(--active-color);
}
/* Table and Cards */
.table-card {
background: var(--card-bg);
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--border-color);
box-shadow: 0 4px 15px var(--shadow);
}
.coupons-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.coupons-table th {
text-align: left;
padding: 1rem;
background: var(--bg-secondary);
color: var(--text-secondary);
font-weight: 700;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
}
.coupons-table th.text-center {
text-align: center;
}
.coupons-table td {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
vertical-align: middle;
}
.text-center {
text-align: center;
}
.justify-center {
justify-content: center;
}
.align-center {
align-items: center;
}
.coupons-table tr:last-child td {
border-bottom: none;
}
.coupon-header-cell {
display: flex;
align-items: center;
gap: 12px;
}
.coupon-mini-img {
width: 40px;
height: 40px;
border-radius: 8px;
object-fit: cover;
border: 1px solid var(--border-color);
}
.business-tag {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
color: var(--active-color);
font-weight: 600;
margin-top: 2px;
}
.business-tag .material-icons {
font-size: 0.9rem;
}
.badge {
background: var(--bg-secondary);
color: var(--text-secondary);
padding: 4px 8px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 700;
}
.discount-label {
font-weight: 800;
color: #c62828;
background: #ffebee;
padding: 4px 8px;
border-radius: 6px;
}
.form-group.full { grid-column: 1 / -1; }
.status-toggle {
padding: 0.25rem 0.75rem;
border-radius: 4px;
border: none;
font-size: 0.75rem;
cursor: pointer;
background: #ffcdd2;
color: #c62828;
}
.status-toggle.active {
background: #c8e6c9;
color: #2e7d32;
}
.action-buttons {
display: flex;
gap: 0.5rem;
}
.icon-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
}
.icon-btn.edit { color: var(--active-color); }
.icon-btn.delete { color: #d32f2f; }
.icon-btn:hover { background: var(--bg-secondary); }
.contact-info {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.8rem;
color: var(--text-secondary);
}
.contact-info span {
display: flex;
align-items: center;
gap: 4px;
}
.contact-info .material-icons {
font-size: 0.9rem;
}
.social-tag {
color: var(--active-color);
font-weight: 500;
}
.address-cell {
max-width: 200px;
font-size: 0.8rem;
color: var(--text-secondary);
margin: 0 auto;
}
/* File Upload Styles */
.file-upload-wrapper {
display: flex;
flex-direction: column;
gap: 1rem;
}
.file-input {
padding: 0.5rem;
border: 2px dashed var(--border-color);
border-radius: 8px;
cursor: pointer;
width: 100%;
}
.file-preview {
width: 100px;
height: 100px;
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.file-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.modal-content {
background: var(--card-bg);
width: 95%;
max-width: 600px;
border-radius: 16px;
padding: 2rem;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.close-btn { background: none; border: none; cursor: pointer; color: var(--text-primary); }
.coupon-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.form-group label {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-secondary);
}
.form-group input, .form-group textarea, .form-group select {
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 0.9rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
.submit-btn {
flex: 1;
padding: 0.8rem;
border-radius: 8px;
background: var(--text-primary);
color: var(--bg-primary);
border: none;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.submit-btn:hover { opacity: 0.9; }
/* Form section labels (organize modal into visual sections) */
.form-section-label {
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--active-color);
padding: 8px 0 2px;
border-bottom: 1px solid var(--border-color);
margin-top: 4px;
}
.form-hint {
font-size: 0.75rem;
color: var(--text-secondary);
line-height: 1.5;
margin-top: 4px;
}
/* Submit btn icon alignment */
.submit-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.submit-btn .material-icons { font-size: 1.1rem; }
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin { 100% { transform: rotate(360deg); } }
.loading-state, .empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: var(--text-secondary);
text-align: center;
}
.empty-state .material-icons { font-size: 3rem; margin-bottom: 1rem; }
.map-picker-btn-container {
margin-bottom: 15px;
}
.map-btn {
width: 100%;
justify-content: center;
gap: 8px;
background: var(--bg-secondary);
}
.picker-map-wrapper {
margin-bottom: 20px;
}
.map-picker-div {
width: 100%;
height: 250px;
border-radius: 12px;
border: 2px solid var(--border-color);
}
.picker-hint {
font-size: 0.8rem;
color: var(--text-secondary);
text-align: center;
margin-top: 5px;
}
.secondary-btn {
display: flex;
align-items: center;
padding: 10px 16px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: white;
cursor: pointer;
font-weight: 600;
}
.badge.area-badge {
background: var(--bg-secondary);
color: var(--active-color);
border: 1px solid var(--border-color);
}
/* Search and Filter Bar */
.search-filter-bar {
display: flex;
gap: 15px;
margin-bottom: 20px;
background: var(--card-bg);
padding: 15px;
border-radius: 12px;
border: 1px solid var(--border-color);
}
.search-box {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
background: var(--bg-primary);
padding: 0 15px;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.search-box input {
flex: 1;
border: none;
background: transparent;
padding: 10px 0;
color: var(--text-primary);
font-size: 0.95rem;
}
.search-box input:focus { outline: none; }
.filter-box {
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-primary);
padding: 0 15px;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.filter-box select {
border: none;
background: transparent;
color: var(--text-primary);
padding: 10px 0;
cursor: pointer;
}
.back-link, .back-analytics {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
cursor: pointer;
font-size: 0.95rem;
font-weight: 600;
padding: 10px 16px;
border-radius: 8px;
transition: all 0.2s;
margin-bottom: 8px;
display: inline-flex;
align-items: center;
gap: 4px;
margin-right: 8px;
}
.back-link:hover, .back-analytics:hover {
background: var(--hover-bg);
border-color: var(--accent-color);
color: var(--accent-color);
transform: translateX(-2px);
}
</style>