<?php
namespace CioFormBuilder\Subscriber;
use CioFormBuilder\Definition\CioForm\CioFormEntity;
use CioFormBuilder\Definition\CioFormField\CioFormFieldEntity;
use CioFormBuilder\Model\CioFormBuilder;
use CioFormBuilder\Model\Field\AbstractField;
use CioFormBuilder\Model\Field\FileField;
use CioFormBuilder\Model\Field\SelectField;
use CioFormBuilder\Model\Field\TextField;
use CioPodProducts\CioPodProducts;
use Shopware\Core\Checkout\Cart\CartPersister;
use Shopware\Core\Checkout\Cart\Event\AfterLineItemAddedEvent;
use Shopware\Core\Checkout\Cart\Event\AfterLineItemQuantityChangedEvent;
use Shopware\Core\Checkout\Cart\Event\BeforeLineItemAddedEvent;
use Shopware\Core\Checkout\Cart\LineItem\LineItem;
use Shopware\Core\Checkout\Customer\CustomerEntity;
use Shopware\Core\Content\Media\File\FileSaver;
use Shopware\Core\Content\Media\MediaService;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\Uuid\Uuid;
use Shopware\Storefront\Event\StorefrontRenderEvent;
use Shopware\Storefront\Page\Product\ProductPage;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\RequestStack;
class ProductDetailSubscriber implements EventSubscriberInterface
{
protected RequestStack $requestStack;
protected MediaService $mediaService;
protected CartPersister $cartPersister;
protected EntityRepository $cioFormRepository;
public function __construct(RequestStack $requestStack, MediaService $mediaService, CartPersister $cartPersister, EntityRepository $cioFormRepository)
{
$this->requestStack = $requestStack;
$this->mediaService = $mediaService;
$this->cartPersister = $cartPersister;
$this->cioFormRepository = $cioFormRepository;
}
public static function getSubscribedEvents()
{
return [
StorefrontRenderEvent::class => 'addProductDetailFormData',
AfterLineItemAddedEvent::class => 'onAfterLineItemAddedEvent',
AfterLineItemQuantityChangedEvent::class => 'onAfterLineItemQuantityChangedEvent'
];
}
public function addProductDetailFormData(StorefrontRenderEvent $event)
{
$customer = $event->getSalesChannelContext()->getCustomer();
if ($customer instanceof CustomerEntity) {
if (is_array($event->getParameters()) && key_exists('page', $event->getParameters())) {
$page = $event->getParameters()['page'];
if ($page instanceof ProductPage) {
$request = $event->getRequest();
$lineItem = null;
if ($request->query->has('podLineItemId') && $event->getSalesChannelContext()->getToken()) {
$cart = $this->cartPersister->load($event->getSalesChannelContext()->getToken(), $event->getSalesChannelContext());
$lineItem = $cart->getLineItems()->get($request->query->get('podLineItemId'));
}
$form = null;
$extensionFormData = $page->getProduct()->getExtension('formData');
if ($extensionFormData instanceof CioFormEntity) {
$criteria = new Criteria();
$criteria->addFilter(new EqualsFilter('id', $extensionFormData->getId()));
$criteria->addAssociation('fields');
/** @var CioFormEntity $formEntity */
$formEntity = $this->cioFormRepository->search($criteria, $event->getContext())->first();
$form = new CioFormBuilder($formEntity);
$form->formName = 'productDetailPageBuyProductForm';
if ($lineItem instanceof LineItem && is_array($lineItem->getPayload())) {
$payload = $lineItem->getPayload();
if (array_key_exists('customFields', $payload) && is_array($payload['customFields'])) {
$cf = $payload['customFields'];
// Dynamische Zeilenanzahl beim Bearbeiten übernehmen
if (!empty($cf['cioDynamicRowMode'])) {
if (isset($cf['cioDynamicRowCount'])) {
$form->setOverrideRowCount((int) $cf['cioDynamicRowCount']);
} elseif (isset($cf['cioTableFormData']) && is_array($cf['cioTableFormData'])) {
$form->setOverrideRowCount((int) count($cf['cioTableFormData']));
}
} elseif (isset($cf['cioTableFormData']) && is_array($cf['cioTableFormData'])) {
// Legacy-Fall: Anzahl aus vorhandenen Tabellenzeilen ableiten
$form->setOverrideRowCount((int) count($cf['cioTableFormData']));
}
self::mapPayloadDataToFieldDefaults($form, $payload['customFields']);
}
}
}
$event->setParameter('cioFormOnProductDetailPage', $form);
}
}
}
}
public function onAfterLineItemAddedEvent(AfterLineItemAddedEvent $event)
{
$request = $this->requestStack->getCurrentRequest();
if (count($event->getLineItems()) === 1) {
if ($request->request->has('cioProductFormId')) {
$criteria = new Criteria();
$criteria->addFilter(new EqualsFilter('id', $request->request->get('cioProductFormId')));
$criteria->addAssociation('fields');
$formEntity = $this->cioFormRepository->search($criteria, $event->getContext())->first();
if ($formEntity instanceof CioFormEntity) {
$form = new CioFormBuilder($formEntity);
$formData = [];
$tableFormData = [];
if ($form->isValid($request)) {
/** @var AbstractField $field */
foreach ($form->getFormFields() as $field) {
if ($field instanceof FileField) {
if (
$request->files->has($field->getEntity()->getId()) &&
!is_null($request->files->get($field->getEntity()->getId()))
) {
$uploadedFile = $request->files->get($field->getEntity()->getId());
if ($uploadedFile instanceof UploadedFile) {
$blob = file_get_contents($uploadedFile->getPathname());
$extension = $uploadedFile->getClientOriginalExtension();
$contentType = $uploadedFile->getClientMimeType();
$filename = $uploadedFile->getClientOriginalName();
$mediaId = $this->mediaService->saveFile(
$blob,
$extension,
$contentType,
// remove file extension from filename
$this->cleanFilename($filename) . '_' . Uuid::randomHex(),
$event->getContext(),
CioPodProducts::MEDIA_UPLOAD_FOLDER_ENTITY,
null,
false
);
$formData[] = [
'id' => $field->getEntity()->getId(),
'technicalName' => $field->getEntity()->getTechnicalName(),
'label' => $field->getEntity()->getLabel(),
'type' => $field->getEntity()->getType(),
'value' => $mediaId
];
}
} else {
if ($request->request->has($field->getEntity()->getId() . '_media_id')) {
$formData[] = [
'id' => $field->getEntity()->getId(),
'technicalName' => $field->getEntity()->getTechnicalName(),
'label' => $field->getEntity()->getLabel(),
'type' => $field->getEntity()->getType(),
'value' => $request->request->get($field->getEntity()->getId() . '_media_id')
];
}
}
} else {
$formData[] = [
'id' => $field->getEntity()->getId(),
'technicalName' => $field->getEntity()->getTechnicalName(),
'label' => $field->getEntity()->getLabel(),
'type' => $field->getEntity()->getType(),
'value' => $field->getData($request)
];
}
}
// Build table form data
if ($form->hasDynamicRowMode()) {
$rowCount = $this->computeDynamicRowCount($form, $request);
$tableFormData = $this->buildTableFormDataDynamic($formEntity, $request, $rowCount);
} else {
if (is_array($form->getTableRepresentationFormFields()) && count($form->getTableRepresentationFormFields()) > 0 && is_array($form->getTableRepresentationFormFields()[0])) {
/** @var array $row */
foreach ($form->getTableRepresentationFormFields() as $row) {
$rowData = [];
/** @var AbstractField $field */
foreach ($row as $field) {
if ($field instanceof TextField || $field instanceof SelectField) {
$rowData[] = [
'id' => $field->getEntity()->getId(),
'technicalName' => $field->getEntity()->getTechnicalName(),
'label' => $field->getEntity()->getLabel(),
'type' => $field->getEntity()->getType(),
'value' => $field->getData($request)
];
}
}
$tableFormData[] = $rowData;
}
}
}
/** @var LineItem $newLineItem */
$newLineItem = $event->getLineItems()[0];
$payload = $newLineItem->getPayload();
if (!isset($payload['customFields']) || !is_array($payload['customFields'])) {
$payload['customFields'] = [];
}
$payload['customFields']['cioFormData'] = $formData;
$payload['customFields']['cioTableFormData'] = $tableFormData;
$payload['customFields']['cioTableFormPrefix'] = $form->getEntity()->getTableRowsPrefix();
// Mark dynamic mode and count if applicable
$cartLineItem = $event->getCart()->getLineItems()->get($newLineItem->getId());
if ($form->hasDynamicRowMode()) {
$rowCount = $rowCount ?? $this->computeDynamicRowCount($form, $request);
$payload['customFields']['cioDynamicRowMode'] = true;
$payload['customFields']['cioDynamicRowCount'] = $rowCount;
}
// Always write payload first so user input is not lost even if quantity change fails
$cartLineItem->setPayload($payload);
// Set quantity to rowCount when possible (only for stackable items) – this was the working behavior for empty carts
if ($form->hasDynamicRowMode()) {
try {
$isStackable = method_exists($cartLineItem, 'isStackable')
? $cartLineItem->isStackable()
: (method_exists($cartLineItem, 'getStackable') ? $cartLineItem->getStackable() : true);
if ($isStackable) {
$cartLineItem->setQuantity(max(1, (int) $rowCount));
}
} catch (\Throwable $e) {
// non-stackable or guarded – keep payload and continue without changing quantity
}
}
$this->cartPersister->save($event->getCart(), $event->getSalesChannelContext());
// TODO: check if save line item to database is needed?
}
}
}
}
}
protected function cleanFilename(string $filename): string
{
$filenameWithoutExtension = substr($filename, 0, strrpos($filename, '.'));
$cleanFilename = preg_replace('/[^A-Za-z0-9\-_]/', '-', $filenameWithoutExtension);
$cleanFilename = strtolower(trim($cleanFilename, '-'));
return $cleanFilename;
}
protected static function mapPayloadDataToFieldDefaults(CioFormBuilder $formBuilder, array $payloadCustomFields): void
{
$simpleData = [];
if (array_key_exists('cioFormData', $payloadCustomFields) && is_array($payloadCustomFields['cioFormData'])) {
foreach ($payloadCustomFields['cioFormData'] as $fieldData) {
if (array_key_exists('id', $fieldData)) {
$simpleData[$fieldData['id']] = $fieldData['value'];
}
}
}
$tableData = [];
if (array_key_exists('cioTableFormData', $payloadCustomFields) && is_array($payloadCustomFields['cioTableFormData'])) {
foreach ($payloadCustomFields['cioTableFormData'] as $row) {
if (is_array($row)) {
foreach ($row as $fieldData) {
if (array_key_exists('id', $fieldData)) {
$tableData[$fieldData['id']][] = $fieldData['value'];
}
}
}
}
}
/** @var AbstractField $fieldInstance */
foreach ($formBuilder->getFormFields() as $fieldInstance) {
if (array_key_exists($fieldInstance->getEntity()->getId(), $simpleData)) {
$fieldInstance->getEntity()->setDefaultValue($simpleData[$fieldInstance->getEntity()->getId()]);
}
}
foreach ($formBuilder->getTableRepresentationFormFields()[0] as $field) {
if (array_key_exists($field->getEntity()->getId(), $tableData)) {
$field->getEntity()->setDefaultValue(json_encode($tableData[$field->getEntity()->getId()]));
}
}
}
/**
* Compute the dynamic row count from request and clamp to [min,max].
*/
protected function computeDynamicRowCount(CioFormBuilder $form, \Symfony\Component\HttpFoundation\Request $request): int
{
$min = $form->getEntity()->getMinTableRowsNumber() ?? 0;
$max = $form->getEntity()->getMaxTableRowsNumber() ?? 0;
// Prefer hidden field if present
$count = 0;
if ($request->request->has('cioDynamicRowCount')) {
$val = (int) $request->request->get('cioDynamicRowCount');
if ($val > 0) {
$count = $val;
}
}
// Fallback: derive from posted table field names by finding max index+1
if ($count === 0) {
$maxIndex = -1;
// Build a list of field IDs that are shown as table fields (text/select)
$tableFieldIds = [];
foreach ($form->getEntity()->getFields() as $fieldEntity) {
if ($fieldEntity instanceof \CioFormBuilder\Definition\CioFormField\CioFormFieldEntity) {
if ($fieldEntity->getShowAsTable() === true) {
$type = $fieldEntity->getType();
if (in_array($type, [CioFormBuilder::FIELD_TYPE_TEXT, CioFormBuilder::FIELD_TYPE_SELECT], true)) {
$tableFieldIds[] = $fieldEntity->getId();
}
}
}
}
foreach ($request->request->keys() as $name) {
foreach ($tableFieldIds as $id) {
// match id_N at the end
if (preg_match('/^' . preg_quote($id, '/') . '_(\d+)$/', $name, $m)) {
$idx = (int) $m[1];
if ($idx > $maxIndex) {
$maxIndex = $idx;
}
}
}
}
if ($maxIndex >= 0) {
$count = $maxIndex + 1;
}
}
if ($min > 0 && $max >= $min) {
if ($count === 0) {
$count = $min;
}
if ($count < $min) {
$count = $min;
}
if ($count > $max) {
$count = $max;
}
}
return max(1, (int) $count);
}
/**
* Build table form data for dynamic mode up to given row count.
* Cuts off rows beyond the clamped max.
*
* @return array<array<array{id:string,technicalName:string,label:string,type:string,value:mixed}>>
*/
protected function buildTableFormDataDynamic(CioFormEntity $formEntity, \Symfony\Component\HttpFoundation\Request $request, int $rowCount): array
{
$result = [];
// Collect table field entities (only text/select supported for table)
$tableFields = [];
foreach ($formEntity->getFields() as $fieldEntity) {
if ($fieldEntity instanceof \CioFormBuilder\Definition\CioFormField\CioFormFieldEntity) {
if ($fieldEntity->getShowAsTable() === true) {
$type = $fieldEntity->getType();
if (in_array($type, [CioFormBuilder::FIELD_TYPE_TEXT, CioFormBuilder::FIELD_TYPE_SELECT], true)) {
$tableFields[] = $fieldEntity;
}
}
}
}
for ($i = 0; $i < $rowCount; $i++) {
$rowData = [];
foreach ($tableFields as $fieldEntity) {
$name = $fieldEntity->getId() . '_' . $i;
$value = '';
if ($request->request->has($name)) {
$val = $request->request->get($name);
if (is_string($val)) {
$value = $val;
}
}
$rowData[] = [
'id' => $fieldEntity->getId(),
'technicalName' => $fieldEntity->getTechnicalName(),
'label' => $fieldEntity->getLabel(),
'type' => $fieldEntity->getType(),
'value' => $value
];
}
$result[] = $rowData;
}
return $result;
}
/**
* Enforce readonly quantity for dynamic-mode line items by resetting quantity to stored row count.
*/
public function onAfterLineItemQuantityChangedEvent(AfterLineItemQuantityChangedEvent $event): void
{
// Shopware 6.4: Event-API variiert, daher ohne getLineItem()/getLineItemId arbeiten.
// Wir laufen über alle Positionen und erzwingen nur für Dynamic-Mode-Positionen die Menge.
$cart = $event->getCart();
foreach ($cart->getLineItems() as $lineItem) {
if (!$lineItem instanceof LineItem) {
continue;
}
$payload = $lineItem->getPayload();
if (!is_array($payload) || !isset($payload['customFields']) || !is_array($payload['customFields'])) {
continue;
}
$cf = $payload['customFields'];
if (empty($cf['cioDynamicRowMode']) || !isset($cf['cioDynamicRowCount'])) {
continue;
}
$rowCount = (int) $cf['cioDynamicRowCount'];
try {
$isStackable = method_exists($lineItem, 'isStackable')
? $lineItem->isStackable()
: (method_exists($lineItem, 'getStackable') ? $lineItem->getStackable() : true);
if ($isStackable) {
$lineItem->setQuantity(max(1, $rowCount));
}
} catch (\Throwable $e) {
// ignore for non-stackable or guarded carts
}
}
$this->cartPersister->save($cart, $event->getSalesChannelContext());
}
}