From 9bbeea742424837fb58207d88e10dbca0b2cae04 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Sun, 03 May 2026 22:04:17 +0000
Subject: [PATCH] =SEO Field registration and formatting

---
 inc/managers/SEO/render/Traits/_Properties/sloganTrait.php                    |   13 
 inc/managers/SEO/render/Traits/_Properties/openingHoursSpecificationTrait.php |   68 +++
 inc/managers/SEO/render/Traits/_Properties/keywordsTrait.php                  |   27 +
 inc/managers/SEO/render/Traits/_Properties/offersTrait.php                    |  171 ++++++++-
 inc/managers/SEO/render/Traits/_Properties/alternateNameTrait.php             |   18 +
 inc/managers/SEO/render/Traits/_Properties/additionalPropertyTrait.php        |    6 
 inc/managers/SEO/render/Traits/_Properties/dissolutionDateTrait.php           |   35 +
 inc/managers/SEO/render/Traits/_Properties/awardTrait.php                     |   57 ++
 inc/managers/SEO/render/Traits/_Properties/hasOfferCatalogTrait.php           |  103 +++++
 inc/managers/SEO/render/Traits/_Properties/photoTrait.php                     |   24 +
 inc/registrar/Fields.php                                                      |  190 +++------
 inc/managers/SEO/render/Traits/_Properties/amenityFeatureTrait.php            |   63 +++
 inc/managers/SEO/render/Traits/_Properties/paymentAcceptedTrait.php           |   33 +
 inc/managers/SEO/render/Traits/_Properties/aggregateRatingTrait.php           |  114 ++++-
 inc/managers/SEO/render/Traits/_Properties/reviewTrait.php                    |  156 ++++++++
 15 files changed, 876 insertions(+), 202 deletions(-)

diff --git a/inc/managers/SEO/render/Traits/_Properties/additionalPropertyTrait.php b/inc/managers/SEO/render/Traits/_Properties/additionalPropertyTrait.php
index ab417a8..191efbe 100644
--- a/inc/managers/SEO/render/Traits/_Properties/additionalPropertyTrait.php
+++ b/inc/managers/SEO/render/Traits/_Properties/additionalPropertyTrait.php
@@ -38,6 +38,12 @@
 				return property_exists($property, 'value') && !empty($property->value);
 			});
 		}
+		if (!empty($this->additionalProperty)) {
+			if (!is_array($this->additionalProperty)) {
+				$this->additionalProperty = [$this->additionalProperty];
+			}
+			$additionalProperty = array_merge($this->additionalProperty, $additionalProperty);
+		}
 		$this->additionalProperty = $additionalProperty;
 	}
 
diff --git a/inc/managers/SEO/render/Traits/_Properties/aggregateRatingTrait.php b/inc/managers/SEO/render/Traits/_Properties/aggregateRatingTrait.php
index 314b685..2b67d27 100644
--- a/inc/managers/SEO/render/Traits/_Properties/aggregateRatingTrait.php
+++ b/inc/managers/SEO/render/Traits/_Properties/aggregateRatingTrait.php
@@ -3,6 +3,8 @@
 
 use JVBase\base\SchemaHelper;
 use JVBase\managers\SEO\render\Thing\Intangible\Rating\AggregateRating;
+use JVBase\meta\Meta;
+use JVBase\registrar\Fields;
 
 if (!defined('ABSPATH')) {
 	exit;
@@ -29,32 +31,94 @@
 		$this->aggregateRating = $aggregateRating;
 	}
 
-	public function getAggregateRatingFieldConfig():array
+	public function setAggregateRatingField(Fields $fields):void
 	{
-		return [
-			'type'	=> 'group',
-			'label'	=> 'Aggregate/Average Rating',
-			'wrap'	=> 'details',
-			'fields'	=> [
-				'ratingCount'	=> [
-					'type'	=> 'number',
-					'label'	=> 'The total number of ratings (without text)',
-				],
-				'reviewCount'	=> [
-					'type'	=> 'number',
-					'label'	=> 'The total number of reviews (with text)'
-				],
-				'bestRating'	=> [
-					'type'	=> 'number',
-					'label'	=> 'The best rating',
-					'default'=> 5,
-				],
-				'worstRating'	=> [
-					'type'	=> 'number',
-					'label'	=> 'The worst rating',
-					'default'=> 1,
-				]
+		$fields->addField('average_rating', [
+			'type'	=> 'select',
+			'label'	=> __('Average Rating', 'jvb'),
+			'options'	=> [
+				'none'	=> 'None',
+				'0.5'	=> '0.5',
+				'1' 	=> '1',
+				'1.5' 	=> '1.5',
+				'2' 	=> '2',
+				'2.5' 	=> '2.5',
+				'3' 	=> '3',
+				'3.5' 	=> '3.5',
+				'4' 	=> '4',
+				'4.5' 	=> '4.5',
+				'5' 	=> '5',
+			],
+			'default'	=> 'none',
+		]);
+		$fields->addField('ratingCount', [
+			'type'	=> 'number',
+			'label'	=> __('The total number of ratings', 'jvb'),
+			'condition' => [
+				'field'		=> 'average_rating',
+				'operator' 	=> '!=',
+				'value'		=> 'none',
 			]
-		];
+		]);
+
+		$fields->addField('reviewCount', [
+			'type'	=> 'number',
+			'label'	=> __('The total number of reviews (with text)', 'jvb'),
+			'condition' => [
+				'field'		=> 'average_rating',
+				'operator' 	=> '!=',
+				'value'		=> 'none',
+			]
+		]);
+
+		$fields->addField('bestRating', [
+			'type'	=> 'number',
+			'label'	=> __('The best possible rating value (top of scale)', 'jvb'),
+			'default' => 5,
+			'condition' => [
+				'field'		=> 'average_rating',
+				'operator' 	=> '!=',
+				'value'		=> 'none',
+			]
+		]);
+		$fields->addField('worstRating', [
+			'type'	=> 'number',
+			'label'	=> __('The worst possible rating value (bottom of scale)', 'jvb'),
+			'default' => 1,
+			'condition' => [
+				'field'		=> 'average_rating',
+				'operator' 	=> '!=',
+				'value'		=> 'none',
+			]
+		]);
+	}
+
+	public function formatAggregateRatingField(Meta $meta):void
+	{
+		[$average, $ratingCount, $reviewCount, $bestRating, $worstRating] = $meta->getAll([
+			'average_rating',
+			'ratingCount',
+			'reviewCount',
+			'bestRating',
+			'worstRating'
+		]);
+
+		if (!empty($average)) {
+			$rating = new AggregateRating();
+			$rating->setRatingValue((float)$average);
+			if (!empty($ratingCount)) {
+				$rating->setRatingCount($ratingCount);
+			}
+			if (!empty($reviewCount)){
+				$rating->setReviewCount($reviewCount);
+			}
+			if ($bestRating !== 5) {
+				$rating->setBestRating($bestRating);
+			}
+			if ($worstRating !== 1) {
+				$rating->setWorstRating($worstRating);
+			}
+			$this->setAggregateRating($rating);
+		}
 	}
 }
diff --git a/inc/managers/SEO/render/Traits/_Properties/alternateNameTrait.php b/inc/managers/SEO/render/Traits/_Properties/alternateNameTrait.php
index 8eb38e5..f637660 100644
--- a/inc/managers/SEO/render/Traits/_Properties/alternateNameTrait.php
+++ b/inc/managers/SEO/render/Traits/_Properties/alternateNameTrait.php
@@ -2,6 +2,7 @@
 namespace JVBase\managers\SEO\render\Traits\_Properties;
 
 use JVBase\managers\SEO\render\Traits\_Helpers\arrayHelper;
+use JVBase\registrar\Fields;
 
 if (!defined('ABSPATH')) {
 	exit;
@@ -27,4 +28,21 @@
 		}
 		$this->alternateName = $alternateName;
 	}
+
+	public function setAlternateNameField(Fields $fields):void
+	{
+		$fields->addField(
+			'alternate_name',
+			[
+				'type'    => 'repeater',
+				'label'   => 'Alternate Name',
+				'fields'  => [
+					'name' => [
+						'type'  => 'text',
+						'label' => 'Name',
+					]
+				]
+			]
+		);
+	}
 }
diff --git a/inc/managers/SEO/render/Traits/_Properties/amenityFeatureTrait.php b/inc/managers/SEO/render/Traits/_Properties/amenityFeatureTrait.php
index 3a72e23..d188897 100644
--- a/inc/managers/SEO/render/Traits/_Properties/amenityFeatureTrait.php
+++ b/inc/managers/SEO/render/Traits/_Properties/amenityFeatureTrait.php
@@ -2,7 +2,10 @@
 namespace JVBase\managers\SEO\render\Traits\_Properties;
 
 use JVBase\managers\SEO\render\Thing\Intangible\StructuredValue\LocationFeatureSpecification;
+use JVBase\managers\SEO\render\Thing\Intangible\StructuredValue\PropertyValue;
 use JVBase\managers\SEO\render\Traits\_Helpers\arrayHelper;
+use JVBase\meta\Meta;
+use JVBase\registrar\Fields;
 
 if (!defined('ABSPATH')) {
 	exit;
@@ -26,15 +29,61 @@
 		$this->amenityFeature = $amenityFeature;
 	}
 
-	public function getAmenityFeatureFieldConfig():array
+	public function setAmenityFeatureField(Fields $fields):void
 	{
-		return [
-			'type'	=> 'repeater',
-			'label'	=> 'Amenity Feature',
-			'hint'	=> 'An amenity feature (e.g. a characteristic or service) of the Accommodation. This generic property does not make a statement about whether the feature is included in an offer for the main accommodation or available at extra costs.',
-			'fields'	=> [
-
+		$fields->addField('amenityFeature', [
+			'type'	=> 'set',
+			'label'	=> __('Amenities', 'jvb'),
+			'options' => [
+				'Wheelchair Accessible' => 'Wheelchair Accessible',
+				'Free Parking' => 'Free Parking',
+				'Private Rooms' => 'Private Rooms',
+				'Air Conditioning' => 'Air Conditioning',
+				'WiFi' => 'WiFi',
+				'Gender Neutral Restroom' => 'Gender Neutral Restroom',
+				'LGBTQ+ Friendly' => 'LGBTQ+ Friendly',
+				'Sterilization Room' => 'Sterilization Room',
+				'Refreshments Available' => 'Refreshments Available',
+				'Street Level Access' => 'Street Level Access',
+				'Single Use Needles' => 'Single Use Needles',
+				'Consultation Room' => 'Consultation Room',
+				'Aftercare Products Available' => 'Aftercare Products Available',
+				'Walk-Ins Welcome' => 'Walk-Ins Welcome',
 			]
+		]);
+	}
+
+	public function formatAmenityFeatureField(Meta $meta):void
+	{
+		$amenities = $meta->get('amenityFeature');
+
+		$properties = [
+			'Walk-Ins Welcome',
+			'LGBTQ+ Friendly',
 		];
+
+		if (!empty($amenities)) {
+			$out = [];
+			$prop = [];
+			foreach ($amenities as $amenity) {
+				if (in_array($amenity, $properties)) {
+					$pr = new PropertyValue();
+					$pr->setName($amenity);
+					$pr->setValue(true);
+					$prop[] = $pr;
+				} else {
+					$am = new LocationFeatureSpecification();
+					$am->setName($amenity);
+					$am->setValue(true);
+					$out[] = $am;
+				}
+			}
+			if (!empty($out)) {
+				$this->setAmenityFeature($out);
+			}
+			if (!empty($prop)) {
+				$this->setAdditionalProperty($prop);
+			}
+		}
 	}
 }
diff --git a/inc/managers/SEO/render/Traits/_Properties/awardTrait.php b/inc/managers/SEO/render/Traits/_Properties/awardTrait.php
index b0a678a..94672c2 100644
--- a/inc/managers/SEO/render/Traits/_Properties/awardTrait.php
+++ b/inc/managers/SEO/render/Traits/_Properties/awardTrait.php
@@ -2,6 +2,8 @@
 namespace JVBase\managers\SEO\render\Traits\_Properties;
 
 use JVBase\managers\SEO\render\Traits\_Helpers\arrayHelper;
+use JVBase\meta\Meta;
+use JVBase\registrar\Fields;
 
 if (!defined('ABSPATH')) {
 	exit;
@@ -24,19 +26,56 @@
 		}
 		$this->award = $award;
 	}
-	public function getAwardFieldConfig():array
+
+	public function setAwardField(Fields $fields):void
 	{
-		return [
-			'type' 		=> 'repeater',
-			'label'		=> 'Award(s)',
-			'hint'		=> 'List any awards won by this Thing.',
+		$fields->addField('awards', [
+			'type'	=> 'tagList',
+			'label'	=> __('Award(s)', 'jvb'),
+			'hint'	=> 'List any awards won by this Thing.',
 			'fields'	=> [
-				'award'	=> [
+				'name'	=> [
 					'type'	=> 'text',
-					'label'	=> 'Award',
-					'hint'	=> 'Include the award name, year, and presenter (if any)'
+					'label'	=> __('Award Name', 'jvb'),
+					'required'	=> true,
+				],
+				'presenter'	=> [
+					'type'	=> 'text',
+					'label'	=> __('Presenter', 'jvb'),
+				],
+				'year'	=> [
+					'type'	=> 'number',
+					'label'	=> __('Year', 'jvb')
 				]
 			]
-		];
+		]);
+	}
+
+	public function formatAwardField(Meta $meta):void
+	{
+		$awards = $meta->get('awards');
+		$out = [];
+		if (!empty($awards)) {
+			foreach ($awards as $a) {
+				$award = false;
+				if (!empty($a['name'])) {
+					$award = '"'.$a['name'].'"';
+				}else {
+					continue;
+				}
+				if (!empty($a['presenter'])) {
+					$award .= ', presented by '.$a['presenter'];
+				}
+				if (!empty($a['year'])) {
+					$award .= ' - '.$a['year'];
+				}
+				if ($award) {
+					$out[] = $award;
+				}
+			}
+		}
+		if (!empty ($out)) {
+			$this->setAward($out);
+		}
 	}
 }
diff --git a/inc/managers/SEO/render/Traits/_Properties/dissolutionDateTrait.php b/inc/managers/SEO/render/Traits/_Properties/dissolutionDateTrait.php
index 9b51d29..c327327 100644
--- a/inc/managers/SEO/render/Traits/_Properties/dissolutionDateTrait.php
+++ b/inc/managers/SEO/render/Traits/_Properties/dissolutionDateTrait.php
@@ -2,6 +2,8 @@
 namespace JVBase\managers\SEO\render\Traits\_Properties;
 
 use JVBase\managers\SEO\render\DataType\Date;
+use JVBase\meta\Meta;
+use JVBase\registrar\Fields;
 
 if (!defined('ABSPATH')) {
 	exit;
@@ -31,4 +33,37 @@
 			'hint'	=> 'IMPORTANT: Do not fill this out, unless your business has actually closed.'
 		];
 	}
+	public function setDissolutionDateField(Fields $fields):void
+	{
+		$fields->addField(
+			'permanently_close',
+			[
+				'type'	=> 'true_false',
+				'label'	=> __('Permanently Close', 'jvb'),
+				'hint'	=> '*IMPORTANT* This signals to search engines that this business is no longer in business. Use only if your shop is closing!',
+			]
+		);
+		$fields->addField(
+			'dissolution_date',
+			[
+				'type'	=> 'date',
+				'label'	=> __('Dissolution Date', 'jvb'),
+				'condition' => [
+					'field'		=> 'permanently_close',
+					'operator'	=> '==',
+					'value'		=> true,
+				]
+			]
+		);
+	}
+
+	public function formatDissolutionDateField(Meta $meta):void
+	{
+		[$closed, $dissolution] = $meta->getAll(['permanently_close', 'dissolution_date']);
+		if (!empty($closed) && $closed === true) {
+			if (!empty($dissolution)) {
+				$this->setDissolutionDate($dissolution);
+			}
+		}
+	}
 }
diff --git a/inc/managers/SEO/render/Traits/_Properties/hasOfferCatalogTrait.php b/inc/managers/SEO/render/Traits/_Properties/hasOfferCatalogTrait.php
index 0230b99..e7d32a7 100644
--- a/inc/managers/SEO/render/Traits/_Properties/hasOfferCatalogTrait.php
+++ b/inc/managers/SEO/render/Traits/_Properties/hasOfferCatalogTrait.php
@@ -3,6 +3,12 @@
 
 use JVBase\base\SchemaHelper;
 use JVBase\inc\managers\SEO\render\Thing\Intangible\ItemList\OfferCatalog;
+use JVBase\managers\SEO\render\Thing\Intangible\Offer;
+use JVBase\managers\SEO\render\Thing\Intangible\Service;
+use JVBase\managers\SEO\render\Thing\Intangible\StructuredValue\PriceSpecification;
+use JVBase\managers\SEO\render\Thing\Product\Product;
+use JVBase\meta\Meta;
+use JVBase\registrar\Fields;
 use JVBase\registrar\Registrar;
 
 if (!defined('ABSPATH')) {
@@ -97,4 +103,101 @@
 			]
 		];
 	}
+
+	public function setHasOfferCatalogField(Fields $fields):void
+	{
+		$fields->addField(
+			'hasOfferCatalog',
+			[
+				'type'	=> 'repeater',
+				'label'	=> 'Main Products & Services',
+				'fields'	=> [
+					'type'	=> [
+						'type'	=> 'radio',
+						'label'	=> __('Select Type', 'jvb'),
+						'options'	=> [
+							'product'	=> 'Product',
+							'service'	=> 'Service'
+						],
+						'required' => true,
+					],
+					'name'	=> [
+						'type'	=> 'text',
+						'label'	=> __('Name of Product or Service', 'jvb'),
+						'required'	=> true,
+					],
+					'description'	=> [
+						'type'	=> 'textarea',
+						'label'	=> __('Description (optional)', 'jvb'),
+					],
+					'price'	=> [
+						'type'	=> 'text',
+						'subtype'	=> 'number',
+						'label'		=> __('Price', 'jvb'),
+					],
+					'unitText' 	=> [
+						'type'	=> 'radio',
+						'label'	=> 'Price per unit',
+						'options'	=> [
+							'hour'	=> 'Hour',
+							'unit'	=> 'Unit',
+						],
+						'default'	=> 'unit',
+					]
+				],
+			]
+		);
+	}
+
+	public function formatHasOfferCatalogField(Meta $meta):void
+	{
+		$catalog = $meta->get('hasOfferCatalog');
+		if (!empty($catalog)) {
+			$offerCatalog = [];
+			$name = '';
+			$services = array_filter($catalog, function ($item) {
+				return $item['type'] === 'service';
+			});
+			$products =array_filter($catalog, function ($item) {
+				return $item['type'] === 'product';
+			});
+			if (count($products) > 0) {
+				$name = 'Products';
+				if (count($services) > 0) {
+					$name .= ' & ';
+				}
+			}
+			if (count($services) > 0) {
+				$name .= 'Services';
+			}
+			foreach ($catalog as $row) {
+				$offer = new Offer();
+
+				$item = match($row['type']) {
+					'product'	=> new Product(),
+					'service' 	=> new Service(),
+				};
+
+				$item->setName($row['name']);
+				if (!empty($row['description'])) {
+					$item->setDescription($row['description']);
+				}
+				if (!empty($row['price'])) {
+					$price = new PriceSpecification();
+					$price->setPrice($row['price']);
+					$price->setPriceCurrency('CAD');
+					$price->setUnitText($row['unitText']);
+					$offer->setPriceSpecification($price);
+				}
+				$offer->setItemOffered($item);
+				$offerCatalog[] =  $offer;
+			}
+			if (!empty(!$offerCatalog)) {
+				$final = new OfferCatalog();
+				$final->setName($name);
+				$final->setItemListElement($offerCatalog);
+				$this->sethasOfferCatalog($final);
+			}
+		}
+	}
 }
diff --git a/inc/managers/SEO/render/Traits/_Properties/keywordsTrait.php b/inc/managers/SEO/render/Traits/_Properties/keywordsTrait.php
index 658ffff..5ddbb33 100644
--- a/inc/managers/SEO/render/Traits/_Properties/keywordsTrait.php
+++ b/inc/managers/SEO/render/Traits/_Properties/keywordsTrait.php
@@ -3,6 +3,8 @@
 
 use JVBase\managers\SEO\render\Thing\Intangible\DefinedTerm;
 use JVBase\managers\SEO\render\Traits\_Helpers\arrayHelper;
+use JVBase\meta\Meta;
+use JVBase\registrar\Fields;
 
 if (!defined('ABSPATH')) {
 	exit;
@@ -25,4 +27,29 @@
 		}
 		$this->keywords = $keywords;
 	}
+
+	public function setKeywordsField(Fields $fields):void
+	{
+		$fields->addField(
+			'keywords',
+			[
+				'type'      => 'repeater',
+				'label'     => 'Keywords',
+				'fields'    => [
+					'keyword' => [
+						'type'  => 'text',
+						'label' => 'Keyword',
+					],
+				],
+			]
+		);
+	}
+
+	public function formatKeywordsField(Meta $meta):void
+	{
+		$keywords = $meta->get('keywords');
+		if (!empty($keywords)) {
+			$this->setKeywords($keywords);
+		}
+	}
 }
diff --git a/inc/managers/SEO/render/Traits/_Properties/offersTrait.php b/inc/managers/SEO/render/Traits/_Properties/offersTrait.php
index c1dda00..a97162e 100644
--- a/inc/managers/SEO/render/Traits/_Properties/offersTrait.php
+++ b/inc/managers/SEO/render/Traits/_Properties/offersTrait.php
@@ -1,9 +1,17 @@
 <?php
 namespace JVBase\managers\SEO\render\Traits\_Properties;
 
+use JVBase\inc\managers\SEO\render\Thing\Intangible\ItemList\OfferCatalog;
 use JVBase\managers\SEO\render\Thing\Intangible\Demand;
 use JVBase\managers\SEO\render\Thing\Intangible\Offer;
+use JVBase\managers\SEO\render\Thing\Intangible\Service;
+use JVBase\managers\SEO\render\Thing\Intangible\StructuredValue\PriceSpecification;
+use JVBase\managers\SEO\render\Thing\Product\Product;
 use JVBase\managers\SEO\render\Traits\_Helpers\arrayHelper;
+use JVBase\meta\Meta;
+use JVBase\registrar\Fields;
+use JVBase\registrar\Registrar;
+use WP_Query;
 
 if (!defined('ABSPATH')) {
 	exit;
@@ -30,34 +38,155 @@
 		}
 		$this->offers = $offers;
 	}
-	public function getOpeningHoursFieldConfig():array
+	public function setOffersField(Fields $fields):void
 	{
-		return [
+		$fields->addField('generate_offers_from_content',
+		[
+			'type'	=> 'true_false',
+			'label'	=> __('Generate Offers from Content', 'jvb'),
+		]);
+		$fields->addField('seo_offers', [
 			'type'	=> 'repeater',
-			'label'	=> 'Opening Hours',
+			'label'	=> __('Offers', 'jvb'),
+			'condition'	=> [
+				'field'	=> 'generate_offers_from_content',
+				'value'	=> true,
+				'operator'	=> '=='
+			],
 			'fields'	=> [
-				'dayOfWeek'	=> [
+				'type'	=> [
 					'type'	=> 'radio',
-					'label'	=> 'Day(s) of Week',
+					'label'	=> __('Select Type', 'jvb'),
 					'options'	=> [
-						'Mo'	=> 'Monday',
-						'Tu'	=> 'Tuesday',
-						'We'	=> 'Wednesday',
-						'Th'	=> 'Thursday',
-						'Fr'	=> 'Friday',
-						'Sa'	=> 'Saturday',
-						'Su'	=> 'Sunday'
-					]
+						'product'	=> 'Product',
+						'service'	=> 'Service'
+					],
+					'required' => true,
 				],
-				'opens'	=> [
-					'type'	=> 'time',
-					'label'	=> 'Opens At',
+				'name'	=> [
+					'type'	=> 'text',
+					'label'	=> __('Name of Product or Service', 'jvb'),
+					'required'	=> true,
 				],
-				'closes'	=> [
-					'type'	=> 'time',
-					'label'	=> 'Closes At',
+				'description'	=> [
+					'type'	=> 'textarea',
+					'label'	=> __('Description (optional)', 'jvb'),
+				],
+				'price'	=> [
+					'type'	=> 'text',
+					'subtype'	=> 'number',
+					'label'		=> __('Price', 'jvb'),
+				],
+				'unitText' 	=> [
+					'type'	=> 'radio',
+					'label'	=> 'Price per unit',
+					'options'	=> [
+						'hour'	=> 'Hour',
+						'unit'	=> 'Unit',
+					],
+					'default'	=> 'unit',
 				]
-			]
-		];
+			],
+		]);
+	}
+
+	public function formatOffersField(Meta $meta):void
+	{
+		$generate = $meta->get('generate_offers_from_content');
+		if (!empty($generate) && $generate === true) {
+			$this->generateOffersFromContent($meta);
+			return;
+		}
+		$catalog = $meta->get('seo_offers');
+		if (!empty($catalog)) {
+			$offerCatalog = [];
+			$name = '';
+			$services = array_filter($catalog, function ($item) {
+				return $item['type'] === 'service';
+			});
+			$products =array_filter($catalog, function ($item) {
+				return $item['type'] === 'product';
+			});
+			if (count($products) > 0) {
+				$name = 'Products';
+				if (count($services) > 0) {
+					$name .= ' & ';
+				}
+			}
+			if (count($services) > 0) {
+				$name .= 'Services';
+			}
+			foreach ($catalog as $row) {
+				$offer = new Offer();
+
+				$item = match($row['type']) {
+					'product'	=> new Product(),
+					'service' 	=> new Service(),
+				};
+
+				$item->setName($row['name']);
+				if (!empty($row['description'])) {
+					$item->setDescription($row['description']);
+				}
+				if (!empty($row['price'])) {
+					$price = new PriceSpecification();
+					$price->setPrice($row['price']);
+					$price->setPriceCurrency('CAD');
+					$price->setUnitText($row['unitText']);
+					$offer->setPriceSpecification($price);
+				}
+				$offer->setItemOffered($item);
+				$offerCatalog[] =  $offer;
+			}
+			if (!empty(!$offerCatalog)) {
+				$this->setOffers($offerCatalog);
+			}
+		}
+	}
+
+	/**
+	 * This presumes that the Meta instance being passed is for a profile content type.
+	 * @param Meta $meta
+	 * @return void
+	 */
+	public function generateOffersFromContent(Meta $meta):void
+	{
+		$userID = (int)get_post_meta($meta->id(), BASE.'profile_link', true);
+		if (empty($userID)) {
+			return;
+		}
+		$user = get_userdata($userID);
+		if (!$user) {
+			return;
+		}
+		$registrar = Registrar::getInstance(jvbUserRole($userID));
+		if (!$registrar) {
+			return;
+		}
+		$types = $registrar->getCreatable();
+		if (empty($types)) {
+			return;
+		}
+		$offers = [];
+		$cities = $meta->get('city');
+		$rate = $meta->get('rate');
+		$reviews = $meta->get('reviews');
+		$rating = $meta->get('average_rating');
+		$theCities = $theRate = $theReviews = $theRating = null;
+
+		foreach($types as $type) {
+			$based = jvbCheckBase($type);
+			$hasAny = new WP_Query([
+				'post_type'		=> $based,
+				'posts_per_page'=> 1,
+				'fields'		=> 'ids',
+				'post_author'	=> $userID
+			]);
+			if ($hasAny->have_posts()) {
+				$offer = new Offer();
+				$typeRegistrar = Registrar::getInstance($type);
+				
+			}
+		}
 	}
 }
diff --git a/inc/managers/SEO/render/Traits/_Properties/openingHoursSpecificationTrait.php b/inc/managers/SEO/render/Traits/_Properties/openingHoursSpecificationTrait.php
index f9aba4a..594ae51 100644
--- a/inc/managers/SEO/render/Traits/_Properties/openingHoursSpecificationTrait.php
+++ b/inc/managers/SEO/render/Traits/_Properties/openingHoursSpecificationTrait.php
@@ -4,6 +4,8 @@
 use JVBase\base\SchemaHelper;
 use JVBase\managers\SEO\render\Thing\Intangible\StructuredValue\OpeningHoursSpecification;
 use JVBase\managers\SEO\render\Traits\_Helpers\arrayHelper;
+use JVBase\meta\Meta;
+use JVBase\registrar\Fields;
 
 if (!defined('ABSPATH')) {
 	exit;
@@ -41,15 +43,15 @@
 		$this->openingHoursSpecification = $openingHoursSpecification;
 	}
 
-	public function getOpeningHoursSpecificationFieldConfig():array
+	public function setOpeningHoursSpecificationField(Fields $fields):void
 	{
-		return [
+		$fields->addField('openingHours', [
 			'type'	=> 'repeater',
-			'label'	=> 'Opening Hours',
+			'label'	=> __('Opening Hours', 'jvb'),
 			'fields'	=> [
 				'dayOfWeek'	=> [
-					'type'	=> 'radio',
-					'label'	=> 'Day(s) of Week',
+					'type'		=> 'set',
+					'label'		=> __('Day(s) of Week', 'jvb'),
 					'options'	=> [
 						'Mo'	=> 'Monday',
 						'Tu'	=> 'Tuesday',
@@ -58,17 +60,65 @@
 						'Fr'	=> 'Friday',
 						'Sa'	=> 'Saturday',
 						'Su'	=> 'Sunday'
-					]
+					],
+					'required'	=> true
 				],
 				'opens'	=> [
 					'type'	=> 'time',
-					'label'	=> 'Opens At',
+					'label'	=> __('Opens at', 'jvb'),
+					'required'	=> true
 				],
 				'closes'	=> [
 					'type'	=> 'time',
-					'label'	=> 'Closes At',
+					'label'	=> __('Closes at', 'jvb'),
+					'required'	=> true
 				]
 			]
-		];
+		]);
+		$fields->addField('by_appointment', [
+			'type'	=> 'true_false',
+			'label'	=> __('By Appointment Only', 'jvb'),
+		]);
+		$fields->addField('allow_walkins', [
+			'type'	=> 'true_false',
+			'label'	=> __('Walk Ins Welcome', 'jvb')
+		]);
+	}
+	public function formatOpeningHoursSpecificationField(Meta $meta):void
+	{
+		$openingHours = $meta->get('openingHours');
+
+		if (!empty($openingHours)) {
+			$used = [
+				'Mo' 	=> false,
+				'Tu'	=> false,
+				'We'	=> false,
+				'Th'	=> false,
+				'Fr'	=> false,
+				'Sa'	=> false,
+				'Su'	=> false,
+			];
+			$hours = [];
+			foreach ($openingHours as $row) {
+				$days = array_filter(explode(',', $row['dayOfWeek']),
+					function ($d) use ($used) {
+						if ($used[$d] === false) {
+							$used[$d] = true;
+							return true;
+						}
+						return false;
+					});
+				if (empty($days)) {
+					continue;
+				}
+				$opens = new OpeningHoursSpecification();
+				$opens->setDayOfWeek($days);
+				$opens->setOpens($row['opens']);
+				$opens->setCloses($row['closes']);
+			}
+			if (!empty($hours)){
+				$this->setOpeningHoursSpecification($hours);
+			}
+		}
 	}
 }
diff --git a/inc/managers/SEO/render/Traits/_Properties/paymentAcceptedTrait.php b/inc/managers/SEO/render/Traits/_Properties/paymentAcceptedTrait.php
index fd7ced0..5dd3c7c 100644
--- a/inc/managers/SEO/render/Traits/_Properties/paymentAcceptedTrait.php
+++ b/inc/managers/SEO/render/Traits/_Properties/paymentAcceptedTrait.php
@@ -2,6 +2,8 @@
 namespace JVBase\managers\SEO\render\Traits\_Properties;
 
 use JVBase\managers\SEO\render\Traits\_Helpers\arrayHelper;
+use JVBase\meta\Meta;
+use JVBase\registrar\Fields;
 
 if (!defined('ABSPATH')) {
 	exit;
@@ -25,12 +27,31 @@
 		$this->paymentAccepted = $paymentAccepted;
 	}
 
-	public function getPaymentAcceptedFieldConfig():array
+	public function setPaymentAcceptedField(Fields $fields):void
 	{
-		return [
-			'type'	=> 'string',
-			'label'	=> 'Payment Accepted',
-			'hint'	=> 'A comma separated list of payment accepted, example: Cash, Credit Card, Cryptocurrency, Local Exchange Tradings System, etc.'
-		];
+		$fields->addField(
+			'payment_accepted',
+			[
+				'type'	=> 'set',
+				'label'	=> __('Payment Accepted', 'jvb'),
+				'options'	=> [
+					'Cash'          => 'Cash',
+					'Credit Card'   => 'Credit Card',
+					'Debit'         => 'Debit',
+					'Google Pay'    => 'Google Pay',
+					'Apple Pay'     => 'Apple Pay',
+					'PayPal'        => 'PayPal',
+					'Interac'       => 'Interac',
+					'AMEX'          => 'AMEX',
+				],
+			]
+		);
+	}
+	public function formatPaymentAcceptedField(Meta $meta):void
+	{
+		$accepted = $meta->get('payment_accepted');
+		if (!empty($accepted)){
+			$this->setPaymentAccepted($accepted);
+		}
 	}
 }
diff --git a/inc/managers/SEO/render/Traits/_Properties/photoTrait.php b/inc/managers/SEO/render/Traits/_Properties/photoTrait.php
index 883984a..ebe9e43 100644
--- a/inc/managers/SEO/render/Traits/_Properties/photoTrait.php
+++ b/inc/managers/SEO/render/Traits/_Properties/photoTrait.php
@@ -3,6 +3,8 @@
 
 use JVBase\managers\SEO\render\Thing\CreativeWork\MediaObject\ImageObject;
 use JVBase\managers\SEO\render\Thing\CreativeWork\Photograph;
+use JVBase\meta\Meta;
+use JVBase\registrar\Fields;
 
 if (!defined('ABSPATH')) {
 	exit;
@@ -31,4 +33,26 @@
 			'hint'	=> 'A photograph of this place.'
 		];
 	}
+	public function setPhotoField(Fields $fields):void
+	{
+		$fields->addField(
+			'outside_photo',
+			[
+				'type'	=> 'image',
+				'limit'	=> 1,
+				'label'	=> __('Outside Photo', 'jvb')
+			]
+		);
+	}
+
+	public function formatPhotoField(Meta $meta):void
+	{
+		$image = $meta->get('outside_photo');
+		if (!empty($image) && $image > 0) {
+			$image = $this->createImageFromID($image);
+			if ($image) {
+				$this->setPhoto($image);
+			}
+		}
+	}
 }
diff --git a/inc/managers/SEO/render/Traits/_Properties/reviewTrait.php b/inc/managers/SEO/render/Traits/_Properties/reviewTrait.php
index 2f5a238..db6bcf1 100644
--- a/inc/managers/SEO/render/Traits/_Properties/reviewTrait.php
+++ b/inc/managers/SEO/render/Traits/_Properties/reviewTrait.php
@@ -2,7 +2,11 @@
 namespace JVBase\managers\SEO\render\Traits\_Properties;
 
 use JVBase\managers\SEO\render\Thing\CreativeWork\Review;
+use JVBase\managers\SEO\render\Thing\Intangible\Rating\Rating;
+use JVBase\managers\SEO\render\Thing\Person\Person;
 use JVBase\managers\SEO\render\Traits\_Helpers\arrayHelper;
+use JVBase\meta\Meta;
+use JVBase\registrar\Fields;
 
 if (!defined('ABSPATH')) {
 	exit;
@@ -64,4 +68,156 @@
 			]
 		];
 	}
+	public function setReviewField(Fields $fields):void
+	{
+		$fields->addField(
+			'enable_gmb_review_sync',
+			[
+				'type'	=> 'true_false',
+				'label'	=> __('Enable Google My Business Sync', 'jvb'),
+				'hint'	=> 'Automagically fetch your latest positive reviews from your Google Business Listing.
+				Note: you must enable your GMB integration in settings.',
+			]
+		);
+		$fields->addField('gmb_min_rating',
+			[
+				'type'	=> 'number',
+				'default'	=> 4,
+				'min'	=> 1,
+				'max'	=> 5,
+				'label'	=> 'Minimum Rating to fetch',
+				'hint'	=> 'Defaults to 4 stars and above',
+				'condition'	=> [
+					'field'	=> 'enable_gmb_review_sync',
+					'operator'	=> '==',
+					'value'	=> true,
+				]
+			]
+		);
+		$fields->addField(
+			'reviews',
+			[
+				'type'	=> 'repeater',
+				'label'	=> __('Reviews', 'jvb'),
+				'fields'	=> [
+					'author'	=> [
+						'type'	=> 'text',
+						'label'	=> __('Review Author', 'jvb'),
+						'required'	=> true,
+					],
+					'reviewRating'	=> [
+						'type'	=> 'number',
+						'min'	=> 1,
+						'max'	=> 5,
+						'label'	=> __('Review Rating', 'jvb'),
+						'required'	=> true,
+					],
+					'reviewBody'	=> [
+						'type'	=> 'textarea',
+						'label'	=> __('Review Text Content', 'jvb'),
+					],
+					'url'		=> [
+						'type'	=> 'url',
+						'label'	=> __('Link to actual review'),
+						'hint'	=> 'Optional. Link directly to the review source for added authenticity.'
+					]
+				],
+				'condition'	=> [
+					'field'	=> 'enable_gmb_review_sync',
+					'operator'	=> '!=',
+					'value'	=> true,
+				]
+			]
+		);
+	}
+
+	public function formatReviewField(Meta $meta):void
+	{
+		$gmb = $meta->get('enable_gmb_review_sync');
+		if (!empty($gmb) && $gmb === true) {
+			$this->fetchGMBReviews($meta);
+			return;
+		}
+
+		$reviews = $meta->get('reviews');
+		if (!empty($reviews)) {
+			$theReviews = [];
+			foreach ($reviews as $row) {
+				$review = new Review();
+				$author = $row['author'];
+				$reviewer = new Person();
+				$reviewer->setName($author);
+				$review->setAuthor($reviewer);
+				$rating = new Rating();
+				$rating->setRatingValue($row['reviewRating']);
+				$review->setReviewRating($rating);
+				if (!empty($row['reviewBody'])) {
+					$review->setReviewBody($row['reviewBody']);
+				}
+				if (!empty($row['url'])){
+					$review->setUrl($row['url']);
+				}
+				$theReviews[] = $review;
+			}
+			if (!empty($theReviews)) {
+				$this->setReview($theReviews);
+			}
+		}
+	}
+
+	protected function fetchGMBReviews(Meta $meta):void
+	{
+		$userID = (int) get_post_meta($meta->id(), BASE.'profile_link', true);
+		if ($userID && $userID > 0) {
+			$integration = JVB()->connect('gmb', $userID);
+			if ($integration && $integration->isSetUp()){
+				$fetched = $integration->getReviews(20);
+
+				if (!empty($fetched)) {
+					$reviews = [];
+					$minRating = $meta->get('gmb_min_rating');
+					foreach ($fetched as $review) {
+						$rating = $review['starRating'] ?? 0;
+						if ($rating >= $minRating) {
+							$reviews[] = $review;
+							if (count($reviews) >= 10) {
+								break;
+							}
+						}
+					}
+				}
+				if (!empty($reviews)) {
+					$theReviews = [];
+					foreach ($reviews as $r) {
+						$review = new Review();
+						$author = $r['reviewer']['displayName'] ?? 'Anonymous';
+						$author = strtok($author, ' ');
+						$reviewer = new Person();
+						$reviewer->setName($author);
+						$review->setAuthor($reviewer);
+						$rating = new Rating();
+						$rating->setRatingValue(match($r['starRating']) {
+							'FIVE'	=> 5,
+							'FOUR'	=> 4,
+							'THREE'	=> 3,
+							'TWO'	=> 2,
+							'ONE'	=> 1,
+							default => 0
+						});
+						$review->setReviewRating($rating);
+						if (!empty($r['comment'])) {
+							$review->setReviewBody($r['comment']);
+						}
+						if (!empty($r['updateTime']??'')){
+							$review->setDateCreated($r['updateTime']);
+						}
+						$theReviews[] = $review;
+					}
+					if (!empty($theReviews)) {
+						$this->setReview($theReviews);
+					}
+				}
+			}
+		}
+	}
 }
diff --git a/inc/managers/SEO/render/Traits/_Properties/sloganTrait.php b/inc/managers/SEO/render/Traits/_Properties/sloganTrait.php
index dca604c..1a95375 100644
--- a/inc/managers/SEO/render/Traits/_Properties/sloganTrait.php
+++ b/inc/managers/SEO/render/Traits/_Properties/sloganTrait.php
@@ -1,6 +1,8 @@
 <?php
 namespace JVBase\managers\SEO\render\Traits\_Properties;
 
+use JVBase\registrar\Fields;
+
 if (!defined('ABSPATH')) {
 	exit;
 }
@@ -18,4 +20,15 @@
 	{
 		$this->slogan = $slogan;
 	}
+
+	public function setSloganField(Fields $fields):void
+	{
+		$fields->addField(
+			'slogan',
+			[
+				'type'	=> 'text',
+				'label'	=> __('Tagline or Slogan', 'jvb')
+			]
+		);
+	}
 }
diff --git a/inc/registrar/Fields.php b/inc/registrar/Fields.php
index ada4615..f310a3f 100644
--- a/inc/registrar/Fields.php
+++ b/inc/registrar/Fields.php
@@ -1,6 +1,8 @@
 <?php
 namespace JVBase\registrar;
 
+use JVBase\managers\SEO\render\Thing\Organization\LocalBusiness\LocalBusiness;
+use JVBase\managers\SEO\render\Thing\Thing;
 use JVBase\registrar\fields\Field;
 use JVBase\registrar\fields\GroupedField;
 use JVBase\registrar\fields\OptionsField;
@@ -267,159 +269,97 @@
 
 	protected function addReviewField(?string $label = null):void
 	{
-		$this->addField(
-			'reviews',
-			[
-				'type'    => 'repeater',
-				'add_label'	=> 'name',
-				'label'   => $label ?: 'Reviews',
-				'fields'  => [
-					'name'   => [
-						'type'  => 'text',
-						'label' => 'Reviewer Name',
-					],
-					'review' => [
-						'type'  => 'textarea',
-						'quill' => false,
-						'label' => 'Review',
-					],
-					'rating' => [
-						'type'    => 'select',
-						'label'   => 'Rating',
-						'options' => [
-							'none' => 'Not Given',
-							'0.5'  => '0.5',
-							'1'    => '1',
-							'1.5'  => '1.5',
-							'2'    => '2',
-							'2.5'  => '2.5',
-							'3'    => '3',
-							'3.5'  => '3.5',
-							'4'    => '4',
-							'4.5'  => '4.5',
-							'5'    => '5',
-						],
-						'default' => 'none'
-					],
-					'date'   => [
-						'type'  => 'date',
-						'label' => 'Date of Review',
-					],
-					'url'    => [
-						'type'  => 'url',
-						'label' => 'Link to Review (optional)',
-					],
-				],
-				'section' => 'seo'
-			]
-		);
+		$biz = new LocalBusiness();
+		$biz->setReviewField($this);
 	}
 
 	protected function addAlternateNameField():void
 	{
-		$this->addField(
-			'alternate_name',
-			[
-				'type'    => 'repeater',
-				'label'   => 'Alternate Name',
-				'fields'  => [
-					'name' => [
-						'type'  => 'text',
-						'label' => 'Name',
-					]
-				],
-				'section' => 'seo'
-			]
-		);
+		$thing = new Thing();
+		$thing->setAlternateNameField($this);
 	}
 	protected function addKeywordsField():void
 	{
-		$this->addField(
-			'keywords',
-			[
-				'type'      => 'repeater',
-				'label'     => 'Keywords',
-				'fields'    => [
-					'keyword' => [
-						'type'  => 'text',
-						'label' => 'Keyword',
-					],
-				],
-				'default'   => $labels ?? [ 'Edmonton tattoos', 'Edmonton tattoo artist', 'Edmonton tattooist' ],
-				'section'   => 'seo',
-				'quickEdit' => true,
-			]
-		);
+		$thing = new LocalBusiness();
+		$thing->setKeywordsField($this);
 	}
 
 	protected function addOutsidePhotoField():void
 	{
-		$this->addField(
-			'outside_photo',
-			[
-				'type'	=> 'image',
-				'limit'	=> 1,
-				'label'	=> __('Outside Photo', 'jvb')
-			]
-		);
+		$business = new LocalBusiness();
+		$business->setPhotoField($this);
 	}
 
 	protected function addSloganField():void
 	{
-		$this->addField(
-			'slogan',
-			[
-				'type'	=> 'text',
-				'label'	=> __('Tagline or Slogan', 'jvb')
-			]
-		);
+		$business = new LocalBusiness();
+		$business->setSloganField($this);
 	}
 
 	protected function addPaymentField():void
 	{
-		$this->addField(
-			'payment_accepted',
-			[
-				'type'	=> 'set',
-				'label'	=> __('Payment Accepted', 'jvb'),
-				'options'	=> [
-					'Cash'          => 'Cash',
-					'Credit Card'   => 'Credit Card',
-					'Debit'         => 'Debit',
-					'Google Pay'    => 'Google Pay',
-					'Apple Pay'     => 'Apple Pay',
-					'PayPal'        => 'PayPal',
-					'Interac'       => 'Interac',
-					'AMEX'          => 'AMEX',
-				],
-			]
-		);
+		$business = new LocalBusiness();
+		$business->setPaymentAcceptedField($this);
 	}
 
 	protected function addAmenitiesField():void
 	{
+		$business = new LocalBusiness();
+		$business->setAmenityFeatureField($this);
+	}
+	protected function addCredentialsField():void
+	{
 		$this->addField(
-			'amenities',
+			'credentials',
 			[
 				'type'	=> 'set',
-				'label'	=> __('Amenities', 'jvb'),
-				'options' => [
-					'Wheelchair Accessible' => 'Wheelchair Accessible',
-					'Free Parking' => 'Free Parking',
-					'Private Rooms' => 'Private Rooms',
-					'Air Conditioning' => 'Air Conditioning',
-					'WiFi' => 'WiFi',
-					'Gender Neutral Restroom' => 'Gender Neutral Restroom',
-					'LGBTQ+ Friendly' => 'LGBTQ+ Friendly',
-					'Sterilization Room' => 'Sterilization Room',
-					'Refreshments Available' => 'Refreshments Available',
-					'Street Level Access' => 'Street Level Access',
-					'Single Use Needles' => 'Single Use Needles',
-					'Consultation Room' => 'Consultation Room',
-					'Aftercare Products Available' => 'Aftercare Products Available',
-					'Walk-Ins Welcome' => 'Walk-Ins Welcome',
+				'label'	=> __('Credentials', 'jvb'),
+				'options'=> [
+					'WHMIS 2015' => 'WHMIS 2015',
+					'Tattoo and Piercing Safety Standards' => 'Tattoo and Piercing Safety Standards',
+					'Bloodborne Pathogens and Infection Control' => 'Bloodborne Pathogens and Infection Control',
+					'First Aid Training' => 'First Aid Training',
 				]
 			]
 		);
 	}
+	protected function addPermanentlyCloseField():void
+	{
+		$business = new LocalBusiness();
+		$business->setDissolutionDateField($this);
+	}
+
+	protected function addHoursField():void
+	{
+		$business = new LocalBusiness();
+		$business->setOpeningHoursSpecificationField($this);
+	}
+
+	protected function addRateField():void
+	{
+		$this->addField('rate',
+		[
+			'type'	=> 'text',
+			'subtype'	=> 'number',
+			'label'		=> __('Hourly Rate', 'jvb')
+		]);
+	}
+
+	protected function addAwardsField():void
+	{
+		$business = new LocalBusiness();
+		$business->setAwardField($this);
+	}
+
+	protected function addRatingsField():void
+	{
+		$business = new LocalBusiness();
+		$business->setAggregateRatingField($this);
+	}
+
+	protected function addServicesField():void
+	{
+		$business = new LocalBusiness();
+		$business->setHasOfferCatalogField($this);
+	}
 }

--
Gitblit v1.10.0