first commit splitting onto its own repo

This commit is contained in:
2025-10-11 18:31:07 -07:00
commit 48df5db468
27 changed files with 1661 additions and 0 deletions

99
src/CakeCartsPlugin.php Normal file
View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace CakeCarts;
use Cake\Console\CommandCollection;
use Cake\Core\BasePlugin;
use Cake\Core\ContainerInterface;
use Cake\Core\PluginApplicationInterface;
use Cake\Http\MiddlewareQueue;
use Cake\Routing\RouteBuilder;
/**
* Plugin for CakeCarts
*/
class CakeCartsPlugin extends BasePlugin
{
/**
* Load all the plugin configuration and bootstrap logic.
*
* The host application is provided as an argument. This allows you to load
* additional plugin dependencies, or attach events.
*
* @param \Cake\Core\PluginApplicationInterface $app The host application
* @return void
*/
public function bootstrap(PluginApplicationInterface $app): void
{
// remove this method hook if you don't need it
}
/**
* Add routes for the plugin.
*
* If your plugin has many routes and you would like to isolate them into a separate file,
* you can create `$plugin/config/routes.php` and delete this method.
*
* @param \Cake\Routing\RouteBuilder $routes The route builder to update.
* @return void
*/
public function routes(RouteBuilder $routes): void
{
// remove this method hook if you don't need it
$routes->plugin(
'CakeCarts',
['path' => '/cake-carts'],
function (RouteBuilder $builder) {
// Add custom routes here
$builder->connect('/wishlist', ['controller' => 'CakeCarts', 'action' => 'wishlist']);
$builder->fallbacks();
}
);
parent::routes($routes);
}
/**
* Add middleware for the plugin.
*
* @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to update.
* @return \Cake\Http\MiddlewareQueue
*/
public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue
{
// Add your middlewares here
// remove this method hook if you don't need it
return $middlewareQueue;
}
/**
* Add commands for the plugin.
*
* @param \Cake\Console\CommandCollection $commands The command collection to update.
* @return \Cake\Console\CommandCollection
*/
public function console(CommandCollection $commands): CommandCollection
{
// Add your commands here
// remove this method hook if you don't need it
$commands = parent::console($commands);
return $commands;
}
/**
* Register application container services.
*
* @param \Cake\Core\ContainerInterface $container The Container to update.
* @return void
* @link https://book.cakephp.org/5/en/development/dependency-injection.html#dependency-injection
*/
public function services(ContainerInterface $container): void
{
// Add your services here
// remove this method hook if you don't need it
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace CakeCarts\Controller;
use App\Controller\AppController as BaseController;
class AppController extends BaseController
{
}

View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace CakeCarts\Controller;
use Cake\Core\Configure;
use Cake\Datasource\Exception\RecordNotFoundException;
use Cake\Http\Client;
use Cake\Log\Log;
use CakeCarts\Controller\AppController;
/**
* CartItems Controller
*
* @property \CakeCarts\Model\Table\CartItemsTable $CartItems
* @property \Authorization\Controller\Component\AuthorizationComponent $Authorization
*/
class CartItemsController extends AppController
{
public function initialize(): void
{
parent::initialize(); // TODO: Change the autogenerated stub
$this->loadComponent('CakeCarts.ShoppingCart', [
// This is default config. You can modify "actions" as needed to make
// component work only for specified methods.
'actions' => ['add'],
]);
}
/**
* Add method
*
* @return \Cake\Http\Response|null|void Redirects on successful add, renders view otherwise.
*/
public function add()
{
$this->request->allowMethod(['post', 'put', 'patch']);
$this->Authorization->skipAuthorization();
Log::debug(print_r('$this->request->getData()', true));
Log::debug(print_r($this->request->getData(), true));
$cart = $this->viewBuilder()->getVar('cart');
$postData = $this->request->getData();
$postData['cart_id'] = $cart->id;
// get product skus with variants
$price = $this->request->getData('price');
$qty = $this->request->getData('qty', 1);
$postData['price'] = $price;
$postData['subtotal'] = isset($price) ? bcmul("$price", "$qty", 5) : null;
$newCartItem = $this->Carts->CartItems->newEntity($postData, [
'validate' => Configure::readOrFail('CakeCarts.CartItems.requirePricing') ? 'requirePricing' : 'default',
]);
if ($this->Carts->CartItems->save($newCartItem)) {
$this->Flash->success('Added to cart');
return $this->redirect($this->referer([
'plugin' => 'CakeCarts',
'controller' => 'CartItems',
'action' => 'index'
]));
}
Log::debug(print_r('$newCartItem->getErrors()', true));
Log::debug(print_r($newCartItem->getErrors(), true));
$this->Flash->error('Failed to add to cart.');
return $this->redirect($this->referer([
'plugin' => 'CakeCarts',
'controller' => 'CartItems',
'action' => 'index'
]));
}
/**
* Edit method
*
* @param string|null $id Cart Item id.
* @return \Cake\Http\Response|null|void Redirects on successful edit, renders view otherwise.
* @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
*/
public function edit($id = null)
{
$this->request->allowMethod(['post', 'put', 'patch']);
$cartItem = $this->CartItems->find()
->where(['CartItems.id' => $id])
->contain(['Carts'])
->firstOrFail();
$this->ShoppingCart->checkIfIsOwnCart($cartItem->cart);
$this->Authorization->skipAuthorization();
if ($this->request->is(['patch', 'post', 'put'])) {
$postData = $this->request->getData();
$qty = $this->request->getData('qty', 1);
$price = $this->request->getData('price', null);
$subtotal = isset($price) ? bcmul("$qty", "$price", 5) : null;
$postData['subtotal'] = $subtotal;
$cartItem = $this->CartItems->patchEntity($cartItem, $postData, [
'validate' => Configure::readOrFail('CakeCarts.CartItems.requirePricing') ? 'requiresPricing' : 'default',
]);
if ($this->CartItems->save($cartItem)) {
$this->Flash->success(__('The cart item has been saved.'));
return $this->redirect($this->referer([
'plugin' => 'CakeCarts',
'controller' => 'CartItems',
'action' => 'index'
]));
}
Log::debug(print_r('$cartItem->getErrors()', true));
Log::debug(print_r($cartItem->getErrors(), true));
$this->Flash->error(__('The cart item could not be saved. Please, try again.'));
}
return $this->redirect($this->referer([
'plugin' => 'CakeCarts',
'controller' => 'CartItems',
'action' => 'index'
]));
}
/**
* Delete method
*
* @param string|null $id Cart Item id.
* @return \Cake\Http\Response|null Redirects to index.
* @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
*/
public function delete($id = null)
{
$this->request->allowMethod(['post', 'delete']);
$identity = $this->getRequest()->getAttribute('identity');
// $cart = $this->viewBuilder()->getVar('cart');
$cartItem = $this->CartItems->find()
->where(['CartItems.id' => $id])
->contain(['Carts'])
->firstOrFail();
$this->ShoppingCart->checkIfIsOwnCart($cartItem->cart);
$this->Authorization->skipAuthorization();
unset($cartItem->cart);
if ($this->CartItems->delete($cartItem)) {
$this->Flash->success(__('The cart item has been deleted.'));
} else {
$this->Flash->error(__('The cart item could not be deleted. Please, try again.'));
}
return $this->redirect($this->referer([
'plugin' => 'CakeCarts',
'controller' => 'CartItems',
'action' => 'index'
]));
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace CakeCarts\Controller;
use App\Controller\AppController;
use Cake\Core\Configure;
use Cake\Event\EventInterface;
use CakeCarts\Model\Enum\CartTypeId;
/**
* Carts Controller
*
* @property \Authorization\Controller\Component\AuthorizationComponent $Authorization
*/
class CartsController extends AppController
{
public function initialize(): void
{
parent::initialize(); // TODO: Change the autogenerated stub
$this->loadComponent('CakeCarts.ShoppingCart', [
// This is default config. You can modify "actions" as needed to make
// component work only for specified methods.
'actions' => true,
]);
}
/**
* Index method
*
* @return \Cake\Http\Response|null|void Renders view
*/
public function index()
{
// use cart from beforeFilter
}
}

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace CakeCarts\Controller\Component;
use Cake\Controller\Component;
use Cake\Core\Configure;
use Cake\Datasource\EntityInterface;
use Cake\Datasource\Exception\RecordNotFoundException;
use Cake\Event\EventInterface;
use Cake\ORM\TableRegistry;
use CakeCarts\Model\Entity\CartItem;
use CakeCarts\Model\Enum\CartTypeId;
/**
* ShoppingCart component
*/
class ShoppingCartComponent extends Component
{
/**
* @var string $userIdField
*/
protected string $userIdField;
public function initialize(array $config): void
{
parent::initialize($config); // TODO: Change the autogenerated stub
$this->userIdField = Configure::readOrFail('CakeCarts.Users.user_id') === 'uuid' ? 'user_id_uuid' : 'user_id';
}
/**
* Default configuration.
*
* @var array<string, mixed>
*/
protected array $_defaultConfig = [];
public function beforeFilter(EventInterface $event): void
{
if (!$this->_isActionEnabled()) {
return;
}
$sessionId = $this->getSessionId();
$cart = $this->findExistingCartOrCreate($sessionId);
$this->getController()->set(compact('cart'));
}
public function findExistingCartOrCreate(string $sessionId, int $cartTypeId = null)
{
$cartsTable = TableRegistry::getTableLocator()->get(Configure::readOrFail('CakeCarts.Carts.table'));
$userIdField = Configure::readOrFail('CakeCarts.Users.user_id') === 'integer' ? 'user_id' : 'user_id_uuid';
$identity = $this->getController()->getRequest()->getAttribute('identity');
$cartTypeId = $cartTypeId ?? CartTypeId::Cart->value;
$cart = $cartsTable
->findBySessionId($sessionId)
->contain(['CartItems'])
->where(['cart_type_id' => $cartTypeId])
->first();
if (isset($cart) && isset($identity) && !isset($cart[$this->userIdField])) {
$cart = $cartsTable->patchEntity([
$this->userIdField => $identity->getIdentifier(),
]);
$cart = $cartsTable->saveOrFail($cart);
}
if (!isset($cart)) {
$cart = $cartsTable->newEntity([
'cart_type_id' => $cartTypeId,
'session_id' => $sessionId,
$this->userIdField => isset($identity) ? $identity->getIdentifier() : null,
'num_items' => 0,
'cart_items' => [],
]);
$cart = $cartsTable->saveOrFail($cart);
}
return $cart;
}
public function getUserIdField()
{
return $this->userIdField;
}
/**
* @return string
*/
public function getSessionId(): string
{
if (!$this->getController()->getRequest()->getSession()->started()) {
$this->getController()->getRequest()->getSession()->start();
}
if (!$this->getController()->getRequest()->getSession()->check('CakeCarts.session_id')) {
$this->getController()->getRequest()->getSession()->write('CakeCarts.session_id', $this->getController()->getRequest()->getSession()->id());
}
return $this->getController()->getRequest()->getSession()->read('CakeCarts.session_id');
}
/**
* @param EntityInterface $cart
* @throws RecordNotFoundException
*
* @return void
*/
public function checkIfIsOwnCart(EntityInterface $cart): void
{
$identity = $this->getController()->getRequest()->getAttribute('identity');
if (!isset($identity) && isset($cart->session_id) && ($cart->session_id != $this->getSessionId())) {
throw new RecordNotFoundException();
}
if (isset($identity) && $identity->getIdentifier() != $cart->get($this->getUserIdField())) {
throw new RecordNotFoundException();
}
}
/**
* @return bool
*/
protected function _isActionEnabled(): bool
{
$actions = $this->getConfig('actions');
if (is_bool($actions)) {
return $actions;
}
return in_array($this->getController()->getRequest()->getParam('action'), (array)$actions, true);
}
}

50
src/Model/Entity/Cart.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace CakeCarts\Model\Entity;
use Cake\ORM\Entity;
/**
* Cart Entity
*
* @property string $id
* @property int $cart_type_id
* @property string|null $session_id
* @property integer|null $user_id
* @property string|null $user_id_uuid
* @property \Cake\I18n\DateTime $created
* @property \Cake\I18n\DateTime|null $modified
* @property \Cake\I18n\DateTime|null $deleted
* @property \Cake\I18n\DateTime $removed
* @property int $removed_reason_id
* @property int $num_items
*
* @property \CakeCarts\Model\Entity\CartItem[] $cart_items
*/
class Cart extends Entity
{
/**
* Fields that can be mass assigned using newEntity() or patchEntity().
*
* Note that when '*' is set to true, this allows all unspecified fields to
* be mass assigned. For security purposes, it is advised to set '*' to false
* (or remove it), and explicitly make individual fields accessible as needed.
*
* @var array<string, bool>
*/
protected array $_accessible = [
'cart_type_id' => true,
'session_id' => true,
'user_id' => true,
'user_id_uuid' => true,
'created' => true,
'modified' => true,
'deleted' => true,
'removed' => true,
'removed_reason_id' => true,
'num_items' => true,
'user' => true,
'cart_items' => true,
];
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace CakeCarts\Model\Entity;
use Cake\ORM\Entity;
/**
* CartItem Entity
*
* @property string $id
* @property int $foreign_key
* @property string $foreign_key_uuid
* @property string $model
* @property string $cart_id
* @property int|null $position
* @property int $qty
* @property string $price
* @property string $subtotal
*
* @property \CakeCarts\Model\Entity\Cart $cart
*/
class CartItem extends Entity
{
/**
* Fields that can be mass assigned using newEntity() or patchEntity().
*
* Note that when '*' is set to true, this allows all unspecified fields to
* be mass assigned. For security purposes, it is advised to set '*' to false
* (or remove it), and explicitly make individual fields accessible as needed.
*
* @var array<string, bool>
*/
protected array $_accessible = [
'foreign_key' => true,
'foreign_key_uuid' => true,
'model' => true,
'cart_id' => true,
'position' => true,
'qty' => true,
'price' => true,
'subtotal' => true,
'cart' => true,
];
}

View File

@@ -0,0 +1,23 @@
<?php
namespace CakeCarts\Model\Enum;
use Cake\Database\Type\EnumLabelInterface;
use Tools\Model\Enum\EnumOptionsTrait;
enum CartTypeId: int implements EnumLabelInterface
{
use EnumOptionsTrait;
case Cart = 1;
case Wishlist = 2;
case CustomList = 3;
public function label(): string
{
return match($this) {
self::Cart => 'Cart',
self::Wishlist => 'Wishlist',
self::CustomList => 'CustomList'
};
}
}

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace CakeCarts\Model\Table;
use Cake\ORM\Query\SelectQuery;
use Cake\ORM\RulesChecker;
use Cake\ORM\Table;
use Cake\Validation\Validator;
/**
* CartItems Model
*
* @property \CakeCarts\Model\Table\CartsTable&\Cake\ORM\Association\BelongsTo $Carts
*
* @method \CakeCarts\Model\Entity\CartItem newEmptyEntity()
* @method \CakeCarts\Model\Entity\CartItem newEntity(array $data, array $options = [])
* @method array<\CakeCarts\Model\Entity\CartItem> newEntities(array $data, array $options = [])
* @method \CakeCarts\Model\Entity\CartItem get(mixed $primaryKey, array|string $finder = 'all', \Psr\SimpleCache\CacheInterface|string|null $cache = null, \Closure|string|null $cacheKey = null, mixed ...$args)
* @method \CakeCarts\Model\Entity\CartItem findOrCreate($search, ?callable $callback = null, array $options = [])
* @method \CakeCarts\Model\Entity\CartItem patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = [])
* @method array<\CakeCarts\Model\Entity\CartItem> patchEntities(iterable $entities, array $data, array $options = [])
* @method \CakeCarts\Model\Entity\CartItem|false save(\Cake\Datasource\EntityInterface $entity, array $options = [])
* @method \CakeCarts\Model\Entity\CartItem saveOrFail(\Cake\Datasource\EntityInterface $entity, array $options = [])
* @method iterable<\CakeCarts\Model\Entity\CartItem>|\Cake\Datasource\ResultSetInterface<\CakeCarts\Model\Entity\CartItem>|false saveMany(iterable $entities, array $options = [])
* @method iterable<\CakeCarts\Model\Entity\CartItem>|\Cake\Datasource\ResultSetInterface<\CakeCarts\Model\Entity\CartItem> saveManyOrFail(iterable $entities, array $options = [])
* @method iterable<\CakeCarts\Model\Entity\CartItem>|\Cake\Datasource\ResultSetInterface<\CakeCarts\Model\Entity\CartItem>|false deleteMany(iterable $entities, array $options = [])
* @method iterable<\CakeCarts\Model\Entity\CartItem>|\Cake\Datasource\ResultSetInterface<\CakeCarts\Model\Entity\CartItem> deleteManyOrFail(iterable $entities, array $options = [])
*/
class CartItemsTable extends Table
{
/**
* Initialize method
*
* @param array<string, mixed> $config The configuration for the Table.
* @return void
*/
public function initialize(array $config): void
{
parent::initialize($config);
$this->setTable('cart_items');
$this->setDisplayField('id');
$this->setPrimaryKey('id');
$this->belongsTo('Carts', [
'foreignKey' => 'cart_id',
'joinType' => 'INNER',
'className' => 'CakeCarts.Carts',
]);
}
/**
* Default validation rules.
*
* @param \Cake\Validation\Validator $validator Validator instance.
* @return \Cake\Validation\Validator
*/
public function validationDefault(Validator $validator): Validator
{
$validator
->uuid('product_sku_id')
->requirePresence('product_sku_id', 'create')
->notEmptyString('product_sku_id');
$validator
->uuid('cart_id')
->notEmptyString('cart_id');
$validator
->integer('position')
->allowEmptyString('position');
$validator
->integer('qty')
->requirePresence('qty', 'create')
->notEmptyString('qty');
return $validator;
}
public function validationRequiresPricing(Validator $validator): Validator
{
$validator = $this->validationDefault($validator);
$validator
->decimal('price')
->requirePresence('price', 'create')
->allowEmptyString('price');
$validator
->decimal('subtotal')
->requirePresence('subtotal', 'create')
->notEmptyString('subtotal');
return $validator;
}
/**
* Returns a rules checker object that will be used for validating
* application integrity.
*
* @param \Cake\ORM\RulesChecker $rules The rules object to be modified.
* @return \Cake\ORM\RulesChecker
*/
public function buildRules(RulesChecker $rules): RulesChecker
{
$rules->add($rules->existsIn(['cart_id'], 'Carts'), ['errorField' => 'cart_id']);
return $rules;
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace CakeCarts\Model\Table;
use Cake\Database\Type\EnumType;
use Cake\ORM\Query\SelectQuery;
use Cake\ORM\RulesChecker;
use Cake\ORM\Table;
use Cake\Validation\Validator;
use CakeCarts\Model\Enum\CartTypeId;
/**
* Carts Model
*
* @property \CakeCarts\Model\Table\UsersTable&\Cake\ORM\Association\BelongsTo $Users
* @property \CakeCarts\Model\Table\CartItemsTable&\Cake\ORM\Association\HasMany $CartItems
*
* @method \CakeCarts\Model\Entity\Cart newEmptyEntity()
* @method \CakeCarts\Model\Entity\Cart newEntity(array $data, array $options = [])
* @method array<\CakeCarts\Model\Entity\Cart> newEntities(array $data, array $options = [])
* @method \CakeCarts\Model\Entity\Cart get(mixed $primaryKey, array|string $finder = 'all', \Psr\SimpleCache\CacheInterface|string|null $cache = null, \Closure|string|null $cacheKey = null, mixed ...$args)
* @method \CakeCarts\Model\Entity\Cart findOrCreate($search, ?callable $callback = null, array $options = [])
* @method \CakeCarts\Model\Entity\Cart patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = [])
* @method array<\CakeCarts\Model\Entity\Cart> patchEntities(iterable $entities, array $data, array $options = [])
* @method \CakeCarts\Model\Entity\Cart|false save(\Cake\Datasource\EntityInterface $entity, array $options = [])
* @method \CakeCarts\Model\Entity\Cart saveOrFail(\Cake\Datasource\EntityInterface $entity, array $options = [])
* @method iterable<\CakeCarts\Model\Entity\Cart>|\Cake\Datasource\ResultSetInterface<\CakeCarts\Model\Entity\Cart>|false saveMany(iterable $entities, array $options = [])
* @method iterable<\CakeCarts\Model\Entity\Cart>|\Cake\Datasource\ResultSetInterface<\CakeCarts\Model\Entity\Cart> saveManyOrFail(iterable $entities, array $options = [])
* @method iterable<\CakeCarts\Model\Entity\Cart>|\Cake\Datasource\ResultSetInterface<\CakeCarts\Model\Entity\Cart>|false deleteMany(iterable $entities, array $options = [])
* @method iterable<\CakeCarts\Model\Entity\Cart>|\Cake\Datasource\ResultSetInterface<\CakeCarts\Model\Entity\Cart> deleteManyOrFail(iterable $entities, array $options = [])
*
* @mixin \Cake\ORM\Behavior\TimestampBehavior
*/
class CartsTable extends Table
{
/**
* Initialize method
*
* @param array<string, mixed> $config The configuration for the Table.
* @return void
*/
public function initialize(array $config): void
{
parent::initialize($config);
$this->setTable('carts');
$this->setDisplayField('id');
$this->setPrimaryKey('id');
$this->addBehavior('Timestamp');
$this->hasMany('CartItems', [
'foreignKey' => 'cart_id',
'className' => 'CakeCarts.CartItems',
]);
$this->getSchema()->setColumnType('cart_type_id', EnumType::from(CartTypeId::class));
}
/**
* Default validation rules.
*
* @param \Cake\Validation\Validator $validator Validator instance.
* @return \Cake\Validation\Validator
*/
public function validationDefault(Validator $validator): Validator
{
$validator
->integer('cart_type_id')
->requirePresence('cart_type_id', 'create')
->notEmptyString('cart_type_id');
$validator
->scalar('session_id')
->maxLength('session_id', 255)
->allowEmptyString('session_id');
$validator
->uuid('user_id_uuid')
->allowEmptyString('user_id_uuid');
$validator
->integer('user_id')
->allowEmptyString('user_id');
$validator
->dateTime('deleted')
->allowEmptyDateTime('deleted');
$validator
->dateTime('removed')
->allowEmptyDateTime('removed');
$validator
->integer('removed_reason_id')
->allowEmptyString('removed_reason_id');
$validator
->integer('num_items')
->notEmptyString('num_items');
return $validator;
}
}