diff --git a/web/app/Helpers/NumberHelper.php b/web/app/Helpers/NumberHelper.php index dae2883..73ef585 100644 --- a/web/app/Helpers/NumberHelper.php +++ b/web/app/Helpers/NumberHelper.php @@ -26,6 +26,6 @@ class NumberHelper break; } - return number_format($number / $divisor, 0) . $shorthand; + return number_format(floor($number / $divisor), 0) . $shorthand; } } diff --git a/web/app/Http/Controllers/Api/ShopController.php b/web/app/Http/Controllers/Api/ShopController.php index 7619bf8..47d5cde 100644 --- a/web/app/Http/Controllers/Api/ShopController.php +++ b/web/app/Http/Controllers/Api/ShopController.php @@ -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); + } } diff --git a/web/app/Http/Controllers/Web/ClientController.php b/web/app/Http/Controllers/Web/ClientController.php index 34a35a9..36a3d81 100644 --- a/web/app/Http/Controllers/Web/ClientController.php +++ b/web/app/Http/Controllers/Web/ClientController.php @@ -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); } diff --git a/web/app/Http/Kernel.php b/web/app/Http/Kernel.php index e07d5f9..47fa205 100644 --- a/web/app/Http/Kernel.php +++ b/web/app/Http/Kernel.php @@ -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 ], ]; diff --git a/web/app/Models/Punishment.php b/web/app/Models/Punishment.php index 9cc3281..2e30c6a 100644 --- a/web/app/Models/Punishment.php +++ b/web/app/Models/Punishment.php @@ -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) diff --git a/web/app/Models/Transaction.php b/web/app/Models/Transaction.php new file mode 100644 index 0000000..01bc7b8 --- /dev/null +++ b/web/app/Models/Transaction.php @@ -0,0 +1,56 @@ + 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)])) + ]; + } +} diff --git a/web/app/Models/TransactionType.php b/web/app/Models/TransactionType.php new file mode 100644 index 0000000..6edd82d --- /dev/null +++ b/web/app/Models/TransactionType.php @@ -0,0 +1,11 @@ +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(); diff --git a/web/app/Models/UserAsset.php b/web/app/Models/UserAsset.php index 17c1ce1..a190090 100644 --- a/web/app/Models/UserAsset.php +++ b/web/app/Models/UserAsset.php @@ -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 + ]); + } } diff --git a/web/database/migrations/2022_06_05_140351_create_assets_table.php b/web/database/migrations/2022_06_05_140351_create_assets_table.php index 2fcc40d..d92ef31 100644 --- a/web/database/migrations/2022_06_05_140351_create_assets_table.php +++ b/web/database/migrations/2022_06_05_140351_create_assets_table.php @@ -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'); diff --git a/web/database/migrations/2023_01_07_221314_create_transactions_table.php b/web/database/migrations/2023_01_07_221314_create_transactions_table.php new file mode 100644 index 0000000..29ea2e0 --- /dev/null +++ b/web/database/migrations/2023_01_07_221314_create_transactions_table.php @@ -0,0 +1,39 @@ +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'); + } +}; diff --git a/web/database/migrations/2023_01_07_221336_create_transaction_types_table.php b/web/database/migrations/2023_01_07_221336_create_transaction_types_table.php new file mode 100644 index 0000000..107f453 --- /dev/null +++ b/web/database/migrations/2023_01_07_221336_create_transaction_types_table.php @@ -0,0 +1,36 @@ +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'); + } +}; diff --git a/web/database/seeders/DatabaseSeeder.php b/web/database/seeders/DatabaseSeeder.php index 96d6c36..6c50b19 100644 --- a/web/database/seeders/DatabaseSeeder.php +++ b/web/database/seeders/DatabaseSeeder.php @@ -19,7 +19,8 @@ class DatabaseSeeder extends Seeder AssetTypeSeeder::class, UsageCounterSeeder::class, RolesetSeeder::class, - PunishmentTypeSeeder::class + PunishmentTypeSeeder::class, + TransactionTypeSeeder::class //FFlagSeeder::class ]); } diff --git a/web/database/seeders/TransactionTypeSeeder.php b/web/database/seeders/TransactionTypeSeeder.php new file mode 100644 index 0000000..6007d60 --- /dev/null +++ b/web/database/seeders/TransactionTypeSeeder.php @@ -0,0 +1,25 @@ + '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]); + } +} diff --git a/web/resources/js/components/PurchaseButton.js b/web/resources/js/components/PurchaseButton.js index aa789e6..c85cbf2 100644 --- a/web/resources/js/components/PurchaseButton.js +++ b/web/resources/js/components/PurchaseButton.js @@ -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(); + } + else if(res.data.userFacingMessage != null) + { + this.props.setModal( + +

{ res.data.userFacingMessage }

+
+ ); + } + 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( + +

This item's price has changed from { oldPrice } to { res.data.priceInTokens }. {newTokens >= 0 ? 'Would you still like to purchase?' : null }

+
+ ); + } + }) + .catch(err => { + this.props.setModal( + +

An unexpected error occurred while purchasing this item.

+
+ ); + }); + } + + render() { + return ( +
+
+
+
+
Purchase Item
+ +
+
+

Would you like to purchase the { this.state.assetType } { this.state.assetName } from { this.state.assetCreator } for { this.state.assetPrice }?

+ +
+
+
+ +   + +
+

You will have { Math.max(0, (this.state.userTokens - this.state.assetPrice)) } after this purchase.

+
+
+
+
+ ); + } +} + +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 ( +
+
+
+
+
Error Occurred
+ +
+
+ { this.props.children } +
+
+ { + this.props.purchaseAsset && this.props.newTokens >= 0 ? + <> +
+ +   + +
+

You will have { this.props.newTokens } after this purchase.

+ + : + + } +
+
+
+
+ ); + } +} + +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 {
-
Purchase Item
- +
Purchase Successful
-
-

Would you like to purchase the { this.state.assetType } { this.state.assetName } from { this.state.assetCreator } for { this.state.assetPrice }?

- { -
-
-
- -   - -
-

You will have { Math.max(0, (this.state.userTokens - this.state.assetPrice)) } after this purchase.

+
+

You have successfully purchased { this.state.assetName } for { this.state.assetPrice }!

@@ -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 + ? <> { this.state.showModal ? this.visibleModal : null } + : + ); } } diff --git a/web/resources/views/web/auth/moderated.blade.php b/web/resources/views/web/auth/moderated.blade.php index 027a8fe..756e0aa 100644 --- a/web/resources/views/web/auth/moderated.blade.php +++ b/web/resources/views/web/auth/moderated.blade.php @@ -5,7 +5,7 @@ @section('content')
-
+

{{ $punishment->punishment_type->label }}

Your account has been {{ $punishment->isDeletion() ? 'closed ' : 'temporarily restricted' }} for violating our Terms of Service. diff --git a/web/resources/views/web/shop/asset.blade.php b/web/resources/views/web/shop/asset.blade.php index fd677d6..aee52eb 100644 --- a/web/resources/views/web/shop/asset.blade.php +++ b/web/resources/views/web/shop/asset.blade.php @@ -35,6 +35,9 @@ @section('content')

+ @if(isset($status)) +
{{ $status }}
+ @endif @if(!$asset->approved)
This asset is pending approval. It will not appear in-game and cannot be voted on or purchased at this time.
@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 @@

{{ $asset->name }}

-

By {{ $asset->user->username }}

+

+ By {{ $asset->user->username }} + @if(Auth::user()->hasAsset($asset->id)) + +   +   + + Item Owned + + @endif +


{{-- TODO: XlXi: limiteds/trading --}}
diff --git a/web/routes/api.php b/web/routes/api.php index 06ae448..c8566da 100644 --- a/web/routes/api.php +++ b/web/routes/api.php @@ -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'); }); });