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
This commit is contained in:
@ -2,6 +2,8 @@
|
|||||||
import { supabase } from '@/supabase'
|
import { supabase } from '@/supabase'
|
||||||
import type { Business } from '@/types'
|
import type { Business } from '@/types'
|
||||||
|
|
||||||
|
const SELECT_FIELDS = 'id, name, address, phone, image_url, social_media, category, latitude, longitude, area, description, website, schedule, whatsapp, instagram, facebook, gallery_images, updated_at'
|
||||||
|
|
||||||
export const businessService = {
|
export const businessService = {
|
||||||
/** Helper to upload file to supabase storage */
|
/** Helper to upload file to supabase storage */
|
||||||
async uploadImage(file: File): Promise<string> {
|
async uploadImage(file: File): Promise<string> {
|
||||||
@ -18,58 +20,59 @@ export const businessService = {
|
|||||||
|
|
||||||
/** Get all businesses */
|
/** Get all businesses */
|
||||||
async getAllBusinesses(): Promise<Business[]> {
|
async getAllBusinesses(): Promise<Business[]> {
|
||||||
const { data, error } = await supabase.from('businesses').select('id, name, address, phone, image_url, social_media, category, latitude, longitude, area, description, website, updated_at')
|
const { data, error } = await supabase.from('businesses').select(SELECT_FIELDS)
|
||||||
if (error) throw new Error(error.message)
|
if (error) throw new Error(error.message)
|
||||||
return data as Business[]
|
return data as Business[]
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get a single business by ID */
|
/** Get a single business by ID */
|
||||||
async getBusiness(id: string): Promise<Business> {
|
async getBusiness(id: string): Promise<Business> {
|
||||||
const { data, error } = await supabase.from('businesses').select('id, name, address, phone, image_url, social_media, category, latitude, longitude, area, description, website, updated_at').eq('id', id).single()
|
const { data, error } = await supabase.from('businesses').select(SELECT_FIELDS).eq('id', id).single()
|
||||||
if (error) throw new Error(error.message)
|
if (error) throw new Error(error.message)
|
||||||
return data as Business
|
return data as Business
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Create a new business */
|
/** Parse FormData into a payload object, handling gallery_images JSON */
|
||||||
async createBusiness(businessData: FormData): Promise<Business> {
|
_parseFormData(businessData: FormData): { payload: any; fileUpload: File | null } {
|
||||||
const payload: any = {}
|
const payload: any = {}
|
||||||
let fileUpload: File | null = null
|
let fileUpload: File | null = null
|
||||||
|
|
||||||
businessData.forEach((value, key) => {
|
businessData.forEach((value, key) => {
|
||||||
if ((key === 'file' || key === 'image') && value instanceof File) {
|
if ((key === 'file' || key === 'image') && value instanceof File) {
|
||||||
fileUpload = value
|
fileUpload = value
|
||||||
|
} else if (key === 'gallery_images' && typeof value === 'string') {
|
||||||
|
// Comes as JSON string from the form
|
||||||
|
try { payload[key] = JSON.parse(value) } catch { /* ignore */ }
|
||||||
} else if (value !== 'null' && value !== '' && key !== 'file' && key !== 'image') {
|
} else if (value !== 'null' && value !== '' && key !== 'file' && key !== 'image') {
|
||||||
payload[key] = value
|
payload[key] = value
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return { payload, fileUpload }
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Create a new business */
|
||||||
|
async createBusiness(businessData: FormData): Promise<Business> {
|
||||||
|
const { payload, fileUpload } = this._parseFormData(businessData)
|
||||||
|
|
||||||
if (fileUpload) {
|
if (fileUpload) {
|
||||||
payload.image_url = await this.uploadImage(fileUpload)
|
payload.image_url = await this.uploadImage(fileUpload)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await supabase.from('businesses').insert([payload]).select().single()
|
const { data, error } = await supabase.from('businesses').insert([payload]).select(SELECT_FIELDS).single()
|
||||||
if (error) throw new Error(error.message)
|
if (error) throw new Error(error.message)
|
||||||
return data as Business
|
return data as Business
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Update an existing business */
|
/** Update an existing business */
|
||||||
async updateBusiness(id: string, businessData: FormData): Promise<Business> {
|
async updateBusiness(id: string, businessData: FormData): Promise<Business> {
|
||||||
const payload: any = {}
|
const { payload, fileUpload } = this._parseFormData(businessData)
|
||||||
let fileUpload: File | null = null
|
|
||||||
|
|
||||||
businessData.forEach((value, key) => {
|
|
||||||
if ((key === 'file' || key === 'image') && value instanceof File) {
|
|
||||||
fileUpload = value
|
|
||||||
} else if (value !== 'null' && value !== '' && key !== 'file' && key !== 'image') {
|
|
||||||
payload[key] = value
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (fileUpload) {
|
if (fileUpload) {
|
||||||
payload.image_url = await this.uploadImage(fileUpload)
|
payload.image_url = await this.uploadImage(fileUpload)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await supabase.from('businesses').update(payload).eq('id', id).select().single()
|
const { data, error } = await supabase.from('businesses').update(payload).eq('id', id).select(SELECT_FIELDS).single()
|
||||||
if (error) throw new Error(error.message)
|
if (error) throw new Error(error.message)
|
||||||
return data as Business
|
return data as Business
|
||||||
},
|
},
|
||||||
|
|||||||
@ -97,6 +97,12 @@ export interface Business {
|
|||||||
area?: string | null
|
area?: string | null
|
||||||
description?: string | null
|
description?: string | null
|
||||||
website?: string | null
|
website?: string | null
|
||||||
|
// Template/Mold fields
|
||||||
|
schedule?: string | null // "Lun-Sáb 8am-10pm"
|
||||||
|
whatsapp?: string | null // WhatsApp number
|
||||||
|
instagram?: string | null // @handle
|
||||||
|
facebook?: string | null // page URL or username
|
||||||
|
gallery_images?: string[] // carousel photo URLs
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -56,7 +56,13 @@ const currentBusiness = ref<Partial<Business>>({
|
|||||||
category: 'Restaurante',
|
category: 'Restaurante',
|
||||||
area: 'Boquete',
|
area: 'Boquete',
|
||||||
description: '',
|
description: '',
|
||||||
website: ''
|
website: '',
|
||||||
|
// Template fields
|
||||||
|
schedule: '',
|
||||||
|
whatsapp: '',
|
||||||
|
instagram: '',
|
||||||
|
facebook: '',
|
||||||
|
gallery_images: []
|
||||||
})
|
})
|
||||||
|
|
||||||
const userName = localStorage.getItem('user_name') || 'Promotor'
|
const userName = localStorage.getItem('user_name') || 'Promotor'
|
||||||
@ -179,7 +185,12 @@ function openCreateBusinessModal() {
|
|||||||
category: 'Restaurante',
|
category: 'Restaurante',
|
||||||
area: 'Boquete',
|
area: 'Boquete',
|
||||||
description: '',
|
description: '',
|
||||||
website: ''
|
website: '',
|
||||||
|
schedule: '',
|
||||||
|
whatsapp: '',
|
||||||
|
instagram: '',
|
||||||
|
facebook: '',
|
||||||
|
gallery_images: []
|
||||||
}
|
}
|
||||||
showBusinessModal.value = true
|
showBusinessModal.value = true
|
||||||
businessImageFile.value = null
|
businessImageFile.value = null
|
||||||
@ -214,6 +225,15 @@ async function saveBusiness() {
|
|||||||
formData.append('area', currentBusiness.value.area || 'Boquete')
|
formData.append('area', currentBusiness.value.area || 'Boquete')
|
||||||
formData.append('description', currentBusiness.value.description || '')
|
formData.append('description', currentBusiness.value.description || '')
|
||||||
formData.append('website', currentBusiness.value.website || '')
|
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) {
|
if (businessImageFile.value) {
|
||||||
formData.append('image', businessImageFile.value)
|
formData.append('image', businessImageFile.value)
|
||||||
@ -692,39 +712,57 @@ async function toggleCouponStatus(coupon: Coupon) {
|
|||||||
<button class="close-btn" @click="showBusinessModal = false"><span class="material-icons">close</span></button>
|
<button class="close-btn" @click="showBusinessModal = false"><span class="material-icons">close</span></button>
|
||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="saveBusiness" class="coupon-form">
|
<form @submit.prevent="saveBusiness" class="coupon-form">
|
||||||
|
|
||||||
|
<!-- ── Info básica ── -->
|
||||||
|
<div class="form-section-label">📋 Información Básica</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Nombre del Negocio</label>
|
<label>Nombre del Negocio *</label>
|
||||||
<input v-model="currentBusiness.name" type="text" required>
|
<input v-model="currentBusiness.name" type="text" required placeholder="Ej: Restaurante La Casona">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Categoría</label>
|
<label>Categoría</label>
|
||||||
<select v-model="currentBusiness.category">
|
<select v-model="currentBusiness.category">
|
||||||
<option value="Restaurante">Restaurante</option>
|
<option value="Restaurante">Restaurante</option>
|
||||||
<option value="Area Turistica">Área Turística</option>
|
<option value="Hotel">Hotel</option>
|
||||||
|
<option value="Café">Café</option>
|
||||||
<option value="Bebidas">Bar / Bebidas</option>
|
<option value="Bebidas">Bar / Bebidas</option>
|
||||||
<option value="Viajes de Turismo">Viajes de Turismo</option>
|
<option value="Comercio">Comercio</option>
|
||||||
|
<option value="Turismo">Turismo</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label>Teléfono</label>
|
|
||||||
<input v-model="currentBusiness.phone" type="text">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Área / Región</label>
|
<label>Área / Región</label>
|
||||||
<select v-model="currentBusiness.area">
|
<select v-model="currentBusiness.area">
|
||||||
<option value="Boquete">Boquete</option>
|
<option value="Boquete">Boquete</option>
|
||||||
|
<option value="Alto Boquete">Alto Boquete</option>
|
||||||
<option value="Dolega">Dolega</option>
|
<option value="Dolega">Dolega</option>
|
||||||
<option value="David">David</option>
|
<option value="David">David</option>
|
||||||
|
<option value="Caldera">Caldera</option>
|
||||||
|
<option value="Chiriquí">Chiriquí</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="form-group">
|
||||||
<label>Dirección Física</label>
|
<label>Dirección Física</label>
|
||||||
<input v-model="currentBusiness.address" type="text" placeholder="Ej: Calle Principal #123, Frente al Parque">
|
<input v-model="currentBusiness.address" type="text" placeholder="Ej: Calle Principal #123, Frente al Parque">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Imagen principal ── -->
|
||||||
|
<div class="form-section-label">🖼️ Imagen de Portada</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Imagen del Negocio (Logo o Fachada)</label>
|
<label>Foto Principal (Logo o Fachada)</label>
|
||||||
<div class="file-upload-wrapper">
|
<div class="file-upload-wrapper">
|
||||||
<input type="file" @change="handleBusinessImage" accept="image/*" class="file-input">
|
<input type="file" @change="handleBusinessImage" accept="image/*" class="file-input">
|
||||||
<div class="file-preview" v-if="businessImagePreview">
|
<div class="file-preview" v-if="businessImagePreview">
|
||||||
@ -732,20 +770,55 @@ async function toggleCouponStatus(coupon: Coupon) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Descripción ── -->
|
||||||
|
<div class="form-section-label">📝 Descripción</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Descripción / Historia del Negocio</label>
|
<label>Sobre el Negocio (Texto de marketing)</label>
|
||||||
<textarea v-model="currentBusiness.description" placeholder="Cuéntanos un poco sobre el negocio para darle un toque premium..." rows="3"></textarea>
|
<textarea v-model="currentBusiness.description" placeholder="Describe la experiencia del lugar, su especialidad y ambiente para atraer clientes..." rows="4"></textarea>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label>Página Web (Opcional)</label>
|
<label>URLs de fotos adicionales (una por línea)</label>
|
||||||
<input v-model="currentBusiness.website" type="url" placeholder="https://www.ejemplo.com">
|
<textarea
|
||||||
</div>
|
:value="currentBusiness.gallery_images?.join('\n') || ''"
|
||||||
<div class="form-group">
|
@input="(e: any) => currentBusiness.gallery_images = e.target.value.split('\n').map((s: string) => s.trim()).filter((s: string) => s.length > 0)"
|
||||||
<label>Redes Sociales</label>
|
placeholder="https://url-foto1.jpg https://url-foto2.jpg https://url-foto3.jpg"
|
||||||
<input v-model="currentBusiness.social_media" type="text" placeholder="Ej: @pizzeria_centro">
|
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>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="submit-btn">Guardar Negocio</button>
|
<button type="submit" class="submit-btn">
|
||||||
|
<span class="material-icons">{{ isEditingBusiness ? 'save' : 'store' }}</span>
|
||||||
|
{{ isEditingBusiness ? 'Guardar Cambios' : 'Publicar Negocio' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -1182,6 +1255,35 @@ async function toggleCouponStatus(coupon: Coupon) {
|
|||||||
|
|
||||||
.submit-btn:hover { opacity: 0.9; }
|
.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 {
|
.spin {
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user