MASSIVE UPDATE:

bounty board feature

buyers to see bounty boards

seller profile page (like have theme chooser)

Have the game and set name be filters.

Add cards to vault manually

update card inventory add to have the autocomplete for the card  -

store analytics, clicks, views, link to store (url/QR code)

bulk item inventory creation --

Make the banner feature flag driven so I can have a beta site setup like the primary site

don't use primary key values in urls - update to use uuid4 values

site analytics. tianji is being sent

item potent on the mtg and lorcana populate scripts

Card item images for specific listings

check that when you buy a card it is in the vault

Buys should be able to search on store inventories

More pie charts for the seller!

post bounty board is slow to load

seller reviews/ratings - show a historgram - need a way for someone to rate

Report a seller feature for buyer to report

Make sure the stlying is consistent based on the theme choosen

smart minimum order quantity and shipping amounts (defined by the store itself)

put virtual packs behind a feature flag like bounty board

proxy service feature flag

Terms of Service

new description for TCGKof

store SSN, ITIN, and EIN

optomize for SEO
This commit is contained in:
2026-01-23 12:28:20 -06:00
parent c43603bfb5
commit 9040021d1b
80 changed files with 6938 additions and 592 deletions

View File

@@ -24,8 +24,12 @@ SECRET_KEY = 'django-insecure-y-a6jb6gv)dd!32_c!8rn70cnn%8&_$nc=m)0#4d=%b65%g(0*
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
FEATURE_BOUNTY_BOARD = DEBUG
FEATURE_DEMO_SITE = True
FEATURE_PLAYTEST_PROXY = DEBUG
FEATURE_VIRTUAL_PACKS = DEBUG
ALLOWED_HOSTS = []
ALLOWED_HOSTS = ['testserver', 'localhost', '127.0.0.1']
# Application definition
@@ -45,6 +49,9 @@ INSTALLED_APPS = [
AUTH_USER_MODEL = 'users.User'
LOGOUT_REDIRECT_URL = '/'
LOGIN_REDIRECT_URL = 'users:login_success'
# Stripe Configuration
STRIPE_PUBLISHABLE_KEY = 'pk_test_placeholder'
STRIPE_SECRET_KEY = 'sk_test_placeholder'
@@ -71,7 +78,9 @@ TEMPLATES = [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'django.contrib.messages.context_processors.messages',
'django.template.context_processors.debug',
'store.context_processors.feature_flags',
],
},
},
@@ -126,3 +135,11 @@ USE_TZ = True
# https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = 'static/'
STATICFILES_DIRS = [
BASE_DIR / "static",
]
# Media files (Uploaded by user)
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'

View File

@@ -1,6 +1,6 @@
from django.contrib import admin
from django.urls import path, include
from django.views.generic import RedirectView
from django.views.generic import RedirectView, TemplateView
urlpatterns = [
path('admin/', admin.site.urls),
@@ -9,4 +9,13 @@ urlpatterns = [
path('', include('store.urls')), # Store is the home app
path('home', RedirectView.as_view(url='/', permanent=True), name='home'), # Redirect /home to /
path('decks/', include('decks.urls')),
# SEO
path('robots.txt', TemplateView.as_view(template_name="robots.txt", content_type="text/plain")),
]
from django.conf import settings
from django.conf.urls.static import static
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@@ -1,4 +1,4 @@
# Generated by Django 6.0.1 on 2026-01-19 13:38
# Generated by Django 6.0.1 on 2026-01-21 17:40
from django.db import migrations, models

View File

@@ -1,4 +1,4 @@
# Generated by Django 6.0.1 on 2026-01-19 13:38
# Generated by Django 6.0.1 on 2026-01-21 17:40
import django.db.models.deletion
from django.db import migrations, models

View File

@@ -1,4 +1,4 @@
# Generated by Django 6.0.1 on 2026-01-19 13:38
# Generated by Django 6.0.1 on 2026-01-21 17:40
import django.db.models.deletion
from django.conf import settings

View File

@@ -6,12 +6,15 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"black>=26.1.0",
"bs4>=0.0.2",
"coverage>=7.13.1",
"cryptography>=46.0.3",
"django>=6.0.1",
"faker>=40.1.2",
"pillow>=12.1.0",
"pytest-cov>=7.0.0",
"pytest-django>=4.11.1",
"requests>=2.32.5",
"sitemap>=20191121",
"stripe>=14.2.0",
]

View File

@@ -0,0 +1,383 @@
:root {
--primary-color: #6366f1;
--secondary-color: #a855f7;
--bg-color: #0f172a;
--text-color: #f8fafc;
--card-bg: #1e293b;
--border-color: #334155;
--nav-height: 70px;
/* Semantic colors for theming */
--muted-text-color: #94a3b8;
--input-bg: #0f172a;
--success-color: #34d399;
--info-color: #60a5fa;
--danger-color: #ef4444;
--warning-color: #f59e0b;
}
[data-theme="light"] {
--primary-color: #4f46e5;
--secondary-color: #9333ea;
--bg-color: #f8fafc;
--text-color: #0f172a;
--card-bg: #ffffff;
--border-color: #e2e8f0;
--muted-text-color: #64748b;
--input-bg: #ffffff;
--success-color: #059669;
--info-color: #2563eb;
--danger-color: #dc2626;
--warning-color: #d97706;
}
[data-theme="compact"] {
--primary-color: #2563eb;
--secondary-color: #475569;
--bg-color: #f1f5f9;
--text-color: #1e293b;
--card-bg: #ffffff;
--border-color: #cbd5e1;
--gap-size: 0.5rem;
--muted-text-color: #64748b;
--input-bg: #ffffff;
--success-color: #059669;
--info-color: #2563eb;
--danger-color: #dc2626;
--warning-color: #d97706;
}
[data-theme="expressive"] {
--primary-color: #ec4899;
--secondary-color: #f59e0b;
--bg-color: #2a0a2e;
/* Dark purple */
--text-color: #fdf2f8;
--card-bg: #4a1d4b;
--border-color: #831843;
--muted-text-color: #f9a8d4;
--input-bg: #3b0d40;
--success-color: #34d399;
--info-color: #a78bfa;
--danger-color: #fb7185;
--warning-color: #fbbf24;
font-family: 'Outfit', sans-serif;
}
[data-theme="technical"] {
--primary-color: #10b981;
/* Green */
--secondary-color: #0ea5e9;
/* Blue */
--bg-color: #000000;
--text-color: #d1d5db;
--card-bg: #111827;
--border-color: #374151;
--muted-text-color: #9ca3af;
--input-bg: #0a0a0a;
--success-color: #10b981;
--info-color: #0ea5e9;
--danger-color: #ef4444;
--warning-color: #f59e0b;
font-family: 'JetBrains Mono', monospace;
}
/* Compact specific overrides */
[data-theme="compact"] .card-grid {
gap: 1rem;
}
[data-theme="compact"] .tcg-card-body {
padding: 0.5rem;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
margin: 0;
line-height: 1.5;
transition: background-color 0.3s, color 0.3s;
}
nav {
background-color: var(--card-bg);
border-bottom: 1px solid var(--border-color);
padding: 0 2rem;
height: var(--nav-height);
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 100;
}
.nav-brand {
font-size: 1.5rem;
font-weight: 800;
background: linear-gradient(to right, var(--primary-color), var(--secondary-color));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
text-decoration: none;
}
.nav-links {
display: flex;
align-items: center;
}
.nav-links a {
color: var(--text-color);
text-decoration: none;
margin-left: 1.5rem;
font-weight: 500;
transition: color 0.2s;
}
.nav-links a:hover {
color: var(--primary-color);
}
.container {
max-width: 1200px;
margin: 2rem auto;
padding: 0 1rem;
min-height: 80vh;
}
.btn {
display: inline-block;
padding: 0.5rem 1rem;
background-color: var(--primary-color);
color: white;
text-decoration: none;
border-radius: 0.375rem;
font-weight: 600;
transition: opacity 0.2s;
border: none;
cursor: pointer;
}
.btn:hover {
opacity: 0.9;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 2rem;
}
.tcg-card {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
overflow: hidden;
transition: transform 0.2s;
}
.tcg-card:hover {
transform: translateY(-4px);
}
.tcg-card img {
width: 100%;
height: auto;
display: block;
}
.tcg-card-body {
padding: 1rem;
}
.messages {
list-style: none;
padding: 0;
margin-bottom: 2rem;
}
.messages li {
padding: 1rem;
border-radius: 0.375rem;
margin-bottom: 0.5rem;
}
.messages .success {
background-color: #064e3b;
color: #a7f3d0;
}
.messages .error {
background-color: #7f1d1d;
color: #fecaca;
}
[data-theme="light"] .messages .success {
background-color: #d1fae5;
color: #065f46;
}
[data-theme="light"] .messages .error {
background-color: #fce7f3;
color: #9d174d;
}
/* Auth Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s, visibility 0.3s;
backdrop-filter: blur(4px);
}
.modal-overlay.active {
opacity: 1;
visibility: visible;
}
.auth-modal {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
padding: 2rem;
max-width: 400px;
width: 90%;
text-align: center;
transform: translateY(20px);
transition: transform 0.3s;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.modal-overlay.active .auth-modal {
transform: translateY(0);
}
.auth-modal h2 {
margin-top: 0;
color: var(--primary-color);
font-size: 1.5rem;
margin-bottom: 1rem;
}
.auth-modal p {
margin-bottom: 2rem;
color: var(--text-color);
line-height: 1.6;
}
.auth-modal-actions {
display: flex;
gap: 1rem;
justify-content: center;
}
.btn-outline {
background-color: transparent;
border: 1px solid var(--primary-color);
color: var(--primary-color);
}
.btn-outline:hover {
background-color: rgba(99, 102, 241, 0.1);
}
.close-modal {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
color: var(--text-color);
font-size: 1.5rem;
cursor: pointer;
opacity: 0.5;
padding: 0;
line-height: 1;
}
.close-modal:hover {
opacity: 1;
}
/* Mobile Menu Button */
.mobile-menu-btn {
display: none;
background: none;
border: none;
cursor: pointer;
padding: 0.5rem;
}
.mobile-menu-btn .bar {
display: block;
width: 25px;
height: 3px;
margin: 5px auto;
-webkit-transition: all 0.3s ease-in-out;
transition: all 0.3s ease-in-out;
background-color: var(--text-color);
}
/* Media Queries */
@media (max-width: 768px) {
/* Navigation */
.mobile-menu-btn {
display: block;
}
.nav-links {
position: fixed;
left: -100%;
top: var(--nav-height);
gap: 0;
flex-direction: column;
background-color: var(--card-bg);
width: 100%;
text-align: center;
transition: 0.3s;
border-bottom: 1px solid var(--border-color);
padding-bottom: 1rem;
}
.nav-links.active {
left: 0;
}
.nav-links a {
margin: 1rem 0;
display: block;
}
/* Card Layout - Move filters to top */
.browse-container {
grid-template-columns: 1fr !important;
/* Force single column */
}
.browse-sidebar {
width: 100% !important;
margin-bottom: 1rem;
}
.browse-sidebar form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.browse-sidebar button {
grid-column: span 2;
}
}

View File

@@ -1,3 +1,91 @@
from django.contrib import admin
from .models import Seller, Game, Set, Card, CardListing, PackListing, VirtualPack, Order, OrderItem, Cart, Bounty, VaultItem
# Register your models here.
@admin.register(Seller)
class SellerAdmin(admin.ModelAdmin):
list_display = ['store_name', 'user', 'slug', 'created_at']
search_fields = ['store_name', 'user__username']
@admin.register(Game)
class GameAdmin(admin.ModelAdmin):
list_display = ['name', 'slug']
@admin.register(Set)
class SetAdmin(admin.ModelAdmin):
list_display = ['name', 'game', 'code', 'release_date']
list_select_related = ['game']
search_fields = ['name', 'code']
list_filter = ['game']
class CardListingInline(admin.StackedInline):
model = CardListing
extra = 0
autocomplete_fields = ['seller']
@admin.register(Card)
class CardAdmin(admin.ModelAdmin):
list_display = ['name', 'set', 'rarity', 'collector_number', 'scryfall_id', 'uuid']
list_select_related = ['set', 'set__game']
search_fields = ['name', 'set__name', 'collector_number', 'uuid']
list_filter = ['set__game', 'rarity']
inlines = [CardListingInline]
@admin.register(CardListing)
class CardListingAdmin(admin.ModelAdmin):
list_display = ['card', 'seller', 'condition', 'price', 'status', 'quantity', 'uuid']
list_select_related = ['card', 'card__set', 'seller']
list_filter = ['status', 'condition', 'is_foil']
autocomplete_fields = ['card', 'seller']
@admin.register(PackListing)
class PackListingAdmin(admin.ModelAdmin):
list_display = ['name', 'game', 'seller', 'listing_type', 'price', 'uuid']
list_select_related = ['game', 'seller']
list_filter = ['listing_type', 'game']
autocomplete_fields = ['seller']
@admin.register(VirtualPack)
class VirtualPackAdmin(admin.ModelAdmin):
list_display = ['listing', 'owner', 'status', 'created_at', 'uuid']
list_select_related = ['listing', 'owner', 'owner__user']
list_filter = ['status']
raw_id_fields = ['owner'] # Buyer might not have search_fields set up yet, safer to use raw_id or just autocomplete if Buyer has search
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ['id', 'uuid', 'buyer_info', 'status', 'total_price', 'created_at']
list_select_related = ['buyer', 'buyer__user']
list_filter = ['status', 'created_at']
def buyer_info(self, obj):
return obj.buyer.user.username
buyer_info.short_description = 'Buyer'
@admin.register(OrderItem)
class OrderItemAdmin(admin.ModelAdmin):
list_display = ['order', 'item_description', 'price_at_purchase', 'quantity']
list_select_related = ['order', 'listing', 'listing__card', 'pack_listing']
def item_description(self, obj):
if obj.pack_listing:
return obj.pack_listing.name
return obj.listing.card.name if obj.listing else "Deleted Listing"
@admin.register(Cart)
class CartAdmin(admin.ModelAdmin):
list_display = ['buyer', 'created_at', 'insurance']
list_select_related = ['buyer', 'buyer__user']
@admin.register(Bounty)
class BountyAdmin(admin.ModelAdmin):
list_display = ['card', 'target_price', 'quantity_wanted', 'is_active', 'uuid']
list_select_related = ['card', 'card__set']
autocomplete_fields = ['card']
@admin.register(VaultItem)
class VaultItemAdmin(admin.ModelAdmin):
list_display = ['buyer', 'card', 'quantity', 'added_at']
list_select_related = ['buyer', 'buyer__user', 'card', 'card__set']
autocomplete_fields = ['card']
raw_id_fields = ['buyer']

View File

@@ -0,0 +1,10 @@
from django.conf import settings
def feature_flags(request):
return {
'FEATURE_DEMO_SITE': getattr(settings, 'FEATURE_DEMO_SITE', False),
'FEATURE_BOUNTY_BOARD': getattr(settings, 'FEATURE_BOUNTY_BOARD', False),
'FEATURE_PLAYTEST_PROXY': getattr(settings, 'FEATURE_PLAYTEST_PROXY', False),
'FEATURE_VIRTUAL_PACKS': getattr(settings, 'FEATURE_VIRTUAL_PACKS', False),
'debug': settings.DEBUG,
}

110
store/forms.py Normal file
View File

@@ -0,0 +1,110 @@
from django import forms
from .models import Seller, CardListing, PackListing, Game, Bounty, BountyOffer
from users.models import Profile
class SellerThemeForm(forms.ModelForm):
class Meta:
model = Profile
fields = ['theme_preference']
widgets = {
'theme_preference': forms.Select(attrs={'class': 'form-select'})
}
class SellerRegistrationForm(forms.ModelForm):
class Meta:
model = Seller
fields = ['store_name', 'description', 'contact_email', 'contact_phone', 'business_address']
widgets = {
'description': forms.Textarea(attrs={'rows': 4}),
'business_address': forms.Textarea(attrs={'rows': 3}),
}
class SellerEditForm(forms.ModelForm):
tax_id = forms.CharField(required=False, widget=forms.TextInput(attrs={'type': 'password'}), help_text="SSN, ITIN, or EIN (Stored securely)")
payout_details = forms.CharField(required=False, widget=forms.Textarea(attrs={'rows': 3}), help_text="Bank account or other payout details (Stored securely)")
class Meta:
model = Seller
fields = ['store_name', 'description', 'contact_email', 'contact_phone', 'business_address', 'store_image', 'hero_image', 'minimum_order_amount', 'shipping_cost', 'tax_id', 'payout_details']
widgets = {
'description': forms.Textarea(attrs={'rows': 4}),
'business_address': forms.Textarea(attrs={'rows': 3}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance and self.instance.pk:
self.fields['tax_id'].initial = self.instance.tax_id
self.fields['payout_details'].initial = self.instance.payout_details
def save(self, commit=True):
seller = super().save(commit=False)
seller.tax_id = self.cleaned_data.get('tax_id')
seller.payout_details = self.cleaned_data.get('payout_details')
if commit:
seller.save()
return seller
class CardListingForm(forms.ModelForm):
class Meta:
model = CardListing
fields = ['condition', 'price', 'quantity', 'status', 'image']
# TODO: Add search widget for card selection or filter by game/set
class PackListingForm(forms.ModelForm):
class Meta:
model = PackListing
fields = ['game', 'name', 'listing_type', 'price', 'quantity', 'image_url']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Helper to indicate quantity logic
self.fields['quantity'].help_text = "For Virtual packs, this is automatically updated based on inventory."
class AddCardListingForm(forms.Form):
card_name = forms.CharField(max_length=200, label="Card Name", help_text="Enter the card name.")
game = forms.ModelChoiceField(queryset=Game.objects.all(), empty_label="Select Game")
set_name = forms.CharField(max_length=200, label="Set Name", help_text="Enter the set name (e.g., 'Alpha', 'Base Set').")
collector_number = forms.CharField(max_length=20, required=False, label="Card Number", help_text="The number on the bottom of the card (e.g. '197'). Useful for variants.")
condition = forms.ChoiceField(choices=CardListing.CONDITION_CHOICES)
price = forms.DecimalField(max_digits=10, decimal_places=2)
quantity = forms.IntegerField(min_value=1, initial=1)
image = forms.ImageField(required=False, label="Card Image")
class BountyForm(forms.ModelForm):
card_name = forms.CharField(max_length=200, label="Card Name", help_text="Search for a card...", required=False)
card_id = forms.CharField(widget=forms.HiddenInput(), required=False)
class Meta:
model = Bounty
fields = ['title', 'description', 'target_price', 'quantity_wanted']
widgets = {
'description': forms.Textarea(attrs={'rows': 3}),
}
def clean(self):
cleaned_data = super().clean()
card_id = cleaned_data.get('card_id')
title = cleaned_data.get('title')
card_name = cleaned_data.get('card_name')
if not card_id and not title and not card_name:
raise forms.ValidationError("You must either select a Card or provide a Title.")
return cleaned_data
class BountyOfferForm(forms.ModelForm):
class Meta:
model = BountyOffer
fields = ['price', 'description']
widgets = {
'description': forms.Textarea(attrs={'rows': 3}),
}
class MultipleFileInput(forms.ClearableFileInput):
allow_multiple_selected = True
class BulkListingForm(forms.Form):
csv_file = forms.FileField(label="Upload CSV", help_text="Upload the filled-out template CSV.")
images = forms.FileField(widget=MultipleFileInput(attrs={'multiple': True}), required=False, label="Upload Images", help_text="Select all images referenced in your CSV.")

View File

@@ -73,12 +73,12 @@ class Command(BaseCommand):
card, _ = Card.objects.get_or_create(
set=set_obj,
name=card_data['name'],
collector_number=card_data['collector_number'],
defaults={
'rarity': card_data['rarity'].capitalize(),
'image_url': image,
'scryfall_id': card_data['id'],
'tcgplayer_id': card_data.get('tcgplayer_id'),
'collector_number': card_data['collector_number']
}
)

View File

@@ -0,0 +1,140 @@
import requests
import sys
from django.core.management.base import BaseCommand
from django.utils.dateparse import parse_date
from store.models import Game, Set, Card
class Command(BaseCommand):
help = 'Populates the database with Disney Lorcana sets and cards from Lorcast.'
def add_arguments(self, parser):
parser.add_argument(
'--clear',
action='store_true',
help='Clear existing Disney Lorcana cards and sets before populating.'
)
parser.add_argument(
'--duration',
default='7',
help='Duration in days to look back for new sets. Use "all" to fetch everything. Default is 7 days.'
)
def handle(self, *args, **options):
self.stdout.write(self.style.SUCCESS('Starting Lorcana population...'))
# 1. Ensure Game exists
game, created = Game.objects.get_or_create(
name="Disney Lorcana",
defaults={'slug': 'disney-lorcana'}
)
if created:
self.stdout.write(self.style.SUCCESS(f'Created Game: {game.name}'))
else:
self.stdout.write(f'Found Game: {game.name}')
# Handle --clear
if options['clear']:
self.stdout.write(self.style.WARNING('Clearing existing Lorcana data...'))
Card.objects.filter(set__game=game).delete()
Set.objects.filter(game=game).delete()
self.stdout.write(self.style.SUCCESS('Cleared Lorcana data.'))
# Handle --duration
duration = options['duration']
start_date = None
if duration != 'all':
try:
days = int(duration)
from django.utils import timezone
from datetime import timedelta
start_date = timezone.now().date() - timedelta(days=days)
self.stdout.write(f'Fetching data from the last {days} days (since {start_date})...')
except ValueError:
self.stdout.write(self.style.ERROR('Invalid duration. Must be an integer or "all".'))
return
# 2. Fetch Sets
self.stdout.write('Fetching sets from Lorcast...')
response = requests.get('https://api.lorcast.com/v0/sets')
if response.status_code != 200:
self.stdout.write(self.style.ERROR(f'Failed to fetch sets: {response.status_code}'))
return
sets_data = response.json().get('results', []) # Lorcast returns { results: [...] }
self.stdout.write(f'Found {len(sets_data)} sets. Processing...')
# Iterate through sets
for set_data in sets_data:
release_date = parse_date(set_data.get('released_at')) if set_data.get('released_at') else None
# Update API might return sets that are not in DB, we should add them?
# Start date filter:
# If we are doing a partial update, we only want to PROCESS cards for sets that are new?
# But we update the Set object itself anyway because it is cheap.
set_obj, created = Set.objects.update_or_create(
code=set_data.get('code'),
game=game,
defaults={
'name': set_data.get('name'),
'release_date': release_date,
}
)
# Decide whether to fetch cards for this set
should_fetch_cards = True
if start_date:
# If set is older than start_date, skip fetching cards
if not release_date or release_date < start_date:
should_fetch_cards = False
if not should_fetch_cards:
# self.stdout.write(f' Skipping cards for older set: {set_obj.name}')
continue
# Fetch cards for this set
# GET https://api.lorcast.com/v0/sets/:id/cards
set_id = set_data.get('id')
self.stdout.write(f' Fetching cards for set: {set_obj.name} (ID: {set_id})...')
cards_response = requests.get(f'https://api.lorcast.com/v0/sets/{set_id}/cards')
if cards_response.status_code != 200:
self.stdout.write(self.style.ERROR(f' Failed to fetch cards for set {set_obj.name}'))
continue
cards_data = cards_response.json() # Returns list directly according to docs example
self.stdout.write(f' Found {len(cards_data)} cards. Updating...')
for card_data in cards_data:
# Extract Image URL
image_url = ''
if 'image_uris' in card_data and 'digital' in card_data['image_uris']:
if 'normal' in card_data['image_uris']['digital']:
image_url = card_data['image_uris']['digital']['normal']
# TCGPlayer ID
tcgplayer_id = card_data.get('tcgplayer_id')
lorcast_id = card_data.get('id')
# External URL
external_url = f"https://lorcast.com/cards/{lorcast_id}"
collector_number = card_data.get('collector_number', '')
Card.objects.update_or_create(
scryfall_id=lorcast_id, # Re-using this field for unique ID
defaults={
'set': set_obj,
'name': card_data.get('name'),
'rarity': card_data.get('rarity'),
'image_url': image_url,
'tcgplayer_id': tcgplayer_id,
'collector_number': collector_number,
'external_url': external_url,
}
)
self.stdout.write(self.style.SUCCESS(f'Finished Lorcana population!'))

View File

@@ -0,0 +1,231 @@
import requests
import sys
from django.core.management.base import BaseCommand
from django.utils.dateparse import parse_date
from store.models import Game, Set, Card
class Command(BaseCommand):
help = 'Populates the database with Magic: The Gathering sets and cards from Scryfall.'
def add_arguments(self, parser):
parser.add_argument(
'--clear',
action='store_true',
help='Clear existing Magic: The Gathering cards and sets before populating.'
)
parser.add_argument(
'--duration',
default='7',
help='Duration in days to look back for new cards/sets. Use "all" to fetch everything. Default is 7 days.'
)
def handle(self, *args, **options):
self.stdout.write(self.style.SUCCESS('Starting MTG population...'))
# 1. Ensure Game exists
game, created = Game.objects.get_or_create(
name="Magic: The Gathering",
defaults={'slug': 'magic-the-gathering'}
)
if created:
self.stdout.write(self.style.SUCCESS(f'Created Game: {game.name}'))
else:
self.stdout.write(f'Found Game: {game.name}')
# Handle --clear
if options['clear']:
self.stdout.write(self.style.WARNING('Clearing existing MTG data...'))
Card.objects.filter(set__game=game).delete()
Set.objects.filter(game=game).delete()
self.stdout.write(self.style.SUCCESS('Cleared MTG data.'))
# Handle --duration
duration = options['duration']
start_date = None
if duration != 'all':
try:
days = int(duration)
from django.utils import timezone
from datetime import timedelta
start_date = timezone.now().date() - timedelta(days=days)
self.stdout.write(f'Fetching data from the last {days} days (since {start_date})...')
except ValueError:
self.stdout.write(self.style.ERROR('Invalid duration. Must be an integer or "all".'))
return
# 2. Fetch Sets
self.stdout.write('Fetching sets from Scryfall...')
response = requests.get('https://api.scryfall.com/sets')
if response.status_code != 200:
self.stdout.write(self.style.ERROR('Failed to fetch sets'))
return
sets_data = response.json().get('data', [])
self.stdout.write(f'Found {len(sets_data)} sets total. Filtering and processing...')
sets_processed = 0
for set_data in sets_data:
release_date = parse_date(set_data.get('released_at')) if set_data.get('released_at') else None
# If start_date is set, skip sets older than start_date
# Note: Scryfall API doesn't allow filtering sets by date in the list endpoint efficienty, so we filter here.
# However, cards might be added to old sets? Scryfall cards usually are released with sets.
# But if we are doing a partial update, we only care about sets released recently?
# Or should we check all sets? The user requirement implies "grabs the data from the past X days".
# Safest is to update all sets (lightweight) or just new ones.
# Let's update all sets if "all", or filter if "duration".
# Actually, updating Sets is fast. Let's just update all sets regardless of duration to be safe,
# unless clearing.
# WAIT, if we only want "latest set", we should filter.
# User said: "If we can go based off of duration then we can do sets too. and only grab that latests set."
# So let's filter sets by date if duration is set.
if start_date and release_date and release_date < start_date:
continue
set_obj, created = Set.objects.update_or_create(
code=set_data.get('code'),
game=game,
defaults={
'name': set_data.get('name'),
'release_date': release_date,
}
)
sets_processed += 1
self.stdout.write(self.style.SUCCESS(f'Processed {sets_processed} sets.'))
# 3. Fetch Cards
if duration == 'all':
self.fetch_bulk_data(game)
else:
self.fetch_recent_cards(game, start_date)
self.stdout.write(self.style.SUCCESS('Finished MTG population!'))
def fetch_bulk_data(self, game):
self.stdout.write('Fetching Bulk Data info (Full Update)...')
response = requests.get('https://api.scryfall.com/bulk-data')
if response.status_code != 200:
self.stdout.write(self.style.ERROR('Failed to fetch bulk data info'))
return
bulk_data_list = response.json().get('data', [])
download_uri = None
for item in bulk_data_list:
if item.get('type') == 'default_cards':
download_uri = item.get('download_uri')
break
if not download_uri:
self.stdout.write(self.style.ERROR('Could not find "default_cards" bulk data.'))
return
self.stdout.write(f'Downloading card data from {download_uri} ...')
with requests.get(download_uri, stream=True) as r:
r.raise_for_status()
try:
cards_data = r.json()
except Exception as e:
self.stdout.write(self.style.ERROR(f'Failed to load JSON: {e}'))
return
self.process_cards(cards_data, game)
def fetch_recent_cards(self, game, start_date):
self.stdout.write(f'Fetching cards released since {start_date}...')
# Use Search API
# date>=YYYY-MM-DD
query = f"date>={start_date.isoformat()}"
url = "https://api.scryfall.com/cards/search"
params = {'q': query, 'order': 'released'}
has_more = True
next_url = url
total_processed = 0
while has_more:
self.stdout.write(f' Requesting: {next_url} (params: {params})')
response = requests.get(next_url, params=params if next_url == url else None)
if response.status_code != 200:
self.stdout.write(self.style.ERROR(f'Failed to search cards: {response.text}'))
return
data = response.json()
cards_data = data.get('data', [])
self.process_cards(cards_data, game, verbose=False)
total_processed += len(cards_data)
has_more = data.get('has_more', False)
next_url = data.get('next_page')
# Scryfall requests being nice
import time
time.sleep(0.1)
self.stdout.write(f'fetched {total_processed} cards via search.')
def process_cards(self, cards_data, game, verbose=True):
if verbose:
self.stdout.write(f'Processing {len(cards_data)} cards...')
# Cache sets
sets_map = {s.code: s for s in Set.objects.filter(game=game)}
count = 0
for card_data in cards_data:
set_code = card_data.get('set')
if set_code not in sets_map:
# If we filtered sets by date, we might miss the set for this card if the card is newer than the set release?
# (e.g. late additions).
# Or if we have a partial set update but the card belongs to an old set (reprints in new product?)
# If set is not in DB, we skip or fetch it?
# Given we updated sets based on duration, if the set isn't there, we probably shouldn't add the card
# OR we should lazily create the set.
# For safety, let's try to get the set from DB again or skip.
# If we skipped the set because of date, we probably shouldn't double guess ourselves.
continue
set_obj = sets_map[set_code]
# Extract Image URL
image_url = ''
if 'image_uris' in card_data and 'normal' in card_data['image_uris']:
image_url = card_data['image_uris']['normal']
elif 'card_faces' in card_data and card_data['card_faces'] and 'image_uris' in card_data['card_faces'][0]:
if 'normal' in card_data['card_faces'][0]['image_uris']:
image_url = card_data['card_faces'][0]['image_uris']['normal']
# TCGPlayer ID
tcgplayer_id = card_data.get('tcgplayer_id')
# Scryfall ID
scryfall_id = card_data.get('id')
# External URL (Scryfall URI)
external_url = card_data.get('scryfall_uri', '')
# Collector Number
collector_number = card_data.get('collector_number', '')
Card.objects.update_or_create(
scryfall_id=scryfall_id,
defaults={
'set': set_obj,
'name': card_data.get('name'),
'rarity': card_data.get('rarity'),
'image_url': image_url,
'tcgplayer_id': tcgplayer_id,
'collector_number': collector_number,
'external_url': external_url,
}
)
count += 1
if verbose and count % 1000 == 0:
self.stdout.write(f' Processed {count}...')
if verbose:
self.stdout.write(f'Batch processed {count} cards.')

View File

@@ -1,4 +1,4 @@
# Generated by Django 6.0.1 on 2026-01-19 13:38
# Generated by Django 6.0.1 on 2026-01-21 17:40
import django.db.models.deletion
from django.db import migrations, models
@@ -29,6 +29,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('insurance', models.BooleanField(default=False)),
],
),
migrations.CreateModel(
@@ -56,6 +57,8 @@ class Migration(migrations.Migration):
('total_price', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
('stripe_payment_intent', models.CharField(blank=True, max_length=100)),
('shipping_address', models.TextField(blank=True)),
('insurance_purchased', models.BooleanField(default=False)),
('proxy_service', models.BooleanField(default=False)),
],
),
migrations.CreateModel(
@@ -66,6 +69,28 @@ class Migration(migrations.Migration):
('quantity', models.PositiveIntegerField(default=1)),
],
),
migrations.CreateModel(
name='PackListing',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('price', models.DecimalField(decimal_places=2, max_digits=10)),
('image_url', models.URLField(blank=True, max_length=500)),
],
),
migrations.CreateModel(
name='Seller',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('store_name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(unique=True)),
('description', models.TextField(blank=True)),
('contact_email', models.EmailField(blank=True, max_length=254)),
('contact_phone', models.CharField(blank=True, max_length=20)),
('business_address', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='Set',
fields=[
@@ -75,6 +100,33 @@ class Migration(migrations.Migration):
('release_date', models.DateField(blank=True, null=True)),
],
),
migrations.CreateModel(
name='VaultItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1)),
('added_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='VirtualPack',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('sealed', 'Sealed'), ('opened', 'Opened')], default='sealed', max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='Bounty',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('target_price', models.DecimalField(decimal_places=2, max_digits=10)),
('quantity_wanted', models.PositiveIntegerField(default=1)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bounties', to='store.card')),
],
),
migrations.CreateModel(
name='CardListing',
fields=[

View File

@@ -1,4 +1,4 @@
# Generated by Django 6.0.1 on 2026-01-19 13:38
# Generated by Django 6.0.1 on 2026-01-21 17:40
import django.db.models.deletion
from django.conf import settings
@@ -11,14 +11,15 @@ class Migration(migrations.Migration):
dependencies = [
('store', '0001_initial'),
('users', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='cart',
name='user',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cart', to=settings.AUTH_USER_MODEL),
name='buyer',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cart', to='users.buyer'),
),
migrations.AddField(
model_name='cartitem',
@@ -28,23 +29,53 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='cartitem',
name='listing',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='store.cardlisting'),
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='store.cardlisting'),
),
migrations.AddField(
model_name='order',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to=settings.AUTH_USER_MODEL),
name='buyer',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='users.buyer'),
),
migrations.AddField(
model_name='orderitem',
name='listing',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.cardlisting'),
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.cardlisting'),
),
migrations.AddField(
model_name='orderitem',
name='order',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='store.order'),
),
migrations.AddField(
model_name='packlisting',
name='game',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pack_listings', to='store.game'),
),
migrations.AddField(
model_name='orderitem',
name='pack_listing',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.packlisting'),
),
migrations.AddField(
model_name='cartitem',
name='pack_listing',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='store.packlisting'),
),
migrations.AddField(
model_name='seller',
name='user',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='seller_profile', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='packlisting',
name='seller',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='pack_listings', to='store.seller'),
),
migrations.AddField(
model_name='cardlisting',
name='seller',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='card_listings', to='store.seller'),
),
migrations.AddField(
model_name='set',
name='game',
@@ -55,4 +86,33 @@ class Migration(migrations.Migration):
name='set',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cards', to='store.set'),
),
migrations.AddField(
model_name='vaultitem',
name='buyer',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vault_items', to='users.buyer'),
),
migrations.AddField(
model_name='vaultitem',
name='card',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vault_items', to='store.card'),
),
migrations.AddField(
model_name='virtualpack',
name='cards',
field=models.ManyToManyField(related_name='packs', to='store.card'),
),
migrations.AddField(
model_name='virtualpack',
name='listing',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='packs', to='store.packlisting'),
),
migrations.AddField(
model_name='virtualpack',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='packs', to='users.buyer'),
),
migrations.AlterUniqueTogether(
name='vaultitem',
unique_together={('buyer', 'card')},
),
]

View File

@@ -1,40 +0,0 @@
# Generated by Django 6.0.1 on 2026-01-19 16:22
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0002_initial'),
]
operations = [
migrations.AddField(
model_name='cart',
name='insurance',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='order',
name='insurance_purchased',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='order',
name='proxy_service',
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name='Bounty',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('target_price', models.DecimalField(decimal_places=2, max_digits=10)),
('quantity_wanted', models.PositiveIntegerField(default=1)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bounties', to='store.card')),
],
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-01-21 18:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0002_initial'),
]
operations = [
migrations.AddField(
model_name='packlisting',
name='quantity',
field=models.PositiveIntegerField(default=0),
),
]

View File

@@ -1,57 +0,0 @@
# Generated by Django 6.0.1 on 2026-01-19 18:44
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0003_cart_insurance_order_insurance_purchased_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='cartitem',
name='listing',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='store.cardlisting'),
),
migrations.AlterField(
model_name='orderitem',
name='listing',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.cardlisting'),
),
migrations.CreateModel(
name='PackListing',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('price', models.DecimalField(decimal_places=2, max_digits=10)),
('image_url', models.URLField(blank=True, max_length=500)),
('game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pack_listings', to='store.game')),
],
),
migrations.AddField(
model_name='cartitem',
name='pack_listing',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='store.packlisting'),
),
migrations.AddField(
model_name='orderitem',
name='pack_listing',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.packlisting'),
),
migrations.CreateModel(
name='VirtualPack',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('sealed', 'Sealed'), ('opened', 'Opened')], default='sealed', max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
('cards', models.ManyToManyField(related_name='packs', to='store.card')),
('listing', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='packs', to='store.packlisting')),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='packs', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-01-21 19:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0003_packlisting_quantity'),
]
operations = [
migrations.AddField(
model_name='packlisting',
name='listing_type',
field=models.CharField(choices=[('physical', 'Physical (Shipped)'), ('virtual', 'Virtual (Open on Store)')], default='physical', max_length=10),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-01-21 20:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0004_packlisting_listing_type'),
]
operations = [
migrations.AddField(
model_name='cardlisting',
name='image',
field=models.ImageField(blank=True, null=True, upload_to='listing_images/'),
),
migrations.AddField(
model_name='cardlisting',
name='status',
field=models.CharField(choices=[('listed', 'Listed'), ('sold', 'Sold'), ('off_market', 'Off Market')], default='listed', max_length=20),
),
]

View File

@@ -1,29 +0,0 @@
# Generated by Django 6.0.1 on 2026-01-19 19:14
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0004_alter_cartitem_listing_alter_orderitem_listing_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='VaultItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1)),
('added_at', models.DateTimeField(auto_now_add=True)),
('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vault_items', to='store.card')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vault_items', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'card')},
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-01-21 20:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0005_cardlisting_image_cardlisting_status'),
]
operations = [
migrations.AddField(
model_name='card',
name='external_url',
field=models.URLField(blank=True, help_text='Link to official card page (e.g. Scryfall, Lorcast)', max_length=500),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-01-22 13:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0006_card_external_url'),
]
operations = [
migrations.AddField(
model_name='seller',
name='hero_image',
field=models.ImageField(blank=True, null=True, upload_to='seller_hero_images/'),
),
migrations.AddField(
model_name='seller',
name='store_image',
field=models.ImageField(blank=True, null=True, upload_to='seller_images/'),
),
]

View File

@@ -0,0 +1,47 @@
# Generated by Django 6.0.1 on 2026-01-22 15:02
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0007_seller_hero_image_seller_store_image'),
('users', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='bounty',
name='description',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='bounty',
name='seller',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='bounties', to='store.seller'),
),
migrations.AddField(
model_name='bounty',
name='title',
field=models.CharField(blank=True, help_text='Required if no card selected', max_length=200),
),
migrations.AlterField(
model_name='bounty',
name='card',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bounties', to='store.card'),
),
migrations.CreateModel(
name='BountyOffer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('price', models.DecimalField(decimal_places=2, max_digits=10)),
('description', models.TextField(blank=True, help_text='Details about what you are offering (e.g. card condition)')),
('status', models.CharField(choices=[('pending', 'Pending'), ('accepted', 'Accepted'), ('rejected', 'Rejected'), ('countered', 'Countered')], default='pending', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('bounty', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='offers', to='store.bounty')),
('buyer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bounty_offers', to='users.buyer')),
],
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-01-23 12:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0008_bounty_description_bounty_seller_bounty_title_and_more'),
]
operations = [
migrations.AddField(
model_name='seller',
name='listing_clicks',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='seller',
name='store_views',
field=models.PositiveIntegerField(default=0),
),
]

View File

@@ -0,0 +1,58 @@
# Generated by Django 6.0.1 on 2026-01-23 12:48
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("store", "0009_seller_listing_clicks_seller_store_views"),
]
operations = [
migrations.RemoveField(
model_name="cartitem",
name="pack_listing",
),
migrations.AddField(
model_name="bounty",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, editable=False, null=True),
),
migrations.AddField(
model_name="bountyoffer",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, editable=False, null=True),
),
migrations.AddField(
model_name="card",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, editable=False, null=True),
),
migrations.AddField(
model_name="cardlisting",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, editable=False, null=True),
),
migrations.AddField(
model_name="cartitem",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, editable=False, null=True),
),
migrations.AddField(
model_name="order",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, editable=False, null=True),
),
migrations.AddField(
model_name="packlisting",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, editable=False, null=True),
),
migrations.AddField(
model_name="virtualpack",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, editable=False, null=True),
),
]

View File

@@ -0,0 +1,74 @@
# Generated by Django 6.0.1 on 2026-01-23 12:48
import uuid
from django.db import migrations, models
def populate_uuids(apps, schema_editor):
models_to_update = [
"Bounty",
"BountyOffer",
"Card",
"CardListing",
"CartItem",
"Order",
"PackListing",
"VirtualPack",
]
for model_name in models_to_update:
try:
Model = apps.get_model("store", model_name)
for obj in Model.objects.all():
obj.uuid = uuid.uuid4()
obj.save()
except LookupError:
pass
class Migration(migrations.Migration):
dependencies = [
("store", "0010_remove_cartitem_pack_listing_bounty_uuid_and_more"),
]
operations = [
migrations.RunPython(populate_uuids, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name="bounty",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
migrations.AlterField(
model_name="bountyoffer",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
migrations.AlterField(
model_name="card",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
migrations.AlterField(
model_name="cardlisting",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
migrations.AlterField(
model_name="cartitem",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
migrations.AlterField(
model_name="order",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
migrations.AlterField(
model_name="packlisting",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
migrations.AlterField(
model_name="virtualpack",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 6.0.1 on 2026-01-23 15:49
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("store", "0011_alter_bounty_uuid_alter_bountyoffer_uuid_and_more"),
]
operations = [
migrations.AddField(
model_name="cartitem",
name="pack_listing",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="store.packlisting",
),
),
]

View File

@@ -0,0 +1,38 @@
# Generated by Django 6.0.1 on 2026-01-23 16:16
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("store", "0012_cartitem_pack_listing"),
]
operations = [
migrations.AddField(
model_name="order",
name="rating",
field=models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(5),
],
),
),
migrations.AddField(
model_name="order",
name="seller",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="orders",
to="store.seller",
),
),
]

View File

@@ -0,0 +1,59 @@
# Generated by Django 6.0.1 on 2026-01-23 16:34
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("store", "0013_order_rating_order_seller"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="SellerReport",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"reason",
models.CharField(
choices=[
("explicit", "Explicit/NSFW Content"),
("scam", "Scam"),
("other", "Other"),
],
max_length=20,
),
),
("details", models.TextField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"reporter",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="seller_reports",
to=settings.AUTH_USER_MODEL,
),
),
(
"seller",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="reports",
to="store.seller",
),
),
],
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-01-23 17:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("store", "0014_sellerreport"),
]
operations = [
migrations.AddField(
model_name="seller",
name="minimum_order_amount",
field=models.DecimalField(decimal_places=2, default=0, max_digits=10),
),
migrations.AddField(
model_name="seller",
name="shipping_cost",
field=models.DecimalField(decimal_places=2, default=0, max_digits=10),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-01-23 17:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("store", "0015_seller_minimum_order_amount_seller_shipping_cost"),
]
operations = [
migrations.AddField(
model_name="seller",
name="payout_details_encrypted",
field=models.BinaryField(blank=True, null=True),
),
migrations.AddField(
model_name="seller",
name="tax_id_encrypted",
field=models.BinaryField(blank=True, null=True),
),
]

View File

@@ -1,5 +1,7 @@
from django.db import models
from django.core.validators import MinValueValidator, MaxValueValidator
from django.conf import settings
import uuid
class Game(models.Model):
name = models.CharField(max_length=100, unique=True)
@@ -17,6 +19,48 @@ class Set(models.Model):
def __str__(self):
return f"{self.game.name} - {self.name}"
class Seller(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='seller_profile')
store_name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(unique=True)
description = models.TextField(blank=True)
store_image = models.ImageField(upload_to='seller_images/', blank=True, null=True)
hero_image = models.ImageField(upload_to='seller_hero_images/', blank=True, null=True)
contact_email = models.EmailField(blank=True)
contact_phone = models.CharField(max_length=20, blank=True)
business_address = models.TextField(blank=True)
store_views = models.PositiveIntegerField(default=0)
listing_clicks = models.PositiveIntegerField(default=0)
minimum_order_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0)
shipping_cost = models.DecimalField(max_digits=10, decimal_places=2, default=0)
created_at = models.DateTimeField(auto_now_add=True)
tax_id_encrypted = models.BinaryField(blank=True, null=True)
payout_details_encrypted = models.BinaryField(blank=True, null=True)
@property
def tax_id(self):
from .utils import Encryptor
return Encryptor.decrypt(self.tax_id_encrypted)
@tax_id.setter
def tax_id(self, value):
from .utils import Encryptor
self.tax_id_encrypted = Encryptor.encrypt(value)
@property
def payout_details(self):
from .utils import Encryptor
return Encryptor.decrypt(self.payout_details_encrypted)
@payout_details.setter
def payout_details(self, value):
from .utils import Encryptor
self.payout_details_encrypted = Encryptor.encrypt(value)
def __str__(self):
return self.store_name
class Card(models.Model):
set = models.ForeignKey(Set, on_delete=models.CASCADE, related_name='cards')
name = models.CharField(max_length=200)
@@ -25,10 +69,13 @@ class Card(models.Model):
scryfall_id = models.CharField(max_length=100, blank=True, null=True)
tcgplayer_id = models.CharField(max_length=100, blank=True, null=True)
collector_number = models.CharField(max_length=50, blank=True)
external_url = models.URLField(max_length=500, blank=True, help_text="Link to official card page (e.g. Scryfall, Lorcast)")
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
def __str__(self):
return self.name
class CardListing(models.Model):
CONDITION_CHOICES = (
('NM', 'Near Mint'),
@@ -37,21 +84,42 @@ class CardListing(models.Model):
('HP', 'Heavily Played'),
)
STATUS_CHOICES = (
('listed', 'Listed'),
('sold', 'Sold'),
('off_market', 'Off Market'),
)
card = models.ForeignKey(Card, on_delete=models.CASCADE, related_name='listings')
seller = models.ForeignKey(Seller, on_delete=models.CASCADE, related_name='card_listings', null=True, blank=True)
condition = models.CharField(max_length=2, choices=CONDITION_CHOICES, default='NM')
price = models.DecimalField(max_digits=10, decimal_places=2)
quantity = models.PositiveIntegerField(default=0)
market_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
is_foil = models.BooleanField(default=False)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='listed')
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='listed')
image = models.ImageField(upload_to='listing_images/', blank=True, null=True)
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
def __str__(self):
return f"{self.card.name} ({self.condition}) - ${self.price}"
return f"{self.card.name} ({self.condition}) - ${self.price} [{self.status}]"
class PackListing(models.Model):
LISTING_TYPE_CHOICES = (
('physical', 'Physical (Shipped)'),
('virtual', 'Virtual (Open on Store)'),
)
game = models.ForeignKey(Game, on_delete=models.CASCADE, related_name='pack_listings')
seller = models.ForeignKey(Seller, on_delete=models.CASCADE, related_name='pack_listings', null=True, blank=True)
name = models.CharField(max_length=200)
listing_type = models.CharField(max_length=10, choices=LISTING_TYPE_CHOICES, default='physical')
price = models.DecimalField(max_digits=10, decimal_places=2)
quantity = models.PositiveIntegerField(default=0)
quantity = models.PositiveIntegerField(default=0)
image_url = models.URLField(max_length=500, blank=True)
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
def __str__(self):
return f"{self.name} - ${self.price}"
@@ -64,8 +132,9 @@ class VirtualPack(models.Model):
listing = models.ForeignKey(PackListing, on_delete=models.CASCADE, related_name='packs')
cards = models.ManyToManyField(Card, related_name='packs')
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='sealed')
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='packs')
owner = models.ForeignKey('users.Buyer', on_delete=models.SET_NULL, null=True, blank=True, related_name='packs')
created_at = models.DateTimeField(auto_now_add=True)
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
def __str__(self):
return f"{self.listing.name} ({self.get_status_display()})"
@@ -78,7 +147,7 @@ class Order(models.Model):
('cancelled', 'Cancelled'),
)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='orders')
buyer = models.ForeignKey('users.Buyer', on_delete=models.CASCADE, related_name='orders')
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -87,9 +156,12 @@ class Order(models.Model):
shipping_address = models.TextField(blank=True)
insurance_purchased = models.BooleanField(default=False)
proxy_service = models.BooleanField(default=False)
seller = models.ForeignKey(Seller, on_delete=models.SET_NULL, related_name='orders', null=True, blank=True)
rating = models.PositiveSmallIntegerField(null=True, blank=True, validators=[MinValueValidator(1), MaxValueValidator(5)])
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
def __str__(self):
return f"Order #{self.id} - {self.user.username}"
return f"Order #{self.id} - {self.buyer.user.username}"
class OrderItem(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items')
@@ -104,7 +176,7 @@ class OrderItem(models.Model):
return f"{self.quantity}x {self.listing.card.name if self.listing else 'Deleted Listing'}"
class Cart(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='cart', null=True, blank=True)
buyer = models.OneToOneField('users.Buyer', on_delete=models.CASCADE, related_name='cart', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
insurance = models.BooleanField(default=False)
@@ -120,6 +192,7 @@ class CartItem(models.Model):
listing = models.ForeignKey(CardListing, on_delete=models.CASCADE, null=True, blank=True)
pack_listing = models.ForeignKey(PackListing, on_delete=models.CASCADE, null=True, blank=True)
quantity = models.PositiveIntegerField(default=1)
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
@property
def total_price(self):
@@ -130,23 +203,69 @@ class CartItem(models.Model):
return 0
class Bounty(models.Model):
card = models.ForeignKey(Card, on_delete=models.CASCADE, related_name='bounties')
seller = models.ForeignKey(Seller, on_delete=models.CASCADE, related_name='bounties', null=True)
card = models.ForeignKey(Card, on_delete=models.SET_NULL, related_name='bounties', null=True, blank=True)
title = models.CharField(max_length=200, blank=True, help_text="Required if no card selected")
description = models.TextField(blank=True)
target_price = models.DecimalField(max_digits=10, decimal_places=2)
quantity_wanted = models.PositiveIntegerField(default=1)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
def __str__(self):
return f"WANTED: {self.card.name} @ ${self.target_price}"
if self.card:
return f"WANTED: {self.card.name} @ ${self.target_price}"
return f"WANTED: {self.title} @ ${self.target_price}"
def save(self, *args, **kwargs):
if not self.card and not self.title:
raise ValueError("Bounty must have either a Card or a Title")
if self.card and not self.title:
self.title = f"Buying {self.card.name}"
super().save(*args, **kwargs)
class BountyOffer(models.Model):
STATUS_CHOICES = (
('pending', 'Pending'),
('accepted', 'Accepted'),
('rejected', 'Rejected'),
('countered', 'Countered'),
)
bounty = models.ForeignKey(Bounty, on_delete=models.CASCADE, related_name='offers')
buyer = models.ForeignKey('users.Buyer', on_delete=models.CASCADE, related_name='bounty_offers')
price = models.DecimalField(max_digits=10, decimal_places=2)
description = models.TextField(blank=True, help_text="Details about what you are offering (e.g. card condition)")
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
created_at = models.DateTimeField(auto_now_add=True)
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
def __str__(self):
return f"Offer of ${self.price} by {self.buyer.user.username} on {self.bounty}"
class VaultItem(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='vault_items')
buyer = models.ForeignKey('users.Buyer', on_delete=models.CASCADE, related_name='vault_items')
card = models.ForeignKey(Card, on_delete=models.CASCADE, related_name='vault_items')
quantity = models.PositiveIntegerField(default=1)
added_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('user', 'card')
unique_together = ('buyer', 'card')
def __str__(self):
return f"{self.user.username}'s {self.card.name} ({self.quantity})"
return f"{self.buyer.user.username}'s {self.card.name} ({self.quantity})"
class SellerReport(models.Model):
reporter = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='seller_reports')
seller = models.ForeignKey(Seller, on_delete=models.CASCADE, related_name='reports')
REASON_CHOICES = [
('explicit', 'Explicit/NSFW Content'),
('scam', 'Scam'),
('other', 'Other'),
]
reason = models.CharField(max_length=20, choices=REASON_CHOICES)
details = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Report by {self.reporter} on {self.seller.store_name} - {self.get_reason_display()}"

View File

@@ -0,0 +1,348 @@
{% extends 'base/layout.html' %}
{% block content %}
<div style="max-width: 800px; margin: 0 auto;">
<div style="background: var(--card-bg); padding: 2rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
<h2 style="margin-top: 0; color: var(--text-color);">Add New Card Listing</h2>
<p style="color: var(--muted-text-color); margin-bottom: 2rem;">
Specify the card details. If the card or set doesn't exist, it will be created automatically.
</p>
<!-- Tabs for Single vs Bulk -->
<div style="display: flex; gap: 1rem; margin-bottom: 2rem; border-bottom: 1px solid var(--border-color);">
<button id="tab-single" class="tab-btn active" onclick="switchTab('single')" style="background: none; border: none; color: var(--text-color); padding: 0.5rem 1rem; cursor: pointer; border-bottom: 2px solid var(--primary-color);">Single Listing</button>
<button id="tab-bulk" class="tab-btn" onclick="switchTab('bulk')" style="background: none; border: none; color: var(--muted-text-color); padding: 0.5rem 1rem; cursor: pointer; border-bottom: 2px solid transparent;">Bulk Upload</button>
</div>
<!-- Single Listing Form -->
<div id="single-form-container">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{% for field in form %}
<div style="margin-bottom: 1.5rem;">
<label for="{{ field.id_for_label }}" style="display: block; color: var(--muted-text-color); font-size: 0.875rem; margin-bottom: 0.5rem;">
{{ field.label }}
</label>
{{ field }}
{% if field.help_text %}
<div style="color: var(--muted-text-color); font-size: 0.75rem; margin-top: 0.5rem;">{{ field.help_text }}</div>
{% endif %}
{% if field.errors %}
<div style="color: var(--danger-color); font-size: 0.875rem; margin-top: 0.25rem;">
{{ field.errors.as_text }}
</div>
{% endif %}
</div>
{% endfor %}
<div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 2rem;">
<a href="{% url 'store:manage_listings' %}" class="btn" style="background: transparent; border: 1px solid var(--border-color); text-decoration: none;">Cancel</a>
<button type="submit" class="btn">Create Listing</button>
</div>
</form>
</div>
<!-- Bulk Upload Form -->
<div id="bulk-form-container" style="display: none;">
<div style="margin-bottom: 2rem; padding: 1rem; background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.2); border-radius: 0.5rem;">
<h3 style="margin-top: 0; color: var(--text-color); font-size: 1.1rem;">Instructions</h3>
<ol style="color: var(--muted-text-color); margin-left: 1.5rem; margin-bottom: 0;">
<li>Download the <a href="{% url 'store:download_listing_template' type='card' %}" style="color: var(--primary-color);">CSV Template</a>.</li>
<li>Fill out the CSV with your card details.</li>
<li>For images, put the filename (e.g., "lotus.jpg") in the CSV.</li>
<li>Upload the CSV and select ALL corresponding image files at once below.</li>
</ol>
</div>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="bulk_upload" value="true">
{% for field in bulk_form %}
<div style="margin-bottom: 1.5rem;">
<label for="{{ field.id_for_label }}" style="display: block; color: #94a3b8; font-size: 0.875rem; margin-bottom: 0.5rem;">
{{ field.label }}
</label>
{{ field }}
{% if field.help_text %}
<div style="color: var(--muted-text-color); font-size: 0.75rem; margin-top: 0.5rem;">{{ field.help_text }}</div>
{% endif %}
{% if field.errors %}
<div style="color: var(--danger-color); font-size: 0.875rem; margin-top: 0.25rem;">
{{ field.errors.as_text }}
</div>
{% endif %}
</div>
{% endfor %}
<div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 2rem;">
<a href="{% url 'store:manage_listings' %}" class="btn" style="background: transparent; border: 1px solid var(--border-color); text-decoration: none;">Cancel</a>
<button type="submit" class="btn">Upload Bulk Listings</button>
</div>
</form>
</div>
</div>
</div>
<style>
/* Suggestions list styling */
#suggestions-list li {
padding: 0.5rem;
cursor: pointer;
border-bottom: 1px solid var(--border-color);
background: var(--bg-color);
color: var(--text-color);
}
#suggestions-list li:hover {
background: var(--card-bg);
}
/* Scoped styles for form inputs in this view to match dashboard theme */
form input[type="text"],
form input[type="number"],
form input[type="file"],
form select,
form textarea {
display: block;
width: 100%;
padding: 0.75rem;
background: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
color: var(--text-color);
font-size: 1rem;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
form input:focus,
form select:focus,
form textarea:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2); /* Soft glow */
}
/* File input styling tweaks */
form input[type="file"] {
padding: 0.5rem;
line-height: 1.5;
}
form input[type="file"]::file-selector-button {
background: var(--primary-color);
border: none;
color: var(--text-color);
padding: 0.25rem 0.75rem;
border-radius: 0.25rem;
margin-right: 1rem;
cursor: pointer;
font-size: 0.875rem;
}
.tab-btn:hover {
color: var(--text-color) !important;
}
</style>
<script>
function switchTab(tab) {
const singleBtn = document.getElementById('tab-single');
const bulkBtn = document.getElementById('tab-bulk');
const singleForm = document.getElementById('single-form-container');
const bulkForm = document.getElementById('bulk-form-container');
if (tab === 'single') {
singleBtn.classList.add('active');
singleBtn.style.borderBottom = "2px solid var(--primary-color)";
singleBtn.style.color = "white";
bulkBtn.classList.remove('active');
bulkBtn.style.borderBottom = "2px solid transparent";
bulkBtn.style.color = "#94a3b8";
singleForm.style.display = "block";
bulkForm.style.display = "none";
} else {
bulkBtn.classList.add('active');
bulkBtn.style.borderBottom = "2px solid var(--primary-color)";
bulkBtn.style.color = "white";
singleBtn.classList.remove('active');
singleBtn.style.borderBottom = "2px solid transparent";
singleBtn.style.color = "#94a3b8";
bulkForm.style.display = "block";
singleForm.style.display = "none";
}
}
document.addEventListener("DOMContentLoaded", function() {
const cardNameInput = document.getElementById("id_card_name");
const gameSelect = document.getElementById("id_game");
const setNameInput = document.getElementById("id_set_name");
const collectorNumberInput = document.getElementById("id_collector_number");
// Setup suggestions container
let suggestionsList = document.createElement("ul");
suggestionsList.id = "suggestions-list";
suggestionsList.style.display = "none";
suggestionsList.style.position = "absolute";
suggestionsList.style.zIndex = "1000";
suggestionsList.style.width = "100%";
suggestionsList.style.maxHeight = "200px";
suggestionsList.style.overflowY = "auto";
suggestionsList.style.border = "1px solid var(--border-color)";
suggestionsList.style.borderRadius = "0.25rem";
suggestionsList.style.padding = "0";
suggestionsList.style.margin = "0";
suggestionsList.style.listStyle = "none";
// Wrap cardNameInput in relative container
const wrapper = document.createElement("div");
wrapper.style.position = "relative";
cardNameInput.parentNode.insertBefore(wrapper, cardNameInput);
wrapper.appendChild(cardNameInput);
wrapper.appendChild(suggestionsList);
let debounceTimer;
let currentVariants = [];
// Autocomplete Logic
cardNameInput.addEventListener("input", function() {
const query = this.value;
clearTimeout(debounceTimer);
if (query.length < 2) {
suggestionsList.style.display = "none";
return;
}
debounceTimer = setTimeout(() => {
fetch(`/api/card-autocomplete/?q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(data => {
suggestionsList.innerHTML = "";
if (data.results.length > 0) {
data.results.forEach(name => {
const li = document.createElement("li");
li.textContent = name;
li.addEventListener("click", () => {
cardNameInput.value = name;
suggestionsList.style.display = "none";
fetchVariants(name);
});
suggestionsList.appendChild(li);
});
suggestionsList.style.display = "block";
} else {
suggestionsList.style.display = "none";
}
});
}, 300);
});
// Fetch Variants and Update Filters
function fetchVariants(cardName) {
fetch(`/api/card-variants/?name=${encodeURIComponent(cardName)}`)
.then(response => response.json())
.then(data => {
if (data.results.length > 0) {
currentVariants = data.results;
// 1. Auto-select Game if only one game found OR multiple games but same ID
// We check uniqueness of game_slug
const games = [...new Set(currentVariants.map(v => v.game_slug))];
if (games.length === 1) {
const gameName = currentVariants[0].game_name;
for (let i = 0; i < gameSelect.options.length; i++) {
if (gameSelect.options[i].text === gameName) {
gameSelect.selectedIndex = i;
break;
}
}
}
// 2. Populate Set Name Datalist (create if needed)
let dataList = document.getElementById("set-name-list");
if (!dataList) {
dataList = document.createElement("datalist");
dataList.id = "set-name-list";
document.body.appendChild(dataList);
setNameInput.setAttribute("list", "set-name-list");
}
dataList.innerHTML = "";
const sets = [...new Set(currentVariants.map(v => v.set_name))];
sets.forEach(setName => {
const option = document.createElement("option");
option.value = setName;
dataList.appendChild(option);
});
// If only one set, simplify life
if (sets.length === 1) {
setNameInput.value = sets[0];
}
updateCollectorNumbers();
}
});
}
function updateCollectorNumbers() {
const currentSetName = setNameInput.value;
// Filter variants by selected set (if any)
let relevantVariants = currentVariants;
if (currentSetName) {
relevantVariants = currentVariants.filter(v => v.set_name === currentSetName);
}
// Populate Collector Number Datalist
let numList = document.getElementById("collector-number-list");
if (!numList) {
numList = document.createElement("datalist");
numList.id = "collector-number-list";
document.body.appendChild(numList);
collectorNumberInput.setAttribute("list", "collector-number-list");
}
numList.innerHTML = "";
const numbers = [...new Set(relevantVariants.map(v => v.collector_number).filter(n => n))]; // Filter out empty strings
numbers.forEach(num => {
const option = document.createElement("option");
option.value = num;
numList.appendChild(option);
});
// Auto-fill if only one variant for this set
if (relevantVariants.length === 1 && relevantVariants[0].collector_number) {
collectorNumberInput.value = relevantVariants[0].collector_number;
} else if (relevantVariants.length > 1 && numbers.length === 1) {
// All variants have same number? (unlikely but possible if id differs by something else)
collectorNumberInput.value = numbers[0];
} else {
// clear if valid set but multiple numbers?
// Maybe best not to clear if user typed something, only auto-fill if empty?
// Let's leave it alone unless match found.
}
}
// Listen for Set Name changes to update collector numbers
setNameInput.addEventListener("input", updateCollectorNumbers);
setNameInput.addEventListener("change", updateCollectorNumbers);
// Close suggestions on outside click
document.addEventListener("click", function(e) {
if (e.target !== cardNameInput && e.target !== suggestionsList) {
suggestionsList.style.display = "none";
}
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,153 @@
{% extends 'base/layout.html' %}
{% block content %}
<div style="max-width: 800px; margin: 0 auto;">
<div style="background: var(--card-bg); padding: 2rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
<h2 style="margin-top: 0; color: var(--text-color);">Add New Pack Listing</h2>
<p style="color: var(--muted-text-color); margin-bottom: 2rem;">
Create a listing for physical or virtual packs.
</p>
<!-- Tabs for Single vs Bulk -->
<div style="display: flex; gap: 1rem; margin-bottom: 2rem; border-bottom: 1px solid var(--border-color);">
<button id="tab-single" class="tab-btn active" onclick="switchTab('single')" style="background: none; border: none; color: var(--text-color); padding: 0.5rem 1rem; cursor: pointer; border-bottom: 2px solid var(--primary-color);">Single Listing</button>
<button id="tab-bulk" class="tab-btn" onclick="switchTab('bulk')" style="background: none; border: none; color: var(--muted-text-color); padding: 0.5rem 1rem; cursor: pointer; border-bottom: 2px solid transparent;">Bulk Upload</button>
</div>
<!-- Single Listing Form -->
<div id="single-form-container">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{% for field in form %}
<div style="margin-bottom: 1.5rem;">
<label for="{{ field.id_for_label }}" style="display: block; color: var(--muted-text-color); font-size: 0.875rem; margin-bottom: 0.5rem;">
{{ field.label }}
</label>
{{ field }}
{% if field.help_text %}
<div style="color: var(--muted-text-color); font-size: 0.75rem; margin-top: 0.5rem;">{{ field.help_text }}</div>
{% endif %}
{% if field.errors %}
<div style="color: var(--danger-color); font-size: 0.875rem; margin-top: 0.25rem;">
{{ field.errors.as_text }}
</div>
{% endif %}
</div>
{% endfor %}
<div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 2rem;">
<a href="{% url 'store:manage_listings' %}" class="btn" style="background: transparent; border: 1px solid var(--border-color); text-decoration: none;">Cancel</a>
<button type="submit" class="btn">Create Listing</button>
</div>
</form>
</div>
<!-- Bulk Upload Form -->
<div id="bulk-form-container" style="display: none;">
<div style="margin-bottom: 2rem; padding: 1rem; background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.2); border-radius: 0.5rem;">
<h3 style="margin-top: 0; color: var(--text-color); font-size: 1.1rem;">Instructions</h3>
<ol style="color: var(--muted-text-color); margin-left: 1.5rem; margin-bottom: 0;">
<li>Download the <a href="{% url 'store:download_listing_template' type='pack' %}" style="color: var(--primary-color);">CSV Template</a>.</li>
<li>Fill out the CSV with pack details.</li>
<li>For images, put the filename (e.g., "bootstrap.jpg") in the CSV.</li>
<li>Upload the CSV and select ALL corresponding image files at once below.</li>
</ol>
</div>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="bulk_upload" value="true">
{% for field in bulk_form %}
<div style="margin-bottom: 1.5rem;">
<label for="{{ field.id_for_label }}" style="display: block; color: #94a3b8; font-size: 0.875rem; margin-bottom: 0.5rem;">
{{ field.label }}
</label>
{{ field }}
{% if field.help_text %}
<div style="color: #64748b; font-size: 0.75rem; margin-top: 0.5rem;">{{ field.help_text }}</div>
{% endif %}
{% if field.errors %}
<div style="color: #ef4444; font-size: 0.875rem; margin-top: 0.25rem;">
{{ field.errors.as_text }}
</div>
{% endif %}
</div>
{% endfor %}
<div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 2rem;">
<a href="{% url 'store:manage_listings' %}" class="btn" style="background: transparent; border: 1px solid var(--border-color); text-decoration: none;">Cancel</a>
<button type="submit" class="btn">Upload Bulk Listings</button>
</div>
</form>
</div>
</div>
</div>
<style>
/* Scoped styles for form inputs in this view to match dashboard theme */
form input[type="text"],
form input[type="number"],
form input[type="file"],
form select,
form textarea {
display: block;
width: 100%;
padding: 0.75rem;
background: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
color: var(--text-color);
font-size: 1rem;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
form input:focus,
form select:focus,
form textarea:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2); /* Soft glow */
}
.tab-btn:hover {
color: var(--text-color) !important;
}
</style>
<script>
function switchTab(tab) {
const singleBtn = document.getElementById('tab-single');
const bulkBtn = document.getElementById('tab-bulk');
const singleForm = document.getElementById('single-form-container');
const bulkForm = document.getElementById('bulk-form-container');
if (tab === 'single') {
singleBtn.classList.add('active');
singleBtn.style.borderBottom = "2px solid var(--primary-color)";
singleBtn.style.color = "white";
bulkBtn.classList.remove('active');
bulkBtn.style.borderBottom = "2px solid transparent";
bulkBtn.style.color = "#94a3b8";
singleForm.style.display = "block";
bulkForm.style.display = "none";
} else {
bulkBtn.classList.add('active');
bulkBtn.style.borderBottom = "2px solid var(--primary-color)";
bulkBtn.style.color = "white";
singleBtn.classList.remove('active');
singleBtn.style.borderBottom = "2px solid transparent";
singleBtn.style.color = "#94a3b8";
bulkForm.style.display = "block";
singleForm.style.display = "none";
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,86 @@
{% extends 'base/layout.html' %}
{% block content %}
<div class="revenue-dashboard">
<h1 style="margin-bottom: 2rem;">Platform Revenue Dashboard</h1>
<div class="stats-card">
<h2 style="margin-bottom: 0.5rem;">Total Platform Revenue</h2>
<p class="money">${{ total_platform_revenue }}</p>
</div>
<div class="table-responsive">
<table>
<thead>
<tr>
<th>Seller</th>
<th>Total Sales</th>
<th>Platform Fees</th>
</tr>
</thead>
<tbody>
{% for row in seller_data %}
<tr>
<td>
<strong>{{ row.seller.store_name }}</strong><br>
<small>{{ row.seller.user.email }}</small>
</td>
<td>${{ row.total_revenue }}</td>
<td>${{ row.platform_fees }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<style>
.revenue-dashboard {
max-width: 900px;
margin: 2rem auto;
}
.stats-card {
background: var(--card-bg, #1f2937);
padding: 2rem;
border-radius: 0.75rem;
margin-bottom: 2rem;
text-align: center;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.money {
font-size: 3rem;
font-weight: 800;
color: #f59e0b;
margin: 0;
}
.table-responsive {
overflow-x: auto;
background: var(--card-bg, #1f2937);
border-radius: 0.75rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
table {
width: 100%;
border-collapse: collapse;
color: #e5e7eb;
}
th, td {
padding: 1rem 1.5rem;
text-align: left;
border-bottom: 1px solid #374151;
}
th {
background-color: #374151;
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
}
tr:last-child td {
border-bottom: none;
}
tr:hover td {
background-color: rgba(255,255,255,0.02);
}
</style>
{% endblock %}

View File

@@ -0,0 +1,126 @@
{% extends 'base/layout.html' %}
{% block content %}
<div class="container mx-auto" style="padding: 2rem 1rem;">
<div style="max-width: 800px; margin: 0 auto;">
<!-- Bounty Header -->
<div style="background: var(--card-bg); padding: 2rem; border-radius: 0.5rem; border: 1px solid var(--border-color); margin-bottom: 2rem;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem;">
<div>
<h1 style="margin: 0 0 0.5rem;">
{% if bounty.card %}
Wanted: {{ bounty.card.name }}
{% else %}
Wanted: {{ bounty.title }}
{% endif %}
</h1>
<p style="color: var(--muted-text-color); font-size: 0.875rem;">
Posted by <span style="color: var(--text-color); font-weight: 500;">{{ bounty.seller.store_name }}</span> on {{ bounty.created_at|date:"M d, Y" }}
</p>
</div>
<div style="text-align: right;">
<span style="display: block; font-size: 1.5rem; font-weight: 700; color: var(--success-color);">${{ bounty.target_price }}</span>
<span style="font-size: 0.875rem; color: var(--muted-text-color);">Target Price / item</span>
</div>
</div>
<div style="margin-bottom: 1.5rem;">
<h3 style="margin: 0 0 0.5rem; font-size: 1.125rem;">Details</h3>
<p style="color: var(--text-color); line-height: 1.6;">{{ bounty.description|default:"No additional details provided."|linebreaks }}</p>
</div>
<div style="border-top: 1px solid var(--border-color); padding-top: 1rem; display: flex; gap: 2rem; color: var(--muted-text-color); font-size: 0.875rem;">
<div>
<span style="font-weight: 600; display: block; color: var(--text-color);">Quantity Wanted</span>
{{ bounty.quantity_wanted }}
</div>
<!-- Add more stats if needed -->
</div>
</div>
{% if is_seller %}
<!-- Seller View: Offers -->
<div style="background: var(--card-bg); padding: 2rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
<h2 style="margin-top: 0; margin-bottom: 1.5rem;">Offers Received ({{ offers|length }})</h2>
{% if offers %}
<div style="display: flex; flex-direction: column; gap: 1rem;">
{% for offer in offers %}
<div style="border: 1px solid var(--border-color); border-radius: 0.5rem; padding: 1rem; {% if offer.status == 'accepted' %}background: rgba(16, 185, 129, 0.1); border-color: #059669;{% endif %}">
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<div>
<p style="font-size: 1.125rem; font-weight: 600; margin: 0;">
${{ offer.price }}
<span style="font-size: 0.875rem; font-weight: 400; color: var(--muted-text-color);">from {{ offer.buyer.user.username }}</span>
</p>
<p style="margin: 0.25rem 0 0; font-size: 0.875rem; color: var(--text-color);">
{{ offer.description|default:"No note included." }}
</p>
<p style="margin: 0.5rem 0 0; font-size: 0.75rem; color: var(--muted-text-color);">{{ offer.created_at|date:"M d, H:i" }}</p>
</div>
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: 0.5rem;">
<span style="padding: 0.25rem 0.5rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase;
{% if offer.status == 'pending' %}background: #fef3c7; color: #92400e;
{% elif offer.status == 'accepted' %}background: #d1fae5; color: #065f46;
{% elif offer.status == 'rejected' %}background: #fee2e2; color: #991b1b;
{% else %}background: var(--border-color); color: var(--text-color);{% endif %}">
{{ offer.get_status_display }}
</span>
{% if offer.status == 'pending' %}
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<a href="{% url 'store:bounty_process_offer' offer.uuid 'accept' %}" class="btn" style="padding: 0.25rem 0.75rem; font-size: 0.875rem; background-color: #10b981;">Accept</a>
<a href="{% url 'store:bounty_process_offer' offer.uuid 'reject' %}" class="btn" style="padding: 0.25rem 0.75rem; font-size: 0.875rem; background-color: #ef4444;">Reject</a>
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p style="color: var(--muted-text-color); text-align: center; margin: 1rem 0;">No offers yet.</p>
{% endif %}
</div>
{% elif is_buyer %}
<!-- Buyer View: Make Offer -->
<div style="background: var(--card-bg); padding: 2rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
{% if user_offer %}
<div style="text-align: center; padding: 1.5rem;">
<h2 style="margin-top: 0; margin-bottom: 0.5rem;">Your Offer</h2>
<div style="display: inline-block; padding: 1rem; background: var(--bg-color); border-radius: 0.5rem; margin-bottom: 1rem;">
<span style="display: block; font-size: 1.5rem; font-weight: 700; color: var(--info-color);">${{ user_offer.price }}</span>
<span style="display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; margin-top: 0.25rem;
{% if user_offer.status == 'pending' %}color: #f59e0b;
{% elif user_offer.status == 'accepted' %}color: #10b981;
{% elif user_offer.status == 'rejected' %}color: #ef4444;{% endif %}">
{{ user_offer.get_status_display }}
</span>
</div>
<p style="color: var(--muted-text-color); font-size: 0.875rem;">You have already submitted an offer for this bounty.</p>
</div>
{% else %}
<h2 style="margin-top: 0; margin-bottom: 1.5rem;">Make an Offer</h2>
<form method="post" style="display: flex; flex-direction: column; gap: 1rem;">
{% csrf_token %}
<div>
<label style="display: block; font-size: 0.875rem; margin-bottom: 0.5rem; color: var(--muted-text-color);">Your Price ($)</label>
{{ offer_form.price }}
<!-- Assuming simple form rendering, styling inputs usually handled by global CSS or widget attrs -->
</div>
<div>
<label style="display: block; font-size: 0.875rem; margin-bottom: 0.5rem; color: var(--muted-text-color);">Note (Condition, details, etc)</label>
{{ offer_form.description }}
</div>
<button type="submit" class="btn" style="background-color: var(--info-color); width: 100%; margin-top: 1rem;">
Submit Offer
</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,211 @@
{% extends 'base/layout.html' %}
{% block content %}
<div class="container mx-auto" style="padding: 2rem 1rem;">
<div style="max-width: 600px; margin: 0 auto; background: var(--card-bg); padding: 2rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
<h1 style="margin-top: 0; margin-bottom: 1.5rem; font-size: 1.5rem;">{{ title }}</h1>
<form method="post" style="display: flex; flex-direction: column; gap: 1.5rem;">
{% csrf_token %}
{% for field in form %}
<div style="display: flex; flex-direction: column;">
<label for="{{ field.id_for_label }}" style="margin-bottom: 0.5rem; font-size: 0.875rem; font-weight: 500; color: var(--muted-text-color);">
{{ field.label }}
</label>
{{ field }}
{% if field.help_text %}
<p style="margin-top: 0.25rem; font-size: 0.75rem; color: var(--muted-text-color);">{{ field.help_text }}</p>
{% endif %}
{% if field.errors %}
<div style="margin-top: 0.25rem; font-size: 0.875rem; color: var(--danger-color);">
{{ field.errors }}
</div>
{% endif %}
</div>
{% endfor %}
<div style="display: flex; justify-content: flex-end; padding-top: 1rem; border-top: 1px solid var(--border-color);">
<a href="{% url 'store:bounty_list' %}" class="btn" style="background-color: var(--bg-color); color: var(--text-color); margin-right: 1rem; border: 1px solid var(--border-color);">
Cancel
</a>
<button type="submit" class="btn" style="background-color: var(--info-color);">
Post Bounty
</button>
</div>
</form>
</div>
</div>
<style>
/* Suggestions list styling */
#suggestions-list, #variants-list {
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 0.25rem;
padding: 0;
margin: 0;
list-style: none;
max-height: 200px;
overflow-y: auto;
position: absolute;
width: 100%;
z-index: 1000;
display: none;
}
#suggestions-list li, #variants-list li {
padding: 0.5rem;
cursor: pointer;
border-bottom: 1px solid var(--border-color);
color: var(--text-color);
}
#suggestions-list li:hover, #variants-list li:hover {
background: var(--card-bg);
}
/* Highlight selected variant */
.variant-selected {
background-color: rgba(59, 130, 246, 0.2) !important;
border-left: 3px solid #60a5fa;
}
</style>
<script>
document.addEventListener("DOMContentLoaded", function() {
const cardNameInput = document.getElementById("id_card_name");
const cardIdInput = document.getElementById("id_card_id");
// Ensure relative positioning for dropdowns
const wrapper = document.createElement("div");
wrapper.style.position = "relative";
cardNameInput.parentNode.insertBefore(wrapper, cardNameInput);
wrapper.appendChild(cardNameInput);
// Create Suggestions List (Names)
const suggestionsList = document.createElement("ul");
suggestionsList.id = "suggestions-list";
wrapper.appendChild(suggestionsList);
// Create Variants List (Specific Cards)
const variantsList = document.createElement("ul");
variantsList.id = "variants-list";
// Insert after suggestions list
wrapper.appendChild(variantsList);
// Optional: Add a label to show selected card details clearly
const feedbackDiv = document.createElement("div");
feedbackDiv.style.marginTop = "0.5rem";
feedbackDiv.style.fontSize = "0.875rem";
feedbackDiv.style.color = "#60a5fa";
feedbackDiv.style.minHeight = "1.5rem";
wrapper.appendChild(feedbackDiv);
let debounceTimer;
// 1. Input Handler - Search Names
cardNameInput.addEventListener("input", function() {
const query = this.value;
clearTimeout(debounceTimer);
// Clear previous selection
cardIdInput.value = "";
variantsList.style.display = "none";
feedbackDiv.textContent = "";
if (query.length < 2) {
suggestionsList.style.display = "none";
return;
}
debounceTimer = setTimeout(() => {
fetch(`/api/card-autocomplete/?q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(data => {
suggestionsList.innerHTML = "";
if (data.results.length > 0) {
data.results.forEach(name => {
const li = document.createElement("li");
li.textContent = name;
li.addEventListener("click", () => {
cardNameInput.value = name;
suggestionsList.style.display = "none";
fetchVariants(name);
});
suggestionsList.appendChild(li);
});
suggestionsList.style.display = "block";
} else {
suggestionsList.style.display = "none";
}
});
}, 300);
});
// 2. Fetch Variants for a chosen Name
function fetchVariants(cardName) {
feedbackDiv.textContent = "Loading variants...";
fetch(`/api/card-variants/?name=${encodeURIComponent(cardName)}`)
.then(response => response.json())
.then(data => {
variantsList.innerHTML = "";
if (data.results.length === 0) {
feedbackDiv.textContent = "No variants found. Bounty will be generic by Title.";
return;
}
// If only one variant, auto-select it
if (data.results.length === 1) {
selectVariant(data.results[0]);
return;
}
feedbackDiv.textContent = "Please select a specific version below:";
data.results.forEach(variant => {
const li = document.createElement("li");
// Text: "Base Set (#4) - Pokemon"
let label = `${variant.set_name}`;
if (variant.collector_number) {
label += ` (#${variant.collector_number})`;
}
label += ` - ${variant.game_name}`;
li.textContent = label;
li.addEventListener("click", () => {
selectVariant(variant);
variantsList.style.display = "none";
});
variantsList.appendChild(li);
});
variantsList.style.display = "block";
});
}
function selectVariant(variant) {
cardIdInput.value = variant.card_id;
let label = `${variant.game_name} - ${variant.set_name}`;
if (variant.collector_number) {
label += ` #${variant.collector_number}`;
}
feedbackDiv.innerHTML = `Selected: <strong>${label}</strong>`;
}
// Close on outside click
document.addEventListener("click", function(e) {
if (!wrapper.contains(e.target)) {
suggestionsList.style.display = "none";
// Don't hide variants list immediately if user is interacting?
// Actually yes, hide it to be clean. user can re-trigger by typing or logic needs improvement if re-opening is hard.
// For now, simple hiding.
variantsList.style.display = "none";
}
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,78 @@
{% extends 'base/layout.html' %}
{% block content %}
<div class="container mx-auto" style="padding: 2rem 1rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<div>
<h1 style="margin: 0;">Bounty Board</h1>
<p style="color: var(--muted-text-color); font-size: 0.875rem; margin-top: 0.5rem;">Seller/Buyer Marketplace for Buying Cards</p>
</div>
{% if user.is_authenticated and user.seller_profile %}
<div style="display: flex; gap: 1rem;">
<a href="{% url 'store:bounty_create' %}" class="btn" style="background-color: #10b981;">+ Post Bounty</a>
</div>
{% endif %}
</div>
{% if bounties %}
<div class="card-grid">
{% for bounty in bounties %}
<div class="tcg-card" style="display: flex; flex-direction: column; height: 100%;">
<div style="padding: 1rem; flex-grow: 1;">
<div style="display: flex; gap: 1rem; margin-bottom: 1rem;">
<!-- Image or Placeholder -->
{% if bounty.card.image_url %}
<img src="{{ bounty.card.image_url }}" alt="{{ bounty.card.name }}" style="width: 48px; height: 64px; object-fit: cover; border-radius: 4px;">
{% else %}
<div style="width: 48px; height: 64px; background: var(--border-color); border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 1.5rem;">📝</div>
{% endif %}
<!-- Title & Seller -->
<div>
<h3 style="margin: 0; font-size: 1rem; line-height: 1.2;">
<a href="{% url 'store:bounty_detail' bounty.pk %}" style="text-decoration: none; color: inherit;">
{% if bounty.card %}
{{ bounty.card.name }}
{% else %}
{{ bounty.title }}
{% endif %}
</a>
</h3>
<p style="margin: 0.25rem 0 0; font-size: 0.75rem; color: var(--muted-text-color);">Wanted by {{ bounty.seller.store_name }}</p>
</div>
</div>
<div style="border-top: 1px solid var(--border-color); padding-top: 1rem; display: flex; justify-content: space-between; align-items: flex-end;">
<div>
<p style="margin: 0; font-size: 0.75rem; color: var(--muted-text-color); text-transform: uppercase;">Buying For</p>
<p style="margin: 0; font-size: 1.125rem; font-weight: 700; color: var(--success-color);">${{ bounty.target_price }}</p>
</div>
<div style="text-align: right;">
<p style="margin: 0; font-size: 0.75rem; color: var(--muted-text-color); text-transform: uppercase;">Needed</p>
<p style="margin: 0; font-size: 1.125rem; font-weight: 700; color: var(--info-color);">{{ bounty.quantity_wanted }}</p>
</div>
</div>
<div style="margin-top: 1rem;">
<p style="color: var(--muted-text-color); font-size: 0.875rem; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;">
{{ bounty.description|default:"No details." }}
</p>
</div>
</div>
<div style="padding: 0.5rem 1rem 1rem;">
<a href="{% url 'store:bounty_detail' bounty.pk %}" class="btn-outline" style="display: block; text-align: center; border-radius: 0.375rem; padding: 0.5rem;">View Details</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div style="background: var(--card-bg); padding: 3rem; border-radius: 0.5rem; border: 1px dashed var(--border-color); text-align: center;">
<p style="color: var(--muted-text-color); margin-bottom: 1rem;">No active bounties at the moment.</p>
{% if user.is_authenticated and user.seller_profile %}
<a href="{% url 'store:bounty_create' %}" style="color: var(--info-color); text-decoration: none;">Be the first to post one!</a>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,3 @@
Game,Set,Card Name,Collector Number,Condition (NM/LP/MP/HP),Price,Quantity,Image Filename
Magic: The Gathering,Alpha,Black Lotus,,NM,10000.00,1,black_lotus.jpg
Magic: The Gathering,Beta,Mox Sapphire,,LP,5000.00,1,
1 Game Set Card Name Collector Number Condition (NM/LP/MP/HP) Price Quantity Image Filename
2 Magic: The Gathering Alpha Black Lotus NM 10000.00 1 black_lotus.jpg
3 Magic: The Gathering Beta Mox Sapphire LP 5000.00 1

View File

@@ -0,0 +1,3 @@
Game,Name,Listing Type (physical/virtual),Price,Quantity,Image Filename
Magic: The Gathering,Alpha Booster Box,physical,15000.00,1,alpha_box.jpg
Magic: The Gathering,Beta Booster Pack,virtual,500.00,10,
1 Game Name Listing Type (physical/virtual) Price Quantity Image Filename
2 Magic: The Gathering Alpha Booster Box physical 15000.00 1 alpha_box.jpg
3 Magic: The Gathering Beta Booster Pack virtual 500.00 10

View File

@@ -0,0 +1,49 @@
{% extends 'base/layout.html' %}
{% block content %}
<div class="container mx-auto" style="padding: 2rem 1rem; max-width: 800px;">
<h1 style="margin-bottom: 1rem;">Add Content to Pack</h1>
<p style="margin-bottom: 2rem; color: #94a3b8;">Listing: {{ listing.name }}</p>
<!-- Search Section -->
<form method="get" style="margin-bottom: 2rem; display: flex; gap: 0.5rem;">
<input type="text" name="q" value="{{ query|default:'' }}" placeholder="Search for cards..." class="form-input" style="flex-grow: 1;">
<button type="submit" class="btn" style="background: #3b82f6;">Search</button>
</form>
<form method="post">
{% csrf_token %}
{% if cards %}
<div style="margin-bottom: 2rem;">
<h2 style="margin-bottom: 1rem;">Select Cards to Include</h2>
<div style="max-height: 400px; overflow-y: auto; border: 1px solid var(--border-color); border-radius: 0.5rem; padding: 1rem;">
{% for card in cards %}
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; border-bottom: 1px solid var(--border-color);">
<input type="checkbox" name="cards" value="{{ card.id }}" id="card_{{ card.id }}" style="width: 1.25rem; height: 1.25rem;">
<label for="card_{{ card.id }}" style="display: flex; gap: 1rem; align-items: center; flex-grow: 1; cursor: pointer;">
{% if card.image_url %}
<img src="{{ card.image_url }}" alt="{{ card.name }}" style="width: 32px; height: 44px; object-fit: cover; border-radius: 2px;">
{% endif %}
<div>
<div style="font-weight: bold;">{{ card.name }}</div>
<div style="font-size: 0.8rem; color: #94a3b8;">{{ card.set.name }}</div>
</div>
</label>
</div>
{% endfor %}
</div>
<p style="margin-top: 0.5rem; font-size: 0.875rem; color: #94a3b8;">Select multiple cards to create a single pack containing all selected items.</p>
</div>
<div style="display: flex; gap: 1rem;">
<button type="submit" class="btn" style="background: #10b981;">Create Pack Instance</button>
<a href="{% url 'store:manage_pack_inventory' listing.uuid %}" class="btn" style="background: var(--border-color);">Cancel</a>
</div>
{% elif query %}
<p>No cards found matching "{{ query }}"</p>
{% endif %}
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,21 @@
{% extends 'base/layout.html' %}
{% block content %}
<div class="container mx-auto px-4 py-16 text-center">
<div class="bg-gray-800 rounded-lg shadow-lg p-8 max-w-lg mx-auto border border-red-900">
<h1 class="text-3xl font-bold text-red-500 mb-4">Confirm Delete</h1>
<p class="text-xl text-gray-300 mb-8">Are you sure you want to delete this listing?</p>
<p class="text-gray-400 mb-8">{{ item }}</p>
<form method="post" class="flex justify-center space-x-4">
{% csrf_token %}
<a href="{% url 'store:manage_listings' %}" class="bg-gray-600 hover:bg-gray-700 text-white font-bold py-2 px-6 rounded transition">
Cancel
</a>
<button type="submit" class="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-6 rounded transition">
Delete
</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,330 @@
{% extends 'base/layout.html' %}
{% block content %}
<div class="card-grid" style="grid-template-columns: 1fr 3fr; gap: 2rem; align-items: start;">
<!-- Sidebar: Store Info -->
<div style="background: var(--card-bg); padding: 2rem; border-radius: 0.5rem; border: 1px solid var(--border-color); position: sticky; top: calc(var(--nav-height) + 2rem);">
<h2 style="margin-top: 0;">{{ seller.store_name }}</h2>
<p style="color: #94a3b8; font-size: 0.875rem;">Seller Dashboard</p>
<div style="margin-top: 2rem; display: flex; flex-direction: column; gap: 1rem;">
<a href="{% url 'store:seller_profile' seller.slug %}" class="btn" style="text-align: center;">View Storefront</a>
<a href="{% url 'store:edit_seller_profile' %}" class="btn-outline" style="text-align: center; padding: 0.5rem 1rem; border-radius: 0.375rem; text-decoration: none; font-weight: 600;">Edit Profile</a>
<a href="{% url 'store:manage_listings' %}" class="btn-outline" style="text-align: center; padding: 0.5rem 1rem; border-radius: 0.375rem; text-decoration: none; font-weight: 600;">Manage Inventory</a>
<a href="{% url 'store:bounty_list' %}" class="btn-outline" style="text-align: center; padding: 0.5rem 1rem; border-radius: 0.375rem; text-decoration: none; font-weight: 600;">Bounty Board</a>
<hr style="border: 0; border-top: 1px solid var(--border-color); width: 100%;">
<div style="font-size: 0.875rem;">
<p style="margin: 0.5rem 0;"><strong>Active Listings:</strong> {{ active_listings_count }}</p>
<p style="margin: 0.5rem 0;"><strong>Items Sold:</strong> {{ items_sold }}</p>
<p style="margin: 0.5rem 0;"><strong>Revenue:</strong> ${{ total_revenue|floatformat:2 }}</p>
{% if avg_rating %}
<p style="margin: 0.5rem 0; display: flex; align-items: center; gap: 0.5rem;">
<strong>Avg Rating:</strong>
<span style="display: flex; align-items: center; gap: 0.25rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="#fbbf24" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
</svg>
{{ avg_rating }}/5
</span>
</p>
{% endif %}
</div>
<hr style="border: 0; border-top: 1px solid var(--border-color); width: 100%;">
<!-- Theme Selection -->
<div>
<h3 style="margin: 0 0 0.5rem; font-size: 0.875rem; color: #94a3b8; text-transform: uppercase;">Theme</h3>
<form method="post" style="display: flex; gap: 0.5rem; flex-direction: column;">
{% csrf_token %}
{{ theme_form.theme_preference }}
<button type="submit" class="btn-outline" style="width: 100%; text-align: center; padding: 0.25rem; font-size: 0.875rem;">Apply Theme</button>
</form>
</div>
</div>
</div>
<!-- Main Content -->
<div style="display: flex; flex-direction: column; gap: 2rem;">
<!-- Analytics Row -->
<div class="card-grid" style="grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
<div style="background: var(--card-bg); padding: 1.5rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
<h3 style="margin: 0 0 0.5rem; font-size: 0.875rem; color: #94a3b8; text-transform: uppercase;">Store Views</h3>
<p style="font-size: 1.5rem; font-weight: 700; color: #a78bfa; margin: 0;">{{ store_views }}</p>
</div>
<div style="background: var(--card-bg); padding: 1.5rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
<h3 style="margin: 0 0 0.5rem; font-size: 0.875rem; color: #94a3b8; text-transform: uppercase;">Listing Clicks</h3>
<p style="font-size: 1.5rem; font-weight: 700; color: #f472b6; margin: 0;">{{ listing_clicks }}</p>
</div>
<div style="background: var(--card-bg); padding: 1.5rem; border-radius: 0.5rem; border: 1px solid var(--border-color); display: flex; flex-direction: column; gap: 0.5rem;">
<h3 style="margin: 0; font-size: 0.875rem; color: #94a3b8; text-transform: uppercase;">Store Link</h3>
<div style="display: flex; gap: 1rem; align-items: center;">
<img src="{{ qr_code_url }}" alt="Store QR Code" style="width: 60px; height: 60px; border-radius: 0.25rem;">
<div style="overflow: hidden;">
<a href="{{ store_full_url }}" target="_blank" style="color: var(--primary-color); text-decoration: none; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block;">/store/{{ seller.slug }}</a>
<p style="margin: 0; font-size: 0.75rem; color: #94a3b8;">Share this QR code</p>
</div>
</div>
</div>
</div>
<!-- Stats Row -->
<div class="card-grid" style="grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
<div style="background: var(--card-bg); padding: 1.5rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
<h3 style="margin: 0 0 0.5rem; font-size: 0.875rem; color: #94a3b8; text-transform: uppercase;">Revenue (All Time)</h3>
<p style="font-size: 1.5rem; font-weight: 700; color: #34d399; margin: 0;">${{ total_revenue|floatformat:2 }}</p>
</div>
<div style="background: var(--card-bg); padding: 1.5rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
<h3 style="margin: 0 0 0.5rem; font-size: 0.875rem; color: #94a3b8; text-transform: uppercase;">Units Sold</h3>
<p style="font-size: 1.5rem; font-weight: 700; color: #60a5fa; margin: 0;">{{ items_sold }}</p>
</div>
<div style="background: var(--card-bg); padding: 1.5rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
<h3 style="margin: 0 0 0.5rem; font-size: 0.875rem; color: #94a3b8; text-transform: uppercase;">Avg. Order Value</h3>
<!-- Simple calc if items sold > 0 -->
<p style="font-size: 1.5rem; font-weight: 700; color: #f59e0b; margin: 0;">
{% if items_sold > 0 %}
${{ total_revenue|divisibleby:items_sold|default:"0.00" }}
{# Django template math is limited, leaving simpler for now or use filters if available #}
{# Just leaving placeholder logic or better computed in view #}
--
{% else %}
$0.00
{% endif %}
</p>
</div>
</div>
<!-- Charts Section -->
<div style="display: grid; grid-template-columns: 1fr; gap: 2rem;">
<!-- Global Filter -->
<div style="background: var(--card-bg); padding: 1rem; border-radius: 0.5rem; border: 1px solid var(--border-color); display: flex; align-items: center; justify-content: space-between;">
<h3 style="margin: 0; font-size: 1rem;">Analytics</h3>
<form method="get" style="display: flex; gap: 0.5rem; align-items: center;">
<select name="game" class="form-control" style="padding: 0.25rem 0.5rem; border-radius: 0.25rem; background: var(--bg-color); color: var(--text-color); border: 1px solid var(--border-color);">
<option value="">All Games</option>
{% for game in all_games %}
<option value="{{ game.name }}" {% if selected_game == game.name %}selected{% endif %}>{{ game.name }}</option>
{% endfor %}
</select>
<button type="submit" class="btn" style="padding: 0.25rem 0.75rem;">Filter</button>
{% if selected_game %}<a href="{% url 'store:seller_dashboard' %}" style="font-size: 0.875rem; color: #ef4444; margin-left: 0.5rem;">Clear</a>{% endif %}
</form>
</div>
<!-- Sales & Revenue Chart -->
<div style="background: var(--card-bg); padding: 2rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h3 style="margin: 0;">Sales & Revenue</h3>
<div class="inline-flex rounded-md shadow-sm" role="group">
<button type="button" onclick="updateChart('day')" id="btn-day" class="btn active" style="padding: 0.25rem 0.75rem; border-radius: 0.25rem 0 0 0.25rem;">Day</button>
<button type="button" onclick="updateChart('week')" id="btn-week" class="btn-outline" style="padding: 0.25rem 0.75rem; border-radius: 0;">Week</button>
<button type="button" onclick="updateChart('month')" id="btn-month" class="btn-outline" style="padding: 0.25rem 0.75rem; border-radius: 0 0.25rem 0.25rem 0;">Month</button>
</div>
</div>
<canvas id="salesChart" height="250"></canvas>
</div>
<!-- Breakdown Charts (Tabs) -->
<div style="background: var(--card-bg); padding: 2rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
<div style="border-bottom: 1px solid var(--border-color); margin-bottom: 1.5rem;">
<div style="display: flex; gap: 1rem;">
<button onclick="openTab('game')" id="tab-game" class="tab-btn active" style="background: none; border: none; padding: 0.5rem 1rem; color: var(--primary-color); border-bottom: 2px solid var(--primary-color); font-weight: 600; cursor: pointer;">By Game</button>
<button onclick="openTab('condition')" id="tab-condition" class="tab-btn" style="background: none; border: none; padding: 0.5rem 1rem; color: #94a3b8; border-bottom: 2px solid transparent; font-weight: 600; cursor: pointer;">By Condition</button>
<button onclick="openTab('set')" id="tab-set" class="tab-btn" style="background: none; border: none; padding: 0.5rem 1rem; color: #94a3b8; border-bottom: 2px solid transparent; font-weight: 600; cursor: pointer;">By Set</button>
</div>
</div>
<div id="content-game" class="tab-content" style="height: 300px; display: flex; justify-content: center;">
<canvas id="gameChart"></canvas>
</div>
<div id="content-condition" class="tab-content" style="height: 300px; display: none; justify-content: center;">
<canvas id="conditionChart"></canvas>
</div>
<div id="content-set" class="tab-content" style="height: 300px; display: none; justify-content: center;">
<canvas id="setChart"></canvas>
</div>
</div>
</div>
</div>
<!-- Chart.js Integration -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
// Global variables to hold data
let chartData = {};
let salesChart = null;
document.addEventListener('DOMContentLoaded', function() {
// Data from Django
chartData = {
day: JSON.parse('{{ chart_data_day|safe }}'),
week: JSON.parse('{{ chart_data_week|safe }}'),
month: JSON.parse('{{ chart_data_month|safe }}')
};
const gameLabels = JSON.parse('{{ game_labels|safe }}');
const gameData = JSON.parse('{{ game_data|safe }}');
const conditionLabels = JSON.parse('{{ condition_labels|safe }}');
const conditionData = JSON.parse('{{ condition_data|safe }}');
const setLabels = JSON.parse('{{ set_labels|safe }}');
const setData = JSON.parse('{{ set_data|safe }}');
// 1. Sales/Revenue Chart (Mixed Line/Bar)
const ctxSales = document.getElementById('salesChart').getContext('2d');
salesChart = new Chart(ctxSales, {
type: 'bar',
data: {
labels: chartData.day.labels,
datasets: [
{
label: 'Revenue ($)',
data: chartData.day.revenue,
borderColor: '#34d399',
backgroundColor: 'rgba(52, 211, 153, 0.2)',
type: 'line',
yAxisID: 'y',
tension: 0.4
},
{
label: 'Items Sold',
data: chartData.day.sales,
backgroundColor: '#60a5fa',
yAxisID: 'y1'
}
]
},
options: {
responsive: true,
interaction: {
mode: 'index',
intersect: false,
},
scales: {
x: {
grid: { color: 'rgba(75, 85, 99, 0.4)' },
ticks: { color: '#9ca3af' }
},
y: {
type: 'linear',
display: true,
position: 'left',
grid: { color: 'rgba(75, 85, 99, 0.4)' },
ticks: { color: '#9ca3af' },
title: { display: true, text: 'Revenue ($)', color: '#34d399' }
},
y1: {
type: 'linear',
display: true,
position: 'right',
grid: { drawOnChartArea: false },
ticks: { color: '#60a5fa' },
title: { display: true, text: 'Units', color: '#60a5fa' }
}
},
plugins: {
legend: { labels: { color: '#e5e7eb' } }
}
}
});
// 2. Sales by Game (Pie)
initPieChart('gameChart', gameLabels, gameData, 'No sales data by game yet.');
// 3. Sales by Condition (Pie/Doughnut)
initPieChart('conditionChart', conditionLabels, conditionData, 'No sales data by condition yet.');
// 4. Sales by Set (Pie/Doughnut)
initPieChart('setChart', setLabels, setData, 'No sales data by set yet.');
function initPieChart(canvasId, labels, dataPoints, emptyMsg) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
if (labels.length > 0) {
new Chart(canvas.getContext('2d'), {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: dataPoints,
backgroundColor: [
'#f87171', '#fbbf24', '#34d399', '#60a5fa', '#a78bfa', '#f472b6',
'#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ec4899'
],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'right', labels: { color: '#e5e7eb', padding: 20 } }
}
}
});
} else {
canvas.parentElement.innerHTML = `<p style="color: #94a3b8; align-self: center;">${emptyMsg}</p>`;
}
}
});
// Tab Switching
function openTab(tabName) {
// Hide all contents
document.querySelectorAll('.tab-content').forEach(el => {
el.style.display = 'none';
el.classList.remove('active');
});
// Remove active class from buttons
document.querySelectorAll('.tab-btn').forEach(el => {
el.style.borderBottomColor = 'transparent';
el.style.color = '#94a3b8';
});
// Show selected
const content = document.getElementById('content-' + tabName);
if(content) {
content.style.display = 'flex';
}
// Update button style
const btn = document.getElementById('tab-' + tabName);
if(btn) {
btn.style.borderBottomColor = 'var(--primary-color)';
btn.style.color = 'var(--primary-color)';
}
}
// Function to update chart period
function updateChart(period) {
if (!salesChart) return;
const data = chartData[period];
salesChart.data.labels = data.labels;
salesChart.data.datasets[0].data = data.revenue;
salesChart.data.datasets[1].data = data.sales;
salesChart.update();
// Update Buttons
document.querySelectorAll('#btn-day, #btn-week, #btn-month').forEach(btn => {
btn.classList.remove('active', 'btn');
btn.classList.add('btn-outline');
// Reset styling hack (since we mix inline styles and classes, handling active state efficiently)
btn.style.backgroundColor = 'transparent';
btn.style.color = 'var(--primary-color)';
});
const activeBtn = document.getElementById('btn-' + period);
activeBtn.classList.remove('btn-outline');
activeBtn.classList.add('btn', 'active');
activeBtn.style.backgroundColor = 'var(--primary-color)';
activeBtn.style.color = 'white';
}
</script>
</div>
{% endblock %}

View File

@@ -0,0 +1,91 @@
{% extends 'base/layout.html' %}
{% block content %}
<div style="max-width: 800px; margin: 0 auto;">
<div style="background: var(--card-bg); padding: 2rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
<h2 style="margin-top: 0; color: var(--text-color);">{{ title }}</h2>
<p style="color: var(--muted-text-color); margin-bottom: 2rem;">
Please fill out the details below as accurately as possible.
</p>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{% if form.non_field_errors %}
<div style="background: var(--danger-color); color: var(--text-color); padding: 0.75rem; border-radius: 0.375rem; margin-bottom: 1.5rem;">
{{ form.non_field_errors }}
</div>
{% endif %}
{% for field in form %}
<div style="margin-bottom: 1.5rem;">
<label for="{{ field.id_for_label }}" style="display: block; color: var(--muted-text-color); font-size: 0.875rem; margin-bottom: 0.5rem;">
{{ field.label }}
</label>
{{ field }}
{% if field.help_text %}
<div style="color: var(--muted-text-color); font-size: 0.75rem; margin-top: 0.5rem;">{{ field.help_text }}</div>
{% endif %}
{% if field.errors %}
<div style="color: var(--danger-color); font-size: 0.875rem; margin-top: 0.25rem;">
{{ field.errors.as_text }}
</div>
{% endif %}
</div>
{% endfor %}
<div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 2rem;">
<a href="{% url 'store:manage_listings' %}" class="btn" style="background: transparent; border: 1px solid var(--border-color); text-decoration: none;">Cancel</a>
<button type="submit" class="btn">Save Listing</button>
</div>
</form>
</div>
</div>
<style>
/* Scoped styles for form inputs in this view to match dashboard theme */
form input[type="text"],
form input[type="email"],
form input[type="number"],
form input[type="password"],
form input[type="file"],
form select,
form textarea {
display: block;
width: 100%;
padding: 0.75rem;
background: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
color: var(--text-color);
font-size: 1rem;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
form input:focus,
form select:focus,
form textarea:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2); /* Soft glow */
}
/* File input styling tweaks */
form input[type="file"] {
padding: 0.5rem;
line-height: 1.5;
}
form input[type="file"]::file-selector-button {
background: var(--primary-color);
border: none;
color: var(--text-color);
padding: 0.25rem 0.75rem;
border-radius: 0.25rem;
margin-right: 1rem;
cursor: pointer;
font-size: 0.875rem;
}
</style>
{% endblock %}

View File

@@ -0,0 +1,127 @@
{% extends 'base/layout.html' %}
{% block content %}
<div class="container" style="max-width: 800px; margin: 0 auto; padding: 2rem;">
<div style="background: var(--card-bg); padding: 2rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
<h1 style="margin-top: 0; margin-bottom: 2rem;">Edit Store Profile</h1>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{% if form.non_field_errors %}
<div style="background: rgba(239, 68, 68, 0.1); border: 1px solid #ef4444; color: #ef4444; padding: 1rem; border-radius: 0.5rem; margin-bottom: 1.5rem;">
{{ form.non_field_errors }}
</div>
{% endif %}
<div style="display: grid; gap: 1.5rem;">
<!-- Store Info -->
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: #94a3b8;">Store Name</label>
{{ form.store_name }}
{% if form.store_name.errors %}
<div style="color: #ef4444; font-size: 0.875rem; margin-top: 0.25rem;">{{ form.store_name.errors }}</div>
{% endif %}
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: #94a3b8;">Description</label>
{{ form.description }}
{% if form.description.errors %}
<div style="color: #ef4444; font-size: 0.875rem; margin-top: 0.25rem;">{{ form.description.errors }}</div>
{% endif %}
</div>
<!-- Images -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem;">
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: #94a3b8;">Store Icon (Avatar)</label>
{{ form.store_image }}
{% if form.store_image.errors %}
<div style="color: #ef4444; font-size: 0.875rem; margin-top: 0.25rem;">{{ form.store_image.errors }}</div>
{% endif %}
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: #94a3b8;">Hero Banner Image</label>
{{ form.hero_image }}
{% if form.hero_image.errors %}
<div style="color: #ef4444; font-size: 0.875rem; margin-top: 0.25rem;">{{ form.hero_image.errors }}</div>
{% endif %}
</div>
</div>
<!-- Contact Info -->
<div style="border-top: 1px solid var(--border-color); padding-top: 1.5rem; margin-top: 0.5rem;">
<h3 style="margin-top: 0; margin-bottom: 1rem; font-size: 1.125rem;">Contact Information</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem;">
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: #94a3b8;">Email</label>
{{ form.contact_email }}
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: #94a3b8;">Phone</label>
{{ form.contact_phone }}
</div>
</div>
<div class="form-group" style="margin-top: 1.5rem;">
<label style="display: block; margin-bottom: 0.5rem; color: #94a3b8;">Business Address</label>
{{ form.business_address }}
</div>
</div>
<!-- Shipping Settings -->
<div style="border-top: 1px solid var(--border-color); padding-top: 1.5rem; margin-top: 0.5rem;">
<h3 style="margin-top: 0; margin-bottom: 1rem; font-size: 1.125rem;">Shipping Settings</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem;">
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: #94a3b8;">Minimum Order Amount ($) for Free Shipping</label>
{{ form.minimum_order_amount }}
{% if form.minimum_order_amount.errors %}
<div style="color: #ef4444; font-size: 0.875rem; margin-top: 0.25rem;">{{ form.minimum_order_amount.errors }}</div>
{% endif %}
<small style="display: block; margin-top: 0.25rem; color: #64748b; font-size: 0.75rem;">Set to 0 if shipping is never free.</small>
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: #94a3b8;">Shipping Cost ($)</label>
{{ form.shipping_cost }}
{% if form.shipping_cost.errors %}
<div style="color: #ef4444; font-size: 0.875rem; margin-top: 0.25rem;">{{ form.shipping_cost.errors }}</div>
{% endif %}
<small style="display: block; margin-top: 0.25rem; color: #64748b; font-size: 0.75rem;">Standard cost for orders below minimum.</small>
</div>
</div>
</div>
</div>
<div style="margin-top: 2rem; display: flex; gap: 1rem;">
<button type="submit" class="btn">Save Changes</button>
<a href="{% url 'store:seller_dashboard' %}" class="btn" style="background: transparent; border: 1px solid var(--border-color);">Cancel</a>
</div>
</form>
</div>
</div>
<style>
.form-group input[type="text"],
.form-group input[type="email"],
.form-group input[type="number"],
.form-group textarea {
width: 100%;
padding: 0.75rem;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
color: var(--text-color);
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.2);
}
</style>
{% endblock %}

View File

@@ -0,0 +1,135 @@
{% extends 'base/layout.html' %}
{% block content %}
<div class="container mx-auto" style="padding: 2rem 1rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<div>
<h1 style="margin: 0;">Manage Listings</h1>
<p style="color: var(--muted-text-color); font-size: 0.875rem; margin-top: 0.5rem;">Manage your inventory of Single Cards and Packs</p>
</div>
<div style="display: flex; gap: 1rem;">
<a href="{% url 'store:add_card_listing' %}" class="btn" style="background-color: #10b981;">+ Add Card</a>
{% if FEATURE_VIRTUAL_PACKS %}
<a href="{% url 'store:add_pack_listing' %}" class="btn" style="background-color: #3b82f6;">+ Add Pack</a>
{% endif %}
<a href="{% url 'store:bounty_create' %}" class="btn" style="background-color: var(--primary-color);">+ Post Bounty</a>
<a href="{% url 'store:seller_dashboard' %}" class="btn" style="background-color: var(--card-bg); border: 1px solid var(--border-color);">Dashboard</a>
</div>
</div>
<!-- Listings Content -->
<div style="display: flex; flex-direction: column; gap: 3rem;">
<!-- Card Listings Section -->
<section>
<div style="display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 1.5rem;">
<h2 style="margin: 0; font-size: 1.5rem;">Card Listings <span style="font-size: 1rem; color: var(--muted-text-color); font-weight: normal;">({{ card_listings|length }})</span></h2>
</div>
{% if card_listings %}
<div class="card-grid">
{% for listing in card_listings %}
<div class="tcg-card" style="display: flex; flex-direction: column; height: 100%;">
<div style="padding: 1rem; flex-grow: 1;">
<div style="display: flex; gap: 1rem; margin-bottom: 1rem;">
{% if listing.image %}
<img src="{{ listing.image.url }}" alt="{{ listing.card.name }}" style="width: 48px; height: 64px; object-fit: cover; border-radius: 4px;">
{% elif listing.card.image_url %}
<img src="{{ listing.card.image_url }}" alt="{{ listing.card.name }}" style="width: 48px; height: 64px; object-fit: cover; border-radius: 4px;">
{% else %}
<div style="width: 48px; height: 64px; background: var(--border-color); border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 0.75rem; color: var(--muted-text-color);">N/A</div>
{% endif %}
<div>
<h3 style="margin: 0; font-size: 1rem; line-height: 1.2;">{{ listing.card.name }}</h3>
<p style="margin: 0.25rem 0 0; font-size: 0.75rem; color: var(--muted-text-color);">{{ listing.card.set.name }}</p>
<span style="display: inline-block; margin-top: 0.25rem; font-size: 0.625rem; padding: 2px 6px; border-radius: 4px; font-weight: bold;
{% if listing.condition == 'NM' %}background: #064e3b; color: #a7f3d0;
{% elif listing.condition == 'LP' %}background: #1e3a8a; color: #bfdbfe;
{% else %}background: #451a03; color: #fde68a;{% endif %}">
{{ listing.get_condition_display }}
</span>
</div>
</div>
<div style="border-top: 1px solid var(--border-color); padding-top: 1rem; display: flex; justify-content: space-between; align-items: flex-end;">
<div>
<p style="margin: 0; font-size: 0.75rem; color: #94a3b8; text-transform: uppercase;">Price</p>
<p style="margin: 0; font-size: 1.125rem; font-weight: 700; color: #34d399;">${{ listing.price }}</p>
</div>
<div style="text-align: right;">
<p style="margin: 0; font-size: 0.75rem; color: #94a3b8; text-transform: uppercase;">Stock</p>
<p style="margin: 0; font-size: 1.125rem; font-weight: 700; {% if listing.quantity == 0 %}color: #ef4444;{% endif %}">{{ listing.quantity }}</p>
</div>
</div>
</div>
<div style="padding: 0.5rem 1rem 1rem; display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem;">
<a href="{% url 'store:edit_card_listing' listing.uuid %}" class="btn" style="text-align: center; font-size: 0.875rem; padding: 0.25rem 0.5rem; background-color: #3b82f6;">Edit</a>
<a href="{% url 'store:delete_card_listing' listing.uuid %}" class="btn" style="text-align: center; font-size: 0.875rem; padding: 0.25rem 0.5rem; background-color: var(--border-color); color: #f87171;">Delete</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div style="background: var(--card-bg); padding: 3rem; border-radius: 0.5rem; border: 1px dashed var(--border-color); text-align: center;">
<p style="color: var(--muted-text-color); margin-bottom: 1rem;">No card listings found.</p>
<a href="{% url 'store:add_card_listing' %}" style="color: var(--info-color); text-decoration: none;">Add your first card</a>
</div>
{% endif %}
</section>
<!-- Pack Listings Section -->
{% if FEATURE_VIRTUAL_PACKS %}
<section>
<div style="display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 1.5rem;">
<h2 style="margin: 0; font-size: 1.5rem;">Pack Listings <span style="font-size: 1rem; color: var(--muted-text-color); font-weight: normal;">({{ pack_listings|length }})</span></h2>
</div>
{% if pack_listings %}
<div class="card-grid">
{% for listing in pack_listings %}
<div class="tcg-card" style="display: flex; flex-direction: column; height: 100%;">
<div style="padding: 1rem; flex-grow: 1;">
<div style="display: flex; gap: 1rem; margin-bottom: 1rem;">
{% if listing.image_url %}
<img src="{{ listing.image_url }}" alt="{{ listing.name }}" style="width: 48px; height: 64px; object-fit: cover; border-radius: 4px;">
{% else %}
<div style="width: 48px; height: 64px; background: var(--border-color); border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 1.5rem;">📦</div>
{% endif %}
<div>
<h3 style="margin: 0; font-size: 1rem; line-height: 1.2;">{{ listing.name }}</h3>
<p style="margin: 0.25rem 0 0; font-size: 0.75rem; color: #a78bfa;">{{ listing.game.name }}</p>
</div>
</div>
<div style="border-top: 1px solid var(--border-color); padding-top: 1rem; display: flex; justify-content: space-between; align-items: flex-end;">
<div>
<p style="margin: 0; font-size: 0.75rem; color: var(--muted-text-color); text-transform: uppercase;">Price</p>
<p style="margin: 0; font-size: 1.125rem; font-weight: 700; color: var(--success-color);">${{ listing.price }}</p>
</div>
<div style="text-align: right;">
<p style="margin: 0; font-size: 0.75rem; color: var(--muted-text-color); text-transform: uppercase;">Stock</p>
<p style="margin: 0; font-size: 1.125rem; font-weight: 700; {% if listing.quantity == 0 %}color: #ef4444;{% endif %}">{{ listing.quantity }}</p>
</div>
</div>
</div>
<div style="padding: 0.5rem 1rem 1rem; display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem;">
{% if listing.listing_type == 'virtual' %}
<a href="{% url 'store:manage_pack_inventory' listing.uuid %}" class="btn" style="text-align: center; font-size: 0.875rem; padding: 0.25rem 0.5rem; background-color: #8b5cf6; grid-column: span 2;">Manage Inventory</a>
{% endif %}
<a href="{% url 'store:edit_pack_listing' listing.uuid %}" class="btn" style="text-align: center; font-size: 0.875rem; padding: 0.25rem 0.5rem; background-color: #3b82f6;">Edit</a>
<a href="{% url 'store:delete_pack_listing' listing.uuid %}" class="btn" style="text-align: center; font-size: 0.875rem; padding: 0.25rem 0.5rem; background-color: var(--border-color); color: #f87171;">Delete</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div style="background: var(--card-bg); padding: 3rem; border-radius: 0.5rem; border: 1px dashed var(--border-color); text-align: center;">
<p style="color: var(--muted-text-color); margin-bottom: 1rem;">No pack listings found.</p>
<a href="{% url 'store:add_pack_listing' %}" style="color: var(--info-color); text-decoration: none;">Add your first pack</a>
</div>
{% endif %}
</section>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,43 @@
{% extends 'base/layout.html' %}
{% block content %}
<div class="container mx-auto" style="padding: 2rem 1rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<div>
<h1 style="margin: 0;">Manage Pack Inventory</h1>
<p style="color: #94a3b8; font-size: 0.875rem; margin-top: 0.5rem;">Listing: <strong>{{ listing.name }}</strong></p>
</div>
<div style="display: flex; gap: 1rem;">
<a href="{% url 'store:add_virtual_pack_content' listing.uuid %}" class="btn" style="background-color: #10b981;">+ Add Pack Instance</a>
<a href="{% url 'store:manage_listings' %}" class="btn" style="background-color: var(--card-bg); border: 1px solid var(--border-color);">Back to Listings</a>
</div>
</div>
<section>
<h2 style="margin-bottom: 1rem;">Existing Packs ({{ packs|length }})</h2>
{% if packs %}
<div class="card-grid">
{% for pack in packs %}
<div class="tcg-card" style="padding: 1rem;">
<h3 style="font-size: 1rem; margin-bottom: 0.5rem;">Pack #{{ pack.id }}</h3>
<p style="font-size: 0.875rem; color: #94a3b8; margin-bottom: 1rem;">{{ pack.get_status_display }}</p>
<h4 style="font-size: 0.75rem; text-transform: uppercase; color: #64748b; margin-bottom: 0.5rem;">Cards Inside:</h4>
<ul style="list-style: none; padding: 0; margin: 0; font-size: 0.875rem;">
{% for card in pack.cards.all %}
<li style="margin-bottom: 0.25rem;">• {{ card.name }} <span style="color: #94a3b8;">({{ card.set.code }})</span></li>
{% empty %}
<li style="color: #ef4444;">No cards assigned!</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
{% else %}
<div style="background: var(--card-bg); padding: 3rem; border-radius: 0.5rem; border: 1px dashed var(--border-color); text-align: center;">
<p style="color: #94a3b8;">No pack instances found for this listing.</p>
</div>
{% endif %}
</section>
</div>
{% endblock %}

View File

@@ -0,0 +1,374 @@
{% extends 'base/layout.html' %}
{% block content %}
<!-- Hero Section -->
{% if seller.hero_image %}
<div style="height: 300px; width: 100%; position: relative; margin-bottom: 2rem; border-radius: 0.5rem; overflow: hidden;">
<img src="{{ seller.hero_image.url }}" alt="{{ seller.store_name }} Banner" style="width: 100%; height: 100%; object-fit: cover;">
<div style="position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(transparent, rgba(0,0,0,0.8)); padding: 2rem;">
<!-- Optional: Could put store name here too overlaying the image -->
</div>
</div>
{% endif %}
<div class="card-grid" style="grid-template-columns: 1fr 3fr; gap: 2rem; align-items: start;">
<!-- Seller Info Sidebar -->
<div style="background: var(--card-bg); padding: 2rem; border-radius: 0.5rem; border: 1px solid var(--border-color); position: sticky; top: calc(var(--nav-height) + 2rem);">
<div style="text-align: center; margin-bottom: 2rem;">
<!-- Logo -->
{% if seller.store_image %}
<img src="{{ seller.store_image.url }}" alt="{{ seller.store_name }}" style="width: 100px; height: 100px; border-radius: 50%; border: 3px solid var(--card-bg); margin: 0 auto 1rem; object-fit: cover; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
{% else %}
<div style="width: 100px; height: 100px; background: var(--bg-color); border: 1px solid var(--border-color); border-radius: 50%; margin: 0 auto 1rem; display: flex; align-items: center; justify-content: center; font-size: 2rem;">
🏪
</div>
{% endif %}
<h1 style="margin: 0; font-size: 1.5rem;">{{ seller.store_name }}</h1>
<p style="color: #94a3b8; font-size: 0.875rem;">Verified Seller</p>
{% if avg_rating %}
<div style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; margin-top: 0.5rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#fbbf24" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
</svg>
<span style="font-weight: 600; font-size: 1.125rem;">{{ avg_rating }}/5</span>
</div>
{% endif %}
</div>
<p style="color: var(--text-color); margin-bottom: 2rem; text-align: center; font-size: 0.95rem;">
{{ seller.description|default:"No description available." }}
</p>
{% if seller.contact_email or seller.contact_phone or seller.business_address %}
<div style="border-top: 1px solid var(--border-color); padding-top: 1rem;">
<h3 style="font-size: 0.875rem; text-transform: uppercase; color: #94a3b8; margin-bottom: 1rem;">Contact Info</h3>
<div style="display: flex; flex-direction: column; gap: 0.5rem; font-size: 0.875rem;">
{% if seller.contact_email %}
<p style="margin: 0;"><span style="color: #94a3b8;">📧</span> {{ seller.contact_email }}</p>
{% endif %}
{% if seller.contact_phone %}
<p style="margin: 0;"><span style="color: #94a3b8;">📞</span> {{ seller.contact_phone }}</p>
{% endif %}
{% if seller.business_address %}
<p style="margin: 0;"><span style="color: #94a3b8;">📍</span> {{ seller.business_address|linebreaksbr }}</p>
{% endif %}
</div>
</div>
{% endif %}
<!-- Shipping Policy -->
<div style="border-top: 1px solid var(--border-color); padding-top: 1rem;">
<h3 style="font-size: 0.875rem; text-transform: uppercase; color: #94a3b8; margin-bottom: 1rem;">Shipping Policy</h3>
{% if seller.minimum_order_amount > 0 %}
<p style="margin: 0; font-size: 0.875rem; color: var(--text-color);">
Standard Shipping: <strong>${{ seller.shipping_cost }}</strong>
</p>
<p style="margin: 0.5rem 0 0; font-size: 0.875rem; color: #10b981;">
Free shipping on orders over <strong>${{ seller.minimum_order_amount }}</strong>!
</p>
{% else %}
<p style="margin: 0; font-size: 0.875rem; color: #10b981;">
<strong>Free Shipping</strong> on all orders!
</p>
{% endif %}
</div>
<!-- Report Seller Button -->
<div style="border-top: 1px solid var(--border-color); padding-top: 1rem; margin-top: 1rem;">
<button id="report-seller-btn" type="button" style="width: 100%; padding: 0.5rem 1rem; background: transparent; border: 1px solid #ef4444; color: #ef4444; border-radius: 0.25rem; cursor: pointer; font-size: 0.875rem; transition: all 0.2s ease;">
⚠️ Report Seller
</button>
</div>
</div>
<!-- Listings Content -->
<div>
<h2 style="margin-top: 0; margin-bottom: 1.5rem;">Active Listings</h2>
<!-- Filter Form -->
<div style="background: var(--card-bg); padding: 1.5rem; border-radius: 0.5rem; border: 1px solid var(--border-color); margin-bottom: 2rem;">
<form method="get" action="" class="filter-form">
<div style="display: grid; grid-template-columns: 1fr; gap: 1rem; margin-bottom: 1rem;">
<!-- Search Bar -->
<div>
<label for="id_q" style="display: block; margin-bottom: 0.5rem; font-size: 0.875rem; color: #94a3b8;">Card Name</label>
<input type="text" name="q" id="id_q" value="{{ filters.q|default:'' }}" placeholder="Search card name..." class="form-control" style="width: 100%; padding: 0.5rem; border-radius: 0.25rem; border: 1px solid var(--border-color); background: var(--bg-color); color: var(--text-color);">
<div id="autocomplete-results" style="position: absolute; z-index: 1000; background: var(--card-bg); border: 1px solid var(--border-color); width: 100%; max-height: 200px; overflow-y: auto; display: none;"></div>
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem;">
<!-- Game Filter -->
<div>
<label for="id_game" style="display: block; margin-bottom: 0.5rem; font-size: 0.875rem; color: #94a3b8;">Game</label>
<select name="game" id="id_game" style="width: 100%; padding: 0.5rem; border-radius: 0.25rem; border: 1px solid var(--border-color); background: var(--bg-color); color: var(--text-color);">
<option value="">All Games</option>
{% for game in games %}
<option value="{{ game.slug }}" {% if filters.game == game.slug %}selected{% endif %}>{{ game.name }}</option>
{% endfor %}
</select>
</div>
<!-- Set Filter -->
<div>
<label for="id_set" style="display: block; margin-bottom: 0.5rem; font-size: 0.875rem; color: #94a3b8;">Set</label>
<select name="set" id="id_set" style="width: 100%; padding: 0.5rem; border-radius: 0.25rem; border: 1px solid var(--border-color); background: var(--bg-color); color: var(--text-color);">
<option value="">All Sets</option>
{% for set in sets %}
<option value="{{ set.code }}" {% if filters.set == set.code %}selected{% endif %}>{{ set.name }}</option>
{% endfor %}
</select>
</div>
<!-- Condition Filter -->
<div>
<label for="id_condition" style="display: block; margin-bottom: 0.5rem; font-size: 0.875rem; color: #94a3b8;">Condition</label>
<select name="condition" id="id_condition" style="width: 100%; padding: 0.5rem; border-radius: 0.25rem; border: 1px solid var(--border-color); background: var(--bg-color); color: var(--text-color);">
<option value="">Any Condition</option>
{% for code, label in conditions %}
<option value="{{ code }}" {% if filters.condition == code %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<!-- Price Min -->
<div>
<label for="id_min_price" style="display: block; margin-bottom: 0.5rem; font-size: 0.875rem; color: #94a3b8;">Min Price</label>
<input type="number" name="min_price" id="id_min_price" value="{{ filters.min_price|default:'' }}" step="0.01" min="0" placeholder="0.00" style="width: 100%; padding: 0.5rem; border-radius: 0.25rem; border: 1px solid var(--border-color); background: var(--bg-color); color: var(--text-color);">
</div>
<!-- Price Max -->
<div>
<label for="id_max_price" style="display: block; margin-bottom: 0.5rem; font-size: 0.875rem; color: #94a3b8;">Max Price</label>
<input type="number" name="max_price" id="id_max_price" value="{{ filters.max_price|default:'' }}" step="0.01" min="0" placeholder="0.00" style="width: 100%; padding: 0.5rem; border-radius: 0.25rem; border: 1px solid var(--border-color); background: var(--bg-color); color: var(--text-color);">
</div>
<!-- Min Qty -->
<div>
<label for="id_min_qty" style="display: block; margin-bottom: 0.5rem; font-size: 0.875rem; color: #94a3b8;">Min Qty</label>
<input type="number" name="min_qty" id="id_min_qty" value="{{ filters.min_qty|default:'' }}" min="1" placeholder="1" style="width: 100%; padding: 0.5rem; border-radius: 0.25rem; border: 1px solid var(--border-color); background: var(--bg-color); color: var(--text-color);">
</div>
</div>
<div style="margin-top: 1.5rem; display: flex; gap: 1rem;">
<button type="submit" class="btn" style="padding: 0.5rem 1.5rem;">Apply Filters</button>
<a href="{{ request.path }}" class="btn" style="background: transparent; border: 1px solid var(--border-color); padding: 0.5rem 1.5rem;">Clear</a>
</div>
</form>
</div>
<div class="card-grid" style="gap: 1.5rem;">
{% for listing in card_listings %}
<div class="tcg-card" style="display: flex; flex-direction: column;">
{% if listing.card.image_url %}
<img src="{{ listing.card.image_url }}" alt="{{ listing.card.name }}" style="width: 100%; height: 200px; object-fit: cover;">
{% else %}
<div style="width: 100%; height: 200px; background: var(--border-color); display: flex; align-items: center; justify-content: center; color: #94a3b8;">No Image</div>
{% endif %}
<div style="padding: 1rem; flex-grow: 1; display: flex; flex-direction: column;">
<h3 style="margin: 0 0 0.5rem; font-size: 1rem;">{{ listing.card.name }}</h3>
<p style="margin: 0 0 1rem; color: #94a3b8; font-size: 0.875rem;">{{ listing.card.set.name }}</p>
<div style="margin-top: auto; display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<span style="font-weight: 700; font-size: 1.125rem; color: #34d399;">${{ listing.price }}</span>
<span style="font-size: 0.75rem; background: var(--bg-color); padding: 0.25rem 0.5rem; border-radius: 4px; border: 1px solid var(--border-color);">{{ listing.get_condition_display }}</span>
</div>
<form action="{% url 'store:add_to_cart' listing.uuid %}" method="post">
{% csrf_token %}
<button type="submit" class="btn" style="width: 100%;">Add to Cart</button>
</form>
</div>
</div>
{% endfor %}
{% for listing in pack_listings %}
<div class="tcg-card" style="display: flex; flex-direction: column;">
<div style="width: 100%; height: 200px; background: linear-gradient(135deg, #4c1d95, #6d28d9); display: flex; align-items: center; justify-content: center; color: white; font-weight: bold;">
PACK
</div>
<div style="padding: 1rem; flex-grow: 1; display: flex; flex-direction: column;">
<h3 style="margin: 0 0 0.5rem; font-size: 1rem;">{{ listing.name }}</h3>
<p style="margin: 0 0 1rem; color: #a78bfa; font-size: 0.875rem;">{{ listing.game.name }}</p>
<div style="margin-top: auto; display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<span style="font-weight: 700; font-size: 1.125rem; color: #34d399;">${{ listing.price }}</span>
</div>
<form action="{% url 'store:add_pack_to_cart' listing.uuid %}" method="post">
{% csrf_token %}
<button type="submit" class="btn" style="width: 100%;">Add Pack</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% if not card_listings and not pack_listings %}
<div style="text-align: center; padding: 4rem; background: var(--card-bg); border-radius: 0.5rem; color: #94a3b8; border: 1px solid var(--border-color);">
<p>This seller has no active listings at the moment.</p>
</div>
{% endif %}
<!-- Bounty Board Section -->
<h2 style="margin-top: 3rem; margin-bottom: 1.5rem;">Bounty Board</h2>
<div style="background: var(--card-bg); padding: 4rem; border-radius: 0.5rem; border: 1px solid var(--border-color); text-align: center; border-style: dashed;">
<p style="font-size: 1.5rem; font-weight: bold; color: #94a3b8;">TBD</p>
<p style="color: #64748b;">This seller is not currently accepting bounties.</p>
</div>
</div>
</div>
</div>
<script>
// Simple Autocomplete Logic
const searchInput = document.getElementById('id_q');
const resultsContainer = document.getElementById('autocomplete-results');
// Position the results container relative to input
if (searchInput) {
const parent = searchInput.parentElement;
parent.style.position = 'relative';
searchInput.addEventListener('input', function() {
const query = this.value;
if (query.length < 2) {
resultsContainer.style.display = 'none';
return;
}
fetch(`/api/card-autocomplete/?q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(data => {
if (data.results && data.results.length > 0) {
resultsContainer.innerHTML = '';
resultsContainer.style.display = 'block';
data.results.forEach(name => {
const div = document.createElement('div');
div.textContent = name;
div.style.padding = '0.5rem';
div.style.cursor = 'pointer';
div.style.borderBottom = '1px solid var(--border-color)';
div.addEventListener('mouseenter', () => {
div.style.background = 'var(--bg-color)';
});
div.addEventListener('mouseleave', () => {
div.style.background = 'transparent';
});
div.addEventListener('click', () => {
searchInput.value = name;
resultsContainer.style.display = 'none';
// Optional: Auto submit
// searchInput.form.submit();
});
resultsContainer.appendChild(div);
});
} else {
resultsContainer.style.display = 'none';
}
});
});
// Hide when clicking outside
document.addEventListener('click', function(e) {
if (e.target !== searchInput && e.target !== resultsContainer) {
resultsContainer.style.display = 'none';
}
});
}
</script>
<!-- Report Seller Modal -->
<div id="report-modal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center;">
<div style="background: var(--card-bg); padding: 2rem; border-radius: 0.5rem; max-width: 500px; width: 90%; border: 1px solid var(--border-color);">
<h2 style="margin-top: 0; margin-bottom: 1rem;">Report Seller</h2>
<p style="color: #94a3b8; margin-bottom: 1.5rem; font-size: 0.875rem;">Please select a reason for reporting this seller.</p>
<form id="report-form">
<div style="display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.5rem;">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="radio" name="reason" value="explicit" required>
<span>Explicit/NSFW Content</span>
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="radio" name="reason" value="scam">
<span>Scam</span>
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="radio" name="reason" value="other">
<span>Other</span>
</label>
</div>
<div style="margin-bottom: 1.5rem;">
<label for="report-details" style="display: block; margin-bottom: 0.5rem; font-size: 0.875rem; color: #94a3b8;">Additional Details (Optional)</label>
<textarea id="report-details" name="details" rows="4" style="width: 100%; padding: 0.75rem; border-radius: 0.25rem; border: 1px solid var(--border-color); background: var(--bg-color); color: var(--text-color); resize: vertical;" placeholder="Provide any additional context..."></textarea>
</div>
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
<button type="button" id="cancel-report-btn" style="padding: 0.5rem 1rem; background: transparent; border: 1px solid var(--border-color); color: var(--text-color); border-radius: 0.25rem; cursor: pointer;">Cancel</button>
<button type="submit" style="padding: 0.5rem 1rem; background: #ef4444; border: none; color: white; border-radius: 0.25rem; cursor: pointer;">Submit Report</button>
</div>
</form>
</div>
</div>
<script>
// Report Seller Modal Logic
const reportBtn = document.getElementById('report-seller-btn');
const reportModal = document.getElementById('report-modal');
const cancelReportBtn = document.getElementById('cancel-report-btn');
const reportForm = document.getElementById('report-form');
if (reportBtn && reportModal) {
reportBtn.addEventListener('click', function() {
reportModal.style.display = 'flex';
});
cancelReportBtn.addEventListener('click', function() {
reportModal.style.display = 'none';
reportForm.reset();
});
reportModal.addEventListener('click', function(e) {
if (e.target === reportModal) {
reportModal.style.display = 'none';
reportForm.reset();
}
});
reportForm.addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(reportForm);
formData.append('csrfmiddlewaretoken', '{{ csrf_token }}');
fetch('{% url "store:report_seller" seller.slug %}', {
method: 'POST',
body: formData,
headers: {
'X-CSRFToken': '{{ csrf_token }}'
}
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
reportModal.style.display = 'none';
reportForm.reset();
alert('Report submitted successfully. Thank you for helping keep our marketplace safe.');
} else {
alert('Error: ' + (data.message || 'Failed to submit report'));
}
})
.catch(error => {
console.error('Error:', error);
alert('An error occurred while submitting the report.');
});
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,66 @@
{% extends 'base/layout.html' %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="max-w-md mx-auto bg-gray-800 rounded-lg shadow-lg p-6">
<h1 class="text-3xl font-bold mb-6 text-center text-white">Become a Seller</h1>
<form method="post" class="space-y-4">
{% csrf_token %}
{% if user_form %}
<h2 class="text-xl font-semibold text-white mb-2">Account Details</h2>
{% if user_form.non_field_errors %}
<div class="bg-red-500 text-white p-3 rounded mb-2">{{ user_form.non_field_errors }}</div>
{% endif %}
{% for field in user_form %}
<div class="flex flex-col mb-2">
<label for="{{ field.id_for_label }}" class="text-gray-300 mb-1">{{ field.label }}</label>
{{ field }}
{% if field.errors %}<p class="text-red-400 text-sm mt-1">{{ field.errors.0 }}</p>{% endif %}
</div>
{% endfor %}
<hr class="border-gray-600 my-4">
<h2 class="text-xl font-semibold text-white mb-2">Store Details</h2>
{% endif %}
{% if form.non_field_errors %}
<div class="bg-red-500 text-white p-3 rounded">
{{ form.non_field_errors }}
</div>
{% endif %}
{% for field in form %}
<div class="flex flex-col">
<label for="{{ field.id_for_label }}" class="text-gray-300 mb-1">{{ field.label }}</label>
{{ field }}
{% if field.errors %}
<p class="text-red-400 text-sm mt-1">{{ field.errors.0 }}</p>
{% endif %}
</div>
{% endfor %}
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded transition duration-200">
Register Store
</button>
</form>
</div>
</div>
<style>
/* basic styling for form inputs since we are using django form rendering */
input[type="text"], input[type="email"], input[type="number"], textarea {
background-color: var(--input-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
padding: 0.5rem;
border-radius: 0.25rem;
width: 100%;
}
input:focus, textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3);
}
</style>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends 'base/layout.html' %}
{% block content %}
<div class="container mx-auto px-4 py-16 text-center">
<div class="bg-gray-800 rounded-lg shadow-lg p-8 max-w-lg mx-auto">
<h1 class="text-4xl font-bold text-green-400 mb-4">Store Created!</h1>
<p class="text-xl text-gray-300 mb-8">Congratulations, your store "{{ store_name }}" is now active.</p>
<div class="flex justify-center space-x-4">
<a href="{% url 'store:seller_dashboard' %}" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-6 rounded transition">
Go to Dashboard
</a>
<a href="{% url 'store:card_list' %}" class="bg-gray-600 hover:bg-gray-700 text-white font-bold py-2 px-6 rounded transition">
Browse Market
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,3 +1,239 @@
from django.test import TestCase
from django.test import TestCase, Client
from django.urls import reverse
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.files.uploadedfile import SimpleUploadedFile
from .models import Seller, Game, Set, Card, CardListing, Order
from decimal import Decimal
# Create your tests here.
User = get_user_model()
class CardListingTests(TestCase):
def setUp(self):
# Create User and Seller
self.user = User.objects.create_user(username='seller', password='password')
self.seller = Seller.objects.create(
user=self.user,
store_name='Test Store',
slug='test-store'
)
# Create Game, Set, Card
self.game = Game.objects.create(name='Test Game', slug='test-game')
self.set = Set.objects.create(game=self.game, name='Test Set')
self.card = Card.objects.create(set=self.set, name='Test Card')
# Create Listing
self.listing = CardListing.objects.create(
card=self.card,
seller=self.seller,
price=10.00,
quantity=1,
condition='NM'
)
self.client = Client()
self.client.force_login(self.user)
def test_edit_card_listing_image_upload(self):
url = reverse('store:edit_card_listing', args=[self.listing.uuid])
# Create a small image file
image_content = b'\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff\x21\xf9\x04\x01\x00\x00\x00\x00\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x01\x44\x00\x3b'
image = SimpleUploadedFile("test_image.gif", image_content, content_type="image/gif")
data = {
'condition': 'LP',
'price': '15.00',
'quantity': 2,
'status': 'listed',
'image': image
}
response = self.client.post(url, data, format='multipart')
# Check redirect
self.assertRedirects(response, reverse('store:manage_listings'))
# Reload listing
self.listing.refresh_from_db()
# Check updates
self.assertEqual(self.listing.condition, 'LP')
self.assertEqual(self.listing.price, 15.00)
self.assertEqual(self.listing.quantity, 2)
# Check image
self.assertTrue(self.listing.image)
self.assertTrue(self.listing.image.name.endswith('test_image.gif'))
class SellerDashboardTests(TestCase):
def setUp(self):
# Create User and Seller
self.user = User.objects.create_user(username='dashboard_seller', password='password')
self.seller = Seller.objects.create(
user=self.user,
store_name='Dashboard Store',
slug='dashboard-store'
)
self.buyer_user = User.objects.create_user(username='buyer', password='password')
from users.models import Buyer
self.buyer = Buyer.objects.create(user=self.buyer_user)
# Create Game, Set, Card
self.game = Game.objects.create(name='Dashboard Game', slug='dashboard-game')
self.set = Set.objects.create(game=self.game, name='Dashboard Set')
self.card = Card.objects.create(set=self.set, name='Dashboard Card')
# Create Listing
self.listing = CardListing.objects.create(
card=self.card,
seller=self.seller,
price=10.00,
quantity=10,
condition='NM'
)
# Create Order & OrderItem
from .models import Order, OrderItem
self.order = Order.objects.create(
buyer=self.buyer,
status='paid',
total_price=20.00
)
OrderItem.objects.create(
order=self.order,
listing=self.listing,
price_at_purchase=10.00,
quantity=2
)
self.client = Client()
self.client.force_login(self.user)
def test_dashboard_context_data(self):
url = reverse('store:seller_dashboard')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
# Check for new context keys
self.assertIn('condition_labels', response.context)
self.assertIn('condition_data', response.context)
self.assertIn('set_labels', response.context)
self.assertIn('set_data', response.context)
self.assertIn('game_labels', response.context)
self.assertIn('game_data', response.context)
self.assertIn('all_games', response.context)
# Check data correctness (we sold 2 NM items)
import json
cond_data = json.loads(response.context['condition_data'])
cond_labels = json.loads(response.context['condition_labels'])
self.assertIn('Near Mint', cond_labels)
idx = cond_labels.index('Near Mint')
self.assertEqual(cond_data[idx], 2)
def test_dashboard_game_filter(self):
from .models import OrderItem
url = reverse('store:seller_dashboard')
# Create another game/sale
game2 = Game.objects.create(name='Other Game', slug='other-game')
set2 = Set.objects.create(game=game2, name='Other Set')
card2 = Card.objects.create(set=set2, name='Other Card')
listing2 = CardListing.objects.create(card=card2, seller=self.seller, price=5, quantity=5, condition='LP')
OrderItem.objects.create(
order=self.order,
listing=listing2,
price_at_purchase=5.00,
quantity=1
)
# 1. No Filter - Should see both
response = self.client.get(url)
import json
game_labels = json.loads(response.context['game_labels'])
self.assertIn('Dashboard Game', game_labels)
self.assertIn('Other Game', game_labels)
# 2. Filter by Dashboard Game
response = self.client.get(url, {'game': 'Dashboard Game'})
game_labels = json.loads(response.context['game_labels'])
self.assertIn('Dashboard Game', game_labels)
self.assertNotIn('Other Game', game_labels)
# Check condition data also filtered
cond_labels = json.loads(response.context['condition_labels'])
# Dashboard Game items were NM. Other Game items were LP.
self.assertIn('Near Mint', cond_labels)
self.assertNotIn('Lightly Played', cond_labels)
class AdminRevenueTests(TestCase):
def setUp(self):
self.client = Client()
self.staff_user = User.objects.create_user(username='staff', password='password', is_staff=True)
self.seller_user = User.objects.create_user(username='seller2', password='password')
self.seller = Seller.objects.create(
user=self.seller_user,
store_name='Revenue Store',
slug='revenue-store',
tax_id='123-45-6789',
payout_details='Bank Acct 123'
)
# Create Orders
from users.models import Buyer
buyer_user = User.objects.create_user(username='buyer2', password='password')
buyer = Buyer.objects.create(user=buyer_user)
# Order 1: $100 -> Fee: 5 + 0.70 = 5.70
order1 = Order.objects.create(buyer=buyer, status='paid', total_price=Decimal('100.00'), seller=self.seller)
# Order 2: $1000 -> Fee: 50 + 0.70 = 50.70 -> Capped at 25.00
order2 = Order.objects.create(buyer=buyer, status='shipped', total_price=Decimal('1000.00'), seller=self.seller)
# Order 3: Pending (should be ignored)
order3 = Order.objects.create(buyer=buyer, status='pending', total_price=Decimal('50.00'), seller=self.seller)
def test_encryption(self):
# Refresh from db
seller = Seller.objects.get(pk=self.seller.pk)
# Verify decrypted values match
self.assertEqual(seller.tax_id, '123-45-6789')
self.assertEqual(seller.payout_details, 'Bank Acct 123')
# Verify db values are encrypted (not plaintext and are bytes)
self.assertIsInstance(seller.tax_id_encrypted, bytes)
self.assertNotEqual(seller.tax_id_encrypted, b'123-45-6789')
# Ensure it's not just the string encoded
self.assertNotEqual(seller.tax_id_encrypted, '123-45-6789'.encode('utf-8'))
def test_fee_calculation(self):
from .utils import calculate_platform_fee
# 5% + 0.70
self.assertEqual(calculate_platform_fee(Decimal('10.00')), Decimal('1.20')) # 0.50 + 0.70
self.assertEqual(calculate_platform_fee(Decimal('100.00')), Decimal('5.70')) # 5.00 + 0.70
# Cap at 25
# Threshold: (25 - 0.70) / 0.05 = 486.00
self.assertEqual(calculate_platform_fee(Decimal('486.00')), Decimal('25.00'))
self.assertEqual(calculate_platform_fee(Decimal('1000.00')), Decimal('25.00'))
def test_dashboard_view(self):
self.client.force_login(self.staff_user)
response = self.client.get(reverse('store:admin_revenue_dashboard'))
self.assertEqual(response.status_code, 200)
# Check context
total_rev = response.context['total_platform_revenue']
# Order 1 (5.70) + Order 2 (25.00) = 30.70
self.assertEqual(total_rev, Decimal('30.70'))
seller_data = response.context['seller_data']
self.assertEqual(len(seller_data), 1)
self.assertEqual(seller_data[0]['total_revenue'], Decimal('1100.00'))
self.assertEqual(seller_data[0]['platform_fees'], Decimal('30.70'))

87
store/tests_bounty.py Normal file
View File

@@ -0,0 +1,87 @@
from django.test import TestCase, Client
from django.urls import reverse
from django.contrib.auth import get_user_model
from .models import Seller, Game, Set, Card, Bounty
from django.conf import settings
User = get_user_model()
class BountyCreateTest(TestCase):
def setUp(self):
# Ensure feature is on (might need to mock settings if not settable directly,
# but usually tests run with default settings. We need ensuring it's True)
# Note: In some setups, modifying settings in test needs override_settings
self.user = User.objects.create_user(username='bounty_seller', password='password')
self.seller = Seller.objects.create(
user=self.user,
store_name='Bounty Store',
slug='bounty-store'
)
self.game = Game.objects.create(name='Bounty Game', slug='bounty-game')
self.set = Set.objects.create(game=self.game, name='Bounty Set')
self.card = Card.objects.create(set=self.set, name='Bounty Card')
self.client = Client()
self.client.force_login(self.user)
self.url = reverse('store:bounty_create')
def test_create_bounty_with_card_id(self):
# Simulate selecting a card via autocomplete
data = {
'card_name': 'Bounty Card',
'card_id': self.card.id,
'target_price': '100.00',
'quantity_wanted': 1,
# Title is optional if card picked? Form logic says "if no card and no title raise error"
# Our view logic: if card_id -> get card -> set bounty.card.
# Form clean: if not card_id and not title and not card_name -> Error.
}
with self.settings(FEATURE_BOUNTY_BOARD=True):
response = self.client.post(self.url, data)
self.assertRedirects(response, reverse('store:bounty_list'))
# Verify
bounty = Bounty.objects.first()
self.assertIsNotNone(bounty)
self.assertEqual(bounty.card, self.card)
self.assertEqual(bounty.seller, self.seller)
# Title should be auto-set in save() if empty
self.assertEqual(bounty.title, "Buying Bounty Card")
def test_create_bounty_with_name_fallback(self):
# Simulate typing a name but not selecting a card (no ID)
data = {
'card_name': 'Generic Card',
'card_id': '',
'target_price': '50.00',
'quantity_wanted': 1
}
with self.settings(FEATURE_BOUNTY_BOARD=True):
response = self.client.post(self.url, data)
self.assertRedirects(response, reverse('store:bounty_list'))
bounty = Bounty.objects.last()
self.assertIsNone(bounty.card)
self.assertEqual(bounty.title, 'Generic Card')
def test_create_bounty_validation_error(self):
# Empty everything
data = {
'card_name': '',
'card_id': '',
'target_price': '50.00',
'quantity_wanted': 1
}
with self.settings(FEATURE_BOUNTY_BOARD=True):
response = self.client.post(self.url, data)
self.assertEqual(response.status_code, 200) # Form errors, no redirect
form = response.context['form']
self.assertIn("You must either select a Card or provide a Title.", form.non_field_errors())

View File

@@ -0,0 +1,95 @@
from django.test import TestCase, Client
from django.contrib.auth import get_user_model
from .models import Seller, Game, Set, Card, CardListing, Cart, CartItem, Order
User = get_user_model()
class SmartPricingTests(TestCase):
def setUp(self):
# Create Seller User
self.seller_user = User.objects.create_user(username='seller', password='password')
self.seller = Seller.objects.create(
user=self.seller_user,
store_name='Smart Store',
slug='smart-store',
minimum_order_amount=10.00,
shipping_cost=2.50
)
# Create Buyer User
self.buyer_user = User.objects.create_user(username='buyer', password='password')
from users.models import Buyer
self.buyer = Buyer.objects.create(user=self.buyer_user)
# Create Products
self.game = Game.objects.create(name='Test Game', slug='test-game')
self.set = Set.objects.create(game=self.game, name='Test Set')
self.card = Card.objects.create(set=self.set, name='Test Card')
self.listing_cheap = CardListing.objects.create(
card=self.card, seller=self.seller, price=5.00, quantity=10, condition='NM'
)
self.listing_expensive = CardListing.objects.create(
card=self.card, seller=self.seller, price=15.00, quantity=10, condition='NM'
)
self.client = Client()
self.client.force_login(self.buyer_user)
def test_cart_shipping_below_minimum(self):
from django.urls import reverse
# Add cheap item (5.00) < 10.00 minimum
url = reverse('store:add_to_cart', args=[self.listing_cheap.uuid])
self.client.post(url)
# Determine cart directly
cart, _ = Cart.objects.get_or_create(buyer=self.buyer)
response = self.client.get(reverse('store:cart'))
self.assertEqual(response.status_code, 200)
# Check context data
cart_data = response.context['cart_data']
self.assertEqual(len(cart_data), 1)
data = cart_data[0]
self.assertEqual(data['subtotal'], 5.00)
self.assertEqual(data['shipping_cost'], 2.50)
self.assertEqual(data['total'], 7.50)
self.assertEqual(data['free_shipping_needed'], 5.00)
# Check Final Calculation
self.assertEqual(response.context['grand_total'], 7.50)
def test_cart_shipping_above_minimum(self):
from django.urls import reverse
# Add expensive item (15.00) > 10.00 minimum
cart, _ = Cart.objects.get_or_create(buyer=self.buyer)
CartItem.objects.create(cart=cart, listing=self.listing_expensive, quantity=1)
response = self.client.get(reverse('store:cart'))
cart_data = response.context['cart_data']
data = cart_data[0]
self.assertEqual(data['subtotal'], 15.00)
self.assertEqual(data['shipping_cost'], 0)
self.assertEqual(data['total'], 15.00)
self.assertEqual(data['free_shipping_needed'], 0)
def test_checkout_shipping_application(self):
from django.urls import reverse
# Setup cart below minimum
cart, _ = Cart.objects.get_or_create(buyer=self.buyer)
CartItem.objects.create(cart=cart, listing=self.listing_cheap, quantity=1)
response = self.client.get(reverse('store:checkout'))
# Checkout redirects to my_packs (if virtual) or vault (users:vault)
# Since we have no virtual, it goes to vault.
# Check for 302 Found
self.assertEqual(response.status_code, 302)
# Verify Order
order = Order.objects.filter(buyer=self.buyer).first()
self.assertIsNotNone(order)
self.assertEqual(order.total_price, 7.50) # 5.00 + 2.50 shipping

View File

@@ -4,20 +4,44 @@ from . import views
app_name = 'store'
urlpatterns = [
path('', views.card_list, name='card_list'), # Home page associated with 'card_list' view
path('home/', views.card_list, name='home'), # Explicit home alias for readability and templates using 'home' naming convention
path('card/<int:card_id>/', views.card_detail, name='card_detail'),
path('', views.index, name='index'), # Root redirect logic
path('browse/', views.card_list, name='card_list'), # Detailed browse page
path('home/', views.card_list, name='home'), # Explicit home alias for compatibility
path('card/<uuid:card_id>/', views.card_detail, name='card_detail'),
path('cart/', views.cart_view, name='cart'),
path('cart/add/<int:listing_id>/', views.add_to_cart, name='add_to_cart'),
path('cart/remove/<int:item_id>/', views.remove_from_cart, name='remove_from_cart'),
path('api/stock/<int:card_id>/', views.get_card_stock, name='get_card_stock'),
path('cart/add/<uuid:listing_id>/', views.add_to_cart, name='add_to_cart'),
path('cart/remove/<uuid:item_id>/', views.remove_from_cart, name='remove_from_cart'),
path('api/stock/<uuid:card_id>/', views.get_card_stock, name='get_card_stock'),
path('api/card-autocomplete/', views.card_autocomplete, name='card_autocomplete'),
path('api/bounty-autocomplete/', views.bounty_autocomplete, name='bounty_autocomplete'),
path('api/card-variants/', views.card_variants, name='card_variants'),
path('deck-buyer/', views.deck_buyer, name='deck_buyer'),
path('cart/insurance/', views.toggle_insurance, name='toggle_insurance'),
path('bounty-board/', views.bounty_board, name='bounty_board'),
path('bounties/', views.bounty_list, name='bounty_list'),
path('bounties/create/', views.bounty_create, name='bounty_create'),
path('bounties/<uuid:pk>/', views.bounty_detail, name='bounty_detail'),
path('bounties/offer/<uuid:offer_id>/<str:action>/', views.bounty_process_offer, name='bounty_process_offer'),
path('packs/', views.pack_list, name='pack_list'),
path('cart/add-pack/<int:pack_listing_id>/', views.add_pack_to_cart, name='add_pack_to_cart'),
path('cart/add-pack/<uuid:pack_listing_id>/', views.add_pack_to_cart, name='add_pack_to_cart'),
path('checkout/', views.checkout, name='checkout'),
path('my-packs/', views.my_packs, name='my_packs'),
path('packs/open/<int:pack_id>/', views.open_pack, name='open_pack'),
path('order/<int:order_id>/', views.order_detail, name='order_detail'),
path('packs/open/<uuid:pack_id>/', views.open_pack, name='open_pack'),
path('order/<uuid:order_id>/', views.order_detail, name='order_detail'),
path('sell/register/', views.seller_register, name='seller_register'),
path('sell/dashboard/', views.seller_dashboard, name='seller_dashboard'),
path('sell/profile/edit/', views.edit_seller_profile, name='edit_seller_profile'),
path('sell/listings/', views.manage_listings, name='manage_listings'),
path('sell/listings/card/add/', views.add_card_listing, name='add_card_listing'),
path('sell/listings/download-template/<str:type>/', views.download_listing_template, name='download_listing_template'),
path('sell/listings/card/<uuid:listing_id>/edit/', views.edit_card_listing, name='edit_card_listing'),
path('sell/listings/card/<uuid:listing_id>/delete/', views.delete_card_listing, name='delete_card_listing'),
path('sell/listings/pack/add/', views.add_pack_listing, name='add_pack_listing'),
path('sell/listings/pack/<uuid:listing_id>/edit/', views.edit_pack_listing, name='edit_pack_listing'),
path('sell/listings/pack/<uuid:listing_id>/delete/', views.delete_pack_listing, name='delete_pack_listing'),
path('sell/listings/pack/<uuid:listing_id>/inventory/', views.manage_pack_inventory, name='manage_pack_inventory'),
path('sell/listings/pack/<uuid:listing_id>/inventory/add/', views.add_virtual_pack_content, name='add_virtual_pack_content'),
path('store/<slug:slug>/', views.seller_profile, name='seller_profile'),
path('store/<slug:slug>/report/', views.report_seller, name='report_seller'),
path('platform-admin/revenue/', views.admin_revenue_dashboard, name='admin_revenue_dashboard'),
path('terms/', views.terms, name='terms'),
]

View File

@@ -1,12 +1,16 @@
import re
from .models import Card, CardListing, Order, OrderItem, VaultItem
from django.db.models import Min
import base64
from cryptography.fernet import Fernet
from django.conf import settings
from decimal import Decimal
def add_to_vault(user, card, quantity=1):
def add_to_vault(buyer, card, quantity=1):
"""
Adds a card to the user's vault.
Adds a card to the buyer's vault.
"""
vault_item, created = VaultItem.objects.get_or_create(user=user, card=card)
vault_item, created = VaultItem.objects.get_or_create(buyer=buyer, card=card)
if not created:
vault_item.quantity += quantity
else:
@@ -96,10 +100,10 @@ def get_user_collection(user):
Returns a dict {card_name: quantity} of cards in user's vault.
"""
owned = {}
if not user.is_authenticated:
if not user.is_authenticated or not hasattr(user, 'buyer_profile'):
return owned
vault_items = VaultItem.objects.filter(user=user).select_related('card')
vault_items = VaultItem.objects.filter(buyer=user.buyer_profile).select_related('card')
for item in vault_items:
owned[item.card.name] = item.quantity
@@ -130,4 +134,54 @@ def filter_deck_by_collection(parsed_cards, owned_cards):
if remaining > 0:
filtered.append({'name': name, 'quantity': remaining})
return filtered
class Encryptor:
"""
Utility for encrypting and decrypting sensitive data using Fernet.
Derives a key from settings.SECRET_KEY.
"""
_cipher = None
@classmethod
def get_cipher(cls):
if cls._cipher is None:
# Derive a 32-byte key from SECRET_KEY
# Ensure key is url-safe base64-encoded 32-byte key
# We use hashlib to ensure we get a valid 32-byte key for Fernet,
# regardless of SECRET_KEY length.
import hashlib
key_hash = hashlib.sha256(settings.SECRET_KEY.encode('utf-8')).digest()
key_b64 = base64.urlsafe_b64encode(key_hash)
cls._cipher = Fernet(key_b64)
return cls._cipher
@classmethod
def encrypt(cls, plaintext):
if not plaintext:
return None
if isinstance(plaintext, str):
plaintext = plaintext.encode('utf-8')
return cls.get_cipher().encrypt(plaintext)
@classmethod
def decrypt(cls, ciphertext):
if not ciphertext:
return None
if isinstance(ciphertext, memoryview):
ciphertext = bytes(ciphertext)
try:
return cls.get_cipher().decrypt(ciphertext).decode('utf-8')
except Exception:
return None
def calculate_platform_fee(total_amount):
"""
Calculates platform fee: 5% + $0.70, capped at $25.
"""
if not total_amount:
return Decimal('0.00')
fee = (total_amount * Decimal('0.05')) + Decimal('0.70')
return min(fee, Decimal('25.00'))

File diff suppressed because it is too large Load Diff

View File

@@ -4,246 +4,98 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Phantom Card Fam - Premium TCG Store{% endblock %}</title>
<title>{% block title %}TCGKof - Premium TCG Store{% endblock %}</title>
<meta name="description" content="{% block meta_description %}TCGKof is the premier marketplace for trading card games. Buy and sell Magic: The Gathering, Lorcana, and more.{% endblock %}">
<meta name="keywords" content="{% block meta_keywords %}TCG, Magic: The Gathering, Lorcana, cards, marketplace, buy cards, sell cards{% endblock %}">
<link rel="canonical" href="{% block canonical_url %}{{ request.build_absolute_uri }}{% endblock %}">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="{% block og_type %}website{% endblock %}">
<meta property="og:url" content="{{ request.build_absolute_uri }}">
<meta property="og:title" content="{% block og_title %}{{ self.title }}{% endblock %}">
<meta property="og:description" content="{% block og_description %}TCGKof is the premier marketplace for trading card games. Buy and sell Magic: The Gathering, Lorcana, and more.{% endblock %}">
<meta property="og:image" content="{% block og_image %}{{ request.scheme }}://{{ request.get_host }}{% static 'img/og-default.jpg' %}{% endblock %}">
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:url" content="{{ request.build_absolute_uri }}">
<meta property="twitter:title" content="{% block twitter_title %}{{ self.title }}{% endblock %}">
<meta property="twitter:description" content="{% block twitter_description %}TCGKof is the premier marketplace for trading card games. Buy and sell Magic: The Gathering, Lorcana, and more.{% endblock %}">
<meta property="twitter:image" content="{% block twitter_image %}{{ request.scheme }}://{{ request.get_host }}{% static 'img/og-default.jpg' %}{% endblock %}">
<link rel="stylesheet" href="{% static 'css/style.css' %}">
{% if not debug %}
<script async defer src="https://tianji.aimloperations.com/tracker.js" data-website-id="cmklg5jenh4wx14nrurt5yqyl"></script>
{% endif %}
<style>
:root {
--primary-color: #6366f1;
--secondary-color: #a855f7;
--bg-color: #0f172a;
--text-color: #f8fafc;
--card-bg: #1e293b;
--border-color: #334155;
}
[data-theme="light"] {
--primary-color: #4f46e5;
--secondary-color: #9333ea;
--bg-color: #f8fafc;
--text-color: #0f172a;
--card-bg: #ffffff;
--border-color: #e2e8f0;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
margin: 0;
line-height: 1.5;
transition: background-color 0.3s, color 0.3s;
}
nav {
background-color: var(--card-bg);
border-bottom: 1px solid var(--border-color);
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-brand {
font-size: 1.5rem;
font-weight: 800;
background: linear-gradient(to right, var(--primary-color), var(--secondary-color));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
text-decoration: none;
}
.nav-links a {
color: var(--text-color);
text-decoration: none;
margin-left: 1.5rem;
font-weight: 500;
transition: color 0.2s;
}
.nav-links a:hover {
color: var(--primary-color);
}
.container {
max-width: 1200px;
margin: 2rem auto;
padding: 0 1rem;
}
.btn {
display: inline-block;
padding: 0.5rem 1rem;
background-color: var(--primary-color);
color: white;
text-decoration: none;
border-radius: 0.375rem;
font-weight: 600;
transition: opacity 0.2s;
border: none;
cursor: pointer;
}
.btn:hover {
opacity: 0.9;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 2rem;
}
.tcg-card {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
overflow: hidden;
transition: transform 0.2s;
}
.tcg-card:hover {
transform: translateY(-4px);
}
.tcg-card img {
width: 100%;
height: auto;
display: block;
}
.tcg-card-body {
padding: 1rem;
}
.messages {
list-style: none;
padding: 0;
margin-bottom: 2rem;
}
.messages li {
padding: 1rem;
border-radius: 0.375rem;
margin-bottom: 0.5rem;
}
.messages .success { background-color: #064e3b; color: #a7f3d0; }
.messages .error { background-color: #7f1d1d; color: #fecaca; }
[data-theme="light"] .messages .success { background-color: #d1fae5; color: #065f46; }
[data-theme="light"] .messages .error { background-color: #fce7f3; color: #9d174d; }
/* Auth Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s, visibility 0.3s;
backdrop-filter: blur(4px);
}
.modal-overlay.active {
opacity: 1;
visibility: visible;
}
.auth-modal {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
padding: 2rem;
max-width: 400px;
width: 90%;
text-align: center;
transform: translateY(20px);
transition: transform 0.3s;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.modal-overlay.active .auth-modal {
transform: translateY(0);
}
.auth-modal h2 {
margin-top: 0;
color: var(--primary-color);
font-size: 1.5rem;
margin-bottom: 1rem;
}
.auth-modal p {
margin-bottom: 2rem;
color: var(--text-color);
line-height: 1.6;
}
.auth-modal-actions {
display: flex;
gap: 1rem;
justify-content: center;
}
.btn-outline {
background-color: transparent;
border: 1px solid var(--primary-color);
color: var(--primary-color);
}
.btn-outline:hover {
background-color: rgba(99, 102, 241, 0.1);
}
.close-modal {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
color: var(--text-color);
font-size: 1.5rem;
cursor: pointer;
opacity: 0.5;
padding: 0;
line-height: 1;
}
.close-modal:hover {
opacity: 1;
}
</style>
</head>
<body>
{% if FEATURE_DEMO_SITE %}
<div style="background: #f59e0b; color: #000; text-align: center; padding: 0.5rem; font-weight: 600; font-size: 0.875rem;">
DEMO SITE: This is an example application. No real products, payments, or purchases are processed.
</div>
{% endif %}
<nav>
<a href="{% url 'home' %}" class="nav-brand">Phantom Card Fam</a>
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
<a href="{% url 'home' %}" class="nav-brand">TCGKof</a>
<button class="mobile-menu-btn" aria-label="Toggle Navigation">
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
</button>
</div>
<div class="nav-links">
<a href="{% url 'store:card_list' %}">Browse</a>
<a href="{% url 'store:pack_list' %}">Packs</a>
{% if user.is_authenticated %}
<a href="{% url 'decks:deck_list' %}">Decks</a>
<a href="{% url 'users:vault' %}">Vault</a>
<a href="{% url 'store:my_packs' %}">My Packs</a>
<a href="{% url 'store:cart' %}">Cart ({{ user.cart.items.count|default:0 }})</a>
<a href="{% url 'users:profile' %}">Profile</a>
<form action="{% url 'logout' %}" method="post" style="display:inline;">
{% csrf_token %}
<button type="submit" class="btn" style="background:none; color:var(--text-color); margin-left:1rem;">Logout</button>
</form>
{% if user.seller_profile %}
{# Seller Navigation #}
<a href="{% url 'store:seller_dashboard' %}">Dashboard</a>
<a href="{% url 'store:manage_listings' %}">Store</a>
<a href="{% url 'store:manage_listings' %}">Inventory</a>
{% if debug %}
<a href="{% url 'store:bounty_list' %}">Bounties</a>
{% endif %}
<form action="{% url 'logout' %}" method="post" style="display:inline;">
{% csrf_token %}
<button type="submit" class="btn" style="background:none; color:var(--text-color); margin-left:1rem;">Logout</button>
</form>
{% elif user.buyer_profile %}
{# Buyer Navigation #}
<a href="{% url 'store:card_list' %}">Browse</a>
{% if FEATURE_VIRTUAL_PACKS %}
<a href="{% url 'store:pack_list' %}">Packs</a>
{% endif %}
<a href="{% url 'decks:deck_list' %}">Decks</a>
<a href="{% url 'users:vault' %}">Vault</a>
{% if FEATURE_VIRTUAL_PACKS %}
<a href="{% url 'store:my_packs' %}">My Packs</a>
{% endif %}
<a href="{% url 'store:cart' %}">Cart ({{ user.buyer_profile.cart.items.count|default:0 }})</a>
<a href="{% url 'users:profile' %}">Profile</a>
<a href="{% url 'store:bounty_list' %}">Bounties</a>
<form action="{% url 'logout' %}" method="post" style="display:inline;">
{% csrf_token %}
<button type="submit" class="btn" style="background:none; color:var(--text-color); margin-left:1rem;">Logout</button>
</form>
{% else %}
{# Fallback for incomplete profiles #}
<form action="{% url 'logout' %}" method="post" style="display:inline;">
{% csrf_token %}
<button type="submit" class="btn" style="background:none; color:var(--text-color);">Logout (Invalid Account)</button>
</form>
{% endif %}
{% if user.is_staff %}
<a href="{% url 'store:admin_revenue_dashboard' %}" style="color: #f59e0b;">Platform Admin</a>
{% endif %}
{% else %}
{# Public Navigation #}
<a href="{% url 'store:card_list' %}">Browse</a>
{% if FEATURE_VIRTUAL_PACKS %}
<a href="{% url 'store:pack_list' %}">Packs</a>
{% endif %}
{% if debug %}
<a href="{% url 'store:bounty_list' %}">Bounties</a>
{% endif %}
<a href="{% url 'decks:deck_list' %}"
class="auth-required"
data-feature-title="Deck Builder"
@@ -254,13 +106,16 @@
data-feature-title="Collection Vault"
data-feature-desc="Track your entire card collection, monitor value trends, and manage your inventory in one place. Log in to access your Vault.">Vault</a>
{% if FEATURE_VIRTUAL_PACKS %}
<a href="{% url 'store:my_packs' %}"
class="auth-required"
data-feature-title="My Packs"
data-feature-desc="Open virtual packs, collect rare cards, and grow your digital library. Sign up now to start cracking packs!">My Packs</a>
{% endif %}
<a href="{% url 'login' %}">Login</a>
<a href="{% url 'users:register' %}">Register</a>
<a href="{% url 'store:seller_register' %}">Seller?</a>
{% endif %}
</div>
</nav>
@@ -278,9 +133,27 @@
{% endblock %}
</div>
<footer style="text-align: center; padding: 2rem; color: #64748b; font-size: 0.875rem;">
<p>&copy; 2026 Phantom Card Fam TCG Store.</p>
<p>Made by <a href="https://aimloperations.com" target="_blank" style="color: inherit; text-decoration: underline;">AI ML Operations, LLC</a></p>
<footer style="background-color: #1e293b; border-top: 1px solid #334155; padding: 3rem 1rem; margin-top: 4rem; color: #94a3b8;">
<div class="container" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 2rem; max-width: 1200px; margin: 0 auto;">
<div style="text-align: left;">
<h3 style="color: #f59e0b; font-weight: 700; font-size: 1.25rem; margin-bottom: 1rem;">TCGKof</h3>
<p style="font-size: 0.875rem; line-height: 1.6;">The premier destination for trading card game buyers and sellers.</p>
<p style="font-size: 0.875rem;">&copy; 2026 TCGKof Store.</p>
<p style="font-size: 0.875rem;">Operated by <a href="https://aimloperations.com" target="_blank" style="color: inherit; text-decoration: underline;">AI ML Operations, LLC</a></p>
</div>
<div style="text-align: left;">
<h4 style="color: #e2e8f0; font-weight: 600; margin-bottom: 1rem;">Sellers</h4>
<ul style="list-style: none; padding: 0;">
<li style="margin-bottom: 0.5rem;"><a href="{% url 'users:sell_on_tcgkof' %}" style="color: inherit; text-decoration: none; transition: color 0.2s;">Sell on TCGKof</a></li>
<li style="margin-bottom: 0.5rem;"><a href="{% url 'store:seller_register' %}" style="color: inherit; text-decoration: none; transition: color 0.2s;">Seller Registration</a></li>
</ul>
</div>
<div style="text-align: left;">
<h4 style="color: #e2e8f0; font-weight: 600; margin-bottom: 1rem;">Legal</h4>
<ul style="list-style: none; padding: 0;">
<li style="margin-bottom: 0.5rem;"><a href="{% url 'store:terms' %}" style="color: inherit; text-decoration: none; transition: color 0.2s;">Terms and Service</a></li>
</ul>
</div>
</footer>
{% if not user.is_authenticated %}
@@ -298,48 +171,59 @@
<script>
document.addEventListener('DOMContentLoaded', function() {
const modal = document.getElementById('authModal');
const closeBtn = document.querySelector('.close-modal');
const modalTitle = document.getElementById('modalTitle');
const modalDesc = document.getElementById('modalDesc');
const authLinks = document.querySelectorAll('.auth-required');
// Mobile Menu Toggle
const mobileBtn = document.querySelector('.mobile-menu-btn');
const navLinks = document.querySelector('.nav-links');
function openModal(title, desc) {
modalTitle.textContent = title;
modalDesc.textContent = desc;
modal.classList.add('active');
document.body.style.overflow = 'hidden'; // Prevent scrolling
}
function closeModal() {
modal.classList.remove('active');
document.body.style.overflow = '';
}
authLinks.forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const title = this.dataset.featureTitle || 'Login Required';
const desc = this.dataset.featureDesc || 'Please log in to access this feature.';
openModal(title, desc);
if (mobileBtn) {
mobileBtn.addEventListener('click', function() {
navLinks.classList.toggle('active');
});
});
}
closeBtn.addEventListener('click', closeModal);
// Auth Modal Logic
const modal = document.getElementById('authModal');
if (modal) {
const closeBtn = document.querySelector('.close-modal');
const modalTitle = document.getElementById('modalTitle');
const modalDesc = document.getElementById('modalDesc');
const authLinks = document.querySelectorAll('.auth-required');
// Close on outside click
modal.addEventListener('click', function(e) {
if (e.target === modal) {
closeModal();
function openModal(title, desc) {
modalTitle.textContent = title;
modalDesc.textContent = desc;
modal.classList.add('active');
document.body.style.overflow = 'hidden';
}
});
// Close on Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && modal.classList.contains('active')) {
closeModal();
function closeModal() {
modal.classList.remove('active');
document.body.style.overflow = '';
}
});
authLinks.forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const title = this.dataset.featureTitle || 'Login Required';
const desc = this.dataset.featureDesc || 'Please log in to access this feature.';
openModal(title, desc);
});
});
closeBtn.addEventListener('click', closeModal);
modal.addEventListener('click', function(e) {
if (e.target === modal) {
closeModal();
}
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && modal.classList.contains('active')) {
closeModal();
}
});
}
});
</script>
{% endif %}

View File

@@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends 'base/layout.html' %}
{% block content %}
<div class="container py-4">

View File

@@ -0,0 +1,33 @@
{% extends 'base/layout.html' %}
{% block title %}Terms and Service - TCGKof{% endblock %}
{% block content %}
<div class="terms-container" style="max-width: 800px; margin: 2rem auto; padding: 2rem; background: var(--bg-card); border-radius: var(--border-radius); border: 1px solid var(--border-color);">
<h1 style="color: var(--primary-color); margin-bottom: 2rem; text-align: center;">Terms of Service</h1>
<div class="terms-content" style="color: var(--text-color); line-height: 1.6;">
<p style="margin-bottom: 1.5rem;">Welcome to TCGKof. By accessing our website, you agree to these terms and conditions.</p>
<h2 style="color: var(--text-heading); font-size: 1.5rem; margin-top: 2rem; margin-bottom: 1rem;">1. General Terms</h2>
<p style="margin-bottom: 1rem;">TCGKof is a platform for buying and selling trading cards. Users must be at least 18 years old or have parental consent to use this service.</p>
<h2 style="color: var(--text-heading); font-size: 1.5rem; margin-top: 2rem; margin-bottom: 1rem;">2. User Accounts</h2>
<p style="margin-bottom: 1rem;">You are responsible for maintaining the confidentiality of your account and password. You agree to accept responsibility for all activities that occur under your account.</p>
<h2 style="color: var(--text-heading); font-size: 1.5rem; margin-top: 2rem; margin-bottom: 1rem;">3. Buying and Selling</h2>
<p style="margin-bottom: 1rem;">Sellers must accurately describe items. Buyers must pay for items they commit to purchase. TCGKof facilitates transactions but is not a party to the contract between buyer and seller.</p>
<h2 style="color: var(--text-heading); font-size: 1.5rem; margin-top: 2rem; margin-bottom: 1rem;">4. Prohibited Content</h2>
<p style="margin-bottom: 1rem;">Users may not post content that is illegal, obscene, threatening, defamatory, or otherwise objectionable.</p>
<h2 style="color: var(--text-heading); font-size: 1.5rem; margin-top: 2rem; margin-bottom: 1rem;">5. Limitation of Liability</h2>
<p style="margin-bottom: 1rem;">TCGKof shall not be liable for any indirect, incidental, special, consequential, or punitive damages happening out of or in connection with your use of the site.</p>
<h2 style="color: var(--text-heading); font-size: 1.5rem; margin-top: 2rem; margin-bottom: 1rem;">6. Changes to Terms</h2>
<p style="margin-bottom: 1rem;">We reserve the right to modify these terms at any time. Your continued use of the site constitutes acceptance of the modified terms.</p>
<p style="margin-top: 3rem; font-size: 0.875rem; color: var(--text-muted); text-align: center;">Last Updated: January 2026</p>
</div>
</div>
{% endblock %}

View File

@@ -13,5 +13,8 @@
Don't have an account? <a href="{% url 'users:register' %}" style="color: var(--primary-color);">Register
here</a>.
</p>
<p style="margin-top: 0.5rem; text-align: center;">
Want to sell on TCGKof? <a href="{% url 'store:seller_register' %}" style="color: var(--primary-color);">Register here</a>.
</p>
</div>
{% endblock %}

6
templates/robots.txt Normal file
View File

@@ -0,0 +1,6 @@
User-agent: *
Disallow: /admin/
Disallow: /users/
Disallow: /cart/
Disallow: /checkout/
Disallow: /api/

View File

@@ -0,0 +1,164 @@
{% extends 'base/layout.html' %}
{% load static %}
{% block content %}
<div class="browse-container" style="display: grid; grid-template-columns: 250px 1fr; gap: 2rem;">
<!-- Sidebar Filters -->
<aside class="browse-sidebar"
style="background: var(--card-bg); padding: 1.5rem; border-radius: 0.5rem; border: 1px solid var(--border-color); height: fit-content;">
<h3 style="margin-top: 0;">Filter Bounties</h3>
<form method="get">
<div style="margin-bottom: 1rem;">
<label style="display: block; font-size: 0.875rem; margin-bottom: 0.5rem;">Game</label>
<select name="game"
style="width: 100%; padding: 0.5rem; border-radius: 0.25rem; background: var(--bg-color); color: var(--text-color); border: 1px solid var(--border-color);"
onchange="this.form.submit()">
<option value="">All Games</option>
{% for game in games %}
<option value="{{ game.slug }}" {% if current_game == game.slug %}selected{% endif %}>
{{game.name}}
</option>
{% endfor %}
</select>
</div>
{% if current_game %}
<div style="margin-bottom: 1rem;">
<label style="display: block; font-size: 0.875rem; margin-bottom: 0.5rem;">Set</label>
<select name="set"
style="width: 100%; padding: 0.5rem; border-radius: 0.25rem; background: var(--bg-color); color: var(--text-color); border: 1px solid var(--border-color);">
<option value="">All Sets</option>
{% for set in sets %}
<option value="{{ set.id }}" {% if request.GET.set|add:"0" == set.id %}selected{% endif %}>{{ set.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div style="margin-bottom: 1rem; position: relative;">
<label style="display: block; font-size: 0.875rem; margin-bottom: 0.5rem;">Search</label>
<input type="text" name="q" id="search-input" value="{{ search_query|default:'' }}" placeholder="Card or Bounty title..." autocomplete="off"
style="width: 90%; padding: 0.5rem; border-radius: 0.25rem; background: var(--bg-color); color: var(--text-color); border: 1px solid var(--border-color);">
<ul id="suggestions-list" style="display: none; position: absolute; top: 100%; left: 0; width: 90%; background: var(--bg-color); border: 1px solid var(--border-color); border-radius: 0.25rem; z-index: 1000; list-style: none; padding: 0; margin: 0; max-height: 200px; overflow-y: auto;">
</ul>
</div>
<script>
document.addEventListener("DOMContentLoaded", function() {
const searchInput = document.getElementById('search-input');
const suggestionsList = document.getElementById('suggestions-list');
let debounceTimer;
if (searchInput) {
searchInput.addEventListener('input', function() {
const query = this.value;
clearTimeout(debounceTimer);
if (query.length < 2) {
suggestionsList.style.display = 'none';
return;
}
debounceTimer = setTimeout(() => {
fetch(`/api/bounty-autocomplete/?q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(data => {
suggestionsList.innerHTML = '';
if (data.results.length > 0) {
data.results.forEach(name => {
const li = document.createElement('li');
li.textContent = name;
li.style.padding = '0.5rem';
li.style.cursor = 'pointer';
li.style.borderBottom = '1px solid var(--border-color)';
li.addEventListener('mouseenter', () => {
li.style.background = 'var(--card-bg)';
});
li.addEventListener('mouseleave', () => {
li.style.background = 'transparent';
});
li.addEventListener('click', () => {
searchInput.value = name;
suggestionsList.style.display = 'none';
searchInput.form.submit();
});
suggestionsList.appendChild(li);
});
suggestionsList.style.display = 'block';
} else {
suggestionsList.style.display = 'none';
}
});
}, 300);
});
document.addEventListener('click', function(e) {
if (e.target !== searchInput && e.target !== suggestionsList) {
suggestionsList.style.display = 'none';
}
});
}
});
</script>
<button type="submit" class="btn" style="width: 100%;">Apply Filters</button>
<a href="{% url 'store:bounty_list' %}"
style="display: block; text-align: center; margin-top: 1rem; color: #94a3b8; font-size: 0.875rem; text-decoration: none;">Clear
Filters</a>
</form>
</aside>
<!-- Bounty Grid -->
<div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h2 style="margin: 0;">Bounty Board</h2>
{% if user.seller_profile %}
<a href="{% url 'store:bounty_create' %}" class="btn btn-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;">Post a Bounty</a>
{% endif %}
</div>
<div class="card-grid">
{% for bounty in bounties %}
<div class="tcg-card">
<a href="{% url 'store:bounty_detail' bounty.uuid %}" style="text-decoration: none; color: inherit;">
<div style="aspect-ratio: 2.5/3.5; background: #000; position: relative;">
{% if bounty.card.image_url %}
<img src="{{ bounty.card.image_url }}" alt="{{ bounty.card.name }}"
style="width: 100%; height: 100%; object-fit: cover;">
{% else %}
<div
style="display: flex; align-items: center; justify-content: center; height: 100%; color: #64748b; background: #334155; padding: 1rem; text-align: center;">
{{ bounty.title|default:"No Image" }}
</div>
{% endif %}
</div>
<div class="tcg-card-body">
<h4
style="margin: 0 0 0.5rem; font-size: 1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{% if bounty.card %}{{ bounty.card.name }}{% else %}{{ bounty.title }}{% endif %}
</h4>
<div
style="display: flex; justify-content: space-between; align-items: center; font-size: 0.875rem; color: #94a3b8;">
<span>{% if bounty.card %}{{ bounty.card.set.code|default:bounty.card.set.game.name }}{% else %}Wanted{% endif %}</span>
<span style="color: #10b981;">Offering ${{ bounty.target_price }}</span>
</div>
<div
style="margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 0.75rem; color: #94a3b8;">{{ bounty.offers.count }} Offers</span>
<span class="btn" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;">View Bounty</span>
</div>
</div>
</a>
</div>
{% empty %}
<div style="grid-column: 1 / -1; text-align: center; padding: 3rem; background: var(--card-bg); border-radius: 0.5rem; border: 1px solid var(--border-color);">
<p style="color: #94a3b8; font-size: 1.1rem; margin-bottom: 1rem;">No active bounties found matching your criteria.</p>
<a href="{% url 'store:bounty_list' %}" class="btn btn-outline">Clear Filters</a>
</div>
{% endfor %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,16 +1,52 @@
{% extends 'base/layout.html' %}
{% block title %}{{ card.name }} - TCGKof{% endblock %}
{% block meta_description %}Buy {{ card.name }} ({{ card.set.name }}) on TCGKof. {{ listings|length }} listings available starting from ${{ listings.first.price|default:'0.00' }}.{% endblock %}
{% block meta_keywords %}{{ card.name }}, {{ card.set.name }}, {{ card.rarity }}, {{ card.set.game.name }}, buy {{ card.name }}, sell {{ card.name }}{% endblock %}
{% block og_title %}{{ card.name }} - {{ card.set.name }} | TCGKof{% endblock %}
{% block og_description %}Buy {{ card.name }} from {{ card.set.name }} set. best prices on TCGKof.{% endblock %}
{% block og_image %}{{ card.image_url|default:'' }}{% endblock %}
{% block og_type %}product{% endblock %}
{% block twitter_title %}{{ card.name }} - {{ card.set.name }}{% endblock %}
{% block twitter_description %}Find the best deals for {{ card.name }} on TCGKof.{% endblock %}
{% block twitter_image %}{{ card.image_url|default:'' }}{% endblock %}
{% block content %}
<script type="application/ld+json">
{
"@context": "https://schema.org/",
"@type": "Product",
"name": "{{ card.name }}",
"image": "{{ card.image_url }}",
"description": "Buy {{ card.name }} from the {{ card.set.name }} set.",
"sku": "{{ card.uuid }}",
"brand": {
"@type": "Brand",
"name": "{{ card.set.game.name }}"
},
"offers": {
"@type": "AggregateOffer",
"url": "{{ request.build_absolute_uri }}",
"priceCurrency": "USD",
"lowPrice": "{{ listings.first.price|default:'0.00' }}",
"offerCount": "{{ listings|length }}",
"availability": "https://schema.org/InStock"
}
}
</script>
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 3rem;">
<!-- Image Column -->
<div>
<div
style="background: #000; border-radius: 0.75rem; overflow: hidden; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);">
style="background: var(--card-bg); border-radius: 0.75rem; overflow: hidden; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);">
{% if card.image_url %}
<img src="{{ card.image_url }}" alt="{{ card.name }}" style="width: 100%; display: block;">
{% else %}
<div
style="aspect-ratio: 2.5/3.5; display: flex; align-items: center; justify-content: center; color: #64748b; background: #334155;">
style="aspect-ratio: 2.5/3.5; display: flex; align-items: center; justify-content: center; color: var(--muted-text-color); background: var(--border-color);">
No Image</div>
{% endif %}
</div>
@@ -19,13 +55,13 @@
<div style="margin-top: 1.5rem; display: grid; gap: 0.5rem;">
{% if card.tcgplayer_id %}
<a href="https://www.tcgplayer.com/product/{{ card.tcgplayer_id }}" target="_blank"
style="display: block; text-align: center; padding: 0.75rem; background: #27272a; color: white; border-radius: 0.375rem; text-decoration: none; font-size: 0.875rem;">
style="display: block; text-align: center; padding: 0.75rem; background: var(--card-bg); color: var(--text-color); border-radius: 0.375rem; text-decoration: none; font-size: 0.875rem;">
View on TCGPlayer
</a>
{% endif %}
<a href="https://www.ebay.com/sch/i.html?_nkw={{ card.name|urlencode }}+{{ card.set.name|urlencode }}"
target="_blank"
style="display: block; text-align: center; padding: 0.75rem; background: #27272a; color: white; border-radius: 0.375rem; text-decoration: none; font-size: 0.875rem;">
style="display: block; text-align: center; padding: 0.75rem; background: var(--card-bg); color: var(--text-color); border-radius: 0.375rem; text-decoration: none; font-size: 0.875rem;">
Search on eBay
</a>
</div>
@@ -36,7 +72,7 @@
<div style="margin-bottom: 2rem;">
<h4 style="margin: 0; color: var(--primary-color);">{{ card.set.game.name }} &bull; {{ card.set.name }}</h4>
<h1 style="margin: 0.5rem 0 0; font-size: 2.5rem;">{{ card.name }}</h1>
<div style="margin-top: 1rem; display: flex; gap: 1rem; color: #94a3b8;">
<div style="margin-top: 1rem; display: flex; gap: 1rem; color: var(--muted-text-color);">
<span>Rarity: <strong style="color: var(--text-color);">{{ card.rarity }}</strong></span>
<span>Collector #: <strong style="color: var(--text-color);">{{ card.collector_number }}</strong></span>
</div>
@@ -46,7 +82,7 @@
Listings</h3>
<div style="margin-bottom: 1rem;">
<label style="font-size: 0.875rem; color: #94a3b8;">Filter Condition:</label>
<label style="font-size: 0.875rem; color: var(--muted-text-color);">Filter Condition:</label>
<div style="display: flex; gap: 0.5rem; margin-top: 0.25rem;">
<button onclick="filterCondition('ALL')" class="btn" style="padding: 0.25rem 0.75rem; font-size: 0.75rem; background: var(--card-bg); border: 1px solid var(--border-color);">All</button>
<button onclick="filterCondition('NM')" class="btn" style="padding: 0.25rem 0.75rem; font-size: 0.75rem; background: var(--card-bg); border: 1px solid var(--border-color);">NM</button>
@@ -61,30 +97,43 @@
<div class="listing-item" data-condition="{{ listing.condition }}"
style="display: flex; justify-content: space-between; align-items: center; background: var(--card-bg); padding: 1rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
<div style="display: flex; gap: 1rem; align-items: center;">
<span style="font-weight: 700; font-size: 1.25rem; width: 3rem; text-align: center;">{{
listing.condition }}</span>
{% if listing.image %}
<img src="{{ listing.image.url }}" alt="Listing Image" style="width: 50px; height: 70px; object-fit: cover; border-radius: 4px; border: 1px solid #444;">
{% endif %}
<span style="font-weight: 700; font-size: 1.25rem;">
{{ listing.condition }}</span>
<div>
<div style="font-size: 0.875rem; color: #94a3b8;">Condition</div>
<div style="font-size: 0.875rem; color: var(--muted-text-color);">Condition</div>
{% if listing.is_foil %}
<span
style="background: linear-gradient(45deg, #f59e0b, #d97706); -webkit-background-clip: text; color: transparent; font-weight: 700; font-size: 0.75rem; text-transform: uppercase;">Foil</span>
style="background: linear-gradient(45deg, #f59e0b, #d97706); -webkit-background-clip: text; background-clip: text; color: transparent; font-weight: 700; font-size: 0.75rem; text-transform: uppercase;">Foil</span>
{% endif %}
</div>
</div>
<div style="display: flex; align-items: center; gap: 2rem;">
<div style="min-width: 150px;">
<div style="font-size: 0.875rem; color: var(--muted-text-color);">Seller</div>
<div style="font-weight: 500;">
{% if listing.seller %}
<a href="{% url 'store:seller_profile' listing.seller.slug %}" style="color: var(--info-color); text-decoration: none;">{{ listing.seller.store_name }}</a>
{% else %}
<span style="color: var(--muted-text-color);">TCGKof Direct</span>
{% endif %}
</div>
</div>
<div style="text-align: right;">
<div style="font-weight: 700; font-size: 1.5rem;">${{ listing.price }}</div>
<div style="font-size: 0.75rem; color: #94a3b8;">{{ listing.quantity }} available</div>
<div style="font-size: 0.75rem; color: var(--muted-text-color);">{{ listing.quantity }} available</div>
</div>
{% if user.is_authenticated %}
<form action="{% url 'store:add_to_cart' listing.id %}" method="post">
<form action="{% url 'store:add_to_cart' listing.uuid %}" method="post">
{% csrf_token %}
<button type="submit" class="btn">Add to Cart</button>
</form>
{% else %}
<a href="{% url 'login' %}?next={{ request.path }}" class="btn" style="background: #334155;">Login
<a href="{% url 'login' %}?next={{ request.path }}" class="btn" style="background: var(--border-color);">Login
to Buy</a>
{% endif %}
</div>
@@ -92,21 +141,23 @@
{% empty %}
<div
style="text-align: center; padding: 2rem; background: var(--card-bg); border-radius: 0.5rem; border: 1px dashed var(--border-color);">
<p style="margin: 0; color: #94a3b8;">No listings currently available for this card.</p>
<p style="margin: 0; color: var(--muted-text-color);">No listings currently available for this card.</p>
</div>
{% endfor %}
</div>
<!-- Proxy Service -->
{% if FEATURE_PLAYTEST_PROXY %}
<div style="margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid var(--border-color);">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h3 style="margin: 0 0 0.5rem;">Playtest Proxy Service</h3>
<p style="margin: 0; color: #94a3b8; font-size: 0.875rem;">Download a high-res proxy for playtesting. Credit offered if you buy later.</p>
<p style="margin: 0; color: var(--muted-text-color); font-size: 0.875rem;">Download a high-res proxy for playtesting. Credit offered if you buy later.</p>
</div>
<button onclick="alert('Proxy PDF generated! (Mockup)')" class="btn" style="background: transparent; border: 1px solid var(--border-color);">Download Proxy</button>
</div>
</div>
{% endif %}
<script>
function filterCondition(cond) {

View File

@@ -2,9 +2,9 @@
{% load static %}
{% block content %}
<div style="display: grid; grid-template-columns: 250px 1fr; gap: 2rem;">
<div class="browse-container" style="display: grid; grid-template-columns: 250px 1fr; gap: 2rem;">
<!-- Sidebar Filters -->
<aside
<aside class="browse-sidebar"
style="background: var(--card-bg); padding: 1.5rem; border-radius: 0.5rem; border: 1px solid var(--border-color); height: fit-content;">
<h3 style="margin-top: 0;">Filters</h3>
<form method="get">
@@ -29,19 +29,80 @@
style="width: 100%; padding: 0.5rem; border-radius: 0.25rem; background: var(--bg-color); color: var(--text-color); border: 1px solid var(--border-color);">
<option value="">All Sets</option>
{% for set in sets %}
<option value="{{ set.id }}" {% if request.GET.set|add:"0" == set.id %}selected{% endif %}>{{ set.name
}}</option>
<option value="{{ set.id }}" {% if request.GET.set|add:"0" == set.id %}selected{% endif %}>{{ set.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div style="margin-bottom: 1rem;">
<label style="display: block; font-size: 0.875rem; margin-bottom: 0.5rem;">Search</label>
<input type="text" name="q" value="{{ search_query|default:'' }}" placeholder="Card name..."
style="width: 90%; padding: 0.5rem; border-radius: 0.25rem; background: var(--bg-color); color: var(--text-color); border: 1px solid var(--border-color);">
<div style="margin-bottom: 1rem; display: flex; align-items: center;">
<input type="checkbox" id="hide_out_of_stock" name="hide_out_of_stock" {% if hide_oos == 'on' %}checked{% endif %} onchange="this.form.submit()" style="margin-right: 0.5rem;">
<label for="hide_out_of_stock" style="font-size: 0.875rem; cursor: pointer;">Hide Out of Stock</label>
</div>
<div style="margin-bottom: 1rem; position: relative;">
<label style="display: block; font-size: 0.875rem; margin-bottom: 0.5rem;">Search</label>
<input type="text" name="q" id="search-input" value="{{ search_query|default:'' }}" placeholder="Card name..." autocomplete="off"
style="width: 90%; padding: 0.5rem; border-radius: 0.25rem; background: var(--bg-color); color: var(--text-color); border: 1px solid var(--border-color);">
<ul id="suggestions-list" style="display: none; position: absolute; top: 100%; left: 0; width: 90%; background: var(--bg-color); border: 1px solid var(--border-color); border-radius: 0.25rem; z-index: 1000; list-style: none; padding: 0; margin: 0; max-height: 200px; overflow-y: auto;">
</ul>
</div>
<script>
const searchInput = document.getElementById('search-input');
const suggestionsList = document.getElementById('suggestions-list');
let debounceTimer;
searchInput.addEventListener('input', function() {
const query = this.value;
clearTimeout(debounceTimer);
if (query.length < 2) {
suggestionsList.style.display = 'none';
return;
}
debounceTimer = setTimeout(() => {
fetch(`/api/card-autocomplete/?q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(data => {
suggestionsList.innerHTML = '';
if (data.results.length > 0) {
data.results.forEach(name => {
const li = document.createElement('li');
li.textContent = name;
li.style.padding = '0.5rem';
li.style.cursor = 'pointer';
li.style.borderBottom = '1px solid var(--border-color)';
li.addEventListener('mouseenter', () => {
li.style.background = 'var(--card-bg)';
});
li.addEventListener('mouseleave', () => {
li.style.background = 'transparent';
});
li.addEventListener('click', () => {
searchInput.value = name;
suggestionsList.style.display = 'none';
searchInput.form.submit();
});
suggestionsList.appendChild(li);
});
suggestionsList.style.display = 'block';
} else {
suggestionsList.style.display = 'none';
}
});
}, 300);
});
document.addEventListener('click', function(e) {
if (e.target !== searchInput && e.target !== suggestionsList) {
suggestionsList.style.display = 'none';
}
});
</script>
<button type="submit" class="btn" style="width: 100%;">Apply Filters</button>
<a href="{% url 'store:card_list' %}"
style="display: block; text-align: center; margin-top: 1rem; color: #94a3b8; font-size: 0.875rem; text-decoration: none;">Clear
@@ -55,7 +116,7 @@
<div class="card-grid">
{% for card in page_obj %}
<div class="tcg-card">
<a href="{% url 'store:card_detail' card.id %}" style="text-decoration: none; color: inherit;">
<a href="{% url 'store:card_detail' card.uuid %}" style="text-decoration: none; color: inherit;">
<div style="aspect-ratio: 2.5/3.5; background: #000; position: relative;">
<!-- Placeholder or Real Image -->
{% if card.image_url %}
@@ -85,7 +146,7 @@
<span style="color: #64748b;">Out of Stock</span>
{% endif %}
{% endwith %}
<span id="stock-{{ card.id }}" class="stock-counter" data-card-id="{{ card.id }}" style="font-size: 0.75rem; color: #94a3b8; margin-left: auto;">...</span>
<span id="stock-{{ card.uuid }}" class="stock-counter" data-card-id="{{ card.uuid }}" style="font-size: 0.75rem; color: #94a3b8; margin-left: auto;">...</span>
</div>
</div>
</a>
@@ -99,7 +160,7 @@
{% if page_obj.has_other_pages %}
<div style="margin-top: 2rem; display: flex; justify-content: center; gap: 0.5rem;">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}&game={{ current_game }}&q={{ search_query }}" class="btn"
<a href="?page={{ page_obj.previous_page_number }}&game={{ current_game }}&q={{ search_query }}&hide_out_of_stock={{ hide_oos }}" class="btn"
style="padding: 0.25rem 0.5rem; font-size: 0.875rem;">Prev</a>
{% endif %}
@@ -109,7 +170,7 @@
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}&game={{ current_game }}&q={{ search_query }}" class="btn"
<a href="?page={{ page_obj.next_page_number }}&game={{ current_game }}&q={{ search_query }}&hide_out_of_stock={{ hide_oos }}" class="btn"
style="padding: 0.25rem 0.5rem; font-size: 0.875rem;">Next</a>
{% endif %}
</div>

View File

@@ -7,7 +7,19 @@
{% if cart and cart.items.count > 0 %}
<div
style="background: var(--card-bg); border-radius: 0.5rem; border: 1px solid var(--border-color); overflow: hidden;">
{% for item in cart.items.all %}
<div
style="background: var(--card-bg); border-radius: 0.5rem; border: 1px solid var(--border-color); overflow: hidden;">
{% for section in cart_data %}
<div style="background: rgba(0,0,0,0.05); padding: 0.75rem 1.5rem; border-bottom: 1px solid var(--border-color); font-weight: 600;">
{% if section.seller %}
Store: <a href="{% url 'store:seller_profile' section.seller.slug %}" style="text-decoration: none; color: inherit;">{{ section.seller.store_name }}</a>
{% else %}
System Items
{% endif %}
</div>
{% for item in section.items %}
<div
style="display: flex; justify-content: space-between; align-items: center; padding: 1.5rem; border-bottom: 1px solid var(--border-color);">
<div style="display: flex; gap: 1.5rem; align-items: center;">
@@ -43,12 +55,27 @@
<div style="font-weight: 700; font-size: 1.25rem;">
${{ item.total_price }}
</div>
<a href="{% url 'store:remove_from_cart' item.id %}"
<a href="{% url 'store:remove_from_cart' item.uuid %}"
style="color: #ef4444; text-decoration: none; font-size: 1.25rem;">&times;</a>
</div>
</div>
{% endfor %}
<div style="padding: 1rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; background: rgba(0,0,0,0.02);">
<div>
{% if section.free_shipping_needed > 0 %}
<span style="color: #ef4444; font-size: 0.875rem;">Add ${{ section.free_shipping_needed }} more for free shipping!</span>
{% else %}
<span style="color: #10b981; font-size: 0.875rem;">Free Shipping Qualifies!</span>
{% endif %}
</div>
<div style="text-align: right; font-size: 0.875rem;">
<div>Subtotal: ${{ section.subtotal }}</div>
<div>Shipping: ${{ section.shipping_cost }}</div>
</div>
</div>
{% endfor %}
<div style="padding: 1rem 1.5rem; background: rgba(0,0,0,0.1); border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
<div style="display: flex; align-items: center; gap: 0.75rem;">
<a href="{% url 'store:toggle_insurance' %}" style="text-decoration: none; display: flex; align-items: center; gap: 0.5rem; color: var(--text-color);">
@@ -63,7 +90,7 @@
<div
style="padding: 1.5rem; background: rgba(0,0,0,0.2); display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 1.25rem; font-weight: 600;">Total</span>
<span style="font-size: 1.5rem; font-weight: 800;">${{ cart.total_price }}</span>
<span style="font-size: 1.5rem; font-weight: 800;">${{ grand_total }}</span>
</div>
</div>

View File

@@ -15,7 +15,7 @@
</div>
<div class="tcg-card-body" style="text-align: center;">
<h4 style="margin: 0 0 0.5rem; font-size: 1rem;">{{ pack.listing.name }}</h4>
<a href="{% url 'store:open_pack' pack.id %}" class="btn" style="width: 100%; display: block; margin-top: 1rem;">Open Pack</a>
<a href="{% url 'store:open_pack' pack.uuid %}" class="btn" style="width: 100%; display: block; margin-top: 1rem;">Open Pack</a>
</div>
</div>
{% endfor %}

View File

@@ -70,5 +70,85 @@
</div>
{% endfor %}
</div>
<!-- Rating Section -->
{% if order.seller %}
<div style="background: var(--card-bg); padding: 2rem; border-radius: 0.5rem; border: 1px solid var(--border-color); margin-top: 2rem;">
<h3 style="margin-bottom: 1rem;">Rate Your Order</h3>
{% if order.rating %}
<div style="display: flex; align-items: center; gap: 0.5rem;">
<p style="margin: 0; color: #94a3b8;">You rated this order:</p>
<div style="display: flex; gap: 0.25rem;">
{% for i in "12345" %}
{% if forloop.counter <= order.rating %}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="#fbbf24" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
</svg>
{% else %}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#64748b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
</svg>
{% endif %}
{% endfor %}
</div>
<p style="margin: 0; font-weight: 600;">{{ order.rating }}/5</p>
</div>
{% else %}
<p style="color: #94a3b8; margin-bottom: 1rem;">How would you rate your experience with {{ order.seller.store_name }}?</p>
<form method="post" style="display: flex; gap: 1rem; align-items: center;">
{% csrf_token %}
<div style="display: flex; gap: 0.5rem;" id="star-rating">
{% for i in "12345" %}
<label style="cursor: pointer;">
<input type="radio" name="rating" value="{{ forloop.counter }}" style="display: none;" class="rating-input">
<svg class="star-icon" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#64748b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="transition: all 0.2s;">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
</svg>
</label>
{% endfor %}
</div>
<button type="submit" class="btn" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border: none; opacity: 0.5; pointer-events: none;" id="submit-rating" disabled>Submit Rating</button>
</form>
<script>
const stars = document.querySelectorAll('.star-icon');
const ratingInputs = document.querySelectorAll('.rating-input');
const submitBtn = document.getElementById('submit-rating');
let selectedRating = 0;
stars.forEach((star, index) => {
star.addEventListener('mouseenter', () => {
highlightStars(index + 1);
});
star.addEventListener('click', () => {
selectedRating = index + 1;
ratingInputs[index].checked = true;
submitBtn.disabled = false;
submitBtn.style.opacity = '1';
submitBtn.style.pointerEvents = 'auto';
});
});
document.getElementById('star-rating').addEventListener('mouseleave', () => {
highlightStars(selectedRating);
});
function highlightStars(count) {
stars.forEach((star, index) => {
if (index < count) {
star.setAttribute('fill', '#fbbf24');
star.setAttribute('stroke', '#fbbf24');
} else {
star.setAttribute('fill', 'none');
star.setAttribute('stroke', '#64748b');
}
});
}
</script>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -18,9 +18,17 @@
</div>
<div class="tcg-card-body">
<h4 style="margin: 0 0 0.5rem; font-size: 1rem;">{{ pack.name }}</h4>
<div style="font-size: 0.75rem; color: #94a3b8; margin-bottom: 0.5rem;">
Sold by:
{% if pack.seller %}
<a href="{% url 'store:seller_profile' pack.seller.slug %}" style="color: #60a5fa;">{{ pack.seller.store_name }}</a>
{% else %}
TCGKof Direct
{% endif %}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1rem;">
<span style="font-weight: 700;">${{ pack.price }}</span>
<a href="{% url 'store:add_pack_to_cart' pack.id %}" class="btn" style="padding: 0.25rem 0.75rem; font-size: 0.875rem;">Add to Cart</a>
<a href="{% url 'store:add_pack_to_cart' pack.uuid %}" class="btn" style="padding: 0.25rem 0.75rem; font-size: 0.875rem;">Add to Cart</a>
</div>
</div>
</div>

View File

@@ -109,7 +109,7 @@
{% if orders %}
<div style="display: grid; gap: 1rem;">
{% for order in orders %}
<a href="{% url 'store:order_detail' order.id %}" style="text-decoration: none; color: inherit;">
<a href="{% url 'store:order_detail' order.uuid %}" style="text-decoration: none; color: inherit;">
<div
style="background: var(--card-bg); padding: 1.5rem; border-radius: 0.5rem; border: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; transition: transform 0.2s;"
onmouseover="this.style.transform='translateY(-2px)'; this.style.borderColor='var(--accent-color)'"
@@ -124,6 +124,14 @@
style="display: inline-block; padding: 0.25rem 0.5rem; border-radius: 0.25rem; font-size: 0.75rem; background: #334155; margin-top: 0.5rem;">
{{ order.get_status_display }}
</span>
{% if order.rating %}
<div style="display: flex; align-items: center; justify-content: flex-end; gap: 0.25rem; margin-top: 0.5rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="#fbbf24" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
</svg>
<span style="font-size: 0.75rem; color: #fbbf24;">{{ order.rating }}/5</span>
</div>
{% endif %}
</div>
</div>
</a>

View File

@@ -0,0 +1,83 @@
{% extends 'base/layout.html' %}
{% load static %}
{% block title %}Sell on TCGKof - Become a Seller{% endblock %}
{% block content %}
<div style="padding: 4rem 1rem; max-width: 1200px; margin: 0 auto; text-align: center;">
<!-- Hero Section -->
<div style="margin-bottom: 5rem;">
<h1 style="font-size: 3.5rem; font-weight: 800; background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin-bottom: 1.5rem; letter-spacing: -0.025em;">
Unlock the Value of Your Collection
</h1>
<p style="font-size: 1.25rem; color: var(--text-muted, #94a3b8); max-width: 600px; margin: 0 auto 2.5rem; line-height: 1.6;">
Join the fastest-growing TCG marketplace. Turn your extra cards into cash with powerful tools designed for sellers of all sizes.
</p>
<div style="display: flex; gap: 1rem; justify-content: center;">
<a href="{% url 'users:register' %}" class="btn" style="padding: 1rem 2.5rem; font-size: 1.1rem;">Start Selling Today</a>
<a href="#benefits" class="btn btn-outline" style="padding: 1rem 2.5rem; font-size: 1.1rem;">Learn More</a>
</div>
</div>
<!-- Stats Section -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 2rem; margin-bottom: 5rem; padding: 2rem; background: rgba(255, 255, 255, 0.03); border-radius: 1rem; border: 1px solid rgba(255, 255, 255, 0.1);">
<div>
<div style="font-size: 2.5rem; font-weight: 700; color: #f59e0b;">$2M+</div>
<div style="color: var(--text-muted, #94a3b8); margin-top: 0.5rem;">Paid to Sellers</div>
</div>
<div>
<div style="font-size: 2.5rem; font-weight: 700; color: #f59e0b;">50k+</div>
<div style="color: var(--text-muted, #94a3b8); margin-top: 0.5rem;">Active Buyers</div>
</div>
<div>
<div style="font-size: 2.5rem; font-weight: 700; color: #f59e0b;">24h</div>
<div style="color: var(--text-muted, #94a3b8); margin-top: 0.5rem;">Average Payout Time</div>
</div>
</div>
<!-- Benefits Section -->
<div id="benefits" style="margin-bottom: 5rem; text-align: left;">
<h2 style="font-size: 2.5rem; font-weight: 700; text-align: center; margin-bottom: 3rem;">Why Sell on TCGKof?</h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 2rem;">
<!-- Benefit 1 -->
<div style="padding: 2rem; background: var(--bg-card, #1e293b); border-radius: 1rem; border: 1px solid var(--border-color, #334155); transition: transform 0.2s;">
<div style="width: 50px; height: 50px; background: rgba(245, 158, 11, 0.1); border-radius: 12px; display: flex; align-items: center; justify-content: center; margin-bottom: 1.5rem; color: #f59e0b;">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"></line><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path></svg>
</div>
<h3 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 1rem;">Low Selling Fees</h3>
<p style="color: var(--text-muted, #94a3b8); line-height: 1.6;">Keep more of your profit with our straightforward 5% commission rate, one of the lowest in the industry.</p>
</div>
<!-- Benefit 2 -->
<div style="padding: 2rem; background: var(--bg-card, #1e293b); border-radius: 1rem; border: 1px solid var(--border-color, #334155); transition: transform 0.2s;">
<div style="width: 50px; height: 50px; background: rgba(245, 158, 11, 0.1); border-radius: 12px; display: flex; align-items: center; justify-content: center; margin-bottom: 1.5rem; color: #f59e0b;">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
</div>
<h3 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 1rem;">Massive Audience</h3>
<p style="color: var(--text-muted, #94a3b8); line-height: 1.6;">Instantly connect with thousands of collectors actively looking for the cards you have in your binder.</p>
</div>
<!-- Benefit 3 -->
<div style="padding: 2rem; background: var(--bg-card, #1e293b); border-radius: 1rem; border: 1px solid var(--border-color, #334155); transition: transform 0.2s;">
<div style="width: 50px; height: 50px; background: rgba(245, 158, 11, 0.1); border-radius: 12px; display: flex; align-items: center; justify-content: center; margin-bottom: 1.5rem; color: #f59e0b;">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
</div>
<h3 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 1rem;">Secure Protection</h3>
<p style="color: var(--text-muted, #94a3b8); line-height: 1.6;">Our Seller Protection Guarantee ensures you're covered against fraud, chargebacks, and shipping issues.</p>
</div>
</div>
</div>
<!-- CTA Section -->
<div style="padding: 4rem; background: linear-gradient(135deg, rgba(245, 158, 11, 0.1) 0%, rgba(30, 41, 59, 0) 100%); border-radius: 2rem; border: 1px solid rgba(245, 158, 11, 0.2);">
<h2 style="font-size: 2rem; font-weight: 700; margin-bottom: 1rem;">Ready to Get Started?</h2>
<p style="color: var(--text-muted, #94a3b8); margin-bottom: 2rem; max-width: 500px; margin-left: auto; margin-right: auto;">
Create your account in less than 2 minutes and list your first card today. It's completely free to join.
</p>
<a href="{% url 'users:register' %}" class="btn" style="padding: 1rem 3rem; font-size: 1.1rem; box-shadow: 0 4px 14px 0 rgba(245, 158, 11, 0.39);">Create Seller Account</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,37 @@
{% extends 'base/layout.html' %}
{% load static %}
{% block title %}Seller Dashboard - TCGKof{% endblock %}
{% block content %}
<div class="dashboard-container" style="padding: 2rem 0;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<h1 style="font-size: 2rem; font-weight: 700;">Seller Dashboard</h1>
<a href="{% url 'store:card_list' %}" class="btn btn-outline">View Marketplace</a>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1.5rem; margin-bottom: 3rem;">
<!-- Stat Card 1 -->
<div style="background: var(--bg-card, #1e293b); padding: 1.5rem; border-radius: 1rem; border: 1px solid var(--border-color, #334155);">
<div style="color: var(--text-muted, #94a3b8); font-size: 0.875rem; margin-bottom: 0.5rem;">Total Sales</div>
<div style="font-size: 1.5rem; font-weight: 700;">$0.00</div>
</div>
<!-- Stat Card 2 -->
<div style="background: var(--bg-card, #1e293b); padding: 1.5rem; border-radius: 1rem; border: 1px solid var(--border-color, #334155);">
<div style="color: var(--text-muted, #94a3b8); font-size: 0.875rem; margin-bottom: 0.5rem;">Active Listings</div>
<div style="font-size: 1.5rem; font-weight: 700;">0</div>
</div>
<!-- Stat Card 3 -->
<div style="background: var(--bg-card, #1e293b); padding: 1.5rem; border-radius: 1rem; border: 1px solid var(--border-color, #334155);">
<div style="color: var(--text-muted, #94a3b8); font-size: 0.875rem; margin-bottom: 0.5rem;">Orders to Ship</div>
<div style="font-size: 1.5rem; font-weight: 700;">0</div>
</div>
</div>
<div style="background: var(--bg-card, #1e293b); padding: 2rem; border-radius: 1rem; border: 1px solid var(--border-color, #334155); text-align: center;">
<h2 style="font-size: 1.5rem; font-weight: 600; margin-bottom: 1rem;">Start Listing</h2>
<p style="color: var(--text-muted, #94a3b8); margin-bottom: 1.5rem;">You don't have any active listings yet. Add your cards to the marketplace to start selling.</p>
<button class="btn" disabled style="opacity: 0.5; cursor: not-allowed;">Add New Listing (Coming Soon)</button>
</div>
</div>
{% endblock %}

View File

@@ -12,6 +12,14 @@
<!-- Filters -->
<div style="margin-bottom: 2rem; background: var(--card-bg); padding: 1rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
<form method="get" style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: center;">
<div style="position: relative;">
<label for="search-input" style="margin-right: 0.5rem; font-size: 0.875rem;">Search:</label>
<input type="text" name="q" id="search-input" value="{{ search_query|default:'' }}" placeholder="Card name..." autocomplete="off"
style="padding: 0.25rem 0.5rem; border-radius: 0.25rem; background: var(--bg-color); color: var(--text-color); border: 1px solid var(--border-color);">
<ul id="suggestions-list" style="display: none; position: absolute; top: 100%; left: 0; width: 100%; background: var(--bg-color); border: 1px solid var(--border-color); border-radius: 0.25rem; z-index: 1000; list-style: none; padding: 0; margin: 0; max-height: 200px; overflow-y: auto;">
</ul>
</div>
<div>
<label for="set" style="margin-right: 0.5rem; font-size: 0.875rem;">Set:</label>
<select name="set" id="set" style="padding: 0.25rem 0.5rem; border-radius: 0.25rem; background: var(--bg-color); color: var(--text-color); border: 1px solid var(--border-color);">
@@ -35,6 +43,66 @@
</form>
</div>
<script>
document.addEventListener("DOMContentLoaded", function() {
const searchInput = document.getElementById('search-input');
const suggestionsList = document.getElementById('suggestions-list');
let debounceTimer;
if (searchInput) {
searchInput.addEventListener('input', function() {
const query = this.value;
clearTimeout(debounceTimer);
if (query.length < 2) {
suggestionsList.style.display = 'none';
return;
}
debounceTimer = setTimeout(() => {
// Correct URL for users app API
fetch(`/users/api/vault-autocomplete/?q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(data => {
suggestionsList.innerHTML = '';
if (data.results.length > 0) {
data.results.forEach(name => {
const li = document.createElement('li');
li.textContent = name;
li.style.padding = '0.5rem';
li.style.cursor = 'pointer';
li.style.borderBottom = '1px solid var(--border-color)';
li.addEventListener('mouseenter', () => {
li.style.background = 'var(--card-bg)';
});
li.addEventListener('mouseleave', () => {
li.style.background = 'transparent';
});
li.addEventListener('click', () => {
searchInput.value = name;
suggestionsList.style.display = 'none';
searchInput.form.submit();
});
suggestionsList.appendChild(li);
});
suggestionsList.style.display = 'block';
} else {
suggestionsList.style.display = 'none';
}
});
}, 300);
});
document.addEventListener('click', function(e) {
if (e.target !== searchInput && e.target !== suggestionsList) {
suggestionsList.style.display = 'none';
}
});
}
});
</script>
{% if vault_items %}
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1.5rem;">
{% for item in vault_items %}

View File

@@ -1,3 +1,32 @@
from django.contrib import admin
from .models import User, Profile, Address, PaymentMethod, Buyer
# Register your models here.
from django.contrib.auth.admin import UserAdmin
from .models import User, Profile, Address, PaymentMethod, Buyer
admin.site.register(User, UserAdmin)
@admin.register(Profile)
class ProfileAdmin(admin.ModelAdmin):
list_display = ['user', 'theme_preference', 'is_pro', 'is_seller']
list_select_related = ['user']
search_fields = ['user__username', 'user__email']
@admin.register(Address)
class AddressAdmin(admin.ModelAdmin):
list_display = ['user', 'name', 'address_type', 'city', 'state']
list_select_related = ['user']
list_filter = ['address_type']
search_fields = ['user__username', 'name', 'street']
@admin.register(PaymentMethod)
class PaymentMethodAdmin(admin.ModelAdmin):
list_display = ['user', 'brand', 'last4', 'is_default']
list_select_related = ['user']
search_fields = ['user__username']
@admin.register(Buyer)
class BuyerAdmin(admin.ModelAdmin):
list_display = ['user', 'created_at']
list_select_related = ['user']
search_fields = ['user__username', 'user__email']

View File

@@ -1,4 +1,4 @@
# Generated by Django 6.0.1 on 2026-01-19 13:38
# Generated by Django 6.0.1 on 2026-01-21 17:40
import django.contrib.auth.models
import django.contrib.auth.validators
@@ -43,12 +43,47 @@ class Migration(migrations.Migration):
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='Address',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('street', models.CharField(max_length=200)),
('city', models.CharField(max_length=100)),
('state', models.CharField(max_length=100)),
('zip_code', models.CharField(max_length=20)),
('address_type', models.CharField(choices=[('shipping', 'Shipping'), ('billing', 'Billing')], default='shipping', max_length=20)),
('is_default', models.BooleanField(default=False)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addresses', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Buyer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='buyer_profile', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='PaymentMethod',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('brand', models.CharField(max_length=50)),
('last4', models.CharField(max_length=4)),
('exp_month', models.PositiveIntegerField()),
('exp_year', models.PositiveIntegerField()),
('is_default', models.BooleanField(default=False)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payment_methods', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Profile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_pro', models.BooleanField(default=False)),
('theme_preference', models.CharField(choices=[('light', 'Light'), ('dark', 'Dark')], default='dark', max_length=10)),
('is_seller', models.BooleanField(default=False)),
('theme_preference', models.CharField(choices=[('light', 'Light'), ('dark', 'Dark'), ('compact', 'Compact'), ('expressive', 'Expressive'), ('technical', 'Technical')], default='dark', max_length=10)),
('shipping_address', models.TextField(blank=True)),
('stripe_customer_id', models.CharField(blank=True, max_length=100, null=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),

View File

@@ -1,41 +0,0 @@
# Generated by Django 6.0.1 on 2026-01-20 11:08
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Address',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('street', models.CharField(max_length=200)),
('city', models.CharField(max_length=100)),
('state', models.CharField(max_length=100)),
('zip_code', models.CharField(max_length=20)),
('address_type', models.CharField(choices=[('shipping', 'Shipping'), ('billing', 'Billing')], default='shipping', max_length=20)),
('is_default', models.BooleanField(default=False)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addresses', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='PaymentMethod',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('brand', models.CharField(max_length=50)),
('last4', models.CharField(max_length=4)),
('exp_month', models.PositiveIntegerField()),
('exp_year', models.PositiveIntegerField()),
('is_default', models.BooleanField(default=False)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payment_methods', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -10,10 +10,14 @@ class Profile(models.Model):
THEME_CHOICES = (
('light', 'Light'),
('dark', 'Dark'),
('compact', 'Compact'),
('expressive', 'Expressive'),
('technical', 'Technical'),
)
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
is_pro = models.BooleanField(default=False)
is_seller = models.BooleanField(default=False)
theme_preference = models.CharField(max_length=10, choices=THEME_CHOICES, default='dark')
# Keeping this simple text field for legacy/simple addressing, whilst adding robust Address model below
shipping_address = models.TextField(blank=True)
@@ -60,3 +64,20 @@ class PaymentMethod(models.Model):
def __str__(self):
return f"{self.brand} ending in {self.last4}"
class Buyer(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='buyer_profile')
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.user.username
@receiver(post_save, sender=User)
def create_buyer_profile(sender, instance, created, **kwargs):
# We only auto-create Buyer if NOT a seller?
# Or do we create it manually in views?
# The requirement says "/user/register creates a buy account and /sell/register creates a seller account"
# So we probably shouldn't auto-create blindly for EVERY user if we want them distinct.
# However, easy way is to create it manually in the view.
# I will NOT add a signal here to avoid auto-creation when we might want a pure Seller.
pass

View File

@@ -7,9 +7,13 @@ urlpatterns = [
path('register/', views.RegisterView.as_view(), name='register'),
path('profile/', views.profile_view, name='profile'),
path('vault/', views.vault_view, name='vault'),
path('api/vault-autocomplete/', views.vault_autocomplete, name='vault_autocomplete'),
path('upgrade/', views.upgrade_account_view, name='upgrade_account'),
path('profile/address/add/', views.add_address_view, name='add_address'),
path('profile/payment/add/', views.add_payment_method_view, name='add_payment_method'),
path('profile/address/delete/<int:pk>/', views.delete_address_view, name='delete_address'),
path('profile/payment/delete/<int:pk>/', views.delete_payment_method_view, name='delete_payment_method'),
path('sell-on-tcgkof/', views.sell_on_tcgkof_view, name='sell_on_tcgkof'),
path('dashboard/', views.seller_dashboard_view, name='seller_dashboard'),
path('login-success/', views.login_success_view, name='login_success'),
]

View File

@@ -1,4 +1,5 @@
from django.shortcuts import render, redirect, get_object_or_404
from django.http import JsonResponse
from django.contrib.auth import login
from .forms import CustomUserCreationForm, ProfileForm, AddressForm, PaymentMethodForm
from django.views.generic import CreateView
@@ -12,12 +13,26 @@ class RegisterView(CreateView):
model = User
form_class = CustomUserCreationForm
template_name = 'users/register.html'
success_url = reverse_lazy('home')
success_url = reverse_lazy('users:login_success')
def form_valid(self, form):
user = form.save()
# Create Buyer profile for every new user registered via this form
from .models import Buyer
Buyer.objects.create(user=user)
login(self.request, user)
return redirect('home')
return redirect('users:login_success')
@login_required
def seller_dashboard_view(request):
return render(request, 'users/seller_dashboard.html')
@login_required
def login_success_view(request):
if hasattr(request.user, 'profile') and request.user.profile.is_seller:
return redirect('store:seller_dashboard')
return redirect('home')
@login_required
def profile_view(request):
@@ -31,9 +46,12 @@ def profile_view(request):
form = ProfileForm(instance=request.user.profile)
# Order filtering
orders = request.user.orders.all().order_by('-created_at')
orders = []
if hasattr(request.user, 'buyer_profile'):
orders = request.user.buyer_profile.orders.all().order_by('-created_at')
date_query = request.GET.get('date')
if date_query:
if date_query and orders:
orders = orders.filter(created_at__date=date_query)
addresses = request.user.addresses.all()
@@ -110,11 +128,20 @@ def delete_payment_method_view(request, pk):
@login_required
def vault_view(request):
vault_items = VaultItem.objects.filter(user=request.user).select_related('card', 'card__set').order_by('-added_at')
if not hasattr(request.user, 'buyer_profile'):
# If not a buyer (e.g. seller), maybe show empty or redirect?
# For now, let's just show empty list
vault_items = VaultItem.objects.none()
else:
vault_items = VaultItem.objects.filter(buyer=request.user.buyer_profile).select_related('card', 'card__set').order_by('-added_at')
# Filtering
set_id = request.GET.get('set')
rarity = request.GET.get('rarity')
search_query = request.GET.get('q')
if search_query:
vault_items = vault_items.filter(card__name__icontains=search_query)
if set_id:
vault_items = vault_items.filter(card__set_id=set_id)
@@ -125,7 +152,10 @@ def vault_view(request):
# Get options for filters
# We only want sets and rarities that are actually in the user's vault
user_card_ids = VaultItem.objects.filter(user=request.user).values_list('card_id', flat=True)
if hasattr(request.user, 'buyer_profile'):
user_card_ids = VaultItem.objects.filter(buyer=request.user.buyer_profile).values_list('card_id', flat=True)
else:
user_card_ids = []
available_sets = Set.objects.filter(cards__id__in=user_card_ids).distinct()
available_rarities = Card.objects.filter(id__in=user_card_ids).values_list('rarity', flat=True).distinct()
@@ -135,5 +165,26 @@ def vault_view(request):
'available_sets': available_sets,
'available_rarities': available_rarities,
'current_set': int(set_id) if set_id else None,
'current_rarity': rarity
'current_rarity': rarity,
'search_query': search_query
})
@login_required
def vault_autocomplete(request):
if not hasattr(request.user, 'buyer_profile'):
return JsonResponse({'results': []})
query = request.GET.get('q', '')
if len(query) < 2:
return JsonResponse({'results': []})
# Filter items in user's vault matching the query
items = VaultItem.objects.filter(
buyer=request.user.buyer_profile,
card__name__icontains=query
).values_list('card__name', flat=True).distinct()[:10]
return JsonResponse({'results': list(items)})
def sell_on_tcgkof_view(request):
return render(request, 'users/sell_on_tcgkof.html')

168
uv.lock generated
View File

@@ -11,6 +11,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" },
]
[[package]]
name = "beautifulsoup4"
version = "4.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
]
[[package]]
name = "black"
version = "26.1.0"
@@ -43,6 +56,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/3d/51bdb3ecbfadfaf825ec0c75e1de6077422b4afa2091c6c9ba34fbfc0c2d/black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede", size = 204010, upload-time = "2026-01-18T04:50:09.978Z" },
]
[[package]]
name = "bs4"
version = "0.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beautifulsoup4" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/aa/4acaf814ff901145da37332e05bb510452ebed97bc9602695059dd46ef39/bs4-0.0.2.tar.gz", hash = "sha256:a48685c58f50fe127722417bae83fe6badf500d54b55f7e39ffe43b798653925", size = 698, upload-time = "2024-01-17T18:15:47.371Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/bb/bf7aab772a159614954d84aa832c129624ba6c32faa559dfb200a534e50b/bs4-0.0.2-py2.py3-none-any.whl", hash = "sha256:abf8742c0805ef7f662dce4b51cca104cffe52b835238afc169142ab9b3fbccc", size = 1189, upload-time = "2024-01-17T18:15:48.613Z" },
]
[[package]]
name = "certifi"
version = "2026.1.4"
@@ -52,6 +77,63 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
@@ -204,6 +286,62 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" },
]
[[package]]
name = "cryptography"
version = "46.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
{ url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
{ url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
{ url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
{ url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
{ url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
{ url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
{ url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
{ url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
{ url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
{ url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
{ url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
{ url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
{ url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
{ url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
{ url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" },
{ url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" },
{ url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
{ url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
{ url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
{ url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
{ url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
{ url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
{ url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
{ url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
{ url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
{ url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" },
{ url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" },
{ url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
{ url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
{ url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
{ url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
{ url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
{ url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
{ url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
{ url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
{ url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
{ url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
{ url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
{ url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
]
[[package]]
name = "django"
version = "6.0.1"
@@ -224,26 +362,32 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "black" },
{ name = "bs4" },
{ name = "coverage" },
{ name = "cryptography" },
{ name = "django" },
{ name = "faker" },
{ name = "pillow" },
{ name = "pytest-cov" },
{ name = "pytest-django" },
{ name = "requests" },
{ name = "sitemap" },
{ name = "stripe" },
]
[package.metadata]
requires-dist = [
{ name = "black", specifier = ">=26.1.0" },
{ name = "bs4", specifier = ">=0.0.2" },
{ name = "coverage", specifier = ">=7.13.1" },
{ name = "cryptography", specifier = ">=46.0.3" },
{ name = "django", specifier = ">=6.0.1" },
{ name = "faker", specifier = ">=40.1.2" },
{ name = "pillow", specifier = ">=12.1.0" },
{ name = "pytest-cov", specifier = ">=7.0.0" },
{ name = "pytest-django", specifier = ">=4.11.1" },
{ name = "requests", specifier = ">=2.32.5" },
{ name = "sitemap", specifier = ">=20191121" },
{ name = "stripe", specifier = ">=14.2.0" },
]
@@ -391,6 +535,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
@@ -486,6 +639,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "sitemap"
version = "20191121"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/df/4b9c43a9ae042c634d7d19e3a172fc1a88bd139cade8050b3e87d5f3a839/sitemap-20191121.tar.gz", hash = "sha256:00570c0306f697c8786262e105822418bb7bce4826d862991932fad5117002e6", size = 2254, upload-time = "2019-11-20T06:36:21.534Z" }
[[package]]
name = "soupsieve"
version = "2.8.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
]
[[package]]
name = "sqlparse"
version = "0.5.5"