Compare commits
81 Commits
b154a578c3
...
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 | |||
| 27c06fe0b5 | |||
| 69da8d81e7 | |||
| 4011f96ca6 | |||
| f736597b8c | |||
| 58127de1a7 | |||
| 03399077d0 | |||
| 1b872f1905 | |||
| 769915b962 | |||
| a5562623c1 | |||
| 5da73a9408 | |||
| 3337503473 | |||
| cd40105bbb | |||
| ccd65d87a7 | |||
| 23abe3f832 | |||
| 362a636f5f | |||
| b0edc7a1f3 | |||
| 82376b0aed | |||
| 465e71e83d | |||
| 6b194623c8 | |||
| f4ec7aab13 | |||
| 44bf6df686 |
@@ -2,6 +2,7 @@
|
|||||||
SECRET_KEY=django-insecure-change-me
|
SECRET_KEY=django-insecure-change-me
|
||||||
DEBUG=True
|
DEBUG=True
|
||||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
|
S3_ENABLE=False
|
||||||
|
|
||||||
# PostgreSQL (por defecto habilitado; si POSTGRES_ENABLED=False se usa SQLite)
|
# PostgreSQL (por defecto habilitado; si POSTGRES_ENABLED=False se usa SQLite)
|
||||||
POSTGRES_ENABLED=True
|
POSTGRES_ENABLED=True
|
||||||
@@ -14,6 +15,17 @@ POSTGRES_PORT=5432
|
|||||||
# Redis
|
# Redis
|
||||||
REDIS_URL=redis://127.0.0.1:6379/1
|
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
|
||||||
STRIPE_PUBLISHABLE_KEY=
|
STRIPE_PUBLISHABLE_KEY=
|
||||||
STRIPE_SECRET_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
|
- **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
|
- **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/')`
|
- **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
|
## Shipping Restrictions
|
||||||
- **Zona de envío**: Solo se vende/envía dentro de la provincia de Almería
|
- **Zona de envío**: Solo se vende/envía dentro de la provincia de Almería
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
name: Build Docker Image (No Push)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches-ignore:
|
||||||
|
- development
|
||||||
|
- latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout del código
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
- name: Configurar Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: '3.14'
|
||||||
|
- name: Instalar dependencias
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
- name: Ejecutar tests
|
||||||
|
env:
|
||||||
|
DJANGO_SETTINGS_MODULE: proyecto.settings
|
||||||
|
run: |
|
||||||
|
python manage.py test
|
||||||
|
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout del código
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Configurar Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
|
- name: Build (sin push)
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: false
|
||||||
@@ -3,7 +3,8 @@ name: Build and Push Docker Image
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- '**' # Esto aplica para cualquier rama
|
- development
|
||||||
|
- latest
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
@@ -56,4 +57,4 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
# Sanitizamos el nombre de la rama (reemplazamos / por -)
|
# Sanitizamos el nombre de la rama (reemplazamos / por -)
|
||||||
tags: ghcr.io/dsaub/proyecto-mvc:${{ env.IMAGE_TAG }}
|
tags: ghcr.io/dsaub/proyecto-mvc:${{ env.IMAGE_TAG }}
|
||||||
|
|||||||
@@ -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
-1
@@ -3,5 +3,8 @@ db.sqlite3
|
|||||||
.venv
|
.venv
|
||||||
.env
|
.env
|
||||||
logs/
|
logs/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
tienda/__pycache__/
|
tienda/__pycache__/
|
||||||
proyecto/__pycache__/
|
proyecto/__pycache__/
|
||||||
|
media
|
||||||
Binary file not shown.
Binary file not shown.
+4
-2
@@ -34,7 +34,9 @@ http {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
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/ {
|
location /static/ {
|
||||||
alias /static/;
|
alias /static/;
|
||||||
expires 30d;
|
expires 30d;
|
||||||
@@ -42,7 +44,7 @@ http {
|
|||||||
access_log off;
|
access_log off;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Archivos subidos por usuarios.
|
# Archivos subidos por usuarios en modo local.
|
||||||
location /media/ {
|
location /media/ {
|
||||||
alias /media/;
|
alias /media/;
|
||||||
expires 7d;
|
expires 7d;
|
||||||
|
|||||||
+45
-4
@@ -53,6 +53,21 @@ def env_int(name: str, default: int) -> int:
|
|||||||
return default
|
return default
|
||||||
return int(value)
|
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'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
load_dotenv(BASE_DIR / '.env')
|
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!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = env_bool('DEBUG', True)
|
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', [
|
ALLOWED_HOSTS = env_list('ALLOWED_HOSTS', [
|
||||||
'192.168.1.142',
|
'192.168.1.142',
|
||||||
@@ -87,9 +104,11 @@ INSTALLED_APPS = [
|
|||||||
'compressor',
|
'compressor',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if S3_ENABLE:
|
||||||
|
INSTALLED_APPS.append('storages')
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
@@ -98,6 +117,9 @@ MIDDLEWARE = [
|
|||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if not S3_ENABLE:
|
||||||
|
MIDDLEWARE.insert(1, 'whitenoise.middleware.WhiteNoiseMiddleware')
|
||||||
|
|
||||||
ROOT_URLCONF = 'proyecto.urls'
|
ROOT_URLCONF = 'proyecto.urls'
|
||||||
|
|
||||||
TEMPLATES = [
|
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 = [
|
STATICFILES_FINDERS = [
|
||||||
'django.contrib.staticfiles.finders.FileSystemFinder',
|
'django.contrib.staticfiles.finders.FileSystemFinder',
|
||||||
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
|
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
|
||||||
@@ -384,6 +427,4 @@ CELERY_RESULT_SERIALIZER = 'json'
|
|||||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
|
|
||||||
USE_X_FORWARDED_HOST = True
|
USE_X_FORWARDED_HOST = True
|
||||||
SECURE_REFERER_POLICY = "strict-origin-when-cross-origin"
|
SECURE_REFERER_POLICY = "strict-origin-when-cross-origin"
|
||||||
|
|
||||||
print(f"DEBUG: ALLOWED_HOSTS is {ALLOWED_HOSTS}")
|
|
||||||
+3
-1
@@ -26,5 +26,7 @@ urlpatterns = [
|
|||||||
path('tienda/', include('tienda.urls'))
|
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)
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Django==6.0.4
|
|||||||
django-appconf==1.2.0
|
django-appconf==1.2.0
|
||||||
django-redis==5.4.0
|
django-redis==5.4.0
|
||||||
django_compressor==4.6.0
|
django_compressor==4.6.0
|
||||||
|
django-storages[boto3]==1.14.6
|
||||||
gunicorn==25.1.0
|
gunicorn==25.1.0
|
||||||
idna==3.11
|
idna==3.11
|
||||||
Jinja2==3.1.6
|
Jinja2==3.1.6
|
||||||
@@ -22,6 +23,7 @@ MarkupSafe==3.0.3
|
|||||||
packaging==26.0
|
packaging==26.0
|
||||||
paypalrestsdk==1.13.3
|
paypalrestsdk==1.13.3
|
||||||
pillow==12.2.0
|
pillow==12.2.0
|
||||||
|
boto3==1.42.97
|
||||||
prompt_toolkit==3.0.52
|
prompt_toolkit==3.0.52
|
||||||
pycparser==3.0
|
pycparser==3.0
|
||||||
pyOpenSSL==26.0.0
|
pyOpenSSL==26.0.0
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+31
-1
@@ -25,6 +25,11 @@ class User(AbstractUser):
|
|||||||
choices = RegisterStatus.choices,
|
choices = RegisterStatus.choices,
|
||||||
default = RegisterStatus.CONFIRMATION_REQUIRED
|
default = RegisterStatus.CONFIRMATION_REQUIRED
|
||||||
)
|
)
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"username": self.username,
|
||||||
|
"fullname": self.get_full_name()
|
||||||
|
}
|
||||||
|
|
||||||
class VerificationCode(models.Model):
|
class VerificationCode(models.Model):
|
||||||
class VerificationModes(models.TextChoices):
|
class VerificationModes(models.TextChoices):
|
||||||
@@ -41,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,
|
||||||
@@ -55,6 +60,11 @@ class Category(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"name": self.name
|
||||||
|
}
|
||||||
|
|
||||||
class Image(models.Model):
|
class Image(models.Model):
|
||||||
name = models.CharField(max_length=200, default="")
|
name = models.CharField(max_length=200, default="")
|
||||||
@@ -63,6 +73,13 @@ class Image(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"image": self.image.url,
|
||||||
|
"alt": self.alt
|
||||||
|
}
|
||||||
|
|
||||||
class Product(models.Model):
|
class Product(models.Model):
|
||||||
name = models.CharField(max_length=200, default="")
|
name = models.CharField(max_length=200, default="")
|
||||||
@@ -85,6 +102,19 @@ class Product(models.Model):
|
|||||||
def get_vat_amount(self):
|
def get_vat_amount(self):
|
||||||
"""Retorna la cantidad de IVA"""
|
"""Retorna la cantidad de IVA"""
|
||||||
return round(self.price * VAT_RATE, 2)
|
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):
|
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) {
|
@media (min-width: 1250px) {
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -63,8 +88,9 @@ p.price {
|
|||||||
.navbar.header .site-title-mobile {
|
.navbar.header .site-title-mobile {
|
||||||
color: #FFF;
|
color: #FFF;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
top: calc(var(--bs-navbar-padding-y) + 20px);
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translate(-50%, -50%);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
max-width: calc(100% - 9rem);
|
max-width: calc(100% - 9rem);
|
||||||
overflow: hidden;
|
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;
|
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>
|
||||||
|
|||||||
@@ -2,8 +2,24 @@
|
|||||||
{% load vat_filters %}
|
{% load vat_filters %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row mt-2">
|
<div class="row mt-2">
|
||||||
<div class="col-md-2 d-none d-lg-block">
|
<div class="col-12 d-lg-none mb-3">
|
||||||
<h5 class="categorias-titulo">Categorias</h5>
|
<button class="btn btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#mobileCategoriasCollapse" aria-expanded="false" aria-controls="mobileCategoriasCollapse">
|
||||||
|
Categorías
|
||||||
|
</button>
|
||||||
|
<div class="collapse mt-2" id="mobileCategoriasCollapse">
|
||||||
|
<ul class="list-group categorias-lista">
|
||||||
|
{% if categories %}
|
||||||
|
{% for category in categories %}
|
||||||
|
<li class="list-group-item categoria-item">
|
||||||
|
<a href="{% url 'categoria' category.id %}">{{ category.name }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-2 d-none d-lg-block">
|
||||||
|
<h5 class="categorias-titulo">Categorías</h5>
|
||||||
<ul class="list-group categorias-lista">
|
<ul class="list-group categorias-lista">
|
||||||
{% if categories %}
|
{% if categories %}
|
||||||
{% for category in categories %}
|
{% for category in categories %}
|
||||||
@@ -14,7 +30,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-10 grid">
|
<div class="col-12 col-lg-10 grid">
|
||||||
{% if products %}
|
{% if products %}
|
||||||
{% for producto in products %}
|
{% for producto in products %}
|
||||||
<div class="card card-producto mt-5" style="width: 18rem;">
|
<div class="card card-producto mt-5" style="width: 18rem;">
|
||||||
@@ -39,4 +55,4 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
@@ -1358,11 +1360,29 @@ class EndpointViewTests(TestCase):
|
|||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_index_shows_mobile_categories_toggle(self):
|
||||||
|
response = self.client.get(reverse("index"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, 'data-bs-target="#mobileCategoriasCollapse"')
|
||||||
|
self.assertContains(response, 'id="mobileCategoriasCollapse"')
|
||||||
|
self.assertContains(response, "Categorías")
|
||||||
|
|
||||||
def test_home_header_renders_mobile_title_outside_collapsible_menu(self):
|
def test_home_header_renders_mobile_title_outside_collapsible_menu(self):
|
||||||
response = self.client.get(reverse("home"))
|
response = self.client.get(reverse("home"))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, 'site-title-mobile d-md-none')
|
self.assertContains(response, 'site-title-mobile d-md-none')
|
||||||
self.assertContains(response, 'site-title-desktop')
|
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):
|
def test_home_mobile_welcome_title_centered(self):
|
||||||
response = self.client.get(reverse("home"))
|
response = self.client.get(reverse("home"))
|
||||||
html = response.content.decode()
|
html = response.content.decode()
|
||||||
|
|||||||
+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