Compare commits
60 Commits
27c06fe0b5
..
latest
| Author | SHA1 | Date | |
|---|---|---|---|
| 4661bcdffd | |||
| 191f8823d4 | |||
| d75165e31a | |||
| 6ed4fb1954 | |||
| c190a65e57 | |||
| 756f1ad36b | |||
| 033c52a365 | |||
| 297b319a20 | |||
| 830966f3ee | |||
| 81d3694210 | |||
| dce0937511 | |||
| 7f8f70bc42 | |||
| 7203a07350 | |||
| ba75a0ab2e | |||
| 1f7db2db3a | |||
| a2e6e5ad97 | |||
| e78a936b21 | |||
| 30f260c9bf | |||
| 84d8a0e3b6 | |||
| 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 | |||
| d8f6838f0c | |||
| d9d9e5b1a6 | |||
| 501d7aade5 | |||
| 7d3cff0bd9 | |||
| 3cbca38c32 | |||
| dc967c114f | |||
| 60cd29ee30 | |||
| 540b3fdc43 | |||
| 9e33f5b89c | |||
| e1e175f18f | |||
| a45830cf25 | |||
| 369b6764c9 |
@@ -2,6 +2,7 @@
|
||||
SECRET_KEY=django-insecure-change-me
|
||||
DEBUG=True
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
S3_ENABLE=False
|
||||
|
||||
# PostgreSQL (por defecto habilitado; si POSTGRES_ENABLED=False se usa SQLite)
|
||||
POSTGRES_ENABLED=True
|
||||
@@ -14,6 +15,17 @@ POSTGRES_PORT=5432
|
||||
# Redis
|
||||
REDIS_URL=redis://127.0.0.1:6379/1
|
||||
|
||||
# S3 (activar con S3_ENABLE=True)
|
||||
AWS_STORAGE_BUCKET_NAME=
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_S3_REGION_NAME=
|
||||
AWS_S3_ENDPOINT_URL=
|
||||
AWS_S3_CUSTOM_DOMAIN=
|
||||
AWS_S3_USE_SSL=True
|
||||
AWS_QUERYSTRING_AUTH=False
|
||||
AWS_DEFAULT_ACL=public-read
|
||||
|
||||
# Stripe
|
||||
STRIPE_PUBLISHABLE_KEY=
|
||||
STRIPE_SECRET_KEY=
|
||||
|
||||
@@ -35,6 +35,7 @@ Templates use Django's inheritance pattern:
|
||||
- **Image uploads**: Organized in `tienda/static/media/images/` via `upload_to='images/'` in ImageField
|
||||
- **Access**: Media files served automatically in development via Django's static file handler
|
||||
- **Image model**: Located in [tienda/models.py](tienda/models.py) with `ImageField(upload_to='images/')`
|
||||
- **S3 mode**: if `S3_ENABLE=True` (case-insensitive), static and media switch to S3 storages instead of the local filesystem; Nginx should proxy the app only and the browser should load asset URLs from the bucket or CDN
|
||||
|
||||
## Shipping Restrictions
|
||||
- **Zona de envío**: Solo se vende/envía dentro de la provincia de Almería
|
||||
|
||||
@@ -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
|
||||
@@ -7,3 +7,4 @@ __pycache__/
|
||||
*.pyc
|
||||
tienda/__pycache__/
|
||||
proyecto/__pycache__/
|
||||
media
|
||||
+4
-2
@@ -34,7 +34,9 @@ http {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Archivos estáticos generados por collectstatic.
|
||||
# Modo local: sirve static/media desde volúmenes montados.
|
||||
# Si S3_ENABLE=True, estos bloques no se usan y el navegador debe
|
||||
# cargar los assets directamente desde el bucket o CDN.
|
||||
location /static/ {
|
||||
alias /static/;
|
||||
expires 30d;
|
||||
@@ -42,7 +44,7 @@ http {
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Archivos subidos por usuarios.
|
||||
# Archivos subidos por usuarios en modo local.
|
||||
location /media/ {
|
||||
alias /media/;
|
||||
expires 7d;
|
||||
|
||||
+45
-4
@@ -53,6 +53,21 @@ def env_int(name: str, default: int) -> int:
|
||||
return default
|
||||
return int(value)
|
||||
|
||||
|
||||
def env_str(name: str, default: str = '') -> str:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip()
|
||||
|
||||
|
||||
def env_optional_str(name: str) -> str | None:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return None
|
||||
value = value.strip()
|
||||
return value or None
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
load_dotenv(BASE_DIR / '.env')
|
||||
@@ -66,6 +81,8 @@ SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-#g((q@lvnkt(j6)2(gvtn0px)r
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = env_bool('DEBUG', True)
|
||||
S3_ENABLE = env_bool('S3_ENABLE', False)
|
||||
S3_USE_LOCAL_URLS = env_bool('S3_USE_LOCAL_URLS', False)
|
||||
|
||||
ALLOWED_HOSTS = env_list('ALLOWED_HOSTS', [
|
||||
'192.168.1.142',
|
||||
@@ -87,9 +104,11 @@ INSTALLED_APPS = [
|
||||
'compressor',
|
||||
]
|
||||
|
||||
if S3_ENABLE:
|
||||
INSTALLED_APPS.append('storages')
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
@@ -98,6 +117,9 @@ MIDDLEWARE = [
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
if not S3_ENABLE:
|
||||
MIDDLEWARE.insert(1, 'whitenoise.middleware.WhiteNoiseMiddleware')
|
||||
|
||||
ROOT_URLCONF = 'proyecto.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
@@ -211,6 +233,27 @@ STORAGES = {
|
||||
},
|
||||
}
|
||||
|
||||
if S3_ENABLE:
|
||||
AWS_STORAGE_BUCKET_NAME = env_str('AWS_STORAGE_BUCKET_NAME') or None
|
||||
AWS_ACCESS_KEY_ID = env_optional_str('AWS_ACCESS_KEY_ID')
|
||||
AWS_SECRET_ACCESS_KEY = env_optional_str('AWS_SECRET_ACCESS_KEY')
|
||||
AWS_S3_REGION_NAME = env_optional_str('AWS_S3_REGION_NAME')
|
||||
AWS_S3_ENDPOINT_URL = env_optional_str('AWS_S3_ENDPOINT_URL')
|
||||
AWS_S3_CUSTOM_DOMAIN = env_optional_str('AWS_S3_CUSTOM_DOMAIN')
|
||||
AWS_S3_USE_SSL = env_bool('AWS_S3_USE_SSL', True)
|
||||
AWS_QUERYSTRING_AUTH = env_bool('AWS_QUERYSTRING_AUTH', False)
|
||||
AWS_DEFAULT_ACL = env_str('AWS_DEFAULT_ACL', 'public-read') or None
|
||||
AWS_S3_OBJECT_PARAMETERS = {}
|
||||
|
||||
STORAGES = {
|
||||
'default': {
|
||||
'BACKEND': 'tienda.storage_backends.MediaStorage',
|
||||
},
|
||||
'staticfiles': {
|
||||
'BACKEND': 'tienda.storage_backends.StaticStorage',
|
||||
},
|
||||
}
|
||||
|
||||
STATICFILES_FINDERS = [
|
||||
'django.contrib.staticfiles.finders.FileSystemFinder',
|
||||
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
|
||||
@@ -384,6 +427,4 @@ CELERY_RESULT_SERIALIZER = 'json'
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
|
||||
USE_X_FORWARDED_HOST = True
|
||||
SECURE_REFERER_POLICY = "strict-origin-when-cross-origin"
|
||||
|
||||
print(f"DEBUG: ALLOWED_HOSTS is {ALLOWED_HOSTS}")
|
||||
SECURE_REFERER_POLICY = "strict-origin-when-cross-origin"
|
||||
+3
-1
@@ -26,5 +26,7 @@ urlpatterns = [
|
||||
path('tienda/', include('tienda.urls'))
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
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)
|
||||
|
||||
@@ -14,6 +14,7 @@ Django==6.0.4
|
||||
django-appconf==1.2.0
|
||||
django-redis==5.4.0
|
||||
django_compressor==4.6.0
|
||||
django-storages[boto3]==1.14.6
|
||||
gunicorn==25.1.0
|
||||
idna==3.11
|
||||
Jinja2==3.1.6
|
||||
@@ -22,6 +23,7 @@ MarkupSafe==3.0.3
|
||||
packaging==26.0
|
||||
paypalrestsdk==1.13.3
|
||||
pillow==12.2.0
|
||||
boto3==1.42.97
|
||||
prompt_toolkit==3.0.52
|
||||
pycparser==3.0
|
||||
pyOpenSSL==26.0.0
|
||||
|
||||
+31
-1
@@ -25,6 +25,11 @@ class User(AbstractUser):
|
||||
choices = RegisterStatus.choices,
|
||||
default = RegisterStatus.CONFIRMATION_REQUIRED
|
||||
)
|
||||
def to_dict(self):
|
||||
return {
|
||||
"username": self.username,
|
||||
"fullname": self.get_full_name()
|
||||
}
|
||||
|
||||
class VerificationCode(models.Model):
|
||||
class VerificationModes(models.TextChoices):
|
||||
@@ -41,7 +46,7 @@ class VerificationCode(models.Model):
|
||||
|
||||
def generate(user: User, code_mode: str) -> VerificationCode:
|
||||
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():
|
||||
return VerificationCode.objects.create(
|
||||
code = code,
|
||||
@@ -55,6 +60,11 @@ class Category(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"name": self.name
|
||||
}
|
||||
|
||||
class Image(models.Model):
|
||||
name = models.CharField(max_length=200, default="")
|
||||
@@ -63,6 +73,13 @@ class Image(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
"image": self.image.url,
|
||||
"alt": self.alt
|
||||
}
|
||||
|
||||
class Product(models.Model):
|
||||
name = models.CharField(max_length=200, default="")
|
||||
@@ -85,6 +102,19 @@ class Product(models.Model):
|
||||
def get_vat_amount(self):
|
||||
"""Retorna la cantidad de IVA"""
|
||||
return round(self.price * VAT_RATE, 2)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"briefdesc": self.briefdesc,
|
||||
"price": self.price,
|
||||
"stock": self.stock,
|
||||
"category": self.category.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()],
|
||||
"creator": self.creator.to_dict() if self.creator else None
|
||||
}
|
||||
|
||||
|
||||
class StockReservation(models.Model):
|
||||
|
||||
@@ -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;
|
||||
@@ -63,8 +88,9 @@ p.price {
|
||||
.navbar.header .site-title-mobile {
|
||||
color: #FFF;
|
||||
position: absolute;
|
||||
top: calc(var(--bs-navbar-padding-y) + 20px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
transform: translate(-50%, -50%);
|
||||
margin: 0;
|
||||
max-width: calc(100% - 9rem);
|
||||
overflow: hidden;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from django.utils.encoding import iri_to_uri
|
||||
from storages.backends.s3 import S3Storage
|
||||
|
||||
|
||||
def _use_local_asset_urls() -> bool:
|
||||
return os.getenv('S3_USE_LOCAL_URLS', '').strip().lower() in {'1', 'true', 'yes', 'on'}
|
||||
|
||||
|
||||
def _local_asset_url(prefix: str, name: str) -> str:
|
||||
return iri_to_uri(f'/{prefix}/{name.lstrip("/")}')
|
||||
|
||||
|
||||
class StaticStorage(S3Storage):
|
||||
location = 'static'
|
||||
default_acl = 'public-read'
|
||||
querystring_auth = False
|
||||
file_overwrite = True
|
||||
object_parameters = {
|
||||
'CacheControl': 'public, max-age=31536000, immutable',
|
||||
}
|
||||
|
||||
def url(self, name: str) -> str:
|
||||
if _use_local_asset_urls():
|
||||
return _local_asset_url('static', name)
|
||||
return super().url(name)
|
||||
|
||||
|
||||
class MediaStorage(S3Storage):
|
||||
location = 'media'
|
||||
default_acl = 'public-read'
|
||||
querystring_auth = False
|
||||
file_overwrite = False
|
||||
object_parameters = {
|
||||
'CacheControl': 'public, max-age=604800',
|
||||
}
|
||||
|
||||
def url(self, name: str) -> str:
|
||||
if _use_local_asset_urls():
|
||||
return _local_asset_url('media', name)
|
||||
return super().url(name)
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
@@ -1371,6 +1373,16 @@ class EndpointViewTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'site-title-mobile d-md-none')
|
||||
self.assertContains(response, 'site-title-desktop')
|
||||
|
||||
def test_mobile_site_title_css_keeps_title_pinned_to_header_row(self):
|
||||
css_path = Path(__file__).resolve().parent / "static" / "css" / "custom.css"
|
||||
css_content = css_path.read_text(encoding="utf-8")
|
||||
selector_match = re.search(r"\.navbar\.header \.site-title-mobile\s*\{(?P<body>[^}]*)\}", css_content, re.DOTALL)
|
||||
self.assertIsNotNone(selector_match)
|
||||
|
||||
rule_block = selector_match.group("body")
|
||||
self.assertRegex(rule_block, r"top:\s*calc\(var\(--bs-navbar-padding-y\)\s*\+\s*20px\);")
|
||||
self.assertRegex(rule_block, r"transform:\s*translate\(-50%,\s*-50%\);")
|
||||
def test_home_mobile_welcome_title_centered(self):
|
||||
response = self.client.get(reverse("home"))
|
||||
html = response.content.decode()
|
||||
|
||||
+6
-7
@@ -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
|
||||
@@ -239,6 +240,9 @@ def login(request: HttpRequest):
|
||||
|
||||
# Autenticar usuario
|
||||
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)
|
||||
if user.registration_status == "CR":
|
||||
audit_logger.info(
|
||||
@@ -704,6 +708,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:
|
||||
@@ -2253,13 +2258,6 @@ def verify(request: HttpRequest, code: str):
|
||||
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):
|
||||
return render(request, "tienda/rgpd.html", {})
|
||||
|
||||
@@ -2312,6 +2310,7 @@ def reset_password_phase2(request: HttpRequest, code: str):
|
||||
user = ver_code.user
|
||||
user.set_password(password)
|
||||
user.save()
|
||||
ver_code.delete() # Delete Verification code after changing password
|
||||
messages.success(request, "Se ha cambiado la contraseña!")
|
||||
return redirect(reverse("index"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user