14 KiB
Richer Product Details Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add badges, screenshots, pricing plans, a team, and tag-based alternatives to products, on the form, edit form, product page and cards.
Architecture: New columns and tables hang off products. ProductController gains two private helpers, detailRules() and syncDetails(), that every task extends, so store and update stay thin. Each section is a Blade component included from products/show.blade.php, and each hides itself when empty.
Tech Stack: Laravel (Sail, MySQL), Blade, Tailwind 4, Alpine, Pest. Run tests with docker compose exec -T laravel.test php artisan test --filter=<name>.
Spec: docs/superpowers/specs/2026-10-17-product-details-design.md
Global Constraints
- Badges are exactly four booleans:
free_tier,open_source,eu_hosted,eu_made; labels "Free tier", "Open source", "EU hosted", "EU made". - Once a product is approved, only users with role
moderatororadminchange badges; the submitter's badge input is ignored. - Screenshots: unlimited count; each image jpeg, png, jpg or gif, max 2048 KB; stored on the
publicdisk underscreenshots/. - Pricing plan: name, price (decimal 2 places, euros, 0 or more), period one of
monthly,yearly,one-time, short description. - Team: registered users only, direct add, free-text role; submitter cannot be a member; no duplicate member.
- Alternatives: approved products only, never the product itself, at least one shared tag, 3 shown, ordered by shared tags then upvotes then newest.
- Every new section is hidden when empty; products with no new data must render exactly as before.
- Dark mode classes like the rest of the site; no feature flag.
Review Focus
- Submitter posts
eu_hosted=1to the update of an approved product: ignored. (Task 1) - Submitter posts
remove_screenshots[]with a screenshot id from another product: nothing deleted. (Task 2) - Negative, non-numeric price or unknown period: validation error, nothing saved. (Task 3)
- Team member list containing the submitter or the same user twice: validation error. (Task 4)
- Another product sharing zero tags, a pending product sharing tags, and the product itself never appear as alternatives. (Task 5)
File Structure
database/migrations/*_add_badges_to_products_table.php,*_create_product_screenshots_table.php,*_create_pricing_plans_table.php,*_create_product_members_table.phpapp/Models/ProductScreenshot.php,app/Models/PricingPlan.php(new);app/Models/Product.php(relations, badge constants,alternatives())app/Http/Controllers/ProductController.php(detailRules,syncDetails, delete cleanup);app/Http/Controllers/UserSearchController.php(new)resources/views/components/product/{badges,gallery,pricing,team,alternatives}.blade.php(new);products/{create,edit,show}.blade.php,components/product/card.blade.php- Tests:
tests/Feature/Product{Badges,Screenshots,Pricing,Team,Alternatives}Test.php; helpers intests/Pest.php
Task 1: Badges and show-page skeleton
Files:
- Create: migration
add_badges_to_products_table,resources/views/components/product/badges.blade.php,tests/Feature/ProductBadgesTest.php - Modify:
tests/Pest.php,app/Models/Product.php,app/Http/Controllers/ProductController.php,database/factories/ProductFactory.php,products/create|edit|show.blade.php,components/product/card.blade.php
Interfaces:
-
Produces:
Product::BADGES(['free_tier' => 'Free tier', 'open_source' => 'Open source', 'eu_hosted' => 'EU hosted', 'eu_made' => 'EU made']); the four boolean attributes, fillable and cast to bool; component<x-product.badges :product="$product" />; Pest helperuserWithRole(string $slug): User(creates the role if missing) andmakeCategory(): Categoryintests/Pest.php;ProductController::detailRules(): arrayandsyncDetails(Request $request, Product $product): void(both start with badges only);show.blade.phphas a main column and a sidebar column with comment markers<!-- slot:gallery -->(above columns),<!-- slot:pricing -->,<!-- slot:alternatives -->(main column, in that order, before Comments) and<!-- slot:team -->(sidebar). -
Step 1: Write failing tests in
ProductBadgesTest.phpsubmitter ticks badges on create: POSTproducts.storewitheu_hosted=1, open_source=1→ product has those two true, others false.submitter can change badges while pending: PUT update on a pending product flipseu_madeto true.submitter cannot change badges after approval: approved product witheu_hosted=false; owner PUTseu_hosted=1plus valid required fields →eu_hostedstill false.moderator can untick a badge on an approved product: product witheu_hosted=true; moderator PUTs withouteu_hosted→ false.edit form shows badges read-only to owner of approved product: GET edit as owner, assertSeedisablednext to "EU hosted"; for pending product assertDontSee that.show page lists ticked badges only: assertSee "EU hosted", assertDontSee "EU made".existing product without new data still renders: factory product, GET show → 200.
-
Step 2: Run to verify they fail —
--filter=ProductBadgesTest; expect failures on missing columns. -
Step 3: Implement
- Migration: four
booleancolumns defaultfalseonproducts. Product: add the constants, fillable, casts, and aProductFactorydefault of false for each.detailRules()returns['free_tier' => 'boolean', ...]for the four;storeandupdatemerge it into their validation. Instore, the validated booleans are stored directly. Inupdate, drop the four keys unless$product->status !== 'approved'orAuth::user()->hasAnyRole(['moderator', 'admin']). Use a hidden0input before each checkbox so unticking sends false.- Views: checkbox row (create), checkbox row that renders
disabledplus a "Locked after approval" note when locked (edit),badgescomponent (pills) used in the show header and oncard.blade.php. Restructureshow.blade.phpinto header, gallery slot, then a two-column area (main about 3/4, sidebar 1/4) with the slot markers listed above; show the product's tags as#tagtext in the header.
- Migration: four
-
Step 4: Run
--filter=ProductBadgesTestand the existingProductTest; expect all pass. -
Step 5: Commit —
git commit -m "feat: product badges and show page layout"
Task 2: Screenshots
Files:
- Create: migration
create_product_screenshots_table(product_idcascade FK,path,positionunsigned int default 0, timestamps),app/Models/ProductScreenshot.php,components/product/gallery.blade.php,tests/Feature/ProductScreenshotsTest.php - Modify:
Product.php,ProductController.php,create|edit|show.blade.php
Interfaces:
-
Produces:
Product::screenshots(): HasManyordered byposition,id;ProductScreenshot::$urlaccessor (asset('storage/'.$path)); form fieldsscreenshots[](files) andremove_screenshots[](ids);<x-product.gallery :product="$product" />placed at<!-- slot:gallery -->. -
Step 1: Write failing tests
submitter uploads several screenshots: store with 3 fake images → 3 rows, 3 files on fakepublicdisk.screenshots must be images up to 2 MB: pdf and a 3000 KB image each fail validation.owner removes a screenshot on edit: row and file deleted.remove_screenshots ignores ids from another product: other product's screenshot survives and returns no error.deleting a product removes its screenshot files.show page renders gallery only when screenshots exist(assertSee a gallery marker likedata-galleryonly with rows).
-
Step 2: Run to verify fail.
-
Step 3: Implement — add
screenshotsandscreenshots.*rules todetailRules(); insyncDetailsstore new files with nextpositionvalues and delete only rows whereproduct_idmatches. Productdestroydeletes screenshot files (rows go by cascade). Gallery: large first image left, up to three small tiles, "+N more" tile; Alpine lightbox opens the clicked image full size with next/previous. Form: file inputmultiplewith client-side thumbnails; edit form lists existing ones with remove checkboxes. -
Step 4: Run
--filter=ProductScreenshotsTestandProductTest; expect pass. -
Step 5: Commit —
git commit -m "feat: product screenshots gallery"
Task 3: Pricing plans
Files:
- Create: migration
create_pricing_plans_table(product_idcascade FK,name,pricedecimal(8,2),period,descriptionnullable,position),app/Models/PricingPlan.php,components/product/pricing.blade.php,tests/Feature/ProductPricingTest.php - Modify:
Product.php,ProductController.php,create|edit|show.blade.php
Interfaces:
-
Produces:
PricingPlan::PERIODS = ['monthly', 'yearly', 'one-time'];Product::pricingPlans(): HasManyordered byposition; form fieldplans[i][name|price|period|description]; on update the submitted list replaces all existing plans;<x-product.pricing>at<!-- slot:pricing -->. -
Step 1: Write failing tests
submitter adds plans: two plans (Free €0 monthly, Team 9.99 monthly) saved in order.price must be zero or more,price must be numeric,period must be one of the allowed values,name is required— each fails validation and saves nothing.update replaces the plan list: update with one plan → only that plan remains.show page prints price and period: assertSee "€9.99", "monthly"; a €0 plan shows "Free" copy as "€0".no plans, no pricing section.
-
Step 2: Run to verify fail.
-
Step 3: Implement — rules
plansarray,plans.*.name required|max:100,price required|numeric|min:0|max:99999,period in:PERIODS,description nullable|max:255; replace-all insyncDetails. Form: Alpine repeater rows with "Add plan" and remove buttons, prefilled fromold()or the product. Cards side by side. -
Step 4: Run
--filter=ProductPricingTest; expect pass. -
Step 5: Commit —
git commit -m "feat: product pricing plans"
Task 4: Team
Files:
- Create: migration
create_product_members_table(product_idanduser_idcascade FKs,rolestring 100, timestamps, uniqueproduct_id,user_id),app/Http/Controllers/UserSearchController.php,components/product/team.blade.php,tests/Feature/ProductTeamTest.php - Modify:
Product.php,routes/web.php,ProductController.php,create|edit|show.blade.php
Interfaces:
-
Produces:
Product::members(): BelongsToMany(users, tableproduct_members,withPivot('role')); routeGET /users/search?q=namedusers.search, behindauth, returns JSON[{id, name, slug}]for up to 10 users whose name containsq(min 2 characters, else[]); form fieldmembers[i][user_id|role], replace-all on update;<x-product.team>at<!-- slot:team -->. -
Step 1: Write failing tests
user search returns matching users, requires auth, and returns[]for one-character queries.submitter adds members with roles.submitter cannot be added as a memberandsame user cannot be added twice: validation errors, nothing saved.unknown user id fails validation.deleting a user removes their membership(row gone, product stays).show page lists submitter first as Author then members linking to profiles;no members shows only the submitter as author.
-
Step 2: Run to verify fail.
-
Step 3: Implement — rules
members.*.user_id required|exists:users,id|distinct|different from the product owner(the owner isAuth::id()on store,$product->user_idon update),members.*.role required|max:100;syncDetailsusessyncwith pivot roles. Form: Alpine search box callingusers.search(debounced), selected people listed with a role input and remove button. -
Step 4: Run
--filter=ProductTeamTest; expect pass. -
Step 5: Commit —
git commit -m "feat: product team members"
Task 5: Alternatives
Files:
- Create:
components/product/alternatives.blade.php,tests/Feature/ProductAlternativesTest.php - Modify:
app/Models/Product.php,ProductController@show,show.blade.php
Interfaces:
-
Produces:
Product::alternatives(int $limit = 3): \Illuminate\Database\Eloquent\Collection(approved products other than this one that share at least one tag, ordered by shared-tag count desc, upvote count desc,created_atdesc; each result has ashared_tags_countattribute);<x-product.alternatives :product="$product" />at<!-- slot:alternatives -->, each card showing thumbnail or placeholder, title linking to the product, description excerpt, its badges, and "N shared tags". -
Step 1: Write failing tests
orders by shared tags: products sharing 3, 2 tags come first and second.excludes self, pending, rejected and zero-shared-tag products.ties are ordered by upvotes then newest.limits to 3.show page renders alternative cards and hides the section when none.
-
Step 2: Run to verify fail.
-
Step 3: Implement
alternatives()as one query: joinproduct_tagon tag ids of this product, group by product,withCount('upvotes'). Eager loadtagsis not needed. -
Step 4: Run the full suite —
docker compose exec -T laravel.test php artisan test; expect all pass. -
Step 5: Commit —
git commit -m "feat: tag-based product alternatives"