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

@@ -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