177 lines
14 KiB
Markdown
177 lines
14 KiB
Markdown
# 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"`
|