cambios propuestos por dru
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -158,7 +159,7 @@ type eaCustomer struct {
|
||||
ID flexInt `json:"id,omitempty"`
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
Email string `json:"email"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Phone string `json:"phone"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
@@ -199,7 +200,7 @@ var (
|
||||
// 1. valida la entrada,
|
||||
// 2. lee los servicios de EA (fuente de verdad de nombre/duración),
|
||||
// 3. resuelve el provider (server-side, ver providerID),
|
||||
// 4. busca o crea el cliente por email,
|
||||
// 4. busca o crea el cliente por teléfono (es el dato que lo identifica; el email es opcional),
|
||||
// 5. crea UNA cita con el servicio de mayor duración y el bloque total en notas.
|
||||
func handleBook(ea *eaClient) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -219,7 +220,7 @@ func handleBook(ea *eaClient) http.HandlerFunc {
|
||||
req.Telefono = strings.TrimSpace(req.Telefono)
|
||||
req.Mensaje = strings.TrimSpace(req.Mensaje)
|
||||
|
||||
if req.Nombre == "" || req.Apellidos == "" || req.Email == "" || req.Telefono == "" ||
|
||||
if req.Nombre == "" || req.Apellidos == "" || normPhone(req.Telefono) == "" ||
|
||||
len(req.ServiceIDs) == 0 ||
|
||||
!reDate.MatchString(req.Date) || !reTime.MatchString(req.Time) {
|
||||
writeJSONError(w, http.StatusBadRequest, "datos_incompletos")
|
||||
@@ -278,11 +279,15 @@ func handleBook(ea *eaClient) http.HandlerFunc {
|
||||
}
|
||||
notesLines := []string{
|
||||
"Nombre: " + req.Nombre + " " + req.Apellidos,
|
||||
"Email: " + req.Email,
|
||||
"Teléfono: " + req.Telefono,
|
||||
"Servicios: " + strings.Join(names, " + "),
|
||||
fmt.Sprintf("Duración total: %d min", total),
|
||||
}
|
||||
if req.Email != "" {
|
||||
notesLines = append(notesLines, "Email: "+req.Email)
|
||||
}
|
||||
notesLines = append(notesLines,
|
||||
"Servicios: "+strings.Join(names, " + "),
|
||||
fmt.Sprintf("Duración total: %d min", total),
|
||||
)
|
||||
if req.Mensaje != "" {
|
||||
notesLines = append(notesLines, "Mensaje: "+req.Mensaje)
|
||||
}
|
||||
@@ -296,7 +301,7 @@ func handleBook(ea *eaClient) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// 6. cliente: buscar por email o crear (en EA).
|
||||
// 6. cliente: buscar por teléfono o crear (en EA).
|
||||
customerID, err := ea.findOrCreateCustomer(req)
|
||||
if err != nil {
|
||||
log.Printf("book: error con cliente: %v", err)
|
||||
@@ -370,12 +375,137 @@ func handleConfig(ea *eaClient) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// findOrCreateCustomer busca un cliente por email en EA y, si no existe, lo crea.
|
||||
// normPhone deja solo los dígitos ("690 91 88 15" → "690918815") para que el
|
||||
// mismo número escrito de formas distintas identifique al mismo cliente.
|
||||
func normPhone(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// samePhone compara dos teléfonos por sus dígitos, tolerando prefijos de país:
|
||||
// "+34 690918815" y "690918815" son el mismo número (sufijo común de ≥9 dígitos).
|
||||
func samePhone(a, b string) bool {
|
||||
a, b = normPhone(a), normPhone(b)
|
||||
if a == "" || b == "" {
|
||||
return false
|
||||
}
|
||||
if len(a) > len(b) {
|
||||
a, b = b, a
|
||||
}
|
||||
return a == b || (len(a) >= 9 && strings.HasSuffix(b, a))
|
||||
}
|
||||
|
||||
// handleMonthAvailability devuelve los días de un mes sin NINGÚN hueco libre
|
||||
// para un servicio ("unavailableDays"), para que el calendario los deshabilite
|
||||
// sin que el usuario tenga que pulsarlos. Consulta las availabilities de EA
|
||||
// día a día (en paralelo, saltando los pasados) y cachea el resultado 10 min
|
||||
// por mes+servicio para no repetir ~30 llamadas a EA en cada render.
|
||||
func handleMonthAvailability(ea *eaClient) http.HandlerFunc {
|
||||
type cacheEntry struct {
|
||||
expires time.Time
|
||||
days []string
|
||||
}
|
||||
var (
|
||||
mu sync.Mutex
|
||||
cache = make(map[string]cacheEntry)
|
||||
)
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSONError(w, http.StatusMethodNotAllowed, "method_not_allowed")
|
||||
return
|
||||
}
|
||||
month := r.URL.Query().Get("month")
|
||||
first, errMonth := time.Parse("2006-01", month)
|
||||
serviceID, errService := strconv.Atoi(r.URL.Query().Get("serviceId"))
|
||||
|
||||
// Solo del mes actual a 12 meses vista: es lo único que el calendario
|
||||
// puede pedir y acota el abanico de llamadas a EA.
|
||||
now := time.Now()
|
||||
currentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
if errMonth != nil || errService != nil || serviceID <= 0 ||
|
||||
first.Before(currentMonth) || first.After(currentMonth.AddDate(1, 0, 0)) {
|
||||
writeJSONError(w, http.StatusBadRequest, "parametros_invalidos")
|
||||
return
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("%s:%d", month, serviceID)
|
||||
mu.Lock()
|
||||
e, hit := cache[key]
|
||||
mu.Unlock()
|
||||
|
||||
if !hit || !now.Before(e.expires) {
|
||||
providerID, err := ea.providerID()
|
||||
if err != nil {
|
||||
log.Printf("month-availability: error resolviendo provider: %v", err)
|
||||
writeJSONError(w, http.StatusBadGateway, "proveedor_no_disponible")
|
||||
return
|
||||
}
|
||||
|
||||
daysInMonth := first.AddDate(0, 1, -1).Day()
|
||||
today := now.Format("2006-01-02")
|
||||
unavailable := make([]bool, daysInMonth+1)
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, 6) // máx. 6 llamadas a EA en paralelo
|
||||
for d := 1; d <= daysInMonth; d++ {
|
||||
date := fmt.Sprintf("%s-%02d", month, d)
|
||||
if date < today { // los días pasados ya los bloquea el navegador
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(d int, date string) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
var slots []string
|
||||
path := fmt.Sprintf("/availabilities?providerId=%d&serviceId=%d&date=%s", providerID, serviceID, date)
|
||||
// Si EA falla, el día se deja clicable: al seleccionarlo
|
||||
// se consulta de nuevo y allí se informa del error.
|
||||
if err := ea.do(http.MethodGet, path, nil, &slots); err == nil && len(slots) == 0 {
|
||||
unavailable[d] = true
|
||||
}
|
||||
}(d, date)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
days := []string{}
|
||||
for d := 1; d <= daysInMonth; d++ {
|
||||
if unavailable[d] {
|
||||
days = append(days, fmt.Sprintf("%s-%02d", month, d))
|
||||
}
|
||||
}
|
||||
e = cacheEntry{expires: time.Now().Add(10 * time.Minute), days: days}
|
||||
mu.Lock()
|
||||
cache[key] = e
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string][]string{"unavailableDays": e.days})
|
||||
}
|
||||
}
|
||||
|
||||
// findOrCreateCustomer busca el cliente por TELÉFONO en EA (es el dato que lo
|
||||
// identifica; el email es opcional) y, si no existe, lo crea. Así todas las
|
||||
// citas hechas con el mismo teléfono quedan asignadas al mismo cliente.
|
||||
func (c *eaClient) findOrCreateCustomer(req bookRequest) (int, error) {
|
||||
phone := normPhone(req.Telefono)
|
||||
|
||||
// La búsqueda usa los últimos 9 dígitos (número nacional) para encontrar
|
||||
// también registros guardados con prefijo de país.
|
||||
q := phone
|
||||
if len(q) > 9 {
|
||||
q = q[len(q)-9:]
|
||||
}
|
||||
var found []eaCustomer
|
||||
if err := c.do(http.MethodGet, "/customers?q="+url.QueryEscape(req.Email), nil, &found); err == nil {
|
||||
if err := c.do(http.MethodGet, "/customers?q="+url.QueryEscape(q), nil, &found); err == nil {
|
||||
for _, cust := range found {
|
||||
if strings.EqualFold(strings.TrimSpace(cust.Email), req.Email) {
|
||||
if samePhone(cust.Phone, phone) {
|
||||
return int(cust.ID), nil
|
||||
}
|
||||
}
|
||||
@@ -386,7 +516,7 @@ func (c *eaClient) findOrCreateCustomer(req bookRequest) (int, error) {
|
||||
FirstName: req.Nombre,
|
||||
LastName: req.Apellidos,
|
||||
Email: req.Email,
|
||||
Phone: req.Telefono,
|
||||
Phone: phone,
|
||||
Notes: "Creado desde web MAY Studio",
|
||||
}
|
||||
if err := c.do(http.MethodPost, "/customers", body, &created); err != nil {
|
||||
@@ -468,6 +598,10 @@ func main() {
|
||||
// EA_PROVIDER_ID), nunca va hardcodeado en el HTML.
|
||||
http.HandleFunc("/api/config", handleConfig(ea))
|
||||
|
||||
// Días del mes visible sin ningún hueco libre: el calendario los pinta
|
||||
// deshabilitados para ahorrar clics en días completos o festivos.
|
||||
http.HandleFunc("/api/month-availability", handleMonthAvailability(ea))
|
||||
|
||||
http.Handle("/", http.FileServer(http.FS(staticFS)))
|
||||
|
||||
addr := fmt.Sprintf(":%d", *port)
|
||||
|
||||
Reference in New Issue
Block a user