Files
besttechfrom.eu/docs/superpowers/plans/2026-10-17-product-details.md
T
2026-10-17 15:54:23 +03:00

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 moderator or admin change badges; the submitter's badge input is ignored.
  • Screenshots: unlimited count; each image jpeg, png, jpg or gif, max 2048 KB; stored on the public disk under screenshots/.
  • 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=1 to 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.php
  • app/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 in tests/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 helper userWithRole(string $slug): User (creates the role if missing) and makeCategory(): Category in tests/Pest.php; ProductController::detailRules(): array and syncDetails(Request $request, Product $product): void (both start with badges only); show.blade.php has 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.php

    • submitter ticks badges on create: POST products.store with eu_hosted=1, open_source=1 → product has those two true, others false.
    • submitter can change badges while pending: PUT update on a pending product flips eu_made to true.
    • submitter cannot change badges after approval: approved product with eu_hosted=false; owner PUTs eu_hosted=1 plus valid required fields → eu_hosted still false.
    • moderator can untick a badge on an approved product: product with eu_hosted=true; moderator PUTs without eu_hosted → false.
    • edit form shows badges read-only to owner of approved product: GET edit as owner, assertSee disabled next 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 boolean columns default false on products.
    • Product: add the constants, fillable, casts, and a ProductFactory default of false for each.
    • detailRules() returns ['free_tier' => 'boolean', ...] for the four; store and update merge it into their validation. In store, the validated booleans are stored directly. In update, drop the four keys unless $product->status !== 'approved' or Auth::user()->hasAnyRole(['moderator', 'admin']). Use a hidden 0 input before each checkbox so unticking sends false.
    • Views: checkbox row (create), checkbox row that renders disabled plus a "Locked after approval" note when locked (edit), badges component (pills) used in the show header and on card.blade.php. Restructure show.blade.php into 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 #tag text in the header.
  • Step 4: Run --filter=ProductBadgesTest and the existing ProductTest; 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_id cascade FK, path, position unsigned 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(): HasMany ordered by position, id; ProductScreenshot::$url accessor (asset('storage/'.$path)); form fields screenshots[] (files) and remove_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 fake public disk.
    • 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 like data-gallery only with rows).
  • Step 2: Run to verify fail.

  • Step 3: Implement — add screenshots and screenshots.* rules to detailRules(); in syncDetails store new files with next position values and delete only rows where product_id matches. Product destroy deletes 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 input multiple with client-side thumbnails; edit form lists existing ones with remove checkboxes.

  • Step 4: Run --filter=ProductScreenshotsTest and ProductTest; expect pass.

  • Step 5: Commit — git commit -m "feat: product screenshots gallery"


Task 3: Pricing plans

Files:

  • Create: migration create_pricing_plans_table (product_id cascade FK, name, price decimal(8,2), period, description nullable, 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(): HasMany ordered by position; form field plans[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 plans array, plans.*.name required|max:100, price required|numeric|min:0|max:99999, period in:PERIODS, description nullable|max:255; replace-all in syncDetails. Form: Alpine repeater rows with "Add plan" and remove buttons, prefilled from old() 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_id and user_id cascade FKs, role string 100, timestamps, unique product_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, table product_members, withPivot('role')); route GET /users/search?q= named users.search, behind auth, returns JSON [{id, name, slug}] for up to 10 users whose name contains q (min 2 characters, else []); form field members[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 member and same 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 is Auth::id() on store, $product->user_id on update), members.*.role required|max:100; syncDetails uses sync with pivot roles. Form: Alpine search box calling users.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_at desc; each result has a shared_tags_count attribute); <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: join product_tag on tag ids of this product, group by product, withCount('upvotes'). Eager load tags is 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"