Merge pull request #56 from dsaub/rama-usabilidad
Agregado parche de usabilidad
This commit is contained in:
@@ -1,3 +1,28 @@
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
top: -100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #fff;
|
||||
color: #513CB0;
|
||||
padding: 8px 24px;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
z-index: 10001;
|
||||
text-decoration: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
border: 2px solid #513CB0;
|
||||
border-top: none;
|
||||
box-shadow: 0 4px 12px rgba(81, 60, 176, 0.25);
|
||||
transition: top 0.2s ease;
|
||||
outline: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.skip-link:focus,
|
||||
.skip-link:focus-visible {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1250px) {
|
||||
.grid {
|
||||
display: grid;
|
||||
|
||||
@@ -50,8 +50,11 @@
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.search-suggestion-item:hover {
|
||||
.search-suggestion-item:hover,
|
||||
.search-suggestion-item.active {
|
||||
background-color: #f8f9fa;
|
||||
outline: 2px solid #0d6efd;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.search-suggestion-item:last-child {
|
||||
@@ -78,6 +81,7 @@
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="d-flex flex-column min-vh-100">
|
||||
<a href="#main-content" class="skip-link">Saltar al contenido</a>
|
||||
{% cache 500 sidebar request.user.username %}
|
||||
<nav class="navbar navbar-expand-md header" role="banner">
|
||||
<div class="container-fluid">
|
||||
@@ -107,10 +111,10 @@
|
||||
<!-- Barra de búsqueda con sugerencias -->
|
||||
<form class="search-suggestions-container" method="GET" action="{% url 'search' %}" role="search" id="searchForm">
|
||||
<div class="input-group">
|
||||
<input class="form-control" type="search" name="q" id="searchInput" placeholder="Buscar productos..." aria-label="Buscar" autocomplete="off">
|
||||
<button class="btn btn-outline-primary" type="submit">🔍</button>
|
||||
<input class="form-control" type="search" name="q" id="searchInput" placeholder="Buscar productos..." aria-label="Buscar" autocomplete="off" role="combobox" aria-expanded="false" aria-autocomplete="list" aria-controls="searchSuggestions" aria-activedescendant="" aria-haspopup="listbox">
|
||||
<button class="btn btn-outline-primary" type="submit" aria-label="Buscar productos">🔍 Buscar</button>
|
||||
</div>
|
||||
<div class="search-suggestions" id="searchSuggestions"></div>
|
||||
<div class="search-suggestions" id="searchSuggestions" role="listbox" aria-label="Sugerencias de búsqueda"></div>
|
||||
</form>
|
||||
|
||||
<div class="navbar-nav ms-auto d-flex align-items-md-center gap-2 flex-wrap" role="navigation">
|
||||
@@ -137,7 +141,7 @@
|
||||
</nav>
|
||||
{% endcache %}
|
||||
|
||||
<div class="container-fluid flex-grow-1 d-flex flex-column" role="main">
|
||||
<div id="main-content" class="container-fluid flex-grow-1 d-flex flex-column" role="main">
|
||||
<!-- Mensajes -->
|
||||
{% if messages %}
|
||||
<div class="row mt-3">
|
||||
@@ -201,6 +205,35 @@
|
||||
const searchSuggestions = document.getElementById('searchSuggestions');
|
||||
const searchForm = document.getElementById('searchForm');
|
||||
let searchTimeout;
|
||||
let currentFocusIndex = -1;
|
||||
|
||||
// Helpers para gestionar el estado ARIA del combobox
|
||||
function openSuggestions() {
|
||||
searchSuggestions.classList.add('show');
|
||||
searchInput.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
|
||||
function closeSuggestions() {
|
||||
searchSuggestions.classList.remove('show');
|
||||
searchInput.setAttribute('aria-expanded', 'false');
|
||||
searchInput.setAttribute('aria-activedescendant', '');
|
||||
currentFocusIndex = -1;
|
||||
}
|
||||
|
||||
function updateFocus(options) {
|
||||
options.forEach((option, index) => {
|
||||
const active = index === currentFocusIndex;
|
||||
option.classList.toggle('active', active);
|
||||
option.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
});
|
||||
if (currentFocusIndex >= 0) {
|
||||
const activeOption = options[currentFocusIndex];
|
||||
searchInput.setAttribute('aria-activedescendant', activeOption.id);
|
||||
activeOption.scrollIntoView({ block: 'nearest' });
|
||||
} else {
|
||||
searchInput.setAttribute('aria-activedescendant', '');
|
||||
}
|
||||
}
|
||||
|
||||
// Escuchar cambios en el input
|
||||
searchInput.addEventListener('input', function() {
|
||||
@@ -208,7 +241,7 @@
|
||||
const query = this.value.trim();
|
||||
|
||||
if (query.length < 2) {
|
||||
searchSuggestions.classList.remove('show');
|
||||
closeSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -218,6 +251,31 @@
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Navegación por teclado (ArrowDown/ArrowUp/Enter/Escape)
|
||||
searchInput.addEventListener('keydown', function(event) {
|
||||
const options = searchSuggestions.querySelectorAll('[role="option"]');
|
||||
if (!options.length || !searchSuggestions.classList.contains('show')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
currentFocusIndex = Math.min(currentFocusIndex + 1, options.length - 1);
|
||||
updateFocus(options);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
currentFocusIndex = Math.max(currentFocusIndex - 1, -1);
|
||||
updateFocus(options);
|
||||
} else if (event.key === 'Enter' && currentFocusIndex >= 0) {
|
||||
event.preventDefault();
|
||||
const selected = options[currentFocusIndex];
|
||||
window.location.href = selected.dataset.href;
|
||||
} else if (event.key === 'Escape') {
|
||||
closeSuggestions();
|
||||
searchInput.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Función para obtener sugerencias del servidor
|
||||
function fetchSuggestions(query) {
|
||||
fetch(`{% url 'search_suggestions' %}?q=${encodeURIComponent(query)}`)
|
||||
@@ -227,33 +285,37 @@
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching suggestions:', error);
|
||||
searchSuggestions.classList.remove('show');
|
||||
closeSuggestions();
|
||||
});
|
||||
}
|
||||
|
||||
// Función para mostrar las sugerencias
|
||||
function displaySuggestions(suggestions, query) {
|
||||
currentFocusIndex = -1;
|
||||
if (suggestions.length === 0) {
|
||||
searchSuggestions.innerHTML = '<div class="search-suggestion-item text-muted">No se encontraron productos</div>';
|
||||
searchSuggestions.classList.add('show');
|
||||
openSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
suggestions.forEach(suggestion => {
|
||||
suggestions.forEach((suggestion, index) => {
|
||||
// Resaltar la coincidencia en el nombre
|
||||
const highlightedName = highlightMatch(suggestion.name, query);
|
||||
const priceWithVAT = (suggestion.price * 1.21).toFixed(2);
|
||||
html += `
|
||||
<a href="/tienda/producto/${suggestion.id}" class="search-suggestion-item text-decoration-none">
|
||||
<div class="search-suggestion-item" role="option" id="search-option-${index}"
|
||||
aria-selected="false" tabindex="-1"
|
||||
data-href="/tienda/producto/${suggestion.id}"
|
||||
onclick="window.location.href=this.dataset.href">
|
||||
<span class="suggestion-name">${highlightedName}</span>
|
||||
<span class="suggestion-price">€${priceWithVAT}</span>
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
searchSuggestions.innerHTML = html;
|
||||
searchSuggestions.classList.add('show');
|
||||
openSuggestions();
|
||||
}
|
||||
|
||||
// Función para resaltar el texto que coincide
|
||||
@@ -265,19 +327,19 @@
|
||||
// Cerrar sugerencias cuando se hace clic fuera
|
||||
document.addEventListener('click', function(event) {
|
||||
if (!searchForm.contains(event.target)) {
|
||||
searchSuggestions.classList.remove('show');
|
||||
closeSuggestions();
|
||||
}
|
||||
});
|
||||
|
||||
// Cerrar sugerencias al enviar el formulario
|
||||
searchForm.addEventListener('submit', function() {
|
||||
searchSuggestions.classList.remove('show');
|
||||
closeSuggestions();
|
||||
});
|
||||
|
||||
// Mostrar sugerencias al hacer clic en el input (si hay texto)
|
||||
searchInput.addEventListener('focus', function() {
|
||||
if (this.value.trim().length >= 2 && searchSuggestions.innerHTML) {
|
||||
searchSuggestions.classList.add('show');
|
||||
openSuggestions();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<td>
|
||||
<form method="post" action="{% url 'update_cart_item' item.id %}" class="d-flex align-items-center" style="max-width: 150px;">
|
||||
{% csrf_token %}
|
||||
<input type="number" name="quantity" value="{{ item.quantity }}" min="1" max="{{ item.product.stock }}" class="form-control form-control-sm me-2" style="width: 70px;">
|
||||
<input type="number" name="quantity" value="{{ item.quantity }}" min="1" max="{{ item.product.stock }}" class="form-control form-control-sm me-2" style="width: 70px;" aria-label="Cantidad para {{ item.product.name }}">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Actualizar</button>
|
||||
</form>
|
||||
</td>
|
||||
@@ -59,7 +59,7 @@
|
||||
{% if item.product.stock > 0 %}
|
||||
{{ item.product.stock }}
|
||||
{% else %}
|
||||
<span class="text-danger">0</span>
|
||||
<span class="text-danger" role="status"><span aria-hidden="true">✗</span> Sin stock</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="price">{{ item.get_subtotal_with_vat|format_price }} €</td>
|
||||
@@ -89,7 +89,7 @@
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span>IVA (21%)</span>
|
||||
<span class="price text-success">{{ cart.get_vat_amount|format_price }} €</span>
|
||||
<span class="price text-success"><span aria-hidden="true">ℹ️</span> {{ cart.get_vat_amount|format_price }} €</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span>Envío</span>
|
||||
|
||||
@@ -127,22 +127,25 @@
|
||||
<!-- Tabs -->
|
||||
<ul class="nav nav-tabs mb-3" id="paymentTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="tab-card" data-tab="pane-card" type="button" role="tab">
|
||||
<button class="nav-link active" id="tab-card" data-tab="pane-card" type="button"
|
||||
role="tab" aria-selected="true" aria-controls="pane-card" tabindex="0">
|
||||
💳 Tarjeta
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-paypal" data-tab="pane-paypal" type="button" role="tab">
|
||||
<button class="nav-link" id="tab-paypal" data-tab="pane-paypal" type="button"
|
||||
role="tab" aria-selected="false" aria-controls="pane-paypal" tabindex="-1">
|
||||
🅿️ PayPal
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Tarjeta tab -->
|
||||
<div id="pane-card" class="payment-tab-content active">
|
||||
<div id="pane-card" class="payment-tab-content active"
|
||||
role="tabpanel" aria-labelledby="tab-card" tabindex="0">
|
||||
{% if saved_cards %}
|
||||
<div class="mb-3">
|
||||
<p class="fw-semibold">Tarjetas guardadas:</p>
|
||||
<fieldset class="mb-3">
|
||||
<legend class="fw-semibold fs-6 mb-2">Selección de tarjeta</legend>
|
||||
{% for card in saved_cards %}
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="radio" name="saved_card_choice" id="card-{{ card.id }}" value="{{ card.id }}" data-pm-id="{{ card.stripe_payment_method_id }}" {% if card.is_default %}checked{% endif %}>
|
||||
@@ -156,7 +159,7 @@
|
||||
<input class="form-check-input" type="radio" name="saved_card_choice" id="card-new" value="new">
|
||||
<label class="form-check-label" for="card-new">Usar nueva tarjeta</label>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
|
||||
<div id="new-card-section" {% if saved_cards %}style="display:none;"{% endif %}>
|
||||
@@ -183,7 +186,8 @@
|
||||
</div>
|
||||
|
||||
<!-- PayPal tab -->
|
||||
<div id="pane-paypal" class="payment-tab-content">
|
||||
<div id="pane-paypal" class="payment-tab-content"
|
||||
role="tabpanel" aria-labelledby="tab-paypal" tabindex="0">
|
||||
{% if saved_paypal %}
|
||||
<div class="alert alert-light border mb-3">
|
||||
<small class="text-muted">Cuenta PayPal guardada:</small>
|
||||
@@ -196,6 +200,7 @@
|
||||
Guardar esta cuenta de PayPal para futuras compras
|
||||
</label>
|
||||
</div>
|
||||
<div id="paypal-errors" class="alert alert-danger d-none mt-2" role="alert"></div>
|
||||
<div id="paypal-button-container"></div>
|
||||
{% if not addresses or stock_issues %}
|
||||
<div class="alert alert-warning mt-2">Selecciona una dirección de envío válida para activar el pago.</div>
|
||||
@@ -221,12 +226,42 @@ const HAS_STOCK_ISSUES = {{ stock_issues|yesno:"true,false" }};
|
||||
const HAS_ADDRESS = {{ addresses|yesno:"true,false" }};
|
||||
|
||||
// ---- Tab switching ----
|
||||
document.querySelectorAll('#paymentTabs .nav-link').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('#paymentTabs .nav-link').forEach(b => b.classList.remove('active'));
|
||||
document.querySelectorAll('.payment-tab-content').forEach(p => p.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById(btn.dataset.tab).classList.add('active');
|
||||
const paymentTabs = Array.from(document.querySelectorAll('#paymentTabs .nav-link[role="tab"]'));
|
||||
|
||||
function activateTab(tab) {
|
||||
paymentTabs.forEach(b => {
|
||||
const isSelected = b === tab;
|
||||
b.classList.toggle('active', isSelected);
|
||||
b.setAttribute('aria-selected', isSelected ? 'true' : 'false');
|
||||
b.setAttribute('tabindex', isSelected ? '0' : '-1');
|
||||
});
|
||||
document.querySelectorAll('.payment-tab-content').forEach(p => p.classList.remove('active'));
|
||||
document.getElementById(tab.dataset.tab).classList.add('active');
|
||||
}
|
||||
|
||||
paymentTabs.forEach(btn => {
|
||||
btn.addEventListener('click', () => activateTab(btn));
|
||||
btn.addEventListener('keydown', e => {
|
||||
const idx = paymentTabs.indexOf(e.currentTarget);
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
const next = paymentTabs[(idx + 1) % paymentTabs.length];
|
||||
activateTab(next);
|
||||
next.focus();
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
const prev = paymentTabs[(idx - 1 + paymentTabs.length) % paymentTabs.length];
|
||||
activateTab(prev);
|
||||
prev.focus();
|
||||
} else if (e.key === 'Home') {
|
||||
e.preventDefault();
|
||||
activateTab(paymentTabs[0]);
|
||||
paymentTabs[0].focus();
|
||||
} else if (e.key === 'End') {
|
||||
e.preventDefault();
|
||||
activateTab(paymentTabs[paymentTabs.length - 1]);
|
||||
paymentTabs[paymentTabs.length - 1].focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -254,10 +289,13 @@ document.getElementById('pay-card-btn').addEventListener('click', async () => {
|
||||
|
||||
const addressId = document.getElementById('shipping-address').value;
|
||||
if (!addressId) {
|
||||
alert('Selecciona una dirección de envío para continuar.');
|
||||
const cardErrors = document.getElementById('card-errors');
|
||||
if (cardErrors) cardErrors.textContent = 'Selecciona una dirección de envío para continuar.';
|
||||
return;
|
||||
}
|
||||
|
||||
const cardErrorsEl = document.getElementById('card-errors');
|
||||
if (cardErrorsEl) cardErrorsEl.textContent = '';
|
||||
const btn = document.getElementById('pay-card-btn');
|
||||
const spinner = document.getElementById('card-spinner');
|
||||
btn.disabled = true;
|
||||
@@ -335,9 +373,15 @@ paypal.Buttons({
|
||||
createOrder: async () => {
|
||||
const addressId = document.getElementById('shipping-address').value;
|
||||
if (!addressId) {
|
||||
alert('Selecciona una dirección de envío para continuar.');
|
||||
const paypalErrors = document.getElementById('paypal-errors');
|
||||
if (paypalErrors) {
|
||||
paypalErrors.textContent = 'Selecciona una dirección de envío para continuar.';
|
||||
paypalErrors.classList.remove('d-none');
|
||||
}
|
||||
return Promise.reject(new Error('Sin dirección'));
|
||||
}
|
||||
const paypalErrorsEl = document.getElementById('paypal-errors');
|
||||
if (paypalErrorsEl) paypalErrorsEl.classList.add('d-none');
|
||||
const resp = await fetch('{% url "crear_orden_paypal" %}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN },
|
||||
@@ -359,7 +403,11 @@ paypal.Buttons({
|
||||
showSuccess(result.transaction_code);
|
||||
},
|
||||
onError: (err) => {
|
||||
alert('Error en el pago con PayPal: ' + err);
|
||||
const paypalErrors = document.getElementById('paypal-errors');
|
||||
if (paypalErrors) {
|
||||
paypalErrors.textContent = 'Error en el pago con PayPal: ' + err;
|
||||
paypalErrors.classList.remove('d-none');
|
||||
}
|
||||
},
|
||||
}).render('#paypal-button-container');
|
||||
{% endif %}
|
||||
|
||||
@@ -196,9 +196,11 @@
|
||||
<a href="{% url 'producto' product.id %}" class="btn btn-primary btn-sm flex-grow-1">
|
||||
Ver detalles
|
||||
</a>
|
||||
<a href="{% url 'add_to_cart' product.id %}" class="btn btn-outline-primary btn-sm">
|
||||
🛒
|
||||
</a>
|
||||
<form method="post" action="{% url 'add_to_cart' product.id %}" style="display:inline;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="quantity" value="1">
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm">🛒</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,16 +34,16 @@
|
||||
|
||||
<div class="small text-primary font-weight-bold mb-2">Precio total (IVA incluido):</div>
|
||||
<span class="price" style="font-size: 2rem; color: #28a745;">€{{ product.get_price_with_vat|format_price }}</span>
|
||||
<div class="small text-success mt-2">IVA: €{{ product.get_vat_amount|format_price }}</div>
|
||||
<div class="small text-success mt-2"><span aria-hidden="true">ℹ️</span> IVA incluido: €{{ product.get_vat_amount|format_price }}</div>
|
||||
</div>
|
||||
<div id="descripcion" class="texto-ajustado">
|
||||
{{ product.briefdesc }}
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
{% if product.stock > 0 %}
|
||||
<span class="badge bg-success">Stock disponible: {{ product.stock }}</span>
|
||||
<span class="badge bg-success" role="status"><span aria-hidden="true">✓</span> Stock disponible: {{ product.stock }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Sin stock</span>
|
||||
<span class="badge bg-danger" role="status"><span aria-hidden="true">✗</span> Sin stock</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="acceptTerms" name="terms" required>
|
||||
<label class="form-check-label" for="acceptTerms">
|
||||
Acepto los <a href="#" target="_blank">términos y condiciones</a>
|
||||
Acepto los <a href="{% url 'terminos' %}" target="_blank">términos y condiciones</a>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -79,9 +79,11 @@
|
||||
<a href="{% url 'producto' product.id %}" class="btn btn-primary btn-sm flex-grow-1">
|
||||
Ver detalles
|
||||
</a>
|
||||
<a href="{% url 'add_to_cart' product.id %}" class="btn btn-outline-primary btn-sm">
|
||||
🛒
|
||||
</a>
|
||||
<form method="post" action="{% url 'add_to_cart' product.id %}" style="display:inline;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="quantity" value="1">
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm">🛒</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ from .vars import (
|
||||
)
|
||||
from django.conf import settings
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
@@ -704,6 +705,7 @@ def create_order_from_cart(request, payment_method, payment_reference="", shippi
|
||||
return order, ""
|
||||
|
||||
|
||||
@require_POST
|
||||
def add_to_cart(request: HttpRequest, product_id: int):
|
||||
"""Agrega un producto al carrito"""
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user