@@ -503,6 +503,120 @@ enumValues: ['standard', 'gift', 'bulk'],
503503 return false ;
504504 }
505505
506+ /**
507+ * CART extra field — the cart is a COMMON-only entity: it has no cart_lang /
508+ * cart_shop base table (its id_lang / id_shop are plain columns), so LANG and
509+ * SHOP scopes are rejected at registration. No form/grid/API placements exist
510+ * for the cart either; the value is written by a hook and displayed on checkout.
511+ */
512+
513+ // Cart (common) : delivery_note
514+ $ cartDeliveryNoteRegistered = $ this ->registerExtraProperty (
515+ new ExtraPropertyDefinition (
516+ entityName: 'cart ' ,
517+ propertyName: 'delivery_note ' ,
518+ type: ExtraPropertyType::STRING ,
519+ scope: ExtraPropertyScope::COMMON ,
520+ nullable: true ,
521+ displayFront: true ,
522+ formType: TextareaType::class,
523+ labelWording: 'Delivery note ' ,
524+ labelDomain: self ::TRANSLATION_DOMAIN ,
525+ descriptionWording: 'Delivery instructions attached to the cart during checkout ' ,
526+ descriptionDomain: self ::TRANSLATION_DOMAIN ,
527+ )
528+ );
529+ if (!$ cartDeliveryNoteRegistered ) {
530+ $ this ->_errors [] = $ this ->trans ('Failed to register Cart extra field "delivery_note" (scope: common). ' , [], 'Modules.Demoextrafield.Admin ' );
531+
532+ return false ;
533+ }
534+
535+ /**
536+ * ORDER extra field — registered with the natural entity name 'order': the core
537+ * resolves the physical table ('orders') and the primary key ('id_order') from
538+ * the Order ObjectModel. COMMON is the only supported scope (no orders_lang /
539+ * orders_shop tables). Deliberately NO order-grid placement: the order grid uses
540+ * id-first pagination, which the generic grid integration cannot join yet (core
541+ * issue #42536) — the column would render empty.
542+ */
543+
544+ // Order (common) : delivery_note — filled from the cart's note at order validation.
545+ $ orderDeliveryNoteRegistered = $ this ->registerExtraProperty (
546+ new ExtraPropertyDefinition (
547+ entityName: 'order ' ,
548+ propertyName: 'delivery_note ' ,
549+ type: ExtraPropertyType::STRING ,
550+ scope: ExtraPropertyScope::COMMON ,
551+ nullable: true ,
552+ displayFront: true ,
553+ formType: TextareaType::class,
554+ labelWording: 'Delivery note (copied from the cart) ' ,
555+ labelDomain: self ::TRANSLATION_DOMAIN ,
556+ descriptionWording: 'Delivery instructions copied from the cart when the order is validated ' ,
557+ descriptionDomain: self ::TRANSLATION_DOMAIN ,
558+ )
559+ );
560+ if (!$ orderDeliveryNoteRegistered ) {
561+ $ this ->_errors [] = $ this ->trans ('Failed to register Order extra field "delivery_note" (scope: common). ' , [], 'Modules.Demoextrafield.Admin ' );
562+
563+ return false ;
564+ }
565+
566+ /**
567+ * COMBINATION extra fields — registered with the natural entity name
568+ * 'combination' (the 'Combination'/'product_attribute'/'ProductAttribute'
569+ * spellings work identically): the core resolves the physical table
570+ * ('product_attribute') and the primary key ('id_product_attribute'). All three
571+ * scopes are available (product_attribute_lang / product_attribute_shop exist).
572+ */
573+
574+ // Combination (common) : ean_verified — exposed on the combinations API list.
575+ $ combinationEanVerifiedRegistered = $ this ->registerExtraProperty (
576+ new ExtraPropertyDefinition (
577+ entityName: 'combination ' ,
578+ propertyName: 'ean_verified ' ,
579+ type: ExtraPropertyType::BOOL ,
580+ scope: ExtraPropertyScope::COMMON ,
581+ defaultValue: false ,
582+ nullable: false ,
583+ displayFront: true ,
584+ associatedApis: ['/products/{productId}/combinations ' ],
585+ formType: SwitchType::class,
586+ labelWording: 'EAN verified ' ,
587+ labelDomain: self ::TRANSLATION_DOMAIN ,
588+ descriptionWording: 'Whether the combination EAN has been verified by the merchant ' ,
589+ descriptionDomain: self ::TRANSLATION_DOMAIN ,
590+ )
591+ );
592+ if (!$ combinationEanVerifiedRegistered ) {
593+ $ this ->_errors [] = $ this ->trans ('Failed to register Combination extra field "ean_verified" (scope: common). ' , [], 'Modules.Demoextrafield.Admin ' );
594+
595+ return false ;
596+ }
597+
598+ // Combination (shop) : restock_note — one value per store.
599+ $ combinationRestockNoteRegistered = $ this ->registerExtraProperty (
600+ new ExtraPropertyDefinition (
601+ entityName: 'combination ' ,
602+ propertyName: 'restock_note ' ,
603+ type: ExtraPropertyType::STRING ,
604+ scope: ExtraPropertyScope::SHOP ,
605+ nullable: true ,
606+ displayFront: false ,
607+ formType: TextareaType::class,
608+ labelWording: 'Restock note (per store) ' ,
609+ labelDomain: self ::TRANSLATION_DOMAIN ,
610+ descriptionWording: 'Internal restocking note, stored per store ' ,
611+ descriptionDomain: self ::TRANSLATION_DOMAIN ,
612+ )
613+ );
614+ if (!$ combinationRestockNoteRegistered ) {
615+ $ this ->_errors [] = $ this ->trans ('Failed to register Combination extra field "restock_note" (scope: shop). ' , [], 'Modules.Demoextrafield.Admin ' );
616+
617+ return false ;
618+ }
619+
506620 $ hooksRegistered = $ this ->registerHook ('displayProductAdditionalInfo ' )
507621 && $ this ->registerHook ('displayCartExtraProductInfo ' )
508622 && $ this ->registerHook ('displayHeaderCategory ' )
@@ -512,7 +626,11 @@ enumValues: ['standard', 'gift', 'bulk'],
512626 && $ this ->registerHook ('actionCmsPageFormDataProviderData ' )
513627 && $ this ->registerHook ('actionAfterCreateCmsPageFormHandler ' )
514628 && $ this ->registerHook ('actionAfterUpdateCmsPageFormHandler ' )
515- && $ this ->registerHook ('displayCMSDisputeInformation ' );
629+ && $ this ->registerHook ('displayCMSDisputeInformation ' )
630+ && $ this ->registerHook ('displayCheckoutSummaryTop ' )
631+ && $ this ->registerHook ('actionCartSave ' )
632+ && $ this ->registerHook ('actionValidateOrder ' )
633+ && $ this ->registerHook ('displayOrderDetail ' );
516634 if (!$ hooksRegistered ) {
517635 $ this ->_errors [] = $ this ->trans ('Failed to register one or more hooks. ' , [], 'Modules.Demoextrafield.Admin ' );
518636
@@ -554,6 +672,11 @@ public function uninstall(): bool
554672 && $ this ->unregisterExtraProperty (new ExtraPropertyDefinition ('cms ' , 'promo_banner ' ), $ dropColumn )
555673 && $ this ->unregisterExtraProperty (new ExtraPropertyDefinition ('cms ' , 'revision_code ' ), $ dropColumn )
556674
675+ && $ this ->unregisterExtraProperty (new ExtraPropertyDefinition ('cart ' , 'delivery_note ' ), $ dropColumn )
676+ && $ this ->unregisterExtraProperty (new ExtraPropertyDefinition ('order ' , 'delivery_note ' ), $ dropColumn )
677+ && $ this ->unregisterExtraProperty (new ExtraPropertyDefinition ('combination ' , 'ean_verified ' ), $ dropColumn )
678+ && $ this ->unregisterExtraProperty (new ExtraPropertyDefinition ('combination ' , 'restock_note ' ), $ dropColumn )
679+
557680 && $ this ->unregisterHook ('displayProductAdditionalInfo ' )
558681 && $ this ->unregisterHook ('displayCartExtraProductInfo ' )
559682 && $ this ->unregisterHook ('displayHeaderCategory ' )
@@ -564,6 +687,10 @@ public function uninstall(): bool
564687 && $ this ->unregisterHook ('actionAfterCreateCmsPageFormHandler ' )
565688 && $ this ->unregisterHook ('actionAfterUpdateCmsPageFormHandler ' )
566689 && $ this ->unregisterHook ('displayCMSDisputeInformation ' )
690+ && $ this ->unregisterHook ('displayCheckoutSummaryTop ' )
691+ && $ this ->unregisterHook ('actionCartSave ' )
692+ && $ this ->unregisterHook ('actionValidateOrder ' )
693+ && $ this ->unregisterHook ('displayOrderDetail ' )
567694
568695 && parent ::uninstall ();
569696 }
@@ -674,6 +801,99 @@ public function hookDisplayHeaderCategory(): string
674801 return $ this ->display (__FILE__ , 'views/templates/hook/category_header.tpl ' );
675802 }
676803
804+ /**
805+ * Action hook — fires on every cart save.
806+ *
807+ * Demo of the CART extra field write path: seeds the delivery_note the first time the
808+ * cart is persisted (a real module would set it from a checkout form field). Writing
809+ * through $cart->update() re-triggers actionCartSave, hence the re-entrancy guard.
810+ */
811+ public function hookActionCartSave (): void
812+ {
813+ static $ seeding = false ;
814+ if ($ seeding ) {
815+ return ;
816+ }
817+
818+ $ cart = $ this ->context ->cart ;
819+ if (!Validate::isLoadedObject ($ cart )) {
820+ return ;
821+ }
822+
823+ $ existingNote = $ cart ->extra_properties ['demoextrafield ' ]['delivery_note ' ];
824+ if (is_string ($ existingNote ) && '' !== $ existingNote ) {
825+ return ;
826+ }
827+
828+ $ seeding = true ;
829+ try {
830+ $ cart ->extra_properties ['demoextrafield ' ]['delivery_note ' ] = sprintf (
831+ 'Leave the parcel at the pickup point (demo note seeded on %s). ' ,
832+ date ('Y-m-d H:i ' )
833+ );
834+ $ cart ->update ();
835+ } finally {
836+ $ seeding = false ;
837+ }
838+ }
839+
840+ /**
841+ * Front Office hook (checkout / cart summary).
842+ *
843+ * Displays the cart delivery_note read through the presented cart: `$cart` is a Smarty
844+ * global on every FO page (a CartLazyArray), and the extra properties are exposed under
845+ * its `extra_properties` key — snake_case, like every Smarty surface (the camelCase
846+ * `extraProperties` spelling belongs to the Admin API JSON only).
847+ */
848+ public function hookDisplayCheckoutSummaryTop (array $ params ): string
849+ {
850+ return $ this ->display (__FILE__ , 'views/templates/hook/checkout_summary_top.tpl ' );
851+ }
852+
853+ /**
854+ * Action hook — fires when an order is validated.
855+ *
856+ * The cart dies at the end of checkout (its cookie is dropped once the order exists),
857+ * so a cart-level value that must stay visible after the purchase has to be copied
858+ * onto the order. This is the intended pattern: the module owns the copy.
859+ *
860+ * Also a live demo of the entity-name resolution: the definition was registered as
861+ * 'order' while the ObjectModel writes through its physical table 'orders' /
862+ * primary key 'id_order'.
863+ */
864+ public function hookActionValidateOrder (array $ params ): void
865+ {
866+ $ cart = $ params ['cart ' ] ?? null ;
867+ $ order = $ params ['order ' ] ?? null ;
868+ if (!$ cart instanceof Cart || !$ order instanceof Order || (int ) $ order ->id <= 0 ) {
869+ return ;
870+ }
871+
872+ $ deliveryNote = $ cart ->extra_properties ['demoextrafield ' ]['delivery_note ' ];
873+ if (!is_string ($ deliveryNote ) || '' === $ deliveryNote ) {
874+ return ;
875+ }
876+
877+ $ order ->extra_properties ['demoextrafield ' ]['delivery_note ' ] = $ deliveryNote ;
878+ $ order ->update ();
879+ }
880+
881+ /**
882+ * Front Office hook (order detail page, customer account).
883+ * Displays the order's extra fields (e.g. the delivery_note copied from the cart).
884+ */
885+ public function hookDisplayOrderDetail (array $ params ): string
886+ {
887+ $ order = $ params ['order ' ] ?? null ;
888+ if (!$ order instanceof Order || (int ) $ order ->id <= 0 ) {
889+ return '' ;
890+ }
891+
892+ $ this ->context ->smarty ->assign ('orderObjectModel ' , $ order );
893+
894+ return $ this ->display (__FILE__ , 'views/templates/hook/order_detail.tpl ' );
895+ }
896+
677897 /**
678898 * Front Office hook (customer my-account page).
679899 *
@@ -697,6 +917,18 @@ public function hookDisplayCustomerAccountTop(): string
697917 return '' ;
698918 }
699919
920+ // JSON showcase: write a real PHP structure — the writer json_encodes it for
921+ // storage, the constraint (Assert\Json) validates the encoded string, and reads
922+ // give the decoded structure back (iterable in the template below).
923+ $ extraJson = $ customer ->extra_properties ['demoextrafield ' ]['extra_json ' ];
924+ if (empty ($ extraJson )) {
925+ $ customer ->extra_properties ['demoextrafield ' ]['extra_json ' ] = [
926+ 'loyalty ' => ['points ' => 0 , 'tier ' => 'bronze ' ],
927+ 'preferences ' => ['newsletter ' => true ],
928+ ];
929+ $ customer ->update ();
930+ }
931+
700932 $ this ->context ->smarty ->assign ('customerObjectModel ' , $ customer );
701933
702934 return $ this ->display (__FILE__ , 'views/templates/hook/customer_account_top.tpl ' );
0 commit comments