Fix create trade offer flow and other related bugs

This commit is contained in:
badblocks 2025-03-26 11:38:02 -07:00
parent f3a1366269
commit 65ca344582
40 changed files with 867 additions and 278 deletions

View file

@ -3,6 +3,8 @@ from django.contrib.auth.forms import UserCreationForm, UserChangeForm
from .models import CustomUser, FriendCode
from allauth.account.forms import SignupForm
from crispy_tailwind.tailwind import CSSContainer
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field, Submit
class CustomUserChangeForm(UserChangeForm):
@ -80,3 +82,8 @@ class CustomUserCreationForm(SignupForm):
user.default_friend_code = friend_code_pk
user.save()
return user
class UserSettingsForm(forms.ModelForm):
class Meta:
model = CustomUser
fields = ['show_friend_code_on_link_previews']

View file

@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2025-03-20 00:08
# Generated by Django 5.1.2 on 2025-03-22 04:08
import django.contrib.auth.models
import django.contrib.auth.validators
@ -31,6 +31,7 @@ class Migration(migrations.Migration):
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('show_friend_code_on_link_previews', models.BooleanField(default=False, help_text='This will primarily affect share link previews on X, Discord, etc.', verbose_name='Show Friend Code on Link Previews')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],

View file

@ -4,6 +4,11 @@ from django.core.exceptions import ValidationError
class CustomUser(AbstractUser):
default_friend_code = models.ForeignKey("FriendCode", on_delete=models.SET_NULL, null=True, blank=True)
show_friend_code_on_link_previews = models.BooleanField(
default=False,
verbose_name="Show Friend Code on Link Previews",
help_text="This will primarily affect share link previews on X, Discord, etc."
)
def __str__(self):
return self.email

View file

@ -3,8 +3,8 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.shortcuts import redirect, get_object_or_404
from django.views.generic import ListView, CreateView, DeleteView, View, TemplateView, UpdateView
from accounts.models import FriendCode
from accounts.forms import FriendCodeForm
from accounts.models import FriendCode, CustomUser
from accounts.forms import FriendCodeForm, UserSettingsForm
from django.db.models import Case, When, Value, BooleanField
class ListFriendCodesView(LoginRequiredMixin, ListView):
@ -123,11 +123,23 @@ class ChangeDefaultFriendCodeView(LoginRequiredMixin, View):
messages.success(request, "Default friend code updated successfully.")
return redirect("list_friend_codes")
class SettingsView(LoginRequiredMixin, TemplateView):
# Updated SettingsView to update the new user setting.
class SettingsView(LoginRequiredMixin, UpdateView):
"""
Display the user's settings.
Display account navigation links and allow the user to update their friend code
visibility setting.
"""
model = CustomUser
form_class = UserSettingsForm
template_name = "account/settings.html"
success_url = reverse_lazy("settings")
def get_object(self):
return self.request.user
def form_valid(self, form):
messages.success(self.request, "Settings updated successfully.")
return super().form_valid(form)
class ProfileView(LoginRequiredMixin, TemplateView):
"""

View file

@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2025-03-20 00:08
# Generated by Django 5.1.2 on 2025-03-22 04:08
import django.db.models.deletion
from django.db import migrations, models

View file

@ -1,5 +1,14 @@
from django.urls import path
from .views import (
CardDetailView,
TradeOfferHaveCardListView,
TradeOfferWantCardListView,
)
app_name = "cards"
urlpatterns = [
path('<int:pk>/', CardDetailView.as_view(), name='card_detail'),
path('<int:pk>/trade-offers-have/', TradeOfferHaveCardListView.as_view(), name='card_trade_offer_have_list'),
path('<int:pk>/trade-offers-want/', TradeOfferWantCardListView.as_view(), name='card_trade_offer_want_list'),
]

View file

@ -1,4 +1,64 @@
from django.views.generic import TemplateView
from django.urls import reverse_lazy
from django.views.generic import UpdateView, DeleteView, CreateView, ListView, DetailView
from cards.models import Card
from trades.models import TradeOffer
class CardDetailView(DetailView):
model = Card
template_name = "cards/card_detail.html"
context_object_name = "card"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
card = self.get_object()
# Count of trade offers where the card appears as a "have" in a trade.
context['trade_offer_have_count'] = TradeOffer.objects.filter(
trade_offer_have_cards__card=card
).distinct().count()
# Count of trade offers where the card appears as a "want" in a trade.
context['trade_offer_want_count'] = TradeOffer.objects.filter(
trade_offer_want_cards__card=card
).distinct().count()
return context
class TradeOfferHaveCardListView(ListView):
model = TradeOffer
template_name = "cards/_trade_offer_list.html"
context_object_name = "trade_offers"
paginate_by = 2
def get_queryset(self):
card_id = self.kwargs.get("pk")
order_param = self.request.GET.get("order", "newest")
ordering = "-updated_at" if order_param == "newest" else "updated_at"
return TradeOffer.objects.filter(
trade_offer_have_cards__card_id=card_id
).order_by(ordering).distinct()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['side'] = 'have'
return context
class TradeOfferWantCardListView(ListView):
model = TradeOffer
template_name = "cards/_trade_offer_list.html"
context_object_name = "trade_offers"
paginate_by = 2
def get_queryset(self):
card_id = self.kwargs.get("pk")
order_param = self.request.GET.get("order", "newest")
ordering = "-updated_at" if order_param == "newest" else "updated_at"
return TradeOffer.objects.filter(
trade_offer_want_cards__card_id=card_id
).order_by(ordering).distinct()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['side'] = 'want'
return context

View file

@ -57,7 +57,6 @@ INSTALLED_APPS = [
"home",
"trades.apps.TradesConfig",
"meta",
#"silk",
]
SILKY_PYTHON_PROFILER = True

View file

@ -60,6 +60,7 @@ class HomePageView(TemplateView):
for rarity_level, rarity_icon in distinct_rarities:
offers = base_offer_qs.filter(rarity_level=rarity_level).order_by("created_at")[:6]
rarity_offers.append((rarity_level, rarity_icon, offers))
print(rarity_offers)
# Sort by rarity_level (from greatest to least)
rarity_offers.sort(key=lambda x: x[0], reverse=True)

View file

@ -1,3 +1,11 @@
[x-cloak] { display: none !important; }
select.card-multiselect {
height: calc(var(--spacing) * 35);
/*background-image: linear-gradient(45deg, #0000 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, #0000 50%); */
background-image: none;
}
.choices.is-disabled .choices__inner,
.choices.is-disabled .choices__input {
background-color: var(--color-neutral);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 484 B

After

Width:  |  Height:  |  Size: 549 B

Before After
Before After

View file

@ -1,20 +1,137 @@
const $ = x => Array.from(document.querySelectorAll(x));
const $$ = x => Array.from(document.querySelector(x));
/* global window, document, localStorage */
document.addEventListener('DOMContentLoaded', function() {
const themeToggleBtn = document.getElementById('theme-toggle-btn');
if (themeToggleBtn) {
themeToggleBtn.addEventListener('click', function() {
const root = document.documentElement;
if (root.classList.contains("dark")) {
root.classList.remove("dark");
root.setAttribute("data-theme", "light");
localStorage.setItem("theme", "light");
const $ = selector => Array.from(document.querySelectorAll(selector));
const $$ = selector => Array.from(document.querySelector(selector));
(() => {
"use strict";
/**
* Initialize the theme toggle button functionality.
* Toggles between 'dark' and 'light' themes and persists the state in localStorage.
*/
function initThemeToggle() {
const themeToggleButton = document.getElementById("theme-toggle-btn");
if (!themeToggleButton) return;
themeToggleButton.addEventListener("click", () => {
const documentRoot = document.documentElement;
const isDarkTheme = documentRoot.classList.contains("dark");
const newTheme = isDarkTheme ? "light" : "dark";
if (newTheme === "light") {
documentRoot.classList.remove("dark");
} else {
root.classList.add("dark");
root.setAttribute("data-theme", "dark");
localStorage.setItem("theme", "dark");
documentRoot.classList.add("dark");
}
documentRoot.setAttribute("data-theme", newTheme);
localStorage.setItem("theme", newTheme);
});
}
/**
* Initialize event listeners for forms containing multiselect fields.
* When the form is submitted, process each 'card-multiselect' to create hidden inputs.
*/
function initCardMultiselectHandling() {
const forms = document.querySelectorAll("form");
forms.forEach(form => {
if (form.querySelector("select.card-multiselect")) {
form.addEventListener("submit", () => {
processMultiselectForm(form);
});
}
});
}
/**
* Process multiselect fields within a form before submission by:
* - Creating hidden inputs for each selected option with value in 'card_id:quantity' format.
* - Removing the original name attribute to avoid duplicate submissions.
*
* @param {HTMLFormElement} form - The form element to process.
*/
function processMultiselectForm(form) {
const multiselectFields = form.querySelectorAll("select.card-multiselect");
multiselectFields.forEach(selectField => {
const originalFieldName =
selectField.getAttribute("data-original-name") || selectField.getAttribute("name");
if (!originalFieldName) return;
selectField.setAttribute("data-original-name", originalFieldName);
// Remove any previously generated hidden inputs for this multiselect.
form
.querySelectorAll(`input[data-generated-for-card-multiselect="${originalFieldName}"]`)
.forEach(input => input.remove());
// For each selected option, create a hidden input.
selectField.querySelectorAll("option:checked").forEach(option => {
const cardId = option.value;
const quantity = option.getAttribute("data-quantity") || "1";
const hiddenInput = document.createElement("input");
hiddenInput.type = "hidden";
hiddenInput.name = originalFieldName;
hiddenInput.value = `${cardId}:${quantity}`;
hiddenInput.setAttribute("data-generated-for-card-multiselect", originalFieldName);
form.appendChild(hiddenInput);
});
// Prevent the browser from submitting the select field directly.
selectField.removeAttribute("name");
});
}
/**
* Reset stale selections in all card multiselect fields.
* This is triggered on the window's 'pageshow' event to clear any lingering selections.
*/
function resetCardMultiselectState() {
const multiselectFields = document.querySelectorAll("select.card-multiselect");
multiselectFields.forEach(selectField => {
// Deselect all options.
selectField.querySelectorAll("option").forEach(option => {
option.selected = false;
});
// If the select field has an associated Choices.js instance, clear its selection.
if (selectField.choicesInstance) {
const activeSelections = selectField.choicesInstance.getValue(true);
if (activeSelections.length > 0) {
selectField.choicesInstance.removeActiveItemsByValue(activeSelections);
}
selectField.choicesInstance.setValue([]);
}
});
}
// On DOMContentLoaded, initialize theme toggling and form processing.
document.addEventListener("DOMContentLoaded", () => {
initThemeToggle();
initCardMultiselectHandling();
});
// On pageshow, only reset multiselect state if the page was loaded from bfcache.
window.addEventListener("pageshow", function(event) {
if (event.persisted) {
resetCardMultiselectState();
}
});
// Expose tradeOfferCard globally if not already defined.
if (!window.tradeOfferCard) {
window.tradeOfferCard = function() {
return {
flipped: false,
badgeExpanded: false,
acceptanceExpanded: false,
/**
* Update the badge's expanded state.
*
* @param {boolean} expanded - The new state of the badge.
*/
setBadge(expanded) {
this.badgeExpanded = expanded;
},
};
};
}
})();

View file

@ -41,6 +41,16 @@ module.exports = {
*/
// '../../**/*.py'
],
safelist: [
'alert-info',
'alert-success',
'alert-warning',
'alert-error',
'bg-info',
'bg-success',
'bg-warning',
'bg-error',
],
theme: {
extend: {},
},

View file

@ -1,9 +1,9 @@
{% if messages %}
<div class="flex flex-col gap-2">
{% for message in messages %}
<div class="alert {% if message.tags %}alert-{{ message.tags }}{% else %}alert-info{% endif %} mb-4 flex justify-between items-center">
<div class="alert {% if message.tags %}alert-{{ message.tags }} text-(--color-{{ message.tags }}-content){% else %}alert-info text-(--color-info-content){% endif %} font-semibold mb-4 flex justify-between items-center">
<span>{{ message }}</span>
<button class="btn btn-xs btn-circle" onclick="this.parentElement.remove();" aria-label="Dismiss"></button>
<button class="btn btn-xs btn-circle border-none bg-black/20" onclick="this.parentElement.remove();" aria-label="Dismiss"></button>
</div>
{% endfor %}
</div>

View file

@ -8,15 +8,19 @@
<h1 class="text-3xl font-semibold text-center mb-6">{% trans "Profile" %}</h1>
<div class="card card-border bg-base-100 shadow-lg w-4/5 mx-auto">
<div class="card-body">
<div class="mx-auto mb-4">{{ user.email|gravatar:100 }}</div>
<div class="hovercard-preview">
<iframe src="https://gravatar.com/{{ user.email|gravatar_hash }}.card" width="100%" height="344px" style="border:0; margin:0; padding:0;" onload="resizeIframe(this)"></iframe>
</div>
<p class="text-center">All profile information is managed through Gravatar.</p>
<p class="text-center mt-4">
<a href="https://gravatar.com/profile/" target="_blank" rel="noopener noreferrer" class="btn btn-secondary">
<div class="text-center mt-4 flex flex-col gap-4 mx-auto">
<a href="https://gravatar.com/profile/" target="_blank" rel="noopener noreferrer" class="btn btn-primary">
Edit Profile on Gravatar
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a></p>
</a>
<a href="{% url 'settings' %}" class="btn btn-secondary">Back to Settings</a>
</div>
<div class="divider"></div>
<h2 class="text-lg font-semibold pt-0">What is Gravatar?</h2>
<p class="mb-4">Gravatar (Globally Recognized Avatar) is a free service that links your email address to a profile picture and, optionally, a profile. Many websites, including this one, use Gravatar to display your avatar and profile automatically.</p>
@ -24,6 +28,9 @@
<h2 class="text-lg font-semibold">How does it work?</h2>
<p class="mb-4">If you've set up a Gravatar, your profile picture will appear here whenever you use your email on supported sites. When someone hovers over or clicks on your avatar, your Gravatar profile will also appear if you have one. If you don't have a Gravatar yet, you'll see a default image instead.</p>
<h2 class="text-lg font-semibold">Is it safe? What about privacy?</h2>
<p class="mb-4">Gravatar is completely optional, opt-in, and prioritizes your security and privacy. Your email is never shared and only a hashed version is sent to Gravatar, protecting your identity while ensuring that your email address is not exposed to bots or scrapers. Your personal data remains secure, and you maintain full control over your public profile.</p>
<h2 class="text-lg font-semibold">Want to update or add a Gravatar?</h2>
<p class="mb-4">Go to Gravatar.com to set up or change your avatar or profile. Your updates will appear here once saved!</p>
</div>

View file

@ -1,19 +1,31 @@
{% extends 'base.html' %}
{% load i18n %}
{% load i18n crispy_forms_tags %}
{% block head_title %}{% trans "Settings" %}{% endblock %}
{% block content %}
<div class="container mx-auto">
<div class="container mx-auto space-y-8">
<h1 class="text-3xl font-bold text-center mb-6">{% trans "Settings" %}</h1>
<div class="card card-border bg-base-100 shadow-lg w-4/5 mx-auto">
<div class="flex flex-col gap-6 w-full mx-auto p-6">
<a href="{% url 'profile' %}" class="btn btn-secondary">
<!-- Account Navigation Section -->
<div class="card card-border bg-base-100 shadow-lg w-4/5 mx-auto p-6">
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button type="submit" class="w-full btn btn-success mt-4">
{% trans "Save Settings" %}
</button>
</form>
<div class="divider my-4"></div>
<div class="flex flex-col gap-4">
<div class="flex flex-row gap-4">
<a href="{% url 'profile' %}" class="btn btn-secondary w-20 grow-1">
{% trans "Profile" %}
</a>
<a href="{% url 'list_friend_codes' %}" class="btn btn-primary">
<a href="{% url 'list_friend_codes' %}" class="btn btn-primary w-20 grow-1">
{% trans "Friend Codes" %}
</a>
</div>
<a href="{% url 'account_logout' %}" class="btn btn-warning">
{% trans "Sign Out" %}
</a>

View file

@ -46,11 +46,13 @@
<!-- Import the hovercards library -->
<script src="https://unpkg.com/@gravatar-com/hovercards@0.10.8"></script>
<script src="{% static 'js/base.js' %}"></script>
{% block css %}{% endblock %}
{% block javascript_head %}{% endblock %}
</head>
<div class="min-h-screen bg-base-200">
<body class="min-h-screen bg-base-200" id="body">
<!-- Header and Navigation -->
<div class="navbar bg-base-100 shadow-sm">
<div class="navbar-start">
@ -76,7 +78,7 @@
<span class="inline-block relative align-text-top">P</span><span class="inline-block relative align-text-bottom">K</span><span class="inline-block relative align-text-top">M</span><span class="inline-block relative align-text-bottom">N</span>
<span class="inline-block relative">Trade Club</span>
</span>
<span aria-hidden="false" class="sr-only">Pokemon Trade Club</span>
<span aria-hidden="false" class="sr-only">PKMN Trade Club</span>
</a>
</div>
<div class="navbar-center hidden md:flex">
@ -114,13 +116,8 @@
tabindex="0"
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-32 p-2 shadow">
<li>
<a class="flex items-center justify-between" href="{% url 'profile' %}">
Profile
</a>
</li>
<li>
<a class="justify-between" href="{% url 'list_friend_codes' %}">
Friend Codes
<a class="justify-between" href="{% url 'settings' %}">
Settings
</a>
</li>
<li><a href="{% url 'account_logout' %}">Sign Out</a></li>
@ -143,7 +140,7 @@
<!-- Footer -->
<footer class="bg-base-200 text-base-content p-4">
<div class="container mx-auto text-center">
<div class="container mx-auto text-center text-sm">
<p>&copy; {% now "Y" %} PKMNTrade.Club. All rights reserved.</p>
</div>
</footer>
@ -181,7 +178,7 @@
document.addEventListener('DOMContentLoaded', function() {
if (typeof Gravatar !== 'undefined' && typeof Gravatar.Hovercards !== 'undefined') {
const hovercards = new Gravatar.Hovercards({
myHash: '{{ user.email|gravatar_hash }}'
myHash: '{{ user.email|gravatar_hash }}',
});
hovercards.attach( document.body, { ignoreSelector: 'img[src*="gravatar.com/avatar/"].ignore' } );
}
@ -197,7 +194,7 @@
// 'Internal Server Error.': 'Erreur interne du serveur.',
// }
</script>
<script defer src="{% static 'js/base.js' %}"></script>
{% block javascript %}{% endblock %}
</body>

View file

@ -0,0 +1,35 @@
{% load trade_offer_tags %}
{% if trade_offers %}
<div class="flex flex-col">
{% for offer in trade_offers %}
<div class="mb-4">
{% render_trade_offer offer %}
</div>
{% endfor %}
</div>
{% if is_paginated %}
<div class="flex justify-between items-center mt-4">
{% if page_obj.has_previous %}
<button type="button" class="btn btn-sm"
@click="$dispatch('change-page-{{ side }}', { page: {{ page_obj.previous_page_number }} })">
Previous
</button>
{% else %}
<span></span>
{% endif %}
{% if paginator.num_pages > 1 %}
<span class="text-sm">Page {{ page_obj.number }} of {{ paginator.num_pages }}</span>
{% endif %}
{% if page_obj.has_next %}
<button type="button" class="btn btn-sm"
@click="$dispatch('change-page-{{ side }}', { page: {{ page_obj.next_page_number }} })">
Next
</button>
{% else %}
<span></span>
{% endif %}
</div>
{% endif %}
{% else %}
<p class="text-gray-500">No trade offers found.</p>
{% endif %}

View file

@ -0,0 +1,27 @@
{% load trade_offer_tags %}
{% if trade_offers %}
<div class="grid grid-cols-1 gap-4">
{% for offer in trade_offers %}
<div class="mb-4">
{% render_trade_offer offer %}
</div>
{% endfor %}
</div>
<div class="flex justify-between items-center mt-4">
{% if trade_offers.has_previous %}
<button type="button" class="btn btn-sm" @click="$dispatch('change-page', { page: {{ trade_offers.previous_page_number }} })">Previous</button>
{% else %}
<span></span>
{% endif %}
{% if trade_offers.paginator.num_pages > 1 %}
<span class="text-sm">Page {{ trade_offers.number }} of {{ trade_offers.paginator.num_pages }}</span>
{% endif %}
{% if trade_offers.has_next %}
<button type="button" class="btn btn-sm" @click="$dispatch('change-page', { page: {{ trade_offers.next_page_number }} })">Next</button>
{% else %}
<span></span>
{% endif %}
</div>
{% else %}
<p class="text-gray-500">No trade offers found.</p>
{% endif %}

View file

@ -0,0 +1,91 @@
{% extends "base.html" %}
{% load static card_badge %}
{% block content %}
<div class="container mx-auto p-4">
<!-- Card header with badge and details -->
<div class="flex items-center mb-6">
<div class="ml-4">
<h1 class="text-3xl font-bold">{{card.name}}</h1>
<h2 class="text-lg text-gray-500">{{ card.cardset }} #{{ card.cardnum }} &bull; {{ card.rarity_icon }}</h2>
</div>
</div>
<!-- Trade Offers sections -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Trade Offers: Have -->
<div x-data="{
order: 'newest',
page: 1,
loadOffers() {
document.activeElement.blur();
fetch(`{% url 'cards:card_trade_offer_have_list' card.pk %}?order=` + this.order + '&page=' + this.page)
.then(response => response.text())
.then(html => { this.$refs.offerList.innerHTML = html; });
}
}"
x-init="loadOffers()"
x-on:change-page-have.window="page = $event.detail.page; loadOffers()"
class="p-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold">Have this Card ({{ trade_offer_have_count }})</h2>
<!-- DaisyUI dropdown replacing the select -->
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn m-1" x-text="order === 'newest' ? 'Newest 🞃' : 'Oldest 🞃'"></div>
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-1 w-26 p-2 shadow-sm">
<li>
<a href="#" @click.prevent="order = 'newest'; page = 1; loadOffers()">
Newest
</a>
</li>
<li>
<a href="#" @click.prevent="order = 'oldest'; page = 1; loadOffers()">
Oldest
</a>
</li>
</ul>
</div>
</div>
<div x-ref="offerList">
</div>
</div>
<!-- Trade Offers: Want -->
<div x-data="{
order: 'newest',
page: 1,
loadOffers() {
fetch(`{% url 'cards:card_trade_offer_want_list' card.pk %}?order=` + this.order + '&page=' + this.page)
.then(response => response.text())
.then(html => { this.$refs.offerList.innerHTML = html; });
}
}"
x-init="loadOffers()"
x-on:change-page.window="page = $event.detail.page; loadOffers()"
class="p-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold">Want this Card ({{ trade_offer_want_count }})</h2>
<!-- DaisyUI dropdown replacing the select -->
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn m-1" x-text="order === 'newest' ? 'Newest 🞃' : 'Oldest 🞃'"></div>
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-1 w-26 p-2 shadow-sm">
<li>
<a href="#" @click.prevent="order = 'newest'; page = 1; loadOffers()">
Newest
</a>
</li>
<li>
<a href="#" @click.prevent="order = 'oldest'; page = 1; loadOffers()">
Oldest
</a>
</li>
</ul>
</div>
</div>
<div x-ref="offerList">
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -35,7 +35,8 @@
<p>You do not have any friend codes added yet.</p>
{% endif %}
<div class="mt-4">
<div class="mt-4 flex flex-row justify-between">
<a href="{% url 'settings' %}" class="btn btn-secondary">Back to Settings</a>
<a href="{% url 'add_friend_code' %}" class="btn btn-primary">Add a New Friend Code</a>
</div>
</div>

View file

@ -12,9 +12,9 @@
<div class="mx-4 grid gap-3 grid-cols-[repeat(auto-fit,minmax(150px,1fr))] justify-items-center">
{% for card in cards %}
{% if mode == "offered" %}
<a href="?offered_cards={{ card.id }}"
<a href="{% url 'cards:card_detail' card.id %}"
{% else %}
<a href="?wanted_cards={{ card.id }}"
<a href="{% url 'cards:card_detail' card.id %}"
{% endif %}
class="flex justify-between items-center text-primary no-underline">
{% card_badge card card.offer_count %}

View file

@ -8,12 +8,12 @@
<span class="inline-block relative align-text-top">P</span><span class="inline-block relative align-text-bottom">K</span><span class="inline-block relative align-text-top">M</span><span class="inline-block relative align-text-bottom">N</span>
<span class="inline-block relative">Trade Club</span>
</span>
<span aria-hidden="false" class="sr-only">Welcome to Pokemon Trade Club</span>
<span aria-hidden="false" class="sr-only">Welcome to PKMN Trade Club</span>
</h1>
<!-- Search Form Section -->
<!-- Search/Create Form Section -->
<section id="trade-search" class="mb-8">
<form method="post" action="{% url 'trade_offer_search' %}" class="space-y-4">
<form id="tradeSearchForm" method="post" class="space-y-4">
{% csrf_token %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
@ -23,26 +23,28 @@
{% card_multiselect "want_cards" "I Want:" "Select some cards..." cards want_cards %}
</div>
</div>
{# Pass the user's default friend code as a hidden field for creation #}
<input type="hidden" name="initiated_by" value="{{ request.user.default_friend_code.pk }}">
<div class="flex flex-col md:flex-row gap-4">
<button type="submit" class="btn btn-primary grow">
<button type="submit" name="search" formaction="{% url 'trade_offer_search' %}" class="btn btn-primary grow">
Find a Trade Offer
</button>
<a href="{% url 'trade_offer_create' %}" id="createTradeOfferBtn" class="btn btn-secondary grow">
<button type="submit" name="preview" formaction="{% url 'trade_offer_confirm_create' %}" class="btn btn-secondary grow">
Create Trade Offer
</a>
</button>
</div>
</form>
</section>
<!-- Market Stats Section -->
<!-- Card Stats Section -->
<section aria-labelledby="stats-heading" class="mb-8">
<h2 id="stats-heading" class="text-2xl font-semibold mb-4">Market Stats</h2>
<h2 id="stats-heading" class="text-2xl font-semibold mb-4">Card Stats</h2>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
<!-- Most Offered Cards -->
<div>
<div class="card card-border bg-base-100 shadow-lg">
<div class="card-header text-base-content p-4">
<h5 class="text-xl text-center font-semibold whitespace-nowrap truncate mb-0">Most Offered Cards</h5>
<h5 class="text-xl text-center font-semibold whitespace-nowrap truncate mb-0">Most Offered</h5>
</div>
<div class="card-body my-4 p-0">
{% cache 3600 most_offered_cards %}
@ -55,7 +57,7 @@
<div>
<div class="card card-border bg-base-100 shadow-lg">
<div class="card-header text-base-content p-4">
<h5 class="text-xl text-center font-semibold whitespace-nowrap truncate mb-0">Most Wanted Cards</h5>
<h5 class="text-xl text-center font-semibold whitespace-nowrap truncate mb-0">Most Wanted</h5>
</div>
<div class="card-body my-4 p-0">
{% cache 3600 most_wanted_cards %}
@ -68,7 +70,7 @@
<div class="col-span-2 md:col-span-1">
<div class="card card-border bg-base-100 shadow-lg">
<div class="card-header text-base-content p-4">
<h5 class="text-xl text-center font-semibold whitespace-nowrap truncate mb-0">Least Offered Cards</h5>
<h5 class="text-xl text-center font-semibold whitespace-nowrap truncate mb-0">Least Offered</h5>
</div>
<div class="card-body my-4 p-0">
{% cache 3600 least_offered_cards %}
@ -160,39 +162,6 @@
{% block javascript %}
<script defer>
document.addEventListener('DOMContentLoaded', function() {
// Updated: JS to carry over selections (including quantities) to the Create Trade Offer page.
const createBtn = document.getElementById('createTradeOfferBtn');
if (createBtn) {
createBtn.addEventListener('click', function(e) {
e.preventDefault();
// Use the standardized field names for both "have_cards" and "want_cards"
const haveSelect = document.querySelector('select[name="have_cards"]');
const wantSelect = document.querySelector('select[name="want_cards"]');
const url = new URL(createBtn.href, window.location.origin);
if (haveSelect) {
// For each selected option, include the quantity from data-quantity (defaulting to "1")
const selectedHave = Array.from(haveSelect.selectedOptions).map(opt => {
const cardId = opt.value;
const quantity = opt.getAttribute('data-quantity') || '1';
return cardId + ':' + quantity;
});
selectedHave.forEach(val => url.searchParams.append('have_cards', val));
}
if (wantSelect) {
const selectedWant = Array.from(wantSelect.selectedOptions).map(opt => {
const cardId = opt.value;
const quantity = opt.getAttribute('data-quantity') || '1';
return cardId + ':' + quantity;
});
selectedWant.forEach(val => url.searchParams.append('want_cards', val));
}
window.location.href = url.href;
});
}
// Minimal JavaScript for toggling Featured Offers tabs
const featuredTabs = document.querySelectorAll('input[name="featured_tabs"]');
const featuredTabContents = document.querySelectorAll('#featured-tab-contents .tab-content');
@ -202,11 +171,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (radio.checked) {
const target = radio.id;
featuredTabContents.forEach(content => {
if (content.getAttribute('data-tab') === target) {
content.style.display = 'block';
} else {
content.style.display = 'none';
}
content.style.display = content.getAttribute('data-tab') === target ? 'block' : 'none';
});
}
});

View file

@ -0,0 +1,37 @@
{% load tailwind_field %}
{% if field.is_hidden %}
{{ field }}
{% else %}
{# Opening Div and Label first #}
<{% if tag %}{{ tag }}{% else %}div{% endif %} id="div_{{ field.auto_id }}" class="{% if wrapper_class %}{{ wrapper_class }} {% endif %}{% if field_class %}{{ field_class }}{% else %}mb-3{% endif %}">
{% if field.label and form_show_labels %}
<label for="{{ field.id_for_label }}" class="{% if label_class %}{{ label_class }}{% else %}block text-base-content bg-base-100 text-sm font-bold mb-2{% endif %}">
{{ field.label|safe }}{% if field.field.required %}<span class="asteriskField">*</span>{% endif %}
</label>
{% endif %}
{# if field has a special template then use this #}
{% if field|is_select %}
<div class="{% if field_class %}{{ field_class }}{% else %}mb-3{% endif %}"{% if flat_attrs %} {{ flat_attrs|safe }}{% endif %}>
{% include 'tailwind/layout/select.html' %}
</div>
{% elif field|is_checkboxselectmultiple %}
<div class="{% if field_class %}{{ field_class }}{% else %}mb-3{% endif %}"{% if flat_attrs %} {{ flat_attrs|safe }}{% endif %}>
{% include 'tailwind/layout/checkboxselectmultiple.html' %}
</div>
{% elif field|is_radioselect %}
<div class="{% if field_class %}{{ field_class }}{% else %}mb-3{% endif %}"{% if flat_attrs %} {{ flat_attrs|safe }}{% endif %}>
{% include 'tailwind/layout/radioselect.html' %}
</div>
{% else %}
{# otherwise use django rendering with additional classes added #}
{% tailwind_field field %}
{% endif %}
{% include 'tailwind/layout/help_text_and_errors.html' %}
</{% if tag %}{{ tag }}{% else %}div{% endif %}>
{% endif %}

View file

@ -0,0 +1,7 @@
{% if field.help_text %}
{% if help_text_inline %}
<p {% if field.id_for_label %}id="{{ field.id_for_label }}_helptext" {% endif %}class="text-base-content bg-base-100">{{ field.help_text|safe }}</p>
{% else %}
<small {% if field.id_for_label %}id="{{ field.id_for_label }}_helptext" {% endif %}class="text-base-content bg-base-100">{{ field.help_text|safe }}</small>
{% endif %}
{% endif %}

View file

@ -1,5 +1,5 @@
{% load trade_offer_tags %}
{% if offered_cards or wanted_cards %}
{% if have_cards or want_cards %}
<hr class="my-8 border-t border-base-300">
<h2 class="text-2xl font-bold mb-4">Results</h2>
{% if search_results %}

View file

@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% load static card_badge trade_offer_tags %}
{% block title %}Confirm Trade Offer{% endblock title %}
{% block content %}
<div class="container mx-auto max-w-xl mt-6">
<h2 class="text-2xl font-bold mb-4">Confirm Trade Offer</h2>
<form method="post">
{% csrf_token %}
{# Re-create hidden inputs from POST data, except the preview button #}
{% for key, values in post_data.lists %}
{% for value in values %}
{% if key != "preview" %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endif %}
{% endfor %}
{% endfor %}
{% render_trade_offer dummy_trade_offer False False True %}
<div class="flex justify-between mt-4">
<button type="submit" name="edit" class="btn btn-secondary">Edit</button>
<button type="submit" name="confirm" class="btn btn-primary">Confirm Trade Offer</button>
</div>
</form>
</div>
{% endblock content %}

View file

@ -6,7 +6,7 @@
{% block content %}
<div class="container mx-auto max-w-xl mt-6">
<h2 class="text-2xl font-bold mb-4">Create a Trade Offer</h2>
<form method="post" novalidate class="space-y-4">
<form method="post" action="{% url 'trade_offer_confirm_create' %}" novalidate class="space-y-4">
{% csrf_token %}
{% include "trades/_friend_code_select.html" with friend_codes=friend_codes selected_friend_code=selected_friend_code field_name=form.initiated_by.html_name label="Initiated by" %}
@ -14,14 +14,18 @@
<!-- Card Selectors: "Have" and "Want" -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
{% card_multiselect "have_cards" "I Have:" "Select some cards..." cards form.initial.have_cards %}
{% with have_values=form.have_cards.value|default:form.initial.have_cards %}
{% card_multiselect "have_cards" "I Have:" "Select some cards..." cards have_values %}
{% endwith %}
</div>
<div class="form-control">
{% card_multiselect "want_cards" "I Want:" "Select some cards..." cards form.initial.want_cards %}
{% with want_values=form.want_cards.value|default:form.initial.want_cards %}
{% card_multiselect "want_cards" "I Want:" "Select some cards..." cards want_values %}
{% endwith %}
</div>
</div>
<button type="submit" class="btn btn-primary w-full">Create Offer</button>
<button type="submit" name="preview" class="btn btn-primary w-full">Preview Trade Offer</button>
</form>
{% if form.errors %}
<div class="alert alert-error mt-4">

View file

@ -8,9 +8,9 @@
<h2 class="text-2xl font-bold">Trade Offer Details</h2>
<div class="flex justify-center mt-10">
{% if screenshot_mode == "true" %}
{% render_trade_offer object True %}
{% render_trade_offer object True show_friend_code %}
{% else %}
{% render_trade_offer object %}
{% render_trade_offer object False False True %}
{% endif %}
</div>
{% if acceptance_form %}
@ -55,7 +55,7 @@
{% if is_initiator %}
<a href="{{ delete_close_url }}" class="btn btn-danger">Delete/Close Trade Offer</a>
{% elif request.user.is_authenticated %}
<button type="submit" class="btn btn-primary">Submit Acceptance</button>
<button type="submit" class="btn btn-primary">Accept Offer</button>
{% endif %}
</form>
</div>

View file

@ -10,21 +10,21 @@
{% csrf_token %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
{% card_multiselect "offered_cards" "Have:" "Select zero or more cards..." cards offered_cards %}
{% card_multiselect "have_cards" "I Have:" "Select zero or more cards..." cards have_cards %}
</div>
<div>
{% card_multiselect "wanted_cards" "Want:" "Select zero or more cards..." cards wanted_cards %}
{% card_multiselect "want_cards" "I Want:" "Select zero or more cards..." cards want_cards %}
</div>
</div>
<div class="flex flex-col md:flex-row gap-4">
<button type="submit" class="btn btn-primary flex-1">Find a Trade Offer</button>
<button type="submit" class="btn btn-primary">Find a Trade Offer</button>
</div>
</form>
</section>
<!-- Search Results Section -->
<section id="search-results" class="mb-8">
{% include "trades/_search_results.html" %}
{% include "trades/_search_results.html" with search_results=search_results %}
</section>
{% endblock content %}

View file

@ -1,10 +1,8 @@
<div class="card-badge relative inline-block">
<div class="card-badge-inner freeze-bg-color grid grid-cols-4 grid-rows-2 my-2 px-2 py-2 h-16 w-36 text-white shadow-md shadow-black/50" style="{{ style }}">
<div class="card-badge-inner freeze-bg-color grid grid-cols-4 grid-rows-2 my-2 px-2 py-2 h-14 w-36 text-white shadow-md shadow-black/50" style="{{ style }}">
<div class="cardname row-span-1 col-span-4 truncate text-ellipsis self-start font-semibold leading-tight text-sm max-w-7/8">{{ name }}</div>
<div class="rarity row-start-2 col-span-2 truncate self-end align-bottom text-xs">{{ rarity }}</div>
<div class="cardset row-start-2 col-start-3 col-span-2 text-right truncate self-end align-bottom font-semibold leading-none text-sm">{{ cardset }}</div>
</div>
<span class="card-quantity-badge freeze-bg-color absolute top-3.5 right-1 bg-gray-600 text-white text-xs font-semibold rounded-full px-2">
{{ quantity }}
</span>
{% if quantity %}<span class="card-quantity-badge freeze-bg-color absolute top-3.75 right-1.5 bg-gray-600 text-white text-sm font-semibold rounded-full px-1.5">{{ quantity }}</span>{% endif %}
</div>

View file

@ -2,7 +2,7 @@
<label for="{{ field_id }}" class="label">
<span class="label-text">{{ label }}</span>
</label>
<select name="{{ field_name }}" id="{{ field_id }}" class="select select-bordered w-full card-multiselect" data-placeholder="{{ placeholder }}" multiple>
<select name="{{ field_name }}" id="{{ field_id }}" class="select select-bordered w-full card-multiselect" data-placeholder="{{ placeholder }}" multiple x-cloak>
{% cache cache_timeout card_multiselect selected_values|join:"," %}
<option value="" disabled>{{ placeholder }}</option>
{% for card in cards %}
@ -27,19 +27,17 @@ if (!window.updateGlobalCardFilters) {
console.log("updateGlobalCardFilters called.");
const selects = document.querySelectorAll('.card-multiselect');
// Rebuilt every call to updateGlobalCardFilters.
// Rebuild global selections and rarity filtering.
const globalSelectedIds = [];
let globalRarity = null;
selects.forEach(select => {
const selectedValues = select.choicesInstance ? select.choicesInstance.getValue(true) : [];
selectedValues.forEach(cardId => {
if (cardId && globalSelectedIds.indexOf(cardId) === -1) {
globalSelectedIds.push(cardId);
}
});
if (selectedValues.length > 0 && globalRarity === null) {
const option = select.querySelector(`option[value="${selectedValues[0]}"]`);
if (option) {
@ -52,15 +50,15 @@ if (!window.updateGlobalCardFilters) {
selects.forEach(select => {
if (select.choicesInstance && select.choicesInstance.dropdown.element) {
// reset all options to enabled.
// Reset all options to enabled.
select.querySelectorAll('option').forEach(function(option) {
option.disabled = false;
});
// reset all items to visible.
// Reset all items to visible.
select.choicesInstance.dropdown.element.querySelectorAll('[data-card-id]').forEach(function(item) {
item.style.display = '';
});
// filter out options and items that don't match the global rarity.
// Filter out options/items that do not match the global rarity.
if (globalRarity) {
select.querySelectorAll('option[data-rarity]:not([data-rarity="'+globalRarity+'"])').forEach(function(option) {
option.disabled = true;
@ -69,8 +67,7 @@ if (!window.updateGlobalCardFilters) {
item.style.display = 'none';
});
}
// filter out options and items that match the global selected card IDs.
// Filter out options/items that match the global selected card IDs.
for (const cardId of globalSelectedIds) {
select.choicesInstance.dropdown.element.querySelectorAll('[data-card-id="' + cardId + '"]').forEach(function(item) {
item.style.display = 'none';
@ -192,7 +189,6 @@ document.addEventListener('DOMContentLoaded', function() {
quantity = quantity + 1;
quantityBadge.innerText = quantity;
updateOptionQuantity(container, quantity);
}
}
if (e.target.classList.contains('decrement')) {
@ -234,7 +230,6 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
// Update the option's value by rewriting the "card:qty" string.
function updateOptionQuantity(item, quantity) {
const cardId = item.getAttribute('data-card-id');
console.log("Updating option quantity for card", cardId, "to", quantity);
@ -251,42 +246,9 @@ document.addEventListener('DOMContentLoaded', function() {
return option ? parseInt(option.getAttribute('data-quantity')) : 1;
}
// Initial global filters update on page load.
if (window.updateGlobalCardFilters) {
if (choicesInstance.getValue(true).length > 0 && window.updateGlobalCardFilters) {
window.updateGlobalCardFilters();
}
// Attach the form submit event by locating the parent form of the select field.
const form = selectField.closest('form');
if (form) {
form.addEventListener('submit', function(e) {
// Remove any previously generated hidden inputs to avoid duplicates on resubmission.
const generatedInputs = form.querySelectorAll('input[data-generated-for-card-multiselect]');
generatedInputs.forEach(input => input.remove());
// Iterate over all selected options.
const selectedOptions = selectField.querySelectorAll('option:checked');
selectedOptions.forEach(function(option) {
const cardId = option.value; // The static card ID
const quantity = option.getAttribute('data-quantity') || '1';
// Create a hidden input that mimics the multi-select behavior.
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
// Using the same name as the multi-select means the POST data will contain an array of values for `{{ field_name }}`.
hiddenInput.name = '{{ field_name }}';
hiddenInput.value = cardId + ':' + quantity;
// Mark this input as generated by our script.
hiddenInput.setAttribute('data-generated-for-card-multiselect', 'true');
form.appendChild(hiddenInput);
});
// Remove the name attribute from the actual select to prevent duplicate submission.
selectField.removeAttribute('name');
console.log("Form submission: generated hidden inputs for selected cards.");
});
}
});
</script>
@ -295,6 +257,18 @@ document.addEventListener('DOMContentLoaded', function() {
height: inherit;
padding-inline-start: 0;
}
.choices__inner {
border: 1px solid var(--color-gray-500) !important;
}
.choices__list {
border: none !important;
}
.choices__list--dropdown {
border-left: 1px solid var(--color-gray-500) !important;
border-right: 1px solid var(--color-gray-500) !important;
border-bottom: 1px solid var(--color-gray-500) !important;
border-top: none !important;
}
.choices.select[data-type*="select-one"]::after {
display: none;
}

View file

@ -1,21 +1,6 @@
{% load gravatar card_badge cache %}
{% cache 60 trade_offer offer_pk %}
<script>
if (!window.tradeOfferCard) {
window.tradeOfferCard = function() {
return {
flipped: false,
badgeExpanded: false,
acceptanceExpanded: false,
// Helper method to set the badgeExpanded state
setBadge(expanded) {
this.badgeExpanded = expanded;
},
};
}
}
</script>
<div class="trade-offer-card-screenshot p-4 h-full w-auto flex justify-center"
{% if screenshot_mode %}
x-data="{
@ -33,7 +18,7 @@
}"
x-init="setDimension(); window.addEventListener('resize', setDimension)"
{% endif %}>
<div x-data="tradeOfferCard()" class="transition-all duration-500 trade-offer-card my-auto"
<div x-data="tradeOfferCard()" x-init="badgeExpanded = {{expanded|lower}}" class="transition-all duration-500 trade-offer-card my-auto"
@toggle-all.window="setBadge($event.detail.expanded)">
<!-- Flip container providing perspective -->
@ -72,10 +57,10 @@
<!-- Main Trade Offer Row -->
<div class="flip-face-body self-start">
<a href="{% url 'trade_offer_detail' pk=offer_pk %}" class="no-underline block">
<div class="px-2 pb-0 main-badges">
<div class="px-2 main-badges {% if not screenshot_mode and have_cards_available|length == 1 and want_cards_available|length == 1 %}py-[14px]{%else%}pb-0{% endif %}">
{% if screenshot_mode and num_cards_available >= 4 %}
<!-- When screenshot_mode is true, use an outer grid with 3 columns: Has side, a vertical divider, and Wants side -->
<div class="flex flex-row gap-2 justify-between">
<div class="flex flex-row gap-2 justify-around">
<!-- Has Side (inner grid of 2 columns) -->
<div class="flex flex-row gap-2">
{% for card in have_cards_available|slice:"0:2" %}
@ -95,7 +80,7 @@
</div>
{% else %}
<!-- Normal mode: just use an outer grid with 2 columns -->
<div class="flex flex-row gap-2 {% if not screenshot_mode %}justify-between{% else %}justify-around{% endif %}">
<div class="flex flex-row gap-2 justify-around">
<!-- Has Side -->
<div class="flex flex-col gap-2">
{% for card in have_cards_available|slice:"0:1" %}
@ -117,9 +102,9 @@
{% if screenshot_mode and num_cards_available >= 4 %}
<div class="px-2 extra-badges">
<!-- In screenshot mode, add a vertical divider between the Has and Wants sides -->
<div class="flex flex-row gap-2 justify-between">
<div class="flex flex-row gap-2 justify-around">
<!-- Has Side Extra Badges -->
<div class="grid grid-cols-2 gap-2">
<div class="grid grid-cols-2 gap-2 {% if screenshot_mode and num_cards_available >= 4 %}w-[296px]{% endif %}">
{% for card in have_cards_available|slice:"2:" %}
{% card_badge card.card card.quantity %}
{% endfor %}
@ -129,7 +114,7 @@
<div class="w-px bg-gray-300 h-full"></div>
</div>
<!-- Wants Side Extra Badges -->
<div class="grid grid-cols-2 gap-2">
<div class="grid grid-cols-2 gap-2 {% if screenshot_mode and num_cards_available >= 4 %}w-[296px]{% endif %}">
{% for card in want_cards_available|slice:"2:" %}
{% card_badge card.card card.quantity %}
{% endfor %}
@ -137,9 +122,9 @@
</div>
</div>
{% else %}
<div {% if screenshot_mode %}x-show="badgeExpanded" x-collapse.duration.500ms{% endif %} class="px-2 extra-badges">
<div {% if not screenshot_mode %}x-show="badgeExpanded" x-collapse.duration.500ms x-cloak{% endif %} class="px-2 extra-badges">
<a href="{% url 'trade_offer_detail' pk=offer_pk %}" class="no-underline block">
<div class="flex flex-row gap-2 {% if not screenshot_mode %}justify-between{% else %}justify-around{% endif %}">
<div class="flex flex-row gap-2 justify-around">
<!-- Has Side Extra Badges -->
<div class="flex flex-col gap-2">
{% for card in have_cards_available|slice:"1:" %}
@ -157,17 +142,14 @@
</div>
{% endif %}
</div>
{% if not screenshot_mode %}
<div class="flex justify-center h-5">
{% if have_cards_available|length > 1 or want_cards_available|length > 1 %}
<svg @click="badgeExpanded = !badgeExpanded"
x-bind:class="{ 'rotate-180': badgeExpanded }"
{% if not screenshot_mode and have_cards_available|length > 1 and want_cards_available|length > 1 %}
<div @click="badgeExpanded = !badgeExpanded" class="flex justify-center h-5 cursor-pointer">
<svg x-bind:class="{ 'rotate-180': badgeExpanded }"
class="transition-transform duration-500 h-5 w-5 cursor-pointer"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 9l-7 7-7-7" />
</svg>
{% endif %}
</div>
{% endif %}
{% if not screenshot_mode %}
@ -193,7 +175,7 @@
{% else %}
<div class="flip-face-footer self-end">
<div class="flex flex-col gap-2 text-center">
<div class="text-sm font-semibold text-base-content">{{ in_game_name }} <span class="text-base-content/50">&bull;</span> {{ friend_code }}</div>
<div class="text-sm font-semibold text-base-content">{{ in_game_name }} {% if show_friend_code %}<span class="text-base-content/50">&bull;</span> {{ friend_code }}{% endif %}</div>
</div>
</div>
{% endif %}

View file

@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2025-03-20 00:08
# Generated by Django 5.1.2 on 2025-03-22 04:08
import django.db.models.deletion
from django.db import migrations, models

23
trades/mixins.py Normal file
View file

@ -0,0 +1,23 @@
from cards.models import Card
class TradeOfferContextMixin:
def get_context_data(self, **kwargs):
# Start with any context passed in.
context = kwargs.copy()
# Include available cards requirements for multiselect fields.
context.setdefault("cards", Card.objects.all().order_by("name", "rarity_level"))
# Provide friend_codes and selected_friend_code as in TradeOfferCreateView
friend_codes = self.request.user.friend_codes.all()
context["friend_codes"] = friend_codes
if "initiated_by" in self.request.GET:
try:
selected_friend_code = friend_codes.get(pk=self.request.GET.get("initiated_by"))
except friend_codes.model.DoesNotExist:
selected_friend_code = self.request.user.default_friend_code or friend_codes.first()
else:
selected_friend_code = self.request.user.default_friend_code or friend_codes.first()
context["selected_friend_code"] = selected_friend_code
return context

View file

@ -1,13 +1,16 @@
from django.db import models
from django.core.exceptions import ValidationError
from django.db.models import Q, Count, Prefetch, F, Sum
from django.db.models import Q, Count, Prefetch, F, Sum, Max
import hashlib
from cards.models import Card
from accounts.models import FriendCode
from datetime import timedelta
from django.utils import timezone
class TradeOfferManager(models.Manager):
def get_queryset(self):
queryset = super().get_queryset().select_related(
qs = super().get_queryset().select_related(
"initiated_by__user",
).prefetch_related(
"trade_offer_have_cards__card",
@ -16,8 +19,11 @@ class TradeOfferManager(models.Manager):
"acceptances__requested_card",
"acceptances__offered_card",
"acceptances__accepted_by__user",
).order_by("-updated_at")
return queryset
)
cutoff = timezone.now() - timedelta(days=28)
qs = qs.filter(created_at__gte=cutoff)
return qs.order_by("-updated_at")
class TradeOffer(models.Model):
objects = TradeOfferManager()
@ -57,6 +63,29 @@ class TradeOffer(models.Model):
self.hash = hashlib.md5((str(self.id) + "z").encode("utf-8")).hexdigest()[:8] + "z"
super().save(update_fields=["hash"])
def update_rarity_fields(self):
"""
Recalculates and updates the rarity_level and rarity_icon fields based on
the associated have_cards and want_cards.
Enforces that all cards in the trade offer share the same rarity.
Uses the first card's rarity details to update both fields.
"""
# Gather all cards from both sides.
cards = list(self.have_cards.all()) + list(self.want_cards.all())
if not cards:
return
# Enforce same rarity across all cards.
rarity_levels = {card.rarity_level for card in cards}
if len(rarity_levels) > 1:
raise ValidationError("All cards in a trade offer must have the same rarity.")
first_card = cards[0]
if self.rarity_level != first_card.rarity_level or self.rarity_icon != first_card.rarity_icon:
self.rarity_level = first_card.rarity_level
self.rarity_icon = first_card.rarity_icon
# Use super().save() here to avoid recursion.
super(TradeOffer, self).save(update_fields=["rarity_level", "rarity_icon"])
class TradeOfferHaveCard(models.Model):
"""
Through model for TradeOffer.have_cards.
@ -70,12 +99,20 @@ class TradeOfferHaveCard(models.Model):
)
card = models.ForeignKey("cards.Card", on_delete=models.PROTECT, db_index=True)
quantity = models.PositiveIntegerField(default=1)
# New field to track number of accepted cards for this entry.
qty_accepted = models.PositiveIntegerField(default=0, editable=False)
def __str__(self):
return f"{self.card.name} x{self.quantity} (Accepted: {self.qty_accepted})"
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
self.trade_offer.update_rarity_fields()
def delete(self, *args, **kwargs):
trade_offer = self.trade_offer
super().delete(*args, **kwargs)
trade_offer.update_rarity_fields()
class Meta:
unique_together = ("trade_offer", "card")
@ -91,12 +128,20 @@ class TradeOfferWantCard(models.Model):
)
card = models.ForeignKey("cards.Card", on_delete=models.PROTECT)
quantity = models.PositiveIntegerField(default=1)
# New field for tracking accepted count.
qty_accepted = models.PositiveIntegerField(default=0, editable=False)
def __str__(self):
return f"{self.card.name} x{self.quantity} (Accepted: {self.qty_accepted})"
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
self.trade_offer.update_rarity_fields()
def delete(self, *args, **kwargs):
trade_offer = self.trade_offer
super().delete(*args, **kwargs)
trade_offer.update_rarity_fields()
class Meta:
unique_together = ("trade_offer", "card")

View file

@ -1,45 +1,9 @@
from django.core.exceptions import ValidationError
from django.db.models.signals import m2m_changed, post_save, post_delete, pre_save
from django.db.models.signals import post_save, post_delete, pre_save
from django.dispatch import receiver
from .models import TradeOffer
from cards.models import Card
from django.db.models import F
from trades.models import TradeOfferHaveCard, TradeOfferWantCard, TradeAcceptance
from django.db import transaction
def validate_and_set_trade_offer_rarity(instance):
"""
Ensures all cards on both sides share the same rarity and sets the TradeOffer's
rarity_level and rarity_icon if they haven't been set already.
"""
combined_cards = list(instance.have_cards.all()) + list(instance.want_cards.all())
if not combined_cards:
return
rarities = {card.rarity_level for card in combined_cards}
if len(rarities) > 1:
raise ValidationError("All cards in a trade offer must have the same rarity.")
updated_fields = []
if instance.rarity_level is None:
instance.rarity_level = combined_cards[0].rarity_level
updated_fields.append("rarity_level")
if instance.rarity_icon is None:
instance.rarity_icon = combined_cards[0].rarity_icon
updated_fields.append("rarity_icon")
if updated_fields:
instance.save(update_fields=updated_fields)
@receiver(m2m_changed, sender=TradeOffer.have_cards.through)
def validate_have_cards_rarity(sender, instance, action, **kwargs):
if action == "post_add":
transaction.on_commit(lambda: validate_and_set_trade_offer_rarity(instance))
@receiver(m2m_changed, sender=TradeOffer.want_cards.through)
def validate_want_cards_rarity(sender, instance, action, **kwargs):
if action == "post_add":
transaction.on_commit(lambda: validate_and_set_trade_offer_rarity(instance))
ACTIVE_STATES = [
TradeAcceptance.AcceptanceState.ACCEPTED,
TradeAcceptance.AcceptanceState.SENT,
@ -83,40 +47,32 @@ def update_trade_offer_closed_status(trade_offer):
trade_offer.is_closed = closed
trade_offer.save(update_fields=["is_closed"])
# Pre-save signal to capture the original state before any changes.
@receiver(pre_save, sender=TradeAcceptance)
def trade_acceptance_pre_save(sender, instance, **kwargs):
if instance.pk:
old_instance = TradeAcceptance.objects.get(pk=instance.pk)
instance._old_state = old_instance.state
# Post-save signal to adjust qty_accepted incrementally.
@receiver(post_save, sender=TradeAcceptance)
def trade_acceptance_post_save(sender, instance, created, **kwargs):
delta = 0
if created:
# For a new acceptance, increment only if the state is active.
if instance.state in ACTIVE_STATES:
delta = 1
else:
old_state = getattr(instance, '_old_state', None)
if old_state is not None:
# Transition from active to non-active (e.g. a rejection)
if old_state in ACTIVE_STATES and instance.state not in ACTIVE_STATES:
delta = -1
# Transition from non-active to active
elif old_state not in ACTIVE_STATES and instance.state in ACTIVE_STATES:
delta = 1
if delta != 0:
trade_offer = instance.trade_offer
# Update the "have" side using the requested_card.
adjust_qty_for_trade_offer(trade_offer, instance.requested_card, side='have', delta=delta)
# Update the "want" side using the offered_card.
adjust_qty_for_trade_offer(trade_offer, instance.offered_card, side='want', delta=delta)
update_trade_offer_closed_status(trade_offer)
# Post-delete signal to decrement qty_accepted if the deleted acceptance was active.
@receiver(post_delete, sender=TradeAcceptance)
def trade_acceptance_post_delete(sender, instance, **kwargs):
if instance.state in ACTIVE_STATES:

View file

@ -3,7 +3,7 @@ from django import template
register = template.Library()
@register.inclusion_tag('templatetags/trade_offer.html', takes_context=True)
def render_trade_offer(context, offer, screenshot_mode=False):
def render_trade_offer(context, offer, screenshot_mode=False, show_friend_code=False, expanded=False):
"""
Renders a trade offer including detailed trade acceptance information.
Freezes the through-model querysets to avoid extra DB hits.
@ -26,6 +26,7 @@ def render_trade_offer(context, offer, screenshot_mode=False):
return {
'offer_pk': offer.pk,
'expanded': expanded,
'offer_hash': offer.hash,
'rarity_icon': offer.rarity_icon,
'initiated_by_email': offer.initiated_by.user.email,
@ -36,6 +37,7 @@ def render_trade_offer(context, offer, screenshot_mode=False):
'screenshot_mode': screenshot_mode,
'in_game_name': offer.initiated_by.in_game_name,
'friend_code': offer.initiated_by.friend_code,
'show_friend_code': show_friend_code,
'num_cards_available': len(have_cards_available) + len(want_cards_available),
}

View file

@ -1,7 +1,8 @@
from django.urls import path
from django.views.decorators.cache import cache_page
from .views import (
TradeOfferCreateView,
TradeOfferCreateConfirmView,
TradeOfferAllListView,
TradeOfferMyListView,
TradeOfferDetailView,
@ -14,11 +15,12 @@ from .views import (
urlpatterns = [
path("create/", TradeOfferCreateView.as_view(), name="trade_offer_create"),
path("create/confirm/", TradeOfferCreateConfirmView.as_view(), name="trade_offer_confirm_create"),
path("all/", TradeOfferAllListView.as_view(), name="trade_offer_list"),
path("my/", TradeOfferMyListView.as_view(), name="trade_offer_my_list"),
path("search/", TradeOfferSearchView.as_view(), name="trade_offer_search"),
path("<int:pk>/", TradeOfferDetailView.as_view(), name="trade_offer_detail"),
path("<int:pk>.png", TradeOfferPNGView.as_view(), name="trade_offer_png"),
path("<int:pk>.png", cache_page(15)(TradeOfferPNGView.as_view()), name="trade_offer_png"),
path("delete/<int:pk>/", TradeOfferDeleteView.as_view(), name="trade_offer_delete"),
path("offer/<int:offer_pk>", TradeAcceptanceCreateView.as_view(), name="trade_acceptance_create"),
path("accept/<int:pk>/", TradeAcceptanceUpdateView.as_view(), name="trade_acceptance_update"),

View file

@ -12,7 +12,7 @@ from django.utils.decorators import method_decorator
from django.views.decorators.http import require_http_methods
from django.core.paginator import Paginator
from django.contrib import messages
from django.views.decorators.cache import cache_page
from meta.views import Meta
from .models import TradeOffer, TradeAcceptance
from .forms import (TradeOfferAcceptForm,
@ -27,6 +27,7 @@ from playwright.sync_api import sync_playwright
from django.conf import settings
class TradeOfferCreateView(LoginRequiredMixin, CreateView):
http_method_names = ['get'] # restricts this view to GET only
model = TradeOffer
form_class = TradeOfferCreateForm
template_name = "trades/trade_offer_create.html"
@ -63,13 +64,6 @@ class TradeOfferCreateView(LoginRequiredMixin, CreateView):
context["selected_friend_code"] = selected_friend_code
return context
def form_valid(self, form):
friend_codes = self.request.user.friend_codes.all()
if form.cleaned_data.get("initiated_by") not in friend_codes:
raise PermissionDenied("You cannot initiate trade offers for friend codes that do not belong to you.")
self.object = form.save()
return HttpResponseRedirect(self.get_success_url())
class TradeOfferAllListView(ListView):
model = TradeOffer
template_name = "trades/trade_offer_all_list.html"
@ -326,17 +320,16 @@ class TradeOfferSearchView(ListView):
#@silk_profile(name="Trade Offer Search- Get Queryset")
def get_queryset(self):
from django.db.models import F
# For a GET request (initial load), return an empty queryset.
if self.request.method == "GET":
return TradeOffer.objects.none()
# Parse the POST data for offered and wanted selections.
offered_selections = self.parse_selections(self.request.POST.getlist("offered_cards"))
wanted_selections = self.parse_selections(self.request.POST.getlist("wanted_cards"))
have_selections = self.parse_selections(self.request.POST.getlist("have_cards"))
want_selections = self.parse_selections(self.request.POST.getlist("want_cards"))
# If no selections are provided, return an empty queryset.
if not offered_selections and not wanted_selections:
if not have_selections and not want_selections:
return TradeOffer.objects.none()
qs = TradeOffer.objects.filter(
@ -344,16 +337,16 @@ class TradeOfferSearchView(ListView):
).exclude(initiated_by__in=self.request.user.friend_codes.all())
# Chain filters for offered selections (i.e. the user "has" cards).
if offered_selections:
for card_id, qty in offered_selections:
if have_selections:
for card_id, qty in have_selections:
qs = qs.filter(
trade_offer_want_cards__card_id=card_id,
trade_offer_want_cards__quantity__gte=qty,
)
# Chain filters for wanted selections (i.e. the user "wants" cards).
if wanted_selections:
for card_id, qty in wanted_selections:
if want_selections:
for card_id, qty in want_selections:
qs = qs.filter(
trade_offer_have_cards__card_id=card_id,
trade_offer_have_cards__quantity__gte=qty,
@ -373,11 +366,11 @@ class TradeOfferSearchView(ListView):
# Populate available_cards to re-populate the multiselects.
context["cards"] = Card.objects.all().order_by("name")
if self.request.method == "POST":
context["offered_cards"] = self.request.POST.getlist("offered_cards")
context["wanted_cards"] = self.request.POST.getlist("wanted_cards")
context["have_cards"] = self.request.POST.getlist("have_cards")
context["want_cards"] = self.request.POST.getlist("want_cards")
else:
context["offered_cards"] = []
context["wanted_cards"] = []
context["have_cards"] = []
context["want_cards"] = []
return context
#@silk_profile(name="Trade Offer Search- Render to Response")
@ -405,6 +398,8 @@ class TradeOfferDetailView(DetailView):
context = super().get_context_data(**kwargs)
trade_offer = self.get_object()
screenshot_mode = self.request.GET.get("screenshot_mode")
if screenshot_mode:
context["show_friend_code"] = trade_offer.initiated_by.user.show_friend_code_on_link_previews
context["screenshot_mode"] = screenshot_mode
# Calculate the number of cards in each category.
@ -418,14 +413,14 @@ class TradeOfferDetailView(DetailView):
# Calculate a base height using our previous assumptions:
# - 80px per card row (with rows computed as round(num_cards/2))
# - plus 138px for header/footer.
base_height = (round(num_cards / 2) * 80) + 138
base_height = (round(num_cards / 2) * 56) + 138
# Calculate a base width by assuming two columns of card badges.
# Here we assume each card badge is 80px wide plus the same horizontal offset of 138px.
if (num_wants + num_has) >= 3:
base_width = ((num_wants + num_has) * 144) + 96
else:
if (num_wants + num_has) >= 4:
base_width = (4 * 144) + 96
else:
base_width = (2 * 144) + 128
if base_height > base_width:
# The trade-offer card is taller than wide;
@ -610,6 +605,7 @@ class TradeOfferPNGView(View):
This view loads the trade offer detail page, waits for the trade offer element to render,
simulates a click to expand extra badges, and then screenshots only the trade offer element.
"""
def get(self, request, *args, **kwargs):
# For demonstration purposes, get the first trade offer.
# In production, you might want to identify the offer via a URL parameter.
@ -682,3 +678,174 @@ class TradeOfferPNGView(View):
browser.close()
return HttpResponse(png_bytes, content_type="image/png")
class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
"""
Processes a two-step create for TradeOffer; on confirmation,
commits the offer and shows form errors if any occur.
"""
def post(self, request, *args, **kwargs):
if "confirm" in request.POST:
return self._commit_offer(request)
elif "edit" in request.POST:
return self._redirect_to_edit(request)
elif "preview" in request.POST:
return self._preview_offer(request)
else:
return self._preview_offer(request)
def _commit_offer(self, request):
"""
Commits the offer after confirmation. Any model ValidationError (for example,
due to mismatched card rarities) is caught and added to the form errors so that
it shows up in trade_offer_create.html.
"""
# Instantiate the form with POST data.
form = TradeOfferCreateForm(request.POST)
# Ensure that the 'initiated_by' queryset is limited to the user's friend codes.
form.fields["initiated_by"].queryset = request.user.friend_codes.all()
if form.is_valid():
try:
trade_offer = form.save()
except ValidationError as error:
form.add_error(None, error)
# Update the form's initial data so the template can safely reference have_cards/want_cards.
form.initial = {
"have_cards": request.POST.getlist("have_cards"),
"want_cards": request.POST.getlist("want_cards"),
"initiated_by": request.POST.get("initiated_by"),
}
# Supply additional context required by trade_offer_create.html.
from cards.models import Card
context = {
"form": form,
"friend_codes": request.user.friend_codes.all(),
"selected_friend_code": (
request.user.default_friend_code or request.user.friend_codes.first()
),
"cards": Card.objects.all().order_by("name", "rarity_level"),
}
return render(request, "trades/trade_offer_create.html", context)
messages.success(request, "Trade offer created successfully!")
return HttpResponseRedirect(reverse_lazy("trade_offer_list"))
else:
# When the form is not valid, update its initial data as well:
form.initial = {
"have_cards": request.POST.getlist("have_cards"),
"want_cards": request.POST.getlist("want_cards"),
"initiated_by": request.POST.get("initiated_by"),
}
from cards.models import Card
context = {
"form": form,
"friend_codes": request.user.friend_codes.all(),
"selected_friend_code": (
request.user.default_friend_code or request.user.friend_codes.first()
),
"cards": Card.objects.all().order_by("name", "rarity_level"),
}
return render(request, "trades/trade_offer_create.html", context)
def _redirect_to_edit(self, request):
query_params = request.POST.copy()
query_params.pop("csrfmiddlewaretoken", None)
query_params.pop("edit", None)
query_params.pop("confirm", None)
query_params.pop("preview", None)
from django.urls import reverse
base_url = reverse("trade_offer_create")
url_with_params = f"{base_url}?{query_params.urlencode()}"
return HttpResponseRedirect(url_with_params)
def _preview_offer(self, request):
"""
Processes the preview action (existing logic remains unchanged).
"""
form = TradeOfferCreateForm(request.POST)
form.fields["initiated_by"].queryset = request.user.friend_codes.all()
if not form.is_valid():
# Re-render the creation template with errors.
return render(request, "trades/trade_offer_create.html", {"form": form})
# Parse the card selections for "have" and "want" cards.
have_selections = self._parse_card_selections("have_cards")
want_selections = self._parse_card_selections("want_cards")
from cards.models import Card
have_cards_ids = [card_id for card_id, _ in have_selections]
cards_have_qs = Card.objects.filter(pk__in=have_cards_ids)
cards_have_dict = {card.pk: card for card in cards_have_qs}
# Define a dummy wrapper for a trade offer card entry.
class DummyOfferCard:
def __init__(self, card, quantity):
self.card = card
self.quantity = quantity
self.qty_accepted = 0
have_offer_cards = []
for card_id, quantity in have_selections:
card = cards_have_dict.get(card_id)
if card:
have_offer_cards.append(DummyOfferCard(card, quantity))
want_cards_ids = [card_id for card_id, _ in want_selections]
cards_want_qs = Card.objects.filter(pk__in=want_cards_ids)
cards_want_dict = {card.pk: card for card in cards_want_qs}
want_offer_cards = []
for card_id, quantity in want_selections:
card = cards_want_dict.get(card_id)
if card:
want_offer_cards.append(DummyOfferCard(card, quantity))
# Mimic a related manager's all() method.
class DummyManager:
def __init__(self, items):
self.items = items
def all(self):
return self.items
# Create a dummy TradeOffer object with properties required by the render_trade_offer tag.
class DummyTradeOffer:
pass
dummy_trade_offer = DummyTradeOffer()
dummy_trade_offer.pk = 0 # a placeholder primary key
dummy_trade_offer.hash = "preview"
dummy_trade_offer.rarity_icon = ""
dummy_trade_offer.trade_offer_have_cards = DummyManager(have_offer_cards)
dummy_trade_offer.trade_offer_want_cards = DummyManager(want_offer_cards)
dummy_trade_offer.acceptances = DummyManager([]) # no acceptances in preview
dummy_trade_offer.initiated_by = form.cleaned_data["initiated_by"]
# Pass along the POST data so that hidden inputs can be re-generated.
context = {
"dummy_trade_offer": dummy_trade_offer,
"post_data": request.POST,
}
return render(request, "trades/trade_offer_confirm_create.html", context)
def _parse_card_selections(self, key):
"""
Parses card selections from POST data for a given key (e.g., 'have_cards' or 'want_cards').
Selections are expected in the format 'card_id:quantity', defaulting quantity to 1 if missing.
Returns a list of (card_id, quantity) tuples.
"""
selections = self.request.POST.getlist(key)
results = []
for selection in selections:
parts = selection.split(":")
try:
card_id = int(parts[0])
except (ValueError, IndexError):
continue
quantity = 1
if len(parts) > 1:
try:
quantity = int(parts[1])
except ValueError:
pass
results.append((card_id, quantity))
return results