Compare commits
47 Commits
a2e6e5ad97
...
latest
| Author | SHA1 | Date | |
|---|---|---|---|
| 4661bcdffd | |||
| 191f8823d4 | |||
| d75165e31a | |||
| 6ed4fb1954 | |||
| c190a65e57 | |||
| 756f1ad36b | |||
| 033c52a365 | |||
| 297b319a20 | |||
| 830966f3ee | |||
| 81d3694210 | |||
| dce0937511 | |||
| 7f8f70bc42 | |||
| 7203a07350 | |||
| ba75a0ab2e | |||
| 1f7db2db3a | |||
| e78a936b21 | |||
| 68dbbcad07 | |||
| a94c256ad5 | |||
| 0ff70589b9 | |||
| 25c6fc7315 | |||
| 9f598f56fe | |||
| b905ef435a | |||
| d849e7d3e6 | |||
| 5fa127ddf7 | |||
| 3f521d81b4 | |||
| 6828074dd1 | |||
| ad9fa741e5 | |||
| 07486bb5ec | |||
| a36740b02d | |||
| cb31784097 | |||
| 17935c6160 | |||
| 63df5cf73f | |||
| fe61b3a212 | |||
| d55026b69d | |||
| bdae5b073c | |||
| 183685519a | |||
| 0a9b9138bc | |||
| 3eb963fadf | |||
| 8a5edce758 | |||
| 0eaaa8d19d | |||
| 71cbf6825e | |||
| dd49a6a7d6 | |||
| f785b1862f | |||
| ea6c9c49a0 | |||
| edda5aca50 | |||
| 27c06fe0b5 | |||
| 44bf6df686 |
@@ -0,0 +1,33 @@
|
|||||||
|
name: opencode
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
pull_request_review_comment:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
opencode:
|
||||||
|
if: |
|
||||||
|
contains(github.event.comment.body, ' /oc') ||
|
||||||
|
startsWith(github.event.comment.body, '/oc') ||
|
||||||
|
contains(github.event.comment.body, ' /opencode') ||
|
||||||
|
startsWith(github.event.comment.body, '/opencode')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
issues: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Run opencode
|
||||||
|
uses: anomalyco/opencode/github@latest
|
||||||
|
env:
|
||||||
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
with:
|
||||||
|
model: openai/gpt-5.3-codex
|
||||||
+4
-4
@@ -19,14 +19,14 @@ from django.conf.urls.static import static
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from tienda import views as tienda_views
|
from tienda import views as tienda_views
|
||||||
from tienda.api import api
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', tienda_views.home, name='home'),
|
path('', tienda_views.home, name='home'),
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
path('tienda/', include('tienda.urls')),
|
path('tienda/', include('tienda.urls'))
|
||||||
path('api/', api.urls)
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.DEBUG and not settings.S3_ENABLE:
|
if settings.DEBUG and (
|
||||||
|
not settings.S3_ENABLE or getattr(settings, 'S3_USE_LOCAL_URLS', False)
|
||||||
|
):
|
||||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|||||||
+1
-2
@@ -43,5 +43,4 @@ vine==5.1.0
|
|||||||
wcwidth==0.6.0
|
wcwidth==0.6.0
|
||||||
whitenoise==6.12.0
|
whitenoise==6.12.0
|
||||||
fpdf2==2.8.7
|
fpdf2==2.8.7
|
||||||
psycopg2-binary==2.9.11
|
psycopg2-binary==2.9.11
|
||||||
django-ninja==1.6.2
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
from ninja import NinjaAPI
|
|
||||||
from .models import Product
|
|
||||||
api = NinjaAPI()
|
|
||||||
|
|
||||||
@api.get("/hola")
|
|
||||||
def hola(request):
|
|
||||||
return {"mensaje": "¡Hola Mundo!"}
|
|
||||||
|
|
||||||
@api.get("/products")
|
|
||||||
def products(request):
|
|
||||||
productos = Product.objects.all()
|
|
||||||
return [producto.to_dict() for producto in productos]
|
|
||||||
+3
-3
@@ -46,7 +46,7 @@ class VerificationCode(models.Model):
|
|||||||
|
|
||||||
def generate(user: User, code_mode: str) -> VerificationCode:
|
def generate(user: User, code_mode: str) -> VerificationCode:
|
||||||
while True:
|
while True:
|
||||||
code = "".join(random.choices(string.ascii_letters+string.digits+string.punctuation))
|
code = "".join(random.choices(string.ascii_letters+string.digits, k=64))
|
||||||
if not VerificationCode.objects.filter(code=code).exists():
|
if not VerificationCode.objects.filter(code=code).exists():
|
||||||
return VerificationCode.objects.create(
|
return VerificationCode.objects.create(
|
||||||
code = code,
|
code = code,
|
||||||
@@ -111,9 +111,9 @@ class Product(models.Model):
|
|||||||
"price": self.price,
|
"price": self.price,
|
||||||
"stock": self.stock,
|
"stock": self.stock,
|
||||||
"category": self.category.to_dict(),
|
"category": self.category.to_dict(),
|
||||||
"primary_image": self.primary_image.to_dict(),
|
"primary_image": self.primary_image.to_dict() if self.primary_image else None,
|
||||||
"secondary_images": [secondary_image.to_dict() for secondary_image in self.secondary_images.all()],
|
"secondary_images": [secondary_image.to_dict() for secondary_image in self.secondary_images.all()],
|
||||||
"creator": self.creator.to_dict()
|
"creator": self.creator.to_dict() if self.creator else None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
@media (min-width: 1250px) {
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -50,8 +50,11 @@
|
|||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-suggestion-item:hover {
|
.search-suggestion-item:hover,
|
||||||
|
.search-suggestion-item.active {
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
|
outline: 2px solid #0d6efd;
|
||||||
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-suggestion-item:last-child {
|
.search-suggestion-item:last-child {
|
||||||
@@ -78,6 +81,7 @@
|
|||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="d-flex flex-column min-vh-100">
|
<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 %}
|
{% cache 500 sidebar request.user.username %}
|
||||||
<nav class="navbar navbar-expand-md header" role="banner">
|
<nav class="navbar navbar-expand-md header" role="banner">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
@@ -107,10 +111,10 @@
|
|||||||
<!-- Barra de búsqueda con sugerencias -->
|
<!-- Barra de búsqueda con sugerencias -->
|
||||||
<form class="search-suggestions-container" method="GET" action="{% url 'search' %}" role="search" id="searchForm">
|
<form class="search-suggestions-container" method="GET" action="{% url 'search' %}" role="search" id="searchForm">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input class="form-control" type="search" name="q" id="searchInput" placeholder="Buscar productos..." aria-label="Buscar" autocomplete="off">
|
<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">🔍</button>
|
<button class="btn btn-outline-primary" type="submit" aria-label="Buscar productos">🔍 Buscar</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-suggestions" id="searchSuggestions"></div>
|
<div class="search-suggestions" id="searchSuggestions" role="listbox" aria-label="Sugerencias de búsqueda"></div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="navbar-nav ms-auto d-flex align-items-md-center gap-2 flex-wrap" role="navigation">
|
<div class="navbar-nav ms-auto d-flex align-items-md-center gap-2 flex-wrap" role="navigation">
|
||||||
@@ -137,7 +141,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
{% endcache %}
|
{% 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 -->
|
<!-- Mensajes -->
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
<div class="row mt-3">
|
<div class="row mt-3">
|
||||||
@@ -201,6 +205,35 @@
|
|||||||
const searchSuggestions = document.getElementById('searchSuggestions');
|
const searchSuggestions = document.getElementById('searchSuggestions');
|
||||||
const searchForm = document.getElementById('searchForm');
|
const searchForm = document.getElementById('searchForm');
|
||||||
let searchTimeout;
|
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
|
// Escuchar cambios en el input
|
||||||
searchInput.addEventListener('input', function() {
|
searchInput.addEventListener('input', function() {
|
||||||
@@ -208,7 +241,7 @@
|
|||||||
const query = this.value.trim();
|
const query = this.value.trim();
|
||||||
|
|
||||||
if (query.length < 2) {
|
if (query.length < 2) {
|
||||||
searchSuggestions.classList.remove('show');
|
closeSuggestions();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +251,31 @@
|
|||||||
}, 300);
|
}, 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
|
// Función para obtener sugerencias del servidor
|
||||||
function fetchSuggestions(query) {
|
function fetchSuggestions(query) {
|
||||||
fetch(`{% url 'search_suggestions' %}?q=${encodeURIComponent(query)}`)
|
fetch(`{% url 'search_suggestions' %}?q=${encodeURIComponent(query)}`)
|
||||||
@@ -227,33 +285,37 @@
|
|||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Error fetching suggestions:', error);
|
console.error('Error fetching suggestions:', error);
|
||||||
searchSuggestions.classList.remove('show');
|
closeSuggestions();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Función para mostrar las sugerencias
|
// Función para mostrar las sugerencias
|
||||||
function displaySuggestions(suggestions, query) {
|
function displaySuggestions(suggestions, query) {
|
||||||
|
currentFocusIndex = -1;
|
||||||
if (suggestions.length === 0) {
|
if (suggestions.length === 0) {
|
||||||
searchSuggestions.innerHTML = '<div class="search-suggestion-item text-muted">No se encontraron productos</div>';
|
searchSuggestions.innerHTML = '<div class="search-suggestion-item text-muted">No se encontraron productos</div>';
|
||||||
searchSuggestions.classList.add('show');
|
openSuggestions();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
suggestions.forEach(suggestion => {
|
suggestions.forEach((suggestion, index) => {
|
||||||
// Resaltar la coincidencia en el nombre
|
// Resaltar la coincidencia en el nombre
|
||||||
const highlightedName = highlightMatch(suggestion.name, query);
|
const highlightedName = highlightMatch(suggestion.name, query);
|
||||||
const priceWithVAT = (suggestion.price * 1.21).toFixed(2);
|
const priceWithVAT = (suggestion.price * 1.21).toFixed(2);
|
||||||
html += `
|
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-name">${highlightedName}</span>
|
||||||
<span class="suggestion-price">€${priceWithVAT}</span>
|
<span class="suggestion-price">€${priceWithVAT}</span>
|
||||||
</a>
|
</div>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
|
|
||||||
searchSuggestions.innerHTML = html;
|
searchSuggestions.innerHTML = html;
|
||||||
searchSuggestions.classList.add('show');
|
openSuggestions();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Función para resaltar el texto que coincide
|
// Función para resaltar el texto que coincide
|
||||||
@@ -265,19 +327,19 @@
|
|||||||
// Cerrar sugerencias cuando se hace clic fuera
|
// Cerrar sugerencias cuando se hace clic fuera
|
||||||
document.addEventListener('click', function(event) {
|
document.addEventListener('click', function(event) {
|
||||||
if (!searchForm.contains(event.target)) {
|
if (!searchForm.contains(event.target)) {
|
||||||
searchSuggestions.classList.remove('show');
|
closeSuggestions();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cerrar sugerencias al enviar el formulario
|
// Cerrar sugerencias al enviar el formulario
|
||||||
searchForm.addEventListener('submit', function() {
|
searchForm.addEventListener('submit', function() {
|
||||||
searchSuggestions.classList.remove('show');
|
closeSuggestions();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mostrar sugerencias al hacer clic en el input (si hay texto)
|
// Mostrar sugerencias al hacer clic en el input (si hay texto)
|
||||||
searchInput.addEventListener('focus', function() {
|
searchInput.addEventListener('focus', function() {
|
||||||
if (this.value.trim().length >= 2 && searchSuggestions.innerHTML) {
|
if (this.value.trim().length >= 2 && searchSuggestions.innerHTML) {
|
||||||
searchSuggestions.classList.add('show');
|
openSuggestions();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<form method="post" action="{% url 'update_cart_item' item.id %}" class="d-flex align-items-center" style="max-width: 150px;">
|
<form method="post" action="{% url 'update_cart_item' item.id %}" class="d-flex align-items-center" style="max-width: 150px;">
|
||||||
{% csrf_token %}
|
{% 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>
|
<button type="submit" class="btn btn-sm btn-primary">Actualizar</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
{% if item.product.stock > 0 %}
|
{% if item.product.stock > 0 %}
|
||||||
{{ item.product.stock }}
|
{{ item.product.stock }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-danger">0</span>
|
<span class="text-danger" role="status"><span aria-hidden="true">✗</span> Sin stock</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="price">{{ item.get_subtotal_with_vat|format_price }} €</td>
|
<td class="price">{{ item.get_subtotal_with_vat|format_price }} €</td>
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-between mb-2">
|
<div class="d-flex justify-content-between mb-2">
|
||||||
<span>IVA (21%)</span>
|
<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>
|
||||||
<div class="d-flex justify-content-between mb-2">
|
<div class="d-flex justify-content-between mb-2">
|
||||||
<span>Envío</span>
|
<span>Envío</span>
|
||||||
|
|||||||
@@ -127,22 +127,25 @@
|
|||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<ul class="nav nav-tabs mb-3" id="paymentTabs" role="tablist">
|
<ul class="nav nav-tabs mb-3" id="paymentTabs" role="tablist">
|
||||||
<li class="nav-item" role="presentation">
|
<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
|
💳 Tarjeta
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<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
|
🅿️ PayPal
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<!-- Tarjeta tab -->
|
<!-- 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 %}
|
{% if saved_cards %}
|
||||||
<div class="mb-3">
|
<fieldset class="mb-3">
|
||||||
<p class="fw-semibold">Tarjetas guardadas:</p>
|
<legend class="fw-semibold fs-6 mb-2">Selección de tarjeta</legend>
|
||||||
{% for card in saved_cards %}
|
{% for card in saved_cards %}
|
||||||
<div class="form-check mb-2">
|
<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 %}>
|
<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">
|
<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>
|
<label class="form-check-label" for="card-new">Usar nueva tarjeta</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</fieldset>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div id="new-card-section" {% if saved_cards %}style="display:none;"{% endif %}>
|
<div id="new-card-section" {% if saved_cards %}style="display:none;"{% endif %}>
|
||||||
@@ -183,7 +186,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- PayPal tab -->
|
<!-- 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 %}
|
{% if saved_paypal %}
|
||||||
<div class="alert alert-light border mb-3">
|
<div class="alert alert-light border mb-3">
|
||||||
<small class="text-muted">Cuenta PayPal guardada:</small>
|
<small class="text-muted">Cuenta PayPal guardada:</small>
|
||||||
@@ -196,6 +200,7 @@
|
|||||||
Guardar esta cuenta de PayPal para futuras compras
|
Guardar esta cuenta de PayPal para futuras compras
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="paypal-errors" class="alert alert-danger d-none mt-2" role="alert"></div>
|
||||||
<div id="paypal-button-container"></div>
|
<div id="paypal-button-container"></div>
|
||||||
{% if not addresses or stock_issues %}
|
{% 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>
|
<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" }};
|
const HAS_ADDRESS = {{ addresses|yesno:"true,false" }};
|
||||||
|
|
||||||
// ---- Tab switching ----
|
// ---- Tab switching ----
|
||||||
document.querySelectorAll('#paymentTabs .nav-link').forEach(btn => {
|
const paymentTabs = Array.from(document.querySelectorAll('#paymentTabs .nav-link[role="tab"]'));
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
document.querySelectorAll('#paymentTabs .nav-link').forEach(b => b.classList.remove('active'));
|
function activateTab(tab) {
|
||||||
document.querySelectorAll('.payment-tab-content').forEach(p => p.classList.remove('active'));
|
paymentTabs.forEach(b => {
|
||||||
btn.classList.add('active');
|
const isSelected = b === tab;
|
||||||
document.getElementById(btn.dataset.tab).classList.add('active');
|
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;
|
const addressId = document.getElementById('shipping-address').value;
|
||||||
if (!addressId) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cardErrorsEl = document.getElementById('card-errors');
|
||||||
|
if (cardErrorsEl) cardErrorsEl.textContent = '';
|
||||||
const btn = document.getElementById('pay-card-btn');
|
const btn = document.getElementById('pay-card-btn');
|
||||||
const spinner = document.getElementById('card-spinner');
|
const spinner = document.getElementById('card-spinner');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
@@ -335,9 +373,15 @@ paypal.Buttons({
|
|||||||
createOrder: async () => {
|
createOrder: async () => {
|
||||||
const addressId = document.getElementById('shipping-address').value;
|
const addressId = document.getElementById('shipping-address').value;
|
||||||
if (!addressId) {
|
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'));
|
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" %}', {
|
const resp = await fetch('{% url "crear_orden_paypal" %}', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN },
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN },
|
||||||
@@ -359,7 +403,11 @@ paypal.Buttons({
|
|||||||
showSuccess(result.transaction_code);
|
showSuccess(result.transaction_code);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
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');
|
}).render('#paypal-button-container');
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -196,9 +196,11 @@
|
|||||||
<a href="{% url 'producto' product.id %}" class="btn btn-primary btn-sm flex-grow-1">
|
<a href="{% url 'producto' product.id %}" class="btn btn-primary btn-sm flex-grow-1">
|
||||||
Ver detalles
|
Ver detalles
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'add_to_cart' product.id %}" class="btn btn-outline-primary btn-sm">
|
<form method="post" action="{% url 'add_to_cart' product.id %}" style="display:inline;">
|
||||||
🛒
|
{% csrf_token %}
|
||||||
</a>
|
<input type="hidden" name="quantity" value="1">
|
||||||
|
<button type="submit" class="btn btn-outline-primary btn-sm">🛒</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,16 +34,16 @@
|
|||||||
|
|
||||||
<div class="small text-primary font-weight-bold mb-2">Precio total (IVA incluido):</div>
|
<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>
|
<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>
|
||||||
<div id="descripcion" class="texto-ajustado">
|
<div id="descripcion" class="texto-ajustado">
|
||||||
{{ product.briefdesc }}
|
{{ product.briefdesc }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
{% if product.stock > 0 %}
|
{% 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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
<div class="mb-3 form-check">
|
<div class="mb-3 form-check">
|
||||||
<input type="checkbox" class="form-check-input" id="acceptTerms" name="terms" required>
|
<input type="checkbox" class="form-check-input" id="acceptTerms" name="terms" required>
|
||||||
<label class="form-check-label" for="acceptTerms">
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -79,9 +79,11 @@
|
|||||||
<a href="{% url 'producto' product.id %}" class="btn btn-primary btn-sm flex-grow-1">
|
<a href="{% url 'producto' product.id %}" class="btn btn-primary btn-sm flex-grow-1">
|
||||||
Ver detalles
|
Ver detalles
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'add_to_cart' product.id %}" class="btn btn-outline-primary btn-sm">
|
<form method="post" action="{% url 'add_to_cart' product.id %}" style="display:inline;">
|
||||||
🛒
|
{% csrf_token %}
|
||||||
</a>
|
<input type="hidden" name="quantity" value="1">
|
||||||
|
<button type="submit" class="btn btn-outline-primary btn-sm">🛒</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+6
-7
@@ -16,6 +16,7 @@ from .vars import (
|
|||||||
)
|
)
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from decimal import Decimal, ROUND_HALF_UP
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
@@ -239,6 +240,9 @@ def login(request: HttpRequest):
|
|||||||
|
|
||||||
# Autenticar usuario
|
# Autenticar usuario
|
||||||
user = authenticate(request, username=username, password=password)
|
user = authenticate(request, username=username, password=password)
|
||||||
|
if user is None: # Bug de error 500 en caso de fallar la contra
|
||||||
|
messages.error(request, "Correo electrónico o contraseña incorrectos.")
|
||||||
|
return render(request, "tienda/login.html")
|
||||||
user = User.objects.get(username=user.username)
|
user = User.objects.get(username=user.username)
|
||||||
if user.registration_status == "CR":
|
if user.registration_status == "CR":
|
||||||
audit_logger.info(
|
audit_logger.info(
|
||||||
@@ -704,6 +708,7 @@ def create_order_from_cart(request, payment_method, payment_reference="", shippi
|
|||||||
return order, ""
|
return order, ""
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
def add_to_cart(request: HttpRequest, product_id: int):
|
def add_to_cart(request: HttpRequest, product_id: int):
|
||||||
"""Agrega un producto al carrito"""
|
"""Agrega un producto al carrito"""
|
||||||
try:
|
try:
|
||||||
@@ -2253,13 +2258,6 @@ def verify(request: HttpRequest, code: str):
|
|||||||
return HttpResponse("<h1>Error</h1><p>No existe el codigo de verificación</p>")
|
return HttpResponse("<h1>Error</h1><p>No existe el codigo de verificación</p>")
|
||||||
|
|
||||||
|
|
||||||
def reset_password(request: HttpRequest):
|
|
||||||
if request.user.is_authenticated:
|
|
||||||
return redirect("index")
|
|
||||||
|
|
||||||
|
|
||||||
return render(request, "tienda/reset_password", {})
|
|
||||||
|
|
||||||
def rgpd(request: HttpRequest):
|
def rgpd(request: HttpRequest):
|
||||||
return render(request, "tienda/rgpd.html", {})
|
return render(request, "tienda/rgpd.html", {})
|
||||||
|
|
||||||
@@ -2312,6 +2310,7 @@ def reset_password_phase2(request: HttpRequest, code: str):
|
|||||||
user = ver_code.user
|
user = ver_code.user
|
||||||
user.set_password(password)
|
user.set_password(password)
|
||||||
user.save()
|
user.save()
|
||||||
|
ver_code.delete() # Delete Verification code after changing password
|
||||||
messages.success(request, "Se ha cambiado la contraseña!")
|
messages.success(request, "Se ha cambiado la contraseña!")
|
||||||
return redirect(reverse("index"))
|
return redirect(reverse("index"))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user