commit a58a61b98c61a707d1d14a827d7fea157433b36c Author: Muhammad Aizat Date: Tue Sep 15 22:09:55 2026 +0800 Pak Jabit: full stack — storefront, Spree customisations, catalogue tooling A South African men's apparel store on Spree 5.6 + Next.js 15. 160 products live, three markets, live Malaysian courier rates, customer-owned carts. What is here: storefront/ the Next.js app backend/ ONLY the custom Spree code. Spree itself is a prebuilt image that mounts no app code, so every file here needs an explicit volume entry in docker-compose.yml or it is silently ignored. catalogue/ the scripts that loaded and maintain the products docs/ manifest, mock registry, napkins Real .env files are committed deliberately. This lives on the house's private Gitea, and a clone that runs beats a clone that needs a secrets handover. LANDMINE.md maps every key, what it opens, and what must be rotated before this repo goes anywhere less private. The admin API key used to be a literal in six catalogue scripts. They now read SPREE_ADMIN_KEY from the environment and refuse to run without it. Co-Authored-By: Claude Opus 5 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23aee30 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Build output and dependencies only. +# +# .env files are deliberately NOT ignored: this lives on the house's private +# Gitea, and a clone that runs beats a clone that needs a secrets handover. +# LANDMINE.md maps every key and says what has to be rotated before this repo +# goes anywhere less private. +node_modules/ +.next/ +*.tsbuildinfo +Archived/ +__pycache__/ +*.pyc +products/ +products-enhanced/ diff --git a/LANDMINE.md b/LANDMINE.md new file mode 100644 index 0000000..6fad9fe --- /dev/null +++ b/LANDMINE.md @@ -0,0 +1,97 @@ +# 🧨 LANDMINE — where every key lives and what it opens + +This repo is on the house's **private** Gitea (`gitmirah:3000`, Tailscale-only), +so real `.env` files are committed on purpose. That is a deliberate trade: no +`.env.example` drift, no "works on my box", and anyone cloning has a running +stack. It also means **a clone of this repo is a full set of production +credentials.** Treat it that way. + +Before this repo goes anywhere else — a public mirror, GitHub, a zip to a +client — every secret below has to be rotated first. Deleting the file later does +not help; git keeps history. + +--- + +## What each key opens + +| Key | Lives in | Opens | Blast radius if leaked | +|---|---|---|---| +| **`SPREE_ADMIN_KEY`** | `catalogue/.env` | Full Spree **admin** API | 🔴 Total. Create/delete/reprice every product, read every order and customer. | +| `SECRET_KEY_BASE` | `backend/.env` | Rails signing | 🔴 Forges sessions and signed URLs. **Rotating breaks every signed image URL and the admin API key** — see below. | +| `NEXT_PUBLIC_SPREE_API_KEY` | `storefront/.env.production` | Spree **store** API | 🟡 Low. Public by design — it ships to every browser. Read products, create carts. Not admin. | +| `EASYPARCEL_CLIENT_ID` / `_SECRET` | `storefront/.env.production` | EasyParcel account | 🟠 Book real shipments, spend real money. | +| `PHOTOROOM_API_KEY` | `backend/.env` | Photoroom **sandbox** | 🟢 Low. Watermarked output, 1000/month free. The **live** key is not in this repo. | + +## Not in this repo + +| Thing | Where it actually is | +|---|---| +| Photoroom **live** key | `/root/.photoroom/credentials` on the house box (chmod 600). 10 images/month. | +| Spree **admin password** | Nowhere written down. One shared account — see below. | +| GCloud SSH key | `/root/.ssh/google_compute_engine` on the house box. | +| `storefront/.easyparcel-token.json` | A **cached OAuth token**, committed with the rest. It expires and regenerates itself; harmless, but it is a live credential while valid. | + +--- + +## 🔴 Do not rotate `SECRET_KEY_BASE` casually + +It signs more than sessions. Changing it: + +- breaks **every ActiveStorage image URL** — 3,228 blobs, the whole catalogue goes blank +- invalidates the **admin API key**, so every script in `catalogue/` stops working + +To force everyone out of the admin, change the **admin password** instead — that +invalidates sessions without touching signed URLs. + +## 🔴 One shared admin account + +Every admin session is indistinguishable. This already cost an investigation: an +unknown IP (`42.188.122.106`, Windows/Brave, valid session cookie) was browsing +`/admin/api_keys` and `/admin/admin_users`, and there was no way to attribute it. +It turned out to be a colleague. + +~5 people are expected to use the admin. Give each one their own account +(Settings → Users) before that number grows. + +--- + +## Where the boxes are + +| | Host | Access | +|---|---|---| +| Storefront | `celest-proto` 34.21.217.116 | `ssh ijat@34.21.217.116` | +| Backend + DB | `pak-jabit-backend` 35.247.170.3 | `ssh -i ~/.ssh/google_compute_engine ijat@35.247.170.3` | + +Public: `shop.jux-lab.com` · `api.jux-lab.com` · `admin.jux-lab.com` + +--- + +## 🔴 The custom backend code exists ONLY as bind mounts + +`ghcr.io/spree/spree` is prebuilt and mounts **no app code**. Everything under +`backend/config/` and `backend/app/` needs an explicit volume entry in +`docker-compose.yml`, or the container simply never loads it — **no error, no +warning, dead file**. The 10MB body-limit initializer sat on the host for a whole +day doing nothing before anyone noticed. + +None of it is in the database. A rebuilt box without these files comes back with +no Image Studio, no address labels, no region preference, and no EasyParcel +rates. + +--- + +## Sandbox things that are NOT real + +Full list in `docs/MOCKS.md`. The ones that matter most: + +- **`Test Card (sandbox)`** — a Bogus gateway that approves any card ending in + the right digits. **Deactivate before launch.** +- **`Test Payment`** — offline placeholder, no money moves. +- **`Standard Delivery (South Africa)` R99** and **`Free Shipping` US $0** — + placeholder rates, not quotes. (Malaysia is real: live EasyParcel.) +- **`studio-test-chinos`** — a fake product, kept deliberately as the worked + example for the Image Studio. + +--- + +*Written 2026-09-15, the night the stack got its region anchor and a customer-owned cart.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..ffc654a --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +# 🧵 Pak Jabit — men's apparel store + +A South African e-commerce stack built for NSH. Spree Commerce backend, Next.js +storefront, 160 real products. + +| | Live | What it is | +|---|---|---| +| Storefront | https://shop.jux-lab.com | Next.js 15 (App Router), systemd `pak-jabit-store` :3200 | +| Store API | https://api.jux-lab.com | Spree 5.6 in Docker | +| Admin | https://admin.jux-lab.com | Same container, split by hostname | +| Image Studio | https://admin.jux-lab.com/admin/image_studio | Custom admin page, Photoroom pipeline | + +🔴 **Read [`LANDMINE.md`](LANDMINE.md) before touching anything.** Real +credentials are committed here on purpose (private Gitea), and several traps in +this stack fail *silently* rather than erroring. + +--- + +## Layout + +``` +storefront/ Next.js app. Everything a shopper sees. +backend/ ONLY the custom Spree code + compose file. Spree itself is a + prebuilt image — see LANDMINE.md on bind mounts. +catalogue/ Scripts that loaded and maintain the 160 products. +docs/ Manifest, mock registry, and the napkins (ideas + open questions). +``` + +## Running it + +**Backend** — on `pak-jabit-backend`: + +```bash +cd ~/pak-jabit +docker compose up -d +docker compose exec web bin/rails runner 'puts Spree::Product.count' +``` + +**Storefront** — on `celest-proto`: + +```bash +cd ~/pak-jabit-store +npm ci +npm run build +sudo systemctl restart pak-jabit-store +``` + +**Catalogue scripts** — they need the admin key in the environment and refuse to +run without it: + +```bash +export SPREE_ADMIN_KEY=$(grep SPREE_ADMIN_KEY catalogue/.env | cut -d= -f2) +python3 catalogue/reprice_zar.py --dry-run +``` + +--- + +## How it fits together + +### Region anchor — one choice, three consequences + +A customer picks a **currency**. In Spree a market *is* currency + shippable +countries + shipping methods, so that single choice sets all three: + +| Currency | Market | Ships to | Delivery | +|---|---|---|---| +| ZAR | South Africa | ZA | R99 flat (placeholder) | +| MYR | Malaysia | MY | Live EasyParcel courier rates | +| USD | United States | US | Free | + +The choice lives in `localStorage` for guests and in the customer's +`public_metadata` when signed in; the profile wins. + +⚠️ **Spree fixes a cart's currency AND market at creation and neither can change +afterwards.** That is why the anchor exists at all, and why switching currency +*rebuilds* the cart and re-adds every line rather than converting it. + +🔴 This model has a known hole — one person with addresses in several countries. +Written up in [`docs/napkin-storefront.md`](docs/napkin-storefront.md) §2. + +### Cart belongs to the customer + +A guest cart lives in `localStorage`. On sign-in it is **claimed** +(`PATCH /carts/:id/associate`) and from then on the cart is fetched from the +server, so it follows the customer to any device. + +### EasyParcel shipping is real, and verified server-side + +Malaysian addresses get live courier quotes — 20 couriers, real prices. The +chosen one becomes the actual charge through a custom calculator +(`backend/config/initializers/easyparcel_calculator.rb`). + +🔴 The price is confirmed against a fresh quote inside +`/api/shipping/select` before it can become a cost, and `/api/cart` **rejects** +any body carrying `metadata`. Both guards exist because cart metadata is +client-writable — without them a customer could set their own shipping to one +sen. Do not loosen either. + +### Image Studio + +Upload phone photos in the admin → Photoroom (background removal, lighting, +crop) → review before/after → attach to a product or create a new listing. +Nothing touches the catalogue until Apply. + +--- + +## Traps that bite + +Full list in [`docs/pak-jabit-manifest.md`](docs/pak-jabit-manifest.md) and +[`LANDMINE.md`](LANDMINE.md). The pattern worth internalising: + +> **Spree's store API answers `200` and silently saves nothing** for fields it +> does not permit. + +That cost three separate debugging rounds in one night — address `label`, +customer `public_metadata`, and product `price`. If a field vanishes after a +successful write, it was never permitted; the fix is a decorator in +`backend/config/initializers/`. + +Others: + +- **A shipping rate only applies when its calculator's currency matches the + order's.** A mismatch produces no rate, no fulfillment, and a checkout with + nothing to charge — and no error anywhere. +- **`POST /admin/products` drops a scalar `price`.** It needs + `prices: [{amount, currency}]`. A product with no price row in the store + currency is invisible on the storefront. +- **Product visibility is per-channel** via `Spree::ProductPublication`, not + `status`. No publication row → the join drops it, however active and priced. +- **New assets default to position 1** — they silently become the thumbnail. +- **`require` in a Rails initializer dies under Zeitwerk.** Define classes inside + `config.to_prepare` instead. It took the container down once. +- **`metadata` in the cart API maps to `private_metadata`**, not `public_metadata`. +- **`curl -o` loses binary bodies on the backend box** — 200, correct + `content-length`, zero-byte file. Use Python for binary downloads. + +--- + +## Where it stands + +**Working:** catalogue, currencies, cart, addresses, checkout in all three +markets, live MY shipping, saved cards (sandbox), Image Studio, nightly backups, +fail2ban. + +**Not done:** real payment gateway (Payfast, gated on NSH merchant +registration), SA courier account (Bob Go), product variants — the S/M/L/XL +buttons are decorative, and stock/restock has never been tested. + +See `docs/napkin-*.md` for the open questions and ideas. diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..58815e3 --- /dev/null +++ b/backend/.env @@ -0,0 +1,5 @@ +SECRET_KEY_BASE=0f8a1c6b4075f08553321d397557c0970e90f88eb12bfd971adb0e1d18050d771a194035fa69be0216f46e78e311cfc64d4520ac48e0f06eacba9351d9f8db13 +RAILS_HOST=api.jux-lab.com +SPREE_PORT=3000 +# #mock — sandbox key — every output is watermarked. Live key is in /root/.photoroom/credentials on the house box (10/month allowance). +PHOTOROOM_API_KEY=sandbox_sk_pr_default_e10d721df37e9884f1d638b44a140c3698410d67 diff --git a/backend/app/controllers/spree/admin/image_studio_controller.rb b/backend/app/controllers/spree/admin/image_studio_controller.rb new file mode 100644 index 0000000..c2a46a8 --- /dev/null +++ b/backend/app/controllers/spree/admin/image_studio_controller.rb @@ -0,0 +1,336 @@ +require 'net/http' +require 'securerandom' + +module Spree + module Admin + # Image Studio — the seller's control surface for the Photoroom pipeline. + # + # The real flow this exists for: photograph stock on a phone, open this page, + # upload the shots, see what they look like standardised, and post them to + # the store — either onto an existing product or as a brand new listing. + # + # Three steps, deliberately: + # + # show choose a source (uploaded photos, or an existing product's image) + # preview send each image to Photoroom, hold the results as UNATTACHED + # ActiveStorage blobs, show them beside the originals + # apply attach those blobs — to an existing product, or to one created here + # + # Nothing touches the catalogue until `apply`. Rejecting a batch is just + # navigating away: the blobs were never attached, so there is nothing to undo + # and no rejects left behind in a product gallery. + class ImageStudioController < BaseController + PHOTOROOM_EDIT = 'https://image-api.photoroom.com/v2/edit'.freeze + + # Each image is a round trip of roughly 3-5 seconds, run one after another. + # Past this many the request starts flirting with the web server's timeout, + # so the cap is enforced rather than left to chance. + MAX_PHOTOS = 8 + + BACKGROUNDS = { + 'White' => 'FFFFFF', + 'Studio grey' => 'F2F2F2', + 'Beige' => 'EDE6DA', + 'Black' => '0E0D0B' + }.freeze + + SIZES = ['1000x1000', '1500x1500', '2000x2000'].freeze + + def show + load_form + end + + def preview + load_form + + key = photoroom_key + if key.blank? + # Loud, not a guess. A missing key must never quietly become a no-op + # that leaves the seller believing the photos were processed. + flash.now[:error] = 'PHOTOROOM_API_KEY is not set on the server. ' \ + 'Add it to .env and run `docker compose up -d`.' + return render :show, status: :unprocessable_entity + end + + sources = collect_sources + if sources.empty? + flash.now[:error] = 'Choose some photos to upload, or pick a product to reprocess.' + return render :show, status: :unprocessable_entity + end + + size = params[:size].presence_in(SIZES) || SIZES.first + background = BACKGROUNDS.values.include?(params[:background]) ? params[:background] : 'FFFFFF' + + @results = [] + sources.each do |src| + begin + png = photoroom_edit(src[:bytes], src[:filename], key, size, background) + rescue StandardError => e + @results << { filename: src[:filename], error: e.message } + next + end + + after_blob = ActiveStorage::Blob.create_and_upload!( + io: StringIO.new(png), + filename: "#{File.basename(src[:filename], '.*')}-photoroom.png", + content_type: 'image/png' + ) + + @results << { + filename: src[:filename], + before_url: blob_path(src[:before_blob]), + after_url: blob_path(after_blob), + signed_id: after_blob.signed_id, + before_bytes: src[:bytes].bytesize, + after_bytes: png.bytesize + } + end + + @processed = @results.count { |r| r[:error].blank? } + @failed = @results.count { |r| r[:error].present? } + @size = size + + if @processed.zero? + flash.now[:error] = 'Every image failed. Nothing to apply.' + return render :show, status: :unprocessable_entity + end + + render :show + end + + def apply + signed_ids = Array(params[:signed_ids]).reject(&:blank?) + if signed_ids.empty? + return redirect_to(spree.admin_image_studio_path, alert: 'Nothing to apply.') + end + + product = + if params[:destination] == 'new' + build_new_product + else + current_store.products.find_by!(slug: params[:target_slug]) + end + + return redirect_to(spree.admin_image_studio_path, alert: @create_error) if @create_error + + make_primary = params[:make_primary] == '1' + position = make_primary ? 1 : next_position(product) + attached = 0 + + signed_ids.each_with_index do |signed_id, i| + asset = product.media.build(position: position + i) + asset.type = 'Spree::Image' + asset.attachment.attach(signed_id) + attached += 1 if asset.save + end + + if attached.zero? + return redirect_to(spree.admin_image_studio_path, alert: 'No images could be attached.') + end + + notice = "#{attached} image#{'s' if attached != 1} added to #{product.name}" + notice += ' — created as a draft, publish it once the details are filled in' if @created_draft + if @visibility_warning + return redirect_to spree.admin_product_path(product), + alert: "#{notice}, #{@visibility_warning}." + end + redirect_to spree.admin_product_path(product), notice: notice + end + + private + + def load_form + @breadcrumb_icon = 'photo' + add_breadcrumb 'Image Studio', spree.admin_image_studio_path + @products = current_store.products.where(deleted_at: nil).order(:name) + @backgrounds = BACKGROUNDS + @sizes = SIZES + @currency = current_store.default_currency + @max_photos = MAX_PHOTOS + end + + # Two ways in: files straight off the seller's device, or an image already + # on a product (useful for re-cutting an old listing). + def collect_sources + uploads = Array(params[:photos]).reject(&:blank?).first(MAX_PHOTOS) + + if uploads.any? + return uploads.map do |file| + bytes = file.read + blob = ActiveStorage::Blob.create_and_upload!( + io: StringIO.new(bytes), + filename: file.original_filename, + content_type: file.content_type.presence || 'image/jpeg' + ) + { bytes: bytes, filename: file.original_filename, before_blob: blob } + end + end + + return [] if params[:slug].blank? + + product = current_store.products.find_by(slug: params[:slug]) + asset = product && primary_asset(product) + return [] if asset.nil? + + @product = product + [{ + bytes: asset.attachment.download, + filename: asset.attachment.filename.to_s, + before_blob: asset.attachment.blob + }] + end + + def build_new_product + name = params[:new_name].to_s.strip + if name.blank? + @create_error = 'A new product needs a name.' + return nil + end + + price = params[:new_price].to_s.strip + if price.blank? || price.to_f <= 0 + # A product with no price row in the store currency is excluded from + # the storefront with no error anywhere. Refuse rather than create one. + @create_error = "A new product needs a price in #{current_store.default_currency}." + return nil + end + + publish = params[:publish] == '1' + @created_draft = !publish + + # A product belongs to ONE store in Spree 5.6 (`belongs_to :store`). + # `product.stores` is a Relation, not a collection to push onto — + # `stores << store` raises NoMethodError. + product = Spree::Product.new( + name: name, + store: current_store, + shipping_category_id: Spree::ShippingCategory.first&.id, + status: publish ? 'active' : 'draft', + # beginning_of_minute, not Time.current: the storefront scope compares + # against cutoff.beginning_of_minute, which truncates DOWN, so a + # timestamp from seconds ago reads as the future and the product stays + # invisible until the minute ticks over. + available_on: publish ? Time.current.beginning_of_minute : nil + ) + product.save! + + # Set the price explicitly against the STORE currency. Spree's generic + # price setter uses the global currency preference, which is not the same + # thing — that mismatch is what makes products invisible on the storefront. + product.master.prices + .find_or_initialize_by(currency: current_store.default_currency) + .update!(amount: price.to_f) + + # Stock, or the storefront will never show it. `available` requires the + # product to be purchasable, and a master with no stock item is not: + # in_stock? false -> purchasable? false -> excluded from the storefront + # with no error anywhere. Matching how the existing catalogue is set up: + # inventory untracked (on_hand Infinity) with a stock item per location. + master = product.master + master.update!(track_inventory: false) + Spree::StockLocation.where(active: true).find_each do |location| + master.stock_items.find_or_create_by!(stock_location_id: location.id) + end + + # Publish to the channel. Visibility is per-channel via + # Spree::ProductPublication, NOT the product's `status` alone — a product + # with no publication row is dropped by the storefront's join, no matter + # how active and priced and stocked it is. + if publish + channel = Spree::Current.channel || + Spree::Channel.find_by(name: 'Online Store') || + Spree::Channel.order(:id).first + if channel + Spree::ProductPublication + .find_or_create_by!(product_id: product.id, channel_id: channel.id) do |pub| + pub.published_at = Time.current.beginning_of_minute + end + end + end + + # Check rather than assume. This is the exact failure that hid 130 + # products for an hour: every write returned success and the storefront + # showed nothing. + unless current_store.products + .available(Time.current, current_store.default_currency) + .where(id: product.id).exists? + # A warning, not an error: the product exists now, so bailing out here + # would leave it behind with no images at all. Attach them, then say so. + @visibility_warning = 'but it is NOT visible on the storefront yet — ' \ + 'check its price, stock and status' + end + + product + rescue ActiveRecord::RecordInvalid => e + @create_error = "Could not create product: #{e.record.errors.full_messages.to_sentence}" + nil + end + + # A product's gallery is the UNION of product-level `media` and any legacy + # assets pinned to the master variant, so the next free slot has to clear + # both. Taking max(:position) from `media` alone put an appended image at + # position 2, in the middle of the gallery, instead of at the end. + def next_position(product) + product_max = product.media.maximum(:position).to_i + master_max = product.master&.images&.maximum(:position).to_i || 0 + [product_max, master_max].max + 1 + end + + def primary_asset(product) + product.media.order(:position).first || + product.master&.images&.order(:position)&.first + end + + # url_for on an attachment resolves to attachment_url, which does not exist + # on a Spree engine controller. Go through the ActiveStorage helpers. + def blob_path(blob_or_attachment) + Rails.application.routes.url_helpers.rails_blob_path(blob_or_attachment, only_path: true) + end + + def photoroom_key + ENV['PHOTOROOM_API_KEY'] + end + + def photoroom_edit(image_bytes, filename, key, size, background) + boundary = SecureRandom.hex(16) + fields = { + 'background.color' => background, + 'outputSize' => size, + 'padding' => '0.05' + } + + body = +'' + fields.each do |k, v| + body << "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{k}\"\r\n\r\n#{v}\r\n" + end + body << "--#{boundary}\r\nContent-Disposition: form-data; " \ + "name=\"imageFile\"; filename=\"#{filename}\"\r\n" \ + "Content-Type: image/jpeg\r\n\r\n" + body = body.b + image_bytes.b + "\r\n--#{boundary}--\r\n".b + + uri = URI(PHOTOROOM_EDIT) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.read_timeout = 180 + + req = Net::HTTP::Post.new(uri) + req['x-api-key'] = key + req['Content-Type'] = "multipart/form-data; boundary=#{boundary}" + req.body = body + + res = http.request(req) + + raise "HTTP #{res.code}: #{res.body.to_s[0, 200]}" unless res.is_a?(Net::HTTPSuccess) + + # A 200 is not proof. Photoroom answers 200 with an empty body on some + # failures, and an empty attachment saves cleanly and serves nothing. + raise 'returned 200 with an empty body' if res.body.nil? || res.body.empty? + unless res['content-type'].to_s.start_with?('image/') + raise "returned #{res['content-type']} instead of an image" + end + + res.body + end + end + end +end diff --git a/backend/app/views/spree/admin/image_studio/show.html.erb b/backend/app/views/spree/admin/image_studio/show.html.erb new file mode 100644 index 0000000..b23cd10 --- /dev/null +++ b/backend/app/views/spree/admin/image_studio/show.html.erb @@ -0,0 +1,233 @@ +<% content_for :page_title do %> + Image Studio +<% end %> + +
+

+ Upload photos straight off your phone or camera — background, framing and size + are standardised, then posted to the store. + Nothing is saved until you press Apply. +

+ + <%= form_with url: spree.admin_preview_image_studio_path, method: :post, + local: true, multipart: true do |f| %> +
+
+
1 · Photos
+ +
+ + <%= file_field_tag 'photos[]', multiple: true, accept: 'image/*', + id: 'photos', class: 'form-control' %> +
+ Pick several at once. On a phone this opens the camera roll. + Up to <%= @max_photos %> per batch — each takes a few seconds. +
+ + + +
+ +
+ + …or reprocess an image already on a product + +
+ +
Ignored when you upload files above.
+
+
+
+
+ +
+
+
2 · Look
+
+
+ + +
+
+ + +
+
+ <%= submit_tag 'Process photos', class: 'btn btn-primary w-100', + data: { disable_with: 'Processing… this can take a minute' } %> +
+
+
+
+ <% end %> + + <% if @results.present? %> +
+
+
3 · Review
+

+ <%= @processed %> processed<%= ", #{@failed} failed" if @failed.to_i.positive? %> + · <%= @size %> +

+ + <% @results.each_with_index do |r, i| %> +
+
+ <%= r[:filename] %> +
+ + <% if r[:error].present? %> +
+
+ Failed: <%= r[:error] %> +
+
+ <% else %> +
+
+ <%= image_tag r[:before_url], style: 'max-width:100%; height:auto;' %> +
+

+ Before · <%= number_to_human_size(r[:before_bytes]) %> +

+
+
+
+ <%= image_tag r[:after_url], style: 'max-width:100%; height:auto;' %> +
+

+ After · <%= number_to_human_size(r[:after_bytes]) %> +

+
+ <% end %> +
+ <% end %> +
+
+ + <%= form_with url: spree.admin_apply_image_studio_path, method: :post, local: true do |f| %> + <% @results.each do |r| %> + <% next if r[:error].present? %> + <%= hidden_field_tag 'signed_ids[]', r[:signed_id], id: nil %> + <% end %> + +
+
+
4 · Post to store
+ +
+ <%= radio_button_tag :destination, 'existing', true, class: 'form-check-input', id: 'dest_existing' %> + +
+
+ +
+ +
+ <%= radio_button_tag :destination, 'new', false, class: 'form-check-input', id: 'dest_new' %> + +
+
+
+ <%= text_field_tag :new_name, params[:new_name], class: 'form-control', + placeholder: 'Product name' %> +
+
+ <%= text_field_tag :new_price, params[:new_price], class: 'form-control', + placeholder: "Price (#{@currency})" %> +
+
+
+ <%= check_box_tag :publish, '1', false, class: 'form-check-input', id: 'publish' %> + +
+
+
+
+ Left unpublished it is created as a draft, so you can add the + description and details before customers see it. +
+
+
+ +
+ +
+ <%= check_box_tag :make_primary, '1', true, class: 'form-check-input', id: 'make_primary' %> + +
+ Unchecked, they are added to the end of the gallery and the current + main photo is left alone. +
+
+ + <%= submit_tag 'Apply to store', class: 'btn btn-primary' %> + <%= link_to 'Discard', spree.admin_image_studio_path, class: 'btn btn-outline-secondary ms-2' %> + +

+ Discarding leaves the catalogue untouched — nothing was attached. +

+
+
+ <% end %> + <% end %> +
+ + diff --git a/backend/config/initializers/address_label.rb b/backend/config/initializers/address_label.rb new file mode 100644 index 0000000..f5d1ebd --- /dev/null +++ b/backend/config/initializers/address_label.rb @@ -0,0 +1,33 @@ +# Customer address labels — "Home", "Office", "Mom's place". +# +# Spree::Address already HAS a `label` column, and the ADMIN serializer already +# exposes it. The STORE API does neither: AddressesController#permitted_params +# omits :label, so a POST carrying one answers 201 and saves nil — no error, no +# warning, the field just vanishes. Spree::Addresses::Create passes params +# straight to Address.new, so permitting it is enough to persist it. +# +# prepend, not class_eval: permitted_params is defined directly on that +# controller, so redefining it in place would send `super` past the original to +# ResourceController and silently drop every other permitted field. +# +# Bind-mounted — the prebuilt ghcr.io/spree/spree image mounts no app code, so +# this file needs its own entry in docker-compose.yml's web volumes block. + +module PakJabit + module CustomerAddressLabel + def permitted_params + super.merge(params.permit(:label)) + end + end +end + +Rails.application.config.to_prepare do + unless Spree::Api::V3::Store::Customer::AddressesController.include?(PakJabit::CustomerAddressLabel) + Spree::Api::V3::Store::Customer::AddressesController.prepend(PakJabit::CustomerAddressLabel) + end + + Spree::Api::V3::AddressSerializer.class_eval do + typelize label: [:string, nullable: true] + attributes :label + end +end diff --git a/backend/config/initializers/api_body_limit.rb b/backend/config/initializers/api_body_limit.rb new file mode 100644 index 0000000..bacd795 --- /dev/null +++ b/backend/config/initializers/api_body_limit.rb @@ -0,0 +1 @@ +Spree::Api::Config[:max_request_body_size] = 10_485_760 diff --git a/backend/config/initializers/customer_preferences.rb b/backend/config/initializers/customer_preferences.rb new file mode 100644 index 0000000..71d7b40 --- /dev/null +++ b/backend/config/initializers/customer_preferences.rb @@ -0,0 +1,38 @@ +# Customer region preference — the anchor for currency, prices and shipping. +# +# A cart's currency and market are fixed by Spree at CREATION and cannot be +# changed afterwards, so the choice has to exist before the cart does. Holding it +# on the customer is what makes that possible: sign in, and every cart from then +# on is created in the right market without asking again. +# +# Stock Spree's store API neither permits nor serializes public_metadata — a +# PATCH carrying it answers 200 and saves nothing, the same silent drop that +# hides a bad address label. This adds both. +# +# prepend, not class_eval: permitted_params is defined directly on that +# controller, so redefining it in place would send `super` past the original and +# drop every other permitted field. +# +# Bind-mounted — the prebuilt ghcr.io/spree/spree image mounts no app code, so +# this file needs its own entry in docker-compose.yml's web volumes block. + +module PakJabit + module CustomerPublicMetadata + def permitted_params + super.merge(params.permit(public_metadata: {})) + end + end +end + +Rails.application.config.to_prepare do + unless Spree::Api::V3::Store::CustomersController.include?(PakJabit::CustomerPublicMetadata) + Spree::Api::V3::Store::CustomersController.prepend(PakJabit::CustomerPublicMetadata) + end + + Spree::Api::V3::CustomerSerializer.class_eval do + typelize public_metadata: 'Record' + attribute :public_metadata do |user| + user.public_metadata || {} + end + end +end diff --git a/backend/config/initializers/easyparcel_calculator.rb b/backend/config/initializers/easyparcel_calculator.rb new file mode 100644 index 0000000..22bed47 --- /dev/null +++ b/backend/config/initializers/easyparcel_calculator.rb @@ -0,0 +1,58 @@ +# EasyParcel live courier rates as a real Spree shipping cost. +# +# The storefront has always shown live EasyParcel quotes — 20 couriers for a +# Melaka address, with logos and durations. What was missing is that the chosen +# one never reached Spree, so the customer picked "City-Link RM6.79" and the +# order was charged whatever the flat-rate method said. The quote screen looked +# decorative because of that last missing step, and I removed it on that reading. +# This restores it by making the selection the thing Spree actually charges. +# +# The amount is read from order.private_metadata['easyparcel_amount'], written +# ONLY by the storefront's /api/shipping/select route, which re-fetches the live +# quotes server-side and confirms the chosen service_id really costs that. The +# browser can PATCH cart metadata directly, so an unverified amount here would +# let a customer set their own shipping to one sen. +# +# No quote chosen => nil => no shipping rate => no fulfillment => checkout cannot +# complete. That is deliberate: a Malaysian order must carry a real courier. +# +# The class is defined INSIDE to_prepare: at initializer load time Spree is not +# autoloaded yet, and `require 'spree/shipping_calculator'` raises LoadError +# under Zeitwerk — that took the container down once. +# +# Bind-mounted — the prebuilt image mounts no app code. See docker-compose.yml. + +Rails.application.config.to_prepare do + unless defined?(Spree::Calculator::Shipping::EasyParcelQuote) + Spree::Calculator::Shipping.const_set( + :EasyParcelQuote, + Class.new(Spree::ShippingCalculator) do + preference :currency, :string, default: 'MYR' + + def self.description + 'EasyParcel live courier quote' + end + + def compute_package(package) + order = package.respond_to?(:order) ? package.order : nil + return nil if order.nil? + + meta = order.private_metadata || {} + raw = meta['easyparcel_amount'] || meta[:easyparcel_amount] + return nil if raw.blank? + + BigDecimal(raw.to_s) + rescue ArgumentError, TypeError + # A malformed amount must not become a free shipment. + nil + end + end + ) + end + + # Registering only populates the admin's calculator dropdown; the method below + # gets the class assigned directly, so a nil list here must not abort boot. + klass = Spree::Calculator::Shipping::EasyParcelQuote + list = Rails.application.config.spree.calculators&.shipping_methods + list << klass if list.respond_to?(:include?) && !list.include?(klass) +end diff --git a/backend/config/initializers/image_studio.rb b/backend/config/initializers/image_studio.rb new file mode 100644 index 0000000..4c27412 --- /dev/null +++ b/backend/config/initializers/image_studio.rb @@ -0,0 +1,17 @@ +# Image Studio — custom admin page for the Photoroom pipeline. +# +# Delivered as bind-mounted files rather than a rebuilt image: this deployment +# runs the prebuilt ghcr.io/spree/spree image with no app code mounted, so any +# custom file needs an explicit mount in docker-compose.yml. See the web +# service's volumes block. + +# Routes live in config/routes.rb — see the note there. + +Rails.application.config.after_initialize do + Spree.admin.navigation.sidebar.add :image_studio, + label: 'Image Studio', + url: :admin_image_studio_path, + icon: 'photo', + position: 35, + active: -> { controller_name == 'image_studio' } +end diff --git a/backend/config/routes.rb b/backend/config/routes.rb new file mode 100644 index 0000000..c2a1015 --- /dev/null +++ b/backend/config/routes.rb @@ -0,0 +1,49 @@ + +Rails.application.routes.draw do + Spree::Core::Engine.add_routes do + # Image Studio - custom admin page for the Photoroom pipeline. + # Must live here, not in an initializer: Spree draws engine routes before + # config/initializers run, so add_routes from an initializer is a silent + # no-op (the sidebar entry registers, the path helper never exists). + namespace :admin do + get 'image_studio', to: 'image_studio#show', as: :image_studio + post 'image_studio/preview', to: 'image_studio#preview', as: :preview_image_studio + post 'image_studio/apply', to: 'image_studio#apply', as: :apply_image_studio + end + + # Admin authentication + devise_for( + Spree.admin_user_class.model_name.singular_route_key, + class_name: Spree.admin_user_class.to_s, + controllers: { + sessions: 'spree/admin/user_sessions', + passwords: 'spree/admin/user_passwords' + }, + skip: :registrations, + path: :admin_user, + router_name: :spree + ) + end + # This line mounts Spree's routes at the root of your application. + # This means, any requests to URLs such as /products, will go to + # Spree::ProductsController. + # If you would like to change where this engine is mounted, simply change the + # :at option to something different. + # + # We ask that you don't use the :as option here, as Spree relies on it being + # the default of "spree". + mount Spree::Core::Engine, at: '/' + devise_for :admin_users, class_name: "Spree::AdminUser" + devise_for :users, class_name: "Spree::User" + + # Job dashboard (Mission Control) — inspect, retry, and discard Solid Queue + # jobs at http://localhost:3000/jobs. Guarded by its own HTTP Basic auth + # (see config/application.rb), independent of app sessions. + mount MissionControl::Jobs::Engine, at: "/jobs" + + # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. + # Can be used by load balancers and uptime monitors to verify that the app is live. + get "up" => "rails/health#show", as: :rails_health_check + + root to: redirect('/admin') +end diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..cbb178f --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,106 @@ +# Quick-start: docker compose up +# Uses prebuilt Spree image — no local build needed. +# To customize the backend, run: docker compose -f docker-compose.dev.yml up +x-app: &app + image: ghcr.io/spree/spree:${SPREE_VERSION_TAG:-latest} + depends_on: + postgres: + condition: service_healthy + # Uncomment when enabling the optional meilisearch service below. + # meilisearch: + # condition: service_healthy + mailpit: + condition: service_started + env_file: .env + environment: + DATABASE_URL: postgres://postgres@postgres:5432/spree_production + SECRET_KEY_BASE: ${SECRET_KEY_BASE} + RAILS_FORCE_SSL: ${RAILS_FORCE_SSL:-false} + RAILS_ASSUME_SSL: ${RAILS_ASSUME_SSL:-false} + # Public host for generated URLs (image/attachment URLs in API responses, + # email links). Override in .env when serving from a real domain or IP. + RAILS_HOST: ${RAILS_HOST:-localhost:${SPREE_PORT:-3000}} + # Product search runs on the database by default. To switch to Meilisearch, + # uncomment this together with the meilisearch depends_on/service/volume + # blocks, then reindex: docker compose exec web bin/rails spree:search:reindex + # MEILISEARCH_URL: http://meilisearch:7700 + # All outgoing mail is captured by Mailpit — read it at http://localhost:8025. + # Set SMTP_HOST (and friends) in .env to deliver through a real provider instead. + SMTP_HOST: ${SMTP_HOST:-mailpit} + SMTP_PORT: ${SMTP_PORT:-1025} + +services: + postgres: + image: postgres:18-alpine + environment: + POSTGRES_HOST_AUTH_METHOD: trust + PGDATA: /var/lib/postgresql/18/docker + volumes: + - postgres_data:/var/lib/postgresql + healthcheck: + test: pg_isready -U postgres + interval: 5s + timeout: 5s + retries: 5 + + # Optional Meilisearch search provider (typo tolerance, instant facets). + # Uncomment together with the meilisearch lines under x-app's depends_on and + # environment, and the meilisearch_data volume at the bottom. + # meilisearch: + # image: getmeili/meilisearch:latest + # volumes: + # - meilisearch_data:/meili_data + # ports: + # # Loopback-only: the app reaches Meilisearch over the compose network; + # # the host port is for debugging and must not be LAN-reachable (Docker's + # # NAT rules bypass host firewalls on Linux, and no API key is set). + # - "127.0.0.1:${SPREE_MEILISEARCH_PORT:-7700}:7700" + # healthcheck: + # test: curl -f http://localhost:7700/health || exit 1 + # interval: 5s + # timeout: 5s + # retries: 5 + + mailpit: + image: axllent/mailpit:latest + ports: + # Loopback-only, matching meilisearch: reachable from the host browser + # but not the LAN (Docker's NAT rules bypass host firewalls on Linux). + # 8025 = web UI — open http://localhost:8025 to read captured mail. + # 1025 = SMTP endpoint the app delivers to (in-cluster as mailpit:1025). + - "127.0.0.1:${MAILPIT_UI_PORT:-8025}:8025" + - "127.0.0.1:${MAILPIT_SMTP_PORT:-1025}:1025" + environment: + MP_MAX_MESSAGES: "500" + MP_SMTP_AUTH_ACCEPT_ANY: "true" + MP_SMTP_AUTH_ALLOW_INSECURE: "true" + + # One app container: Puma serves the web AND runs Solid Queue's job + # supervisor in-process (SOLID_QUEUE_IN_PUMA, on by default). To split job + # processing out, add a service from the same image with + # `command: bin/jobs` and set SOLID_QUEUE_IN_PUMA: "false" here. + web: + <<: *app + ports: + - "${SPREE_PORT:-3000}:3000" + volumes: + - storage_data:/rails/storage + - ./config/initializers/api_body_limit.rb:/rails/config/initializers/api_body_limit.rb:ro + - ./config/initializers/image_studio.rb:/rails/config/initializers/image_studio.rb:ro + - ./config/initializers/address_label.rb:/rails/config/initializers/address_label.rb:ro + - ./config/initializers/customer_preferences.rb:/rails/config/initializers/customer_preferences.rb:ro + - ./config/initializers/easyparcel_calculator.rb:/rails/config/initializers/easyparcel_calculator.rb:ro + - ./config/routes.rb:/rails/config/routes.rb:ro + - ./app/controllers/spree:/rails/app/controllers/spree:ro + - ./app/views/spree:/rails/app/views/spree:ro + healthcheck: + test: curl -f http://localhost:3000/up || exit 1 + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + +volumes: + postgres_data: + # meilisearch_data: + storage_data: diff --git a/catalogue/.env b/catalogue/.env new file mode 100644 index 0000000..f1295fa --- /dev/null +++ b/catalogue/.env @@ -0,0 +1,6 @@ +# Spree ADMIN API key — full write access to products, prices and orders. +# The scripts here read it from the environment and refuse to run without it; +# it used to be a literal in six files, which is why this exists. +# +# export SPREE_ADMIN_KEY=$(grep SPREE_ADMIN_KEY catalogue/.env | cut -d= -f2) +SPREE_ADMIN_KEY=sk_ntDFxFE1KwiDwqGzpCBGTJ8N diff --git a/catalogue/backup-pak-jabit.sh b/catalogue/backup-pak-jabit.sh new file mode 100755 index 0000000..7522e32 --- /dev/null +++ b/catalogue/backup-pak-jabit.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Pull a full Pak Jabit backend backup to the house. +# - pg_dump (custom format) of spree_production +# - ActiveStorage volume, rsync'd mirror (~208MB, only deltas after the first run) +# - .env (SECRET_KEY_BASE lives here — without it every signed image URL and +# the admin API token die on restore) +# +# Usage: backup-pak-jabit.sh # normal run +# backup-pak-jabit.sh --verify # also list the dump's table counts +set -euo pipefail + +HOST=ijat@35.247.170.3 +KEY=/root/.ssh/google_compute_engine +SSH=(ssh -i "$KEY" -o ConnectTimeout=15 -o BatchMode=yes) +DEST=/root/Backups/pak-jabit +KEEP=14 # dated dumps to retain +STAMP=$(date +%Y%m%d-%H%M) + +STORAGE_REMOTE=/var/lib/docker/volumes/pak-jabit_storage_data/_data/ +mkdir -p "$DEST"/{db,storage} +chmod 700 "$DEST" + +log() { printf '%s %s\n' "$(date +%H:%M:%S)" "$*"; } + +# --- 1. database ------------------------------------------------------------- +log "pg_dump → db/spree-$STAMP.dump" +"${SSH[@]}" "$HOST" \ + 'docker exec pak-jabit-postgres-1 pg_dump -U postgres -Fc spree_production' \ + > "$DEST/db/spree-$STAMP.dump.part" +mv "$DEST/db/spree-$STAMP.dump.part" "$DEST/db/spree-$STAMP.dump" + +# A truncated dump is the classic silent-success backup. Refuse to keep one. +if ! pg_restore -l "$DEST/db/spree-$STAMP.dump" >/dev/null 2>&1; then + rm -f "$DEST/db/spree-$STAMP.dump" + echo "FATAL: dump failed pg_restore -l — not a usable archive, deleted." >&2 + exit 1 +fi +log " ok $(du -h "$DEST/db/spree-$STAMP.dump" | cut -f1)" + +# --- 2. ActiveStorage -------------------------------------------------------- +log "rsync storage/" +rsync -a --delete --info=stats2 \ + -e "ssh -i $KEY -o BatchMode=yes" \ + --rsync-path='sudo rsync' \ + "$HOST:$STORAGE_REMOTE" "$DEST/storage/" | tail -n 6 + +# --- 3. secrets & compose ---------------------------------------------------- +log "env + compose" +"${SSH[@]}" "$HOST" 'cat ~/pak-jabit/.env' > "$DEST/env-$STAMP" +"${SSH[@]}" "$HOST" 'cat ~/pak-jabit/docker-compose.yml' > "$DEST/docker-compose.yml" +chmod 600 "$DEST"/env-* +ln -sfn "env-$STAMP" "$DEST/env.latest" + +# --- 3b. custom app code ----------------------------------------------------- +# The Spree image is prebuilt and mounts no app code, so every customisation +# (Image Studio, the API body-limit initializer, the patched routes.rb) exists +# ONLY as bind-mounted files on this VM. The database dump does not contain them +# and a rebuilt box would come back without them. +log "custom code" +rsync -a --delete \ + -e "ssh -i $KEY -o BatchMode=yes" \ + --include='config/' --include='config/routes.rb' \ + --include='config/initializers/***' \ + --include='app/' --include='app/controllers/***' --include='app/views/***' \ + --exclude='*' \ + "$HOST:pak-jabit/" "$DEST/custom/" +find "$DEST/custom" -type f | sed "s|$DEST/custom/| |" + +# --- 4. rotate --------------------------------------------------------------- +ls -1t "$DEST"/db/spree-*.dump 2>/dev/null | tail -n +$((KEEP + 1)) | xargs -r rm -v +ls -1t "$DEST"/env-* 2>/dev/null | tail -n +$((KEEP + 1)) | xargs -r rm -v + +if [[ ${1:-} == --verify ]]; then + log "dump contents:" + pg_restore -l "$DEST/db/spree-$STAMP.dump" | grep -c 'TABLE DATA' \ + | xargs printf ' %s tables with data\n' +fi + +log "done → $DEST ($(du -sh "$DEST" | cut -f1) total)" diff --git a/catalogue/bulk_import.py b/catalogue/bulk_import.py new file mode 100644 index 0000000..684d732 --- /dev/null +++ b/catalogue/bulk_import.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +""" +Pak Jabit Store — Bulk Product Import via Spree Admin API v3 + +Creates products, uploads images, publishes to Online Store channel, +and sets stock — all through the official Admin API. + +Usage: + python3 bulk_import.py [--dry-run] [--start N] [--limit N] +""" + +import json +import os +import sys +import time +import requests +import argparse +from pathlib import Path + +import os as _os + + +def _spree_admin_key() -> str: + """The Spree admin API key. Required — never defaulted.""" + key = _os.environ.get("SPREE_ADMIN_KEY", "").strip() + if not key: + raise SystemExit( + "SPREE_ADMIN_KEY is not set.\n" + " export SPREE_ADMIN_KEY=\n" + "It is deliberately not stored in this file — see README.md." + ) + return key + + +# --- Config --- +API_BASE = "https://api.jux-lab.com/api/v3/admin" +API_KEY = _spree_admin_key() +HEADERS = {"X-Spree-Api-Key": API_KEY, "Content-Type": "application/json"} +CHANNEL_ID = "ch_UkLWZg9DAJ" # Online Store +MYR_TO_USD = 4.8 +PUBLISHED_AT = "2026-09-15T00:00:00Z" + +CATALOGUE_DIR = Path("/root/Opus/Projects/PeakyBuildr/scraped-catalogue") +PRODUCTS_DIR = CATALOGUE_DIR / "products" +BATCH_FILES = ["products-batch2.jsonl", "products-batch3.jsonl"] + +LOG_FILE = CATALOGUE_DIR / "import_log.jsonl" + + +def api(method, path, **kwargs): + url = f"{API_BASE}{path}" + headers = dict(HEADERS) + if "files" in kwargs: + headers.pop("Content-Type", None) + r = getattr(requests, method)(url, headers=headers, timeout=60, **kwargs) + try: + return r.json() + except Exception: + return {"error": {"code": r.status_code, "message": r.text[:200]}} + + +def find_product_folder(code, name): + """Match JSONL entry to local image folder.""" + for folder in sorted(PRODUCTS_DIR.iterdir()): + info_file = folder / "info.json" + if info_file.exists(): + info = json.loads(info_file.read_text()) + if info.get("source_code") == code: + return folder + return None + + +def create_product(name, brand, price_myr, slug=None): + price = round(float(price_myr) * MYR_TO_USD, 2) + desc = f"{brand} - {name}" + # A scalar "price" is silently dropped; Spree wants a prices array, and a + # product with no price row is excluded from the storefront's + # available(currency) scope no matter how it is published. + payload = { + "name": name, + "description": desc, + "prices": [{"amount": price, "currency": "USD"}], + "status": "active", + "available_on": "2026-09-15T00:00:00Z", + } + if slug: + payload["slug"] = slug + return api("post", "/products", json=payload) + + +def upload_image(product_id, variant_id, image_path): + with open(image_path, "rb") as f: + return api("post", + f"/products/{product_id}/variants/{variant_id}/media", + files={"attachment": (image_path.name, f, "image/jpeg")}) + + +def publish_to_channel(product_id): + # Without published_at the publication row exists but is not live. + return api("post", + f"/channels/{CHANNEL_ID}/add_products", + json={"product_ids": [product_id], "published_at": PUBLISHED_AT}) + + +def make_purchasable(product_id, variant_id): + return api("patch", f"/products/{product_id}/variants/{variant_id}", + json={"track_inventory": False}) + + +def log_result(entry): + with open(LOG_FILE, "a") as f: + f.write(json.dumps(entry) + "\n") + + +def get_existing_slugs(): + slugs = set() + page = 1 + while True: + data = api("get", f"/products?per_page=100&page={page}") + products = data.get("data", []) + for p in products: + slugs.add(p.get("slug", "")) + meta = data.get("meta", {}) + if page >= meta.get("pages", 1): + break + page += 1 + return slugs + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--start", type=int, default=0) + parser.add_argument("--limit", type=int, default=999) + args = parser.parse_args() + + # Load all products from batch files + products = [] + for batch_file in BATCH_FILES: + path = CATALOGUE_DIR / batch_file + if path.exists(): + with open(path) as f: + for line in f: + products.append(json.loads(line.strip())) + + print(f"Loaded {len(products)} products from {len(BATCH_FILES)} batch files") + + # Get existing to skip duplicates + print("Fetching existing products...") + existing = get_existing_slugs() + print(f" {len(existing)} products already in store") + + # Process + imported = 0 + skipped = 0 + failed = 0 + products_slice = products[args.start:args.start + args.limit] + + for i, prod in enumerate(products_slice): + idx = args.start + i + name = prod["name"] + brand = prod["brand"] + code = prod["code"] + price = prod["price"] + + folder = find_product_folder(code, name) + if not folder: + print(f" [{idx+1}] SKIP (no folder): {brand} - {name}") + skipped += 1 + continue + + # Check slug collision + info = json.loads((folder / "info.json").read_text()) + slug = folder.name + + if slug in existing: + print(f" [{idx+1}] SKIP (exists): {slug}") + skipped += 1 + continue + + if args.dry_run: + images = list(folder.glob("image-*.jpg")) + print(f" [{idx+1}] DRY: {brand} - {name} ({len(images)} imgs, ${float(price)*MYR_TO_USD:.2f})") + continue + + # 1. Create product + result = create_product(name, brand, price, slug) + if "error" in result: + print(f" [{idx+1}] FAIL create: {result['error']}") + log_result({"code": code, "action": "create", "error": result["error"]}) + failed += 1 + continue + + product_id = result["id"] + variant_id = result.get("default_variant_id", "") + print(f" [{idx+1}] Created: {product_id} — {brand} - {name}") + + # 2. Upload images + images = sorted(folder.glob("image-*.jpg")) + img_ok = 0 + for img in images: + r = upload_image(product_id, variant_id, img) + if "error" not in r: + img_ok += 1 + else: + print(f" WARN image {img.name}: {r['error']}") + time.sleep(0.3) + print(f" Images: {img_ok}/{len(images)}") + + # 3. Publish to Online Store + pub = publish_to_channel(product_id) + if "error" in pub: + print(f" WARN publish: {pub['error']}") + + # 4. Make purchasable + stk = make_purchasable(product_id, variant_id) + if "error" in stk: + print(f" WARN stock: {stk['error']}") + + log_result({ + "code": code, "product_id": product_id, "slug": slug, + "brand": brand, "name": name, "images": img_ok, + "price_usd": round(float(price) * MYR_TO_USD, 2) + }) + + imported += 1 + existing.add(slug) + time.sleep(0.5) + + print(f"\nDone! Imported: {imported}, Skipped: {skipped}, Failed: {failed}") + if not args.dry_run and imported > 0: + print(f"Log: {LOG_FILE}") + + +if __name__ == "__main__": + main() diff --git a/catalogue/download.py b/catalogue/download.py new file mode 100644 index 0000000..abe76ec --- /dev/null +++ b/catalogue/download.py @@ -0,0 +1,33 @@ +import json, os, re, urllib.request, time + +base = os.path.dirname(os.path.abspath(__file__)) +out_root = os.path.join(base, "products") +os.makedirs(out_root, exist_ok=True) + +def slugify(name): + s = re.sub(r'[^a-zA-Z0-9]+', '-', name).strip('-').lower() + return s[:60] + +catalogue = [] +with open(os.path.join(base, "products.jsonl")) as f: + for line in f: + line = line.strip() + if not line: + continue + catalogue.append(json.loads(line)) + +for i, p in enumerate(catalogue, 1): + dirname = f"{i:02d}-{slugify(p['brand'])}-{slugify(p['name'])}" + pdir = os.path.join(out_root, dirname) + os.makedirs(pdir, exist_ok=True) + with open(os.path.join(pdir, "info.json"), "w") as f: + json.dump({"name": p["name"], "brand": p["brand"], "price_myr": p["price"], "source_code": p["code"]}, f, indent=2) + for j, url in enumerate(p["imgs"], 1): + dest = os.path.join(pdir, f"image-{j}.jpg") + try: + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req, timeout=15) as r, open(dest, "wb") as out: + out.write(r.read()) + except Exception as e: + print(f"FAIL {url}: {e}") + print(f"[{i}/{len(catalogue)}] {p['brand']} - {p['name']} -> {len(p['imgs'])} imgs") diff --git a/catalogue/download_batches.py b/catalogue/download_batches.py new file mode 100644 index 0000000..d715691 --- /dev/null +++ b/catalogue/download_batches.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Download images for batch2 + batch3 products.""" +import json, os, re, urllib.request, time, sys + +base = os.path.dirname(os.path.abspath(__file__)) +out_root = os.path.join(base, "products") +os.makedirs(out_root, exist_ok=True) + +def slugify(name): + s = re.sub(r'[^a-zA-Z0-9]+', '-', name).strip('-').lower() + return s[:60] + +def load_jsonl(path): + items = [] + with open(path) as f: + for line in f: + line = line.strip() + if line: + items.append(json.loads(line)) + return items + +existing = load_jsonl(os.path.join(base, "products.jsonl")) +offset = len(existing) + +batches = [] +for fname in ["products-batch2.jsonl", "products-batch3.jsonl"]: + path = os.path.join(base, fname) + if os.path.exists(path): + batches.extend(load_jsonl(path)) + +print(f"Existing: {offset} products, new: {len(batches)} to download") + +ok, fail = 0, 0 +for i, p in enumerate(batches, 1): + idx = offset + i + dirname = f"{idx:03d}-{slugify(p['brand'])}-{slugify(p['name'])}" + pdir = os.path.join(out_root, dirname) + os.makedirs(pdir, exist_ok=True) + + info = {"name": p["name"], "brand": p["brand"], "price_myr": p["price"], "source_code": p["code"]} + if "category" in p: + info["category"] = p["category"] + with open(os.path.join(pdir, "info.json"), "w") as f: + json.dump(info, f, indent=2) + + img_ok = 0 + for j, url in enumerate(p["imgs"], 1): + dest = os.path.join(pdir, f"image-{j}.jpg") + if os.path.exists(dest) and os.path.getsize(dest) > 1000: + img_ok += 1 + continue + try: + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"}) + with urllib.request.urlopen(req, timeout=15) as r, open(dest, "wb") as out: + out.write(r.read()) + img_ok += 1 + except Exception as e: + print(f" FAIL {url}: {e}") + fail += 1 + time.sleep(0.2) + + ok += img_ok + print(f"[{i}/{len(batches)}] {p['brand']} - {p['name'][:40]} -> {img_ok}/{len(p['imgs'])} imgs") + +print(f"\nDone: {ok} images downloaded, {fail} failed") diff --git a/catalogue/fix_prices.py b/catalogue/fix_prices.py new file mode 100644 index 0000000..0b4bc39 --- /dev/null +++ b/catalogue/fix_prices.py @@ -0,0 +1,57 @@ +import json, time, requests +from pathlib import Path + +import os as _os + + +def _spree_admin_key() -> str: + """The Spree admin API key. Required — never defaulted.""" + key = _os.environ.get("SPREE_ADMIN_KEY", "").strip() + if not key: + raise SystemExit( + "SPREE_ADMIN_KEY is not set.\n" + " export SPREE_ADMIN_KEY=\n" + "It is deliberately not stored in this file — see README.md." + ) + return key + + +API = "https://api.jux-lab.com/api/v3/admin" +H = {"X-Spree-Api-Key": _spree_admin_key(), "Content-Type": "application/json"} +PRODUCTS = Path("/root/Opus/Projects/PeakyBuildr/scraped-catalogue/products") +MYR_TO_USD = 4.8 + +prices = {} +for folder in PRODUCTS.iterdir(): + info = folder / "info.json" + if info.exists(): + prices[folder.name] = round(float(json.loads(info.read_text())["price_myr"]) * MYR_TO_USD, 2) + +items, page = [], 1 +while True: + d = requests.get(f"{API}/products?per_page=100&page={page}", headers=H, timeout=60).json() + items += d.get("data", []) + if page >= d.get("meta", {}).get("pages", 1): + break + page += 1 + +print(f"{len(items)} products, {len(prices)} source prices") +ok = fail = 0 +for i, p in enumerate(items): + amt = prices.get(p["slug"]) + if amt is None: + print(f" no source price: {p['slug'][:45]}") + fail += 1 + continue + r = requests.patch(f"{API}/products/{p['id']}", headers=H, + json={"prices": [{"amount": amt, "currency": "USD"}]}, timeout=60).json() + if "error" in r: + fail += 1 + print(f" FAIL {p['slug'][:40]}: {r['error'].get('message')}") + else: + ok += 1 + if (i + 1) % 40 == 0: + print(f" {i+1}/{len(items)}") + time.sleep(0.12) + +print(f"Done ok={ok} fail={fail}") diff --git a/catalogue/fix_stock.py b/catalogue/fix_stock.py new file mode 100644 index 0000000..ccb600a --- /dev/null +++ b/catalogue/fix_stock.py @@ -0,0 +1,48 @@ +import json, sys, time, requests + +import os as _os + + +def _spree_admin_key() -> str: + """The Spree admin API key. Required — never defaulted.""" + key = _os.environ.get("SPREE_ADMIN_KEY", "").strip() + if not key: + raise SystemExit( + "SPREE_ADMIN_KEY is not set.\n" + " export SPREE_ADMIN_KEY=\n" + "It is deliberately not stored in this file — see README.md." + ) + return key + + +API = "https://api.jux-lab.com/api/v3/admin" +KEY = _spree_admin_key() +H = {"X-Spree-Api-Key": KEY, "Content-Type": "application/json"} + +products, page = [], 1 +while True: + d = requests.get(f"{API}/products?per_page=100&page={page}", headers=H, timeout=60).json() + products += d.get("data", []) + if page >= d.get("meta", {}).get("pages", 1): + break + page += 1 + +print(f"{len(products)} products") +ok = fail = 0 +for i, p in enumerate(products): + vid = p.get("default_variant_id") + if not vid: + fail += 1 + continue + r = requests.patch(f"{API}/products/{p['id']}/variants/{vid}", headers=H, + json={"count_on_hand": 100, "track_inventory": False}, timeout=60).json() + if "error" in r: + fail += 1 + print(f" FAIL {p['slug'][:40]}: {r['error'].get('message')}") + else: + ok += 1 + if (i + 1) % 25 == 0: + print(f" {i+1}/{len(products)}") + time.sleep(0.15) + +print(f"Done. ok={ok} fail={fail}") diff --git a/catalogue/import_log.jsonl b/catalogue/import_log.jsonl new file mode 100644 index 0000000..04f266f --- /dev/null +++ b/catalogue/import_log.jsonl @@ -0,0 +1,164 @@ +{"code": "hm-3377-7607064", "product_id": "prod_rsClYtECFI", "slug": "031-h-m-oversized-fit-scuba-zip-through-hoodie", "brand": "H&M", "name": "Oversized Fit Scuba zip-through hoodie", "images": 5, "price_usd": 719.52} +{"code": "urban-revivo-3653-8941654", "product_id": "prod_8DR0I7fEbw", "slug": "032-urban-revivo-casual-crew-neck-short-sleeve-tee", "brand": "Urban Revivo", "name": "Casual Crew Neck Short Sleeve Tee", "images": 6, "price_usd": 571.2} +{"code": "urban-revivo-4852-8181854", "product_id": "prod_aAtNvLSUAr", "slug": "033-urban-revivo-short-sleeve-shirts", "brand": "Urban Revivo", "name": "Short Sleeve Shirts", "images": 7, "price_usd": 557.28} +{"code": "nike-4039-6920264", "product_id": "prod_QiA48cIW7A", "slug": "034-nike-training-t-shirt", "brand": "Nike", "name": "Training T-Shirt", "images": 1, "price_usd": 706.8} +{"code": "hm-8811-9083444", "product_id": "prod_QDA48cIW7A", "slug": "035-h-m-regular-fit-track-pants-with-drymove", "brand": "H&M", "name": "Regular Fit Track pants with DryMove\u2122", "images": 8, "price_usd": 719.52} +{"code": "trendyol-6397-0057634", "product_id": "prod_8cR0I7fEbw", "slug": "036-trendyol-beige-oversize-wide-cut-long-sleeve-animal-embroidered-sweat", "brand": "Trendyol", "name": "Beige Oversize/Wide Cut Long Sleeve Animal Embroidered Sweatshirt TMNAW24SW00164", "images": 5, "price_usd": 269.04} +{"code": "trendyol-9722-5541154", "product_id": "prod_oqlrKdXjaP", "slug": "037-trendyol-horse-embroidered-sweatshirt", "brand": "Trendyol", "name": "Horse Embroidered Sweatshirt", "images": 1, "price_usd": 264.96} +{"code": "hm-1602-9539354", "product_id": "prod_48Olu6p4zZ", "slug": "038-h-m-relaxed-fit-resort-shirt", "brand": "H&M", "name": "Relaxed Fit Resort shirt", "images": 6, "price_usd": 479.52} +{"code": "trendyol-6524-2362464", "product_id": "prod_qp31JYFduR", "slug": "039-trendyol-anthracite-001-relaxed-mock-neck-plain-sweater-tmnaw26kz0001", "brand": "Trendyol", "name": "Anthracite*001 Relaxed Mock Neck Plain Sweater Tmnaw26Kz00013", "images": 5, "price_usd": 405.6} +{"code": "giordano-7623-8198654", "product_id": "prod_JgaEBgznCp", "slug": "040-giordano-men-s-cotton-short-sleeve-slim-tee-01026250", "brand": "Giordano", "name": "Men's Cotton Short Sleeve Slim Tee 01026250", "images": 2, "price_usd": 178.42} +{"code": "hm-4373-6317064", "product_id": "prod_tXyJwvFCuK", "slug": "041-h-m-ultra-baggy-jeans", "brand": "H&M", "name": "Ultra Baggy Jeans", "images": 5, "price_usd": 719.52} +{"code": "hm-0024-5243544", "product_id": "prod_NJzXvxTk8u", "slug": "042-h-m-relaxed-jeans", "brand": "H&M", "name": "Relaxed Jeans", "images": 4, "price_usd": 623.52} +{"code": "ysocool-brand-7905-5999931", "product_id": "prod_XLbb5HTmVa", "slug": "043-ysocool-men-compression-body-shaper-vests-top", "brand": "YSoCool", "name": "Men Compression Body Shaper Vests Top", "images": 3, "price_usd": 223.44} +{"code": "nike-8330-6036864", "product_id": "prod_k2KT9MVfb0", "slug": "044-nike-dri-fit-training-t-shirt", "brand": "Nike", "name": "Dri-FIT Training T-Shirt", "images": 1, "price_usd": 542.64} +{"code": "hm-8820-5290854", "product_id": "prod_Z5MUPd7wVj", "slug": "045-h-m-loose-fit-t-shirt", "brand": "H&M", "name": "Loose Fit T-shirt", "images": 4, "price_usd": 239.52} +{"code": "hm-6806-1878823", "product_id": "prod_STIszOMIvc", "slug": "046-h-m-slim-fit-t-shirt", "brand": "H&M", "name": "Slim Fit T-shirt", "images": 5, "price_usd": 119.52} +{"code": "hm-6462-8250054", "product_id": "prod_LYpHOn63Vw", "slug": "047-h-m-relaxed-fit-textured-resort-shirt", "brand": "H&M", "name": "Relaxed Fit Textured resort shirt", "images": 4, "price_usd": 479.52} +{"code": "trendyol-3134-8272734", "product_id": "prod_Kj5Q8YFGzd", "slug": "048-trendyol-black-regular-fit-100-viscose-loose-and-loose-summer-shirt-t", "brand": "Trendyol", "name": "Black Regular Fit 100% Viscose Loose and Loose Summer Shirt TMNSS25GO00048", "images": 1, "price_usd": 433.44} +{"code": "altinyildiz-8185-0860924", "product_id": "prod_hDBvQ85Ws9", "slug": "049-altinyildiz-basic-slim-fit-t-shirt", "brand": "Altinyildiz", "name": "Basic Slim Fit T-Shirt", "images": 1, "price_usd": 250.37} +{"code": "nike-0596-4602954", "product_id": "prod_IOvBlcSU2T", "slug": "050-nike-palm-tree-men-s-tee", "brand": "Nike", "name": "Palm Tree Men's Tee", "images": 1, "price_usd": 446.4} +{"code": "lacoste-6939-3474274", "product_id": "prod_K95Q8YFGzd", "slug": "051-lacoste-monogram-jacquard-t-shirt", "brand": "Lacoste", "name": "Monogram Jacquard T-shirt", "images": 4, "price_usd": 2587.2} +{"code": "hm-2842-2711854", "product_id": "prod_iIDfaj7G4u", "slug": "052-h-m-relaxed-fit-cotton-chinos", "brand": "H&M", "name": "Relaxed Fit Cotton chinos", "images": 2, "price_usd": 623.52} +{"code": "urban-revivo-8524-3567374", "product_id": "prod_6AgxZbenF4", "slug": "053-urban-revivo-classic-solid-color-thick-shirt-in-viscose-blend", "brand": "Urban Revivo", "name": "Classic Solid Color Thick Shirt in Viscose Blend", "images": 8, "price_usd": 907.44} +{"code": "hm-4144-4577954", "product_id": "prod_Aks1igzRC6", "slug": "054-h-m-relaxed-fit-oxford-shirt", "brand": "H&M", "name": "Relaxed Fit Oxford shirt", "images": 5, "price_usd": 623.52} +{"code": "trendyol-7335-7898364", "product_id": "prod_lmiGAYKIUz", "slug": "055-trendyol-black-regular-normal-cut-hooded-textured-sweatshirt-tmnaw26s", "brand": "Trendyol", "name": "Black Regular/Normal Cut Hooded Textured Sweatshirt Tmnaw26Sw00068", "images": 3, "price_usd": 379.34} +{"code": "trendyol-7769-9773614", "product_id": "prod_yar3EWPH85", "slug": "056-trendyol-striped-knit-polo-shirt", "brand": "Trendyol", "name": "Striped Knit Polo Shirt", "images": 4, "price_usd": 410.5} +{"code": "hm-4087-3660754", "product_id": "prod_zNcwP3ZbDr", "slug": "057-h-m-regular-fit-hole-knit-sweater-vest", "brand": "H&M", "name": "Regular Fit Hole-knit sweater vest", "images": 6, "price_usd": 671.52} +{"code": "hm-9694-6720944", "product_id": "prod_ccuasQi8rC", "slug": "058-h-m-easy-care-relaxed-fit-linen-blend-shorts", "brand": "H&M", "name": "Easy-care Relaxed Fit Linen-blend shorts", "images": 4, "price_usd": 719.52} +{"code": "hm-9884-9573454", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "trendyol-1466-7949634", "product_id": "prod_bnnZn4Y5Fq", "slug": "060-trendyol-brown-oversize-wide-cut-buttoned-polo-collar-thick-polar-fle", "brand": "Trendyol", "name": "Brown Oversize/Wide Cut Buttoned Polo Collar Thick Polar Fleece Sweatshirt TMNAW24SW00107", "images": 1, "price_usd": 396.14} +{"code": "ben-sherman-1585-5545954", "product_id": "prod_UkKLWZg9DA", "slug": "061-ben-sherman-short-sleeves-jacquard-polo-shirt", "brand": "Ben Sherman", "name": "Short Sleeves Jacquard Polo Shirt", "images": 1, "price_usd": 1500.24} +{"code": "hm-9884-9573454", "product_id": "prod_UkpLWZg9DA", "slug": "059-h-m-oversized-fit-t-shirt", "brand": "H&M", "name": "Oversized Fit T-shirt", "images": 6, "price_usd": 335.52} +{"code": "trendyol-1466-7949634", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "ben-sherman-1585-5545954", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "hm-3289-5938864", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "hm-3289-5938864", "product_id": "prod_gXZHJdmfrX", "slug": "062-h-m-relaxed-fit-sweatshirt", "brand": "H&M", "name": "Relaxed Fit Sweatshirt", "images": 2, "price_usd": 287.52} +{"code": "hm-7368-5259764", "product_id": "prod_Ej9hxLZ9ck", "slug": "063-h-m-regular-fit-easy-iron-shirt", "brand": "H&M", "name": "Regular Fit Easy-iron shirt", "images": 2, "price_usd": 383.52} +{"code": "hm-7368-5259764", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "hm-0914-3034864", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "hm-0914-3034864", "product_id": "prod_VZ3XmZF31w", "slug": "064-h-m-polo-shirt", "brand": "H&M", "name": "Polo shirt", "images": 2, "price_usd": 479.52} +{"code": "adidas-0957-2958274", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "tommy-hilfiger-6431-9934964", "product_id": "prod_OtvJLhNcSb", "slug": "066-tommy-hilfiger-linear-logo-slim-crew-neck-t-shirt-tommy-jeans", "brand": "Tommy Hilfiger", "name": "Linear Logo Slim Crew Neck T-Shirt - Tommy Jeans", "images": 1, "price_usd": 725.04} +{"code": "adidas-0957-2958274", "product_id": "prod_uEf2YK1rnl", "slug": "065-adidas-liverpool-fc-26-27-third-jersey", "brand": "ADIDAS", "name": "Liverpool Fc 26/27 Third Jersey", "images": 4, "price_usd": 1579.2} +{"code": "tommy-hilfiger-6431-9934964", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "gap-2969-6099274", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "gap-2969-6099274", "product_id": "prod_ARbs1igzRC", "slug": "067-gap-logo-t-shirt", "brand": "GAP", "name": "Logo T-Shirt", "images": 1, "price_usd": 479.95} +{"code": "gap-0710-4300374", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "gap-0710-4300374", "product_id": "prod_p5gklVeMgb", "slug": "068-gap-logo-t-shirt", "brand": "GAP", "name": "Logo T-Shirt", "images": 1, "price_usd": 479.95} +{"code": "hm-0924-7104664", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "adidas-9961-7990474", "product_id": "prod_9URFqgRua4", "slug": "070-adidas-liverpool-fc-26-27-home-fan-jersey", "brand": "ADIDAS", "name": "Liverpool FC 26/27 Home Fan Jersey", "images": 1, "price_usd": 451.44} +{"code": "gap-3366-0689274", "product_id": "prod_xiBoCpeNMW", "slug": "071-gap-pique-polo-shirt", "brand": "GAP", "name": "Pique Polo Shirt", "images": 1, "price_usd": 647.14} +{"code": "hm-0924-7104664", "product_id": "prod_nWaqfPa3Cp", "slug": "069-h-m-loose-fit-interlock-t-shirt", "brand": "H&M", "name": "Loose Fit Interlock T-shirt", "images": 6, "price_usd": 239.52} +{"code": "adidas-9961-7990474", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "gap-3366-0689274", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "adidas-6543-9526964", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "adidas-6543-9526964", "product_id": "prod_vohmzDoNU0", "slug": "072-adidas-mercedes-amg-petronas-formula-1-team-miami-tee", "brand": "ADIDAS", "name": "Mercedes - AMG Petronas Formula 1 Team Miami Tee", "images": 1, "price_usd": 679.44} +{"code": "giordano-8229-4161474", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "giordano-8229-4161474", "product_id": "prod_2h5Y5XM6fr", "slug": "073-giordano-giordano-by-poly-pac-20-24-28-fury-unbreakable-pp-expandable", "brand": "Giordano", "name": "Giordano by Poly Pac 20'' 24\" 28'' Fury UNBREAKABLE PP Expandable Travel Luggage - GA2620 - Black", "images": 6, "price_usd": 3835.2} +{"code": "adidas-9235-1679564", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "adidas-9235-1679564", "product_id": "prod_HbJVtlDrOm", "slug": "074-adidas-vl-court-base-shoes", "brand": "ADIDAS", "name": "VL Court Base Shoes", "images": 8, "price_usd": 745.92} +{"code": "hm-3158-4937164", "product_id": "prod_eH46l8wVqS", "slug": "075-h-m-relaxed-fit-zip-top-rib-knit-jumper", "brand": "H&M", "name": "Relaxed Fit Zip-top rib-knit jumper", "images": 1, "price_usd": 719.52} +{"code": "hm-3158-4937164", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "hm-5561-3429464", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "mango-man-6546-3612374", "product_id": "prod_GpMUcnUYI5", "slug": "077-mango-man-fine-knit-cotton-blend-sweater", "brand": "MANGO Man", "name": "Fine Knit Cotton-Blend Sweater", "images": 1, "price_usd": 959.52} +{"code": "hm-5561-3429464", "product_id": "prod_se78jaGony", "slug": "076-h-m-loose-fit-scuba-zip-through-hoodie", "brand": "H&M", "name": "Loose Fit Scuba Zip-through hoodie", "images": 2, "price_usd": 719.52} +{"code": "mango-man-6546-3612374", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "adidas-3065-8268274", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "adidas-3065-8268274", "product_id": "prod_RgJeNhrwyV", "slug": "078-adidas-trefoil-cushion-crew-socks-3-pairs", "brand": "ADIDAS", "name": "Trefoil Cushion Crew Socks 3 Pairs", "images": 1, "price_usd": 314.64} +{"code": "mango-man-5487-0122374", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "mango-man-5487-0122374", "product_id": "prod_LKWpHOn63V", "slug": "079-mango-man-fine-knit-sweater-with-zip", "brand": "MANGO Man", "name": "Fine-Knit Sweater With Zip", "images": 1, "price_usd": 1055.52} +{"code": "hm-6071-6565464", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "harley-davidson-6140-0807274", "product_id": "prod_CvFSrJgpzQ", "slug": "081-harley-davidson-h-d-tech-vented-short-sleeve-shirt", "brand": "Harley-Davidson", "name": "H-D\u00ae TECH Vented Short Sleeve Shirt", "images": 3, "price_usd": 2409.6} +{"code": "hm-6071-6565464", "product_id": "prod_PcANMcnNFW", "slug": "080-h-m-10-pack-mid-trunks-with-lycra", "brand": "H&M", "name": "10-pack mid trunks with Lycra\u00ae", "images": 5, "price_usd": 959.52} +{"code": "harley-davidson-6140-0807274", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "harley-davidson-6555-0017274", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "mango-man-6179-9132374", "product_id": "prod_5sJQZnCRf8", "slug": "083-mango-man-fine-knit-cotton-blend-sweater", "brand": "MANGO Man", "name": "Fine Knit Cotton-Blend Sweater", "images": 1, "price_usd": 959.52} +{"code": "harley-davidson-6555-0017274", "product_id": "prod_YBVETxCGlb", "slug": "082-harley-davidson-harley-davidson-vintage-drop-h-ringer-tee", "brand": "Harley-Davidson", "name": "Harley-Davidson\u00ae Vintage Drop H Ringer Tee", "images": 3, "price_usd": 993.6} +{"code": "mango-man-6179-9132374", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "hm-3272-3490754", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "hm-3272-3490754", "product_id": "prod_FLTGX8NaP3", "slug": "084-h-m-regular-jeans", "brand": "H&M", "name": "Regular Jeans", "images": 3, "price_usd": 479.52} +{"code": "levi-s-8540-2089554", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "levi-s-8540-2089554", "product_id": "prod_kQJKT9MVfb", "slug": "085-levi-s-levi-s-men-s-505-regular-jeans-00505-3166", "brand": "Levi's", "name": "Levi's\u00ae Men's 505\u2122 Regular Jeans 00505-3166", "images": 5, "price_usd": 879.36} +{"code": "volkswagen-4831-1155093", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "cotton-on-6837-1419164", "product_id": "prod_DzgPsx31Wa", "slug": "087-cotton-on-baggy-jeans", "brand": "Cotton On", "name": "Baggy Jeans", "images": 1, "price_usd": 822.86} +{"code": "volkswagen-4831-1155093", "product_id": "prod_3T2WpPTcf5", "slug": "086-volkswagen-slim-long-jeans", "brand": "Volkswagen", "name": "Slim Long Jeans", "images": 4, "price_usd": 763.2} +{"code": "cotton-on-6837-1419164", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "levi-s-4894-3133664", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "levi-s-4894-3133664", "product_id": "prod_ml8x3nOJap", "slug": "088-levi-s-levi-s-men-s-505-regular-jeans-00505-3276", "brand": "Levi's", "name": "Levi's\u00ae Men's 505\u2122 Regular Jeans 00505-3276", "images": 3, "price_usd": 879.36} +{"code": "volkswagen-4840-0055093", "product_id": "prod_5sSQZnCRf8", "slug": "089-volkswagen-straight-long-jeans", "brand": "Volkswagen", "name": "Straight Long Jeans", "images": 2, "price_usd": 763.2} +{"code": "volkswagen-4840-0055093", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "connor-3964-1987644", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "connor-3964-1987644", "product_id": "prod_M3YnyloHRU", "slug": "090-connor-toronto-straight-jean", "brand": "Connor", "name": "Toronto Straight Jean", "images": 3, "price_usd": 690.77} +{"code": "levi-s-8200-6615651", "product_id": "prod_p5CklVeMgb", "slug": "091-levi-s-511-slim-fit-jeans-04511-1907", "brand": "Levi's", "name": "511 Slim Fit Jeans 04511-1907", "images": 2, "price_usd": 1071.36} +{"code": "levi-s-8200-6615651", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "fidelio-1184-7387944", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "fidelio-1184-7387944", "product_id": "prod_sel8jaGony", "slug": "092-fidelio-601-slim-fit-washed-denim-jeans", "brand": "FIDELIO", "name": "601 Slim Fit Washed Denim Jeans", "images": 3, "price_usd": 523.2} +{"code": "levi-s-9015-6656924", "action": "create", "error": {"code": "validation_error", "message": "Slug has already been taken", "details": {"slug": ["has already been taken"]}}} +{"code": "drum-3906-4664754", "product_id": "prod_86PR0I7fEb", "slug": "094-drum-drum-casual-skinny-to-slim-fit-jeans-dark-blue", "brand": "DRUM", "name": "DRUM Casual Skinny to Slim Fit Jeans- Dark Blue", "images": 2, "price_usd": 479.52} +{"code": "connor-3962-2987644", "product_id": "prod_arstNvLSUA", "slug": "095-connor-arran-straight-jean", "brand": "Connor", "name": "Arran Straight Jean", "images": 5, "price_usd": 690.77} +{"code": "trendyol-3864-8297064", "product_id": "prod_Q8DA48cIW7", "slug": "096-trendyol-essential-fit-jeans-denim-pants", "brand": "Trendyol", "name": "Essential Fit Jeans Denim Pants", "images": 1, "price_usd": 520.46} +{"code": "electro-denim-lab-7932-5806954", "product_id": "prod_Q8PA48cIW7", "slug": "097-electro-denim-lab-dub-relaxed-fit-jeans", "brand": "Electro Denim Lab", "name": "Dub Relaxed Fit Jeans", "images": 5, "price_usd": 374.4} +{"code": "drum-5753-5393434", "product_id": "prod_86uR0I7fEb", "slug": "098-drum-drum-digital-print-denim-straight-cut-jeans-brown", "brand": "DRUM", "name": "DRUM Digital Print Denim Straight Cut Jeans- Brown", "images": 4, "price_usd": 671.52} +{"code": "trendyol-4181-4687064", "product_id": "prod_oD3lrKdXja", "slug": "099-trendyol-slim-fit-stretch-fabric-denim-pants-jeans", "brand": "Trendyol", "name": "Slim Fit Stretch Fabric Denim Pants Jeans", "images": 1, "price_usd": 702.24} +{"code": "urban-revivo-4820-1281854", "product_id": "prod_4aROlu6p4z", "slug": "100-urban-revivo-denim-shorts", "brand": "Urban Revivo", "name": "Denim Shorts", "images": 6, "price_usd": 431.57} +{"code": "hm-2256-5380854", "product_id": "prod_q0k31JYFdu", "slug": "101-h-m-2-pack-regular-fit-shorts", "brand": "H&M", "name": "2-pack Regular Fit Shorts", "images": 2, "price_usd": 479.52} +{"code": "hm-5374-3237954", "product_id": "prod_JyHaEBgznC", "slug": "102-h-m-regular-fit-linen-blend-shorts", "brand": "H&M", "name": "Regular Fit Linen-blend shorts", "images": 2, "price_usd": 479.52} +{"code": "cotton-on-4000-9099341", "product_id": "prod_tCbyJwvFCu", "slug": "103-cotton-on-easy-shorts", "brand": "Cotton On", "name": "Easy Shorts", "images": 1, "price_usd": 588.24} +{"code": "connor-3932-5487644", "product_id": "prod_NuazXvxTk8", "slug": "104-connor-darwin-chino-stretch-short", "brand": "Connor", "name": "Darwin Chino Stretch Short", "images": 3, "price_usd": 561.17} +{"code": "fidelio-4233-8849254", "product_id": "prod_XMpbb5HTmV", "slug": "105-fidelio-chino-super-shorts", "brand": "FIDELIO", "name": "Chino Super Shorts", "images": 5, "price_usd": 427.2} +{"code": "cotton-on-5986-9744864", "product_id": "prod_kQYKT9MVfb", "slug": "106-cotton-on-easy-active-shorts", "brand": "Cotton On", "name": "Easy Active Shorts", "images": 1, "price_usd": 428.54} +{"code": "cotton-on-6085-0724713", "product_id": "prod_ZqQMUPd7wV", "slug": "107-cotton-on-stretch-swim-shorts", "brand": "Cotton On", "name": "Stretch Swim Shorts", "images": 7, "price_usd": 588.24} +{"code": "under-armour-0166-9009864", "product_id": "prod_Sm1IszOMIv", "slug": "108-under-armour-challenger-core-men-s-7-5-shorts", "brand": "Under Armour", "name": "Challenger Core Men's 7.5\" Shorts", "images": 1, "price_usd": 360.24} +{"code": "cotton-on-1627-2412493", "product_id": "prod_LKEpHOn63V", "slug": "109-cotton-on-everyday-fleece-shorts", "brand": "Cotton On", "name": "Everyday Fleece Shorts", "images": 2, "price_usd": 433.44} +{"code": "connor-3936-8387644", "product_id": "prod_KAw5Q8YFGz", "slug": "110-connor-jethro-cargo-short", "brand": "Connor", "name": "Jethro Cargo Short", "images": 1, "price_usd": 561.17} +{"code": "nike-9182-7836064", "product_id": "prod_hwPBvQ85Ws", "slug": "111-nike-miler-dri-fit-5-brief-lined-running-shorts", "brand": "Nike", "name": "Miler Dri-FIT 5\" Brief-Lined Running Shorts", "images": 5, "price_usd": 615.6} +{"code": "electro-denim-lab-8284-8755973", "product_id": "prod_IxJvBlcSU2", "slug": "112-electro-denim-lab-tailored-shorts", "brand": "Electro Denim Lab", "name": "Tailored Shorts", "images": 4, "price_usd": 345.6} +{"code": "electro-denim-lab-5883-6755973", "product_id": "prod_KAF5Q8YFGz", "slug": "113-electro-denim-lab-tailored-shorts", "brand": "Electro Denim Lab", "name": "Tailored Shorts", "images": 3, "price_usd": 345.6} +{"code": "under-armour-0103-4898864", "product_id": "prod_iNvDfaj7G4", "slug": "114-under-armour-vanish-woven-2-0-men-s-8-shorts", "brand": "Under Armour", "name": "Vanish Woven 2.0 Men's 8\" Shorts", "images": 2, "price_usd": 668.64} +{"code": "under-armour-9965-8909864", "product_id": "prod_6VsgxZbenF", "slug": "115-under-armour-vanish-woven-2-0-men-s-6-shorts", "brand": "Under Armour", "name": "Vanish Woven 2.0 Men's 6\" Shorts", "images": 2, "price_usd": 668.64} +{"code": "hm-4916-5208854", "product_id": "prod_ARKs1igzRC", "slug": "116-h-m-oversized-fit-printed-boxy-vest-top", "brand": "H&M", "name": "Oversized Fit Printed boxy vest top", "images": 2, "price_usd": 383.52} +{"code": "hm-3300-2208854", "product_id": "prod_lPxiGAYKIU", "slug": "117-h-m-loose-fit-printed-vest-top", "brand": "H&M", "name": "Loose Fit Printed vest top", "images": 2, "price_usd": 143.52} +{"code": "hm-0791-0454064", "product_id": "prod_yStr3EWPH8", "slug": "118-h-m-regular-fit-cotton-vest-top", "brand": "H&M", "name": "Regular Fit Cotton vest top", "images": 3, "price_usd": 239.52} +{"code": "jack-jones-5952-7458954", "product_id": "prod_z2zcwP3ZbD", "slug": "119-jack-jones-charge-bomber-jacket", "brand": "Jack & Jones", "name": "Charge Bomber Jacket", "images": 1, "price_usd": 673.63} +{"code": "jordan-9145-3516754", "product_id": "prod_cE2uasQi8r", "slug": "120-jordan-brooklyn-tricot-track-jacket", "brand": "Jordan", "name": "Brooklyn Tricot Track Jacket", "images": 4, "price_usd": 861.12} +{"code": "under-armour-5681-1265724", "product_id": "prod_UKkLWZg9DA", "slug": "121-under-armour-meridian-zip-jacket", "brand": "Under Armour", "name": "Meridian \u00bc Zip Jacket", "images": 1, "price_usd": 861.6} +{"code": "desigual-1923-6483534", "product_id": "prod_bqqZn4Y5Fq", "slug": "122-desigual-contrast-collar-jacket", "brand": "Desigual", "name": "Contrast collar jacket", "images": 3, "price_usd": 2373.6} +{"code": "mango-man-1932-0456174", "product_id": "prod_UK5LWZg9DA", "slug": "123-mango-man-denim-overshirt-with-pockets", "brand": "MANGO Man", "name": "Denim Overshirt With Pockets", "images": 7, "price_usd": 1727.52} +{"code": "nike-3433-7819904", "product_id": "prod_gbMHJdmfrX", "slug": "124-nike-stride-repel-uv-running-jacket", "brand": "Nike", "name": "Stride Repel UV Running Jacket", "images": 1, "price_usd": 1773.84} +{"code": "fitleasure-6107-0154414", "product_id": "prod_EwFhxLZ9ck", "slug": "125-fitleasure-men-s-navy-training-track-jacket", "brand": "Fitleasure", "name": "Men's Navy Training Track Jacket", "images": 3, "price_usd": 331.2} +{"code": "mango-man-7595-9855154", "product_id": "prod_VMWXmZF31w", "slug": "126-mango-man-wool-overshirt-with-buttons", "brand": "MANGO Man", "name": "Wool Overshirt with Buttons", "images": 1, "price_usd": 1919.52} +{"code": "fitleasure-7254-8558544", "product_id": "prod_uh92YK1rnl", "slug": "127-fitleasure-ultraflex-men-s-premium-grey-athleisure-blazer", "brand": "Fitleasure", "name": "UltraFlex Men\u2019s Premium Grey Athleisure Blazer", "images": 6, "price_usd": 448.8} +{"code": "tommy-hilfiger-3102-7968464", "product_id": "prod_OymJLhNcSb", "slug": "128-tommy-hilfiger-washed-flag-embroidery-denim-chore-jacket-th-mainline", "brand": "Tommy Hilfiger", "name": "Washed Flag Embroidery Denim Chore Jacket - Th Mainline", "images": 1, "price_usd": 2292.48} +{"code": "adidas-6064-9829864", "product_id": "prod_AeZs1igzRC", "slug": "129-adidas-lifestyle-barreda-shoes-men-white-jp5928", "brand": "ADIDAS", "name": "Lifestyle Barreda Shoes Men White JP5928", "images": 1, "price_usd": 803.52} +{"code": "adidas-7467-6165014", "product_id": "prod_pQHklVeMgb", "slug": "130-adidas-runfalcon-5-running-shoes", "brand": "ADIDAS", "name": "Runfalcon 5 Running Shoes", "images": 1, "price_usd": 717.12} +{"code": "adidas-3835-1386964", "product_id": "prod_n0tqfPa3Cp", "slug": "131-adidas-lifestyle-vl-court-3-0-shoes-men-white-jr8635", "brand": "ADIDAS", "name": "Lifestyle VL Court 3.0 Shoes Men White JR8635", "images": 4, "price_usd": 947.52} +{"code": "adidas-3902-2344624", "product_id": "prod_9LeFqgRua4", "slug": "132-adidas-adizero-evo-sl-shoes", "brand": "ADIDAS", "name": "Adizero EVO SL Shoes", "images": 3, "price_usd": 3211.2} +{"code": "new-balance-7276-4452864", "product_id": "prod_xDjoCpeNMW", "slug": "133-new-balance-fuelcell-rebel-v5-running-shoes", "brand": "New Balance", "name": "FuelCell Rebel v5 Running Shoes", "images": 1, "price_usd": 2180.64} +{"code": "nike-8718-6589064", "product_id": "prod_vlBmzDoNU0", "slug": "134-nike-air-monarch-iv-workout-shoes", "brand": "Nike", "name": "Air Monarch IV Workout Shoes", "images": 2, "price_usd": 825.6} +{"code": "under-armour-5894-7391454", "product_id": "prod_2BQY5XM6fr", "slug": "135-under-armour-halo-runner-se-men-s-running-shoes", "brand": "Under Armour", "name": "Halo Runner SE Men's Running Shoes", "images": 5, "price_usd": 1725.12} +{"code": "new-balance-0654-0936864", "product_id": "prod_HZaVtlDrOm", "slug": "136-new-balance-603-lifestyle-shoes", "brand": "New Balance", "name": "603 Lifestyle Shoes", "images": 1, "price_usd": 1163.81} +{"code": "hm-1277-2755944", "product_id": "prod_eVO6l8wVqS", "slug": "137-h-m-canvas-espadrilles", "brand": "H&M", "name": "Canvas espadrilles", "images": 3, "price_usd": 383.52} +{"code": "adidas-7643-4564454", "product_id": "prod_s6d8jaGony", "slug": "138-adidas-handball-spezial-shoes", "brand": "ADIDAS", "name": "Handball Spezial Shoes", "images": 1, "price_usd": 1437.12} +{"code": "puma-7967-0821193", "product_id": "prod_GknUcnUYI5", "slug": "139-puma-puma-shuffle", "brand": "PUMA", "name": "Puma Shuffle", "images": 3, "price_usd": 971.04} +{"code": "veja-7352-9893832", "product_id": "prod_RHaeNhrwyV", "slug": "140-veja-campo-chromefree-sneakers", "brand": "Veja", "name": "Campo Chromefree Sneakers", "images": 1, "price_usd": 3600.0} +{"code": "new-balance-2615-5260964", "product_id": "prod_L50pHOn63V", "slug": "141-new-balance-530-lifestyle-shoes", "brand": "New Balance", "name": "530 Lifestyle Shoes", "images": 1, "price_usd": 1569.46} +{"code": "tommy-hilfiger-6391-3924164", "product_id": "prod_PusNMcnNFW", "slug": "142-tommy-hilfiger-the-greenwich-edge-leather-sneakers-tommy-jeans", "brand": "Tommy Hilfiger", "name": "The Greenwich Edge Leather Sneakers - Tommy Jeans", "images": 2, "price_usd": 1615.68} +{"code": "veja-6434-3897744", "product_id": "prod_CmGSrJgpzQ", "slug": "143-veja-campo-chromefree-sneakers", "brand": "Veja", "name": "Campo Chromefree Sneakers", "images": 2, "price_usd": 3600.0} +{"code": "veja-7346-2897744", "product_id": "prod_YjXETxCGlb", "slug": "144-veja-volley-o-t-leather-sneakers", "brand": "Veja", "name": "Volley O.T. Leather Sneakers", "images": 1, "price_usd": 3480.0} +{"code": "new-balance-8119-1352864", "product_id": "prod_58aQZnCRf8", "slug": "145-new-balance-2010-lifestyle-shoes", "brand": "New Balance", "name": "2010 Lifestyle Shoes", "images": 1, "price_usd": 2128.32} +{"code": "new-balance-8362-1136064", "product_id": "prod_Fp1GX8NaP3", "slug": "146-new-balance-9060-lifestyle-shoes", "brand": "New Balance", "name": "9060 Lifestyle Shoes", "images": 5, "price_usd": 2106.38} +{"code": "hm-3543-9075264", "product_id": "prod_kAaKT9MVfb", "slug": "147-h-m-chelsea-boots", "brand": "H&M", "name": "Chelsea boots", "images": 3, "price_usd": 719.52} +{"code": "dr-cardin-3393-7528864", "product_id": "prod_31YWpPTcf5", "slug": "148-dr-cardin-dr-cardin-men-pillow-foam-faux-leather-chelsea-boot-shoe-tbp", "brand": "Dr. Cardin", "name": "Dr Cardin Men Pillow Foam Faux Leather Chelsea Boot Shoe TBP-6855", "images": 7, "price_usd": 420.34} +{"code": "hunter-boots-3275-8311344", "product_id": "prod_DcHPsx31Wa", "slug": "149-hunter-boots-men-s-play-short-boots", "brand": "Hunter Boots", "name": "MEN'S PLAY SHORT BOOTS", "images": 6, "price_usd": 2731.2} +{"code": "mango-man-1327-3466174", "product_id": "prod_miRx3nOJap", "slug": "150-mango-man-suede-leather-ankle-boots", "brand": "MANGO Man", "name": "Suede Leather Ankle Boots", "images": 1, "price_usd": 2303.52} +{"code": "clarks-5776-2553954", "product_id": "prod_58IQZnCRf8", "slug": "151-clarks-mens-wallabee-shoes-blue-green-stripe", "brand": "CLARKS", "name": "Mens Wallabee Shoes - Blue Green Stripe", "images": 2, "price_usd": 3643.2} +{"code": "clarks-2605-0299864", "product_id": "prod_MWEnyloHRU", "slug": "152-clarks-mens-paulson-up-boots-black-leather", "brand": "CLARKS", "name": "Mens Paulson Up Boots - Black Leather", "images": 6, "price_usd": 2395.2} +{"code": "hm-5557-0718954", "product_id": "prod_pQSklVeMgb", "slug": "153-h-m-regular-fit-merino-wool-jumper", "brand": "H&M", "name": "Regular Fit Merino wool jumper", "images": 5, "price_usd": 959.52} +{"code": "hm-3731-3617164", "product_id": "prod_s6i8jaGony", "slug": "154-h-m-regular-fit-rib-knit-cotton-jumper", "brand": "H&M", "name": "Regular Fit Rib-knit cotton jumper", "images": 4, "price_usd": 671.52} +{"code": "harley-davidson-0026-6212544", "product_id": "prod_rvRClYtECF", "slug": "155-harley-davidson-h-d-realtree-apg-pullover-hoodie", "brand": "Harley-Davidson", "name": "H-D\u00ae Realtree\u00ae APG Pullover Hoodie", "images": 4, "price_usd": 1881.6} +{"code": "gap-6704-2923414", "product_id": "prod_8gNR0I7fEb", "slug": "156-gap-heritage-logo-zip-hoodie", "brand": "GAP", "name": "Heritage Logo Zip Hoodie", "images": 1, "price_usd": 1071.6} +{"code": "gap-1231-2925864", "product_id": "prod_aC8tNvLSUA", "slug": "157-gap-arch-sherpa-zip-hoodie", "brand": "GAP", "name": "Arch Sherpa Zip Hoodie", "images": 2, "price_usd": 840.67} +{"code": "cotton-on-6809-2319164", "product_id": "prod_QRPA48cIW7", "slug": "158-cotton-on-zip-up-hoodie", "brand": "Cotton On", "name": "Zip Up Hoodie", "images": 1, "price_usd": 573.12} +{"code": "cotton-on-6979-5652664", "product_id": "prod_QRNA48cIW7", "slug": "159-cotton-on-box-fit-hoodie", "brand": "Cotton On", "name": "Box Fit Hoodie", "images": 1, "price_usd": 357.6} +{"code": "gap-1516-1025214", "product_id": "prod_8g2R0I7fEb", "slug": "160-gap-french-heritage-logo-zip-hoodie", "brand": "GAP", "name": "French Heritage Logo Zip Hoodie", "images": 1, "price_usd": 735.5} diff --git a/catalogue/load_products.rb b/catalogue/load_products.rb new file mode 100644 index 0000000..aa69e61 --- /dev/null +++ b/catalogue/load_products.rb @@ -0,0 +1,42 @@ +require 'json' +require 'open-uri' + +store = Spree::Store.default +taxonomy = Spree::Taxonomy.find_or_create_by!(name: "Categories", store: store) +shipping_category = Spree::ShippingCategory.first_or_create!(name: "Default") + +products_file = "/home/juxtapo/products.jsonl" + +File.readlines(products_file).each_with_index do |line, idx| + data = JSON.parse(line) + + taxon = taxonomy.taxons.find_or_create_by!(name: data["brand"]) do |t| + t.parent = taxonomy.root + end + + product = Spree::Product.create!( + name: data["name"], + price: data["price"].to_f, + description: "#{data["brand"]} - #{data["name"]}", + available_on: Time.current, + shipping_category: shipping_category, + status: "active", + stores: [store] + ) + + product.taxons << taxon + + # attach first image + if data["imgs"] && data["imgs"].first + begin + img_url = data["imgs"].first + downloaded = URI.open(img_url) + product.images.create!(attachment: { io: downloaded, filename: "#{data["code"]}.jpg" }) + puts "✅ #{idx+1}/30: #{data["name"]} (#{data["brand"]}) - RM#{data["price"]}" + rescue => e + puts "⚠️ #{idx+1}/30: #{data["name"]} - image failed: #{e.message}" + end + end +end + +puts "Done! Total products: #{Spree::Product.count}" diff --git a/catalogue/photoroom_pipeline.py b/catalogue/photoroom_pipeline.py new file mode 100644 index 0000000..b56092b --- /dev/null +++ b/catalogue/photoroom_pipeline.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +""" +Pak Jabit — Product Image Pipeline +PhotoRoom API: remove background + studio-quality packshot + +Usage: + python3 photoroom_pipeline.py --input products/01-gshock-*/image-1.jpg + python3 photoroom_pipeline.py --input-dir products/ --all + python3 photoroom_pipeline.py --input products/01-gshock-*/image-1.jpg --dry-run + +Env: + PHOTOROOM_API_KEY — sandbox_ prefix for testing (watermarked), real key for production +""" + +import argparse, glob, json, os, sys, time +from pathlib import Path +import urllib.request, urllib.error + +API_KEY = os.environ.get("PHOTOROOM_API_KEY") +EDIT_URL = "https://image-api.photoroom.com/v2/edit" +SEGMENT_URL = "https://sdk.photoroom.com/v1/segment" + +def check_account(): + req = urllib.request.Request( + "https://image-api.photoroom.com/v2/account", + headers={"x-api-key": API_KEY}, + ) + with urllib.request.urlopen(req, timeout=15) as r: + data = json.loads(r.read()) + print(f"Plan: {data['plan']} | Credits: {data['images']['available']}/{data['images']['subscription']}") + return data + +def remove_background(image_path: str, output_path: str): + boundary = "----PakJabitBoundary" + filename = os.path.basename(image_path) + with open(image_path, "rb") as f: + image_data = f.read() + + body = ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="image_file"; filename="{filename}"\r\n' + f"Content-Type: image/jpeg\r\n\r\n" + ).encode() + image_data + ( + f"\r\n--{boundary}\r\n" + f'Content-Disposition: form-data; name="format"\r\n\r\npng\r\n' + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="size"\r\n\r\nhd\r\n' + f"--{boundary}--\r\n" + ).encode() + + req = urllib.request.Request(SEGMENT_URL, data=body, method="POST", headers={ + "x-api-key": API_KEY, + "Content-Type": f"multipart/form-data; boundary={boundary}", + }) + with urllib.request.urlopen(req, timeout=60) as r: + with open(output_path, "wb") as out: + out.write(r.read()) + return output_path + +def studio_packshot(image_path: str, output_path: str, bg_color: str = "FFFFFF"): + boundary = "----PakJabitBoundary" + filename = os.path.basename(image_path) + with open(image_path, "rb") as f: + image_data = f.read() + + fields = [ + ("background.color", bg_color), + ("padding", "0.1"), + ("shadow.mode", "ai.soft"), + ("outputSize", "1200x1600"), + ("export.format", "jpeg"), + ("removeBackground", "true"), + ] + + body = b"" + for name, value in fields: + body += ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n' + ).encode() + + body += ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="imageFile"; filename="{filename}"\r\n' + f"Content-Type: image/jpeg\r\n\r\n" + ).encode() + image_data + f"\r\n--{boundary}--\r\n".encode() + + req = urllib.request.Request(EDIT_URL, data=body, method="POST", headers={ + "x-api-key": API_KEY, + "Content-Type": f"multipart/form-data; boundary={boundary}", + }) + with urllib.request.urlopen(req, timeout=90) as r: + with open(output_path, "wb") as out: + out.write(r.read()) + return output_path + +def process_image(image_path: str, output_dir: str, mode: str = "packshot"): + stem = Path(image_path).stem + parent = Path(image_path).parent.name + + out_subdir = os.path.join(output_dir, parent) + os.makedirs(out_subdir, exist_ok=True) + + if mode == "remove-bg": + out_path = os.path.join(out_subdir, f"{stem}-nobg.png") + remove_background(image_path, out_path) + elif mode == "packshot": + out_path = os.path.join(out_subdir, f"{stem}-studio.jpg") + studio_packshot(image_path, out_path) + else: + raise ValueError(f"Unknown mode: {mode}") + + print(f" ✅ {os.path.basename(image_path)} → {os.path.basename(out_path)}") + return out_path + +def main(): + parser = argparse.ArgumentParser(description="PhotoRoom product image pipeline") + parser.add_argument("--input", nargs="+", help="Image file(s) or glob pattern") + parser.add_argument("--input-dir", help="Process all images in product subdirs") + parser.add_argument("--all", action="store_true", help="Process all image-*.jpg in input-dir") + parser.add_argument("--first-only", action="store_true", help="Only process image-1.jpg per product") + parser.add_argument("--output-dir", default="products-enhanced", help="Output directory") + parser.add_argument("--mode", choices=["remove-bg", "packshot"], default="packshot") + parser.add_argument("--dry-run", action="store_true", help="List files without processing") + parser.add_argument("--account", action="store_true", help="Check account credits") + parser.add_argument("--delay", type=float, default=1.0, help="Delay between API calls (seconds)") + args = parser.parse_args() + + if not API_KEY: + print("❌ Set PHOTOROOM_API_KEY env var first") + print(" Sandbox key (free, watermarked): starts with sandbox_") + print(" Get it from: https://app.photoroom.com/api-dashboard") + sys.exit(1) + + if args.account: + check_account() + return + + images = [] + if args.input: + for pattern in args.input: + images.extend(glob.glob(pattern)) + elif args.input_dir and args.all: + pattern = "image-1.jpg" if args.first_only else "image-*.jpg" + images = sorted(glob.glob(os.path.join(args.input_dir, "**", pattern), recursive=True)) + else: + parser.print_help() + return + + if not images: + print("No images found") + return + + print(f"📸 {len(images)} images | mode: {args.mode} | output: {args.output_dir}") + + if args.dry_run: + for img in images: + print(f" would process: {img}") + return + + check_account() + print() + + ok, fail = 0, 0 + for i, img in enumerate(images, 1): + print(f"[{i}/{len(images)}] {img}") + try: + process_image(img, args.output_dir, args.mode) + ok += 1 + except urllib.error.HTTPError as e: + print(f" ❌ HTTP {e.code}: {e.read().decode()[:200]}") + fail += 1 + if e.code == 429: + print(" ⏳ Rate limited, waiting 30s...") + time.sleep(30) + except Exception as e: + print(f" ❌ {e}") + fail += 1 + + if i < len(images): + time.sleep(args.delay) + + print(f"\n✅ Done: {ok} ok, {fail} failed") + +if __name__ == "__main__": + main() diff --git a/catalogue/photoroom_to_spree.py b/catalogue/photoroom_to_spree.py new file mode 100755 index 0000000..cfec8b1 --- /dev/null +++ b/catalogue/photoroom_to_spree.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +""" +Pak Jabit — Photoroom return leg. + +The missing half of photoroom_pipeline.py: takes a source image, runs it through +Photoroom, and pushes the result back onto the Spree product as a media asset. + + source jpg -> Photoroom /v2/edit -> Spree POST /products/:id/media -> verify + +Verification is not optional here. Spree's media#create has a url path that +enqueues a background job and answers `head :accepted` — a 202 with no body and +no way to know if the image ever landed. This uses the multipart path, which +returns 201 plus the serialized asset, and then re-reads the product's media to +confirm the asset id is really there. + +Usage: + photoroom_to_spree.py --slug 19-electro-denim-lab-chino-pants --live + photoroom_to_spree.py --slug --position 1 # make it the thumbnail + photoroom_to_spree.py --slug --dry-run + +Keys come from /root/.photoroom/credentials. Sandbox is the default and +watermarks every image; --live uses the paid allowance (10/month) and is the +only way to get a clean result onto a live storefront. +""" +import argparse +import json +import mimetypes +import pathlib +import sys +import urllib.error +import urllib.request +import uuid + +import os as _os + + +def _spree_admin_key() -> str: + """The Spree admin API key. Required — never defaulted.""" + key = _os.environ.get("SPREE_ADMIN_KEY", "").strip() + if not key: + raise SystemExit( + "SPREE_ADMIN_KEY is not set.\n" + " export SPREE_ADMIN_KEY=\n" + "It is deliberately not stored in this file — see README.md." + ) + return key + + +SPREE_API = "https://api.jux-lab.com/api/v3/admin" +SPREE_KEY = _spree_admin_key() +PHOTOROOM_EDIT = "https://image-api.photoroom.com/v2/edit" +CREDS = pathlib.Path("/root/.photoroom/credentials") +SOURCE_ROOT = pathlib.Path( + "/root/Opus/Projects/PeakyBuildr/scraped-catalogue/products" +) + + +def fatal(msg): + print(f"FATAL: {msg}", file=sys.stderr) + sys.exit(1) + + +def load_key(live: bool) -> str: + if not CREDS.exists(): + fatal(f"{CREDS} missing — no API key to use") + want = "PHOTOROOM_LIVE_KEY" if live else "PHOTOROOM_SANDBOX_KEY" + for line in CREDS.read_text().splitlines(): + if line.startswith(want + "="): + return line.split("=", 1)[1].strip() + fatal(f"{want} not found in {CREDS}") + + +def multipart(fields: dict, file_field: str, filename: str, blob: bytes, content_type: str): + boundary = uuid.uuid4().hex + parts = [] + for k, v in fields.items(): + parts.append( + f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode() + ) + parts.append( + f'--{boundary}\r\nContent-Disposition: form-data; name="{file_field}"; ' + f'filename="{filename}"\r\nContent-Type: {content_type}\r\n\r\n'.encode() + + blob + + b"\r\n" + ) + parts.append(f"--{boundary}--\r\n".encode()) + return b"".join(parts), f"multipart/form-data; boundary={boundary}" + + +def spree_get(path): + req = urllib.request.Request( + f"{SPREE_API}{path}", headers={"X-Spree-Api-Key": SPREE_KEY} + ) + with urllib.request.urlopen(req, timeout=60) as r: + return json.loads(r.read()) + + +def find_product(slug): + data = spree_get(f"/products?filter[slug_eq]={slug}&per_page=50") + for p in data.get("data", []): + if p.get("slug") == slug or p.get("attributes", {}).get("slug") == slug: + return p + # The filter key varies by Spree build; fall back to a full scan rather than + # reporting "not found" for a product that is actually there. + page = 1 + while True: + d = spree_get(f"/products?per_page=100&page={page}") + for p in d.get("data", []): + if p.get("slug") == slug: + return p + if page >= d.get("meta", {}).get("pages", 1): + return None + page += 1 + + +def photoroom(img_path: pathlib.Path, key: str, size: str, bg: str) -> bytes: + body, ctype = multipart( + {"background.color": bg, "outputSize": size, "padding": "0.05"}, + "imageFile", + img_path.name, + img_path.read_bytes(), + mimetypes.guess_type(img_path.name)[0] or "image/jpeg", + ) + req = urllib.request.Request( + PHOTOROOM_EDIT, data=body, headers={"x-api-key": key, "Content-Type": ctype} + ) + try: + with urllib.request.urlopen(req, timeout=180) as r: + data = r.read() + if not data: + fatal("Photoroom returned 200 with an empty body") + if not r.headers.get("content-type", "").startswith("image/"): + fatal(f"Photoroom returned non-image: {r.headers.get('content-type')}") + return data + except urllib.error.HTTPError as e: + fatal(f"Photoroom HTTP {e.code}: {e.read().decode()[:300]}") + + +def upload_to_spree(product_id: str, blob: bytes, filename: str, position): + # position is always sent. Spree defaults a new asset to position 1, which + # silently makes it the product thumbnail — a watermarked sandbox image went + # live on the storefront that way. Callers pass the append position unless + # they explicitly want the front. + fields = {"type": "Spree::Image", "position": str(position)} + body, ctype = multipart(fields, "attachment", filename, blob, "image/png") + req = urllib.request.Request( + f"{SPREE_API}/products/{product_id}/media", + data=body, + headers={"X-Spree-Api-Key": SPREE_KEY, "Content-Type": ctype}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=180) as r: + return r.status, json.loads(r.read() or b"{}") + except urllib.error.HTTPError as e: + fatal(f"Spree HTTP {e.code}: {e.read().decode()[:400]}") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--slug", required=True) + ap.add_argument("--image", help="source file (default: products//image-1.jpg)") + ap.add_argument("--live", action="store_true", help="use the paid key (10/month, no watermark)") + ap.add_argument("--position", type=int, + help="1 makes it the product thumbnail. Default: appended last.") + ap.add_argument("--size", default="1000x1000") + ap.add_argument("--bg", default="FFFFFF") + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + + src = pathlib.Path(args.image) if args.image else SOURCE_ROOT / args.slug / "image-1.jpg" + if not src.exists(): + fatal(f"source image not found: {src}") + + product = find_product(args.slug) + if not product: + fatal(f"no Spree product with slug {args.slug}") + pid = product["id"] + name = product.get("name", "?") + + before = spree_get(f"/products/{pid}/media").get("data", []) + print(f"product : {name} ({pid})") + print(f"source : {src} ({src.stat().st_size:,} bytes)") + print(f"media : {len(before)} existing") + print(f"key : {'LIVE (consumes paid quota)' if args.live else 'sandbox (watermarked)'}") + + if args.dry_run: + print("\n--dry-run: nothing sent.") + return + + key = load_key(args.live) + position = args.position if args.position is not None else len(before) + 1 + print(f"\nprocessing... (will land at position {position}" + f"{' — THUMBNAIL' if position == 1 else ''})") + out = photoroom(src, key, args.size, args.bg) + print(f" photoroom ok: {len(out):,} bytes png") + + status, resp = upload_to_spree(pid, out, f"{args.slug}-photoroom.png", position) + asset_id = resp.get("data", resp).get("id") + print(f" spree upload: HTTP {status} asset={asset_id} position={position}") + + # Re-read rather than trusting the response. A 201 is not proof the asset is + # attached and serving. + after = spree_get(f"/products/{pid}/media").get("data", []) + ids = [m.get("id") for m in after] + if asset_id and asset_id in ids: + url = next((m.get("original_url") or m.get("url") for m in after if m.get("id") == asset_id), None) + print(f"\ncheck ok: media {len(before)} -> {len(after)}, asset present") + if url: + print(f"url: {url}") + else: + print(f"\nCHECK FAILED: asset {asset_id} not in product media after upload", file=sys.stderr) + print(f" media ids: {ids}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/catalogue/products-batch2.jsonl b/catalogue/products-batch2.jsonl new file mode 100644 index 0000000..47fcb99 --- /dev/null +++ b/catalogue/products-batch2.jsonl @@ -0,0 +1,53 @@ +{"brand": "H&M", "code": "hm-3377-7607064", "name": "Oversized Fit Scuba zip-through hoodie", "price": "149.90", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/hm-3377-7607064-1.jpg", "https://static-my.zacdn.com/p/hm-3377-7607064-2.jpg", "https://static-my.zacdn.com/p/hm-3377-7607064-3.jpg", "https://static-my.zacdn.com/p/hm-3377-7607064-4.jpg", "https://static-my.zacdn.com/p/hm-3377-7607064-5.jpg"]} +{"brand": "Urban Revivo", "code": "urban-revivo-3653-8941654", "name": "Casual Crew Neck Short Sleeve Tee", "price": "119.00", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/urban-revivo-3653-8941654-1.jpg", "https://static-my.zacdn.com/p/urban-revivo-3653-8941654-2.jpg", "https://static-my.zacdn.com/p/urban-revivo-3653-8941654-3.jpg", "https://static-my.zacdn.com/p/urban-revivo-3653-8941654-4.jpg", "https://static-my.zacdn.com/p/urban-revivo-3653-8941654-5.jpg", "https://static-my.zacdn.com/p/urban-revivo-3653-8941654-6.jpg"]} +{"brand": "Urban Revivo", "code": "urban-revivo-4852-8181854", "name": "Short Sleeve Shirts", "price": "116.10", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/urban-revivo-4852-8181854-1.jpg", "https://static-my.zacdn.com/p/urban-revivo-4852-8181854-2.jpg", "https://static-my.zacdn.com/p/urban-revivo-4852-8181854-3.jpg", "https://static-my.zacdn.com/p/urban-revivo-4852-8181854-4.jpg", "https://static-my.zacdn.com/p/urban-revivo-4852-8181854-5.jpg", "https://static-my.zacdn.com/p/urban-revivo-4852-8181854-6.jpg", "https://static-my.zacdn.com/p/urban-revivo-4852-8181854-7.jpg"]} +{"brand": "Nike", "code": "nike-4039-6920264", "name": "Training T-Shirt", "price": "147.25", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/nike-4039-6920264-1.jpg"]} +{"brand": "H&M", "code": "hm-8811-9083444", "name": "Regular Fit Track pants with DryMove™", "price": "149.90", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/hm-8811-9083444-1.jpg", "https://static-my.zacdn.com/p/hm-8811-9083444-2.jpg", "https://static-my.zacdn.com/p/hm-8811-9083444-3.jpg", "https://static-my.zacdn.com/p/hm-8811-9083444-4.jpg", "https://static-my.zacdn.com/p/hm-8811-9083444-5.jpg", "https://static-my.zacdn.com/p/hm-8811-9083444-6.jpg", "https://static-my.zacdn.com/p/hm-8811-9083444-7.jpg", "https://static-my.zacdn.com/p/hm-8811-9083444-8.jpg"]} +{"brand": "Trendyol", "code": "trendyol-6397-0057634", "name": "Beige Oversize/Wide Cut Long Sleeve Animal Embroidered Sweatshirt TMNAW24SW00164", "price": "56.05", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/trendyol-6397-0057634-1.jpg", "https://static-my.zacdn.com/p/trendyol-6397-0057634-2.jpg", "https://static-my.zacdn.com/p/trendyol-6397-0057634-3.jpg", "https://static-my.zacdn.com/p/trendyol-6397-0057634-4.jpg", "https://static-my.zacdn.com/p/trendyol-6397-0057634-5.jpg"]} +{"brand": "Trendyol", "code": "trendyol-9722-5541154", "name": "Horse Embroidered Sweatshirt", "price": "55.20", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/trendyol-9722-5541154-1.jpg"]} +{"brand": "H&M", "code": "hm-1602-9539354", "name": "Relaxed Fit Resort shirt", "price": "99.90", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/hm-1602-9539354-1.jpg", "https://static-my.zacdn.com/p/hm-1602-9539354-2.jpg", "https://static-my.zacdn.com/p/hm-1602-9539354-3.jpg", "https://static-my.zacdn.com/p/hm-1602-9539354-4.jpg", "https://static-my.zacdn.com/p/hm-1602-9539354-5.jpg", "https://static-my.zacdn.com/p/hm-1602-9539354-6.jpg"]} +{"brand": "Trendyol", "code": "trendyol-6524-2362464", "name": "Anthracite*001 Relaxed Mock Neck Plain Sweater Tmnaw26Kz00013", "price": "84.50", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/trendyol-6524-2362464-1.jpg", "https://static-my.zacdn.com/p/trendyol-6524-2362464-2.jpg", "https://static-my.zacdn.com/p/trendyol-6524-2362464-3.jpg", "https://static-my.zacdn.com/p/trendyol-6524-2362464-4.jpg", "https://static-my.zacdn.com/p/trendyol-6524-2362464-5.jpg"]} +{"brand": "Giordano", "code": "giordano-7623-8198654", "name": "Men's Cotton Short Sleeve Slim Tee 01026250", "price": "37.17", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/giordano-7623-8198654-1.jpg", "https://static-my.zacdn.com/p/giordano-7623-8198654-2.jpg"]} +{"brand": "H&M", "code": "hm-4373-6317064", "name": "Ultra Baggy Jeans", "price": "149.90", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/hm-4373-6317064-1.jpg", "https://static-my.zacdn.com/p/hm-4373-6317064-2.jpg", "https://static-my.zacdn.com/p/hm-4373-6317064-3.jpg", "https://static-my.zacdn.com/p/hm-4373-6317064-4.jpg", "https://static-my.zacdn.com/p/hm-4373-6317064-5.jpg"]} +{"brand": "H&M", "code": "hm-0024-5243544", "name": "Relaxed Jeans", "price": "129.90", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/hm-0024-5243544-1.jpg", "https://static-my.zacdn.com/p/hm-0024-5243544-2.jpg", "https://static-my.zacdn.com/p/hm-0024-5243544-3.jpg", "https://static-my.zacdn.com/p/hm-0024-5243544-4.jpg"]} +{"brand": "YSoCool", "code": "ysocool-brand-7905-5999931", "name": "Men Compression Body Shaper Vests Top", "price": "46.55", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/ysocool-brand-7905-5999931-1.jpg", "https://static-my.zacdn.com/p/ysocool-brand-7905-5999931-2.jpg", "https://static-my.zacdn.com/p/ysocool-brand-7905-5999931-3.jpg"]} +{"brand": "Nike", "code": "nike-8330-6036864", "name": "Dri-FIT Training T-Shirt", "price": "113.05", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/nike-8330-6036864-1.jpg"]} +{"brand": "H&M", "code": "hm-8820-5290854", "name": "Loose Fit T-shirt", "price": "49.90", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/hm-8820-5290854-1.jpg", "https://static-my.zacdn.com/p/hm-8820-5290854-2.jpg", "https://static-my.zacdn.com/p/hm-8820-5290854-3.jpg", "https://static-my.zacdn.com/p/hm-8820-5290854-4.jpg"]} +{"brand": "H&M", "code": "hm-6806-1878823", "name": "Slim Fit T-shirt", "price": "24.90", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/hm-6806-1878823-1.jpg", "https://static-my.zacdn.com/p/hm-6806-1878823-2.jpg", "https://static-my.zacdn.com/p/hm-6806-1878823-3.jpg", "https://static-my.zacdn.com/p/hm-6806-1878823-4.jpg", "https://static-my.zacdn.com/p/hm-6806-1878823-5.jpg"]} +{"brand": "H&M", "code": "hm-6462-8250054", "name": "Relaxed Fit Textured resort shirt", "price": "99.90", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/hm-6462-8250054-1.jpg", "https://static-my.zacdn.com/p/hm-6462-8250054-2.jpg", "https://static-my.zacdn.com/p/hm-6462-8250054-3.jpg", "https://static-my.zacdn.com/p/hm-6462-8250054-4.jpg"]} +{"brand": "Trendyol", "code": "trendyol-3134-8272734", "name": "Black Regular Fit 100% Viscose Loose and Loose Summer Shirt TMNSS25GO00048", "price": "90.30", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/trendyol-3134-8272734-1.jpg"]} +{"brand": "Altinyildiz", "code": "altinyildiz-8185-0860924", "name": "Basic Slim Fit T-Shirt", "price": "52.16", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/altinyildiz-8185-0860924-1.jpg"]} +{"brand": "Nike", "code": "nike-0596-4602954", "name": "Palm Tree Men's Tee", "price": "93.00", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/nike-0596-4602954-1.jpg"]} +{"brand": "Lacoste", "code": "lacoste-6939-3474274", "name": "Monogram Jacquard T-shirt", "price": "539.00", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/lacoste-6939-3474274-1.jpg", "https://static-my.zacdn.com/p/lacoste-6939-3474274-2.jpg", "https://static-my.zacdn.com/p/lacoste-6939-3474274-3.jpg", "https://static-my.zacdn.com/p/lacoste-6939-3474274-4.jpg"]} +{"brand": "H&M", "code": "hm-2842-2711854", "name": "Relaxed Fit Cotton chinos", "price": "129.90", "category": "t-shirts", "imgs": ["https://static-my.zacdn.com/p/hm-2842-2711854-1.jpg", "https://static-my.zacdn.com/p/hm-2842-2711854-2.jpg"]} +{"brand": "Urban Revivo", "code": "urban-revivo-8524-3567374", "name": "Classic Solid Color Thick Shirt in Viscose Blend", "price": "189.05", "category": "shirts", "imgs": ["https://static-my.zacdn.com/p/urban-revivo-8524-3567374-1.jpg", "https://static-my.zacdn.com/p/urban-revivo-8524-3567374-2.jpg", "https://static-my.zacdn.com/p/urban-revivo-8524-3567374-3.jpg", "https://static-my.zacdn.com/p/urban-revivo-8524-3567374-4.jpg", "https://static-my.zacdn.com/p/urban-revivo-8524-3567374-5.jpg", "https://static-my.zacdn.com/p/urban-revivo-8524-3567374-6.jpg", "https://static-my.zacdn.com/p/urban-revivo-8524-3567374-7.jpg", "https://static-my.zacdn.com/p/urban-revivo-8524-3567374-8.jpg"]} +{"brand": "H&M", "code": "hm-4144-4577954", "name": "Relaxed Fit Oxford shirt", "price": "129.90", "category": "shirts", "imgs": ["https://static-my.zacdn.com/p/hm-4144-4577954-1.jpg", "https://static-my.zacdn.com/p/hm-4144-4577954-2.jpg", "https://static-my.zacdn.com/p/hm-4144-4577954-3.jpg", "https://static-my.zacdn.com/p/hm-4144-4577954-4.jpg", "https://static-my.zacdn.com/p/hm-4144-4577954-5.jpg"]} +{"brand": "Trendyol", "code": "trendyol-7335-7898364", "name": "Black Regular/Normal Cut Hooded Textured Sweatshirt Tmnaw26Sw00068", "price": "79.03", "category": "shirts", "imgs": ["https://static-my.zacdn.com/p/trendyol-7335-7898364-1.jpg", "https://static-my.zacdn.com/p/trendyol-7335-7898364-2.jpg", "https://static-my.zacdn.com/p/trendyol-7335-7898364-3.jpg"]} +{"brand": "Trendyol", "code": "trendyol-7769-9773614", "name": "Striped Knit Polo Shirt", "price": "85.52", "category": "pants", "imgs": ["https://static-my.zacdn.com/p/trendyol-7769-9773614-1.jpg", "https://static-my.zacdn.com/p/trendyol-7769-9773614-2.jpg", "https://static-my.zacdn.com/p/trendyol-7769-9773614-3.jpg", "https://static-my.zacdn.com/p/trendyol-7769-9773614-4.jpg"]} +{"brand": "H&M", "code": "hm-4087-3660754", "name": "Regular Fit Hole-knit sweater vest", "price": "139.90", "category": "pants", "imgs": ["https://static-my.zacdn.com/p/hm-4087-3660754-1.jpg", "https://static-my.zacdn.com/p/hm-4087-3660754-2.jpg", "https://static-my.zacdn.com/p/hm-4087-3660754-3.jpg", "https://static-my.zacdn.com/p/hm-4087-3660754-4.jpg", "https://static-my.zacdn.com/p/hm-4087-3660754-5.jpg", "https://static-my.zacdn.com/p/hm-4087-3660754-6.jpg"]} +{"brand": "H&M", "code": "hm-9694-6720944", "name": "Easy-care Relaxed Fit Linen-blend shorts", "price": "149.90", "category": "pants", "imgs": ["https://static-my.zacdn.com/p/hm-9694-6720944-1.jpg", "https://static-my.zacdn.com/p/hm-9694-6720944-2.jpg", "https://static-my.zacdn.com/p/hm-9694-6720944-3.jpg", "https://static-my.zacdn.com/p/hm-9694-6720944-4.jpg"]} +{"brand": "H&M", "code": "hm-9884-9573454", "name": "Oversized Fit T-shirt", "price": "69.90", "category": "pants", "imgs": ["https://static-my.zacdn.com/p/hm-9884-9573454-1.jpg", "https://static-my.zacdn.com/p/hm-9884-9573454-2.jpg", "https://static-my.zacdn.com/p/hm-9884-9573454-3.jpg", "https://static-my.zacdn.com/p/hm-9884-9573454-4.jpg", "https://static-my.zacdn.com/p/hm-9884-9573454-5.jpg", "https://static-my.zacdn.com/p/hm-9884-9573454-6.jpg"]} +{"brand": "Trendyol", "code": "trendyol-1466-7949634", "name": "Brown Oversize/Wide Cut Buttoned Polo Collar Thick Polar Fleece Sweatshirt TMNAW24SW00107", "price": "82.53", "category": "pants", "imgs": ["https://static-my.zacdn.com/p/trendyol-1466-7949634-1.jpg"]} +{"brand": "Ben Sherman", "code": "ben-sherman-1585-5545954", "name": "Short Sleeves Jacquard Polo Shirt", "price": "312.55", "category": "accessories", "imgs": ["https://static-my.zacdn.com/p/ben-sherman-1585-5545954-1.jpg"]} +{"brand": "H&M", "code": "hm-3289-5938864", "name": "Relaxed Fit Sweatshirt", "price": "59.90", "category": "new", "imgs": ["https://static-my.zacdn.com/p/hm-3289-5938864-1.jpg", "https://static-my.zacdn.com/p/hm-3289-5938864-2.jpg"]} +{"brand": "H&M", "code": "hm-7368-5259764", "name": "Regular Fit Easy-iron shirt", "price": "79.90", "category": "new", "imgs": ["https://static-my.zacdn.com/p/hm-7368-5259764-1.jpg", "https://static-my.zacdn.com/p/hm-7368-5259764-2.jpg"]} +{"brand": "H&M", "code": "hm-0914-3034864", "name": "Polo shirt", "price": "99.90", "category": "new", "imgs": ["https://static-my.zacdn.com/p/hm-0914-3034864-1.jpg", "https://static-my.zacdn.com/p/hm-0914-3034864-2.jpg"]} +{"brand": "ADIDAS", "code": "adidas-0957-2958274", "name": "Liverpool Fc 26/27 Third Jersey", "price": "329.00", "category": "new", "imgs": ["https://static-my.zacdn.com/p/adidas-0957-2958274-1.jpg", "https://static-my.zacdn.com/p/adidas-0957-2958274-2.jpg", "https://static-my.zacdn.com/p/adidas-0957-2958274-3.jpg", "https://static-my.zacdn.com/p/adidas-0957-2958274-4.jpg"]} +{"brand": "Tommy Hilfiger", "code": "tommy-hilfiger-6431-9934964", "name": "Linear Logo Slim Crew Neck T-Shirt - Tommy Jeans", "price": "151.05", "category": "new", "imgs": ["https://static-my.zacdn.com/p/tommy-hilfiger-6431-9934964-1.jpg"]} +{"brand": "GAP", "code": "gap-2969-6099274", "name": "Logo T-Shirt", "price": "99.99", "category": "new", "imgs": ["https://static-my.zacdn.com/p/gap-2969-6099274-1.jpg"]} +{"brand": "GAP", "code": "gap-0710-4300374", "name": "Logo T-Shirt", "price": "99.99", "category": "new", "imgs": ["https://static-my.zacdn.com/p/gap-0710-4300374-1.jpg"]} +{"brand": "H&M", "code": "hm-0924-7104664", "name": "Loose Fit Interlock T-shirt", "price": "49.90", "category": "new", "imgs": ["https://static-my.zacdn.com/p/hm-0924-7104664-1.jpg", "https://static-my.zacdn.com/p/hm-0924-7104664-2.jpg", "https://static-my.zacdn.com/p/hm-0924-7104664-3.jpg", "https://static-my.zacdn.com/p/hm-0924-7104664-4.jpg", "https://static-my.zacdn.com/p/hm-0924-7104664-5.jpg", "https://static-my.zacdn.com/p/hm-0924-7104664-6.jpg"]} +{"brand": "ADIDAS", "code": "adidas-9961-7990474", "name": "Liverpool FC 26/27 Home Fan Jersey", "price": "94.05", "category": "new", "imgs": ["https://static-my.zacdn.com/p/adidas-9961-7990474-1.jpg"]} +{"brand": "GAP", "code": "gap-3366-0689274", "name": "Pique Polo Shirt", "price": "134.82", "category": "new", "imgs": ["https://static-my.zacdn.com/p/gap-3366-0689274-1.jpg"]} +{"brand": "ADIDAS", "code": "adidas-6543-9526964", "name": "Mercedes - AMG Petronas Formula 1 Team Miami Tee", "price": "141.55", "category": "new", "imgs": ["https://static-my.zacdn.com/p/adidas-6543-9526964-1.jpg"]} +{"brand": "Giordano", "code": "giordano-8229-4161474", "name": "Giordano by Poly Pac 20'' 24\" 28'' Fury UNBREAKABLE PP Expandable Travel Luggage - GA2620 - Black", "price": "799.00", "category": "new", "imgs": ["https://static-my.zacdn.com/p/giordano-8229-4161474-1.jpg", "https://static-my.zacdn.com/p/giordano-8229-4161474-2.jpg", "https://static-my.zacdn.com/p/giordano-8229-4161474-3.jpg", "https://static-my.zacdn.com/p/giordano-8229-4161474-4.jpg", "https://static-my.zacdn.com/p/giordano-8229-4161474-5.jpg", "https://static-my.zacdn.com/p/giordano-8229-4161474-6.jpg"]} +{"brand": "ADIDAS", "code": "adidas-9235-1679564", "name": "VL Court Base Shoes", "price": "155.40", "category": "new", "imgs": ["https://static-my.zacdn.com/p/adidas-9235-1679564-1.jpg", "https://static-my.zacdn.com/p/adidas-9235-1679564-2.jpg", "https://static-my.zacdn.com/p/adidas-9235-1679564-3.jpg", "https://static-my.zacdn.com/p/adidas-9235-1679564-4.jpg", "https://static-my.zacdn.com/p/adidas-9235-1679564-5.jpg", "https://static-my.zacdn.com/p/adidas-9235-1679564-6.jpg", "https://static-my.zacdn.com/p/adidas-9235-1679564-7.jpg", "https://static-my.zacdn.com/p/adidas-9235-1679564-8.jpg"]} +{"brand": "H&M", "code": "hm-3158-4937164", "name": "Relaxed Fit Zip-top rib-knit jumper", "price": "149.90", "category": "new", "imgs": ["https://static-my.zacdn.com/p/hm-3158-4937164-1.jpg"]} +{"brand": "H&M", "code": "hm-5561-3429464", "name": "Loose Fit Scuba Zip-through hoodie", "price": "149.90", "category": "new", "imgs": ["https://static-my.zacdn.com/p/hm-5561-3429464-1.jpg", "https://static-my.zacdn.com/p/hm-5561-3429464-2.jpg"]} +{"brand": "MANGO Man", "code": "mango-man-6546-3612374", "name": "Fine Knit Cotton-Blend Sweater", "price": "199.90", "category": "new", "imgs": ["https://static-my.zacdn.com/p/mango-man-6546-3612374-1.jpg"]} +{"brand": "ADIDAS", "code": "adidas-3065-8268274", "name": "Trefoil Cushion Crew Socks 3 Pairs", "price": "65.55", "category": "new", "imgs": ["https://static-my.zacdn.com/p/adidas-3065-8268274-1.jpg"]} +{"brand": "MANGO Man", "code": "mango-man-5487-0122374", "name": "Fine-Knit Sweater With Zip", "price": "219.90", "category": "new", "imgs": ["https://static-my.zacdn.com/p/mango-man-5487-0122374-1.jpg"]} +{"brand": "H&M", "code": "hm-6071-6565464", "name": "10-pack mid trunks with Lycra®", "price": "199.90", "category": "new", "imgs": ["https://static-my.zacdn.com/p/hm-6071-6565464-1.jpg", "https://static-my.zacdn.com/p/hm-6071-6565464-2.jpg", "https://static-my.zacdn.com/p/hm-6071-6565464-3.jpg", "https://static-my.zacdn.com/p/hm-6071-6565464-4.jpg", "https://static-my.zacdn.com/p/hm-6071-6565464-5.jpg"]} +{"brand": "Harley-Davidson", "code": "harley-davidson-6140-0807274", "name": "H-D® TECH Vented Short Sleeve Shirt", "price": "502.00", "category": "new", "imgs": ["https://static-my.zacdn.com/p/harley-davidson-6140-0807274-1.jpg", "https://static-my.zacdn.com/p/harley-davidson-6140-0807274-2.jpg", "https://static-my.zacdn.com/p/harley-davidson-6140-0807274-3.jpg"]} +{"brand": "Harley-Davidson", "code": "harley-davidson-6555-0017274", "name": "Harley-Davidson® Vintage Drop H Ringer Tee", "price": "207.00", "category": "new", "imgs": ["https://static-my.zacdn.com/p/harley-davidson-6555-0017274-1.jpg", "https://static-my.zacdn.com/p/harley-davidson-6555-0017274-2.jpg", "https://static-my.zacdn.com/p/harley-davidson-6555-0017274-3.jpg"]} +{"brand": "MANGO Man", "code": "mango-man-6179-9132374", "name": "Fine Knit Cotton-Blend Sweater", "price": "199.90", "category": "new", "imgs": ["https://static-my.zacdn.com/p/mango-man-6179-9132374-1.jpg"]} diff --git a/catalogue/products-batch3.jsonl b/catalogue/products-batch3.jsonl new file mode 100644 index 0000000..a1e2f97 --- /dev/null +++ b/catalogue/products-batch3.jsonl @@ -0,0 +1,77 @@ +{"brand": "H&M", "code": "hm-3272-3490754", "name": "Regular Jeans", "price": "99.90", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/hm-3272-3490754-1.jpg", "https://static-my.zacdn.com/p/hm-3272-3490754-2.jpg", "https://static-my.zacdn.com/p/hm-3272-3490754-3.jpg"]} +{"brand": "Levi's", "code": "levi-s-8540-2089554", "name": "Levi's® Men's 505™ Regular Jeans 00505-3166", "price": "183.20", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/levi-s-8540-2089554-1.jpg", "https://static-my.zacdn.com/p/levi-s-8540-2089554-2.jpg", "https://static-my.zacdn.com/p/levi-s-8540-2089554-3.jpg", "https://static-my.zacdn.com/p/levi-s-8540-2089554-4.jpg", "https://static-my.zacdn.com/p/levi-s-8540-2089554-5.jpg"]} +{"brand": "Volkswagen", "code": "volkswagen-4831-1155093", "name": "Slim Long Jeans", "price": "159.00", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/volkswagen-4831-1155093-1.jpg", "https://static-my.zacdn.com/p/volkswagen-4831-1155093-2.jpg", "https://static-my.zacdn.com/p/volkswagen-4831-1155093-3.jpg", "https://static-my.zacdn.com/p/volkswagen-4831-1155093-4.jpg"]} +{"brand": "Cotton On", "code": "cotton-on-6837-1419164", "name": "Baggy Jeans", "price": "171.43", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/cotton-on-6837-1419164-1.jpg"]} +{"brand": "Levi's", "code": "levi-s-4894-3133664", "name": "Levi's® Men's 505™ Regular Jeans 00505-3276", "price": "183.20", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/levi-s-4894-3133664-1.jpg", "https://static-my.zacdn.com/p/levi-s-4894-3133664-2.jpg", "https://static-my.zacdn.com/p/levi-s-4894-3133664-3.jpg"]} +{"brand": "Volkswagen", "code": "volkswagen-4840-0055093", "name": "Straight Long Jeans", "price": "159.00", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/volkswagen-4840-0055093-1.jpg", "https://static-my.zacdn.com/p/volkswagen-4840-0055093-2.jpg"]} +{"brand": "Connor", "code": "connor-3964-1987644", "name": "Toronto Straight Jean", "price": "143.91", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/connor-3964-1987644-1.jpg", "https://static-my.zacdn.com/p/connor-3964-1987644-2.jpg", "https://static-my.zacdn.com/p/connor-3964-1987644-3.jpg"]} +{"brand": "Levi's", "code": "levi-s-8200-6615651", "name": "511 Slim Fit Jeans 04511-1907", "price": "223.20", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/levi-s-8200-6615651-1.jpg", "https://static-my.zacdn.com/p/levi-s-8200-6615651-2.jpg"]} +{"brand": "FIDELIO", "code": "fidelio-1184-7387944", "name": "601 Slim Fit Washed Denim Jeans", "price": "109.00", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/fidelio-1184-7387944-1.jpg", "https://static-my.zacdn.com/p/fidelio-1184-7387944-2.jpg", "https://static-my.zacdn.com/p/fidelio-1184-7387944-3.jpg"]} +{"brand": "Levi's", "code": "levi-s-9015-6656924", "name": "Men's 511™ Slim Jeans 04511-6225", "price": "239.20", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/levi-s-9015-6656924-1.jpg", "https://static-my.zacdn.com/p/levi-s-9015-6656924-2.jpg"]} +{"brand": "DRUM", "code": "drum-3906-4664754", "name": "DRUM Casual Skinny to Slim Fit Jeans- Dark Blue", "price": "99.90", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/drum-3906-4664754-1.jpg", "https://static-my.zacdn.com/p/drum-3906-4664754-2.jpg"]} +{"brand": "Connor", "code": "connor-3962-2987644", "name": "Arran Straight Jean", "price": "143.91", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/connor-3962-2987644-1.jpg", "https://static-my.zacdn.com/p/connor-3962-2987644-2.jpg", "https://static-my.zacdn.com/p/connor-3962-2987644-3.jpg", "https://static-my.zacdn.com/p/connor-3962-2987644-4.jpg", "https://static-my.zacdn.com/p/connor-3962-2987644-5.jpg"]} +{"brand": "Trendyol", "code": "trendyol-3864-8297064", "name": "Essential Fit Jeans Denim Pants", "price": "108.43", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/trendyol-3864-8297064-1.jpg"]} +{"brand": "Electro Denim Lab", "code": "electro-denim-lab-7932-5806954", "name": "Dub Relaxed Fit Jeans", "price": "78.00", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/electro-denim-lab-7932-5806954-1.jpg", "https://static-my.zacdn.com/p/electro-denim-lab-7932-5806954-2.jpg", "https://static-my.zacdn.com/p/electro-denim-lab-7932-5806954-3.jpg", "https://static-my.zacdn.com/p/electro-denim-lab-7932-5806954-4.jpg", "https://static-my.zacdn.com/p/electro-denim-lab-7932-5806954-5.jpg"]} +{"brand": "DRUM", "code": "drum-5753-5393434", "name": "DRUM Digital Print Denim Straight Cut Jeans- Brown", "price": "139.90", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/drum-5753-5393434-1.jpg", "https://static-my.zacdn.com/p/drum-5753-5393434-2.jpg", "https://static-my.zacdn.com/p/drum-5753-5393434-3.jpg", "https://static-my.zacdn.com/p/drum-5753-5393434-4.jpg"]} +{"brand": "Trendyol", "code": "trendyol-4181-4687064", "name": "Slim Fit Stretch Fabric Denim Pants Jeans", "price": "146.30", "category": "jeans", "imgs": ["https://static-my.zacdn.com/p/trendyol-4181-4687064-1.jpg"]} +{"brand": "Urban Revivo", "code": "urban-revivo-4820-1281854", "name": "Denim Shorts", "price": "89.91", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/urban-revivo-4820-1281854-1.jpg", "https://static-my.zacdn.com/p/urban-revivo-4820-1281854-2.jpg", "https://static-my.zacdn.com/p/urban-revivo-4820-1281854-3.jpg", "https://static-my.zacdn.com/p/urban-revivo-4820-1281854-4.jpg", "https://static-my.zacdn.com/p/urban-revivo-4820-1281854-5.jpg", "https://static-my.zacdn.com/p/urban-revivo-4820-1281854-6.jpg"]} +{"brand": "H&M", "code": "hm-2256-5380854", "name": "2-pack Regular Fit Shorts", "price": "99.90", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/hm-2256-5380854-1.jpg", "https://static-my.zacdn.com/p/hm-2256-5380854-2.jpg"]} +{"brand": "H&M", "code": "hm-5374-3237954", "name": "Regular Fit Linen-blend shorts", "price": "99.90", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/hm-5374-3237954-1.jpg", "https://static-my.zacdn.com/p/hm-5374-3237954-2.jpg"]} +{"brand": "Cotton On", "code": "cotton-on-4000-9099341", "name": "Easy Shorts", "price": "122.55", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/cotton-on-4000-9099341-1.jpg"]} +{"brand": "Connor", "code": "connor-3932-5487644", "name": "Darwin Chino Stretch Short", "price": "116.91", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/connor-3932-5487644-1.jpg", "https://static-my.zacdn.com/p/connor-3932-5487644-2.jpg", "https://static-my.zacdn.com/p/connor-3932-5487644-3.jpg"]} +{"brand": "FIDELIO", "code": "fidelio-4233-8849254", "name": "Chino Super Shorts", "price": "89.00", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/fidelio-4233-8849254-1.jpg", "https://static-my.zacdn.com/p/fidelio-4233-8849254-2.jpg", "https://static-my.zacdn.com/p/fidelio-4233-8849254-3.jpg", "https://static-my.zacdn.com/p/fidelio-4233-8849254-4.jpg", "https://static-my.zacdn.com/p/fidelio-4233-8849254-5.jpg"]} +{"brand": "Cotton On", "code": "cotton-on-5986-9744864", "name": "Easy Active Shorts", "price": "89.28", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/cotton-on-5986-9744864-1.jpg"]} +{"brand": "Cotton On", "code": "cotton-on-6085-0724713", "name": "Stretch Swim Shorts", "price": "122.55", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/cotton-on-6085-0724713-1.jpg", "https://static-my.zacdn.com/p/cotton-on-6085-0724713-2.jpg", "https://static-my.zacdn.com/p/cotton-on-6085-0724713-3.jpg", "https://static-my.zacdn.com/p/cotton-on-6085-0724713-4.jpg", "https://static-my.zacdn.com/p/cotton-on-6085-0724713-5.jpg", "https://static-my.zacdn.com/p/cotton-on-6085-0724713-6.jpg", "https://static-my.zacdn.com/p/cotton-on-6085-0724713-7.jpg"]} +{"brand": "Under Armour", "code": "under-armour-0166-9009864", "name": "Challenger Core Men's 7.5\" Shorts", "price": "75.05", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/under-armour-0166-9009864-1.jpg"]} +{"brand": "Cotton On", "code": "cotton-on-1627-2412493", "name": "Everyday Fleece Shorts", "price": "90.30", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/cotton-on-1627-2412493-1.jpg", "https://static-my.zacdn.com/p/cotton-on-1627-2412493-2.jpg"]} +{"brand": "Connor", "code": "connor-3936-8387644", "name": "Jethro Cargo Short", "price": "116.91", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/connor-3936-8387644-1.jpg"]} +{"brand": "Nike", "code": "nike-9182-7836064", "name": "Miler Dri-FIT 5\" Brief-Lined Running Shorts", "price": "128.25", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/nike-9182-7836064-1.jpg", "https://static-my.zacdn.com/p/nike-9182-7836064-2.jpg", "https://static-my.zacdn.com/p/nike-9182-7836064-3.jpg", "https://static-my.zacdn.com/p/nike-9182-7836064-4.jpg", "https://static-my.zacdn.com/p/nike-9182-7836064-5.jpg"]} +{"brand": "Electro Denim Lab", "code": "electro-denim-lab-8284-8755973", "name": "Tailored Shorts", "price": "72.00", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/electro-denim-lab-8284-8755973-1.jpg", "https://static-my.zacdn.com/p/electro-denim-lab-8284-8755973-2.jpg", "https://static-my.zacdn.com/p/electro-denim-lab-8284-8755973-3.jpg", "https://static-my.zacdn.com/p/electro-denim-lab-8284-8755973-4.jpg"]} +{"brand": "Electro Denim Lab", "code": "electro-denim-lab-5883-6755973", "name": "Tailored Shorts", "price": "72.00", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/electro-denim-lab-5883-6755973-1.jpg", "https://static-my.zacdn.com/p/electro-denim-lab-5883-6755973-2.jpg", "https://static-my.zacdn.com/p/electro-denim-lab-5883-6755973-3.jpg"]} +{"brand": "Under Armour", "code": "under-armour-0103-4898864", "name": "Vanish Woven 2.0 Men's 8\" Shorts", "price": "139.30", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/under-armour-0103-4898864-1.jpg", "https://static-my.zacdn.com/p/under-armour-0103-4898864-2.jpg"]} +{"brand": "Under Armour", "code": "under-armour-9965-8909864", "name": "Vanish Woven 2.0 Men's 6\" Shorts", "price": "139.30", "category": "shorts", "imgs": ["https://static-my.zacdn.com/p/under-armour-9965-8909864-1.jpg", "https://static-my.zacdn.com/p/under-armour-9965-8909864-2.jpg"]} +{"brand": "H&M", "code": "hm-4916-5208854", "name": "Oversized Fit Printed boxy vest top", "price": "79.90", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/hm-4916-5208854-1.jpg", "https://static-my.zacdn.com/p/hm-4916-5208854-2.jpg"]} +{"brand": "H&M", "code": "hm-3300-2208854", "name": "Loose Fit Printed vest top", "price": "29.90", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/hm-3300-2208854-1.jpg", "https://static-my.zacdn.com/p/hm-3300-2208854-2.jpg"]} +{"brand": "H&M", "code": "hm-0791-0454064", "name": "Regular Fit Cotton vest top", "price": "49.90", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/hm-0791-0454064-1.jpg", "https://static-my.zacdn.com/p/hm-0791-0454064-2.jpg", "https://static-my.zacdn.com/p/hm-0791-0454064-3.jpg"]} +{"brand": "Jack & Jones", "code": "jack-jones-5952-7458954", "name": "Charge Bomber Jacket", "price": "140.34", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/jack-jones-5952-7458954-1.jpg"]} +{"brand": "Jordan", "code": "jordan-9145-3516754", "name": "Brooklyn Tricot Track Jacket", "price": "179.40", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/jordan-9145-3516754-1.jpg", "https://static-my.zacdn.com/p/jordan-9145-3516754-2.jpg", "https://static-my.zacdn.com/p/jordan-9145-3516754-3.jpg", "https://static-my.zacdn.com/p/jordan-9145-3516754-4.jpg"]} +{"brand": "Under Armour", "code": "under-armour-5681-1265724", "name": "Meridian ¼ Zip Jacket", "price": "179.50", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/under-armour-5681-1265724-1.jpg"]} +{"brand": "Desigual", "code": "desigual-1923-6483534", "name": "Contrast collar jacket", "price": "494.50", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/desigual-1923-6483534-1.jpg", "https://static-my.zacdn.com/p/desigual-1923-6483534-2.jpg", "https://static-my.zacdn.com/p/desigual-1923-6483534-3.jpg"]} +{"brand": "MANGO Man", "code": "mango-man-1932-0456174", "name": "Denim Overshirt With Pockets", "price": "359.90", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/mango-man-1932-0456174-1.jpg", "https://static-my.zacdn.com/p/mango-man-1932-0456174-2.jpg", "https://static-my.zacdn.com/p/mango-man-1932-0456174-3.jpg", "https://static-my.zacdn.com/p/mango-man-1932-0456174-4.jpg", "https://static-my.zacdn.com/p/mango-man-1932-0456174-5.jpg", "https://static-my.zacdn.com/p/mango-man-1932-0456174-6.jpg", "https://static-my.zacdn.com/p/mango-man-1932-0456174-7.jpg"]} +{"brand": "Nike", "code": "nike-3433-7819904", "name": "Stride Repel UV Running Jacket", "price": "369.55", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/nike-3433-7819904-1.jpg"]} +{"brand": "Fitleasure", "code": "fitleasure-6107-0154414", "name": "Men's Navy Training Track Jacket", "price": "69.00", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/fitleasure-6107-0154414-1.jpg", "https://static-my.zacdn.com/p/fitleasure-6107-0154414-2.jpg", "https://static-my.zacdn.com/p/fitleasure-6107-0154414-3.jpg"]} +{"brand": "MANGO Man", "code": "mango-man-7595-9855154", "name": "Wool Overshirt with Buttons", "price": "399.90", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/mango-man-7595-9855154-1.jpg"]} +{"brand": "Fitleasure", "code": "fitleasure-7254-8558544", "name": "UltraFlex Men’s Premium Grey Athleisure Blazer", "price": "93.50", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/fitleasure-7254-8558544-1.jpg", "https://static-my.zacdn.com/p/fitleasure-7254-8558544-2.jpg", "https://static-my.zacdn.com/p/fitleasure-7254-8558544-3.jpg", "https://static-my.zacdn.com/p/fitleasure-7254-8558544-4.jpg", "https://static-my.zacdn.com/p/fitleasure-7254-8558544-5.jpg", "https://static-my.zacdn.com/p/fitleasure-7254-8558544-6.jpg"]} +{"brand": "Tommy Hilfiger", "code": "tommy-hilfiger-3102-7968464", "name": "Washed Flag Embroidery Denim Chore Jacket - Th Mainline", "price": "477.60", "category": "jackets-coats", "imgs": ["https://static-my.zacdn.com/p/tommy-hilfiger-3102-7968464-1.jpg"]} +{"brand": "ADIDAS", "code": "adidas-6064-9829864", "name": "Lifestyle Barreda Shoes Men White JP5928", "price": "167.40", "category": "shoes-sports", "imgs": ["https://static-my.zacdn.com/p/adidas-6064-9829864-1.jpg"]} +{"brand": "ADIDAS", "code": "adidas-7467-6165014", "name": "Runfalcon 5 Running Shoes", "price": "149.40", "category": "shoes-sports", "imgs": ["https://static-my.zacdn.com/p/adidas-7467-6165014-1.jpg"]} +{"brand": "ADIDAS", "code": "adidas-3835-1386964", "name": "Lifestyle VL Court 3.0 Shoes Men White JR8635", "price": "197.40", "category": "shoes-sports", "imgs": ["https://static-my.zacdn.com/p/adidas-3835-1386964-1.jpg", "https://static-my.zacdn.com/p/adidas-3835-1386964-2.jpg", "https://static-my.zacdn.com/p/adidas-3835-1386964-3.jpg", "https://static-my.zacdn.com/p/adidas-3835-1386964-4.jpg"]} +{"brand": "ADIDAS", "code": "adidas-3902-2344624", "name": "Adizero EVO SL Shoes", "price": "669.00", "category": "shoes-sports", "imgs": ["https://static-my.zacdn.com/p/adidas-3902-2344624-1.jpg", "https://static-my.zacdn.com/p/adidas-3902-2344624-2.jpg", "https://static-my.zacdn.com/p/adidas-3902-2344624-3.jpg"]} +{"brand": "New Balance", "code": "new-balance-7276-4452864", "name": "FuelCell Rebel v5 Running Shoes", "price": "454.30", "category": "shoes-sports", "imgs": ["https://static-my.zacdn.com/p/new-balance-7276-4452864-1.jpg"]} +{"brand": "Nike", "code": "nike-8718-6589064", "name": "Air Monarch IV Workout Shoes", "price": "172.00", "category": "shoes-sports", "imgs": ["https://static-my.zacdn.com/p/nike-8718-6589064-1.jpg", "https://static-my.zacdn.com/p/nike-8718-6589064-2.jpg"]} +{"brand": "Under Armour", "code": "under-armour-5894-7391454", "name": "Halo Runner SE Men's Running Shoes", "price": "359.40", "category": "shoes-sports", "imgs": ["https://static-my.zacdn.com/p/under-armour-5894-7391454-1.jpg", "https://static-my.zacdn.com/p/under-armour-5894-7391454-2.jpg", "https://static-my.zacdn.com/p/under-armour-5894-7391454-3.jpg", "https://static-my.zacdn.com/p/under-armour-5894-7391454-4.jpg", "https://static-my.zacdn.com/p/under-armour-5894-7391454-5.jpg"]} +{"brand": "New Balance", "code": "new-balance-0654-0936864", "name": "603 Lifestyle Shoes", "price": "242.46", "category": "shoes-sports", "imgs": ["https://static-my.zacdn.com/p/new-balance-0654-0936864-1.jpg"]} +{"brand": "H&M", "code": "hm-1277-2755944", "name": "Canvas espadrilles", "price": "79.90", "category": "shoes-sneakers", "imgs": ["https://static-my.zacdn.com/p/hm-1277-2755944-1.jpg", "https://static-my.zacdn.com/p/hm-1277-2755944-2.jpg", "https://static-my.zacdn.com/p/hm-1277-2755944-3.jpg"]} +{"brand": "ADIDAS", "code": "adidas-7643-4564454", "name": "Handball Spezial Shoes", "price": "299.40", "category": "shoes-sneakers", "imgs": ["https://static-my.zacdn.com/p/adidas-7643-4564454-1.jpg"]} +{"brand": "PUMA", "code": "puma-7967-0821193", "name": "Puma Shuffle", "price": "202.30", "category": "shoes-sneakers", "imgs": ["https://static-my.zacdn.com/p/puma-7967-0821193-1.jpg", "https://static-my.zacdn.com/p/puma-7967-0821193-2.jpg", "https://static-my.zacdn.com/p/puma-7967-0821193-3.jpg"]} +{"brand": "Veja", "code": "veja-7352-9893832", "name": "Campo Chromefree Sneakers", "price": "750.00", "category": "shoes-sneakers", "imgs": ["https://static-my.zacdn.com/p/veja-7352-9893832-1.jpg"]} +{"brand": "New Balance", "code": "new-balance-2615-5260964", "name": "530 Lifestyle Shoes", "price": "326.97", "category": "shoes-sneakers", "imgs": ["https://static-my.zacdn.com/p/new-balance-2615-5260964-1.jpg"]} +{"brand": "Tommy Hilfiger", "code": "tommy-hilfiger-6391-3924164", "name": "The Greenwich Edge Leather Sneakers - Tommy Jeans", "price": "336.60", "category": "shoes-sneakers", "imgs": ["https://static-my.zacdn.com/p/tommy-hilfiger-6391-3924164-1.jpg", "https://static-my.zacdn.com/p/tommy-hilfiger-6391-3924164-2.jpg"]} +{"brand": "Veja", "code": "veja-6434-3897744", "name": "Campo Chromefree Sneakers", "price": "750.00", "category": "shoes-sneakers", "imgs": ["https://static-my.zacdn.com/p/veja-6434-3897744-1.jpg", "https://static-my.zacdn.com/p/veja-6434-3897744-2.jpg"]} +{"brand": "Veja", "code": "veja-7346-2897744", "name": "Volley O.T. Leather Sneakers", "price": "725.00", "category": "shoes-sneakers", "imgs": ["https://static-my.zacdn.com/p/veja-7346-2897744-1.jpg"]} +{"brand": "New Balance", "code": "new-balance-8119-1352864", "name": "2010 Lifestyle Shoes", "price": "443.40", "category": "shoes-sneakers", "imgs": ["https://static-my.zacdn.com/p/new-balance-8119-1352864-1.jpg"]} +{"brand": "New Balance", "code": "new-balance-8362-1136064", "name": "9060 Lifestyle Shoes", "price": "438.83", "category": "shoes-sneakers", "imgs": ["https://static-my.zacdn.com/p/new-balance-8362-1136064-1.jpg", "https://static-my.zacdn.com/p/new-balance-8362-1136064-2.jpg", "https://static-my.zacdn.com/p/new-balance-8362-1136064-3.jpg", "https://static-my.zacdn.com/p/new-balance-8362-1136064-4.jpg", "https://static-my.zacdn.com/p/new-balance-8362-1136064-5.jpg"]} +{"brand": "H&M", "code": "hm-3543-9075264", "name": "Chelsea boots", "price": "149.90", "category": "shoes-boots", "imgs": ["https://static-my.zacdn.com/p/hm-3543-9075264-1.jpg", "https://static-my.zacdn.com/p/hm-3543-9075264-2.jpg", "https://static-my.zacdn.com/p/hm-3543-9075264-3.jpg"]} +{"brand": "Dr. Cardin", "code": "dr-cardin-3393-7528864", "name": "Dr Cardin Men Pillow Foam Faux Leather Chelsea Boot Shoe TBP-6855", "price": "87.57", "category": "shoes-boots", "imgs": ["https://static-my.zacdn.com/p/dr-cardin-3393-7528864-1.jpg", "https://static-my.zacdn.com/p/dr-cardin-3393-7528864-2.jpg", "https://static-my.zacdn.com/p/dr-cardin-3393-7528864-3.jpg", "https://static-my.zacdn.com/p/dr-cardin-3393-7528864-4.jpg", "https://static-my.zacdn.com/p/dr-cardin-3393-7528864-5.jpg", "https://static-my.zacdn.com/p/dr-cardin-3393-7528864-6.jpg", "https://static-my.zacdn.com/p/dr-cardin-3393-7528864-7.jpg"]} +{"brand": "Hunter Boots", "code": "hunter-boots-3275-8311344", "name": "MEN'S PLAY SHORT BOOTS", "price": "569.00", "category": "shoes-boots", "imgs": ["https://static-my.zacdn.com/p/hunter-boots-3275-8311344-1.jpg", "https://static-my.zacdn.com/p/hunter-boots-3275-8311344-2.jpg", "https://static-my.zacdn.com/p/hunter-boots-3275-8311344-3.jpg", "https://static-my.zacdn.com/p/hunter-boots-3275-8311344-4.jpg", "https://static-my.zacdn.com/p/hunter-boots-3275-8311344-5.jpg", "https://static-my.zacdn.com/p/hunter-boots-3275-8311344-6.jpg"]} +{"brand": "MANGO Man", "code": "mango-man-1327-3466174", "name": "Suede Leather Ankle Boots", "price": "479.90", "category": "shoes-boots", "imgs": ["https://static-my.zacdn.com/p/mango-man-1327-3466174-1.jpg"]} +{"brand": "CLARKS", "code": "clarks-5776-2553954", "name": "Mens Wallabee Shoes - Blue Green Stripe", "price": "759.00", "category": "shoes-boots", "imgs": ["https://static-my.zacdn.com/p/clarks-5776-2553954-1.jpg", "https://static-my.zacdn.com/p/clarks-5776-2553954-2.jpg"]} +{"brand": "CLARKS", "code": "clarks-2605-0299864", "name": "Mens Paulson Up Boots - Black Leather", "price": "499.00", "category": "shoes-boots", "imgs": ["https://static-my.zacdn.com/p/clarks-2605-0299864-1.jpg", "https://static-my.zacdn.com/p/clarks-2605-0299864-2.jpg", "https://static-my.zacdn.com/p/clarks-2605-0299864-3.jpg", "https://static-my.zacdn.com/p/clarks-2605-0299864-4.jpg", "https://static-my.zacdn.com/p/clarks-2605-0299864-5.jpg", "https://static-my.zacdn.com/p/clarks-2605-0299864-6.jpg"]} +{"brand": "H&M", "code": "hm-5557-0718954", "name": "Regular Fit Merino wool jumper", "price": "199.90", "category": "hoodies", "imgs": ["https://static-my.zacdn.com/p/hm-5557-0718954-1.jpg", "https://static-my.zacdn.com/p/hm-5557-0718954-2.jpg", "https://static-my.zacdn.com/p/hm-5557-0718954-3.jpg", "https://static-my.zacdn.com/p/hm-5557-0718954-4.jpg", "https://static-my.zacdn.com/p/hm-5557-0718954-5.jpg"]} +{"brand": "H&M", "code": "hm-3731-3617164", "name": "Regular Fit Rib-knit cotton jumper", "price": "139.90", "category": "hoodies", "imgs": ["https://static-my.zacdn.com/p/hm-3731-3617164-1.jpg", "https://static-my.zacdn.com/p/hm-3731-3617164-2.jpg", "https://static-my.zacdn.com/p/hm-3731-3617164-3.jpg", "https://static-my.zacdn.com/p/hm-3731-3617164-4.jpg"]} +{"brand": "Harley-Davidson", "code": "harley-davidson-0026-6212544", "name": "H-D® Realtree® APG Pullover Hoodie", "price": "392.00", "category": "hoodies", "imgs": ["https://static-my.zacdn.com/p/harley-davidson-0026-6212544-1.jpg", "https://static-my.zacdn.com/p/harley-davidson-0026-6212544-2.jpg", "https://static-my.zacdn.com/p/harley-davidson-0026-6212544-3.jpg", "https://static-my.zacdn.com/p/harley-davidson-0026-6212544-4.jpg"]} +{"brand": "GAP", "code": "gap-6704-2923414", "name": "Heritage Logo Zip Hoodie", "price": "223.25", "category": "hoodies", "imgs": ["https://static-my.zacdn.com/p/gap-6704-2923414-1.jpg"]} +{"brand": "GAP", "code": "gap-1231-2925864", "name": "Arch Sherpa Zip Hoodie", "price": "175.14", "category": "hoodies", "imgs": ["https://static-my.zacdn.com/p/gap-1231-2925864-1.jpg", "https://static-my.zacdn.com/p/gap-1231-2925864-2.jpg"]} +{"brand": "Cotton On", "code": "cotton-on-6809-2319164", "name": "Zip Up Hoodie", "price": "119.40", "category": "hoodies", "imgs": ["https://static-my.zacdn.com/p/cotton-on-6809-2319164-1.jpg"]} +{"brand": "Cotton On", "code": "cotton-on-6979-5652664", "name": "Box Fit Hoodie", "price": "74.50", "category": "hoodies", "imgs": ["https://static-my.zacdn.com/p/cotton-on-6979-5652664-1.jpg"]} +{"brand": "GAP", "code": "gap-1516-1025214", "name": "French Heritage Logo Zip Hoodie", "price": "153.23", "category": "hoodies", "imgs": ["https://static-my.zacdn.com/p/gap-1516-1025214-1.jpg"]} diff --git a/catalogue/products.jsonl b/catalogue/products.jsonl new file mode 100644 index 0000000..14bc119 --- /dev/null +++ b/catalogue/products.jsonl @@ -0,0 +1,30 @@ +{"brand":"G-Shock","code":"g-shock-9407-8856754","name":"DW-5600STT-1 Stranger Things Men Digital Watch Black Resin Band","price":"536.90","imgs":["https://static-my.zacdn.com/p/g-shock-9407-8856754-1.jpg","https://static-my.zacdn.com/p/g-shock-9407-8856754-2.jpg","https://static-my.zacdn.com/p/g-shock-9407-8856754-3.jpg","https://static-my.zacdn.com/p/g-shock-9407-8856754-4.jpg"]} +{"brand":"H&M","code":"hm-4512-4118154","name":"Relaxed Fit Printed T-shirt","price":"29.90","imgs":["https://static-my.zacdn.com/p/hm-4512-4118154-1.jpg","https://static-my.zacdn.com/p/hm-4512-4118154-2.jpg","https://static-my.zacdn.com/p/hm-4512-4118154-3.jpg","https://static-my.zacdn.com/p/hm-4512-4118154-4.jpg","https://static-my.zacdn.com/p/hm-4512-4118154-5.jpg"]} +{"brand":"H&M","code":"hm-4434-6513734","name":"Regular Jeans","price":"99.90","imgs":["https://static-my.zacdn.com/p/hm-4434-6513734-1.jpg","https://static-my.zacdn.com/p/hm-4434-6513734-2.jpg","https://static-my.zacdn.com/p/hm-4434-6513734-3.jpg","https://static-my.zacdn.com/p/hm-4434-6513734-4.jpg","https://static-my.zacdn.com/p/hm-4434-6513734-5.jpg","https://static-my.zacdn.com/p/hm-4434-6513734-6.jpg","https://static-my.zacdn.com/p/hm-4434-6513734-7.jpg"]} +{"brand":"Lacoste","code":"lacoste-2548-0143293","name":"Cotton T-shirt with Breast Pocket","price":"184.50","imgs":["https://static-my.zacdn.com/p/lacoste-2548-0143293-1.jpg","https://static-my.zacdn.com/p/lacoste-2548-0143293-2.jpg"]} +{"brand":"Tommy Hilfiger","code":"tommy-hilfiger-9447-9378554","name":"Heritage Logo Washed Baseball Cap - Tommy Jeans","price":"116.45","imgs":["https://static-my.zacdn.com/p/tommy-hilfiger-9447-9378554-1.jpg"]} +{"brand":"Cotton On","code":"cotton-on-4997-4515844","name":"Baggy Worker Pants","price":"114.45","imgs":["https://static-my.zacdn.com/p/cotton-on-4997-4515844-1.jpg"]} +{"brand":"GAP","code":"gap-0235-5300064","name":"Arch Logo T-Shirt","price":"60.95","imgs":["https://static-my.zacdn.com/p/gap-0235-5300064-1.jpg"]} +{"brand":"Giordano","code":"giordano-4368-3595764","name":"Men's Cotton Short Sleeve Print Tee 13096224","price":"28.03","imgs":["https://static-my.zacdn.com/p/giordano-4368-3595764-1.jpg","https://static-my.zacdn.com/p/giordano-4368-3595764-2.jpg","https://static-my.zacdn.com/p/giordano-4368-3595764-3.jpg","https://static-my.zacdn.com/p/giordano-4368-3595764-4.jpg","https://static-my.zacdn.com/p/giordano-4368-3595764-5.jpg"]} +{"brand":"Tommy Hilfiger","code":"tommy-hilfiger-1700-4474664","name":"Square Buckle Leather Belt - TH Mainline","price":"164.34","imgs":["https://static-my.zacdn.com/p/tommy-hilfiger-1700-4474664-1.jpg"]} +{"brand":"Superdry","code":"superdry-9309-2538964","name":"Vintage Script T-Shirt","price":"80.73","imgs":["https://static-my.zacdn.com/p/superdry-9309-2538964-1.jpg","https://static-my.zacdn.com/p/superdry-9309-2538964-2.jpg","https://static-my.zacdn.com/p/superdry-9309-2538964-3.jpg","https://static-my.zacdn.com/p/superdry-9309-2538964-4.jpg","https://static-my.zacdn.com/p/superdry-9309-2538964-5.jpg","https://static-my.zacdn.com/p/superdry-9309-2538964-6.jpg","https://static-my.zacdn.com/p/superdry-9309-2538964-7.jpg","https://static-my.zacdn.com/p/superdry-9309-2538964-8.jpg"]} +{"brand":"Under Armour","code":"under-armour-4667-9098804","name":"Tech™ Short Sleeve T-Shirt","price":"44.24","imgs":["https://static-my.zacdn.com/p/under-armour-4667-9098804-1.jpg"]} +{"brand":"Under Armour","code":"under-armour-8098-5270454","name":"Tech™ Polo Shirt","price":"114.56","imgs":["https://static-my.zacdn.com/p/under-armour-8098-5270454-1.jpg"]} +{"brand":"Tommy Hilfiger","code":"tommy-hilfiger-1774-1674664","name":"Six-Panel Baseball Cap - Th Mainline","price":"143.34","imgs":["https://static-my.zacdn.com/p/tommy-hilfiger-1774-1674664-1.jpg"]} +{"brand":"Tommy Hilfiger","code":"tommy-hilfiger-2343-0274664","name":"1985 Regular Polo Shirt - Th Mainline","price":"238.14","imgs":["https://static-my.zacdn.com/p/tommy-hilfiger-2343-0274664-1.jpg"]} +{"brand":"Cotton On","code":"cotton-on-1718-5169564","name":"Box Fit Text T-Shirt","price":"78.33","imgs":["https://static-my.zacdn.com/p/cotton-on-1718-5169564-1.jpg"]} +{"brand":"Giordano","code":"giordano-5996-5838764","name":"Men's Cotton Short Sleeve Print Tee 13096299","price":"28.03","imgs":["https://static-my.zacdn.com/p/giordano-5996-5838764-1.jpg","https://static-my.zacdn.com/p/giordano-5996-5838764-2.jpg","https://static-my.zacdn.com/p/giordano-5996-5838764-3.jpg","https://static-my.zacdn.com/p/giordano-5996-5838764-4.jpg","https://static-my.zacdn.com/p/giordano-5996-5838764-5.jpg"]} +{"brand":"GAP","code":"gap-7655-7195541","name":"Gap Original Arch Crew","price":"143.36","imgs":["https://static-my.zacdn.com/p/gap-7655-7195541-1.jpg"]} +{"brand":"Under Armour","code":"under-armour-3483-5826724","name":"Performance Tech™ Unisex 3-Pack Crew Socks","price":"52.43","imgs":["https://static-my.zacdn.com/p/under-armour-3483-5826724-1.jpg","https://static-my.zacdn.com/p/under-armour-3483-5826724-2.jpg","https://static-my.zacdn.com/p/under-armour-3483-5826724-3.jpg"]} +{"brand":"Electro Denim Lab","code":"electro-denim-lab-8086-0906954","name":"Chino Pants","price":"65.00","imgs":["https://static-my.zacdn.com/p/electro-denim-lab-8086-0906954-1.jpg","https://static-my.zacdn.com/p/electro-denim-lab-8086-0906954-2.jpg","https://static-my.zacdn.com/p/electro-denim-lab-8086-0906954-3.jpg","https://static-my.zacdn.com/p/electro-denim-lab-8086-0906954-4.jpg","https://static-my.zacdn.com/p/electro-denim-lab-8086-0906954-5.jpg"]} +{"brand":"Tommy Hilfiger","code":"tommy-hilfiger-2147-5964664","name":"Flag Raglan Sleeves Relaxed Jersey T-Shirt - Th Mainline","price":"179.94","imgs":["https://static-my.zacdn.com/p/tommy-hilfiger-2147-5964664-1.jpg"]} +{"brand":"Cotton On","code":"cotton-on-6238-0809164","name":"3-Pack Essential Crew Socks","price":"33.05","imgs":["https://static-my.zacdn.com/p/cotton-on-6238-0809164-1.jpg"]} +{"brand":"Cotton On","code":"cotton-on-4057-8012493","name":"Everyday Fleece Shorts","price":"60.95","imgs":["https://static-my.zacdn.com/p/cotton-on-4057-8012493-1.jpg"]} +{"brand":"Cotton On","code":"cotton-on-6382-0063854","name":"Box Fit Graphic Hoodie","price":"142.03","imgs":["https://static-my.zacdn.com/p/cotton-on-6382-0063854-1.jpg"]} +{"brand":"Trendyol","code":"trendyol-3907-9299634","name":"Grimelanj Regular Fit Plaid Knitted Shorts Pajama Set TMNSS25PT00034","price":"79.03","imgs":["https://static-my.zacdn.com/p/trendyol-3907-9299634-1.jpg"]} +{"brand":"Camel Active","code":"camel-active-5848-1383954","name":"C BY CAMEL ACTIVE OUTDOOR HIKING TRAVEL BACKPACK - 51250081","price":"269.00","imgs":["https://static-my.zacdn.com/p/camel-active-5848-1383954-1.jpg","https://static-my.zacdn.com/p/camel-active-5848-1383954-2.jpg","https://static-my.zacdn.com/p/camel-active-5848-1383954-3.jpg","https://static-my.zacdn.com/p/camel-active-5848-1383954-4.jpg","https://static-my.zacdn.com/p/camel-active-5848-1383954-5.jpg","https://static-my.zacdn.com/p/camel-active-5848-1383954-6.jpg","https://static-my.zacdn.com/p/camel-active-5848-1383954-7.jpg"]} +{"brand":"Lacoste","code":"lacoste-8093-9302554","name":"Cotton T-shirt","price":"258.30","imgs":["https://static-my.zacdn.com/p/lacoste-8093-9302554-1.jpg"]} +{"brand":"Under Armour","code":"under-armour-6462-2343264","name":"Under Armour UA Tech™ Vent Men's Short Sleeve","price":"83.44","imgs":["https://static-my.zacdn.com/p/under-armour-6462-2343264-1.jpg","https://static-my.zacdn.com/p/under-armour-6462-2343264-2.jpg","https://static-my.zacdn.com/p/under-armour-6462-2343264-3.jpg","https://static-my.zacdn.com/p/under-armour-6462-2343264-4.jpg","https://static-my.zacdn.com/p/under-armour-6462-2343264-5.jpg"]} +{"brand":"Tommy Hilfiger","code":"tommy-hilfiger-1355-5874664","name":"Pool Slide Sandals - TH Mainline","price":"191.94","imgs":["https://static-my.zacdn.com/p/tommy-hilfiger-1355-5874664-1.jpg"]} +{"brand":"Calvin Klein","code":"calvin-klein-3281-1558554","name":"Low Rise Trunks 3 Pack - Calvin Klein Underwear","price":"180.45","imgs":["https://static-my.zacdn.com/p/calvin-klein-3281-1558554-1.jpg"]} +{"brand":"Swiss Polo","code":"swiss-polo-9699-7969744","name":"Men's Backpack - Black","price":"103.55","imgs":["https://static-my.zacdn.com/p/swiss-polo-9699-7969744-1.jpg","https://static-my.zacdn.com/p/swiss-polo-9699-7969744-2.jpg"]} diff --git a/catalogue/reframe_129.py b/catalogue/reframe_129.py new file mode 100644 index 0000000..faca5d2 --- /dev/null +++ b/catalogue/reframe_129.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Replace product 129's square thumbnail with the reframed 762x1100 version. + +The source was a 2400x2400 square. The storefront grid renders at aspect-3/4 +with object-cover, so a 1:1 image loses 12.5% off each side — enough to cut the +heel and the toe off the shoe. Every other shoe in the catalogue is 762x1100 +with the subject at ~93% width, so this reframes 129 to match its siblings. + +Uploads the new asset at position 1, verifies it is really there, then removes +the square original. --apply is required; the default is a dry run. +""" +import argparse +import json +import pathlib +import sys +import urllib.error +import urllib.request +import uuid + +import os as _os + + +def _spree_admin_key() -> str: + """The Spree admin API key. Required — never defaulted.""" + key = _os.environ.get("SPREE_ADMIN_KEY", "").strip() + if not key: + raise SystemExit( + "SPREE_ADMIN_KEY is not set.\n" + " export SPREE_ADMIN_KEY=\n" + "It is deliberately not stored in this file — see README.md." + ) + return key + + +SPREE_API = "https://api.jux-lab.com/api/v3/admin" +SPREE_KEY = _spree_admin_key() +SLUG = "129-adidas-lifestyle-barreda-shoes-men-white-jp5928" +NEW_IMAGE = pathlib.Path( + "/root/Opus/Projects/PeakyBuildr/scraped-catalogue/products/" + "129-adidas-lifestyle-barreda-shoes-men-white-jp5928/image-1-reframed.jpg" +) + + +def fatal(msg): + print(f"FATAL: {msg}", file=sys.stderr) + sys.exit(1) + + +def multipart(fields, file_field, filename, blob, content_type): + boundary = uuid.uuid4().hex + parts = [] + for k, v in fields.items(): + parts.append( + f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode() + ) + parts.append( + f'--{boundary}\r\nContent-Disposition: form-data; name="{file_field}"; ' + f'filename="{filename}"\r\nContent-Type: {content_type}\r\n\r\n'.encode() + + blob + + b"\r\n" + ) + parts.append(f"--{boundary}--\r\n".encode()) + return b"".join(parts), f"multipart/form-data; boundary={boundary}" + + +def api(path, method="GET", data=None, ctype=None): + headers = {"X-Spree-Api-Key": SPREE_KEY} + if ctype: + headers["Content-Type"] = ctype + req = urllib.request.Request( + f"{SPREE_API}{path}", data=data, headers=headers, method=method + ) + try: + with urllib.request.urlopen(req, timeout=180) as r: + raw = r.read() + return r.status, (json.loads(raw) if raw else {}) + except urllib.error.HTTPError as e: + fatal(f"{method} {path} -> HTTP {e.code}: {e.read().decode()[:400]}") + + +# This Spree build serves flat objects, not JSON:API — the fields are on the +# object itself, not under "attributes" — and it pages on `limit`, ignoring +# `per_page` entirely. +def find_product(slug): + page = 1 + while True: + _, d = api(f"/products?limit=100&page={page}") + for p in d.get("data", []): + if p.get("slug") == slug: + return p + if page >= d.get("meta", {}).get("pages", 1): + return None + page += 1 + + +def media_list(pid): + _, d = api(f"/products/{pid}/media?limit=100") + return d.get("data", []) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--apply", action="store_true", help="actually write to Spree") + args = ap.parse_args() + + if not NEW_IMAGE.exists(): + fatal(f"{NEW_IMAGE} missing") + + p = find_product(SLUG) + if not p: + fatal(f"product {SLUG} not found") + pid = p["id"] + print(f"product {pid} {p.get('name')}") + + before = media_list(pid) + print(f"media before: {len(before)}") + for m in before: + print(f" id={m['id']} position={m.get('position')} {str(m.get('original_url',''))[:80]}") + + if not args.apply: + print("\nDRY RUN — re-run with --apply to upload and remove the square original.") + return + + old_ids = [m["id"] for m in before] + + body, ctype = multipart( + {"type": "Spree::Image", "position": "1"}, + "attachment", + NEW_IMAGE.name, + NEW_IMAGE.read_bytes(), + "image/jpeg", + ) + status, resp = api(f"/products/{pid}/media", "POST", body, ctype) + new_id = (resp.get("data") or resp).get("id") + print(f"\nupload -> HTTP {status}, new asset id={new_id}") + if status == 202 or not new_id: + fatal("Spree answered without a serialized asset — cannot confirm the image landed") + + # Re-read rather than trusting the create response: media#create has a path + # that enqueues a background job and answers 202 with no body. + after = media_list(pid) + if new_id not in [m["id"] for m in after]: + fatal(f"asset {new_id} is not in the product media after upload") + print(f"verified: asset {new_id} present, {len(after)} media on product") + + for oid in old_ids: + st, _ = api(f"/products/{pid}/media/{oid}", "DELETE") + print(f"removed old asset {oid} -> HTTP {st}") + + final = media_list(pid) + print(f"\nmedia after: {len(final)}") + for m in final: + print(f" id={m['id']} position={m.get('position')} {str(m.get('original_url',''))[:80]}") + + +if __name__ == "__main__": + main() diff --git a/catalogue/reprice_zar.py b/catalogue/reprice_zar.py new file mode 100644 index 0000000..f31a5a8 --- /dev/null +++ b/catalogue/reprice_zar.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +""" +Reprice all products from the source MYR catalogue. + +Replaces the prices written by fix_prices.py, which multiplied by 4.8 instead of +dividing — putting every product on the store at ~23x its real price. + +Writes BOTH currency rows the store supports: + ZAR (store default) = price_myr * MYR_TO_ZAR + USD (still in supported_currencies) = price_myr * MYR_TO_USD + +The USD row is corrected in the same pass on purpose: leaving the old 23x rows +behind means anyone switching the store currency to USD sees the broken prices +again, with nothing to warn them. + +Rates are fetched live and printed. No hardcoded rate, no fallback rate — if the +fetch fails this exits rather than pricing a live store off a guess. + +Usage: + reprice_zar.py --dry-run # show the changes, write nothing (do this first) + reprice_zar.py # apply, then read back and check every row +""" +import json +import sys +import time +from pathlib import Path + +import requests + +import os as _os + + +def _spree_admin_key() -> str: + """The Spree admin API key. Required — never defaulted.""" + key = _os.environ.get("SPREE_ADMIN_KEY", "").strip() + if not key: + raise SystemExit( + "SPREE_ADMIN_KEY is not set.\n" + " export SPREE_ADMIN_KEY=\n" + "It is deliberately not stored in this file — see README.md." + ) + return key + + +API = "https://api.jux-lab.com/api/v3/admin" +H = {"X-Spree-Api-Key": _spree_admin_key(), "Content-Type": "application/json"} +PRODUCTS = Path("/root/Opus/Projects/PeakyBuildr/scraped-catalogue/products") +DRY = "--dry-run" in sys.argv + + +def fatal(msg): + print(f"\nFATAL: {msg}", file=sys.stderr) + sys.exit(1) + + +# --- rates ------------------------------------------------------------------- +def get_rates(): + """Live MYR base rates. Loud failure beats a stale constant — see fix_prices.py.""" + try: + d = requests.get("https://open.er-api.com/v6/latest/MYR", timeout=20).json() + except Exception as e: + fatal(f"rate fetch failed: {e}") + r = d.get("rates") or {} + zar, usd = r.get("ZAR"), r.get("USD") + if not zar or not usd: + fatal(f"rate response missing ZAR/USD: {d}") + print(f"rates 1 MYR = {zar} ZAR = {usd} USD ({d.get('time_last_update_utc')})") + return float(zar), float(usd) + + +# --- source catalogue -------------------------------------------------------- +def load_source(): + prices = {} + for folder in sorted(PRODUCTS.iterdir()): + info = folder / "info.json" + if not info.exists(): + continue + myr = json.loads(info.read_text()).get("price_myr") + if myr is None: + fatal(f"{folder.name}/info.json has no price_myr") + prices[folder.name] = float(myr) + if not prices: + fatal(f"no source prices found under {PRODUCTS}") + print(f"source {len(prices)} products, MYR {min(prices.values()):.2f}-{max(prices.values()):.2f}") + return prices + + +def fetch_products(): + items, page = [], 1 + while True: + d = requests.get(f"{API}/products?per_page=100&page={page}", headers=H, timeout=60).json() + items += d.get("data", []) + if page >= d.get("meta", {}).get("pages", 1): + break + page += 1 + return items + + +def main(): + zar_rate, usd_rate = get_rates() + src = load_source() + items = fetch_products() + print(f"store {len(items)} products") + + planned, missing = {}, [] + for p in items: + slug = p["slug"] + myr = src.get(slug) + if myr is None: + missing.append(slug) + continue + planned[p["id"]] = { + "slug": slug, + "myr": myr, + "ZAR": round(myr * zar_rate, 2), + "USD": round(myr * usd_rate, 2), + } + + if missing: + # Not fatal: these keep their current prices. But they must be named, not + # silently skipped — a product left at 23x is the whole bug again. + print(f"\n{len(missing)} products have NO source price and will be LEFT UNCHANGED:") + for s in missing: + print(f" ! {s}") + + sample = list(planned.values())[:5] + print(f"\n{len(planned)} to reprice. Sample:") + for s in sample: + print(f" MYR {s['myr']:>7.2f} -> ZAR {s['ZAR']:>8.2f} / USD {s['USD']:>7.2f} {s['slug'][:44]}") + zars = [v["ZAR"] for v in planned.values()] + print(f" new ZAR range: {min(zars):.2f} - {max(zars):.2f}") + + if DRY: + print("\n--dry-run: nothing written.") + return + + # --- apply --------------------------------------------------------------- + print() + ok = fail = 0 + for n, (pid, s) in enumerate(planned.items(), 1): + body = {"prices": [ + {"amount": s["ZAR"], "currency": "ZAR"}, + {"amount": s["USD"], "currency": "USD"}, + ]} + try: + r = requests.patch(f"{API}/products/{pid}", headers=H, json=body, timeout=60).json() + except Exception as e: + fail += 1 + print(f" ERROR {s['slug'][:40]}: {e}") + continue + if "error" in r: + fail += 1 + print(f" FAIL {s['slug'][:40]}: {r['error'].get('message')}") + else: + ok += 1 + if n % 40 == 0: + print(f" {n}/{len(planned)}") + time.sleep(0.12) + print(f"patched ok={ok} fail={fail}") + + # --- read back ----------------------------------------------------------- + # The store returns 200 for a price write that never created a row in the + # store currency. Trusting that 200 is what hid 130 products for an hour. + print("\nreading back...") + time.sleep(2) + bad = [] + for p in fetch_products(): + want = planned.get(p["id"]) + if not want: + continue + a = p.get("attributes", p) + got = a.get("price") + cur = a.get("currency") + if got is None or abs(float(got) - want["ZAR"]) > 0.01 or cur != "ZAR": + bad.append((want["slug"], want["ZAR"], got, cur)) + if bad: + print(f"\n{len(bad)} products did NOT take the ZAR price:") + for slug, want, got, cur in bad[:15]: + print(f" ! {slug[:40]} want ZAR {want} got {got} {cur}") + sys.exit(1) + print(f"check ok: all {len(planned)} products report the expected ZAR price.") + + +if __name__ == "__main__": + main() diff --git a/docs/MOCKS.md b/docs/MOCKS.md new file mode 100644 index 0000000..aba265a --- /dev/null +++ b/docs/MOCKS.md @@ -0,0 +1,139 @@ +# 🏷️ MOCKS — every fake value in Pak Jabit + +**The convention:** anything standing in for a real credential, rate, or record is +tagged `#mock`. Going live = find them all, replace them, delete this list. + +```bash +# storefront + backend files +rg '#mock' ~/pak-jabit-store ~/pak-jabit +``` + +🔴 **`rg` cannot find half of them.** Mocks that live in the Spree **database** — +payment methods, test products, test orders, test users — have no file to grep. +This file is the authority; the inline tags are a convenience. When you add a +mock, add it here first. + +⚠️ **Never tag an env var inline.** Docker Compose's `env_file` does not strip +trailing comments — it takes the rest of the line as the value, so +`PHOTOROOM_API_KEY=abc123 #mock` makes the key literally `abc123 #mock` and +Photoroom fails with no error anywhere. The tag goes on its **own line above**, +which is safe in both Compose and dotenv. + +--- + +## Files — `rg '#mock'` finds these + +### Storefront `~/pak-jabit-store/.env.production` (celest-proto, 34.21.217.116) + +| Var | Why it's mock | Replace with | +|---|---|---| +| `EASYPARCEL_CLIENT_ID` | Malaysia-only courier, left from the MY phase | Bob Go credentials | +| `EASYPARCEL_CLIENT_SECRET` | same | Bob Go credentials | +| `STORE_POSTCODE` | `10150` = Penang, Malaysia | real SA warehouse postcode | +| `STORE_STATE` | `MY-07` = Penang, Malaysia | real SA province | + +The whole EasyParcel lane (`src/lib/easyparcel.ts`, `src/app/api/shipping/*`, +`src/app/api/webhooks/easyparcel`) is MY-only and dead for a South African store. +It points at `https://api.easyparcel.com` — the production host, not a sandbox. +It is not wired into the SA checkout path; it just hasn't been removed. + +### Backend `~/pak-jabit/.env` (pak-jabit-backend, 35.247.170.3) + +| Var | Why it's mock | Replace with | +|---|---|---| +| `PHOTOROOM_API_KEY` | sandbox key — **every output is watermarked** | live key from `/root/.photoroom/credentials` (house box), 10/month | + +### Storefront env — `SPREE_CARD_PAYMENT_METHOD_ID` + +Tagged in `.env.production`. Holds `pm_EfhxLZ9ck8`, the prefixed id of the Bogus +gateway. Repoint it at the Payfast payment method when that lands. The route that +reads it (`src/app/api/auth/cards/setup/route.ts`) errors loud with an explicit +message when it is unset — it never guesses an id, because a wrong one would +attach cards to whichever gateway happened to match. + +That route is itself `#mock`-shaped: it does BOTH halves of card setup in one +request, which only works because Bogus needs no card entry. A real gateway must +split it — server creates the session, the CLIENT collects the card in the +gateway's hosted field against `external_client_secret`, then the server +completes. Card numbers must never reach our server. The file says so at the top. + +--- + +## Database — grep will never find these + +### Payment methods + +| Name | Class | Why it's mock | +|---|---|---| +| `Test Card (sandbox)` | `Spree::Gateway::Bogus` | Fake card gateway, added 2026-09-15. Accepts `4111111111111111` (Visa), `5555555555554444` (MC), `378282246310005` (Amex), `6011111111111117` (Discover); **anything else is a forced decline**, which is how to test the failure path. `show_in_admin?` is false so it stays out of the admin list. | +| `Test Payment` | `Spree::PaymentMethod::Check` | Offline placeholder. No money moves. | + +Both carry `#mock` in their `description` column, visible in admin. + +**Replace with:** Payfast, once NSH's merchant registration lands. Payflex after. +Deactivate both of these before launch — a live store must not offer a gateway +that approves every card ending in the right digits. + +### Shipping methods + +| Name | Why it's mock | +|---|---| +| `Standard Delivery (South Africa)` | Flat **R99**, zone `South Africa` (ZA only), added 2026-09-15. The number is a PLACEHOLDER, not a quoted rate — it exists so SA checkout can complete at all. Replace with Bob Go once the SA courier account is registered. | + +Before this, ZA sat in the `Africa` zone with no method attached and every +physical method was bound to Malaysia/US, so a South African buyer got no quote +and checkout dead-ended. + +### Shipping rates per market — all placeholders + +| Market | Method | Rate | +|---|---|---| +| South Africa (ZAR) | `Standard Delivery (South Africa)` | R99 | +| Malaysia (MYR) | `EasyParcel Domestic` | **live EasyParcel quote** — not mock | +| United States (USD) | `Free Shipping` | $0 | + +🔴 **ZA and US are placeholders; Malaysia is real.** MY charges the courier the customer picked, verified server-side against a live EasyParcel quote. They exist so each market can complete a +checkout at all. A shipping rate only applies when its calculator's currency +matches the ORDER's currency — every non-SA method was priced in a currency no +order used, which produced no rate, no fulfillment, and a checkout with nothing +to charge. That, not zones or stock, is why Malaysian checkout died silently. + +`Standard Shipping` (USD $15) sits in the Malaysia zone and will never offer — +wrong currency for that market. Harmless, but it is noise to clear when the MY +lane is removed. + +### Products + +| Slug | Why it's mock | +|---|---| +| `studio-test-chinos` | Built 2026-09-15 proving the Image Studio pipe end to end. Kept deliberately as the worked example for colleagues testing the image editor. R259.11, watermarked sandbox images. **Delete before launch.** | + +That's 1 of 161 products. The other 160 are real scraped stock. + +### Orders + +14 orders, 4 completed, all test. They are priced in the **old currency at the +old 23× rate** — the newest completed one reads `$2,577.12`, which is neither +ZAR nor a real price. They predate both the price fix and the ZAR switch. +**Purge before launch** or the first real report is nonsense. + +### Users + +Test accounts matching `celeste-*@example.com` — created during API verification +on 2026-09-15. No real customers exist yet. + +--- + +## Not mock, but not real either + +These aren't fake credentials; they're claims the store makes that nothing backs. + +| Thing | Problem | +|---|---| +| **"Free shipping over R500"** | Hardcoded storefront copy. No shipping rule exists behind it. A buyer who qualifies gets charged anyway — or gets no quote at all, see below. | +| **SA shipping** | ZA is in the `Africa` zone with **no shipping method attached**. Every physical method is bound to Malaysia/US. A South African buyer gets no quote and cannot complete checkout. Blocked on courier registration. | +| **S/M/L/XL buttons** | Decorative. No `Spree::Variant` behind them — every product is one variant. | + +--- + +*Started 2026-09-15. Add to this file when you add a mock, not after.* diff --git a/docs/napkin-image-pipeline.md b/docs/napkin-image-pipeline.md new file mode 100644 index 0000000..05d2c1c --- /dev/null +++ b/docs/napkin-image-pipeline.md @@ -0,0 +1,169 @@ +# 🎨 Image Pipeline — Napkin Notes + +## What the spec wants +Seller uploads phone photo → system auto-standardizes → consistent product catalogue +- Background removal +- Colour correction +- Lighting normalization +- Resize/crop to exact same dimensions +- Store processed image against product variant + +## What we found (FAFO'd 2026-09-13) + +### 🥇 Photoroom (recommended for production) +- **URL:** https://app.photoroom.com/create (signed up as `celestjux`) +- **Docs:** https://docs.photoroom.com/ +- **Pricing:** Basic $20/mo (BG removal only) · Plus $100/mo (full pipeline) · Enterprise custom +- **Per image:** $0.02 (Basic) / $0.10 (Plus) +- **Apparel-specific features:** + - Ghost Mannequin — makes clothes look worn without a real model + - Virtual Model — AI fashion models wearing the clothes + - Virtual Try-On — customers see clothes on themselves + - AI Ironing — smooth wrinkles on clothes + - AI Relight — fix lighting + - AI Shadows — realistic shadows + - Product Staging, AI Backgrounds, AI Beautifier, AI Expand +- **Batch:** yes, directory/Google Drive/Sheets +- **Sandbox:** 1,000 free watermarked calls + 10 real for testing +- **Verdict:** Does EVERYTHING the spec asks in one API call. $100/mo for 1K images. + Ghost mannequin + AI ironing = killer for men's apparel. + +### 🥈 Bria AI (good BG removal, lighter features) +- **API key:** saved at `~/.bria/credentials` +- **Skills installed:** `/root/.claude/skills/bria-skills/` +- **Tested:** ✅ BG removal works (RMBG 2.0), 39% transparent pixels on test image +- **Features:** BG removal, relighting, upscale, product photography, object editing +- **No ghost mannequin, no AI ironing, no virtual try-on** +- **Verdict:** Good for BG removal + basic edits. Fallback to Photoroom for apparel-specific stuff. + +### 🥉 rembg (free, self-hosted, offline) +- **Installed:** ✅ via pip, u2net model (170MB) cached at `/root/.rembg/models/` +- **Tested:** ✅ works, decent edges, white-on-light handled ok +- **Limit:** BG removal ONLY. No colour correction, no lighting, no crop. +- **Speed:** ~20s on CPU per image +- **Verdict:** Free forever. Good offline fallback. Need OpenCV/Pillow on top for the rest. + +### Quick-RMBG / Claude Background Removal Plugin +- **Repo:** cloned at `/mnt/playground/Playground_V2/Claude-Background-Removal-Plugin/` +- **What it is:** Claude Code skill wrapper around rembg (single/two-pass/batch/Dolphin right-click) +- **pipx install blocked** by ctx git shim — not installed yet +- **Verdict:** Nice wrapper but not needed if we use Photoroom/Bria for the platform. + +## Architecture decision + +``` +Production: Photoroom Plus API ($100/mo) + └─ one call = bg removal + lighting + shadows + crop + resize + └─ ghost mannequin for clothes-on-invisible-model shots + └─ AI ironing for wrinkle-free product photos + +Fallback: Bria RMBG 2.0 (free tier / per-image) + └─ BG removal when Photoroom is down or over quota + +Offline: rembg + Pillow/OpenCV + └─ dev/testing, batch processing without API costs +``` + +## ✅ BUILT 2026-09-15 evening — the pipe is real, end to end + +``` +phone/disk photo -> Image Studio (admin page) -> Photoroom /v2/edit + -> unattached ActiveStorage blob -> review before/after + -> Apply -> Spree product (existing, or created here) +``` + +- **Admin page:** `/admin/image_studio` (sidebar, between Products and Customers). + Multi-upload from the device, background + size presets, per-file before/after, + then attach to an existing product **or create a new listing** (name, price, + publish toggle). Nothing touches the catalogue until Apply — the result is held + as an unattached blob, so Discard is just navigating away. +- **CLI:** `scraped-catalogue/photoroom_to_spree.py --slug [--live]` +- **Proven live:** a 6.52 MB phone PNG → 543 KB at 1000×1000; and two uploads → + a new published product visible on the storefront at R259.11. + +### The architecture question, settled + +Abang's call, and he was right: **the page is needed.** I had argued for a silent +auto-on-upload hook (the spec says *"seller uploads → system standardises"*), but +a seller wants to choose the shot and see the result before it ships. Fully +automatic means a bad cutout goes live silently and the customer finds it first. +**Manual control at the front, programmatic pipe behind it.** + +Photoroom's *Shopify Products* connector is a red herring for us — it exists for +merchants with no backend. We have one, so the plain API is the simpler path. + +### Money, accurately + +| | Reality | +|---|---| +| API free tier | **10 images/month**, not 7 days. 9 left as of 2026-09-15 | +| Sandbox | 1000/month, free, **every output watermarked** | +| 7-day trial | That's the **web app**, a separate meter. Its Batch tool does **250 images at once** through the browser and costs no API credits | +| Plus | $100/mo for the full pipeline | + +🔴 **Photoroom adds little to the existing 160 products.** They are scraped +studio shots already on white — background removal removes a background that is +already gone. Across 160 first-images there are only **3 aspect ratios and 4 +sizes**; framing is the sole real defect, and Pillow squares them for free, +offline. Photoroom earns its money on *phone photos of new stock*, which is +exactly what Image Studio is for. + +### Still open +- [ ] **Batch** — capped at `MAX_PHOTOS = 8`, synchronous, ~3-5s per image. More + needs a background job +- [ ] Currently on the **sandbox** key, so everything is watermarked. Flip + `PHOTOROOM_API_KEY` in the backend `.env` for clean output +- [x] ~~Pillow square-and-pad backfill for the existing 160~~ — **scrapped 2026-09-15, + measured and unnecessary.** See below. +- [ ] Never tested on a real phone photo from Lisa — the actual input this exists + for. Ghost mannequin and AI ironing have nothing to work with on a scraped + studio shot +- [ ] Virtual Try-On — could be a differentiator for the platform +- [ ] Self-hosted option — for v2, when volume justifies building our own pipeline + +## 📐 Framing survey — 2026-09-15, the backfill that wasn't needed + +I had the Pillow square-and-pad backfill queued as the next job. Measuring the +160 source images first killed it. The real distribution of **first images**: + +| Size | Ratio | Count | +|---|---|---| +| 762×1100 | 0.693 | 122 | +| 800×1200 / 801×1200 | 0.667 | 32 | +| 1200×1800 | 0.667 | 4 | +| 1524×2200 | 0.693 | 1 | +| **2400×2400** | **1.000** | **1** | + +The grid renders at `aspect-[3/4]` (0.75) with `object-cover`. At 0.693 the crop +takes 7.6% of the height; at 0.667 it takes 11%. The **entire** inconsistency +across 159 products is a 3.4% difference in vertical crop — invisible. Padding +them to square would also be self-defeating: a square displayed in a 3/4 frame +gets re-cropped, so the padding I just added is what gets cut. + +🔴 **One image was genuinely broken** — `129-adidas-lifestyle-barreda-shoes-men-white-jp5928`, +the only 1:1 in the catalogue. A square in a 3/4 frame loses **12.5% off each +side**, which cut the heel and the toe off the shoe. Its 22 sibling shoe +listings are all 762×1100 with the subject at 90-95% width and 24-33% height, so +it was reframed to match: crop to the subject bbox, scale to 93% width, centre on +the source's own background (234,238,239). 186KB → 65KB. Applied live via +`reframe_129.py --apply` (upload at position 1, verify by re-read, then delete +the original). + +**The lesson, not the fix:** the napkin said "3 aspect ratios and 4 sizes, +framing is the sole real defect" and I turned that into a 160-file job. The +numbers were right and the conclusion was wrong — nobody had checked how far +apart those ratios actually were. Measure the spread, not the count. + +### Spree API shape — two gotchas that cost a run + +- **Flat objects, not JSON:API.** `/api/v3/admin/products` returns fields on the + object itself (`p["slug"]`), not under `p["attributes"]["slug"]`. The helper in + `photoroom_to_spree.py` only survives because its fallback scan happens to use + the flat form. +- **`per_page` is silently ignored** — it pages on `limit`. Sending `per_page=100` + gets you 25 rows and no error, so a scan that stops at page 1 "finds" nothing. + Meta key is `pages`, not `total_pages`. + + +**Keys:** `/root/.photoroom/credentials` (chmod 600) on the house box; +`PHOTOROOM_API_KEY` in the backend `.env`. diff --git a/docs/napkin-payment-courier.md b/docs/napkin-payment-courier.md new file mode 100644 index 0000000..b1af080 --- /dev/null +++ b/docs/napkin-payment-courier.md @@ -0,0 +1,109 @@ +# 💳 Payment & Courier — Napkin Notes + +## Payment Gateways (SA Market) + +### Recommended Stack +| Gateway | What it covers | Fees | Priority | +|---------|---------------|------|----------| +| **Payfast** | Cards (Visa/MC), EFT, SnapScan, Masterpass | 3.5% + R2/tx | Must-have | +| **Payflex** | Buy now pay later (4 instalments) | They pay you upfront | High — big conversion booster for fashion | +| **Yoco** | Cards, tap-to-pay (if physical retail later) | 2.6-2.95%/tx | Nice-to-have | + +### Why this stack +- Payfast alone covers ~90% of SA payment methods +- Payflex is the killer feature for apparel — "R1,500 jacket? Pay R375 × 4" removes price friction +- Cards (Visa/MC/Amex) come free with Payfast +- EFT = still king in SA, people trust bank transfers + +### NOT needed (yet) +- Stripe — limited SA support, overkill for local-first +- PayPal — high fees, withdrawal pain in SA +- Crypto — niche, not the market + +### Integration notes +- Spree has payment provider plugin architecture — write adapter, plug in API keys +- Morne's company (NSH/INET) registers as merchant — they get the API keys +- We build the pipe, they plug in credentials +- Payfast has well-documented REST API + existing Spree community plugins + +--- + +## Courier / Logistics (SA Market) + +### Recommended: Bob Go (aggregator) +- **One API** → connects to 10+ SA couriers +- API docs: https://api-docs.bob.co.za/bobgo +- Couriers included: The Courier Guy, Pargo, RAM, SkyNet, Internet Express, DPD/Dawn Wing +- Features: compare rates, print waybills, track parcels +- Shopify integration exists → Spree adapter needed + +### What Bob Go solves (all 3 spec blockers) +| Spec Action Item | Status | +|-----------------|--------| +| 🔴 Identify suitable SA courier/API | ✅ Bob Go = one API, all couriers | +| 🔴 Confirm automated shipment creation | ✅ Bob Go API supports it | +| 🔴 Confirm waybill generation | ✅ Bob Go handles waybills | + +### Direct courier alternatives (if Bob Go doesn't fit) +- **The Courier Guy** — most popular SA e-commerce courier +- **Internet Express** — free API integration +- **CourierIT** — volume discounts +- **Evership** — another aggregator (Fastway, RAM, Aramex, TCG, DPD) + +### Shipping zones consideration +- SA is big — courier coverage varies by province +- Major metros (Joburg, Cape Town, Durban) = next day +- Rural = 3-5 days, some couriers don't cover +- Bob Go handles zone logic per courier + +--- + +## 🏷️ Mock Convention + +All test/sandbox values tagged with `#mock` in code and config. +When going live: `rg '#mock'` → find all → replace with production credentials. + +``` +# Example in config: +PAYFAST_MERCHANT_ID=10000100 #mock +PAYFAST_MERCHANT_KEY=46f0cd694581a #mock +PAYFAST_PASSPHRASE=jt7NOE43FZPn #mock +PAYFAST_MODE=sandbox #mock → change to "live" +BOBGO_API_KEY=test_xxxxxxxxxxxx #mock +PAYFLEX_API_KEY=staging_xxxxx #mock +``` + +--- + +## Open Questions +- [ ] Local SA only or international shipping? +- [ ] Currency: ZAR only or multi-currency? +- [ ] Who registers as merchant — NSH or a new entity? +- [ ] Cash on delivery — needed? (Still popular outside metros) +- [ ] Payflex merchant approval — timeline? + +--- + +## Architecture Summary + +``` +Buyer → Spree Storefront (Next.js) + ↓ + Checkout + ↓ + ┌────┴────┐ + │ Payfast │ → Cards, EFT, SnapScan + │ Payflex │ → Buy now pay later + └────┬────┘ + ↓ + Order Created + ↓ + Bob Go API → auto-select cheapest courier + ↓ + ┌────┴────┐ + │ Waybill │ → PDF generated + │ Tracking│ → number assigned + └────┬────┘ + ↓ + Customer gets tracking link +``` diff --git a/docs/napkin-storefront.md b/docs/napkin-storefront.md new file mode 100644 index 0000000..eca77d5 --- /dev/null +++ b/docs/napkin-storefront.md @@ -0,0 +1,111 @@ +# 🛒 Storefront — Napkin Notes + +Ideas and landmines for the shopper-facing side. Raised by abang 2026-09-15, +after the night we wired the region anchor and put the cart on the customer. + +--- + +## 1. Currency chosen after the total, not before 💡 + +**The idea:** let someone shop, reach the total, and *then* pick what currency to +pay in. More natural than being asked up front, before they know what anything +costs. + +**Why it's hard as things stand:** Spree fixes a cart's currency **and** market at +creation and neither can be changed afterwards. That is exactly why the anchor +had to come first — see `switchCurrency` in `CartProvider`, which cannot convert +a cart and instead builds a new one and re-adds every line. + +So "choose at the end" is really "rebuild the basket at the end." That already +works (the switcher does it), it just gets more expensive the fuller the basket, +and the totals move under the customer at the worst moment. + +**Worth trying:** show every currency's total on the cart/checkout page as a +read-only comparison, and only rebuild when they commit. They get the "see it +first" benefit without a silent re-price. The price rows already exist — all 160 +products carry ZAR, USD and MYR. + +--- + +## 2. 🔴 LANDMINE — one person, addresses in several countries + +**The case:** a customer lives in South Africa, has an office in Kuala Lumpur, +and family in the US. Perfectly ordinary. Our model does not survive it. + +**The mechanism.** The region anchor ties three things into one choice, because a +Spree market *is* all three: + +``` +currency -> market -> shippable countries -> shipping methods +``` + +So a customer anchored to ZAR can only deliver to ZA. To send something to their +KL office they must switch to MYR, which **rebuilds the cart and re-prices the +whole basket** — even though the only thing they wanted to change was where the +parcel goes. + +Worse, it is silent about the real reason. They'd read it as "this shop won't +deliver to my office," not "delivery country is welded to display currency." + +**What is actually separable:** currency and delivery country are independent in +principle — you can price in ZAR and ship to Malaysia. What welds them here is +that shipping methods are per-zone AND their calculators are per-currency (see +`MOCKS.md`, the bug that killed Malaysian checkout). A ZAR order finds no rate in +the Malaysia zone because every method there quotes in MYR. + +**Possible shapes, none built:** +- **Delivery country separate from currency.** Give each zone a method in every + supported currency. A ZAR order shipping to MY charges a ZAR rate. Most honest + to how people actually shop; most rows to maintain. +- **Ask on the address, not in the header.** The customer picks a delivery + address first and the store says plainly: *"Delivering to Malaysia switches + your prices to MYR."* Same restriction, but named instead of hidden. +- **Single market, international rates.** Drop the multi-market idea, price + everything in ZAR, treat non-SA as international shipping. Simplest by far, and + it matches the actual NSH brief — a South African store. + +⚠️ Decide this before more countries get added, not after. Every product carries +three price rows already; that is three times the pricing surface to keep honest +for a market nobody has confirmed we sell to. + +--- + +## 3. Dark / light mode selection + +The storefront is dark-only right now (`#0E0D0B` ground, `#EDE6DA` text, +`#C8873A` ochre). Abang shops in Brave with a dark setup, so dark stays the +default — this is about offering the choice, not flipping it. + +- Respect `prefers-color-scheme` on first visit, then let an explicit toggle win +- The palette is hardcoded as hex literals all through the components. A light + theme means lifting those into CSS custom properties first — that refactor is + most of the work, the theming itself is small after it +- Store the choice next to the region anchor: localStorage for guests, + `public_metadata` for signed-in customers (the plumbing for that exists now) + +--- + +## 4. Admin — stock and restock, actually tested + +Never exercised. Known state: + +- Two stock locations: `Default` (no country) and `Shop location` (US), both + active, both `propagate_all_variants = false` +- 161 products, every one a single variant — the S/M/L/XL buttons on the product + page are decorative, there is no `Spree::Variant` behind them +- No restock flow has been run, and no out-of-stock path has been seen end to end + +**Worth testing, in order:** +1. Set a variant to 0 on hand → does the storefront hide it, or let it be added + to a cart and fail at checkout? +2. Backorderable on/off — which one do we want for Lisa's stock? +3. Restock through admin → does the storefront pick it up, and how fast? +4. Two locations with nothing propagated: which one is a new product stocked in? + This is the likeliest silent trap. + +Variants matter here too: real S/M/L/XL means per-size stock, which is the whole +point of restocking apparel. Right now "medium out of stock" cannot be expressed. + +--- + +*Started 2026-09-15 from abang's list. Add to it rather than remembering.* diff --git a/docs/pak-jabit-manifest.md b/docs/pak-jabit-manifest.md new file mode 100644 index 0000000..f3913c6 --- /dev/null +++ b/docs/pak-jabit-manifest.md @@ -0,0 +1,346 @@ +# Pak Jabit Store — Project Manifest + +## 🏗️ Architecture + +Restructured 2026-09-15: backend and frontend now live on separate VMs, both +under jux-lab.com. Vercel is no longer in the path. + +``` +shop.jux-lab.com api.jux-lab.com + admin.jux-lab.com +(celest-proto, 34.21.217.116) (pak-jabit-backend, 35.247.170.3) +├── Next.js 16.3.5 storefront ├── Caddy (SSL) +│ systemd: pak-jabit-store └── Docker Compose +│ :3200 behind Caddy ├── Spree 5.6.1 (Rails 8.1.3) +├── Tailwind 4 ├── PostgreSQL 18 +└── calls api.jux-lab.com └── Mailpit +``` + +Also on celest-proto (unrelated): jux-lab.com landing, mmo.jux-lab.com, +vibein.jux-lab.com, purge relay. + +## 🔑 Credentials & Connections + +| What | Value | +|------|-------| +| **Backend VM** | `pak-jabit-backend` · asia-southeast1-a · e2-medium · 35.247.170.3 | +| **Frontend VM** | `celest-proto` · asia-southeast1-a · e2-custom-4-8192 · 34.21.217.116 | +| **SSH** | `ssh -i /root/.ssh/google_compute_engine ijat@` | +| **Storefront** | https://shop.jux-lab.com | +| **Spree Admin** | https://admin.jux-lab.com (bare host redirects to the panel) | +| **Admin Login** | juxtapo.opus@gmail.com / ayambasuhkaki | +| **Storefront API** | https://api.jux-lab.com/api/v3/store/ | +| **Admin API** | https://api.jux-lab.com/api/v3/admin/ | +| **Publishable key** | pk_RVVf1fTUW1T4nW9FTFACm92D (header: X-Spree-Api-Key) | +| **Secret key (admin)** | sk_ntDFxFE1KwiDwqGzpCBGTJ8N — all scopes | +| **API Media** | `?expand=media` on product endpoint returns full gallery | +| **Store Currency** | **ZAR** (switched 2026-09-15). Supported: ZAR, USD, MYR — one `spree_markets` row per currency; the `spree_stores.default_currency` COLUMN is ignored, `Spree::Stores::Markets` overrides the reader | +| **Channel (Online Store)** | ch_UkLWZg9DAJ | +| **GitHub Repo** | https://github.com/juxtapo9090/pak-jabit-store (private) | +| **GCloud Project** | project-19f6479b-f9db-4588-92a | +| **DNS** | Cloud DNS zone `jux-lab-com`, same project | +| **Firewall Rule** | `allow-spree` (tcp:80,443) — applies to all instances. :3000 closed 2026-09-15; Caddy reaches Spree on 127.0.0.1:3000 | + +## 🐳 Docker on the backend VM + +```bash +ssh -i /root/.ssh/google_compute_engine ijat@35.247.170.3 +cd ~/pak-jabit +docker compose up -d # start +docker compose down # stop +docker logs pak-jabit-web-1 # spree logs +docker exec pak-jabit-web-1 bin/rails runner "..." # rails console (~30s boot) +``` + +⚠️ `docker compose restart` does NOT re-read `.env`. Use `docker compose up -d` +after any env change. + +## 🖥️ Storefront on celest-proto + +```bash +ssh ijat@34.21.217.116 +cd ~/pak-jabit-store +npm run build && sudo systemctl restart pak-jabit-store +sudo journalctl -u pak-jabit-store -f +``` + +⚠️ Delete `.env.local` before building — Next.js gives it precedence over +`.env.production`, and the Vercel-era copy points at the old backend. + +## 📦 Products + +- **160 products** live (30 original + 130 imported 2026-09-15) +- Scraped from Zalora (source prices are **MYR**), 500+ images in ActiveStorage +- Priced from source at the live rate 2026-09-15: ZAR R99.26-R3185.09 · USD $6.11-$196.02 · MYR RM24.90-RM799.00 +- Imported via the Spree Admin API v3 (`scraped-catalogue/bulk_import.py`) +- Brands: G-Shock, H&M, Lacoste, Tommy Hilfiger, Cotton On, GAP, Giordano, + Superdry, Under Armour, Calvin Klein, Camel Active, Nike, Urban Revivo, + Trendyol, Swiss Polo, and more + +### 🔴 Five gates a product must pass to appear on the storefront + +Learned the hard way twice — 130 products invisible on 2026-09-15 morning, and +again that evening when Image Studio created products that looked perfect and +showed nothing. The scope is `Spree::Product.available(Time.current, currency)`, +defined in `spree_core-5.6.1/app/models/concerns/spree/product_scopes.rb:315`. +Read it rather than guessing; every gate below is a clause in it. + +1. **`status: 'active'`** and not discontinued. +2. **A price row in the store currency.** `POST /products` silently ignores a + scalar `"price"`. It needs `"prices": [{"amount": N, "currency": "ZAR"}]`. + The clause is `with_currency(currency)`, which joins + `variants_including_master: :prices` — no row in that exact currency, no + product. +3. **A `Spree::ProductPublication` row for the channel.** Visibility is + **per-channel**, not per-product: `for_channel` INNER JOINs + `spree_product_publications`, so a product with no publication row is dropped + whatever its status. Channels here are `1 Online Store` and `2 Wholesale`. + Creating a product in Rails does **not** create this row. +4. **Purchasable.** Either stock on hand, or `track_inventory: false` on the + master **plus a stock item per active location**. `track_inventory: false` + alone is not enough — with zero stock items `in_stock?` is false, + `purchasable?` is false, and the product is excluded. +5. **Timestamps at or before `cutoff.beginning_of_minute`.** The scope truncates + the cutoff DOWN to the minute, so `published_at = Time.current` reads as the + *future* and the product stays hidden until the next minute ticks over. Set + `Time.current.beginning_of_minute` or it looks broken for up to 59 seconds + and then mysteriously fixes itself. + +`Spree::Admin::ImageStudioController#build_new_product` does all five and then +re-checks `available?` before reporting success — copy that shape. + + +## 🎨 Design Tokens (THANDO) + +| Token | Value | +|-------|-------| +| ink | #0E0D0B | +| ochre | #C8873A | +| bone | #EDE6DA | +| bone-light | #F5F1EB | +| ochre-dark | #A86E2D | +| Font | Instrument Sans (Google Fonts) | +| Default theme | Dark | + +## ☁️ NextCloudPi (Team File Sharing) + +| What | Value | +|------|-------| +| **URL** | https://cloudmi/ | +| **WebDAV** | https://cloudmi/remote.php/dav/files/juxtapo/ | +| **User** | juxtapo | +| **App Password** | Bk7qS-Pe5zk-7gWoY-kpc6E-KWYky | +| **Box** | C-fu's mirah (192.168.1.22) | + +## 🚚 EasyParcel (MY Shipping) + +| What | Value | +|------|-------| +| **Developer Hub** | https://developer.easyparcel.com | +| **App Name** | Pak Jabit Store | +| **Client ID** | 5be15621-f711-485b-bc79-15256747ce65 | +| **Client Secret** | 9a1e611e-c4e2-4d1a-87c8-f695c1323e63 | +| **Account** | Muhammad Aizat (#10935549) | +| **Auth** | OAuth 2.0 | +| **API Docs** | https://developer.easyparcel.com (Open API Documentation) | +| **Sandbox** | Available (connect from app config page) | +| **Redirect URI** | `https://shop.jux-lab.com/api/easyparcel/callback` — must match the developer hub exactly | +| **Token store** | `~/pak-jabit-store/.easyparcel-token.json` (chmod 600) | +| **Sender address** | `STORE_POSTCODE=10150` / `STORE_STATE=MY-07` (Penang) in `.env.production` | +| **Coverage** | All 16 MY states verified live — 20 couriers peninsular, 14-15 Sabah/Sarawak | +| **Re-auth** | Open the callback URL in a browser; EasyParcel asks for login, then writes the token | + +### EasyParcel token handling (fixed 2026-09-15) + +The access token lives 10 hours and **cannot** be extended — that is EasyParcel's +server-side policy, and it does not need extending: `getToken()` renews it with the +refresh token automatically, and writes the rotated refresh token back. + +🔴 **The original code kept the token in a module-level variable**, so every restart, +redeploy or serverless cold start dropped it and the next quote died with "run OAuth +flow first". On Vercel it would have looked random. Now persisted to disk — verified +by restarting the service mid-session and getting 20 live quotes straight after. + +🔴 **`req.nextUrl.origin` is wrong behind Caddy** — it resolved to `localhost:3200` +and produced a redirect URI EasyParcel would reject. Use `APP_BASE_URL` instead. + +## ⚠️ Known Issues / TODO + +- [x] ~~Currency USD → ZAR~~ done 2026-09-15, all 160 products in ZAR/USD/MYR +- [ ] Payment gateway not wired (Payfast — see napkin-payment-courier.md) +- [ ] Courier: EasyParcel is in for MY; Bob Go still needed for SA +- [ ] No product variants (size/colour) — every product is master-only +- [ ] `track_inventory: false` on all products — fine for demo, not for real stock +- [ ] Admin panel has no lockout or rate limiting — Devise `lockable` is off and + there is no rack-attack, so login attempts are unlimited. Worth an IP + allowlist or basic auth on the admin.jux-lab.com Caddy block before this + goes anywhere real +- [ ] Old Spree containers still on celest-proto (stopped, volumes intact) — + delete once the new backend has proven itself for a few days +- [ ] No backups on the backend VM — daily snapshots exist for celest-proto only + +## 🗺️ Planned — customer data & image pipeline + +### Customer account data (UI only — the data layer already exists) +Spree ships this; we just haven't surfaced it in the storefront. + +| Need | Where it already lives | +|---|---| +| Saved addresses | `spree_addresses` · `GET/POST/PATCH/DELETE /api/v3/store/customers/me/addresses` | +| Saved cards | `spree_credit_cards` · `GET /api/v3/store/customers/me/credit_cards` | + +🔴 **Never store raw card data.** `spree_credit_cards` holds `last_digits`, +`cc_type`, `month`/`year`, and `gateway_customer_id` / `gateway_payment_profile_id` +— a **gateway token**, not the PAN, and no CVV column at all. That is the +PCI-correct design and it is already correct. Cards only populate once a gateway +(Payfast) is wired, so this sequences after payment. + +### Photoroom batch pipeline + +✅ **Built 2026-09-15 evening.** Two ways in, same pipe: + +- **Image Studio** — custom Spree admin page at `/admin/image_studio`. Upload + photos from the device (multiple, `accept="image/*"` so a phone opens the + camera roll) → Photoroom → before/after review → attach to an existing product + or create a new listing. Nothing touches the catalogue until Apply; discarding + is just navigating away, since the result is held as an unattached blob. +- **CLI** — `scraped-catalogue/photoroom_to_spree.py --slug [--live] + [--position 1]`. Same pipe, scriptable, with a read-back check. + +🔴 **Batch is NOT built.** One at a time, capped at `MAX_PHOTOS = 8` per request +because each image is a 3-5s synchronous round trip and more starts risking the +web server timeout. A real batch needs a background job. + +**Keys** live at `/root/.photoroom/credentials` (chmod 600) on the house box, and +`PHOTOROOM_API_KEY` in the backend `.env`. Currently the **sandbox** key — +1000/month free, every output watermarked. The live key has **10 images/month** +on the free tier (9 left as of 2026-09-15); the 7-day trial is the *web app*, a +separate meter from API credits, and its Batch tool does 250 images at once +through the browser without touching API quota. + +⚠️ Photoroom adds little to the scraped catalogue — those images are already +studio shots on white, and the only real inconsistency is framing (2 aspect +ratios, 4 sizes across 160 products). Its value is phone photos of new stock, +which is what Image Studio is for. A free Pillow square-and-pad would fix the +existing 160. + +## 📜 Session Log — 2026-09-14 (midnight build) + +1. Discovered old `spree-app` VM deleted, `celest-proto` available with jux-lab.com +2. Installed Docker, cloned spree_starter, `docker compose up` +3. Created admin user, loaded 30 products via Rails script +4. Uploaded 76 product images to Spree ActiveStorage +5. Published products to "Online Store" channel (`Spree::ProductPublication`) +6. Scaffolded Next.js storefront with THANDO design tokens +7. Wired storefront to Spree API v3 (`X-Spree-Api-Key`, `?expand=media`) +8. Deployed to Vercel (pak-jabit-store-juxtapo9090.vercel.app) +9. Pushed to GitHub (juxtapo9090/pak-jabit-store) +10. Luc found `?expand=media` param for full image gallery +11. Built Zalora-style vertical thumbnail gallery (ProductImages component) +12. Hardcoded dark theme as default + +## 📜 Session Log — 2026-09-15 (restructure) + +1. Found the "deleted" `spree-app` VM alive in the `jux-mmo` project — stopped, + then deleted it. Its admin key never matched because each Spree instance + HMACs tokens with its own `SECRET_KEY_BASE`. +2. Imported 130 products via the Spree Admin API v3 (proper API, not Rails + runner — the presentation story is "automated import pipeline"). +3. Spent an hour on 130 invisible products. Three stacked causes, all silent: + no `published_at`, not purchasable, and no price row at all. +4. Built `pak-jabit-backend` VM, migrated Postgres + 207MB ActiveStorage. + Kept `SECRET_KEY_BASE` so API keys and image URLs stayed valid. +5. Split the domain: api + admin → backend VM, shop → celest-proto. +6. Moved the storefront off Vercel onto celest-proto (systemd + Caddy). + A leftover `.env.local` from Vercel silently built it against the old + backend twice before we caught it. +7. Deleted the Vercel project. One project, two VMs, one domain. +8. Added `admin.jux-lab.com` — panel split from the API by hostname, so it can be + locked down separately later. Tested in a real browser: no bounce back to api. +9. Security pass: admin email off `spree@example.com`, password off `spree123`, + and closed `:3000` at the firewall (it was open to the internet over plain + HTTP, bypassing Caddy entirely). +10. Cart proven end-to-end on the new stack: create → add 2x item → $5,154.24. + +## 📜 Session Log — 2026-09-15 (evening: ZAR, currency switcher, Image Studio, fail2ban) + +1. **Backups** — `Projects/PeakyBuildr/backup-pak-jabit.sh`, nightly 04:30 via + `pak-jabit-backup.timer` on the house box → `/root/Backups/pak-jabit`. + pg_dump + ActiveStorage rsync + `.env` + compose + **the bind-mounted custom + code** (none of which is in the database). Restore proven into a throwaway + PG18 cluster: 160 products, 3200 blobs, 0 blobs missing their file. + `RESTORE.md` sits beside the dumps. +2. **The 23x price bug** — `fix_prices.py:6` set `MYR_TO_USD = 4.8` and + *multiplied*. 4.8 is the USD→MYR rate; MYR→USD is a divide. Every product was + listed at 4.8² = **23.04x** its real price (a MYR 537 watch at $2,577). + Repriced all 160 from source at the live rate. +3. **USD → ZAR** — store now quotes rand. 160 rows each in ZAR/USD/MYR. +4. **Currency switcher** on the storefront (ZAR · USD · MYR), URL-param driven so + the server fetches real Spree price rows — nothing converted in the browser. + The cart deliberately ignores it and stays on the store default. +5. **Image Studio** — custom admin page, see the Photoroom section above. +6. **fail2ban** — three jails, see Security below. + +## 🔒 Security (2026-09-15 evening) + +**fail2ban**, enabled at boot on the backend VM, banning at iptables so offenders +never reach Caddy or Rails: + +| Jail | Trigger | Ban | +|---|---|---| +| `caddy-admin-login` | 8 POSTs to `/admin_users/sign_in` in 10 min | 24h | +| `caddy-scan` | 25 × 4xx in 10 min | 1h | +| `sshd` | 5 failed SSH in 10 min | 24h | + +`ignoreip = 127.0.0.1/8 ::1 34.21.217.116 27.125.248.0/24` — celest-proto must +never be banned (it pulls product images constantly; banning it takes the shop +down), and the /24 is abang's ISP block, which his public IP rotates inside. +An allports ban on that range would also kill SSH to the box. + +Caddy has **no `rate_limit` module** in the standard build, which is why this is +fail2ban and not a Caddy directive. + +🔴 **Caddy had no access log until tonight** — errors only, so successful +requests left no trace whatsoever. Now JSON to journald (a file under +`/var/log/caddy/` fails to open even with the directory writable, and Caddy +fails the *whole service* on start rather than skipping the log — it dropped the +site for about a minute finding that out). + +⚠️ **Unresolved:** `42.188.122.106` (Windows, Brave 153, carrying a session +cookie) requested `/admin/api_keys`, `/admin/store/edit` and `/admin/admin_users`. +Abang's browser is Linux on `27.125.248.0/24` — different machine. Those specific +requests 502'd mid-restart, but with no access logs before tonight any earlier +success is invisible. Most likely a colleague, since abang expects colleagues to +have access. `sign_in_count` is 0 but Devise trackable looks disabled, so that +number proves nothing. + +🔴 **There is exactly ONE admin account**, shared. Every session is +indistinguishable, which is precisely why the above cannot be attributed. Give +each person their own account (Settings → Users) before more people get access. + +## 💣 Landmines hit today + +Every one of these returned success while being wrong. Worth knowing before +touching this stack again. + +| Landmine | What it looked like | The actual cause | +|---|---|---| +| **Scalar `price` is silently dropped** | `POST /products` returns 200, product exists, price is `null` | Spree wants `prices: [{amount, currency}]`. No price row in the store currency → excluded from `available(Time.current, currency)` → invisible on the storefront, no error anywhere | +| **`published_at` left null** | `add_products` returns `{product_count: 130}` | The publication row is created but not live unless you pass `published_at` | +| **Not purchasable** | Product active, available, published | No stock and `track_inventory: true`. Needs stock or `track_inventory: false` | +| **`.env.local` beats `.env.production`** | Build succeeds, only 30 of 160 pages | Next.js precedence. The Vercel-era file pointed at the old backend, which was still running and still serving 30 products — so the build read stale data and reported nothing wrong. Cost two full rebuilds | +| **`docker compose restart` ignores `.env`** | Env edited, behaviour unchanged | `restart` reuses the existing container config. Needs `up -d` | +| **Admin API key looked broken** | `find_by_secret_token` → no match, every time | The key was copied from a *different* Spree instance. Each one HMACs tokens with its own `SECRET_KEY_BASE` | +| **"Deleted" VM was still running** | Manifest said deleted; nothing in the project list | It was in a different GCP project (`jux-mmo`), still billing | +| **OAuth token in a module variable** | Shipping worked, then randomly didn't | Process memory dies on restart/redeploy/cold start. Persist the refresh token to disk or DB | +| **`req.nextUrl.origin` behind a proxy** | OAuth redirect_uri came out as `localhost:3200` | Next.js sees the internal listener, not the public host. Use an explicit `APP_BASE_URL` | +| **100KB API body cap** | Image upload → `request_too_large` on a 115KB jpg | `Spree::Api::Config[:max_request_body_size]` default. 🔴 **The fix recorded here was never live.** `config/initializers/api_body_limit.rb` existed on the host with the 10MB value but the container never loaded it — the compose file mounts no app code, so the file was dead. Fixed by bind-mounting it (2026-09-15 evening). A runtime assignment also does nothing, but not for the reason written before: it isn't read at boot, it just isn't *persisted*, so it only affects the process that ran it | +| **Prebuilt image mounts no app code** | Initializer written, never ran | `ghcr.io/spree/spree` ships its own `/rails`. Anything custom needs an explicit bind mount in `docker-compose.yml`. This silently killed the 10MB body-limit fix for a whole day | +| **`add_routes` from an initializer is a no-op** | Sidebar entry appears, path helper does not exist | Spree draws engine routes BEFORE `config/initializers` run. Routes must go in `config/routes.rb` | +| **New asset defaults to position 1** | Appended an image, it became the product thumbnail | Spree defaults `position` to 1. A watermarked sandbox image went live as a public thumbnail this way. Always send an explicit position | +| **`max(:position)` on `media` misses half the gallery** | Appended image landed mid-gallery at position 2 | A gallery is the UNION of product-level `media` and legacy assets on the master variant. Clear both | +| **`product.stores << store`** | `NoMethodError` on a Relation | Products `belongs_to :store` (singular) in 5.6. Use `store:` on create | +| **`published_at = Time.current` hides the product** | Created, active, priced, stocked — invisible for up to 59s | The scope compares against `cutoff.beginning_of_minute`, truncating DOWN. Use `Time.current.beginning_of_minute` | +| **`curl -o` loses binary bodies here** | HTTP 200, `content-length: 758983`, zero-byte file | The rtk proxy layer eats it. Use Python for binary downloads on the house box | + +Full write-up: `/mnt/playground/Server_files/Debug-Logs/2026-09-15_celeste_pak-jabit-restructure-and-invisible-products.md` + diff --git a/storefront/.easyparcel-token.json b/storefront/.easyparcel-token.json new file mode 100644 index 0000000..9971cc7 --- /dev/null +++ b/storefront/.easyparcel-token.json @@ -0,0 +1 @@ +{"access_token":"eyJhbGciOiJIUzI1NiJ9.eyJhcHAiOnsiY2xpZW50X2lkIjoiNWJlMTU2MjEtZjcxMS00ODViLWJjNzktMTUyNTY3NDdjZTY1In0sInVzZXIiOnsiaWQiOiIyMGYyYjk0NTIzOWZhNmE2YjAwODI2ODM1ZmJhNWQ5YlI0Wlo3SVhTdS9BM0dLb2lzWUNFdEE9PSIsImVhc3lfYWNjb3VudF9pZCI6ImFhZDdhYTBjLWVmYjMtNDVkMi1iNzgwLTlkYTgzYmRiZDhlMiIsImFjY291bnRfaWQiOiIxMDkzNTU3MSJ9LCJhY2NvdW50X2FwcF91dWlkIjoiYzI5ZjI5YzYtY2M1My00YjE1LWE4NTEtNjY3OGUxN2JmMDk1IiwiaWF0IjoxNzg5NDcxMTQ5LCJpc3MiOiJlYXN5cGFyY2VsIiwiYXVkIjoiZWFzeXBhcmNlbCIsImV4cCI6MTc4OTUwNzE0OX0.fJ-INDL0sJb9bPqiJWlaiuZ08Aw9yhRYdPHHYn6rkNs","refresh_token":"de885b0754817f63c445f278703166c83535eb7455e149962cfda8305c9bab11","expires_at":1789507149231} \ No newline at end of file diff --git a/storefront/.env.production b/storefront/.env.production new file mode 100644 index 0000000..e5e6796 --- /dev/null +++ b/storefront/.env.production @@ -0,0 +1,16 @@ +NEXT_PUBLIC_SPREE_API_URL=https://api.jux-lab.com +NEXT_PUBLIC_SPREE_API_KEY=pk_RVVf1fTUW1T4nW9FTFACm92D +# #mock — Malaysia-only courier, left over from the MY phase. The store sells to South Africa now — Bob Go replaces this. +EASYPARCEL_CLIENT_ID=5be15621-f711-485b-bc79-15256747ce65 +# #mock — see EASYPARCEL_CLIENT_ID above. +EASYPARCEL_CLIENT_SECRET=9a1e611e-c4e2-4d1a-87c8-f695c1323e63 +EASYPARCEL_TOKEN_PATH=/home/ijat/pak-jabit-store/.easyparcel-token.json +APP_BASE_URL=https://shop.jux-lab.com +# #mock — 10150 is Penang, Malaysia — placeholder origin for EasyParcel quotes. Needs the real SA warehouse postcode. +STORE_POSTCODE=10150 +# #mock — MY-07 is Penang, Malaysia. Needs the real SA province. +STORE_STATE=MY-07 +# #mock — prefixed id of the card gateway. Currently points at Spree::Gateway::Bogus +# ("Test Card (sandbox)"), which approves any card and mints one ending 4242. +# Repoint at the Payfast payment method once NSH merchant registration lands. See MOCKS.md. +SPREE_CARD_PAYMENT_METHOD_ID=pm_EfhxLZ9ck8 diff --git a/storefront/.gitignore b/storefront/.gitignore new file mode 100644 index 0000000..665ed08 --- /dev/null +++ b/storefront/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +.next/ +.vercel + +# .env.production is committed on purpose — see ../LANDMINE.md. This repo lives +# on the house's private Gitea; a clone that runs beats a secrets handover. +# Local-only overrides still stay out: +.env.local diff --git a/storefront/AGENTS.md b/storefront/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/storefront/AGENTS.md @@ -0,0 +1,9 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/storefront/CLAUDE.md b/storefront/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/storefront/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/storefront/README.md b/storefront/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/storefront/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/storefront/eslint.config.mjs b/storefront/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/storefront/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/storefront/next-env.d.ts b/storefront/next-env.d.ts new file mode 100644 index 0000000..ce4e94a --- /dev/null +++ b/storefront/next-env.d.ts @@ -0,0 +1,7 @@ +/// +/// +import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/storefront/next.config.ts b/storefront/next.config.ts new file mode 100644 index 0000000..1f516a9 --- /dev/null +++ b/storefront/next.config.ts @@ -0,0 +1,18 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "api.jux-lab.com", + }, + { + protocol: "http", + hostname: "api.jux-lab.com", + }, + ], + }, +}; + +export default nextConfig; diff --git a/storefront/package-lock.json b/storefront/package-lock.json new file mode 100644 index 0000000..ce0b941 --- /dev/null +++ b/storefront/package-lock.json @@ -0,0 +1,6896 @@ +{ + "name": "pak-jabit-store", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pak-jabit-store", + "version": "0.1.0", + "dependencies": { + "next": "16.3.5", + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.3.5", + "tailwindcss": "^4", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.3.0.tgz", + "integrity": "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", + "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@next/env": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.5.tgz", + "integrity": "sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.5.tgz", + "integrity": "sha512-PGfSeItHJ12DH8t+6sEbuMe59NE5rAhCfgk06QKTH2ne9VUL1JlaXdYXY3B8RaiF2SO50rDYvd39TulP6A6xZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "4.9.1", + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/eslint-plugin-next/node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@next/eslint-plugin-next/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.5.tgz", + "integrity": "sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.5.tgz", + "integrity": "sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.5.tgz", + "integrity": "sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.5.tgz", + "integrity": "sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.5.tgz", + "integrity": "sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.5.tgz", + "integrity": "sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.5.tgz", + "integrity": "sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.5.tgz", + "integrity": "sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz", + "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "postcss": "^8.5.16", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", + "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/type-utils": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.70.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", + "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz", + "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", + "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.70.0", + "@typescript-eslint/types": "^8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", + "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", + "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", + "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", + "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", + "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.70.0", + "@typescript-eslint/tsconfig-utils": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz", + "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", + "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.2.0.tgz", + "integrity": "sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "es-shim-unscopables": "^1.1.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz", + "integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.427", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz", + "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", + "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.4.0.tgz", + "integrity": "sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.5.tgz", + "integrity": "sha512-wPjq9MLQuWykHs8tsm2gH6OJThk6N27L8Te2JatlaGZ7jT1gtgGmJKukygL28xOlWsg5J1a1bGy/PvLyQC8OYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "16.3.5", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.5.tgz", + "integrity": "sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==", + "license": "MIT", + "dependencies": { + "@next/env": "16.3.5", + "@swc/helpers": "0.5.23", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.5.23", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.3.5", + "@next/swc-darwin-x64": "16.3.5", + "@next/swc-linux-arm64-gnu": "16.3.5", + "@next/swc-linux-arm64-musl": "16.3.5", + "@next/swc-linux-x64-gnu": "16.3.5", + "@next/swc-linux-x64-musl": "16.3.5", + "@next/swc-win32-arm64-msvc": "16.3.5", + "@next/swc-win32-x64-msvc": "16.3.5", + "sharp": "^0.35.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-releases": { + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.1.0.tgz", + "integrity": "sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "get-intrinsic": "^1.3.0", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.4", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz", + "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.70.0", + "@typescript-eslint/parser": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.4.tgz", + "integrity": "sha512-AXSD6hvGdvRjajG/l1cC+d6IrhH+sjmPKtYeQdJIK8MFJl3LyClzS+o/YsVC+zQZPupAaeH5skwwm8YqYH7BqA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/storefront/package.json b/storefront/package.json new file mode 100644 index 0000000..a6b1c4f --- /dev/null +++ b/storefront/package.json @@ -0,0 +1,26 @@ +{ + "name": "pak-jabit-store", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "next": "16.3.5", + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.3.5", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/storefront/postcss.config.mjs b/storefront/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/storefront/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/storefront/public/file.svg b/storefront/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/storefront/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/storefront/public/globe.svg b/storefront/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/storefront/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/storefront/public/next.svg b/storefront/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/storefront/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/storefront/public/vercel.svg b/storefront/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/storefront/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/storefront/public/window.svg b/storefront/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/storefront/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/storefront/src/app/account/page.tsx b/storefront/src/app/account/page.tsx new file mode 100644 index 0000000..7a17623 --- /dev/null +++ b/storefront/src/app/account/page.tsx @@ -0,0 +1,705 @@ +"use client"; + +import { useAuth } from "@/components/AuthProvider"; +import { useCallback, useEffect, useState } from "react"; +import { useCountries } from "@/lib/geo"; +import { useCart } from "@/components/CartProvider"; +import { CURRENCIES, parseCurrency, type CurrencyCode } from "@/lib/currency"; +import { + listAddresses, + createAddress, + updateAddress, + deleteAddress, + listCards, + addTestCard, + deleteCard, + updateProfile, + Address, + AddressInput, + SavedCard, +} from "@/lib/auth"; + +const LABEL_PRESETS = ["Home", "Office"]; + +const EMPTY_ADDRESS: AddressInput = { + label: "Home", + first_name: "", + last_name: "", + address1: "", + address2: "", + city: "", + state_name: "", + postal_code: "", + phone: "", + country_iso: "ZA", +}; + +const inputClass = + "w-full bg-[#0E0D0B] border border-[#EDE6DA]/20 rounded-lg px-3 py-2 text-[#EDE6DA] text-sm placeholder:text-[#EDE6DA]/30 focus:border-[#C8873A] focus:outline-none"; +const labelClass = "block text-xs text-[#EDE6DA]/50 mb-1"; + +export default function AccountPage() { + const { user, token, logout, isLoading, setSession } = useAuth(); + const { currency, switchCurrency, switching, cart } = useCart(); + const [regionError, setRegionError] = useState(""); + + const [addresses, setAddresses] = useState([]); + const [cards, setCards] = useState([]); + + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + const [form, setForm] = useState({ first_name: "", last_name: "", phone: "" }); + const [message, setMessage] = useState(""); + + const [addrForm, setAddrForm] = useState(EMPTY_ADDRESS); + const [addrEditingId, setAddrEditingId] = useState(null); + const [addrOpen, setAddrOpen] = useState(false); + const [addrSaving, setAddrSaving] = useState(false); + const [addrError, setAddrError] = useState(""); + + const [cardBusy, setCardBusy] = useState(false); + const [cardError, setCardError] = useState(""); + + // Countries come from Spree, already filtered to the store's checkout zone, + // each carrying its own subdivisions. Picking a country repopulates the + // province list from that country's real data. + const { + countries, + states, + statesRequired, + error: geoError, + } = useCountries(addrForm.country_iso); + + useEffect(() => { + if (user) { + setForm({ + first_name: user.first_name || "", + last_name: user.last_name || "", + phone: user.phone || "", + }); + } + }, [user]); + + const refreshAddresses = useCallback(async () => { + if (!token) return; + try { + setAddresses(await listAddresses(token)); + setAddrError(""); + } catch (e) { + setAddrError(e instanceof Error ? e.message : "Could not load addresses"); + } + }, [token]); + + const refreshCards = useCallback(async () => { + if (!token) return; + try { + setCards(await listCards(token)); + setCardError(""); + } catch (e) { + setCardError(e instanceof Error ? e.message : "Could not load cards"); + } + }, [token]); + + useEffect(() => { + refreshAddresses(); + refreshCards(); + }, [refreshAddresses, refreshCards]); + + // The profile is the durable half of the anchor; localStorage is the local + // half. Writing both keeps a customer in their own currency on a new device. + async function chooseCurrency(code: CurrencyCode) { + if (!token || code === currency || switching) return; + setRegionError(""); + + if (cart && cart.items.length > 0) { + const ok = confirm( + `Switch to ${code}? Your ${cart.items.length} item${ + cart.items.length === 1 ? "" : "s" + } stay in the basket and are re-priced in ${code}.` + ); + if (!ok) return; + } + + try { + await switchCurrency(code); + const updated = await updateProfile(token, { + public_metadata: { ...(user?.public_metadata || {}), preferred_currency: code }, + }); + const auth = JSON.parse(localStorage.getItem("pak-jabit-auth") || "{}"); + setSession(auth.token, auth.refreshToken, updated); + } catch (e) { + setRegionError(e instanceof Error ? e.message : "Could not save that preference"); + } + } + + async function handleSave() { + if (!token) return; + setSaving(true); + setMessage(""); + try { + const updated = await updateProfile(token, form); + const auth = JSON.parse(localStorage.getItem("pak-jabit-auth") || "{}"); + setSession(auth.token, auth.refreshToken, updated); + setMessage("Profile updated!"); + setEditing(false); + } catch (e) { + setMessage(e instanceof Error ? e.message : "Failed to update profile"); + } finally { + setSaving(false); + } + } + + function openNewAddress() { + setAddrForm({ + ...EMPTY_ADDRESS, + first_name: user?.first_name || "", + last_name: user?.last_name || "", + phone: user?.phone || "", + // The first address a customer saves becomes their default automatically in + // Spree; past that they have to say so. + is_default_shipping: addresses.length === 0, + }); + setAddrEditingId(null); + setAddrError(""); + setAddrOpen(true); + } + + function openEditAddress(a: Address) { + setAddrForm({ + label: a.label || "", + first_name: a.first_name, + last_name: a.last_name, + address1: a.address1, + address2: a.address2 || "", + city: a.city, + state_name: a.state_name || "", + postal_code: a.postal_code, + phone: a.phone || "", + country_iso: a.country_iso, + }); + setAddrEditingId(a.id); + setAddrError(""); + setAddrOpen(true); + } + + async function handleAddressSave() { + if (!token) return; + setAddrSaving(true); + setAddrError(""); + try { + if (addrEditingId) { + await updateAddress(token, addrEditingId, addrForm); + } else { + await createAddress(token, addrForm); + } + await refreshAddresses(); + setAddrOpen(false); + setAddrEditingId(null); + } catch (e) { + setAddrError(e instanceof Error ? e.message : "Could not save address"); + } finally { + setAddrSaving(false); + } + } + + async function handleMakeDefault(id: string) { + if (!token) return; + try { + await updateAddress(token, id, { is_default_shipping: true, is_default_billing: true }); + await refreshAddresses(); + } catch (e) { + setAddrError(e instanceof Error ? e.message : "Could not set default"); + } + } + + async function handleDeleteAddress(id: string) { + if (!token) return; + if (!confirm("Delete this address?")) return; + try { + await deleteAddress(token, id); + await refreshAddresses(); + } catch (e) { + setAddrError(e instanceof Error ? e.message : "Could not delete address"); + } + } + + async function handleAddCard() { + if (!token) return; + setCardBusy(true); + setCardError(""); + try { + await addTestCard(token); + await refreshCards(); + } catch (e) { + setCardError(e instanceof Error ? e.message : "Could not add card"); + } finally { + setCardBusy(false); + } + } + + async function handleDeleteCard(id: string) { + if (!token) return; + setCardBusy(true); + setCardError(""); + try { + await deleteCard(token, id); + await refreshCards(); + } catch (e) { + setCardError(e instanceof Error ? e.message : "Could not remove card"); + } finally { + setCardBusy(false); + } + } + + if (isLoading) { + return ( +
Loading…
+ ); + } + + if (!user) { + return ( +
+

You need to sign in first.

+ + Sign in → + +
+ ); + } + + const set = (field: string) => (e: React.ChangeEvent) => + setForm((f) => ({ ...f, [field]: e.target.value })); + + const setAddr = + (field: keyof AddressInput) => + (e: React.ChangeEvent) => + setAddrForm((f) => ({ ...f, [field]: e.target.value })); + + // A province from the old country is worse than none: it saves silently and + // the courier gets an address that cannot exist. + const setCountry = (e: React.ChangeEvent) => + setAddrForm((f) => ({ ...f, country_iso: e.target.value, state_name: "" })); + + return ( +
+

My Account

+ + {/* Profile */} +
+
+

Profile

+ {!editing && ( + + )} +
+ + {editing ? ( +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +

{user.email}

+
+
+ + +
+
+ ) : ( +
+
+ Name + + {user.first_name && user.last_name + ? `${user.first_name} ${user.last_name}` + : "Not set"} + +
+
+ Email + {user.email} +
+
+ Phone + {user.phone || "Not set"} +
+
+ )} + {message && ( +

+ {message} +

+ )} +
+ + {/* Region */} +
+

Region & Currency

+

+ Sets the prices you see, the countries we can deliver to, and the delivery + options at checkout. +

+ +
+ {CURRENCIES.map((c) => ( + + ))} +
+ + {regionError &&

{regionError}

} +
+ + {/* Addresses */} +
+
+

Saved Addresses

+ {!addrOpen && ( + + )} +
+ + {addrError &&

{addrError}

} + + {addrOpen && ( +
+
+ +
+ {LABEL_PRESETS.map((l) => ( + + ))} + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + + {geoError &&

{geoError}

} +
+ +
+
+ + {statesRequired ? ( + + ) : ( + // Not every country has subdivisions. Forcing a dropdown with + // nothing in it would block an otherwise valid address. + + )} +
+
+ + +
+
+ + + +
+ + +
+
+ )} + + {addresses.length === 0 && !addrOpen ? ( +

+ No saved addresses yet. Add one here and checkout will fill itself in. +

+ ) : ( +
+ {addresses.map((addr) => ( +
+
+
+ {addr.label && ( + + {addr.label} + + )} + {addr.is_default_shipping && ( + + Default + + )} +
+
+ + +
+
+ +

+ {addr.first_name} {addr.last_name} +

+

{addr.address1}

+ {addr.address2 &&

{addr.address2}

} +

+ {addr.city} {addr.postal_code} + {addr.state_name ? `, ${addr.state_name}` : ""} +

+

{addr.country_name}

+ {addr.phone &&

{addr.phone}

} + + {!addr.is_default_shipping && ( + + )} +
+ ))} +
+ )} +
+ + {/* Payment methods */} +
+
+

Payment Methods

+ +
+ + {/* #mock — the store runs the Bogus gateway until Payfast is registered. + This banner has to go with it. */} +

+ Sandbox mode — cards here are simulated and no money moves. +

+ + {cardError &&

{cardError}

} + + {cards.length === 0 ? ( +

No saved cards yet.

+ ) : ( +
+ {cards.map((c) => ( +
+
+

+ {c.brand || "Card"} •••• {c.last4} +

+

+ Expires {String(c.month).padStart(2, "0")}/{c.year} +

+
+ +
+ ))} +
+ )} +
+ + {/* Sign out */} + +
+ ); +} diff --git a/storefront/src/app/api/auth/addresses/[id]/route.ts b/storefront/src/app/api/auth/addresses/[id]/route.ts new file mode 100644 index 0000000..6498c91 --- /dev/null +++ b/storefront/src/app/api/auth/addresses/[id]/route.ts @@ -0,0 +1,22 @@ +import { NextRequest } from "next/server"; +import { bearer, proxy, unauthorized } from "@/lib/spreeProxy"; + +// PATCH /api/auth/addresses/:id -> edit, or set as default (is_default_shipping) +// DELETE /api/auth/addresses/:id + +export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const token = bearer(req); + if (!token) return unauthorized(); + const { id } = await ctx.params; + return proxy(`/customers/me/addresses/${id}`, token, { + method: "PATCH", + body: await req.json(), + }); +} + +export async function DELETE(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const token = bearer(req); + if (!token) return unauthorized(); + const { id } = await ctx.params; + return proxy(`/customers/me/addresses/${id}`, token, { method: "DELETE" }); +} diff --git a/storefront/src/app/api/auth/addresses/route.ts b/storefront/src/app/api/auth/addresses/route.ts new file mode 100644 index 0000000..1d35a8d --- /dev/null +++ b/storefront/src/app/api/auth/addresses/route.ts @@ -0,0 +1,25 @@ +import { NextRequest } from "next/server"; +import { bearer, proxy, unauthorized } from "@/lib/spreeProxy"; + +// GET /api/auth/addresses -> list the customer's saved addresses +// POST /api/auth/addresses -> create one +// +// `label` ("Home", "Office") is not permitted by stock Spree — the store API +// answers 201 and silently saves nil. The backend carries a decorator that +// permits and serializes it: config/initializers/address_label.rb on the +// pak-jabit-backend box, bind-mounted into the container. + +export async function GET(req: NextRequest) { + const token = bearer(req); + if (!token) return unauthorized(); + return proxy("/customers/me/addresses", token); +} + +export async function POST(req: NextRequest) { + const token = bearer(req); + if (!token) return unauthorized(); + return proxy("/customers/me/addresses", token, { + method: "POST", + body: await req.json(), + }); +} diff --git a/storefront/src/app/api/auth/cards/[id]/route.ts b/storefront/src/app/api/auth/cards/[id]/route.ts new file mode 100644 index 0000000..2e681e1 --- /dev/null +++ b/storefront/src/app/api/auth/cards/[id]/route.ts @@ -0,0 +1,10 @@ +import { NextRequest } from "next/server"; +import { bearer, proxy, unauthorized } from "@/lib/spreeProxy"; + +// DELETE /api/auth/cards/:id +export async function DELETE(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const token = bearer(req); + if (!token) return unauthorized(); + const { id } = await ctx.params; + return proxy(`/customers/me/credit_cards/${id}`, token, { method: "DELETE" }); +} diff --git a/storefront/src/app/api/auth/cards/route.ts b/storefront/src/app/api/auth/cards/route.ts new file mode 100644 index 0000000..bfffd4b --- /dev/null +++ b/storefront/src/app/api/auth/cards/route.ts @@ -0,0 +1,9 @@ +import { NextRequest } from "next/server"; +import { bearer, proxy, unauthorized } from "@/lib/spreeProxy"; + +// GET /api/auth/cards -> the customer's saved cards +export async function GET(req: NextRequest) { + const token = bearer(req); + if (!token) return unauthorized(); + return proxy("/customers/me/credit_cards", token); +} diff --git a/storefront/src/app/api/auth/cards/setup/route.ts b/storefront/src/app/api/auth/cards/setup/route.ts new file mode 100644 index 0000000..9dbe241 --- /dev/null +++ b/storefront/src/app/api/auth/cards/setup/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from "next/server"; +import { bearer, call, unauthorized } from "@/lib/spreeProxy"; + +// POST /api/auth/cards/setup -> save a card on the customer's account. +// +// #mock — this route does BOTH halves of the setup in one request, which only +// works because the Bogus gateway needs no card data: its +// complete_payment_setup_session ignores params and mints a card ending 4242. +// +// With a real gateway (Payfast, Stripe) the two halves MUST split: +// 1. server creates the setup session and returns external_client_secret +// 2. the CLIENT collects the card in the gateway's own hosted field/iframe +// and confirms against that secret — card numbers never touch our server, +// which is the whole point (PCI scope) +// 3. server PATCHes .../complete +// So when the gateway swaps, this file splits into a "create" route and a +// "complete" route. Do not carry the one-shot shape over to real cards. + +const PAYMENT_METHOD_ID = process.env.SPREE_CARD_PAYMENT_METHOD_ID; + +export async function POST(req: NextRequest) { + const token = bearer(req); + if (!token) return unauthorized(); + + // No fallback id here on purpose. Guessing one would either 404 or, worse, + // attach cards to whichever gateway happened to match — silently wrong. + if (!PAYMENT_METHOD_ID) { + return NextResponse.json( + { + error: { + message: + "SPREE_CARD_PAYMENT_METHOD_ID is not set. Add it to .env.production — it is the prefixed id of the card gateway (see MOCKS.md).", + }, + }, + { status: 500 } + ); + } + + const created = await call("/customers/me/payment_setup_sessions", token, { + method: "POST", + body: { payment_method_id: PAYMENT_METHOD_ID }, + }); + if (created.status >= 300) { + return NextResponse.json(created.data, { status: created.status }); + } + + const sessionId = created.data.id as string | undefined; + if (!sessionId) { + return NextResponse.json( + { error: { message: "Spree created a setup session with no id" } }, + { status: 502 } + ); + } + + const completed = await call( + `/customers/me/payment_setup_sessions/${sessionId}/complete`, + token, + { method: "PATCH", body: {} } + ); + return NextResponse.json(completed.data, { status: completed.status }); +} diff --git a/storefront/src/app/api/auth/login/route.ts b/storefront/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..d05c539 --- /dev/null +++ b/storefront/src/app/api/auth/login/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; + +const SPREE_URL = process.env.NEXT_PUBLIC_SPREE_API_URL || "https://api.jux-lab.com"; +const API_KEY = process.env.NEXT_PUBLIC_SPREE_API_KEY || ""; + +export async function POST(req: NextRequest) { + const { email, password } = await req.json(); + + const res = await fetch(`${SPREE_URL}/api/v3/store/auth/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Spree-Api-Key": API_KEY, + }, + body: JSON.stringify({ email, password }), + }); + + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); +} diff --git a/storefront/src/app/api/auth/me/route.ts b/storefront/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..028582f --- /dev/null +++ b/storefront/src/app/api/auth/me/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; + +const SPREE_URL = process.env.NEXT_PUBLIC_SPREE_API_URL || "https://api.jux-lab.com"; +const API_KEY = process.env.NEXT_PUBLIC_SPREE_API_KEY || ""; + +export async function GET(req: NextRequest) { + const token = req.headers.get("Authorization")?.replace("Bearer ", ""); + if (!token) return NextResponse.json({ error: "No token" }, { status: 401 }); + + // Spree's store API wants the store key AND the customer token. Sending only + // the Bearer answers 401 "Valid API key required" — which is what this route + // did from the day it was written, so the profile never loaded at all. + const res = await fetch(`${SPREE_URL}/api/v3/store/customers/me`, { + headers: { + Authorization: `Bearer ${token}`, + "X-Spree-Api-Key": API_KEY, + }, + }); + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); +} + +export async function PATCH(req: NextRequest) { + const token = req.headers.get("Authorization")?.replace("Bearer ", ""); + if (!token) return NextResponse.json({ error: "No token" }, { status: 401 }); + + const body = await req.json(); + const res = await fetch(`${SPREE_URL}/api/v3/store/customers/me`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-Spree-Api-Key": API_KEY, + }, + body: JSON.stringify(body), + }); + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); +} diff --git a/storefront/src/app/api/auth/refresh/route.ts b/storefront/src/app/api/auth/refresh/route.ts new file mode 100644 index 0000000..1a19e00 --- /dev/null +++ b/storefront/src/app/api/auth/refresh/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; + +const SPREE_URL = process.env.NEXT_PUBLIC_SPREE_API_URL || "https://api.jux-lab.com"; +const API_KEY = process.env.NEXT_PUBLIC_SPREE_API_KEY || ""; + +// POST /api/auth/refresh — trade a refresh token for a fresh access token. +// +// Spree's access tokens expire after 3600s (Spree::Api::Config[:jwt_expiration]). +// Before this route existed the storefront had no way to renew one, so every +// customer went dead exactly one hour after signing in: still "signed in" on +// screen, every request 401. +export async function POST(req: NextRequest) { + // A missing or malformed body must not 500 — req.json() throws on an empty one. + let refresh_token: string | undefined; + try { + ({ refresh_token } = await req.json()); + } catch { + refresh_token = undefined; + } + + if (!refresh_token) { + return NextResponse.json( + { error: { message: "No refresh token" } }, + { status: 400 } + ); + } + + const res = await fetch(`${SPREE_URL}/api/v3/store/auth/refresh`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Spree-Api-Key": API_KEY }, + body: JSON.stringify({ refresh_token }), + }); + + const text = await res.text(); + try { + return NextResponse.json(text ? JSON.parse(text) : {}, { status: res.status }); + } catch { + return NextResponse.json( + { error: { message: `Spree returned non-JSON (HTTP ${res.status})` } }, + { status: 502 } + ); + } +} diff --git a/storefront/src/app/api/auth/register/route.ts b/storefront/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..8f68a1a --- /dev/null +++ b/storefront/src/app/api/auth/register/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; + +const SPREE_URL = process.env.NEXT_PUBLIC_SPREE_API_URL || "https://api.jux-lab.com"; +const API_KEY = process.env.NEXT_PUBLIC_SPREE_API_KEY || ""; + +export async function POST(req: NextRequest) { + const { email, password } = await req.json(); + + const res = await fetch(`${SPREE_URL}/api/v3/store/customers`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Spree-Api-Key": API_KEY, + }, + body: JSON.stringify({ email, password, password_confirmation: password }), + }); + + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); +} diff --git a/storefront/src/app/api/cart/route.ts b/storefront/src/app/api/cart/route.ts new file mode 100644 index 0000000..c377ee2 --- /dev/null +++ b/storefront/src/app/api/cart/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; + +const SPREE_URL = process.env.NEXT_PUBLIC_SPREE_API_URL || "https://api.jux-lab.com"; +const API_KEY = process.env.NEXT_PUBLIC_SPREE_API_KEY || ""; + +export async function POST(req: NextRequest) { + const { pathname, body, method, spreeToken, userToken } = await req.json(); + + // Shipping price is derived from cart metadata, and /api/shipping/select is + // what verifies it against a live courier quote. Letting a body through here + // with metadata in it would route around that check entirely. + if (body && typeof body === "object" && "metadata" in body) { + return NextResponse.json( + { + error: { + message: "Cart metadata cannot be set from the browser. Use /api/shipping/select.", + }, + }, + { status: 400 } + ); + } + + const headers: Record = { + "Content-Type": "application/json", + "X-Spree-Api-Key": API_KEY, + }; + if (spreeToken) { + headers["X-Spree-Token"] = spreeToken; + } + // The cart token proves possession of a cart; the Bearer proves who is + // holding it. Ownership, association and "my cart" all need the second one. + if (userToken) { + headers["Authorization"] = `Bearer ${userToken}`; + } + + const res = await fetch(`${SPREE_URL}/api/v3/store${pathname}`, { + method: method || "POST", + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); +} diff --git a/storefront/src/app/api/countries/route.ts b/storefront/src/app/api/countries/route.ts new file mode 100644 index 0000000..aefd242 --- /dev/null +++ b/storefront/src/app/api/countries/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; + +const SPREE_URL = process.env.NEXT_PUBLIC_SPREE_API_URL || "https://api.jux-lab.com"; +const API_KEY = process.env.NEXT_PUBLIC_SPREE_API_KEY || ""; + +// GET /api/countries — the store's checkout countries, each with its states. +// +// Spree already filters this to the store's checkout zone (3 countries, not the +// 240 in the database) and `?expand=states` attaches the subdivisions, so one +// ~3.5KB call feeds the whole country→province cascade with no further requests. +// +// Public on purpose: a signed-out guest fills the same address form. +// +// force-dynamic, NOT `export const revalidate`: that prerenders the whole route +// at BUILD time, so a correction to the state data in Spree stays invisible +// until the next deploy. The route runs per request; the fetch below is what +// carries the hour of caching, so Spree is still only hit once an hour. +export const dynamic = "force-dynamic"; + +export async function GET() { + const res = await fetch(`${SPREE_URL}/api/v3/store/countries?expand=states`, { + headers: { "X-Spree-Api-Key": API_KEY }, + next: { revalidate: 3600 }, + }); + + const text = await res.text(); + try { + return NextResponse.json(text ? JSON.parse(text) : { data: [] }, { status: res.status }); + } catch { + return NextResponse.json( + { error: { message: `Spree returned non-JSON (HTTP ${res.status})` } }, + { status: 502 } + ); + } +} diff --git a/storefront/src/app/api/easyparcel/callback/route.ts b/storefront/src/app/api/easyparcel/callback/route.ts new file mode 100644 index 0000000..8649dec --- /dev/null +++ b/storefront/src/app/api/easyparcel/callback/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { EP_BASE, clientId, basicAuth, setToken } from "@/lib/easyparcel"; + +// Behind Caddy, req.nextUrl.origin resolves to the internal listener +// (localhost:3200). OAuth needs the public URL, and it must match the redirect +// URI registered in the EasyParcel developer hub exactly. +function callbackUrl(): string { + const base = process.env.APP_BASE_URL; + if (!base) { + throw new Error("APP_BASE_URL is not set — cannot build the OAuth redirect URI"); + } + return `${base}/api/easyparcel/callback`; +} + +export async function GET(req: NextRequest) { + const code = req.nextUrl.searchParams.get("code"); + if (!code) { + const authUrl = `${EP_BASE}/oauth/login?client_id=${clientId()}&redirect_uri=${encodeURIComponent(callbackUrl())}&state=pakjabit`; + return NextResponse.redirect(authUrl); + } + + const res = await fetch(`${EP_BASE}/oauth/token`, { + method: "POST", + headers: { + Authorization: `Basic ${basicAuth()}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + grant_type: "authorization_code", + redirect_uri: callbackUrl(), + code, + }), + }); + + if (!res.ok) { + const text = await res.text(); + return NextResponse.json({ error: "Token exchange failed", detail: text }, { status: 500 }); + } + + const data = await res.json(); + setToken(data.access_token, data.refresh_token, data.expires_in); + + return NextResponse.json({ + message: "EasyParcel connected", + expires_in: data.expires_in, + }); +} diff --git a/storefront/src/app/api/markets/route.ts b/storefront/src/app/api/markets/route.ts new file mode 100644 index 0000000..f23838f --- /dev/null +++ b/storefront/src/app/api/markets/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; + +const SPREE_URL = process.env.NEXT_PUBLIC_SPREE_API_URL || "https://api.jux-lab.com"; +const API_KEY = process.env.NEXT_PUBLIC_SPREE_API_KEY || ""; + +// GET /api/markets — each market's id, currency and shippable countries. +// +// A market is the anchor for everything regional at once: which currency prices +// are quoted in, which countries can be delivered to, and which shipping methods +// produce a rate. Spree fixes a cart's market and currency at CREATION, so the +// storefront needs these ids before it makes a cart, not after. +// +// force-dynamic, not `export const revalidate`: the latter prerenders the route +// at BUILD time, so a market change stays invisible until the next deploy. The +// hour of caching sits on the fetch below instead. +export const dynamic = "force-dynamic"; + +export async function GET() { + const res = await fetch(`${SPREE_URL}/api/v3/store/markets`, { + headers: { "X-Spree-Api-Key": API_KEY }, + next: { revalidate: 3600 }, + }); + + const text = await res.text(); + try { + return NextResponse.json(text ? JSON.parse(text) : { data: [] }, { status: res.status }); + } catch { + return NextResponse.json( + { error: { message: `Spree returned non-JSON (HTTP ${res.status})` } }, + { status: 502 } + ); + } +} diff --git a/storefront/src/app/api/shipping/book/route.ts b/storefront/src/app/api/shipping/book/route.ts new file mode 100644 index 0000000..14daaa3 --- /dev/null +++ b/storefront/src/app/api/shipping/book/route.ts @@ -0,0 +1,15 @@ +import { NextRequest, NextResponse } from "next/server"; +import { bookShipment } from "@/lib/easyparcel"; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const result = await bookShipment(body); + return NextResponse.json(result); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Booking failed" }, + { status: 500 } + ); + } +} diff --git a/storefront/src/app/api/shipping/quote/route.ts b/storefront/src/app/api/shipping/quote/route.ts new file mode 100644 index 0000000..ccf21f6 --- /dev/null +++ b/storefront/src/app/api/shipping/quote/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getQuotes } from "@/lib/easyparcel"; + +export async function POST(req: NextRequest) { + try { + const { receiver_postcode, receiver_state, weight } = await req.json(); + + if (!receiver_postcode) { + return NextResponse.json({ error: "receiver_postcode required" }, { status: 400 }); + } + + const quotes = await getQuotes({ + sender_postcode: process.env.STORE_POSTCODE || "10150", + sender_state: process.env.STORE_STATE || "MY-07", + receiver_postcode, + receiver_state: receiver_state || "", + weight: weight || 1, + }); + + return NextResponse.json({ quotes }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Quote failed" }, + { status: 500 } + ); + } +} diff --git a/storefront/src/app/api/shipping/select/route.ts b/storefront/src/app/api/shipping/select/route.ts new file mode 100644 index 0000000..9745979 --- /dev/null +++ b/storefront/src/app/api/shipping/select/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getQuotes } from "@/lib/easyparcel"; + +const SPREE_URL = process.env.NEXT_PUBLIC_SPREE_API_URL || "https://api.jux-lab.com"; +const API_KEY = process.env.NEXT_PUBLIC_SPREE_API_KEY || ""; + +/** + * POST /api/shipping/select — lock in a chosen EasyParcel courier. + * + * The price is re-fetched from EasyParcel here and matched against the chosen + * service_id. The browser never gets to say what shipping costs: cart metadata + * is PATCHable by the client, so trusting an amount sent from the page would let + * a customer set their own shipping to one sen. + * + * The verified amount lands in the order's private_metadata, where the + * EasyParcelQuote calculator reads it (config/initializers/easyparcel_calculator.rb + * on the backend box). No selection means no rate and no fulfillment, so a + * Malaysian order cannot complete without a real courier behind it. + */ +export async function POST(req: NextRequest) { + try { + const { cart_id, cart_token, service_id, receiver_postcode, receiver_state, weight } = + await req.json(); + + if (!cart_id || !cart_token) { + return NextResponse.json({ error: "cart_id and cart_token required" }, { status: 400 }); + } + if (!service_id) { + return NextResponse.json({ error: "service_id required" }, { status: 400 }); + } + if (!receiver_postcode) { + return NextResponse.json({ error: "receiver_postcode required" }, { status: 400 }); + } + + const quotes = await getQuotes({ + sender_postcode: process.env.STORE_POSTCODE || "10150", + sender_state: process.env.STORE_STATE || "MY-07", + receiver_postcode, + receiver_state: receiver_state || "", + weight: weight || 1, + }); + + const chosen = quotes.find((q) => String(q.service_id) === String(service_id)); + if (!chosen) { + // The quote list is live and can change between rendering and choosing. + return NextResponse.json( + { error: "That courier is no longer quoting for this address. Pick another." }, + { status: 409 } + ); + } + + const res = await fetch(`${SPREE_URL}/api/v3/store/carts/${cart_id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-Spree-Api-Key": API_KEY, + "X-Spree-Token": cart_token, + }, + body: JSON.stringify({ + metadata: { + easyparcel_service_id: String(chosen.service_id), + easyparcel_amount: String(chosen.total_amount), + easyparcel_courier: chosen.courier_name, + easyparcel_service: chosen.service_name, + }, + }), + }); + + if (!res.ok) { + const text = await res.text(); + return NextResponse.json( + { error: `Could not save the courier choice (HTTP ${res.status}): ${text.slice(0, 200)}` }, + { status: res.status } + ); + } + + return NextResponse.json({ selected: chosen }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Selection failed" }, + { status: 500 } + ); + } +} diff --git a/storefront/src/app/api/shipping/track/route.ts b/storefront/src/app/api/shipping/track/route.ts new file mode 100644 index 0000000..8cb9636 --- /dev/null +++ b/storefront/src/app/api/shipping/track/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { trackShipment } from "@/lib/easyparcel"; + +export async function POST(req: NextRequest) { + try { + const { awb_numbers } = await req.json(); + if (!awb_numbers?.length) { + return NextResponse.json({ error: "awb_numbers required" }, { status: 400 }); + } + const result = await trackShipment(awb_numbers); + return NextResponse.json(result); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Tracking failed" }, + { status: 500 } + ); + } +} diff --git a/storefront/src/app/api/webhooks/easyparcel/route.ts b/storefront/src/app/api/webhooks/easyparcel/route.ts new file mode 100644 index 0000000..042b48f --- /dev/null +++ b/storefront/src/app/api/webhooks/easyparcel/route.ts @@ -0,0 +1,12 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(req: NextRequest) { + try { + const payload = await req.json(); + console.log("[EasyParcel Webhook]", JSON.stringify(payload)); + // TODO: Update Spree order status based on payload.topic + payload.awb_number + return NextResponse.json({ received: true }); + } catch { + return NextResponse.json({ received: true }); + } +} diff --git a/storefront/src/app/checkout/page.tsx b/storefront/src/app/checkout/page.tsx new file mode 100644 index 0000000..a955362 --- /dev/null +++ b/storefront/src/app/checkout/page.tsx @@ -0,0 +1,761 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCart } from "@/components/CartProvider"; +import { useAuth } from "@/components/AuthProvider"; +import { listAddresses, Address } from "@/lib/auth"; +import { useCountries } from "@/lib/geo"; +import Image from "next/image"; + +type Step = "info" | "shipping" | "processing" | "done"; + +// EasyParcel quotes only exist for Malaysia — the account is MY-domestic. Every +// other market uses Spree's own delivery rates. +const EASYPARCEL_COUNTRY = "MY"; + +interface CourierOption { + service_id: string; + service_name: string; + courier_name: string; + delivery_duration: string | null; + total_amount: string; + currency: string; + courier_logo: string | null; +} + +interface DeliveryRate { + id: string; + name: string; + display_cost?: string; + display_amount?: string; + selected?: boolean; +} + +interface OrderResult { + number: string; + display_total: string; + completed_at: string; +} + +async function checkoutProxy(pathname: string, method: string, spreeToken: string, body?: unknown) { + const res = await fetch("/api/cart", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pathname, method, spreeToken, body }), + }); + return res.json(); +} + +function rateCost(r: DeliveryRate) { + return r.display_cost || r.display_amount || ""; +} + +export default function CheckoutPage() { + const { cart } = useCart(); + const { user, token } = useAuth(); + + const [step, setStep] = useState("info"); + const [order, setOrder] = useState(null); + const [error, setError] = useState(""); + const [working, setWorking] = useState(false); + + // Spree's own delivery rates — the ones the order is actually charged. + const [rates, setRates] = useState([]); + const [fulfillmentId, setFulfillmentId] = useState(null); + const [selectedRateId, setSelectedRateId] = useState(null); + const [shipTotal, setShipTotal] = useState(""); + + // Live EasyParcel couriers, Malaysia only. + const [couriers, setCouriers] = useState([]); + const [selectedCourierId, setSelectedCourierId] = useState(null); + + const [saved, setSaved] = useState([]); + const [pickedAddressId, setPickedAddressId] = useState(null); + + // A cart belongs to ONE market, and shipping only resolves for that market's + // countries. Offering anything else guarantees a dead end: Spree answers 200 + // with zero fulfillments and there is nothing to charge. + const marketIsos: string[] = cart?.market?.country_isos ?? []; + // Empty means the cart carries no market. Guessing a country list here would + // put the customer through a checkout nobody configured, so it is surfaced. + const marketMissing = !!cart && marketIsos.length === 0; + + const [form, setForm] = useState({ + email: "", + first_name: "", + last_name: "", + address1: "", + city: "", + postal_code: "", + state_name: "", + phone: "", + country_iso: "", + }); + + const { countries, states, statesRequired, zipRequired } = useCountries(form.country_iso); + const shippable = useMemo( + () => countries.filter((c) => marketIsos.includes(c.iso)), + [countries, marketIsos.join(",")] + ); + + // The + + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + {statesRequired ? ( + + ) : ( + + )} +
+
+ +
+
+ + +
+
+ + +
+
+ + {error &&

{error}

} + + {marketMissing && ( +

+ This cart has no delivery region set, so we can't work out where + it ships. Please empty the cart and add the items again. +

+ )} + + {pickedUndeliverable && ( +

+ {pickedAddress?.label || "That saved address"} is in{" "} + {pickedAddress?.country_name}, which we don't deliver to yet. + Fill in a local address below, or add one under{" "} + + your account + + . +

+ )} + + + + {/* A disabled button that will not say why reads as broken. It was + showing a loading cursor too, which read as buffering. */} + {missing.length > 0 && !working && ( +

+ Still needed: {missing.join(", ")} +

+ )} + + )} + + {step === "shipping" && ( +
+ + + {useEasyParcel ? ( +
+ {couriers.map((c) => ( + + ))} +
+ ) : ( +
+ {rates.map((r) => ( + + ))} +
+ )} + + {error &&

{error}

} + + + +

+ Test mode — no real payment will be charged +

+
+ )} + + {step === "processing" && ( +
+
⏳
+

Processing your order…

+
+ )} + + + {/* Order Summary */} +
+
+

Order Summary

+
+ {cart.items.map((item) => ( +
+
+ {item.image_url ? ( + {item.name} + ) : ( +
📷
+ )} + + {item.quantity} + +
+
+

{item.name}

+

{item.display_total}

+
+
+ ))} +
+
+
+ Subtotal + {cart.display_item_total} +
+
+ Shipping + {shipTotal || "Calculated next"} +
+
+ Total + {cart.display_total} +
+
+
+
+ + + ); +} diff --git a/storefront/src/app/favicon.ico b/storefront/src/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/storefront/src/app/favicon.ico differ diff --git a/storefront/src/app/globals.css b/storefront/src/app/globals.css new file mode 100644 index 0000000..9acbb17 --- /dev/null +++ b/storefront/src/app/globals.css @@ -0,0 +1,42 @@ +@import url('https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600;700&display=swap'); +@import "tailwindcss"; + +:root { + --ink: #0E0D0B; + --ochre: #C8873A; + --bone: #EDE6DA; + --bone-light: #F5F1EB; + --ochre-dark: #A86E2D; + --background: var(--ink); + --foreground: var(--bone); + --card-bg: #1a1917; + --card-text: var(--bone); + --section-bg: var(--ink); +} + +.light { + --background: var(--bone-light); + --foreground: var(--ink); + --card-bg: #ffffff; + --card-text: var(--ink); + --section-bg: var(--bone-light); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-ink: var(--ink); + --color-ochre: var(--ochre); + --color-ochre-dark: var(--ochre-dark); + --color-bone: var(--bone); + --color-bone-light: var(--bone-light); + --color-card-bg: var(--card-bg); + --color-card-text: var(--card-text); + --color-section-bg: var(--section-bg); +} + +body { + background: var(--background); + color: var(--foreground); + font-family: 'Instrument Sans', system-ui, sans-serif; +} diff --git a/storefront/src/app/layout.tsx b/storefront/src/app/layout.tsx new file mode 100644 index 0000000..1563da7 --- /dev/null +++ b/storefront/src/app/layout.tsx @@ -0,0 +1,66 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import "./globals.css"; +import { CartProvider } from "@/components/CartProvider"; +import { AuthProvider } from "@/components/AuthProvider"; +import CartButton from "@/components/CartButton"; +import CartDrawer from "@/components/CartDrawer"; +import AuthButton from "@/components/AuthButton"; +import CurrencySwitcher from "@/components/CurrencySwitcher"; + +export const metadata: Metadata = { + title: { + default: "Pak Jabit Store — Men's Apparel & Accessories", + template: "%s | Pak Jabit", + }, + description: "Curated men's apparel from brands that matter. G-Shock, Lacoste, Tommy Hilfiger, Calvin Klein and more.", + metadataBase: new URL("https://shop.jux-lab.com"), + openGraph: { + siteName: "Pak Jabit Store", + type: "website", + locale: "en_US", + }, + twitter: { + card: "summary_large_image", + }, + robots: { + index: true, + follow: true, + }, +}; + +export default function RootLayout({ children }: LayoutProps<"/">) { + return ( + + + {/* AuthProvider outside: the cart belongs to the customer, so + CartProvider needs to know who is signed in. */} + + +
+ + PAK JABIT. + + +
+
+ {children} +
+ +
+
+
+

© 2026 Pak Jabit Store. Built with thread and needle.

+
+ + + ); +} diff --git a/storefront/src/app/login/page.tsx b/storefront/src/app/login/page.tsx new file mode 100644 index 0000000..afdbb48 --- /dev/null +++ b/storefront/src/app/login/page.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useState } from "react"; +import { useAuth } from "@/components/AuthProvider"; +import { login, register } from "@/lib/auth"; + +export default function LoginPage() { + const { setSession } = useAuth(); + const [isRegister, setIsRegister] = useState(false); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setLoading(true); + + try { + const result = isRegister + ? await register(email, password) + : await login(email, password); + + if (result.error || !result.token) { + setError(result.error?.message || "Login failed"); + setLoading(false); + return; + } + + setSession(result.token, result.refresh_token, result.user); + window.location.replace("/"); + } catch { + setError("Something went wrong"); + } finally { + setLoading(false); + } + } + + return ( +
+

+ {isRegister ? "Create Account" : "Sign In"} +

+

+ {isRegister + ? "Join Pak Jabit to save your addresses and track orders." + : "Welcome back to Pak Jabit."} +

+ +
+
+ + setEmail(e.target.value)} + placeholder="you@example.com" + className="w-full bg-[#1a1917] border border-[#EDE6DA]/20 rounded-lg px-4 py-3 text-[#EDE6DA] placeholder:text-[#EDE6DA]/30 focus:border-[#C8873A] focus:outline-none" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + minLength={6} + className="w-full bg-[#1a1917] border border-[#EDE6DA]/20 rounded-lg px-4 py-3 text-[#EDE6DA] placeholder:text-[#EDE6DA]/30 focus:border-[#C8873A] focus:outline-none" + /> +
+ + {error &&

{error}

} + + +
+ +

+ {isRegister ? "Already have an account?" : "Don't have an account?"}{" "} + +

+
+ ); +} diff --git a/storefront/src/app/page.tsx b/storefront/src/app/page.tsx new file mode 100644 index 0000000..0645a1c --- /dev/null +++ b/storefront/src/app/page.tsx @@ -0,0 +1,30 @@ +import { getProducts } from "@/lib/spree"; +import { DEFAULT_CURRENCY, parseCurrency } from "@/lib/currency"; +import ProductGrid from "@/components/ProductGrid"; + +export default async function Home({ + searchParams, +}: { + searchParams: Promise<{ currency?: string }>; +}) { + const { currency } = await searchParams; + const selected = parseCurrency(currency) ?? DEFAULT_CURRENCY; + const products = await getProducts(selected); + + return ( +
+ {/* Hero */} +
+

+ Dress Sharp. Stay Real. +

+

+ Curated men's apparel from brands that matter. +

+
+ + {/* Product Grid with Filters */} + +
+ ); +} diff --git a/storefront/src/app/product/[slug]/ProductImages.tsx b/storefront/src/app/product/[slug]/ProductImages.tsx new file mode 100644 index 0000000..e29f945 --- /dev/null +++ b/storefront/src/app/product/[slug]/ProductImages.tsx @@ -0,0 +1,38 @@ +"use client"; + +import Image from "next/image"; +import { useState } from "react"; + +export default function ProductImages({ images, name }: { images: string[]; name: string }) { + const [selected, setSelected] = useState(0); + + return ( +
+ {images.length > 1 && ( +
+ {images.map((img, i) => ( + + ))} +
+ )} +
+ {name} +
+
+ ); +} diff --git a/storefront/src/app/product/[slug]/page.tsx b/storefront/src/app/product/[slug]/page.tsx new file mode 100644 index 0000000..27f387b --- /dev/null +++ b/storefront/src/app/product/[slug]/page.tsx @@ -0,0 +1,112 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { getProducts, getProduct } from "@/lib/spree"; +import { DEFAULT_CURRENCY, parseCurrency } from "@/lib/currency"; +import ProductImages from "./ProductImages"; +import AddToCartButton from "@/components/AddToCartButton"; + +export async function generateStaticParams() { + const products = await getProducts(); + return products.map((p) => ({ slug: p.slug })); +} + +export async function generateMetadata({ + params, + searchParams, +}: { + params: Promise<{ slug: string }>; + searchParams: Promise<{ currency?: string }>; +}): Promise { + const { slug } = await params; + const { currency } = await searchParams; + const product = await getProduct(slug, parseCurrency(currency) ?? DEFAULT_CURRENCY); + if (!product) return {}; + + const title = `${product.name} — ${product.price.display_amount}`; + const description = product.description || `Shop ${product.name} at Pak Jabit Store`; + + return { + title, + description, + openGraph: { + title, + description, + images: product.thumbnail_url ? [product.thumbnail_url] : [], + type: "website", + }, + twitter: { + card: "summary_large_image", + title, + description, + images: product.thumbnail_url ? [product.thumbnail_url] : [], + }, + }; +} + +export default async function ProductPage({ + params, + searchParams, +}: { + params: Promise<{ slug: string }>; + searchParams: Promise<{ currency?: string }>; +}) { + const { slug } = await params; + const { currency } = await searchParams; + const product = await getProduct(slug, parseCurrency(currency) ?? DEFAULT_CURRENCY); + if (!product) notFound(); + + const images = product.media?.length + ? product.media.sort((a, b) => a.position - b.position).map((m) => m.original_url) + : product.thumbnail_url + ? [product.thumbnail_url] + : []; + + return ( +
+
+ + ← Back to shop + +
+ {images.length > 0 ? ( + + ) : ( +
📷
+ )} + +
+

{product.name}

+

+ {product.price.display_amount} +

+

{product.description}

+ +
+
+ +
+ {["S", "M", "L", "XL"].map((size) => ( + + ))} +
+
+ + +
+ +
+

✓ Free shipping on orders over R500

+

✓ 30-day returns

+

✓ Secure checkout

+
+
+
+
+
+ ); +} diff --git a/storefront/src/app/robots.ts b/storefront/src/app/robots.ts new file mode 100644 index 0000000..7d50afd --- /dev/null +++ b/storefront/src/app/robots.ts @@ -0,0 +1,12 @@ +import type { MetadataRoute } from "next"; + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: "*", + allow: "/", + disallow: ["/api/", "/checkout"], + }, + sitemap: "https://pak-jabit-store.vercel.app/sitemap.xml", + }; +} diff --git a/storefront/src/app/sitemap.ts b/storefront/src/app/sitemap.ts new file mode 100644 index 0000000..1253041 --- /dev/null +++ b/storefront/src/app/sitemap.ts @@ -0,0 +1,25 @@ +import type { MetadataRoute } from "next"; +import { getProducts } from "@/lib/spree"; + +const BASE = "https://pak-jabit-store.vercel.app"; + +export default async function sitemap(): Promise { + const products = await getProducts(); + + const productUrls = products.map((p) => ({ + url: `${BASE}/product/${p.slug}`, + lastModified: new Date(), + changeFrequency: "weekly" as const, + priority: 0.8, + })); + + return [ + { + url: BASE, + lastModified: new Date(), + changeFrequency: "daily", + priority: 1, + }, + ...productUrls, + ]; +} diff --git a/storefront/src/components/AddToCartButton.tsx b/storefront/src/components/AddToCartButton.tsx new file mode 100644 index 0000000..41e536a --- /dev/null +++ b/storefront/src/components/AddToCartButton.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { useCart } from "./CartProvider"; + +export default function AddToCartButton({ variantId }: { variantId: string }) { + const { add, loading } = useCart(); + + return ( + + ); +} diff --git a/storefront/src/components/AuthButton.tsx b/storefront/src/components/AuthButton.tsx new file mode 100644 index 0000000..9ccbbaa --- /dev/null +++ b/storefront/src/components/AuthButton.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { useAuth } from "./AuthProvider"; + +export default function AuthButton() { + const { user, isLoading } = useAuth(); + + if (isLoading) return null; + + if (user) { + return ( + + Account + + ); + } + + return ( + + Sign in + + ); +} diff --git a/storefront/src/components/AuthProvider.tsx b/storefront/src/components/AuthProvider.tsx new file mode 100644 index 0000000..ee46a9b --- /dev/null +++ b/storefront/src/components/AuthProvider.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { createContext, useContext, useState, useEffect, useCallback } from "react"; +import { User, loadAuth, saveAuth, clearAuth, getProfile, AUTH_REFRESHED } from "@/lib/auth"; +import { saveRegion } from "@/lib/region"; +import { parseCurrency } from "@/lib/currency"; + +// A signed-in customer's stored choice outranks whatever this browser happens to +// hold, so signing in on a new device lands you in your own currency. +function adoptRegion(u: User | null | undefined) { + const code = parseCurrency(u?.public_metadata?.preferred_currency); + if (code) saveRegion(code); +} + +interface AuthContextType { + user: User | null; + token: string | null; + isLoading: boolean; + setSession: (token: string, refreshToken: string, user: User) => void; + logout: () => void; +} + +const AuthContext = createContext({ + user: null, + token: null, + isLoading: true, + setSession: () => {}, + logout: () => {}, +}); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [token, setToken] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const stored = loadAuth(); + if (stored?.token) { + setToken(stored.token); + if (stored.user) setUser(stored.user); + // getProfile renews the token on its own if it has expired. If it still + // fails, the session really is dead — drop it. Keeping the cached + // stored.user here is what made an expired login look signed in. + getProfile(stored.token) + .then((u) => { + setUser(u); + adoptRegion(u); + const now = loadAuth(); + if (now?.token) setToken(now.token); + }) + .catch(() => { + clearAuth(); + setToken(null); + setUser(null); + }) + .finally(() => setIsLoading(false)); + } else { + setIsLoading(false); + } + }, []); + + // A renewal can happen inside any API call; adopt the new token so the next + // request does not start from the expired one. + useEffect(() => { + function onRefreshed(e: Event) { + const d = (e as CustomEvent).detail as { token?: string; user?: User }; + if (d?.token) setToken(d.token); + if (d?.user) setUser(d.user); + } + window.addEventListener(AUTH_REFRESHED, onRefreshed); + return () => window.removeEventListener(AUTH_REFRESHED, onRefreshed); + }, []); + + const setSession = useCallback((t: string, rt: string, u: User) => { + setToken(t); + setUser(u); + adoptRegion(u); + saveAuth(t, rt, u); + }, []); + + const logout = useCallback(() => { + setToken(null); + setUser(null); + clearAuth(); + }, []); + + return ( + + {children} + + ); +} + +export function useAuth() { + return useContext(AuthContext); +} diff --git a/storefront/src/components/CartButton.tsx b/storefront/src/components/CartButton.tsx new file mode 100644 index 0000000..cb93fcc --- /dev/null +++ b/storefront/src/components/CartButton.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useCart } from "./CartProvider"; + +export default function CartButton() { + const { cart, setDrawerOpen } = useCart(); + const count = cart?.total_quantity || 0; + + return ( + + ); +} diff --git a/storefront/src/components/CartDrawer.tsx b/storefront/src/components/CartDrawer.tsx new file mode 100644 index 0000000..84e7dc0 --- /dev/null +++ b/storefront/src/components/CartDrawer.tsx @@ -0,0 +1,100 @@ +"use client"; + +import Image from "next/image"; +import { useCart } from "./CartProvider"; + +export default function CartDrawer() { + const { cart, loading, drawerOpen, setDrawerOpen, updateQuantity, remove } = useCart(); + + if (!drawerOpen) return null; + + const items = cart?.items || []; + const totalQty = cart?.total_quantity || 0; + + return ( + <> +
setDrawerOpen(false)} + /> +
+
+

+ Cart ({totalQty}) +

+ +
+ +
+ {items.length === 0 ? ( +

Your cart is empty

+ ) : ( + items.map((item) => ( +
+
+ {item.image_url ? ( + {item.name} + ) : ( +
📷
+ )} +
+
+

{item.name}

+

{item.display_price}

+
+ + {item.quantity} + + +
+
+
+ )) + )} +
+ + {items.length > 0 && ( + + )} +
+ + ); +} diff --git a/storefront/src/components/CartProvider.tsx b/storefront/src/components/CartProvider.tsx new file mode 100644 index 0000000..1ff5cba --- /dev/null +++ b/storefront/src/components/CartProvider.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react"; +import { + createCart, + getCart, + getMyCart, + associateCart, + addItem, + updateItemQuantity, + removeItem, + type Cart, +} from "@/lib/cart"; +import { loadMarkets, loadRegion, saveRegion, cartOptionsFor } from "@/lib/region"; +import { DEFAULT_CURRENCY, type CurrencyCode } from "@/lib/currency"; +import { useAuth } from "@/components/AuthProvider"; + +interface CartContextValue { + cart: Cart | null; + loading: boolean; + drawerOpen: boolean; + setDrawerOpen: (open: boolean) => void; + add: (variantId: string, quantity?: number) => Promise; + updateQuantity: (itemId: string, quantity: number) => Promise; + remove: (itemId: string) => Promise; + /** The region anchor. Changing it rebuilds the cart in the new currency. */ + currency: CurrencyCode; + switchCurrency: (code: CurrencyCode) => Promise; + switching: boolean; +} + +const CartContext = createContext(null); + +export function useCart() { + const ctx = useContext(CartContext); + if (!ctx) throw new Error("useCart must be used within CartProvider"); + return ctx; +} + +const STORAGE_KEY = "pak-jabit-cart"; + +function loadStored(): { id: string; token: string } | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (parsed.id && parsed.token) return parsed; + } catch {} + return null; +} + +function saveStored(id: string, token: string) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ id, token })); + } catch {} +} + +function clearStored() { + try { + localStorage.removeItem(STORAGE_KEY); + } catch {} +} + +export function CartProvider({ children }: { children: ReactNode }) { + const { token: userToken } = useAuth(); + + const [cart, setCart] = useState(null); + const [loading, setLoading] = useState(false); + const [switching, setSwitching] = useState(false); + const [drawerOpen, setDrawerOpen] = useState(false); + const [currency, setCurrency] = useState(DEFAULT_CURRENCY); + + // Spree fixes a cart's currency at creation. A cart from before the customer + // chose a different region still prices itself in the old money and would + // check out in it, so it is dropped rather than carried. + const staleFor = useCallback((c: Cart, want: CurrencyCode) => { + return !!c.currency && c.currency !== want; + }, []); + + // Guest path: whatever this browser is holding. + useEffect(() => { + const saved = loadRegion(); + if (saved) setCurrency(saved); + + const stored = loadStored(); + if (!stored) return; + + getCart(stored.id, stored.token) + .then((c) => { + if (staleFor(c, saved || DEFAULT_CURRENCY)) { + clearStored(); + setCart(null); + return; + } + setCart(c); + if (c.currency) setCurrency(c.currency as CurrencyCode); + }) + .catch(() => clearStored()); + }, [staleFor]); + + // Signed-in path: the cart belongs to the customer, not to this browser. A + // guest basket built before signing in is claimed rather than abandoned. + useEffect(() => { + if (!userToken) return; + let live = true; + + (async () => { + const stored = loadStored(); + if (stored) { + try { + await associateCart(stored.id, stored.token, userToken); + } catch { + // Already claimed, or claimed by someone else. The server copy below + // is the authority either way. + } + } + + try { + const mine = await getMyCart(userToken); + if (!live || !mine) return; + + const want = loadRegion() || DEFAULT_CURRENCY; + if (staleFor(mine, want)) { + // Their saved basket is in a currency they no longer shop in. Leave it + // on the server untouched rather than silently checking out old money. + clearStored(); + setCart(null); + return; + } + + setCart(mine); + saveStored(mine.id, mine.token); + if (mine.currency) setCurrency(mine.currency as CurrencyCode); + } catch { + // No server cart, or the lookup failed — keep whatever is local. + } + })(); + + return () => { + live = false; + }; + }, [userToken, staleFor]); + + const makeCart = useCallback( + async (code: CurrencyCode): Promise => { + const markets = await loadMarkets(); + const opts = cartOptionsFor(markets, code); + // No fallback to a default market: a cart built on a guessed region prices + // and ships somewhere nobody chose, and Spree will not let it be corrected + // afterwards. + if (!opts) { + throw new Error(`No market quotes in ${code}. Pick another currency.`); + } + const created = await createCart(opts, userToken ?? undefined); + saveStored(created.id, created.token); + return created; + }, + [userToken] + ); + + const ensureCart = useCallback(async (): Promise => { + if (cart) return cart; + const created = await makeCart(currency); + setCart(created); + return created; + }, [cart, currency, makeCart]); + + const add = useCallback( + async (variantId: string, quantity = 1) => { + setLoading(true); + try { + const c = await ensureCart(); + const updated = await addItem(c.id, c.token, variantId, quantity); + updated.token = c.token; + setCart(updated); + saveStored(updated.id, c.token); + setDrawerOpen(true); + } finally { + setLoading(false); + } + }, + [ensureCart] + ); + + const updateQuantity = useCallback( + async (itemId: string, quantity: number) => { + if (!cart) return; + setLoading(true); + try { + const updated = await updateItemQuantity(cart.id, cart.token, itemId, quantity); + updated.token = cart.token; + setCart(updated); + } finally { + setLoading(false); + } + }, + [cart] + ); + + const remove = useCallback( + async (itemId: string) => { + if (!cart) return; + setLoading(true); + try { + const updated = await removeItem(cart.id, cart.token, itemId); + updated.token = cart.token; + setCart(updated); + } finally { + setLoading(false); + } + }, + [cart] + ); + + /** + * Move the whole session to another currency. + * + * The cart cannot be converted, so it is rebuilt: a new cart in the new market + * and every line re-added. The basket survives the switch, which is the point — + * otherwise changing currency silently empties it. + */ + const switchCurrency = useCallback( + async (code: CurrencyCode) => { + if (code === currency) return; + setSwitching(true); + try { + const carry = + cart?.items.map((i) => ({ variant_id: i.variant_id, quantity: i.quantity })) ?? []; + + const fresh = await makeCart(code); + let latest = fresh; + for (const line of carry) { + latest = await addItem(fresh.id, fresh.token, line.variant_id, line.quantity); + latest.token = fresh.token; + } + + setCart(carry.length ? latest : fresh); + setCurrency(code); + saveRegion(code); + } finally { + setSwitching(false); + } + }, + [cart, currency, makeCart] + ); + + return ( + + {children} + + ); +} diff --git a/storefront/src/components/CurrencySwitcher.tsx b/storefront/src/components/CurrencySwitcher.tsx new file mode 100644 index 0000000..c9734bb --- /dev/null +++ b/storefront/src/components/CurrencySwitcher.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useSearchParams, useRouter, usePathname } from "next/navigation"; +import { useTransition } from "react"; +import { CURRENCIES, DEFAULT_CURRENCY, parseCurrency, type CurrencyCode } from "@/lib/currency"; +import { useCart } from "@/components/CartProvider"; + +/** + * Sliding segmented control for the region anchor. + * + * The choice does two jobs. It stays in the URL (?currency=USD) so the server + * component re-fetches real Spree prices — nothing is converted in the browser. + * And it rebuilds the cart in the matching market, because Spree fixes a cart's + * currency at creation: without the rebuild the basket would keep quoting money + * the order can never be placed in. + */ +export default function CurrencySwitcher() { + const params = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + const [pending, startTransition] = useTransition(); + const { switchCurrency, switching, cart } = useCart(); + + const active = parseCurrency(params.get("currency")) ?? DEFAULT_CURRENCY; + const index = CURRENCIES.findIndex((c) => c.code === active); + const busy = pending || switching; + + async function select(code: CurrencyCode) { + if (code === active || busy) return; + + // Ask before discarding a basket's pricing. The items are carried over, but + // the totals change, and a price changing under someone without warning is + // its own kind of broken. + if (cart && cart.items.length > 0) { + const ok = confirm( + `Switch to ${code}? Your ${cart.items.length} item${ + cart.items.length === 1 ? "" : "s" + } stay in the basket and are re-priced in ${code}.` + ); + if (!ok) return; + } + + await switchCurrency(code); + + const next = new URLSearchParams(params.toString()); + if (code === DEFAULT_CURRENCY) next.delete("currency"); + else next.set("currency", code); + const qs = next.toString(); + startTransition(() => { + router.push(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); + }); + } + + return ( +
+ {/* sliding indicator */} +
+ ); +} diff --git a/storefront/src/components/ProductGrid.tsx b/storefront/src/components/ProductGrid.tsx new file mode 100644 index 0000000..adb24e2 --- /dev/null +++ b/storefront/src/components/ProductGrid.tsx @@ -0,0 +1,383 @@ +"use client"; + +import { useState, useMemo, useEffect } from "react"; +import Image from "next/image"; +import { DEFAULT_CURRENCY, symbolFor, type CurrencyCode } from "@/lib/currency"; + +interface Product { + id: string; + name: string; + slug: string; + description: string; + thumbnail_url: string | null; + price: { amount: string; currency: string; display_amount: string }; +} + +function extractBrand(description: string, name: string): string { + if (description && description.includes(" - ")) { + return description.split(" - ")[0].trim(); + } + const known = [ + "G-Shock", "H&M", "Lacoste", "Tommy Hilfiger", "Cotton On", "GAP", + "Giordano", "Superdry", "Under Armour", "Calvin Klein", "Camel Active", + "Electro Denim Lab", "Trendyol", "Swiss Polo", "Adidas", "Nike", + "New Balance", "Levi's", "Mango Man", "Urban Revivo", "Puma", + "Jack & Jones", "Harley-Davidson", "Clarks", "Ben Sherman", + "Connor", "Jordan", "Desigual", "Dr. Cardin", "Veja", "Hunter Boots", + ]; + for (const b of known) { + if (name.toLowerCase().includes(b.toLowerCase())) return b; + } + return "Other"; +} + +type SortOption = "default" | "price-asc" | "price-desc" | "name"; + +// 24, not 25: the grid is 2 columns on mobile and 3 on desktop, so 24 is the +// largest round page size that fills every row in both layouts. +const PER_PAGE = 24; + +export default function ProductGrid({ + products, + currency = DEFAULT_CURRENCY, +}: { + products: Product[]; + currency?: CurrencyCode; +}) { + const [selectedBrands, setSelectedBrands] = useState>(new Set()); + const [sort, setSort] = useState("default"); + const [showFilters, setShowFilters] = useState(false); + const [page, setPage] = useState(1); + + const symbol = symbolFor(currency); + + const productsWithBrand = useMemo( + () => products.map((p) => ({ ...p, brand: extractBrand(p.description, p.name) })), + [products] + ); + + const brands = useMemo(() => { + const counts = new Map(); + for (const p of productsWithBrand) { + counts.set(p.brand, (counts.get(p.brand) || 0) + 1); + } + return Array.from(counts.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([name, count]) => ({ name, count })); + }, [productsWithBrand]); + + const maxPrice = useMemo(() => { + const amounts = productsWithBrand.map((p) => parseFloat(p.price.amount)); + if (!amounts.length) return 0; + return Math.ceil(Math.max(...amounts) / 100) * 100; + }, [productsWithBrand]); + + // Roughly 100 notches across whatever range this currency spans. A fixed + // step of 50 was unusable in MYR (max ~800) and too coarse in ZAR. + const step = useMemo(() => Math.max(1, Math.round(maxPrice / 100)), [maxPrice]); + + const [priceRange, setPriceRange] = useState<[number, number]>([0, maxPrice]); + + // Prices change scale entirely when the currency changes (R3,185 vs RM799 vs + // $196), so a range carried over from the previous currency would silently + // hide most of the catalogue. Reset it whenever the ceiling moves. + useEffect(() => { + setPriceRange([0, maxPrice]); + }, [maxPrice, currency]); + + const filtered = useMemo(() => { + let result = productsWithBrand; + + if (selectedBrands.size > 0) { + result = result.filter((p) => selectedBrands.has(p.brand)); + } + + const price = parseFloat; + result = result.filter( + (p) => price(p.price.amount) >= priceRange[0] && price(p.price.amount) <= priceRange[1] + ); + + switch (sort) { + case "price-asc": + result = [...result].sort((a, b) => price(a.price.amount) - price(b.price.amount)); + break; + case "price-desc": + result = [...result].sort((a, b) => price(b.price.amount) - price(a.price.amount)); + break; + case "name": + result = [...result].sort((a, b) => a.name.localeCompare(b.name)); + break; + } + + return result; + }, [productsWithBrand, selectedBrands, sort, priceRange]); + + const totalPages = Math.max(1, Math.ceil(filtered.length / PER_PAGE)); + + // A filter that shrinks the result set can strand the viewer on a page that no + // longer exists (page 6 of 2), which renders an empty grid with no explanation. + // Snap back to the first page whenever the selection changes. + useEffect(() => { + setPage(1); + }, [selectedBrands, sort, priceRange, currency]); + + const paged = useMemo( + () => filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE), + [filtered, page] + ); + + const firstShown = filtered.length === 0 ? 0 : (page - 1) * PER_PAGE + 1; + const lastShown = Math.min(page * PER_PAGE, filtered.length); + + function goToPage(next: number) { + setPage(Math.min(Math.max(1, next), totalPages)); + if (typeof window !== "undefined") { + window.scrollTo({ top: 0, behavior: "smooth" }); + } + } + + // Up to 7 page buttons; longer runs collapse the middle to an ellipsis so the + // row never wraps on a phone. + const pageNumbers = useMemo<(number | "gap")[]>(() => { + if (totalPages <= 7) { + return Array.from({ length: totalPages }, (_, i) => i + 1); + } + const out: (number | "gap")[] = [1]; + const from = Math.max(2, page - 1); + const to = Math.min(totalPages - 1, page + 1); + if (from > 2) out.push("gap"); + for (let i = from; i <= to; i++) out.push(i); + if (to < totalPages - 1) out.push("gap"); + out.push(totalPages); + return out; + }, [page, totalPages]); + + function toggleBrand(brand: string) { + setSelectedBrands((prev) => { + const next = new Set(prev); + if (next.has(brand)) next.delete(brand); + else next.add(brand); + return next; + }); + } + + function clearFilters() { + setSelectedBrands(new Set()); + setPriceRange([0, maxPrice]); + setSort("default"); + } + + // Keep the chosen currency when following a product link. + function productHref(slug: string) { + return currency === DEFAULT_CURRENCY + ? `/product/${slug}` + : `/product/${slug}?currency=${currency}`; + } + + const activeFilterCount = selectedBrands.size + (priceRange[0] > 0 || priceRange[1] < maxPrice ? 1 : 0); + + return ( +
+ {/* Header */} +
+
+

All Products

+ + {filtered.length === 0 + ? "0 items" + : `${firstShown}–${lastShown} of ${filtered.length} items`} + +
+
+ + +
+
+ +
+ {/* Sidebar Filters */} + + + {/* Product Grid */} +
+ {filtered.length === 0 ? ( +
+

No products match your filters

+ +
+ ) : ( + + )} + + {totalPages > 1 && ( + + )} +
+
+
+ ); +} diff --git a/storefront/src/data/products.json b/storefront/src/data/products.json new file mode 100644 index 0000000..4e9eb14 --- /dev/null +++ b/storefront/src/data/products.json @@ -0,0 +1,302 @@ +[ + { + "id": 1, + "name": "DW-5600STT-1 Stranger Things Men Digital Watch Black Resin Band", + "brand": "G-Shock", + "price": 2577.12, + "currency": "ZAR", + "slug": "01-g-shock-dw-5600stt-1-stranger-things-men-digital-watch-black-resin-b", + "image_count": 4, + "image": "/products/01-g-shock-dw-5600stt-1-stranger-things-men-digital-watch-black-resin-b/image-1.jpg" + }, + { + "id": 2, + "name": "Relaxed Fit Printed T-shirt", + "brand": "H&M", + "price": 143.52, + "currency": "ZAR", + "slug": "02-h-m-relaxed-fit-printed-t-shirt", + "image_count": 5, + "image": "/products/02-h-m-relaxed-fit-printed-t-shirt/image-1.jpg" + }, + { + "id": 3, + "name": "Regular Jeans", + "brand": "H&M", + "price": 479.52, + "currency": "ZAR", + "slug": "03-h-m-regular-jeans", + "image_count": 7, + "image": "/products/03-h-m-regular-jeans/image-1.jpg" + }, + { + "id": 4, + "name": "Cotton T-shirt with Breast Pocket", + "brand": "Lacoste", + "price": 885.6, + "currency": "ZAR", + "slug": "04-lacoste-cotton-t-shirt-with-breast-pocket", + "image_count": 2, + "image": "/products/04-lacoste-cotton-t-shirt-with-breast-pocket/image-1.jpg" + }, + { + "id": 5, + "name": "Heritage Logo Washed Baseball Cap - Tommy Jeans", + "brand": "Tommy Hilfiger", + "price": 558.96, + "currency": "ZAR", + "slug": "05-tommy-hilfiger-heritage-logo-washed-baseball-cap-tommy-jeans", + "image_count": 1, + "image": "/products/05-tommy-hilfiger-heritage-logo-washed-baseball-cap-tommy-jeans/image-1.jpg" + }, + { + "id": 6, + "name": "Baggy Worker Pants", + "brand": "Cotton On", + "price": 549.36, + "currency": "ZAR", + "slug": "06-cotton-on-baggy-worker-pants", + "image_count": 1, + "image": "/products/06-cotton-on-baggy-worker-pants/image-1.jpg" + }, + { + "id": 7, + "name": "Arch Logo T-Shirt", + "brand": "GAP", + "price": 292.56, + "currency": "ZAR", + "slug": "07-gap-arch-logo-t-shirt", + "image_count": 1, + "image": "/products/07-gap-arch-logo-t-shirt/image-1.jpg" + }, + { + "id": 8, + "name": "Men's Cotton Short Sleeve Print Tee 13096224", + "brand": "Giordano", + "price": 134.54, + "currency": "ZAR", + "slug": "08-giordano-men-s-cotton-short-sleeve-print-tee-13096224", + "image_count": 5, + "image": "/products/08-giordano-men-s-cotton-short-sleeve-print-tee-13096224/image-1.jpg" + }, + { + "id": 9, + "name": "Square Buckle Leather Belt - TH Mainline", + "brand": "Tommy Hilfiger", + "price": 788.83, + "currency": "ZAR", + "slug": "09-tommy-hilfiger-square-buckle-leather-belt-th-mainline", + "image_count": 1, + "image": "/products/09-tommy-hilfiger-square-buckle-leather-belt-th-mainline/image-1.jpg" + }, + { + "id": 10, + "name": "Vintage Script T-Shirt", + "brand": "Superdry", + "price": 387.5, + "currency": "ZAR", + "slug": "10-superdry-vintage-script-t-shirt", + "image_count": 8, + "image": "/products/10-superdry-vintage-script-t-shirt/image-1.jpg" + }, + { + "id": 11, + "name": "Tech\u2122 Short Sleeve T-Shirt", + "brand": "Under Armour", + "price": 212.35, + "currency": "ZAR", + "slug": "11-under-armour-tech-short-sleeve-t-shirt", + "image_count": 1, + "image": "/products/11-under-armour-tech-short-sleeve-t-shirt/image-1.jpg" + }, + { + "id": 12, + "name": "Tech\u2122 Polo Shirt", + "brand": "Under Armour", + "price": 549.89, + "currency": "ZAR", + "slug": "12-under-armour-tech-polo-shirt", + "image_count": 1, + "image": "/products/12-under-armour-tech-polo-shirt/image-1.jpg" + }, + { + "id": 13, + "name": "Six-Panel Baseball Cap - Th Mainline", + "brand": "Tommy Hilfiger", + "price": 688.03, + "currency": "ZAR", + "slug": "13-tommy-hilfiger-six-panel-baseball-cap-th-mainline", + "image_count": 1, + "image": "/products/13-tommy-hilfiger-six-panel-baseball-cap-th-mainline/image-1.jpg" + }, + { + "id": 14, + "name": "1985 Regular Polo Shirt - Th Mainline", + "brand": "Tommy Hilfiger", + "price": 1143.07, + "currency": "ZAR", + "slug": "14-tommy-hilfiger-1985-regular-polo-shirt-th-mainline", + "image_count": 1, + "image": "/products/14-tommy-hilfiger-1985-regular-polo-shirt-th-mainline/image-1.jpg" + }, + { + "id": 15, + "name": "Box Fit Text T-Shirt", + "brand": "Cotton On", + "price": 375.98, + "currency": "ZAR", + "slug": "15-cotton-on-box-fit-text-t-shirt", + "image_count": 1, + "image": "/products/15-cotton-on-box-fit-text-t-shirt/image-1.jpg" + }, + { + "id": 16, + "name": "Men's Cotton Short Sleeve Print Tee 13096299", + "brand": "Giordano", + "price": 134.54, + "currency": "ZAR", + "slug": "16-giordano-men-s-cotton-short-sleeve-print-tee-13096299", + "image_count": 5, + "image": "/products/16-giordano-men-s-cotton-short-sleeve-print-tee-13096299/image-1.jpg" + }, + { + "id": 17, + "name": "Gap Original Arch Crew", + "brand": "GAP", + "price": 688.13, + "currency": "ZAR", + "slug": "17-gap-gap-original-arch-crew", + "image_count": 1, + "image": "/products/17-gap-gap-original-arch-crew/image-1.jpg" + }, + { + "id": 18, + "name": "Performance Tech\u2122 Unisex 3-Pack Crew Socks", + "brand": "Under Armour", + "price": 251.66, + "currency": "ZAR", + "slug": "18-under-armour-performance-tech-unisex-3-pack-crew-socks", + "image_count": 3, + "image": "/products/18-under-armour-performance-tech-unisex-3-pack-crew-socks/image-1.jpg" + }, + { + "id": 19, + "name": "Chino Pants", + "brand": "Electro Denim Lab", + "price": 312.0, + "currency": "ZAR", + "slug": "19-electro-denim-lab-chino-pants", + "image_count": 5, + "image": "/products/19-electro-denim-lab-chino-pants/image-1.jpg" + }, + { + "id": 20, + "name": "Flag Raglan Sleeves Relaxed Jersey T-Shirt - Th Mainline", + "brand": "Tommy Hilfiger", + "price": 863.71, + "currency": "ZAR", + "slug": "20-tommy-hilfiger-flag-raglan-sleeves-relaxed-jersey-t-shirt-th-mainline", + "image_count": 1, + "image": "/products/20-tommy-hilfiger-flag-raglan-sleeves-relaxed-jersey-t-shirt-th-mainline/image-1.jpg" + }, + { + "id": 21, + "name": "3-Pack Essential Crew Socks", + "brand": "Cotton On", + "price": 158.64, + "currency": "ZAR", + "slug": "21-cotton-on-3-pack-essential-crew-socks", + "image_count": 1, + "image": "/products/21-cotton-on-3-pack-essential-crew-socks/image-1.jpg" + }, + { + "id": 22, + "name": "Everyday Fleece Shorts", + "brand": "Cotton On", + "price": 292.56, + "currency": "ZAR", + "slug": "22-cotton-on-everyday-fleece-shorts", + "image_count": 1, + "image": "/products/22-cotton-on-everyday-fleece-shorts/image-1.jpg" + }, + { + "id": 23, + "name": "Box Fit Graphic Hoodie", + "brand": "Cotton On", + "price": 681.74, + "currency": "ZAR", + "slug": "23-cotton-on-box-fit-graphic-hoodie", + "image_count": 1, + "image": "/products/23-cotton-on-box-fit-graphic-hoodie/image-1.jpg" + }, + { + "id": 24, + "name": "Grimelanj Regular Fit Plaid Knitted Shorts Pajama Set TMNSS25PT00034", + "brand": "Trendyol", + "price": 379.34, + "currency": "ZAR", + "slug": "24-trendyol-grimelanj-regular-fit-plaid-knitted-shorts-pajama-set-tmnss2", + "image_count": 1, + "image": "/products/24-trendyol-grimelanj-regular-fit-plaid-knitted-shorts-pajama-set-tmnss2/image-1.jpg" + }, + { + "id": 25, + "name": "C BY CAMEL ACTIVE OUTDOOR HIKING TRAVEL BACKPACK - 51250081", + "brand": "Camel Active", + "price": 1291.2, + "currency": "ZAR", + "slug": "25-camel-active-c-by-camel-active-outdoor-hiking-travel-backpack-51250081", + "image_count": 7, + "image": "/products/25-camel-active-c-by-camel-active-outdoor-hiking-travel-backpack-51250081/image-1.jpg" + }, + { + "id": 26, + "name": "Cotton T-shirt", + "brand": "Lacoste", + "price": 1239.84, + "currency": "ZAR", + "slug": "26-lacoste-cotton-t-shirt", + "image_count": 1, + "image": "/products/26-lacoste-cotton-t-shirt/image-1.jpg" + }, + { + "id": 27, + "name": "Under Armour UA Tech\u2122 Vent Men's Short Sleeve", + "brand": "Under Armour", + "price": 400.51, + "currency": "ZAR", + "slug": "27-under-armour-under-armour-ua-tech-vent-men-s-short-sleeve", + "image_count": 5, + "image": "/products/27-under-armour-under-armour-ua-tech-vent-men-s-short-sleeve/image-1.jpg" + }, + { + "id": 28, + "name": "Pool Slide Sandals - TH Mainline", + "brand": "Tommy Hilfiger", + "price": 921.31, + "currency": "ZAR", + "slug": "28-tommy-hilfiger-pool-slide-sandals-th-mainline", + "image_count": 1, + "image": "/products/28-tommy-hilfiger-pool-slide-sandals-th-mainline/image-1.jpg" + }, + { + "id": 29, + "name": "Low Rise Trunks 3 Pack - Calvin Klein Underwear", + "brand": "Calvin Klein", + "price": 866.16, + "currency": "ZAR", + "slug": "29-calvin-klein-low-rise-trunks-3-pack-calvin-klein-underwear", + "image_count": 1, + "image": "/products/29-calvin-klein-low-rise-trunks-3-pack-calvin-klein-underwear/image-1.jpg" + }, + { + "id": 30, + "name": "Men's Backpack - Black", + "brand": "Swiss Polo", + "price": 497.04, + "currency": "ZAR", + "slug": "30-swiss-polo-men-s-backpack-black", + "image_count": 2, + "image": "/products/30-swiss-polo-men-s-backpack-black/image-1.jpg" + } +] \ No newline at end of file diff --git a/storefront/src/lib/auth.ts b/storefront/src/lib/auth.ts new file mode 100644 index 0000000..eca3dc5 --- /dev/null +++ b/storefront/src/lib/auth.ts @@ -0,0 +1,283 @@ +export interface User { + id: string; + email: string; + first_name: string | null; + last_name: string | null; + phone: string | null; + full_name: string; + addresses: Address[]; + default_shipping_address: Address | null; + // Where the region anchor lives for a signed-in customer. Stock Spree neither + // permits nor serializes this — the backend carries a decorator for it + // (config/initializers/customer_preferences.rb on pak-jabit-backend). + public_metadata?: { preferred_currency?: string } | null; +} + +export interface Address { + id: string; + label: string | null; + first_name: string; + last_name: string; + full_name: string; + address1: string; + address2: string | null; + company: string | null; + city: string; + state_name: string | null; + postal_code: string; + phone: string | null; + country_iso: string; + country_name: string; + is_default_shipping: boolean; + is_default_billing: boolean; +} + +export interface AddressInput { + label?: string; + first_name: string; + last_name: string; + address1: string; + address2?: string; + city: string; + state_name?: string; + postal_code: string; + phone?: string; + country_iso: string; + is_default_shipping?: boolean; + is_default_billing?: boolean; +} + +export interface SavedCard { + id: string; + brand: string | null; + last4: string; + month: number; + year: number; + name: string | null; + default: boolean; +} + +export interface AuthResult { + token: string; + refresh_token: string; + user: User; + error?: { message: string }; +} + +export async function register(email: string, password: string): Promise { + const res = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + return res.json(); +} + +export async function login(email: string, password: string): Promise { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + return res.json(); +} + +// Returning null on error hid a 401 behind an empty-looking profile page for the +// whole life of this route. Throw with whatever Spree actually said so the caller +// can show it. +async function readError(res: Response): Promise { + try { + const body = await res.json(); + return body?.error?.message || body?.error || body?.message || `HTTP ${res.status}`; + } catch { + return `HTTP ${res.status}`; + } +} + +export async function getProfile(token: string): Promise { + const res = await authFetch("/api/auth/me", token); + if (!res.ok) throw new Error(await readError(res)); + return res.json(); +} + +export async function updateProfile( + token: string, + data: { + first_name?: string; + last_name?: string; + phone?: string; + public_metadata?: Record; + } +): Promise { + const res = await authFetch("/api/auth/me", token, { + method: "PATCH", + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error(await readError(res)); + return res.json(); +} + +// An empty array here is indistinguishable from "this customer has no addresses", +// so a failed request used to read as a legitimately empty address book. +export async function getAddresses(token: string): Promise { + const user = await getProfile(token); + return user.addresses || []; +} + +export function saveAuth(token: string, refreshToken: string, user?: User) { + try { + localStorage.setItem("pak-jabit-auth", JSON.stringify({ token, refreshToken, user })); + } catch {} +} + +export function loadAuth(): { token: string; refreshToken: string; user?: User } | null { + try { + const raw = localStorage.getItem("pak-jabit-auth"); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +export function clearAuth() { + try { + localStorage.removeItem("pak-jabit-auth"); + } catch {} +} + +// --------------------------------------------------------------------------- +// Address book +// --------------------------------------------------------------------------- + +function authHeaders(token: string): HeadersInit { + return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; +} + +/** Fired after a silent token renewal so AuthProvider can pick up the new one. */ +export const AUTH_REFRESHED = "pak-jabit-auth-refreshed"; + +// In-flight renewal, shared. Without this, a page that loads addresses and cards +// at once fires two refreshes on the same expired token; the second spends an +// already-rotated refresh token and fails, signing the customer out mid-load. +let refreshing: Promise | null = null; + +async function renewToken(): Promise { + if (refreshing) return refreshing; + + refreshing = (async () => { + const stored = loadAuth(); + if (!stored?.refreshToken) return null; + + const res = await fetch("/api/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token: stored.refreshToken }), + }); + if (!res.ok) return null; + + const body = await res.json(); + if (!body?.token) return null; + + saveAuth(body.token, body.refresh_token || stored.refreshToken, body.user || stored.user); + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent(AUTH_REFRESHED, { + detail: { token: body.token, refreshToken: body.refresh_token, user: body.user }, + }) + ); + } + return body.token as string; + })().finally(() => { + refreshing = null; + }); + + return refreshing; +} + +/** + * Call the storefront's auth API. On 401 the access token is renewed once and + * the request replayed. If renewal fails the session is cleared — staying + * "signed in" with a dead token is what made this invisible for so long. + */ +async function authFetch( + path: string, + token: string, + init: RequestInit = {} +): Promise { + const send = (t: string) => + fetch(path, { ...init, headers: { ...authHeaders(t), ...(init.headers || {}) } }); + + let res = await send(token); + if (res.status !== 401) return res; + + const fresh = await renewToken(); + if (!fresh) { + clearAuth(); + throw new Error("Your session expired. Please sign in again."); + } + + res = await send(fresh); + if (res.status === 401) { + clearAuth(); + throw new Error("Your session expired. Please sign in again."); + } + return res; +} + +export async function listAddresses(token: string): Promise { + const res = await authFetch("/api/auth/addresses", token); + if (!res.ok) throw new Error(await readError(res)); + const body = await res.json(); + return body.data || []; +} + +export async function createAddress(token: string, data: AddressInput): Promise
{ + const res = await authFetch("/api/auth/addresses", token, { + method: "POST", + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error(await readError(res)); + return res.json(); +} + +export async function updateAddress( + token: string, + id: string, + data: Partial +): Promise
{ + const res = await authFetch(`/api/auth/addresses/${id}`, token, { + method: "PATCH", + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error(await readError(res)); + return res.json(); +} + +export async function deleteAddress(token: string, id: string): Promise { + const res = await authFetch(`/api/auth/addresses/${id}`, token, { method: "DELETE" }); + if (!res.ok) throw new Error(await readError(res)); +} + +// --------------------------------------------------------------------------- +// Saved cards +// --------------------------------------------------------------------------- + +export async function listCards(token: string): Promise { + const res = await authFetch("/api/auth/cards", token); + if (!res.ok) throw new Error(await readError(res)); + const body = await res.json(); + return body.data || []; +} + +// #mock — one-shot because the Bogus gateway needs no card entry. A real gateway +// splits this: create session here, collect the card in the gateway's own hosted +// field, then complete. See src/app/api/auth/cards/setup/route.ts. +export async function addTestCard(token: string): Promise { + const res = await authFetch("/api/auth/cards/setup", token, { method: "POST" }); + if (!res.ok) throw new Error(await readError(res)); +} + +export async function deleteCard(token: string, id: string): Promise { + const res = await authFetch(`/api/auth/cards/${id}`, token, { method: "DELETE" }); + if (!res.ok) throw new Error(await readError(res)); +} diff --git a/storefront/src/lib/cart.ts b/storefront/src/lib/cart.ts new file mode 100644 index 0000000..95b84c5 --- /dev/null +++ b/storefront/src/lib/cart.ts @@ -0,0 +1,152 @@ +async function cartProxy( + pathname: string, + method: string, + spreeToken?: string, + body?: unknown, + userToken?: string +) { + const res = await fetch("/api/cart", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pathname, method, spreeToken, body, userToken }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Cart API ${res.status}: ${text}`); + } + return res.json(); +} + +export interface CartItem { + id: string; + name: string; + quantity: number; + price: string; + display_price: string; + total: string; + display_total: string; + variant_id: string; + image_url: string | null; +} + +export interface Market { + id: string; + name: string; + currency: string; + country_isos: string[]; +} + +export interface Cart { + id: string; + token: string; + total_quantity: number; + display_total: string; + display_item_total: string; + items: CartItem[]; + // Fixed at creation by Spree. A cart older than a currency change keeps the + // old one, so it is compared against the market's to spot a stale cart. + currency: string; + // A cart belongs to ONE market, and shipping only resolves for that market's + // countries. Checkout uses this to offer exactly what can actually ship. + market: Market | null; +} + +function parseCart(data: Record): Cart { + const items = (data.items as Record[]) || []; + return { + id: data.id as string, + token: data.token as string, + total_quantity: data.total_quantity as number, + display_total: data.display_total as string, + display_item_total: data.display_item_total as string, + market: (data.market as Market) ?? null, + currency: (data.currency as string) ?? "", + items: items.map((item) => ({ + id: item.id as string, + name: item.name as string, + quantity: item.quantity as number, + price: item.price as string, + display_price: item.display_price as string, + total: item.total as string, + display_total: item.display_total as string, + variant_id: item.variant_id as string, + image_url: (item.thumbnail_url as string) || null, + })), + }; +} + +/** + * Spree fixes a cart's market AND currency at creation - neither can be changed + * afterwards. Passing them here is the only chance to get the region right. + */ +export async function createCart( + opts?: { market_id: string; currency: string }, + userToken?: string +): Promise { + // Passing the customer's token makes Spree attach the cart to them at + // creation, so it is theirs on every device from the first item. + const data = await cartProxy("/carts", "POST", undefined, opts, userToken); + return parseCart(data); +} + +/** The signed-in customer's own cart, held server-side. Null when they have none. */ +export async function getMyCart(userToken: string): Promise { + const data = await cartProxy("/carts", "GET", undefined, undefined, userToken); + const list = (data?.data as Record[]) || []; + if (list.length === 0) return null; + // Newest first: a customer can end up with more than one open cart, and the + // last one they touched is the one they mean. + const newest = [...list].sort((a, b) => + String(b.created_at ?? "").localeCompare(String(a.created_at ?? "")) + )[0]; + return parseCart(newest); +} + +/** Claim a guest cart for the signed-in customer. */ +export async function associateCart( + id: string, + cartToken: string, + userToken: string +): Promise { + const data = await cartProxy(`/carts/${id}/associate`, "PATCH", cartToken, {}, userToken); + return parseCart(data); +} + +export async function getCart(id: string, token: string): Promise { + const data = await cartProxy(`/carts/${id}`, "GET", token); + return parseCart(data); +} + +export async function addItem( + cartId: string, + token: string, + variantId: string, + quantity: number = 1 +): Promise { + const data = await cartProxy(`/carts/${cartId}/items`, "POST", token, { + variant_id: variantId, + quantity, + }); + return parseCart(data); +} + +export async function updateItemQuantity( + cartId: string, + token: string, + itemId: string, + quantity: number +): Promise { + const data = await cartProxy(`/carts/${cartId}/items/${itemId}`, "PATCH", token, { + quantity, + }); + return parseCart(data); +} + +export async function removeItem( + cartId: string, + token: string, + itemId: string +): Promise { + const data = await cartProxy(`/carts/${cartId}/items/${itemId}`, "DELETE", token); + return parseCart(data); +} diff --git a/storefront/src/lib/currency.ts b/storefront/src/lib/currency.ts new file mode 100644 index 0000000..994c0fa --- /dev/null +++ b/storefront/src/lib/currency.ts @@ -0,0 +1,30 @@ +// The currencies the store can display. Each one needs BOTH a Spree market +// (markets are what make a currency selectable — the store's +// supported_currencies column is not read by the v3 API) and a price row per +// product in that currency. Adding a code here without both is a silent +// no-op: Spree rejects the unsupported currency and serves the default. +export const CURRENCIES = [ + { code: "ZAR", symbol: "R", label: "ZAR" }, + { code: "USD", symbol: "$", label: "USD" }, + { code: "MYR", symbol: "RM", label: "MYR" }, +] as const; + +export type CurrencyCode = (typeof CURRENCIES)[number]["code"]; + +export const DEFAULT_CURRENCY: CurrencyCode = "ZAR"; + +/** + * Narrow an arbitrary value (a URL query param) to a supported currency. + * Returns null when it isn't one, so callers decide what to do rather than + * inheriting a guessed default. + */ +export function parseCurrency(value: unknown): CurrencyCode | null { + if (typeof value !== "string") return null; + const upper = value.toUpperCase(); + const hit = CURRENCIES.find((c) => c.code === upper); + return hit ? hit.code : null; +} + +export function symbolFor(code: string): string { + return CURRENCIES.find((c) => c.code === code)?.symbol ?? ""; +} diff --git a/storefront/src/lib/easyparcel.ts b/storefront/src/lib/easyparcel.ts new file mode 100644 index 0000000..8e5eaf9 --- /dev/null +++ b/storefront/src/lib/easyparcel.ts @@ -0,0 +1,267 @@ +import fs from "fs"; +import path from "path"; + +const EP_BASE = "https://api.easyparcel.com"; +const EP_API_VERSION = "2026-06"; + +// Read lazily, not at module load: `next build` imports this file without the +// deployment env, and a module-level throw would break the build. +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is not set — EasyParcel cannot authenticate`); + } + return value; +} + +const clientId = () => requireEnv("EASYPARCEL_CLIENT_ID"); + +// The refresh token has to outlive the process. It used to live only in a +// module variable, so every restart, redeploy or serverless cold start silently +// dropped it and the next quote failed with "run OAuth flow first". +const TOKEN_PATH = + process.env.EASYPARCEL_TOKEN_PATH || path.join(process.cwd(), ".easyparcel-token.json"); + +interface StoredToken { + access_token: string; + refresh_token: string; + expires_at: number; +} + +let cachedToken: StoredToken | null = null; + +function readToken(): StoredToken | null { + if (cachedToken) return cachedToken; + try { + cachedToken = JSON.parse(fs.readFileSync(TOKEN_PATH, "utf8")) as StoredToken; + return cachedToken; + } catch (err) { + // Absent is normal before the first OAuth run; unreadable or corrupt is not. + if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; + throw new Error(`EasyParcel token at ${TOKEN_PATH} is unreadable: ${(err as Error).message}`); + } +} + +function writeToken(token: StoredToken): void { + cachedToken = token; + fs.writeFileSync(TOKEN_PATH, JSON.stringify(token), { mode: 0o600 }); +} + +function basicAuth(): string { + return Buffer.from( + `${requireEnv("EASYPARCEL_CLIENT_ID")}:${requireEnv("EASYPARCEL_CLIENT_SECRET")}` + ).toString("base64"); +} + +export async function getToken(): Promise { + const stored = readToken(); + + if (stored && Date.now() < stored.expires_at - 60000) { + return stored.access_token; + } + + if (stored?.refresh_token) { + const res = await fetch(`${EP_BASE}/oauth/token`, { + method: "POST", + headers: { + Authorization: `Basic ${basicAuth()}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + grant_type: "refresh_token", + refresh_token: stored.refresh_token, + }), + }); + if (res.ok) { + const data = await res.json(); + writeToken({ + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_at: Date.now() + data.expires_in * 1000, + }); + return data.access_token; + } + } + + throw new Error("No valid EasyParcel token — run OAuth flow first"); +} + +export function setToken(access_token: string, refresh_token: string, expires_in: number) { + writeToken({ + access_token, + refresh_token, + expires_at: Date.now() + expires_in * 1000, + }); +} + +async function ep(path: string, body: unknown): Promise { + const token = await getToken(); + const res = await fetch(`${EP_BASE}/open_api/${EP_API_VERSION}${path}`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`EasyParcel ${res.status}: ${text}`); + } + return res.json(); +} + +export interface QuoteRequest { + sender_postcode: string; + sender_state: string; + receiver_postcode: string; + receiver_state: string; + weight: number; +} + +export interface CourierOption { + service_id: string; + service_name: string; + courier_name: string; + courier_logo: string; + delivery_duration: string; + is_pickup: boolean; + is_dropoff: boolean; + total_amount: string; + currency: string; +} + +export async function getQuotes(req: QuoteRequest): Promise { + const data = (await ep("/shipment/quotations", { + shipment: [ + { + sender: { + postcode: req.sender_postcode, + subdivision_code: req.sender_state, + country: "MY", + }, + receiver: { + postcode: req.receiver_postcode, + subdivision_code: req.receiver_state, + country: "MY", + }, + weight: req.weight, + }, + ], + })) as { data: Array<{ quotations: Array<{ courier: Record; pricing: Record }> }> }; + + const shipment = data.data?.[0]; + if (!shipment?.quotations) return []; + + return shipment.quotations.map((q) => { + let duration = q.courier.delivery_duration as string; + try { + const parsed = JSON.parse(duration); + if (parsed?.type === "days" && parsed?.value) { + duration = `${parsed.value} day${parsed.value === "1" ? "" : "s"}`; + } + } catch { /* already a string */ } + + return { + service_id: q.courier.service_id as string, + service_name: q.courier.service_name as string, + courier_name: q.courier.courier_name as string, + courier_logo: q.courier.courier_logo as string, + delivery_duration: duration, + is_pickup: q.courier.is_pickup as boolean, + is_dropoff: q.courier.is_dropoff as boolean, + total_amount: (q.pricing as Record).total_amount, + currency: (q.pricing as Record).currency, + }; + }); +} + +export interface BookRequest { + service_id: string; + reference: string; + collection_date: string; + weight: number; + sender: { + name: string; + phone: string; + email: string; + address: string; + postcode: string; + city: string; + state: string; + }; + receiver: { + name: string; + phone: string; + email: string; + address: string; + postcode: string; + city: string; + state: string; + }; + items: Array<{ + content: string; + weight: number; + value: number; + quantity: number; + }>; +} + +export async function bookShipment(req: BookRequest) { + return ep("/shipment/submit_orders", { + shipment: [ + { + reference: req.reference, + service_id: req.service_id, + collection_date: req.collection_date, + weight: req.weight, + height: 10, + length: 10, + width: 10, + item: req.items.map((i) => ({ + content: i.content, + weight: i.weight, + height: 10, + length: 10, + width: 10, + currency_code: "MYR", + value: i.value, + quantity: i.quantity, + })), + sender: { + name: req.sender.name, + phone_number_country_code: "MY", + phone_number: req.sender.phone, + email: req.sender.email, + address_1: req.sender.address, + postcode: req.sender.postcode, + city: req.sender.city, + subdivision_code: req.sender.state, + country_code: "MY", + }, + receiver: { + name: req.receiver.name, + phone_number_country_code: "MY", + phone_number: req.receiver.phone, + email: req.receiver.email, + address_1: req.receiver.address, + postcode: req.receiver.postcode, + city: req.receiver.city, + subdivision_code: req.receiver.state, + country_code: "MY", + }, + feature: { + sms_tracking: false, + email_tracking: true, + whatsapp_tracking: false, + }, + }, + ], + }); +} + +export async function trackShipment(awbNumbers: string[]) { + return ep("/shipment/tracking_status", { awb_numbers: awbNumbers }); +} + +export { EP_BASE, clientId, basicAuth }; diff --git a/storefront/src/lib/geo.ts b/storefront/src/lib/geo.ts new file mode 100644 index 0000000..9d2d4e3 --- /dev/null +++ b/storefront/src/lib/geo.ts @@ -0,0 +1,68 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export interface GeoState { + abbr: string; + name: string; +} + +export interface GeoCountry { + iso: string; + iso3: string; + name: string; + states_required: boolean; + zipcode_required: boolean; + states?: GeoState[]; +} + +// One fetch per page load, shared by every component that asks. The list is the +// store's checkout countries and changes about never, so the route caches it for +// an hour server-side too. +let cache: Promise | null = null; + +export function loadCountries(): Promise { + if (!cache) { + cache = fetch("/api/countries") + .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))) + .then((b) => (b.data || []) as GeoCountry[]) + .catch((e) => { + // Let the next caller try again rather than caching the failure forever. + cache = null; + throw e; + }); + } + return cache; +} + +/** + * Countries for the address form, plus whichever states belong to the selected + * one. `statesRequired` says whether the country has subdivisions at all — + * several do not, and demanding a province for those blocks a valid address. + */ +export function useCountries(selectedIso: string) { + const [countries, setCountries] = useState([]); + const [error, setError] = useState(""); + + useEffect(() => { + let live = true; + loadCountries() + .then((c) => live && setCountries(c)) + .catch((e) => live && setError(e instanceof Error ? e.message : "Could not load countries")); + return () => { + live = false; + }; + }, []); + + const country = countries.find((c) => c.iso === selectedIso) || null; + const states = country?.states || []; + + return { + countries, + country, + states, + statesRequired: !!country?.states_required && states.length > 0, + zipRequired: country?.zipcode_required ?? true, + error, + }; +} diff --git a/storefront/src/lib/region.ts b/storefront/src/lib/region.ts new file mode 100644 index 0000000..76a6d28 --- /dev/null +++ b/storefront/src/lib/region.ts @@ -0,0 +1,78 @@ +"use client"; + +import { CurrencyCode, DEFAULT_CURRENCY, parseCurrency } from "@/lib/currency"; + +/** + * The region anchor. + * + * Spree fixes a cart's currency AND market at creation and neither can be + * changed afterwards — switching currency mid-basket leaves items priced in + * money the order will never use. So the choice has to exist before the cart + * does, and it has to outlive any single cart. + * + * It lives in two places on purpose: localStorage so a guest keeps it, and the + * customer's public_metadata so it follows a signed-in person to another device. + * The profile wins when both exist. + */ + +export interface Market { + id: string; + name: string; + currency: string; + country_isos: string[]; + default: boolean; +} + +const STORAGE_KEY = "pak-jabit-region"; + +export function loadRegion(): CurrencyCode | null { + try { + return parseCurrency(localStorage.getItem(STORAGE_KEY)); + } catch { + return null; + } +} + +export function saveRegion(code: CurrencyCode) { + try { + localStorage.setItem(STORAGE_KEY, code); + } catch { + // A browser with site data blocked still shops; the choice just resets on + // reload, and a signed-in customer's profile carries it anyway. + } +} + +let cache: Promise | null = null; + +export function loadMarkets(): Promise { + if (!cache) { + cache = fetch("/api/markets") + .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))) + .then((b) => (b.data || []) as Market[]) + .catch((e) => { + cache = null; + throw e; + }); + } + return cache; +} + +/** The market quoting in this currency, or null when none does. */ +export function marketFor(markets: Market[], currency: string): Market | null { + return markets.find((m) => m.currency === currency) || null; +} + +/** + * What a new cart should be created with. Returns null rather than guessing when + * no market quotes the requested currency — a cart built on a guessed market + * prices and ships somewhere nobody chose. + */ +export function cartOptionsFor( + markets: Market[], + currency: CurrencyCode +): { market_id: string; currency: string } | null { + const m = marketFor(markets, currency); + return m ? { market_id: m.id, currency: m.currency } : null; +} + +export { DEFAULT_CURRENCY }; diff --git a/storefront/src/lib/spree.ts b/storefront/src/lib/spree.ts new file mode 100644 index 0000000..e011f68 --- /dev/null +++ b/storefront/src/lib/spree.ts @@ -0,0 +1,86 @@ +import { DEFAULT_CURRENCY, type CurrencyCode } from "./currency"; + +const API_URL = process.env.NEXT_PUBLIC_SPREE_API_URL || "https://api.jux-lab.com"; +const API_KEY = process.env.NEXT_PUBLIC_SPREE_API_KEY || ""; + +// Spree builds media URLs from RAILS_HOST, which carries no scheme, so they +// always come back http://. Caddy serves the same paths over TLS. +export function fixImageUrl(url: string | null): string { + if (!url) return ""; + return url.replace(/^http:\/\//, "https://"); +} + +async function spree(path: string, currency: CurrencyCode = DEFAULT_CURRENCY) { + const sep = path.includes("?") ? "&" : "?"; + const res = await fetch(`${API_URL}/api/v3/store${path}${sep}currency=${currency}`, { + headers: { "X-Spree-Api-Key": API_KEY }, + next: { revalidate: 60 }, + }); + if (!res.ok) throw new Error(`Spree API ${res.status}: ${path}`); + return res.json(); +} + +export interface SpreeMedia { + id: string; + position: number; + original_url: string; + mini_url: string; + small_url?: string; + large_url?: string; +} + +export interface SpreeProduct { + id: string; + name: string; + slug: string; + description: string; + available: boolean; + default_variant_id: string; + price: { amount: string; currency: string; display_amount: string }; + thumbnail_url: string | null; + tags: string[]; + media?: SpreeMedia[]; +} + +function fixProductUrls(p: SpreeProduct): SpreeProduct { + return { + ...p, + thumbnail_url: fixImageUrl(p.thumbnail_url), + media: p.media?.map((m) => ({ + ...m, + original_url: fixImageUrl(m.original_url), + mini_url: fixImageUrl(m.mini_url), + small_url: m.small_url ? fixImageUrl(m.small_url) : undefined, + large_url: m.large_url ? fixImageUrl(m.large_url) : undefined, + })), + }; +} + +export async function getProducts( + currency: CurrencyCode = DEFAULT_CURRENCY +): Promise { + const allProducts: SpreeProduct[] = []; + let page = 1; + while (true) { + const data = await spree(`/products?per_page=25&page=${page}`, currency); + const products = (data.data || []).map(fixProductUrls); + allProducts.push(...products); + const meta = data.meta; + if (!meta?.next || page >= (meta.pages || 1)) break; + page++; + } + return allProducts; +} + +export async function getProduct( + slug: string, + currency: CurrencyCode = DEFAULT_CURRENCY +): Promise { + try { + const data = await spree(`/products/${slug}?expand=media`, currency); + const product = data.data || data || null; + return product ? fixProductUrls(product) : null; + } catch { + return null; + } +} diff --git a/storefront/src/lib/spreeProxy.ts b/storefront/src/lib/spreeProxy.ts new file mode 100644 index 0000000..8fbd526 --- /dev/null +++ b/storefront/src/lib/spreeProxy.ts @@ -0,0 +1,80 @@ +/** + * Thin proxy to Spree's store API. + * + * Every store-API call needs BOTH the store key and the customer's Bearer token. + * Sending only the Bearer answers 401 "Valid API key required" — which is exactly + * what /api/auth/me did from the day it was written, so the account page never + * loaded a profile at all. Routing every call through here means that can only + * be got wrong in one place. + */ +import { NextRequest, NextResponse } from "next/server"; + +const SPREE_URL = process.env.NEXT_PUBLIC_SPREE_API_URL || "https://api.jux-lab.com"; +const API_KEY = process.env.NEXT_PUBLIC_SPREE_API_KEY || ""; + +export function bearer(req: NextRequest): string | null { + return req.headers.get("Authorization")?.replace("Bearer ", "") || null; +} + +export function unauthorized() { + return NextResponse.json({ error: { message: "Not signed in" } }, { status: 401 }); +} + +/** Call the Spree store API as the signed-in customer and pass the answer straight back. */ +export async function proxy( + path: string, + token: string, + init: { method?: string; body?: unknown } = {} +) { + const res = await fetch(`${SPREE_URL}/api/v3/store${path}`, { + method: init.method || "GET", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-Spree-Api-Key": API_KEY, + }, + body: init.body === undefined ? undefined : JSON.stringify(init.body), + }); + + // 204 No Content has no body to parse — DELETE answers with one. + if (res.status === 204) return new NextResponse(null, { status: 204 }); + + const text = await res.text(); + if (!text) return new NextResponse(null, { status: res.status }); + + try { + return NextResponse.json(JSON.parse(text), { status: res.status }); + } catch { + // Spree answered with something that isn't JSON (a proxy error page, an HTML + // 500). Say so plainly instead of throwing an opaque parse error. + return NextResponse.json( + { error: { message: `Spree returned non-JSON (HTTP ${res.status})` } }, + { status: 502 } + ); + } +} + +/** Raw variant for routes that need to inspect the parsed body before answering. */ +export async function call( + path: string, + token: string, + init: { method?: string; body?: unknown } = {} +): Promise<{ status: number; data: Record }> { + const res = await fetch(`${SPREE_URL}/api/v3/store${path}`, { + method: init.method || "GET", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-Spree-Api-Key": API_KEY, + }, + body: init.body === undefined ? undefined : JSON.stringify(init.body), + }); + const text = await res.text(); + let data: Record = {}; + try { + data = text ? JSON.parse(text) : {}; + } catch { + data = { error: { message: `Spree returned non-JSON (HTTP ${res.status})` } }; + } + return { status: res.status, data }; +} diff --git a/storefront/tsconfig.json b/storefront/tsconfig.json new file mode 100644 index 0000000..cf9c65d --- /dev/null +++ b/storefront/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +}