More catalog changes.

- Added padding to punishments page.
- Also fixes a bug with the expiration date in the Punishment model.
- Transactionss.
- Created methods for user assets to automatically calculate an item's serial.
- Added a "hasAsset" function to the User model to check if a user owns an asset id.
- Asset purchase API.
- Fix bug where apis could be accessed despite the site being under maintenance.
- Fixed rounding issue with number formatting.
- Updated shop JS to allow for purchases.
- Added userasset downloading to the /asset client api.
This commit is contained in:
Graphictoria 2023-01-07 21:25:24 -05:00
parent 0083a01d85
commit c00e8ccc75
18 changed files with 545 additions and 45 deletions

View File

@ -26,6 +26,6 @@ class NumberHelper
break;
}
return number_format($number / $divisor, 0) . $shorthand;
return number_format(floor($number / $divisor), 0) . $shorthand;
}
}

View File

@ -2,12 +2,17 @@
namespace App\Http\Controllers\Api;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use App\Helpers\ValidationHelper;
use App\Http\Controllers\Controller;
use App\Models\Asset;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use App\Models\Transaction;
use App\Models\TransactionType;
use App\Models\UserAsset;
class ShopController extends Controller
{
@ -89,4 +94,57 @@ class ShopController extends Controller
'data' => $data
]);
}
protected function purchase(Request $request, Asset $asset)
{
// TODO: XlXi: limiteds
$validator = Validator::make($request->all(), [
'expectedPrice' => ['int']
]);
if($validator->fails()) {
return ValidationHelper::generateValidatorError($validator);
}
$valid = $validator->valid();
$result = [
'success' => false,
'userFacingMessage' => null,
'priceInTokens' => $asset->priceInTokens
];
$price = $asset->priceInTokens;
$user = Auth::user();
if($asset->assetType->locked)
{
$result['userFacingMessage'] = 'This asset cannot be purchased.';
$result['priceInTokens'] = null;
return response($result);
}
if($user->hasAsset($asset->id))
{
$result['userFacingMessage'] = 'You already own this item.';
return response($result);
}
if($valid['expectedPrice'] != $price)
return response($result);
if($asset->priceInTokens > $user->tokens)
{
$result['userFacingMessage'] = 'You can\'t afford this item.';
return response($result);
}
$result['success'] = true;
Transaction::createAssetSale($user, $asset);
$user->removeFunds($price);
$asset->user->addFunds($price * (1-.3)); // XlXi: 30% tax
UserAsset::createSerialed($user->id, $asset->id);
return response($result);
}
}

View File

@ -13,6 +13,7 @@ use App\Http\Controllers\Controller;
use App\Models\Asset;
use App\Models\AssetVersion;
use App\Models\RobloxAsset;
use App\Models\UserAsset;
class ClientController extends Controller
{
@ -43,16 +44,27 @@ class ClientController extends Controller
];
}
function userAssetValidator()
{
return [
'userassetid' => [
'required',
Rule::exists('App\Models\UserAsset', 'id')
]
];
}
function asset(Request $request)
{
// TODO: XlXi: userAssetId (owned asset)
$reqData = array_change_key_case($request->all());
$isVersionIdRequest = array_key_exists('assetversionid', $reqData);
$isUserAssetIdRequest = array_key_exists('userassetid', $reqData);
$validatorRuleSet = 'assetRegularValidator';
if(array_key_exists('assetversionid', $reqData))
if($isVersionIdRequest)
$validatorRuleSet = 'assetVersionValidator';
elseif(array_key_exists('userassetid', $reqData))
return response('todo');
elseif($isUserAssetIdRequest)
$validatorRuleSet = 'userAssetValidator';
$validator = Validator::make($reqData, $this->{$validatorRuleSet}());
@ -68,20 +80,23 @@ class ClientController extends Controller
$valid = $validator->valid();
$asset = null;
if(array_key_exists('assetversionid', $reqData)) {
if($isVersionIdRequest) {
$assetVersion = AssetVersion::where('id', $valid['assetversionid'])->first();
$asset = $assetVersion->asset;
$valid['version'] = $assetVersion->localVersion;
} elseif($isUserAssetIdRequest) {
$userAsset = UserAsset::where('id', $valid['userassetid'])->first();
$asset = $userAsset->asset;
} else {
$asset = Asset::where('id', $valid['id'])->first();
if(!array_key_exists('version', $valid))
$valid['version'] = 0;
}
if(!$isVersionIdRequest && !array_key_exists('version', $valid))
$valid['version'] = 0;
if($asset == null) {
$validator->errors()->add('version', 'Unknown asset version.');
$validator->errors()->add('version', 'Unknown asset' . ($isVersionIdRequest ? ' version' : null) . '.');
return ValidationHelper::generateValidatorError($validator);
}

View File

@ -51,6 +51,8 @@ class Kernel extends HttpKernel
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\App\Http\Middleware\UserPunishmentMiddleware::class
],
];

View File

@ -72,7 +72,7 @@ class Punishment extends Model
if(!$this->expiration)
return 'Never';
return $this->created_at->isoFormat('lll');
return $this->expiration->isoFormat('lll');
}
public static function activeFor($userId)

View File

@ -0,0 +1,56 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Transaction extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'transaction_type_id',
'asset_id',
'place_id',
'user_id',
'delta',
'seller_id'
];
protected static function createPurchase($transaction)
{
return self::create(array_merge($transaction, [
'transaction_type_id' => TransactionType::where('name', 'Purchases')->first()->id
]));
}
protected static function createSale($transaction)
{
return self::create(array_merge($transaction, [
'transaction_type_id' => TransactionType::where('name', 'Sales')->first()->id
]));
}
public static function createAssetSale($user, $asset, $placeId = null)
{
$transaction = [
'asset_id' => $asset->id,
'place_id' => $placeId,
'user_id' => $user->id,
'delta' => $asset->priceInTokens,
'seller_id' => $asset->creatorId
];
// XlXi: Assets have a 30% tax.
return [
self::createPurchase($transaction),
self::createSale(array_merge($transaction, ['delta' => $transaction['delta'] * (1-.3)]))
];
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class TransactionType extends Model
{
use HasFactory;
}

View File

@ -147,6 +147,25 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->hasMany(Punishment::class, 'user_id');
}
public function hasAsset($assetId)
{
return UserAsset::where('asset_id', $assetId)
->where('owner_id', $this->id)
->exists();
}
public function removeFunds($delta)
{
$this->tokens -= $delta;
$this->save();
}
public function addFunds($delta)
{
$this->tokens += $delta;
$this->save();
}
public function _hasRolesetInternal($roleName)
{
$roleset = Roleset::where('Name', $roleName)->first();

View File

@ -8,4 +8,29 @@ use Illuminate\Database\Eloquent\Model;
class UserAsset extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'owner_id',
'asset_id',
'serial'
];
public function asset()
{
return $this->belongsTo(Asset::class, 'asset_id');
}
public static function createSerialed($ownerId, $assetId)
{
return self::create([
'owner_id' => $ownerId,
'asset_id' => $assetId,
'serial' => self::where('asset_id', $assetId)->count()+1
]);
}
}

View File

@ -29,7 +29,6 @@ return new class extends Migration
$table->unsignedBigInteger('downVotes')->default(0);
$table->unsignedBigInteger('priceInTokens')->default(15);
$table->unsignedBigInteger('sales')->default(0);
$table->boolean('onSale')->default(false);
$table->unsignedSmallInteger('assetTypeId');

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('transactions', function (Blueprint $table) {
$table->id();
$table->unsignedSmallInteger('transaction_type_id');
$table->unsignedBigInteger('asset_id')->nullable();
$table->unsignedBigInteger('place_id')->nullable();
$table->unsignedBigInteger('user_id');
$table->bigInteger('delta');
$table->unsignedBigInteger('seller_id');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('transactions');
}
};

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('transaction_types', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('format');
$table->boolean('hidden')->default(false);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('transaction_types');
}
};

View File

@ -19,7 +19,8 @@ class DatabaseSeeder extends Seeder
AssetTypeSeeder::class,
UsageCounterSeeder::class,
RolesetSeeder::class,
PunishmentTypeSeeder::class
PunishmentTypeSeeder::class,
TransactionTypeSeeder::class
//FFlagSeeder::class
]);
}

View File

@ -0,0 +1,25 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use App\Models\TransactionType;
class TransactionTypeSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
TransactionType::create(['name' => 'Purchases', 'format' => 'Purchased ']);
TransactionType::create(['name' => 'Sales', 'format' => 'Sold ']);
TransactionType::create(['name' => 'Commissions', 'format' => 'Sold ']); // third party sales
TransactionType::create(['name' => 'Group Payouts', 'format' => '']);
TransactionType::create(['name' => 'Admin Adjustment', 'format' => 'Tokens adjusted by administrator.', 'hidden' => true]);
}
}

View File

@ -3,12 +3,198 @@
*/
import { createRef, Component } from 'react';
import axios from 'axios';
import classNames from 'classnames/bind';
import ProgressiveImage from './ProgressiveImage';
import { buildGenericApiUrl } from '../util/HTTP.js';
axios.defaults.withCredentials = true;
const itemId = 'vb-item';
class PurchaseConfirmationModal extends Component {
constructor(props) {
super(props);
this.state = {
buttonDisabled: true
};
this.ModalRef = createRef();
this.Modal = null;
this.purchaseAsset = this.purchaseAsset.bind(this);
}
componentDidMount() {
let itemElement = document.getElementById(itemId);
if(itemElement) {
this.setState({
assetId: itemElement.getAttribute('data-asset-id'),
assetName: itemElement.getAttribute('data-asset-name'),
assetCreator: itemElement.getAttribute('data-asset-creator'),
assetType: itemElement.getAttribute('data-asset-type'),
assetPrice: parseInt(itemElement.getAttribute('data-asset-price')),
assetThumbnail: itemElement.getAttribute('data-asset-thumbnail-2d'),
userTokens: parseInt(itemElement.getAttribute('data-user-currency'))
});
}
this.Modal = new Bootstrap.Modal(this.ModalRef.current);
this.Modal.show();
this.ModalRef.current.addEventListener('hidden.bs.modal', (event) => {
this.props.setModal(null);
})
setTimeout(function(){
this.setState({buttonDisabled: false});
}.bind(this), 1000);
}
componentWillUnmount() {
this.Modal.dispose();
}
purchaseAsset() {
console.log('hi');
if(this.state.buttonDisabled) return;
this.setState({buttonDisabled: true});
axios.post(buildGenericApiUrl('api', `shop/v1/purchase/${this.state.assetId}?expectedPrice=${this.state.assetPrice}`))
.then(res => {
if(res.data.success)
{
this.props.setModal(<PurchaseSuccessModal setModal={this.props.setModal} />);
}
else if(res.data.userFacingMessage != null)
{
this.props.setModal(
<PurchaseErrorModal setModal={this.props.setModal}>
<p>{ res.data.userFacingMessage }</p>
</PurchaseErrorModal>
);
}
else
{
let oldPrice = this.state.assetPrice;
let newTokens = (this.state.userTokens - res.data.priceInTokens);
this.setState({assetPrice: res.data.priceInTokens, buttonDisabled: false});
this.props.setModal(
<PurchaseErrorModal setModal={ this.props.setModal } purchaseAsset={ this.purchaseAsset } newTokens={ newTokens }>
<p>This item's price has changed from <span className="virtubrick-tokens">{ oldPrice }</span> to <span className="virtubrick-tokens">{ res.data.priceInTokens }</span>. {newTokens >= 0 ? 'Would you still like to purchase?' : null }</p>
</PurchaseErrorModal>
);
}
})
.catch(err => {
this.props.setModal(
<PurchaseErrorModal setModal={this.props.setModal}>
<p>An unexpected error occurred while purchasing this item.</p>
</PurchaseErrorModal>
);
});
}
render() {
return (
<div ref={this.ModalRef} className="modal fade">
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content text-center">
<div className="modal-header">
<h5 className="modal-title">Purchase Item</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div className="modal-body d-flex flex-column">
<p>Would you like to purchase the { this.state.assetType } <strong>{ this.state.assetName }</strong> from { this.state.assetCreator } for <span className="virtubrick-tokens">{ this.state.assetPrice }</span>?</p>
<ProgressiveImage
src={ this.state.assetThumbnail }
placeholderImg={ buildGenericApiUrl('www', 'images/busy/asset.png') }
alt={ this.state.assetName }
width='240'
height='240'
className='mx-auto img-fluid'
/>
</div>
<div className="modal-footer flex-column">
<div className="mx-auto">
<button className="btn btn-success" disabled={ this.state.buttonDisabled } onClick={ this.purchaseAsset }>Purchase</button>
&nbsp;
<button className="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
<p className="text-muted pt-1">You will have <span className="virtubrick-tokens">{ Math.max(0, (this.state.userTokens - this.state.assetPrice)) }</span> after this purchase.</p>
</div>
</div>
</div>
</div>
);
}
}
class PurchaseErrorModal extends Component {
constructor(props) {
super(props);
this.state = {
buttonDisabled: true
};
this.ModalRef = createRef();
this.Modal = null;
}
componentDidMount() {
this.Modal = new Bootstrap.Modal(this.ModalRef.current);
this.Modal.show();
this.ModalRef.current.addEventListener('hidden.bs.modal', (event) => {
this.props.setModal(null);
})
setTimeout(function(){
this.setState({buttonDisabled: false});
}.bind(this), 1000);
}
componentWillUnmount() {
this.Modal.dispose();
}
render() {
return (
<div ref={this.ModalRef} className="modal fade">
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content text-center">
<div className="modal-header">
<h5 className="modal-title">Error Occurred</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div className="modal-body">
{ this.props.children }
</div>
<div className="modal-footer flex-column">
{
this.props.purchaseAsset && this.props.newTokens >= 0 ?
<>
<div className="mx-auto">
<button className="btn btn-success" disabled={ this.state.buttonDisabled } onClick={ () => { this.setState({buttonDisabled: true}); this.props.purchaseAsset(); } }>Purchase</button>
&nbsp;
<button className="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
<p className="text-muted pt-1">You will have <span className="virtubrick-tokens">{ this.props.newTokens }</span> after this purchase.</p>
</>
:
<button className="btn btn-secondary" data-bs-dismiss="modal">Ok</button>
}
</div>
</div>
</div>
</div>
);
}
}
class PurchaseSuccessModal extends Component {
constructor(props) {
super(props);
this.state = {
@ -24,19 +210,22 @@ class PurchaseConfirmationModal extends Component {
if(itemElement) {
this.setState({
assetName: itemElement.getAttribute('data-asset-name'),
assetCreator: itemElement.getAttribute('data-asset-creator'),
assetType: itemElement.getAttribute('data-asset-type'),
assetPrice: parseInt(itemElement.getAttribute('data-asset-price')),
userTokens: parseInt(itemElement.getAttribute('data-user-currency'))
assetPrice: parseInt(itemElement.getAttribute('data-asset-price'))
});
}
this.Modal = new Bootstrap.Modal(this.ModalRef.current);
this.Modal = new Bootstrap.Modal(
this.ModalRef.current,
{
backdrop: 'static',
keyboard: false
}
);
this.Modal.show();
this.ModalRef.current.addEventListener('hidden.bs.modal', (event) => {
this.props.setModal(null);
})
setTimeout(function(){
window.location.reload();
}, 2000);
}
componentWillUnmount() {
@ -49,20 +238,10 @@ class PurchaseConfirmationModal extends Component {
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content text-center">
<div className="modal-header">
<h5 className="modal-title">Purchase Item</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<h5 className="modal-title">Purchase Successful</h5>
</div>
<div className="modal-body d-flex flex-column">
<p>Would you like to purchase the { this.state.assetType } <strong>{ this.state.assetName }</strong> from { this.state.assetCreator } for <span className="virtubrick-tokens">{ this.state.assetPrice }</span>?</p>
<img src="/images/testing/hat.png" width="240" height="240" alt={ this.state.assetName } className="mx-auto my-2 img-fluid" />
</div>
<div className="modal-footer flex-column">
<div className="mx-auto">
<button className="btn btn-success">Purchase</button>
&nbsp;
<button className="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
<p className="text-muted pt-1">You will have <span className="virtubrick-tokens">{ Math.max(0, (this.state.userTokens - this.state.assetPrice)) }</span> after this purchase.</p>
<div className="modal-body">
<p>You have successfully purchased { this.state.assetName } for <span className="virtubrick-tokens">{ this.state.assetPrice }</span>!</p>
</div>
</div>
</div>
@ -130,7 +309,8 @@ class PurchaseButton extends Component {
super(props);
this.state = {
loaded: false,
showModal: false
showModal: false,
canPurchase: false
};
this.visibleModal = null;
@ -144,7 +324,11 @@ class PurchaseButton extends Component {
if(itemElement) {
this.setState({
assetOnSale: (itemElement.getAttribute('data-asset-on-sale') === '1'),
canAfford: (itemElement.getAttribute('data-can-afford') === '1')
canAfford: (itemElement.getAttribute('data-can-afford') === '1'),
owned: (itemElement.getAttribute('data-owned') === '1')
}, function(){
let canPurchase = (!this.state.owned && this.state.assetOnSale);
this.setState({canPurchase: canPurchase});
});
}
@ -170,23 +354,37 @@ class PurchaseButton extends Component {
render() {
return (
this.state.loaded
?
<>
<button
className={classNames({
'px-5': true,
'btn': true,
'btn-lg': true,
'btn-success': this.state.assetOnSale,
'btn-secondary': !this.state.assetOnSale
'btn-success': this.state.canPurchase,
'btn-secondary': !this.state.canPurchase
})}
disabled={ !(this.state.loaded && this.state.assetOnSale) ? true : null }
disabled={ !this.state.canPurchase ? true : null }
onClick={ this.showPrompt }
>
{ (!this.state.loaded || this.state.assetOnSale) ? 'Buy' : 'Offsale' }
{
this.state.canPurchase
?
'Buy'
:
this.state.owned
?
'Owned'
:
'Offsale'
}
</button>
{ this.state.showModal ? this.visibleModal : null }
</>
:
<button className="px-5 btn btn-lg btn-success" disabled={true}>Buy</button>
);
}
}

View File

@ -5,7 +5,7 @@
@section('content')
<div class="container m-auto">
<div class="card p-3 virtubrick-moderation-card">
<div class="card p-3 my-4 virtubrick-moderation-card">
<h3>{{ $punishment->punishment_type->label }}</h3>
<p>
Your account has been {{ $punishment->isDeletion() ? 'closed ' : 'temporarily restricted' }} for violating our Terms of Service.

View File

@ -35,6 +35,9 @@
@section('content')
<div class="container mx-auto py-5">
@if(isset($status))
<div class="alert alert-success text-center">{{ $status }}</div>
@endif
@if(!$asset->approved)
<div class="alert alert-danger text-center"><strong>This asset is pending approval.</strong> It will not appear in-game and cannot be voted on or purchased at this time.</div>
@endif
@ -44,7 +47,9 @@
data-asset-name="{{ $asset->name }}"
data-asset-creator="{{ $asset->user->username }}"
data-asset-type="{{ $asset->typeString() }}"
data-asset-thumbnail-2d="{{ $asset->getThumbnail() }}"
data-asset-on-sale="{{ $asset->onSale }}"
data-owned="{{ Auth::user()->hasAsset($asset->id) ? '1' : '0' }}"
@if ($asset->onSale)
data-asset-price="{{ $asset->priceInTokens }}"
data-user-currency="{{ Auth::user()->tokens }}"
@ -74,7 +79,17 @@
</div>
<div class="flex-fill">
<h3 class="mb-0">{{ $asset->name }}</h3>
<p>By <a class="text-decoration-none fw-normal" href="{{ $asset->user->getProfileUrl() }}">{{ $asset->user->username }}</a></p>
<p>
By <a class="text-decoration-none fw-normal" href="{{ $asset->user->getProfileUrl() }}">{{ $asset->user->username }}</a>
@if(Auth::user()->hasAsset($asset->id))
<span>
&nbsp;
&nbsp;
<i class="fa-solid fa-circle-check text-success"></i>
Item Owned
</span>
@endif
</p>
<hr />
{{-- TODO: XlXi: limiteds/trading --}}
<div class="row mt-2">

View File

@ -50,6 +50,7 @@ Route::group(['as' => 'comments.', 'prefix' => 'comments'], function() {
Route::group(['as' => 'shop.', 'prefix' => 'shop'], function() {
Route::group(['as' => 'v1.', 'prefix' => 'v1'], function() {
Route::get('/list-json', 'ShopController@listJson')->name('list');
Route::post('/purchase/{asset}', 'ShopController@purchase')->name('purchase');
});
});