diff --git a/myproject/accounts/__pycache__/admin.cpython-311.pyc b/myproject/accounts/__pycache__/admin.cpython-311.pyc index e7fd73b..5f5d188 100644 Binary files a/myproject/accounts/__pycache__/admin.cpython-311.pyc and b/myproject/accounts/__pycache__/admin.cpython-311.pyc differ diff --git a/myproject/accounts/__pycache__/apps.cpython-311.pyc b/myproject/accounts/__pycache__/apps.cpython-311.pyc index 9d10bf2..65e227b 100644 Binary files a/myproject/accounts/__pycache__/apps.cpython-311.pyc and b/myproject/accounts/__pycache__/apps.cpython-311.pyc differ diff --git a/myproject/accounts/__pycache__/delivery.cpython-311.pyc b/myproject/accounts/__pycache__/delivery.cpython-311.pyc new file mode 100644 index 0000000..0576a09 Binary files /dev/null and b/myproject/accounts/__pycache__/delivery.cpython-311.pyc differ diff --git a/myproject/accounts/__pycache__/forms.cpython-311.pyc b/myproject/accounts/__pycache__/forms.cpython-311.pyc new file mode 100644 index 0000000..0c18088 Binary files /dev/null and b/myproject/accounts/__pycache__/forms.cpython-311.pyc differ diff --git a/myproject/accounts/__pycache__/models.cpython-311.pyc b/myproject/accounts/__pycache__/models.cpython-311.pyc index 57ea40a..3c94d8f 100644 Binary files a/myproject/accounts/__pycache__/models.cpython-311.pyc and b/myproject/accounts/__pycache__/models.cpython-311.pyc differ diff --git a/myproject/accounts/__pycache__/tests.cpython-311.pyc b/myproject/accounts/__pycache__/tests.cpython-311.pyc new file mode 100644 index 0000000..d61cb51 Binary files /dev/null and b/myproject/accounts/__pycache__/tests.cpython-311.pyc differ diff --git a/myproject/accounts/__pycache__/views.cpython-311.pyc b/myproject/accounts/__pycache__/views.cpython-311.pyc index 6de2d8e..11947ea 100644 Binary files a/myproject/accounts/__pycache__/views.cpython-311.pyc and b/myproject/accounts/__pycache__/views.cpython-311.pyc differ diff --git a/myproject/accounts/admin.py b/myproject/accounts/admin.py index 5b7afaf..08c15dd 100644 --- a/myproject/accounts/admin.py +++ b/myproject/accounts/admin.py @@ -1,20 +1,88 @@ from django.contrib import admin +from django.db.models import Q from django.utils.html import format_html from accounts.models import Profile +class GPSAvailabilityFilter(admin.SimpleListFilter): + title = 'GPS availability' + parameter_name = 'gps' + + def lookups(self, request, model_admin): + return ( + ('yes', 'With GPS'), + ('no', 'Manual only'), + ) + + def queryset(self, request, queryset): + if self.value() == 'yes': + return queryset.filter(latitude__isnull=False, longitude__isnull=False) + if self.value() == 'no': + return queryset.filter(Q(latitude__isnull=True) | Q(longitude__isnull=True)) + return queryset + + @admin.register(Profile) class ProfileAdmin(admin.ModelAdmin): - list_display = ('user', 'is_seller', 'image_preview') + list_display = ( + 'user', + 'delivery_readiness', + 'phone', + 'short_location', + 'has_precise_location', + 'location_updated_at', + 'is_seller', + 'image_preview', + ) + list_select_related = ('user',) + search_fields = ('user__username', 'user__email', 'phone', 'location_label', 'default_address') + list_filter = ('is_seller', GPSAvailabilityFilter, 'location_updated_at') + readonly_fields = ('location_updated_at', 'image_preview', 'maps_link') + autocomplete_fields = ('user',) + fieldsets = ( + ('Account', {'fields': ('user', 'is_seller')}), + ('Delivery defaults', {'fields': ('phone', 'location_label', 'default_address')}), + ('GPS location', {'fields': ('latitude', 'longitude', 'location_accuracy_m', 'location_updated_at', 'maps_link')}), + ('Profile details', {'fields': ('image', 'image_preview', 'bio')}), + ) + @admin.display(description='Delivery readiness') + def delivery_readiness(self, obj): + missing = [] + if not obj.phone: + missing.append('phone') + if not obj.formatted_delivery_address: + missing.append('address') + if not obj.has_precise_location: + missing.append('GPS') + if not missing: + return 'Complete' + return 'Missing ' + ', '.join(missing) + + @admin.display(description='Location') + def short_location(self, obj): + return obj.short_location or '-' + + @admin.display(boolean=True, description='GPS') + def has_precise_location(self, obj): + return obj.has_precise_location + + @admin.display(description='Map') + def maps_link(self, obj): + if not obj.has_precise_location: + return '-' + return format_html( + 'Open map', + obj.latitude, + obj.longitude, + ) + + @admin.display(description='Image') def image_preview(self, obj): if obj.image: return format_html( - '', - obj.image.url + '', + obj.image.url, ) - return "No Image" - - image_preview.short_description = 'Image' + return 'No image' diff --git a/myproject/accounts/apps.py b/myproject/accounts/apps.py index 3cab1e0..3e3c765 100644 --- a/myproject/accounts/apps.py +++ b/myproject/accounts/apps.py @@ -1,5 +1,6 @@ -from django.apps import AppConfig - - -class AccountsConfig(AppConfig): - name = 'accounts' +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts' diff --git a/myproject/accounts/delivery.py b/myproject/accounts/delivery.py new file mode 100644 index 0000000..07ea4a5 --- /dev/null +++ b/myproject/accounts/delivery.py @@ -0,0 +1,128 @@ +from decimal import Decimal, InvalidOperation +import re + +from django.utils import timezone + + +def phone_has_valid_digits(phone): + digits = re.sub(r'\D+', '', (phone or '').strip()) + return 7 <= len(digits) <= 15 + + +def first_address_line(address): + trimmed = (address or '').strip() + if not trimmed: + return '' + return trimmed.splitlines()[0].strip() + + +def derive_location_label(location_label='', address=''): + label = (location_label or '').strip() + if label: + return label[:255] + return first_address_line(address)[:255] + + +def parse_decimal_value(raw_value, *, field_label, minimum=None, maximum=None): + value = (raw_value or '').strip() + if not value: + return None + + try: + parsed = Decimal(value) + except (InvalidOperation, TypeError): + raise ValueError(f'Invalid {field_label}.') + + if minimum is not None and parsed < minimum: + raise ValueError(f'{field_label.capitalize()} must be at least {minimum}.') + if maximum is not None and parsed > maximum: + raise ValueError(f'{field_label.capitalize()} must be no more than {maximum}.') + return parsed + + +def stringify_decimal(value): + if value in {None, ''}: + return '' + return str(value) + + +def build_delivery_payload( + *, + full_name='', + phone='', + address='', + location_label='', + delivery_notes='', + latitude=None, + longitude=None, + location_accuracy_m=None, + save_as_default=True, + **extra, +): + payload = { + 'full_name': (full_name or '').strip(), + 'phone': (phone or '').strip(), + 'address': (address or '').strip(), + 'location_label': derive_location_label(location_label, address), + 'delivery_notes': (delivery_notes or '').strip(), + 'latitude': stringify_decimal(latitude), + 'longitude': stringify_decimal(longitude), + 'location_accuracy_m': stringify_decimal(location_accuracy_m), + 'save_as_default': bool(save_as_default), + } + payload.update(extra) + return payload + + +def profile_delivery_changes(*, phone, address, location_label='', latitude=None, longitude=None, location_accuracy_m=None): + return { + 'phone': (phone or '').strip(), + 'default_address': (address or '').strip(), + 'location_label': derive_location_label(location_label, address), + 'latitude': latitude, + 'longitude': longitude, + 'location_accuracy_m': location_accuracy_m, + 'location_updated_at': timezone.now() if latitude is not None and longitude is not None else None, + } + + +def apply_profile_delivery_fields(profile, **kwargs): + changes = profile_delivery_changes(**kwargs) + for field, value in changes.items(): + setattr(profile, field, value) + return profile + + +def save_profile_delivery_defaults(profile, **kwargs): + if profile is None: + return [] + + changes = profile_delivery_changes(**kwargs) + update_fields = [] + + for field, value in changes.items(): + if getattr(profile, field) != value: + setattr(profile, field, value) + update_fields.append(field) + + if update_fields: + profile.save(update_fields=update_fields) + + return update_fields + + +def delivery_payload_from_profile(profile, *, full_name='', **extra): + if profile is None: + return build_delivery_payload(full_name=full_name, **extra) + + return build_delivery_payload( + full_name=full_name, + phone=profile.phone, + address=profile.formatted_delivery_address, + location_label=profile.short_location, + latitude=profile.latitude, + longitude=profile.longitude, + location_accuracy_m=profile.location_accuracy_m, + save_as_default=True, + **extra, + ) diff --git a/myproject/accounts/forms.py b/myproject/accounts/forms.py index 7db28e6..da54286 100644 --- a/myproject/accounts/forms.py +++ b/myproject/accounts/forms.py @@ -1,27 +1,266 @@ -from django import forms -from django.contrib.auth.models import User -from .models import Profile - - -class ProfileForm(forms.ModelForm): - first_name = forms.CharField(required=False, max_length=30) - last_name = forms.CharField(required=False, max_length=150) - email = forms.EmailField(required=False) - - class Meta: - model = Profile - fields = ['image', 'bio'] - - def save(self, commit=True): - profile = super().save(commit=False) - # update related user fields - user = profile.user - user.first_name = self.cleaned_data.get('first_name', user.first_name) - user.last_name = self.cleaned_data.get('last_name', user.last_name) - email = self.cleaned_data.get('email') - if email: - user.email = email - if commit: - user.save() - profile.save() - return profile +from django import forms +from django.contrib.auth.models import User + +from .delivery import apply_profile_delivery_fields, derive_location_label, phone_has_valid_digits +from .models import Profile + + +class DeliveryLocationValidationMixin: + def clean_phone(self): + phone = (self.cleaned_data.get('phone') or '').strip() + if not phone_has_valid_digits(phone): + raise forms.ValidationError('Please enter a valid phone number.') + return phone + + def clean_default_address(self): + address = (self.cleaned_data.get('default_address') or '').strip() + if not address: + raise forms.ValidationError('Please enter your delivery address.') + return address + + def clean(self): + cleaned_data = super().clean() + latitude = cleaned_data.get('latitude') + longitude = cleaned_data.get('longitude') + + if (latitude is None) != (longitude is None): + raise forms.ValidationError('GPS location is incomplete. Please retry the location button or save without GPS.') + + if latitude is not None and not (-90 <= latitude <= 90): + raise forms.ValidationError('Latitude is outside the valid range.') + + if longitude is not None and not (-180 <= longitude <= 180): + raise forms.ValidationError('Longitude is outside the valid range.') + + location_accuracy_m = cleaned_data.get('location_accuracy_m') + if location_accuracy_m is not None and location_accuracy_m < 0: + raise forms.ValidationError('Location accuracy cannot be negative.') + + if cleaned_data.get('default_address') and not cleaned_data.get('location_label'): + cleaned_data['location_label'] = derive_location_label('', cleaned_data['default_address']) + + return cleaned_data + + def apply_location_to_profile(self, profile): + return apply_profile_delivery_fields( + profile, + phone=self.cleaned_data.get('phone', ''), + location_label=self.cleaned_data.get('location_label', ''), + address=self.cleaned_data.get('default_address', ''), + latitude=self.cleaned_data.get('latitude'), + longitude=self.cleaned_data.get('longitude'), + location_accuracy_m=self.cleaned_data.get('location_accuracy_m'), + ) + + +class RegistrationForm(DeliveryLocationValidationMixin, forms.Form): + username = forms.CharField( + min_length=3, + max_length=150, + widget=forms.TextInput( + attrs={ + 'placeholder': 'Choose a username', + 'autocomplete': 'username', + } + ), + ) + first_name = forms.CharField( + required=False, + max_length=30, + widget=forms.TextInput(attrs={'placeholder': 'First name', 'autocomplete': 'given-name'}), + ) + last_name = forms.CharField( + required=False, + max_length=150, + widget=forms.TextInput(attrs={'placeholder': 'Last name', 'autocomplete': 'family-name'}), + ) + email = forms.EmailField( + required=False, + widget=forms.EmailInput(attrs={'placeholder': 'your@email.com', 'autocomplete': 'email'}), + ) + password = forms.CharField( + min_length=6, + widget=forms.PasswordInput(attrs={'placeholder': 'At least 6 characters', 'autocomplete': 'new-password'}), + ) + confirm_password = forms.CharField( + min_length=6, + widget=forms.PasswordInput(attrs={'placeholder': 'Confirm your password', 'autocomplete': 'new-password'}), + ) + phone = forms.CharField( + max_length=30, + required=True, + widget=forms.TextInput(attrs={'placeholder': 'Phone number', 'autocomplete': 'tel'}), + ) + location_label = forms.CharField( + max_length=255, + required=False, + widget=forms.TextInput( + attrs={ + 'placeholder': 'Area / city / delivery label', + 'autocomplete': 'address-level2', + } + ), + ) + default_address = forms.CharField( + required=True, + widget=forms.Textarea( + attrs={ + 'rows': 4, + 'placeholder': 'Street, city, area, landmark', + 'autocomplete': 'street-address', + } + ), + ) + latitude = forms.DecimalField(required=False, max_digits=9, decimal_places=6, widget=forms.HiddenInput()) + longitude = forms.DecimalField(required=False, max_digits=9, decimal_places=6, widget=forms.HiddenInput()) + location_accuracy_m = forms.DecimalField(required=False, max_digits=8, decimal_places=2, widget=forms.HiddenInput()) + register_as_seller = forms.BooleanField(required=False) + + def clean_username(self): + username = (self.cleaned_data.get('username') or '').strip() + if User.objects.filter(username__iexact=username).exists(): + raise forms.ValidationError('Username already exists.') + return username + + def clean_email(self): + email = (self.cleaned_data.get('email') or '').strip() + if email and User.objects.filter(email__iexact=email).exists(): + raise forms.ValidationError('Email already registered.') + return email + + def clean(self): + cleaned_data = super().clean() + password = cleaned_data.get('password') + confirm_password = cleaned_data.get('confirm_password') + + if password and confirm_password and password != confirm_password: + self.add_error('confirm_password', 'Passwords do not match.') + + return cleaned_data + + def save(self): + user = User.objects.create_user( + username=self.cleaned_data['username'], + password=self.cleaned_data['password'], + email=self.cleaned_data.get('email', ''), + ) + user.first_name = self.cleaned_data.get('first_name', '').strip() + user.last_name = self.cleaned_data.get('last_name', '').strip() + user.save(update_fields=['first_name', 'last_name', 'email']) + + profile = user.profile + self.apply_location_to_profile(profile) + profile.is_seller = self.cleaned_data.get('register_as_seller', False) + profile.save() + return user + + +class ProfileForm(DeliveryLocationValidationMixin, forms.ModelForm): + first_name = forms.CharField(required=False, max_length=30) + last_name = forms.CharField(required=False, max_length=150) + email = forms.EmailField(required=False) + phone = forms.CharField( + max_length=30, + required=True, + widget=forms.TextInput(attrs={'placeholder': 'Phone number', 'autocomplete': 'tel'}), + ) + location_label = forms.CharField( + max_length=255, + required=False, + widget=forms.TextInput( + attrs={ + 'placeholder': 'Area / city / delivery label', + 'autocomplete': 'address-level2', + } + ), + ) + default_address = forms.CharField( + required=True, + widget=forms.Textarea( + attrs={ + 'rows': 4, + 'placeholder': 'Street, city, area, landmark', + 'autocomplete': 'street-address', + } + ), + ) + latitude = forms.DecimalField(required=False, max_digits=9, decimal_places=6, widget=forms.HiddenInput()) + longitude = forms.DecimalField(required=False, max_digits=9, decimal_places=6, widget=forms.HiddenInput()) + location_accuracy_m = forms.DecimalField(required=False, max_digits=8, decimal_places=2, widget=forms.HiddenInput()) + + class Meta: + model = Profile + fields = ['image', 'bio', 'phone', 'location_label', 'default_address', 'latitude', 'longitude', 'location_accuracy_m'] + widgets = { + 'bio': forms.Textarea(attrs={'rows': 5, 'placeholder': 'Tell shoppers something about yourself'}), + } + + def __init__(self, *args, user=None, **kwargs): + super().__init__(*args, **kwargs) + self.user = user or getattr(self.instance, 'user', None) + if self.user: + self.fields['first_name'].initial = self.user.first_name + self.fields['last_name'].initial = self.user.last_name + self.fields['email'].initial = self.user.email + + def clean_email(self): + email = (self.cleaned_data.get('email') or '').strip() + if email and User.objects.exclude(pk=getattr(self.user, 'pk', None)).filter(email__iexact=email).exists(): + raise forms.ValidationError('That email is already being used by another account.') + return email + + def save(self, commit=True): + profile = super().save(commit=False) + self.apply_location_to_profile(profile) + + user = self.user or profile.user + user.first_name = self.cleaned_data.get('first_name', '').strip() + user.last_name = self.cleaned_data.get('last_name', '').strip() + user.email = self.cleaned_data.get('email', '').strip() + + if commit: + user.save() + profile.save() + return profile + + +class DeliveryPreferencesForm(DeliveryLocationValidationMixin, forms.ModelForm): + phone = forms.CharField( + max_length=30, + required=True, + widget=forms.TextInput(attrs={'placeholder': 'Phone number', 'autocomplete': 'tel'}), + ) + location_label = forms.CharField( + max_length=255, + required=False, + widget=forms.TextInput( + attrs={ + 'placeholder': 'Area / city / delivery label', + 'autocomplete': 'address-level2', + } + ), + ) + default_address = forms.CharField( + required=True, + widget=forms.Textarea( + attrs={ + 'rows': 4, + 'placeholder': 'Street, city, area, landmark', + 'autocomplete': 'street-address', + } + ), + ) + latitude = forms.DecimalField(required=False, max_digits=9, decimal_places=6, widget=forms.HiddenInput()) + longitude = forms.DecimalField(required=False, max_digits=9, decimal_places=6, widget=forms.HiddenInput()) + location_accuracy_m = forms.DecimalField(required=False, max_digits=8, decimal_places=2, widget=forms.HiddenInput()) + + class Meta: + model = Profile + fields = ['phone', 'location_label', 'default_address', 'latitude', 'longitude', 'location_accuracy_m'] + + def save(self, commit=True): + profile = super().save(commit=False) + self.apply_location_to_profile(profile) + if commit: + profile.save() + return profile diff --git a/myproject/accounts/migrations/0003_profile_default_address_profile_latitude_and_more.py b/myproject/accounts/migrations/0003_profile_default_address_profile_latitude_and_more.py new file mode 100644 index 0000000..a2e47df --- /dev/null +++ b/myproject/accounts/migrations/0003_profile_default_address_profile_latitude_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.14 on 2026-05-20 09:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_profile_is_seller'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='default_address', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='profile', + name='latitude', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True), + ), + migrations.AddField( + model_name='profile', + name='location_accuracy_m', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True), + ), + migrations.AddField( + model_name='profile', + name='location_label', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AddField( + model_name='profile', + name='location_updated_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='profile', + name='longitude', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True), + ), + migrations.AddField( + model_name='profile', + name='phone', + field=models.CharField(blank=True, default='', max_length=30), + ), + ] diff --git a/myproject/accounts/migrations/__pycache__/0003_profile_default_address_profile_latitude_and_more.cpython-311.pyc b/myproject/accounts/migrations/__pycache__/0003_profile_default_address_profile_latitude_and_more.cpython-311.pyc new file mode 100644 index 0000000..56bec6f Binary files /dev/null and b/myproject/accounts/migrations/__pycache__/0003_profile_default_address_profile_latitude_and_more.cpython-311.pyc differ diff --git a/myproject/accounts/models.py b/myproject/accounts/models.py index d2f6e52..339ee2b 100644 --- a/myproject/accounts/models.py +++ b/myproject/accounts/models.py @@ -1,31 +1,54 @@ -from django.db import models -from django.contrib.auth.models import User -from django.db.models.signals import post_save -from django.dispatch import receiver - - -def user_profile_upload_path(instance, filename): - # Files will be uploaded to MEDIA_ROOT/profile_pics/user_/ - return f'profile_pics/user_{instance.user.id}/{filename}' - - +from django.contrib.auth.models import User +from django.db import models +from django.db.models.signals import post_save +from django.dispatch import receiver + + +def user_profile_upload_path(instance, filename): + # Files will be uploaded to MEDIA_ROOT/profile_pics/user_/ + return f'profile_pics/user_{instance.user.id}/{filename}' + + class Profile(models.Model): - user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') - bio = models.TextField(blank=True, null=True) - image = models.ImageField(upload_to=user_profile_upload_path, blank=True, null=True) - is_seller = models.BooleanField(default=False) - - def __str__(self): - return f'Profile for {self.user.username}' - - -@receiver(post_save, sender=User) -def ensure_profile_exists(sender, instance, created, **kwargs): - if created: - Profile.objects.create(user=instance) - else: - # save existing profile to ensure any related signals run - try: - instance.profile.save() - except Exception: - pass + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') + bio = models.TextField(blank=True, null=True) + image = models.ImageField(upload_to=user_profile_upload_path, blank=True, null=True) + is_seller = models.BooleanField(default=False) + phone = models.CharField(max_length=30, blank=True, default='') + location_label = models.CharField(max_length=255, blank=True, default='') + default_address = models.TextField(blank=True, default='') + latitude = models.DecimalField(max_digits=9, decimal_places=6, blank=True, null=True) + longitude = models.DecimalField(max_digits=9, decimal_places=6, blank=True, null=True) + location_accuracy_m = models.DecimalField(max_digits=8, decimal_places=2, blank=True, null=True) + location_updated_at = models.DateTimeField(blank=True, null=True) + + def __str__(self): + return f'Profile for {self.user.username}' + + @property + def short_location(self): + if self.location_label and self.location_label.strip(): + return self.location_label.strip() + if self.default_address and self.default_address.strip(): + return self.default_address.splitlines()[0].strip() + return '' + + @property + def formatted_delivery_address(self): + return (self.default_address or '').strip() + + @property + def has_precise_location(self): + return self.latitude is not None and self.longitude is not None + + +@receiver(post_save, sender=User) +def ensure_profile_exists(sender, instance, created, **kwargs): + if created: + Profile.objects.create(user=instance) + else: + # save existing profile to ensure any related signals run + try: + instance.profile.save() + except Exception: + pass diff --git a/myproject/accounts/tests.py b/myproject/accounts/tests.py index de8bdc0..de77001 100644 --- a/myproject/accounts/tests.py +++ b/myproject/accounts/tests.py @@ -1,3 +1,109 @@ -from django.test import TestCase - -# Create your tests here. +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse + +from accounts.forms import DeliveryPreferencesForm + +User = get_user_model() + + +class AccountRegistrationTests(TestCase): + def test_registration_creates_profile_with_delivery_location(self): + response = self.client.post( + reverse('register'), + { + 'username': 'newbuyer', + 'first_name': 'New', + 'last_name': 'Buyer', + 'email': 'newbuyer@example.com', + 'password': 'strongpass123', + 'confirm_password': 'strongpass123', + 'phone': '+9779800000001', + 'location_label': 'Boudha, Kathmandu', + 'default_address': 'Boudha Stupa Gate\nKathmandu', + 'latitude': '27.721900', + 'longitude': '85.361100', + 'location_accuracy_m': '14.50', + }, + ) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], reverse('login')) + + user = User.objects.get(username='newbuyer') + self.assertEqual(user.first_name, 'New') + self.assertEqual(user.last_name, 'Buyer') + self.assertEqual(user.profile.phone, '+9779800000001') + self.assertEqual(user.profile.location_label, 'Boudha, Kathmandu') + self.assertEqual(user.profile.default_address, 'Boudha Stupa Gate\nKathmandu') + self.assertIsNotNone(user.profile.latitude) + self.assertIsNotNone(user.profile.longitude) + self.assertIsNotNone(user.profile.location_updated_at) + + def test_registration_defaults_location_label_from_address_when_blank(self): + self.client.post( + reverse('register'), + { + 'username': 'autolabel', + 'first_name': 'Auto', + 'last_name': 'Label', + 'email': 'autolabel@example.com', + 'password': 'strongpass123', + 'confirm_password': 'strongpass123', + 'phone': '+9779800000002', + 'location_label': '', + 'default_address': 'Imadol Height\nLalitpur', + }, + ) + + user = User.objects.get(username='autolabel') + self.assertEqual(user.profile.location_label, 'Imadol Height') + + def test_registration_requires_phone_and_address(self): + response = self.client.post( + reverse('register'), + { + 'username': 'missinglocation', + 'password': 'strongpass123', + 'confirm_password': 'strongpass123', + 'phone': '', + 'default_address': '', + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Please correct the highlighted fields and try again.') + self.assertFalse(User.objects.filter(username='missinglocation').exists()) + + +class DeliveryPreferencesFormTests(TestCase): + def test_saving_manual_address_clears_stale_gps_from_profile(self): + user = User.objects.create_user(username='gpsuser', password='password123') + profile = user.profile + profile.phone = '+9779811111111' + profile.location_label = 'Old Baneshwor' + profile.default_address = 'Old Address\nKathmandu' + profile.latitude = '27.700000' + profile.longitude = '85.300000' + profile.location_accuracy_m = '9.50' + profile.save() + + form = DeliveryPreferencesForm( + data={ + 'phone': '+9779801234567', + 'location_label': 'Manual only', + 'default_address': 'New Road\nBhaktapur', + }, + instance=profile, + ) + + self.assertTrue(form.is_valid(), form.errors) + form.save() + profile.refresh_from_db() + + self.assertEqual(profile.phone, '+9779801234567') + self.assertEqual(profile.default_address, 'New Road\nBhaktapur') + self.assertIsNone(profile.latitude) + self.assertIsNone(profile.longitude) + self.assertIsNone(profile.location_accuracy_m) + self.assertIsNone(profile.location_updated_at) diff --git a/myproject/accounts/views.py b/myproject/accounts/views.py index 2bcd653..0808e47 100644 --- a/myproject/accounts/views.py +++ b/myproject/accounts/views.py @@ -1,13 +1,39 @@ from django.contrib import messages from django.contrib.auth import authenticate, login, logout from django.contrib.auth.decorators import login_required -from django.contrib.auth.models import User from django.db.models import Sum from django.shortcuts import redirect, render from orders.models import Order from products.models import WishlistItem +from .forms import ProfileForm, RegistrationForm +from .models import Profile + + +def _sync_delivery_location_session(request, user): + profile = getattr(user, 'profile', None) + if profile and profile.short_location: + request.session['delivery_location'] = profile.short_location + else: + request.session.pop('delivery_location', None) + + +def _recent_delivery_points(order_queryset, *, limit=3): + points = [] + seen = set() + + for order in order_queryset.exclude(address='').order_by('-created_at'): + key = (order.phone.strip(), order.address.strip(), order.location_label.strip()) + if key in seen: + continue + seen.add(key) + points.append(order) + if len(points) >= limit: + break + + return points + def login_view(request): if request.user.is_authenticated: @@ -24,6 +50,7 @@ def login_view(request): if user: login(request, user) + _sync_delivery_location_session(request, user) messages.success(request, f'Welcome back, {username}!') return redirect('profile') @@ -37,41 +64,28 @@ def register_view(request): return redirect('profile') if request.method == 'POST': - username = request.POST.get('username', '').strip() - password = request.POST.get('password', '').strip() - confirm_password = request.POST.get('confirm_password', '').strip() - email = request.POST.get('email', '').strip() - register_as_seller = request.POST.get('register_as_seller') == 'on' - - if not username or not password or not confirm_password: - return render(request, 'accounts/register.html', {'error': 'All fields are required', 'username': username, 'email': email, 'register_as_seller': register_as_seller}) - if len(username) < 3: - return render(request, 'accounts/register.html', {'error': 'Username must be at least 3 characters long', 'username': username, 'email': email, 'register_as_seller': register_as_seller}) - if len(password) < 6: - return render(request, 'accounts/register.html', {'error': 'Password must be at least 6 characters long', 'username': username, 'email': email, 'register_as_seller': register_as_seller}) - if password != confirm_password: - return render(request, 'accounts/register.html', {'error': 'Passwords do not match', 'username': username, 'email': email, 'register_as_seller': register_as_seller}) - if User.objects.filter(username=username).exists(): - return render(request, 'accounts/register.html', {'error': 'Username already exists', 'email': email, 'register_as_seller': register_as_seller}) - if email and User.objects.filter(email=email).exists(): - return render(request, 'accounts/register.html', {'error': 'Email already registered', 'username': username, 'register_as_seller': register_as_seller}) - - user = User.objects.create_user(username=username, password=password, email=email) - if register_as_seller: - user.profile.is_seller = True - user.profile.save(update_fields=['is_seller']) - messages.success(request, 'Account created successfully! Please log in.') - return redirect('login') + form = RegistrationForm(request.POST) + if form.is_valid(): + form.save() + messages.success(request, 'Account created successfully! Please log in.') + return redirect('login') + messages.error(request, 'Please correct the highlighted fields and try again.') + else: + form = RegistrationForm(initial={'register_as_seller': request.GET.get('seller') == '1'}) return render( request, 'accounts/register.html', - {'register_as_seller': request.GET.get('seller') == '1'}, + { + 'form': form, + 'register_as_seller': request.GET.get('seller') == '1', + }, ) def logout_view(request): logout(request) + request.session.pop('delivery_location', None) return redirect('/') @@ -80,53 +94,53 @@ def profile_view(request): user_orders = Order.objects.filter(user=request.user) delivered_orders = user_orders.filter(status='Delivered') recent_orders = user_orders.order_by('-created_at')[:5] + recent_delivery_points = _recent_delivery_points(user_orders, limit=3) + profile = getattr(request.user, 'profile', None) total_spent = delivered_orders.aggregate(total=Sum('total_price')).get('total') or 0 wishlist_count = WishlistItem.objects.filter(user=request.user).count() + profile_checks = [ + bool(request.user.email), + bool(profile and profile.phone), + bool(profile and profile.formatted_delivery_address), + bool(profile and profile.has_precise_location), + ] + profile_completion = int(sum(profile_checks) / len(profile_checks) * 100) if profile_checks else 0 + return render( request, 'accounts/profile.html', { 'user': request.user, + 'profile': profile, 'orders_count': user_orders.count(), 'delivered_count': delivered_orders.count(), 'pending_count': user_orders.exclude(status='Delivered').count(), 'wishlist_count': wishlist_count, 'total_spent': total_spent, 'recent_orders': recent_orders, + 'recent_delivery_points': recent_delivery_points, + 'profile_completion': profile_completion, }, ) @login_required def edit_profile(request): - from .forms import ProfileForm - profile = getattr(request.user, 'profile', None) if profile is None: - # ensure profile exists - from .models import Profile - profile = Profile.objects.create(user=request.user) if request.method == 'POST': - form = ProfileForm(request.POST, request.FILES, instance=profile) - # populate user fields into form for display/save - form.fields['first_name'].initial = request.user.first_name - form.fields['last_name'].initial = request.user.last_name - form.fields['email'].initial = request.user.email - + form = ProfileForm(request.POST, request.FILES, instance=profile, user=request.user) if form.is_valid(): - form.save() + profile = form.save() + _sync_delivery_location_session(request, request.user) messages.success(request, 'Profile updated successfully.') return redirect('profile') - else: - messages.error(request, 'Please correct the errors below.') + messages.error(request, 'Please correct the errors below.') else: - form = ProfileForm(instance=profile) - form.fields['first_name'].initial = request.user.first_name - form.fields['last_name'].initial = request.user.last_name - form.fields['email'].initial = request.user.email + form = ProfileForm(instance=profile, user=request.user) return render(request, 'accounts/edit_profile.html', {'form': form, 'profile': profile}) diff --git a/myproject/cart/__pycache__/apps.cpython-311.pyc b/myproject/cart/__pycache__/apps.cpython-311.pyc index 461c625..1754494 100644 Binary files a/myproject/cart/__pycache__/apps.cpython-311.pyc and b/myproject/cart/__pycache__/apps.cpython-311.pyc differ diff --git a/myproject/cart/__pycache__/tests.cpython-311.pyc b/myproject/cart/__pycache__/tests.cpython-311.pyc new file mode 100644 index 0000000..dab7752 Binary files /dev/null and b/myproject/cart/__pycache__/tests.cpython-311.pyc differ diff --git a/myproject/cart/__pycache__/views.cpython-311.pyc b/myproject/cart/__pycache__/views.cpython-311.pyc index 874578b..f1d9566 100644 Binary files a/myproject/cart/__pycache__/views.cpython-311.pyc and b/myproject/cart/__pycache__/views.cpython-311.pyc differ diff --git a/myproject/cart/apps.py b/myproject/cart/apps.py index 4b6b018..f3e3ec9 100644 --- a/myproject/cart/apps.py +++ b/myproject/cart/apps.py @@ -1,5 +1,6 @@ -from django.apps import AppConfig - - -class CartConfig(AppConfig): - name = 'cart' +from django.apps import AppConfig + + +class CartConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'cart' diff --git a/myproject/cart/views.py b/myproject/cart/views.py index 1037266..d6712c0 100644 --- a/myproject/cart/views.py +++ b/myproject/cart/views.py @@ -120,12 +120,16 @@ def cart_view(request): cart = get_cart(request) products = [] subtotal = Decimal('0') + item_count = 0 + saved_amount = Decimal('0') for id, qty in cart.items(): product = get_object_or_404(Product, id=int(id)) product.qty = qty product.subtotal = product.display_price * qty subtotal += product.subtotal + item_count += qty + saved_amount += Decimal(str(product.savings or 0)) * qty products.append(product) shipping = Decimal('60') if products else Decimal('0') @@ -155,5 +159,7 @@ def cart_view(request): 'grand_total': grand_total, 'coupon_code': coupon_code, 'coupon': coupon, + 'item_count': item_count, + 'saved_amount': saved_amount, }, - ) + ) diff --git a/myproject/core/__pycache__/context_processors.cpython-311.pyc b/myproject/core/__pycache__/context_processors.cpython-311.pyc index 8ef28b2..a90daa4 100644 Binary files a/myproject/core/__pycache__/context_processors.cpython-311.pyc and b/myproject/core/__pycache__/context_processors.cpython-311.pyc differ diff --git a/myproject/core/__pycache__/tests.cpython-311.pyc b/myproject/core/__pycache__/tests.cpython-311.pyc new file mode 100644 index 0000000..e614aa7 Binary files /dev/null and b/myproject/core/__pycache__/tests.cpython-311.pyc differ diff --git a/myproject/core/__pycache__/views.cpython-311.pyc b/myproject/core/__pycache__/views.cpython-311.pyc index b57d773..5c013b2 100644 Binary files a/myproject/core/__pycache__/views.cpython-311.pyc and b/myproject/core/__pycache__/views.cpython-311.pyc differ diff --git a/myproject/core/context_processors.py b/myproject/core/context_processors.py index dd21107..45ad2f7 100644 --- a/myproject/core/context_processors.py +++ b/myproject/core/context_processors.py @@ -142,10 +142,14 @@ def language_context(request): seen.add(default_category['value'].lower()) delivery_location = request.session.get('delivery_location', '').strip() - if not delivery_location and request.user.is_authenticated: - last_address = Order.objects.filter(user=request.user).exclude(address='').order_by('-created_at').values_list('address', flat=True).first() - if last_address: - delivery_location = last_address.split('\n', 1)[0].strip() + if request.user.is_authenticated: + profile = getattr(request.user, 'profile', None) + if profile and profile.short_location: + delivery_location = profile.short_location + elif not delivery_location: + last_address = Order.objects.filter(user=request.user).exclude(address='').order_by('-created_at').values_list('address', flat=True).first() + if last_address: + delivery_location = last_address.split('\n', 1)[0].strip() return { 'site_language': site_language, diff --git a/myproject/core/views.py b/myproject/core/views.py index 2b9d599..bc9a874 100644 --- a/myproject/core/views.py +++ b/myproject/core/views.py @@ -1,8 +1,11 @@ from decimal import Decimal -from django.db.models import Avg +from django.contrib import messages +from django.db.models import Avg, Count, Q from django.shortcuts import redirect, render +from accounts.forms import DeliveryPreferencesForm +from accounts.models import Profile from products.models import Product @@ -18,6 +21,26 @@ def _format_discount_offer(product): } +def _build_category_spotlights(): + top_categories = list( + Product.objects.exclude(category='') + .values('category') + .annotate( + total=Count('id'), + featured_total=Count('id', filter=Q(featured=True)), + avg_rating=Avg('rating'), + ) + .order_by('-total', 'category')[:4] + ) + + for item in top_categories: + category_products = Product.objects.filter(category=item['category']) + item['avg_rating'] = item.get('avg_rating') or 0 + item['best_price'] = min((product.display_price for product in category_products), default=Decimal('0')) + + return top_categories + + def home(request): trending_products = Product.objects.filter(featured=True).order_by('-created_at')[:4] if not trending_products.exists(): @@ -40,24 +63,30 @@ def home(request): if offer_deal: special_deals.append(offer_deal) - special_deals.append({ - 'title': f'{len(discounted_products)} offers live', - 'description': 'Discounts available across top Nepali categories.', - 'note': 'Browse products with extra savings today.', - }) + special_deals.append( + { + 'title': f'{len(discounted_products)} offers live', + 'description': 'Discounts available across top Nepali categories.', + 'note': 'Browse products with extra savings today.', + } + ) if featured_count: - special_deals.append({ - 'title': 'Featured Seller Picks', - 'description': f'{featured_count} curated products from trusted sellers.', - 'note': 'Popular with Nepali shoppers.', - }) + special_deals.append( + { + 'title': 'Featured Seller Picks', + 'description': f'{featured_count} curated products from trusted sellers.', + 'note': 'Popular with Nepali shoppers.', + } + ) else: - special_deals.append({ - 'title': 'Daily Deals', - 'description': 'Fresh discount offers updated every day.', - 'note': 'Check back for more savings.', - }) + special_deals.append( + { + 'title': 'Daily Deals', + 'description': 'Fresh discount offers updated every day.', + 'note': 'Check back for more savings.', + } + ) advertised_products = sorted( discounted_products, @@ -68,7 +97,7 @@ def home(request): if not advertised_products: advertised_products = list(trending_products[:4]) - for index, product in enumerate(advertised_products): + for product in advertised_products: savings = (product.price - product.discount_price) if product.discount_price else Decimal('0') percent = int((savings / product.price * Decimal('100')).quantize(Decimal('1'))) if product.price else 0 if percent >= 50: @@ -83,6 +112,29 @@ def home(request): label = 'Featured Deal' product.deal_label = label + experience_highlights = [ + { + 'title': 'Verified payments', + 'description': 'Orders are only marked paid after wallet or card verification is completed on the server.', + 'badge': 'Trusted checkout', + }, + { + 'title': 'Flexible payment flow', + 'description': 'Customers can start with cash on delivery and later switch to eSewa or Khalti before shipment.', + 'badge': 'Wallet ready', + }, + { + 'title': 'Smart product discovery', + 'description': 'Search, categories, sorting, wishlist, and featured filters make it easier to find the right item fast.', + 'badge': 'Fast browsing', + }, + { + 'title': 'Trackable orders', + 'description': 'Order status, payment status, and next actions are visible from one clean dashboard-style experience.', + 'badge': 'Clear visibility', + }, + ] + return render( request, 'core/home.html', @@ -94,6 +146,8 @@ def home(request): 'avg_rating': aggregates.get('avg_rating') or 0, 'special_deals': special_deals, 'advertised_products': advertised_products, + 'category_spotlights': _build_category_spotlights(), + 'experience_highlights': experience_highlights, }, ) @@ -128,12 +182,44 @@ def landing(request): def settings_view(request): - if request.method == 'POST': - delivery_location = request.POST.get('delivery_location', '').strip() - if delivery_location: - request.session['delivery_location'] = delivery_location + location_form = None + guest_delivery_location = request.session.get('delivery_location', '').strip() + profile = None + + if request.user.is_authenticated: + profile = getattr(request.user, 'profile', None) + if profile is None: + profile = Profile.objects.create(user=request.user) + + if request.method == 'POST': + location_form = DeliveryPreferencesForm(request.POST, instance=profile) + if location_form.is_valid(): + profile = location_form.save() + if profile.short_location: + request.session['delivery_location'] = profile.short_location + else: + request.session.pop('delivery_location', None) + messages.success(request, 'Delivery preferences updated successfully.') + return redirect('settings') + messages.error(request, 'Please correct the location details below.') + else: + location_form = DeliveryPreferencesForm(instance=profile) + elif request.method == 'POST': + guest_delivery_location = request.POST.get('delivery_location', '').strip() + if guest_delivery_location: + request.session['delivery_location'] = guest_delivery_location + messages.success(request, 'Temporary delivery location saved for this browser.') else: request.session.pop('delivery_location', None) + messages.info(request, 'Temporary delivery location cleared.') return redirect('settings') - return render(request, 'core/settings.html') + return render( + request, + 'core/settings.html', + { + 'location_form': location_form, + 'guest_delivery_location': guest_delivery_location, + 'location_saved_at': getattr(profile, 'location_updated_at', None), + }, + ) diff --git a/myproject/db.sqlite3 b/myproject/db.sqlite3 index d8f4295..314a1ec 100644 Binary files a/myproject/db.sqlite3 and b/myproject/db.sqlite3 differ diff --git a/myproject/media/products/checkout.gif b/myproject/media/products/checkout.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/checkout.gif differ diff --git a/myproject/media/products/checkout_7Tq3pK9.gif b/myproject/media/products/checkout_7Tq3pK9.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/checkout_7Tq3pK9.gif differ diff --git a/myproject/media/products/checkout_Apl5oIV.gif b/myproject/media/products/checkout_Apl5oIV.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/checkout_Apl5oIV.gif differ diff --git a/myproject/media/products/checkout_Fu8w9sg.gif b/myproject/media/products/checkout_Fu8w9sg.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/checkout_Fu8w9sg.gif differ diff --git a/myproject/media/products/checkout_OIU3UZr.gif b/myproject/media/products/checkout_OIU3UZr.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/checkout_OIU3UZr.gif differ diff --git a/myproject/media/products/checkout_UwESDAP.gif b/myproject/media/products/checkout_UwESDAP.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/checkout_UwESDAP.gif differ diff --git a/myproject/media/products/checkout_XwH82Ej.gif b/myproject/media/products/checkout_XwH82Ej.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/checkout_XwH82Ej.gif differ diff --git a/myproject/media/products/checkout_cHT6Jqv.gif b/myproject/media/products/checkout_cHT6Jqv.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/checkout_cHT6Jqv.gif differ diff --git a/myproject/media/products/checkout_kHc70mM.gif b/myproject/media/products/checkout_kHc70mM.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/checkout_kHc70mM.gif differ diff --git a/myproject/media/products/checkout_kXmZj1w.gif b/myproject/media/products/checkout_kXmZj1w.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/checkout_kXmZj1w.gif differ diff --git a/myproject/media/products/checkout_swmZZ69.gif b/myproject/media/products/checkout_swmZZ69.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/checkout_swmZZ69.gif differ diff --git a/myproject/media/products/checkout_tynTK9s.gif b/myproject/media/products/checkout_tynTK9s.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/checkout_tynTK9s.gif differ diff --git a/myproject/media/products/product.gif b/myproject/media/products/product.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/product.gif differ diff --git a/myproject/media/products/product_43wNxQz.gif b/myproject/media/products/product_43wNxQz.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/product_43wNxQz.gif differ diff --git a/myproject/media/products/product_6lHiVJy.gif b/myproject/media/products/product_6lHiVJy.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/product_6lHiVJy.gif differ diff --git a/myproject/media/products/product_Ant52cM.gif b/myproject/media/products/product_Ant52cM.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/product_Ant52cM.gif differ diff --git a/myproject/media/products/product_AuUXWqH.gif b/myproject/media/products/product_AuUXWqH.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/product_AuUXWqH.gif differ diff --git a/myproject/media/products/product_ETEIG5v.gif b/myproject/media/products/product_ETEIG5v.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/product_ETEIG5v.gif differ diff --git a/myproject/media/products/product_ElMt9Bz.gif b/myproject/media/products/product_ElMt9Bz.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/product_ElMt9Bz.gif differ diff --git a/myproject/media/products/product_Jr0cyVq.gif b/myproject/media/products/product_Jr0cyVq.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/product_Jr0cyVq.gif differ diff --git a/myproject/media/products/product_LLFVOs6.gif b/myproject/media/products/product_LLFVOs6.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/product_LLFVOs6.gif differ diff --git a/myproject/media/products/product_OEdan1B.gif b/myproject/media/products/product_OEdan1B.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/product_OEdan1B.gif differ diff --git a/myproject/media/products/product_P2RBysy.gif b/myproject/media/products/product_P2RBysy.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/product_P2RBysy.gif differ diff --git a/myproject/media/products/product_QD5Pj7T.gif b/myproject/media/products/product_QD5Pj7T.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/product_QD5Pj7T.gif differ diff --git a/myproject/media/products/product_R2JLv1Y.gif b/myproject/media/products/product_R2JLv1Y.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/product_R2JLv1Y.gif differ diff --git a/myproject/media/products/product_RHmtSgn.gif b/myproject/media/products/product_RHmtSgn.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/product_RHmtSgn.gif differ diff --git a/myproject/media/products/product_RoWLVcr.gif b/myproject/media/products/product_RoWLVcr.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/product_RoWLVcr.gif differ diff --git a/myproject/media/products/product_SaeMYQz.gif b/myproject/media/products/product_SaeMYQz.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/product_SaeMYQz.gif differ diff --git a/myproject/media/products/product_bbYiWAc.gif b/myproject/media/products/product_bbYiWAc.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/product_bbYiWAc.gif differ diff --git a/myproject/media/products/product_g0hum12.gif b/myproject/media/products/product_g0hum12.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/product_g0hum12.gif differ diff --git a/myproject/media/products/product_k2IZBN8.gif b/myproject/media/products/product_k2IZBN8.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/product_k2IZBN8.gif differ diff --git a/myproject/media/products/product_qrGyMS2.gif b/myproject/media/products/product_qrGyMS2.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/product_qrGyMS2.gif differ diff --git a/myproject/media/products/product_rCx1Jzm.gif b/myproject/media/products/product_rCx1Jzm.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/product_rCx1Jzm.gif differ diff --git a/myproject/media/products/product_rGz8cvy.gif b/myproject/media/products/product_rGz8cvy.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/product_rGz8cvy.gif differ diff --git a/myproject/media/products/product_tjnDy5f.gif b/myproject/media/products/product_tjnDy5f.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/product_tjnDy5f.gif differ diff --git a/myproject/media/products/product_vcUB9za.gif b/myproject/media/products/product_vcUB9za.gif new file mode 100644 index 0000000..bdf7a77 Binary files /dev/null and b/myproject/media/products/product_vcUB9za.gif differ diff --git a/myproject/media/profile_pics/user_3/samanya_you.webp b/myproject/media/profile_pics/user_3/samanya_you.webp new file mode 100644 index 0000000..f3ff009 Binary files /dev/null and b/myproject/media/profile_pics/user_3/samanya_you.webp differ diff --git a/myproject/media/profile_pics/user_6/logo.jpg b/myproject/media/profile_pics/user_6/logo.jpg new file mode 100644 index 0000000..b68a9c8 Binary files /dev/null and b/myproject/media/profile_pics/user_6/logo.jpg differ diff --git a/myproject/myproject/__pycache__/settings.cpython-311.pyc b/myproject/myproject/__pycache__/settings.cpython-311.pyc index 907880d..b163443 100644 Binary files a/myproject/myproject/__pycache__/settings.cpython-311.pyc and b/myproject/myproject/__pycache__/settings.cpython-311.pyc differ diff --git a/myproject/myproject/settings.py b/myproject/myproject/settings.py index d0c76f7..2687e85 100644 --- a/myproject/myproject/settings.py +++ b/myproject/myproject/settings.py @@ -10,6 +10,7 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/6.0/ref/settings/ """ +import os from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -27,6 +28,8 @@ DEBUG = True ALLOWED_HOSTS = ['localhost', '127.0.0.1', '[::1]', 'testserver', '.flatlogic.app'] CSRF_TRUSTED_ORIGINS = ['https://*.flatlogic.app', 'http://*.flatlogic.app'] +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +USE_X_FORWARDED_HOST = True # Application definition @@ -43,7 +46,6 @@ INSTALLED_APPS = [ 'cart', # cart app for shopping cart functionality 'orders', # orders app for order management 'products', # products app for product management - ] MIDDLEWARE = [ @@ -61,7 +63,7 @@ ROOT_URLCONF = 'myproject.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [BASE_DIR / 'tempelates'], # ✅ FIX HERE + 'DIRS': [BASE_DIR / 'tempelates'], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -128,10 +130,46 @@ USE_TZ = True STATIC_URL = '/static/' STATICFILES_DIRS = [ - BASE_DIR / "static", + BASE_DIR / 'static', ] # Authentication Settings LOGIN_URL = 'login' LOGIN_REDIRECT_URL = 'profile' LOGOUT_REDIRECT_URL = 'home' + +# Payment configuration +PUBLIC_APP_URL = os.getenv('PUBLIC_APP_URL', '').strip().rstrip('/') +PAYMENT_CURRENCY = (os.getenv('PAYMENT_CURRENCY', 'NPR').strip() or 'NPR').upper() + +# Stripe fallback for card payments +STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY', '').strip() +STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET', '').strip() +STRIPE_CURRENCY = (os.getenv('STRIPE_CURRENCY', PAYMENT_CURRENCY.lower()).strip() or PAYMENT_CURRENCY.lower()).lower() + +# eSewa ePay (defaults to official UAT credentials in this dev environment) +ESEWA_SANDBOX = (os.getenv('ESEWA_SANDBOX', '1').strip() or '1').lower() not in {'0', 'false', 'no'} +ESEWA_PRODUCT_CODE = os.getenv('ESEWA_PRODUCT_CODE', 'EPAYTEST' if ESEWA_SANDBOX else '').strip() +ESEWA_SECRET_KEY = os.getenv('ESEWA_SECRET_KEY', '8gBm/:&EnhH.1/q' if ESEWA_SANDBOX else '').strip() +ESEWA_FORM_URL = os.getenv( + 'ESEWA_FORM_URL', + 'https://rc-epay.esewa.com.np/api/epay/main/v2/form' if ESEWA_SANDBOX else 'https://epay.esewa.com.np/api/epay/main/v2/form', +).strip() +ESEWA_STATUS_URL = os.getenv( + 'ESEWA_STATUS_URL', + 'https://rc.esewa.com.np/api/epay/transaction/status/' if ESEWA_SANDBOX else 'https://epay.esewa.com.np/api/epay/transaction/status/', +).strip() + +# Khalti KPG-2 +KHALTI_SANDBOX = (os.getenv('KHALTI_SANDBOX', '1').strip() or '1').lower() not in {'0', 'false', 'no'} +KHALTI_SECRET_KEY = os.getenv('KHALTI_SECRET_KEY', '').strip() +KHALTI_INITIATE_URL = os.getenv( + 'KHALTI_INITIATE_URL', + 'https://dev.khalti.com/api/v2/epayment/initiate/' if KHALTI_SANDBOX else 'https://khalti.com/api/v2/epayment/initiate/', +).strip() +KHALTI_LOOKUP_URL = os.getenv( + 'KHALTI_LOOKUP_URL', + 'https://dev.khalti.com/api/v2/epayment/lookup/' if KHALTI_SANDBOX else 'https://khalti.com/api/v2/epayment/lookup/', +).strip() + +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' diff --git a/myproject/orders/__pycache__/admin.cpython-311.pyc b/myproject/orders/__pycache__/admin.cpython-311.pyc index bb5fde2..4537e0b 100644 Binary files a/myproject/orders/__pycache__/admin.cpython-311.pyc and b/myproject/orders/__pycache__/admin.cpython-311.pyc differ diff --git a/myproject/orders/__pycache__/models.cpython-311.pyc b/myproject/orders/__pycache__/models.cpython-311.pyc index c7d90e2..4e3717e 100644 Binary files a/myproject/orders/__pycache__/models.cpython-311.pyc and b/myproject/orders/__pycache__/models.cpython-311.pyc differ diff --git a/myproject/orders/__pycache__/payments.cpython-311.pyc b/myproject/orders/__pycache__/payments.cpython-311.pyc new file mode 100644 index 0000000..91df17a Binary files /dev/null and b/myproject/orders/__pycache__/payments.cpython-311.pyc differ diff --git a/myproject/orders/__pycache__/tests.cpython-311.pyc b/myproject/orders/__pycache__/tests.cpython-311.pyc new file mode 100644 index 0000000..ab72e21 Binary files /dev/null and b/myproject/orders/__pycache__/tests.cpython-311.pyc differ diff --git a/myproject/orders/__pycache__/urls.cpython-311.pyc b/myproject/orders/__pycache__/urls.cpython-311.pyc index ff02850..5399a90 100644 Binary files a/myproject/orders/__pycache__/urls.cpython-311.pyc and b/myproject/orders/__pycache__/urls.cpython-311.pyc differ diff --git a/myproject/orders/__pycache__/views.cpython-311.pyc b/myproject/orders/__pycache__/views.cpython-311.pyc index 6f1a0c0..def1882 100644 Binary files a/myproject/orders/__pycache__/views.cpython-311.pyc and b/myproject/orders/__pycache__/views.cpython-311.pyc differ diff --git a/myproject/orders/admin.py b/myproject/orders/admin.py index fc07dac..0f73875 100644 --- a/myproject/orders/admin.py +++ b/myproject/orders/admin.py @@ -1,8 +1,30 @@ from django.contrib import admin +from django.db.models import Q +from django.template.defaultfilters import linebreaksbr +from django.utils import timezone +from django.utils.html import format_html, format_html_join from .models import Order, OrderItem +class GPSAvailabilityFilter(admin.SimpleListFilter): + title = 'GPS availability' + parameter_name = 'gps' + + def lookups(self, request, model_admin): + return ( + ('yes', 'With GPS'), + ('no', 'Manual only'), + ) + + def queryset(self, request, queryset): + if self.value() == 'yes': + return queryset.filter(latitude__isnull=False, longitude__isnull=False) + if self.value() == 'no': + return queryset.filter(Q(latitude__isnull=True) | Q(longitude__isnull=True)) + return queryset + + class OrderItemInline(admin.TabularInline): model = OrderItem extra = 0 @@ -10,10 +32,129 @@ class OrderItemInline(admin.TabularInline): @admin.register(Order) class OrderAdmin(admin.ModelAdmin): - list_display = ('id', 'user', 'status', 'payment_method', 'total_price', 'created_at') - list_filter = ('status', 'payment_method') - search_fields = ('user__username', 'id') + list_display = ( + 'id', + 'user', + 'status', + 'payment_status', + 'payment_method', + 'payment_provider_label', + 'short_location', + 'has_precise_location', + 'total_price', + 'created_at', + 'paid_at', + ) + list_select_related = ('user',) + list_filter = ('status', 'payment_status', 'payment_provider', 'payment_method', GPSAvailabilityFilter) + search_fields = ( + 'user__username', + 'user__email', + 'id', + 'payment_reference', + 'payment_session_id', + 'full_name', + 'phone', + 'address', + 'location_label', + 'delivery_notes', + ) + readonly_fields = ('created_at', 'payment_reference', 'payment_session_id', 'paid_at', 'delivery_snapshot', 'maps_link') + date_hierarchy = 'created_at' + autocomplete_fields = ('user',) + actions = ('mark_payment_pending', 'mark_payment_paid', 'mark_status_shipped', 'mark_status_delivered') + fieldsets = ( + ('Order', {'fields': ('user', 'status', 'created_at', 'total_price')}), + ( + 'Payment', + { + 'fields': ( + 'payment_status', + 'payment_method', + 'payment_provider', + 'payment_currency', + 'payment_reference', + 'payment_session_id', + 'paid_at', + ) + }, + ), + ('Delivery snapshot', {'fields': ('full_name', 'phone', 'location_label', 'address', 'delivery_notes', 'delivery_snapshot')}), + ('GPS', {'fields': ('latitude', 'longitude', 'location_accuracy_m', 'maps_link')}), + ) inlines = [OrderItemInline] + @admin.display(description='Location') + def short_location(self, obj): + return obj.location_label or (obj.address.splitlines()[0].strip() if obj.address else '-') -admin.site.register(OrderItem) + @admin.display(boolean=True, description='GPS') + def has_precise_location(self, obj): + return obj.has_precise_location + + @admin.display(description='Payment provider') + def payment_provider_label(self, obj): + return obj.payment_provider_label + + @admin.display(description='Delivery summary') + def delivery_snapshot(self, obj): + lines = [] + if obj.full_name: + lines.append(format_html('{}', obj.full_name)) + if obj.phone: + lines.append(obj.phone) + if obj.location_label: + lines.append(obj.location_label) + if obj.address: + lines.append(linebreaksbr(obj.address)) + if obj.delivery_notes: + lines.append(format_html('Notes: {}', obj.delivery_notes)) + if not lines: + return '-' + return format_html_join(format_html('
'), '{}', ((line,) for line in lines)) + + @admin.display(description='Map') + def maps_link(self, obj): + if not obj.has_precise_location: + return '-' + return format_html( + 'Open map', + obj.latitude, + obj.longitude, + ) + + @admin.action(description='Mark selected orders as payment pending') + def mark_payment_pending(self, request, queryset): + updated = queryset.exclude(payment_status='Pending').update(payment_status='Pending') + self.message_user(request, f'{updated} order(s) marked as payment pending.') + + @admin.action(description='Mark selected orders as paid') + def mark_payment_paid(self, request, queryset): + now = timezone.now() + updated = queryset.exclude(payment_status='Paid').update(payment_status='Paid', paid_at=now) + queryset.filter(status='Pending').update(status='Paid') + self.message_user(request, f'{updated} order(s) marked as paid.') + + @admin.action(description='Mark selected orders as shipped') + def mark_status_shipped(self, request, queryset): + updated = queryset.exclude(status__in=['Shipped', 'Delivered']).update(status='Shipped') + self.message_user(request, f'{updated} order(s) marked as shipped.') + + @admin.action(description='Mark selected orders as delivered') + def mark_status_delivered(self, request, queryset): + updated = queryset.exclude(status='Delivered').update(status='Delivered') + cod_collected = queryset.filter(payment_method='Cash on Delivery').exclude(payment_status='Paid').update( + payment_status='Paid', + paid_at=timezone.now(), + ) + message = f'{updated} order(s) marked as delivered.' + if cod_collected: + message += f' {cod_collected} COD payment(s) were also marked paid.' + self.message_user(request, message) + + +@admin.register(OrderItem) +class OrderItemAdmin(admin.ModelAdmin): + list_display = ('order', 'product', 'quantity', 'price') + search_fields = ('order__id', 'product__name') + autocomplete_fields = ('order', 'product') diff --git a/myproject/orders/migrations/0005_order_paid_at_order_payment_currency_and_more.py b/myproject/orders/migrations/0005_order_paid_at_order_payment_currency_and_more.py new file mode 100644 index 0000000..178f78a --- /dev/null +++ b/myproject/orders/migrations/0005_order_paid_at_order_payment_currency_and_more.py @@ -0,0 +1,58 @@ +# Generated by Django 5.2.14 on 2026-05-20 02:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orders', '0004_order_delivery_fields'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='paid_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='order', + name='payment_currency', + field=models.CharField(blank=True, default='NPR', max_length=10), + ), + migrations.AddField( + model_name='order', + name='payment_provider', + field=models.CharField(blank=True, default='', max_length=50), + ), + migrations.AddField( + model_name='order', + name='payment_reference', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AddField( + model_name='order', + name='payment_session_id', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AddField( + model_name='order', + name='payment_status', + field=models.CharField(choices=[('Unpaid', 'Unpaid'), ('Pending', 'Pending'), ('Paid', 'Paid'), ('Failed', 'Failed')], default='Unpaid', max_length=20), + ), + migrations.AlterField( + model_name='order', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='order', + name='payment_method', + field=models.CharField(default='Pending selection', max_length=100), + ), + migrations.AlterField( + model_name='orderitem', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] diff --git a/myproject/orders/migrations/0006_order_delivery_notes_order_latitude_and_more.py b/myproject/orders/migrations/0006_order_delivery_notes_order_latitude_and_more.py new file mode 100644 index 0000000..b436f6b --- /dev/null +++ b/myproject/orders/migrations/0006_order_delivery_notes_order_latitude_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.14 on 2026-05-20 09:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orders', '0005_order_paid_at_order_payment_currency_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='delivery_notes', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AddField( + model_name='order', + name='latitude', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True), + ), + migrations.AddField( + model_name='order', + name='location_accuracy_m', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True), + ), + migrations.AddField( + model_name='order', + name='location_label', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AddField( + model_name='order', + name='longitude', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True), + ), + ] diff --git a/myproject/orders/migrations/__pycache__/0005_order_paid_at_order_payment_currency_and_more.cpython-311.pyc b/myproject/orders/migrations/__pycache__/0005_order_paid_at_order_payment_currency_and_more.cpython-311.pyc new file mode 100644 index 0000000..a6f15ba Binary files /dev/null and b/myproject/orders/migrations/__pycache__/0005_order_paid_at_order_payment_currency_and_more.cpython-311.pyc differ diff --git a/myproject/orders/migrations/__pycache__/0006_order_delivery_notes_order_latitude_and_more.cpython-311.pyc b/myproject/orders/migrations/__pycache__/0006_order_delivery_notes_order_latitude_and_more.cpython-311.pyc new file mode 100644 index 0000000..3593ac5 Binary files /dev/null and b/myproject/orders/migrations/__pycache__/0006_order_delivery_notes_order_latitude_and_more.cpython-311.pyc differ diff --git a/myproject/orders/models.py b/myproject/orders/models.py index fc6d7d4..fadb101 100644 --- a/myproject/orders/models.py +++ b/myproject/orders/models.py @@ -1,5 +1,5 @@ -from django.db import models from django.contrib.auth.models import User +from django.db import models from products.models import Product @@ -11,15 +11,39 @@ class Order(models.Model): ('Shipped', 'Shipped'), ('Delivered', 'Delivered'), ) + PAYMENT_STATUS_CHOICES = ( + ('Unpaid', 'Unpaid'), + ('Pending', 'Pending'), + ('Paid', 'Paid'), + ('Failed', 'Failed'), + ) + PAYMENT_PROVIDER_LABELS = { + 'esewa': 'eSewa', + 'khalti': 'Khalti', + 'fonepay': 'Fonepay', + 'stripe': 'Stripe', + 'offline': 'Offline', + } user = models.ForeignKey(User, on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) total_price = models.DecimalField(max_digits=10, decimal_places=2) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='Pending') - payment_method = models.CharField(max_length=100, default='COD') + payment_status = models.CharField(max_length=20, choices=PAYMENT_STATUS_CHOICES, default='Unpaid') + payment_method = models.CharField(max_length=100, default='Pending selection') + payment_provider = models.CharField(max_length=50, blank=True, default='') + payment_reference = models.CharField(max_length=255, blank=True, default='') + payment_session_id = models.CharField(max_length=255, blank=True, default='') + payment_currency = models.CharField(max_length=10, blank=True, default='NPR') + paid_at = models.DateTimeField(null=True, blank=True) full_name = models.CharField(max_length=150, blank=True, default='') phone = models.CharField(max_length=30, blank=True, default='') address = models.TextField(blank=True, default='') + location_label = models.CharField(max_length=255, blank=True, default='') + latitude = models.DecimalField(max_digits=9, decimal_places=6, blank=True, null=True) + longitude = models.DecimalField(max_digits=9, decimal_places=6, blank=True, null=True) + location_accuracy_m = models.DecimalField(max_digits=8, decimal_places=2, blank=True, null=True) + delivery_notes = models.CharField(max_length=255, blank=True, default='') class Meta: ordering = ['-created_at'] @@ -27,6 +51,16 @@ class Order(models.Model): def __str__(self): return f"Order #{self.id} - {self.user.username}" + @property + def payment_provider_label(self): + if not self.payment_provider: + return '-' + return self.PAYMENT_PROVIDER_LABELS.get(self.payment_provider, self.payment_provider) + + @property + def has_precise_location(self): + return self.latitude is not None and self.longitude is not None + class OrderItem(models.Model): order = models.ForeignKey(Order, related_name='items', on_delete=models.CASCADE) @@ -35,4 +69,4 @@ class OrderItem(models.Model): price = models.DecimalField(max_digits=10, decimal_places=2) def __str__(self): - return f"{self.quantity} x {self.product.name} @ {self.price}" + return f"{self.quantity} x {self.product.name} @ {self.price}" diff --git a/myproject/orders/payments.py b/myproject/orders/payments.py new file mode 100644 index 0000000..c78ae9a --- /dev/null +++ b/myproject/orders/payments.py @@ -0,0 +1,497 @@ +import base64 +import hashlib +import hmac +import json +import uuid +from decimal import Decimal, ROUND_HALF_UP +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode, urljoin +from urllib.request import Request, urlopen + +from django.conf import settings +from django.urls import reverse + +import stripe + +ZERO_DECIMAL_CURRENCIES = { + 'bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf' +} + + +class PaymentGatewayError(RuntimeError): + def __init__(self, message, *, details=''): + super().__init__(message) + self.details = details + + +def payment_currency(): + return getattr(settings, 'PAYMENT_CURRENCY', 'NPR').upper() + + +def stripe_configured(): + return bool(settings.STRIPE_SECRET_KEY) + + +def esewa_configured(): + return all( + [ + getattr(settings, 'ESEWA_PRODUCT_CODE', '').strip(), + getattr(settings, 'ESEWA_SECRET_KEY', '').strip(), + getattr(settings, 'ESEWA_FORM_URL', '').strip(), + getattr(settings, 'ESEWA_STATUS_URL', '').strip(), + ] + ) + + +def khalti_configured(): + return all( + [ + getattr(settings, 'KHALTI_SECRET_KEY', '').strip(), + getattr(settings, 'KHALTI_INITIATE_URL', '').strip(), + getattr(settings, 'KHALTI_LOOKUP_URL', '').strip(), + ] + ) + + +def stripe_value(obj, key, default=None): + if obj is None: + return default + if isinstance(obj, dict): + return obj.get(key, default) + getter = getattr(obj, 'get', None) + if callable(getter): + return getter(key, default) + return getattr(obj, key, default) + + +def _public_base_url(request): + if settings.PUBLIC_APP_URL: + return settings.PUBLIC_APP_URL + + forwarded_proto = request.META.get('HTTP_X_FORWARDED_PROTO', '').split(',')[0].strip() + scheme = forwarded_proto or request.scheme or 'http' + host = request.get_host() + if host.endswith('.flatlogic.app'): + scheme = 'https' + return f'{scheme}://{host}' + + +def absolute_url(request, path): + return urljoin(f"{_public_base_url(request)}/", path.lstrip('/')) + + +def _format_amount(amount): + value = Decimal(amount).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + return format(value, 'f') + + +def _to_minor_units(amount, currency): + value = Decimal(amount) + if currency.lower() in ZERO_DECIMAL_CURRENCIES: + return int(value.quantize(Decimal('1'), rounding=ROUND_HALF_UP)) + return int((value * Decimal('100')).quantize(Decimal('1'), rounding=ROUND_HALF_UP)) + + +def _configure_stripe(): + stripe.api_key = settings.STRIPE_SECRET_KEY + + +def _line_items_for_order(order): + currency = settings.STRIPE_CURRENCY.lower() + line_items = [] + for item in order.items.select_related('product').all(): + line_items.append( + { + 'price_data': { + 'currency': currency, + 'unit_amount': _to_minor_units(item.price, currency), + 'product_data': { + 'name': item.product.name, + 'description': item.product.description[:500], + }, + }, + 'quantity': int(item.quantity), + } + ) + return line_items + + +def create_checkout_session(request, order): + if not stripe_configured(): + raise RuntimeError('Stripe is not configured.') + + _configure_stripe() + success_path = reverse('success') + cancel_path = reverse('payment', kwargs={'order_id': order.id}) + success_url = f"{absolute_url(request, success_path)}?order_id={order.id}&session_id={{CHECKOUT_SESSION_ID}}" + cancel_url = f"{absolute_url(request, cancel_path)}?cancelled=1" + + payload = { + 'mode': 'payment', + 'line_items': _line_items_for_order(order), + 'success_url': success_url, + 'cancel_url': cancel_url, + 'client_reference_id': str(order.id), + 'metadata': { + 'order_id': str(order.id), + 'user_id': str(order.user_id), + }, + 'payment_method_types': ['card'], + 'billing_address_collection': 'auto', + 'submit_type': 'pay', + } + + if order.user.email: + payload['customer_email'] = order.user.email + + return stripe.checkout.Session.create(**payload) + + +def retrieve_checkout_session(session_id): + if not stripe_configured(): + raise RuntimeError('Stripe is not configured.') + _configure_stripe() + return stripe.checkout.Session.retrieve(session_id) + + +def construct_webhook_event(payload, signature): + if not stripe_configured() or not settings.STRIPE_WEBHOOK_SECRET: + raise RuntimeError('Stripe webhook is not configured.') + _configure_stripe() + return stripe.Webhook.construct_event(payload, signature, settings.STRIPE_WEBHOOK_SECRET) + + +def _extract_error_message(body): + try: + payload = json.loads(body) + except json.JSONDecodeError: + return (body or 'Unknown gateway error.').strip()[:300] + + if isinstance(payload, dict): + if payload.get('detail'): + return str(payload['detail']) + if payload.get('error_message'): + return str(payload['error_message']) + for key, value in payload.items(): + if isinstance(value, list) and value: + return f"{key}: {value[0]}" + return json.dumps(payload)[:300] + + return str(payload)[:300] + + +def _json_request(url, *, method='GET', payload=None, headers=None, timeout=20): + request_headers = { + 'Accept': 'application/json', + } + data = None + + if payload is not None: + data = json.dumps(payload).encode('utf-8') + request_headers['Content-Type'] = 'application/json' + + if headers: + request_headers.update(headers) + + request = Request(url, data=data, headers=request_headers, method=method) + + try: + with urlopen(request, timeout=timeout) as response: + body = response.read().decode('utf-8') + except HTTPError as exc: + error_body = exc.read().decode('utf-8', errors='replace') + raise PaymentGatewayError( + _extract_error_message(error_body) or f'Gateway request failed with HTTP {exc.code}.', + details=error_body, + ) from exc + except URLError as exc: + raise PaymentGatewayError('Gateway request could not be completed.', details=str(exc.reason)) from exc + + if not body: + return {} + + try: + return json.loads(body) + except json.JSONDecodeError as exc: + raise PaymentGatewayError('Gateway returned an invalid response.', details=body[:500]) from exc + + +def _signed_field_names_list(signed_field_names): + return [field.strip() for field in str(signed_field_names).split(',') if field and field.strip()] + + +def _signed_message(payload, signed_field_names): + parts = [] + for field_name in _signed_field_names_list(signed_field_names): + if field_name not in payload: + raise PaymentGatewayError(f'Missing signed field: {field_name}') + parts.append(f'{field_name}={payload[field_name]}') + return ','.join(parts) + + +def _hmac_sha256_base64(secret_key, message): + digest = hmac.new(secret_key.encode('utf-8'), message.encode('utf-8'), hashlib.sha256).digest() + return base64.b64encode(digest).decode('utf-8') + + +def _first_present(payload, *keys): + for key in keys: + value = payload.get(key) + if value not in (None, ''): + return value + return '' + + +def build_esewa_redirect(request, order): + if not esewa_configured(): + raise PaymentGatewayError('eSewa is not configured yet.') + + transaction_uuid = f"ORD-{order.id}-{uuid.uuid4().hex[:12].upper()}" + total_amount = _format_amount(order.total_price) + success_url = f"{absolute_url(request, reverse('esewa_return'))}?order_id={order.id}" + failure_url = f"{absolute_url(request, reverse('esewa_return'))}?order_id={order.id}&failed=1" + + fields = { + 'amount': total_amount, + 'tax_amount': '0', + 'total_amount': total_amount, + 'transaction_uuid': transaction_uuid, + 'product_code': settings.ESEWA_PRODUCT_CODE, + 'product_service_charge': '0', + 'product_delivery_charge': '0', + 'success_url': success_url, + 'failure_url': failure_url, + 'signed_field_names': 'total_amount,transaction_uuid,product_code', + } + fields['signature'] = _hmac_sha256_base64( + settings.ESEWA_SECRET_KEY, + _signed_message(fields, fields['signed_field_names']), + ) + + return { + 'action_url': settings.ESEWA_FORM_URL, + 'fields': fields, + 'session_id': transaction_uuid, + 'currency': payment_currency(), + } + + +def _decode_esewa_callback(encoded_data): + if not encoded_data: + raise PaymentGatewayError('eSewa did not return any payment payload.') + + padded = encoded_data + ('=' * (-len(encoded_data) % 4)) + try: + raw = base64.b64decode(padded) + except Exception as exc: + raise PaymentGatewayError('eSewa returned an unreadable payment payload.') from exc + + try: + payload = json.loads(raw.decode('utf-8')) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise PaymentGatewayError('eSewa returned an invalid payment payload.') from exc + + if not isinstance(payload, dict): + raise PaymentGatewayError('eSewa returned an unexpected payment payload.') + + return {str(key): '' if value is None else str(value) for key, value in payload.items()} + + +def _verify_esewa_callback_signature(payload): + signature = payload.get('signature', '').strip() + signed_field_names = payload.get('signed_field_names', '').strip() + + if not signature or not signed_field_names: + raise PaymentGatewayError('eSewa callback signature is missing.') + + expected_signature = _hmac_sha256_base64( + settings.ESEWA_SECRET_KEY, + _signed_message(payload, signed_field_names), + ) + + if not hmac.compare_digest(expected_signature, signature): + raise PaymentGatewayError('eSewa callback signature could not be verified.') + + +def _esewa_status_lookup(order): + params = urlencode( + { + 'product_code': settings.ESEWA_PRODUCT_CODE, + 'total_amount': _format_amount(order.total_price), + 'transaction_uuid': order.payment_session_id, + } + ) + base_url = settings.ESEWA_STATUS_URL.rstrip('/') + '/' + return _json_request(f'{base_url}?{params}') + + +def verify_esewa_payment(order, encoded_data): + if not esewa_configured(): + raise PaymentGatewayError('eSewa is not configured yet.') + + callback = _decode_esewa_callback(encoded_data.strip()) + _verify_esewa_callback_signature(callback) + + transaction_uuid = callback.get('transaction_uuid', '').strip() + if not transaction_uuid: + raise PaymentGatewayError('eSewa did not return a transaction id.') + + if transaction_uuid != (order.payment_session_id or '').strip(): + raise PaymentGatewayError('The returned eSewa session did not match the latest payment attempt for this order.') + + callback_total = _format_amount(callback.get('total_amount', order.total_price)) + order_total = _format_amount(order.total_price) + if callback_total != order_total: + raise PaymentGatewayError('The returned eSewa amount did not match this order total.') + + callback_product_code = callback.get('product_code', '').strip() + if callback_product_code and callback_product_code != settings.ESEWA_PRODUCT_CODE: + raise PaymentGatewayError('The returned eSewa product code did not match the configured merchant code.') + + status_response = _esewa_status_lookup(order) + + if status_response.get('error_message'): + raise PaymentGatewayError(f"eSewa status lookup failed: {status_response['error_message']}") + + gateway_status = str(status_response.get('status', '')).upper().strip() + if not gateway_status: + raise PaymentGatewayError('eSewa did not return a payment status for this transaction.') + + returned_total = _first_present(status_response, 'total_amount', 'totalAmount') + if returned_total and _format_amount(returned_total) != order_total: + raise PaymentGatewayError('The verified eSewa amount did not match this order total.') + + returned_product_code = str(_first_present(status_response, 'product_code', 'productCode', 'scd')).strip() + if returned_product_code and returned_product_code != settings.ESEWA_PRODUCT_CODE: + raise PaymentGatewayError('The verified eSewa merchant code did not match this order.') + + reference = str(_first_present(status_response, 'ref_id', 'refId') or callback.get('transaction_code') or transaction_uuid).strip() + + if gateway_status == 'COMPLETE': + local_status = 'Paid' + message = 'eSewa payment verified successfully.' + elif gateway_status in {'PENDING', 'AMBIGUOUS'}: + local_status = 'Pending' + message = 'eSewa is still processing this payment.' + else: + local_status = 'Failed' + message = f'eSewa returned {gateway_status} for this payment.' + + return { + 'status': local_status, + 'gateway_status': gateway_status, + 'reference': reference, + 'session_id': transaction_uuid, + 'currency': payment_currency(), + 'message': message, + } + + +def initiate_khalti_payment(request, order): + if not khalti_configured(): + raise PaymentGatewayError('Khalti is not configured yet.') + + amount_in_paisa = _to_minor_units(order.total_price, payment_currency()) + if amount_in_paisa < 1000: + raise PaymentGatewayError('Khalti requires a minimum payment amount of Rs. 10.') + + purchase_order_id = f"ORDER-{order.id}-{uuid.uuid4().hex[:8].upper()}" + payload = { + 'return_url': f"{absolute_url(request, reverse('khalti_return'))}?order_id={order.id}", + 'website_url': _public_base_url(request), + 'amount': amount_in_paisa, + 'purchase_order_id': purchase_order_id, + 'purchase_order_name': f'Order #{order.id}', + } + + customer_info = { + 'name': order.full_name or order.user.get_full_name() or order.user.username, + 'phone': order.phone, + } + if order.user.email: + customer_info['email'] = order.user.email + payload['customer_info'] = {key: value for key, value in customer_info.items() if value} + + response = _json_request( + settings.KHALTI_INITIATE_URL, + method='POST', + payload=payload, + headers={'Authorization': f'key {settings.KHALTI_SECRET_KEY}'}, + ) + + payment_url = str(response.get('payment_url', '')).strip() + pidx = str(response.get('pidx', '')).strip() + + if not payment_url or not pidx: + raise PaymentGatewayError('Khalti did not return a payment URL for this order.') + + return { + 'payment_url': payment_url, + 'session_id': pidx, + 'reference': purchase_order_id, + 'currency': payment_currency(), + } + + +def _khalti_lookup(pidx): + return _json_request( + settings.KHALTI_LOOKUP_URL, + method='POST', + payload={'pidx': pidx}, + headers={'Authorization': f'key {settings.KHALTI_SECRET_KEY}'}, + ) + + +def verify_khalti_payment(order, pidx): + if not khalti_configured(): + raise PaymentGatewayError('Khalti is not configured yet.') + + normalized_pidx = (pidx or '').strip() + if not normalized_pidx: + raise PaymentGatewayError('Khalti did not return a payment session id.') + + if normalized_pidx != (order.payment_session_id or '').strip(): + raise PaymentGatewayError('The returned Khalti session did not match the latest payment attempt for this order.') + + lookup = _khalti_lookup(normalized_pidx) + gateway_status = str(lookup.get('status', '')).strip() + if not gateway_status: + raise PaymentGatewayError('Khalti did not return a payment status for this transaction.') + + returned_amount = lookup.get('total_amount') + if returned_amount not in (None, ''): + try: + returned_amount_value = int(returned_amount) + except (TypeError, ValueError) as exc: + raise PaymentGatewayError('Khalti returned an invalid payment amount.') from exc + + expected_amount = _to_minor_units(order.total_price, payment_currency()) + if returned_amount_value != expected_amount: + raise PaymentGatewayError('The returned Khalti amount did not match this order total.') + + if order.payment_reference: + returned_order_reference = str(lookup.get('purchase_order_id', '')).strip() + if returned_order_reference and returned_order_reference != order.payment_reference: + raise PaymentGatewayError('The returned Khalti purchase order id did not match this order.') + + refunded = bool(lookup.get('refunded')) + transaction_reference = str(lookup.get('transaction_id') or normalized_pidx).strip() + + if gateway_status == 'Completed' and not refunded: + local_status = 'Paid' + message = 'Khalti payment verified successfully.' + elif gateway_status in {'Pending', 'Initiated'}: + local_status = 'Pending' + message = 'Khalti is still processing this payment.' + else: + local_status = 'Failed' + message = f'Khalti returned {gateway_status} for this payment.' + + return { + 'status': local_status, + 'gateway_status': gateway_status, + 'reference': transaction_reference, + 'session_id': normalized_pidx, + 'currency': payment_currency(), + 'message': message, + } diff --git a/myproject/orders/tests.py b/myproject/orders/tests.py index de8bdc0..1915df8 100644 --- a/myproject/orders/tests.py +++ b/myproject/orders/tests.py @@ -1,3 +1,282 @@ -from django.test import TestCase - -# Create your tests here. +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, override_settings +from django.urls import reverse + +from orders.models import Order, OrderItem +from products.models import Product + +User = get_user_model() + +SAMPLE_GIF = bytes.fromhex( + '47494638396101000100800000000000ffffff21f90401000000002c00000000010001000002024401003b' +) + + +class OrderPaymentFlowTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='alice', password='password123', email='alice@example.com') + self.client.force_login(self.user) + image = SimpleUploadedFile('product.gif', SAMPLE_GIF, content_type='image/gif') + self.product = Product.objects.create( + name='Wireless Mouse', + description='A test product for order payments.', + price='1250.00', + listing_type='sale', + image=image, + category='Electronics', + stock=8, + ) + self.order = Order.objects.create( + user=self.user, + total_price='1250.00', + full_name='Alice Example', + phone='+9779800000000', + address='Kathmandu', + ) + OrderItem.objects.create(order=self.order, product=self.product, quantity=1, price='1250.00') + + def test_payment_page_does_not_fake_pay_order(self): + response = self.client.get(reverse('payment', args=[self.order.id])) + self.assertEqual(response.status_code, 200) + self.order.refresh_from_db() + self.assertEqual(self.order.payment_status, 'Unpaid') + self.assertEqual(self.order.status, 'Pending') + + @override_settings( + PAYMENT_CURRENCY='NPR', + ESEWA_SANDBOX=True, + ESEWA_PRODUCT_CODE='EPAYTEST', + ESEWA_SECRET_KEY='secret-key', + ESEWA_FORM_URL='https://esewa.test/form', + ESEWA_STATUS_URL='https://esewa.test/status/', + ) + def test_posting_esewa_returns_redirect_form_and_sets_pending_state(self): + response = self.client.post(reverse('payment', args=[self.order.id]), {'payment': 'esewa'}) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'order/payment_redirect.html') + self.assertContains(response, 'https://esewa.test/form') + + self.order.refresh_from_db() + self.assertEqual(self.order.payment_provider, 'esewa') + self.assertEqual(self.order.payment_method, 'eSewa') + self.assertEqual(self.order.payment_status, 'Pending') + self.assertEqual(self.order.payment_currency, 'NPR') + self.assertTrue(self.order.payment_session_id.startswith(f'ORD-{self.order.id}-')) + self.assertContains(response, self.order.payment_session_id) + + @override_settings(PAYMENT_CURRENCY='NPR') + @patch('orders.views.verify_esewa_payment') + def test_esewa_return_marks_order_paid_only_after_verified_status(self, mock_verify_esewa_payment): + self.order.payment_method = 'eSewa' + self.order.payment_provider = 'esewa' + self.order.payment_status = 'Pending' + self.order.payment_session_id = 'ORD-1-ABC123' + self.order.payment_currency = 'NPR' + self.order.save() + + mock_verify_esewa_payment.return_value = { + 'status': 'Paid', + 'reference': 'ESEWA-REF-123', + 'session_id': 'ORD-1-ABC123', + 'currency': 'NPR', + 'message': 'eSewa payment verified successfully.', + } + + response = self.client.get(reverse('esewa_return') + f'?order_id={self.order.id}&data=dummy') + + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], reverse('success') + f'?order_id={self.order.id}') + self.order.refresh_from_db() + self.assertEqual(self.order.payment_status, 'Paid') + self.assertEqual(self.order.status, 'Paid') + self.assertEqual(self.order.payment_reference, 'ESEWA-REF-123') + self.assertEqual(self.order.payment_provider, 'esewa') + self.assertIsNotNone(self.order.paid_at) + + @override_settings( + PAYMENT_CURRENCY='NPR', + KHALTI_SECRET_KEY='test_secret', + KHALTI_INITIATE_URL='https://khalti.test/initiate/', + KHALTI_LOOKUP_URL='https://khalti.test/lookup/', + ) + @patch('orders.views.initiate_khalti_payment') + def test_posting_khalti_redirects_to_payment_url(self, mock_initiate_khalti_payment): + mock_initiate_khalti_payment.return_value = { + 'payment_url': 'https://pay.khalti.test/session/123', + 'session_id': 'pidx_test_123', + 'reference': 'ORDER-1-TEST123', + 'currency': 'NPR', + } + + response = self.client.post(reverse('payment', args=[self.order.id]), {'payment': 'khalti'}) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], 'https://pay.khalti.test/session/123') + self.order.refresh_from_db() + self.assertEqual(self.order.payment_provider, 'khalti') + self.assertEqual(self.order.payment_method, 'Khalti') + self.assertEqual(self.order.payment_status, 'Pending') + self.assertEqual(self.order.payment_session_id, 'pidx_test_123') + self.assertEqual(self.order.payment_reference, 'ORDER-1-TEST123') + + @override_settings(PAYMENT_CURRENCY='NPR') + @patch('orders.views.verify_khalti_payment') + def test_khalti_return_marks_order_paid_only_after_verified_lookup(self, mock_verify_khalti_payment): + self.order.payment_method = 'Khalti' + self.order.payment_provider = 'khalti' + self.order.payment_status = 'Pending' + self.order.payment_session_id = 'pidx_test_123' + self.order.payment_reference = 'ORDER-1-TEST123' + self.order.payment_currency = 'NPR' + self.order.save() + + mock_verify_khalti_payment.return_value = { + 'status': 'Paid', + 'reference': 'TXN-456', + 'session_id': 'pidx_test_123', + 'currency': 'NPR', + 'message': 'Khalti payment verified successfully.', + } + + response = self.client.get(reverse('khalti_return') + f'?order_id={self.order.id}&pidx=pidx_test_123') + + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], reverse('success') + f'?order_id={self.order.id}') + self.order.refresh_from_db() + self.assertEqual(self.order.payment_status, 'Paid') + self.assertEqual(self.order.status, 'Paid') + self.assertEqual(self.order.payment_reference, 'TXN-456') + self.assertEqual(self.order.payment_provider, 'khalti') + self.assertIsNotNone(self.order.paid_at) + + def test_cash_on_delivery_does_not_mark_order_as_paid(self): + response = self.client.post(reverse('payment', args=[self.order.id]), {'payment': 'cod'}) + + self.assertEqual(response.status_code, 302) + self.order.refresh_from_db() + self.assertEqual(self.order.payment_method, 'Cash on Delivery') + self.assertEqual(self.order.payment_status, 'Pending') + self.assertEqual(self.order.status, 'Pending') + + +class CheckoutLocationFlowTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='buyer', password='password123', email='buyer@example.com') + self.user.profile.phone = '+9779811111111' + self.user.profile.location_label = 'Baneshwor, Kathmandu' + self.user.profile.default_address = 'Old Baneshwor Chowk\nKathmandu' + self.user.profile.latitude = '27.693400' + self.user.profile.longitude = '85.335000' + self.user.profile.location_accuracy_m = '20.50' + self.user.profile.save() + + image = SimpleUploadedFile('checkout.gif', SAMPLE_GIF, content_type='image/gif') + self.product = Product.objects.create( + name='Smart Watch', + description='Checkout location test product.', + price='2500.00', + listing_type='sale', + image=image, + category='Electronics', + stock=5, + ) + self.client.force_login(self.user) + session = self.client.session + session['cart'] = {str(self.product.id): 1} + session.save() + + def test_checkout_prefills_saved_profile_delivery_details(self): + response = self.client.get(reverse('checkout')) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['delivery']['phone'], '+9779811111111') + self.assertEqual(response.context['delivery']['location_label'], 'Baneshwor, Kathmandu') + self.assertEqual(response.context['delivery']['address'], 'Old Baneshwor Chowk\nKathmandu') + self.assertEqual(response.context['delivery']['latitude'], '27.693400') + self.assertEqual(response.context['delivery']['longitude'], '85.335000') + + def test_checkout_saves_order_delivery_snapshot_and_updates_profile(self): + response = self.client.post( + reverse('checkout'), + { + 'full_name': 'Buyer Example', + 'phone': '+9779801234567', + 'location_label': 'Lalitpur, Kupondole', + 'address': 'Kupondole Height\nLalitpur', + 'delivery_notes': 'Call before reaching the gate', + 'latitude': '27.685100', + 'longitude': '85.316700', + 'location_accuracy_m': '12.75', + 'save_as_default': 'on', + }, + ) + + order = Order.objects.get(user=self.user) + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], reverse('payment', args=[order.id])) + self.assertEqual(order.phone, '+9779801234567') + self.assertEqual(order.location_label, 'Lalitpur, Kupondole') + self.assertEqual(order.delivery_notes, 'Call before reaching the gate') + self.assertIsNotNone(order.latitude) + self.assertIsNotNone(order.longitude) + + self.user.profile.refresh_from_db() + self.assertEqual(self.user.profile.phone, '+9779801234567') + self.assertEqual(self.user.profile.location_label, 'Lalitpur, Kupondole') + self.assertEqual(self.user.profile.default_address, 'Kupondole Height\nLalitpur') + + def test_checkout_can_prefill_from_recent_delivery_shortcut(self): + recent_order = Order.objects.create( + user=self.user, + total_price='2500.00', + status='Delivered', + payment_status='Paid', + payment_method='Cash on Delivery', + full_name='Buyer Example', + phone='+9779809999999', + address='Naxal Chowk\nKathmandu', + location_label='Naxal, Kathmandu', + latitude='27.717300', + longitude='85.331900', + location_accuracy_m='11.25', + delivery_notes='Ring the bell once', + ) + + response = self.client.get(reverse('checkout') + f'?delivery_shortcut=order-{recent_order.id}') + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['delivery']['phone'], '+9779809999999') + self.assertEqual(response.context['delivery']['location_label'], 'Naxal, Kathmandu') + self.assertEqual(response.context['delivery']['address'], 'Naxal Chowk\nKathmandu') + self.assertEqual(response.context['delivery']['delivery_notes'], 'Ring the bell once') + self.assertEqual(response.context['delivery']['selected_shortcut_id'], f'order-{recent_order.id}') + self.assertTrue(any(shortcut['selected'] for shortcut in response.context['delivery_shortcuts'])) + + def test_checkout_save_as_default_clears_stale_profile_gps_when_manual_address_has_no_coordinates(self): + response = self.client.post( + reverse('checkout'), + { + 'full_name': 'Buyer Example', + 'phone': '+9779801234567', + 'location_label': 'Bhaktapur Durbar Area', + 'address': 'Taumadhi Square\nBhaktapur', + 'delivery_notes': 'Call after arrival', + 'save_as_default': 'on', + }, + ) + + order = Order.objects.get(user=self.user) + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], reverse('payment', args=[order.id])) + + self.user.profile.refresh_from_db() + self.assertEqual(self.user.profile.default_address, 'Taumadhi Square\nBhaktapur') + self.assertIsNone(self.user.profile.latitude) + self.assertIsNone(self.user.profile.longitude) + self.assertIsNone(self.user.profile.location_accuracy_m) + self.assertIsNone(self.user.profile.location_updated_at) + diff --git a/myproject/orders/urls.py b/myproject/orders/urls.py index a9ec0cc..06f8532 100644 --- a/myproject/orders/urls.py +++ b/myproject/orders/urls.py @@ -1,11 +1,15 @@ -from django.urls import path -from . import views - -urlpatterns = [ - path('checkout/', views.checkout, name='checkout'), - path('payment//', views.payment_page, name='payment'), - path('payment///', views.payment_gateway, name='payment_gateway'), - path('success/', views.success, name='success'), - path('my-orders/', views.my_orders, name='my_orders'), - path('order//', views.order_detail, name='order_detail'), -] \ No newline at end of file +from django.urls import path + +from . import views + +urlpatterns = [ + path('checkout/', views.checkout, name='checkout'), + path('payment//', views.payment_page, name='payment'), + path('payment///', views.payment_gateway, name='payment_gateway'), + path('payment/return/esewa/', views.esewa_return, name='esewa_return'), + path('payment/return/khalti/', views.khalti_return, name='khalti_return'), + path('payment/webhooks/stripe/', views.stripe_webhook, name='stripe_webhook'), + path('success/', views.success, name='success'), + path('my-orders/', views.my_orders, name='my_orders'), + path('order//', views.order_detail, name='order_detail'), +] diff --git a/myproject/orders/views.py b/myproject/orders/views.py index 031004c..454b567 100644 --- a/myproject/orders/views.py +++ b/myproject/orders/views.py @@ -1,19 +1,47 @@ -from datetime import timedelta +import logging from decimal import Decimal -import re +from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required +from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from django.utils import timezone +from django.views.decorators.csrf import csrf_exempt +from accounts.delivery import ( + build_delivery_payload, + delivery_payload_from_profile, + derive_location_label, + parse_decimal_value, + phone_has_valid_digits, + save_profile_delivery_defaults, +) from cart.views import get_cart, save_cart from products.models import Product from .models import Order, OrderItem +from .payments import ( + PaymentGatewayError, + build_esewa_redirect, + construct_webhook_event, + create_checkout_session, + esewa_configured, + initiate_khalti_payment, + khalti_configured, + payment_currency, + retrieve_checkout_session, + stripe_configured, + stripe_value, + verify_esewa_payment, + verify_khalti_payment, +) +logger = logging.getLogger(__name__) STATUS_SEQUENCE = ['Pending', 'Paid', 'Shipped', 'Delivered'] +UNSET = object() def _build_timeline(status): @@ -25,24 +53,6 @@ def _build_timeline(status): return timeline -def _sync_demo_status(order): - elapsed = timezone.now() - order.created_at - if elapsed >= timedelta(minutes=3): - target = 'Delivered' - elif elapsed >= timedelta(minutes=2): - target = 'Shipped' - elif elapsed >= timedelta(minutes=1): - target = 'Paid' - else: - target = 'Pending' - - current_index = STATUS_SEQUENCE.index(order.status) if order.status in STATUS_SEQUENCE else 0 - target_index = STATUS_SEQUENCE.index(target) - if target_index > current_index: - order.status = target - order.save(update_fields=['status']) - - def _get_checkout_source(request): buy_now = request.session.get('buy_now', {}) if isinstance(buy_now, dict) and buy_now: @@ -50,6 +60,7 @@ def _get_checkout_source(request): return get_cart(request), 'cart' + def _clear_checkout_source(request, source_key): if source_key == 'buy_now': request.session.pop('buy_now', None) @@ -58,9 +69,291 @@ def _clear_checkout_source(request, source_key): save_cart(request, {}) -def _validate_phone(phone): - digits = re.sub(r'\D+', '', phone) - return 7 <= len(digits) <= 15 + +def _build_delivery_shortcuts(user, profile, *, limit=4): + shortcuts = [] + seen = set() + default_full_name = user.get_full_name() or user.username + + def add_shortcut( + identifier, + *, + badge, + title, + note='', + full_name='', + phone='', + address='', + location_label='', + delivery_notes='', + latitude=None, + longitude=None, + location_accuracy_m=None, + ): + payload = build_delivery_payload( + full_name=full_name, + phone=phone, + address=address, + location_label=location_label, + delivery_notes=delivery_notes, + latitude=latitude, + longitude=longitude, + location_accuracy_m=location_accuracy_m, + save_as_default=True, + ) + dedupe_key = ( + payload['phone'].lower(), + payload['address'].lower(), + payload['location_label'].lower(), + ) + if not payload['address'] or dedupe_key in seen: + return + + seen.add(dedupe_key) + shortcuts.append( + { + 'id': identifier, + 'badge': badge, + 'title': title, + 'note': note, + 'full_name': payload['full_name'], + 'phone': payload['phone'], + 'address': payload['address'], + 'location_label': payload['location_label'], + 'delivery_notes': payload['delivery_notes'], + 'latitude': payload['latitude'], + 'longitude': payload['longitude'], + 'location_accuracy_m': payload['location_accuracy_m'], + 'has_precise_location': bool(payload['latitude'] and payload['longitude']), + 'selected': False, + } + ) + + if profile is not None and (profile.phone or profile.formatted_delivery_address): + add_shortcut( + 'profile-default', + badge='Saved profile', + title=profile.short_location or 'Default account address', + note='Use your currently saved checkout defaults.', + full_name=default_full_name, + phone=profile.phone, + address=profile.formatted_delivery_address, + location_label=profile.short_location, + latitude=profile.latitude, + longitude=profile.longitude, + location_accuracy_m=profile.location_accuracy_m, + ) + + recent_orders = ( + Order.objects.filter(user=user) + .exclude(address='') + .order_by('-created_at') + .only( + 'id', + 'full_name', + 'phone', + 'address', + 'location_label', + 'delivery_notes', + 'latitude', + 'longitude', + 'location_accuracy_m', + 'created_at', + ) + ) + + for order in recent_orders: + add_shortcut( + f'order-{order.id}', + badge=f'Order #{order.id}', + title=order.location_label or order.address.splitlines()[0].strip() or f'Order #{order.id}', + note=f'Last used {order.created_at:%b %d, %Y}.', + full_name=order.full_name or default_full_name, + phone=order.phone, + address=order.address, + location_label=order.location_label, + delivery_notes=order.delivery_notes, + latitude=order.latitude, + longitude=order.longitude, + location_accuracy_m=order.location_accuracy_m, + ) + if len(shortcuts) >= limit: + break + + return shortcuts + + + +def _resolve_delivery_shortcut(shortcuts, shortcut_id): + if not shortcut_id: + return None + return next((shortcut for shortcut in shortcuts if shortcut['id'] == shortcut_id), None) + + + +def _set_selected_delivery_shortcut(shortcuts, shortcut_id): + for shortcut in shortcuts: + shortcut['selected'] = shortcut['id'] == shortcut_id + + + +def _render_checkout(request, *, cart_products, total, delivery, delivery_shortcuts): + return render( + request, + 'order/checkout.html', + { + 'products': cart_products, + 'total': total, + 'delivery': delivery, + 'delivery_shortcuts': delivery_shortcuts, + }, + ) + + + +def _save_order_updates(order, **changes): + update_fields = [] + for field, value in changes.items(): + if value is UNSET: + continue + if getattr(order, field) != value: + setattr(order, field, value) + update_fields.append(field) + if update_fields: + order.save(update_fields=update_fields) + + + +def _mark_order_paid( + order, + *, + payment_method=UNSET, + payment_provider=UNSET, + payment_reference=UNSET, + payment_session_id=UNSET, + payment_currency_value=UNSET, +): + _save_order_updates( + order, + status='Paid', + payment_status='Paid', + payment_method=payment_method, + payment_provider=payment_provider, + payment_reference=payment_reference, + payment_session_id=payment_session_id, + payment_currency=payment_currency_value, + paid_at=order.paid_at or timezone.now(), + ) + + + +def _mark_order_paid_from_stripe(order, session): + payment_reference = stripe_value(session, 'payment_intent', '') or UNSET + payment_session_id = stripe_value(session, 'id', '') or UNSET + payment_currency_value = (stripe_value(session, 'currency', '') or settings.STRIPE_CURRENCY).upper() + _mark_order_paid( + order, + payment_method='Card / Wallet', + payment_provider='stripe', + payment_reference=payment_reference, + payment_session_id=payment_session_id, + payment_currency_value=payment_currency_value, + ) + + + +def _build_payment_options(order): + stripe_ready = stripe_configured() + esewa_ready = esewa_configured() + khalti_ready = khalti_configured() + + options = [ + { + 'value': 'esewa', + 'title': 'eSewa ePay', + 'badge': 'Sandbox / UAT' if settings.ESEWA_SANDBOX else 'Live gateway', + 'badge_muted': not esewa_ready, + 'description': 'Redirect to the official eSewa wallet page and verify the transaction on return before marking the order paid.', + 'note': ( + 'Ready to test with the official eSewa sandbox merchant.' + if esewa_ready and settings.ESEWA_SANDBOX + else 'Add your live eSewa merchant code and secret to switch this gateway to production.' + if esewa_ready + else 'Configure eSewa credentials to enable this wallet.' + ), + 'ready': esewa_ready, + }, + { + 'value': 'khalti', + 'title': 'Khalti', + 'badge': 'KPG-2', + 'badge_muted': not khalti_ready, + 'description': 'Create a real Khalti payment session on the server and verify its final status using the lookup API.', + 'note': ( + 'Ready with your configured Khalti secret key.' + if khalti_ready + else 'Add KHALTI_SECRET_KEY to enable Khalti ePayment.' + ), + 'ready': khalti_ready, + }, + { + 'value': 'fonepay', + 'title': 'Fonepay', + 'badge': 'Merchant onboarding', + 'badge_muted': True, + 'description': 'Reserved for Fonepay Business / Dynamic QR integration once merchant onboarding details are available.', + 'note': 'Kept disabled so the app never claims a Fonepay payment succeeded without official verification.', + 'ready': False, + }, + ] + + if stripe_ready: + options.append( + { + 'value': 'stripe', + 'title': 'Card / Wallet', + 'badge': 'Stripe Checkout', + 'badge_muted': False, + 'description': 'Fallback card checkout with Stripe, verified before the order is marked paid.', + 'note': f'Charges are created in {settings.STRIPE_CURRENCY.upper()} and verified on return/webhook.', + 'ready': True, + } + ) + + options.append( + { + 'value': 'cod', + 'title': 'Cash on Delivery', + 'badge': 'Offline', + 'badge_muted': True, + 'description': 'Confirm the order now and collect payment when the package arrives.', + 'note': 'You can still return later and switch to eSewa or Khalti before delivery.', + 'ready': True, + } + ) + + ready_option_values = {option['value'] for option in options if option['ready']} + selected_payment = None + + if order.payment_provider in ready_option_values: + selected_payment = order.payment_provider + elif order.payment_method == 'Cash on Delivery': + selected_payment = 'cod' + else: + selected_payment = next((option['value'] for option in options if option['ready'] and option['value'] != 'cod'), 'cod') + + for option in options: + option['checked'] = option['value'] == selected_payment + + online_ready = any(option['ready'] and option['value'] != 'cod' for option in options) + + return { + 'payment_options': options, + 'online_ready': online_ready, + 'stripe_ready': stripe_ready, + 'esewa_ready': esewa_ready, + 'khalti_ready': khalti_ready, + } @login_required @@ -108,50 +401,146 @@ def checkout(request): _clear_checkout_source(request, source_key) return redirect('cart') + profile = getattr(request.user, 'profile', None) + delivery_shortcuts = _build_delivery_shortcuts(request.user, profile) + selected_shortcut_id = ( + request.POST.get('selected_shortcut_id', '').strip() + if request.method == 'POST' + else request.GET.get('delivery_shortcut', '').strip() + ) + selected_shortcut = _resolve_delivery_shortcut(delivery_shortcuts, selected_shortcut_id) + if selected_shortcut is None: + selected_shortcut_id = '' + _set_selected_delivery_shortcut(delivery_shortcuts, selected_shortcut_id) + if request.method == 'POST': full_name = request.POST.get('full_name', '').strip() phone = request.POST.get('phone', '').strip() address = request.POST.get('address', '').strip() + location_label = request.POST.get('location_label', '').strip() + delivery_notes = request.POST.get('delivery_notes', '').strip() + save_as_default = request.POST.get('save_as_default') == 'on' + + try: + latitude = parse_decimal_value( + request.POST.get('latitude'), + field_label='latitude', + minimum=Decimal('-90'), + maximum=Decimal('90'), + ) + longitude = parse_decimal_value( + request.POST.get('longitude'), + field_label='longitude', + minimum=Decimal('-180'), + maximum=Decimal('180'), + ) + location_accuracy_m = parse_decimal_value( + request.POST.get('location_accuracy_m'), + field_label='location accuracy', + minimum=Decimal('0'), + maximum=Decimal('100000'), + ) + except ValueError as exc: + messages.error(request, str(exc)) + return _render_checkout( + request, + cart_products=cart_products, + total=total, + delivery=build_delivery_payload( + full_name=full_name, + phone=phone, + address=address, + location_label=location_label, + delivery_notes=delivery_notes, + latitude=request.POST.get('latitude', ''), + longitude=request.POST.get('longitude', ''), + location_accuracy_m=request.POST.get('location_accuracy_m', ''), + save_as_default=save_as_default, + selected_shortcut_id=selected_shortcut_id, + ), + delivery_shortcuts=delivery_shortcuts, + ) + + if (latitude is None) != (longitude is None): + messages.error(request, 'GPS location is incomplete. Please retry the location button or continue without GPS.') + return _render_checkout( + request, + cart_products=cart_products, + total=total, + delivery=build_delivery_payload( + full_name=full_name, + phone=phone, + address=address, + location_label=location_label, + delivery_notes=delivery_notes, + latitude=latitude, + longitude=longitude, + location_accuracy_m=location_accuracy_m, + save_as_default=save_as_default, + selected_shortcut_id=selected_shortcut_id, + ), + delivery_shortcuts=delivery_shortcuts, + ) + + location_label = derive_location_label(location_label, address) if not full_name or not phone or not address: messages.error(request, 'Please fill in full name, phone, and address.') - return render( + return _render_checkout( request, - 'order/checkout.html', - { - 'products': cart_products, - 'total': total, - 'delivery': { - 'full_name': full_name, - 'phone': phone, - 'address': address, - }, - }, + cart_products=cart_products, + total=total, + delivery=build_delivery_payload( + full_name=full_name, + phone=phone, + address=address, + location_label=location_label, + delivery_notes=delivery_notes, + latitude=latitude, + longitude=longitude, + location_accuracy_m=location_accuracy_m, + save_as_default=save_as_default, + selected_shortcut_id=selected_shortcut_id, + ), + delivery_shortcuts=delivery_shortcuts, ) - if not _validate_phone(phone): + if not phone_has_valid_digits(phone): messages.error(request, 'Please enter a valid phone number.') - return render( + return _render_checkout( request, - 'order/checkout.html', - { - 'products': cart_products, - 'total': total, - 'delivery': { - 'full_name': full_name, - 'phone': phone, - 'address': address, - }, - }, + cart_products=cart_products, + total=total, + delivery=build_delivery_payload( + full_name=full_name, + phone=phone, + address=address, + location_label=location_label, + delivery_notes=delivery_notes, + latitude=latitude, + longitude=longitude, + location_accuracy_m=location_accuracy_m, + save_as_default=save_as_default, + selected_shortcut_id=selected_shortcut_id, + ), + delivery_shortcuts=delivery_shortcuts, ) order = Order.objects.create( user=request.user, total_price=total, status='Pending', + payment_status='Unpaid', + payment_method='Pending selection', + payment_currency=payment_currency(), full_name=full_name, phone=phone, address=address, + location_label=location_label, + latitude=latitude, + longitude=longitude, + location_accuracy_m=location_accuracy_m, + delivery_notes=delivery_notes, ) for product in cart_products: @@ -159,113 +548,470 @@ def checkout(request): product.stock = max(product.stock - product.qty, 0) product.save(update_fields=['stock']) + if save_as_default: + save_profile_delivery_defaults( + profile, + phone=phone, + address=address, + location_label=location_label, + latitude=latitude, + longitude=longitude, + location_accuracy_m=location_accuracy_m, + ) + + if location_label: + request.session['delivery_location'] = location_label + else: + request.session.pop('delivery_location', None) + _clear_checkout_source(request, source_key) - messages.success(request, 'Order created successfully. Complete payment to finish checkout.') + messages.success(request, 'Order created successfully. Complete the payment step to finish checkout.') return redirect('payment', order_id=order.id) - return render( - request, - 'order/checkout.html', - { - 'products': cart_products, - 'total': total, - 'delivery': { - 'full_name': request.user.get_full_name(), - 'phone': '', - 'address': '', - }, - }, + initial_delivery = delivery_payload_from_profile( + profile, + full_name=request.user.get_full_name() or request.user.username, + selected_shortcut_id=selected_shortcut_id, ) + if selected_shortcut is not None: + initial_delivery = build_delivery_payload( + full_name=selected_shortcut['full_name'] or request.user.get_full_name() or request.user.username, + phone=selected_shortcut['phone'], + address=selected_shortcut['address'], + location_label=selected_shortcut['location_label'], + delivery_notes=selected_shortcut['delivery_notes'], + latitude=selected_shortcut['latitude'], + longitude=selected_shortcut['longitude'], + location_accuracy_m=selected_shortcut['location_accuracy_m'], + save_as_default=True, + selected_shortcut_id=selected_shortcut_id, + ) - -GATEWAY_OPTIONS = { - 'esewa': { - 'name': 'eSewa', - 'description': 'Pay securely using eSewa mobile wallet or QR code.', - 'button_text': 'Pay with eSewa', - }, - 'khalti': { - 'name': 'Khalti', - 'description': 'Complete payment instantly with Khalti.', - 'button_text': 'Pay with Khalti', - }, - 'fonpay': { - 'name': 'Fonpay', - 'description': 'Use Fonpay to pay from your mobile wallet.', - 'button_text': 'Pay with Fonpay', - }, -} + return _render_checkout( + request, + cart_products=cart_products, + total=total, + delivery=initial_delivery, + delivery_shortcuts=delivery_shortcuts, + ) @login_required def payment_page(request, order_id): order = get_object_or_404(Order, id=order_id, user=request.user) + payment_context = _build_payment_options(order) - if order.status == 'Paid': + if order.payment_status == 'Paid' or order.status == 'Paid': messages.info(request, 'This order has already been paid.') return redirect('order_detail', order_id=order.id) + if request.GET.get('cancelled'): + messages.warning(request, 'Payment was cancelled. Your order is still saved and waiting for payment.') + if request.method == 'POST': - method = request.POST.get('payment') - if not method: - messages.error(request, 'Please select a payment method.') + method = request.POST.get('payment', '').strip() + + if method == 'esewa': + if not payment_context['esewa_ready']: + messages.error(request, 'eSewa is not configured yet. Add the merchant credentials to enable it.') + return redirect('payment', order_id=order.id) + + try: + gateway = build_esewa_redirect(request, order) + except PaymentGatewayError as exc: + messages.error(request, str(exc)) + return redirect('payment', order_id=order.id) + except Exception: + logger.exception('Unable to start eSewa payment for order %s', order.id) + messages.error(request, 'We could not start the eSewa payment window right now. Please try again in a moment.') + return redirect('payment', order_id=order.id) + + _save_order_updates( + order, + payment_method='eSewa', + payment_provider='esewa', + payment_status='Pending', + payment_reference='', + payment_session_id=gateway['session_id'], + payment_currency=gateway['currency'], + ) + return render( + request, + 'order/payment_redirect.html', + { + 'order': order, + 'gateway_name': 'eSewa', + 'action_url': gateway['action_url'], + 'fields': gateway['fields'], + }, + ) + + if method == 'khalti': + if not payment_context['khalti_ready']: + messages.error(request, 'Khalti is not configured yet. Add the Khalti secret key to enable it.') + return redirect('payment', order_id=order.id) + + try: + gateway = initiate_khalti_payment(request, order) + except PaymentGatewayError as exc: + messages.error(request, str(exc)) + return redirect('payment', order_id=order.id) + except Exception: + logger.exception('Unable to start Khalti payment for order %s', order.id) + messages.error(request, 'We could not start Khalti right now. Please try again in a moment.') + return redirect('payment', order_id=order.id) + + _save_order_updates( + order, + payment_method='Khalti', + payment_provider='khalti', + payment_status='Pending', + payment_reference=gateway['reference'], + payment_session_id=gateway['session_id'], + payment_currency=gateway['currency'], + ) + return redirect(gateway['payment_url']) + + if method == 'fonepay': + messages.error( + request, + 'Fonepay automation is intentionally disabled until the official merchant onboarding/API details are available.', + ) return redirect('payment', order_id=order.id) - method_key = method.lower().replace(' ', '') - if method_key in GATEWAY_OPTIONS: - return redirect('payment_gateway', order_id=order.id, gateway=method_key) + if method == 'stripe': + if not payment_context['stripe_ready']: + messages.error(request, 'Online card payments are not configured yet. Add a Stripe secret key to enable them.') + return redirect('payment', order_id=order.id) - if method == 'Cash on Delivery': - order.payment_method = method - order.status = 'Paid' - order.save(update_fields=['payment_method', 'status']) - messages.success(request, 'Cash on Delivery selected. Your order is confirmed.') - return redirect('success') + try: + session = create_checkout_session(request, order) + except Exception: + logger.exception('Unable to start Stripe Checkout for order %s', order.id) + messages.error(request, 'We could not start the secure payment window right now. Please try again in a moment.') + return redirect('payment', order_id=order.id) - messages.error(request, 'Selected payment method is not supported.') + _save_order_updates( + order, + payment_method='Card / Wallet', + payment_provider='stripe', + payment_status='Pending', + payment_currency=settings.STRIPE_CURRENCY.upper(), + payment_session_id=stripe_value(session, 'id', '') or '', + ) + + checkout_url = stripe_value(session, 'url') + if not checkout_url: + messages.error(request, 'Stripe did not return a checkout URL. Please try again.') + return redirect('payment', order_id=order.id) + return redirect(checkout_url) + + if method == 'cod': + _save_order_updates( + order, + payment_method='Cash on Delivery', + payment_provider='offline', + payment_status='Pending', + payment_reference='', + payment_session_id='', + payment_currency=payment_currency(), + ) + messages.success(request, 'Cash on delivery selected. You can still switch to an online wallet later if you want.') + return redirect(f"{reverse('success')}?order_id={order.id}") + + messages.error(request, 'Please choose a supported payment method.') return redirect('payment', order_id=order.id) - return render(request, 'order/payment.html', {'order': order, 'gateways': GATEWAY_OPTIONS}) + return render( + request, + 'order/payment.html', + { + 'order': order, + 'currency_code': payment_currency(), + 'esewa_sandbox': settings.ESEWA_SANDBOX, + **payment_context, + }, + ) @login_required def payment_gateway(request, order_id, gateway): + messages.info(request, 'Use the payment selection page to start the gateway securely.') + return redirect('payment', order_id=order_id) + + +@login_required +def esewa_return(request): + order_id = request.GET.get('order_id') order = get_object_or_404(Order, id=order_id, user=request.user) - if order.status == 'Paid': + if order.payment_status == 'Paid': messages.info(request, 'This order has already been paid.') - return redirect('order_detail', order_id=order.id) + return redirect(f"{reverse('success')}?order_id={order.id}") - gateway_data = GATEWAY_OPTIONS.get(gateway) - if gateway_data is None: - messages.error(request, 'Selected payment gateway is not available.') + if request.GET.get('failed'): + _save_order_updates( + order, + payment_method='eSewa', + payment_provider='esewa', + payment_status='Failed', + payment_currency=payment_currency(), + ) + messages.warning(request, 'eSewa payment was not completed. Your order is still saved and waiting for payment.') return redirect('payment', order_id=order.id) - if request.method == 'POST': - order.payment_method = gateway_data['name'] - order.status = 'Paid' - order.save(update_fields=['payment_method', 'status']) - messages.success(request, f'Payment completed through {gateway_data["name"]}.') - return redirect('success') + try: + result = verify_esewa_payment(order, request.GET.get('data', '')) + except PaymentGatewayError as exc: + logger.warning('Unable to verify eSewa payment for order %s: %s', order.id, exc) + messages.error(request, str(exc)) + return redirect('payment', order_id=order.id) + except Exception: + logger.exception('Unexpected eSewa verification error for order %s', order.id) + messages.error(request, 'We could not verify the eSewa payment yet. Please try again.') + return redirect('payment', order_id=order.id) - return render(request, 'order/payment_gateway.html', {'order': order, 'gateway': gateway_data}) + if result['status'] == 'Paid': + _mark_order_paid( + order, + payment_method='eSewa', + payment_provider='esewa', + payment_reference=result['reference'], + payment_session_id=result['session_id'], + payment_currency_value=result['currency'], + ) + messages.success(request, result['message']) + return redirect(f"{reverse('success')}?order_id={order.id}") + + if result['status'] == 'Pending': + _save_order_updates( + order, + payment_method='eSewa', + payment_provider='esewa', + payment_status='Pending', + payment_session_id=result['session_id'], + payment_currency=result['currency'], + ) + messages.info(request, result['message']) + else: + _save_order_updates( + order, + payment_method='eSewa', + payment_provider='esewa', + payment_status='Failed', + payment_session_id=result['session_id'], + payment_currency=result['currency'], + ) + messages.warning(request, result['message']) + + return redirect('payment', order_id=order.id) +@login_required +def khalti_return(request): + order_id = request.GET.get('order_id') + order = get_object_or_404(Order, id=order_id, user=request.user) + + if order.payment_status == 'Paid': + messages.info(request, 'This order has already been paid.') + return redirect(f"{reverse('success')}?order_id={order.id}") + + pidx = request.GET.get('pidx', '').strip() + if not pidx: + _save_order_updates( + order, + payment_method='Khalti', + payment_provider='khalti', + payment_status='Failed', + payment_currency=payment_currency(), + ) + messages.warning(request, 'Khalti payment was not completed. Your order is still saved and waiting for payment.') + return redirect('payment', order_id=order.id) + + try: + result = verify_khalti_payment(order, pidx) + except PaymentGatewayError as exc: + logger.warning('Unable to verify Khalti payment for order %s: %s', order.id, exc) + messages.error(request, str(exc)) + return redirect('payment', order_id=order.id) + except Exception: + logger.exception('Unexpected Khalti verification error for order %s', order.id) + messages.error(request, 'We could not verify the Khalti payment yet. Please try again.') + return redirect('payment', order_id=order.id) + + if result['status'] == 'Paid': + _mark_order_paid( + order, + payment_method='Khalti', + payment_provider='khalti', + payment_reference=result['reference'], + payment_session_id=result['session_id'], + payment_currency_value=result['currency'], + ) + messages.success(request, result['message']) + return redirect(f"{reverse('success')}?order_id={order.id}") + + if result['status'] == 'Pending': + _save_order_updates( + order, + payment_method='Khalti', + payment_provider='khalti', + payment_status='Pending', + payment_session_id=result['session_id'], + payment_currency=result['currency'], + ) + messages.info(request, result['message']) + else: + _save_order_updates( + order, + payment_method='Khalti', + payment_provider='khalti', + payment_status='Failed', + payment_session_id=result['session_id'], + payment_currency=result['currency'], + ) + messages.warning(request, result['message']) + + return redirect('payment', order_id=order.id) + + +@login_required def success(request): - return render(request, 'order/success.html') + order_id = request.GET.get('order_id') + session_id = request.GET.get('session_id', '').strip() + + if not order_id: + messages.info(request, 'Open an order to view its payment status.') + return redirect('my_orders') + + order = get_object_or_404(Order, id=order_id, user=request.user) + payment_verified = order.payment_status == 'Paid' + verification_message = 'Your order has been created successfully.' + + if session_id: + if not stripe_configured(): + verification_message = 'The order exists, but Stripe is not configured on the server to verify the payment session.' + else: + try: + session = retrieve_checkout_session(session_id) + except Exception: + logger.exception('Unable to verify Stripe checkout session %s for order %s', session_id, order.id) + verification_message = 'We could not verify the payment yet. Please refresh this page or open the order details page.' + else: + metadata = stripe_value(session, 'metadata', {}) or {} + session_order_id = str(metadata.get('order_id') or stripe_value(session, 'client_reference_id', '') or '') + if session_order_id != str(order.id): + verification_message = 'The returned payment session did not match this order, so payment could not be confirmed.' + elif stripe_value(session, 'payment_status') == 'paid': + _mark_order_paid_from_stripe(order, session) + payment_verified = True + verification_message = 'Payment verified successfully. Your order is confirmed.' + else: + verification_message = 'Your order was created, but Stripe has not marked the payment as paid yet.' + elif order.payment_status == 'Paid': + payment_verified = True + verification_message = 'Payment verified successfully. Your order is confirmed.' + elif order.payment_status == 'Failed': + provider_name = order.payment_provider_label if order.payment_provider else 'payment gateway' + verification_message = f'The last {provider_name} attempt did not complete. You can retry from the payment page.' + elif order.payment_method == 'Cash on Delivery': + verification_message = 'Your order is confirmed. Payment will be collected on delivery, and you can still switch to an online wallet before shipment if needed.' + elif order.payment_provider == 'esewa': + verification_message = 'Your order was created, but eSewa has not marked the payment as complete yet.' + elif order.payment_provider == 'khalti': + verification_message = 'Your order was created, but Khalti has not marked the payment as complete yet.' + elif order.payment_provider == 'stripe': + verification_message = 'Your order was created, but Stripe has not marked the payment as complete yet.' + else: + verification_message = 'Your order is waiting for payment. You can return to the payment page any time.' + + return render( + request, + 'order/success.html', + { + 'order': order, + 'payment_verified': payment_verified, + 'verification_message': verification_message, + }, + ) + + +@csrf_exempt +def stripe_webhook(request): + if request.method != 'POST': + return HttpResponse(status=405) + + if not stripe_configured() or not settings.STRIPE_WEBHOOK_SECRET: + return HttpResponse('Stripe webhook is not configured.', status=503) + + signature = request.META.get('HTTP_STRIPE_SIGNATURE', '') + + try: + event = construct_webhook_event(request.body, signature) + except Exception: + logger.exception('Invalid Stripe webhook received') + return HttpResponse(status=400) + + event_type = event.get('type', '') + session = event.get('data', {}).get('object', {}) + metadata = stripe_value(session, 'metadata', {}) or {} + order_id = str(metadata.get('order_id') or stripe_value(session, 'client_reference_id', '') or '').strip() + order = Order.objects.filter(id=order_id).first() if order_id else None + + if order and event_type in {'checkout.session.completed', 'checkout.session.async_payment_succeeded'}: + _mark_order_paid_from_stripe(order, session) + elif order and event_type == 'checkout.session.expired' and order.payment_status != 'Paid': + _save_order_updates(order, payment_status='Failed') + + return HttpResponse(status=200) @login_required def my_orders(request): - orders = list(Order.objects.filter(user=request.user).order_by('-created_at')) - for order in orders: - _sync_demo_status(order) - return render(request, 'order/my_orders.html', {'orders': orders}) + status_filter = request.GET.get('status', '').strip() + payment_filter = request.GET.get('payment', '').strip() + + valid_statuses = [choice[0] for choice in Order.STATUS_CHOICES] + valid_payment_statuses = [choice[0] for choice in Order.PAYMENT_STATUS_CHOICES] + + if status_filter not in valid_statuses: + status_filter = '' + if payment_filter not in valid_payment_statuses: + payment_filter = '' + + base_orders = Order.objects.filter(user=request.user) + orders = base_orders.prefetch_related('items__product').order_by('-created_at') + + if status_filter: + orders = orders.filter(status=status_filter) + if payment_filter: + orders = orders.filter(payment_status=payment_filter) + + order_summary = { + 'total': base_orders.count(), + 'paid': base_orders.filter(payment_status='Paid').count(), + 'pending_payment': base_orders.exclude(payment_status='Paid').count(), + 'shipped': base_orders.filter(status='Shipped').count(), + 'delivered': base_orders.filter(status='Delivered').count(), + } + + return render( + request, + 'order/my_orders.html', + { + 'orders': orders, + 'order_summary': order_summary, + 'status_filters': valid_statuses, + 'payment_filters': valid_payment_statuses, + 'selected_status': status_filter, + 'selected_payment_status': payment_filter, + 'has_order_filters': bool(status_filter or payment_filter), + }, + ) @login_required def order_detail(request, order_id): - order = get_object_or_404(Order, id=order_id, user=request.user) - _sync_demo_status(order) + order = get_object_or_404(Order.objects.prefetch_related('items__product'), id=order_id, user=request.user) timeline = _build_timeline(order.status) return render(request, 'order/order_detail.html', {'order': order, 'timeline': timeline}) diff --git a/myproject/products/__pycache__/apps.cpython-311.pyc b/myproject/products/__pycache__/apps.cpython-311.pyc index 73cf746..e1f6fc1 100644 Binary files a/myproject/products/__pycache__/apps.cpython-311.pyc and b/myproject/products/__pycache__/apps.cpython-311.pyc differ diff --git a/myproject/products/__pycache__/models.cpython-311.pyc b/myproject/products/__pycache__/models.cpython-311.pyc index 55c26fe..0f815ee 100644 Binary files a/myproject/products/__pycache__/models.cpython-311.pyc and b/myproject/products/__pycache__/models.cpython-311.pyc differ diff --git a/myproject/products/__pycache__/tests.cpython-311.pyc b/myproject/products/__pycache__/tests.cpython-311.pyc new file mode 100644 index 0000000..8275d40 Binary files /dev/null and b/myproject/products/__pycache__/tests.cpython-311.pyc differ diff --git a/myproject/products/__pycache__/views.cpython-311.pyc b/myproject/products/__pycache__/views.cpython-311.pyc index 1361085..0ef7cb6 100644 Binary files a/myproject/products/__pycache__/views.cpython-311.pyc and b/myproject/products/__pycache__/views.cpython-311.pyc differ diff --git a/myproject/products/apps.py b/myproject/products/apps.py index e2e4a85..145a2ac 100644 --- a/myproject/products/apps.py +++ b/myproject/products/apps.py @@ -1,5 +1,6 @@ -from django.apps import AppConfig - - -class ProductsConfig(AppConfig): - name = 'products' +from django.apps import AppConfig + + +class ProductsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'products' diff --git a/myproject/products/migrations/0008_alter_product_category.py b/myproject/products/migrations/0008_alter_product_category.py new file mode 100644 index 0000000..2ec4f2d --- /dev/null +++ b/myproject/products/migrations/0008_alter_product_category.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.14 on 2026-05-20 10:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0007_alter_product_category_alter_product_listing_type_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='product', + name='category', + field=models.CharField(choices=[('Electronics', 'Electronics'), ('Mobiles', 'Mobiles'), ('Fashion', 'Fashion'), ('Home', 'Home'), ('Beauty', 'Beauty'), ('Sports', 'Sports'), ('Books', 'Books'), ('Groceries', 'Groceries'), ('Pets', 'Pets'), ('Tools', 'Tools'), ('Office', 'Office'), ('Kitchen', 'Kitchen'), ('Travel', 'Travel'), ('Automotive', 'Automotive'), ('Garden', 'Garden'), ('Party', 'Party'), ('General', 'General')], default='General', max_length=100), + ), + ] diff --git a/myproject/products/migrations/__pycache__/0008_alter_product_category.cpython-311.pyc b/myproject/products/migrations/__pycache__/0008_alter_product_category.cpython-311.pyc new file mode 100644 index 0000000..2f1cc2f Binary files /dev/null and b/myproject/products/migrations/__pycache__/0008_alter_product_category.cpython-311.pyc differ diff --git a/myproject/products/models.py b/myproject/products/models.py index 5e5eeb9..95eff53 100644 --- a/myproject/products/models.py +++ b/myproject/products/models.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from django.conf import settings from django.db import models @@ -64,6 +66,13 @@ class Product(models.Model): def savings(self): if self.discount_price and self.discount_price < self.price: return self.price - self.discount_price + return Decimal('0') + + @property + def discount_percent(self): + if self.discount_price and self.discount_price < self.price and self.price: + percent = ((self.price - self.discount_price) / self.price) * Decimal('100') + return int(percent.quantize(Decimal('1'))) return 0 @property diff --git a/myproject/products/views.py b/myproject/products/views.py index 64adfb6..e784d59 100644 --- a/myproject/products/views.py +++ b/myproject/products/views.py @@ -1,11 +1,18 @@ from django.contrib.auth.decorators import login_required -from django.db.models import Q +from django.db.models import Avg, Q from django.db.models.functions import Coalesce from django.shortcuts import get_object_or_404, redirect, render from cart.views import add_to_cart from .models import Product, WishlistItem +SORT_LABELS = { + 'newest': 'Newest first', + 'rating': 'Top rated', + 'price_asc': 'Price low to high', + 'price_desc': 'Price high to low', +} + def _normalized_categories(): categories = [choice[0] for choice in Product.CATEGORY_CHOICES] @@ -54,6 +61,18 @@ def product_list(request, category=None): if request.user.is_authenticated: wishlist_ids = set(WishlistItem.objects.filter(user=request.user).values_list('product_id', flat=True)) + active_filters = [] + if query: + active_filters.append(f'Search: “{query}”') + if selected_category: + active_filters.append(f'Category: {selected_category}') + if selected_featured: + active_filters.append('Featured only') + if sort: + active_filters.append(f'Sort: {SORT_LABELS.get(sort, sort)}') + + aggregates = products.aggregate(avg_rating=Avg('rating')) + return render( request, 'products/product_list.html', @@ -68,6 +87,10 @@ def product_list(request, category=None): 'products_count': products.count(), 'featured_count': products.filter(featured=True).count(), 'in_stock_count': products.filter(stock__gt=0).count(), + 'discounted_count': products.filter(discount_price__isnull=False).count(), + 'avg_rating': aggregates.get('avg_rating') or 0, + 'active_filters': active_filters, + 'has_active_filters': bool(active_filters), }, ) @@ -115,4 +138,3 @@ def move_wishlist_to_cart(request, product_id): def wishlist_view(request): wishlist_items = WishlistItem.objects.filter(user=request.user).select_related('product') return render(request, 'products/wishlist.html', {'wishlist_items': wishlist_items}) - diff --git a/myproject/static/css/auth.css b/myproject/static/css/auth.css index acd8f4d..81c1948 100644 --- a/myproject/static/css/auth.css +++ b/myproject/static/css/auth.css @@ -1,131 +1,266 @@ -.auth-container { - display: flex; - justify-content: center; - align-items: center; - min-height: 76vh; - padding: 24px; -} - -.auth-box { - background: #fff; - border: 1px solid #dbe4f8; - border-radius: 24px; - padding: 36px; - width: 100%; - max-width: 420px; - box-shadow: 0 24px 60px rgba(15, 23, 42, 0.08); -} - -.auth-box h2 { - font-size: 2rem; - margin-bottom: 18px; - color: #0f172a; - text-align: center; -} - -.auth-form { - margin-top: 24px; - display: grid; - gap: 18px; -} - -.form-group { - display: grid; - gap: 8px; -} - -.form-group label { - font-weight: 700; - color: #334155; -} - -.form-group input { - width: 100%; - padding: 14px 16px; - border: 1px solid #cbd5e1; - border-radius: 14px; - background: #f8fbff; - color: #0f172a; - font-size: 1rem; - transition: border-color 0.2s ease, box-shadow 0.2s ease; -} - -.form-group input:focus { - outline: none; - border-color: #3b82f6; - box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.12); -} - -.form-group input::placeholder { - color: #94a3b8; -} - -.form-group small { - color: #64748b; - font-size: 0.85rem; -} - -.btn-primary { - width: 100%; - padding: 14px; - background: linear-gradient(135deg, #2874f0 0%, #3b82f6 100%); - color: #fff; - border: none; - border-radius: 14px; - font-weight: 700; - font-size: 1rem; - cursor: pointer; - transition: transform 0.2s ease, box-shadow 0.2s ease; - margin-top: 10px; -} - -.btn-primary:hover { - transform: translateY(-1px); - box-shadow: 0 18px 30px rgba(40, 116, 240, 0.24); -} - -.alert { - padding: 14px 16px; - border-radius: 14px; - margin-bottom: 12px; - font-weight: 500; -} - -.alert-success { - background: #ecfdf5; - border: 1px solid #34d399; - color: #065f46; -} - -.alert-error { - background: #fef2f2; - border: 1px solid #ef4444; - color: #b91c1c; -} - -.auth-link { - text-align: center; - margin-top: 18px; - color: #475569; -} - -.auth-link a { - color: #2563eb; - text-decoration: none; - font-weight: 700; -} - -.auth-link a:hover { - text-decoration: underline; -} - -/* Responsive */ -@media (max-width: 480px) { - .auth-box { - padding: 24px; - } - - .auth-box h2 { - font-size: 1.6rem; - } -} +.auth-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 76vh; + padding: 24px; +} + +.auth-container--wide { + align-items: flex-start; +} + +.auth-box { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 24px; + padding: 36px; + width: 100%; + max-width: 420px; + box-shadow: 0 24px 60px rgba(15, 23, 42, 0.08); +} + +.auth-box--wide { + max-width: 860px; +} + +.auth-copy-block { + display: grid; + gap: 8px; +} + +.auth-copy-block p { + color: var(--muted); + line-height: 1.7; +} + +.auth-box h2 { + font-size: 2rem; + margin-bottom: 4px; + color: var(--text); +} + +.auth-form { + margin-top: 24px; + display: grid; + gap: 18px; +} + +.auth-form--wide { + gap: 20px; +} + +.auth-form-grid { + gap: 18px; +} + +.form-group { + display: grid; + gap: 8px; +} + +.form-group--full { + grid-column: 1 / -1; +} + +.form-group label { + font-weight: 700; + color: var(--text); +} + +.form-group input, +.form-group textarea, +.form-group select { + width: 100%; + padding: 14px 16px; + border: 1px solid #cbd5e1; + border-radius: 14px; + background: #f8fbff; + color: #0f172a; + font-size: 1rem; + transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; +} + +.form-group textarea { + min-height: 110px; + resize: vertical; +} + +.form-group input:focus, +.form-group textarea:focus, +.form-group select:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.12); + background: #fff; +} + +.form-group input::placeholder, +.form-group textarea::placeholder { + color: #94a3b8; +} + +.form-group small, +.field-note { + color: #64748b; + font-size: 0.85rem; + line-height: 1.6; +} + +.field-errors { + color: #b91c1c; + font-size: 0.85rem; +} + +.field-errors ul { + margin: 0; + padding-left: 18px; +} + +.form-group--checkbox { + background: var(--surface-soft); + border: 1px solid var(--border); + border-radius: 16px; + padding: 16px 18px; +} + +.checkbox-label { + display: inline-flex; + align-items: center; + gap: 10px; + font-weight: 700; + cursor: pointer; +} + +.checkbox-label input { + width: 18px; + height: 18px; +} + +.btn-primary { + width: 100%; + padding: 14px; + background: linear-gradient(135deg, #2874f0 0%, #3b82f6 100%); + color: #fff; + border: none; + border-radius: 14px; + font-weight: 700; + font-size: 1rem; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; + margin-top: 4px; +} + +.btn-primary:hover { + transform: translateY(-1px); + box-shadow: 0 18px 30px rgba(40, 116, 240, 0.24); +} + +.alert { + padding: 14px 16px; + border-radius: 14px; + margin-bottom: 12px; + font-weight: 500; +} + +.alert-success { + background: #ecfdf5; + border: 1px solid #34d399; + color: #065f46; +} + +.alert-error { + background: #fef2f2; + border: 1px solid #ef4444; + color: #b91c1c; +} + +.auth-link { + text-align: center; + margin-top: 18px; + color: #475569; +} + +.auth-link a { + color: #2563eb; + text-decoration: none; + font-weight: 700; +} + +.auth-link a:hover { + text-decoration: underline; +} + +body.theme-dark .auth-box, +body.theme-dark .form-group--checkbox { + background: var(--surface); + border-color: var(--border); +} + +body.theme-dark .form-group input, +body.theme-dark .form-group textarea, +body.theme-dark .form-group select { + background: var(--surface-soft); + border-color: var(--border); + color: var(--text); +} + +body.theme-dark .form-group input:focus, +body.theme-dark .form-group textarea:focus, +body.theme-dark .form-group select:focus { + background: var(--surface); +} + +body.theme-dark .auth-link, +body.theme-dark .field-note, +body.theme-dark .form-group small, +body.theme-dark .auth-copy-block p { + color: var(--muted); +} + +body.theme-dark .alert-success { + background: rgba(22, 163, 74, 0.12); + border-color: rgba(74, 222, 128, 0.32); + color: #bbf7d0; +} + +body.theme-dark .alert-error { + background: rgba(239, 68, 68, 0.12); + border-color: rgba(248, 113, 113, 0.3); + color: #fecaca; +} + +@media (max-width: 768px) { + .auth-container { + padding: 16px; + } + + .auth-box { + padding: 28px 22px; + } + + .auth-box h2 { + font-size: 1.8rem; + } +} + +@media (max-width: 480px) { + .auth-box { + padding: 22px 16px; + border-radius: 20px; + } + + .auth-form, + .auth-form--wide { + gap: 16px; + } + + .checkbox-label { + align-items: flex-start; + } + + .btn-primary, + .location-card .btn { + width: 100%; + } +} diff --git a/myproject/static/css/home.css b/myproject/static/css/home.css index 4f934de..06a11ae 100644 --- a/myproject/static/css/home.css +++ b/myproject/static/css/home.css @@ -274,3 +274,199 @@ .top-category-bar { padding: 10px 16px; } .top-navbar { padding: 12px 16px; } } + +.hero-metric-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.hero-metric-card { + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 18px; + padding: 16px 18px; + display: grid; + gap: 6px; +} + +.hero-metric-card strong { + font-size: 1.45rem; +} + +.hero-metric-card span { + color: rgba(255, 255, 255, 0.82); + font-size: 0.92rem; +} + +.market-pill { + display: grid; + gap: 4px; +} + +.market-pill strong { + font-size: 1rem; +} + +.market-pill span { + color: var(--muted); + font-size: 0.94rem; + font-weight: 600; +} + +.spotlight-shell, +.experience-shell, +.quick-action-shell { + margin-top: 10px; +} + +.spotlight-grid, +.experience-grid, +.quick-action-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; +} + +.spotlight-card, +.experience-card, +.quick-action-card { + background: #fff; + border: 1px solid #dbe4f8; + border-radius: 22px; + padding: 22px; + box-shadow: var(--shadow-soft); + display: grid; + gap: 12px; +} + +.spotlight-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.spotlight-kicker { + color: #2563eb; + font-size: 0.82rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.spotlight-pill, +.experience-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: fit-content; + border-radius: 999px; + padding: 6px 10px; + background: #eff6ff; + color: #1d4ed8; + font-size: 0.82rem; + font-weight: 800; +} + +.spotlight-card h3, +.experience-card h3, +.quick-action-card strong { + color: var(--text); +} + +.spotlight-card p, +.experience-card p, +.quick-action-card p { + color: var(--muted); + line-height: 1.7; +} + +.spotlight-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.spotlight-meta span { + display: inline-flex; + align-items: center; + padding: 6px 10px; + border-radius: 999px; + background: #f8fbff; + border: 1px solid #dbe4f8; + color: #475569; + font-size: 0.84rem; + font-weight: 700; +} + +.quick-action-card { + text-decoration: none; + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; +} + +.quick-action-card:hover, +.spotlight-card:hover, +.experience-card:hover { + transform: translateY(-3px); + box-shadow: 0 18px 44px rgba(15, 23, 42, 0.12); + border-color: #bfd4ff; +} + +.deal-topline { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +body.theme-dark .hero { + background: linear-gradient(135deg, #1d4ed8 0%, #1e3a8a 38%, #0f172a 100%); +} + +body.theme-dark .spotlight-card, +body.theme-dark .experience-card, +body.theme-dark .quick-action-card, +body.theme-dark .deal-card, +body.theme-dark .card, +body.theme-dark .market-pill { + background: var(--surface); + border-color: var(--border); + color: var(--text); +} + +body.theme-dark .market-pill span, +body.theme-dark .spotlight-card p, +body.theme-dark .experience-card p, +body.theme-dark .quick-action-card p, +body.theme-dark .deal-note, +body.theme-dark .section-heading p { + color: var(--muted); +} + +body.theme-dark .spotlight-meta span, +body.theme-dark .spotlight-pill, +body.theme-dark .experience-badge, +body.theme-dark .deal-badge { + background: rgba(59, 130, 246, 0.16); + border-color: rgba(96, 165, 250, 0.22); + color: #93c5fd; +} + +@media (max-width: 1024px) { + .hero-metric-grid, + .spotlight-grid, + .experience-grid, + .quick-action-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .hero-metric-grid, + .spotlight-grid, + .experience-grid, + .quick-action-grid { + grid-template-columns: 1fr; + } +} diff --git a/myproject/static/css/navbar.css b/myproject/static/css/navbar.css index df5f5a2..4e56505 100644 --- a/myproject/static/css/navbar.css +++ b/myproject/static/css/navbar.css @@ -322,3 +322,150 @@ justify-content: flex-end; } } + +.fk-theme-toggle { + border: 1px solid rgba(255, 255, 255, 0.22); + background: rgba(255, 255, 255, 0.1); +} + +.fk-theme-toggle .theme-icon { + font-size: 1rem; +} + +.fk-theme-toggle .theme-label { + font-weight: 700; +} + +.fk-dropdown-menu--right { + left: auto; + right: 0; +} + +body.theme-dark .fk-theme-toggle { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.16); +} + +@media (max-width: 768px) { + .fk-theme-toggle { + order: 2; + } +} + +.fk-location-text { + display: inline-flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.fk-location-copy { + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fk-location-link { + white-space: nowrap; +} + +@media (max-width: 960px) { + .fk-top-row, + .fk-middle-row { + flex-direction: column; + align-items: stretch; + gap: 12px; + } + + .fk-top-right { + font-size: 0.82rem; + } + + .fk-main-actions { + justify-content: space-between; + flex-wrap: wrap; + gap: 10px; + } + + .fk-action-btn { + min-height: 44px; + } +} + +@media (max-width: 640px) { + .fk-container { + padding: 0 12px; + } + + .fk-brand-btn { + width: 100%; + justify-content: center; + } + + .fk-search-bar { + min-width: 100%; + padding: 10px 12px; + } + + .fk-main-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; + } + + .fk-action-btn, + .fk-dropdown-wrapper { + width: 100%; + } + + .fk-dropdown-wrapper > .fk-action-btn, + .fk-cart-btn { + justify-content: center; + } + + .fk-dropdown-menu, + .fk-dropdown-menu--right { + left: 0; + right: 0; + min-width: 100%; + } + + .fk-category-bar, + .nav-scrolled .fk-category-bar { + height: 82px; + } + + .fk-category-scroll, + .nav-scrolled .fk-category-scroll { + min-height: 82px; + padding: 8px 0; + } + + .fk-cat-item, + .nav-scrolled .fk-cat-item { + width: 68px; + min-width: 68px; + height: 66px; + } + + .fk-location-copy { + max-width: 180px; + } +} + +@media (max-width: 420px) { + .fk-main-actions { + grid-template-columns: 1fr; + } + + .fk-location-copy { + max-width: 120px; + } + + .fk-cat-item, + .nav-scrolled .fk-cat-item { + width: 62px; + min-width: 62px; + } +} diff --git a/myproject/static/css/product.css b/myproject/static/css/product.css index babbb83..a47185b 100644 --- a/myproject/static/css/product.css +++ b/myproject/static/css/product.css @@ -456,3 +456,1094 @@ .timeline-step.upcoming { background: #f8fafc; } @media (max-width: 760px) { .timeline { grid-template-columns: 1fr 1fr; } } + + +.page-eyebrow { + margin: 0 0 6px; + font-size: 0.82rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #2563eb; +} + +.payment-panel--main { + display: grid; + gap: 18px; +} + +.payment-panel-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.payment-step { + margin: 0 0 6px; + font-size: 0.82rem; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #2563eb; +} + +.payment-caption { + margin: 6px 0 0; + color: var(--muted); +} + +.payment-method-card { + border: 1px solid #dbe4f8; + border-radius: 16px; + padding: 16px; + display: flex; + align-items: flex-start; + gap: 14px; + background: #f8fbff; + cursor: pointer; + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; +} + +.payment-method-card:hover { + border-color: #93c5fd; + box-shadow: 0 18px 44px rgba(37, 99, 235, 0.08); + transform: translateY(-1px); +} + +.payment-method-card input[type="radio"] { + margin-top: 4px; +} + +.payment-method-card.is-disabled { + opacity: 0.72; + cursor: not-allowed; +} + +.payment-method-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.payment-method-body { + display: grid; + gap: 6px; + width: 100%; +} + +.payment-method-heading { + display: grid; + gap: 2px; +} + +.payment-method-subtitle { + font-size: 0.85rem; + color: #64748b; +} + +.payment-badge { + display: inline-flex; + align-items: center; + padding: 6px 10px; + border-radius: 999px; + background: #dbeafe; + color: #1d4ed8; + font-size: 0.8rem; + font-weight: 700; +} + +.payment-badge--muted { + background: #e2e8f0; + color: #475569; +} + +.payment-choice-form { + display: grid; + gap: 14px; +} + +.payment-trust-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.payment-trust-item { + background: #fff; + border: 1px solid #dbe4f8; + border-radius: 14px; + padding: 12px 14px; + color: #334155; + font-size: 0.95rem; +} + +.gateway-status-banner { + border-radius: 14px; + padding: 14px 16px; + border: 1px solid transparent; + display: grid; + gap: 4px; +} + +.gateway-status-banner p, +.alert-card p, +.payment-mini-card p, +.success-summary-item span { + margin: 0; + color: var(--muted); +} + +.gateway-status-banner.info { + border-color: #bfdbfe; + background: #eff6ff; + color: #1e3a8a; +} + +.gateway-status-banner.warning, +.alert-card.warning { + border-color: #fcd34d; + background: #fffbeb; + color: #92400e; +} + +.gateway-status-banner.danger { + border-color: #fecaca; + background: #fef2f2; + color: #991b1b; +} + +.alert-card.neutral { + border-color: #dbe4f8; + background: #f8fbff; + color: #334155; +} + +.alert-card { + border-radius: 16px; + padding: 16px; + border: 1px solid transparent; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; + flex-wrap: wrap; +} + +.payment-summary { + position: sticky; + top: 96px; + align-self: start; +} + +.payment-summary-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + flex-wrap: wrap; +} + +.payment-mini-card { + gap: 6px; + background: #f8fbff; +} + +.checkout-form { + gap: 14px; +} + +.field-stack { + display: grid; + gap: 8px; +} + +.field-stack label { + font-weight: 700; + color: #1e293b; +} + +.inline-status-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + color: var(--muted); +} + +.inline-status-row--spaced { + margin-top: 10px; +} + +.order-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.status-payment-unpaid { + color: #92400e; + background: #fff7ed; + border-color: #fed7aa; +} + +.status-payment-pending { + color: #1e40af; + background: #eff6ff; + border-color: #bfdbfe; +} + +.status-payment-failed { + color: #991b1b; + background: #fef2f2; + border-color: #fecaca; +} + +.success-summary { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin: 24px 0; + text-align: left; +} + +.success-summary-item { + background: #f8fbff; + border: 1px solid #dbe4f8; + border-radius: 14px; + padding: 14px 16px; + display: grid; + gap: 8px; +} + +.success-summary-item strong { + color: #0f172a; +} + +@media (max-width: 1024px) { + .payment-summary { + position: static; + } + + .payment-trust-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 760px) { + .payment-method-card, + .alert-card, + .order-item-row { + flex-direction: column; + align-items: flex-start; + } + + .success-summary { + grid-template-columns: 1fr; + } +} + +.redirect-gateway-form { + gap: 12px; +} + +.redirect-gateway-form .btn { + width: fit-content; +} + +.payment-method-card.is-disabled:hover { + transform: none; + box-shadow: none; + border-color: #dbe4f8; +} + +.product-hero-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.product-hero-stat { + background: #fff; + border: 1px solid #dbe4f8; + border-radius: 18px; + padding: 16px; + display: grid; + gap: 4px; + box-shadow: var(--shadow-soft); +} + +.product-hero-stat strong { + font-size: 1.35rem; + color: #0f172a; +} + +.product-hero-stat span { + color: #475569; + font-size: 0.92rem; + font-weight: 700; +} + +.search-form-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.filter-summary-bar { + max-width: 1180px; + margin: 0 auto 18px; + padding: 16px 18px; + border: 1px solid #dbe4f8; + border-radius: 18px; + background: #fff; + box-shadow: var(--shadow-soft); + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.filter-chip-row { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.filter-chip { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 8px 12px; + background: #eff6ff; + color: #1d4ed8; + font-size: 0.88rem; + font-weight: 800; +} + +.shop-feature-strip { + max-width: 1180px; + margin: 0 auto 18px; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.shop-stat-card, +.cart-insight-card, +.checkout-side-card, +.orders-summary-card, +.payment-overview-card { + background: #fff; + border: 1px solid #dbe4f8; + border-radius: 18px; + padding: 18px; + box-shadow: var(--shadow-soft); + display: grid; + gap: 8px; +} + +.shop-stat-label { + color: #2563eb; + font-size: 0.82rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.shop-stat-value { + color: #0f172a; + font-size: clamp(1.35rem, 2vw, 1.9rem); + font-weight: 800; +} + +.shop-stat-note { + color: #64748b; + line-height: 1.6; +} + +.product-rating-row { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.rating-chip, +.availability-chip { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + padding: 7px 12px; + font-size: 0.84rem; + font-weight: 800; +} + +.rating-chip { + background: #fffbeb; + color: #92400e; +} + +.availability-chip { + background: #ecfdf5; + color: #166534; +} + +.availability-chip.out { + background: #fef2f2; + color: #991b1b; +} + +.savings-tag { + background: #ecfdf5; + color: #047857; +} + +.product-card-caption { + color: #64748b; + font-size: 0.94rem; + line-height: 1.7; +} + +.product-detail-page { + max-width: 1180px; + margin: 0 auto 28px; + display: grid; + grid-template-columns: minmax(0, 2.2fr) minmax(280px, 0.8fr); + gap: 16px; + align-items: start; +} + +.product-detail-page > .related-products { + grid-column: 1 / -1; +} + +.product-extras div { + display: grid; + gap: 6px; + text-align: left; +} + +.product-extras div strong { + font-size: 1rem; +} + +.product-extras div span { + color: #475569; + font-weight: 600; + line-height: 1.5; +} + +.detail-usp-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.detail-usp-card, +.detail-support-card, +.order-support-card { + background: #fff; + border: 1px solid #dbe4f8; + border-radius: 18px; + padding: 18px; + box-shadow: var(--shadow-soft); + display: grid; + gap: 10px; +} + +.detail-usp-card strong, +.detail-support-card h3, +.order-support-card h4 { + color: #0f172a; +} + +.detail-usp-card p, +.detail-support-card p, +.order-support-card p { + color: #64748b; + line-height: 1.7; +} + +.cart-insight-strip, +.orders-summary-grid, +.payment-overview-grid { + max-width: 1180px; + margin: 0 auto 18px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.orders-summary-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.cart-card-left { + display: flex; + justify-content: space-between; + gap: 18px; + flex: 1; +} + +.cart-product-copy { + display: grid; + gap: 8px; +} + +.cart-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; +} + +.cart-summary-note { + border-radius: 16px; + padding: 14px 16px; + background: #f8fbff; + border: 1px solid #dbe4f8; + display: grid; + gap: 6px; +} + +.cart-summary-note strong { + color: #0f172a; +} + +.cart-summary-note p { + margin: 0; + color: #64748b; + line-height: 1.7; +} + +.cart-summary-note--success { + background: #ecfdf5; + border-color: #bbf7d0; +} + +.checkout-progress { + max-width: 1180px; + margin: 0 auto 18px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.checkout-progress-step { + background: #fff; + border: 1px solid #dbe4f8; + border-radius: 18px; + padding: 16px 18px; + display: flex; + align-items: center; + gap: 12px; + box-shadow: var(--shadow-soft); +} + +.checkout-progress-step strong { + width: 34px; + height: 34px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: #eff6ff; + color: #1d4ed8; +} + +.checkout-progress-step span { + font-weight: 800; + color: #1f2937; +} + +.checkout-progress-step.is-active { + border-color: #93c5fd; + box-shadow: 0 18px 44px rgba(37, 99, 235, 0.1); +} + +.checkout-progress-step.is-done strong { + background: #dcfce7; + color: #166534; +} + +.checkout-overview-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-top: 18px; +} + +.checkout-form-actions, +.orders-filter-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; +} + +.orders-filter-bar { + max-width: 1180px; + margin: 0 auto 18px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)) auto; + gap: 14px; + padding: 18px; + background: #fff; + border: 1px solid #dbe4f8; + border-radius: 18px; + box-shadow: var(--shadow-soft); + align-items: end; +} + +.payment-overview-card p { + margin: 0; + color: #64748b; + line-height: 1.7; +} + +.success-wrap { + max-width: 1180px; + margin: 0 auto 28px; +} + +.success-card { + background: #fff; + border: 1px solid #dbe4f8; + border-radius: 24px; + padding: 28px; + box-shadow: var(--shadow-soft); +} + +.success-next-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 24px; +} + +.success-next-card { + background: #f8fbff; + border: 1px solid #dbe4f8; + border-radius: 18px; + padding: 16px; + display: grid; + gap: 8px; +} + +.success-next-card strong { + color: #0f172a; +} + +.success-next-card p { + margin: 0; + color: #64748b; + line-height: 1.7; +} + +.success-actions { + justify-content: flex-start; +} + +.order-side-panel { + position: sticky; + top: 96px; +} + +.order-support-card { + margin-top: 6px; +} + +body.theme-dark .product-hero, +body.theme-dark .product-hero-stat, +body.theme-dark .filter-summary-bar, +body.theme-dark .shop-stat-card, +body.theme-dark .card, +body.theme-dark .product-detail-card, +body.theme-dark .detail-usp-card, +body.theme-dark .detail-support-card, +body.theme-dark .cart-card, +body.theme-dark .summary-box, +body.theme-dark .payment-panel, +body.theme-dark .order-detail-box, +body.theme-dark .order-card, +body.theme-dark .cart-insight-card, +body.theme-dark .checkout-side-card, +body.theme-dark .orders-summary-card, +body.theme-dark .orders-filter-bar, +body.theme-dark .payment-overview-card, +body.theme-dark .success-card, +body.theme-dark .success-next-card, +body.theme-dark .order-support-card, +body.theme-dark .checkout-progress-step, +body.theme-dark .payment-method-card, +body.theme-dark .payment-trust-item, +body.theme-dark .payment-mini-card, +body.theme-dark .order-item-row, +body.theme-dark .timeline-step, +body.theme-dark .category-chip, +body.theme-dark .product-image-placeholder, +body.theme-dark .cart-summary-note { + background: var(--surface); + border-color: var(--border); + color: var(--text); +} + +body.theme-dark .product-search-bar input, +body.theme-dark .product-search-bar select, +body.theme-dark .field-input, +body.theme-dark .cart-qty-form input, +body.theme-dark .orders-filter-bar select { + background: var(--surface-soft); + border-color: var(--border); + color: var(--text); +} + +body.theme-dark .product-hero h1, +body.theme-dark .product-hero-stat strong, +body.theme-dark .shop-stat-value, +body.theme-dark .success-summary-item strong, +body.theme-dark .success-next-card strong, +body.theme-dark .detail-usp-card strong, +body.theme-dark .cart-summary-note strong { + color: var(--text); +} + +body.theme-dark .product-hero p, +body.theme-dark .product-hero-stat span, +body.theme-dark .description, +body.theme-dark .stock, +body.theme-dark .product-card-caption, +body.theme-dark .shop-stat-note, +body.theme-dark .payment-caption, +body.theme-dark .payment-overview-card p, +body.theme-dark .success-next-card p, +body.theme-dark .detail-usp-card p, +body.theme-dark .product-extras div span, +body.theme-dark .cart-summary-note p, +body.theme-dark .order-meta, +body.theme-dark .field-stack label, +body.theme-dark .order-summary-list li span { + color: var(--muted); +} + +body.theme-dark .payment-badge, +body.theme-dark .filter-chip, +body.theme-dark .category-chip.active, +body.theme-dark .rating-chip, +body.theme-dark .checkout-progress-step strong { + background: rgba(59, 130, 246, 0.16); + color: #93c5fd; +} + +body.theme-dark .payment-badge--muted { + background: rgba(148, 163, 184, 0.16); + color: #cbd5e1; +} + +body.theme-dark .availability-chip { + background: rgba(22, 163, 74, 0.14); + color: #86efac; +} + +body.theme-dark .availability-chip.out, +body.theme-dark .status-payment-failed { + background: rgba(239, 68, 68, 0.14); + color: #fca5a5; +} + +body.theme-dark .gateway-status-banner.info, +body.theme-dark .alert-card.neutral { + background: rgba(59, 130, 246, 0.12); + border-color: rgba(96, 165, 250, 0.24); + color: #bfdbfe; +} + +body.theme-dark .gateway-status-banner.warning, +body.theme-dark .alert-card.warning { + background: rgba(234, 179, 8, 0.12); + border-color: rgba(250, 204, 21, 0.28); + color: #fde68a; +} + +body.theme-dark .gateway-status-banner.danger { + background: rgba(239, 68, 68, 0.12); + border-color: rgba(248, 113, 113, 0.28); + color: #fecaca; +} + +@media (max-width: 1180px) { + .product-hero-stats, + .shop-feature-strip, + .orders-summary-grid, + .checkout-overview-grid, + .payment-overview-grid, + .success-next-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 1024px) { + .product-detail-page, + .cart-insight-strip, + .orders-summary-grid, + .payment-overview-grid, + .success-next-grid, + .checkout-progress { + grid-template-columns: 1fr; + } + + .product-detail-page > .related-products { + grid-column: auto; + } + + .detail-usp-grid, + .orders-filter-bar { + grid-template-columns: 1fr; + } + + .order-side-panel { + position: static; + } +} + +@media (max-width: 768px) { + .product-hero-stats, + .shop-feature-strip, + .cart-insight-strip, + .checkout-overview-grid, + .payment-overview-grid, + .success-next-grid, + .orders-summary-grid { + grid-template-columns: 1fr; + } + + .search-form-actions, + .checkout-form-actions, + .orders-filter-actions, + .cart-actions, + .success-actions { + width: 100%; + } + + .search-form-actions .btn, + .checkout-form-actions .btn, + .orders-filter-actions .btn, + .success-actions .btn { + width: 100%; + } + + .filter-summary-bar, + .orders-filter-bar { + grid-template-columns: 1fr; + } + + .cart-card-left { + flex-direction: column; + } +} + +.checkout-form-grid { + margin-top: 4px; +} + +.field-stack--full { + grid-column: 1 / -1; +} + +.field-textarea--compact { + min-height: 74px; +} + +.checkout-location-card { + margin-top: 4px; +} + +.checkout-shortcuts { + display: grid; + gap: 12px; + margin: 16px 0 6px; +} + +.checkout-shortcuts-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + flex-wrap: wrap; +} + +.checkout-shortcuts-head p { + margin: 4px 0 0; + color: var(--muted); + line-height: 1.6; +} + +.checkout-shortcuts-grid { + display: grid; + gap: 10px; +} + +.checkout-shortcut-card { + display: grid; + gap: 10px; + padding: 14px 16px; + border-radius: 16px; + border: 1px solid var(--border); + background: var(--surface-soft); + color: var(--text); + text-decoration: none; + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease; +} + +.checkout-shortcut-card:hover { + border-color: var(--accent-soft); + background: var(--surface); + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08); + transform: translateY(-1px); +} + +.checkout-shortcut-card.is-selected { + border-color: var(--accent); + background: rgba(40, 116, 240, 0.08); + box-shadow: 0 0 0 1px rgba(40, 116, 240, 0.12); +} + +.checkout-shortcut-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.checkout-shortcut-top strong { + color: var(--text); +} + +.checkout-shortcut-top span, +.checkout-shortcut-card small, +.checkout-shortcut-meta { + color: var(--muted); +} + +.checkout-shortcut-top span { + font-size: 0.82rem; + font-weight: 700; +} + +.checkout-shortcut-card p { + margin: 0; + line-height: 1.6; +} + +.checkout-shortcut-meta { + display: flex; + gap: 8px 16px; + flex-wrap: wrap; + font-size: 0.9rem; +} + +.order-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.order-card-header h2, +.payment-panel-heading h2 { + overflow-wrap: anywhere; +} + +.summary-row { + gap: 12px; +} + +.summary-row strong { + text-align: right; +} + +.payment-mini-card p { + overflow-wrap: anywhere; +} + +@media (max-width: 680px) { + .payment-page, + .order-detail-page, + .cart-page { + gap: 14px; + } + + .payment-panel, + .order-detail-box, + .summary-box, + .order-card, + .cart-card { + padding: 16px; + } + + .order-item-row, + .order-summary-list li, + .summary-row { + flex-direction: column; + align-items: flex-start; + } + + .summary-row strong, + .order-item-row strong { + text-align: left; + } + + .order-item-left { + align-items: flex-start; + } + + .checkout-form-grid { + grid-template-columns: 1fr; + } + + .checkbox-inline--solid { + align-items: flex-start; + } + + .checkout-shortcuts-head, + .checkout-shortcut-top { + flex-direction: column; + align-items: flex-start; + } +} + +@media (max-width: 520px) { + .payment-panel-heading, + .payment-summary-header, + .order-card-header, + .order-actions, + .inline-status-row, + .inline-status-row--spaced { + flex-direction: column; + align-items: flex-start; + } + + .payment-badge, + .status-tag { + max-width: 100%; + } + + .order-card-items { + gap: 8px; + } + + .order-thumb, + .order-item-avatar { + width: 46px; + height: 46px; + } +} diff --git a/myproject/static/css/profile.css b/myproject/static/css/profile.css index 3c63d3a..216a6c1 100644 --- a/myproject/static/css/profile.css +++ b/myproject/static/css/profile.css @@ -3,7 +3,7 @@ } .profile-shell { - max-width: 920px; + max-width: 980px; margin: 0 auto; display: grid; gap: 24px; @@ -25,11 +25,34 @@ .profile-head::before { content: ''; position: absolute; - top: 0; left: 0; right: 0; + top: 0; + left: 0; + right: 0; height: 5px; background: linear-gradient(90deg, var(--accent), var(--accent-2)); } +.profile-head-copy { + display: grid; + gap: 4px; +} + +.profile-readiness { + display: inline-flex; + align-items: center; + gap: 10px; + margin-top: 8px; + padding: 10px 14px; + border-radius: 999px; + background: rgba(40, 116, 240, 0.12); + color: var(--accent); + width: fit-content; +} + +.profile-readiness strong { + font-size: 1rem; +} + .profile-avatar { width: 100px; height: 100px; @@ -60,14 +83,14 @@ .profile-head h1 { font-size: 1.8rem; - margin-bottom: 6px; + margin-bottom: 2px; color: var(--text); } .profile-head p { color: var(--muted); font-size: 1rem; - margin-bottom: 4px; + margin-bottom: 2px; } .profile-stats { @@ -111,19 +134,137 @@ box-shadow: var(--shadow-soft); } +.profile-panel h1, .profile-panel h2 { margin-bottom: 16px; font-size: 1.3rem; color: var(--text); +} + +.profile-panel h2 { border-bottom: 2px solid var(--surface-soft); padding-bottom: 12px; } +.profile-section-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 18px; +} + +.profile-section-head p { + color: var(--muted); + line-height: 1.7; +} + +.profile-delivery-grid { + display: grid; + grid-template-columns: 1.4fr 1fr; + gap: 18px; +} + +.delivery-details-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.delivery-detail-card { + background: var(--surface-soft); + border: 1px solid var(--border); + border-radius: 16px; + padding: 16px; + display: grid; + gap: 8px; +} + +.delivery-detail-card--full { + grid-column: 1 / -1; +} + +.delivery-detail-card span { + color: var(--muted); + font-size: 0.88rem; + font-weight: 700; +} + +.delivery-detail-card strong { + color: var(--text); + line-height: 1.6; +} + +.profile-checklist { + display: grid; + gap: 10px; +} + +.check-row { + padding: 14px 16px; + border-radius: 14px; + border: 1px solid var(--border); + background: var(--surface-soft); + color: var(--muted); + font-weight: 600; +} + +.check-row.is-done { + border-color: rgba(22, 163, 74, 0.28); + background: rgba(22, 163, 74, 0.08); + color: #0f766e; +} + .recent-orders { display: grid; gap: 12px; } +.delivery-shortcuts-list { + display: grid; + gap: 12px; +} + +.delivery-shortcut-card { + display: grid; + gap: 10px; + padding: 16px; + border-radius: 16px; + border: 1px solid var(--border); + background: var(--surface-soft); +} + +.delivery-shortcut-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + flex-wrap: wrap; +} + +.delivery-shortcut-top strong { + color: var(--text); +} + +.delivery-shortcut-top span, +.delivery-shortcut-meta { + color: var(--muted); + font-size: 0.9rem; +} + +.delivery-shortcut-card p { + margin: 0; + line-height: 1.7; + color: var(--text); +} + +.delivery-shortcut-meta { + display: flex; + gap: 8px 16px; + flex-wrap: wrap; +} + .recent-order-row { display: grid; grid-template-columns: 80px minmax(0, 1fr) 110px 130px; @@ -165,8 +306,11 @@ } .profile-edit-form { - max-width: 640px; display: grid; + gap: 18px; +} + +.profile-form-grid { gap: 16px; } @@ -175,6 +319,10 @@ gap: 8px; } +.profile-edit-form .form-row--full { + grid-column: 1 / -1; +} + .profile-edit-form label { font-size: 0.95rem; color: var(--text); @@ -216,16 +364,28 @@ box-shadow: 0 0 0 2px var(--accent-soft); } -.profile-edit-form .form-row:last-child { +.profile-edit-actions { display: flex; + flex-wrap: wrap; gap: 12px; align-items: center; - margin-top: 8px; } -@media (max-width: 900px) { +.field-errors { + color: #b91c1c; + font-size: 0.85rem; +} + +.field-errors ul { + margin: 0; + padding-left: 18px; +} + +@media (max-width: 980px) { + .profile-delivery-grid, + .delivery-details-grid, .recent-order-row { - grid-template-columns: 1fr 1fr; + grid-template-columns: 1fr; } .recent-order-row strong { @@ -245,7 +405,23 @@ padding: 24px; } - .profile-edit-form .form-row:last-child { + .profile-readiness { + margin-left: auto; + margin-right: auto; + } + + .profile-actions .btn, + .profile-edit-actions .btn, + .profile-section-head .btn { + width: 100%; + } + + .delivery-shortcut-top { + flex-direction: column; + align-items: flex-start; + } + + .profile-edit-actions { flex-direction: column; align-items: stretch; } diff --git a/myproject/static/css/style.css b/myproject/static/css/style.css index 61257a6..a2a0746 100644 --- a/myproject/static/css/style.css +++ b/myproject/static/css/style.css @@ -290,3 +290,220 @@ textarea { border-color: var(--accent); outline: none; } + +.section-shell { + max-width: 1180px; + margin: 0 auto 30px; +} + +.page-header--split { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 18px; + flex-wrap: wrap; +} + +.page-header-actions { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.form-control { + width: 100%; + padding: 14px 16px; + border-radius: 14px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.form-group { + display: grid; + gap: 8px; +} + +.form-group--full { + grid-column: 1 / -1; +} + +.form-group label { + font-weight: 700; + color: var(--text); +} + +.form-group input, +.form-group textarea, +.form-group select { + width: 100%; + padding: 14px 16px; + border-radius: 14px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); +} + +.form-group textarea { + min-height: 110px; + resize: vertical; +} + +.form-group input:focus, +.form-group textarea:focus, +.form-group select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(40, 116, 240, 0.1); +} + +.location-card { + background: var(--surface-soft); + border: 1px solid var(--border); + border-radius: 18px; + padding: 16px; + display: grid; + gap: 12px; +} + +.location-card-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + flex-wrap: wrap; +} + +.location-status { + color: var(--muted); + line-height: 1.7; + font-size: 0.95rem; +} + +.location-status.is-success { + color: #0f766e; +} + +.location-status.is-error { + color: #b91c1c; +} + +.checkbox-inline { + display: inline-flex; + align-items: flex-start; + gap: 10px; + cursor: pointer; +} + +.checkbox-inline input { + width: 18px; + height: 18px; + margin-top: 2px; +} + +.checkbox-inline--solid { + padding: 14px 16px; + border-radius: 16px; + border: 1px solid var(--border); + background: var(--surface-soft); + color: var(--text); + font-weight: 600; +} + +.settings-card--wide { + grid-column: 1 / -1; +} + +.settings-action--stretch { + align-items: stretch; +} + +.settings-location-form { + width: 100%; + display: grid; + gap: 16px; +} + +.settings-form-grid { + gap: 16px; +} + +.reveal { + opacity: 0; + transform: translateY(18px); + transition: opacity 0.45s ease, transform 0.45s ease; +} + +.reveal.is-visible { + opacity: 1; + transform: translateY(0); +} + +body.theme-dark .page-header, +body.theme-dark .message, +body.theme-dark .empty-state, +body.theme-dark .settings-card, +body.theme-dark .form-control, +body.theme-dark .form-select, +body.theme-dark .btn-secondary, +body.theme-dark .location-card, +body.theme-dark .checkbox-inline--solid { + background: var(--surface); + border-color: var(--border); + color: var(--text); +} + +body.theme-dark .message.success { + background: rgba(22, 163, 74, 0.12); + border-color: rgba(74, 222, 128, 0.32); + color: #bbf7d0; +} + +body.theme-dark .message.error, +body.theme-dark .message.danger, +body.theme-dark .message.warning { + background: rgba(239, 68, 68, 0.12); + border-color: rgba(248, 113, 113, 0.3); + color: #fecaca; +} + +body.theme-dark .page-header p, +body.theme-dark .settings-card p, +body.theme-dark .settings-header p { + color: var(--muted); +} + +@media (max-width: 768px) { + .page-header-actions { + width: 100%; + } + + .page-header-actions .btn, + .location-card .btn { + width: 100%; + } + + .form-grid, + .settings-grid { + grid-template-columns: 1fr; + } + + .settings-card--wide { + grid-column: auto; + } + + .settings-container { + padding: 16px; + } + + .location-card-head { + flex-direction: column; + } +} diff --git a/myproject/static/js/app.js b/myproject/static/js/app.js index 569884f..a3e5cc8 100644 --- a/myproject/static/js/app.js +++ b/myproject/static/js/app.js @@ -2,12 +2,33 @@ document.addEventListener('DOMContentLoaded', function () { const sidebarToggle = document.querySelector('.sidebar-toggle'); const sidebarOverlay = document.querySelector('.sidebar-overlay'); const sidebarClose = document.querySelector('.sidebar-close'); - const modeToggle = document.querySelector('.mode-toggle'); + const modeToggles = document.querySelectorAll('.mode-toggle'); + const geolocateButtons = document.querySelectorAll('[data-geolocate-btn]'); const body = document.body; const collapseKey = 'hk_nav_collapsed'; const themeKey = 'hk_theme'; + const syncThemeToggleLabels = function (dark) { + modeToggles.forEach(function (toggle) { + const icon = toggle.querySelector('.theme-icon'); + const label = toggle.querySelector('.theme-label'); + const text = dark ? 'Light Mode' : 'Dark Mode'; + + if (icon) { + icon.textContent = dark ? '☀️' : '🌙'; + } + + if (label) { + label.textContent = dark ? 'Light' : 'Dark'; + } else { + toggle.textContent = text; + } + + toggle.setAttribute('aria-label', dark ? 'Switch to light mode' : 'Switch to dark mode'); + }); + }; + const setCollapsed = function (collapsed) { body.classList.toggle('nav-collapsed', collapsed); localStorage.setItem(collapseKey, collapsed ? '1' : '0'); @@ -16,10 +37,7 @@ document.addEventListener('DOMContentLoaded', function () { const setTheme = function (theme) { const dark = theme === 'dark'; body.classList.toggle('theme-dark', dark); - if (modeToggle) { - modeToggle.textContent = dark ? 'Light' : 'Dark'; - modeToggle.setAttribute('aria-label', dark ? 'Switch to light mode' : 'Switch to dark mode'); - } + syncThemeToggleLabels(dark); localStorage.setItem(themeKey, dark ? 'dark' : 'light'); }; @@ -30,9 +48,11 @@ document.addEventListener('DOMContentLoaded', function () { setCollapsed(localStorage.getItem(collapseKey) === '1'); } - if (modeToggle) { - modeToggle.addEventListener('click', function () { - setTheme(body.classList.contains('theme-dark') ? 'light' : 'dark'); + if (modeToggles.length) { + modeToggles.forEach(function (toggle) { + toggle.addEventListener('click', function () { + setTheme(body.classList.contains('theme-dark') ? 'light' : 'dark'); + }); }); } @@ -63,4 +83,195 @@ document.addEventListener('DOMContentLoaded', function () { body.classList.remove('sidebar-open'); } }); + + const revealElements = document.querySelectorAll('.reveal'); + if (revealElements.length) { + if ('IntersectionObserver' in window) { + const observer = new IntersectionObserver( + function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + entry.target.classList.add('is-visible'); + observer.unobserve(entry.target); + } + }); + }, + { + threshold: 0.12, + rootMargin: '0px 0px -40px 0px', + } + ); + + revealElements.forEach(function (element) { + observer.observe(element); + }); + } else { + revealElements.forEach(function (element) { + element.classList.add('is-visible'); + }); + } + } + + const findLocationField = function (container, names) { + for (let index = 0; index < names.length; index += 1) { + const field = container.querySelector('[name="' + names[index] + '"]'); + if (field) { + return field; + } + } + return null; + }; + + const setStatus = function (container, type, message) { + const statusNode = container.querySelector('[data-location-status]'); + if (!statusNode) { + return; + } + statusNode.textContent = message; + statusNode.classList.remove('is-success', 'is-error'); + if (type === 'success') { + statusNode.classList.add('is-success'); + } + if (type === 'error') { + statusNode.classList.add('is-error'); + } + }; + + const setFieldValue = function (field, value, overwriteWhenFilled) { + if (!field) { + return; + } + if (!overwriteWhenFilled && field.value) { + return; + } + field.value = value || ''; + }; + + const buildAddressText = function (address) { + const roadLine = [address.house_number, address.road].filter(Boolean).join(' ').trim(); + const areaLine = [ + address.suburb, + address.neighbourhood, + address.city || address.town || address.village || address.municipality, + address.state, + address.postcode, + address.country, + ] + .filter(Boolean) + .filter(function (value, index, values) { + return values.indexOf(value) === index; + }) + .join(', ') + .trim(); + + return [roadLine, areaLine].filter(Boolean).join('\n').trim(); + }; + + const buildLocationLabel = function (address) { + return [ + address.suburb || address.neighbourhood || address.hamlet, + address.city || address.town || address.village || address.municipality, + address.state, + ] + .filter(Boolean) + .filter(function (value, index, values) { + return values.indexOf(value) === index; + }) + .join(', ') + .trim(); + }; + + const reverseGeocode = async function (latitude, longitude) { + const url = 'https://nominatim.openstreetmap.org/reverse?format=jsonv2&addressdetails=1&lat=' + encodeURIComponent(latitude) + '&lon=' + encodeURIComponent(longitude); + const response = await fetch(url, { + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Reverse geocoding is temporarily unavailable.'); + } + + return response.json(); + }; + + if (geolocateButtons.length) { + geolocateButtons.forEach(function (button) { + button.addEventListener('click', function () { + const container = button.closest('[data-location-form]') || button.closest('form') || document; + const latitudeField = findLocationField(container, ['latitude']); + const longitudeField = findLocationField(container, ['longitude']); + const accuracyField = findLocationField(container, ['location_accuracy_m']); + const labelField = findLocationField(container, ['location_label']); + const addressField = findLocationField(container, ['default_address', 'address']); + + if (!navigator.geolocation) { + setStatus(container, 'error', 'Your browser does not support GPS access. You can still enter your address manually.'); + return; + } + + button.disabled = true; + setStatus(container, '', 'Requesting GPS access from your device…'); + + navigator.geolocation.getCurrentPosition( + async function (position) { + const latitude = Number(position.coords.latitude).toFixed(6); + const longitude = Number(position.coords.longitude).toFixed(6); + const accuracy = Number(position.coords.accuracy || 0).toFixed(2); + + if (latitudeField) { + latitudeField.value = latitude; + } + if (longitudeField) { + longitudeField.value = longitude; + } + if (accuracyField) { + accuracyField.value = accuracy; + } + + try { + const payload = await reverseGeocode(latitude, longitude); + const address = payload.address || {}; + const suggestedAddress = buildAddressText(address); + const locationLabel = buildLocationLabel(address); + + setFieldValue(labelField, locationLabel, false); + setFieldValue(addressField, suggestedAddress || payload.display_name || '', false); + setStatus( + container, + 'success', + 'GPS captured successfully. Accuracy ±' + Math.round(Number(accuracy)) + ' meters. Please review the address before saving.' + ); + } catch (error) { + setStatus( + container, + 'success', + 'GPS captured successfully. Exact coordinates were saved, but the address suggestion could not be loaded right now.' + ); + } finally { + button.disabled = false; + } + }, + function (error) { + let message = 'Unable to get your location. Please enter your address manually.'; + + if (error.code === error.PERMISSION_DENIED) { + message = 'Location permission was denied. You can still continue with a manual address.'; + } else if (error.code === error.TIMEOUT) { + message = 'Location request timed out. Please retry or continue manually.'; + } + + setStatus(container, 'error', message); + button.disabled = false; + }, + { + enableHighAccuracy: true, + timeout: 15000, + maximumAge: 0, + } + ); + }); + }); + } }); diff --git a/myproject/tempelates/accounts/edit_profile.html b/myproject/tempelates/accounts/edit_profile.html index 8d7cd47..cbcf2c0 100644 --- a/myproject/tempelates/accounts/edit_profile.html +++ b/myproject/tempelates/accounts/edit_profile.html @@ -1,44 +1,110 @@ -{% extends 'base.html' %} -{% load static %} - +{% extends 'base.html' %} +{% load static %} + {% block extra_head %} - + +{% endblock %} + +{% block content %} +
+
+
+
+
+

Edit Profile

+

Manage your account info, saved phone number, address, and GPS delivery preference.

+
+ Back to Profile +
+ +
+ {% csrf_token %} + {{ form.latitude }} + {{ form.longitude }} + {{ form.location_accuracy_m }} + + {% if form.non_field_errors %} +
{{ form.non_field_errors }}
+ {% endif %} + +
+
+ + {{ form.first_name }} + {% if form.first_name.errors %}
{{ form.first_name.errors }}
{% endif %} +
+
+ + {{ form.last_name }} + {% if form.last_name.errors %}
{{ form.last_name.errors }}
{% endif %} +
+
+ + {{ form.email }} + {% if form.email.errors %}
{{ form.email.errors }}
{% endif %} +
+
+ + {{ form.phone }} + {% if form.phone.errors %}
{{ form.phone.errors }}
{% endif %} +
+ +
+ + {{ form.location_label }} + {% if form.location_label.errors %}
{{ form.location_label.errors }}
{% else %}Shown in the navbar and used as your quick delivery area.{% endif %} +
+ +
+
+
+
+ GPS delivery pin +

Refresh your exact coordinates when you move or want more accurate delivery support.

+
+ +
+

+ {% if profile.has_precise_location %} + GPS is saved for this account. Update it anytime if your delivery point changes. + {% else %} + No exact GPS saved yet. You can still use a manual address, but GPS improves delivery accuracy. + {% endif %} +

+ {% if profile.location_updated_at %} + Last GPS update: {{ profile.location_updated_at|date:'M d, Y H:i' }} + {% endif %} +
+
+ +
+ + {{ form.default_address }} + {% if form.default_address.errors %}
{{ form.default_address.errors }}
{% endif %} +
+ +
+ + {{ form.bio }} + {% if form.bio.errors %}
{{ form.bio.errors }}
{% endif %} +
+ +
+ + {% if profile.image %} +
avatar
+ {% endif %} + {{ form.image }} + {% if form.image.errors %}
{{ form.image.errors }}
{% endif %} +
+
+ +
+ + Cancel +
+
+
+
+
{% endblock %} - -{% block content %} -
-
-

Edit Profile

-
- {% csrf_token %} -
- - {{ form.first_name }} -
-
- - {{ form.last_name }} -
-
- - {{ form.email }} -
-
- - {% if profile.image %} -
avatar
- {% endif %} - {{ form.image }} -
-
- - {{ form.bio }} -
-
- - Cancel -
-
-
-
-{% endblock %} diff --git a/myproject/tempelates/accounts/login.html b/myproject/tempelates/accounts/login.html index 5785fe6..71e911f 100644 --- a/myproject/tempelates/accounts/login.html +++ b/myproject/tempelates/accounts/login.html @@ -1,47 +1,47 @@ -{% extends 'base.html' %} -{% load static %} - -{% block extra_head %} - - -{% endblock %} - -{% block content %} - -
-
-

Login

- - {% if messages %} - {% for message in messages %} -
{{ message }}
- {% endfor %} - {% endif %} - - {% if error %} -
{{ error }}
- {% endif %} - -
- {% csrf_token %} - -
- - -
- -
- - -
- - -
- - -
-
- +{% extends 'base.html' %} +{% load static %} + +{% block extra_head %} + + +{% endblock %} + +{% block content %} + +
+
+

Login

+ + {% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + + {% if error %} +
{{ error }}
+ {% endif %} + +
+ {% csrf_token %} + +
+ + +
+ +
+ + +
+ + +
+ + +
+
+ {% endblock %} \ No newline at end of file diff --git a/myproject/tempelates/accounts/profile.html b/myproject/tempelates/accounts/profile.html index 685fdc0..e2ee11c 100644 --- a/myproject/tempelates/accounts/profile.html +++ b/myproject/tempelates/accounts/profile.html @@ -2,7 +2,7 @@ {% load static %} {% block extra_head %} - + {% endblock %} {% block content %} @@ -16,12 +16,20 @@
{{ user.username|slice:":1"|upper }}
{% endif %} -
-

{{ user.username }}

+
+

{{ user.get_full_name|default:user.username }}

+

@{{ user.username }}

{{ user.email|default:'No email added yet' }}

+ {% if profile.short_location %} +

📍 {{ profile.short_location }}

+ {% endif %} {% if user.profile.is_seller %}

Seller Account: Active

{% endif %} +
+ {{ profile_completion }}% + checkout-ready account setup +
@@ -34,6 +42,74 @@

{{ user.date_joined|date:'Y' }}

Member Since

+
+
+

Saved Delivery Details

+
+
+ Phone + {{ profile.phone|default:'Not set' }} +
+
+ Quick location + {{ profile.short_location|default:'Not set' }} +
+
+ Default address + {% if profile.formatted_delivery_address %}{{ profile.formatted_delivery_address|linebreaksbr }}{% else %}Not set{% endif %} +
+
+ GPS status + {% if profile.has_precise_location %}Saved{% else %}Manual only{% endif %} +
+
+ Last GPS update + {% if profile.location_updated_at %}{{ profile.location_updated_at|date:'M d, Y H:i' }}{% else %}-{% endif %} +
+
+
+ +
+

What this unlocks

+
+
Email for order/payment communication
+
Phone ready for delivery calls
+
Default address prefilled at checkout
+
Precise GPS support for delivery help
+
+
+
+ +
+
+
+

Recent Delivery Shortcuts

+

These recent delivery points appear as quick-select shortcuts on the checkout page.

+
+ Update Default Address +
+ {% if recent_delivery_points %} +
+ {% for order in recent_delivery_points %} +
+
+ {{ order.location_label|default:order.address|truncatechars:48 }} + Order #{{ order.id }} +
+

{{ order.address|linebreaksbr }}

+
+ {{ order.phone }} + {{ order.created_at|date:'M d, Y' }} + {% if order.has_precise_location %}GPS saved{% else %}Manual{% endif %} +
+
+ {% endfor %} +
+ {% else %} +

Complete a checkout once and your recent delivery points will appear here as reusable shortcuts.

+ {% endif %} +
+

Recent Orders

{% if recent_orders %} @@ -55,6 +131,7 @@ diff --git a/myproject/tempelates/accounts/register.html b/myproject/tempelates/accounts/register.html index 9c0f8d9..b5d8c1f 100644 --- a/myproject/tempelates/accounts/register.html +++ b/myproject/tempelates/accounts/register.html @@ -1,67 +1,124 @@ -{% extends 'base.html' %} -{% load static %} - -{% block extra_head %} - - -{% endblock %} - -{% block content %} - -
-
-

Create Account

- - {% if messages %} - {% for message in messages %} -
{{ message }}
- {% endfor %} - {% endif %} - - {% if error %} -
{{ error }}
- {% endif %} - -
- {% csrf_token %} - -
- - - At least 3 characters -
- -
- - -
- -
- - - At least 6 characters -
- -
- - -
+{% extends 'base.html' %} +{% load static %} -
- - Enable this if you want to sell products on Hamro Karma. -
- - -
- - -
-
- +{% block extra_head %} + + +{% endblock %} + +{% block content %} +
+
+
+

Create Account

+

Set your phone and delivery location now so checkout is faster, more accurate, and GPS-ready later.

+
+ + {% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + + {% if form.non_field_errors %} +
{{ form.non_field_errors }}
+ {% endif %} + +
+ {% csrf_token %} + {{ form.latitude }} + {{ form.longitude }} + {{ form.location_accuracy_m }} + +
+
+ + {{ form.username }} + {% if form.username.errors %}
{{ form.username.errors }}
{% else %}At least 3 characters{% endif %} +
+ +
+ + {{ form.email }} + {% if form.email.errors %}
{{ form.email.errors }}
{% endif %} +
+ +
+ + {{ form.first_name }} + {% if form.first_name.errors %}
{{ form.first_name.errors }}
{% endif %} +
+ +
+ + {{ form.last_name }} + {% if form.last_name.errors %}
{{ form.last_name.errors }}
{% endif %} +
+ +
+ + {{ form.password }} + {% if form.password.errors %}
{{ form.password.errors }}
{% else %}At least 6 characters{% endif %} +
+ +
+ + {{ form.confirm_password }} + {% if form.confirm_password.errors %}
{{ form.confirm_password.errors }}
{% endif %} +
+ +
+ + {{ form.phone }} + {% if form.phone.errors %}
{{ form.phone.errors }}
{% else %}Required for delivery updates and order confirmation.{% endif %} +
+ +
+ + {{ form.location_label }} + {% if form.location_label.errors %}
{{ form.location_label.errors }}
{% else %}City / area / landmark name shown across the app.{% endif %} +
+ +
+
+
+
+ GPS delivery setup +

Allow browser location to capture live coordinates and improve delivery accuracy.

+
+ +
+

+ {% if form.latitude.value and form.longitude.value %} + GPS coordinates are already attached to this account form. + {% else %} + GPS is optional, but recommended for accurate delivery and support. + {% endif %} +

+
+
+ +
+ + {{ form.default_address }} + {% if form.default_address.errors %}
{{ form.default_address.errors }}
{% else %}Required so your first checkout starts prefilled.{% endif %} +
+ +
+ + Enable this if you also want to sell products on Hamro Karma. +
+
+ + +
+ + +
+
{% endblock %} diff --git a/myproject/tempelates/base.html b/myproject/tempelates/base.html index b787654..4281af9 100644 --- a/myproject/tempelates/base.html +++ b/myproject/tempelates/base.html @@ -8,9 +8,9 @@ - - - + + + {% block extra_head %}{% endblock %} @@ -32,7 +32,7 @@ {% include 'includes/footer.html' %} - + diff --git a/myproject/tempelates/cart/cart.html b/myproject/tempelates/cart/cart.html index 3b2783a..94a6c4e 100644 --- a/myproject/tempelates/cart/cart.html +++ b/myproject/tempelates/cart/cart.html @@ -2,22 +2,49 @@ {% load static %} {% block extra_head %} - + {% endblock %} {% block content %} -