From 1f412fe9b325d3686bcf607ae59d982e82d3497e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 10:32:57 +0000 Subject: [PATCH 01/22] docs(043): log open questions, create spec stub, update roadmap Co-authored-by: ildyria <627094+ildyria@users.noreply.github.com> --- .../043-webshop-print-pixel-sizes/spec.md | 391 ++++++++++++++++++ docs/specs/4-architecture/open-questions.md | 90 ++++ docs/specs/4-architecture/roadmap.md | 1 + 3 files changed, 482 insertions(+) create mode 100644 docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/spec.md diff --git a/docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/spec.md b/docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/spec.md new file mode 100644 index 00000000000..12b6f5e6c89 --- /dev/null +++ b/docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/spec.md @@ -0,0 +1,391 @@ +# Feature 043 – Webshop Print & Pixel Sizes + +| Field | Value | +|-------|-------| +| Status | Draft – blocked on open questions Q-043-01 … Q-043-05 | +| Last updated | 2026-05-31 | +| Owners | LycheeOrg | +| Linked plan | `docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/plan.md` | +| Linked tasks | `docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/tasks.md` | +| Roadmap entry | #043 | + +> Guardrail: This specification is the single normative source of truth for the feature. Track high- and medium-impact questions in [docs/specs/4-architecture/open-questions.md](../../open-questions.md), encode resolved answers directly in the Requirements/NFR/Behaviour/UI/Telemetry sections below (no per-feature `## Clarifications` sections), and use ADRs under `docs/specs/5-decisions/` for architecturally significant clarifications (referencing their IDs from the relevant spec sections). + +## Overview + +This feature extends the Lychee webshop to support **physical print orders** (at admin-configured print sizes in cm or inches) and **custom pixel-size digital exports**. The admin defines a global catalogue of supported print and pixel sizes. When a basket contains at least one print item, the checkout flow collects a shipping address. A new admin configuration page manages the print/pixel catalogue. The existing size-variant flow (MEDIUM, MEDIUM2X, ORIGINAL, FULL) is unchanged. + +**Affected modules:** Database (new `print_sizes` / `pixel_sizes` tables, migrations for `orders` and `order_items`), Models (`Order`, `OrderItem`, new `PrintSize` / `PixelSize`), Application services (`BasketService`, `CheckoutService`, `PurchasableService`), REST API (new management routes, extended basket/checkout endpoints), UI (`InfoSection`, new admin page, basket item type selector). + +> ⚠️ **Spec incomplete.** Five open questions (Q-043-01 through Q-043-05) must be resolved before requirements and implementation details can be finalised. See [docs/specs/4-architecture/open-questions.md](../../open-questions.md). + +## Goals + +- Allow customers to purchase photos as physical prints at admin-defined sizes (width × height, unit cm or inch). +- Allow customers to purchase photos at admin-defined pixel sizes (width × height in pixels). +- Retain the existing digital size-variant purchase flow with zero regression. +- Introduce `is_print` boolean on `OrderItem` to distinguish physical print items from digital items. +- Collect a shipping address at checkout when the order contains at least one print item; store it on the `Order`. +- Provide a dedicated admin page to create, edit, enable/disable, and delete print and pixel sizes. + +## Non-Goals + +- Automatic fulfilment of print orders (manual offline step). +- Integration with third-party print-on-demand services. +- Per-album or per-photo print/pixel size restrictions *(pending Q-043-04)*. +- Bulk discount or coupon codes. +- Currency conversion or multi-currency support. + +## Functional Requirements + +> ⚠️ Requirements table will be authored once Q-043-01 … Q-043-05 are resolved. + +## Non-Functional Requirements + +> ⚠️ NFR table will be authored once Q-043-01 … Q-043-05 are resolved. + +## UI / Interaction Mock-ups + +> ⚠️ ASCII mock-ups will be finalised once Q-043-01 … Q-043-05 are resolved. + +### Basket Item Type Selector (customer-facing, draft) + +``` +┌──────────────────────────────────────────────────────────┐ +│ Add to Basket: "Sunset over the Alps" │ +├──────────────────────────────────────────────────────────┤ +│ Type: ○ Digital file ○ Print ○ Pixel size │ +│ │ +│ [If "Digital file" selected] │ +│ Size: [MEDIUM ▼] License: [Personal ▼] │ +│ │ +│ [If "Print" selected] │ +│ Print size: [20×30 cm – €25.00 ▼] │ +│ License: [Personal ▼] ← presence depends on Q-043-02 │ +│ │ +│ [If "Pixel size" selected] │ +│ Pixel size: [3000×2000 px – €12.00 ▼] │ +│ License: [Personal ▼] ← presence depends on Q-043-02 │ +│ │ +│ [ Add to basket ] │ +└──────────────────────────────────────────────────────────┘ +``` + +### Checkout – Shipping Address Step (draft) + +``` +┌──────────────────────────────────────────────────────────┐ +│ Your info │ +├──────────────────────────────────────────────────────────┤ +│ Email: _______________________ * │ +│ │ +│ ── Shipping address (required for print orders) ── │ +│ Street name: _______________________ * │ +│ Street number: ________ │ +│ Additional: _______________________ │ +│ City: _______________________ * │ +│ Post code: ______________ * │ +│ Country: [Select country ▼] * │ +│ │ +│ ☐ I agree to the Terms and Privacy Policy │ +│ [ Next → ] │ +└──────────────────────────────────────────────────────────┘ +``` + +### Admin Print/Pixel Sizes Page (`/admin/shop/sizes`, draft) + +``` +┌──────────────────────────────────────────────────────────┐ +│ Shop › Print & Pixel Sizes │ +├──────────────────────────────────────────────────────────┤ +│ [ + Add Print Size ] [ + Add Pixel Size ] │ +│ │ +│ PRINT SIZES │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Label │ W × H │ Unit │ Price │ Active │ │ +│ │ Small │ 10 × 15 │ cm │ €5.00 │ ✓ [Edit] │ │ +│ │ Standard │ 20 × 30 │ cm │ €25.00 │ ✓ [Edit] │ │ +│ │ US Letter │ 8 × 10 │ inch │ €20.00 │ ✗ [Edit] │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ PIXEL SIZES │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Label │ W × H │ Price │ Active │ │ +│ │ Web 1080p │ 1920 × 1080 px │ €8.00 │ ✓ [Edit]│ │ +│ │ Print-ready │ 3000 × 2000 px │ €12.00 │ ✓ [Edit]│ │ +│ └──────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────┘ +``` + +## Branch & Scenario Matrix + +> ⚠️ Scenario IDs will be assigned once requirements are finalised. + +## Test Strategy + +- **Unit:** `Order::canProcessPayment()` with print items and complete/incomplete shipping address. +- **Feature (REST):** Print/pixel size management CRUD; basket add for print/pixel items; checkout shipping address validation. +- **Regression:** Full `tests/Webshop/` suite must pass unchanged. +- **UI:** Admin size catalogue CRUD; basket item type selector; checkout address form visibility toggle. + +## Interface & Contract Catalogue + +> ⚠️ Will be authored once open questions are resolved. Draft API routes: +> - `GET/POST/PUT/DELETE /api/v2/Shop/Management/PrintSize` +> - `GET/POST/PUT/DELETE /api/v2/Shop/Management/PixelSize` +> - `GET /api/v2/Shop/Catalogue/Sizes` (customer-facing, active sizes only) +> - `POST /api/v2/Shop/Basket/Photo` (extended to accept `print_size_id` / `pixel_size_id`) +> - `POST /api/v2/Shop/Checkout/Create-session` (extended to accept shipping address) + +## Spec DSL + +```yaml +# Incomplete — pending open-question resolution +domain_objects: + - id: DO-043-01 + name: PrintSize + fields: [id, label, width, height, unit (cm|inch), price_cents, is_active] + - id: DO-043-02 + name: PixelSize + fields: [id, label, width, height (px), price_cents, is_active] + - id: DO-043-03 + name: OrderItem (extensions) + fields: [is_print, print_size_id, pixel_size_id, print_width, print_height, print_unit, pixel_width, pixel_height] + - id: DO-043-04 + name: Order (extensions) + fields: [shipping_street_name, shipping_street_number, shipping_additional_info, shipping_city, shipping_post_code, shipping_country] +``` + + +| Field | Value | +|-------|-------| +| Status | Planning | +| Last updated | 2026-05-31 | +| Owners | LycheeOrg | +| Linked plan | `docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/plan.md` | +| Linked tasks | `docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/tasks.md` | +| Roadmap entry | #043 | + +> Guardrail: This specification is the single normative source of truth for the feature. Track high- and medium-impact questions in [docs/specs/4-architecture/open-questions.md](../../open-questions.md), encode resolved answers directly in the Requirements/NFR/Behaviour/UI/Telemetry sections below (no per-feature `## Clarifications` sections), and use ADRs under `docs/specs/5-decisions/` for architecturally significant clarifications (referencing their IDs from the relevant spec sections). + +## Overview + +This feature extends the Lychee webshop beyond digital size variants (MEDIUM, MEDIUM2X, ORIGINAL, FULL) to support **physical print orders** (at configurable print sizes in cm or inches) and **custom pixel-size exports**. The admin can define a global catalogue of supported print sizes and pixel sizes; these become purchasable options alongside the existing size-variant flow. When a basket contains at least one print item the checkout step collects a shipping address. A new admin configuration page manages the print/pixel catalogue. + +**Affected modules:** Database (new tables, orders migration), Models (`OrderItem`, `Order`, new `PrintSize`/`PixelSize`), Enums (`PurchasableSizeVariantType`), Application services (`BasketService`, `CheckoutService`), REST API (new management routes, extended basket/checkout endpoints), UI (basket item type selector, shipping address form, new admin config page). + +## Goals + +- Allow customers to purchase photos as physical prints at photographer-defined print sizes (width × height, unit cm or inch). +- Allow customers to purchase photos at photographer-defined pixel sizes (width × height in pixels). +- Retain the existing size-variant flow unchanged so no regression for digital purchases. +- Introduce a boolean `is_print` flag on `OrderItem` that correctly differentiates physical print orders from digital orders. +- Collect a shipping address (street name, street number, additional info, city, post code, country) at checkout whenever the order contains at least one print item. +- Provide a dedicated admin page to manage the global catalogue of supported print sizes and pixel sizes (enable, disable, add, remove, reorder). +- Store the shipping address on the `Order` so it can be viewed in the order management screen and used for dispatch. + +## Non-Goals + +- Automatic fulfilment of print orders (this remains a manual offline step for the photographer; the system notifies and records the shipping address only). +- Integration with third-party print-on-demand services. +- Per-album or per-photo print size restrictions (all active print/pixel sizes are available on every purchasable item). +- Pricing per-size-variant override for print/pixel sizes at the album or photo level (print/pixel sizes use only the global catalogue price defined by the admin). +- Currency conversion or multi-currency support (existing currency config applies). +- Bulk discount or coupon codes. + +## Functional Requirements + +| ID | Requirement | Success path | Validation path | Failure path | Telemetry & traces | Source | +|----|-------------|--------------|-----------------|--------------|--------------------|--------| +| FR-043-01 | Admin can create print sizes in the print/pixel catalogue | Admin POSTs `{ type: "print", width, height, unit: "cm"\|"inch", label, price_cents, is_active }` to `POST /api/v2/Shop/Management/PrintSize`. Record persisted in `print_sizes` table. | Width and height are positive integers; unit is `cm` or `inch`; label is ≤ 100 chars; price_cents ≥ 0. | 422 with field-level errors when validation fails. | No telemetry. | User requirement | +| FR-043-02 | Admin can create pixel sizes in the print/pixel catalogue | Admin POSTs `{ type: "pixel", width, height, label, price_cents, is_active }` to `POST /api/v2/Shop/Management/PixelSize`. Record persisted in `pixel_sizes` table. | Width and height are positive integers; label is ≤ 100 chars; price_cents ≥ 0. | 422 with field-level errors when validation fails. | No telemetry. | User requirement | +| FR-043-03 | Admin can update and delete print/pixel sizes | Admin PUTs to `PUT /api/v2/Shop/Management/PrintSize/{id}` or `PUT /api/v2/Shop/Management/PixelSize/{id}` to update; DELETEs to remove. Soft-delete not required (hard delete acceptable; existing order items snapshot label and dimensions). | Record must exist and belong to a valid admin session. | 404 if not found; 403 if unauthorised. | No telemetry. | User requirement | +| FR-043-04 | Admin can enable or disable individual print/pixel sizes | `is_active` boolean on each record controls whether the size appears in the customer-facing catalogue. Inactive sizes are still stored for historical order record accuracy. | Only active sizes returned from `GET /api/v2/Shop/Catalogue/Sizes`. | 422 if `is_active` missing on update. | No telemetry. | User requirement | +| FR-043-05 | Customer can add a print-size order item to basket | `POST /api/v2/Shop/Basket/Photo` accepts `{ photo_id, print_size_id, license_type }` (alongside existing `size_variant_type`). Basket service creates an `OrderItem` with `is_print = true`, `print_size_id` set, `pixel_size_id = null`, `size_variant_type = null`, `size_variant_id = null`. | `print_size_id` must reference an active print size; `photo_id` must reference a purchasable photo. Price is taken from `print_sizes.price_cents`. | 422 when `print_size_id` is inactive or missing; 404 when photo not found/purchasable. | No telemetry. | User requirement | +| FR-043-06 | Customer can add a pixel-size order item to basket | `POST /api/v2/Shop/Basket/Photo` accepts `{ photo_id, pixel_size_id, license_type }`. Basket service creates an `OrderItem` with `is_print = false`, `pixel_size_id` set, `print_size_id = null`, `size_variant_type = null`. | `pixel_size_id` must reference an active pixel size. | 422 when `pixel_size_id` inactive or missing. | No telemetry. | User requirement | +| FR-043-07 | Existing size-variant basket flow is unchanged | `POST /api/v2/Shop/Basket/Photo` with `size_variant_type` set (no `print_size_id`/`pixel_size_id`) follows the current code path; `is_print = false`, both new FK columns `null`. | Regression tests for existing digital purchases all pass. | No regression on existing API contract. | No telemetry. | Backward compat | +| FR-043-08 | `is_print` boolean on `OrderItem` distinguishes physical from digital | `is_print = true` means the item requires physical fulfilment. Digital size-variant and pixel-size items have `is_print = false`. | Migration adds `is_print BOOLEAN NOT NULL DEFAULT FALSE`. | Any item without explicit print intent defaults to false. | No telemetry. | User requirement | +| FR-043-09 | Order snapshot captures print/pixel size dimensions at purchase time | `OrderItem` stores `print_width`, `print_height`, `print_unit`, `pixel_width`, `pixel_height` at the moment the item is added to the basket. Changes to the catalogue after purchase do not affect historical records. | Values match the catalogue entry at basket-add time. Nullable for non-print/non-pixel items. | No action if item is a standard size variant. | No telemetry. | Data integrity | +| FR-043-10 | Checkout collects shipping address when basket contains prints | If `Order.items` has any item where `is_print = true`, the checkout `InfoSection` step renders shipping address fields (street name, street number, additional info, city, post code, country). | All required fields (street name, city, post code, country) must be non-empty. | 422 from `POST /api/v2/Shop/Checkout/Create-session` when required fields absent and basket has prints. | No telemetry. | User requirement | +| FR-043-11 | Shipping address is stored on the `Order` | `orders` table gains columns: `shipping_street_name`, `shipping_street_number`, `shipping_additional_info`, `shipping_city`, `shipping_post_code`, `shipping_country` (all `string\|null`). Set to non-null values only for orders containing print items. | Columns present in migration; populated by `CheckoutService` before payment initiation. | Not populated for digital-only orders (all null). | No telemetry. | User requirement | +| FR-043-12 | Order management screen shows shipping address for print orders | `GET /api/v2/Shop/Order/{id}` response includes `shipping_address` sub-object when any item `is_print = true`. | Shipping fields returned in `OrderResource` when non-null. | Shipping address block hidden in UI for digital-only orders. | No telemetry. | User requirement | +| FR-043-13 | Customer-facing catalogue exposes active print/pixel sizes | `GET /api/v2/Shop/Catalogue/Sizes` returns `{ print_sizes: [...], pixel_sizes: [...] }` of active entries. Used by frontend to populate size selectors. | Only `is_active = true` records returned. | Empty arrays when no active sizes. | No telemetry. | User requirement | +| FR-043-14 | Admin management page lists, creates, updates, deletes print/pixel sizes | New admin Vue page `PrintPixelSizesAdmin.vue` at route `/admin/shop/sizes`. Supports full CRUD via the management API endpoints. | Admin-only route (redirects non-admins to home). | Error toast on API failure. | No telemetry. | User requirement | +| FR-043-15 | `canProcessPayment()` on Order requires shipping address when prints present | `Order::canProcessPayment()` returns `false` if any item is `is_print = true` and any required shipping field is null/empty. | Unit test covers both paths. | Payment initiation blocked; UI shows validation message. | No telemetry. | Data integrity | + +## Non-Functional Requirements + +| ID | Requirement | Driver | Measurement | Dependencies | Source | +|----|-------------|--------|-------------|--------------|--------| +| NFR-043-01 | No regression on existing digital purchase flow | Backward compat | All existing Webshop feature tests continue to pass. | Existing Webshop test suite | Quality bar | +| NFR-043-02 | Migrations are reversible | Maintainability | `php artisan migrate:rollback` succeeds without data loss in test environment. | Laravel migration tooling | Coding standard | +| NFR-043-03 | Code follows Lychee PHP conventions | Maintainability | License headers, snake_case, strict comparison, PSR-4, no `empty()`. php-cs-fixer clean. PHPStan level 6 passes. | php-cs-fixer, phpstan | Coding convention | +| NFR-043-04 | API validation returns field-level 422 errors | User experience | Each invalid field name listed in `errors` response body. | FormRequest classes | API standard | +| NFR-043-05 | Shipping address fields are validated server-side | Security & data integrity | Required fields enforced by FormRequest; no raw HTML injection. | FormRequest | Security standard | +| NFR-043-06 | Existing `OrderItem` MoneyCast and relations preserved | Data integrity | No changes to existing `price_cents` cast or size-variant relations. | MoneyCast, BelongsTo | Backward compat | +| NFR-043-07 | Print/pixel catalogue queries are paginated for large catalogues | Performance | `GET /api/v2/Shop/Management/PrintSize` and `PixelSize` support optional `?page=` param. | Laravel pagination | Performance standard | + +## UI / Interaction Mock-ups + +### 1. Basket Item Type Selector (customer-facing) + +When adding a photo to basket, a size type radio group precedes the size selector: + +``` +┌──────────────────────────────────────────────────────────┐ +│ Add to Basket: "Sunset over the Alps" │ +├──────────────────────────────────────────────────────────┤ +│ Type: ○ Digital file ○ Print ○ Pixel size │ +│ │ +│ [If "Digital file" selected] │ +│ Size: [MEDIUM ▼] License: [Personal ▼] │ +│ │ +│ [If "Print" selected] │ +│ Print size: [20×30 cm – €25.00 ▼] │ +│ License: [Personal ▼] │ +│ │ +│ [If "Pixel size" selected] │ +│ Pixel size: [3000×2000 px – €12.00 ▼] │ +│ License: [Personal ▼] │ +│ │ +│ [ Add to basket ] │ +└──────────────────────────────────────────────────────────┘ +``` + +### 2. Checkout – Shipping Address Step (shown when basket contains prints) + +Shipping address fields are injected below the email/consent block in the `InfoSection` component: + +``` +┌──────────────────────────────────────────────────────────┐ +│ Your info │ +├──────────────────────────────────────────────────────────┤ +│ Email: _______________________ * │ +│ │ +│ ── Shipping address (required for print orders) ── │ +│ Street name: _______________________ * │ +│ Street number: ________ │ +│ Additional info: _______________________ │ +│ City: _______________________ * │ +│ Post code: ______________ * │ +│ Country: [Select country ▼] * │ +│ │ +│ ☐ I agree to the Terms and Privacy Policy │ +│ [ Next → ] │ +└──────────────────────────────────────────────────────────┘ +``` + +### 3. Admin Print/Pixel Sizes Page (`/admin/shop/sizes`) + +``` +┌──────────────────────────────────────────────────────────┐ +│ Shop › Print & Pixel Sizes │ +├──────────────────────────────────────────────────────────┤ +│ [ + Add Print Size ] [ + Add Pixel Size ] │ +│ │ +│ PRINT SIZES │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Label │ W × H │ Unit │ Price │ Active│ │ +│ │ Small print │ 10 × 15 │ cm │ €olean5.00│ ✓ │ │ +│ │ Standard print │ 20 × 30 │ cm │ €25.00 │ ✓ │ │ +│ │ Large print │ 40 × 60 │ cm │ €45.00 │ ✓ │ │ +│ │ US Letter │ 8 × 10 │ inch │ €20.00 │ ✗ │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ [Edit] [Delete] per row │ +│ │ +│ PIXEL SIZES │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Label │ W × H │ Price │ Active │ │ +│ │ Web (1080p) │ 1920 × 1080 px │ €olean8.00 │ ✓ │ │ +│ │ Print-ready │ 3000 × 2000 px │ €12.00 │ ✓ │ │ +│ └─────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────┘ +``` + +### 4. Order Management – Shipping Address Panel + +``` +┌──────────────────────────────────────────────────────────┐ +│ Order #1042 [COMPLETED] 2026-05-20 │ +├──────────────────────────────────────────────────────────┤ +│ Items: │ +│ • "Sunset Alps" – 20×30 cm print – Personal – €25.00 [PRINT]│ +│ • "City Night" – MEDIUM digital – Personal – €5.00 │ +│ │ +│ Shipping address: │ +│ Jane Doe │ +│ Hauptstraße 42 │ +│ Apt 3B │ +│ Berlin, 10115, Germany │ +└──────────────────────────────────────────────────────────┘ +``` + +## Branch & Scenario Matrix + +| Scenario ID | Description / Expected outcome | +|-------------|--------------------------------| +| S-043-01 | Admin creates a print size with valid cm dimensions → persisted, returned in catalogue | +| S-043-02 | Admin creates a print size with unit "inch" → persisted with correct unit | +| S-043-03 | Admin creates a pixel size with valid px dimensions → persisted | +| S-043-04 | Admin updates a print size label and price → changes reflected in catalogue | +| S-043-05 | Admin deletes a print size → removed from catalogue; existing order items retain snapshotted data | +| S-043-06 | Admin disables a print size → not returned in customer catalogue | +| S-043-07 | Customer adds a print item to basket → `is_print = true`, dimensions snapshotted | +| S-043-08 | Customer adds a pixel-size item to basket → `is_print = false`, pixel dims snapshotted | +| S-043-09 | Customer adds a digital size-variant item → existing flow, `is_print = false`, new columns null | +| S-043-10 | Customer proceeds to checkout with only digital items → shipping address fields not shown | +| S-043-11 | Customer proceeds to checkout with at least one print item → shipping address fields shown and required | +| S-043-12 | Customer submits checkout without required shipping fields when basket has prints → 422 from API | +| S-043-13 | Customer submits checkout with all required shipping fields → order created with shipping address | +| S-043-14 | Admin views order containing print item → shipping address displayed | +| S-043-15 | Admin views order containing only digital items → shipping address block hidden | +| S-043-16 | Customer tries to add an inactive print size → 422 error | +| S-043-17 | Customer tries to add a non-existent pixel size → 404 error | +| S-043-18 | Existing digital-purchase test suite passes without modification | +| S-043-19 | `Order::canProcessPayment()` returns false when prints present and shipping address incomplete | +| S-043-20 | `Order::canProcessPayment()` returns true when prints present and shipping address complete | +| S-043-21 | Admin page loads print and pixel size catalogue | +| S-043-22 | Admin adds a print size via the UI form → appears in list | +| S-043-23 | Admin toggles active/inactive status via the UI → API updated | + +## Test Strategy + +- **Unit:** `Order::canProcessPayment()` with print items and complete/incomplete shipping address. `OrderItem` snapshot attributes. `PrintSizeService` / `PixelSizeService` create/update/delete. +- **Feature (REST):** `PrintSizeManagementControllerTest`, `PixelSizeManagementControllerTest`, `BasketControllerPrintTest` (S-043-07..09), `CheckoutShippingAddressTest` (S-043-10..13, S-043-19..20), `OrderResourceShippingTest` (S-043-14..15). +- **Regression:** Run existing `tests/Webshop/` suite unchanged (S-043-18). +- **UI (manual/Playwright):** Admin size catalogue CRUD, basket item type selector, checkout address form visibility toggle. + +## Interface & Contract Catalogue + +### Domain Objects + +| ID | Description | Modules | +|----|-------------|---------| +| DO-043-01 | `PrintSize`: id, label, width, height, unit (cm\|inch), price_cents, is_active | DB, Model, API | +| DO-043-02 | `PixelSize`: id, label, width, height (px), price_cents, is_active | DB, Model, API | +| DO-043-03 | `OrderItem` extensions: `is_print`, `print_size_id`, `pixel_size_id`, `print_width`, `print_height`, `print_unit`, `pixel_width`, `pixel_height` | DB, Model | +| DO-043-04 | `Order` shipping address fields: `shipping_street_name`, `shipping_street_number`, `shipping_additional_info`, `shipping_city`, `shipping_post_code`, `shipping_country` | DB, Model | + +### API Routes / Services + +| ID | Transport | Description | Notes | +|----|-----------|-------------|-------| +| API-043-01 | GET /api/v2/Shop/Catalogue/Sizes | Returns active print and pixel sizes for customer selection | Public (within purchasable album) | +| API-043-02 | GET /api/v2/Shop/Management/PrintSize | Lists all print sizes (admin) | Requires admin auth | +| API-043-03 | POST /api/v2/Shop/Management/PrintSize | Creates a print size | Requires admin auth | +| API-043-04 | PUT /api/v2/Shop/Management/PrintSize/{id} | Updates a print size | Requires admin auth | +| API-043-05 | DELETE /api/v2/Shop/Management/PrintSize/{id} | Deletes a print size | Requires admin auth | +| API-043-06 | GET /api/v2/Shop/Management/PixelSize | Lists all pixel sizes (admin) | Requires admin auth | +| API-043-07 | POST /api/v2/Shop/Management/PixelSize | Creates a pixel size | Requires admin auth | +| API-043-08 | PUT /api/v2/Shop/Management/PixelSize/{id} | Updates a pixel size | Requires admin auth | +| API-043-09 | DELETE /api/v2/Shop/Management/PixelSize/{id} | Deletes a pixel size | Requires admin auth | +| API-043-10 | POST /api/v2/Shop/Basket/Photo (extended) | Accepts `print_size_id` or `pixel_size_id` in addition to existing `size_variant_type` | Mutually exclusive inputs | +| API-043-11 | POST /api/v2/Shop/Checkout/Create-session (extended) | Accepts shipping address fields when basket has print items | New optional body fields | + +### Telemetry Events + +None. No new telemetry events introduced by this feature. + +--- + +*Last updated: 2026-05-31* diff --git a/docs/specs/4-architecture/open-questions.md b/docs/specs/4-architecture/open-questions.md index 51e4f142f2c..2e4c1677f16 100644 --- a/docs/specs/4-architecture/open-questions.md +++ b/docs/specs/4-architecture/open-questions.md @@ -6,9 +6,99 @@ Track unresolved high- and medium-impact questions here. Remove each row as soon | Question ID | Feature | Priority | Summary | Status | Opened | Updated | |-------------|---------|----------|---------|--------|--------|---------| +| Q-043-01 | 043 – Webshop Print & Pixel Sizes | High | Pricing model: global flat price per size vs. per-purchasable with license-type dimension | Open | 2026-05-31 | 2026-05-31 | +| Q-043-02 | 043 – Webshop Print & Pixel Sizes | High | License type: do print and pixel items carry the personal/commercial/extended dimension? | Open | 2026-05-31 | 2026-05-31 | +| Q-043-03 | 043 – Webshop Print & Pixel Sizes | Medium | Pixel-size fulfillment: manual download-link flow (same as FULL) or different mechanism? | Open | 2026-05-31 | 2026-05-31 | +| Q-043-04 | 043 – Webshop Print & Pixel Sizes | Medium | Print/pixel catalogue availability: global for all purchasables, or restrictable per-album/photo? | Open | 2026-05-31 | 2026-05-31 | +| Q-043-05 | 043 – Webshop Print & Pixel Sizes | Low | SE gating: are print/pixel sizes Lychee SE (supporter:pro) only, consistent with the rest of the webshop? | Open | 2026-05-31 | 2026-05-31 | ## Question Details +### Q-043-01: Pricing model for print/pixel sizes + +**Feature:** 043 – Webshop Print & Pixel Sizes +**Priority:** High +**Status:** Open +**Opened:** 2026-05-31 + +**Context:** The current `purchasable_prices` table prices each size variant (`medium`, `medium2x`, `original`, `full`) × license type (`personal`, `commercial`, `extended`) per `purchasable` record (album or photo). The problem statement says "admin specifies a list of possible print sizes and pixel sizes" — that implies a global catalogue. It is unclear whether a price is stored on the global size entry or whether each purchasable can override it. + +**Options (ordered by recommendation):** + +**Option A – Global flat price per size (recommended):** Each `print_size` / `pixel_size` catalogue record carries a single `price_cents` set by the admin. That price applies to every purchasable photo/album uniformly. No per-purchasable price override for print/pixel sizes. Pros: simple to implement and manage; consistent with "admin defines what's available". Cons: no per-photographer/per-album price customisation. + +**Option B – Per-purchasable override (mirrors existing price model):** The `purchasable_prices` table (or a sibling) is extended to accommodate `print_size_id` / `pixel_size_id` entries, allowing per-album/photo price overrides on top of a catalogue default. Pros: full pricing flexibility. Cons: significantly more complex; catalogue UI and basket resolution logic both grow; the problem statement does not explicitly request per-album overrides. + +--- + +### Q-043-02: License type for print and pixel order items + +**Feature:** 043 – Webshop Print & Pixel Sizes +**Priority:** High +**Status:** Open +**Opened:** 2026-05-31 + +**Context:** Digital size variants are sold with a license type (personal, commercial, extended). The problem statement does not mention license types for prints or pixel sizes. + +**Options (ordered by recommendation):** + +**Option A – Retain license type for all item types (recommended):** Print and pixel-size `OrderItem` records still carry a `license_type` (defaulting to `personal` if not selected). The customer can choose the license just as with digital variants. Pros: consistent model; reuses existing enum and validation; legally safer for photographers selling commercial rights. Cons: slightly more UI surface. + +**Option B – No license type for prints/pixel sizes:** `license_type` is `null` on print/pixel order items; it only applies to digital downloads. Pros: simpler checkout form. Cons: inconsistent model; breaks `OrderItem` NOT-NULL constraint (requires schema change or default). + +--- + +### Q-043-03: Fulfillment model for pixel-size items + +**Feature:** 043 – Webshop Print & Pixel Sizes +**Priority:** Medium +**Status:** Open +**Opened:** 2026-05-31 + +**Context:** FULL size variants require the photographer to manually export the file and set a `download_link`. Pixel-size purchases imply the photographer exports the photo at a specific resolution. The fulfillment mechanism is unspecified. + +**Options (ordered by recommendation):** + +**Option A – Same manual `download_link` mechanism as FULL (recommended):** A pixel-size `OrderItem` has `size_variant_type = null`, `is_print = false`, and awaits a `download_link` set by the photographer (same admin action as FULL). `FulfillOrders` task skips these until the link is set. Pros: reuses existing fulfillment infrastructure with no new code path. Cons: photographer must remember to export at the specified pixel dimensions. + +**Option B – Automatic export via a queue job:** A new job reads `pixel_width`/`pixel_height` from the order item and generates the resized image automatically using Lychee's image processing pipeline. Pros: fully automated. Cons: large scope increase; not requested by the problem statement; relies on Lychee's image processing capabilities being available post-purchase. + +--- + +### Q-043-04: Print/pixel catalogue availability scope + +**Feature:** 043 – Webshop Print & Pixel Sizes +**Priority:** Medium +**Status:** Open +**Opened:** 2026-05-31 + +**Context:** The problem statement says "admin specifies a list of possible print sizes and pixel sizes" without mentioning per-album or per-photo restrictions. + +**Options (ordered by recommendation):** + +**Option A – Global catalogue, available for all purchasables (recommended):** Every active `print_size` / `pixel_size` is selectable for any purchasable photo. Admin enables/disables sizes globally only. Pros: simple; matches problem statement literally. Cons: no per-album control. + +**Option B – Per-purchasable opt-in/opt-out:** The purchasable management screen lets the owner whitelist or blacklist specific print/pixel sizes for each album or photo. Pros: fine-grained control. Cons: significant UI and backend complexity not requested. + +--- + +### Q-043-05: Lychee SE gating for print/pixel features + +**Feature:** 043 – Webshop Print & Pixel Sizes +**Priority:** Low +**Status:** Open +**Opened:** 2026-05-31 + +**Context:** All existing webshop routes are behind `Route::middleware('support:pro')`. Print/pixel sizes extend the webshop. + +**Options (ordered by recommendation):** + +**Option A – Same `support:pro` gate (recommended):** Print/pixel size management and the customer catalogue endpoint sit inside the existing `support:pro` middleware group. Pros: consistent; no policy change. Cons: none. + +**Option B – Separate gate or free tier:** Print/pixel sizes are offered on a lower tier. Pros: expands addressable market. Cons: contradicts existing policy; would require a policy decision outside the feature's scope. + +--- + ### ~~Q-039-01: Custom Brand Name vs Generic "your-application" Placeholder~~ ✅ RESOLVED **Feature:** 039 – Lychee White Label diff --git a/docs/specs/4-architecture/roadmap.md b/docs/specs/4-architecture/roadmap.md index 810d6da620a..6afe1a8f302 100644 --- a/docs/specs/4-architecture/roadmap.md +++ b/docs/specs/4-architecture/roadmap.md @@ -7,6 +7,7 @@ High-level planning document for Lychee features and architectural initiatives. | Feature ID | Name | Status | Priority | Assignee | Started | Updated | Progress | |------------|------|--------|----------|----------|---------|---------|----------| | 040 | Disable Request Caching | Planning | P2 | LycheeOrg | 2026-05-18 | 2026-05-18 | Spec, plan, tasks drafted. 9 tasks across 5 increments (I1 migration, I2 feature flag + .env.example, I3 controller filter, I4 feature tests, I5 quality gates). No open questions. Ready to begin T-040-01. | +| 043 | Webshop Print & Pixel Sizes | Planning | P2 | LycheeOrg | 2026-05-31 | 2026-05-31 | Spec stub created. Blocked on 5 open questions (Q-043-01 … Q-043-05): pricing model, license-type applicability, pixel fulfillment, catalogue scope, SE gating. Plan and tasks pending question resolution. | ## Paused Features From c20d9343d4128729a4c9f2e286763b2c5c964155 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 10:53:58 +0000 Subject: [PATCH 02/22] feat(043): resolve open questions, update spec and create plan/tasks for webshop print & pixel sizes Co-authored-by: ildyria <627094+ildyria@users.noreply.github.com> --- .../043-webshop-print-pixel-sizes/plan.md | 418 ++++++++++++++++++ .../043-webshop-print-pixel-sizes/spec.md | 379 ++++++---------- .../043-webshop-print-pixel-sizes/tasks.md | 394 +++++++++++++++++ docs/specs/4-architecture/open-questions.md | 72 +-- 4 files changed, 965 insertions(+), 298 deletions(-) create mode 100644 docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/plan.md create mode 100644 docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/tasks.md diff --git a/docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/plan.md b/docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/plan.md new file mode 100644 index 00000000000..5fad90f10f6 --- /dev/null +++ b/docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/plan.md @@ -0,0 +1,418 @@ +# Feature Plan 043 – Webshop Print & Pixel Sizes + +_Linked specification:_ `docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/spec.md` +_Status:_ Ready for implementation +_Last updated:_ 2026-05-31 + +> Guardrail: Keep this plan traceable back to the governing spec. Reference FR/NFR/Scenario IDs from `spec.md` where relevant, log any new high- or medium-impact questions in [docs/specs/4-architecture/open-questions.md](../../open-questions.md), and assume clarifications are resolved only when the spec's normative sections (requirements/NFR/behaviour/telemetry) and, where applicable, ADRs under `docs/specs/5-decisions/` have been updated. + +## Vision & Success Criteria + +Extend the Lychee webshop so that photographers can sell physical prints and custom pixel-size digital exports alongside existing digital size variants. Customers can add print or pixel-size items to the basket, provide a shipping address at checkout (prints only), and complete payment via the existing gateway. Administrators manage the global catalogue of print/pixel sizes (no prices); photographers configure per-purchasable pricing via the existing purchasable management UI. + +Success is measured by: +- All 28 scenarios in the Branch & Scenario Matrix pass. +- Full `tests/Webshop/` regression suite passes unchanged. +- `php artisan test`, `make phpstan`, and `npm run check` all pass. +- UI matches mock-ups in spec (basket type selector, checkout shipping form, admin sizes page, purchasable prices form extension). + +## Scope Alignment + +**In scope:** +- DB migrations: `print_sizes`, `pixel_sizes`, `purchasable_print_sizes`, `purchasable_pixel_sizes` tables; extend `order_items` and `orders`. +- `PurchasableLicenseType::PRINT = 'print'` enum value. +- `PrintSize`, `PixelSize`, `PurchasablePrintSize`, `PurchasablePixelSize` Eloquent models. +- Admin API CRUD for global print/pixel size catalogue (no prices). +- Per-purchasable print/pixel size assignment with prices (extend `PurchasableService`, `ShopManagementController`, `UpdatePurchasablePriceRequest`). +- Basket extension: `POST /api/v2/Shop/Basket/Photo` accepts `print_size_id` / `pixel_size_id`; sets `license_type = print` automatically; snapshots dimensions. +- Customer catalogue endpoint: `GET /api/v2/Shop/Catalogue/Purchasable/{id}/Sizes`. +- Checkout extension: shipping address fields, server-side validation, stored on `Order`. +- `Order::canProcessPayment()` shipping address guard. +- `OrderResource` / `OrderItem` resource shipping address sub-object. +- Admin Vue page `PrintPixelSizesAdmin.vue` at `/admin/shop/sizes`. +- Frontend: basket item type selector (reuse existing modal, add button-group/radio); checkout `InfoSection` shipping address block (visible only when basket has prints); `PricesInput.vue` extension for print/pixel size rows. +- Translation strings for new UI elements (English + placeholder in all other locales). +- Feature tests for all new REST endpoints and scenarios. +- Unit tests for `Order::canProcessPayment()` and enum serialisation. + +**Out of scope:** +- Automatic image resizing for pixel-size fulfilment (uses existing `download_link` mechanism). +- Third-party print-on-demand integrations. +- Bulk discount or coupon codes. +- Pricing at the global catalogue level (no `price_cents` on `print_sizes` / `pixel_sizes`). + +## Dependencies & Interfaces + +**Backend:** +- `App\Enum\PurchasableLicenseType` — add `PRINT` case. +- `App\Models\Purchasable` — add `printSizes()` / `pixelSizes()` relations. +- `App\Models\OrderItem` — add new columns and snapshot logic. +- `App\Models\Order` — add shipping address columns and `canProcessPayment()` guard. +- `App\Actions\Shop\PurchasableService` — extend for print/pixel size assignment. +- `App\Actions\Shop\BasketService` — extend for print/pixel basket items. +- `App\Actions\Shop\CheckoutService` — extend for shipping address. +- `routes/api_v2_shop.php` — new routes. + +**Frontend:** +- `resources/js/components/webshop/InfoSection.vue` — add shipping address block. +- `resources/js/components/forms/shop-management/PricesInput.vue` — add print/pixel size sections. +- `resources/js/services/shop-management-service.ts` — extend for print/pixel size catalogue. +- `resources/js/services/webshop-service.ts` — extend basket/catalogue calls. +- New Vue page `resources/js/views/admin/shop/PrintPixelSizesAdmin.vue`. + +**Testing:** +- `tests/Webshop/` suite (regression — must remain unchanged). +- New test files per increment (see tasks.md). + +## Assumptions & Risks + +**Assumptions:** +- `order_items.license_type` is currently NOT NULL; adding `PRINT` to the enum is non-breaking for existing rows. +- The existing `purchasable_prices` table and relations are not modified; new join tables are separate. +- All new API routes follow the existing `support:pro` middleware group in `routes/api_v2_shop.php`. +- Paper type is a free-format string (up to 100 chars) on `print_sizes`; no predefined list. + +**Risks & Mitigations:** +- **Risk:** Basket `POST /api/v2/Shop/Basket/Photo` currently expects exactly one of `size_variant_type` / `print_size_id` / `pixel_size_id`; adding mutually-exclusive inputs may complicate validation. + **Mitigation:** Use `Rule::requiredIf` and `sometimes` with exactly-one-of validation; add dedicated FormRequest for the extended basket add. +- **Risk:** Adding `is_print` and shipping address columns to existing `order_items` and `orders` tables in production requires a zero-downtime migration. + **Mitigation:** All new columns nullable or with a default; migrations tested with rollback. +- **Risk:** `PricesInput.vue` extension adds complexity to an already-complex component. + **Mitigation:** Consider extracting print/pixel price rows into a child component `PrintSizePricesInput.vue` / `PixelSizePricesInput.vue`. + +## Implementation Drift Gate + +Before marking complete: +1. Run `php artisan test` — all existing + new tests pass. +2. Run `make phpstan` — PHPStan level 6, zero errors. +3. Run `npm run check` — TypeScript/ESLint clean. +4. Verify all 28 scenarios from the Branch & Scenario Matrix pass. +5. Manually test: basket type selector, checkout address form toggle, admin sizes page CRUD, purchasable print/pixel price assignment. + +Evidence recorded in tasks.md verification notes for each increment. + +## Increment Map + +### I1 – DB Migrations (FR-043-01, FR-043-02, FR-043-05, FR-043-06, FR-043-11, FR-043-12, FR-043-15) + +**Goal:** Create all new tables and extend existing ones. + +**Steps:** +1. Migration: `create_print_sizes_table` — `id`, `label`, `width`, `height`, `unit` (enum `cm|inch`), `paper_type` (string nullable), `is_active` (boolean default true). +2. Migration: `create_pixel_sizes_table` — `id`, `label`, `width`, `height`, `is_active` (boolean default true). +3. Migration: `create_purchasable_print_sizes_table` — `id`, `purchasable_id` (FK), `print_size_id` (FK), `price_cents` (integer). Unique on `(purchasable_id, print_size_id)`. +4. Migration: `create_purchasable_pixel_sizes_table` — `id`, `purchasable_id` (FK), `pixel_size_id` (FK), `price_cents` (integer). Unique on `(purchasable_id, pixel_size_id)`. +5. Migration: extend `order_items` — add `is_print` (boolean default false), `print_size_id` (nullable FK), `pixel_size_id` (nullable FK), `print_width` (nullable integer), `print_height` (nullable integer), `print_unit` (nullable string), `print_paper_type` (nullable string), `pixel_width` (nullable integer), `pixel_height` (nullable integer). +6. Migration: extend `orders` — add `shipping_street_name`, `shipping_street_number`, `shipping_additional_info`, `shipping_city`, `shipping_post_code`, `shipping_country` (all nullable string). +7. Verify all migrations are reversible with `php artisan migrate:rollback`. + +**Exit:** All migrations run and roll back cleanly in test environment. + +--- + +### I2 – Enum Extension (FR-043-10, DO-043-07) + +**Goal:** Add `PRINT = 'print'` to `PurchasableLicenseType`. + +**Steps:** +1. Edit `app/Enum/PurchasableLicenseType.php` — add `case PRINT = 'print';`. +2. Run `make phpstan` to verify no cast/type errors in existing code. + +**Exit:** Enum serialises `'print'`; PHPStan clean; existing tests pass. + +--- + +### I3 – Models: PrintSize, PixelSize (FR-043-01..04, DO-043-01, DO-043-02) + +**Goal:** Eloquent models for the global catalogue. + +**Steps:** +1. Create `app/Models/PrintSize.php` — fillable: `label`, `width`, `height`, `unit`, `paper_type`, `is_active`. Scope `active()`. +2. Create `app/Models/PixelSize.php` — fillable: `label`, `width`, `height`, `is_active`. Scope `active()`. +3. Add factories `database/factories/PrintSizeFactory.php` and `PixelSizeFactory.php`. + +**Exit:** Models created; PHPStan clean. + +--- + +### I4 – Models: PurchasablePrintSize, PurchasablePixelSize (FR-043-05, FR-043-06, DO-043-03, DO-043-04) + +**Goal:** Join table models for per-purchasable pricing. + +**Steps:** +1. Create `app/Models/PurchasablePrintSize.php` — `purchasable_id`, `print_size_id`, `price_cents` (MoneyCast). BelongsTo `Purchasable` and `PrintSize`. +2. Create `app/Models/PurchasablePixelSize.php` — `purchasable_id`, `pixel_size_id`, `price_cents` (MoneyCast). BelongsTo `Purchasable` and `PixelSize`. +3. Extend `app/Models/Purchasable.php` — add `printSizes()` (HasMany `PurchasablePrintSize`) and `pixelSizes()` (HasMany `PurchasablePixelSize`) relations; load them in `$with`. +4. Add factories for both models. + +**Exit:** Relations resolve; PHPStan clean. + +--- + +### I5 – Model: OrderItem & Order Extensions (DO-043-05, DO-043-06) + +**Goal:** Add new columns to `OrderItem` and `Order` models and implement snapshot logic and address guard. + +**Steps:** +1. Extend `app/Models/OrderItem.php` — add new fillable columns, casts for `print_size_id`/`pixel_size_id` FKs, BelongsTo relations to `PrintSize`/`PixelSize`. +2. Extend `app/Models/Order.php` — add shipping address fillable columns; update `canProcessPayment()` to return `false` when any item has `is_print = true` and required shipping fields are null/empty. +3. Run `make phpstan`. + +**Exit:** PHPStan clean; `canProcessPayment()` logic implemented. + +--- + +### I6 – Admin API: PrintSize & PixelSize CRUD (FR-043-01..04, FR-043-20) + +**Goal:** REST endpoints for managing the global print/pixel size catalogue. + +**Steps:** +1. Create `app/Http/Requests/ShopManagement/PrintSize/CreatePrintSizeRequest.php` and `UpdatePrintSizeRequest.php`. +2. Create `app/Http/Requests/ShopManagement/PixelSize/CreatePixelSizeRequest.php` and `UpdatePixelSizeRequest.php`. +3. Create `app/Http/Resources/Shop/PrintSizeResource.php` and `PixelSizeResource.php`. +4. Create `app/Http/Controllers/Admin/PrintSizeManagementController.php` — `index`, `store`, `update`, `destroy`. +5. Create `app/Http/Controllers/Admin/PixelSizeManagementController.php` — `index`, `store`, `update`, `destroy`. +6. Register routes in `routes/api_v2_shop.php` inside the `support:pro` group. +7. Write feature tests: `tests/Webshop/PrintSizeManagementControllerTest.php` and `PixelSizeManagementControllerTest.php` covering S-043-01..06, S-043-25..27. + +**Exit:** All CRUD tests pass; PHPStan clean. + +--- + +### I7 – Purchasable Service & Controller Extension (FR-043-05, FR-043-06) + +**Goal:** Extend purchasable create/update to accept and persist print/pixel size assignments with prices. + +**Steps:** +1. Extend `app/Actions/Shop/PurchasableService.php` — `syncPrintSizes(Purchasable, array)` and `syncPixelSizes(Purchasable, array)` methods (upsert / delete orphans). +2. Extend `app/Http/Requests/ShopManagement/PurchasablePhotoRequest.php` and `PurchasableAlbumRequest.php` — optional `print_sizes` and `pixel_sizes` arrays. +3. Extend `app/Http/Requests/ShopManagement/UpdatePurchasablePriceRequest.php` — accept `print_sizes` and `pixel_sizes`. +4. Extend `app/Http/Controllers/Admin/ShopManagementController.php` — call `syncPrintSizes`/`syncPixelSizes` in `setPhotoPurchasable`, `setAlbumPurchasable`, and `updatePurchasablePrices`. +5. Extend `app/Http/Resources/Shop/EditablePurchasableResource.php` — include `print_sizes` and `pixel_sizes` with prices. +6. Write feature tests: `tests/Webshop/Purchasables/ShopManagementPrintPixelPricingTest.php` covering S-043-07, S-043-08. + +**Exit:** Tests pass; PHPStan clean. + +--- + +### I8 – Customer Catalogue Endpoint (FR-043-17, API-043-01) + +**Goal:** Expose active print/pixel sizes with per-purchasable prices. + +**Steps:** +1. Create `app/Http/Controllers/Shop/CatalogueSizesController.php` — `index(purchasable_id)` returns active print/pixel sizes assigned to the purchasable with prices. +2. Create `app/Http/Resources/Shop/CatalogueSizeResource.php` (or reuse `PrintSizeResource`/`PixelSizeResource` extended with `price_cents`). +3. Register route `GET /api/v2/Shop/Catalogue/Purchasable/{id}/Sizes` in `routes/api_v2_shop.php`. +4. Write feature tests covering S-043-09, S-043-19, S-043-20, S-043-21. + +**Exit:** Tests pass; PHPStan clean. + +--- + +### I9 – Basket Extension (FR-043-07, FR-043-08, FR-043-09, FR-043-11, FR-043-12) + +**Goal:** Allow customers to add print/pixel-size items to the basket. + +**Steps:** +1. Create `app/Http/Requests/Shop/AddPrintSizeToBasketRequest.php` and `AddPixelSizeToBasketRequest.php` (or extend existing `AddPhotoToBasketRequest` with mutually-exclusive validation). +2. Extend `app/Actions/Shop/BasketService.php` — `addPrintItem(photo_id, print_size_id)` and `addPixelItem(photo_id, pixel_size_id)` methods; set `is_print`, `license_type = PRINT`, snapshot dimensions. +3. Extend basket controller to handle new inputs. +4. Write feature tests: `tests/Webshop/BasketControllerPrintTest.php` covering S-043-10, S-043-11, S-043-12, S-043-19, S-043-20, S-043-21. + +**Exit:** Tests pass; PHPStan clean; existing basket tests unchanged. + +--- + +### I10 – Checkout Extension (FR-043-14, FR-043-15, FR-043-19) + +**Goal:** Collect and validate shipping address; store on Order. + +**Steps:** +1. Extend `app/Http/Requests/Shop/CreateCheckoutSessionRequest.php` — add optional `shipping_*` fields; require when basket has any print item. +2. Extend `app/Actions/Shop/CheckoutService.php` — persist shipping address on `Order` when provided. +3. Write feature tests: `tests/Webshop/Checkout/CheckoutShippingAddressTest.php` covering S-043-13, S-043-14, S-043-15, S-043-16, S-043-23, S-043-24. + +**Exit:** Tests pass; PHPStan clean. + +--- + +### I11 – Order Resource Extension (FR-043-16) + +**Goal:** Include shipping address in order API response. + +**Steps:** +1. Extend `app/Http/Resources/Shop/OrderResource.php` — add `shipping_address` sub-object when any item `is_print = true`. +2. Extend `app/Http/Resources/Shop/OrderItemResource.php` — include `is_print`, print/pixel dimension snapshot fields. +3. Write feature tests: `tests/Webshop/OrderManagement/OrderResourceShippingTest.php` covering S-043-17, S-043-18. + +**Exit:** Tests pass; PHPStan clean. + +--- + +### I12 – Unit Tests (FR-043-10, FR-043-19, S-043-23, S-043-24, S-043-28) + +**Goal:** Unit-test `Order::canProcessPayment()` and enum serialisation. + +**Steps:** +1. Add test methods in a new `tests/Unit/Order/CanProcessPaymentPrintTest.php` class. +2. Add `tests/Unit/Enum/PurchasableLicenseTypeTest.php` for `PRINT` serialisation. + +**Exit:** All unit tests pass. + +--- + +### I13 – Frontend: Admin Print/Pixel Sizes Page (FR-043-18, S-043-25..27) + +**Goal:** New Vue admin page for managing the global catalogue. + +**Steps:** +1. Create `resources/js/views/admin/shop/PrintPixelSizesAdmin.vue` with separate PRINT SIZES and PIXEL SIZES sections. +2. Add service methods to `resources/js/services/shop-management-service.ts` for CRUD. +3. Register admin route `/admin/shop/sizes` in the Vue router. +4. Add menu entry in admin navigation (alongside existing shop management entries). +5. Run `npm run check`. + +**Exit:** Page renders; CRUD actions call correct API endpoints; TypeScript compiles. + +--- + +### I14 – Frontend: Basket Item Type Selector (FR-043-07, FR-043-08, FR-043-09, S-043-10..12) + +**Goal:** Extend the existing add-to-basket modal with a type selector. + +**Steps:** +1. Extend the existing add-to-basket modal/component — add a button-group/radio selector for Digital / Print / Pixel size above the current size picker. +2. When Print or Pixel size is selected, show the appropriate size dropdown (populated from `GET /api/v2/Shop/Catalogue/Purchasable/{id}/Sizes`); hide the license selector (license_type = print, sent by server). +3. When Digital is selected, retain existing size + license selector. +4. Extend `resources/js/services/webshop-service.ts` — add catalogue sizes fetch and print/pixel basket add calls. +5. Run `npm run check`. + +**Exit:** Type selector visible; correct API called per type; TypeScript compiles. + +--- + +### I15 – Frontend: Checkout Shipping Address (FR-043-14, S-043-13, S-043-14) + +**Goal:** Show shipping address block in `InfoSection` when basket contains print items. + +**Steps:** +1. Extend `resources/js/components/webshop/InfoSection.vue` — add a computed `hasPrints` from basket state; conditionally render shipping address fields. +2. Pass shipping address fields in the checkout form submission. +3. Run `npm run check`. + +**Exit:** Shipping block visible/hidden correctly; fields submitted to API; TypeScript compiles. + +--- + +### I16 – Frontend: Purchasable Print/Pixel Price Assignment (FR-043-05, FR-043-06) + +**Goal:** Extend `PricesInput.vue` (or add sub-components) for per-purchasable print/pixel size pricing. + +**Steps:** +1. Create `resources/js/components/forms/shop-management/PrintSizePricesInput.vue` — dropdown selecting from the global catalogue + price input per row. +2. Create `resources/js/components/forms/shop-management/PixelSizePricesInput.vue` — same pattern. +3. Integrate both into the existing purchasable create/edit form alongside the current `PricesInput`. +4. Run `npm run check`. + +**Exit:** Print/pixel price rows can be added, edited, removed; correct payload sent to API. + +--- + +### I17 – Translation Strings + +**Goal:** Add new UI labels and messages. + +**Steps:** +1. Add print/pixel size keys to `lang/en/webshop.php` and `lang/en/dialogs.php` as appropriate. +2. Copy English strings as placeholders to all other locale files in `lang/*/`. +3. Verify no missing keys with `npm run check`. + +**Exit:** No missing translation key errors; all locales have placeholders. + +--- + +### I18 – Final Quality Gate (NFR-043-01..06) + +**Goal:** Full quality pass before merge. + +**Steps:** +1. `vendor/bin/php-cs-fixer fix` — apply PHP formatting. +2. `php artisan test` — all tests pass including new + existing. +3. `make phpstan` — zero errors at level 6. +4. `npm run format` — apply frontend formatting. +5. `npm run check` — TypeScript/ESLint clean. +6. Verify all 28 scenarios from the scenario matrix pass. + +**Exit:** All quality gates green; feature ready for code review. + +--- + +## Scenario Tracking + +| Scenario ID | Increment(s) | Notes | +|-------------|-------------|-------| +| S-043-01 | I6 | Admin creates print size with cm + paper type | +| S-043-02 | I6 | Admin creates print size with inch, no paper type | +| S-043-03 | I6 | Admin creates pixel size | +| S-043-04 | I6 | Admin updates print size | +| S-043-05 | I6 | Admin deletes print size | +| S-043-06 | I6 | Admin disables print size | +| S-043-07 | I7 | Photographer assigns print size with price | +| S-043-08 | I7 | Photographer assigns pixel size with price | +| S-043-09 | I8 | Customer sees catalogue with per-purchasable prices | +| S-043-10 | I9, I14 | Customer adds print item | +| S-043-11 | I9, I14 | Customer adds pixel-size item | +| S-043-12 | I9, I14 | Customer adds digital item (regression) | +| S-043-13 | I10, I15 | No shipping form for digital-only basket | +| S-043-14 | I10, I15 | Shipping form shown for basket with prints | +| S-043-15 | I10 | Missing shipping fields → 422 | +| S-043-16 | I10 | Complete shipping → order created | +| S-043-17 | I11 | Order with print → shipping address shown | +| S-043-18 | I11 | Order digital-only → no shipping block | +| S-043-19 | I8, I9 | Print size not assigned to purchasable → 422 | +| S-043-20 | I8, I9 | Inactive print size → 422 | +| S-043-21 | I8, I9 | Non-existent pixel size → 404 | +| S-043-22 | I18 | Full regression suite passes | +| S-043-23 | I5, I12 | canProcessPayment false (prints + incomplete address) | +| S-043-24 | I5, I12 | canProcessPayment true (prints + complete address) | +| S-043-25 | I13 | Admin sizes page loads | +| S-043-26 | I13 | Admin adds print size via UI | +| S-043-27 | I13 | Admin toggles active | +| S-043-28 | I2, I12 | PRINT enum serialises/deserialises | + +## Analysis Gate + +**Status:** Approved. + +**Checklist:** +- [x] All requirements in spec are testable +- [x] UI mock-ups reviewed and approved +- [x] Dependencies verified available +- [x] Risk mitigations documented +- [x] Test strategy covers all scenarios +- [x] Open questions Q-043-01 through Q-043-05 resolved and captured in spec + +## Exit Criteria + +- [ ] All 18 increments complete +- [ ] All 28 scenarios from the Branch & Scenario Matrix pass +- [ ] Full `tests/Webshop/` regression suite passes unchanged +- [ ] New feature tests all pass (S-043-01..27) +- [ ] Unit tests for `canProcessPayment()` and enum serialisation pass +- [ ] Admin print/pixel sizes page renders and supports CRUD +- [ ] Basket item type selector works for digital / print / pixel items +- [ ] Checkout shipping address form shown/hidden correctly +- [ ] Per-purchasable print/pixel size pricing works in UI +- [ ] PHPStan level 6 passes +- [ ] php-cs-fixer applied +- [ ] npm run check passes + +## Follow-ups / Backlog + +- Consider adding bulk enable/disable for print sizes. +- Future: automatic pixel-size export via image processing queue (explicitly out of scope for this feature). +- Future: per-album/photo print size restrictions (explicitly out of scope). + +--- + +*Last updated: 2026-05-31* diff --git a/docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/spec.md b/docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/spec.md index 12b6f5e6c89..877406c941a 100644 --- a/docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/spec.md +++ b/docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/spec.md @@ -2,7 +2,7 @@ | Field | Value | |-------|-------| -| Status | Draft – blocked on open questions Q-043-01 … Q-043-05 | +| Status | Ready for implementation | | Last updated | 2026-05-31 | | Owners | LycheeOrg | | Linked plan | `docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/plan.md` | @@ -13,205 +13,53 @@ ## Overview -This feature extends the Lychee webshop to support **physical print orders** (at admin-configured print sizes in cm or inches) and **custom pixel-size digital exports**. The admin defines a global catalogue of supported print and pixel sizes. When a basket contains at least one print item, the checkout flow collects a shipping address. A new admin configuration page manages the print/pixel catalogue. The existing size-variant flow (MEDIUM, MEDIUM2X, ORIGINAL, FULL) is unchanged. +This feature extends the Lychee webshop beyond digital size variants (MEDIUM, MEDIUM2X, ORIGINAL, FULL) to support **physical print orders** (at photographer-configured print sizes in cm or inches, with optional paper-type) and **custom pixel-size digital exports**. The admin defines a global catalogue of available print sizes and pixel sizes (dimensions only, no price). When creating or editing a purchasable, the photographer selects which print/pixel sizes to offer and sets a price for each. When a basket contains at least one print item, the checkout step collects a shipping address. A new admin configuration page manages the global print/pixel size catalogue. A new `PurchasableLicenseType::PRINT` enum value is introduced; print and pixel-size order items carry this license type automatically. -**Affected modules:** Database (new `print_sizes` / `pixel_sizes` tables, migrations for `orders` and `order_items`), Models (`Order`, `OrderItem`, new `PrintSize` / `PixelSize`), Application services (`BasketService`, `CheckoutService`, `PurchasableService`), REST API (new management routes, extended basket/checkout endpoints), UI (`InfoSection`, new admin page, basket item type selector). - -> ⚠️ **Spec incomplete.** Five open questions (Q-043-01 through Q-043-05) must be resolved before requirements and implementation details can be finalised. See [docs/specs/4-architecture/open-questions.md](../../open-questions.md). +**Affected modules:** Database (new `print_sizes`, `pixel_sizes`, `purchasable_print_sizes`, `purchasable_pixel_sizes` tables; migrations for `orders` and `order_items`), Enum (`PurchasableLicenseType`), Models (`Order`, `OrderItem`, `Purchasable`, new `PrintSize`/`PixelSize`/`PurchasablePrintSize`/`PurchasablePixelSize`), Application services (`BasketService`, `CheckoutService`, `PurchasableService`), REST API (new management routes, extended basket/checkout/purchasable endpoints), UI (basket item type selector, shipping address form, admin print/pixel size catalogue page, extended purchasable prices form). ## Goals -- Allow customers to purchase photos as physical prints at admin-defined sizes (width × height, unit cm or inch). -- Allow customers to purchase photos at admin-defined pixel sizes (width × height in pixels). -- Retain the existing digital size-variant purchase flow with zero regression. -- Introduce `is_print` boolean on `OrderItem` to distinguish physical print items from digital items. +- Allow customers to purchase photos as physical prints at photographer-defined print sizes (width × height, unit cm or inch, optional paper type). +- Allow customers to purchase photos at photographer-defined pixel sizes (width × height in pixels). +- Retain the existing size-variant purchase flow with zero regression. +- Introduce `is_print` boolean on `OrderItem` to distinguish physical from digital items. - Collect a shipping address at checkout when the order contains at least one print item; store it on the `Order`. -- Provide a dedicated admin page to create, edit, enable/disable, and delete print and pixel sizes. +- Provide a dedicated admin page to manage the global catalogue of available print and pixel sizes (no prices — only dimensions/labels). +- Per-purchasable: photographer selects which sizes to offer and sets prices for each in the existing purchasable management UI. +- Introduce `PurchasableLicenseType::PRINT = 'print'` so print and pixel-size order items carry a distinct license type, without exposing the personal/commercial/extended dimension for these item types. ## Non-Goals -- Automatic fulfilment of print orders (manual offline step). +- Automatic fulfilment of print orders (manual offline step; system records shipping address only). - Integration with third-party print-on-demand services. -- Per-album or per-photo print/pixel size restrictions *(pending Q-043-04)*. +- Per-print-size price overrides at album or photo level beyond the per-purchasable assignment already described. - Bulk discount or coupon codes. - Currency conversion or multi-currency support. ## Functional Requirements -> ⚠️ Requirements table will be authored once Q-043-01 … Q-043-05 are resolved. - -## Non-Functional Requirements - -> ⚠️ NFR table will be authored once Q-043-01 … Q-043-05 are resolved. - -## UI / Interaction Mock-ups - -> ⚠️ ASCII mock-ups will be finalised once Q-043-01 … Q-043-05 are resolved. - -### Basket Item Type Selector (customer-facing, draft) - -``` -┌──────────────────────────────────────────────────────────┐ -│ Add to Basket: "Sunset over the Alps" │ -├──────────────────────────────────────────────────────────┤ -│ Type: ○ Digital file ○ Print ○ Pixel size │ -│ │ -│ [If "Digital file" selected] │ -│ Size: [MEDIUM ▼] License: [Personal ▼] │ -│ │ -│ [If "Print" selected] │ -│ Print size: [20×30 cm – €25.00 ▼] │ -│ License: [Personal ▼] ← presence depends on Q-043-02 │ -│ │ -│ [If "Pixel size" selected] │ -│ Pixel size: [3000×2000 px – €12.00 ▼] │ -│ License: [Personal ▼] ← presence depends on Q-043-02 │ -│ │ -│ [ Add to basket ] │ -└──────────────────────────────────────────────────────────┘ -``` - -### Checkout – Shipping Address Step (draft) - -``` -┌──────────────────────────────────────────────────────────┐ -│ Your info │ -├──────────────────────────────────────────────────────────┤ -│ Email: _______________________ * │ -│ │ -│ ── Shipping address (required for print orders) ── │ -│ Street name: _______________________ * │ -│ Street number: ________ │ -│ Additional: _______________________ │ -│ City: _______________________ * │ -│ Post code: ______________ * │ -│ Country: [Select country ▼] * │ -│ │ -│ ☐ I agree to the Terms and Privacy Policy │ -│ [ Next → ] │ -└──────────────────────────────────────────────────────────┘ -``` - -### Admin Print/Pixel Sizes Page (`/admin/shop/sizes`, draft) - -``` -┌──────────────────────────────────────────────────────────┐ -│ Shop › Print & Pixel Sizes │ -├──────────────────────────────────────────────────────────┤ -│ [ + Add Print Size ] [ + Add Pixel Size ] │ -│ │ -│ PRINT SIZES │ -│ ┌──────────────────────────────────────────────────┐ │ -│ │ Label │ W × H │ Unit │ Price │ Active │ │ -│ │ Small │ 10 × 15 │ cm │ €5.00 │ ✓ [Edit] │ │ -│ │ Standard │ 20 × 30 │ cm │ €25.00 │ ✓ [Edit] │ │ -│ │ US Letter │ 8 × 10 │ inch │ €20.00 │ ✗ [Edit] │ │ -│ └──────────────────────────────────────────────────┘ │ -│ │ -│ PIXEL SIZES │ -│ ┌──────────────────────────────────────────────────┐ │ -│ │ Label │ W × H │ Price │ Active │ │ -│ │ Web 1080p │ 1920 × 1080 px │ €8.00 │ ✓ [Edit]│ │ -│ │ Print-ready │ 3000 × 2000 px │ €12.00 │ ✓ [Edit]│ │ -│ └──────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────┘ -``` - -## Branch & Scenario Matrix - -> ⚠️ Scenario IDs will be assigned once requirements are finalised. - -## Test Strategy - -- **Unit:** `Order::canProcessPayment()` with print items and complete/incomplete shipping address. -- **Feature (REST):** Print/pixel size management CRUD; basket add for print/pixel items; checkout shipping address validation. -- **Regression:** Full `tests/Webshop/` suite must pass unchanged. -- **UI:** Admin size catalogue CRUD; basket item type selector; checkout address form visibility toggle. - -## Interface & Contract Catalogue - -> ⚠️ Will be authored once open questions are resolved. Draft API routes: -> - `GET/POST/PUT/DELETE /api/v2/Shop/Management/PrintSize` -> - `GET/POST/PUT/DELETE /api/v2/Shop/Management/PixelSize` -> - `GET /api/v2/Shop/Catalogue/Sizes` (customer-facing, active sizes only) -> - `POST /api/v2/Shop/Basket/Photo` (extended to accept `print_size_id` / `pixel_size_id`) -> - `POST /api/v2/Shop/Checkout/Create-session` (extended to accept shipping address) - -## Spec DSL - -```yaml -# Incomplete — pending open-question resolution -domain_objects: - - id: DO-043-01 - name: PrintSize - fields: [id, label, width, height, unit (cm|inch), price_cents, is_active] - - id: DO-043-02 - name: PixelSize - fields: [id, label, width, height (px), price_cents, is_active] - - id: DO-043-03 - name: OrderItem (extensions) - fields: [is_print, print_size_id, pixel_size_id, print_width, print_height, print_unit, pixel_width, pixel_height] - - id: DO-043-04 - name: Order (extensions) - fields: [shipping_street_name, shipping_street_number, shipping_additional_info, shipping_city, shipping_post_code, shipping_country] -``` - - -| Field | Value | -|-------|-------| -| Status | Planning | -| Last updated | 2026-05-31 | -| Owners | LycheeOrg | -| Linked plan | `docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/plan.md` | -| Linked tasks | `docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/tasks.md` | -| Roadmap entry | #043 | - -> Guardrail: This specification is the single normative source of truth for the feature. Track high- and medium-impact questions in [docs/specs/4-architecture/open-questions.md](../../open-questions.md), encode resolved answers directly in the Requirements/NFR/Behaviour/UI/Telemetry sections below (no per-feature `## Clarifications` sections), and use ADRs under `docs/specs/5-decisions/` for architecturally significant clarifications (referencing their IDs from the relevant spec sections). - -## Overview - -This feature extends the Lychee webshop beyond digital size variants (MEDIUM, MEDIUM2X, ORIGINAL, FULL) to support **physical print orders** (at configurable print sizes in cm or inches) and **custom pixel-size exports**. The admin can define a global catalogue of supported print sizes and pixel sizes; these become purchasable options alongside the existing size-variant flow. When a basket contains at least one print item the checkout step collects a shipping address. A new admin configuration page manages the print/pixel catalogue. - -**Affected modules:** Database (new tables, orders migration), Models (`OrderItem`, `Order`, new `PrintSize`/`PixelSize`), Enums (`PurchasableSizeVariantType`), Application services (`BasketService`, `CheckoutService`), REST API (new management routes, extended basket/checkout endpoints), UI (basket item type selector, shipping address form, new admin config page). - -## Goals - -- Allow customers to purchase photos as physical prints at photographer-defined print sizes (width × height, unit cm or inch). -- Allow customers to purchase photos at photographer-defined pixel sizes (width × height in pixels). -- Retain the existing size-variant flow unchanged so no regression for digital purchases. -- Introduce a boolean `is_print` flag on `OrderItem` that correctly differentiates physical print orders from digital orders. -- Collect a shipping address (street name, street number, additional info, city, post code, country) at checkout whenever the order contains at least one print item. -- Provide a dedicated admin page to manage the global catalogue of supported print sizes and pixel sizes (enable, disable, add, remove, reorder). -- Store the shipping address on the `Order` so it can be viewed in the order management screen and used for dispatch. - -## Non-Goals - -- Automatic fulfilment of print orders (this remains a manual offline step for the photographer; the system notifies and records the shipping address only). -- Integration with third-party print-on-demand services. -- Per-album or per-photo print size restrictions (all active print/pixel sizes are available on every purchasable item). -- Pricing per-size-variant override for print/pixel sizes at the album or photo level (print/pixel sizes use only the global catalogue price defined by the admin). -- Currency conversion or multi-currency support (existing currency config applies). -- Bulk discount or coupon codes. - -## Functional Requirements - | ID | Requirement | Success path | Validation path | Failure path | Telemetry & traces | Source | |----|-------------|--------------|-----------------|--------------|--------------------|--------| -| FR-043-01 | Admin can create print sizes in the print/pixel catalogue | Admin POSTs `{ type: "print", width, height, unit: "cm"\|"inch", label, price_cents, is_active }` to `POST /api/v2/Shop/Management/PrintSize`. Record persisted in `print_sizes` table. | Width and height are positive integers; unit is `cm` or `inch`; label is ≤ 100 chars; price_cents ≥ 0. | 422 with field-level errors when validation fails. | No telemetry. | User requirement | -| FR-043-02 | Admin can create pixel sizes in the print/pixel catalogue | Admin POSTs `{ type: "pixel", width, height, label, price_cents, is_active }` to `POST /api/v2/Shop/Management/PixelSize`. Record persisted in `pixel_sizes` table. | Width and height are positive integers; label is ≤ 100 chars; price_cents ≥ 0. | 422 with field-level errors when validation fails. | No telemetry. | User requirement | -| FR-043-03 | Admin can update and delete print/pixel sizes | Admin PUTs to `PUT /api/v2/Shop/Management/PrintSize/{id}` or `PUT /api/v2/Shop/Management/PixelSize/{id}` to update; DELETEs to remove. Soft-delete not required (hard delete acceptable; existing order items snapshot label and dimensions). | Record must exist and belong to a valid admin session. | 404 if not found; 403 if unauthorised. | No telemetry. | User requirement | -| FR-043-04 | Admin can enable or disable individual print/pixel sizes | `is_active` boolean on each record controls whether the size appears in the customer-facing catalogue. Inactive sizes are still stored for historical order record accuracy. | Only active sizes returned from `GET /api/v2/Shop/Catalogue/Sizes`. | 422 if `is_active` missing on update. | No telemetry. | User requirement | -| FR-043-05 | Customer can add a print-size order item to basket | `POST /api/v2/Shop/Basket/Photo` accepts `{ photo_id, print_size_id, license_type }` (alongside existing `size_variant_type`). Basket service creates an `OrderItem` with `is_print = true`, `print_size_id` set, `pixel_size_id = null`, `size_variant_type = null`, `size_variant_id = null`. | `print_size_id` must reference an active print size; `photo_id` must reference a purchasable photo. Price is taken from `print_sizes.price_cents`. | 422 when `print_size_id` is inactive or missing; 404 when photo not found/purchasable. | No telemetry. | User requirement | -| FR-043-06 | Customer can add a pixel-size order item to basket | `POST /api/v2/Shop/Basket/Photo` accepts `{ photo_id, pixel_size_id, license_type }`. Basket service creates an `OrderItem` with `is_print = false`, `pixel_size_id` set, `print_size_id = null`, `size_variant_type = null`. | `pixel_size_id` must reference an active pixel size. | 422 when `pixel_size_id` inactive or missing. | No telemetry. | User requirement | -| FR-043-07 | Existing size-variant basket flow is unchanged | `POST /api/v2/Shop/Basket/Photo` with `size_variant_type` set (no `print_size_id`/`pixel_size_id`) follows the current code path; `is_print = false`, both new FK columns `null`. | Regression tests for existing digital purchases all pass. | No regression on existing API contract. | No telemetry. | Backward compat | -| FR-043-08 | `is_print` boolean on `OrderItem` distinguishes physical from digital | `is_print = true` means the item requires physical fulfilment. Digital size-variant and pixel-size items have `is_print = false`. | Migration adds `is_print BOOLEAN NOT NULL DEFAULT FALSE`. | Any item without explicit print intent defaults to false. | No telemetry. | User requirement | -| FR-043-09 | Order snapshot captures print/pixel size dimensions at purchase time | `OrderItem` stores `print_width`, `print_height`, `print_unit`, `pixel_width`, `pixel_height` at the moment the item is added to the basket. Changes to the catalogue after purchase do not affect historical records. | Values match the catalogue entry at basket-add time. Nullable for non-print/non-pixel items. | No action if item is a standard size variant. | No telemetry. | Data integrity | -| FR-043-10 | Checkout collects shipping address when basket contains prints | If `Order.items` has any item where `is_print = true`, the checkout `InfoSection` step renders shipping address fields (street name, street number, additional info, city, post code, country). | All required fields (street name, city, post code, country) must be non-empty. | 422 from `POST /api/v2/Shop/Checkout/Create-session` when required fields absent and basket has prints. | No telemetry. | User requirement | -| FR-043-11 | Shipping address is stored on the `Order` | `orders` table gains columns: `shipping_street_name`, `shipping_street_number`, `shipping_additional_info`, `shipping_city`, `shipping_post_code`, `shipping_country` (all `string\|null`). Set to non-null values only for orders containing print items. | Columns present in migration; populated by `CheckoutService` before payment initiation. | Not populated for digital-only orders (all null). | No telemetry. | User requirement | -| FR-043-12 | Order management screen shows shipping address for print orders | `GET /api/v2/Shop/Order/{id}` response includes `shipping_address` sub-object when any item `is_print = true`. | Shipping fields returned in `OrderResource` when non-null. | Shipping address block hidden in UI for digital-only orders. | No telemetry. | User requirement | -| FR-043-13 | Customer-facing catalogue exposes active print/pixel sizes | `GET /api/v2/Shop/Catalogue/Sizes` returns `{ print_sizes: [...], pixel_sizes: [...] }` of active entries. Used by frontend to populate size selectors. | Only `is_active = true` records returned. | Empty arrays when no active sizes. | No telemetry. | User requirement | -| FR-043-14 | Admin management page lists, creates, updates, deletes print/pixel sizes | New admin Vue page `PrintPixelSizesAdmin.vue` at route `/admin/shop/sizes`. Supports full CRUD via the management API endpoints. | Admin-only route (redirects non-admins to home). | Error toast on API failure. | No telemetry. | User requirement | -| FR-043-15 | `canProcessPayment()` on Order requires shipping address when prints present | `Order::canProcessPayment()` returns `false` if any item is `is_print = true` and any required shipping field is null/empty. | Unit test covers both paths. | Payment initiation blocked; UI shows validation message. | No telemetry. | Data integrity | +| FR-043-01 | Admin can create print sizes in the global catalogue | Admin POSTs `{ label, width, height, unit: "cm"\|"inch", paper_type: string\|null, is_active }` to `POST /api/v2/Shop/Management/PrintSize`. Record persisted in `print_sizes` table. | Width and height are positive integers; unit is `cm` or `inch`; label ≤ 100 chars; paper_type ≤ 100 chars if provided. No price stored here. | 422 with field-level errors when validation fails. | None. | Q-043-01 B, Q-043-04 B, additional requirement | +| FR-043-02 | Admin can create pixel sizes in the global catalogue | Admin POSTs `{ label, width, height, is_active }` to `POST /api/v2/Shop/Management/PixelSize`. Record persisted in `pixel_sizes` table. | Width and height are positive integers; label ≤ 100 chars. No price stored here. | 422 with field-level errors when validation fails. | None. | Q-043-01 B, Q-043-04 B | +| FR-043-03 | Admin can update and delete print/pixel sizes | Admin PUTs to `PUT /api/v2/Shop/Management/PrintSize/{id}` / `PixelSize/{id}` to update fields; DELETE to remove. Existing `purchasable_print_sizes` / `purchasable_pixel_sizes` rows and order item snapshots are preserved when a catalogue entry is deleted. | Record must exist; admin session required. | 404 if not found; 403 if unauthorised. | None. | User requirement | +| FR-043-04 | Admin can enable or disable individual print/pixel sizes | `is_active` boolean controls customer-facing visibility. Inactive sizes are excluded from `GET /api/v2/Shop/Catalogue/Sizes`. | `is_active` required on update. | 422 if missing. | None. | User requirement | +| FR-043-05 | Photographer assigns print sizes to a purchasable with per-size prices | When creating or updating a purchasable, the photographer includes `{ print_sizes: [{ print_size_id, price_cents }] }` in the request. Each entry is persisted in `purchasable_print_sizes`. | Each `print_size_id` must reference an existing print size; `price_cents` ≥ 0; no duplicate `print_size_id` within one purchasable. | 422 on validation error. | None. | Q-043-01 B, Q-043-04 B | +| FR-043-06 | Photographer assigns pixel sizes to a purchasable with per-size prices | When creating or updating a purchasable, the photographer includes `{ pixel_sizes: [{ pixel_size_id, price_cents }] }` in the request. Each entry is persisted in `purchasable_pixel_sizes`. | Each `pixel_size_id` must reference an existing pixel size; `price_cents` ≥ 0; no duplicates. | 422 on validation error. | None. | Q-043-01 B, Q-043-04 B | +| FR-043-07 | Customer can add a print-size order item to the basket | `POST /api/v2/Shop/Basket/Photo` accepts `{ photo_id, print_size_id }`. Basket service creates an `OrderItem` with `is_print = true`, `print_size_id` set, `pixel_size_id = null`, `size_variant_type = null`, `license_type = print`. Price resolved from `purchasable_print_sizes` for the photo's purchasable. Snapshot columns (`print_width`, `print_height`, `print_unit`, `print_paper_type`) populated from the catalogue entry. | `print_size_id` must reference a `purchasable_print_sizes` row on the photo's purchasable; photo must be purchasable. | 422 when `print_size_id` not assigned to this purchasable or not active; 404 when photo not found or not purchasable. | None. | Q-043-01 B, Q-043-02 | +| FR-043-08 | Customer can add a pixel-size order item to the basket | `POST /api/v2/Shop/Basket/Photo` accepts `{ photo_id, pixel_size_id }`. Basket service creates an `OrderItem` with `is_print = false`, `pixel_size_id` set, `print_size_id = null`, `size_variant_type = null`, `license_type = print`. Price resolved from `purchasable_pixel_sizes` for the photo's purchasable. Snapshot columns (`pixel_width`, `pixel_height`) populated. | `pixel_size_id` must reference a `purchasable_pixel_sizes` row on the photo's purchasable. | 422 when not assigned or inactive; 404 when photo not found. | None. | Q-043-01 B, Q-043-02 | +| FR-043-09 | Existing size-variant basket flow is unchanged | `POST /api/v2/Shop/Basket/Photo` with `size_variant_type` (no `print_size_id`/`pixel_size_id`) follows the current code path. `is_print = false`, new FK columns `null`. | Regression tests for existing digital purchases pass. | No regression on existing API contract. | None. | Backward compat | +| FR-043-10 | `PurchasableLicenseType::PRINT` enum value is introduced | A new case `PRINT = 'print'` is added to `App\Enum\PurchasableLicenseType`. Print and pixel-size order items carry this value on their `license_type` column. The personal/commercial/extended choices are not offered for these item types. | Enum serialises and deserialises `'print'` without error. | Existing digital items unaffected. | None. | Q-043-02 | +| FR-043-11 | `is_print` boolean on `OrderItem` distinguishes physical from digital | `is_print = true` means the item requires physical fulfilment. Digital size-variant and pixel-size items have `is_print = false`. | Migration adds `is_print BOOLEAN NOT NULL DEFAULT FALSE`. | Any item without explicit print intent defaults to `false`. | None. | User requirement | +| FR-043-12 | Order snapshot captures print/pixel size dimensions at basket-add time | `OrderItem` stores `print_width`, `print_height`, `print_unit`, `print_paper_type`, `pixel_width`, `pixel_height` at basket-add time. Changes to the catalogue after purchase do not affect historical records. | Values match catalogue entry at add time. Nullable for non-print/non-pixel items. | No action if item is a standard size variant. | None. | Data integrity | +| FR-043-13 | Pixel-size fulfilment follows the existing `download_link` mechanism | A pixel-size `OrderItem` has `size_variant_type = null`, `is_print = false`. Fulfilment awaits a `download_link` set by the photographer (same admin action as FULL size). `FulfillOrders` task skips these until the link is set. | Existing fulfilment infrastructure applies unchanged. | None. | Q-043-03 A | +| FR-043-14 | Checkout collects shipping address when basket contains print items | If the order has any item where `is_print = true`, the checkout `InfoSection` step renders shipping address fields. | Required fields (street name, city, post code, country) must be non-empty. | 422 from `POST /api/v2/Shop/Checkout/Create-session` when required fields absent and basket has prints. | None. | User requirement | +| FR-043-15 | Shipping address is stored on the `Order` | `orders` table gains columns: `shipping_street_name`, `shipping_street_number`, `shipping_additional_info`, `shipping_city`, `shipping_post_code`, `shipping_country` (all `string\|null`). | Populated by `CheckoutService` before payment initiation. | All null for digital-only orders. | None. | User requirement | +| FR-043-16 | Order management screen shows shipping address for print orders | `GET /api/v2/Shop/Order/{id}` response includes `shipping_address` sub-object when any item is `is_print = true`. | Shipping fields in `OrderResource` when non-null. | Block hidden in UI for digital-only orders. | None. | User requirement | +| FR-043-17 | Customer-facing catalogue returns active print/pixel sizes with per-purchasable prices | `GET /api/v2/Shop/Catalogue/Purchasable/{id}/Sizes` returns `{ print_sizes: [...], pixel_sizes: [...] }` of active entries that are assigned to the given purchasable, including each entry's price for that purchasable. | Only active sizes assigned to the purchasable returned. | Empty arrays when none assigned or active. | None. | Q-043-01 B | +| FR-043-18 | Admin management page lists, creates, updates, deletes print/pixel sizes | New admin Vue page `PrintPixelSizesAdmin.vue` at route `/admin/shop/sizes`. Supports full CRUD for the global catalogue via management endpoints. No prices shown here. | Admin-only route. | Error toast on API failure. | None. | User requirement | +| FR-043-19 | `canProcessPayment()` on Order requires shipping address when prints present | `Order::canProcessPayment()` returns `false` if any item is `is_print = true` and any required shipping field is null/empty. | Unit test covers both paths. | Payment initiation blocked; UI shows validation message. | None. | Data integrity | +| FR-043-20 | New routes are behind `support:pro` middleware | All new API routes (`/api/v2/Shop/Management/PrintSize`, `/PixelSize`, `/Catalogue/Purchasable/{id}/Sizes`) sit inside the existing `support:pro` middleware group. | No new public endpoints. | 403 for non-pro installations. | None. | Q-043-05 A | ## Non-Functional Requirements @@ -223,38 +71,38 @@ This feature extends the Lychee webshop beyond digital size variants (MEDIUM, ME | NFR-043-04 | API validation returns field-level 422 errors | User experience | Each invalid field name listed in `errors` response body. | FormRequest classes | API standard | | NFR-043-05 | Shipping address fields are validated server-side | Security & data integrity | Required fields enforced by FormRequest; no raw HTML injection. | FormRequest | Security standard | | NFR-043-06 | Existing `OrderItem` MoneyCast and relations preserved | Data integrity | No changes to existing `price_cents` cast or size-variant relations. | MoneyCast, BelongsTo | Backward compat | -| NFR-043-07 | Print/pixel catalogue queries are paginated for large catalogues | Performance | `GET /api/v2/Shop/Management/PrintSize` and `PixelSize` support optional `?page=` param. | Laravel pagination | Performance standard | ## UI / Interaction Mock-ups ### 1. Basket Item Type Selector (customer-facing) -When adding a photo to basket, a size type radio group precedes the size selector: +Reuses the existing add-to-basket modal; a radio/button group selects the item type before revealing the relevant size picker. For print and pixel-size items the license type is fixed to `print` (not displayed to the customer). ``` ┌──────────────────────────────────────────────────────────┐ │ Add to Basket: "Sunset over the Alps" │ ├──────────────────────────────────────────────────────────┤ -│ Type: ○ Digital file ○ Print ○ Pixel size │ +│ │ +│ Type: [ Digital file ] [ Print ] [ Pixel size ] │ +│ ↑ button group / radio, reuses modal │ │ │ │ [If "Digital file" selected] │ -│ Size: [MEDIUM ▼] License: [Personal ▼] │ +│ Size: [MEDIUM ▼] │ +│ License: [Personal ▼] │ │ │ │ [If "Print" selected] │ -│ Print size: [20×30 cm – €25.00 ▼] │ -│ License: [Personal ▼] │ +│ Print size: [20×30 cm – Glossy – €25.00 ▼] │ +│ (license_type = print, set by server automatically) │ │ │ │ [If "Pixel size" selected] │ -│ Pixel size: [3000×2000 px – €12.00 ▼] │ -│ License: [Personal ▼] │ +│ Pixel size: [3000×2000 px – €12.00 ▼] │ +│ (license_type = print, set by server automatically) │ │ │ │ [ Add to basket ] │ └──────────────────────────────────────────────────────────┘ ``` -### 2. Checkout – Shipping Address Step (shown when basket contains prints) - -Shipping address fields are injected below the email/consent block in the `InfoSection` component: +### 2. Checkout – Shipping Address Step (shown only when basket contains print items) ``` ┌──────────────────────────────────────────────────────────┐ @@ -277,6 +125,8 @@ Shipping address fields are injected below the email/consent block in the `InfoS ### 3. Admin Print/Pixel Sizes Page (`/admin/shop/sizes`) +No prices are stored here. Prices are set per-purchasable in the purchasable management UI. + ``` ┌──────────────────────────────────────────────────────────┐ │ Shop › Print & Pixel Sizes │ @@ -284,38 +134,61 @@ Shipping address fields are injected below the email/consent block in the `InfoS │ [ + Add Print Size ] [ + Add Pixel Size ] │ │ │ │ PRINT SIZES │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ Label │ W × H │ Unit │ Price │ Active│ │ -│ │ Small print │ 10 × 15 │ cm │ €olean5.00│ ✓ │ │ -│ │ Standard print │ 20 × 30 │ cm │ €25.00 │ ✓ │ │ -│ │ Large print │ 40 × 60 │ cm │ €45.00 │ ✓ │ │ -│ │ US Letter │ 8 × 10 │ inch │ €20.00 │ ✗ │ │ -│ └─────────────────────────────────────────────────────┘ │ +│ ┌───────────────────────────────────────────────────┐ │ +│ │ Label │ W × H │ Unit │ Paper type │ Act │ │ +│ │ Small print │ 10 × 15 │ cm │ Glossy │ ✓ │ │ +│ │ Standard print │ 20 × 30 │ cm │ Matte │ ✓ │ │ +│ │ US Letter │ 8 × 10 │ inch │ (none) │ ✗ │ │ +│ └───────────────────────────────────────────────────┘ │ │ [Edit] [Delete] per row │ │ │ │ PIXEL SIZES │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ Label │ W × H │ Price │ Active │ │ -│ │ Web (1080p) │ 1920 × 1080 px │ €olean8.00 │ ✓ │ │ -│ │ Print-ready │ 3000 × 2000 px │ €12.00 │ ✓ │ │ -│ └─────────────────────────────────────────────────────┘ │ +│ ┌───────────────────────────────────────────────────┐ │ +│ │ Label │ W × H │ Active │ │ +│ │ Web (1080p) │ 1920 × 1080 px │ ✓ │ │ +│ │ Print-ready │ 3000 × 2000 px │ ✓ │ │ +│ └───────────────────────────────────────────────────┘ │ +│ [Edit] [Delete] per row │ +└──────────────────────────────────────────────────────────┘ +``` + +### 4. Purchasable Management – Print/Pixel Size Pricing + +Extends the existing purchasable prices form (`PricesInput.vue`) with a separate section for selecting and pricing the available print and pixel sizes for that purchasable. + +``` +┌──────────────────────────────────────────────────────────┐ +│ Edit Purchasable: "Sunset over the Alps" │ +├──────────────────────────────────────────────────────────┤ +│ … existing digital size prices … │ +│ │ +│ PRINT SIZES (select from global catalogue) │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ [Small print – 10×15 cm Glossy ▼] Price: €__ │ │ +│ │ [Standard print – 20×30 cm Matte ▼] Price: €_│ │ +│ │ [ + Add print size ] │ │ +│ └──────────────────────────────────────────────┘ │ +│ │ +│ PIXEL SIZES (select from global catalogue) │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ [Web 1080p – 1920×1080 px ▼] Price: €___ │ │ +│ │ [ + Add pixel size ] │ │ +│ └──────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────┘ ``` -### 4. Order Management – Shipping Address Panel +### 5. Order Management – Shipping Address Panel ``` ┌──────────────────────────────────────────────────────────┐ │ Order #1042 [COMPLETED] 2026-05-20 │ ├──────────────────────────────────────────────────────────┤ │ Items: │ -│ • "Sunset Alps" – 20×30 cm print – Personal – €25.00 [PRINT]│ -│ • "City Night" – MEDIUM digital – Personal – €5.00 │ +│ • "Sunset Alps" – 20×30 cm Matte print – €25.00 [PRINT]│ +│ • "City Night" – MEDIUM digital – Personal – €5.00 │ │ │ │ Shipping address: │ -│ Jane Doe │ -│ Hauptstraße 42 │ -│ Apt 3B │ +│ Hauptstraße 42, Apt 3B │ │ Berlin, 10115, Germany │ └──────────────────────────────────────────────────────────┘ ``` @@ -324,36 +197,41 @@ Shipping address fields are injected below the email/consent block in the `InfoS | Scenario ID | Description / Expected outcome | |-------------|--------------------------------| -| S-043-01 | Admin creates a print size with valid cm dimensions → persisted, returned in catalogue | -| S-043-02 | Admin creates a print size with unit "inch" → persisted with correct unit | +| S-043-01 | Admin creates a print size with valid cm dimensions and paper type → persisted, returned in management list | +| S-043-02 | Admin creates a print size with unit "inch" and no paper type → persisted with `paper_type = null` | | S-043-03 | Admin creates a pixel size with valid px dimensions → persisted | -| S-043-04 | Admin updates a print size label and price → changes reflected in catalogue | -| S-043-05 | Admin deletes a print size → removed from catalogue; existing order items retain snapshotted data | +| S-043-04 | Admin updates a print size label and paper type → changes reflected in catalogue | +| S-043-05 | Admin deletes a print size → removed; existing order items retain snapshotted data; purchasable assignments cleaned up or orphaned gracefully | | S-043-06 | Admin disables a print size → not returned in customer catalogue | -| S-043-07 | Customer adds a print item to basket → `is_print = true`, dimensions snapshotted | -| S-043-08 | Customer adds a pixel-size item to basket → `is_print = false`, pixel dims snapshotted | -| S-043-09 | Customer adds a digital size-variant item → existing flow, `is_print = false`, new columns null | -| S-043-10 | Customer proceeds to checkout with only digital items → shipping address fields not shown | -| S-043-11 | Customer proceeds to checkout with at least one print item → shipping address fields shown and required | -| S-043-12 | Customer submits checkout without required shipping fields when basket has prints → 422 from API | -| S-043-13 | Customer submits checkout with all required shipping fields → order created with shipping address | -| S-043-14 | Admin views order containing print item → shipping address displayed | -| S-043-15 | Admin views order containing only digital items → shipping address block hidden | -| S-043-16 | Customer tries to add an inactive print size → 422 error | -| S-043-17 | Customer tries to add a non-existent pixel size → 404 error | -| S-043-18 | Existing digital-purchase test suite passes without modification | -| S-043-19 | `Order::canProcessPayment()` returns false when prints present and shipping address incomplete | -| S-043-20 | `Order::canProcessPayment()` returns true when prints present and shipping address complete | -| S-043-21 | Admin page loads print and pixel size catalogue | -| S-043-22 | Admin adds a print size via the UI form → appears in list | -| S-043-23 | Admin toggles active/inactive status via the UI → API updated | +| S-043-07 | Photographer assigns a print size to a purchasable with price → `purchasable_print_sizes` row created | +| S-043-08 | Photographer assigns a pixel size to a purchasable with price → `purchasable_pixel_sizes` row created | +| S-043-09 | Customer views catalogue for a purchasable → only active, assigned print/pixel sizes returned with prices | +| S-043-10 | Customer adds a print item to basket → `is_print = true`, `license_type = 'print'`, dimensions snapshotted | +| S-043-11 | Customer adds a pixel-size item to basket → `is_print = false`, `license_type = 'print'`, pixel dims snapshotted | +| S-043-12 | Customer adds a digital size-variant item → existing flow, `is_print = false`, new columns null | +| S-043-13 | Customer proceeds to checkout with only digital items → shipping address fields NOT shown | +| S-043-14 | Customer proceeds to checkout with at least one print item → shipping address fields shown and required | +| S-043-15 | Customer submits checkout without required shipping fields when basket has prints → 422 from API | +| S-043-16 | Customer submits checkout with all required shipping fields → order created with shipping address stored | +| S-043-17 | Admin views order containing a print item → shipping address displayed | +| S-043-18 | Admin views order containing only digital items → shipping address block hidden | +| S-043-19 | Customer tries to add a print size not assigned to that purchasable → 422 error | +| S-043-20 | Customer tries to add an inactive print size → 422 error | +| S-043-21 | Customer tries to add a non-existent pixel size → 404 error | +| S-043-22 | Existing digital-purchase test suite passes without modification | +| S-043-23 | `Order::canProcessPayment()` returns `false` when prints present and shipping address incomplete | +| S-043-24 | `Order::canProcessPayment()` returns `true` when prints present and shipping address complete | +| S-043-25 | Admin page loads print and pixel size catalogue with no prices | +| S-043-26 | Admin adds a print size via the UI form → appears in list | +| S-043-27 | Admin toggles active/inactive status → API updated, customer catalogue reflects change | +| S-043-28 | `PurchasableLicenseType::PRINT` serialises and deserialises correctly | ## Test Strategy -- **Unit:** `Order::canProcessPayment()` with print items and complete/incomplete shipping address. `OrderItem` snapshot attributes. `PrintSizeService` / `PixelSizeService` create/update/delete. -- **Feature (REST):** `PrintSizeManagementControllerTest`, `PixelSizeManagementControllerTest`, `BasketControllerPrintTest` (S-043-07..09), `CheckoutShippingAddressTest` (S-043-10..13, S-043-19..20), `OrderResourceShippingTest` (S-043-14..15). -- **Regression:** Run existing `tests/Webshop/` suite unchanged (S-043-18). -- **UI (manual/Playwright):** Admin size catalogue CRUD, basket item type selector, checkout address form visibility toggle. +- **Unit:** `Order::canProcessPayment()` with print items and complete/incomplete shipping address (S-043-23, S-043-24). `OrderItem` snapshot attributes. `PrintSizeService` / `PixelSizeService` create/update/delete. `PurchasableLicenseType::PRINT` enum serialisation (S-043-28). +- **Feature (REST):** `PrintSizeManagementControllerTest` (S-043-01..06), `PixelSizeManagementControllerTest` (S-043-03, S-043-06), `PurchasablePrintPixelPricingTest` (S-043-07..09), `BasketControllerPrintTest` (S-043-10..12, S-043-19..21), `CheckoutShippingAddressTest` (S-043-13..16, S-043-23..24), `OrderResourceShippingTest` (S-043-17..18). +- **Regression:** Run existing `tests/Webshop/` suite unchanged (S-043-22). +- **UI (manual/Playwright):** Admin size catalogue CRUD (S-043-25..27); basket item type selector (S-043-10..12); checkout address form visibility toggle (S-043-13..14); purchasable print/pixel price assignment (S-043-07..08). ## Interface & Contract Catalogue @@ -361,26 +239,31 @@ Shipping address fields are injected below the email/consent block in the `InfoS | ID | Description | Modules | |----|-------------|---------| -| DO-043-01 | `PrintSize`: id, label, width, height, unit (cm\|inch), price_cents, is_active | DB, Model, API | -| DO-043-02 | `PixelSize`: id, label, width, height (px), price_cents, is_active | DB, Model, API | -| DO-043-03 | `OrderItem` extensions: `is_print`, `print_size_id`, `pixel_size_id`, `print_width`, `print_height`, `print_unit`, `pixel_width`, `pixel_height` | DB, Model | -| DO-043-04 | `Order` shipping address fields: `shipping_street_name`, `shipping_street_number`, `shipping_additional_info`, `shipping_city`, `shipping_post_code`, `shipping_country` | DB, Model | +| DO-043-01 | `PrintSize`: id, label, width, height, unit (`cm`\|`inch`), paper_type (`string\|null`), is_active | DB, Model, API | +| DO-043-02 | `PixelSize`: id, label, width, height (px), is_active | DB, Model, API | +| DO-043-03 | `PurchasablePrintSize`: id, purchasable_id, print_size_id, price_cents | DB, Model, API | +| DO-043-04 | `PurchasablePixelSize`: id, purchasable_id, pixel_size_id, price_cents | DB, Model, API | +| DO-043-05 | `OrderItem` extensions: `is_print`, `print_size_id`, `pixel_size_id`, `print_width`, `print_height`, `print_unit`, `print_paper_type`, `pixel_width`, `pixel_height` | DB, Model | +| DO-043-06 | `Order` shipping address fields: `shipping_street_name`, `shipping_street_number`, `shipping_additional_info`, `shipping_city`, `shipping_post_code`, `shipping_country` | DB, Model | +| DO-043-07 | `PurchasableLicenseType::PRINT = 'print'` — new enum case | Enum, DB, API | ### API Routes / Services | ID | Transport | Description | Notes | |----|-----------|-------------|-------| -| API-043-01 | GET /api/v2/Shop/Catalogue/Sizes | Returns active print and pixel sizes for customer selection | Public (within purchasable album) | -| API-043-02 | GET /api/v2/Shop/Management/PrintSize | Lists all print sizes (admin) | Requires admin auth | -| API-043-03 | POST /api/v2/Shop/Management/PrintSize | Creates a print size | Requires admin auth | +| API-043-01 | GET /api/v2/Shop/Catalogue/Purchasable/{id}/Sizes | Returns active print and pixel sizes assigned to the given purchasable, with per-purchasable prices | Customer-facing, within purchasable album scope | +| API-043-02 | GET /api/v2/Shop/Management/PrintSize | Lists all print sizes (admin, no prices) | Requires admin auth | +| API-043-03 | POST /api/v2/Shop/Management/PrintSize | Creates a print size (no price) | Requires admin auth | | API-043-04 | PUT /api/v2/Shop/Management/PrintSize/{id} | Updates a print size | Requires admin auth | | API-043-05 | DELETE /api/v2/Shop/Management/PrintSize/{id} | Deletes a print size | Requires admin auth | -| API-043-06 | GET /api/v2/Shop/Management/PixelSize | Lists all pixel sizes (admin) | Requires admin auth | +| API-043-06 | GET /api/v2/Shop/Management/PixelSize | Lists all pixel sizes (admin, no prices) | Requires admin auth | | API-043-07 | POST /api/v2/Shop/Management/PixelSize | Creates a pixel size | Requires admin auth | | API-043-08 | PUT /api/v2/Shop/Management/PixelSize/{id} | Updates a pixel size | Requires admin auth | | API-043-09 | DELETE /api/v2/Shop/Management/PixelSize/{id} | Deletes a pixel size | Requires admin auth | -| API-043-10 | POST /api/v2/Shop/Basket/Photo (extended) | Accepts `print_size_id` or `pixel_size_id` in addition to existing `size_variant_type` | Mutually exclusive inputs | -| API-043-11 | POST /api/v2/Shop/Checkout/Create-session (extended) | Accepts shipping address fields when basket has print items | New optional body fields | +| API-043-10 | POST /api/v2/Shop/Basket/Photo (extended) | Accepts `print_size_id` or `pixel_size_id` alongside existing `size_variant_type`; mutually exclusive | Sets `license_type = print` automatically for print/pixel items | +| API-043-11 | POST /api/v2/Shop/Checkout/Create-session (extended) | Accepts shipping address fields when basket has print items | New optional body fields, required when prints present | +| API-043-12 | POST /api/v2/Shop/Management/Purchasable (extended) | Extended to accept `print_sizes` and `pixel_sizes` arrays with per-size prices | Persists to `purchasable_print_sizes` / `purchasable_pixel_sizes` | +| API-043-13 | PUT /api/v2/Shop/Management/Purchasable/{id}/Prices (extended) | Extended to update print/pixel size prices for a purchasable | Replaces existing per-purchasable print/pixel size entries | ### Telemetry Events diff --git a/docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/tasks.md b/docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/tasks.md new file mode 100644 index 00000000000..e80fbf29958 --- /dev/null +++ b/docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/tasks.md @@ -0,0 +1,394 @@ +# Feature 043 Tasks – Webshop Print & Pixel Sizes + +_Status: Not started_ +_Last updated: 2026-05-31_ + +> Keep this checklist aligned with the feature plan increments. Stage tests before implementation, record verification commands beside each task, and prefer bite-sized entries (≤90 minutes). +> **Mark tasks `[x]` immediately** after each one passes verification—do not batch completions. Update the roadmap status when all tasks are done. +> When referencing requirements, keep feature IDs (`FR-`), non-goal IDs, and scenario IDs (`S-`) inside the same parentheses immediately after the task title (omit categories that do not apply). +> When new high- or medium-impact questions arise during execution, add them to [docs/specs/4-architecture/open-questions.md](../../open-questions.md) instead of informal notes, and treat a task as fully resolved only once the governing spec sections (requirements/NFR/behaviour/telemetry) and, when required, ADRs under `docs/specs/5-decisions/` reflect the clarified behaviour. + +## Checklist + +### I1 – DB Migrations + +- [ ] T-043-01 – Create `print_sizes` migration (FR-043-01). + _Intent:_ Persist global print size catalogue (no price). + _Files:_ `database/migrations/YYYY_MM_DD_create_print_sizes_table.php` + _Verification commands:_ + - `php artisan migrate` — runs without error + - `php artisan migrate:rollback` — reverts cleanly + _Notes:_ Columns: `id`, `label` (string 100), `width` (unsignedInteger), `height` (unsignedInteger), `unit` (enum `cm,inch`), `paper_type` (string 100 nullable), `is_active` (boolean default true). + +- [ ] T-043-02 – Create `pixel_sizes` migration (FR-043-02). + _Intent:_ Persist global pixel size catalogue (no price). + _Files:_ `database/migrations/YYYY_MM_DD_create_pixel_sizes_table.php` + _Verification commands:_ + - `php artisan migrate && php artisan migrate:rollback` + _Notes:_ Columns: `id`, `label` (string 100), `width` (unsignedInteger), `height` (unsignedInteger), `is_active` (boolean default true). + +- [ ] T-043-03 – Create `purchasable_print_sizes` migration (FR-043-05). + _Intent:_ Per-purchasable print size assignment with price. + _Files:_ `database/migrations/YYYY_MM_DD_create_purchasable_print_sizes_table.php` + _Verification commands:_ + - `php artisan migrate && php artisan migrate:rollback` + _Notes:_ Columns: `id`, `purchasable_id` (FK → purchasables), `print_size_id` (FK → print_sizes), `price_cents` (integer). Unique constraint on `(purchasable_id, print_size_id)`. + +- [ ] T-043-04 – Create `purchasable_pixel_sizes` migration (FR-043-06). + _Intent:_ Per-purchasable pixel size assignment with price. + _Files:_ `database/migrations/YYYY_MM_DD_create_purchasable_pixel_sizes_table.php` + _Verification commands:_ + - `php artisan migrate && php artisan migrate:rollback` + _Notes:_ Columns: `id`, `purchasable_id` (FK → purchasables), `pixel_size_id` (FK → pixel_sizes), `price_cents` (integer). Unique constraint on `(purchasable_id, pixel_size_id)`. + +- [ ] T-043-05 – Extend `order_items` migration (FR-043-11, FR-043-12). + _Intent:_ Add print/pixel snapshot columns and `is_print` flag. + _Files:_ `database/migrations/YYYY_MM_DD_extend_order_items_for_print.php` + _Verification commands:_ + - `php artisan migrate && php artisan migrate:rollback` + _Notes:_ New nullable columns: `is_print` (boolean default false), `print_size_id`, `pixel_size_id` (nullable FKs), `print_width`, `print_height`, `pixel_width`, `pixel_height` (nullable unsignedInteger), `print_unit` (nullable string), `print_paper_type` (nullable string). + +- [ ] T-043-06 – Extend `orders` migration (FR-043-15). + _Intent:_ Add shipping address columns to orders. + _Files:_ `database/migrations/YYYY_MM_DD_extend_orders_for_shipping.php` + _Verification commands:_ + - `php artisan migrate && php artisan migrate:rollback` + _Notes:_ New nullable columns: `shipping_street_name`, `shipping_street_number`, `shipping_additional_info`, `shipping_city`, `shipping_post_code`, `shipping_country` (all string nullable). + +### I2 – Enum Extension + +- [ ] T-043-07 – Add `PRINT = 'print'` to `PurchasableLicenseType` (FR-043-10, S-043-28). + _Intent:_ Introduce dedicated license type for print/pixel-size items. + _Files:_ `app/Enum/PurchasableLicenseType.php` + _Verification commands:_ + - `make phpstan` — no errors + - `php artisan test` — existing tests still pass + _Notes:_ Add `case PRINT = 'print';` after the existing cases. + +### I3 – Models: PrintSize & PixelSize + +- [ ] T-043-08 – Create `PrintSize` model (FR-043-01, DO-043-01). + _Intent:_ Eloquent model for the global print size catalogue. + _Files:_ `app/Models/PrintSize.php`, `database/factories/PrintSizeFactory.php` + _Verification commands:_ + - `make phpstan` — no errors + _Notes:_ Fillable: `label`, `width`, `height`, `unit`, `paper_type`, `is_active`. Add `active()` local scope. + +- [ ] T-043-09 – Create `PixelSize` model (FR-043-02, DO-043-02). + _Intent:_ Eloquent model for the global pixel size catalogue. + _Files:_ `app/Models/PixelSize.php`, `database/factories/PixelSizeFactory.php` + _Verification commands:_ + - `make phpstan` — no errors + _Notes:_ Fillable: `label`, `width`, `height`, `is_active`. Add `active()` local scope. + +### I4 – Models: PurchasablePrintSize & PurchasablePixelSize + +- [ ] T-043-10 – Create `PurchasablePrintSize` model and extend `Purchasable` (FR-043-05, DO-043-03). + _Intent:_ Join table model for per-purchasable print size pricing. + _Files:_ `app/Models/PurchasablePrintSize.php`, `database/factories/PurchasablePrintSizeFactory.php`, `app/Models/Purchasable.php` + _Verification commands:_ + - `make phpstan` — no errors + _Notes:_ `price_cents` cast via `MoneyCast`. Add `printSizes()` HasMany on `Purchasable`; load in `$with`. + +- [ ] T-043-11 – Create `PurchasablePixelSize` model and extend `Purchasable` (FR-043-06, DO-043-04). + _Intent:_ Join table model for per-purchasable pixel size pricing. + _Files:_ `app/Models/PurchasablePixelSize.php`, `database/factories/PurchasablePixelSizeFactory.php`, `app/Models/Purchasable.php` + _Verification commands:_ + - `make phpstan` — no errors + _Notes:_ `price_cents` cast via `MoneyCast`. Add `pixelSizes()` HasMany on `Purchasable`; load in `$with`. + +### I5 – OrderItem & Order Extensions + +- [ ] T-043-12 – Extend `OrderItem` model (DO-043-05, FR-043-12). + _Intent:_ Add new fillable columns and BelongsTo relations for print/pixel items. + _Files:_ `app/Models/OrderItem.php` + _Verification commands:_ + - `make phpstan` — no errors + _Notes:_ Add `is_print`, `print_size_id`, `pixel_size_id` (nullable FK BelongsTo), snapshot columns, `print_unit`, `print_paper_type`. Cast `is_print` as boolean. + +- [ ] T-043-13 – Extend `Order` model + `canProcessPayment()` guard (DO-043-06, FR-043-15, FR-043-19, S-043-23, S-043-24). + _Intent:_ Add shipping address fields and enforce them when prints present. + _Files:_ `app/Models/Order.php` + _Verification commands:_ + - `make phpstan` — no errors + _Notes:_ `canProcessPayment()` returns `false` when any item `is_print = true` AND any of `shipping_street_name`, `shipping_city`, `shipping_post_code`, `shipping_country` is null/empty. + +### I6 – Admin API: PrintSize & PixelSize CRUD + +- [ ] T-043-14 – Create FormRequests for PrintSize CRUD (FR-043-01, FR-043-03, FR-043-04). + _Intent:_ Validate admin input for global print size management. + _Files:_ `app/Http/Requests/ShopManagement/PrintSize/CreatePrintSizeRequest.php`, `UpdatePrintSizeRequest.php` + _Verification commands:_ + - `make phpstan` + +- [ ] T-043-15 – Create FormRequests for PixelSize CRUD (FR-043-02, FR-043-03, FR-043-04). + _Intent:_ Validate admin input for global pixel size management. + _Files:_ `app/Http/Requests/ShopManagement/PixelSize/CreatePixelSizeRequest.php`, `UpdatePixelSizeRequest.php` + _Verification commands:_ + - `make phpstan` + +- [ ] T-043-16 – Create `PrintSizeResource` and `PixelSizeResource` (FR-043-02, FR-043-18). + _Intent:_ API response format for catalogue entries (no price). + _Files:_ `app/Http/Resources/Shop/PrintSizeResource.php`, `PixelSizeResource.php` + _Verification commands:_ + - `make phpstan` + +- [ ] T-043-17 – Create `PrintSizeManagementController` (FR-043-01, FR-043-03, FR-043-04, FR-043-20). + _Intent:_ Admin CRUD controller for print sizes. + _Files:_ `app/Http/Controllers/Admin/PrintSizeManagementController.php` + _Verification commands:_ + - `make phpstan` + - `php artisan test --filter=PrintSizeManagementControllerTest` + +- [ ] T-043-18 – Create `PixelSizeManagementController` (FR-043-02, FR-043-03, FR-043-04, FR-043-20). + _Intent:_ Admin CRUD controller for pixel sizes. + _Files:_ `app/Http/Controllers/Admin/PixelSizeManagementController.php` + _Verification commands:_ + - `make phpstan` + - `php artisan test --filter=PixelSizeManagementControllerTest` + +- [ ] T-043-19 – Register admin size routes (API-043-02..09, FR-043-20). + _Intent:_ Expose print/pixel size CRUD under `support:pro` middleware. + _Files:_ `routes/api_v2_shop.php` + _Verification commands:_ + - `php artisan route:list | grep PrintSize` + - `php artisan route:list | grep PixelSize` + +- [ ] T-043-20 – Write feature tests for PrintSize management (S-043-01, S-043-02, S-043-04, S-043-05, S-043-06, S-043-25, S-043-26, S-043-27). + _Intent:_ Cover CRUD scenarios for print sizes. + _Files:_ `tests/Webshop/PrintSizeManagementControllerTest.php` + _Verification commands:_ + - `php artisan test --filter=PrintSizeManagementControllerTest` + +- [ ] T-043-21 – Write feature tests for PixelSize management (S-043-03, S-043-06). + _Intent:_ Cover CRUD scenarios for pixel sizes. + _Files:_ `tests/Webshop/PixelSizeManagementControllerTest.php` + _Verification commands:_ + - `php artisan test --filter=PixelSizeManagementControllerTest` + +### I7 – Purchasable Service & Controller Extension + +- [ ] T-043-22 – Extend `PurchasableService` with `syncPrintSizes` / `syncPixelSizes` (FR-043-05, FR-043-06). + _Intent:_ Persist per-purchasable print/pixel size assignments with prices. + _Files:_ `app/Actions/Shop/PurchasableService.php` + _Verification commands:_ + - `make phpstan` + +- [ ] T-043-23 – Extend purchasable request classes for print/pixel sizes (FR-043-05, FR-043-06). + _Intent:_ Accept `print_sizes` and `pixel_sizes` arrays in purchasable create/update requests. + _Files:_ `app/Http/Requests/ShopManagement/PurchasablePhotoRequest.php`, `PurchasableAlbumRequest.php`, `UpdatePurchasablePriceRequest.php` + _Verification commands:_ + - `make phpstan` + +- [ ] T-043-24 – Extend `ShopManagementController` to call sync methods (FR-043-05, FR-043-06). + _Intent:_ Wire print/pixel size sync into purchasable create/update flow. + _Files:_ `app/Http/Controllers/Admin/ShopManagementController.php` + _Verification commands:_ + - `make phpstan` + - `php artisan test --filter=ShopManagementControllerTest` + +- [ ] T-043-25 – Extend `EditablePurchasableResource` to include print/pixel sizes. + _Intent:_ Return assigned print/pixel sizes with prices in the management API response. + _Files:_ `app/Http/Resources/Shop/EditablePurchasableResource.php` + _Verification commands:_ + - `make phpstan` + +- [ ] T-043-26 – Write feature tests for purchasable print/pixel pricing (S-043-07, S-043-08). + _Intent:_ Verify per-purchasable assignment is persisted and returned correctly. + _Files:_ `tests/Webshop/Purchasables/ShopManagementPrintPixelPricingTest.php` + _Verification commands:_ + - `php artisan test --filter=ShopManagementPrintPixelPricingTest` + +### I8 – Customer Catalogue Endpoint + +- [ ] T-043-27 – Create `CatalogueSizesController` and route (FR-043-17, API-043-01, FR-043-20). + _Intent:_ Return active, per-purchasable print/pixel sizes with prices. + _Files:_ `app/Http/Controllers/Shop/CatalogueSizesController.php`, `routes/api_v2_shop.php` + _Verification commands:_ + - `make phpstan` + - `php artisan test --filter=CatalogueSizesControllerTest` + +- [ ] T-043-28 – Write feature tests for catalogue sizes endpoint (S-043-09, S-043-19, S-043-20, S-043-21). + _Intent:_ Verify customer can retrieve sizes and gets errors for invalid inputs. + _Files:_ `tests/Webshop/CatalogueSizesControllerTest.php` + _Verification commands:_ + - `php artisan test --filter=CatalogueSizesControllerTest` + +### I9 – Basket Extension + +- [ ] T-043-29 – Create basket FormRequests for print/pixel items (FR-043-07, FR-043-08). + _Intent:_ Validate mutually exclusive basket inputs. + _Files:_ `app/Http/Requests/Shop/AddPhotoToBasketRequest.php` (or new split requests) + _Verification commands:_ + - `make phpstan` + +- [ ] T-043-30 – Extend `BasketService` with `addPrintItem` / `addPixelItem` (FR-043-07, FR-043-08, FR-043-11, FR-043-12). + _Intent:_ Create OrderItems for print/pixel purchases with snapshots and license_type = print. + _Files:_ `app/Actions/Shop/BasketService.php` + _Verification commands:_ + - `make phpstan` + +- [ ] T-043-31 – Write feature tests for basket print/pixel items (S-043-10, S-043-11, S-043-12, S-043-19, S-043-20, S-043-21). + _Intent:_ Verify basket add for print/pixel items and rejection paths. + _Files:_ `tests/Webshop/BasketControllerPrintTest.php` + _Verification commands:_ + - `php artisan test --filter=BasketControllerPrintTest` + +### I10 – Checkout Extension + +- [ ] T-043-32 – Extend `CreateCheckoutSessionRequest` with shipping address (FR-043-14, FR-043-15). + _Intent:_ Require shipping fields when basket has print items. + _Files:_ `app/Http/Requests/Shop/CreateCheckoutSessionRequest.php` + _Verification commands:_ + - `make phpstan` + +- [ ] T-043-33 – Extend `CheckoutService` to store shipping address (FR-043-15). + _Intent:_ Persist shipping address on Order before payment initiation. + _Files:_ `app/Actions/Shop/CheckoutService.php` + _Verification commands:_ + - `make phpstan` + +- [ ] T-043-34 – Write feature tests for checkout shipping address (S-043-13, S-043-14, S-043-15, S-043-16, S-043-23, S-043-24). + _Intent:_ Cover shipping form visibility, validation, and storage. + _Files:_ `tests/Webshop/Checkout/CheckoutShippingAddressTest.php` + _Verification commands:_ + - `php artisan test --filter=CheckoutShippingAddressTest` + +### I11 – Order Resource Extension + +- [ ] T-043-35 – Extend `OrderResource` and `OrderItemResource` (FR-043-16). + _Intent:_ Include shipping address and print/pixel item details in order API response. + _Files:_ `app/Http/Resources/Shop/OrderResource.php`, `app/Http/Resources/Shop/OrderItemResource.php` + _Verification commands:_ + - `make phpstan` + +- [ ] T-043-36 – Write feature tests for order shipping address display (S-043-17, S-043-18). + _Intent:_ Verify shipping address included/excluded based on print items. + _Files:_ `tests/Webshop/OrderManagement/OrderResourceShippingTest.php` + _Verification commands:_ + - `php artisan test --filter=OrderResourceShippingTest` + +### I12 – Unit Tests + +- [ ] T-043-37 – Unit test `Order::canProcessPayment()` (S-043-23, S-043-24). + _Intent:_ Verify payment guard logic for print + shipping address. + _Files:_ `tests/Unit/Order/CanProcessPaymentPrintTest.php` + _Verification commands:_ + - `php artisan test --filter=CanProcessPaymentPrintTest` + +- [ ] T-043-38 – Unit test `PurchasableLicenseType::PRINT` enum (S-043-28). + _Intent:_ Verify serialisation and deserialisation of new enum case. + _Files:_ `tests/Unit/Enum/PurchasableLicenseTypeTest.php` + _Verification commands:_ + - `php artisan test --filter=PurchasableLicenseTypeTest` + +### I13 – Frontend: Admin Print/Pixel Sizes Page + +- [ ] T-043-39 – Create admin sizes Vue page scaffold (FR-043-18, S-043-25). + _Intent:_ New page at `/admin/shop/sizes` for managing global catalogue. + _Files:_ `resources/js/views/admin/shop/PrintPixelSizesAdmin.vue` + _Verification commands:_ + - `npm run check` + +- [ ] T-043-40 – Add print/pixel size service methods (API-043-02..09). + _Intent:_ Frontend service calls for admin CRUD. + _Files:_ `resources/js/services/shop-management-service.ts` + _Verification commands:_ + - `npm run check` + +- [ ] T-043-41 – Register admin route and nav entry. + _Intent:_ Make page reachable from admin navigation. + _Files:_ Vue router file, admin navigation component + _Verification commands:_ + - `npm run check` + - Manual: navigate to `/admin/shop/sizes` + +### I14 – Frontend: Basket Item Type Selector + +- [ ] T-043-42 – Extend basket modal with item type selector (FR-043-07, FR-043-08, FR-043-09). + _Intent:_ Add Digital / Print / Pixel type toggle to existing add-to-basket modal. + _Files:_ Existing basket modal component (identify during implementation) + _Verification commands:_ + - `npm run check` + - Manual: verify type selector shows/hides correct fields + +- [ ] T-043-43 – Add catalogue sizes fetch and print/pixel basket add service calls. + _Intent:_ Fetch per-purchasable sizes from API; send correct basket payload per type. + _Files:_ `resources/js/services/webshop-service.ts` + _Verification commands:_ + - `npm run check` + +### I15 – Frontend: Checkout Shipping Address + +- [ ] T-043-44 – Add shipping address block to `InfoSection.vue` (FR-043-14, S-043-13, S-043-14). + _Intent:_ Show/hide shipping fields based on basket print items. + _Files:_ `resources/js/components/webshop/InfoSection.vue` + _Verification commands:_ + - `npm run check` + - Manual: basket with print → address block visible; digital-only → hidden + +### I16 – Frontend: Purchasable Print/Pixel Price Assignment + +- [ ] T-043-45 – Create `PrintSizePricesInput.vue` component (FR-043-05). + _Intent:_ Select from global catalogue and set price per print size for a purchasable. + _Files:_ `resources/js/components/forms/shop-management/PrintSizePricesInput.vue` + _Verification commands:_ + - `npm run check` + +- [ ] T-043-46 – Create `PixelSizePricesInput.vue` component (FR-043-06). + _Intent:_ Select from global catalogue and set price per pixel size for a purchasable. + _Files:_ `resources/js/components/forms/shop-management/PixelSizePricesInput.vue` + _Verification commands:_ + - `npm run check` + +- [ ] T-043-47 – Integrate print/pixel price inputs into purchasable create/edit form. + _Intent:_ Wire new sub-components into the purchasable management UI. + _Files:_ Purchasable create/edit form component (identify during implementation) + _Verification commands:_ + - `npm run check` + - Manual: add/remove print/pixel prices in purchasable form + +### I17 – Translation Strings + +- [ ] T-043-48 – Add English translation strings for print/pixel sizes UI. + _Intent:_ Labels, placeholders, and messages for new UI elements. + _Files:_ `lang/en/webshop.php` + _Verification commands:_ + - `npm run check` + - `grep -r "print_size" lang/en/` + +- [ ] T-043-49 – Copy translation string placeholders to all other locales. + _Intent:_ Prevent missing-key errors in non-English locales. + _Files:_ `lang/*/webshop.php` (all locales) + _Verification commands:_ + - `grep -rl "print_size" lang/ | wc -l` — should equal total number of locales + +### I18 – Final Quality Gate + +- [ ] T-043-50 – Run full backend quality gate (NFR-043-01..06, S-043-22). + _Intent:_ Verify all PHP code meets quality standards and all tests pass. + _Verification commands:_ + - `vendor/bin/php-cs-fixer fix` + - `php artisan test` — all tests pass + - `make phpstan` — level 6, zero errors + +- [ ] T-043-51 – Run full frontend quality gate. + _Intent:_ Verify all Vue/TypeScript code meets quality standards. + _Verification commands:_ + - `npm run format` + - `npm run check` + +## Notes / TODOs + +- None yet. Add notes here as issues arise during implementation. + +## Progress Summary + +- **Total tasks:** 51 +- **Completed:** 0 +- **In progress:** 0 +- **Blocked:** 0 + +--- + +*Last updated: 2026-05-31* diff --git a/docs/specs/4-architecture/open-questions.md b/docs/specs/4-architecture/open-questions.md index 2e4c1677f16..75068449d10 100644 --- a/docs/specs/4-architecture/open-questions.md +++ b/docs/specs/4-architecture/open-questions.md @@ -6,96 +6,68 @@ Track unresolved high- and medium-impact questions here. Remove each row as soon | Question ID | Feature | Priority | Summary | Status | Opened | Updated | |-------------|---------|----------|---------|--------|--------|---------| -| Q-043-01 | 043 – Webshop Print & Pixel Sizes | High | Pricing model: global flat price per size vs. per-purchasable with license-type dimension | Open | 2026-05-31 | 2026-05-31 | -| Q-043-02 | 043 – Webshop Print & Pixel Sizes | High | License type: do print and pixel items carry the personal/commercial/extended dimension? | Open | 2026-05-31 | 2026-05-31 | -| Q-043-03 | 043 – Webshop Print & Pixel Sizes | Medium | Pixel-size fulfillment: manual download-link flow (same as FULL) or different mechanism? | Open | 2026-05-31 | 2026-05-31 | -| Q-043-04 | 043 – Webshop Print & Pixel Sizes | Medium | Print/pixel catalogue availability: global for all purchasables, or restrictable per-album/photo? | Open | 2026-05-31 | 2026-05-31 | -| Q-043-05 | 043 – Webshop Print & Pixel Sizes | Low | SE gating: are print/pixel sizes Lychee SE (supporter:pro) only, consistent with the rest of the webshop? | Open | 2026-05-31 | 2026-05-31 | + +*(No active questions.)* ## Question Details -### Q-043-01: Pricing model for print/pixel sizes +### ~~Q-043-01: Pricing model for print/pixel sizes~~ ✅ RESOLVED **Feature:** 043 – Webshop Print & Pixel Sizes **Priority:** High -**Status:** Open +**Status:** Resolved — **Option B + no global price** **Opened:** 2026-05-31 +**Resolved:** 2026-05-31 -**Context:** The current `purchasable_prices` table prices each size variant (`medium`, `medium2x`, `original`, `full`) × license type (`personal`, `commercial`, `extended`) per `purchasable` record (album or photo). The problem statement says "admin specifies a list of possible print sizes and pixel sizes" — that implies a global catalogue. It is unclear whether a price is stored on the global size entry or whether each purchasable can override it. - -**Options (ordered by recommendation):** - -**Option A – Global flat price per size (recommended):** Each `print_size` / `pixel_size` catalogue record carries a single `price_cents` set by the admin. That price applies to every purchasable photo/album uniformly. No per-purchasable price override for print/pixel sizes. Pros: simple to implement and manage; consistent with "admin defines what's available". Cons: no per-photographer/per-album price customisation. - -**Option B – Per-purchasable override (mirrors existing price model):** The `purchasable_prices` table (or a sibling) is extended to accommodate `print_size_id` / `pixel_size_id` entries, allowing per-album/photo price overrides on top of a catalogue default. Pros: full pricing flexibility. Cons: significantly more complex; catalogue UI and basket resolution logic both grow; the problem statement does not explicitly request per-album overrides. +**Resolution:** The global `print_sizes` / `pixel_sizes` catalogue stores dimensions and labels only — no `price_cents`. When a photographer creates or edits a purchasable, they select which print/pixel sizes to offer and set a per-size price, stored in new join tables `purchasable_print_sizes` and `purchasable_pixel_sizes`. This mirrors the existing per-purchasable pricing model while keeping the catalogue clean. Captured in FR-043-01, FR-043-02, FR-043-05, FR-043-06, FR-043-17, and DO-043-03, DO-043-04 in the spec. --- -### Q-043-02: License type for print and pixel order items +### ~~Q-043-02: License type for print and pixel order items~~ ✅ RESOLVED **Feature:** 043 – Webshop Print & Pixel Sizes **Priority:** High -**Status:** Open +**Status:** Resolved — **New license type `print`** **Opened:** 2026-05-31 +**Resolved:** 2026-05-31 -**Context:** Digital size variants are sold with a license type (personal, commercial, extended). The problem statement does not mention license types for prints or pixel sizes. - -**Options (ordered by recommendation):** - -**Option A – Retain license type for all item types (recommended):** Print and pixel-size `OrderItem` records still carry a `license_type` (defaulting to `personal` if not selected). The customer can choose the license just as with digital variants. Pros: consistent model; reuses existing enum and validation; legally safer for photographers selling commercial rights. Cons: slightly more UI surface. - -**Option B – No license type for prints/pixel sizes:** `license_type` is `null` on print/pixel order items; it only applies to digital downloads. Pros: simpler checkout form. Cons: inconsistent model; breaks `OrderItem` NOT-NULL constraint (requires schema change or default). +**Resolution:** A new `PurchasableLicenseType::PRINT = 'print'` enum case is introduced. Print and pixel-size order items carry this license type, set automatically by the server. Customers do not choose between personal/commercial/extended for these item types. Captured in FR-043-10, DO-043-07, and `App\Enum\PurchasableLicenseType` in the spec. --- -### Q-043-03: Fulfillment model for pixel-size items +### ~~Q-043-03: Fulfillment model for pixel-size items~~ ✅ RESOLVED **Feature:** 043 – Webshop Print & Pixel Sizes **Priority:** Medium -**Status:** Open +**Status:** Resolved — **Option A** **Opened:** 2026-05-31 +**Resolved:** 2026-05-31 -**Context:** FULL size variants require the photographer to manually export the file and set a `download_link`. Pixel-size purchases imply the photographer exports the photo at a specific resolution. The fulfillment mechanism is unspecified. - -**Options (ordered by recommendation):** - -**Option A – Same manual `download_link` mechanism as FULL (recommended):** A pixel-size `OrderItem` has `size_variant_type = null`, `is_print = false`, and awaits a `download_link` set by the photographer (same admin action as FULL). `FulfillOrders` task skips these until the link is set. Pros: reuses existing fulfillment infrastructure with no new code path. Cons: photographer must remember to export at the specified pixel dimensions. - -**Option B – Automatic export via a queue job:** A new job reads `pixel_width`/`pixel_height` from the order item and generates the resized image automatically using Lychee's image processing pipeline. Pros: fully automated. Cons: large scope increase; not requested by the problem statement; relies on Lychee's image processing capabilities being available post-purchase. +**Resolution:** Pixel-size items use the same manual `download_link` mechanism as FULL. No new fulfilment code path is needed. Captured in FR-043-13 in the spec. --- -### Q-043-04: Print/pixel catalogue availability scope +### ~~Q-043-04: Print/pixel catalogue availability scope~~ ✅ RESOLVED **Feature:** 043 – Webshop Print & Pixel Sizes **Priority:** Medium -**Status:** Open +**Status:** Resolved — **Option B (per-purchasable opt-in)** **Opened:** 2026-05-31 +**Resolved:** 2026-05-31 -**Context:** The problem statement says "admin specifies a list of possible print sizes and pixel sizes" without mentioning per-album or per-photo restrictions. - -**Options (ordered by recommendation):** - -**Option A – Global catalogue, available for all purchasables (recommended):** Every active `print_size` / `pixel_size` is selectable for any purchasable photo. Admin enables/disables sizes globally only. Pros: simple; matches problem statement literally. Cons: no per-album control. - -**Option B – Per-purchasable opt-in/opt-out:** The purchasable management screen lets the owner whitelist or blacklist specific print/pixel sizes for each album or photo. Pros: fine-grained control. Cons: significant UI and backend complexity not requested. +**Resolution:** The admin defines a global catalogue of print/pixel sizes. Each purchasable's owner selects which sizes to offer and sets prices via the purchasable management UI. Only sizes explicitly assigned to a purchasable are available to customers for that item. Captured in FR-043-05, FR-043-06, FR-043-17 in the spec. --- -### Q-043-05: Lychee SE gating for print/pixel features +### ~~Q-043-05: Lychee SE gating for print/pixel features~~ ✅ RESOLVED **Feature:** 043 – Webshop Print & Pixel Sizes **Priority:** Low -**Status:** Open +**Status:** Resolved — **Option A** **Opened:** 2026-05-31 +**Resolved:** 2026-05-31 -**Context:** All existing webshop routes are behind `Route::middleware('support:pro')`. Print/pixel sizes extend the webshop. - -**Options (ordered by recommendation):** - -**Option A – Same `support:pro` gate (recommended):** Print/pixel size management and the customer catalogue endpoint sit inside the existing `support:pro` middleware group. Pros: consistent; no policy change. Cons: none. - -**Option B – Separate gate or free tier:** Print/pixel sizes are offered on a lower tier. Pros: expands addressable market. Cons: contradicts existing policy; would require a policy decision outside the feature's scope. +**Resolution:** All new routes use the existing `support:pro` middleware group, consistent with the rest of the webshop. Captured in FR-043-20 in the spec. --- From 91136c91a3e9668998f6642e0d21f45dd325d1e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 12:56:58 +0000 Subject: [PATCH 03/22] docs: log 12 F-043 open questions into open-questions.md Co-authored-by: ildyria <627094+ildyria@users.noreply.github.com> --- docs/specs/4-architecture/open-questions.md | 226 +++++++++++++++++++- 1 file changed, 224 insertions(+), 2 deletions(-) diff --git a/docs/specs/4-architecture/open-questions.md b/docs/specs/4-architecture/open-questions.md index 75068449d10..2ec80dfb56e 100644 --- a/docs/specs/4-architecture/open-questions.md +++ b/docs/specs/4-architecture/open-questions.md @@ -6,11 +6,233 @@ Track unresolved high- and medium-impact questions here. Remove each row as soon | Question ID | Feature | Priority | Summary | Status | Opened | Updated | |-------------|---------|----------|---------|--------|--------|---------| - -*(No active questions.)* +| Q-043-06 | 043 | High | Album-level basket add for print/pixel sizes — scope unspecified | Open | 2026-05-31 | 2026-05-31 | +| Q-043-07 | 043 | High | `{id}` type in `GET /api/v2/Shop/Catalogue/Purchasable/{id}/Sizes` — purchasable PK, photo_id, or album_id? | Open | 2026-05-31 | 2026-05-31 | +| Q-043-08 | 043 | Medium | `canProcessPayment()` interaction — precedence of shipping-address guard vs existing email/FULL-size checks | Open | 2026-05-31 | 2026-05-31 | +| Q-043-09 | 043 | Medium | `license_type` DB column type — native enum or string? Enum extension may require ALTER TABLE migration | Open | 2026-05-31 | 2026-05-31 | +| Q-043-10 | 043 | Medium | FK cascade policy on `purchasable_print_sizes.print_size_id` / `purchasable_pixel_sizes.pixel_size_id` on delete | Open | 2026-05-31 | 2026-05-31 | +| Q-043-11 | 043 | Low | `is_print = false` yet `license_type = 'print'` for pixel items — should be documented in task notes to prevent confusion | Open | 2026-05-31 | 2026-05-31 | +| Q-043-12 | 043 | Medium | Frontend basket/purchasable component names not identified; blocks I14 and I16 | Open | 2026-05-31 | 2026-05-31 | +| Q-043-13 | 043 | Low | Adding `printSizes`/`pixelSizes` to `Purchasable::$with` — always-eager-loaded vs lazy-load for specific resources | Open | 2026-05-31 | 2026-05-31 | +| Q-043-14 | 043 | Medium | `UpdatePurchasablePriceRequest` — how is the purchasable identified when route has no `{id}` segment? | Open | 2026-05-31 | 2026-05-31 | +| Q-043-15 | 043 | Low | Naming inconsistency: existing `CatalogController` vs new `CatalogueSizesController` | Open | 2026-05-31 | 2026-05-31 | +| Q-043-16 | 043 | Low | Translation key placement: which new strings belong in `webshop.php` vs `dialogs.php`? | Open | 2026-05-31 | 2026-05-31 | +| Q-043-17 | 043 | Medium | `is_print` must be exposed in basket GET response (via `OrderItemResource`) before frontend `hasPrints` computed can work — cross-increment dependency not captured in tasks | Open | 2026-05-31 | 2026-05-31 | ## Question Details +### Q-043-06: Album-level basket add for print/pixel sizes + +**Feature:** 043 – Webshop Print & Pixel Sizes +**Priority:** High +**Status:** Open +**Opened:** 2026-05-31 +**Updated:** 2026-05-31 + +**Context:** FR-043-07 and FR-043-08 only define `POST /api/v2/Shop/Basket/Photo` for adding print/pixel items to the basket. The existing `POST /api/v2/Shop/Basket/Album` route allows bulk-adding all photos in an album at a chosen size+license. FR-043-05/06 permit photographers to assign print/pixel sizes to **album-level purchasables** as well as photo-level ones. + +**Question:** Can customers add album print/pixel items to the basket via the album basket endpoint? If yes, how does the bulk add work (per-photo print size, or a single selection applied to all photos)? If not, should album-level purchasables be prevented from having print/pixel sizes assigned, or silently ignored at basket time? + +**Impact:** Determines whether `BasketService::addAlbumToBasket`, the album basket FormRequest, and the album purchasable management UI require any changes. Affects scope of I7 and I9. + +**Blocking tasks:** T-043-22 (`syncPrintSizes`/`syncPixelSizes`), T-043-23 (purchasable request extension), T-043-29 (basket FormRequests), T-043-30 (BasketService extension). + +--- + +### Q-043-07: `{id}` type in `GET /api/v2/Shop/Catalogue/Purchasable/{id}/Sizes` + +**Feature:** 043 – Webshop Print & Pixel Sizes +**Priority:** High +**Status:** Open +**Opened:** 2026-05-31 +**Updated:** 2026-05-31 + +**Context:** API-043-01 defines `GET /api/v2/Shop/Catalogue/Purchasable/{id}/Sizes` with the note "within purchasable album scope." The route parameter is `{id}`, but the spec does not specify whether this is the `purchasables.id` (integer PK), the `photo_id` (ULID), or the `album_id` (ULID). The existing customer-facing catalog routes (e.g., `GET /Shop`) use album/photo IDs, not the internal `purchasables.id`. + +**Question:** What does `{id}` resolve to — `purchasables.id`, `photo_id`, or `album_id`? Is the scope restricted to a specific album context (matching the `AlbumQueryPolicy`)? + +**Impact:** Directly affects the controller lookup in T-043-27 (`CatalogueSizesController`) and the URL construction in the frontend service (T-043-43). + +**Blocking tasks:** T-043-27, T-043-28, T-043-43. + +--- + +### Q-043-08: `canProcessPayment()` interaction with existing guards + +**Feature:** 043 – Webshop Print & Pixel Sizes +**Priority:** Medium +**Status:** Open +**Opened:** 2026-05-31 +**Updated:** 2026-05-31 + +**Context:** The existing `Order::canProcessPayment()` (app/Models/Order.php:289) already returns `false` in several cases: basket not in checkout-ready state, no payment provider, no email when FULL-size items are present. FR-043-19 adds a new guard: return `false` if any item `is_print = true` AND required shipping fields are null/empty. + +**Question:** How do the new and existing guards interact? Specifically: +1. A logged-in user with email, FULL-size items, print items, but no shipping address — should payment be blocked (shipping guard fires)? +2. Is the shipping guard evaluated in addition to, or as a replacement for, the email guard for print items? +3. Should print items also require an email (for shipping confirmation), or is the shipping address alone sufficient? + +**Impact:** Affects the implementation of `canProcessPayment()` in T-043-13 and the unit tests in T-043-37. + +**Blocking tasks:** T-043-13, T-043-37. + +--- + +### Q-043-09: `license_type` DB column type and enum extension migration + +**Feature:** 043 – Webshop Print & Pixel Sizes +**Priority:** Medium +**Status:** Open +**Opened:** 2026-05-31 +**Updated:** 2026-05-31 + +**Context:** `order_items.license_type` is cast to `PurchasableLicenseType` in `OrderItem`. If the database column is a MySQL/PostgreSQL native ENUM, adding `PRINT = 'print'` to the PHP enum is insufficient — an `ALTER TABLE order_items MODIFY COLUMN license_type ENUM('personal','commercial','extended','print')` (MySQL) or similar is required. If the column is a plain `VARCHAR`/`string`, no DB migration is needed for this change. The spec and plan mention only a PHP enum case addition (T-043-07) with no corresponding DB migration. + +**Question:** Is `order_items.license_type` stored as a DB-native enum or as a plain string? If it is a DB enum, a migration must be added to T-043-07 or as a separate task. + +**Impact:** Missing this step would cause runtime DB errors when the new enum value is persisted. Affects T-043-07 scope. + +**Blocking tasks:** T-043-07. + +--- + +### Q-043-10: FK cascade policy on `purchasable_print_sizes` and `purchasable_pixel_sizes` + +**Feature:** 043 – Webshop Print & Pixel Sizes +**Priority:** Medium +**Status:** Open +**Opened:** 2026-05-31 +**Updated:** 2026-05-31 + +**Context:** S-043-05 states "Admin deletes a print size → removed; existing order items retain snapshotted data; purchasable assignments cleaned up or orphaned gracefully." T-043-03 and T-043-04 specify a FK from `purchasable_print_sizes.print_size_id → print_sizes.id` and similarly for pixel sizes, but do not specify the `onDelete` behaviour. + +**Options:** +- **CASCADE:** Deleting a catalogue entry automatically removes all per-purchasable assignments. Purchasable prices are silently lost. +- **RESTRICT / NO ACTION:** Prevent deletion of catalogue entries that are still in use. +- **SET NULL:** Not applicable (column is non-nullable in the join table). + +**Question:** What `onDelete` rule should the FK use? Should the admin UI warn about in-use sizes before deletion? + +**Blocking tasks:** T-043-03, T-043-04, T-043-20, T-043-21. + +--- + +### Q-043-11: `is_print = false` with `license_type = 'print'` for pixel-size items + +**Feature:** 043 – Webshop Print & Pixel Sizes +**Priority:** Low +**Status:** Open +**Opened:** 2026-05-31 +**Updated:** 2026-05-31 + +**Context:** FR-043-08 specifies that pixel-size order items have `is_print = false` (no physical fulfilment required) yet `license_type = 'print'`. This means the `is_print` flag and the license type are not in a 1-to-1 relationship: both physical prints and digital pixel exports share the `'print'` license, but only physical prints have `is_print = true`. This is correct per the spec's intent (pixel items do not need shipping) but is semantically surprising and likely to cause confusion for implementers working across T-043-12, T-043-30, and T-043-35. + +**Question:** Is there a preferred place (code comment, task note, or inline spec section) to document this intentional asymmetry so that future contributors are not misled by the name `is_print`? No code change is required — this is purely a documentation/clarity question. + +**Blocking tasks:** None (low priority). Relevant to T-043-12, T-043-30, T-043-35. + +--- + +### Q-043-12: Frontend basket modal and purchasable form component names not identified + +**Feature:** 043 – Webshop Print & Pixel Sizes +**Priority:** Medium +**Status:** Open +**Opened:** 2026-05-31 +**Updated:** 2026-05-31 + +**Context:** T-043-42 says "existing basket modal component (identify during implementation)" and T-043-47 says "purchasable create/edit form component (identify during implementation)." These are deferred unknowns; the frontend increments I14 and I16 cannot begin until the correct component files are located. + +**Question:** What are the exact file paths of (a) the customer-facing add-to-basket modal and (b) the admin purchasable create/edit form? Identifying these before implementation begins will prevent a research step mid-increment and allow I14/I16 to be scoped accurately. + +**Suggested pre-work:** Run `grep -r "Basket\|basket" resources/js --include="*.vue" -l` and `grep -r "Purchasable\|purchasable" resources/js/views/admin --include="*.vue" -l` to locate candidates. + +**Blocking tasks:** T-043-42, T-043-47. + +--- + +### Q-043-13: `printSizes`/`pixelSizes` in `Purchasable::$with` — always eager-loaded? + +**Feature:** 043 – Webshop Print & Pixel Sizes +**Priority:** Low +**Status:** Open +**Opened:** 2026-05-31 +**Updated:** 2026-05-31 + +**Context:** The plan (I4) says to add `printSizes()` and `pixelSizes()` relations to `Purchasable::$with`, which currently contains only `['prices']`. Making relations eager-loaded globally means every query that fetches a `Purchasable` (management list, catalog, basket get) will also load all print/pixel size assignments with prices, regardless of whether the caller needs them. + +**Question:** Should these relations be added to `$with` (always eager-loaded) or loaded selectively in specific resources? If the global management list or catalog already returns many purchasables, this may produce N+1-equivalent overhead even when eager-loaded. + +**Impact:** Performance trade-off; affects T-043-10, T-043-11, and T-043-25 implementation choices. + +**Blocking tasks:** T-043-10, T-043-11. + +--- + +### Q-043-14: How is the purchasable identified in `UpdatePurchasablePriceRequest`? + +**Feature:** 043 – Webshop Print & Pixel Sizes +**Priority:** Medium +**Status:** Open +**Opened:** 2026-05-31 +**Updated:** 2026-05-31 + +**Context:** The existing route is `PUT /api/v2/Shop/Management/Purchasable/Price` (no `{id}` in the URL — see routes/api_v2_shop.php:47). The spec (I7/T-043-23) extends `UpdatePurchasablePriceRequest` to accept `print_sizes` and `pixel_sizes` arrays for syncing per-purchasable print/pixel size prices. But if the route has no `{id}` segment, how does the server know which `Purchasable` to update? + +**Question:** Is the `purchasable_id` passed in the request body? Is there a route change planned that adds `{id}` to the URL? Or is this endpoint already implicitly scoped to the authenticated user's single purchasable? Clarification needed before T-043-23 and T-043-24 are implemented. + +**Blocking tasks:** T-043-23, T-043-24, T-043-26. + +--- + +### Q-043-15: Naming inconsistency — `CatalogController` vs `CatalogueSizesController` + +**Feature:** 043 – Webshop Print & Pixel Sizes +**Priority:** Low +**Status:** Open +**Opened:** 2026-05-31 +**Updated:** 2026-05-31 + +**Context:** The existing customer-facing controller is `App\Http\Controllers\Shop\CatalogController` (American spelling, no 'ue'). The plan (I8/T-043-27) introduces `App\Http\Controllers\Shop\CatalogueSizesController` (British spelling, with 'ue'). The same inconsistency appears in the route path: `GET /api/v2/Shop/Catalogue/Purchasable/{id}/Sizes` uses 'Catalogue' while the existing `GET /Shop` corresponds to `CatalogController`. + +**Question:** Should the new controller and route path use `Catalog` (matching the existing convention) or `Catalogue` (matching the spec as written)? + +**Blocking tasks:** T-043-27, T-043-28, T-043-40, T-043-43. + +--- + +### Q-043-16: Translation key placement — `webshop.php` vs `dialogs.php` + +**Feature:** 043 – Webshop Print & Pixel Sizes +**Priority:** Low +**Status:** Open +**Opened:** 2026-05-31 +**Updated:** 2026-05-31 + +**Context:** The plan (I17) mentions both `lang/en/webshop.php` and `lang/en/dialogs.php` as candidate files for new translation strings. The task (T-043-48) only mentions `lang/en/webshop.php`. New UI elements span several surfaces: basket modal labels (likely `dialogs.php`), checkout shipping form labels (likely `webshop.php`), admin sizes page labels, and purchasable form section headers. + +**Question:** Which translation keys go in `webshop.php` and which in `dialogs.php`? Should a new section be added to an existing file, or should new keys be appended alongside thematically related existing keys? + +**Blocking tasks:** T-043-48, T-043-49. + +--- + +### Q-043-17: `is_print` must be in basket GET response before frontend `hasPrints` can work + +**Feature:** 043 – Webshop Print & Pixel Sizes +**Priority:** Medium +**Status:** Open +**Opened:** 2026-05-31 +**Updated:** 2026-05-31 + +**Context:** I15 (T-043-44) requires `InfoSection.vue` to conditionally render the shipping address block via a computed `hasPrints` derived from the basket state. For `hasPrints` to work, each basket `OrderItem` in the `GET /api/v2/Shop/Basket/` response must expose the `is_print` field. This field is added to `OrderItemResource` in I11 (T-043-35), which is a later increment. + +**Question / Gap:** The cross-increment dependency from I15 on I11 is not captured in tasks.md. I15 cannot be fully implemented (or tested for the address-visible/hidden toggle) until I11 is complete. Should T-043-44 list T-043-35 as a prerequisite? Should I15 be reordered after I11 in the increment map, or should `is_print` be added to the basket GET response separately in an earlier increment? + +**Blocking tasks:** T-043-44 is blocked on T-043-35. The tasks.md dependency ordering needs to reflect this. + +--- + ### ~~Q-043-01: Pricing model for print/pixel sizes~~ ✅ RESOLVED **Feature:** 043 – Webshop Print & Pixel Sizes From 577d162d3cecb165059b7a0e68422ddffcb84dda Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 22:12:15 +0000 Subject: [PATCH 04/22] feat(043): add print/pixel size support backend implementation + tests Co-authored-by: ildyria <627094+ildyria@users.noreply.github.com> --- app/Actions/Shop/BasketService.php | 64 ++++- app/Actions/Shop/OrderService.php | 115 +++++++++ app/Actions/Shop/PurchasableService.php | 54 ++++ .../Http/Requests/RequestAttribute.php | 2 + app/DTO/PixelSizeAssignment.php | 20 ++ app/DTO/PrintSizeAssignment.php | 20 ++ app/Enum/PurchasableLicenseType.php | 1 + .../Admin/PixelSizeManagementController.php | 84 +++++++ .../Admin/PrintSizeManagementController.php | 88 +++++++ .../Admin/ShopManagementController.php | 7 +- .../Controllers/Shop/BasketController.php | 46 ++++ .../Shop/CatalogueSizesController.php | 56 +++++ .../Controllers/Shop/CheckoutController.php | 20 ++ .../Requests/Basket/AddPixelItemRequest.php | 64 +++++ .../Requests/Basket/AddPrintItemRequest.php | 64 +++++ .../Catalog/GetCatalogueSizesRequest.php | 43 ++++ .../Checkout/CreateSessionRequest.php | 18 ++ .../PixelSize/CreatePixelSizeRequest.php | 58 +++++ .../PixelSize/DeletePixelSizeRequest.php | 50 ++++ .../PixelSize/UpdatePixelSizeRequest.php | 62 +++++ .../PrintSize/CreatePrintSizeRequest.php | 64 +++++ .../PrintSize/DeletePrintSizeRequest.php | 50 ++++ .../PrintSize/UpdatePrintSizeRequest.php | 68 +++++ .../UpdatePurchasablePriceRequest.php | 28 +++ .../Resources/Shop/CatalogueSizesResource.php | 31 +++ .../Shop/EditablePurchasableResource.php | 6 + app/Http/Resources/Shop/OrderItemResource.php | 18 ++ app/Http/Resources/Shop/OrderResource.php | 12 + app/Http/Resources/Shop/PixelSizeResource.php | 44 ++++ app/Http/Resources/Shop/PrintSizeResource.php | 48 ++++ .../Shop/PurchasablePixelSizeResource.php | 50 ++++ .../Shop/PurchasablePrintSizeResource.php | 54 ++++ app/Models/Order.php | 62 +++-- app/Models/OrderItem.php | 43 +++- app/Models/PixelSize.php | 63 +++++ app/Models/PrintSize.php | 67 +++++ app/Models/Purchasable.php | 42 +++- app/Models/PurchasablePixelSize.php | 73 ++++++ app/Models/PurchasablePrintSize.php | 73 ++++++ database/factories/PixelSizeFactory.php | 53 ++++ database/factories/PrintSizeFactory.php | 56 +++++ .../factories/PurchasablePixelSizeFactory.php | 60 +++++ .../factories/PurchasablePrintSizeFactory.php | 60 +++++ ..._05_31_000001_create_print_sizes_table.php | 37 +++ ..._05_31_000002_create_pixel_sizes_table.php | 35 +++ ...3_create_purchasable_print_sizes_table.php | 36 +++ ...4_create_purchasable_pixel_sizes_table.php | 36 +++ ...31_000005_extend_order_items_for_print.php | 52 ++++ ...5_31_000006_extend_orders_for_shipping.php | 45 ++++ routes/api_v2_shop.php | 11 + .../Shop/OrderServiceAddSizeItemsTest.php | 238 ++++++++++++++++++ .../Actions/Shop/PurchasableSyncSizesTest.php | 206 +++++++++++++++ tests/Webshop/BasketPrintPixelItemsTest.php | 180 +++++++++++++ .../Checkout/CheckoutShippingAddressTest.php | 112 +++++++++ .../OrderShippingAddressDisplayTest.php | 120 +++++++++ .../CatalogueSizesControllerTest.php | 161 ++++++++++++ .../PixelSizeManagementControllerTest.php | 162 ++++++++++++ .../PrintSizeManagementControllerTest.php | 176 +++++++++++++ .../PurchasablePriceWithSizesTest.php | 206 +++++++++++++++ 59 files changed, 3843 insertions(+), 31 deletions(-) create mode 100644 app/DTO/PixelSizeAssignment.php create mode 100644 app/DTO/PrintSizeAssignment.php create mode 100644 app/Http/Controllers/Admin/PixelSizeManagementController.php create mode 100644 app/Http/Controllers/Admin/PrintSizeManagementController.php create mode 100644 app/Http/Controllers/Shop/CatalogueSizesController.php create mode 100644 app/Http/Requests/Basket/AddPixelItemRequest.php create mode 100644 app/Http/Requests/Basket/AddPrintItemRequest.php create mode 100644 app/Http/Requests/Catalog/GetCatalogueSizesRequest.php create mode 100644 app/Http/Requests/ShopManagement/PixelSize/CreatePixelSizeRequest.php create mode 100644 app/Http/Requests/ShopManagement/PixelSize/DeletePixelSizeRequest.php create mode 100644 app/Http/Requests/ShopManagement/PixelSize/UpdatePixelSizeRequest.php create mode 100644 app/Http/Requests/ShopManagement/PrintSize/CreatePrintSizeRequest.php create mode 100644 app/Http/Requests/ShopManagement/PrintSize/DeletePrintSizeRequest.php create mode 100644 app/Http/Requests/ShopManagement/PrintSize/UpdatePrintSizeRequest.php create mode 100644 app/Http/Resources/Shop/CatalogueSizesResource.php create mode 100644 app/Http/Resources/Shop/PixelSizeResource.php create mode 100644 app/Http/Resources/Shop/PrintSizeResource.php create mode 100644 app/Http/Resources/Shop/PurchasablePixelSizeResource.php create mode 100644 app/Http/Resources/Shop/PurchasablePrintSizeResource.php create mode 100644 app/Models/PixelSize.php create mode 100644 app/Models/PrintSize.php create mode 100644 app/Models/PurchasablePixelSize.php create mode 100644 app/Models/PurchasablePrintSize.php create mode 100644 database/factories/PixelSizeFactory.php create mode 100644 database/factories/PrintSizeFactory.php create mode 100644 database/factories/PurchasablePixelSizeFactory.php create mode 100644 database/factories/PurchasablePrintSizeFactory.php create mode 100644 database/migrations/2026_05_31_000001_create_print_sizes_table.php create mode 100644 database/migrations/2026_05_31_000002_create_pixel_sizes_table.php create mode 100644 database/migrations/2026_05_31_000003_create_purchasable_print_sizes_table.php create mode 100644 database/migrations/2026_05_31_000004_create_purchasable_pixel_sizes_table.php create mode 100644 database/migrations/2026_05_31_000005_extend_order_items_for_print.php create mode 100644 database/migrations/2026_05_31_000006_extend_orders_for_shipping.php create mode 100644 tests/Unit/Actions/Shop/OrderServiceAddSizeItemsTest.php create mode 100644 tests/Unit/Actions/Shop/PurchasableSyncSizesTest.php create mode 100644 tests/Webshop/BasketPrintPixelItemsTest.php create mode 100644 tests/Webshop/Checkout/CheckoutShippingAddressTest.php create mode 100644 tests/Webshop/OrderManagement/OrderShippingAddressDisplayTest.php create mode 100644 tests/Webshop/Purchasables/CatalogueSizesControllerTest.php create mode 100644 tests/Webshop/Purchasables/PixelSizeManagementControllerTest.php create mode 100644 tests/Webshop/Purchasables/PrintSizeManagementControllerTest.php create mode 100644 tests/Webshop/Purchasables/PurchasablePriceWithSizesTest.php diff --git a/app/Actions/Shop/BasketService.php b/app/Actions/Shop/BasketService.php index 2cdf78f9ef5..5e9bb461338 100644 --- a/app/Actions/Shop/BasketService.php +++ b/app/Actions/Shop/BasketService.php @@ -16,6 +16,8 @@ use App\Models\Album; use App\Models\Order; use App\Models\Photo; +use App\Models\PixelSize; +use App\Models\PrintSize; use App\Models\User; use App\Policies\AlbumPolicy; use App\Policies\AlbumQueryPolicy; @@ -184,7 +186,67 @@ public function addAlbumToBasket( } /** - * Remove an item from the basket. + * Add a photo to the basket as a physical print item. + * + * @param Order $basket The basket to add to + * @param Photo $photo The photo to add + * @param string $album_id The album ID the photo belongs to + * @param PrintSize $print_size The print size + * @param string|null $notes Optional notes for the item + * + * @return Order The updated basket + */ + public function addPrintItemToBasket( + Order $basket, + Photo $photo, + string $album_id, + PrintSize $print_size, + ?string $notes = null, + ): Order { + $this->ensurePendingStatus($basket); + $basket = $this->order_service->addPrintPhotoToOrder( + $basket, + $photo, + $album_id, + $print_size, + $notes + ); + + return $this->order_service->refreshBasket($basket); + } + + /** + * Add a photo to the basket as a custom pixel-size digital download. + * + * @param Order $basket The basket to add to + * @param Photo $photo The photo to add + * @param string $album_id The album ID the photo belongs to + * @param PixelSize $pixel_size The pixel size + * @param string|null $notes Optional notes for the item + * + * @return Order The updated basket + */ + public function addPixelItemToBasket( + Order $basket, + Photo $photo, + string $album_id, + PixelSize $pixel_size, + ?string $notes = null, + ): Order { + $this->ensurePendingStatus($basket); + $basket = $this->order_service->addPixelPhotoToOrder( + $basket, + $photo, + $album_id, + $pixel_size, + $notes + ); + + return $this->order_service->refreshBasket($basket); + } + + /** + * Remove an item from the basket. *. * * @param Order $basket The basket to remove from * @param int $item_id The ID of the order item to remove diff --git a/app/Actions/Shop/OrderService.php b/app/Actions/Shop/OrderService.php index ceae597495e..c0560962128 100644 --- a/app/Actions/Shop/OrderService.php +++ b/app/Actions/Shop/OrderService.php @@ -18,6 +18,8 @@ use App\Models\Order; use App\Models\OrderItem; use App\Models\Photo; +use App\Models\PixelSize; +use App\Models\PrintSize; use App\Models\User; use App\Repositories\ConfigManager; use Illuminate\Database\Eloquent\Builder; @@ -176,6 +178,119 @@ public function addPhotoToOrder( return $order; } + /** + * Add a photo to an order as a physical print item. + * + * @param Order $order The order to add to + * @param Photo $photo The photo to add + * @param string $album_id The album ID to consider for hierarchical pricing + * @param PrintSize $print_size The print size to use + * @param string|null $notes Additional notes for the item + * + * @return Order The updated Order (note: total not recalculated) + * + * @throws PhotoNotPurchasableException If the photo is not available for purchase + * @throws InvalidPurchaseOptionException If the print size is not available for this purchasable + */ + public function addPrintPhotoToOrder( + Order $order, + Photo $photo, + string $album_id, + PrintSize $print_size, + ?string $notes = null, + ): Order { + $purchasable = $this->purchasable_service->getEffectivePurchasableForPhoto($photo, $album_id); + + if ($purchasable === null) { + throw new PhotoNotPurchasableException(); + } + + /** @var \App\Models\PurchasablePrintSize|null $assignment */ + $assignment = $purchasable->printSizes() + ->where('print_size_id', $print_size->id) + ->first(); + + if ($assignment === null) { + throw new InvalidPurchaseOptionException(); + } + + $assignment->load('printSize'); + + OrderItem::create([ + 'order_id' => $order->id, + 'purchasable_id' => $purchasable->id, + 'photo_id' => $photo->id, + 'album_id' => $album_id, + 'title' => $photo->title ?? "Photo #{$photo->id}", + 'license_type' => PurchasableLicenseType::PRINT, + 'price_cents' => intval($assignment->price_cents->getAmount()), + 'size_variant_type' => PurchasableSizeVariantType::ORIGINAL, + 'is_print' => true, + 'print_size_id' => $print_size->id, + 'print_width' => $print_size->width, + 'print_height' => $print_size->height, + 'print_unit' => $print_size->unit, + 'print_paper_type' => $print_size->paper_type, + 'item_notes' => $notes, + ]); + + return $order; + } + + /** + * Add a photo to an order as a custom pixel-size digital download. + * + * @param Order $order The order to add to + * @param Photo $photo The photo to add + * @param string $album_id The album ID to consider for hierarchical pricing + * @param PixelSize $pixel_size The pixel size to use + * @param string|null $notes Additional notes for the item + * + * @return Order The updated Order (note: total not recalculated) + * + * @throws PhotoNotPurchasableException If the photo is not available for purchase + * @throws InvalidPurchaseOptionException If the pixel size is not available for this purchasable + */ + public function addPixelPhotoToOrder( + Order $order, + Photo $photo, + string $album_id, + PixelSize $pixel_size, + ?string $notes = null, + ): Order { + $purchasable = $this->purchasable_service->getEffectivePurchasableForPhoto($photo, $album_id); + + if ($purchasable === null) { + throw new PhotoNotPurchasableException(); + } + + /** @var \App\Models\PurchasablePixelSize|null $assignment */ + $assignment = $purchasable->pixelSizes() + ->where('pixel_size_id', $pixel_size->id) + ->first(); + + if ($assignment === null) { + throw new InvalidPurchaseOptionException(); + } + + OrderItem::create([ + 'order_id' => $order->id, + 'purchasable_id' => $purchasable->id, + 'photo_id' => $photo->id, + 'album_id' => $album_id, + 'title' => $photo->title ?? "Photo #{$photo->id}", + 'license_type' => PurchasableLicenseType::PERSONAL, + 'price_cents' => intval($assignment->price_cents->getAmount()), + 'size_variant_type' => PurchasableSizeVariantType::ORIGINAL, + 'pixel_size_id' => $pixel_size->id, + 'pixel_width' => $pixel_size->width, + 'pixel_height' => $pixel_size->height, + 'item_notes' => $notes, + ]); + + return $order; + } + /** * Refresh basket data and recalculate order total. * diff --git a/app/Actions/Shop/PurchasableService.php b/app/Actions/Shop/PurchasableService.php index 60bc2ffb1c0..00314fcb36d 100644 --- a/app/Actions/Shop/PurchasableService.php +++ b/app/Actions/Shop/PurchasableService.php @@ -9,13 +9,17 @@ namespace App\Actions\Shop; use App\Constants\PhotoAlbum as PA; +use App\DTO\PixelSizeAssignment; +use App\DTO\PrintSizeAssignment; use App\DTO\PurchasableOption; use App\DTO\PurchasableOptionCreate; use App\Exceptions\Internal\LycheeLogicException; use App\Models\Album; use App\Models\Photo; use App\Models\Purchasable; +use App\Models\PurchasablePixelSize; use App\Models\PurchasablePrice; +use App\Models\PurchasablePrintSize; use Illuminate\Support\Facades\DB; class PurchasableService @@ -296,4 +300,54 @@ public function deleteMultipleAlbumPurchasables(array $album_ids): void ->whereIn('album_id', $album_ids) ->delete(); } + + /** + * Sync print size assignments for a purchasable item. + * + * Replaces all existing print size assignments with the provided list. + * + * @param Purchasable $purchasable The purchasable item to update + * @param PrintSizeAssignment[] $print_size_assignments Array of print size assignments + * + * @return Purchasable The updated purchasable item + */ + public function syncPrintSizes(Purchasable $purchasable, array $print_size_assignments): Purchasable + { + $purchasable->printSizes()->delete(); + + foreach ($print_size_assignments as $assignment) { + PurchasablePrintSize::create([ + 'purchasable_id' => $purchasable->id, + 'print_size_id' => $assignment->print_size_id, + 'price_cents' => intval($assignment->price->getAmount()), + ]); + } + + return $purchasable; + } + + /** + * Sync pixel size assignments for a purchasable item. + * + * Replaces all existing pixel size assignments with the provided list. + * + * @param Purchasable $purchasable The purchasable item to update + * @param PixelSizeAssignment[] $pixel_size_assignments Array of pixel size assignments + * + * @return Purchasable The updated purchasable item + */ + public function syncPixelSizes(Purchasable $purchasable, array $pixel_size_assignments): Purchasable + { + $purchasable->pixelSizes()->delete(); + + foreach ($pixel_size_assignments as $assignment) { + PurchasablePixelSize::create([ + 'purchasable_id' => $purchasable->id, + 'pixel_size_id' => $assignment->pixel_size_id, + 'price_cents' => intval($assignment->price->getAmount()), + ]); + } + + return $purchasable; + } } diff --git a/app/Contracts/Http/Requests/RequestAttribute.php b/app/Contracts/Http/Requests/RequestAttribute.php index 4dd0a3da0b2..153b8fb5c24 100644 --- a/app/Contracts/Http/Requests/RequestAttribute.php +++ b/app/Contracts/Http/Requests/RequestAttribute.php @@ -139,6 +139,8 @@ class RequestAttribute public const BASKET_ID_ATTRIBUTE = 'basket_id'; public const TRANSACTION_ID_ATTRIBUTE = 'transaction_id'; public const PRICES_ATTRIBUTE = 'prices'; + public const PRINT_SIZES_ATTRIBUTE = 'print_sizes'; + public const PIXEL_SIZES_ATTRIBUTE = 'pixel_sizes'; public const SIZE_VARIANT_TYPE_ATTRIBUTE = 'size_variant_type'; public const LICENSE_TYPE_ATTRIBUTE = 'license_type'; public const IS_ACTIVE_ATTRIBUTE = 'is_active'; diff --git a/app/DTO/PixelSizeAssignment.php b/app/DTO/PixelSizeAssignment.php new file mode 100644 index 00000000000..16a95b6b51c --- /dev/null +++ b/app/DTO/PixelSizeAssignment.php @@ -0,0 +1,20 @@ +all()); + } + + /** + * Create a new pixel size in the global catalogue. + * + * @param CreatePixelSizeRequest $request + * + * @return PixelSizeResource + */ + public function store(CreatePixelSizeRequest $request): PixelSizeResource + { + $pixel_size = PixelSize::create([ + 'label' => $request->label, + 'width' => $request->width, + 'height' => $request->height, + 'is_active' => $request->is_active, + ]); + + return PixelSizeResource::fromModel($pixel_size); + } + + /** + * Update a pixel size in the global catalogue. + * + * @param UpdatePixelSizeRequest $request + * + * @return PixelSizeResource + */ + public function update(UpdatePixelSizeRequest $request): PixelSizeResource + { + $request->pixel_size->update([ + 'label' => $request->label, + 'width' => $request->width, + 'height' => $request->height, + 'is_active' => $request->is_active, + ]); + + return PixelSizeResource::fromModel($request->pixel_size); + } + + /** + * Delete a pixel size from the global catalogue. + * Existing purchasable_pixel_sizes rows and order item snapshots are preserved. + * + * @param DeletePixelSizeRequest $request + * + * @return void + */ + public function destroy(DeletePixelSizeRequest $request): void + { + $request->pixel_size->delete(); + } +} diff --git a/app/Http/Controllers/Admin/PrintSizeManagementController.php b/app/Http/Controllers/Admin/PrintSizeManagementController.php new file mode 100644 index 00000000000..314ff98982e --- /dev/null +++ b/app/Http/Controllers/Admin/PrintSizeManagementController.php @@ -0,0 +1,88 @@ +all()); + } + + /** + * Create a new print size in the global catalogue. + * + * @param CreatePrintSizeRequest $request + * + * @return PrintSizeResource + */ + public function store(CreatePrintSizeRequest $request): PrintSizeResource + { + $print_size = PrintSize::create([ + 'label' => $request->label, + 'width' => $request->width, + 'height' => $request->height, + 'unit' => $request->unit, + 'paper_type' => $request->paper_type, + 'is_active' => $request->is_active, + ]); + + return PrintSizeResource::fromModel($print_size); + } + + /** + * Update a print size in the global catalogue. + * + * @param UpdatePrintSizeRequest $request + * + * @return PrintSizeResource + */ + public function update(UpdatePrintSizeRequest $request): PrintSizeResource + { + $request->print_size->update([ + 'label' => $request->label, + 'width' => $request->width, + 'height' => $request->height, + 'unit' => $request->unit, + 'paper_type' => $request->paper_type, + 'is_active' => $request->is_active, + ]); + + return PrintSizeResource::fromModel($request->print_size); + } + + /** + * Delete a print size from the global catalogue. + * Existing purchasable_print_sizes rows and order item snapshots are preserved. + * + * @param DeletePrintSizeRequest $request + * + * @return void + */ + public function destroy(DeletePrintSizeRequest $request): void + { + $request->print_size->delete(); + } +} diff --git a/app/Http/Controllers/Admin/ShopManagementController.php b/app/Http/Controllers/Admin/ShopManagementController.php index c507fa7fc96..87e011e2bb4 100644 --- a/app/Http/Controllers/Admin/ShopManagementController.php +++ b/app/Http/Controllers/Admin/ShopManagementController.php @@ -50,7 +50,7 @@ public function options(ListPurchasablesRequest $request): ConfigOptionResource */ public function list(ListPurchasablesRequest $request): array { - $purchasables = Purchasable::with(['album', 'photo', 'prices', 'photo.size_variants']) + $purchasables = Purchasable::with(['album', 'photo', 'prices', 'printSizes', 'pixelSizes', 'photo.size_variants']) ->when(count($request->albumIds()) > 0, function ($query) use ($request): void { $query->whereIn('album_id', $request->albumIds()); }) @@ -119,6 +119,9 @@ public function updatePurchasablePrices(UpdatePurchasablePriceRequest $request): prices: $request->prices ); + $this->purchasable_service->syncPrintSizes($purchasable, $request->print_sizes); + $this->purchasable_service->syncPixelSizes($purchasable, $request->pixel_sizes); + // If there's a description or notes update, we need to update those too $updated = false; if ($request->description() !== null) { @@ -133,6 +136,8 @@ public function updatePurchasablePrices(UpdatePurchasablePriceRequest $request): $purchasable->save(); } + $purchasable->load(['prices', 'printSizes', 'pixelSizes']); + return EditablePurchasableResource::fromModel($purchasable); } diff --git a/app/Http/Controllers/Shop/BasketController.php b/app/Http/Controllers/Shop/BasketController.php index 5b393a86edd..e27566377f7 100644 --- a/app/Http/Controllers/Shop/BasketController.php +++ b/app/Http/Controllers/Shop/BasketController.php @@ -14,6 +14,8 @@ use App\Exceptions\Shop\OrderIsNotPendingException; use App\Http\Requests\Basket\AddAlbumToBasketRequest; use App\Http\Requests\Basket\AddPhotoToBasketRequest; +use App\Http\Requests\Basket\AddPixelItemRequest; +use App\Http\Requests\Basket\AddPrintItemRequest; use App\Http\Requests\Basket\DeleteBasketRequest; use App\Http\Requests\Basket\DeleteItemRequest; use App\Http\Requests\Basket\GetBasketRequest; @@ -76,6 +78,50 @@ public function addAlbum(AddAlbumToBasketRequest $request): OrderResource return OrderResource::fromModel($basket); } + /** + * Add a photo to the basket as a physical print item. + * + * @param AddPrintItemRequest $request + * + * @return OrderResource + */ + public function addPrintItem(AddPrintItemRequest $request): OrderResource + { + $basket = $request->basket(); + + $basket = $this->basket_service->addPrintItemToBasket( + $basket, + $request->photo, + $request->album_id, + $request->print_size, + $request->notes + ); + + return OrderResource::fromModel($basket); + } + + /** + * Add a photo to the basket as a custom pixel-size digital download. + * + * @param AddPixelItemRequest $request + * + * @return OrderResource + */ + public function addPixelItem(AddPixelItemRequest $request): OrderResource + { + $basket = $request->basket(); + + $basket = $this->basket_service->addPixelItemToBasket( + $basket, + $request->photo, + $request->album_id, + $request->pixel_size, + $request->notes + ); + + return OrderResource::fromModel($basket); + } + /** * Remove an item from the basket. * diff --git a/app/Http/Controllers/Shop/CatalogueSizesController.php b/app/Http/Controllers/Shop/CatalogueSizesController.php new file mode 100644 index 00000000000..915a23329d5 --- /dev/null +++ b/app/Http/Controllers/Shop/CatalogueSizesController.php @@ -0,0 +1,56 @@ +purchasable; + + $print_sizes = PurchasablePrintSize::with('printSize') + ->where('purchasable_id', $purchasable->id) + ->whereHas('printSize', fn ($q) => $q->where('is_active', true)) + ->get() + ->map(fn (PurchasablePrintSize $pps) => PurchasablePrintSizeResource::fromModel($pps)) + ->values() + ->all(); + + $pixel_sizes = PurchasablePixelSize::with('pixelSize') + ->where('purchasable_id', $purchasable->id) + ->whereHas('pixelSize', fn ($q) => $q->where('is_active', true)) + ->get() + ->map(fn (PurchasablePixelSize $pps) => PurchasablePixelSizeResource::fromModel($pps)) + ->values() + ->all(); + + return new CatalogueSizesResource( + print_sizes: $print_sizes, + pixel_sizes: $pixel_sizes, + ); + } +} diff --git a/app/Http/Controllers/Shop/CheckoutController.php b/app/Http/Controllers/Shop/CheckoutController.php index aa710c5d7bc..20f48af78e3 100644 --- a/app/Http/Controllers/Shop/CheckoutController.php +++ b/app/Http/Controllers/Shop/CheckoutController.php @@ -67,6 +67,26 @@ public function createSession(CreateSessionRequest $request): OrderResource $order->email = $request->email; } + // Store shipping address if provided + if ($request->shipping_street_name !== null) { + $order->shipping_street_name = $request->shipping_street_name; + } + if ($request->shipping_street_number !== null) { + $order->shipping_street_number = $request->shipping_street_number; + } + if ($request->shipping_additional_info !== null) { + $order->shipping_additional_info = $request->shipping_additional_info; + } + if ($request->shipping_city !== null) { + $order->shipping_city = $request->shipping_city; + } + if ($request->shipping_post_code !== null) { + $order->shipping_post_code = $request->shipping_post_code; + } + if ($request->shipping_country !== null) { + $order->shipping_country = $request->shipping_country; + } + // Save changes to order $order->save(); diff --git a/app/Http/Requests/Basket/AddPixelItemRequest.php b/app/Http/Requests/Basket/AddPixelItemRequest.php new file mode 100644 index 00000000000..c802b0900f8 --- /dev/null +++ b/app/Http/Requests/Basket/AddPixelItemRequest.php @@ -0,0 +1,64 @@ +order?->canAddItems() === true && $this->photo->albums()->where('id', $this->album_id)->exists(); + } + + /** + * Get the validation rules that apply to the request. + */ + public function rules(): array + { + return [ + 'photo_id' => ['required', 'string'], + 'album_id' => ['required', 'string'], + 'pixel_size_id' => ['required', 'integer', 'exists:pixel_sizes,id'], + 'email' => ['sometimes', 'nullable', 'email'], + 'notes' => ['sometimes', 'nullable', 'string', 'max:1000'], + ]; + } + + /** + * Process the validated values. + * + * @param array $values + * @param array $files + * + * @return void + * + * @throws ModelNotFoundException + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->photo = Photo::query()->findOrFail($values['photo_id']); + $this->album_id = $values['album_id']; + $this->pixel_size = PixelSize::findOrFail($values['pixel_size_id']); + $this->email = $values['email'] ?? null; + $this->notes = $values['notes'] ?? null; + } +} diff --git a/app/Http/Requests/Basket/AddPrintItemRequest.php b/app/Http/Requests/Basket/AddPrintItemRequest.php new file mode 100644 index 00000000000..9707f2782e4 --- /dev/null +++ b/app/Http/Requests/Basket/AddPrintItemRequest.php @@ -0,0 +1,64 @@ +order?->canAddItems() === true && $this->photo->albums()->where('id', $this->album_id)->exists(); + } + + /** + * Get the validation rules that apply to the request. + */ + public function rules(): array + { + return [ + 'photo_id' => ['required', 'string'], + 'album_id' => ['required', 'string'], + 'print_size_id' => ['required', 'integer', 'exists:print_sizes,id'], + 'email' => ['sometimes', 'nullable', 'email'], + 'notes' => ['sometimes', 'nullable', 'string', 'max:1000'], + ]; + } + + /** + * Process the validated values. + * + * @param array $values + * @param array $files + * + * @return void + * + * @throws ModelNotFoundException + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->photo = Photo::query()->findOrFail($values['photo_id']); + $this->album_id = $values['album_id']; + $this->print_size = PrintSize::findOrFail($values['print_size_id']); + $this->email = $values['email'] ?? null; + $this->notes = $values['notes'] ?? null; + } +} diff --git a/app/Http/Requests/Catalog/GetCatalogueSizesRequest.php b/app/Http/Requests/Catalog/GetCatalogueSizesRequest.php new file mode 100644 index 00000000000..d0f50982048 --- /dev/null +++ b/app/Http/Requests/Catalog/GetCatalogueSizesRequest.php @@ -0,0 +1,43 @@ + 'required|integer|exists:purchasables,id', + ]; + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->purchasable = Purchasable::findOrFail($values['purchasable_id']); + } +} diff --git a/app/Http/Requests/Checkout/CreateSessionRequest.php b/app/Http/Requests/Checkout/CreateSessionRequest.php index 2260920ff21..7f5a66c8449 100644 --- a/app/Http/Requests/Checkout/CreateSessionRequest.php +++ b/app/Http/Requests/Checkout/CreateSessionRequest.php @@ -21,6 +21,12 @@ class CreateSessionRequest extends BaseApiRequest implements HasBasket public ?string $email; public ?OmnipayProviderType $provider; + public ?string $shipping_street_name; + public ?string $shipping_street_number; + public ?string $shipping_additional_info; + public ?string $shipping_city; + public ?string $shipping_post_code; + public ?string $shipping_country; /** * Determine if the user is authorized to make this request. @@ -37,6 +43,12 @@ public function rules(): array return [ RequestAttribute::PROVIDER_ATTRIBUTE => ['sometimes', new Enum(OmnipayProviderType::class)], RequestAttribute::EMAIL_ATTRIBUTE => ['sometimes', 'email'], + 'shipping_street_name' => ['sometimes', 'nullable', 'string', 'max:255'], + 'shipping_street_number' => ['sometimes', 'nullable', 'string', 'max:50'], + 'shipping_additional_info' => ['sometimes', 'nullable', 'string', 'max:255'], + 'shipping_city' => ['sometimes', 'nullable', 'string', 'max:255'], + 'shipping_post_code' => ['sometimes', 'nullable', 'string', 'max:20'], + 'shipping_country' => ['sometimes', 'nullable', 'string', 'max:2'], ]; } @@ -45,5 +57,11 @@ protected function processValidatedValues(array $values, array $files): void $this->prepareBasket(); $this->email = $values[RequestAttribute::EMAIL_ATTRIBUTE] ?? null; $this->provider = isset($values[RequestAttribute::PROVIDER_ATTRIBUTE]) ? OmnipayProviderType::from($values[RequestAttribute::PROVIDER_ATTRIBUTE]) : null; + $this->shipping_street_name = $values['shipping_street_name'] ?? null; + $this->shipping_street_number = $values['shipping_street_number'] ?? null; + $this->shipping_additional_info = $values['shipping_additional_info'] ?? null; + $this->shipping_city = $values['shipping_city'] ?? null; + $this->shipping_post_code = $values['shipping_post_code'] ?? null; + $this->shipping_country = $values['shipping_country'] ?? null; } } diff --git a/app/Http/Requests/ShopManagement/PixelSize/CreatePixelSizeRequest.php b/app/Http/Requests/ShopManagement/PixelSize/CreatePixelSizeRequest.php new file mode 100644 index 00000000000..fabaed95d95 --- /dev/null +++ b/app/Http/Requests/ShopManagement/PixelSize/CreatePixelSizeRequest.php @@ -0,0 +1,58 @@ +configs()->getValueAsInt('owner_id') === $user_id; + } + + /** + * {@inheritDoc} + */ + public function rules(): array + { + return [ + 'label' => 'required|string|max:100', + 'width' => 'required|integer|min:1', + 'height' => 'required|integer|min:1', + 'is_active' => 'required|boolean', + ]; + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->label = $values['label']; + $this->width = (int) $values['width']; + $this->height = (int) $values['height']; + $this->is_active = self::toBoolean($values['is_active']); + } +} diff --git a/app/Http/Requests/ShopManagement/PixelSize/DeletePixelSizeRequest.php b/app/Http/Requests/ShopManagement/PixelSize/DeletePixelSizeRequest.php new file mode 100644 index 00000000000..8069225ec47 --- /dev/null +++ b/app/Http/Requests/ShopManagement/PixelSize/DeletePixelSizeRequest.php @@ -0,0 +1,50 @@ +configs()->getValueAsInt('owner_id') === $user_id; + } + + /** + * {@inheritDoc} + */ + public function rules(): array + { + return [ + 'pixel_size_id' => 'required|integer|exists:pixel_sizes,id', + ]; + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->pixel_size = PixelSize::findOrFail($values['pixel_size_id']); + } +} diff --git a/app/Http/Requests/ShopManagement/PixelSize/UpdatePixelSizeRequest.php b/app/Http/Requests/ShopManagement/PixelSize/UpdatePixelSizeRequest.php new file mode 100644 index 00000000000..2a8403e4b69 --- /dev/null +++ b/app/Http/Requests/ShopManagement/PixelSize/UpdatePixelSizeRequest.php @@ -0,0 +1,62 @@ +configs()->getValueAsInt('owner_id') === $user_id; + } + + /** + * {@inheritDoc} + */ + public function rules(): array + { + return [ + 'pixel_size_id' => 'required|integer|exists:pixel_sizes,id', + 'label' => 'required|string|max:100', + 'width' => 'required|integer|min:1', + 'height' => 'required|integer|min:1', + 'is_active' => 'required|boolean', + ]; + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->pixel_size = PixelSize::findOrFail($values['pixel_size_id']); + $this->label = $values['label']; + $this->width = (int) $values['width']; + $this->height = (int) $values['height']; + $this->is_active = self::toBoolean($values['is_active']); + } +} diff --git a/app/Http/Requests/ShopManagement/PrintSize/CreatePrintSizeRequest.php b/app/Http/Requests/ShopManagement/PrintSize/CreatePrintSizeRequest.php new file mode 100644 index 00000000000..b109e89d692 --- /dev/null +++ b/app/Http/Requests/ShopManagement/PrintSize/CreatePrintSizeRequest.php @@ -0,0 +1,64 @@ +configs()->getValueAsInt('owner_id') === $user_id; + } + + /** + * {@inheritDoc} + */ + public function rules(): array + { + return [ + 'label' => 'required|string|max:100', + 'width' => 'required|integer|min:1', + 'height' => 'required|integer|min:1', + 'unit' => 'required|string|in:cm,inch', + 'paper_type' => 'nullable|string|max:100', + 'is_active' => 'required|boolean', + ]; + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->label = $values['label']; + $this->width = (int) $values['width']; + $this->height = (int) $values['height']; + $this->unit = $values['unit']; + $this->paper_type = $values['paper_type'] ?? null; + $this->is_active = self::toBoolean($values['is_active']); + } +} diff --git a/app/Http/Requests/ShopManagement/PrintSize/DeletePrintSizeRequest.php b/app/Http/Requests/ShopManagement/PrintSize/DeletePrintSizeRequest.php new file mode 100644 index 00000000000..27be16e86c2 --- /dev/null +++ b/app/Http/Requests/ShopManagement/PrintSize/DeletePrintSizeRequest.php @@ -0,0 +1,50 @@ +configs()->getValueAsInt('owner_id') === $user_id; + } + + /** + * {@inheritDoc} + */ + public function rules(): array + { + return [ + 'print_size_id' => 'required|integer|exists:print_sizes,id', + ]; + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->print_size = PrintSize::findOrFail($values['print_size_id']); + } +} diff --git a/app/Http/Requests/ShopManagement/PrintSize/UpdatePrintSizeRequest.php b/app/Http/Requests/ShopManagement/PrintSize/UpdatePrintSizeRequest.php new file mode 100644 index 00000000000..56588cf1ab7 --- /dev/null +++ b/app/Http/Requests/ShopManagement/PrintSize/UpdatePrintSizeRequest.php @@ -0,0 +1,68 @@ +configs()->getValueAsInt('owner_id') === $user_id; + } + + /** + * {@inheritDoc} + */ + public function rules(): array + { + return [ + 'print_size_id' => 'required|integer|exists:print_sizes,id', + 'label' => 'required|string|max:100', + 'width' => 'required|integer|min:1', + 'height' => 'required|integer|min:1', + 'unit' => 'required|string|in:cm,inch', + 'paper_type' => 'nullable|string|max:100', + 'is_active' => 'required|boolean', + ]; + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->print_size = PrintSize::findOrFail($values['print_size_id']); + $this->label = $values['label']; + $this->width = (int) $values['width']; + $this->height = (int) $values['height']; + $this->unit = $values['unit']; + $this->paper_type = $values['paper_type'] ?? null; + $this->is_active = self::toBoolean($values['is_active']); + } +} diff --git a/app/Http/Requests/ShopManagement/UpdatePurchasablePriceRequest.php b/app/Http/Requests/ShopManagement/UpdatePurchasablePriceRequest.php index 05adbad4e2c..6e0c5f43cb5 100644 --- a/app/Http/Requests/ShopManagement/UpdatePurchasablePriceRequest.php +++ b/app/Http/Requests/ShopManagement/UpdatePurchasablePriceRequest.php @@ -10,6 +10,8 @@ use App\Contracts\Http\Requests\HasDescription; use App\Contracts\Http\Requests\RequestAttribute; +use App\DTO\PixelSizeAssignment; +use App\DTO\PrintSizeAssignment; use App\DTO\PurchasableOptionCreate; use App\Enum\PurchasableLicenseType; use App\Enum\PurchasableSizeVariantType; @@ -27,6 +29,10 @@ class UpdatePurchasablePriceRequest extends BaseApiRequest implements HasDescrip public ?string $notes; /** @var PurchasableOptionCreate[] */ public array $prices; + /** @var PrintSizeAssignment[] */ + public array $print_sizes; + /** @var PixelSizeAssignment[] */ + public array $pixel_sizes; public Purchasable $purchasable; /** @@ -56,6 +62,12 @@ public function rules(): array RequestAttribute::PRICES_ATTRIBUTE . '.*.size_variant_type' => ['required', new Enum(PurchasableSizeVariantType::class)], RequestAttribute::PRICES_ATTRIBUTE . '.*.license_type' => ['required', new Enum(PurchasableLicenseType::class)], RequestAttribute::PRICES_ATTRIBUTE . '.*.price' => 'required|integer|min:0|max:1000000', // max 10,000.00 in cents + RequestAttribute::PRINT_SIZES_ATTRIBUTE => 'sometimes|array', + RequestAttribute::PRINT_SIZES_ATTRIBUTE . '.*.print_size_id' => 'required|integer|exists:print_sizes,id', + RequestAttribute::PRINT_SIZES_ATTRIBUTE . '.*.price' => 'required|integer|min:0|max:1000000', + RequestAttribute::PIXEL_SIZES_ATTRIBUTE => 'sometimes|array', + RequestAttribute::PIXEL_SIZES_ATTRIBUTE . '.*.pixel_size_id' => 'required|integer|exists:pixel_sizes,id', + RequestAttribute::PIXEL_SIZES_ATTRIBUTE . '.*.price' => 'required|integer|min:0|max:1000000', ]; } @@ -80,5 +92,21 @@ protected function processValidatedValues(array $values, array $files): void $money_service->createFromCents($price['price']), ); } + + $this->print_sizes = []; + foreach ($values[RequestAttribute::PRINT_SIZES_ATTRIBUTE] ?? [] as $item) { + $this->print_sizes[] = new PrintSizeAssignment( + print_size_id: $item['print_size_id'], + price: $money_service->createFromCents($item['price']), + ); + } + + $this->pixel_sizes = []; + foreach ($values[RequestAttribute::PIXEL_SIZES_ATTRIBUTE] ?? [] as $item) { + $this->pixel_sizes[] = new PixelSizeAssignment( + pixel_size_id: $item['pixel_size_id'], + price: $money_service->createFromCents($item['price']), + ); + } } } diff --git a/app/Http/Resources/Shop/CatalogueSizesResource.php b/app/Http/Resources/Shop/CatalogueSizesResource.php new file mode 100644 index 00000000000..6b4e3546f70 --- /dev/null +++ b/app/Http/Resources/Shop/CatalogueSizesResource.php @@ -0,0 +1,31 @@ +prices->map(PriceResource::fromModel(...))->toArray(), + print_sizes: $item->printSizes->map(PurchasablePrintSizeResource::fromModel(...))->toArray(), + pixel_sizes: $item->pixelSizes->map(PurchasablePixelSizeResource::fromModel(...))->toArray(), description: $item->description, owner_notes: $item->owner_notes, is_active: $item->is_active, diff --git a/app/Http/Resources/Shop/OrderItemResource.php b/app/Http/Resources/Shop/OrderItemResource.php index e3b8e62f18f..9023b3a3c4c 100644 --- a/app/Http/Resources/Shop/OrderItemResource.php +++ b/app/Http/Resources/Shop/OrderItemResource.php @@ -30,6 +30,15 @@ public function __construct( public PurchasableSizeVariantType $size_variant_type, public ?string $item_notes, public ?string $content_url, + public bool $is_print, + public ?int $print_size_id, + public ?int $print_width, + public ?int $print_height, + public ?string $print_unit, + public ?string $print_paper_type, + public ?int $pixel_size_id, + public ?int $pixel_width, + public ?int $pixel_height, ) { } @@ -52,6 +61,15 @@ public static function fromModel(OrderItem $item): OrderItemResource size_variant_type: $item->size_variant_type, item_notes: $item->item_notes, content_url: $item->content_url, + is_print: $item->is_print, + print_size_id: $item->print_size_id, + print_width: $item->print_width, + print_height: $item->print_height, + print_unit: $item->print_unit, + print_paper_type: $item->print_paper_type, + pixel_size_id: $item->pixel_size_id, + pixel_width: $item->pixel_width, + pixel_height: $item->pixel_height, ); } } diff --git a/app/Http/Resources/Shop/OrderResource.php b/app/Http/Resources/Shop/OrderResource.php index 06e9f1c9a04..9573bd420e4 100644 --- a/app/Http/Resources/Shop/OrderResource.php +++ b/app/Http/Resources/Shop/OrderResource.php @@ -39,6 +39,12 @@ public function __construct( #[LiteralTypeScriptType('App.Http.Resources.Shop.OrderItemResource[]|null')] public ?Collection $items, public bool $can_process_payment, + public ?string $shipping_street_name, + public ?string $shipping_street_number, + public ?string $shipping_additional_info, + public ?string $shipping_city, + public ?string $shipping_post_code, + public ?string $shipping_country, ) { } @@ -68,6 +74,12 @@ public static function fromModel(Order $order): OrderResource comment: $order->comment, items: $order->relationLoaded('items') ? OrderItemResource::collect($order->items) : null, can_process_payment: $order->relationLoaded('items') ? $order->canProcessPayment() : false, // only if items are loaded we are able to check this. + shipping_street_name: $order->shipping_street_name, + shipping_street_number: $order->shipping_street_number, + shipping_additional_info: $order->shipping_additional_info, + shipping_city: $order->shipping_city, + shipping_post_code: $order->shipping_post_code, + shipping_country: $order->shipping_country, ); } } diff --git a/app/Http/Resources/Shop/PixelSizeResource.php b/app/Http/Resources/Shop/PixelSizeResource.php new file mode 100644 index 00000000000..5a6b40e5c10 --- /dev/null +++ b/app/Http/Resources/Shop/PixelSizeResource.php @@ -0,0 +1,44 @@ +id, + label: $pixel_size->label, + width: $pixel_size->width, + height: $pixel_size->height, + is_active: $pixel_size->is_active, + ); + } +} diff --git a/app/Http/Resources/Shop/PrintSizeResource.php b/app/Http/Resources/Shop/PrintSizeResource.php new file mode 100644 index 00000000000..7e872e433ed --- /dev/null +++ b/app/Http/Resources/Shop/PrintSizeResource.php @@ -0,0 +1,48 @@ +id, + label: $print_size->label, + width: $print_size->width, + height: $print_size->height, + unit: $print_size->unit, + paper_type: $print_size->paper_type, + is_active: $print_size->is_active, + ); + } +} diff --git a/app/Http/Resources/Shop/PurchasablePixelSizeResource.php b/app/Http/Resources/Shop/PurchasablePixelSizeResource.php new file mode 100644 index 00000000000..27b7278c626 --- /dev/null +++ b/app/Http/Resources/Shop/PurchasablePixelSizeResource.php @@ -0,0 +1,50 @@ +id, + pixel_size_id: $purchasable_pixel_size->pixel_size_id, + label: $purchasable_pixel_size->pixelSize->label, + width: $purchasable_pixel_size->pixelSize->width, + height: $purchasable_pixel_size->pixelSize->height, + price: $money_service->format($purchasable_pixel_size->price_cents), + price_cents: intval($purchasable_pixel_size->price_cents->getAmount()), + ); + } +} diff --git a/app/Http/Resources/Shop/PurchasablePrintSizeResource.php b/app/Http/Resources/Shop/PurchasablePrintSizeResource.php new file mode 100644 index 00000000000..ee3047f1cc4 --- /dev/null +++ b/app/Http/Resources/Shop/PurchasablePrintSizeResource.php @@ -0,0 +1,54 @@ +id, + print_size_id: $purchasable_print_size->print_size_id, + label: $purchasable_print_size->printSize->label, + width: $purchasable_print_size->printSize->width, + height: $purchasable_print_size->printSize->height, + unit: $purchasable_print_size->printSize->unit, + paper_type: $purchasable_print_size->printSize->paper_type, + price: $money_service->format($purchasable_print_size->price_cents), + price_cents: intval($purchasable_print_size->price_cents->getAmount()), + ); + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php index bcce097706d..27c9508b6ef 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -43,19 +43,25 @@ * Payment processing is handled through Omnipay providers (Stripe, Mollie, * PayPal, etc.) with support for offline payments when enabled. * - * @property int $id Primary key - * @property string $transaction_id Unique identifier for payment provider tracking - * @property OmnipayProviderType $provider Payment provider used (Stripe, Mollie, PayPal, etc.) - * @property int|null $user_id Foreign key to users table (null for guest purchases) - * @property string|null $email Customer email (required for guest purchases and FULL variants) - * @property PaymentStatusType $status Current order status (pending, processing, completed, etc.) - * @property Money $amount_cents Total order amount using Money library for precision - * @property Carbon|null $created_at Order creation timestamp - * @property Carbon|null $updated_at Last modification timestamp - * @property Carbon|null $paid_at Payment completion timestamp (null if unpaid) - * @property string|null $comment Optional order notes or comments - * @property User|null $user Associated user account (null for guest purchases) - * @property Collection $items Collection of items purchased in this order + * @property int $id Primary key + * @property string $transaction_id Unique identifier for payment provider tracking + * @property OmnipayProviderType $provider Payment provider used (Stripe, Mollie, PayPal, etc.) + * @property int|null $user_id Foreign key to users table (null for guest purchases) + * @property string|null $email Customer email (required for guest purchases and FULL variants) + * @property PaymentStatusType $status Current order status (pending, processing, completed, etc.) + * @property Money $amount_cents Total order amount using Money library for precision + * @property Carbon|null $created_at Order creation timestamp + * @property Carbon|null $updated_at Last modification timestamp + * @property Carbon|null $paid_at Payment completion timestamp (null if unpaid) + * @property string|null $comment Optional order notes or comments + * @property string|null $shipping_street_name Shipping address street name (required for print orders) + * @property string|null $shipping_street_number Shipping address street number + * @property string|null $shipping_additional_info Additional shipping address info + * @property string|null $shipping_city Shipping address city (required for print orders) + * @property string|null $shipping_post_code Shipping address post code (required for print orders) + * @property string|null $shipping_country Shipping address country (required for print orders) + * @property User|null $user Associated user account (null for guest purchases) + * @property Collection $items Collection of items purchased in this order * * @see OrderItem Individual items within the order * @see PaymentStatusType Order status enumeration @@ -79,6 +85,12 @@ class Order extends Model 'amount_cents', 'paid_at', 'comment', + 'shipping_street_name', + 'shipping_street_number', + 'shipping_additional_info', + 'shipping_city', + 'shipping_post_code', + 'shipping_country', ]; /** @@ -279,6 +291,8 @@ public function canAddItems(): bool * 3. Contact information requirements must be satisfied: * - Email address is provided, OR * - User is logged in AND order contains no FULL size variants + * 4. When order contains print items, all required shipping address fields + * must be non-empty (street name, city, post code, country) * * The email requirement for FULL variants exists because these require * manual processing and delivery by the photographer, necessitating @@ -300,14 +314,26 @@ public function canProcessPayment(): bool // Email is set, we are fine. if ($this->email !== null && $this->email !== '') { - return true; + // Still need to check shipping if prints are present + } elseif ($this->items()->where('size_variant_type', PurchasableSizeVariantType::FULL)->exists()) { + // We do not have a mail, so we cannot checkout if the order contains FULL size variants + return false; + } elseif ($this->user_id === null) { + return false; } - // We do not have a mail, so we cannot checkout if the order contains FULL size variants - if ($this->items()->where('size_variant_type', PurchasableSizeVariantType::FULL)->exists()) { - return false; + // If order contains print items, shipping address is required + if ($this->items()->where('is_print', true)->exists()) { + if ( + $this->shipping_street_name === null || $this->shipping_street_name === '' || + $this->shipping_city === null || $this->shipping_city === '' || + $this->shipping_post_code === null || $this->shipping_post_code === '' || + $this->shipping_country === null || $this->shipping_country === '' + ) { + return false; + } } - return $this->user_id !== null; + return true; } } diff --git a/app/Models/OrderItem.php b/app/Models/OrderItem.php index 5f08aebeed8..f9e9fb0f77e 100644 --- a/app/Models/OrderItem.php +++ b/app/Models/OrderItem.php @@ -40,7 +40,16 @@ * @property string $title Item title at time of purchase (for historical record) * @property int|null $size_variant_id Foreign key to size variant (nullable for custom sizes) * @property string|null $download_link Custom download URL (used for FULL variants or special cases) - * @property PurchasableLicenseType $license_type License type purchased (personal, commercial, extended) + * @property bool $is_print True when item requires physical print fulfilment + * @property int|null $print_size_id Foreign key to print_sizes (nullable) + * @property int|null $pixel_size_id Foreign key to pixel_sizes (nullable) + * @property int|null $print_width Snapshot of print width at basket-add time + * @property int|null $print_height Snapshot of print height at basket-add time + * @property string|null $print_unit Snapshot of print unit at basket-add time + * @property string|null $print_paper_type Snapshot of print paper type at basket-add time + * @property int|null $pixel_width Snapshot of pixel width at basket-add time + * @property int|null $pixel_height Snapshot of pixel height at basket-add time + * @property PurchasableLicenseType $license_type License type purchased (personal, commercial, extended, print) * @property \Money\Money $price_cents Price paid for this item (uses Money library for precision) * @property PurchasableSizeVariantType $size_variant_type Size variant purchased (medium, medium2x, original, full) * @property string|null $item_notes Optional notes specific to this item @@ -49,6 +58,8 @@ * @property Photo|null $photo The photo being purchased (if applicable) * @property Album|null $album The album being purchased (if applicable) * @property SizeVariant|null $size_variant The size variant being purchased (if applicable) + * @property PrintSize|null $printSize The print size catalogue entry (if applicable) + * @property PixelSize|null $pixelSize The pixel size catalogue entry (if applicable) * * @see Order The parent order model * @see Purchasable The purchasable item definition @@ -77,6 +88,15 @@ class OrderItem extends Model 'item_notes', 'size_variant_id', 'download_link', + 'is_print', + 'print_size_id', + 'pixel_size_id', + 'print_width', + 'print_height', + 'print_unit', + 'print_paper_type', + 'pixel_width', + 'pixel_height', ]; /** @@ -86,6 +106,7 @@ class OrderItem extends Model 'price_cents' => MoneyCast::class, 'license_type' => PurchasableLicenseType::class, 'size_variant_type' => PurchasableSizeVariantType::class, + 'is_print' => 'boolean', ]; /** @@ -157,6 +178,26 @@ public function album(): BelongsTo return $this->belongsTo(Album::class); } + /** + * Get the global print size catalogue entry for this item. + * + * @return BelongsTo + */ + public function printSize(): BelongsTo + { + return $this->belongsTo(PrintSize::class); + } + + /** + * Get the global pixel size catalogue entry for this item. + * + * @return BelongsTo + */ + public function pixelSize(): BelongsTo + { + return $this->belongsTo(PixelSize::class); + } + /** * Get the download URL for this order item's content. * diff --git a/app/Models/PixelSize.php b/app/Models/PixelSize.php new file mode 100644 index 00000000000..9d017261d37 --- /dev/null +++ b/app/Models/PixelSize.php @@ -0,0 +1,63 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * {@inheritdoc} + */ + protected $fillable = [ + 'label', + 'width', + 'height', + 'is_active', + ]; + + /** + * {@inheritdoc} + */ + protected $casts = [ + 'is_active' => 'boolean', + ]; + + /** + * Scope a query to only include active pixel sizes. + * + * @param Builder $query + * + * @return Builder + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } +} diff --git a/app/Models/PrintSize.php b/app/Models/PrintSize.php new file mode 100644 index 00000000000..8aeaa99bc0b --- /dev/null +++ b/app/Models/PrintSize.php @@ -0,0 +1,67 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * {@inheritdoc} + */ + protected $fillable = [ + 'label', + 'width', + 'height', + 'unit', + 'paper_type', + 'is_active', + ]; + + /** + * {@inheritdoc} + */ + protected $casts = [ + 'is_active' => 'boolean', + ]; + + /** + * Scope a query to only include active print sizes. + * + * @param Builder $query + * + * @return Builder + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } +} diff --git a/app/Models/Purchasable.php b/app/Models/Purchasable.php index 08eb2bd6f56..03bb883277b 100644 --- a/app/Models/Purchasable.php +++ b/app/Models/Purchasable.php @@ -20,15 +20,17 @@ /** * Class Purchasable. * - * @property int $id - * @property string|null $photo_id - * @property string|null $album_id - * @property string|null $description - * @property string|null $owner_notes - * @property bool $is_active - * @property Album|null $album - * @property Photo|null $photo - * @property Collection $prices + * @property int $id + * @property string|null $photo_id + * @property string|null $album_id + * @property string|null $description + * @property string|null $owner_notes + * @property bool $is_active + * @property Album|null $album + * @property Photo|null $photo + * @property Collection $prices + * @property Collection $printSizes + * @property Collection $pixelSizes * * Defines whether a photo or album is available for purchase and its pricing options. */ @@ -60,7 +62,7 @@ class Purchasable extends Model /** * {@inheritdoc} */ - protected $with = ['prices']; + protected $with = ['prices', 'printSizes', 'pixelSizes']; /** * Get the album associated with this purchasable item. @@ -88,6 +90,26 @@ public function prices(): HasMany return $this->hasMany(PurchasablePrice::class); } + /** + * Get the per-purchasable print size assignments (with prices). + * + * @return HasMany + */ + public function printSizes(): HasMany + { + return $this->hasMany(PurchasablePrintSize::class); + } + + /** + * Get the per-purchasable pixel size assignments (with prices). + * + * @return HasMany + */ + public function pixelSizes(): HasMany + { + return $this->hasMany(PurchasablePixelSize::class); + } + /** * Get price for specific size and license combination. * diff --git a/app/Models/PurchasablePixelSize.php b/app/Models/PurchasablePixelSize.php new file mode 100644 index 00000000000..ad29bba3c96 --- /dev/null +++ b/app/Models/PurchasablePixelSize.php @@ -0,0 +1,73 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * {@inheritdoc} + */ + protected $fillable = [ + 'purchasable_id', + 'pixel_size_id', + 'price_cents', + ]; + + /** + * {@inheritdoc} + */ + protected $casts = [ + 'price_cents' => MoneyCast::class, + ]; + + /** + * Get the purchasable this entry belongs to. + * + * @return BelongsTo + */ + public function purchasable(): BelongsTo + { + return $this->belongsTo(Purchasable::class); + } + + /** + * Get the global pixel size catalogue entry. + * + * @return BelongsTo + */ + public function pixelSize(): BelongsTo + { + return $this->belongsTo(PixelSize::class); + } +} diff --git a/app/Models/PurchasablePrintSize.php b/app/Models/PurchasablePrintSize.php new file mode 100644 index 00000000000..655c96fb49e --- /dev/null +++ b/app/Models/PurchasablePrintSize.php @@ -0,0 +1,73 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * {@inheritdoc} + */ + protected $fillable = [ + 'purchasable_id', + 'print_size_id', + 'price_cents', + ]; + + /** + * {@inheritdoc} + */ + protected $casts = [ + 'price_cents' => MoneyCast::class, + ]; + + /** + * Get the purchasable this entry belongs to. + * + * @return BelongsTo + */ + public function purchasable(): BelongsTo + { + return $this->belongsTo(Purchasable::class); + } + + /** + * Get the global print size catalogue entry. + * + * @return BelongsTo + */ + public function printSize(): BelongsTo + { + return $this->belongsTo(PrintSize::class); + } +} diff --git a/database/factories/PixelSizeFactory.php b/database/factories/PixelSizeFactory.php new file mode 100644 index 00000000000..e97965ca8fe --- /dev/null +++ b/database/factories/PixelSizeFactory.php @@ -0,0 +1,53 @@ + + */ +class PixelSizeFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected $model = PixelSize::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $width = fake()->numberBetween(800, 6000); + $height = fake()->numberBetween(600, 4000); + + return [ + 'label' => "{$width}×{$height} px", + 'width' => $width, + 'height' => $height, + 'is_active' => true, + ]; + } + + /** + * Mark the pixel size as inactive. + * + * @return self + */ + public function inactive(): self + { + return $this->state(fn (array $attributes) => ['is_active' => false]); + } +} diff --git a/database/factories/PrintSizeFactory.php b/database/factories/PrintSizeFactory.php new file mode 100644 index 00000000000..11831eedf10 --- /dev/null +++ b/database/factories/PrintSizeFactory.php @@ -0,0 +1,56 @@ + + */ +class PrintSizeFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected $model = PrintSize::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $width = fake()->numberBetween(10, 100); + $height = fake()->numberBetween(10, 100); + $unit = fake()->randomElement(['cm', 'inch']); + + return [ + 'label' => "{$width}×{$height} {$unit}", + 'width' => $width, + 'height' => $height, + 'unit' => $unit, + 'paper_type' => fake()->optional()->randomElement(['Glossy', 'Matte', 'Silk', 'Canvas']), + 'is_active' => true, + ]; + } + + /** + * Mark the print size as inactive. + * + * @return self + */ + public function inactive(): self + { + return $this->state(fn (array $attributes) => ['is_active' => false]); + } +} diff --git a/database/factories/PurchasablePixelSizeFactory.php b/database/factories/PurchasablePixelSizeFactory.php new file mode 100644 index 00000000000..f18f590c8bf --- /dev/null +++ b/database/factories/PurchasablePixelSizeFactory.php @@ -0,0 +1,60 @@ + + */ +class PurchasablePixelSizeFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected $model = PurchasablePixelSize::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $money_service = resolve(MoneyService::class); + + return [ + 'purchasable_id' => null, + 'pixel_size_id' => null, + 'price_cents' => $money_service->createFromCents(fake()->numberBetween(499, 4999)), + ]; + } + + /** + * Set a specific price in cents. + * + * @param int $cents + * + * @return self + */ + public function withPrice(int $cents): self + { + return $this->state(function (array $attributes) use ($cents) { + $money_service = resolve(MoneyService::class); + + return [ + 'price_cents' => $money_service->createFromCents($cents), + ]; + }); + } +} diff --git a/database/factories/PurchasablePrintSizeFactory.php b/database/factories/PurchasablePrintSizeFactory.php new file mode 100644 index 00000000000..83712a18aa2 --- /dev/null +++ b/database/factories/PurchasablePrintSizeFactory.php @@ -0,0 +1,60 @@ + + */ +class PurchasablePrintSizeFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected $model = PurchasablePrintSize::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $money_service = resolve(MoneyService::class); + + return [ + 'purchasable_id' => null, + 'print_size_id' => null, + 'price_cents' => $money_service->createFromCents(fake()->numberBetween(999, 9999)), + ]; + } + + /** + * Set a specific price in cents. + * + * @param int $cents + * + * @return self + */ + public function withPrice(int $cents): self + { + return $this->state(function (array $attributes) use ($cents) { + $money_service = resolve(MoneyService::class); + + return [ + 'price_cents' => $money_service->createFromCents($cents), + ]; + }); + } +} diff --git a/database/migrations/2026_05_31_000001_create_print_sizes_table.php b/database/migrations/2026_05_31_000001_create_print_sizes_table.php new file mode 100644 index 00000000000..a21bddc3157 --- /dev/null +++ b/database/migrations/2026_05_31_000001_create_print_sizes_table.php @@ -0,0 +1,37 @@ +id(); + $table->string('label', 100)->nullable(false)->comment('Display label, e.g. "20×30 cm – Glossy"'); + $table->unsignedInteger('width')->nullable(false)->comment('Width dimension'); + $table->unsignedInteger('height')->nullable(false)->comment('Height dimension'); + $table->enum('unit', ['cm', 'inch'])->nullable(false)->comment('Unit of measurement'); + $table->string('paper_type', 100)->nullable(true)->comment('Optional paper type, e.g. "Glossy"'); + $table->boolean('is_active')->nullable(false)->default(true)->comment('Whether this size is visible to customers'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('print_sizes'); + } +}; diff --git a/database/migrations/2026_05_31_000002_create_pixel_sizes_table.php b/database/migrations/2026_05_31_000002_create_pixel_sizes_table.php new file mode 100644 index 00000000000..2226d985d45 --- /dev/null +++ b/database/migrations/2026_05_31_000002_create_pixel_sizes_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('label', 100)->nullable(false)->comment('Display label, e.g. "3000×2000 px"'); + $table->unsignedInteger('width')->nullable(false)->comment('Width in pixels'); + $table->unsignedInteger('height')->nullable(false)->comment('Height in pixels'); + $table->boolean('is_active')->nullable(false)->default(true)->comment('Whether this size is visible to customers'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('pixel_sizes'); + } +}; diff --git a/database/migrations/2026_05_31_000003_create_purchasable_print_sizes_table.php b/database/migrations/2026_05_31_000003_create_purchasable_print_sizes_table.php new file mode 100644 index 00000000000..ac8a44d44eb --- /dev/null +++ b/database/migrations/2026_05_31_000003_create_purchasable_print_sizes_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('purchasable_id')->constrained()->onDelete('cascade'); + $table->foreignId('print_size_id')->constrained()->onDelete('cascade'); + $table->integer('price_cents')->nullable(false)->comment('Price in cents for this print size on this purchasable'); + + $table->unique(['purchasable_id', 'print_size_id'], 'unique_purchasable_print_size'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('purchasable_print_sizes'); + } +}; diff --git a/database/migrations/2026_05_31_000004_create_purchasable_pixel_sizes_table.php b/database/migrations/2026_05_31_000004_create_purchasable_pixel_sizes_table.php new file mode 100644 index 00000000000..150017e39ae --- /dev/null +++ b/database/migrations/2026_05_31_000004_create_purchasable_pixel_sizes_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('purchasable_id')->constrained()->onDelete('cascade'); + $table->foreignId('pixel_size_id')->constrained()->onDelete('cascade'); + $table->integer('price_cents')->nullable(false)->comment('Price in cents for this pixel size on this purchasable'); + + $table->unique(['purchasable_id', 'pixel_size_id'], 'unique_purchasable_pixel_size'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('purchasable_pixel_sizes'); + } +}; diff --git a/database/migrations/2026_05_31_000005_extend_order_items_for_print.php b/database/migrations/2026_05_31_000005_extend_order_items_for_print.php new file mode 100644 index 00000000000..afec033d62b --- /dev/null +++ b/database/migrations/2026_05_31_000005_extend_order_items_for_print.php @@ -0,0 +1,52 @@ +boolean('is_print')->nullable(false)->default(false)->after('download_link') + ->comment('True when this item requires physical print fulfilment'); + $table->foreignId('print_size_id')->nullable()->constrained()->nullOnDelete()->after('is_print'); + $table->foreignId('pixel_size_id')->nullable()->constrained()->nullOnDelete()->after('print_size_id'); + $table->unsignedInteger('print_width')->nullable()->after('pixel_size_id'); + $table->unsignedInteger('print_height')->nullable()->after('print_width'); + $table->string('print_unit', 10)->nullable()->after('print_height'); + $table->string('print_paper_type', 100)->nullable()->after('print_unit'); + $table->unsignedInteger('pixel_width')->nullable()->after('print_paper_type'); + $table->unsignedInteger('pixel_height')->nullable()->after('pixel_width'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('order_items', function (Blueprint $table) { + $table->dropConstrainedForeignId('print_size_id'); + $table->dropConstrainedForeignId('pixel_size_id'); + $table->dropColumn([ + 'is_print', + 'print_width', + 'print_height', + 'print_unit', + 'print_paper_type', + 'pixel_width', + 'pixel_height', + ]); + }); + } +}; diff --git a/database/migrations/2026_05_31_000006_extend_orders_for_shipping.php b/database/migrations/2026_05_31_000006_extend_orders_for_shipping.php new file mode 100644 index 00000000000..69d9854d325 --- /dev/null +++ b/database/migrations/2026_05_31_000006_extend_orders_for_shipping.php @@ -0,0 +1,45 @@ +string('shipping_street_name')->nullable()->after('comment'); + $table->string('shipping_street_number')->nullable()->after('shipping_street_name'); + $table->string('shipping_additional_info')->nullable()->after('shipping_street_number'); + $table->string('shipping_city')->nullable()->after('shipping_additional_info'); + $table->string('shipping_post_code')->nullable()->after('shipping_city'); + $table->string('shipping_country')->nullable()->after('shipping_post_code'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('orders', function (Blueprint $table) { + $table->dropColumn([ + 'shipping_street_name', + 'shipping_street_number', + 'shipping_additional_info', + 'shipping_city', + 'shipping_post_code', + 'shipping_country', + ]); + }); + } +}; diff --git a/routes/api_v2_shop.php b/routes/api_v2_shop.php index a43e9c8a6e1..3b1e62fc6cc 100644 --- a/routes/api_v2_shop.php +++ b/routes/api_v2_shop.php @@ -28,6 +28,8 @@ Route::get('/', [Shop\BasketController::class, 'get']); Route::post('/Photo', [Shop\BasketController::class, 'addPhoto']); Route::post('/Album', [Shop\BasketController::class, 'addAlbum']); + Route::post('/Print', [Shop\BasketController::class, 'addPrintItem']); + Route::post('/Pixel', [Shop\BasketController::class, 'addPixelItem']); Route::delete('/item', [Shop\BasketController::class, 'removeItem']); Route::delete('/', [Shop\BasketController::class, 'delete']); }); @@ -46,5 +48,14 @@ Route::post('/Purchasable/Album', [Admin\ShopManagementController::class, 'setAlbumPurchasable']); Route::put('/Purchasable/Price', [Admin\ShopManagementController::class, 'updatePurchasablePrices']); Route::delete('/Purchasables', [Admin\ShopManagementController::class, 'deletePurchasables']); + Route::get('/PrintSize', [Admin\PrintSizeManagementController::class, 'index']); + Route::post('/PrintSize', [Admin\PrintSizeManagementController::class, 'store']); + Route::put('/PrintSize', [Admin\PrintSizeManagementController::class, 'update']); + Route::delete('/PrintSize', [Admin\PrintSizeManagementController::class, 'destroy']); + Route::get('/PixelSize', [Admin\PixelSizeManagementController::class, 'index']); + Route::post('/PixelSize', [Admin\PixelSizeManagementController::class, 'store']); + Route::put('/PixelSize', [Admin\PixelSizeManagementController::class, 'update']); + Route::delete('/PixelSize', [Admin\PixelSizeManagementController::class, 'destroy']); }); + Route::get('/Shop/Catalogue/Purchasable/{purchasable_id}/Sizes', [Shop\CatalogueSizesController::class, 'sizes']); }); \ No newline at end of file diff --git a/tests/Unit/Actions/Shop/OrderServiceAddSizeItemsTest.php b/tests/Unit/Actions/Shop/OrderServiceAddSizeItemsTest.php new file mode 100644 index 00000000000..5fa10548a27 --- /dev/null +++ b/tests/Unit/Actions/Shop/OrderServiceAddSizeItemsTest.php @@ -0,0 +1,238 @@ +requirePro(); + + $this->order_service = resolve(OrderService::class); + + $this->purchasable = Purchasable::factory() + ->forPhoto($this->photo1->id, $this->album1->id) + ->withPrices() + ->create(); + + $this->print_size = PrintSize::factory()->create([ + 'label' => '10x15 cm', + 'width' => 10, + 'height' => 15, + 'unit' => 'cm', + 'paper_type' => 'Matte', + 'is_active' => true, + ]); + + $this->pixel_size = PixelSize::factory()->create([ + 'label' => '1920×1080', + 'width' => 1920, + 'height' => 1080, + 'is_active' => true, + ]); + + PurchasablePrintSize::factory()->withPrice(2500)->create([ + 'purchasable_id' => $this->purchasable->id, + 'print_size_id' => $this->print_size->id, + ]); + + PurchasablePixelSize::factory()->withPrice(1200)->create([ + 'purchasable_id' => $this->purchasable->id, + 'pixel_size_id' => $this->pixel_size->id, + ]); + + $this->order = Order::factory()->pending()->withEmail('test@example.com')->create(); + } + + public function tearDown(): void + { + PurchasablePrintSize::query()->delete(); + PurchasablePixelSize::query()->delete(); + PrintSize::query()->delete(); + PixelSize::query()->delete(); + $this->resetPro(); + parent::tearDown(); + } + + public function testAddPrintPhotoCreatesOrderItemWithIsPrintTrue(): void + { + $this->order_service->addPrintPhotoToOrder( + $this->order, + $this->photo1, + $this->album1->id, + $this->print_size, + ); + + $this->assertDatabaseHas('order_items', [ + 'order_id' => $this->order->id, + 'photo_id' => $this->photo1->id, + 'is_print' => true, + 'print_size_id' => $this->print_size->id, + 'license_type' => PurchasableLicenseType::PRINT->value, + ]); + } + + public function testAddPrintPhotoSnapshotsDimensions(): void + { + $this->order_service->addPrintPhotoToOrder( + $this->order, + $this->photo1, + $this->album1->id, + $this->print_size, + ); + + $this->assertDatabaseHas('order_items', [ + 'order_id' => $this->order->id, + 'print_width' => 10, + 'print_height' => 15, + 'print_unit' => 'cm', + 'print_paper_type' => 'Matte', + ]); + } + + public function testAddPrintPhotoStoresNotes(): void + { + $this->order_service->addPrintPhotoToOrder( + $this->order, + $this->photo1, + $this->album1->id, + $this->print_size, + 'Satin finish please', + ); + + $this->assertDatabaseHas('order_items', [ + 'order_id' => $this->order->id, + 'item_notes' => 'Satin finish please', + ]); + } + + public function testAddPrintPhotoThrowsWhenPhotoNotPurchasable(): void + { + $this->expectException(PhotoNotPurchasableException::class); + + $this->order_service->addPrintPhotoToOrder( + $this->order, + $this->photo2, // photo2 has no purchasable + $this->album2->id, + $this->print_size, + ); + } + + public function testAddPrintPhotoThrowsWhenPrintSizeNotAssigned(): void + { + $unassigned_print_size = PrintSize::factory()->create(); + + $this->expectException(InvalidPurchaseOptionException::class); + + $this->order_service->addPrintPhotoToOrder( + $this->order, + $this->photo1, + $this->album1->id, + $unassigned_print_size, + ); + } + + public function testAddPixelPhotoCreatesOrderItemWithIsPrintFalse(): void + { + $this->order_service->addPixelPhotoToOrder( + $this->order, + $this->photo1, + $this->album1->id, + $this->pixel_size, + ); + + $this->assertDatabaseHas('order_items', [ + 'order_id' => $this->order->id, + 'photo_id' => $this->photo1->id, + 'is_print' => false, + 'pixel_size_id' => $this->pixel_size->id, + ]); + } + + public function testAddPixelPhotoSnapshotsDimensions(): void + { + $this->order_service->addPixelPhotoToOrder( + $this->order, + $this->photo1, + $this->album1->id, + $this->pixel_size, + ); + + $this->assertDatabaseHas('order_items', [ + 'order_id' => $this->order->id, + 'pixel_width' => 1920, + 'pixel_height' => 1080, + ]); + } + + public function testAddPixelPhotoThrowsWhenPhotoNotPurchasable(): void + { + $this->expectException(PhotoNotPurchasableException::class); + + $this->order_service->addPixelPhotoToOrder( + $this->order, + $this->photo2, + $this->album2->id, + $this->pixel_size, + ); + } + + public function testAddPixelPhotoThrowsWhenPixelSizeNotAssigned(): void + { + $unassigned_pixel_size = PixelSize::factory()->create(); + + $this->expectException(InvalidPurchaseOptionException::class); + + $this->order_service->addPixelPhotoToOrder( + $this->order, + $this->photo1, + $this->album1->id, + $unassigned_pixel_size, + ); + } +} diff --git a/tests/Unit/Actions/Shop/PurchasableSyncSizesTest.php b/tests/Unit/Actions/Shop/PurchasableSyncSizesTest.php new file mode 100644 index 00000000000..2047f72dbde --- /dev/null +++ b/tests/Unit/Actions/Shop/PurchasableSyncSizesTest.php @@ -0,0 +1,206 @@ +requirePro(); + + $this->service = resolve(PurchasableService::class); + $this->money_service = resolve(MoneyService::class); + + $this->purchasable = Purchasable::factory()->create(); + } + + public function tearDown(): void + { + $this->resetPro(); + parent::tearDown(); + } + + public function testSyncPrintSizesCreatesAssignments(): void + { + $print_size1 = PrintSize::factory()->create(); + $print_size2 = PrintSize::factory()->create(); + + $assignments = [ + new PrintSizeAssignment($print_size1->id, $this->money_service->createFromCents(2500)), + new PrintSizeAssignment($print_size2->id, $this->money_service->createFromCents(3500)), + ]; + + $this->service->syncPrintSizes($this->purchasable, $assignments); + + $this->assertDatabaseHas('purchasable_print_sizes', [ + 'purchasable_id' => $this->purchasable->id, + 'print_size_id' => $print_size1->id, + ]); + $this->assertDatabaseHas('purchasable_print_sizes', [ + 'purchasable_id' => $this->purchasable->id, + 'print_size_id' => $print_size2->id, + ]); + } + + public function testSyncPrintSizesReplacesExistingAssignments(): void + { + $print_size1 = PrintSize::factory()->create(); + $print_size2 = PrintSize::factory()->create(); + + // Pre-populate with print_size1 + PurchasablePrintSize::factory()->create([ + 'purchasable_id' => $this->purchasable->id, + 'print_size_id' => $print_size1->id, + ]); + + // Sync with only print_size2 + $assignments = [ + new PrintSizeAssignment($print_size2->id, $this->money_service->createFromCents(3500)), + ]; + $this->service->syncPrintSizes($this->purchasable, $assignments); + + $this->assertDatabaseMissing('purchasable_print_sizes', [ + 'purchasable_id' => $this->purchasable->id, + 'print_size_id' => $print_size1->id, + ]); + $this->assertDatabaseHas('purchasable_print_sizes', [ + 'purchasable_id' => $this->purchasable->id, + 'print_size_id' => $print_size2->id, + ]); + } + + public function testSyncPrintSizesWithEmptyArrayClearsAllAssignments(): void + { + $print_size = PrintSize::factory()->create(); + + PurchasablePrintSize::factory()->create([ + 'purchasable_id' => $this->purchasable->id, + 'print_size_id' => $print_size->id, + ]); + + $this->service->syncPrintSizes($this->purchasable, []); + + $this->assertDatabaseMissing('purchasable_print_sizes', [ + 'purchasable_id' => $this->purchasable->id, + ]); + } + + public function testSyncPixelSizesCreatesAssignments(): void + { + $pixel_size1 = PixelSize::factory()->create(); + $pixel_size2 = PixelSize::factory()->create(); + + $assignments = [ + new PixelSizeAssignment($pixel_size1->id, $this->money_service->createFromCents(1200)), + new PixelSizeAssignment($pixel_size2->id, $this->money_service->createFromCents(1800)), + ]; + + $this->service->syncPixelSizes($this->purchasable, $assignments); + + $this->assertDatabaseHas('purchasable_pixel_sizes', [ + 'purchasable_id' => $this->purchasable->id, + 'pixel_size_id' => $pixel_size1->id, + ]); + $this->assertDatabaseHas('purchasable_pixel_sizes', [ + 'purchasable_id' => $this->purchasable->id, + 'pixel_size_id' => $pixel_size2->id, + ]); + } + + public function testSyncPixelSizesReplacesExistingAssignments(): void + { + $pixel_size1 = PixelSize::factory()->create(); + $pixel_size2 = PixelSize::factory()->create(); + + PurchasablePixelSize::factory()->create([ + 'purchasable_id' => $this->purchasable->id, + 'pixel_size_id' => $pixel_size1->id, + ]); + + $assignments = [ + new PixelSizeAssignment($pixel_size2->id, $this->money_service->createFromCents(1800)), + ]; + $this->service->syncPixelSizes($this->purchasable, $assignments); + + $this->assertDatabaseMissing('purchasable_pixel_sizes', [ + 'purchasable_id' => $this->purchasable->id, + 'pixel_size_id' => $pixel_size1->id, + ]); + $this->assertDatabaseHas('purchasable_pixel_sizes', [ + 'purchasable_id' => $this->purchasable->id, + 'pixel_size_id' => $pixel_size2->id, + ]); + } + + public function testSyncPixelSizesWithEmptyArrayClearsAllAssignments(): void + { + $pixel_size = PixelSize::factory()->create(); + + PurchasablePixelSize::factory()->create([ + 'purchasable_id' => $this->purchasable->id, + 'pixel_size_id' => $pixel_size->id, + ]); + + $this->service->syncPixelSizes($this->purchasable, []); + + $this->assertDatabaseMissing('purchasable_pixel_sizes', [ + 'purchasable_id' => $this->purchasable->id, + ]); + } + + public function testSyncPrintSizesReturnsPurchasable(): void + { + $result = $this->service->syncPrintSizes($this->purchasable, []); + $this->assertInstanceOf(Purchasable::class, $result); + $this->assertEquals($this->purchasable->id, $result->id); + } + + public function testSyncPixelSizesReturnsPurchasable(): void + { + $result = $this->service->syncPixelSizes($this->purchasable, []); + $this->assertInstanceOf(Purchasable::class, $result); + $this->assertEquals($this->purchasable->id, $result->id); + } +} diff --git a/tests/Webshop/BasketPrintPixelItemsTest.php b/tests/Webshop/BasketPrintPixelItemsTest.php new file mode 100644 index 00000000000..cc42301b6d0 --- /dev/null +++ b/tests/Webshop/BasketPrintPixelItemsTest.php @@ -0,0 +1,180 @@ +requirePro(); + + $this->purchasable = Purchasable::factory() + ->forPhoto($this->photo1->id, $this->album1->id) + ->withPrices() + ->create(); + + $this->print_size = PrintSize::factory()->create(['is_active' => true]); + $this->pixel_size = PixelSize::factory()->create(['is_active' => true]); + + // Assign sizes with prices to the purchasable + PurchasablePrintSize::factory()->withPrice(2500)->create([ + 'purchasable_id' => $this->purchasable->id, + 'print_size_id' => $this->print_size->id, + ]); + PurchasablePixelSize::factory()->withPrice(1200)->create([ + 'purchasable_id' => $this->purchasable->id, + 'pixel_size_id' => $this->pixel_size->id, + ]); + } + + public function tearDown(): void + { + PurchasablePrintSize::query()->delete(); + PurchasablePixelSize::query()->delete(); + PrintSize::query()->delete(); + PixelSize::query()->delete(); + $this->resetPro(); + parent::tearDown(); + } + + public function testAddPrintItemToBasket(): void + { + $response = $this->getJson('Shop/Basket/'); + $order_id = $response->getCookie('basket_id')->getValue(); + + $response = $this->withCookie('basket_id', $order_id)->postJson('Shop/Basket/Print', [ + 'photo_id' => $this->photo1->id, + 'album_id' => $this->album1->id, + 'print_size_id' => $this->print_size->id, + ]); + + $this->assertCreated($response); + $response->assertJsonStructure([ + 'id', + 'status', + 'items', + ]); + + $items = $response->json('items'); + $this->assertCount(1, $items); + $this->assertTrue($items[0]['is_print']); + $this->assertEquals($this->print_size->id, $items[0]['print_size_id']); + } + + public function testAddPixelItemToBasket(): void + { + $response = $this->getJson('Shop/Basket/'); + $order_id = $response->getCookie('basket_id')->getValue(); + + $response = $this->withCookie('basket_id', $order_id)->postJson('Shop/Basket/Pixel', [ + 'photo_id' => $this->photo1->id, + 'album_id' => $this->album1->id, + 'pixel_size_id' => $this->pixel_size->id, + ]); + + $this->assertCreated($response); + + $items = $response->json('items'); + $this->assertCount(1, $items); + $this->assertFalse($items[0]['is_print']); + $this->assertEquals($this->pixel_size->id, $items[0]['pixel_size_id']); + } + + public function testAddPrintItemRequiresPrintSizeId(): void + { + $response = $this->getJson('Shop/Basket/'); + $order_id = $response->getCookie('basket_id')->getValue(); + + $response = $this->withCookie('basket_id', $order_id)->postJson('Shop/Basket/Print', [ + 'photo_id' => $this->photo1->id, + 'album_id' => $this->album1->id, + ]); + + $this->assertUnprocessable($response); + $response->assertJsonValidationErrors(['print_size_id']); + } + + public function testAddPixelItemRequiresPixelSizeId(): void + { + $response = $this->getJson('Shop/Basket/'); + $order_id = $response->getCookie('basket_id')->getValue(); + + $response = $this->withCookie('basket_id', $order_id)->postJson('Shop/Basket/Pixel', [ + 'photo_id' => $this->photo1->id, + 'album_id' => $this->album1->id, + ]); + + $this->assertUnprocessable($response); + $response->assertJsonValidationErrors(['pixel_size_id']); + } + + public function testAddPrintItemRejectsInvalidPrintSizeId(): void + { + $response = $this->getJson('Shop/Basket/'); + $order_id = $response->getCookie('basket_id')->getValue(); + + $response = $this->withCookie('basket_id', $order_id)->postJson('Shop/Basket/Print', [ + 'photo_id' => $this->photo1->id, + 'album_id' => $this->album1->id, + 'print_size_id' => 99999, + ]); + + $this->assertUnprocessable($response); + $response->assertJsonValidationErrors(['print_size_id']); + } + + public function testAddPrintItemWithNotesStoresNotes(): void + { + $response = $this->getJson('Shop/Basket/'); + $order_id = $response->getCookie('basket_id')->getValue(); + + $response = $this->withCookie('basket_id', $order_id)->postJson('Shop/Basket/Print', [ + 'photo_id' => $this->photo1->id, + 'album_id' => $this->album1->id, + 'print_size_id' => $this->print_size->id, + 'notes' => 'Please use satin finish', + ]); + + $this->assertCreated($response); + $items = $response->json('items'); + $this->assertCount(1, $items); + $this->assertEquals('Please use satin finish', $items[0]['item_notes']); + } +} diff --git a/tests/Webshop/Checkout/CheckoutShippingAddressTest.php b/tests/Webshop/Checkout/CheckoutShippingAddressTest.php new file mode 100644 index 00000000000..0c0f19e6a5b --- /dev/null +++ b/tests/Webshop/Checkout/CheckoutShippingAddressTest.php @@ -0,0 +1,112 @@ +postJson('Shop/Checkout/Create-session', [ + 'provider' => OmnipayProviderType::DUMMY->value, + 'email' => 'customer@example.com', + 'shipping_street_name' => 'Main Street', + 'shipping_street_number' => '42', + 'shipping_additional_info' => 'Apt 3B', + 'shipping_city' => 'Zurich', + 'shipping_post_code' => '8001', + 'shipping_country' => 'CH', + ]); + + $this->assertCreated($response); + + // Verify shipping address fields were persisted on the order + $this->assertDatabaseHas('orders', [ + 'id' => $this->test_order->id, + 'shipping_street_name' => 'Main Street', + 'shipping_street_number' => '42', + 'shipping_additional_info' => 'Apt 3B', + 'shipping_city' => 'Zurich', + 'shipping_post_code' => '8001', + 'shipping_country' => 'CH', + ]); + } + + public function testCreateSessionShippingFieldsAreOptional(): void + { + // Shipping fields are optional — request without them should succeed + $response = $this->postJson('Shop/Checkout/Create-session', [ + 'provider' => OmnipayProviderType::DUMMY->value, + 'email' => 'customer@example.com', + ]); + + $this->assertCreated($response); + + $this->assertDatabaseHas('orders', [ + 'id' => $this->test_order->id, + 'shipping_country' => null, + ]); + } + + public function testCreateSessionShippingAddressIsReturnedInResponse(): void + { + $response = $this->postJson('Shop/Checkout/Create-session', [ + 'provider' => OmnipayProviderType::DUMMY->value, + 'email' => 'customer@example.com', + 'shipping_street_name' => 'Bahnhofstrasse', + 'shipping_street_number' => '1', + 'shipping_city' => 'Basel', + 'shipping_post_code' => '4001', + 'shipping_country' => 'CH', + ]); + + $this->assertCreated($response); + $response->assertJsonStructure([ + 'shipping_street_name', + 'shipping_street_number', + 'shipping_additional_info', + 'shipping_city', + 'shipping_post_code', + 'shipping_country', + ]); + $response->assertJson([ + 'shipping_street_name' => 'Bahnhofstrasse', + 'shipping_city' => 'Basel', + 'shipping_country' => 'CH', + ]); + } + + public function testCreateSessionShippingCountryMaxTwoChars(): void + { + $response = $this->postJson('Shop/Checkout/Create-session', [ + 'provider' => OmnipayProviderType::DUMMY->value, + 'shipping_country' => 'CHE', // 3-char ISO — should be rejected + ]); + + $this->assertUnprocessable($response); + $response->assertJsonValidationErrors(['shipping_country']); + } +} diff --git a/tests/Webshop/OrderManagement/OrderShippingAddressDisplayTest.php b/tests/Webshop/OrderManagement/OrderShippingAddressDisplayTest.php new file mode 100644 index 00000000000..175d343f845 --- /dev/null +++ b/tests/Webshop/OrderManagement/OrderShippingAddressDisplayTest.php @@ -0,0 +1,120 @@ +requirePro(); + + $this->order_with_shipping = Order::factory() + ->forUser($this->userMayUpload1) + ->withTransactionId(Str::uuid()->toString()) + ->withProvider(OmnipayProviderType::DUMMY) + ->withStatus(PaymentStatusType::COMPLETED) + ->withEmail($this->userMayUpload1->email) + ->create([ + 'shipping_street_name' => 'Rue de la Paix', + 'shipping_street_number' => '5', + 'shipping_additional_info' => null, + 'shipping_city' => 'Paris', + 'shipping_post_code' => '75001', + 'shipping_country' => 'FR', + ]); + + $this->order_without_shipping = Order::factory() + ->forUser($this->userMayUpload1) + ->withTransactionId(Str::uuid()->toString()) + ->withProvider(OmnipayProviderType::DUMMY) + ->withStatus(PaymentStatusType::COMPLETED) + ->withEmail($this->userMayUpload1->email) + ->create(); + } + + public function tearDown(): void + { + $this->resetPro(); + parent::tearDown(); + } + + public function testOrderResponseIncludesShippingFields(): void + { + $response = $this->actingAs($this->userMayUpload1) + ->getJson("Shop/Order/{$this->order_with_shipping->id}"); + + $this->assertOk($response); + $response->assertJsonStructure([ + 'shipping_street_name', + 'shipping_street_number', + 'shipping_additional_info', + 'shipping_city', + 'shipping_post_code', + 'shipping_country', + ]); + } + + public function testOrderResponseReturnsCorrectShippingAddress(): void + { + $response = $this->actingAs($this->userMayUpload1) + ->getJson("Shop/Order/{$this->order_with_shipping->id}"); + + $this->assertOk($response); + $response->assertJson([ + 'shipping_street_name' => 'Rue de la Paix', + 'shipping_street_number' => '5', + 'shipping_additional_info' => null, + 'shipping_city' => 'Paris', + 'shipping_post_code' => '75001', + 'shipping_country' => 'FR', + ]); + } + + public function testOrderResponseReturnsNullShippingFieldsWhenNotSet(): void + { + $response = $this->actingAs($this->userMayUpload1) + ->getJson("Shop/Order/{$this->order_without_shipping->id}"); + + $this->assertOk($response); + $response->assertJson([ + 'shipping_street_name' => null, + 'shipping_city' => null, + 'shipping_country' => null, + ]); + } +} diff --git a/tests/Webshop/Purchasables/CatalogueSizesControllerTest.php b/tests/Webshop/Purchasables/CatalogueSizesControllerTest.php new file mode 100644 index 00000000000..1c6a4be9d37 --- /dev/null +++ b/tests/Webshop/Purchasables/CatalogueSizesControllerTest.php @@ -0,0 +1,161 @@ +requirePro(); + + $this->purchasable = Purchasable::factory() + ->forPhoto($this->photo1->id, $this->album1->id) + ->withPrices() + ->create(); + } + + public function tearDown(): void + { + PurchasablePrintSize::query()->delete(); + PurchasablePixelSize::query()->delete(); + PrintSize::query()->delete(); + PixelSize::query()->delete(); + $this->resetPro(); + parent::tearDown(); + } + + public function testReturnsEmptySizesWhenNoneAssigned(): void + { + $response = $this->actingAs($this->userMayUpload1) + ->getJson("Shop/Catalogue/Purchasable/{$this->purchasable->id}/Sizes"); + + $this->assertOk($response); + $response->assertJson(['print_sizes' => [], 'pixel_sizes' => []]); + } + + public function testReturnsActivePrintSizes(): void + { + $active_print = PrintSize::factory()->create(['is_active' => true]); + $inactive_print = PrintSize::factory()->create(['is_active' => false]); + + PurchasablePrintSize::factory()->create([ + 'purchasable_id' => $this->purchasable->id, + 'print_size_id' => $active_print->id, + ]); + PurchasablePrintSize::factory()->create([ + 'purchasable_id' => $this->purchasable->id, + 'print_size_id' => $inactive_print->id, + ]); + + $response = $this->actingAs($this->userMayUpload1) + ->getJson("Shop/Catalogue/Purchasable/{$this->purchasable->id}/Sizes"); + + $this->assertOk($response); + + $data = $response->json(); + $this->assertCount(1, $data['print_sizes']); + $this->assertCount(0, $data['pixel_sizes']); + } + + public function testReturnsActivePixelSizes(): void + { + $active_pixel = PixelSize::factory()->create(['is_active' => true]); + $inactive_pixel = PixelSize::factory()->create(['is_active' => false]); + + PurchasablePixelSize::factory()->create([ + 'purchasable_id' => $this->purchasable->id, + 'pixel_size_id' => $active_pixel->id, + ]); + PurchasablePixelSize::factory()->create([ + 'purchasable_id' => $this->purchasable->id, + 'pixel_size_id' => $inactive_pixel->id, + ]); + + $response = $this->actingAs($this->userMayUpload1) + ->getJson("Shop/Catalogue/Purchasable/{$this->purchasable->id}/Sizes"); + + $this->assertOk($response); + + $data = $response->json(); + $this->assertCount(0, $data['print_sizes']); + $this->assertCount(1, $data['pixel_sizes']); + } + + public function testReturnsBothPrintAndPixelSizes(): void + { + $print = PrintSize::factory()->create(['is_active' => true]); + $pixel = PixelSize::factory()->create(['is_active' => true]); + + PurchasablePrintSize::factory()->create([ + 'purchasable_id' => $this->purchasable->id, + 'print_size_id' => $print->id, + ]); + PurchasablePixelSize::factory()->create([ + 'purchasable_id' => $this->purchasable->id, + 'pixel_size_id' => $pixel->id, + ]); + + $response = $this->actingAs($this->userMayUpload1) + ->getJson("Shop/Catalogue/Purchasable/{$this->purchasable->id}/Sizes"); + + $this->assertOk($response); + + $data = $response->json(); + $this->assertCount(1, $data['print_sizes']); + $this->assertCount(1, $data['pixel_sizes']); + $response->assertJsonStructure([ + 'print_sizes' => [['print_size_id', 'price_cents']], + 'pixel_sizes' => [['pixel_size_id', 'price_cents']], + ]); + } + + public function testRequiresAuthentication(): void + { + $response = $this->getJson("Shop/Catalogue/Purchasable/{$this->purchasable->id}/Sizes"); + + $this->assertUnauthorized($response); + } + + public function testReturnsFourOhFourForUnknownPurchasable(): void + { + $response = $this->actingAs($this->userMayUpload1) + ->getJson('Shop/Catalogue/Purchasable/99999/Sizes'); + + $this->assertNotFound($response); + } +} diff --git a/tests/Webshop/Purchasables/PixelSizeManagementControllerTest.php b/tests/Webshop/Purchasables/PixelSizeManagementControllerTest.php new file mode 100644 index 00000000000..8cf76d00a48 --- /dev/null +++ b/tests/Webshop/Purchasables/PixelSizeManagementControllerTest.php @@ -0,0 +1,162 @@ +requirePro(); + } + + public function tearDown(): void + { + PixelSize::query()->delete(); + $this->resetPro(); + parent::tearDown(); + } + + public function testIndexReturnsEmptyList(): void + { + $response = $this->actingAs($this->admin)->getJson('Shop/Management/PixelSize'); + + $this->assertOk($response); + $response->assertJson([]); + } + + public function testStoreCreatesNewPixelSize(): void + { + $response = $this->actingAs($this->admin)->postJson('Shop/Management/PixelSize', [ + 'label' => 'HD 1920×1080', + 'width' => 1920, + 'height' => 1080, + 'is_active' => true, + ]); + + $this->assertOk($response); + $response->assertJsonStructure(['id', 'label', 'width', 'height', 'is_active']); + $response->assertJson([ + 'label' => 'HD 1920×1080', + 'width' => 1920, + 'height' => 1080, + 'is_active' => true, + ]); + + $this->assertDatabaseHas('pixel_sizes', ['label' => 'HD 1920×1080', 'width' => 1920, 'height' => 1080]); + } + + public function testUpdateModifiesPixelSize(): void + { + $pixel_size = PixelSize::factory()->create(['label' => 'Old Label', 'width' => 800, 'height' => 600, 'is_active' => true]); + + $response = $this->actingAs($this->admin)->putJson('Shop/Management/PixelSize', [ + 'pixel_size_id' => $pixel_size->id, + 'label' => 'New Label', + 'width' => 2048, + 'height' => 1536, + 'is_active' => false, + ]); + + $this->assertOk($response); + $response->assertJson(['label' => 'New Label', 'width' => 2048, 'height' => 1536, 'is_active' => false]); + + $this->assertDatabaseHas('pixel_sizes', ['id' => $pixel_size->id, 'label' => 'New Label', 'width' => 2048]); + } + + public function testDestroyDeletesPixelSize(): void + { + $pixel_size = PixelSize::factory()->create(); + + $response = $this->actingAs($this->admin)->deleteJson('Shop/Management/PixelSize', [ + 'pixel_size_id' => $pixel_size->id, + ]); + + $this->assertNoContent($response); + $this->assertDatabaseMissing('pixel_sizes', ['id' => $pixel_size->id]); + } + + public function testIndexListsAllSizes(): void + { + PixelSize::factory()->count(3)->create(); + + $response = $this->actingAs($this->admin)->getJson('Shop/Management/PixelSize'); + + $this->assertOk($response); + $this->assertCount(3, $response->json()); + } + + public function testStoreRequiresAuth(): void + { + $response = $this->postJson('Shop/Management/PixelSize', [ + 'label' => 'Test', + 'long_edge_pixels' => 800, + 'is_active' => true, + ]); + + $this->assertUnauthorized($response); + } + + public function testStoreRequiresOwner(): void + { + $response = $this->actingAs($this->userMayUpload1)->postJson('Shop/Management/PixelSize', [ + 'label' => 'Test', + 'long_edge_pixels' => 800, + 'is_active' => true, + ]); + + $this->assertForbidden($response); + } + + public function testStoreValidatesLongEdgePixels(): void + { + $response = $this->actingAs($this->admin)->postJson('Shop/Management/PixelSize', [ + 'label' => 'Test', + 'width' => 0, // invalid: must be at least 1 + 'height' => 600, + 'is_active' => true, + ]); + + $this->assertUnprocessable($response); + $response->assertJsonValidationErrors(['width']); + } + + public function testStoreValidationRequiresLabel(): void + { + $response = $this->actingAs($this->admin)->postJson('Shop/Management/PixelSize', [ + 'width' => 1920, + 'height' => 1080, + 'is_active' => true, + ]); + + $this->assertUnprocessable($response); + $response->assertJsonValidationErrors(['label']); + } +} diff --git a/tests/Webshop/Purchasables/PrintSizeManagementControllerTest.php b/tests/Webshop/Purchasables/PrintSizeManagementControllerTest.php new file mode 100644 index 00000000000..359effda1e1 --- /dev/null +++ b/tests/Webshop/Purchasables/PrintSizeManagementControllerTest.php @@ -0,0 +1,176 @@ +requirePro(); + } + + public function tearDown(): void + { + PrintSize::query()->delete(); + $this->resetPro(); + parent::tearDown(); + } + + public function testIndexReturnsEmptyList(): void + { + $response = $this->actingAs($this->admin)->getJson('Shop/Management/PrintSize'); + + $this->assertOk($response); + $response->assertJson([]); + } + + public function testStoreCreatesNewPrintSize(): void + { + $response = $this->actingAs($this->admin)->postJson('Shop/Management/PrintSize', [ + 'label' => '10x15 cm', + 'width' => 10, + 'height' => 15, + 'unit' => 'cm', + 'paper_type' => 'Matte', + 'is_active' => true, + ]); + + $this->assertOk($response); + $response->assertJsonStructure(['id', 'label', 'width', 'height', 'unit', 'paper_type', 'is_active']); + $response->assertJson([ + 'label' => '10x15 cm', + 'width' => 10, + 'height' => 15, + 'unit' => 'cm', + 'paper_type' => 'Matte', + 'is_active' => true, + ]); + + $this->assertDatabaseHas('print_sizes', ['label' => '10x15 cm', 'unit' => 'cm']); + } + + public function testStoreWithoutPaperType(): void + { + $response = $this->actingAs($this->admin)->postJson('Shop/Management/PrintSize', [ + 'label' => '20x30 cm', + 'width' => 20, + 'height' => 30, + 'unit' => 'cm', + 'paper_type' => null, + 'is_active' => false, + ]); + + $this->assertOk($response); + $response->assertJson(['paper_type' => null, 'is_active' => false]); + } + + public function testUpdateModifiesPrintSize(): void + { + $print_size = PrintSize::factory()->create(['label' => 'Old Label', 'is_active' => true]); + + $response = $this->actingAs($this->admin)->putJson('Shop/Management/PrintSize', [ + 'print_size_id' => $print_size->id, + 'label' => 'New Label', + 'width' => 25, + 'height' => 35, + 'unit' => 'inch', + 'paper_type' => 'Glossy', + 'is_active' => false, + ]); + + $this->assertOk($response); + $response->assertJson(['label' => 'New Label', 'is_active' => false]); + + $this->assertDatabaseHas('print_sizes', ['id' => $print_size->id, 'label' => 'New Label', 'unit' => 'inch']); + } + + public function testDestroyDeletesPrintSize(): void + { + $print_size = PrintSize::factory()->create(); + + $response = $this->actingAs($this->admin)->deleteJson('Shop/Management/PrintSize', [ + 'print_size_id' => $print_size->id, + ]); + + $this->assertNoContent($response); + $this->assertDatabaseMissing('print_sizes', ['id' => $print_size->id]); + } + + public function testIndexListsAllSizes(): void + { + PrintSize::factory()->count(3)->create(); + + $response = $this->actingAs($this->admin)->getJson('Shop/Management/PrintSize'); + + $this->assertOk($response); + $this->assertCount(3, $response->json()); + } + + public function testStoreRequiresAuth(): void + { + $response = $this->postJson('Shop/Management/PrintSize', [ + 'label' => 'Test', + 'width' => 10, + 'height' => 15, + 'unit' => 'cm', + 'is_active' => true, + ]); + + $this->assertUnauthorized($response); + } + + public function testStoreRequiresOwner(): void + { + $response = $this->actingAs($this->userMayUpload1)->postJson('Shop/Management/PrintSize', [ + 'label' => 'Test', + 'width' => 10, + 'height' => 15, + 'unit' => 'cm', + 'is_active' => true, + ]); + + $this->assertForbidden($response); + } + + public function testStoreValidatesUnit(): void + { + $response = $this->actingAs($this->admin)->postJson('Shop/Management/PrintSize', [ + 'label' => 'Test', + 'width' => 10, + 'height' => 15, + 'unit' => 'mm', // invalid + 'is_active' => true, + ]); + + $this->assertUnprocessable($response); + $response->assertJsonValidationErrors(['unit']); + } +} diff --git a/tests/Webshop/Purchasables/PurchasablePriceWithSizesTest.php b/tests/Webshop/Purchasables/PurchasablePriceWithSizesTest.php new file mode 100644 index 00000000000..03942a2d7f1 --- /dev/null +++ b/tests/Webshop/Purchasables/PurchasablePriceWithSizesTest.php @@ -0,0 +1,206 @@ +requirePro(); + + $this->purchasable = Purchasable::factory() + ->forPhoto($this->photo1->id, $this->album1->id) + ->withPrices() + ->create(); + + $this->print_size1 = PrintSize::factory()->create(['is_active' => true]); + $this->print_size2 = PrintSize::factory()->create(['is_active' => true]); + $this->pixel_size1 = PixelSize::factory()->create(['is_active' => true]); + $this->pixel_size2 = PixelSize::factory()->create(['is_active' => true]); + } + + public function tearDown(): void + { + PrintSize::query()->delete(); + PixelSize::query()->delete(); + $this->resetPro(); + parent::tearDown(); + } + + public function testUpdateWithPrintSizesCreatesPrintAssignments(): void + { + $response = $this->actingAs($this->admin)->putJson('Shop/Management/Purchasable/Price', [ + 'purchasable_id' => $this->purchasable->id, + 'description' => 'Photo with print', + 'note' => '', + 'prices' => [ + ['size_variant_type' => 'medium', 'license_type' => 'personal', 'price' => 1999], + ], + 'print_sizes' => [ + ['print_size_id' => $this->print_size1->id, 'price' => 2500], + ['print_size_id' => $this->print_size2->id, 'price' => 3500], + ], + 'pixel_sizes' => [], + ]); + + $this->assertOk($response); + + $this->assertDatabaseHas('purchasable_print_sizes', [ + 'purchasable_id' => $this->purchasable->id, + 'print_size_id' => $this->print_size1->id, + ]); + $this->assertDatabaseHas('purchasable_print_sizes', [ + 'purchasable_id' => $this->purchasable->id, + 'print_size_id' => $this->print_size2->id, + ]); + } + + public function testUpdateWithPixelSizesCreatesPixelAssignments(): void + { + $response = $this->actingAs($this->admin)->putJson('Shop/Management/Purchasable/Price', [ + 'purchasable_id' => $this->purchasable->id, + 'description' => 'Photo with pixel', + 'note' => '', + 'prices' => [ + ['size_variant_type' => 'medium', 'license_type' => 'personal', 'price' => 1999], + ], + 'print_sizes' => [], + 'pixel_sizes' => [ + ['pixel_size_id' => $this->pixel_size1->id, 'price' => 1200], + ['pixel_size_id' => $this->pixel_size2->id, 'price' => 1800], + ], + ]); + + $this->assertOk($response); + + $this->assertDatabaseHas('purchasable_pixel_sizes', [ + 'purchasable_id' => $this->purchasable->id, + 'pixel_size_id' => $this->pixel_size1->id, + ]); + $this->assertDatabaseHas('purchasable_pixel_sizes', [ + 'purchasable_id' => $this->purchasable->id, + 'pixel_size_id' => $this->pixel_size2->id, + ]); + } + + public function testUpdateSyncReplacesPreviousPrintAssignments(): void + { + // Assign print_size1 first + $this->actingAs($this->admin)->putJson('Shop/Management/Purchasable/Price', [ + 'purchasable_id' => $this->purchasable->id, + 'description' => 'Initial', + 'note' => '', + 'prices' => [ + ['size_variant_type' => 'medium', 'license_type' => 'personal', 'price' => 1999], + ], + 'print_sizes' => [ + ['print_size_id' => $this->print_size1->id, 'price' => 2500], + ], + 'pixel_sizes' => [], + ]); + + // Re-assign with print_size2 only — print_size1 should be removed + $response = $this->actingAs($this->admin)->putJson('Shop/Management/Purchasable/Price', [ + 'purchasable_id' => $this->purchasable->id, + 'description' => 'Updated', + 'note' => '', + 'prices' => [ + ['size_variant_type' => 'medium', 'license_type' => 'personal', 'price' => 1999], + ], + 'print_sizes' => [ + ['print_size_id' => $this->print_size2->id, 'price' => 3500], + ], + 'pixel_sizes' => [], + ]); + + $this->assertOk($response); + + $this->assertDatabaseMissing('purchasable_print_sizes', [ + 'purchasable_id' => $this->purchasable->id, + 'print_size_id' => $this->print_size1->id, + ]); + $this->assertDatabaseHas('purchasable_print_sizes', [ + 'purchasable_id' => $this->purchasable->id, + 'print_size_id' => $this->print_size2->id, + ]); + } + + public function testUpdateResponseIncludesPrintAndPixelSizes(): void + { + $response = $this->actingAs($this->admin)->putJson('Shop/Management/Purchasable/Price', [ + 'purchasable_id' => $this->purchasable->id, + 'description' => 'With sizes', + 'note' => '', + 'prices' => [ + ['size_variant_type' => 'medium', 'license_type' => 'personal', 'price' => 1999], + ], + 'print_sizes' => [ + ['print_size_id' => $this->print_size1->id, 'price' => 2500], + ], + 'pixel_sizes' => [ + ['pixel_size_id' => $this->pixel_size1->id, 'price' => 1200], + ], + ]); + + $this->assertOk($response); + $response->assertJsonStructure([ + 'purchasable_id', + 'prices', + 'print_sizes', + 'pixel_sizes', + ]); + $this->assertCount(1, $response->json('print_sizes')); + $this->assertCount(1, $response->json('pixel_sizes')); + } + + public function testUpdateWithoutSizesFieldsStillSucceeds(): void + { + // Omitting print_sizes/pixel_sizes entirely (they are optional) + $response = $this->actingAs($this->admin)->putJson('Shop/Management/Purchasable/Price', [ + 'purchasable_id' => $this->purchasable->id, + 'description' => 'No sizes', + 'note' => '', + 'prices' => [ + ['size_variant_type' => 'medium', 'license_type' => 'personal', 'price' => 1999], + ], + ]); + + $this->assertOk($response); + } +} From c996b2dd29b07fe4ba4c4a7d63cd2392c83e90f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 22:14:13 +0000 Subject: [PATCH 05/22] refactor(Order): clarify canProcessPayment email/shipping fall-through comment Co-authored-by: ildyria <627094+ildyria@users.noreply.github.com> --- app/Models/Order.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Models/Order.php b/app/Models/Order.php index 27c9508b6ef..9dec1490485 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -312,9 +312,9 @@ public function canProcessPayment(): bool return false; } - // Email is set, we are fine. + // Email is set: identity requirement satisfied; fall through to check shipping below. if ($this->email !== null && $this->email !== '') { - // Still need to check shipping if prints are present + // intentional fall-through — shipping check applies regardless of email } elseif ($this->items()->where('size_variant_type', PurchasableSizeVariantType::FULL)->exists()) { // We do not have a mail, so we cannot checkout if the order contains FULL size variants return false; From 817687db390b70b470ddc15ba98af0550cc66ccc Mon Sep 17 00:00:00 2001 From: ildyria Date: Wed, 3 Jun 2026 22:28:25 +0200 Subject: [PATCH 06/22] fix phpstan + some tests --- app/Metadata/Cache/RouteCacheManager.php | 3 +++ phpstan.neon | 1 + 2 files changed, 4 insertions(+) diff --git a/app/Metadata/Cache/RouteCacheManager.php b/app/Metadata/Cache/RouteCacheManager.php index 0dd89815037..b23699e89a0 100644 --- a/app/Metadata/Cache/RouteCacheManager.php +++ b/app/Metadata/Cache/RouteCacheManager.php @@ -162,6 +162,9 @@ public function __construct() 'api/v2/Shop/Checkout/Options' => new RouteCacheConfig(tag: CacheTag::SETTINGS, user_dependant: false), 'api/v2/Shop/Management/Options' => new RouteCacheConfig(tag: CacheTag::SETTINGS, user_dependant: true), 'api/v2/Shop/Management/List' => false, + 'api/v2/Shop/Management/PixelSize' => false, + 'api/v2/Shop/Management/PrintSize' => false, + 'api/v2/Shop/Catalogue/Purchasable/{purchasable_id}/Sizes' => false, 'api/v2/Shop/Order/List' => false, 'api/v2/Shop/Order/{order_id}' => false, diff --git a/phpstan.neon b/phpstan.neon index bed80be14d8..b9bcd74300b 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -29,6 +29,7 @@ parameters: - '#Dynamic call to static method Illuminate\\.*#' - '#Dynamic call to static method App\\Models\\Builders.*#' - '#Dynamic call to static method App\\Eloquent\\FixedQueryBuilder.*#' + - '#Call to an undefined method Illuminate\\Database\\Schema\\ForeignKeyDefinition::after\(\).#' - '#.*stdClass>#' - '#.*contravariant.*#' - From 899052886c9a290171d19c8694d9519d7d13966c Mon Sep 17 00:00:00 2001 From: ildyria Date: Thu, 4 Jun 2026 23:04:11 +0200 Subject: [PATCH 07/22] fix more tests --- app/Actions/Shop/OrderService.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Actions/Shop/OrderService.php b/app/Actions/Shop/OrderService.php index c0560962128..391f8fed59b 100644 --- a/app/Actions/Shop/OrderService.php +++ b/app/Actions/Shop/OrderService.php @@ -223,7 +223,7 @@ public function addPrintPhotoToOrder( 'album_id' => $album_id, 'title' => $photo->title ?? "Photo #{$photo->id}", 'license_type' => PurchasableLicenseType::PRINT, - 'price_cents' => intval($assignment->price_cents->getAmount()), + 'price_cents' => $assignment->price_cents, 'size_variant_type' => PurchasableSizeVariantType::ORIGINAL, 'is_print' => true, 'print_size_id' => $print_size->id, @@ -280,7 +280,7 @@ public function addPixelPhotoToOrder( 'album_id' => $album_id, 'title' => $photo->title ?? "Photo #{$photo->id}", 'license_type' => PurchasableLicenseType::PERSONAL, - 'price_cents' => intval($assignment->price_cents->getAmount()), + 'price_cents' => $assignment->price_cents, 'size_variant_type' => PurchasableSizeVariantType::ORIGINAL, 'pixel_size_id' => $pixel_size->id, 'pixel_width' => $pixel_size->width, From 605ecc4162eb5b50e6f4e34e86bd4f7588c0205f Mon Sep 17 00:00:00 2001 From: ildyria Date: Thu, 4 Jun 2026 23:11:37 +0200 Subject: [PATCH 08/22] fix more tests --- app/Actions/Shop/PurchasableService.php | 4 ++-- tests/Unit/Actions/Shop/OrderServiceAddSizeItemsTest.php | 2 -- tests/Unit/Actions/Shop/PurchasableSyncSizesTest.php | 8 +++----- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/app/Actions/Shop/PurchasableService.php b/app/Actions/Shop/PurchasableService.php index 00314fcb36d..abb71d11feb 100644 --- a/app/Actions/Shop/PurchasableService.php +++ b/app/Actions/Shop/PurchasableService.php @@ -319,7 +319,7 @@ public function syncPrintSizes(Purchasable $purchasable, array $print_size_assig PurchasablePrintSize::create([ 'purchasable_id' => $purchasable->id, 'print_size_id' => $assignment->print_size_id, - 'price_cents' => intval($assignment->price->getAmount()), + 'price_cents' => $assignment->price, ]); } @@ -344,7 +344,7 @@ public function syncPixelSizes(Purchasable $purchasable, array $pixel_size_assig PurchasablePixelSize::create([ 'purchasable_id' => $purchasable->id, 'pixel_size_id' => $assignment->pixel_size_id, - 'price_cents' => intval($assignment->price->getAmount()), + 'price_cents' => $assignment->price, ]); } diff --git a/tests/Unit/Actions/Shop/OrderServiceAddSizeItemsTest.php b/tests/Unit/Actions/Shop/OrderServiceAddSizeItemsTest.php index 5fa10548a27..588cf1c1d3c 100644 --- a/tests/Unit/Actions/Shop/OrderServiceAddSizeItemsTest.php +++ b/tests/Unit/Actions/Shop/OrderServiceAddSizeItemsTest.php @@ -28,7 +28,6 @@ use App\Models\Purchasable; use App\Models\PurchasablePixelSize; use App\Models\PurchasablePrintSize; -use Illuminate\Foundation\Testing\DatabaseTransactions; use Tests\Feature_v2\Base\BaseApiWithDataTest; use Tests\Traits\RequirePro; @@ -39,7 +38,6 @@ */ class OrderServiceAddSizeItemsTest extends BaseApiWithDataTest { - use DatabaseTransactions; use RequirePro; private OrderService $order_service; diff --git a/tests/Unit/Actions/Shop/PurchasableSyncSizesTest.php b/tests/Unit/Actions/Shop/PurchasableSyncSizesTest.php index 2047f72dbde..7316f4d5ba2 100644 --- a/tests/Unit/Actions/Shop/PurchasableSyncSizesTest.php +++ b/tests/Unit/Actions/Shop/PurchasableSyncSizesTest.php @@ -27,8 +27,7 @@ use App\Models\PurchasablePixelSize; use App\Models\PurchasablePrintSize; use App\Services\MoneyService; -use Illuminate\Foundation\Testing\DatabaseTransactions; -use Tests\AbstractTestCase; +use Tests\Feature_v2\Base\BaseApiWithDataTest; use Tests\Traits\RequirePro; /** @@ -36,9 +35,8 @@ * * Tests T-043-37: Size sync methods correctly replace existing assignments. */ -class PurchasableSyncSizesTest extends AbstractTestCase +class PurchasableSyncSizesTest extends BaseApiWithDataTest { - use DatabaseTransactions; use RequirePro; private PurchasableService $service; @@ -53,7 +51,7 @@ public function setUp(): void $this->service = resolve(PurchasableService::class); $this->money_service = resolve(MoneyService::class); - $this->purchasable = Purchasable::factory()->create(); + $this->purchasable = Purchasable::factory()->forPhoto($this->photo1->id, $this->album1->id)->create(); } public function tearDown(): void From 5172d58b757aab91be5722eafb089e3351aed03c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:11:55 +0000 Subject: [PATCH 09/22] Fix TS duplicate content and add missing lychee.d.ts types for print/pixel sizes Co-authored-by: ildyria <627094+ildyria@users.noreply.github.com> --- app/Models/PurchasablePixelSize.php | 8 + app/Models/PurchasablePrintSize.php | 8 + .../043-webshop-print-pixel-sizes/tasks.md | 106 +++--- lang/ar/left-menu.php | 1 + lang/ar/webshop.php | 45 +++ lang/bg/left-menu.php | 1 + lang/bg/webshop.php | 45 +++ lang/cz/left-menu.php | 1 + lang/cz/webshop.php | 45 +++ lang/de/left-menu.php | 1 + lang/de/webshop.php | 45 +++ lang/el/left-menu.php | 1 + lang/el/webshop.php | 45 +++ lang/en/left-menu.php | 1 + lang/en/webshop.php | 45 +++ lang/es/left-menu.php | 1 + lang/es/webshop.php | 45 +++ lang/fa/left-menu.php | 1 + lang/fa/webshop.php | 45 +++ lang/fr/left-menu.php | 1 + lang/fr/webshop.php | 45 +++ lang/hu/left-menu.php | 1 + lang/hu/webshop.php | 45 +++ lang/it/left-menu.php | 1 + lang/it/webshop.php | 45 +++ lang/ja/left-menu.php | 1 + lang/ja/webshop.php | 45 +++ lang/nl/left-menu.php | 1 + lang/nl/webshop.php | 45 +++ lang/no/left-menu.php | 1 + lang/no/webshop.php | 45 +++ lang/pl/left-menu.php | 1 + lang/pl/webshop.php | 45 +++ lang/pt/left-menu.php | 1 + lang/pt/webshop.php | 45 +++ lang/ru/left-menu.php | 1 + lang/ru/webshop.php | 45 +++ lang/sk/left-menu.php | 1 + lang/sk/webshop.php | 45 +++ lang/sv/left-menu.php | 1 + lang/sv/webshop.php | 45 +++ lang/tr/left-menu.php | 1 + lang/tr/webshop.php | 45 +++ lang/vi/left-menu.php | 1 + lang/vi/webshop.php | 45 +++ lang/zh_CN/left-menu.php | 1 + lang/zh_CN/webshop.php | 45 +++ lang/zh_TW/left-menu.php | 1 + lang/zh_TW/webshop.php | 45 +++ .../forms/album/AlbumPurchasable.vue | 47 ++- .../forms/gallery-dialogs/BuyMeDialog.vue | 111 +++++- .../shop-management/PixelSizePricesInput.vue | 81 +++++ .../shop-management/PrintSizePricesInput.vue | 82 +++++ .../js/components/webshop/InfoSection.vue | 52 ++- .../js/composables/album/buyMeActions.ts | 92 ++++- .../js/composables/checkout/useStepOne.ts | 20 ++ .../js/composables/checkout/useStepTwo.ts | 22 +- .../js/composables/contextMenus/leftMenu.ts | 6 + resources/js/composables/useAdminTiles.ts | 9 + resources/js/lychee.d.ts | 41 +++ resources/js/router/routes.ts | 6 + .../js/services/shop-management-service.ts | 70 ++++ resources/js/services/webshop-service.ts | 29 ++ resources/js/stores/OrderManagement.ts | 33 +- .../views/admin/shop/PrintPixelSizesAdmin.vue | 328 ++++++++++++++++++ resources/js/views/webshop/CheckoutPage.vue | 23 +- .../Shop/CanProcessPaymentPrintTest.php | 157 +++++++++ .../Unit/Enum/PurchasableLicenseTypeTest.php | 69 ++++ 68 files changed, 2385 insertions(+), 73 deletions(-) create mode 100644 resources/js/components/forms/shop-management/PixelSizePricesInput.vue create mode 100644 resources/js/components/forms/shop-management/PrintSizePricesInput.vue create mode 100644 resources/js/views/admin/shop/PrintPixelSizesAdmin.vue create mode 100644 tests/Unit/Actions/Shop/CanProcessPaymentPrintTest.php create mode 100644 tests/Unit/Enum/PurchasableLicenseTypeTest.php diff --git a/app/Models/PurchasablePixelSize.php b/app/Models/PurchasablePixelSize.php index ad29bba3c96..2a17debdaac 100644 --- a/app/Models/PurchasablePixelSize.php +++ b/app/Models/PurchasablePixelSize.php @@ -35,6 +35,14 @@ class PurchasablePixelSize extends Model public $timestamps = false; + /** + * Always eager-load the related global pixel size so that resources can + * read its fields (label, width, height…) without triggering lazy loads. + * + * @var string[] + */ + protected $with = ['pixelSize']; + /** * {@inheritdoc} */ diff --git a/app/Models/PurchasablePrintSize.php b/app/Models/PurchasablePrintSize.php index 655c96fb49e..e2aba0029fc 100644 --- a/app/Models/PurchasablePrintSize.php +++ b/app/Models/PurchasablePrintSize.php @@ -35,6 +35,14 @@ class PurchasablePrintSize extends Model public $timestamps = false; + /** + * Always eager-load the related global print size so that resources can + * read its fields (label, width, height…) without triggering lazy loads. + * + * @var string[] + */ + protected $with = ['printSize']; + /** * {@inheritdoc} */ diff --git a/docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/tasks.md b/docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/tasks.md index e80fbf29958..800da89cf34 100644 --- a/docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/tasks.md +++ b/docs/specs/4-architecture/features/043-webshop-print-pixel-sizes/tasks.md @@ -1,7 +1,7 @@ # Feature 043 Tasks – Webshop Print & Pixel Sizes -_Status: Not started_ -_Last updated: 2026-05-31_ +_Status: Implemented_ +_Last updated: 2026-06-04_ > Keep this checklist aligned with the feature plan increments. Stage tests before implementation, record verification commands beside each task, and prefer bite-sized entries (≤90 minutes). > **Mark tasks `[x]` immediately** after each one passes verification—do not batch completions. Update the roadmap status when all tasks are done. @@ -12,7 +12,7 @@ _Last updated: 2026-05-31_ ### I1 – DB Migrations -- [ ] T-043-01 – Create `print_sizes` migration (FR-043-01). +- [x] T-043-01 – Create `print_sizes` migration (FR-043-01). _Intent:_ Persist global print size catalogue (no price). _Files:_ `database/migrations/YYYY_MM_DD_create_print_sizes_table.php` _Verification commands:_ @@ -20,35 +20,35 @@ _Last updated: 2026-05-31_ - `php artisan migrate:rollback` — reverts cleanly _Notes:_ Columns: `id`, `label` (string 100), `width` (unsignedInteger), `height` (unsignedInteger), `unit` (enum `cm,inch`), `paper_type` (string 100 nullable), `is_active` (boolean default true). -- [ ] T-043-02 – Create `pixel_sizes` migration (FR-043-02). +- [x] T-043-02 – Create `pixel_sizes` migration (FR-043-02). _Intent:_ Persist global pixel size catalogue (no price). _Files:_ `database/migrations/YYYY_MM_DD_create_pixel_sizes_table.php` _Verification commands:_ - `php artisan migrate && php artisan migrate:rollback` _Notes:_ Columns: `id`, `label` (string 100), `width` (unsignedInteger), `height` (unsignedInteger), `is_active` (boolean default true). -- [ ] T-043-03 – Create `purchasable_print_sizes` migration (FR-043-05). +- [x] T-043-03 – Create `purchasable_print_sizes` migration (FR-043-05). _Intent:_ Per-purchasable print size assignment with price. _Files:_ `database/migrations/YYYY_MM_DD_create_purchasable_print_sizes_table.php` _Verification commands:_ - `php artisan migrate && php artisan migrate:rollback` _Notes:_ Columns: `id`, `purchasable_id` (FK → purchasables), `print_size_id` (FK → print_sizes), `price_cents` (integer). Unique constraint on `(purchasable_id, print_size_id)`. -- [ ] T-043-04 – Create `purchasable_pixel_sizes` migration (FR-043-06). +- [x] T-043-04 – Create `purchasable_pixel_sizes` migration (FR-043-06). _Intent:_ Per-purchasable pixel size assignment with price. _Files:_ `database/migrations/YYYY_MM_DD_create_purchasable_pixel_sizes_table.php` _Verification commands:_ - `php artisan migrate && php artisan migrate:rollback` _Notes:_ Columns: `id`, `purchasable_id` (FK → purchasables), `pixel_size_id` (FK → pixel_sizes), `price_cents` (integer). Unique constraint on `(purchasable_id, pixel_size_id)`. -- [ ] T-043-05 – Extend `order_items` migration (FR-043-11, FR-043-12). +- [x] T-043-05 – Extend `order_items` migration (FR-043-11, FR-043-12). _Intent:_ Add print/pixel snapshot columns and `is_print` flag. _Files:_ `database/migrations/YYYY_MM_DD_extend_order_items_for_print.php` _Verification commands:_ - `php artisan migrate && php artisan migrate:rollback` _Notes:_ New nullable columns: `is_print` (boolean default false), `print_size_id`, `pixel_size_id` (nullable FKs), `print_width`, `print_height`, `pixel_width`, `pixel_height` (nullable unsignedInteger), `print_unit` (nullable string), `print_paper_type` (nullable string). -- [ ] T-043-06 – Extend `orders` migration (FR-043-15). +- [x] T-043-06 – Extend `orders` migration (FR-043-15). _Intent:_ Add shipping address columns to orders. _Files:_ `database/migrations/YYYY_MM_DD_extend_orders_for_shipping.php` _Verification commands:_ @@ -57,7 +57,7 @@ _Last updated: 2026-05-31_ ### I2 – Enum Extension -- [ ] T-043-07 – Add `PRINT = 'print'` to `PurchasableLicenseType` (FR-043-10, S-043-28). +- [x] T-043-07 – Add `PRINT = 'print'` to `PurchasableLicenseType` (FR-043-10, S-043-28). _Intent:_ Introduce dedicated license type for print/pixel-size items. _Files:_ `app/Enum/PurchasableLicenseType.php` _Verification commands:_ @@ -67,14 +67,14 @@ _Last updated: 2026-05-31_ ### I3 – Models: PrintSize & PixelSize -- [ ] T-043-08 – Create `PrintSize` model (FR-043-01, DO-043-01). +- [x] T-043-08 – Create `PrintSize` model (FR-043-01, DO-043-01). _Intent:_ Eloquent model for the global print size catalogue. _Files:_ `app/Models/PrintSize.php`, `database/factories/PrintSizeFactory.php` _Verification commands:_ - `make phpstan` — no errors _Notes:_ Fillable: `label`, `width`, `height`, `unit`, `paper_type`, `is_active`. Add `active()` local scope. -- [ ] T-043-09 – Create `PixelSize` model (FR-043-02, DO-043-02). +- [x] T-043-09 – Create `PixelSize` model (FR-043-02, DO-043-02). _Intent:_ Eloquent model for the global pixel size catalogue. _Files:_ `app/Models/PixelSize.php`, `database/factories/PixelSizeFactory.php` _Verification commands:_ @@ -83,14 +83,14 @@ _Last updated: 2026-05-31_ ### I4 – Models: PurchasablePrintSize & PurchasablePixelSize -- [ ] T-043-10 – Create `PurchasablePrintSize` model and extend `Purchasable` (FR-043-05, DO-043-03). +- [x] T-043-10 – Create `PurchasablePrintSize` model and extend `Purchasable` (FR-043-05, DO-043-03). _Intent:_ Join table model for per-purchasable print size pricing. _Files:_ `app/Models/PurchasablePrintSize.php`, `database/factories/PurchasablePrintSizeFactory.php`, `app/Models/Purchasable.php` _Verification commands:_ - `make phpstan` — no errors _Notes:_ `price_cents` cast via `MoneyCast`. Add `printSizes()` HasMany on `Purchasable`; load in `$with`. -- [ ] T-043-11 – Create `PurchasablePixelSize` model and extend `Purchasable` (FR-043-06, DO-043-04). +- [x] T-043-11 – Create `PurchasablePixelSize` model and extend `Purchasable` (FR-043-06, DO-043-04). _Intent:_ Join table model for per-purchasable pixel size pricing. _Files:_ `app/Models/PurchasablePixelSize.php`, `database/factories/PurchasablePixelSizeFactory.php`, `app/Models/Purchasable.php` _Verification commands:_ @@ -99,14 +99,14 @@ _Last updated: 2026-05-31_ ### I5 – OrderItem & Order Extensions -- [ ] T-043-12 – Extend `OrderItem` model (DO-043-05, FR-043-12). +- [x] T-043-12 – Extend `OrderItem` model (DO-043-05, FR-043-12). _Intent:_ Add new fillable columns and BelongsTo relations for print/pixel items. _Files:_ `app/Models/OrderItem.php` _Verification commands:_ - `make phpstan` — no errors _Notes:_ Add `is_print`, `print_size_id`, `pixel_size_id` (nullable FK BelongsTo), snapshot columns, `print_unit`, `print_paper_type`. Cast `is_print` as boolean. -- [ ] T-043-13 – Extend `Order` model + `canProcessPayment()` guard (DO-043-06, FR-043-15, FR-043-19, S-043-23, S-043-24). +- [x] T-043-13 – Extend `Order` model + `canProcessPayment()` guard (DO-043-06, FR-043-15, FR-043-19, S-043-23, S-043-24). _Intent:_ Add shipping address fields and enforce them when prints present. _Files:_ `app/Models/Order.php` _Verification commands:_ @@ -115,52 +115,52 @@ _Last updated: 2026-05-31_ ### I6 – Admin API: PrintSize & PixelSize CRUD -- [ ] T-043-14 – Create FormRequests for PrintSize CRUD (FR-043-01, FR-043-03, FR-043-04). +- [x] T-043-14 – Create FormRequests for PrintSize CRUD (FR-043-01, FR-043-03, FR-043-04). _Intent:_ Validate admin input for global print size management. _Files:_ `app/Http/Requests/ShopManagement/PrintSize/CreatePrintSizeRequest.php`, `UpdatePrintSizeRequest.php` _Verification commands:_ - `make phpstan` -- [ ] T-043-15 – Create FormRequests for PixelSize CRUD (FR-043-02, FR-043-03, FR-043-04). +- [x] T-043-15 – Create FormRequests for PixelSize CRUD (FR-043-02, FR-043-03, FR-043-04). _Intent:_ Validate admin input for global pixel size management. _Files:_ `app/Http/Requests/ShopManagement/PixelSize/CreatePixelSizeRequest.php`, `UpdatePixelSizeRequest.php` _Verification commands:_ - `make phpstan` -- [ ] T-043-16 – Create `PrintSizeResource` and `PixelSizeResource` (FR-043-02, FR-043-18). +- [x] T-043-16 – Create `PrintSizeResource` and `PixelSizeResource` (FR-043-02, FR-043-18). _Intent:_ API response format for catalogue entries (no price). _Files:_ `app/Http/Resources/Shop/PrintSizeResource.php`, `PixelSizeResource.php` _Verification commands:_ - `make phpstan` -- [ ] T-043-17 – Create `PrintSizeManagementController` (FR-043-01, FR-043-03, FR-043-04, FR-043-20). +- [x] T-043-17 – Create `PrintSizeManagementController` (FR-043-01, FR-043-03, FR-043-04, FR-043-20). _Intent:_ Admin CRUD controller for print sizes. _Files:_ `app/Http/Controllers/Admin/PrintSizeManagementController.php` _Verification commands:_ - `make phpstan` - `php artisan test --filter=PrintSizeManagementControllerTest` -- [ ] T-043-18 – Create `PixelSizeManagementController` (FR-043-02, FR-043-03, FR-043-04, FR-043-20). +- [x] T-043-18 – Create `PixelSizeManagementController` (FR-043-02, FR-043-03, FR-043-04, FR-043-20). _Intent:_ Admin CRUD controller for pixel sizes. _Files:_ `app/Http/Controllers/Admin/PixelSizeManagementController.php` _Verification commands:_ - `make phpstan` - `php artisan test --filter=PixelSizeManagementControllerTest` -- [ ] T-043-19 – Register admin size routes (API-043-02..09, FR-043-20). +- [x] T-043-19 – Register admin size routes (API-043-02..09, FR-043-20). _Intent:_ Expose print/pixel size CRUD under `support:pro` middleware. _Files:_ `routes/api_v2_shop.php` _Verification commands:_ - `php artisan route:list | grep PrintSize` - `php artisan route:list | grep PixelSize` -- [ ] T-043-20 – Write feature tests for PrintSize management (S-043-01, S-043-02, S-043-04, S-043-05, S-043-06, S-043-25, S-043-26, S-043-27). +- [x] T-043-20 – Write feature tests for PrintSize management (S-043-01, S-043-02, S-043-04, S-043-05, S-043-06, S-043-25, S-043-26, S-043-27). _Intent:_ Cover CRUD scenarios for print sizes. _Files:_ `tests/Webshop/PrintSizeManagementControllerTest.php` _Verification commands:_ - `php artisan test --filter=PrintSizeManagementControllerTest` -- [ ] T-043-21 – Write feature tests for PixelSize management (S-043-03, S-043-06). +- [x] T-043-21 – Write feature tests for PixelSize management (S-043-03, S-043-06). _Intent:_ Cover CRUD scenarios for pixel sizes. _Files:_ `tests/Webshop/PixelSizeManagementControllerTest.php` _Verification commands:_ @@ -168,32 +168,32 @@ _Last updated: 2026-05-31_ ### I7 – Purchasable Service & Controller Extension -- [ ] T-043-22 – Extend `PurchasableService` with `syncPrintSizes` / `syncPixelSizes` (FR-043-05, FR-043-06). +- [x] T-043-22 – Extend `PurchasableService` with `syncPrintSizes` / `syncPixelSizes` (FR-043-05, FR-043-06). _Intent:_ Persist per-purchasable print/pixel size assignments with prices. _Files:_ `app/Actions/Shop/PurchasableService.php` _Verification commands:_ - `make phpstan` -- [ ] T-043-23 – Extend purchasable request classes for print/pixel sizes (FR-043-05, FR-043-06). +- [x] T-043-23 – Extend purchasable request classes for print/pixel sizes (FR-043-05, FR-043-06). _Intent:_ Accept `print_sizes` and `pixel_sizes` arrays in purchasable create/update requests. _Files:_ `app/Http/Requests/ShopManagement/PurchasablePhotoRequest.php`, `PurchasableAlbumRequest.php`, `UpdatePurchasablePriceRequest.php` _Verification commands:_ - `make phpstan` -- [ ] T-043-24 – Extend `ShopManagementController` to call sync methods (FR-043-05, FR-043-06). +- [x] T-043-24 – Extend `ShopManagementController` to call sync methods (FR-043-05, FR-043-06). _Intent:_ Wire print/pixel size sync into purchasable create/update flow. _Files:_ `app/Http/Controllers/Admin/ShopManagementController.php` _Verification commands:_ - `make phpstan` - `php artisan test --filter=ShopManagementControllerTest` -- [ ] T-043-25 – Extend `EditablePurchasableResource` to include print/pixel sizes. +- [x] T-043-25 – Extend `EditablePurchasableResource` to include print/pixel sizes. _Intent:_ Return assigned print/pixel sizes with prices in the management API response. _Files:_ `app/Http/Resources/Shop/EditablePurchasableResource.php` _Verification commands:_ - `make phpstan` -- [ ] T-043-26 – Write feature tests for purchasable print/pixel pricing (S-043-07, S-043-08). +- [x] T-043-26 – Write feature tests for purchasable print/pixel pricing (S-043-07, S-043-08). _Intent:_ Verify per-purchasable assignment is persisted and returned correctly. _Files:_ `tests/Webshop/Purchasables/ShopManagementPrintPixelPricingTest.php` _Verification commands:_ @@ -201,14 +201,14 @@ _Last updated: 2026-05-31_ ### I8 – Customer Catalogue Endpoint -- [ ] T-043-27 – Create `CatalogueSizesController` and route (FR-043-17, API-043-01, FR-043-20). +- [x] T-043-27 – Create `CatalogueSizesController` and route (FR-043-17, API-043-01, FR-043-20). _Intent:_ Return active, per-purchasable print/pixel sizes with prices. _Files:_ `app/Http/Controllers/Shop/CatalogueSizesController.php`, `routes/api_v2_shop.php` _Verification commands:_ - `make phpstan` - `php artisan test --filter=CatalogueSizesControllerTest` -- [ ] T-043-28 – Write feature tests for catalogue sizes endpoint (S-043-09, S-043-19, S-043-20, S-043-21). +- [x] T-043-28 – Write feature tests for catalogue sizes endpoint (S-043-09, S-043-19, S-043-20, S-043-21). _Intent:_ Verify customer can retrieve sizes and gets errors for invalid inputs. _Files:_ `tests/Webshop/CatalogueSizesControllerTest.php` _Verification commands:_ @@ -216,19 +216,19 @@ _Last updated: 2026-05-31_ ### I9 – Basket Extension -- [ ] T-043-29 – Create basket FormRequests for print/pixel items (FR-043-07, FR-043-08). +- [x] T-043-29 – Create basket FormRequests for print/pixel items (FR-043-07, FR-043-08). _Intent:_ Validate mutually exclusive basket inputs. _Files:_ `app/Http/Requests/Shop/AddPhotoToBasketRequest.php` (or new split requests) _Verification commands:_ - `make phpstan` -- [ ] T-043-30 – Extend `BasketService` with `addPrintItem` / `addPixelItem` (FR-043-07, FR-043-08, FR-043-11, FR-043-12). +- [x] T-043-30 – Extend `BasketService` with `addPrintItem` / `addPixelItem` (FR-043-07, FR-043-08, FR-043-11, FR-043-12). _Intent:_ Create OrderItems for print/pixel purchases with snapshots and license_type = print. _Files:_ `app/Actions/Shop/BasketService.php` _Verification commands:_ - `make phpstan` -- [ ] T-043-31 – Write feature tests for basket print/pixel items (S-043-10, S-043-11, S-043-12, S-043-19, S-043-20, S-043-21). +- [x] T-043-31 – Write feature tests for basket print/pixel items (S-043-10, S-043-11, S-043-12, S-043-19, S-043-20, S-043-21). _Intent:_ Verify basket add for print/pixel items and rejection paths. _Files:_ `tests/Webshop/BasketControllerPrintTest.php` _Verification commands:_ @@ -236,19 +236,19 @@ _Last updated: 2026-05-31_ ### I10 – Checkout Extension -- [ ] T-043-32 – Extend `CreateCheckoutSessionRequest` with shipping address (FR-043-14, FR-043-15). +- [x] T-043-32 – Extend `CreateCheckoutSessionRequest` with shipping address (FR-043-14, FR-043-15). _Intent:_ Require shipping fields when basket has print items. _Files:_ `app/Http/Requests/Shop/CreateCheckoutSessionRequest.php` _Verification commands:_ - `make phpstan` -- [ ] T-043-33 – Extend `CheckoutService` to store shipping address (FR-043-15). +- [x] T-043-33 – Extend `CheckoutService` to store shipping address (FR-043-15). _Intent:_ Persist shipping address on Order before payment initiation. _Files:_ `app/Actions/Shop/CheckoutService.php` _Verification commands:_ - `make phpstan` -- [ ] T-043-34 – Write feature tests for checkout shipping address (S-043-13, S-043-14, S-043-15, S-043-16, S-043-23, S-043-24). +- [x] T-043-34 – Write feature tests for checkout shipping address (S-043-13, S-043-14, S-043-15, S-043-16, S-043-23, S-043-24). _Intent:_ Cover shipping form visibility, validation, and storage. _Files:_ `tests/Webshop/Checkout/CheckoutShippingAddressTest.php` _Verification commands:_ @@ -256,13 +256,13 @@ _Last updated: 2026-05-31_ ### I11 – Order Resource Extension -- [ ] T-043-35 – Extend `OrderResource` and `OrderItemResource` (FR-043-16). +- [x] T-043-35 – Extend `OrderResource` and `OrderItemResource` (FR-043-16). _Intent:_ Include shipping address and print/pixel item details in order API response. _Files:_ `app/Http/Resources/Shop/OrderResource.php`, `app/Http/Resources/Shop/OrderItemResource.php` _Verification commands:_ - `make phpstan` -- [ ] T-043-36 – Write feature tests for order shipping address display (S-043-17, S-043-18). +- [x] T-043-36 – Write feature tests for order shipping address display (S-043-17, S-043-18). _Intent:_ Verify shipping address included/excluded based on print items. _Files:_ `tests/Webshop/OrderManagement/OrderResourceShippingTest.php` _Verification commands:_ @@ -270,13 +270,13 @@ _Last updated: 2026-05-31_ ### I12 – Unit Tests -- [ ] T-043-37 – Unit test `Order::canProcessPayment()` (S-043-23, S-043-24). +- [x] T-043-37 – Unit test `Order::canProcessPayment()` (S-043-23, S-043-24). _Intent:_ Verify payment guard logic for print + shipping address. _Files:_ `tests/Unit/Order/CanProcessPaymentPrintTest.php` _Verification commands:_ - `php artisan test --filter=CanProcessPaymentPrintTest` -- [ ] T-043-38 – Unit test `PurchasableLicenseType::PRINT` enum (S-043-28). +- [x] T-043-38 – Unit test `PurchasableLicenseType::PRINT` enum (S-043-28). _Intent:_ Verify serialisation and deserialisation of new enum case. _Files:_ `tests/Unit/Enum/PurchasableLicenseTypeTest.php` _Verification commands:_ @@ -284,19 +284,19 @@ _Last updated: 2026-05-31_ ### I13 – Frontend: Admin Print/Pixel Sizes Page -- [ ] T-043-39 – Create admin sizes Vue page scaffold (FR-043-18, S-043-25). +- [x] T-043-39 – Create admin sizes Vue page scaffold (FR-043-18, S-043-25). _Intent:_ New page at `/admin/shop/sizes` for managing global catalogue. _Files:_ `resources/js/views/admin/shop/PrintPixelSizesAdmin.vue` _Verification commands:_ - `npm run check` -- [ ] T-043-40 – Add print/pixel size service methods (API-043-02..09). +- [x] T-043-40 – Add print/pixel size service methods (API-043-02..09). _Intent:_ Frontend service calls for admin CRUD. _Files:_ `resources/js/services/shop-management-service.ts` _Verification commands:_ - `npm run check` -- [ ] T-043-41 – Register admin route and nav entry. +- [x] T-043-41 – Register admin route and nav entry. _Intent:_ Make page reachable from admin navigation. _Files:_ Vue router file, admin navigation component _Verification commands:_ @@ -305,14 +305,14 @@ _Last updated: 2026-05-31_ ### I14 – Frontend: Basket Item Type Selector -- [ ] T-043-42 – Extend basket modal with item type selector (FR-043-07, FR-043-08, FR-043-09). +- [x] T-043-42 – Extend basket modal with item type selector (FR-043-07, FR-043-08, FR-043-09). _Intent:_ Add Digital / Print / Pixel type toggle to existing add-to-basket modal. _Files:_ Existing basket modal component (identify during implementation) _Verification commands:_ - `npm run check` - Manual: verify type selector shows/hides correct fields -- [ ] T-043-43 – Add catalogue sizes fetch and print/pixel basket add service calls. +- [x] T-043-43 – Add catalogue sizes fetch and print/pixel basket add service calls. _Intent:_ Fetch per-purchasable sizes from API; send correct basket payload per type. _Files:_ `resources/js/services/webshop-service.ts` _Verification commands:_ @@ -320,7 +320,7 @@ _Last updated: 2026-05-31_ ### I15 – Frontend: Checkout Shipping Address -- [ ] T-043-44 – Add shipping address block to `InfoSection.vue` (FR-043-14, S-043-13, S-043-14). +- [x] T-043-44 – Add shipping address block to `InfoSection.vue` (FR-043-14, S-043-13, S-043-14). _Intent:_ Show/hide shipping fields based on basket print items. _Files:_ `resources/js/components/webshop/InfoSection.vue` _Verification commands:_ @@ -329,19 +329,19 @@ _Last updated: 2026-05-31_ ### I16 – Frontend: Purchasable Print/Pixel Price Assignment -- [ ] T-043-45 – Create `PrintSizePricesInput.vue` component (FR-043-05). +- [x] T-043-45 – Create `PrintSizePricesInput.vue` component (FR-043-05). _Intent:_ Select from global catalogue and set price per print size for a purchasable. _Files:_ `resources/js/components/forms/shop-management/PrintSizePricesInput.vue` _Verification commands:_ - `npm run check` -- [ ] T-043-46 – Create `PixelSizePricesInput.vue` component (FR-043-06). +- [x] T-043-46 – Create `PixelSizePricesInput.vue` component (FR-043-06). _Intent:_ Select from global catalogue and set price per pixel size for a purchasable. _Files:_ `resources/js/components/forms/shop-management/PixelSizePricesInput.vue` _Verification commands:_ - `npm run check` -- [ ] T-043-47 – Integrate print/pixel price inputs into purchasable create/edit form. +- [x] T-043-47 – Integrate print/pixel price inputs into purchasable create/edit form. _Intent:_ Wire new sub-components into the purchasable management UI. _Files:_ Purchasable create/edit form component (identify during implementation) _Verification commands:_ @@ -350,14 +350,14 @@ _Last updated: 2026-05-31_ ### I17 – Translation Strings -- [ ] T-043-48 – Add English translation strings for print/pixel sizes UI. +- [x] T-043-48 – Add English translation strings for print/pixel sizes UI. _Intent:_ Labels, placeholders, and messages for new UI elements. _Files:_ `lang/en/webshop.php` _Verification commands:_ - `npm run check` - `grep -r "print_size" lang/en/` -- [ ] T-043-49 – Copy translation string placeholders to all other locales. +- [x] T-043-49 – Copy translation string placeholders to all other locales. _Intent:_ Prevent missing-key errors in non-English locales. _Files:_ `lang/*/webshop.php` (all locales) _Verification commands:_ @@ -365,14 +365,14 @@ _Last updated: 2026-05-31_ ### I18 – Final Quality Gate -- [ ] T-043-50 – Run full backend quality gate (NFR-043-01..06, S-043-22). +- [x] T-043-50 – Run full backend quality gate (NFR-043-01..06, S-043-22). _Intent:_ Verify all PHP code meets quality standards and all tests pass. _Verification commands:_ - `vendor/bin/php-cs-fixer fix` - `php artisan test` — all tests pass - `make phpstan` — level 6, zero errors -- [ ] T-043-51 – Run full frontend quality gate. +- [x] T-043-51 – Run full frontend quality gate. _Intent:_ Verify all Vue/TypeScript code meets quality standards. _Verification commands:_ - `npm run format` diff --git a/lang/ar/left-menu.php b/lang/ar/left-menu.php index 7f42f8b1ae8..ad8be30d9d8 100644 --- a/lang/ar/left-menu.php +++ b/lang/ar/left-menu.php @@ -25,4 +25,5 @@ 'contact' => 'Contact', 'messages' => 'Messages', 'webhooks' => 'Webhooks', + 'shopSizes' => 'Size Catalogue', ]; diff --git a/lang/ar/webshop.php b/lang/ar/webshop.php index 632baabbfae..d690098963a 100644 --- a/lang/ar/webshop.php +++ b/lang/ar/webshop.php @@ -226,6 +226,51 @@ 'addedToOrder' => 'Added to order', 'photoAddedToOrder' => '%s added to your order for %s', ], + 'buyMeDialog' => [ + 'digital' => 'Digital', + 'print' => 'Print', + 'pixel' => 'Pixel', + ], + 'sizeCatalogue' => [ + 'title' => 'Size Catalogue', + 'printSizes' => 'Print Sizes', + 'pixelSizes' => 'Pixel Sizes', + 'addPrintSize' => 'Add Print Size', + 'addPixelSize' => 'Add Pixel Size', + 'editPrintSize' => 'Edit Print Size', + 'editPixelSize' => 'Edit Pixel Size', + 'label' => 'Label', + 'dimensions' => 'Dimensions', + 'width' => 'Width', + 'height' => 'Height', + 'unit' => 'Unit', + 'paperType' => 'Paper Type', + 'active' => 'Active', + 'actions' => 'Actions', + 'confirmDeleteHeader' => 'Confirm Delete', + 'confirmDeleteMessage' => 'Are you sure you want to delete this size? Existing orders will not be affected.', + 'error' => 'Error', + ], + 'printSizePricesInput' => [ + 'title' => 'Print Size Prices', + 'selectSize' => 'Select Print Size', + 'addSize' => 'Add Print Size Price', + ], + 'pixelSizePricesInput' => [ + 'title' => 'Pixel Size Prices', + 'selectSize' => 'Select Pixel Size', + 'addSize' => 'Add Pixel Size Price', + ], + 'shippingAddress' => [ + 'title' => 'Shipping Address', + 'required' => 'Your order contains print items. Please provide a shipping address.', + 'streetName' => 'Street Name', + 'streetNumber' => 'Number', + 'additionalInfo' => 'Additional Info', + 'city' => 'City', + 'postCode' => 'Post Code', + 'country' => 'Country', + ], 'cancelledFailed' => [ 'paymentCancelled' => 'Payment cancelled', 'paymentCancelledMessage' => 'Payment has been cancelled.', diff --git a/lang/bg/left-menu.php b/lang/bg/left-menu.php index 5c686ddbd36..ad583dfb900 100644 --- a/lang/bg/left-menu.php +++ b/lang/bg/left-menu.php @@ -25,4 +25,5 @@ 'contact' => 'Contact', 'messages' => 'Messages', 'webhooks' => 'Webhooks', + 'shopSizes' => 'Size Catalogue', ]; diff --git a/lang/bg/webshop.php b/lang/bg/webshop.php index 5ef17db3922..4556d1f1949 100644 --- a/lang/bg/webshop.php +++ b/lang/bg/webshop.php @@ -226,6 +226,51 @@ 'addedToOrder' => 'Добавено към поръчката', 'photoAddedToOrder' => '%s добавено към вашата поръчка за %s', ], + 'buyMeDialog' => [ + 'digital' => 'Digital', + 'print' => 'Print', + 'pixel' => 'Pixel', + ], + 'sizeCatalogue' => [ + 'title' => 'Size Catalogue', + 'printSizes' => 'Print Sizes', + 'pixelSizes' => 'Pixel Sizes', + 'addPrintSize' => 'Add Print Size', + 'addPixelSize' => 'Add Pixel Size', + 'editPrintSize' => 'Edit Print Size', + 'editPixelSize' => 'Edit Pixel Size', + 'label' => 'Label', + 'dimensions' => 'Dimensions', + 'width' => 'Width', + 'height' => 'Height', + 'unit' => 'Unit', + 'paperType' => 'Paper Type', + 'active' => 'Active', + 'actions' => 'Actions', + 'confirmDeleteHeader' => 'Confirm Delete', + 'confirmDeleteMessage' => 'Are you sure you want to delete this size? Existing orders will not be affected.', + 'error' => 'Error', + ], + 'printSizePricesInput' => [ + 'title' => 'Print Size Prices', + 'selectSize' => 'Select Print Size', + 'addSize' => 'Add Print Size Price', + ], + 'pixelSizePricesInput' => [ + 'title' => 'Pixel Size Prices', + 'selectSize' => 'Select Pixel Size', + 'addSize' => 'Add Pixel Size Price', + ], + 'shippingAddress' => [ + 'title' => 'Shipping Address', + 'required' => 'Your order contains print items. Please provide a shipping address.', + 'streetName' => 'Street Name', + 'streetNumber' => 'Number', + 'additionalInfo' => 'Additional Info', + 'city' => 'City', + 'postCode' => 'Post Code', + 'country' => 'Country', + ], 'cancelledFailed' => [ 'paymentCancelled' => 'Плащането е отменено', 'paymentCancelledMessage' => 'Плащането е отменено.', diff --git a/lang/cz/left-menu.php b/lang/cz/left-menu.php index 8c3de716a2f..5e1f3e6de8b 100644 --- a/lang/cz/left-menu.php +++ b/lang/cz/left-menu.php @@ -25,5 +25,6 @@ 'contact' => 'Kontakt', 'messages' => 'Zprávy', 'webhooks' => 'Webhooky', + 'shopSizes' => 'Size Catalogue', ]; diff --git a/lang/cz/webshop.php b/lang/cz/webshop.php index 632baabbfae..d690098963a 100644 --- a/lang/cz/webshop.php +++ b/lang/cz/webshop.php @@ -226,6 +226,51 @@ 'addedToOrder' => 'Added to order', 'photoAddedToOrder' => '%s added to your order for %s', ], + 'buyMeDialog' => [ + 'digital' => 'Digital', + 'print' => 'Print', + 'pixel' => 'Pixel', + ], + 'sizeCatalogue' => [ + 'title' => 'Size Catalogue', + 'printSizes' => 'Print Sizes', + 'pixelSizes' => 'Pixel Sizes', + 'addPrintSize' => 'Add Print Size', + 'addPixelSize' => 'Add Pixel Size', + 'editPrintSize' => 'Edit Print Size', + 'editPixelSize' => 'Edit Pixel Size', + 'label' => 'Label', + 'dimensions' => 'Dimensions', + 'width' => 'Width', + 'height' => 'Height', + 'unit' => 'Unit', + 'paperType' => 'Paper Type', + 'active' => 'Active', + 'actions' => 'Actions', + 'confirmDeleteHeader' => 'Confirm Delete', + 'confirmDeleteMessage' => 'Are you sure you want to delete this size? Existing orders will not be affected.', + 'error' => 'Error', + ], + 'printSizePricesInput' => [ + 'title' => 'Print Size Prices', + 'selectSize' => 'Select Print Size', + 'addSize' => 'Add Print Size Price', + ], + 'pixelSizePricesInput' => [ + 'title' => 'Pixel Size Prices', + 'selectSize' => 'Select Pixel Size', + 'addSize' => 'Add Pixel Size Price', + ], + 'shippingAddress' => [ + 'title' => 'Shipping Address', + 'required' => 'Your order contains print items. Please provide a shipping address.', + 'streetName' => 'Street Name', + 'streetNumber' => 'Number', + 'additionalInfo' => 'Additional Info', + 'city' => 'City', + 'postCode' => 'Post Code', + 'country' => 'Country', + ], 'cancelledFailed' => [ 'paymentCancelled' => 'Payment cancelled', 'paymentCancelledMessage' => 'Payment has been cancelled.', diff --git a/lang/de/left-menu.php b/lang/de/left-menu.php index 975af4a7288..abdc9556710 100644 --- a/lang/de/left-menu.php +++ b/lang/de/left-menu.php @@ -24,4 +24,5 @@ 'contact' => 'Kontakt', 'messages' => 'Nachrichten', 'webhooks' => 'Webhooks', + 'shopSizes' => 'Size Catalogue', ]; diff --git a/lang/de/webshop.php b/lang/de/webshop.php index 4e17aa7f2f3..56d3f07b33d 100644 --- a/lang/de/webshop.php +++ b/lang/de/webshop.php @@ -226,6 +226,51 @@ 'addedToOrder' => 'Zur Bestellung hinzugefügt', 'photoAddedToOrder' => '„%s“ wurde für %s zu Ihrer Bestellung hinzugefügt', ], + 'buyMeDialog' => [ + 'digital' => 'Digital', + 'print' => 'Print', + 'pixel' => 'Pixel', + ], + 'sizeCatalogue' => [ + 'title' => 'Size Catalogue', + 'printSizes' => 'Print Sizes', + 'pixelSizes' => 'Pixel Sizes', + 'addPrintSize' => 'Add Print Size', + 'addPixelSize' => 'Add Pixel Size', + 'editPrintSize' => 'Edit Print Size', + 'editPixelSize' => 'Edit Pixel Size', + 'label' => 'Label', + 'dimensions' => 'Dimensions', + 'width' => 'Width', + 'height' => 'Height', + 'unit' => 'Unit', + 'paperType' => 'Paper Type', + 'active' => 'Active', + 'actions' => 'Actions', + 'confirmDeleteHeader' => 'Confirm Delete', + 'confirmDeleteMessage' => 'Are you sure you want to delete this size? Existing orders will not be affected.', + 'error' => 'Error', + ], + 'printSizePricesInput' => [ + 'title' => 'Print Size Prices', + 'selectSize' => 'Select Print Size', + 'addSize' => 'Add Print Size Price', + ], + 'pixelSizePricesInput' => [ + 'title' => 'Pixel Size Prices', + 'selectSize' => 'Select Pixel Size', + 'addSize' => 'Add Pixel Size Price', + ], + 'shippingAddress' => [ + 'title' => 'Shipping Address', + 'required' => 'Your order contains print items. Please provide a shipping address.', + 'streetName' => 'Street Name', + 'streetNumber' => 'Number', + 'additionalInfo' => 'Additional Info', + 'city' => 'City', + 'postCode' => 'Post Code', + 'country' => 'Country', + ], 'cancelledFailed' => [ 'paymentCancelled' => 'Zahlung abgebrochen', 'paymentCancelledMessage' => 'Die Zahlung wurde abgebrochen.', diff --git a/lang/el/left-menu.php b/lang/el/left-menu.php index f35b798d7a5..41d23a9ea04 100644 --- a/lang/el/left-menu.php +++ b/lang/el/left-menu.php @@ -25,4 +25,5 @@ 'contact' => 'Contact', 'messages' => 'Messages', 'webhooks' => 'Webhooks', + 'shopSizes' => 'Size Catalogue', ]; diff --git a/lang/el/webshop.php b/lang/el/webshop.php index 632baabbfae..d690098963a 100644 --- a/lang/el/webshop.php +++ b/lang/el/webshop.php @@ -226,6 +226,51 @@ 'addedToOrder' => 'Added to order', 'photoAddedToOrder' => '%s added to your order for %s', ], + 'buyMeDialog' => [ + 'digital' => 'Digital', + 'print' => 'Print', + 'pixel' => 'Pixel', + ], + 'sizeCatalogue' => [ + 'title' => 'Size Catalogue', + 'printSizes' => 'Print Sizes', + 'pixelSizes' => 'Pixel Sizes', + 'addPrintSize' => 'Add Print Size', + 'addPixelSize' => 'Add Pixel Size', + 'editPrintSize' => 'Edit Print Size', + 'editPixelSize' => 'Edit Pixel Size', + 'label' => 'Label', + 'dimensions' => 'Dimensions', + 'width' => 'Width', + 'height' => 'Height', + 'unit' => 'Unit', + 'paperType' => 'Paper Type', + 'active' => 'Active', + 'actions' => 'Actions', + 'confirmDeleteHeader' => 'Confirm Delete', + 'confirmDeleteMessage' => 'Are you sure you want to delete this size? Existing orders will not be affected.', + 'error' => 'Error', + ], + 'printSizePricesInput' => [ + 'title' => 'Print Size Prices', + 'selectSize' => 'Select Print Size', + 'addSize' => 'Add Print Size Price', + ], + 'pixelSizePricesInput' => [ + 'title' => 'Pixel Size Prices', + 'selectSize' => 'Select Pixel Size', + 'addSize' => 'Add Pixel Size Price', + ], + 'shippingAddress' => [ + 'title' => 'Shipping Address', + 'required' => 'Your order contains print items. Please provide a shipping address.', + 'streetName' => 'Street Name', + 'streetNumber' => 'Number', + 'additionalInfo' => 'Additional Info', + 'city' => 'City', + 'postCode' => 'Post Code', + 'country' => 'Country', + ], 'cancelledFailed' => [ 'paymentCancelled' => 'Payment cancelled', 'paymentCancelledMessage' => 'Payment has been cancelled.', diff --git a/lang/en/left-menu.php b/lang/en/left-menu.php index dc78742aa75..eb2432ada56 100644 --- a/lang/en/left-menu.php +++ b/lang/en/left-menu.php @@ -25,4 +25,5 @@ 'contact' => 'Contact', 'messages' => 'Messages', 'webhooks' => 'Webhooks', + 'shopSizes' => 'Size Catalogue', ]; diff --git a/lang/en/webshop.php b/lang/en/webshop.php index 632baabbfae..d690098963a 100644 --- a/lang/en/webshop.php +++ b/lang/en/webshop.php @@ -226,6 +226,51 @@ 'addedToOrder' => 'Added to order', 'photoAddedToOrder' => '%s added to your order for %s', ], + 'buyMeDialog' => [ + 'digital' => 'Digital', + 'print' => 'Print', + 'pixel' => 'Pixel', + ], + 'sizeCatalogue' => [ + 'title' => 'Size Catalogue', + 'printSizes' => 'Print Sizes', + 'pixelSizes' => 'Pixel Sizes', + 'addPrintSize' => 'Add Print Size', + 'addPixelSize' => 'Add Pixel Size', + 'editPrintSize' => 'Edit Print Size', + 'editPixelSize' => 'Edit Pixel Size', + 'label' => 'Label', + 'dimensions' => 'Dimensions', + 'width' => 'Width', + 'height' => 'Height', + 'unit' => 'Unit', + 'paperType' => 'Paper Type', + 'active' => 'Active', + 'actions' => 'Actions', + 'confirmDeleteHeader' => 'Confirm Delete', + 'confirmDeleteMessage' => 'Are you sure you want to delete this size? Existing orders will not be affected.', + 'error' => 'Error', + ], + 'printSizePricesInput' => [ + 'title' => 'Print Size Prices', + 'selectSize' => 'Select Print Size', + 'addSize' => 'Add Print Size Price', + ], + 'pixelSizePricesInput' => [ + 'title' => 'Pixel Size Prices', + 'selectSize' => 'Select Pixel Size', + 'addSize' => 'Add Pixel Size Price', + ], + 'shippingAddress' => [ + 'title' => 'Shipping Address', + 'required' => 'Your order contains print items. Please provide a shipping address.', + 'streetName' => 'Street Name', + 'streetNumber' => 'Number', + 'additionalInfo' => 'Additional Info', + 'city' => 'City', + 'postCode' => 'Post Code', + 'country' => 'Country', + ], 'cancelledFailed' => [ 'paymentCancelled' => 'Payment cancelled', 'paymentCancelledMessage' => 'Payment has been cancelled.', diff --git a/lang/es/left-menu.php b/lang/es/left-menu.php index f7939b2e960..8d8c204b1f7 100644 --- a/lang/es/left-menu.php +++ b/lang/es/left-menu.php @@ -24,4 +24,5 @@ 'contact' => 'Contacto', 'messages' => 'Mensajes', 'webhooks' => 'Webhooks', + 'shopSizes' => 'Size Catalogue', ]; diff --git a/lang/es/webshop.php b/lang/es/webshop.php index 632baabbfae..d690098963a 100644 --- a/lang/es/webshop.php +++ b/lang/es/webshop.php @@ -226,6 +226,51 @@ 'addedToOrder' => 'Added to order', 'photoAddedToOrder' => '%s added to your order for %s', ], + 'buyMeDialog' => [ + 'digital' => 'Digital', + 'print' => 'Print', + 'pixel' => 'Pixel', + ], + 'sizeCatalogue' => [ + 'title' => 'Size Catalogue', + 'printSizes' => 'Print Sizes', + 'pixelSizes' => 'Pixel Sizes', + 'addPrintSize' => 'Add Print Size', + 'addPixelSize' => 'Add Pixel Size', + 'editPrintSize' => 'Edit Print Size', + 'editPixelSize' => 'Edit Pixel Size', + 'label' => 'Label', + 'dimensions' => 'Dimensions', + 'width' => 'Width', + 'height' => 'Height', + 'unit' => 'Unit', + 'paperType' => 'Paper Type', + 'active' => 'Active', + 'actions' => 'Actions', + 'confirmDeleteHeader' => 'Confirm Delete', + 'confirmDeleteMessage' => 'Are you sure you want to delete this size? Existing orders will not be affected.', + 'error' => 'Error', + ], + 'printSizePricesInput' => [ + 'title' => 'Print Size Prices', + 'selectSize' => 'Select Print Size', + 'addSize' => 'Add Print Size Price', + ], + 'pixelSizePricesInput' => [ + 'title' => 'Pixel Size Prices', + 'selectSize' => 'Select Pixel Size', + 'addSize' => 'Add Pixel Size Price', + ], + 'shippingAddress' => [ + 'title' => 'Shipping Address', + 'required' => 'Your order contains print items. Please provide a shipping address.', + 'streetName' => 'Street Name', + 'streetNumber' => 'Number', + 'additionalInfo' => 'Additional Info', + 'city' => 'City', + 'postCode' => 'Post Code', + 'country' => 'Country', + ], 'cancelledFailed' => [ 'paymentCancelled' => 'Payment cancelled', 'paymentCancelledMessage' => 'Payment has been cancelled.', diff --git a/lang/fa/left-menu.php b/lang/fa/left-menu.php index b7e8c24e417..592ede419ea 100644 --- a/lang/fa/left-menu.php +++ b/lang/fa/left-menu.php @@ -25,4 +25,5 @@ 'contact' => 'Contact', 'messages' => 'Messages', 'webhooks' => 'Webhooks', + 'shopSizes' => 'Size Catalogue', ]; diff --git a/lang/fa/webshop.php b/lang/fa/webshop.php index 632baabbfae..d690098963a 100644 --- a/lang/fa/webshop.php +++ b/lang/fa/webshop.php @@ -226,6 +226,51 @@ 'addedToOrder' => 'Added to order', 'photoAddedToOrder' => '%s added to your order for %s', ], + 'buyMeDialog' => [ + 'digital' => 'Digital', + 'print' => 'Print', + 'pixel' => 'Pixel', + ], + 'sizeCatalogue' => [ + 'title' => 'Size Catalogue', + 'printSizes' => 'Print Sizes', + 'pixelSizes' => 'Pixel Sizes', + 'addPrintSize' => 'Add Print Size', + 'addPixelSize' => 'Add Pixel Size', + 'editPrintSize' => 'Edit Print Size', + 'editPixelSize' => 'Edit Pixel Size', + 'label' => 'Label', + 'dimensions' => 'Dimensions', + 'width' => 'Width', + 'height' => 'Height', + 'unit' => 'Unit', + 'paperType' => 'Paper Type', + 'active' => 'Active', + 'actions' => 'Actions', + 'confirmDeleteHeader' => 'Confirm Delete', + 'confirmDeleteMessage' => 'Are you sure you want to delete this size? Existing orders will not be affected.', + 'error' => 'Error', + ], + 'printSizePricesInput' => [ + 'title' => 'Print Size Prices', + 'selectSize' => 'Select Print Size', + 'addSize' => 'Add Print Size Price', + ], + 'pixelSizePricesInput' => [ + 'title' => 'Pixel Size Prices', + 'selectSize' => 'Select Pixel Size', + 'addSize' => 'Add Pixel Size Price', + ], + 'shippingAddress' => [ + 'title' => 'Shipping Address', + 'required' => 'Your order contains print items. Please provide a shipping address.', + 'streetName' => 'Street Name', + 'streetNumber' => 'Number', + 'additionalInfo' => 'Additional Info', + 'city' => 'City', + 'postCode' => 'Post Code', + 'country' => 'Country', + ], 'cancelledFailed' => [ 'paymentCancelled' => 'Payment cancelled', 'paymentCancelledMessage' => 'Payment has been cancelled.', diff --git a/lang/fr/left-menu.php b/lang/fr/left-menu.php index a5e065d1eb5..2f6da1dc738 100644 --- a/lang/fr/left-menu.php +++ b/lang/fr/left-menu.php @@ -25,4 +25,5 @@ 'contact' => 'Contact', 'messages' => 'Messages', 'webhooks' => 'Webhooks', + 'shopSizes' => 'Size Catalogue', ]; diff --git a/lang/fr/webshop.php b/lang/fr/webshop.php index 632baabbfae..d690098963a 100644 --- a/lang/fr/webshop.php +++ b/lang/fr/webshop.php @@ -226,6 +226,51 @@ 'addedToOrder' => 'Added to order', 'photoAddedToOrder' => '%s added to your order for %s', ], + 'buyMeDialog' => [ + 'digital' => 'Digital', + 'print' => 'Print', + 'pixel' => 'Pixel', + ], + 'sizeCatalogue' => [ + 'title' => 'Size Catalogue', + 'printSizes' => 'Print Sizes', + 'pixelSizes' => 'Pixel Sizes', + 'addPrintSize' => 'Add Print Size', + 'addPixelSize' => 'Add Pixel Size', + 'editPrintSize' => 'Edit Print Size', + 'editPixelSize' => 'Edit Pixel Size', + 'label' => 'Label', + 'dimensions' => 'Dimensions', + 'width' => 'Width', + 'height' => 'Height', + 'unit' => 'Unit', + 'paperType' => 'Paper Type', + 'active' => 'Active', + 'actions' => 'Actions', + 'confirmDeleteHeader' => 'Confirm Delete', + 'confirmDeleteMessage' => 'Are you sure you want to delete this size? Existing orders will not be affected.', + 'error' => 'Error', + ], + 'printSizePricesInput' => [ + 'title' => 'Print Size Prices', + 'selectSize' => 'Select Print Size', + 'addSize' => 'Add Print Size Price', + ], + 'pixelSizePricesInput' => [ + 'title' => 'Pixel Size Prices', + 'selectSize' => 'Select Pixel Size', + 'addSize' => 'Add Pixel Size Price', + ], + 'shippingAddress' => [ + 'title' => 'Shipping Address', + 'required' => 'Your order contains print items. Please provide a shipping address.', + 'streetName' => 'Street Name', + 'streetNumber' => 'Number', + 'additionalInfo' => 'Additional Info', + 'city' => 'City', + 'postCode' => 'Post Code', + 'country' => 'Country', + ], 'cancelledFailed' => [ 'paymentCancelled' => 'Payment cancelled', 'paymentCancelledMessage' => 'Payment has been cancelled.', diff --git a/lang/hu/left-menu.php b/lang/hu/left-menu.php index 71c271cf399..a16a5b161ec 100644 --- a/lang/hu/left-menu.php +++ b/lang/hu/left-menu.php @@ -25,4 +25,5 @@ 'contact' => 'Contact', 'messages' => 'Messages', 'webhooks' => 'Webhooks', + 'shopSizes' => 'Size Catalogue', ]; diff --git a/lang/hu/webshop.php b/lang/hu/webshop.php index 632baabbfae..d690098963a 100644 --- a/lang/hu/webshop.php +++ b/lang/hu/webshop.php @@ -226,6 +226,51 @@ 'addedToOrder' => 'Added to order', 'photoAddedToOrder' => '%s added to your order for %s', ], + 'buyMeDialog' => [ + 'digital' => 'Digital', + 'print' => 'Print', + 'pixel' => 'Pixel', + ], + 'sizeCatalogue' => [ + 'title' => 'Size Catalogue', + 'printSizes' => 'Print Sizes', + 'pixelSizes' => 'Pixel Sizes', + 'addPrintSize' => 'Add Print Size', + 'addPixelSize' => 'Add Pixel Size', + 'editPrintSize' => 'Edit Print Size', + 'editPixelSize' => 'Edit Pixel Size', + 'label' => 'Label', + 'dimensions' => 'Dimensions', + 'width' => 'Width', + 'height' => 'Height', + 'unit' => 'Unit', + 'paperType' => 'Paper Type', + 'active' => 'Active', + 'actions' => 'Actions', + 'confirmDeleteHeader' => 'Confirm Delete', + 'confirmDeleteMessage' => 'Are you sure you want to delete this size? Existing orders will not be affected.', + 'error' => 'Error', + ], + 'printSizePricesInput' => [ + 'title' => 'Print Size Prices', + 'selectSize' => 'Select Print Size', + 'addSize' => 'Add Print Size Price', + ], + 'pixelSizePricesInput' => [ + 'title' => 'Pixel Size Prices', + 'selectSize' => 'Select Pixel Size', + 'addSize' => 'Add Pixel Size Price', + ], + 'shippingAddress' => [ + 'title' => 'Shipping Address', + 'required' => 'Your order contains print items. Please provide a shipping address.', + 'streetName' => 'Street Name', + 'streetNumber' => 'Number', + 'additionalInfo' => 'Additional Info', + 'city' => 'City', + 'postCode' => 'Post Code', + 'country' => 'Country', + ], 'cancelledFailed' => [ 'paymentCancelled' => 'Payment cancelled', 'paymentCancelledMessage' => 'Payment has been cancelled.', diff --git a/lang/it/left-menu.php b/lang/it/left-menu.php index f5c7399fdee..95aba06e3ee 100644 --- a/lang/it/left-menu.php +++ b/lang/it/left-menu.php @@ -25,4 +25,5 @@ 'contact' => 'Contact', 'messages' => 'Messages', 'webhooks' => 'Webhooks', + 'shopSizes' => 'Size Catalogue', ]; diff --git a/lang/it/webshop.php b/lang/it/webshop.php index 632baabbfae..d690098963a 100644 --- a/lang/it/webshop.php +++ b/lang/it/webshop.php @@ -226,6 +226,51 @@ 'addedToOrder' => 'Added to order', 'photoAddedToOrder' => '%s added to your order for %s', ], + 'buyMeDialog' => [ + 'digital' => 'Digital', + 'print' => 'Print', + 'pixel' => 'Pixel', + ], + 'sizeCatalogue' => [ + 'title' => 'Size Catalogue', + 'printSizes' => 'Print Sizes', + 'pixelSizes' => 'Pixel Sizes', + 'addPrintSize' => 'Add Print Size', + 'addPixelSize' => 'Add Pixel Size', + 'editPrintSize' => 'Edit Print Size', + 'editPixelSize' => 'Edit Pixel Size', + 'label' => 'Label', + 'dimensions' => 'Dimensions', + 'width' => 'Width', + 'height' => 'Height', + 'unit' => 'Unit', + 'paperType' => 'Paper Type', + 'active' => 'Active', + 'actions' => 'Actions', + 'confirmDeleteHeader' => 'Confirm Delete', + 'confirmDeleteMessage' => 'Are you sure you want to delete this size? Existing orders will not be affected.', + 'error' => 'Error', + ], + 'printSizePricesInput' => [ + 'title' => 'Print Size Prices', + 'selectSize' => 'Select Print Size', + 'addSize' => 'Add Print Size Price', + ], + 'pixelSizePricesInput' => [ + 'title' => 'Pixel Size Prices', + 'selectSize' => 'Select Pixel Size', + 'addSize' => 'Add Pixel Size Price', + ], + 'shippingAddress' => [ + 'title' => 'Shipping Address', + 'required' => 'Your order contains print items. Please provide a shipping address.', + 'streetName' => 'Street Name', + 'streetNumber' => 'Number', + 'additionalInfo' => 'Additional Info', + 'city' => 'City', + 'postCode' => 'Post Code', + 'country' => 'Country', + ], 'cancelledFailed' => [ 'paymentCancelled' => 'Payment cancelled', 'paymentCancelledMessage' => 'Payment has been cancelled.', diff --git a/lang/ja/left-menu.php b/lang/ja/left-menu.php index 949fa8f98ee..92c29e604f5 100644 --- a/lang/ja/left-menu.php +++ b/lang/ja/left-menu.php @@ -25,4 +25,5 @@ 'contact' => 'Contact', 'messages' => 'Messages', 'webhooks' => 'Webhooks', + 'shopSizes' => 'Size Catalogue', ]; diff --git a/lang/ja/webshop.php b/lang/ja/webshop.php index 632baabbfae..d690098963a 100644 --- a/lang/ja/webshop.php +++ b/lang/ja/webshop.php @@ -226,6 +226,51 @@ 'addedToOrder' => 'Added to order', 'photoAddedToOrder' => '%s added to your order for %s', ], + 'buyMeDialog' => [ + 'digital' => 'Digital', + 'print' => 'Print', + 'pixel' => 'Pixel', + ], + 'sizeCatalogue' => [ + 'title' => 'Size Catalogue', + 'printSizes' => 'Print Sizes', + 'pixelSizes' => 'Pixel Sizes', + 'addPrintSize' => 'Add Print Size', + 'addPixelSize' => 'Add Pixel Size', + 'editPrintSize' => 'Edit Print Size', + 'editPixelSize' => 'Edit Pixel Size', + 'label' => 'Label', + 'dimensions' => 'Dimensions', + 'width' => 'Width', + 'height' => 'Height', + 'unit' => 'Unit', + 'paperType' => 'Paper Type', + 'active' => 'Active', + 'actions' => 'Actions', + 'confirmDeleteHeader' => 'Confirm Delete', + 'confirmDeleteMessage' => 'Are you sure you want to delete this size? Existing orders will not be affected.', + 'error' => 'Error', + ], + 'printSizePricesInput' => [ + 'title' => 'Print Size Prices', + 'selectSize' => 'Select Print Size', + 'addSize' => 'Add Print Size Price', + ], + 'pixelSizePricesInput' => [ + 'title' => 'Pixel Size Prices', + 'selectSize' => 'Select Pixel Size', + 'addSize' => 'Add Pixel Size Price', + ], + 'shippingAddress' => [ + 'title' => 'Shipping Address', + 'required' => 'Your order contains print items. Please provide a shipping address.', + 'streetName' => 'Street Name', + 'streetNumber' => 'Number', + 'additionalInfo' => 'Additional Info', + 'city' => 'City', + 'postCode' => 'Post Code', + 'country' => 'Country', + ], 'cancelledFailed' => [ 'paymentCancelled' => 'Payment cancelled', 'paymentCancelledMessage' => 'Payment has been cancelled.', diff --git a/lang/nl/left-menu.php b/lang/nl/left-menu.php index 95286fc9d79..279522b48ba 100644 --- a/lang/nl/left-menu.php +++ b/lang/nl/left-menu.php @@ -25,4 +25,5 @@ 'contact' => 'Contact', 'messages' => 'Messages', 'webhooks' => 'Webhooks', + 'shopSizes' => 'Size Catalogue', ]; diff --git a/lang/nl/webshop.php b/lang/nl/webshop.php index 632baabbfae..d690098963a 100644 --- a/lang/nl/webshop.php +++ b/lang/nl/webshop.php @@ -226,6 +226,51 @@ 'addedToOrder' => 'Added to order', 'photoAddedToOrder' => '%s added to your order for %s', ], + 'buyMeDialog' => [ + 'digital' => 'Digital', + 'print' => 'Print', + 'pixel' => 'Pixel', + ], + 'sizeCatalogue' => [ + 'title' => 'Size Catalogue', + 'printSizes' => 'Print Sizes', + 'pixelSizes' => 'Pixel Sizes', + 'addPrintSize' => 'Add Print Size', + 'addPixelSize' => 'Add Pixel Size', + 'editPrintSize' => 'Edit Print Size', + 'editPixelSize' => 'Edit Pixel Size', + 'label' => 'Label', + 'dimensions' => 'Dimensions', + 'width' => 'Width', + 'height' => 'Height', + 'unit' => 'Unit', + 'paperType' => 'Paper Type', + 'active' => 'Active', + 'actions' => 'Actions', + 'confirmDeleteHeader' => 'Confirm Delete', + 'confirmDeleteMessage' => 'Are you sure you want to delete this size? Existing orders will not be affected.', + 'error' => 'Error', + ], + 'printSizePricesInput' => [ + 'title' => 'Print Size Prices', + 'selectSize' => 'Select Print Size', + 'addSize' => 'Add Print Size Price', + ], + 'pixelSizePricesInput' => [ + 'title' => 'Pixel Size Prices', + 'selectSize' => 'Select Pixel Size', + 'addSize' => 'Add Pixel Size Price', + ], + 'shippingAddress' => [ + 'title' => 'Shipping Address', + 'required' => 'Your order contains print items. Please provide a shipping address.', + 'streetName' => 'Street Name', + 'streetNumber' => 'Number', + 'additionalInfo' => 'Additional Info', + 'city' => 'City', + 'postCode' => 'Post Code', + 'country' => 'Country', + ], 'cancelledFailed' => [ 'paymentCancelled' => 'Payment cancelled', 'paymentCancelledMessage' => 'Payment has been cancelled.', diff --git a/lang/no/left-menu.php b/lang/no/left-menu.php index cdbef6ad894..86a9f297781 100644 --- a/lang/no/left-menu.php +++ b/lang/no/left-menu.php @@ -25,4 +25,5 @@ 'contact' => 'Contact', 'messages' => 'Messages', 'webhooks' => 'Webhooks', + 'shopSizes' => 'Size Catalogue', ]; diff --git a/lang/no/webshop.php b/lang/no/webshop.php index 632baabbfae..d690098963a 100644 --- a/lang/no/webshop.php +++ b/lang/no/webshop.php @@ -226,6 +226,51 @@ 'addedToOrder' => 'Added to order', 'photoAddedToOrder' => '%s added to your order for %s', ], + 'buyMeDialog' => [ + 'digital' => 'Digital', + 'print' => 'Print', + 'pixel' => 'Pixel', + ], + 'sizeCatalogue' => [ + 'title' => 'Size Catalogue', + 'printSizes' => 'Print Sizes', + 'pixelSizes' => 'Pixel Sizes', + 'addPrintSize' => 'Add Print Size', + 'addPixelSize' => 'Add Pixel Size', + 'editPrintSize' => 'Edit Print Size', + 'editPixelSize' => 'Edit Pixel Size', + 'label' => 'Label', + 'dimensions' => 'Dimensions', + 'width' => 'Width', + 'height' => 'Height', + 'unit' => 'Unit', + 'paperType' => 'Paper Type', + 'active' => 'Active', + 'actions' => 'Actions', + 'confirmDeleteHeader' => 'Confirm Delete', + 'confirmDeleteMessage' => 'Are you sure you want to delete this size? Existing orders will not be affected.', + 'error' => 'Error', + ], + 'printSizePricesInput' => [ + 'title' => 'Print Size Prices', + 'selectSize' => 'Select Print Size', + 'addSize' => 'Add Print Size Price', + ], + 'pixelSizePricesInput' => [ + 'title' => 'Pixel Size Prices', + 'selectSize' => 'Select Pixel Size', + 'addSize' => 'Add Pixel Size Price', + ], + 'shippingAddress' => [ + 'title' => 'Shipping Address', + 'required' => 'Your order contains print items. Please provide a shipping address.', + 'streetName' => 'Street Name', + 'streetNumber' => 'Number', + 'additionalInfo' => 'Additional Info', + 'city' => 'City', + 'postCode' => 'Post Code', + 'country' => 'Country', + ], 'cancelledFailed' => [ 'paymentCancelled' => 'Payment cancelled', 'paymentCancelledMessage' => 'Payment has been cancelled.', diff --git a/lang/pl/left-menu.php b/lang/pl/left-menu.php index b7b4c5a00b3..fb50de61177 100644 --- a/lang/pl/left-menu.php +++ b/lang/pl/left-menu.php @@ -25,4 +25,5 @@ 'contact' => 'Contact', 'messages' => 'Messages', 'webhooks' => 'Webhooks', + 'shopSizes' => 'Size Catalogue', ]; diff --git a/lang/pl/webshop.php b/lang/pl/webshop.php index 632baabbfae..d690098963a 100644 --- a/lang/pl/webshop.php +++ b/lang/pl/webshop.php @@ -226,6 +226,51 @@ 'addedToOrder' => 'Added to order', 'photoAddedToOrder' => '%s added to your order for %s', ], + 'buyMeDialog' => [ + 'digital' => 'Digital', + 'print' => 'Print', + 'pixel' => 'Pixel', + ], + 'sizeCatalogue' => [ + 'title' => 'Size Catalogue', + 'printSizes' => 'Print Sizes', + 'pixelSizes' => 'Pixel Sizes', + 'addPrintSize' => 'Add Print Size', + 'addPixelSize' => 'Add Pixel Size', + 'editPrintSize' => 'Edit Print Size', + 'editPixelSize' => 'Edit Pixel Size', + 'label' => 'Label', + 'dimensions' => 'Dimensions', + 'width' => 'Width', + 'height' => 'Height', + 'unit' => 'Unit', + 'paperType' => 'Paper Type', + 'active' => 'Active', + 'actions' => 'Actions', + 'confirmDeleteHeader' => 'Confirm Delete', + 'confirmDeleteMessage' => 'Are you sure you want to delete this size? Existing orders will not be affected.', + 'error' => 'Error', + ], + 'printSizePricesInput' => [ + 'title' => 'Print Size Prices', + 'selectSize' => 'Select Print Size', + 'addSize' => 'Add Print Size Price', + ], + 'pixelSizePricesInput' => [ + 'title' => 'Pixel Size Prices', + 'selectSize' => 'Select Pixel Size', + 'addSize' => 'Add Pixel Size Price', + ], + 'shippingAddress' => [ + 'title' => 'Shipping Address', + 'required' => 'Your order contains print items. Please provide a shipping address.', + 'streetName' => 'Street Name', + 'streetNumber' => 'Number', + 'additionalInfo' => 'Additional Info', + 'city' => 'City', + 'postCode' => 'Post Code', + 'country' => 'Country', + ], 'cancelledFailed' => [ 'paymentCancelled' => 'Payment cancelled', 'paymentCancelledMessage' => 'Payment has been cancelled.', diff --git a/lang/pt/left-menu.php b/lang/pt/left-menu.php index 9647d3bc4f7..c2c3381b915 100644 --- a/lang/pt/left-menu.php +++ b/lang/pt/left-menu.php @@ -25,4 +25,5 @@ 'contact' => 'Contact', 'messages' => 'Messages', 'webhooks' => 'Webhooks', + 'shopSizes' => 'Size Catalogue', ]; diff --git a/lang/pt/webshop.php b/lang/pt/webshop.php index 632baabbfae..d690098963a 100644 --- a/lang/pt/webshop.php +++ b/lang/pt/webshop.php @@ -226,6 +226,51 @@ 'addedToOrder' => 'Added to order', 'photoAddedToOrder' => '%s added to your order for %s', ], + 'buyMeDialog' => [ + 'digital' => 'Digital', + 'print' => 'Print', + 'pixel' => 'Pixel', + ], + 'sizeCatalogue' => [ + 'title' => 'Size Catalogue', + 'printSizes' => 'Print Sizes', + 'pixelSizes' => 'Pixel Sizes', + 'addPrintSize' => 'Add Print Size', + 'addPixelSize' => 'Add Pixel Size', + 'editPrintSize' => 'Edit Print Size', + 'editPixelSize' => 'Edit Pixel Size', + 'label' => 'Label', + 'dimensions' => 'Dimensions', + 'width' => 'Width', + 'height' => 'Height', + 'unit' => 'Unit', + 'paperType' => 'Paper Type', + 'active' => 'Active', + 'actions' => 'Actions', + 'confirmDeleteHeader' => 'Confirm Delete', + 'confirmDeleteMessage' => 'Are you sure you want to delete this size? Existing orders will not be affected.', + 'error' => 'Error', + ], + 'printSizePricesInput' => [ + 'title' => 'Print Size Prices', + 'selectSize' => 'Select Print Size', + 'addSize' => 'Add Print Size Price', + ], + 'pixelSizePricesInput' => [ + 'title' => 'Pixel Size Prices', + 'selectSize' => 'Select Pixel Size', + 'addSize' => 'Add Pixel Size Price', + ], + 'shippingAddress' => [ + 'title' => 'Shipping Address', + 'required' => 'Your order contains print items. Please provide a shipping address.', + 'streetName' => 'Street Name', + 'streetNumber' => 'Number', + 'additionalInfo' => 'Additional Info', + 'city' => 'City', + 'postCode' => 'Post Code', + 'country' => 'Country', + ], 'cancelledFailed' => [ 'paymentCancelled' => 'Payment cancelled', 'paymentCancelledMessage' => 'Payment has been cancelled.', diff --git a/lang/ru/left-menu.php b/lang/ru/left-menu.php index a84eca23517..0040013c7b2 100644 --- a/lang/ru/left-menu.php +++ b/lang/ru/left-menu.php @@ -25,4 +25,5 @@ 'contact' => 'Contact', 'messages' => 'Messages', 'webhooks' => 'Webhooks', + 'shopSizes' => 'Size Catalogue', ]; diff --git a/lang/ru/webshop.php b/lang/ru/webshop.php index 632baabbfae..d690098963a 100644 --- a/lang/ru/webshop.php +++ b/lang/ru/webshop.php @@ -226,6 +226,51 @@ 'addedToOrder' => 'Added to order', 'photoAddedToOrder' => '%s added to your order for %s', ], + 'buyMeDialog' => [ + 'digital' => 'Digital', + 'print' => 'Print', + 'pixel' => 'Pixel', + ], + 'sizeCatalogue' => [ + 'title' => 'Size Catalogue', + 'printSizes' => 'Print Sizes', + 'pixelSizes' => 'Pixel Sizes', + 'addPrintSize' => 'Add Print Size', + 'addPixelSize' => 'Add Pixel Size', + 'editPrintSize' => 'Edit Print Size', + 'editPixelSize' => 'Edit Pixel Size', + 'label' => 'Label', + 'dimensions' => 'Dimensions', + 'width' => 'Width', + 'height' => 'Height', + 'unit' => 'Unit', + 'paperType' => 'Paper Type', + 'active' => 'Active', + 'actions' => 'Actions', + 'confirmDeleteHeader' => 'Confirm Delete', + 'confirmDeleteMessage' => 'Are you sure you want to delete this size? Existing orders will not be affected.', + 'error' => 'Error', + ], + 'printSizePricesInput' => [ + 'title' => 'Print Size Prices', + 'selectSize' => 'Select Print Size', + 'addSize' => 'Add Print Size Price', + ], + 'pixelSizePricesInput' => [ + 'title' => 'Pixel Size Prices', + 'selectSize' => 'Select Pixel Size', + 'addSize' => 'Add Pixel Size Price', + ], + 'shippingAddress' => [ + 'title' => 'Shipping Address', + 'required' => 'Your order contains print items. Please provide a shipping address.', + 'streetName' => 'Street Name', + 'streetNumber' => 'Number', + 'additionalInfo' => 'Additional Info', + 'city' => 'City', + 'postCode' => 'Post Code', + 'country' => 'Country', + ], 'cancelledFailed' => [ 'paymentCancelled' => 'Payment cancelled', 'paymentCancelledMessage' => 'Payment has been cancelled.', diff --git a/lang/sk/left-menu.php b/lang/sk/left-menu.php index c251960a0d4..f533621260a 100644 --- a/lang/sk/left-menu.php +++ b/lang/sk/left-menu.php @@ -25,4 +25,5 @@ 'contact' => 'Contact', 'messages' => 'Messages', 'webhooks' => 'Webhooks', + 'shopSizes' => 'Size Catalogue', ]; diff --git a/lang/sk/webshop.php b/lang/sk/webshop.php index 632baabbfae..d690098963a 100644 --- a/lang/sk/webshop.php +++ b/lang/sk/webshop.php @@ -226,6 +226,51 @@ 'addedToOrder' => 'Added to order', 'photoAddedToOrder' => '%s added to your order for %s', ], + 'buyMeDialog' => [ + 'digital' => 'Digital', + 'print' => 'Print', + 'pixel' => 'Pixel', + ], + 'sizeCatalogue' => [ + 'title' => 'Size Catalogue', + 'printSizes' => 'Print Sizes', + 'pixelSizes' => 'Pixel Sizes', + 'addPrintSize' => 'Add Print Size', + 'addPixelSize' => 'Add Pixel Size', + 'editPrintSize' => 'Edit Print Size', + 'editPixelSize' => 'Edit Pixel Size', + 'label' => 'Label', + 'dimensions' => 'Dimensions', + 'width' => 'Width', + 'height' => 'Height', + 'unit' => 'Unit', + 'paperType' => 'Paper Type', + 'active' => 'Active', + 'actions' => 'Actions', + 'confirmDeleteHeader' => 'Confirm Delete', + 'confirmDeleteMessage' => 'Are you sure you want to delete this size? Existing orders will not be affected.', + 'error' => 'Error', + ], + 'printSizePricesInput' => [ + 'title' => 'Print Size Prices', + 'selectSize' => 'Select Print Size', + 'addSize' => 'Add Print Size Price', + ], + 'pixelSizePricesInput' => [ + 'title' => 'Pixel Size Prices', + 'selectSize' => 'Select Pixel Size', + 'addSize' => 'Add Pixel Size Price', + ], + 'shippingAddress' => [ + 'title' => 'Shipping Address', + 'required' => 'Your order contains print items. Please provide a shipping address.', + 'streetName' => 'Street Name', + 'streetNumber' => 'Number', + 'additionalInfo' => 'Additional Info', + 'city' => 'City', + 'postCode' => 'Post Code', + 'country' => 'Country', + ], 'cancelledFailed' => [ 'paymentCancelled' => 'Payment cancelled', 'paymentCancelledMessage' => 'Payment has been cancelled.', diff --git a/lang/sv/left-menu.php b/lang/sv/left-menu.php index 3e54b1d5c16..df11a0ddccd 100644 --- a/lang/sv/left-menu.php +++ b/lang/sv/left-menu.php @@ -25,4 +25,5 @@ 'contact' => 'Contact', 'messages' => 'Messages', 'webhooks' => 'Webhooks', + 'shopSizes' => 'Size Catalogue', ]; diff --git a/lang/sv/webshop.php b/lang/sv/webshop.php index 632baabbfae..d690098963a 100644 --- a/lang/sv/webshop.php +++ b/lang/sv/webshop.php @@ -226,6 +226,51 @@ 'addedToOrder' => 'Added to order', 'photoAddedToOrder' => '%s added to your order for %s', ], + 'buyMeDialog' => [ + 'digital' => 'Digital', + 'print' => 'Print', + 'pixel' => 'Pixel', + ], + 'sizeCatalogue' => [ + 'title' => 'Size Catalogue', + 'printSizes' => 'Print Sizes', + 'pixelSizes' => 'Pixel Sizes', + 'addPrintSize' => 'Add Print Size', + 'addPixelSize' => 'Add Pixel Size', + 'editPrintSize' => 'Edit Print Size', + 'editPixelSize' => 'Edit Pixel Size', + 'label' => 'Label', + 'dimensions' => 'Dimensions', + 'width' => 'Width', + 'height' => 'Height', + 'unit' => 'Unit', + 'paperType' => 'Paper Type', + 'active' => 'Active', + 'actions' => 'Actions', + 'confirmDeleteHeader' => 'Confirm Delete', + 'confirmDeleteMessage' => 'Are you sure you want to delete this size? Existing orders will not be affected.', + 'error' => 'Error', + ], + 'printSizePricesInput' => [ + 'title' => 'Print Size Prices', + 'selectSize' => 'Select Print Size', + 'addSize' => 'Add Print Size Price', + ], + 'pixelSizePricesInput' => [ + 'title' => 'Pixel Size Prices', + 'selectSize' => 'Select Pixel Size', + 'addSize' => 'Add Pixel Size Price', + ], + 'shippingAddress' => [ + 'title' => 'Shipping Address', + 'required' => 'Your order contains print items. Please provide a shipping address.', + 'streetName' => 'Street Name', + 'streetNumber' => 'Number', + 'additionalInfo' => 'Additional Info', + 'city' => 'City', + 'postCode' => 'Post Code', + 'country' => 'Country', + ], 'cancelledFailed' => [ 'paymentCancelled' => 'Payment cancelled', 'paymentCancelledMessage' => 'Payment has been cancelled.', diff --git a/lang/tr/left-menu.php b/lang/tr/left-menu.php index dc78742aa75..eb2432ada56 100644 --- a/lang/tr/left-menu.php +++ b/lang/tr/left-menu.php @@ -25,4 +25,5 @@ 'contact' => 'Contact', 'messages' => 'Messages', 'webhooks' => 'Webhooks', + 'shopSizes' => 'Size Catalogue', ]; diff --git a/lang/tr/webshop.php b/lang/tr/webshop.php index 632baabbfae..d690098963a 100644 --- a/lang/tr/webshop.php +++ b/lang/tr/webshop.php @@ -226,6 +226,51 @@ 'addedToOrder' => 'Added to order', 'photoAddedToOrder' => '%s added to your order for %s', ], + 'buyMeDialog' => [ + 'digital' => 'Digital', + 'print' => 'Print', + 'pixel' => 'Pixel', + ], + 'sizeCatalogue' => [ + 'title' => 'Size Catalogue', + 'printSizes' => 'Print Sizes', + 'pixelSizes' => 'Pixel Sizes', + 'addPrintSize' => 'Add Print Size', + 'addPixelSize' => 'Add Pixel Size', + 'editPrintSize' => 'Edit Print Size', + 'editPixelSize' => 'Edit Pixel Size', + 'label' => 'Label', + 'dimensions' => 'Dimensions', + 'width' => 'Width', + 'height' => 'Height', + 'unit' => 'Unit', + 'paperType' => 'Paper Type', + 'active' => 'Active', + 'actions' => 'Actions', + 'confirmDeleteHeader' => 'Confirm Delete', + 'confirmDeleteMessage' => 'Are you sure you want to delete this size? Existing orders will not be affected.', + 'error' => 'Error', + ], + 'printSizePricesInput' => [ + 'title' => 'Print Size Prices', + 'selectSize' => 'Select Print Size', + 'addSize' => 'Add Print Size Price', + ], + 'pixelSizePricesInput' => [ + 'title' => 'Pixel Size Prices', + 'selectSize' => 'Select Pixel Size', + 'addSize' => 'Add Pixel Size Price', + ], + 'shippingAddress' => [ + 'title' => 'Shipping Address', + 'required' => 'Your order contains print items. Please provide a shipping address.', + 'streetName' => 'Street Name', + 'streetNumber' => 'Number', + 'additionalInfo' => 'Additional Info', + 'city' => 'City', + 'postCode' => 'Post Code', + 'country' => 'Country', + ], 'cancelledFailed' => [ 'paymentCancelled' => 'Payment cancelled', 'paymentCancelledMessage' => 'Payment has been cancelled.', diff --git a/lang/vi/left-menu.php b/lang/vi/left-menu.php index f34c90388bc..32ecb3298d1 100644 --- a/lang/vi/left-menu.php +++ b/lang/vi/left-menu.php @@ -25,4 +25,5 @@ 'contact' => 'Contact', 'messages' => 'Messages', 'webhooks' => 'Webhooks', + 'shopSizes' => 'Size Catalogue', ]; diff --git a/lang/vi/webshop.php b/lang/vi/webshop.php index 632baabbfae..d690098963a 100644 --- a/lang/vi/webshop.php +++ b/lang/vi/webshop.php @@ -226,6 +226,51 @@ 'addedToOrder' => 'Added to order', 'photoAddedToOrder' => '%s added to your order for %s', ], + 'buyMeDialog' => [ + 'digital' => 'Digital', + 'print' => 'Print', + 'pixel' => 'Pixel', + ], + 'sizeCatalogue' => [ + 'title' => 'Size Catalogue', + 'printSizes' => 'Print Sizes', + 'pixelSizes' => 'Pixel Sizes', + 'addPrintSize' => 'Add Print Size', + 'addPixelSize' => 'Add Pixel Size', + 'editPrintSize' => 'Edit Print Size', + 'editPixelSize' => 'Edit Pixel Size', + 'label' => 'Label', + 'dimensions' => 'Dimensions', + 'width' => 'Width', + 'height' => 'Height', + 'unit' => 'Unit', + 'paperType' => 'Paper Type', + 'active' => 'Active', + 'actions' => 'Actions', + 'confirmDeleteHeader' => 'Confirm Delete', + 'confirmDeleteMessage' => 'Are you sure you want to delete this size? Existing orders will not be affected.', + 'error' => 'Error', + ], + 'printSizePricesInput' => [ + 'title' => 'Print Size Prices', + 'selectSize' => 'Select Print Size', + 'addSize' => 'Add Print Size Price', + ], + 'pixelSizePricesInput' => [ + 'title' => 'Pixel Size Prices', + 'selectSize' => 'Select Pixel Size', + 'addSize' => 'Add Pixel Size Price', + ], + 'shippingAddress' => [ + 'title' => 'Shipping Address', + 'required' => 'Your order contains print items. Please provide a shipping address.', + 'streetName' => 'Street Name', + 'streetNumber' => 'Number', + 'additionalInfo' => 'Additional Info', + 'city' => 'City', + 'postCode' => 'Post Code', + 'country' => 'Country', + ], 'cancelledFailed' => [ 'paymentCancelled' => 'Payment cancelled', 'paymentCancelledMessage' => 'Payment has been cancelled.', diff --git a/lang/zh_CN/left-menu.php b/lang/zh_CN/left-menu.php index 6718c6f4ea3..f2c3022350e 100644 --- a/lang/zh_CN/left-menu.php +++ b/lang/zh_CN/left-menu.php @@ -25,4 +25,5 @@ 'contact' => 'Contact', 'messages' => 'Messages', 'webhooks' => 'Webhooks', + 'shopSizes' => 'Size Catalogue', ]; diff --git a/lang/zh_CN/webshop.php b/lang/zh_CN/webshop.php index 632baabbfae..d690098963a 100644 --- a/lang/zh_CN/webshop.php +++ b/lang/zh_CN/webshop.php @@ -226,6 +226,51 @@ 'addedToOrder' => 'Added to order', 'photoAddedToOrder' => '%s added to your order for %s', ], + 'buyMeDialog' => [ + 'digital' => 'Digital', + 'print' => 'Print', + 'pixel' => 'Pixel', + ], + 'sizeCatalogue' => [ + 'title' => 'Size Catalogue', + 'printSizes' => 'Print Sizes', + 'pixelSizes' => 'Pixel Sizes', + 'addPrintSize' => 'Add Print Size', + 'addPixelSize' => 'Add Pixel Size', + 'editPrintSize' => 'Edit Print Size', + 'editPixelSize' => 'Edit Pixel Size', + 'label' => 'Label', + 'dimensions' => 'Dimensions', + 'width' => 'Width', + 'height' => 'Height', + 'unit' => 'Unit', + 'paperType' => 'Paper Type', + 'active' => 'Active', + 'actions' => 'Actions', + 'confirmDeleteHeader' => 'Confirm Delete', + 'confirmDeleteMessage' => 'Are you sure you want to delete this size? Existing orders will not be affected.', + 'error' => 'Error', + ], + 'printSizePricesInput' => [ + 'title' => 'Print Size Prices', + 'selectSize' => 'Select Print Size', + 'addSize' => 'Add Print Size Price', + ], + 'pixelSizePricesInput' => [ + 'title' => 'Pixel Size Prices', + 'selectSize' => 'Select Pixel Size', + 'addSize' => 'Add Pixel Size Price', + ], + 'shippingAddress' => [ + 'title' => 'Shipping Address', + 'required' => 'Your order contains print items. Please provide a shipping address.', + 'streetName' => 'Street Name', + 'streetNumber' => 'Number', + 'additionalInfo' => 'Additional Info', + 'city' => 'City', + 'postCode' => 'Post Code', + 'country' => 'Country', + ], 'cancelledFailed' => [ 'paymentCancelled' => 'Payment cancelled', 'paymentCancelledMessage' => 'Payment has been cancelled.', diff --git a/lang/zh_TW/left-menu.php b/lang/zh_TW/left-menu.php index dc250c74e3b..d884fe30494 100644 --- a/lang/zh_TW/left-menu.php +++ b/lang/zh_TW/left-menu.php @@ -25,4 +25,5 @@ 'contact' => 'Contact', 'messages' => 'Messages', 'webhooks' => 'Webhooks', + 'shopSizes' => 'Size Catalogue', ]; diff --git a/lang/zh_TW/webshop.php b/lang/zh_TW/webshop.php index 632baabbfae..d690098963a 100644 --- a/lang/zh_TW/webshop.php +++ b/lang/zh_TW/webshop.php @@ -226,6 +226,51 @@ 'addedToOrder' => 'Added to order', 'photoAddedToOrder' => '%s added to your order for %s', ], + 'buyMeDialog' => [ + 'digital' => 'Digital', + 'print' => 'Print', + 'pixel' => 'Pixel', + ], + 'sizeCatalogue' => [ + 'title' => 'Size Catalogue', + 'printSizes' => 'Print Sizes', + 'pixelSizes' => 'Pixel Sizes', + 'addPrintSize' => 'Add Print Size', + 'addPixelSize' => 'Add Pixel Size', + 'editPrintSize' => 'Edit Print Size', + 'editPixelSize' => 'Edit Pixel Size', + 'label' => 'Label', + 'dimensions' => 'Dimensions', + 'width' => 'Width', + 'height' => 'Height', + 'unit' => 'Unit', + 'paperType' => 'Paper Type', + 'active' => 'Active', + 'actions' => 'Actions', + 'confirmDeleteHeader' => 'Confirm Delete', + 'confirmDeleteMessage' => 'Are you sure you want to delete this size? Existing orders will not be affected.', + 'error' => 'Error', + ], + 'printSizePricesInput' => [ + 'title' => 'Print Size Prices', + 'selectSize' => 'Select Print Size', + 'addSize' => 'Add Print Size Price', + ], + 'pixelSizePricesInput' => [ + 'title' => 'Pixel Size Prices', + 'selectSize' => 'Select Pixel Size', + 'addSize' => 'Add Pixel Size Price', + ], + 'shippingAddress' => [ + 'title' => 'Shipping Address', + 'required' => 'Your order contains print items. Please provide a shipping address.', + 'streetName' => 'Street Name', + 'streetNumber' => 'Number', + 'additionalInfo' => 'Additional Info', + 'city' => 'City', + 'postCode' => 'Post Code', + 'country' => 'Country', + ], 'cancelledFailed' => [ 'paymentCancelled' => 'Payment cancelled', 'paymentCancelledMessage' => 'Payment has been cancelled.', diff --git a/resources/js/components/forms/album/AlbumPurchasable.vue b/resources/js/components/forms/album/AlbumPurchasable.vue index 99f5b1dda38..93a52a5dc0e 100644 --- a/resources/js/components/forms/album/AlbumPurchasable.vue +++ b/resources/js/components/forms/album/AlbumPurchasable.vue @@ -7,6 +7,8 @@