Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3811cb2c8d |
Binary file not shown.
Binary file not shown.
@ -54,8 +54,20 @@ INSTALLED_APPS = [
|
|||||||
'django.contrib.messages',
|
'django.contrib.messages',
|
||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
'core',
|
'core',
|
||||||
|
'django_paystack',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
PAYSTACK_PUBLIC_KEY = os.getenv("PAYSTACK_PUBLIC_KEY", "")
|
||||||
|
PAYSTACK_SECRET_KEY = os.getenv("PAYSTACK_SECRET_KEY", "")
|
||||||
|
PAYSTACK_SETTINGS = {
|
||||||
|
'BUTTON_ID': 'paystack-button',
|
||||||
|
'CURRENCY': 'NGN',
|
||||||
|
'BUTTON_CLASS': 'btn btn-primary',
|
||||||
|
}
|
||||||
|
|
||||||
|
LOGIN_URL = 'login'
|
||||||
|
LOGIN_REDIRECT_URL = 'index'
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
@ -78,6 +90,7 @@ TEMPLATES = [
|
|||||||
'django.template.context_processors.request',
|
'django.template.context_processors.request',
|
||||||
'django.contrib.auth.context_processors.auth',
|
'django.contrib.auth.context_processors.auth',
|
||||||
'django.contrib.messages.context_processors.messages',
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
'core.context_processors.cart',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -20,4 +20,5 @@ from django.urls import include, path
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("", include("core.urls")),
|
path("", include("core.urls")),
|
||||||
|
path("", include("core.paystack_urls")),
|
||||||
]
|
]
|
||||||
|
|||||||
Binary file not shown.
BIN
core/__pycache__/context_processors.cpython-311.pyc
Normal file
BIN
core/__pycache__/context_processors.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/paystack_urls.cpython-311.pyc
Normal file
BIN
core/__pycache__/paystack_urls.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/paystack_views.cpython-311.pyc
Normal file
BIN
core/__pycache__/paystack_views.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,3 +1,32 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from .models import Category, Product, Cart, CartItem, Order, OrderItem
|
||||||
|
|
||||||
# Register your models here.
|
@admin.register(Category)
|
||||||
|
class CategoryAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('name', 'slug')
|
||||||
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
|
|
||||||
|
@admin.register(Product)
|
||||||
|
class ProductAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('name', 'category', 'price', 'is_available', 'created_at')
|
||||||
|
list_filter = ('category', 'is_available')
|
||||||
|
list_editable = ('price', 'is_available')
|
||||||
|
|
||||||
|
class CartItemInline(admin.TabularInline):
|
||||||
|
model = CartItem
|
||||||
|
raw_id_fields = ['product']
|
||||||
|
|
||||||
|
@admin.register(Cart)
|
||||||
|
class CartAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('user', 'session_key', 'created_at', 'updated_at')
|
||||||
|
inlines = [CartItemInline]
|
||||||
|
|
||||||
|
class OrderItemInline(admin.TabularInline):
|
||||||
|
model = OrderItem
|
||||||
|
raw_id_fields = ['product']
|
||||||
|
|
||||||
|
@admin.register(Order)
|
||||||
|
class OrderAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('id', 'user', 'is_paid', 'created_at')
|
||||||
|
list_filter = ('is_paid', 'created_at')
|
||||||
|
inlines = [OrderItemInline]
|
||||||
|
|||||||
4
core/context_processors.py
Normal file
4
core/context_processors.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
from .views import _get_cart
|
||||||
|
|
||||||
|
def cart(request):
|
||||||
|
return {'cart': _get_cart(request)}
|
||||||
39
core/migrations/0001_initial.py
Normal file
39
core/migrations/0001_initial.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-13 15:24
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Category',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100, unique=True)),
|
||||||
|
('slug', models.SlugField(blank=True, max_length=100, unique=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name_plural': 'Categories',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Product',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=255)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('price', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||||
|
('is_available', models.BooleanField(default=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='products', to='core.category')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
35
core/migrations/0002_cart_cartitem.py
Normal file
35
core/migrations/0002_cart_cartitem.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-13 15:38
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Cart',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('session_key', models.CharField(blank=True, max_length=40, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CartItem',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('quantity', models.PositiveIntegerField(default=1)),
|
||||||
|
('cart', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='core.cart')),
|
||||||
|
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.product')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
35
core/migrations/0003_order_orderitem.py
Normal file
35
core/migrations/0003_order_orderitem.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-13 15:39
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0002_cart_cartitem'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Order',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('is_paid', models.BooleanField(default=False)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='OrderItem',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('quantity', models.PositiveIntegerField(default=1)),
|
||||||
|
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='core.order')),
|
||||||
|
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.product')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/migrations/__pycache__/0002_cart_cartitem.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0002_cart_cartitem.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/migrations/__pycache__/0003_order_orderitem.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0003_order_orderitem.cpython-311.pyc
Normal file
Binary file not shown.
@ -1,3 +1,82 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.utils.text import slugify
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
# Create your models here.
|
class Category(models.Model):
|
||||||
|
name = models.CharField(max_length=100, unique=True)
|
||||||
|
slug = models.SlugField(max_length=100, unique=True, blank=True)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.slug:
|
||||||
|
self.slug = slugify(self.name)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name_plural = "Categories"
|
||||||
|
|
||||||
|
|
||||||
|
class Product(models.Model):
|
||||||
|
category = models.ForeignKey(Category, related_name='products', on_delete=models.CASCADE)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
price = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
is_available = models.BooleanField(default=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class Cart(models.Model):
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True)
|
||||||
|
session_key = models.CharField(max_length=40, blank=True, null=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.user:
|
||||||
|
return f"Cart for {self.user.username}"
|
||||||
|
return f"Cart (session: {self.session_key})"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_price(self):
|
||||||
|
return sum(item.total_price for item in self.items.all())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_price_in_kobo(self):
|
||||||
|
return int(self.total_price * 100)
|
||||||
|
|
||||||
|
class CartItem(models.Model):
|
||||||
|
cart = models.ForeignKey(Cart, related_name='items', on_delete=models.CASCADE)
|
||||||
|
product = models.ForeignKey(Product, on_delete=models.CASCADE)
|
||||||
|
quantity = models.PositiveIntegerField(default=1)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_price(self):
|
||||||
|
return self.product.price * self.quantity
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.quantity} of {self.product.name}"
|
||||||
|
|
||||||
|
class Order(models.Model):
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
is_paid = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Order {self.id} by {self.user.username}"
|
||||||
|
|
||||||
|
def get_total_price(self):
|
||||||
|
return sum(item.product.price * item.quantity for item in self.items.all())
|
||||||
|
|
||||||
|
class OrderItem(models.Model):
|
||||||
|
order = models.ForeignKey(Order, related_name='items', on_delete=models.CASCADE)
|
||||||
|
product = models.ForeignKey(Product, on_delete=models.CASCADE)
|
||||||
|
quantity = models.PositiveIntegerField(default=1)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.quantity} of {self.product.name}"
|
||||||
10
core/paystack_urls.py
Normal file
10
core/paystack_urls.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
from django.urls import path
|
||||||
|
from django.shortcuts import render
|
||||||
|
from . import paystack_views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('payment/success/<str:reference>/', paystack_views.payment_success, name='payment_success'),
|
||||||
|
path('payment/failure/', paystack_views.payment_failure, name='payment_failure'),
|
||||||
|
path('payment/success_page/', lambda request: render(request, 'core/payment_success.html'), name='payment_success_page'),
|
||||||
|
]
|
||||||
37
core/paystack_views.py
Normal file
37
core/paystack_views.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
|
||||||
|
from django.shortcuts import render, redirect
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from .models import Cart, Order, OrderItem
|
||||||
|
from django_paystack.signals import payment_verified
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def payment_success(request, reference):
|
||||||
|
cart = Cart.objects.get(user=request.user)
|
||||||
|
order = Order.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
total_price=cart.total_price,
|
||||||
|
paid=True,
|
||||||
|
payment_reference=reference
|
||||||
|
)
|
||||||
|
for item in cart.items.all():
|
||||||
|
OrderItem.objects.create(
|
||||||
|
order=order,
|
||||||
|
product=item.product,
|
||||||
|
price=item.product.price,
|
||||||
|
quantity=item.quantity
|
||||||
|
)
|
||||||
|
cart.items.all().delete()
|
||||||
|
return redirect('payment_success_page')
|
||||||
|
|
||||||
|
def payment_failure(request):
|
||||||
|
return render(request, 'core/payment_failure.html')
|
||||||
|
|
||||||
|
@payment_verified.connect
|
||||||
|
def on_payment_verified(sender, ref, amount, **kwargs):
|
||||||
|
"""
|
||||||
|
This signal is called when a payment is successfully verified.
|
||||||
|
"""
|
||||||
|
# You can perform actions like updating order status, sending notifications, etc.
|
||||||
|
print(f"Payment verified for reference: {ref} and amount: {amount}")
|
||||||
|
|
||||||
@ -1,11 +1,64 @@
|
|||||||
|
{% load static %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>{% block title %}Knowledge Base{% endblock %}</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
{% block head %}{% endblock %}
|
<title>{% block title %}VTU Nigeria{% endblock %}</title>
|
||||||
</head>
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<body>
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||||
{% block content %}{% endblock %}
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
</body>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
</html>
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&family=Poppins:wght@700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="{% static 'css/custom.css' %}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="navbar navbar-expand-lg" style="background-color: var(--primary-color);">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="/">VTU NG</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav ms-auto">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link text-white" href="{% url 'cart_detail' %}">
|
||||||
|
<i class="bi bi-cart"></i> Cart
|
||||||
|
<span class="badge bg-secondary">{{ cart.items.all.count }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link text-white" href="#">Dashboard</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link text-white" href="{% url 'logout' %}">Logout</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link text-white" href="{% url 'login' %}">Login</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="{% url 'signup' %}" class="btn btn-primary ms-2">Sign Up</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{% block content %}
|
||||||
|
{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container">
|
||||||
|
<p>© 2025 VTU Nigeria. All Rights Reserved.</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
39
core/templates/core/cart_detail.html
Normal file
39
core/templates/core/cart_detail.html
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<h2>Your Shopping Cart</h2>
|
||||||
|
{% if not cart.items.all %}
|
||||||
|
<p>Your cart is empty.</p>
|
||||||
|
{% else %}
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Product</th>
|
||||||
|
<th>Quantity</th>
|
||||||
|
<th>Price</th>
|
||||||
|
<th>Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in cart.items.all %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.product.name }}</td>
|
||||||
|
<td>{{ item.quantity }}</td>
|
||||||
|
<td>${{ item.product.price }}</td>
|
||||||
|
<td>${{ item.total_price }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="text-right">
|
||||||
|
<h3>Total: ${{ cart.total_price }}</h3>
|
||||||
|
<form action="{% url 'initiate_payment' %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-primary">Pay with Paystack</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
10
core/templates/core/checkout_complete.html
Normal file
10
core/templates/core/checkout_complete.html
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<h2>Checkout Complete</h2>
|
||||||
|
<p>Thank you for your order. Your order number is <strong>{{ order.id }}</strong>.</p>
|
||||||
|
<p>We will process your order shortly.</p>
|
||||||
|
<a href="{% url 'index' %}" class="btn btn-primary">Continue Shopping</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -1,154 +1,43 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{{ project_name }}{% endblock %}
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
{% block head %}
|
{% block title %}Welcome to VTU Nigeria{% endblock %}
|
||||||
{% if project_description %}
|
|
||||||
<meta name="description" content="{{ project_description }}">
|
|
||||||
<meta property="og:description" content="{{ project_description }}">
|
|
||||||
<meta property="twitter:description" content="{{ project_description }}">
|
|
||||||
{% endif %}
|
|
||||||
{% if project_image_url %}
|
|
||||||
<meta property="og:image" content="{{ project_image_url }}">
|
|
||||||
<meta property="twitter:image" content="{{ project_image_url }}">
|
|
||||||
{% endif %}
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg-color-start: #6a11cb;
|
|
||||||
--bg-color-end: #2575fc;
|
|
||||||
--text-color: #ffffff;
|
|
||||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
|
||||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
|
||||||
color: var(--text-color);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
text-align: center;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
body::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
|
|
||||||
animation: bg-pan 20s linear infinite;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bg-pan {
|
|
||||||
0% {
|
|
||||||
background-position: 0% 0%;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
background-position: 100% 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--card-bg-color);
|
|
||||||
border: 1px solid var(--card-border-color);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 2.5rem 2rem;
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0 0 1.2rem;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
opacity: 0.92;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader {
|
|
||||||
margin: 1.5rem auto;
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
border: 4px solid rgba(255, 255, 255, 0.25);
|
|
||||||
border-top-color: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.runtime code {
|
|
||||||
background: rgba(0, 0, 0, 0.25);
|
|
||||||
padding: 0.15rem 0.45rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 1rem;
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main>
|
<div class="hero">
|
||||||
<div class="card">
|
<div class="container">
|
||||||
<h1>Analyzing your requirements and generating your app…</h1>
|
<h1>Fast & Reliable VTU Services</h1>
|
||||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
<p>Your one-stop platform for airtime, data, cable subscriptions, and more.</p>
|
||||||
<span class="sr-only">Loading…</span>
|
<a href="{% url 'signup' %}" class="btn btn-primary btn-lg">Get Started Now</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
|
</div>
|
||||||
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
|
|
||||||
<p class="runtime">
|
<section class="products-section">
|
||||||
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
|
<div class="container">
|
||||||
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
|
<h2 class="text-center mb-5">Our Products</h2>
|
||||||
</p>
|
<div class="row g-4">
|
||||||
</div>
|
{% for product in products %}
|
||||||
</main>
|
<div class="col-md-6 col-lg-4">
|
||||||
<footer>
|
<div class="card h-100">
|
||||||
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
|
<div class="card-body">
|
||||||
</footer>
|
<h5 class="card-title">{{ product.name }}</h5>
|
||||||
{% endblock %}
|
<h6 class="card-subtitle mb-2 text-muted">{{ product.category }}</h6>
|
||||||
|
<p class="card-text">{{ product.description|truncatewords:20 }}</p>
|
||||||
|
<p class="card-text fs-4 fw-bold">₦{{ product.price }}</p>
|
||||||
|
<form action="{% url 'add_to_cart' product.id %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="quantity" value="1">
|
||||||
|
<button type="submit" class="btn btn-primary">Add to Cart</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="col">
|
||||||
|
<p class="text-center">No products available at the moment. Please check back later.</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
8
core/templates/core/payment_failure.html
Normal file
8
core/templates/core/payment_failure.html
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<h2>Payment Failed</h2>
|
||||||
|
<p>There was an error processing your payment. Please try again.</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
9
core/templates/core/payment_success.html
Normal file
9
core/templates/core/payment_success.html
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<h2>Payment Successful</h2>
|
||||||
|
<p>Your order has been placed successfully.</p>
|
||||||
|
<p>Order ID: {{ order.id }}</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
22
core/templates/registration/signup.html
Normal file
22
core/templates/registration/signup.html
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Create an Account{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="form-container">
|
||||||
|
<h2>Create Your Account</h2>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.as_p }}
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">Sign Up</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p class="text-center mt-3">
|
||||||
|
Already have an account? <a href="{% url 'login' %}">Login</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
0
core/templatetags/__init__.py
Normal file
0
core/templatetags/__init__.py
Normal file
BIN
core/templatetags/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
core/templatetags/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/templatetags/__pycache__/cart_tags.cpython-311.pyc
Normal file
BIN
core/templatetags/__pycache__/cart_tags.cpython-311.pyc
Normal file
Binary file not shown.
7
core/templatetags/cart_tags.py
Normal file
7
core/templatetags/cart_tags.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
from django import template
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def mul(value, arg):
|
||||||
|
return value * arg
|
||||||
16
core/urls.py
16
core/urls.py
@ -1,7 +1,13 @@
|
|||||||
from django.urls import path
|
from django.urls import path, include
|
||||||
|
from .views import index, signup, add_to_cart, cart_detail, checkout, initiate_payment, verify_payment
|
||||||
from .views import home
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", home, name="home"),
|
path("", index, name="index"),
|
||||||
]
|
path("signup/", signup, name="signup"),
|
||||||
|
path("accounts/", include("django.contrib.auth.urls")), # for login, logout, etc.
|
||||||
|
path('cart/', cart_detail, name='cart_detail'),
|
||||||
|
path('cart/add/<int:product_id>/', add_to_cart, name='add_to_cart'),
|
||||||
|
path('checkout/', checkout, name='checkout'),
|
||||||
|
path('initiate-payment/', initiate_payment, name='initiate_payment'),
|
||||||
|
path('verify-payment/', verify_payment, name='verify_payment'),
|
||||||
|
]
|
||||||
149
core/views.py
149
core/views.py
@ -1,25 +1,134 @@
|
|||||||
import os
|
from django.shortcuts import render, redirect, get_object_or_404
|
||||||
import platform
|
from django.contrib.auth.forms import UserCreationForm
|
||||||
|
from django.contrib.auth import login
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.conf import settings
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
|
||||||
from django import get_version as django_version
|
from .models import Product, Category, Cart, CartItem, Order, OrderItem
|
||||||
from django.shortcuts import render
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
|
|
||||||
def home(request):
|
|
||||||
"""Render the landing screen with loader and environment details."""
|
|
||||||
host_name = request.get_host().lower()
|
|
||||||
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
|
|
||||||
now = timezone.now()
|
|
||||||
|
|
||||||
|
def index(request):
|
||||||
|
"""Render the new landing page."""
|
||||||
|
products = Product.objects.filter(is_available=True)
|
||||||
|
categories = Category.objects.all()
|
||||||
context = {
|
context = {
|
||||||
"project_name": "New Style",
|
'products': products,
|
||||||
"agent_brand": agent_brand,
|
'categories': categories,
|
||||||
"django_version": django_version(),
|
|
||||||
"python_version": platform.python_version(),
|
|
||||||
"current_time": now,
|
|
||||||
"host_name": host_name,
|
|
||||||
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
|
|
||||||
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
|
|
||||||
}
|
}
|
||||||
return render(request, "core/index.html", context)
|
return render(request, "core/index.html", context)
|
||||||
|
|
||||||
|
def signup(request):
|
||||||
|
"""Handle user signup."""
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = UserCreationForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
user = form.save()
|
||||||
|
# You can log the user in directly if you want
|
||||||
|
# login(request, user)
|
||||||
|
return redirect('login')
|
||||||
|
else:
|
||||||
|
form = UserCreationForm()
|
||||||
|
return render(request, 'registration/signup.html', {'form': form})
|
||||||
|
|
||||||
|
def _get_cart(request):
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
cart, created = Cart.objects.get_or_create(user=request.user)
|
||||||
|
else:
|
||||||
|
session_key = request.session.session_key
|
||||||
|
if not session_key:
|
||||||
|
request.session.create()
|
||||||
|
session_key = request.session.session_key
|
||||||
|
cart, created = Cart.objects.get_or_create(session_key=session_key)
|
||||||
|
return cart
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def add_to_cart(request, product_id):
|
||||||
|
cart = _get_cart(request)
|
||||||
|
product = get_object_or_404(Product, id=product_id)
|
||||||
|
quantity = int(request.POST.get('quantity', 1))
|
||||||
|
|
||||||
|
cart_item, created = CartItem.objects.get_or_create(cart=cart, product=product)
|
||||||
|
if not created:
|
||||||
|
cart_item.quantity += quantity
|
||||||
|
else:
|
||||||
|
cart_item.quantity = quantity
|
||||||
|
cart_item.save()
|
||||||
|
|
||||||
|
return redirect('cart_detail')
|
||||||
|
|
||||||
|
def cart_detail(request):
|
||||||
|
cart = _get_cart(request)
|
||||||
|
return render(request, 'core/cart_detail.html', {'cart': cart})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def checkout(request):
|
||||||
|
cart = _get_cart(request)
|
||||||
|
if not cart.items.all():
|
||||||
|
return redirect('index')
|
||||||
|
return render(request, 'core/cart_detail.html', {'cart': cart})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def initiate_payment(request):
|
||||||
|
cart = _get_cart(request)
|
||||||
|
if not cart.items.all():
|
||||||
|
return redirect('index')
|
||||||
|
|
||||||
|
url = 'https://api.paystack.co/transaction/initialize'
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Bearer {settings.PAYSTACK_SECRET_KEY}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
"email": request.user.email,
|
||||||
|
"amount": cart.total_price_in_kobo,
|
||||||
|
"callback_.pyurl": request.build_absolute_uri(f"/verify-payment/"),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(url, headers=headers, data=json.dumps(data))
|
||||||
|
response_data = response.json()
|
||||||
|
if response_data['status']:
|
||||||
|
# store the reference in the session, so we can retrieve it in the callback
|
||||||
|
request.session['payment_ref'] = response_data['data']['reference']
|
||||||
|
return redirect(response_data['data']['authorization_url'])
|
||||||
|
else:
|
||||||
|
return render(request, 'core/payment_failure.html', {'error': response_data['message']})
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
return render(request, 'core/payment_failure.html', {'error': f"An error occurred: {e}"})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def verify_payment(request):
|
||||||
|
ref = request.GET.get('reference')
|
||||||
|
if not ref:
|
||||||
|
# if the reference is not in the get request, check the session
|
||||||
|
ref = request.session.get('payment_ref')
|
||||||
|
if not ref:
|
||||||
|
return redirect('payment_failure')
|
||||||
|
|
||||||
|
url = f'https://api.paystack.co/transaction/verify/{ref}'
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Bearer {settings.PAYSTACK_SECRET_KEY}',
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
response_data = response.json()
|
||||||
|
if response_data['status']:
|
||||||
|
if response_data['data']['status'] == 'success':
|
||||||
|
cart = _get_cart(request)
|
||||||
|
order = Order.objects.create(user=request.user, is_paid=True)
|
||||||
|
for item in cart.items.all():
|
||||||
|
OrderItem.objects.create(order=order, product=item.product, quantity=item.quantity)
|
||||||
|
cart.items.all().delete()
|
||||||
|
# clear the payment reference from the session
|
||||||
|
if 'payment_ref' in request.session:
|
||||||
|
del request.session['payment_ref']
|
||||||
|
return render(request, 'core/payment_success.html', {'order': order})
|
||||||
|
else:
|
||||||
|
return render(request, 'core/payment_failure.html')
|
||||||
|
else:
|
||||||
|
return render(request, 'core/payment_failure.html')
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
return render(request, 'core/payment_failure.html', {'error': f"An error occurred: {e}"})
|
||||||
@ -1,3 +1,8 @@
|
|||||||
Django==5.2.7
|
Django
|
||||||
|
django-environ
|
||||||
|
gunicorn
|
||||||
|
psycopg2-binary
|
||||||
|
whitenoise
|
||||||
|
django-paystack
|
||||||
mysqlclient==2.2.7
|
mysqlclient==2.2.7
|
||||||
python-dotenv==1.1.1
|
python-dotenv==1.1.1
|
||||||
|
|||||||
107
static/css/custom.css
Normal file
107
static/css/custom.css
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&family=Poppins:wght@700&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary-color: #0D0D2B;
|
||||||
|
--secondary-color: #3671E9;
|
||||||
|
--accent-color: #2B076E;
|
||||||
|
--bg-light: #FFFFFF;
|
||||||
|
--bg-gray: #F8F9FA;
|
||||||
|
--text-light: #FFFFFF;
|
||||||
|
--text-dark: #0D0D2B;
|
||||||
|
--font-headings: 'Poppins', sans-serif;
|
||||||
|
--font-body: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
background-color: var(--bg-light);
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: var(--font-headings);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
border-color: var(--secondary-color);
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #2d62c8;
|
||||||
|
border-color: #2d62c8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-family: var(--font-headings);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--text-light) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
background: linear-gradient(90deg, var(--accent-color) 0%, var(--primary-color) 100%);
|
||||||
|
color: var(--text-light);
|
||||||
|
padding: 100px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.services-section {
|
||||||
|
padding: 80px 0;
|
||||||
|
background-color: var(--bg-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card {
|
||||||
|
background-color: var(--bg-light);
|
||||||
|
border: none;
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.07);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card:hover {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card .icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
color: var(--secondary-color);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container {
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 80px auto;
|
||||||
|
padding: 40px;
|
||||||
|
background-color: var(--bg-light);
|
||||||
|
border-radius: 15px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container h2 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: var(--text-light);
|
||||||
|
padding: 40px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user