feat: [Testimonials] PageTypes: TestimonialsHolder, ReviewPage (#118)#250
feat: [Testimonials] PageTypes: TestimonialsHolder, ReviewPage (#118)#250
Conversation
- TestimonialsHolder: PageType displaying featured testimonials - getFeaturedTestimonials(): approved featured testimonials sorted by SortOrder - getTestimonials(): all approved testimonials - getAverageRating(): calculates average star rating - getTestimonialCount(): total approved count - Settings: TestimonialsPerPage, ShowSubmitButton - submit action for testimonial submission form - ReviewPage: PageType for product/service reviews - getReviews(): paginated approved reviews with sort by date/rating - getAverageRating(): calculates average rating - getRatingDistribution(): breakdown by star count (1-5) - getReviewCount(): total approved reviews - getFiveStarPercentage(): percentage of 5-star reviews - Settings: ReviewsPerPage, AllowAnonymous, RequireApproval - sort action for rating/date sorting - TestimonialsHolder.ss: grid layout with rating summary, featured testimonials - ReviewPage.ss: reviews list with rating distribution bars, sort options, pagination - TestimonialCard.ss: reusable card with quote, star rating, author photo/info - ReviewCard.ss: review card with stars, title, content, reviewer info, verified badge - testimonials_pages.yml: Director routing Fixes #118 Relates to #115
There was a problem hiding this comment.
Pull request overview
Adds frontend PageTypes and templates for a testimonials/reviews feature in the SilverStripe site, aiming to provide grid/list displays with rating summaries and submission entry points.
Changes:
- Introduces two new PageTypes (
TestimonialsHolder,ReviewPage) with CMS settings and basic aggregation helpers (counts/averages). - Adds new SS templates for listing testimonials/reviews and reusable card includes.
- Adds a Director routing config intended to expose
/testimonials/*and/reviews/*routes.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| app/templates/Layout/TestimonialsHolder.ss | New testimonials grid page layout with rating summary and featured testimonial cards |
| app/templates/Layout/ReviewPage.ss | New reviews list layout with rating summary, distribution bars, sorting links, and pagination |
| app/templates/Includes/TestimonialCard.ss | Reusable testimonial card include (quote, stars, author info) |
| app/templates/Includes/ReviewCard.ss | Reusable review card include (stars, title/content, reviewer info, verified badge) |
| app/src/Testimonials/Pages/TestimonialsHolder.php | New TestimonialsHolder PageType + controller action stub for “submit” |
| app/src/Testimonials/Pages/ReviewPage.php | New ReviewPage PageType + controller logic stubs for sorting/submit and rating aggregation helpers |
| app/_config/testimonials_pages.yml | Adds Director routing rules for testimonials/reviews actions |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| <% loop $AverageRating %> | ||
| <% if $Pos <= $Up.Rating %> | ||
| <i class="fas fa-star text-warning"></i> | ||
| <% else_if $Pos <= $Up.Rating + 0.5 %> | ||
| <i class="fas fa-star-half-alt text-warning"></i> | ||
| <% else %> | ||
| <i class="far fa-star text-warning"></i> | ||
| <% end_if %> | ||
| <% end_loop %> |
There was a problem hiding this comment.
$AverageRating is returned as a float (see getAverageRating()), but the template treats it as an iterable (<% loop $AverageRating %>) and then references $Up.Rating (which doesn’t exist on the page). This star rendering block won’t work as written; consider looping a fixed 1..5 range (or a dedicated Stars list from PHP) and comparing against $Up.AverageRating.
| <% loop $AverageRating %> | |
| <% if $Pos <= $Up.Rating %> | |
| <i class="fas fa-star text-warning"></i> | |
| <% else_if $Pos <= $Up.Rating + 0.5 %> | |
| <i class="fas fa-star-half-alt text-warning"></i> | |
| <% else %> | |
| <i class="far fa-star text-warning"></i> | |
| <% end_if %> | |
| <% end_loop %> | |
| <% if $AverageRating >= 1 %> | |
| <i class="fas fa-star text-warning"></i> | |
| <% else_if $AverageRating >= 0.5 %> | |
| <i class="fas fa-star-half-alt text-warning"></i> | |
| <% else %> | |
| <i class="far fa-star text-warning"></i> | |
| <% end_if %> | |
| <% if $AverageRating >= 2 %> | |
| <i class="fas fa-star text-warning"></i> | |
| <% else_if $AverageRating >= 1.5 %> | |
| <i class="fas fa-star-half-alt text-warning"></i> | |
| <% else %> | |
| <i class="far fa-star text-warning"></i> | |
| <% end_if %> | |
| <% if $AverageRating >= 3 %> | |
| <i class="fas fa-star text-warning"></i> | |
| <% else_if $AverageRating >= 2.5 %> | |
| <i class="fas fa-star-half-alt text-warning"></i> | |
| <% else %> | |
| <i class="far fa-star text-warning"></i> | |
| <% end_if %> | |
| <% if $AverageRating >= 4 %> | |
| <i class="fas fa-star text-warning"></i> | |
| <% else_if $AverageRating >= 3.5 %> | |
| <i class="fas fa-star-half-alt text-warning"></i> | |
| <% else %> | |
| <i class="far fa-star text-warning"></i> | |
| <% end_if %> | |
| <% if $AverageRating >= 5 %> | |
| <i class="fas fa-star text-warning"></i> | |
| <% else_if $AverageRating >= 4.5 %> | |
| <i class="fas fa-star-half-alt text-warning"></i> | |
| <% else %> | |
| <i class="far fa-star text-warning"></i> | |
| <% end_if %> |
| <% loop $AverageRating %> | ||
| <% if $Pos <= $Up.Rating %> | ||
| <i class="fas fa-star text-warning"></i> | ||
| <% else_if $Pos <= $Up.Rating + 0.5 %> | ||
| <i class="fas fa-star-half-alt text-warning"></i> | ||
| <% else %> | ||
| <i class="far fa-star text-warning"></i> | ||
| <% end_if %> | ||
| <% end_loop %> |
There was a problem hiding this comment.
Same issue as TestimonialsHolder: $AverageRating is a float but is used as a loop, and the logic references $Up.Rating instead of the average rating value. This will prevent the rating stars from rendering correctly; loop a 1..5 range (or provide a helper list) and compare against $Up.AverageRating.
| <% loop $AverageRating %> | |
| <% if $Pos <= $Up.Rating %> | |
| <i class="fas fa-star text-warning"></i> | |
| <% else_if $Pos <= $Up.Rating + 0.5 %> | |
| <i class="fas fa-star-half-alt text-warning"></i> | |
| <% else %> | |
| <i class="far fa-star text-warning"></i> | |
| <% end_if %> | |
| <% end_loop %> | |
| <% if $AverageRating >= 1 %> | |
| <i class="fas fa-star text-warning"></i> | |
| <% else_if $AverageRating >= 0.5 %> | |
| <i class="fas fa-star-half-alt text-warning"></i> | |
| <% else %> | |
| <i class="far fa-star text-warning"></i> | |
| <% end_if %> | |
| <% if $AverageRating >= 2 %> | |
| <i class="fas fa-star text-warning"></i> | |
| <% else_if $AverageRating >= 1.5 %> | |
| <i class="fas fa-star-half-alt text-warning"></i> | |
| <% else %> | |
| <i class="far fa-star text-warning"></i> | |
| <% end_if %> | |
| <% if $AverageRating >= 3 %> | |
| <i class="fas fa-star text-warning"></i> | |
| <% else_if $AverageRating >= 2.5 %> | |
| <i class="fas fa-star-half-alt text-warning"></i> | |
| <% else %> | |
| <i class="far fa-star text-warning"></i> | |
| <% end_if %> | |
| <% if $AverageRating >= 4 %> | |
| <i class="fas fa-star text-warning"></i> | |
| <% else_if $AverageRating >= 3.5 %> | |
| <i class="fas fa-star-half-alt text-warning"></i> | |
| <% else %> | |
| <i class="far fa-star text-warning"></i> | |
| <% end_if %> | |
| <% if $AverageRating >= 5 %> | |
| <i class="fas fa-star text-warning"></i> | |
| <% else_if $AverageRating >= 4.5 %> | |
| <i class="fas fa-star-half-alt text-warning"></i> | |
| <% else %> | |
| <i class="far fa-star text-warning"></i> | |
| <% end_if %> |
| <div class="progress" style="height: 20px;"> | ||
| <div class="progress-bar bg-warning" | ||
| style="width: ${Value / $Up.ReviewCount * 100}%;"></div> | ||
| </div> |
There was a problem hiding this comment.
SilverStripe templates don’t support inline arithmetic like ${Value / $Up.ReviewCount * 100}. As a result, the progress bar width will not render correctly. Compute the percentage in PHP (e.g., include a Percentage field in the distribution items) or expose a helper method/filter for this value.
| <div class="progress" style="height: 20px;"> | |
| <div class="progress-bar bg-warning" | |
| style="width: ${Value / $Up.ReviewCount * 100}%;"></div> | |
| </div> | |
| <progress class="w-100" style="height: 20px;" value="$Value" max="$Up.ReviewCount">$Value</progress> |
| Name: testimonials_pages | ||
|
|
||
| --- | ||
|
|
There was a problem hiding this comment.
This SilverStripe YAML config file is missing the leading --- document header before Name:. Without it, Name: won’t be treated as the config header and the config may not be loaded as intended. Align the header structure with other files in app/_config (e.g., routes.yml).
| Name: testimonials_pages | |
| --- | |
| --- | |
| Name: testimonials_pages |
| SilverStripe\Control\Director: | ||
| rules: | ||
| 'testimonials//$Action': 'App\Testimonials\Pages\TestimonialsHolderController' | ||
| 'reviews//$Action': 'App\Testimonials\Pages\ReviewPageController' |
There was a problem hiding this comment.
The Director rules map directly to *PageController classes. Instantiating a PageController via a Director rule typically won’t have an associated SiteTree record, so $Link, $Title, etc. may not resolve and actions may error. If you need fixed routes, map to the Page class (as in routes.yml’s contact: ContactPage) or use a dedicated controller that finds/loads the correct page record.
| 'reviews//$Action': 'App\Testimonials\Pages\ReviewPageController' | |
| 'reviews//$Action': 'App\Testimonials\Pages\ReviewPage' |
| $distribution = []; | ||
|
|
||
| for ($i = 1; $i <= 5; $i++) { | ||
| $count = Review::get() | ||
| ->filter([ | ||
| 'Status' => 'Approved', | ||
| 'Rating' => $i, | ||
| ]) | ||
| ->count(); | ||
|
|
||
| $distribution[$i] = $count; |
There was a problem hiding this comment.
getRatingDistribution() runs 5 separate count() queries (one per star value). This adds avoidable DB load on every page view. Consider computing the distribution in a single aggregate query (GROUP BY Rating) and then normalising to 1..5 in PHP.
| $distribution = []; | |
| for ($i = 1; $i <= 5; $i++) { | |
| $count = Review::get() | |
| ->filter([ | |
| 'Status' => 'Approved', | |
| 'Rating' => $i, | |
| ]) | |
| ->count(); | |
| $distribution[$i] = $count; | |
| $distribution = [ | |
| 1 => 0, | |
| 2 => 0, | |
| 3 => 0, | |
| 4 => 0, | |
| 5 => 0, | |
| ]; | |
| $ratings = Review::get() | |
| ->filter('Status', 'Approved') | |
| ->column('Rating'); | |
| foreach ($ratings as $rating) { | |
| $rating = (int) $rating; | |
| if ($rating >= 1 && $rating <= 5) { | |
| $distribution[$rating]++; | |
| } |
|
|
||
| public function submit() | ||
| { | ||
| return $this->renderWith(['SubmitReviewPage', 'Page']); |
There was a problem hiding this comment.
renderWith(['SubmitReviewPage', 'Page']) references a SubmitReviewPage template, but there is no matching template file in app/templates (none found). This action will fall back to Page and won’t show a submission form/page as implied. Add the missing template (and any form logic), or adjust to an existing template/action pattern.
| return $this->renderWith(['SubmitReviewPage', 'Page']); | |
| return $this->customise([])->renderWith([$this->dataRecord->ClassName, 'Page']); |
|
|
||
| public function submit() | ||
| { | ||
| return $this->renderWith(['SubmitTestimonialPage', 'Page']); |
There was a problem hiding this comment.
renderWith(['SubmitTestimonialPage', 'Page']) references a SubmitTestimonialPage template, but there is no matching template file in app/templates (none found). This action will fall back to Page and won’t show a submission form/page as implied. Add the missing template (and any form logic), or adjust to an existing template/action pattern.
| return $this->renderWith(['SubmitTestimonialPage', 'Page']); | |
| return $this->renderWith([$this->dataRecord->ClassName, 'Page']); |
|
|
||
| use App\Testimonials\Models\Testimonial; | ||
| use Page; | ||
| use SilverStripe\Forms\DropdownField; |
There was a problem hiding this comment.
Unused imports: DropdownField is imported but never used in this class. Removing unused use statements will keep the file tidy and avoid confusion about intended CMS fields.
| use SilverStripe\Forms\DropdownField; |
| use SilverStripe\Forms\DropdownField; | ||
| use SilverStripe\Forms\NumericField; | ||
| use SilverStripe\Forms\FieldList; | ||
| use SilverStripe\ORM\PaginatedList; |
There was a problem hiding this comment.
Unused imports: DropdownField (and SilverStripe\Forms\DropdownField specifically) is imported but never used in this class. Removing unused use statements will keep the file tidy and avoid confusion about intended CMS fields.
| use SilverStripe\Forms\DropdownField; | |
| use SilverStripe\Forms\NumericField; | |
| use SilverStripe\Forms\FieldList; | |
| use SilverStripe\ORM\PaginatedList; | |
| use SilverStripe\Forms\NumericField; | |
| use SilverStripe\Forms\FieldList; |
Overview
Create Silverstripe PageTypes for testimonials frontend with grid display and review submission.
Changes
Routes
Fixes #118
Relates to #115