Compare commits

..

60 Commits

Author SHA1 Message Date
Daniel (elordenador) 4661bcdffd Merge pull request #62 from dsaub/development
Merge entire work to latest
2026-05-04 13:43:50 +02:00
Daniel (elordenador) 191f8823d4 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-04 12:32:16 +02:00
elordenador d75165e31a Arreglar el bug de posiblemente creator y primary_image este en None... 2026-05-04 12:31:49 +02:00
elordenador 6ed4fb1954 Remove punctuation Signs so we generate 'url-safe' codes 2026-05-04 12:30:09 +02:00
elordenador c190a65e57 Opencore files 2026-05-04 12:25:21 +02:00
elordenador 756f1ad36b Remove entire api for issue #61 2026-04-30 07:43:18 +02:00
elordenador 033c52a365 Fix issue #60 verification code generation 2026-04-30 07:39:14 +02:00
elordenador 297b319a20 Fix issue #59 duplicate reset_password 2026-04-30 07:38:17 +02:00
elordenador 830966f3ee Fix issue #58 not deleting verification code. 2026-04-30 07:37:13 +02:00
elordenador 81d3694210 Solving issue #57 Auth 500 bug 2026-04-30 07:35:28 +02:00
Daniel (elordenador) dce0937511 Merge pull request #56 from dsaub/rama-usabilidad
Agregado parche de usabilidad
2026-04-29 17:02:39 +02:00
Daniel (elordenador) 7f8f70bc42 Merge pull request #55 from dsaub/copilot/unify-add-to-cart-post
[WIP] Fix inconsistency in add to cart action using POST
2026-04-29 11:18:42 +02:00
Daniel (elordenador) 7203a07350 Merge pull request #48 from dsaub/copilot/add-skip-link-to-body
Add "Saltar al contenido" skip link for keyboard/screen reader accessibility
2026-04-29 11:15:44 +02:00
copilot-swe-agent[bot] ba75a0ab2e Style skip link to visually integrate with navbar header
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/a04a8e28-dcc3-4338-8ee9-49c7494bf486

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-29 07:38:20 +00:00
Daniel (elordenador) 1f7db2db3a Merge pull request #54 from dsaub/copilot/fix-terms-link-destination
[WIP] Fix terms link without real destination
2026-04-29 09:30:50 +02:00
elordenador a2e6e5ad97 refactor: change StaticStorage to inherit from S3Storage instead of S3ManifestStaticStorage
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 09:30:09 +02:00
copilot-swe-agent[bot] e78a936b21 Fix terms link in register.html to point to terminos view
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/50c087d4-a283-4c38-bda2-5599d42d382f

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-29 07:24:53 +00:00
elordenador 30f260c9bf feat: add support for local asset URLs in S3 storage backends
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 08:12:57 +02:00
elordenador 84d8a0e3b6 Add S3 Storage... 2026-04-28 21:19:32 +02:00
Daniel (elordenador) 68dbbcad07 Merge pull request #52 from dsaub/copilot/fix-payment-errors-modal
[WIP] Fix payment errors with inline error container
2026-04-28 09:19:53 +02:00
copilot-swe-agent[bot] a94c256ad5 Changes before error encountered
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/f8687aac-de86-402f-b36d-ea422d24ed8e

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-28 07:18:04 +00:00
Daniel (elordenador) 0ff70589b9 Merge pull request #47 from dsaub/copilot/implement-aria-combobox-pattern
Implement ARIA combobox/listbox pattern for search suggestions
2026-04-28 09:17:11 +02:00
copilot-swe-agent[bot] 25c6fc7315 Add null guards for error container DOM lookups
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/e4ef062a-c246-4ec3-9424-987f29891c30

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-28 07:16:26 +00:00
Daniel (elordenador) 9f598f56fe Merge pull request #53 from dsaub/copilot/add-hidden-label-for-quantity
fix(a11y): add unique aria-label to cart quantity inputs
2026-04-28 09:16:22 +02:00
Daniel (elordenador) b905ef435a Merge pull request #49 from dsaub/copilot/improve-stock-iva-color-dependence
fix: replace color-only stock/IVA indicators with explicit text and icons (WCAG Perceptible)
2026-04-28 09:15:22 +02:00
copilot-swe-agent[bot] d849e7d3e6 Replace alert() payment errors with inline role=alert containers in checkout.html
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/e4ef062a-c246-4ec3-9424-987f29891c30

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-28 07:14:49 +00:00
Daniel (elordenador) 5fa127ddf7 Merge pull request #51 from dsaub/copilot/complete-aria-support-for-tabs
fix: complete WAI-ARIA tabs pattern in checkout payment tabs
2026-04-28 09:14:47 +02:00
copilot-swe-agent[bot] 3f521d81b4 fix(a11y): add aria-label to cart quantity input for each product
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/36168486-a2a4-41f3-b3a3-8adf781b354a

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-28 07:13:11 +00:00
copilot-swe-agent[bot] 6828074dd1 fix: complete WAI-ARIA tabs pattern in checkout.html (aria-selected, aria-controls, tabpanel, keyboard nav)
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/73a76f50-8c55-4285-81cf-931b63290b81

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-28 07:11:24 +00:00
copilot-swe-agent[bot] ad9fa741e5 fix: add role=status to stock badge indicators for better screen reader support
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/b6a3a32a-ff80-4431-9ba0-769cbd08b939

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-28 07:11:20 +00:00
Daniel (elordenador) 07486bb5ec Merge pull request #50 from dsaub/copilot/add-fieldset-legend-to-radio-group
fix(a11y): wrap saved-card radio group in fieldset/legend
2026-04-28 09:11:19 +02:00
copilot-swe-agent[bot] a36740b02d fix: add explicit text and icons for stock/IVA accessibility (WCAG Perceptible)
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/b6a3a32a-ff80-4431-9ba0-769cbd08b939

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-28 07:10:02 +00:00
copilot-swe-agent[bot] cb31784097 Implement ARIA combobox/listbox pattern for search suggestions
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/86ca48b3-a56a-4392-9295-0f45ed4f752f

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-28 07:09:44 +00:00
copilot-swe-agent[bot] 17935c6160 Add :focus-visible to skip link for better keyboard navigation UX
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/6f9c00f2-c1ee-4dc2-80fb-2596645e9221

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-28 07:09:31 +00:00
copilot-swe-agent[bot] 63df5cf73f Add skip link 'Saltar al contenido' for keyboard/screen reader accessibility
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/6f9c00f2-c1ee-4dc2-80fb-2596645e9221

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-28 07:08:35 +00:00
copilot-swe-agent[bot] fe61b3a212 fix: wrap saved-card radios in fieldset/legend for accessibility
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/bddffd0c-804e-448e-9954-98917149de3c

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-28 07:07:12 +00:00
copilot-swe-agent[bot] d55026b69d Initial plan 2026-04-28 07:06:40 +00:00
copilot-swe-agent[bot] bdae5b073c Initial plan 2026-04-28 07:06:32 +00:00
copilot-swe-agent[bot] 183685519a Initial plan 2026-04-28 07:06:25 +00:00
copilot-swe-agent[bot] 0a9b9138bc Initial plan 2026-04-28 07:06:16 +00:00
copilot-swe-agent[bot] 3eb963fadf Initial plan 2026-04-28 07:06:05 +00:00
copilot-swe-agent[bot] 8a5edce758 Initial plan 2026-04-28 07:05:57 +00:00
copilot-swe-agent[bot] 0eaaa8d19d Initial plan 2026-04-28 07:05:42 +00:00
copilot-swe-agent[bot] 71cbf6825e Initial plan 2026-04-28 07:05:32 +00:00
copilot-swe-agent[bot] dd49a6a7d6 Initial plan 2026-04-28 07:05:22 +00:00
Daniel (elordenador) f785b1862f Merge pull request #46 from dsaub/copilot/fix-search-button-icon
fix(a11y): add visible text and aria-label to search button
2026-04-28 09:04:47 +02:00
copilot-swe-agent[bot] ea6c9c49a0 fix: add aria-label and visible text to search button for accessibility
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/d1c1f40a-b3a3-4c18-98f9-be267a4a043b

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-28 06:56:38 +00:00
copilot-swe-agent[bot] edda5aca50 Initial plan 2026-04-28 06:50:48 +00:00
elordenador d8f6838f0c refactor: update Product model to use to_dict for image serialization 2026-04-22 09:52:30 +02:00
elordenador d9d9e5b1a6 refactor: update products endpoint to use to_dict method for consistency 2026-04-22 09:52:20 +02:00
elordenador 501d7aade5 chore: add media directory to .gitignore 2026-04-22 09:52:15 +02:00
elordenador 7d3cff0bd9 refactor: rename __dict__ methods to to_dict for consistency in models 2026-04-22 09:22:20 +02:00
elordenador 3cbca38c32 feat: add __dict__ method to models for JSON serialization 2026-04-22 09:15:32 +02:00
elordenador dc967c114f feat: implement initial API endpoints for product retrieval 2026-04-22 09:15:28 +02:00
elordenador 60cd29ee30 feat: add API URL routing to urlpatterns 2026-04-22 09:15:24 +02:00
elordenador 540b3fdc43 chore: add django-ninja to requirements 2026-04-22 09:15:19 +02:00
Daniel (elordenador) 9e33f5b89c Keep mobile navbar title anchored when collapse menu expands (#35)
* Initial plan

* fix: keep mobile header title aligned when navbar menu expands
2026-04-21 07:58:49 +02:00
copilot-swe-agent[bot] e1e175f18f test: make mobile navbar CSS regression assertion order-independent
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/8f4a8d58-4e90-48ad-8195-23b90d8b22d4

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-21 05:45:19 +00:00
copilot-swe-agent[bot] a45830cf25 fix: keep mobile header title aligned when navbar menu expands
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/8f4a8d58-4e90-48ad-8195-23b90d8b22d4

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-21 05:42:24 +00:00
copilot-swe-agent[bot] 369b6764c9 Initial plan 2026-04-21 05:36:42 +00:00
20 changed files with 379 additions and 60 deletions
+12
View File
@@ -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=
+1
View File
@@ -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
+33
View File
@@ -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
+1
View File
@@ -7,3 +7,4 @@ __pycache__/
*.pyc
tienda/__pycache__/
proyecto/__pycache__/
media
+4 -2
View File
@@ -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;
+44 -3
View File
@@ -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',
@@ -385,5 +428,3 @@ 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}")
+3 -1
View File
@@ -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)
+2
View File
@@ -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
View File
@@ -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,
@@ -56,6 +61,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="")
image = models.ImageField(upload_to='images/')
@@ -64,6 +74,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="")
description = models.TextField(default = "")
@@ -86,6 +103,19 @@ class Product(models.Model):
"""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):
STATUS_ACTIVE = "active"
+27 -1
View File
@@ -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;
+44
View File
@@ -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)
+77 -15
View File
@@ -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>
+3 -3
View File
@@ -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>
+64 -16
View File
@@ -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 %}
+5 -3
View File
@@ -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>
+3 -3
View File
@@ -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>
+1 -1
View File
@@ -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>
+5 -3
View File
@@ -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>
+12
View File
@@ -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
View File
@@ -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"))