Skip to content

Implement category listing - #54

Merged
bahman026 merged 32 commits into
shopfrom
implement_category_listing
Jul 24, 2026
Merged

Implement category listing#54
bahman026 merged 32 commits into
shopfrom
implement_category_listing

Conversation

@bahman026

Copy link
Copy Markdown
Owner

No description provided.

bahman026 and others added 30 commits June 27, 2026 12:50
Add /categories/{slug} listing (products incl. descendants) with brand,
attribute, price and availability filters, sorting, pagination and
BreadcrumbList JSON-LD. Digikala-style filter UI: accordion sections,
price slider, brand search, per-option counts, instant apply. Attribute
filtering matches via the product_attribute pivot. Thin controller +
Category actions + CategoryDTO + Pest feature tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
Cards now fall back to the first variety image when a product has no
featured image (eager-load varieties.image to avoid N+1). AppLink gains
a new-tab option and ProductCard opens products in a new tab.

Co-authored-by: Cursor <cursoragent@cursor.com>
Update AGENTS.md and STOREFRONT_IMPLEMENTATION.md for the category
filter UI, product-card image fallback / new-tab links, and the SSR
process restart caveat. Add TAGS.md describing tags as category+attribute
SEO landing pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
Describe ShopFlow as an open-source single-vendor storefront, the
admin/shop monorepo layout, tech stack, domain features, and setup.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add /brands/{slug} listing a brand's products with category, price and
availability facets, sorting, pagination and BreadcrumbList JSON-LD.
Thin BrandController + Brand actions + BrandDTO + BrandFilters.vue
(accordion, price slider, category search + counts). Move shared catalog
test helpers to tests/Helpers.php and add BrandPageTest.

Co-authored-by: Cursor <cursoragent@cursor.com>
Serve CMS pages at top-level slugs (/about-us) and FAQ at /faq so the
seeded footer links resolve. Seed real about-us/return/privacy/cooperation
pages and add Facebook/WhatsApp socials used in the footer.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add SitemapController + GetSitemapUrls (home, faq, active categories/brands,
published products/pages, excluding no_index) and a dynamic robots.txt.
Emit absolute breadcrumb item URLs via a shared seo helper.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Add keyword search (/search) and autocomplete (/search/suggest) behind a
ProductSearch contract (DatabaseProductSearch, ILIKE) so Elasticsearch can
swap in later. HeaderSearch shows a debounced dropdown of matching
categories and products.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add a mobile-first auth flow at /login: identify by mobile, then verify
with a one-time code (OTP) or a password. OTP is the primary path and
registers the user on first login; password login is an alternative.

Codes live in cache via SendOtpCode/VerifyOtpCode and are sent by a
logged stub. Resending is blocked until the current code expires, and
the remaining seconds drive the UI countdown. OTP sign-ups seed
placeholders for the NOT NULL email/password/name columns; blocked users
cannot log in.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add an auth-only /account section with a shared AccountLayout sidebar,
a dashboard overview, and a profile editor (name/email; mobile read-only).
Sidebar links not yet built render a placeholder. UserDTO shapes the user
payload; profile saves flash a shared status message.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add customer addresses under /account/addresses: list, create, edit,
set default and soft-delete. Editing is immutable (new row, soft-delete
old) and delete is soft-only so order history is preserved; removing the
default promotes the newest remaining address.

Location is picked on a Neshan map: the interactive web-key map when
NESHAN_MAP_KEY is set, otherwise a draggable static-map fallback via the
service key (cached, timeout-safe). Selecting a point reverse-geocodes to
fill the address. Plate/unit are encoded into the description column;
lat/long columns are defined in create_addresses_table.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add a web app manifest, app icons and Apple meta tags so the storefront
can be installed to the home screen. On iOS Safari (where there is no
native install prompt) show a light bottom-sheet with step-by-step
instructions; it hides when already installed and snoozes 7 days after
dismissal.

Co-authored-by: Cursor <cursoragent@cursor.com>
Guest/user cart (carts table) with add/update/remove, header count badge,
and merge of the guest cart into the account on login. Product buy box
mirrors the cart per variety. Checkout shipping step picks an address (or
adds one inline) and a per-destination shipping method, seeded in admin
ShippingSeeder. Persian validation messages for the address form.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sync admin's CACHE.md with shop's copy, which already tracked this key.
…ing destination

The documented orders/order_shippings schema never had an address
reference at all, so a paid order had nowhere to record which address
it ships to. Add a nullable FK (nullOnDelete, matching the pattern for
other reference FKs on this table) to the existing create_orders_table
migration, expose it on the model/factory/Filament form, and document
it in both apps' schema docs.
Shop's copy had drifted from admin's (missing the Settings entry,
variety-image notes, seeder-robustness notes) — replace it with an
exact copy so both stay in sync going forward.
Wires up the missing final step of checkout: creating a real order and
charging it through Zarinpal (sandbox mode).

- CreatePendingOrder snapshots the cart + address + shipping method into
  one Order (PENDING) and its OrderVariety lines inside a DB transaction.
- RequestZarinpalPayment/VerifyZarinpalPayment talk to Zarinpal's REST v4
  API; StartCheckoutPayment opens the payment session and StartPay
  redirect, CompleteCheckoutPayment handles the callback (looked up by
  authority, never session) and is idempotent against duplicate/refreshed
  callbacks.
- DecrementInventoryAndMarkPaid performs the Strategy-A row-locked
  inventory decrement (docs/ORDER.md) only after payment is verified.
- ValidateCartStock re-checks live inventory before ever opening a
  Zarinpal session, so a customer is never charged for something already
  out of stock. The residual race that check can't prevent (two
  customers reaching payment for the last unit at once) is handled by
  CompleteCheckoutPayment::failPaidButOversold(), which preserves
  ref_id/paid_at and a refund-needed message instead of a plain FAILED
  transaction that would hide that money needs to go back to the
  customer.
- Checkout/Payment.vue now really submits; new Checkout/Confirmation.vue
  shows the paid order.

Gateway credentials live in config('services.zarinpal.*')/.env rather
than the admin gateways table, since nothing is seeded there yet.
GetCategoryFilters::attributeGroups() ordered facet groups alphabetically
by the internal attribute_groups.name, ignoring the order column that
exists specifically to control this — so an admin's configured facet
sequence had no effect on the category page's filter sidebar. Order by
`order` first, `name` as a tiebreak.
BuildProductDetail mapped descriptive product_attribute values to a
bare {value} with no indication of which attribute group they belonged
to, so ProductSpecs.vue rendered raw values like "پنبه" with nothing
saying it's the material. Eager-load attributes.attributeGroup and pair
each value with its group name; render as a group: value list.
…selector

Two bugs in the product page's variety selector, both rooted in
Product::varieties() having no ORDER BY (Postgres gives no row-order
guarantee without one):

- BuildVariantAxes built each axis's options in whatever order varieties
  happened to be processed in, e.g. a Size axis rendering "L, S, XL, M"
  instead of any sensible order. attributes has no order column, so sort
  by attribute id (creation order) as the best available proxy.
- The primary axis wasn't guaranteed to render first — a variety whose
  primary attribute was deleted (attribute_id null) but still carrying a
  secondary/pivot attribute could push that secondary axis above the
  primary one, contradicting "primary attribute group drives selection".

Add Product::varieties()->orderBy('id') for deterministic fetch order,
sort each axis's options, and always place the primary axis first.
…rder

Both resources have an editable `order` field but no `->defaultSort()`
on their tables, so the list always rendered in insertion/id order
regardless of what an admin configured — the field had no visible
effect. Add `->defaultSort('order')`, matching FaqResource's existing
pattern.
…egory pivot relations

AttributeGroup::categories() and Category::attributeGroups() both
declared ->withPivot(['as_filter', 'required', 'order']), but
attribute_group_category has no order column — calling either relation
threw SQLSTATE[42703]: undefined column. Nothing currently calls them,
so this was a dormant landmine; remove the phantom column and add a
regression test that exercises both relations directly.
Staff saw a Zarinpal-paid order missing from the Order → Receipts tab
and mistook it for a bug — they're two intentionally separate payment
paths (manual/offline vs online gateway). Both list-page subheadings
now say so directly, pointing to the other table.
Drop obsolete version key, pin postgres to 16-alpine, add pgdata volume.
Runs boost:mcp inside the admin container via docker exec, so Claude
Code can reach it from the repo root without cd-ing into the container.
Customer-facing order identifier: a random unique 10-digit string
(orders.tracking_code), auto-generated in Order's creating model event,
shown instead of the sequential id. Searchable/copyable column in the
Filament Order table.
… shipping data

ShippingLineSeeder deleted shipping_lines before its dependents
(shipping_methods/shipping_cities), and CitySeeder deleted cities/
provinces before addresses (which uses SoftDeletes, so even a
soft-deleted row still blocks the FK) — both threw a 23503 foreign key
violation on a normal db:seed run.

Also removed TestSeeder's ShippingLineSeeder/ShippingMethodSeeder/
ShippingCitySeeder calls: they wiped out ShippingSeeder's real,
checkout-critical shipping methods and replaced them with 20 random
fake ones tied to random specific cities (no nationwide fallback),
silently breaking the storefront checkout's shipping-method selection.
…nd returns list

Order history: /account/orders (GetUserOrders, paginated newest-first)
and /account/orders/{order} (reusing BuildOrderDTO/OrderDTO built for
checkout confirmation). Line-items/address/payment-summary markup
extracted from Confirmation.vue into Components/Order/OrderDetail.vue,
shared by both pages.

Pay-again retry (Order::isRetryable + RetryOrderPayment): pays
directly without touching the cart. Re-checks live stock for every
line (all-or-nothing), then resets the SAME order back to PENDING and
opens a fresh Zarinpal session via the new OpenZarinpalSession action
(extracted out of StartCheckoutPayment so both flows share it) rather
than cloning a new order per attempt — a customer who cancels and
retries repeatedly accumulates transactions on one order instead of a
new order row each time.

Returns list: /account/returns reuses the same Orders/Index.vue page
and GetUserOrders, just scoped to OrderStatusEnum::RETURNED with its
own title/empty-state copy.

Customer-facing order number is now order.trackingCode everywhere
(Confirmation.vue, Orders/Index.vue, Orders/Show.vue), never the raw
sequential id.

Docs synced across both apps' ORDER.md/IMPLEMENTATION.md/AGENTS.md,
including a documented (not yet built) plan to expire abandoned
PENDING orders after 15 minutes, and a note that marking an order
RETURNED doesn't restock inventory or track refunds today.
users.mobile is nullable at the schema level (admin/staff accounts
have none) but UserDTO::$mobile was a non-nullable string, and
User::displayName()'s fallback returned $this->mobile despite its own
non-nullable return type — a user with no mobile hitting
/account/profile crashed with a TypeError. Made mobile nullable in the
DTO and hardened displayName()'s fallback chain (name -> mobile ->
email -> generic label).
Receipt: GET /account/orders/{order}/receipt renders a standalone,
chrome-less print page (seller contact from the existing footer
settings, buyer name/mobile/address, itemized lines with per-line
discount, totals) with a print/download button (window.print()). Same
ownership check as the order-show page. Linked from Show.vue via a new
"دانلود رسید" link.

Order detail restyle: new OrderStatusProgress component shows a fill
bar through the order's lifecycle stages (current/next stage labels),
with a distinct banner for CANCELED/RETURNED instead of a bar (no real
shipment-tracking data to show discrete stages). Order header wrapped
in a card with the progress bar underneath; line items show a red
discount tag when a line has one.
Auth-only (shared wishlists table has a unique (user_id, product_id)
and no session_id/guest support, unlike cart). POST
/products/{product}/wishlist (WishlistController@toggle +
ToggleWishlist) checks-then-deletes/creates and redirects back.

Heart toggle lives only in BuyBox.vue on the product detail page;
ProductController@show passes isWishlisted as a sibling Inertia prop
(same pattern as cartItems), not baked into ProductDTO. Deliberately
not added to product cards across listings, to avoid threading a bulk
membership lookup through every card-producing action.

/account/wishlist (AccountController@wishlist + GetUserWishlist) reuses
BuildProductCard's card shape and GetUserOrders' {data, meta}
pagination, rendered by Account/Wishlist/Index.vue with a per-row
remove button. Moved the AccountTest placeholder assertion from the
now-real /account/wishlist to /account/reviews.
@bahman026
bahman026 merged commit ad5ea1e into shop Jul 24, 2026
1 of 2 checks passed
@bahman026
bahman026 deleted the implement_category_listing branch July 24, 2026 10:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant