feat(market): basic page + submit
This commit is contained in:
parent
ce30539808
commit
f7c7eba4da
@ -1,3 +1,65 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from market.models import Market, MarketOption, UserBet
|
||||||
|
|
||||||
# Register your models here.
|
|
||||||
|
class MarketOptionInline(admin.TabularInline):
|
||||||
|
model = MarketOption
|
||||||
|
extra = 1
|
||||||
|
fields = ["text", "position"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Market)
|
||||||
|
class MarketAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["title", "type", "status", "end_date", "created_by", "created_at"]
|
||||||
|
list_filter = ["status", "type", "created_at"]
|
||||||
|
search_fields = ["uuid", "title"]
|
||||||
|
readonly_fields = [
|
||||||
|
"uuid",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"created_by",
|
||||||
|
"winning_option",
|
||||||
|
]
|
||||||
|
inlines = [MarketOptionInline]
|
||||||
|
fieldsets = (
|
||||||
|
("Info", {"fields": ["uuid", "title", "description"]}),
|
||||||
|
("Configuration", {"fields": ["type", "end_date"]}),
|
||||||
|
("Status", {"fields": ["status", "winning_option"]}),
|
||||||
|
("Metadata", {"fields": ["created_by", "created_at", "updated_at"]}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_model(self, request, obj, form, change):
|
||||||
|
if not change: # Creating new market
|
||||||
|
obj.created_by = request.user
|
||||||
|
super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
|
@admin.action(description="Close selected markets")
|
||||||
|
def close_markets(self, request, queryset):
|
||||||
|
updated = queryset.filter(status=Market.Status.OPEN).update(
|
||||||
|
status=Market.Status.CLOSED
|
||||||
|
)
|
||||||
|
self.message_user(request, f"Closed {updated} market(s).")
|
||||||
|
|
||||||
|
actions = ["close_markets"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(MarketOption)
|
||||||
|
class MarketOptionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["text", "market", "position"]
|
||||||
|
list_filter = ["market"]
|
||||||
|
search_fields = ["uuid", "text", "market__title"]
|
||||||
|
readonly_fields = ["uuid"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserBet)
|
||||||
|
class UserBetAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["user", "option", "amount", "created_at"]
|
||||||
|
list_filter = ["created_at", "option__market"]
|
||||||
|
search_fields = ["uuid", "user__username", "option__text"]
|
||||||
|
readonly_fields = ["uuid", "user", "option", "amount", "created_at", "updated_at"]
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|||||||
99
polylan_submitter/market/api.py
Normal file
99
polylan_submitter/market/api.py
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
from typing import List
|
||||||
|
from ninja import Router
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
|
from market.models import Market, MarketOption, UserBet
|
||||||
|
from market.schemas import (
|
||||||
|
MarketListSchema,
|
||||||
|
ResolveMarketSchema,
|
||||||
|
UserBetCreateSchema,
|
||||||
|
UserBetSchema,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = Router(tags=["market"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response=List[MarketListSchema])
|
||||||
|
def list_markets(request):
|
||||||
|
"""List all markets."""
|
||||||
|
return Market.objects.prefetch_related("options").all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user/bets", response=List[UserBetSchema])
|
||||||
|
def list_user_bets(request):
|
||||||
|
"""List all bets placed by the current user."""
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
raise HttpError(401, "Authentication required")
|
||||||
|
|
||||||
|
return UserBet.objects.filter(user=request.user).select_related(
|
||||||
|
"option__market"
|
||||||
|
).prefetch_related("option__market__options")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{market_uuid}/actions/close")
|
||||||
|
def close_market(request, market_uuid: str):
|
||||||
|
"""Close a market. Admin only."""
|
||||||
|
if not request.user.is_staff:
|
||||||
|
raise HttpError(403, "Permission denied")
|
||||||
|
|
||||||
|
market = get_object_or_404(Market, uuid=market_uuid)
|
||||||
|
market.status = Market.Status.CLOSED
|
||||||
|
market.save(update_fields=["status", "updated_at"])
|
||||||
|
return {"status": "closed"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{market_uuid}/actions/resolve", response=MarketListSchema)
|
||||||
|
def resolve_market(request, market_uuid: str, payload: ResolveMarketSchema):
|
||||||
|
"""Resolve a market with a winning option. Admin only."""
|
||||||
|
if not request.user.is_staff:
|
||||||
|
raise HttpError(403, "Permission denied")
|
||||||
|
|
||||||
|
market = get_object_or_404(Market, uuid=market_uuid)
|
||||||
|
winning_option = get_object_or_404(MarketOption, uuid=payload.winning_option_uuid)
|
||||||
|
|
||||||
|
if winning_option.market_id != market.id:
|
||||||
|
raise HttpError(400, "Option does not belong to this market")
|
||||||
|
|
||||||
|
market.winning_option = winning_option
|
||||||
|
market.status = Market.Status.RESOLVED
|
||||||
|
market.save(update_fields=["winning_option", "status", "updated_at"])
|
||||||
|
return market
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{market_uuid}/bets", response=UserBetSchema)
|
||||||
|
def create_bet(request, market_uuid: str, payload: UserBetCreateSchema):
|
||||||
|
"""Place a bet on a market option."""
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
raise HttpError(401, "Authentication required")
|
||||||
|
|
||||||
|
market = get_object_or_404(Market, uuid=market_uuid)
|
||||||
|
option = get_object_or_404(MarketOption, uuid=payload.option_uuid)
|
||||||
|
|
||||||
|
if option.market_id != market.id:
|
||||||
|
raise HttpError(400, "Option does not belong to this market")
|
||||||
|
|
||||||
|
if market.status != Market.Status.OPEN:
|
||||||
|
raise HttpError(400, "Market is not open for betting")
|
||||||
|
|
||||||
|
# Check if user already has a bet on a different option in this market
|
||||||
|
existing_bet_on_market = UserBet.objects.filter(
|
||||||
|
user=request.user,
|
||||||
|
option__market=market
|
||||||
|
).exclude(option=option).first()
|
||||||
|
if existing_bet_on_market:
|
||||||
|
raise HttpError(400, "You can only bet on one option per market")
|
||||||
|
|
||||||
|
# Check if user already has a bet on this option
|
||||||
|
existing_bet = UserBet.objects.filter(user=request.user, option=option).first()
|
||||||
|
if existing_bet and payload.amount < existing_bet.amount:
|
||||||
|
raise HttpError(400, "Cannot decrease bet amount. You can only increase it.")
|
||||||
|
|
||||||
|
user_bet, created = UserBet.objects.update_or_create(
|
||||||
|
user=request.user,
|
||||||
|
option=option,
|
||||||
|
defaults={"amount": payload.amount},
|
||||||
|
)
|
||||||
|
|
||||||
|
return user_bet
|
||||||
@ -7,7 +7,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
|
|
||||||
class BaseModel(models.Model):
|
class BaseModel(models.Model):
|
||||||
@ -25,7 +24,9 @@ class Market(BaseModel):
|
|||||||
title = models.CharField(max_length=255)
|
title = models.CharField(max_length=255)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
type = models.CharField(max_length=10, choices=Type.choices, default=Type.YES_NO)
|
type = models.CharField(max_length=10, choices=Type.choices, default=Type.YES_NO)
|
||||||
status = models.CharField(max_length=10, choices=Status.choices, default=Status.OPEN)
|
status = models.CharField(
|
||||||
|
max_length=10, choices=Status.choices, default=Status.OPEN
|
||||||
|
)
|
||||||
end_date = models.DateTimeField()
|
end_date = models.DateTimeField()
|
||||||
created_by = models.ForeignKey("accounts.CustomUser", on_delete=models.PROTECT)
|
created_by = models.ForeignKey("accounts.CustomUser", on_delete=models.PROTECT)
|
||||||
winning_option = models.ForeignKey(
|
winning_option = models.ForeignKey(
|
||||||
@ -69,8 +70,12 @@ class MarketOption(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class UserBet(BaseModel):
|
class UserBet(BaseModel):
|
||||||
user = models.ForeignKey("accounts.CustomUser", on_delete=models.CASCADE, related_name="bets")
|
user = models.ForeignKey(
|
||||||
option = models.ForeignKey(MarketOption, on_delete=models.CASCADE, related_name="user_bets")
|
"accounts.CustomUser", on_delete=models.CASCADE, related_name="bets"
|
||||||
|
)
|
||||||
|
option = models.ForeignKey(
|
||||||
|
MarketOption, on_delete=models.CASCADE, related_name="user_bets"
|
||||||
|
)
|
||||||
amount = models.PositiveIntegerField()
|
amount = models.PositiveIntegerField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
59
polylan_submitter/market/schemas.py
Normal file
59
polylan_submitter/market/schemas.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional, Any
|
||||||
|
from uuid import UUID
|
||||||
|
from ninja import Schema
|
||||||
|
from pydantic import field_serializer, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class MarketOptionSchema(Schema):
|
||||||
|
uuid: UUID
|
||||||
|
text: str
|
||||||
|
position: int
|
||||||
|
|
||||||
|
@field_serializer('uuid')
|
||||||
|
def serialize_uuid(self, value: UUID) -> str:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
class MarketListSchema(Schema):
|
||||||
|
uuid: UUID
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
type: str
|
||||||
|
status: str
|
||||||
|
end_date: datetime
|
||||||
|
created_at: datetime
|
||||||
|
options: List[MarketOptionSchema]
|
||||||
|
winning_option: Optional[MarketOptionSchema] = None
|
||||||
|
|
||||||
|
@field_serializer('uuid')
|
||||||
|
def serialize_uuid(self, value: UUID) -> str:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveMarketSchema(Schema):
|
||||||
|
winning_option_uuid: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserBetCreateSchema(Schema):
|
||||||
|
option_uuid: str
|
||||||
|
amount: int
|
||||||
|
|
||||||
|
|
||||||
|
class UserBetSchema(Schema):
|
||||||
|
uuid: UUID
|
||||||
|
amount: int
|
||||||
|
created_at: datetime
|
||||||
|
option: MarketOptionSchema
|
||||||
|
market: Optional[MarketListSchema] = None
|
||||||
|
|
||||||
|
@field_serializer('uuid')
|
||||||
|
def serialize_uuid(self, value: UUID) -> str:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
@model_validator(mode='before')
|
||||||
|
@classmethod
|
||||||
|
def resolve_market_from_option(cls, data: Any) -> Any:
|
||||||
|
if hasattr(data, 'option') and hasattr(data.option, 'market'):
|
||||||
|
data.market = data.option.market
|
||||||
|
return data
|
||||||
@ -1,3 +1,2 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
# Create your tests here.
|
||||||
|
|||||||
@ -1,3 +1,8 @@
|
|||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
|
from django.http import HttpRequest
|
||||||
|
|
||||||
# Create your views here.
|
|
||||||
|
@login_required
|
||||||
|
def market_home(request: HttpRequest):
|
||||||
|
return render(request, "market.html", {})
|
||||||
|
|||||||
@ -6,6 +6,7 @@ from submissions.schemas import UserInfoOut
|
|||||||
from animations.api import router as results_router
|
from animations.api import router as results_router
|
||||||
from noita.api import router as noita_router
|
from noita.api import router as noita_router
|
||||||
from games.api import router as games_router
|
from games.api import router as games_router
|
||||||
|
from market.api import router as market_router
|
||||||
|
|
||||||
# Create the main API instance
|
# Create the main API instance
|
||||||
api = NinjaAPI(
|
api = NinjaAPI(
|
||||||
@ -36,6 +37,7 @@ api.add_router("/submissions/", submissions_router, tags=["submissions"])
|
|||||||
api.add_router("/results/", results_router, tags=["results"])
|
api.add_router("/results/", results_router, tags=["results"])
|
||||||
api.add_router("/noita/", noita_router, tags=["noita"])
|
api.add_router("/noita/", noita_router, tags=["noita"])
|
||||||
api.add_router("/games/", games_router, tags=["games"])
|
api.add_router("/games/", games_router, tags=["games"])
|
||||||
|
api.add_router("/market/", market_router)
|
||||||
|
|
||||||
|
|
||||||
# Health check endpoint
|
# Health check endpoint
|
||||||
|
|||||||
@ -24,6 +24,7 @@ from django.conf import settings
|
|||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
|
from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
|
||||||
from games.decorators import require_game_enabled
|
from games.decorators import require_game_enabled
|
||||||
|
from market.views import market_home
|
||||||
from .api import api
|
from .api import api
|
||||||
|
|
||||||
NOITA_APP_ID = 881100
|
NOITA_APP_ID = 881100
|
||||||
@ -60,6 +61,7 @@ urlpatterns = [
|
|||||||
path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"),
|
path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"),
|
||||||
path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"),
|
path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"),
|
||||||
path("api/", api.urls),
|
path("api/", api.urls),
|
||||||
|
path("market", market_home, name="market.home"),
|
||||||
path("opus-magnum", opus_magnum_home, name="opus-magnum.home"),
|
path("opus-magnum", opus_magnum_home, name="opus-magnum.home"),
|
||||||
path("noita", noita_home, name="noita.home"),
|
path("noita", noita_home, name="noita.home"),
|
||||||
path("", home, name="home"),
|
path("", home, name="home"),
|
||||||
|
|||||||
@ -47,6 +47,26 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<!-- Cards Grid -->
|
<!-- Cards Grid -->
|
||||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<!-- Market Card -->
|
||||||
|
<div @click="navigate('/market')"
|
||||||
|
class="card card-xl bg-base-200 shadow-xl hover:shadow-2xl transition-all cursor-pointer transform hover:-translate-y-2 hover:scale-[1.05] hover:bg-base-100 overflow-hidden">
|
||||||
|
<figure class="relative h-60 bg-gradient-to-br from-purple-600 to-blue-600 flex items-center justify-center">
|
||||||
|
<i class="mdi mdi-chart-box text-6xl text-white opacity-80"></i>
|
||||||
|
<div class="absolute inset-0 bg-black/30 group-hover:bg-black/20 transition-colors"></div>
|
||||||
|
</figure>
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl">Market</h2>
|
||||||
|
<p class="text-base-content/70">Place your bets and compete</p>
|
||||||
|
<div class="card-actions justify-end mt-4">
|
||||||
|
<button class="btn btn-primary">
|
||||||
|
<i class="mdi mdi-arrow-right mr-2"></i>
|
||||||
|
Place bets
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Game Cards -->
|
||||||
<div v-for="game in games" :key="game.steam_app_id" @click="navigate(game.path)"
|
<div v-for="game in games" :key="game.steam_app_id" @click="navigate(game.path)"
|
||||||
class="card card-xl bg-base-200 shadow-xl hover:shadow-2xl transition-all cursor-pointer transform hover:-translate-y-2 hover:scale-[1.05] hover:bg-base-100 overflow-hidden">
|
class="card card-xl bg-base-200 shadow-xl hover:shadow-2xl transition-all cursor-pointer transform hover:-translate-y-2 hover:scale-[1.05] hover:bg-base-100 overflow-hidden">
|
||||||
<figure class="relative h-60 bg-base-300 overflow-hidden">
|
<figure class="relative h-60 bg-base-300 overflow-hidden">
|
||||||
|
|||||||
111
polylan_submitter/src/Market.vue
Normal file
111
polylan_submitter/src/Market.vue
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { apiService } from "./services/apiService";
|
||||||
|
import type { Market } from "./types";
|
||||||
|
import MarketCard from "./components/MarketCard.vue";
|
||||||
|
import UserBets from "./components/UserBets.vue";
|
||||||
|
|
||||||
|
const markets = ref<Market[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const userInfo = ref<any>(null);
|
||||||
|
|
||||||
|
const goHome = () => {
|
||||||
|
window.location.href = "/";
|
||||||
|
};
|
||||||
|
|
||||||
|
const reloadPage = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// Fetch user info
|
||||||
|
const userResponse = await apiService.getUserInfo();
|
||||||
|
if (userResponse.data) {
|
||||||
|
userInfo.value = userResponse.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch markets
|
||||||
|
const response = await apiService.getMarkets();
|
||||||
|
if (response.data) {
|
||||||
|
markets.value = response.data;
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-base-200">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="navbar bg-base-100 shadow-lg">
|
||||||
|
<div class="container min-w-3/4 mx-auto w-full flex items-center gap-4">
|
||||||
|
<button @click="goHome" class="btn btn-primary btn-sm">
|
||||||
|
<i class="mdi mdi-arrow-left"></i>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<h1 class="text-xl font-bold">Market</h1>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div v-if="userInfo?.is_authenticated" class="flex items-center gap-2">
|
||||||
|
<div class="text-sm">
|
||||||
|
<span class="font-medium">{{ userInfo.username }}</span>
|
||||||
|
<span v-if="userInfo.is_superuser" class="badge badge-warning badge-xs ml-1">Admin</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm text-base-content/70">Not logged in</div>
|
||||||
|
<a href="/api/docs" class="btn btn-xs">API docs</a>
|
||||||
|
<a href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="container min-w-3/4 mx-auto px-4 py-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-3xl font-bold mb-2">Market</h2>
|
||||||
|
<p class="text-base-content/70">Place your bets on upcoming events</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="flex justify-center py-20">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="space-y-8">
|
||||||
|
<!-- My Bets Section -->
|
||||||
|
<div v-if="userInfo?.is_authenticated">
|
||||||
|
<h3 class="text-2xl font-bold mb-4 flex items-center gap-2">
|
||||||
|
<i class="mdi mdi-heart text-error"></i>
|
||||||
|
My Bets
|
||||||
|
</h3>
|
||||||
|
<UserBets :markets="markets" @refresh="reloadPage" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- All Markets Section -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-2xl font-bold mb-4 flex items-center gap-2">
|
||||||
|
<i class="mdi mdi-list"></i>
|
||||||
|
All Markets
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div v-if="markets.length === 0" class="alert">
|
||||||
|
<i class="mdi mdi-information mr-2"></i>
|
||||||
|
<span>No markets available</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<MarketCard
|
||||||
|
v-for="market in markets"
|
||||||
|
:key="market.uuid"
|
||||||
|
:market="market"
|
||||||
|
@refresh="reloadPage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
225
polylan_submitter/src/components/MarketCard.vue
Normal file
225
polylan_submitter/src/components/MarketCard.vue
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from "vue";
|
||||||
|
import { apiService } from "../services/apiService";
|
||||||
|
import type { Market, UserBet } from "../types";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
market: Market;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
refresh: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const selectedOption = ref<string | null>(null);
|
||||||
|
const betAmount = ref<number>(0);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string>("");
|
||||||
|
const userInfo = ref<any>(null);
|
||||||
|
const userBets = ref<UserBet[]>([]);
|
||||||
|
const existingBet = ref<UserBet | null>(null);
|
||||||
|
|
||||||
|
const timeRemaining = computed(() => {
|
||||||
|
const endDate = new Date(props.market.end_date).getTime();
|
||||||
|
const now = new Date().getTime();
|
||||||
|
const diff = endDate - now;
|
||||||
|
|
||||||
|
if (diff <= 0) return "Ended";
|
||||||
|
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
|
||||||
|
if (days > 0) return `${days}d ${hours}h`;
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||||
|
return `${minutes}m`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusColor = computed(() => {
|
||||||
|
switch (props.market.status) {
|
||||||
|
case "open":
|
||||||
|
return "badge-success";
|
||||||
|
case "closed":
|
||||||
|
return "badge-warning";
|
||||||
|
case "resolved":
|
||||||
|
return "badge-info";
|
||||||
|
default:
|
||||||
|
return "badge-ghost";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const canBet = computed(() => {
|
||||||
|
return props.market.status === "open" && userInfo.value?.is_authenticated && selectedOption.value && betAmount.value > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const placeBet = async () => {
|
||||||
|
if (!selectedOption.value || !betAmount.value) return;
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiService.placeBet(props.market.uuid, {
|
||||||
|
option_uuid: selectedOption.value,
|
||||||
|
amount: betAmount.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 200 || response.status === 201) {
|
||||||
|
// Reload user bets to reflect the new bet
|
||||||
|
await loadUserBets();
|
||||||
|
betAmount.value = 0;
|
||||||
|
} else {
|
||||||
|
error.value = response.error || "Failed to place bet";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = "Error placing bet";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initializeUserInfo = async () => {
|
||||||
|
const response = await apiService.getUserInfo();
|
||||||
|
if (response.data) {
|
||||||
|
userInfo.value = response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadUserBets = async () => {
|
||||||
|
const response = await apiService.getUserBets();
|
||||||
|
if (response.data) {
|
||||||
|
userBets.value = response.data;
|
||||||
|
// Find if user has a bet on this market
|
||||||
|
existingBet.value = response.data.find(bet => bet.market.uuid === props.market.uuid) || null;
|
||||||
|
if (existingBet.value) {
|
||||||
|
selectedOption.value = existingBet.value.option.uuid;
|
||||||
|
betAmount.value = existingBet.value.amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await initializeUserInfo();
|
||||||
|
if (userInfo.value?.is_authenticated) {
|
||||||
|
await loadUserBets();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="card-body pb-3">
|
||||||
|
<div class="flex justify-between items-start gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h2 class="card-title text-2xl mb-2">{{ market.title }}</h2>
|
||||||
|
<p class="text-sm text-base-content/70">{{ market.description }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-end gap-2">
|
||||||
|
<div :class="['badge', statusColor, 'text-white']">
|
||||||
|
{{ market.status }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-base-content/60 text-right">
|
||||||
|
<div>{{ timeRemaining }}</div>
|
||||||
|
<div class="text-xs">until close</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider my-0"></div>
|
||||||
|
|
||||||
|
<!-- Options -->
|
||||||
|
<div class="card-body py-4">
|
||||||
|
<div class="grid gap-3" :class="market.options.length > 2 ? 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3' : 'grid-cols-1 md:grid-cols-2'">
|
||||||
|
<div v-for="option in market.options" :key="option.uuid" class="form-control">
|
||||||
|
<label
|
||||||
|
:class="[
|
||||||
|
'label cursor-pointer border rounded-lg p-3 hover:bg-base-200 transition h-full',
|
||||||
|
existingBet && existingBet.option.uuid === option.uuid ? 'border-primary border-2 bg-primary/5' : ''
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-2 flex-1">
|
||||||
|
<span class="label-text font-medium">{{ option.text }}</span>
|
||||||
|
<span
|
||||||
|
v-if="existingBet && existingBet.option.uuid === option.uuid"
|
||||||
|
class="badge badge-primary badge-sm w-fit"
|
||||||
|
>
|
||||||
|
Your bet: {{ existingBet.amount }} pts
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
:value="option.uuid"
|
||||||
|
v-model="selectedOption"
|
||||||
|
class="radio radio-primary"
|
||||||
|
:disabled="market.status !== 'open' || !!(existingBet && existingBet.option.uuid !== option.uuid)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bet Input -->
|
||||||
|
<div v-if="market.status === 'open' && userInfo?.is_authenticated && selectedOption" class="card-body py-4">
|
||||||
|
<div class="form-control gap-3">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">
|
||||||
|
<span v-if="existingBet">Increase bet (current: {{ existingBet.amount }} pts)</span>
|
||||||
|
<span v-else>Points to bet</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="betAmount"
|
||||||
|
type="number"
|
||||||
|
:placeholder="existingBet ? `Enter amount to increase by` : 'Enter points'"
|
||||||
|
class="input input-bordered"
|
||||||
|
min="1"
|
||||||
|
:disabled="loading"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-error">
|
||||||
|
<i class="mdi mdi-alert-circle"></i>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="placeBet"
|
||||||
|
:disabled="!canBet || loading"
|
||||||
|
class="btn btn-primary"
|
||||||
|
>
|
||||||
|
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
|
||||||
|
<span v-else-if="existingBet">Increase Bet</span>
|
||||||
|
<span v-else>Place Bet</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Show existing bet (market closed/resolved) -->
|
||||||
|
<div v-else-if="market.status !== 'open' && existingBet" class="card-body py-4 bg-base-200">
|
||||||
|
<div class="text-sm">
|
||||||
|
<div class="font-semibold">Your Bet</div>
|
||||||
|
<div class="text-base-content/70 mt-1">
|
||||||
|
Option: <span class="font-semibold">{{ existingBet.option.text }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-base-content/70">
|
||||||
|
Amount: <span class="font-semibold">{{ existingBet.amount }} pts</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Result -->
|
||||||
|
<div v-else-if="market.status === 'resolved' && market.winning_option" class="card-body py-4 bg-base-200">
|
||||||
|
<div class="text-sm">
|
||||||
|
<div class="font-semibold" :class="existingBet && existingBet.option.uuid === market.winning_option.uuid ? 'text-success' : 'text-error'">
|
||||||
|
<span v-if="existingBet && existingBet.option.uuid === market.winning_option.uuid">✓ You Won!</span>
|
||||||
|
<span v-else-if="existingBet">✗ You Lost</span>
|
||||||
|
<span v-else>Resolved</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-base-content/70 mt-1">
|
||||||
|
Winner: <span class="font-semibold">{{ market.winning_option.text }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
187
polylan_submitter/src/components/UserBets.vue
Normal file
187
polylan_submitter/src/components/UserBets.vue
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from "vue";
|
||||||
|
import { apiService } from "../services/apiService";
|
||||||
|
import type { UserBet } from "../types";
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
refresh: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const userBets = ref<UserBet[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
|
||||||
|
const totalBetAmount = computed(() => {
|
||||||
|
return userBets.value.reduce((sum, bet) => sum + bet.amount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const winningBets = computed(() => {
|
||||||
|
return userBets.value.filter(bet => {
|
||||||
|
const market = bet.market;
|
||||||
|
return market.status === "resolved" && market.winning_option?.uuid === bet.option.uuid;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const losingBets = computed(() => {
|
||||||
|
return userBets.value.filter(bet => {
|
||||||
|
const market = bet.market;
|
||||||
|
return market.status === "resolved" && market.winning_option?.uuid !== bet.option.uuid;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const openBets = computed(() => {
|
||||||
|
return userBets.value.filter(bet => bet.market.status === "open");
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalWinnings = computed(() => {
|
||||||
|
return winningBets.value.reduce((sum, bet) => sum + bet.amount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const response = await apiService.getUserBets();
|
||||||
|
if (response.data) {
|
||||||
|
userBets.value = response.data;
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="flex justify-center py-20">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else-if="userBets.length === 0" class="alert">
|
||||||
|
<i class="mdi mdi-information mr-2"></i>
|
||||||
|
<span>You haven't placed any bets yet</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div class="stat bg-base-100 rounded-lg shadow">
|
||||||
|
<div class="stat-title text-sm">Total Bets</div>
|
||||||
|
<div class="stat-value text-2xl">{{ userBets.length }}</div>
|
||||||
|
<div class="stat-desc text-xs">{{ totalBetAmount }} points</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat bg-base-100 rounded-lg shadow">
|
||||||
|
<div class="stat-title text-sm">Active</div>
|
||||||
|
<div class="stat-value text-2xl text-info">{{ openBets.length }}</div>
|
||||||
|
<div class="stat-desc text-xs">Waiting for result</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat bg-base-100 rounded-lg shadow">
|
||||||
|
<div class="stat-title text-sm">Won</div>
|
||||||
|
<div class="stat-value text-2xl text-success">{{ winningBets.length }}</div>
|
||||||
|
<div class="stat-desc text-xs text-success">+{{ totalWinnings }} points</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat bg-base-100 rounded-lg shadow">
|
||||||
|
<div class="stat-title text-sm">Lost</div>
|
||||||
|
<div class="stat-value text-2xl text-error">{{ losingBets.length }}</div>
|
||||||
|
<div class="stat-desc text-xs">Better luck next time</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bets Sections -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Open Bets -->
|
||||||
|
<div v-if="openBets.length > 0">
|
||||||
|
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
|
||||||
|
<i class="mdi mdi-progress-clock text-info"></i>
|
||||||
|
Active Bets ({{ openBets.length }})
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="bet in openBets"
|
||||||
|
:key="bet.uuid"
|
||||||
|
class="card bg-base-100 shadow hover:shadow-lg transition-shadow"
|
||||||
|
>
|
||||||
|
<div class="card-body py-4">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h4 class="font-semibold text-lg">{{ bet.market.title }}</h4>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
Bet on: <span class="font-medium">{{ bet.option.text }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="text-lg font-bold">{{ bet.amount }} pts</div>
|
||||||
|
<div class="text-xs text-base-content/60 mt-1">
|
||||||
|
Status: <span class="badge badge-info badge-sm">{{ bet.market.status }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Winning Bets -->
|
||||||
|
<div v-if="winningBets.length > 0">
|
||||||
|
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
|
||||||
|
<i class="mdi mdi-check-circle text-success"></i>
|
||||||
|
Won Bets ({{ winningBets.length }})
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="bet in winningBets"
|
||||||
|
:key="bet.uuid"
|
||||||
|
class="card bg-success/10 border border-success shadow"
|
||||||
|
>
|
||||||
|
<div class="card-body py-4">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h4 class="font-semibold text-lg">{{ bet.market.title }}</h4>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
Correct! You bet on: <span class="font-medium text-success">{{ bet.option.text }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="text-lg font-bold text-success">+{{ bet.amount }} pts</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Losing Bets -->
|
||||||
|
<div v-if="losingBets.length > 0">
|
||||||
|
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
|
||||||
|
<i class="mdi mdi-close-circle text-error"></i>
|
||||||
|
Lost Bets ({{ losingBets.length }})
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="bet in losingBets"
|
||||||
|
:key="bet.uuid"
|
||||||
|
class="card bg-error/10 border border-error shadow"
|
||||||
|
>
|
||||||
|
<div class="card-body py-4">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h4 class="font-semibold text-lg">{{ bet.market.title }}</h4>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
You bet on: <span class="font-medium">{{ bet.option.text }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-base-content/60 mt-1">
|
||||||
|
Winner: <span class="font-medium">{{ bet.market.winning_option?.text }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="text-lg font-bold text-error">-{{ bet.amount }} pts</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
8
polylan_submitter/src/market.ts
Normal file
8
polylan_submitter/src/market.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import Market from '@/Market.vue'
|
||||||
|
import '@/style.css'
|
||||||
|
|
||||||
|
const selector = "#app"
|
||||||
|
const mountData = document.querySelector<HTMLElement>(selector)
|
||||||
|
const app = createApp(Market, { ...mountData?.dataset })
|
||||||
|
app.mount(selector)
|
||||||
@ -7,7 +7,9 @@ import type {
|
|||||||
SubmissionFile,
|
SubmissionFile,
|
||||||
UserInfo,
|
UserInfo,
|
||||||
TournamentSubmissions,
|
TournamentSubmissions,
|
||||||
TournamentPuzzleResults
|
TournamentPuzzleResults,
|
||||||
|
Market,
|
||||||
|
UserBet
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
|
||||||
// API Configuration
|
// API Configuration
|
||||||
@ -215,6 +217,38 @@ export class ApiService {
|
|||||||
async getUserInfo(): Promise<ApiResponse<UserInfo>> {
|
async getUserInfo(): Promise<ApiResponse<UserInfo>> {
|
||||||
return this.request<UserInfo>('/user')
|
return this.request<UserInfo>('/user')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Market endpoints
|
||||||
|
async getMarkets(): Promise<ApiResponse<Market[]>> {
|
||||||
|
return this.request<Market[]>('/market/')
|
||||||
|
}
|
||||||
|
|
||||||
|
async placeBet(marketUuid: string, betData: {
|
||||||
|
option_uuid: string
|
||||||
|
amount: number
|
||||||
|
}): Promise<ApiResponse<UserBet>> {
|
||||||
|
return this.request<UserBet>(`/market/${marketUuid}/bets`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(betData),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserBets(): Promise<ApiResponse<UserBet[]>> {
|
||||||
|
return this.request<UserBet[]>('/market/user/bets')
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeMarket(marketUuid: string): Promise<ApiResponse<{ status: string }>> {
|
||||||
|
return this.request<{ status: string }>(`/market/${marketUuid}/actions/close`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveMarket(marketUuid: string, winningOptionUuid: string): Promise<ApiResponse<Market>> {
|
||||||
|
return this.request<Market>(`/market/${marketUuid}/actions/resolve`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ winning_option_uuid: winningOptionUuid }),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance
|
// Singleton instance
|
||||||
|
|||||||
@ -169,3 +169,30 @@ export interface PuzzleResults {
|
|||||||
export interface TournamentPuzzleResults {
|
export interface TournamentPuzzleResults {
|
||||||
results: PuzzleResults[]
|
results: PuzzleResults[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MarketOption {
|
||||||
|
uuid: string
|
||||||
|
text: string
|
||||||
|
position: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Market {
|
||||||
|
uuid: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
type: 'yes_no' | 'multiple'
|
||||||
|
status: 'open' | 'closed' | 'resolved'
|
||||||
|
end_date: string
|
||||||
|
created_at: string
|
||||||
|
options: MarketOption[]
|
||||||
|
winning_option?: MarketOption | null
|
||||||
|
userHasBet?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserBet {
|
||||||
|
uuid: string
|
||||||
|
amount: number
|
||||||
|
created_at: string
|
||||||
|
option: MarketOption
|
||||||
|
market: Market
|
||||||
|
}
|
||||||
|
|||||||
13
polylan_submitter/templates/market.html
Normal file
13
polylan_submitter/templates/market.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% load django_vite %}
|
||||||
|
|
||||||
|
{% block title %}Market - PolyLAN Submitter{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{% vite_asset 'src/market.ts' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div id="app"></div>
|
||||||
|
{% endblock %}
|
||||||
Loading…
Reference in New Issue
Block a user