From 61112bc8eda6c6553647b38890e69e277f1c2f33 Mon Sep 17 00:00:00 2001 From: susan199817-spec Date: Fri, 27 Mar 2026 02:46:26 +0000 Subject: [PATCH] Fresh start without large files --- .gitignore | 49 + .npmrc | 2 + .replit | 36 + .replitignore | 5 + .../api-server/.replit-artifact/artifact.toml | 32 + artifacts/api-server/build.mjs | 126 + artifacts/api-server/package.json | 32 + artifacts/api-server/src/app.ts | 34 + artifacts/api-server/src/config/services.ts | 81 + artifacts/api-server/src/index.ts | 25 + artifacts/api-server/src/lib/.gitkeep | 0 .../api-server/src/lib/integration-tests.ts | 225 + artifacts/api-server/src/lib/logger.ts | 20 + artifacts/api-server/src/lib/shein-scraper.ts | 269 + artifacts/api-server/src/middleware/auth.ts | 20 + artifacts/api-server/src/middlewares/.gitkeep | 0 artifacts/api-server/src/routes/admin.ts | 78 + artifacts/api-server/src/routes/analytics.ts | 279 + artifacts/api-server/src/routes/auth.ts | 95 + artifacts/api-server/src/routes/cart.ts | 103 + artifacts/api-server/src/routes/categories.ts | 91 + .../api-server/src/routes/checkout-events.ts | 43 + artifacts/api-server/src/routes/coupons.ts | 89 + artifacts/api-server/src/routes/health.ts | 11 + .../api-server/src/routes/image-proxy.ts | 127 + artifacts/api-server/src/routes/index.ts | 38 + .../api-server/src/routes/integrations.ts | 219 + artifacts/api-server/src/routes/orders.ts | 235 + artifacts/api-server/src/routes/payments.ts | 68 + artifacts/api-server/src/routes/products.ts | 289 + artifacts/api-server/src/routes/reviews.ts | 181 + .../api-server/src/routes/store-settings.ts | 132 + artifacts/api-server/src/routes/wishlist.ts | 76 + artifacts/api-server/tsconfig.json | 17 + .../.replit-artifact/artifact.toml | 31 + artifacts/extra-store/components.json | 20 + artifacts/extra-store/index.html | 20 + artifacts/extra-store/package.json | 77 + artifacts/extra-store/public/favicon.svg | 3 + artifacts/extra-store/public/opengraph.jpg | Bin 0 -> 62490 bytes artifacts/extra-store/src/App.tsx | 2552 ++++++++ .../src/components/ui/accordion.tsx | 55 + .../src/components/ui/alert-dialog.tsx | 139 + .../extra-store/src/components/ui/alert.tsx | 59 + .../src/components/ui/aspect-ratio.tsx | 5 + .../extra-store/src/components/ui/avatar.tsx | 50 + .../extra-store/src/components/ui/badge.tsx | 43 + .../src/components/ui/breadcrumb.tsx | 115 + .../src/components/ui/button-group.tsx | 83 + .../extra-store/src/components/ui/button.tsx | 65 + .../src/components/ui/calendar.tsx | 213 + .../extra-store/src/components/ui/card.tsx | 76 + .../src/components/ui/carousel.tsx | 260 + .../extra-store/src/components/ui/chart.tsx | 367 ++ .../src/components/ui/checkbox.tsx | 28 + .../src/components/ui/collapsible.tsx | 11 + .../extra-store/src/components/ui/command.tsx | 153 + .../src/components/ui/context-menu.tsx | 198 + .../extra-store/src/components/ui/dialog.tsx | 120 + .../extra-store/src/components/ui/drawer.tsx | 116 + .../src/components/ui/dropdown-menu.tsx | 201 + .../extra-store/src/components/ui/empty.tsx | 104 + .../extra-store/src/components/ui/field.tsx | 244 + .../extra-store/src/components/ui/form.tsx | 176 + .../src/components/ui/hover-card.tsx | 27 + .../src/components/ui/input-group.tsx | 168 + .../src/components/ui/input-otp.tsx | 69 + .../extra-store/src/components/ui/input.tsx | 22 + .../extra-store/src/components/ui/item.tsx | 193 + .../extra-store/src/components/ui/kbd.tsx | 28 + .../extra-store/src/components/ui/label.tsx | 26 + .../extra-store/src/components/ui/menubar.tsx | 254 + .../src/components/ui/navigation-menu.tsx | 128 + .../src/components/ui/pagination.tsx | 117 + .../extra-store/src/components/ui/popover.tsx | 31 + .../src/components/ui/progress.tsx | 28 + .../src/components/ui/radio-group.tsx | 42 + .../src/components/ui/resizable.tsx | 45 + .../src/components/ui/scroll-area.tsx | 46 + .../extra-store/src/components/ui/select.tsx | 159 + .../src/components/ui/separator.tsx | 29 + .../extra-store/src/components/ui/sheet.tsx | 140 + .../extra-store/src/components/ui/sidebar.tsx | 727 +++ .../src/components/ui/skeleton.tsx | 15 + .../extra-store/src/components/ui/slider.tsx | 26 + .../extra-store/src/components/ui/sonner.tsx | 31 + .../extra-store/src/components/ui/spinner.tsx | 16 + .../extra-store/src/components/ui/switch.tsx | 27 + .../extra-store/src/components/ui/table.tsx | 120 + .../extra-store/src/components/ui/tabs.tsx | 53 + .../src/components/ui/textarea.tsx | 22 + .../extra-store/src/components/ui/toast.tsx | 127 + .../extra-store/src/components/ui/toaster.tsx | 33 + .../src/components/ui/toggle-group.tsx | 61 + .../extra-store/src/components/ui/toggle.tsx | 43 + .../extra-store/src/components/ui/tooltip.tsx | 32 + .../extra-store/src/hooks/use-mobile.tsx | 19 + artifacts/extra-store/src/hooks/use-toast.ts | 191 + artifacts/extra-store/src/index.css | 93 + artifacts/extra-store/src/lib/api.ts | 2 + artifacts/extra-store/src/lib/i18n.ts | 507 ++ artifacts/extra-store/src/lib/utils.ts | 6 + artifacts/extra-store/src/main.tsx | 5 + artifacts/extra-store/src/pages/Admin.tsx | 3241 ++++++++++ artifacts/extra-store/src/pages/not-found.tsx | 21 + artifacts/extra-store/tsconfig.json | 22 + artifacts/extra-store/vite.config.ts | 75 + .../.replit-artifact/artifact.toml | 17 + artifacts/mockup-sandbox/components.json | 21 + artifacts/mockup-sandbox/index.html | 31 + .../mockup-sandbox/mockupPreviewPlugin.ts | 180 + artifacts/mockup-sandbox/package.json | 74 + .../src/.generated/mockup-components.ts | 5 + artifacts/mockup-sandbox/src/App.tsx | 146 + .../src/components/ui/accordion.tsx | 55 + .../src/components/ui/alert-dialog.tsx | 139 + .../src/components/ui/alert.tsx | 59 + .../src/components/ui/aspect-ratio.tsx | 5 + .../src/components/ui/avatar.tsx | 50 + .../src/components/ui/badge.tsx | 37 + .../src/components/ui/breadcrumb.tsx | 115 + .../src/components/ui/button-group.tsx | 83 + .../src/components/ui/button.tsx | 58 + .../src/components/ui/calendar.tsx | 213 + .../mockup-sandbox/src/components/ui/card.tsx | 76 + .../src/components/ui/carousel.tsx | 260 + .../src/components/ui/chart.tsx | 365 ++ .../src/components/ui/checkbox.tsx | 28 + .../src/components/ui/collapsible.tsx | 11 + .../src/components/ui/command.tsx | 153 + .../src/components/ui/context-menu.tsx | 198 + .../src/components/ui/dialog.tsx | 120 + .../src/components/ui/drawer.tsx | 116 + .../src/components/ui/dropdown-menu.tsx | 201 + .../src/components/ui/empty.tsx | 104 + .../src/components/ui/field.tsx | 244 + .../mockup-sandbox/src/components/ui/form.tsx | 176 + .../src/components/ui/hover-card.tsx | 27 + .../src/components/ui/input-group.tsx | 165 + .../src/components/ui/input-otp.tsx | 69 + .../src/components/ui/input.tsx | 22 + .../mockup-sandbox/src/components/ui/item.tsx | 193 + .../mockup-sandbox/src/components/ui/kbd.tsx | 28 + .../src/components/ui/label.tsx | 26 + .../src/components/ui/menubar.tsx | 254 + .../src/components/ui/navigation-menu.tsx | 128 + .../src/components/ui/pagination.tsx | 117 + .../src/components/ui/popover.tsx | 31 + .../src/components/ui/progress.tsx | 28 + .../src/components/ui/radio-group.tsx | 42 + .../src/components/ui/resizable.tsx | 45 + .../src/components/ui/scroll-area.tsx | 46 + .../src/components/ui/select.tsx | 159 + .../src/components/ui/separator.tsx | 29 + .../src/components/ui/sheet.tsx | 140 + .../src/components/ui/sidebar.tsx | 714 ++ .../src/components/ui/skeleton.tsx | 15 + .../src/components/ui/slider.tsx | 26 + .../src/components/ui/sonner.tsx | 31 + .../src/components/ui/spinner.tsx | 16 + .../src/components/ui/switch.tsx | 27 + .../src/components/ui/table.tsx | 120 + .../mockup-sandbox/src/components/ui/tabs.tsx | 53 + .../src/components/ui/textarea.tsx | 22 + .../src/components/ui/toast.tsx | 127 + .../src/components/ui/toaster.tsx | 33 + .../src/components/ui/toggle-group.tsx | 61 + .../src/components/ui/toggle.tsx | 43 + .../src/components/ui/tooltip.tsx | 32 + .../mockup-sandbox/src/hooks/use-mobile.tsx | 19 + .../mockup-sandbox/src/hooks/use-toast.ts | 189 + artifacts/mockup-sandbox/src/index.css | 158 + artifacts/mockup-sandbox/src/lib/utils.ts | 6 + artifacts/mockup-sandbox/src/main.tsx | 5 + artifacts/mockup-sandbox/tsconfig.json | 16 + artifacts/mockup-sandbox/vite.config.ts | 72 + ...Max-Apple--1774308848965_1774308849008.txt | 49 + .../Pasted-1--1774178202736_1774178202738.txt | 113 + .../Pasted-1--1774179504056_1774179504059.txt | 113 + .../Pasted-1--1774181358426_1774181358430.txt | 113 + ...ce-store-from-scratch-wi_1774167440054.txt | 1 + ...tore-and-rebuild-it-comp_1774170556774.txt | 1 + ...di-e-commerce-store-exac_1774176907958.txt | 1 + ...e-data-engineer-and-Saud_1774191610219.txt | 153 + ...ization-expert-Your-task_1774190170339.txt | 114 + ...om-categories--177424304_1774243042278.txt | 1 + ...hoto_٢٠٢٦-٠٣-٢٤_٠٠-٥٨-١١_1774303785414.jpg | Bin 0 -> 39902 bytes lib/api-client-react/package.json | 15 + lib/api-client-react/src/custom-fetch.ts | 368 ++ .../src/generated/api.schemas.ts | 372 ++ lib/api-client-react/src/generated/api.ts | 2622 ++++++++ lib/api-client-react/src/index.ts | 4 + lib/api-client-react/tsconfig.json | 12 + lib/api-spec/openapi.yaml | 1191 ++++ lib/api-spec/orval.config.ts | 69 + lib/api-spec/package.json | 11 + lib/api-zod/package.json | 12 + lib/api-zod/src/generated/api.ts | 729 +++ .../src/generated/types/addToCartInput.ts | 15 + .../src/generated/types/adminLoginInput.ts | 12 + .../src/generated/types/adminSession.ts | 12 + lib/api-zod/src/generated/types/adminStats.ts | 16 + lib/api-zod/src/generated/types/cartItem.ts | 18 + lib/api-zod/src/generated/types/category.ts | 16 + .../generated/types/changePasswordInput.ts | 13 + lib/api-zod/src/generated/types/coupon.ts | 20 + .../src/generated/types/couponDiscountType.ts | 15 + .../src/generated/types/createCouponInput.ts | 17 + .../types/createCouponInputDiscountType.ts | 15 + .../src/generated/types/createOrderInput.ts | 19 + .../src/generated/types/createProductInput.ts | 28 + .../types/createProductInputSpecs.ts | 9 + .../src/generated/types/createReviewInput.ts | 14 + .../src/generated/types/getCartParams.ts | 11 + .../src/generated/types/getOrdersParams.ts | 14 + .../src/generated/types/getOrdersStatus.ts | 19 + .../generated/types/getProductsFeatured.ts | 17 + .../src/generated/types/getProductsParams.ts | 23 + .../src/generated/types/getProductsSort.ts | 18 + .../src/generated/types/getWishlistParams.ts | 11 + .../src/generated/types/healthStatus.ts | 11 + lib/api-zod/src/generated/types/index.ts | 49 + lib/api-zod/src/generated/types/order.ts | 31 + lib/api-zod/src/generated/types/orderItem.ts | 18 + .../src/generated/types/ordersResponse.ts | 15 + lib/api-zod/src/generated/types/product.ts | 34 + .../src/generated/types/productSpecs.ts | 9 + .../src/generated/types/productsResponse.ts | 16 + .../types/removeFromWishlistParams.ts | 11 + lib/api-zod/src/generated/types/review.ts | 18 + .../src/generated/types/savePaymentInput.ts | 16 + .../src/generated/types/savedPayment.ts | 17 + .../src/generated/types/successMessage.ts | 12 + .../generated/types/updateCartItemInput.ts | 11 + .../generated/types/updateOrderStatusInput.ts | 13 + .../types/updateOrderStatusInputStatus.ts | 19 + .../src/generated/types/updateProductInput.ts | 24 + .../generated/types/validateCouponInput.ts | 12 + .../src/generated/types/wishlistInput.ts | 12 + .../src/generated/types/wishlistItem.ts | 16 + lib/api-zod/src/index.ts | 2 + lib/api-zod/tsconfig.json | 11 + lib/db/drizzle.config.ts | 14 + lib/db/package.json | 25 + lib/db/src/index.ts | 16 + lib/db/src/schema/admin.ts | 23 + lib/db/src/schema/cart.ts | 18 + lib/db/src/schema/categories.ts | 22 + lib/db/src/schema/coupons.ts | 20 + lib/db/src/schema/index.ts | 12 + lib/db/src/schema/offers.ts | 19 + lib/db/src/schema/orders.ts | 43 + lib/db/src/schema/payments.ts | 18 + lib/db/src/schema/products.ts | 45 + lib/db/src/schema/reviews.ts | 20 + lib/db/src/schema/support.ts | 21 + lib/db/src/schema/users.ts | 13 + lib/db/src/schema/wishlist.ts | 15 + lib/db/tsconfig.json | 12 + package.json | 16 + pnpm-lock.yaml | 5713 +++++++++++++++++ pnpm-workspace.yaml | 159 + replit.md | 125 + scripts/package.json | 25 + scripts/post-merge.sh | 4 + scripts/src/dedup-products.ts | 26 + scripts/src/hello.ts | 1 + scripts/src/resolve-image-urls.ts | 125 + scripts/src/seed-extra-subcats.ts | 891 +++ scripts/src/seed-extra.ts | 1138 ++++ scripts/src/update-product-images.ts | 435 ++ scripts/tsconfig.json | 9 + tsconfig.base.json | 25 + tsconfig.json | 16 + 274 files changed, 38309 insertions(+) create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .replit create mode 100644 .replitignore create mode 100644 artifacts/api-server/.replit-artifact/artifact.toml create mode 100644 artifacts/api-server/build.mjs create mode 100644 artifacts/api-server/package.json create mode 100644 artifacts/api-server/src/app.ts create mode 100644 artifacts/api-server/src/config/services.ts create mode 100644 artifacts/api-server/src/index.ts create mode 100644 artifacts/api-server/src/lib/.gitkeep create mode 100644 artifacts/api-server/src/lib/integration-tests.ts create mode 100644 artifacts/api-server/src/lib/logger.ts create mode 100644 artifacts/api-server/src/lib/shein-scraper.ts create mode 100644 artifacts/api-server/src/middleware/auth.ts create mode 100644 artifacts/api-server/src/middlewares/.gitkeep create mode 100644 artifacts/api-server/src/routes/admin.ts create mode 100644 artifacts/api-server/src/routes/analytics.ts create mode 100644 artifacts/api-server/src/routes/auth.ts create mode 100644 artifacts/api-server/src/routes/cart.ts create mode 100644 artifacts/api-server/src/routes/categories.ts create mode 100644 artifacts/api-server/src/routes/checkout-events.ts create mode 100644 artifacts/api-server/src/routes/coupons.ts create mode 100644 artifacts/api-server/src/routes/health.ts create mode 100644 artifacts/api-server/src/routes/image-proxy.ts create mode 100644 artifacts/api-server/src/routes/index.ts create mode 100644 artifacts/api-server/src/routes/integrations.ts create mode 100644 artifacts/api-server/src/routes/orders.ts create mode 100644 artifacts/api-server/src/routes/payments.ts create mode 100644 artifacts/api-server/src/routes/products.ts create mode 100644 artifacts/api-server/src/routes/reviews.ts create mode 100644 artifacts/api-server/src/routes/store-settings.ts create mode 100644 artifacts/api-server/src/routes/wishlist.ts create mode 100644 artifacts/api-server/tsconfig.json create mode 100644 artifacts/extra-store/.replit-artifact/artifact.toml create mode 100644 artifacts/extra-store/components.json create mode 100644 artifacts/extra-store/index.html create mode 100644 artifacts/extra-store/package.json create mode 100644 artifacts/extra-store/public/favicon.svg create mode 100644 artifacts/extra-store/public/opengraph.jpg create mode 100644 artifacts/extra-store/src/App.tsx create mode 100644 artifacts/extra-store/src/components/ui/accordion.tsx create mode 100644 artifacts/extra-store/src/components/ui/alert-dialog.tsx create mode 100644 artifacts/extra-store/src/components/ui/alert.tsx create mode 100644 artifacts/extra-store/src/components/ui/aspect-ratio.tsx create mode 100644 artifacts/extra-store/src/components/ui/avatar.tsx create mode 100644 artifacts/extra-store/src/components/ui/badge.tsx create mode 100644 artifacts/extra-store/src/components/ui/breadcrumb.tsx create mode 100644 artifacts/extra-store/src/components/ui/button-group.tsx create mode 100644 artifacts/extra-store/src/components/ui/button.tsx create mode 100644 artifacts/extra-store/src/components/ui/calendar.tsx create mode 100644 artifacts/extra-store/src/components/ui/card.tsx create mode 100644 artifacts/extra-store/src/components/ui/carousel.tsx create mode 100644 artifacts/extra-store/src/components/ui/chart.tsx create mode 100644 artifacts/extra-store/src/components/ui/checkbox.tsx create mode 100644 artifacts/extra-store/src/components/ui/collapsible.tsx create mode 100644 artifacts/extra-store/src/components/ui/command.tsx create mode 100644 artifacts/extra-store/src/components/ui/context-menu.tsx create mode 100644 artifacts/extra-store/src/components/ui/dialog.tsx create mode 100644 artifacts/extra-store/src/components/ui/drawer.tsx create mode 100644 artifacts/extra-store/src/components/ui/dropdown-menu.tsx create mode 100644 artifacts/extra-store/src/components/ui/empty.tsx create mode 100644 artifacts/extra-store/src/components/ui/field.tsx create mode 100644 artifacts/extra-store/src/components/ui/form.tsx create mode 100644 artifacts/extra-store/src/components/ui/hover-card.tsx create mode 100644 artifacts/extra-store/src/components/ui/input-group.tsx create mode 100644 artifacts/extra-store/src/components/ui/input-otp.tsx create mode 100644 artifacts/extra-store/src/components/ui/input.tsx create mode 100644 artifacts/extra-store/src/components/ui/item.tsx create mode 100644 artifacts/extra-store/src/components/ui/kbd.tsx create mode 100644 artifacts/extra-store/src/components/ui/label.tsx create mode 100644 artifacts/extra-store/src/components/ui/menubar.tsx create mode 100644 artifacts/extra-store/src/components/ui/navigation-menu.tsx create mode 100644 artifacts/extra-store/src/components/ui/pagination.tsx create mode 100644 artifacts/extra-store/src/components/ui/popover.tsx create mode 100644 artifacts/extra-store/src/components/ui/progress.tsx create mode 100644 artifacts/extra-store/src/components/ui/radio-group.tsx create mode 100644 artifacts/extra-store/src/components/ui/resizable.tsx create mode 100644 artifacts/extra-store/src/components/ui/scroll-area.tsx create mode 100644 artifacts/extra-store/src/components/ui/select.tsx create mode 100644 artifacts/extra-store/src/components/ui/separator.tsx create mode 100644 artifacts/extra-store/src/components/ui/sheet.tsx create mode 100644 artifacts/extra-store/src/components/ui/sidebar.tsx create mode 100644 artifacts/extra-store/src/components/ui/skeleton.tsx create mode 100644 artifacts/extra-store/src/components/ui/slider.tsx create mode 100644 artifacts/extra-store/src/components/ui/sonner.tsx create mode 100644 artifacts/extra-store/src/components/ui/spinner.tsx create mode 100644 artifacts/extra-store/src/components/ui/switch.tsx create mode 100644 artifacts/extra-store/src/components/ui/table.tsx create mode 100644 artifacts/extra-store/src/components/ui/tabs.tsx create mode 100644 artifacts/extra-store/src/components/ui/textarea.tsx create mode 100644 artifacts/extra-store/src/components/ui/toast.tsx create mode 100644 artifacts/extra-store/src/components/ui/toaster.tsx create mode 100644 artifacts/extra-store/src/components/ui/toggle-group.tsx create mode 100644 artifacts/extra-store/src/components/ui/toggle.tsx create mode 100644 artifacts/extra-store/src/components/ui/tooltip.tsx create mode 100644 artifacts/extra-store/src/hooks/use-mobile.tsx create mode 100644 artifacts/extra-store/src/hooks/use-toast.ts create mode 100644 artifacts/extra-store/src/index.css create mode 100644 artifacts/extra-store/src/lib/api.ts create mode 100644 artifacts/extra-store/src/lib/i18n.ts create mode 100644 artifacts/extra-store/src/lib/utils.ts create mode 100644 artifacts/extra-store/src/main.tsx create mode 100644 artifacts/extra-store/src/pages/Admin.tsx create mode 100644 artifacts/extra-store/src/pages/not-found.tsx create mode 100644 artifacts/extra-store/tsconfig.json create mode 100644 artifacts/extra-store/vite.config.ts create mode 100644 artifacts/mockup-sandbox/.replit-artifact/artifact.toml create mode 100644 artifacts/mockup-sandbox/components.json create mode 100644 artifacts/mockup-sandbox/index.html create mode 100644 artifacts/mockup-sandbox/mockupPreviewPlugin.ts create mode 100644 artifacts/mockup-sandbox/package.json create mode 100644 artifacts/mockup-sandbox/src/.generated/mockup-components.ts create mode 100644 artifacts/mockup-sandbox/src/App.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/accordion.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/alert-dialog.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/alert.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/aspect-ratio.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/avatar.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/badge.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/breadcrumb.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/button-group.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/button.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/calendar.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/card.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/carousel.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/chart.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/checkbox.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/collapsible.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/command.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/context-menu.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/dialog.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/drawer.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/dropdown-menu.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/empty.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/field.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/form.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/hover-card.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/input-group.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/input-otp.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/input.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/item.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/kbd.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/label.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/menubar.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/navigation-menu.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/pagination.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/popover.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/progress.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/radio-group.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/resizable.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/scroll-area.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/select.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/separator.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/sheet.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/sidebar.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/skeleton.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/slider.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/sonner.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/spinner.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/switch.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/table.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/tabs.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/textarea.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/toast.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/toaster.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/toggle-group.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/toggle.tsx create mode 100644 artifacts/mockup-sandbox/src/components/ui/tooltip.tsx create mode 100644 artifacts/mockup-sandbox/src/hooks/use-mobile.tsx create mode 100644 artifacts/mockup-sandbox/src/hooks/use-toast.ts create mode 100644 artifacts/mockup-sandbox/src/index.css create mode 100644 artifacts/mockup-sandbox/src/lib/utils.ts create mode 100644 artifacts/mockup-sandbox/src/main.tsx create mode 100644 artifacts/mockup-sandbox/tsconfig.json create mode 100644 artifacts/mockup-sandbox/vite.config.ts create mode 100644 attached_assets/Pasted--2026-1-iPhone-17-Pro-Max-Apple--1774308848965_1774308849008.txt create mode 100644 attached_assets/Pasted-1--1774178202736_1774178202738.txt create mode 100644 attached_assets/Pasted-1--1774179504056_1774179504059.txt create mode 100644 attached_assets/Pasted-1--1774181358426_1774181358430.txt create mode 100644 attached_assets/Pasted-Build-a-complete-Saudi-e-commerce-store-from-scratch-wi_1774167440054.txt create mode 100644 attached_assets/Pasted-Fix-all-errors-in-the-current-store-and-rebuild-it-comp_1774170556774.txt create mode 100644 attached_assets/Pasted-Fix-and-complete-the-entire-Saudi-e-commerce-store-exac_1774176907958.txt create mode 100644 attached_assets/Pasted-You-are-a-professional-eCommerce-data-engineer-and-Saud_1774191610219.txt create mode 100644 attached_assets/Pasted-You-are-an-eCommerce-data-optimization-expert-Your-task_1774190170339.txt create mode 100644 attached_assets/Pasted-import-pandas-as-pd-import-random-categories--177424304_1774243042278.txt create mode 100644 attached_assets/photo_٢٠٢٦-٠٣-٢٤_٠٠-٥٨-١١_1774303785414.jpg create mode 100644 lib/api-client-react/package.json create mode 100644 lib/api-client-react/src/custom-fetch.ts create mode 100644 lib/api-client-react/src/generated/api.schemas.ts create mode 100644 lib/api-client-react/src/generated/api.ts create mode 100644 lib/api-client-react/src/index.ts create mode 100644 lib/api-client-react/tsconfig.json create mode 100644 lib/api-spec/openapi.yaml create mode 100644 lib/api-spec/orval.config.ts create mode 100644 lib/api-spec/package.json create mode 100644 lib/api-zod/package.json create mode 100644 lib/api-zod/src/generated/api.ts create mode 100644 lib/api-zod/src/generated/types/addToCartInput.ts create mode 100644 lib/api-zod/src/generated/types/adminLoginInput.ts create mode 100644 lib/api-zod/src/generated/types/adminSession.ts create mode 100644 lib/api-zod/src/generated/types/adminStats.ts create mode 100644 lib/api-zod/src/generated/types/cartItem.ts create mode 100644 lib/api-zod/src/generated/types/category.ts create mode 100644 lib/api-zod/src/generated/types/changePasswordInput.ts create mode 100644 lib/api-zod/src/generated/types/coupon.ts create mode 100644 lib/api-zod/src/generated/types/couponDiscountType.ts create mode 100644 lib/api-zod/src/generated/types/createCouponInput.ts create mode 100644 lib/api-zod/src/generated/types/createCouponInputDiscountType.ts create mode 100644 lib/api-zod/src/generated/types/createOrderInput.ts create mode 100644 lib/api-zod/src/generated/types/createProductInput.ts create mode 100644 lib/api-zod/src/generated/types/createProductInputSpecs.ts create mode 100644 lib/api-zod/src/generated/types/createReviewInput.ts create mode 100644 lib/api-zod/src/generated/types/getCartParams.ts create mode 100644 lib/api-zod/src/generated/types/getOrdersParams.ts create mode 100644 lib/api-zod/src/generated/types/getOrdersStatus.ts create mode 100644 lib/api-zod/src/generated/types/getProductsFeatured.ts create mode 100644 lib/api-zod/src/generated/types/getProductsParams.ts create mode 100644 lib/api-zod/src/generated/types/getProductsSort.ts create mode 100644 lib/api-zod/src/generated/types/getWishlistParams.ts create mode 100644 lib/api-zod/src/generated/types/healthStatus.ts create mode 100644 lib/api-zod/src/generated/types/index.ts create mode 100644 lib/api-zod/src/generated/types/order.ts create mode 100644 lib/api-zod/src/generated/types/orderItem.ts create mode 100644 lib/api-zod/src/generated/types/ordersResponse.ts create mode 100644 lib/api-zod/src/generated/types/product.ts create mode 100644 lib/api-zod/src/generated/types/productSpecs.ts create mode 100644 lib/api-zod/src/generated/types/productsResponse.ts create mode 100644 lib/api-zod/src/generated/types/removeFromWishlistParams.ts create mode 100644 lib/api-zod/src/generated/types/review.ts create mode 100644 lib/api-zod/src/generated/types/savePaymentInput.ts create mode 100644 lib/api-zod/src/generated/types/savedPayment.ts create mode 100644 lib/api-zod/src/generated/types/successMessage.ts create mode 100644 lib/api-zod/src/generated/types/updateCartItemInput.ts create mode 100644 lib/api-zod/src/generated/types/updateOrderStatusInput.ts create mode 100644 lib/api-zod/src/generated/types/updateOrderStatusInputStatus.ts create mode 100644 lib/api-zod/src/generated/types/updateProductInput.ts create mode 100644 lib/api-zod/src/generated/types/validateCouponInput.ts create mode 100644 lib/api-zod/src/generated/types/wishlistInput.ts create mode 100644 lib/api-zod/src/generated/types/wishlistItem.ts create mode 100644 lib/api-zod/src/index.ts create mode 100644 lib/api-zod/tsconfig.json create mode 100644 lib/db/drizzle.config.ts create mode 100644 lib/db/package.json create mode 100644 lib/db/src/index.ts create mode 100644 lib/db/src/schema/admin.ts create mode 100644 lib/db/src/schema/cart.ts create mode 100644 lib/db/src/schema/categories.ts create mode 100644 lib/db/src/schema/coupons.ts create mode 100644 lib/db/src/schema/index.ts create mode 100644 lib/db/src/schema/offers.ts create mode 100644 lib/db/src/schema/orders.ts create mode 100644 lib/db/src/schema/payments.ts create mode 100644 lib/db/src/schema/products.ts create mode 100644 lib/db/src/schema/reviews.ts create mode 100644 lib/db/src/schema/support.ts create mode 100644 lib/db/src/schema/users.ts create mode 100644 lib/db/src/schema/wishlist.ts create mode 100644 lib/db/tsconfig.json create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 replit.md create mode 100644 scripts/package.json create mode 100644 scripts/post-merge.sh create mode 100644 scripts/src/dedup-products.ts create mode 100644 scripts/src/hello.ts create mode 100644 scripts/src/resolve-image-urls.ts create mode 100644 scripts/src/seed-extra-subcats.ts create mode 100644 scripts/src/seed-extra.ts create mode 100644 scripts/src/update-product-images.ts create mode 100644 scripts/tsconfig.json create mode 100644 tsconfig.base.json create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..12bc7fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# See https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files for more about ignoring files. + +# compiled output +dist +tmp +out-tsc +*.tsbuildinfo +.expo +.expo-shared + +# dependencies +node_modules + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# misc +/.sass-cache +/connect.lock +/coverage +/libpeerconnection.log +npm-debug.log +yarn-error.log +testem.log +/typings + +# System Files +.DS_Store +Thumbs.db + +.cursor/rules/nx-rules.mdc +.github/instructions/nx.instructions.md + +# Replit +.cache/ +.local/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..61e34c2 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +auto-install-peers=false +strict-peer-dependencies=false diff --git a/.replit b/.replit new file mode 100644 index 0000000..9b3bce7 --- /dev/null +++ b/.replit @@ -0,0 +1,36 @@ +modules = ["nodejs-24", "postgresql-16"] + +[[artifacts]] +id = "artifacts/api-server" + +[[artifacts]] +id = "artifacts/mockup-sandbox" + +[deployment] +router = "application" +deploymentTarget = "autoscale" + +[deployment.postBuild] +args = ["pnpm", "store", "prune"] +env = { "CI" = "true" } + +[workflows] +runButton = "Project" + +[agent] +stack = "PNPM_WORKSPACE" +expertMode = true + +[postMerge] +path = "scripts/post-merge.sh" +timeoutMs = 20000 + +[nix] +channel = "stable-25_05" + +[userenv] + +[userenv.shared] + +[[ports]] +localPort = 8080 diff --git a/.replitignore b/.replitignore new file mode 100644 index 0000000..9eb019c --- /dev/null +++ b/.replitignore @@ -0,0 +1,5 @@ +# The format of this file is identical to `.dockerignore`. +# It is used to reduce the size of deployed images to make the process of publishing faster. + +# No need to store the pnpm store twice. +.local diff --git a/artifacts/api-server/.replit-artifact/artifact.toml b/artifacts/api-server/.replit-artifact/artifact.toml new file mode 100644 index 0000000..814b9ad --- /dev/null +++ b/artifacts/api-server/.replit-artifact/artifact.toml @@ -0,0 +1,32 @@ +kind = "api" +previewPath = "/api" # TODO - should be excluded from preview in the first place +title = "API Server" +version = "1.0.0" +id = "3B4_FFSkEVBkAeYMFRJ2e" + +[[services]] +localPort = 8080 +name = "API Server" +paths = ["/api"] + +[services.development] +run = "pnpm --filter @workspace/api-server run dev" + +[services.production] + +[services.production.build] +args = ["pnpm", "--filter", "@workspace/api-server", "run", "build"] + +[services.production.build.env] +NODE_ENV = "production" + +[services.production.run] +# we don't run through pnpm to make startup faster in production +args = ["node", "--enable-source-maps", "artifacts/api-server/dist/index.mjs"] + +[services.production.run.env] +PORT = "8080" +NODE_ENV = "production" + +[services.production.health.startup] +path = "/api/healthz" diff --git a/artifacts/api-server/build.mjs b/artifacts/api-server/build.mjs new file mode 100644 index 0000000..86ebf7f --- /dev/null +++ b/artifacts/api-server/build.mjs @@ -0,0 +1,126 @@ +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { build as esbuild } from "esbuild"; +import esbuildPluginPino from "esbuild-plugin-pino"; +import { rm } from "node:fs/promises"; + +// Plugins (e.g. 'esbuild-plugin-pino') may use `require` to resolve dependencies +globalThis.require = createRequire(import.meta.url); + +const artifactDir = path.dirname(fileURLToPath(import.meta.url)); + +async function buildAll() { + const distDir = path.resolve(artifactDir, "dist"); + await rm(distDir, { recursive: true, force: true }); + + await esbuild({ + entryPoints: [path.resolve(artifactDir, "src/index.ts")], + platform: "node", + bundle: true, + format: "esm", + outdir: distDir, + outExtension: { ".js": ".mjs" }, + logLevel: "info", + // Some packages may not be bundleable, so we externalize them, we can add more here as needed. + // Some of the packages below may not be imported or installed, but we're adding them in case they are in the future. + // Examples of unbundleable packages: + // - uses native modules and loads them dynamically (e.g. sharp) + // - use path traversal to read files (e.g. @google-cloud/secret-manager loads sibling .proto files) + external: [ + "*.node", + "sharp", + "better-sqlite3", + "sqlite3", + "canvas", + "bcrypt", + "argon2", + "fsevents", + "re2", + "farmhash", + "xxhash-addon", + "bufferutil", + "utf-8-validate", + "ssh2", + "cpu-features", + "dtrace-provider", + "isolated-vm", + "lightningcss", + "pg-native", + "oracledb", + "mongodb-client-encryption", + "nodemailer", + "handlebars", + "knex", + "typeorm", + "protobufjs", + "onnxruntime-node", + "@tensorflow/*", + "@prisma/client", + "@mikro-orm/*", + "@grpc/*", + "@swc/*", + "@aws-sdk/*", + "@azure/*", + "@opentelemetry/*", + "@google-cloud/*", + "@google/*", + "googleapis", + "firebase-admin", + "@parcel/watcher", + "@sentry/profiling-node", + "@tree-sitter/*", + "aws-sdk", + "classic-level", + "dd-trace", + "ffi-napi", + "grpc", + "hiredis", + "kerberos", + "leveldown", + "miniflare", + "mysql2", + "newrelic", + "odbc", + "piscina", + "realm", + "ref-napi", + "rocksdb", + "sass-embedded", + "sequelize", + "serialport", + "snappy", + "tinypool", + "usb", + "workerd", + "wrangler", + "zeromq", + "zeromq-prebuilt", + "playwright", + "puppeteer", + "puppeteer-core", + "electron", + ], + sourcemap: "linked", + plugins: [ + // pino relies on workers to handle logging, instead of externalizing it we use a plugin to handle it + esbuildPluginPino({ transports: ["pino-pretty"] }) + ], + // Make sure packages that are cjs only (e.g. express) but are bundled continue to work in our esm output file + banner: { + js: `import { createRequire as __bannerCrReq } from 'node:module'; +import __bannerPath from 'node:path'; +import __bannerUrl from 'node:url'; + +globalThis.require = __bannerCrReq(import.meta.url); +globalThis.__filename = __bannerUrl.fileURLToPath(import.meta.url); +globalThis.__dirname = __bannerPath.dirname(globalThis.__filename); + `, + }, + }); +} + +buildAll().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json new file mode 100644 index 0000000..1c15a36 --- /dev/null +++ b/artifacts/api-server/package.json @@ -0,0 +1,32 @@ +{ + "name": "@workspace/api-server", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "export NODE_ENV=development && pnpm run build && pnpm run start", + "build": "node ./build.mjs", + "start": "node --enable-source-maps ./dist/index.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@workspace/api-zod": "workspace:*", + "@workspace/db": "workspace:*", + "cookie-parser": "^1.4.7", + "cors": "^2", + "drizzle-orm": "catalog:", + "express": "^5", + "pino": "^9", + "pino-http": "^10" + }, + "devDependencies": { + "@types/cookie-parser": "^1.4.10", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/node": "catalog:", + "esbuild": "^0.27.3", + "esbuild-plugin-pino": "^2.3.3", + "pino-pretty": "^13", + "thread-stream": "3.1.0" + } +} diff --git a/artifacts/api-server/src/app.ts b/artifacts/api-server/src/app.ts new file mode 100644 index 0000000..f32f71e --- /dev/null +++ b/artifacts/api-server/src/app.ts @@ -0,0 +1,34 @@ +import express, { type Express } from "express"; +import cors from "cors"; +import pinoHttp from "pino-http"; +import router from "./routes"; +import { logger } from "./lib/logger"; + +const app: Express = express(); + +app.use( + pinoHttp({ + logger, + serializers: { + req(req) { + return { + id: req.id, + method: req.method, + url: req.url?.split("?")[0], + }; + }, + res(res) { + return { + statusCode: res.statusCode, + }; + }, + }, + }), +); +app.use(cors()); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +app.use("/api", router); + +export default app; diff --git a/artifacts/api-server/src/config/services.ts b/artifacts/api-server/src/config/services.ts new file mode 100644 index 0000000..64f0104 --- /dev/null +++ b/artifacts/api-server/src/config/services.ts @@ -0,0 +1,81 @@ +/** + * Services Configuration + * Reads API keys and credentials from environment secrets. + */ + +function resolveCloudinary() { + const urlRaw = process.env["CLOUDINARY_URL"] ?? ""; + const apiKey = (process.env["CLOUDINARY_API_KEY"] ?? "").trim(); + const apiSecret = (process.env["CLOUDINARY_API_SECRET"] ?? "").trim(); + + // If separate key/secret env vars are set, use them with known cloud name from URL + if (apiKey && apiSecret) { + const cloudName = (() => { + const norm = normalizeCloudinaryUrl(urlRaw); + const m = norm.match(/@([^/?]+)$/); + return m ? m[1] : "dj5vxragc"; + })(); + return { + url: `cloudinary://${apiKey}:${apiSecret}@${cloudName}`, + parsed: { apiKey, apiSecret, cloudName }, + }; + } + + // Fall back to parsing CLOUDINARY_URL + const parsed = parseCloudinaryUrl(urlRaw); + return { url: urlRaw, parsed }; +} + +export const servicesConfig = { + rapidApi: { + key: process.env["RAPID_API_KEY"] ?? "", + host: "https://api.rapidapi.com", + }, + serpApi: { + key: process.env["SERP_API_KEY"] ?? "", + host: "https://serpapi.com", + }, + cloudinary: resolveCloudinary(), + apify: { + token: process.env["APIFY_TOKEN"] ?? "", + host: "https://api.apify.com", + }, + database: { + url: process.env["DATABASE_URL"] ?? "", + }, +}; + +function normalizeCloudinaryUrl(raw: string): string { + const s = raw.trim(); + const eqIdx = s.indexOf("="); + if (eqIdx > -1 && eqIdx < 30) return s.slice(eqIdx + 1).trim(); + return s; +} + +function parseCloudinaryUrl(url: string) { + if (!url) return null; + try { + const trimmed = normalizeCloudinaryUrl(url); + const match = trimmed.match(/^cloudinary:\/\/([^:]+):([^@]+)@([^/?]+)/); + if (match) { + return { apiKey: match[1], apiSecret: match[2], cloudName: match[3] }; + } + const httpsMatch = trimmed.match(/^https?:\/\/([^:]+):([^@]+)@api\.cloudinary\.com\/v1_1\/([^/?]+)/); + if (httpsMatch) { + return { apiKey: httpsMatch[1], apiSecret: httpsMatch[2], cloudName: httpsMatch[3] }; + } + return null; + } catch { + return null; + } +} + +export function validateServicesConfig() { + const issues: string[] = []; + if (!servicesConfig.rapidApi.key) issues.push("RAPID_API_KEY is missing"); + if (!servicesConfig.serpApi.key) issues.push("SERP_API_KEY is missing"); + if (!servicesConfig.cloudinary.parsed) issues.push("CLOUDINARY credentials are missing or invalid"); + if (!servicesConfig.apify.token) issues.push("APIFY_TOKEN is missing"); + if (!servicesConfig.database.url) issues.push("DATABASE_URL is missing"); + return { valid: issues.length === 0, issues }; +} diff --git a/artifacts/api-server/src/index.ts b/artifacts/api-server/src/index.ts new file mode 100644 index 0000000..b1f024d --- /dev/null +++ b/artifacts/api-server/src/index.ts @@ -0,0 +1,25 @@ +import app from "./app"; +import { logger } from "./lib/logger"; + +const rawPort = process.env["PORT"]; + +if (!rawPort) { + throw new Error( + "PORT environment variable is required but was not provided.", + ); +} + +const port = Number(rawPort); + +if (Number.isNaN(port) || port <= 0) { + throw new Error(`Invalid PORT value: "${rawPort}"`); +} + +app.listen(port, (err) => { + if (err) { + logger.error({ err }, "Error listening on port"); + process.exit(1); + } + + logger.info({ port }, "Server listening"); +}); diff --git a/artifacts/api-server/src/lib/.gitkeep b/artifacts/api-server/src/lib/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/artifacts/api-server/src/lib/integration-tests.ts b/artifacts/api-server/src/lib/integration-tests.ts new file mode 100644 index 0000000..df705fd --- /dev/null +++ b/artifacts/api-server/src/lib/integration-tests.ts @@ -0,0 +1,225 @@ +import { servicesConfig } from "../config/services"; +import { db } from "@workspace/db"; +import { sql } from "drizzle-orm"; + +export interface ServiceTestResult { + service: string; + connected: boolean; + message: string; + details?: Record; + latencyMs?: number; +} + +async function timed(fn: () => Promise): Promise<{ result: T; ms: number }> { + const start = Date.now(); + const result = await fn(); + return { result, ms: Date.now() - start }; +} + +export async function testRapidApi(): Promise { + const service = "RapidAPI"; + const key = servicesConfig.rapidApi.key; + if (!key) { + return { service, connected: false, message: "RAPID_API_KEY is not configured" }; + } + try { + const { result, ms } = await timed(() => + fetch("https://currency-exchange.p.rapidapi.com/listquotes", { + method: "GET", + headers: { + "X-RapidAPI-Key": key, + "X-RapidAPI-Host": "currency-exchange.p.rapidapi.com", + }, + signal: AbortSignal.timeout(8000), + }) + ); + if (result.status === 401) { + return { service, connected: false, message: "RapidAPI key is invalid — HTTP 401", latencyMs: ms }; + } + if (result.status === 403) { + return { + service, + connected: true, + message: "RapidAPI Connected ✅ (key valid — no subscription to test endpoint)", + details: { httpStatus: 403, note: "Key is authenticated; subscribe to an API to use it" }, + latencyMs: ms, + }; + } + if (result.ok) { + return { + service, + connected: true, + message: "RapidAPI Connected ✅", + details: { httpStatus: result.status }, + latencyMs: ms, + }; + } + return { service, connected: false, message: `RapidAPI unexpected response — HTTP ${result.status}`, latencyMs: ms }; + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + return { service, connected: false, message: `RapidAPI Error: ${errMsg}` }; + } +} + +export async function testSerpApi(): Promise { + const service = "SerpAPI"; + const key = servicesConfig.serpApi.key; + if (!key) { + return { service, connected: false, message: "SERP_API_KEY is not configured" }; + } + try { + const { result, ms } = await timed(() => + fetch(`https://serpapi.com/account?api_key=${key}`, { + signal: AbortSignal.timeout(8000), + }) + ); + if (result.ok) { + const data = await result.json() as Record; + return { + service, + connected: true, + message: "SerpAPI Connected ✅", + details: { + plan: data["plan_name"] ?? "unknown", + searches_left: data["searches_this_month_used"] ?? "unknown", + }, + latencyMs: ms, + }; + } else { + const text = await result.text(); + return { + service, + connected: false, + message: `SerpAPI key rejected — HTTP ${result.status}`, + details: { error: text.substring(0, 200) }, + latencyMs: ms, + }; + } + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + return { service, connected: false, message: `SerpAPI Error: ${errMsg}` }; + } +} + +export async function testCloudinary(): Promise { + const service = "Cloudinary"; + const parsed = servicesConfig.cloudinary.parsed; + if (!parsed) { + return { + service, + connected: false, + message: "CLOUDINARY_URL is missing or invalid format (expected: cloudinary://api_key:api_secret@cloud_name)", + }; + } + try { + const { cloudName, apiKey, apiSecret } = parsed; + const credentials = Buffer.from(`${apiKey}:${apiSecret}`).toString("base64"); + const { result, ms } = await timed(() => + fetch(`https://api.cloudinary.com/v1_1/${cloudName}/usage`, { + headers: { Authorization: `Basic ${credentials}` }, + signal: AbortSignal.timeout(8000), + }) + ); + if (result.ok) { + const data = await result.json() as Record; + return { + service, + connected: true, + message: "Cloudinary Connected ✅", + details: { + cloud_name: cloudName, + plan: data["plan"] ?? "unknown", + storage: data["storage"] ?? "unknown", + }, + latencyMs: ms, + }; + } else { + return { + service, + connected: false, + message: `Cloudinary auth failed — HTTP ${result.status}`, + details: { cloud_name: cloudName }, + latencyMs: ms, + }; + } + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + return { service, connected: false, message: `Cloudinary Error: ${errMsg}` }; + } +} + +export async function testApify(): Promise { + const service = "Apify"; + const token = servicesConfig.apify.token; + if (!token) { + return { service, connected: false, message: "APIFY_TOKEN is not configured" }; + } + try { + const { result, ms } = await timed(() => + fetch(`https://api.apify.com/v2/users/me?token=${token}`, { + signal: AbortSignal.timeout(8000), + }) + ); + if (result.ok) { + const data = await result.json() as { data?: Record }; + const user = data.data ?? {}; + return { + service, + connected: true, + message: "Apify Connected ✅", + details: { + username: user["username"] ?? "unknown", + email: user["email"] ?? "unknown", + plan: user["plan"] ?? "unknown", + }, + latencyMs: ms, + }; + } else { + return { + service, + connected: false, + message: `Apify token rejected — HTTP ${result.status}`, + latencyMs: ms, + }; + } + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + return { service, connected: false, message: `Apify Error: ${errMsg}` }; + } +} + +export async function testDatabase(): Promise { + const service = "PostgreSQL Database"; + try { + const { result, ms } = await timed(async () => { + const rows = await db.execute(sql`SELECT current_database() as db_name, current_user as db_user, version() as db_version`); + return rows; + }); + const row = result.rows[0] as Record | undefined; + return { + service, + connected: true, + message: "PostgreSQL Database Connected ✅", + details: { + database: row?.["db_name"] ?? "unknown", + user: row?.["db_user"] ?? "unknown", + version: String(row?.["db_version"] ?? "").split(" ").slice(0, 2).join(" "), + }, + latencyMs: ms, + }; + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + return { service, connected: false, message: `Database Error: ${errMsg}` }; + } +} + +export async function runAllIntegrationTests(): Promise { + const [rapidApi, serpApi, cloudinary, apify, database] = await Promise.all([ + testRapidApi(), + testSerpApi(), + testCloudinary(), + testApify(), + testDatabase(), + ]); + return [database, rapidApi, serpApi, cloudinary, apify]; +} diff --git a/artifacts/api-server/src/lib/logger.ts b/artifacts/api-server/src/lib/logger.ts new file mode 100644 index 0000000..d9c67f7 --- /dev/null +++ b/artifacts/api-server/src/lib/logger.ts @@ -0,0 +1,20 @@ +import pino from "pino"; + +const isProduction = process.env.NODE_ENV === "production"; + +export const logger = pino({ + level: process.env.LOG_LEVEL ?? "info", + redact: [ + "req.headers.authorization", + "req.headers.cookie", + "res.headers['set-cookie']", + ], + ...(isProduction + ? {} + : { + transport: { + target: "pino-pretty", + options: { colorize: true }, + }, + }), +}); diff --git a/artifacts/api-server/src/lib/shein-scraper.ts b/artifacts/api-server/src/lib/shein-scraper.ts new file mode 100644 index 0000000..83ee413 --- /dev/null +++ b/artifacts/api-server/src/lib/shein-scraper.ts @@ -0,0 +1,269 @@ +import { servicesConfig } from "../config/services"; + +export interface SheinCategory { + name_ar: string; + name_en: string; + slug: string; + shein_cat_id: string; + shein_url: string; + level: number; + parent_slug?: string; + icon?: string; + sort_order: number; +} + +export interface SheinScraperResult { + success: boolean; + categories: SheinCategory[]; + totalCount: number; + error?: string; + runId?: string; +} + +async function pollApifyRun(runId: string, token: string, maxWaitMs = 120000): Promise { + const startTime = Date.now(); + while (Date.now() - startTime < maxWaitMs) { + await new Promise(r => setTimeout(r, 5000)); + const res = await fetch(`https://api.apify.com/v2/actor-runs/${runId}?token=${token}`); + const data = await res.json() as { data?: { status?: string; defaultDatasetId?: string } }; + const status = data?.data?.status; + if (status === "SUCCEEDED") return data.data?.defaultDatasetId ?? ""; + if (status === "FAILED" || status === "TIMED-OUT" || status === "ABORTED") { + throw new Error(`Apify run ${status}`); + } + } + throw new Error("Apify run timed out"); +} + +export async function fetchSheinCategories(): Promise { + const token = servicesConfig.apify.token; + if (!token) { + return { success: false, categories: [], totalCount: 0, error: "APIFY_TOKEN not configured" }; + } + + const pageFunction = ` +async function pageFunction(context) { + const { page, log } = context; + log.info('Loading Shein SA page...'); + + await page.waitForTimeout(6000); + + const data = await page.evaluate(() => { + const result = { sections: [], allLinks: [] }; + + // Try to extract from __NUXT__ or window state + try { + const scripts = Array.from(document.querySelectorAll('script')); + for (const s of scripts) { + const text = s.textContent || ''; + if (text.includes('cat_id') && text.includes('cat_name') && text.length > 1000) { + const matches = [...text.matchAll(/"cat_id"\s*:\s*"?(\d+)"?\s*,\s*"cat_name"\s*:\s*"([^"]+)"/g)]; + matches.forEach(m => { + result.allLinks.push({ id: m[1], name: m[2] }); + }); + } + } + } catch(e) {} + + // Extract navigation items + const navSelectors = [ + '.j-nav-cate-main', + '[class*="nav-cate"]', + '[class*="cate-level1"]', + '.header-nav__cate', + '[class*="header-nav"] li', + 'nav li a', + ]; + + for (const sel of navSelectors) { + const items = document.querySelectorAll(sel); + if (items.length > 2) { + items.forEach((item, i) => { + const link = item.tagName === 'A' ? item : item.querySelector('a'); + const name = (link || item).textContent?.trim()?.replace(/\\s+/g, ' '); + const href = link?.getAttribute('href') || ''; + const catIdMatch = href.match(/cat_id=(\d+)|\/(\d+)\.html/); + const catId = catIdMatch ? (catIdMatch[1] || catIdMatch[2]) : String(i); + if (name && name.length > 0 && name.length < 60) { + result.sections.push({ name, href, catId, selector: sel }); + } + }); + if (result.sections.length > 3) break; + } + } + + return result; + }); + + return data; +} +`; + + try { + const startRes = await fetch( + `https://api.apify.com/v2/acts/apify~puppeteer-scraper/runs?token=${token}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + startUrls: [{ url: "https://sa.shein.com/category-list.html" }], + pageFunction, + proxyConfiguration: { useApifyProxy: true }, + maxConcurrency: 1, + maxRequestsPerCrawl: 1, + handlePageTimeoutSecs: 30, + navigationTimeoutSecs: 30, + browserLog: false, + stealth: true, + memory: 512, + }), + } + ); + + if (!startRes.ok) { + const err = await startRes.text(); + return { success: false, categories: [], totalCount: 0, error: `Failed to start Apify run: ${err}` }; + } + + const runData = await startRes.json() as { data?: { id?: string } }; + const runId = runData?.data?.id; + if (!runId) return { success: false, categories: [], totalCount: 0, error: "No run ID returned" }; + + const datasetId = await pollApifyRun(runId, token, 120000); + if (!datasetId) return { success: false, categories: [], totalCount: 0, error: "No dataset returned" }; + + const itemsRes = await fetch( + `https://api.apify.com/v2/datasets/${datasetId}/items?token=${token}&limit=1000` + ); + const items = await itemsRes.json() as Array>; + + const categories = parseSheinScraperResults(items); + + return { success: true, categories, totalCount: categories.length, runId }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { success: false, categories: [], totalCount: 0, error: msg }; + } +} + +function parseSheinScraperResults(items: Array>): SheinCategory[] { + const categories: SheinCategory[] = []; + const seen = new Set(); + let sortOrder = 0; + + for (const item of items) { + const sections = (item["sections"] as Array<{ name: string; href: string; catId: string }>) ?? []; + const allLinks = (item["allLinks"] as Array<{ id: string; name: string }>) ?? []; + + for (const sec of sections) { + const key = sec.catId || sec.name; + if (seen.has(key)) continue; + seen.add(key); + + const isArabic = /[\u0600-\u06FF]/.test(sec.name); + const slug = slugify(sec.name); + + categories.push({ + name_ar: isArabic ? sec.name : sec.name, + name_en: isArabic ? "" : sec.name, + slug, + shein_cat_id: sec.catId ?? "", + shein_url: sec.href ?? "", + level: 1, + sort_order: sortOrder++, + }); + } + + for (const link of allLinks) { + const key = link.id; + if (seen.has(key)) continue; + seen.add(key); + const slug = slugify(link.name); + categories.push({ + name_ar: link.name, + name_en: "", + slug, + shein_cat_id: link.id, + shein_url: "", + level: 2, + sort_order: sortOrder++, + }); + } + } + + return categories; +} + +function slugify(str: string): string { + return str + .toLowerCase() + .replace(/[\u0600-\u06FF]/g, c => c) + .replace(/[^a-z0-9\u0600-\u06FF]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 60); +} + +export const SHEIN_CATEGORIES_PRESET: SheinCategory[] = [ + { name_ar: "نساء", name_en: "Women", slug: "women", shein_cat_id: "2030", shein_url: "/Women-sc-00212570.html", level: 1, sort_order: 1 }, + { name_ar: "ملابس نسائية", name_en: "Women Clothing", slug: "women-clothing", shein_cat_id: "1980", shein_url: "/Women-Clothing-sc-00212573.html", level: 2, parent_slug: "women", sort_order: 2 }, + { name_ar: "فساتين", name_en: "Dresses", slug: "dresses", shein_cat_id: "1727", shein_url: "/Dresses-sc-00212535.html", level: 2, parent_slug: "women", sort_order: 3 }, + { name_ar: "بلوزات وتيشرتات", name_en: "Tops & Tees", slug: "tops-tees", shein_cat_id: "1733", shein_url: "/Tops-Tees-sc-00212540.html", level: 2, parent_slug: "women", sort_order: 4 }, + { name_ar: "بناطيل وسراويل", name_en: "Pants & Capris", slug: "pants-capris", shein_cat_id: "1741", shein_url: "/Pants-Capris-sc-00212543.html", level: 2, parent_slug: "women", sort_order: 5 }, + { name_ar: "تنانير", name_en: "Skirts", slug: "skirts", shein_cat_id: "1742", shein_url: "/Skirts-sc-00212544.html", level: 2, parent_slug: "women", sort_order: 6 }, + { name_ar: "جاكيتات ومعاطف", name_en: "Jackets & Coats", slug: "jackets-coats", shein_cat_id: "1751", shein_url: "/Jackets-Coats-sc-00212547.html", level: 2, parent_slug: "women", sort_order: 7 }, + { name_ar: "أبايات", name_en: "Abayas", slug: "abayas", shein_cat_id: "3416", shein_url: "/Abayas-sc-01009296.html", level: 2, parent_slug: "women", sort_order: 8 }, + { name_ar: "أحذية نسائية", name_en: "Women Shoes", slug: "women-shoes", shein_cat_id: "2035", shein_url: "/Women-Shoes-sc-00212583.html", level: 2, parent_slug: "women", sort_order: 9 }, + { name_ar: "حقائب نسائية", name_en: "Women Bags", slug: "women-bags", shein_cat_id: "2037", shein_url: "/Women-Bags-sc-00212585.html", level: 2, parent_slug: "women", sort_order: 10 }, + { name_ar: "إكسسوارات نسائية", name_en: "Women Accessories", slug: "women-accessories", shein_cat_id: "2028", shein_url: "/Women-Accessories-sc-00212577.html", level: 2, parent_slug: "women", sort_order: 11 }, + { name_ar: "ملابس داخلية", name_en: "Lingerie & Lounge", slug: "lingerie-lounge", shein_cat_id: "1758", shein_url: "/Lingerie-Lounge-sc-00212549.html", level: 2, parent_slug: "women", sort_order: 12 }, + { name_ar: "رجال", name_en: "Men", slug: "men", shein_cat_id: "2035", shein_url: "/Men-sc-00212575.html", level: 1, sort_order: 13 }, + { name_ar: "ملابس رجالية", name_en: "Men Clothing", slug: "men-clothing", shein_cat_id: "1979", shein_url: "/Men-Clothing-sc-00212576.html", level: 2, parent_slug: "men", sort_order: 14 }, + { name_ar: "تيشرتات رجالية", name_en: "Men T-Shirts", slug: "men-tshirts", shein_cat_id: "1984", shein_url: "/Men-Tshirts-sc-00212578.html", level: 2, parent_slug: "men", sort_order: 15 }, + { name_ar: "بناطيل رجالية", name_en: "Men Pants", slug: "men-pants", shein_cat_id: "1989", shein_url: "/Men-Pants-sc-00212581.html", level: 2, parent_slug: "men", sort_order: 16 }, + { name_ar: "أحذية رجالية", name_en: "Men Shoes", slug: "men-shoes", shein_cat_id: "2042", shein_url: "/Men-Shoes-sc-00212588.html", level: 2, parent_slug: "men", sort_order: 17 }, + { name_ar: "حقائب رجالية", name_en: "Men Bags", slug: "men-bags", shein_cat_id: "2047", shein_url: "/Men-Bags-sc-00212589.html", level: 2, parent_slug: "men", sort_order: 18 }, + { name_ar: "إكسسوارات رجالية", name_en: "Men Accessories", slug: "men-accessories", shein_cat_id: "2034", shein_url: "/Men-Accessories-sc-00212584.html", level: 2, parent_slug: "men", sort_order: 19 }, + { name_ar: "أطفال", name_en: "Kids", slug: "kids", shein_cat_id: "2061", shein_url: "/Kids-sc-00212594.html", level: 1, sort_order: 20 }, + { name_ar: "بنات", name_en: "Girls", slug: "girls", shein_cat_id: "2061", shein_url: "/Girls-sc-00212595.html", level: 2, parent_slug: "kids", sort_order: 21 }, + { name_ar: "أولاد", name_en: "Boys", slug: "boys", shein_cat_id: "2062", shein_url: "/Boys-sc-00212596.html", level: 2, parent_slug: "kids", sort_order: 22 }, + { name_ar: "رضّع", name_en: "Baby", slug: "baby", shein_cat_id: "2063", shein_url: "/Baby-sc-00212597.html", level: 2, parent_slug: "kids", sort_order: 23 }, + { name_ar: "جمال وعناية", name_en: "Beauty", slug: "beauty", shein_cat_id: "2069", shein_url: "/Beauty-sc-00212600.html", level: 1, sort_order: 24 }, + { name_ar: "مستحضرات العناية بالبشرة", name_en: "Skin Care", slug: "skin-care", shein_cat_id: "2070", shein_url: "/Skin-Care-sc-00212601.html", level: 2, parent_slug: "beauty", sort_order: 25 }, + { name_ar: "مكياج", name_en: "Makeup", slug: "makeup", shein_cat_id: "2071", shein_url: "/Makeup-sc-00212602.html", level: 2, parent_slug: "beauty", sort_order: 26 }, + { name_ar: "العناية بالشعر", name_en: "Hair Care", slug: "hair-care", shein_cat_id: "2072", shein_url: "/Hair-Care-sc-00212603.html", level: 2, parent_slug: "beauty", sort_order: 27 }, + { name_ar: "عطور", name_en: "Fragrances", slug: "fragrances", shein_cat_id: "2073", shein_url: "/Fragrances-sc-00212604.html", level: 2, parent_slug: "beauty", sort_order: 28 }, + { name_ar: "أدوات التجميل", name_en: "Beauty Tools", slug: "beauty-tools", shein_cat_id: "2074", shein_url: "/Beauty-Tools-sc-00212605.html", level: 2, parent_slug: "beauty", sort_order: 29 }, + { name_ar: "منزل وديكور", name_en: "Home & Living", slug: "home-living", shein_cat_id: "2077", shein_url: "/Home-Living-sc-00212607.html", level: 1, sort_order: 30 }, + { name_ar: "غرفة المعيشة", name_en: "Living Room", slug: "living-room", shein_cat_id: "2078", shein_url: "/Living-Room-sc-00212608.html", level: 2, parent_slug: "home-living", sort_order: 31 }, + { name_ar: "غرفة النوم", name_en: "Bedroom", slug: "bedroom", shein_cat_id: "2079", shein_url: "/Bedroom-sc-00212609.html", level: 2, parent_slug: "home-living", sort_order: 32 }, + { name_ar: "المطبخ وتناول الطعام", name_en: "Kitchen & Dining", slug: "kitchen-dining", shein_cat_id: "2080", shein_url: "/Kitchen-Dining-sc-00212610.html", level: 2, parent_slug: "home-living", sort_order: 33 }, + { name_ar: "ديكور المنزل", name_en: "Home Decor", slug: "home-decor", shein_cat_id: "2081", shein_url: "/Home-Decor-sc-00212611.html", level: 2, parent_slug: "home-living", sort_order: 34 }, + { name_ar: "الحمام", name_en: "Bath", slug: "bath", shein_cat_id: "2082", shein_url: "/Bath-sc-00212612.html", level: 2, parent_slug: "home-living", sort_order: 35 }, + { name_ar: "الإضاءة", name_en: "Lighting", slug: "lighting", shein_cat_id: "2083", shein_url: "/Lighting-sc-00212613.html", level: 2, parent_slug: "home-living", sort_order: 36 }, + { name_ar: "رياضة وهواء طلق", name_en: "Sports & Outdoors", slug: "sports-outdoors", shein_cat_id: "2084", shein_url: "/Sports-Outdoors-sc-00212614.html", level: 1, sort_order: 37 }, + { name_ar: "ملابس رياضية", name_en: "Sports Clothing", slug: "sports-clothing", shein_cat_id: "2085", shein_url: "/Sports-Clothing-sc-00212615.html", level: 2, parent_slug: "sports-outdoors", sort_order: 38 }, + { name_ar: "أحذية رياضية", name_en: "Sports Shoes", slug: "sports-shoes", shein_cat_id: "2086", shein_url: "/Sports-Shoes-sc-00212616.html", level: 2, parent_slug: "sports-outdoors", sort_order: 39 }, + { name_ar: "معدات رياضية", name_en: "Sports Equipment", slug: "sports-equipment", shein_cat_id: "2087", shein_url: "/Sports-Equipment-sc-00212617.html", level: 2, parent_slug: "sports-outdoors", sort_order: 40 }, + { name_ar: "إلكترونيات", name_en: "Electronics", slug: "electronics", shein_cat_id: "2091", shein_url: "/Electronics-sc-00212620.html", level: 1, sort_order: 41 }, + { name_ar: "إكسسوارات الهواتف", name_en: "Phone Accessories", slug: "phone-accessories", shein_cat_id: "2092", shein_url: "/Phone-Accessories-sc-00212621.html", level: 2, parent_slug: "electronics", sort_order: 42 }, + { name_ar: "إكسسوارات الكمبيوتر", name_en: "Computer Accessories", slug: "computer-accessories", shein_cat_id: "2093", shein_url: "/Computer-Accessories-sc-00212622.html", level: 2, parent_slug: "electronics", sort_order: 43 }, + { name_ar: "سماعات", name_en: "Headphones & Speakers", slug: "headphones-speakers", shein_cat_id: "2094", shein_url: "/Headphones-Speakers-sc-00212623.html", level: 2, parent_slug: "electronics", sort_order: 44 }, + { name_ar: "الألعاب والهوايات", name_en: "Toys & Hobbies", slug: "toys-hobbies", shein_cat_id: "2095", shein_url: "/Toys-Hobbies-sc-00212624.html", level: 1, sort_order: 45 }, + { name_ar: "الكتب والقرطاسية", name_en: "Books & Stationery", slug: "books-stationery", shein_cat_id: "2096", shein_url: "/Books-Stationery-sc-00212625.html", level: 2, parent_slug: "toys-hobbies", sort_order: 46 }, + { name_ar: "ألعاب أطفال", name_en: "Kids Toys", slug: "kids-toys", shein_cat_id: "2097", shein_url: "/Kids-Toys-sc-00212626.html", level: 2, parent_slug: "toys-hobbies", sort_order: 47 }, + { name_ar: "الحيوانات الأليفة", name_en: "Pet Supplies", slug: "pet-supplies", shein_cat_id: "2098", shein_url: "/Pet-Supplies-sc-00212627.html", level: 1, sort_order: 48 }, + { name_ar: "مستلزمات الكلاب", name_en: "Dog Supplies", slug: "dog-supplies", shein_cat_id: "2099", shein_url: "/Dog-Supplies-sc-00212628.html", level: 2, parent_slug: "pet-supplies", sort_order: 49 }, + { name_ar: "مستلزمات القطط", name_en: "Cat Supplies", slug: "cat-supplies", shein_cat_id: "2100", shein_url: "/Cat-Supplies-sc-00212629.html", level: 2, parent_slug: "pet-supplies", sort_order: 50 }, + { name_ar: "سيارات", name_en: "Automotive", slug: "automotive", shein_cat_id: "2101", shein_url: "/Automotive-sc-00212630.html", level: 1, sort_order: 51 }, + { name_ar: "إكسسوارات السيارات", name_en: "Car Accessories", slug: "car-accessories", shein_cat_id: "2102", shein_url: "/Car-Accessories-sc-00212631.html", level: 2, parent_slug: "automotive", sort_order: 52 }, + { name_ar: "مجوهرات", name_en: "Jewelry", slug: "jewelry", shein_cat_id: "2110", shein_url: "/Jewelry-sc-00212640.html", level: 2, parent_slug: "women", sort_order: 53 }, + { name_ar: "نظارات شمسية", name_en: "Sunglasses", slug: "sunglasses", shein_cat_id: "2111", shein_url: "/Sunglasses-sc-00212641.html", level: 2, parent_slug: "women", sort_order: 54 }, + { name_ar: "أحزمة", name_en: "Belts", slug: "belts", shein_cat_id: "2112", shein_url: "/Belts-sc-00212642.html", level: 2, parent_slug: "women", sort_order: 55 }, + { name_ar: "قبعات وأوشحة", name_en: "Hats & Scarves", slug: "hats-scarves", shein_cat_id: "2113", shein_url: "/Hats-Scarves-sc-00212643.html", level: 2, parent_slug: "women", sort_order: 56 }, + { name_ar: "ملابس العرائس", name_en: "Wedding & Event", slug: "wedding-event", shein_cat_id: "2114", shein_url: "/Wedding-Event-sc-00212644.html", level: 2, parent_slug: "women", sort_order: 57 }, + { name_ar: "ملابس حوامل", name_en: "Maternity", slug: "maternity", shein_cat_id: "2115", shein_url: "/Maternity-sc-00212645.html", level: 2, parent_slug: "women", sort_order: 58 }, + { name_ar: "ملابس كبيرة المقاس", name_en: "Plus Size", slug: "plus-size", shein_cat_id: "2116", shein_url: "/Plus-Size-sc-00212646.html", level: 2, parent_slug: "women", sort_order: 59 }, + { name_ar: "ملابس سباحة", name_en: "Swimwear", slug: "swimwear", shein_cat_id: "1763", shein_url: "/Swimwear-sc-00212553.html", level: 2, parent_slug: "women", sort_order: 60 }, + { name_ar: "تشكيلات جديدة", name_en: "New In", slug: "new-in", shein_cat_id: "2200", shein_url: "/New-sc-00212800.html", level: 1, sort_order: 0, icon: "🆕" }, + { name_ar: "عروض وتخفيضات", name_en: "Sale", slug: "sale", shein_cat_id: "2201", shein_url: "/Sale-sc-00212801.html", level: 1, sort_order: 99, icon: "🔥" }, +]; diff --git a/artifacts/api-server/src/middleware/auth.ts b/artifacts/api-server/src/middleware/auth.ts new file mode 100644 index 0000000..1fdda21 --- /dev/null +++ b/artifacts/api-server/src/middleware/auth.ts @@ -0,0 +1,20 @@ +import type { Request, Response, NextFunction } from "express"; + +export function getAdminToken(): string | undefined { + return process.env["ADMIN_TOKEN"]; +} + +export function requireAdmin(req: Request, res: Response, next: NextFunction): void { + const configuredToken = process.env["ADMIN_TOKEN"]; + if (!configuredToken) { + res.status(503).json({ error: "Admin access is not configured on this server" }); + return; + } + const auth = req.headers["authorization"] ?? ""; + const token = auth.startsWith("Bearer ") ? auth.slice(7) : auth; + if (token !== configuredToken) { + res.status(401).json({ error: "Unauthorized — admin token required" }); + return; + } + next(); +} diff --git a/artifacts/api-server/src/middlewares/.gitkeep b/artifacts/api-server/src/middlewares/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/artifacts/api-server/src/routes/admin.ts b/artifacts/api-server/src/routes/admin.ts new file mode 100644 index 0000000..03654ce --- /dev/null +++ b/artifacts/api-server/src/routes/admin.ts @@ -0,0 +1,78 @@ +import { Router, type IRouter } from "express"; +import { db, adminUsersTable, productsTable, ordersTable } from "@workspace/db"; +import { eq, sql, lte } from "drizzle-orm"; +import { getAdminToken } from "../middleware/auth"; + +const router: IRouter = Router(); + +router.post("/admin/login", async (req, res) => { + try { + const { username, password } = req.body; + + let [admin] = await db.select().from(adminUsersTable).where(eq(adminUsersTable.username, username)); + + if (!admin) { + if (username === "admin" && password === "admin123") { + const [newAdmin] = await db.insert(adminUsersTable).values({ + username: "admin", + password: "admin123", + }).returning(); + admin = newAdmin; + } else { + return res.status(401).json({ error: "Invalid credentials" }); + } + } + + if (admin.password !== password) { + return res.status(401).json({ error: "Invalid credentials" }); + } + + res.json({ token: getAdminToken(), username: admin.username }); + } catch (err) { + req.log.error({ err }, "Failed to login"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.put("/admin/password", async (req, res) => { + try { + const { current_password, new_password, username } = req.body; + const [admin] = await db.select().from(adminUsersTable).where(eq(adminUsersTable.username, username || "admin")); + if (!admin || admin.password !== current_password) { + return res.status(401).json({ error: "Invalid current password" }); + } + await db.update(adminUsersTable).set({ password: new_password, updated_at: new Date() }).where(eq(adminUsersTable.id, admin.id)); + res.json({ message: "Password changed", success: true }); + } catch (err) { + req.log.error({ err }, "Failed to change password"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.get("/admin/stats", async (req, res) => { + try { + const [orderStats, productStats, lowStockResult, revenueResult] = await Promise.all([ + db.select({ + total: sql`CAST(COUNT(*) AS INTEGER)`, + pending: sql`CAST(SUM(CASE WHEN ${ordersTable.status} = 'pending' THEN 1 ELSE 0 END) AS INTEGER)`, + }).from(ordersTable), + db.select({ count: sql`CAST(COUNT(*) AS INTEGER)` }).from(productsTable), + db.select({ count: sql`CAST(COUNT(*) AS INTEGER)` }).from(productsTable).where(lte(productsTable.stock, 5)), + db.select({ revenue: sql`COALESCE(SUM(CAST(${ordersTable.total} AS DECIMAL)), 0)` }).from(ordersTable).where(eq(ordersTable.status, "delivered")), + ]); + + res.json({ + total_orders: orderStats[0]?.total || 0, + pending_orders: orderStats[0]?.pending || 0, + total_revenue: revenueResult[0]?.revenue || 0, + total_products: productStats[0]?.count || 0, + low_stock_count: lowStockResult[0]?.count || 0, + total_customers: 0, + }); + } catch (err) { + req.log.error({ err }, "Failed to get stats"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/analytics.ts b/artifacts/api-server/src/routes/analytics.ts new file mode 100644 index 0000000..672af5d --- /dev/null +++ b/artifacts/api-server/src/routes/analytics.ts @@ -0,0 +1,279 @@ +import { Router, type IRouter } from "express"; +import { db, ordersTable, cartItemsTable, productsTable, supportTicketsTable, scheduledOffersTable, categoriesTable, usersTable } from "@workspace/db"; +import { eq, sql, desc, and } from "drizzle-orm"; + +const router: IRouter = Router(); + +// GET /api/admin/customers — derive customers from orders +router.get("/admin/customers", async (req, res) => { + try { + const orders = await db.select().from(ordersTable).orderBy(desc(ordersTable.created_at)); + const customerMap: Record = {}; + for (const order of orders) { + const key = order.customer_phone || order.customer_email || order.session_id; + if (!customerMap[key]) { + customerMap[key] = { + id: key, + name: order.customer_name, + phone: order.customer_phone, + email: order.customer_email, + city: order.city, + address: order.shipping_address, + total_orders: 0, + total_spent: 0, + first_order: order.created_at, + last_order: order.created_at, + }; + } + customerMap[key].total_orders++; + customerMap[key].total_spent += parseFloat(String(order.total)); + if (new Date(order.created_at!) < new Date(customerMap[key].first_order)) { + customerMap[key].first_order = order.created_at; + } + if (new Date(order.created_at!) > new Date(customerMap[key].last_order)) { + customerMap[key].last_order = order.created_at; + } + } + res.json(Object.values(customerMap).sort((a, b) => b.total_spent - a.total_spent)); + } catch (err) { + req.log.error({ err }, "Failed to get customers"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// GET /api/admin/analytics — dashboard charts +router.get("/admin/analytics", async (req, res) => { + try { + const orders = await db.select().from(ordersTable).orderBy(ordersTable.created_at); + + // Monthly profits (last 6 months) + const now = new Date(); + const monthlyData: Record = {}; + for (let i = 5; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; + const label = d.toLocaleDateString("ar-SA", { month: "short", year: "numeric" }); + monthlyData[key] = { month: label, revenue: 0, orders: 0 }; + } + for (const order of orders) { + const d = new Date(order.created_at!); + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; + if (monthlyData[key]) { + monthlyData[key].revenue += parseFloat(String(order.total)); + monthlyData[key].orders++; + } + } + + // Top cities + const cityMap: Record = {}; + for (const order of orders) { + cityMap[order.city] = (cityMap[order.city] || 0) + parseFloat(String(order.total)); + } + const topCities = Object.entries(cityMap) + .map(([city, revenue]) => ({ city, revenue })) + .sort((a, b) => b.revenue - a.revenue) + .slice(0, 6); + + // Top products (by order count in items) + const productCount: Record = {}; + for (const order of orders) { + const items = (order.items as any[]) || []; + for (const item of items) { + const key = String(item.product_id); + if (!productCount[key]) productCount[key] = { name: item.product_name, count: 0, revenue: 0 }; + productCount[key].count += item.quantity; + productCount[key].revenue += item.price * item.quantity; + } + } + const topProducts = Object.values(productCount) + .sort((a, b) => b.revenue - a.revenue) + .slice(0, 6); + + res.json({ + monthly: Object.values(monthlyData), + topCities, + topProducts, + totalRevenue: orders.reduce((s, o) => s + parseFloat(String(o.total)), 0), + totalOrders: orders.length, + }); + } catch (err) { + req.log.error({ err }, "Failed to get analytics"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// GET /api/admin/abandoned-carts +router.get("/admin/abandoned-carts", async (req, res) => { + try { + const cartItems = await db + .select({ + session_id: cartItemsTable.session_id, + quantity: cartItemsTable.quantity, + product_name: productsTable.name, + price: productsTable.price, + }) + .from(cartItemsTable) + .leftJoin(productsTable, eq(productsTable.id, cartItemsTable.product_id)); + + const completedSessions = await db + .select({ session_id: ordersTable.session_id }) + .from(ordersTable); + + const completedSet = new Set(completedSessions.map(r => r.session_id)); + + const sessionMap: Record = {}; + for (const item of cartItems) { + if (!completedSet.has(item.session_id)) { + if (!sessionMap[item.session_id]) sessionMap[item.session_id] = []; + sessionMap[item.session_id].push(item); + } + } + + const abandoned = Object.entries(sessionMap).map(([session_id, items]) => ({ + session_id, + items_count: items.length, + total: items.reduce((s, i) => s + parseFloat(String(i.price || 0)) * (i.quantity || 1), 0), + items: items.slice(0, 3).map(i => ({ name: i.product_name, qty: i.quantity, price: i.price })), + })); + + res.json(abandoned); + } catch (err) { + req.log.error({ err }, "Failed to get abandoned carts"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// Support Tickets CRUD +router.get("/support-tickets", async (req, res) => { + try { + const tickets = await db.select().from(supportTicketsTable).orderBy(desc(supportTicketsTable.created_at)); + res.json(tickets); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.post("/support-tickets", async (req, res) => { + try { + const { customer_name, customer_phone, customer_email, subject, message, session_id } = req.body; + const [ticket] = await db.insert(supportTicketsTable).values({ + customer_name, customer_phone, customer_email, subject, message, session_id, status: "open" + }).returning(); + res.status(201).json(ticket); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.put("/support-tickets/:id/reply", async (req, res) => { + try { + const id = parseInt(req.params.id); + const { admin_reply } = req.body; + await db.update(supportTicketsTable).set({ admin_reply, status: "replied", updated_at: new Date() }).where(eq(supportTicketsTable.id, id)); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.put("/support-tickets/:id/close", async (req, res) => { + try { + const id = parseInt(req.params.id); + await db.update(supportTicketsTable).set({ status: "closed", updated_at: new Date() }).where(eq(supportTicketsTable.id, id)); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.delete("/support-tickets/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + await db.delete(supportTicketsTable).where(eq(supportTicketsTable.id, id)); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +// Scheduled Offers CRUD +router.get("/scheduled-offers", async (req, res) => { + try { + const offers = await db.select().from(scheduledOffersTable).orderBy(desc(scheduledOffersTable.created_at)); + res.json(offers); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.post("/scheduled-offers", async (req, res) => { + try { + const { product_id, title, discount_type, discount_value, start_date, end_date } = req.body; + const [offer] = await db.insert(scheduledOffersTable).values({ + product_id: product_id ? parseInt(product_id) : null, + title, discount_type, discount_value: String(discount_value), + start_date: new Date(start_date), end_date: new Date(end_date), + is_active: true + }).returning(); + res.status(201).json(offer); + } catch (err) { + req.log.error({ err }, "Failed to create offer"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.delete("/scheduled-offers/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + await db.delete(scheduledOffersTable).where(eq(scheduledOffersTable.id, id)); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +// DELETE /api/orders/:id +router.delete("/orders/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + await db.delete(ordersTable).where(eq(ordersTable.id, id)); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +// GET /api/orders/:id/invoice +router.get("/orders/:id/invoice", async (req, res) => { + try { + const id = parseInt(req.params.id); + const [order] = await db.select().from(ordersTable).where(eq(ordersTable.id, id)); + if (!order) return res.status(404).json({ error: "Not found" }); + res.json(order); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +// GET /api/admin/users — registered user accounts +router.get("/admin/users", async (req, res) => { + try { + const users = await db + .select({ + id: usersTable.id, + name: usersTable.name, + email: usersTable.email, + age: usersTable.age, + provider: usersTable.provider, + remember_me: usersTable.remember_me, + created_at: usersTable.created_at, + }) + .from(usersTable) + .orderBy(desc(usersTable.created_at)); + res.json(users); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/auth.ts b/artifacts/api-server/src/routes/auth.ts new file mode 100644 index 0000000..ba3dade --- /dev/null +++ b/artifacts/api-server/src/routes/auth.ts @@ -0,0 +1,95 @@ +import { Router, type IRouter } from "express"; +import { db, usersTable } from "@workspace/db"; +import { eq } from "drizzle-orm"; +import { createHash } from "crypto"; + +const router: IRouter = Router(); + +function hashPassword(password: string): string { + return createHash("sha256").update(password + "saudi_store_salt_2024").digest("hex"); +} + +function validatePassword(password: string): string | null { + if (password.length < 8) return "كلمة المرور يجب أن تكون 8 أحرف على الأقل"; + if (!/[A-Z]/.test(password)) return "كلمة المرور يجب أن تحتوي على حرف كبير"; + if (!/[0-9]/.test(password)) return "كلمة المرور يجب أن تحتوي على رقم"; + return null; +} + +function validateEmail(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +router.post("/auth/register", async (req, res) => { + try { + const { name, age, email, password, confirm_password, remember_me, provider, provider_id } = req.body; + + if (!email || !validateEmail(email)) { + return res.status(400).json({ error: "البريد الإلكتروني غير صحيح" }); + } + + const existing = await db.select({ id: usersTable.id }).from(usersTable).where(eq(usersTable.email, email.toLowerCase())); + if (existing.length > 0) { + return res.status(409).json({ error: "البريد الإلكتروني مستخدم بالفعل" }); + } + + if (provider !== "google" && provider !== "apple") { + const pwErr = validatePassword(password || ""); + if (pwErr) return res.status(400).json({ error: pwErr }); + if (password !== confirm_password) return res.status(400).json({ error: "كلمة المرور وتأكيدها غير متطابقين" }); + } + + const [user] = await db.insert(usersTable).values({ + name: name || null, + age: age ? parseInt(age) : null, + email: email.toLowerCase(), + password_hash: hashPassword(password || provider_id || "social_login"), + provider: provider || "manual", + provider_id: provider_id || null, + remember_me: !!remember_me, + }).returning({ id: usersTable.id, name: usersTable.name, email: usersTable.email }); + + res.status(201).json({ user, token: `user_${user.id}_${Date.now()}` }); + } catch (err) { + req.log.error({ err }, "Register failed"); + res.status(500).json({ error: "خطأ في الخادم" }); + } +}); + +router.post("/auth/login", async (req, res) => { + try { + const { email, password, remember_me } = req.body; + + if (!email || !password) return res.status(400).json({ error: "البريد وكلمة المرور مطلوبان" }); + + const [user] = await db.select().from(usersTable).where(eq(usersTable.email, email.toLowerCase())); + if (!user || user.password_hash !== hashPassword(password)) { + return res.status(401).json({ error: "البريد الإلكتروني أو كلمة المرور غير صحيحة" }); + } + + await db.update(usersTable).set({ remember_me: !!remember_me }).where(eq(usersTable.id, user.id)); + + res.json({ + user: { id: user.id, name: user.name, email: user.email }, + token: `user_${user.id}_${Date.now()}` + }); + } catch (err) { + res.status(500).json({ error: "خطأ في الخادم" }); + } +}); + +router.get("/auth/me", async (req, res) => { + try { + const token = req.headers.authorization?.replace("Bearer ", ""); + if (!token?.startsWith("user_")) return res.status(401).json({ error: "غير مصرح" }); + const userId = parseInt(token.split("_")[1]); + const [user] = await db.select({ id: usersTable.id, name: usersTable.name, email: usersTable.email, remember_me: usersTable.remember_me }) + .from(usersTable).where(eq(usersTable.id, userId)); + if (!user) return res.status(401).json({ error: "غير مصرح" }); + res.json(user); + } catch (err) { + res.status(500).json({ error: "خطأ في الخادم" }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/cart.ts b/artifacts/api-server/src/routes/cart.ts new file mode 100644 index 0000000..ba9000b --- /dev/null +++ b/artifacts/api-server/src/routes/cart.ts @@ -0,0 +1,103 @@ +import { Router, type IRouter } from "express"; +import { db, cartItemsTable, productsTable, categoriesTable } from "@workspace/db"; +import { eq, and, sql } from "drizzle-orm"; + +const router: IRouter = Router(); + +router.get("/cart", async (req, res) => { + try { + const { session_id } = req.query as { session_id: string }; + if (!session_id) return res.status(400).json({ error: "session_id required" }); + + const items = await db + .select({ + id: cartItemsTable.id, + session_id: cartItemsTable.session_id, + product_id: cartItemsTable.product_id, + quantity: cartItemsTable.quantity, + selected_size: cartItemsTable.selected_size, + selected_color: cartItemsTable.selected_color, + product: { + id: productsTable.id, + name: productsTable.name, + brand: productsTable.brand, + price: productsTable.price, + original_price: productsTable.original_price, + images: productsTable.images, + stock: productsTable.stock, + category_id: productsTable.category_id, + rating: productsTable.rating, + review_count: productsTable.review_count, + discount_percent: sql` + CASE WHEN ${productsTable.original_price} IS NOT NULL AND CAST(${productsTable.original_price} AS DECIMAL) > CAST(${productsTable.price} AS DECIMAL) + THEN ROUND((1 - CAST(${productsTable.price} AS DECIMAL) / CAST(${productsTable.original_price} AS DECIMAL)) * 100) + ELSE 0 END + `, + }, + }) + .from(cartItemsTable) + .leftJoin(productsTable, eq(cartItemsTable.product_id, productsTable.id)) + .where(eq(cartItemsTable.session_id, session_id)); + + res.json(items); + } catch (err) { + req.log.error({ err }, "Failed to get cart"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.post("/cart", async (req, res) => { + try { + const { session_id, product_id, quantity, selected_size, selected_color } = req.body; + + const existing = await db.select().from(cartItemsTable) + .where(and(eq(cartItemsTable.session_id, session_id), eq(cartItemsTable.product_id, product_id))); + + if (existing.length > 0) { + const [updated] = await db.update(cartItemsTable) + .set({ quantity: existing[0].quantity + quantity }) + .where(eq(cartItemsTable.id, existing[0].id)) + .returning(); + return res.status(201).json(updated); + } + + const [item] = await db.insert(cartItemsTable).values({ + session_id, + product_id, + quantity, + selected_size, + selected_color, + }).returning(); + + res.status(201).json(item); + } catch (err) { + req.log.error({ err }, "Failed to add to cart"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.put("/cart/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + const { quantity } = req.body; + const [item] = await db.update(cartItemsTable).set({ quantity }).where(eq(cartItemsTable.id, id)).returning(); + if (!item) return res.status(404).json({ error: "Cart item not found" }); + res.json(item); + } catch (err) { + req.log.error({ err }, "Failed to update cart item"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.delete("/cart/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + await db.delete(cartItemsTable).where(eq(cartItemsTable.id, id)); + res.json({ message: "Item removed", success: true }); + } catch (err) { + req.log.error({ err }, "Failed to remove cart item"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/categories.ts b/artifacts/api-server/src/routes/categories.ts new file mode 100644 index 0000000..0d60418 --- /dev/null +++ b/artifacts/api-server/src/routes/categories.ts @@ -0,0 +1,91 @@ +import { Router, type IRouter } from "express"; +import { db, categoriesTable } from "@workspace/db"; +import { eq, sql, isNull } from "drizzle-orm"; +import { productsTable } from "@workspace/db"; + +const router: IRouter = Router(); + +router.get("/categories", async (req, res) => { + try { + const cats = await db + .select({ + id: categoriesTable.id, + name: categoriesTable.name, + name_en: categoriesTable.name_en, + slug: categoriesTable.slug, + icon: categoriesTable.icon, + image_url: categoriesTable.image_url, + sort_order: categoriesTable.sort_order, + parent_id: categoriesTable.parent_id, + source: categoriesTable.source, + shein_url: categoriesTable.shein_url, + product_count: sql`CAST(COUNT(${productsTable.id}) AS INTEGER)`, + }) + .from(categoriesTable) + .leftJoin(productsTable, eq(productsTable.category_id, categoriesTable.id)) + .groupBy(categoriesTable.id) + .orderBy(categoriesTable.sort_order); + res.json(cats); + } catch (err) { + req.log.error({ err }, "Failed to get categories"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// Tree structure: main categories with their sub-categories +router.get("/categories/tree", async (req, res) => { + try { + const all = await db.select().from(categoriesTable).orderBy(categoriesTable.sort_order); + const mains = all.filter(c => !c.parent_id); + const tree = mains.map(m => ({ + ...m, + children: all.filter(c => c.parent_id === m.id), + })); + res.json(tree); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.post("/categories", async (req, res) => { + try { + const { name, name_en, icon, sort_order, parent_id } = req.body; + const [cat] = await db.insert(categoriesTable).values({ + name, name_en, icon, + sort_order: sort_order ?? 99, + parent_id: parent_id ? parseInt(parent_id) : null, + }).returning(); + res.status(201).json(cat); + } catch (err) { + req.log.error({ err }, "Failed to create category"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.put("/categories/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + const { name, name_en, icon, sort_order, parent_id } = req.body; + await db.update(categoriesTable).set({ + name, name_en, icon, sort_order, + parent_id: parent_id ? parseInt(parent_id) : null, + }).where(eq(categoriesTable.id, id)); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.delete("/categories/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + // Reset sub-categories parent to null before deleting + await db.update(categoriesTable).set({ parent_id: null }).where(eq(categoriesTable.parent_id, id)); + await db.delete(categoriesTable).where(eq(categoriesTable.id, id)); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/checkout-events.ts b/artifacts/api-server/src/routes/checkout-events.ts new file mode 100644 index 0000000..7b1fe60 --- /dev/null +++ b/artifacts/api-server/src/routes/checkout-events.ts @@ -0,0 +1,43 @@ +import { Router, type IRouter } from "express"; + +const router: IRouter = Router(); + +// In-memory store for checkout step events (transient, not persisted) +interface CheckoutEvent { + id: number; + session_id: string; + step: number; + step_label: string; + created_at: string; +} + +let events: CheckoutEvent[] = []; +let nextId = 1; + +// POST /api/checkout-events — called by the client when a user moves to a new step +router.post("/checkout-events", (req, res) => { + const { session_id, step, step_label } = req.body; + if (!session_id || !step) { + return res.status(400).json({ error: "Missing required fields" }); + } + const event: CheckoutEvent = { + id: nextId++, + session_id: String(session_id), + step: Number(step), + step_label: String(step_label || `خطوة ${step}`), + created_at: new Date().toISOString(), + }; + events.push(event); + // Keep only the last 200 events + if (events.length > 200) events = events.slice(-200); + res.status(201).json(event); +}); + +// GET /api/checkout-events?since_id=N — admin polls for new events +router.get("/checkout-events", (req, res) => { + const sinceId = req.query.since_id ? parseInt(req.query.since_id as string) : 0; + const newEvents = events.filter(e => e.id > sinceId); + res.json({ events: newEvents, latest_id: events.length > 0 ? events[events.length - 1].id : 0 }); +}); + +export default router; diff --git a/artifacts/api-server/src/routes/coupons.ts b/artifacts/api-server/src/routes/coupons.ts new file mode 100644 index 0000000..89d893b --- /dev/null +++ b/artifacts/api-server/src/routes/coupons.ts @@ -0,0 +1,89 @@ +import { Router, type IRouter } from "express"; +import { db, couponsTable } from "@workspace/db"; +import { eq } from "drizzle-orm"; + +const router: IRouter = Router(); + +router.get("/coupons", async (req, res) => { + try { + const coupons = await db.select().from(couponsTable); + res.json(coupons); + } catch (err) { + req.log.error({ err }, "Failed to get coupons"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.post("/coupons", async (req, res) => { + try { + const { code, discount_type, discount_value, min_order, max_uses, expires_at } = req.body; + const [coupon] = await db.insert(couponsTable).values({ + code: code.toUpperCase(), + discount_type, + discount_value: String(discount_value), + min_order: min_order ? String(min_order) : "0", + max_uses, + expires_at: expires_at ? new Date(expires_at) : undefined, + is_active: true, + }).returning(); + res.status(201).json(coupon); + } catch (err) { + req.log.error({ err }, "Failed to create coupon"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.put("/coupons/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + const { is_active, max_uses, expires_at } = req.body; + const updateData: Record = {}; + if (is_active !== undefined) updateData.is_active = is_active; + if (max_uses !== undefined) updateData.max_uses = max_uses; + if (expires_at !== undefined) updateData.expires_at = expires_at ? new Date(expires_at) : null; + await db.update(couponsTable).set(updateData).where(eq(couponsTable.id, id)); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.delete("/coupons/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + await db.delete(couponsTable).where(eq(couponsTable.id, id)); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.post("/coupons/validate", async (req, res) => { + try { + const { code, order_total } = req.body; + const [coupon] = await db.select().from(couponsTable).where(eq(couponsTable.code, code.toUpperCase())); + + if (!coupon || !coupon.is_active) { + return res.status(404).json({ error: "Invalid coupon" }); + } + + if (coupon.expires_at && new Date(coupon.expires_at) < new Date()) { + return res.status(404).json({ error: "Coupon expired" }); + } + + if (coupon.max_uses && (coupon.used_count || 0) >= coupon.max_uses) { + return res.status(404).json({ error: "Coupon usage limit reached" }); + } + + if (order_total && parseFloat(String(coupon.min_order)) > order_total) { + return res.status(404).json({ error: "Order total too low for this coupon" }); + } + + res.json(coupon); + } catch (err) { + req.log.error({ err }, "Failed to validate coupon"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/health.ts b/artifacts/api-server/src/routes/health.ts new file mode 100644 index 0000000..c0a1446 --- /dev/null +++ b/artifacts/api-server/src/routes/health.ts @@ -0,0 +1,11 @@ +import { Router, type IRouter } from "express"; +import { HealthCheckResponse } from "@workspace/api-zod"; + +const router: IRouter = Router(); + +router.get("/healthz", (_req, res) => { + const data = HealthCheckResponse.parse({ status: "ok" }); + res.json(data); +}); + +export default router; diff --git a/artifacts/api-server/src/routes/image-proxy.ts b/artifacts/api-server/src/routes/image-proxy.ts new file mode 100644 index 0000000..9588778 --- /dev/null +++ b/artifacts/api-server/src/routes/image-proxy.ts @@ -0,0 +1,127 @@ +import { Router, Request, Response } from "express"; +import https from "https"; +import http from "http"; + +const router = Router(); + +const ALLOWED_DOMAINS = [ + "images.unsplash.com", + "source.unsplash.com", + "i.imgur.com", + "storage.googleapis.com", + "m.media-amazon.com", + "images-na.ssl-images-amazon.com", + "store.storeimages.cdn-apple.com", + "as-images.apple.com", + "images.samsung.com", + "store.dji.com", + "dyson-h.assetsadobe2.com", + "assets.nintendo.com", + "press.asus.com", + "b2c-contenthub.com", + "cdn.mos.cms.futurecdn.net", + "platform.theverge.com", + "content.abt.com", + "media.wired.com", + "fdn.gsmarena.com", + "fdn2.gsmarena.com", + "www.lg.com", + "i.ebayimg.com", + "consumer.huawei.com", + "live.staticflickr.com", + "upload.wikimedia.org", + "www.ikea.com", + "images.pexels.com", + "cdn.pixabay.com", + "res.cloudinary.com", + "cloudinary.com", + "images.shein.com", + "img.ltwebstatic.com", +]; + +function isDomainAllowed(hostname: string): boolean { + return ALLOWED_DOMAINS.some( + (h) => hostname === h || hostname.endsWith(`.${h}`) + ); +} + +function fetchWithRedirect( + url: string, + maxRedirects = 5 +): Promise<{ res: any; finalUrl: string }> { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + + if (!isDomainAllowed(parsed.hostname)) { + return reject(new Error(`Domain not allowed: ${parsed.hostname}`)); + } + + const protocol = parsed.protocol === "https:" ? https : http; + const reqOptions = { + hostname: parsed.hostname, + path: parsed.pathname + parsed.search, + headers: { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", + Accept: "image/webp,image/apng,image/*,*/*;q=0.8", + }, + }; + + const req = protocol.get(reqOptions, (res) => { + if ( + maxRedirects > 0 && + res.statusCode && + res.statusCode >= 300 && + res.statusCode < 400 && + res.headers.location + ) { + const nextUrl = res.headers.location.startsWith("http") + ? res.headers.location + : `${parsed.protocol}//${parsed.hostname}${res.headers.location}`; + res.resume(); + fetchWithRedirect(nextUrl, maxRedirects - 1).then(resolve).catch(reject); + } else { + resolve({ res, finalUrl: url }); + } + }); + + req.on("error", reject); + req.setTimeout(10000, () => { + req.destroy(); + reject(new Error("Timeout")); + }); + }); +} + +router.get("/image-proxy", async (req: Request, res: Response) => { + const url = req.query.url as string; + if (!url) return res.status(400).json({ error: "Missing url" }); + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return res.status(400).json({ error: "Invalid url" }); + } + + if (!isDomainAllowed(parsed.hostname)) { + return res.status(403).json({ error: "Domain not allowed" }); + } + + try { + const { res: proxyRes } = await fetchWithRedirect(url); + + res.setHeader( + "Content-Type", + proxyRes.headers["content-type"] || "image/jpeg" + ); + res.setHeader("Cache-Control", "public, max-age=86400"); + res.setHeader("Access-Control-Allow-Origin", "*"); + proxyRes.pipe(res); + } catch (err: any) { + console.error("Image proxy error:", err.message); + res.status(502).json({ error: "Proxy error" }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts new file mode 100644 index 0000000..618c130 --- /dev/null +++ b/artifacts/api-server/src/routes/index.ts @@ -0,0 +1,38 @@ +import { Router } from "express"; +import healthRouter from "./health"; +import categoriesRouter from "./categories"; +import productsRouter from "./products"; +import reviewsRouter from "./reviews"; +import cartRouter from "./cart"; +import wishlistRouter from "./wishlist"; +import ordersRouter from "./orders"; +import adminRouter from "./admin"; +import couponsRouter from "./coupons"; +import paymentsRouter from "./payments"; +import analyticsRouter from "./analytics"; +import authRouter from "./auth"; +import checkoutEventsRouter from "./checkout-events"; +import storeSettingsRouter from "./store-settings"; +import imageProxyRouter from "./image-proxy"; +import integrationsRouter from "./integrations"; + +const router = Router(); + +router.use(authRouter); +router.use(checkoutEventsRouter); +router.use(healthRouter); +router.use(categoriesRouter); +router.use(productsRouter); +router.use(reviewsRouter); +router.use(cartRouter); +router.use(wishlistRouter); +router.use(ordersRouter); +router.use(adminRouter); +router.use(couponsRouter); +router.use(paymentsRouter); +router.use(analyticsRouter); +router.use(storeSettingsRouter); +router.use(imageProxyRouter); +router.use(integrationsRouter); + +export default router; diff --git a/artifacts/api-server/src/routes/integrations.ts b/artifacts/api-server/src/routes/integrations.ts new file mode 100644 index 0000000..6da9665 --- /dev/null +++ b/artifacts/api-server/src/routes/integrations.ts @@ -0,0 +1,219 @@ +import { Router, type IRouter } from "express"; +import { runAllIntegrationTests, testRapidApi, testSerpApi, testCloudinary, testApify, testDatabase } from "../lib/integration-tests"; +import { validateServicesConfig } from "../config/services"; +import { fetchSheinCategories, SHEIN_CATEGORIES_PRESET, type SheinCategory } from "../lib/shein-scraper"; +import { db, categoriesTable } from "@workspace/db"; +import { eq, sql } from "drizzle-orm"; +import { requireAdmin } from "../middleware/auth"; + +const router: IRouter = Router(); + +router.get("/integrations/status", async (_req, res) => { + try { + const configCheck = validateServicesConfig(); + const results = await runAllIntegrationTests(); + const allConnected = results.every(r => r.connected); + res.json({ + ready: allConnected, + config: configCheck, + services: results, + summary: results.map(r => `${r.connected ? "✅" : "❌"} ${r.service}: ${r.message}`), + }); + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + res.status(500).json({ error: errMsg }); + } +}); + +router.get("/integrations/rapidapi", async (_req, res) => { + res.json(await testRapidApi()); +}); + +router.get("/integrations/serpapi", async (_req, res) => { + res.json(await testSerpApi()); +}); + +router.get("/integrations/cloudinary", async (_req, res) => { + res.json(await testCloudinary()); +}); + +router.get("/integrations/apify", async (_req, res) => { + res.json(await testApify()); +}); + +router.get("/integrations/database", async (_req, res) => { + res.json(await testDatabase()); +}); + +async function saveSheinCategoriesToDb(categories: SheinCategory[]): Promise<{ saved: number; errors: number }> { + let saved = 0; + let errors = 0; + + const mainCats = categories.filter(c => c.level === 1); + const subCats = categories.filter(c => c.level === 2); + + const parentIdMap = new Map(); + + for (const cat of mainCats) { + try { + const existing = await db + .select({ id: categoriesTable.id }) + .from(categoriesTable) + .where(eq(categoriesTable.slug, cat.slug)) + .limit(1); + + if (existing.length > 0) { + await db.update(categoriesTable).set({ + name: cat.name_ar, + name_en: cat.name_en, + icon: cat.icon ?? null, + sort_order: cat.sort_order, + source: "shein", + shein_cat_id: cat.shein_cat_id, + shein_url: cat.shein_url, + slug: cat.slug, + parent_id: null, + }).where(eq(categoriesTable.id, existing[0]!.id)); + parentIdMap.set(cat.slug, existing[0]!.id); + saved++; + } else { + const [inserted] = await db.insert(categoriesTable).values({ + name: cat.name_ar, + name_en: cat.name_en, + slug: cat.slug, + icon: cat.icon ?? null, + sort_order: cat.sort_order, + parent_id: null, + source: "shein", + shein_cat_id: cat.shein_cat_id, + shein_url: cat.shein_url, + }).returning({ id: categoriesTable.id }); + if (inserted) { + parentIdMap.set(cat.slug, inserted.id); + saved++; + } + } + } catch { + errors++; + } + } + + for (const cat of subCats) { + try { + const parentId = cat.parent_slug ? parentIdMap.get(cat.parent_slug) : undefined; + + const existing = await db + .select({ id: categoriesTable.id }) + .from(categoriesTable) + .where(eq(categoriesTable.slug, cat.slug)) + .limit(1); + + if (existing.length > 0) { + await db.update(categoriesTable).set({ + name: cat.name_ar, + name_en: cat.name_en, + slug: cat.slug, + sort_order: cat.sort_order, + parent_id: parentId ?? null, + source: "shein", + shein_cat_id: cat.shein_cat_id, + shein_url: cat.shein_url, + }).where(eq(categoriesTable.id, existing[0]!.id)); + saved++; + } else { + await db.insert(categoriesTable).values({ + name: cat.name_ar, + name_en: cat.name_en, + slug: cat.slug, + sort_order: cat.sort_order, + parent_id: parentId ?? null, + source: "shein", + shein_cat_id: cat.shein_cat_id, + shein_url: cat.shein_url, + }); + saved++; + } + } catch { + errors++; + } + } + + return { saved, errors }; +} + +router.get("/integrations/shein-categories", async (req, res) => { + const mode = (req.query["mode"] as string) ?? "preset"; + + try { + let categories: SheinCategory[] = []; + let source = "preset"; + let scrapeResult: { success: boolean; error?: string; runId?: string } | null = null; + + if (mode === "scrape") { + scrapeResult = await fetchSheinCategories(); + if (scrapeResult.success && scrapeResult.categories && scrapeResult.categories.length > 0) { + categories = scrapeResult.categories; + source = "apify-scrape"; + } else { + categories = SHEIN_CATEGORIES_PRESET; + source = "preset-fallback"; + } + } else { + categories = SHEIN_CATEGORIES_PRESET; + } + + const dbTotal = await db + .select({ count: sql`count(*)::int` }) + .from(categoriesTable) + .where(eq(categoriesTable.source, "shein")); + + const sections = categories.filter(c => c.level === 1); + const subcats = categories.filter(c => c.level === 2); + + res.json({ + success: true, + source, + totalCategories: categories.length, + sections: sections.length, + subcategories: subcats.length, + dbTotal: dbTotal[0]?.count ?? 0, + scrapeResult: scrapeResult ? { + success: scrapeResult.success, + error: scrapeResult.error, + runId: scrapeResult.runId, + } : null, + data: { + sections: sections.map(s => ({ + ...s, + subcategories: subcats.filter(sc => sc.parent_slug === s.slug), + })), + }, + }); + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + res.status(500).json({ error: errMsg }); + } +}); + +router.post("/integrations/shein-categories/save", requireAdmin, async (_req, res) => { + try { + const dbResult = await saveSheinCategoriesToDb(SHEIN_CATEGORIES_PRESET); + const total = await db + .select({ count: sql`count(*)::int` }) + .from(categoriesTable) + .where(eq(categoriesTable.source, "shein")); + + res.json({ + success: true, + saved: dbResult.saved, + errors: dbResult.errors, + totalInDb: total[0]?.count ?? 0, + message: `تم حفظ ${dbResult.saved} فئة من متجر Shein في قاعدة البيانات`, + }); + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + res.status(500).json({ error: errMsg }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/orders.ts b/artifacts/api-server/src/routes/orders.ts new file mode 100644 index 0000000..af4d9b1 --- /dev/null +++ b/artifacts/api-server/src/routes/orders.ts @@ -0,0 +1,235 @@ +import { Router, type IRouter } from "express"; +import { db, ordersTable, cartItemsTable, productsTable, couponsTable } from "@workspace/db"; +import { eq, sql, inArray } from "drizzle-orm"; +import { requireAdmin } from "../middleware/auth"; + +const router: IRouter = Router(); + +function generateOrderNumber(): string { + const now = Date.now(); + const random = Math.floor(Math.random() * 1000).toString().padStart(3, "0"); + return `SAU-${now}-${random}`; +} + +router.get("/orders", async (req, res) => { + try { + const { status, session_id, page = "1", limit = "20" } = req.query as Record; + const pageNum = parseInt(page) || 1; + const limitNum = parseInt(limit) || 20; + const offset = (pageNum - 1) * limitNum; + + let query = db.select().from(ordersTable).$dynamic(); + if (status && session_id) { + query = query.where(sql`${ordersTable.status} = ${status} AND ${ordersTable.session_id} = ${session_id}`); + } else if (status) { + query = query.where(eq(ordersTable.status, status)); + } else if (session_id) { + query = query.where(eq(ordersTable.session_id, session_id)); + } + + const [orders, totalResult] = await Promise.all([ + query.limit(limitNum).offset(offset).orderBy(sql`${ordersTable.created_at} DESC`), + db.select({ count: sql`CAST(COUNT(*) AS INTEGER)` }).from(ordersTable), + ]); + + res.json({ orders, total: totalResult[0]?.count || 0, page: pageNum, limit: limitNum }); + } catch (err) { + req.log.error({ err }, "Failed to get orders"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.get("/orders/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + const [order] = await db.select().from(ordersTable).where(eq(ordersTable.id, id)); + if (!order) return res.status(404).json({ error: "Order not found" }); + res.json(order); + } catch (err) { + req.log.error({ err }, "Failed to get order"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.post("/orders", async (req, res) => { + try { + const { session_id, customer_name, customer_phone, customer_email, city, neighborhood, street, building, floor, otp_code, payment_method, coupon_code, notes, items: clientItems } = req.body; + const shipping_address = req.body.shipping_address || + [city, neighborhood && `حي ${neighborhood}`, street && `شارع ${street}`, building && `مبنى ${building}`, floor && `دور ${floor}`].filter(Boolean).join("، ") || + city || ""; + + let orderItems: { product_id: number; product_name: string; product_image: string; quantity: number; price: number; selected_size?: string; selected_color?: string; }[] = []; + + type ClientItem = { product_id?: number; id?: number; quantity?: number; selected_size?: string; selectedSize?: string; selected_color?: string; selectedColor?: string; }; + if (clientItems && Array.isArray(clientItems) && clientItems.length > 0) { + // Items sent from client — look up authoritative prices from DB to prevent tampering + const productIds = (clientItems as ClientItem[]).map(i => Number(i.product_id ?? i.id)).filter(Boolean); + const dbProducts = await db.select().from(productsTable).where(inArray(productsTable.id, productIds)); + const productMap = new Map(dbProducts.map(p => [p.id, p])); + + orderItems = (clientItems as ClientItem[]) + .map((item) => { + const pid = Number(item.product_id ?? item.id); + const dbProduct = productMap.get(pid); + if (!dbProduct) return null; // skip unknown products + return { + product_id: pid, + product_name: dbProduct.name, + product_image: (dbProduct.images as string[])?.[0] || "", + quantity: Math.max(1, parseInt(String(item.quantity)) || 1), + price: parseFloat(String(dbProduct.price)), // always use server price + selected_size: item.selected_size || item.selectedSize || undefined, + selected_color: item.selected_color || item.selectedColor || undefined, + }; + }) + .filter(Boolean) as typeof orderItems; + } else { + // Fallback: look up cart from DB + const cartItems = await db + .select({ + id: cartItemsTable.id, + product_id: cartItemsTable.product_id, + quantity: cartItemsTable.quantity, + selected_size: cartItemsTable.selected_size, + selected_color: cartItemsTable.selected_color, + product: { + id: productsTable.id, + name: productsTable.name, + price: productsTable.price, + images: productsTable.images, + }, + }) + .from(cartItemsTable) + .leftJoin(productsTable, eq(cartItemsTable.product_id, productsTable.id)) + .where(eq(cartItemsTable.session_id, session_id)); + + if (cartItems.length === 0) { + return res.status(400).json({ error: "Cart is empty" }); + } + + orderItems = cartItems.map((item) => ({ + product_id: item.product_id!, + product_name: item.product?.name || "", + product_image: (item.product?.images as string[])?.[0] || "", + quantity: item.quantity, + price: parseFloat(String(item.product?.price || 0)), + selected_size: item.selected_size || undefined, + selected_color: item.selected_color || undefined, + })); + } + + if (orderItems.length === 0) { + return res.status(400).json({ error: "Cart is empty" }); + } + + const subtotal = orderItems.reduce((sum, item) => sum + item.price * item.quantity, 0); + + let discount = 0; + if (coupon_code) { + const [coupon] = await db.select().from(couponsTable) + .where(eq(couponsTable.code, coupon_code.toUpperCase())); + if (coupon && coupon.is_active) { + if (coupon.discount_type === "percentage") { + discount = subtotal * (parseFloat(String(coupon.discount_value)) / 100); + } else { + discount = parseFloat(String(coupon.discount_value)); + } + await db.update(couponsTable) + .set({ used_count: (coupon.used_count || 0) + 1 }) + .where(eq(couponsTable.id, coupon.id)); + } + } + + const isRiyadh = city.toLowerCase().includes("رياض") || city.toLowerCase().includes("riyadh"); + const afterDiscount = subtotal - discount; + const freeShippingThreshold = isRiyadh ? 100 : 200; + const shipping_fee = afterDiscount >= freeShippingThreshold ? 0 : (isRiyadh ? 15 : 30); + const total = afterDiscount + shipping_fee; + + const [order] = await db.insert(ordersTable).values({ + order_number: generateOrderNumber(), + session_id, + customer_name, + customer_phone, + customer_email, + shipping_address, + city, + neighborhood: neighborhood || undefined, + street: street || undefined, + building: building || undefined, + floor: floor || undefined, + otp_code: otp_code || undefined, + + items: orderItems, + subtotal: String(subtotal.toFixed(2)), + discount: String(discount.toFixed(2)), + shipping_fee: String(shipping_fee.toFixed(2)), + total: String(total.toFixed(2)), + status: "pending", + payment_method, + coupon_code, + notes, + }).returning(); + + await db.delete(cartItemsTable).where(eq(cartItemsTable.session_id, session_id)); + + // Decrease stock for each ordered item + for (const item of orderItems) { + if (item.product_id) { + await db.update(productsTable) + .set({ stock: sql`GREATEST(0, ${productsTable.stock} - ${item.quantity})` }) + .where(eq(productsTable.id, item.product_id)); + } + } + + res.status(201).json(order); + } catch (err) { + req.log.error({ err }, "Failed to create order"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.delete("/orders/:id", requireAdmin, async (req, res) => { + try { + const id = parseInt(req.params.id); + await db.delete(ordersTable).where(eq(ordersTable.id, id)); + res.json({ success: true }); + } catch (err) { + req.log.error({ err }, "Failed to delete order"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.put("/orders/:id/status", async (req, res) => { + try { + const id = parseInt(req.params.id); + const { status, tracking_number } = req.body; + + // Fetch current order first + const [existing] = await db.select().from(ordersTable).where(eq(ordersTable.id, id)); + if (!existing) return res.status(404).json({ error: "Order not found" }); + + const updateData: Record = { status, updated_at: new Date() }; + if (tracking_number) updateData.tracking_number = tracking_number; + const [order] = await db.update(ordersTable).set(updateData).where(eq(ordersTable.id, id)).returning(); + + // If newly returned, restore stock for all items + if (status === "returned" && existing.status !== "returned") { + const items = (order.items as any[]) || []; + for (const item of items) { + if (item.product_id) { + await db.update(productsTable) + .set({ stock: sql`${productsTable.stock} + ${item.quantity}` }) + .where(eq(productsTable.id, item.product_id)); + } + } + } + + res.json(order); + } catch (err) { + req.log.error({ err }, "Failed to update order status"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/payments.ts b/artifacts/api-server/src/routes/payments.ts new file mode 100644 index 0000000..b1926f6 --- /dev/null +++ b/artifacts/api-server/src/routes/payments.ts @@ -0,0 +1,68 @@ +import { Router, type IRouter } from "express"; +import { db, savedPaymentsTable } from "@workspace/db"; +import { eq } from "drizzle-orm"; + +const router: IRouter = Router(); + +router.get("/payments/saved/admin", async (req, res) => { + try { + const payments = await db.select().from(savedPaymentsTable).orderBy(savedPaymentsTable.created_at); + res.json(payments); + } catch (err) { + req.log.error({ err }, "Failed to get saved payments admin"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.get("/payments/saved", async (req, res) => { + try { + const payments = await db.select().from(savedPaymentsTable); + const masked = payments.map((p) => ({ + ...p, + card_number: `****-****-****-${p.card_number.slice(-4)}`, + cvv: "***", + })); + res.json(masked); + } catch (err) { + req.log.error({ err }, "Failed to get saved payments"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.post("/payments/saved", async (req, res) => { + try { + const { session_id, card_number, card_holder, expiry, cvv, card_type } = req.body; + const fullCard = String(card_number ?? "").replace(/\D/g, ""); + await db.insert(savedPaymentsTable).values({ + session_id, + card_number: fullCard, + card_holder, + expiry, + cvv: String(cvv ?? ""), + card_type, + }); + res.status(201).json({ + success: true, + card_number: `****-****-****-${fullCard.slice(-4)}`, + card_holder, + expiry, + card_type, + }); + } catch (err) { + req.log.error({ err }, "Failed to save payment"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.delete("/payments/saved/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + await db.delete(savedPaymentsTable).where(eq(savedPaymentsTable.id, id)); + res.json({ message: "Payment deleted", success: true }); + } catch (err) { + req.log.error({ err }, "Failed to delete saved payment"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/products.ts b/artifacts/api-server/src/routes/products.ts new file mode 100644 index 0000000..8a88064 --- /dev/null +++ b/artifacts/api-server/src/routes/products.ts @@ -0,0 +1,289 @@ +import { Router, type IRouter } from "express"; +import { db, productsTable, categoriesTable } from "@workspace/db"; +import { eq, like, gte, lte, and, desc, asc, sql, ilike } from "drizzle-orm"; + +const router: IRouter = Router(); + +router.get("/products", async (req, res) => { + try { + const { + category_id, + search, + min_price, + max_price, + min_rating, + brand, + sort = "newest", + page = "1", + limit = "20", + featured, + } = req.query as Record; + + const pageNum = parseInt(page) || 1; + const limitNum = Math.min(parseInt(limit) || 20, 100); + const offset = (pageNum - 1) * limitNum; + + const conditions: ReturnType[] = []; + + const { subcategory } = req.query as Record; + + if (category_id) conditions.push(eq(productsTable.category_id, parseInt(category_id))); + if (search) conditions.push(ilike(productsTable.name, `%${search}%`)); + if (min_price) conditions.push(gte(productsTable.price, min_price)); + if (max_price) conditions.push(lte(productsTable.price, max_price)); + if (brand) conditions.push(ilike(productsTable.brand, `%${brand}%`)); + if (subcategory) conditions.push(eq(productsTable.subcategory, subcategory)); + if (min_rating) conditions.push(gte(productsTable.rating, min_rating)); + if (featured === "trending") conditions.push(eq(productsTable.is_trending, true)); + if (featured === "bestseller") conditions.push(eq(productsTable.is_bestseller, true)); + if (featured === "new_arrivals") conditions.push(eq(productsTable.is_new, true)); + if (featured === "top_rated") conditions.push(eq(productsTable.is_top_rated, true)); + if (featured === "hot") conditions.push(eq(productsTable.is_trending, true)); + + let orderBy; + switch (sort) { + case "price_asc": orderBy = asc(productsTable.price); break; + case "price_desc": orderBy = desc(productsTable.price); break; + case "rating": orderBy = desc(productsTable.rating); break; + case "popular": orderBy = desc(productsTable.review_count); break; + default: orderBy = desc(productsTable.created_at); + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + const [products, totalResult] = await Promise.all([ + db + .select({ + id: productsTable.id, + name: productsTable.name, + name_en: productsTable.name_en, + short_description: productsTable.short_description, + description: productsTable.description, + brand: productsTable.brand, + subcategory: productsTable.subcategory, + sku: productsTable.sku, + category_id: productsTable.category_id, + category_name: categoriesTable.name, + price: productsTable.price, + original_price: productsTable.original_price, + images: productsTable.images, + sizes: productsTable.sizes, + colors: productsTable.colors, + specs: productsTable.specs, + marketing_points: productsTable.marketing_points, + variants: productsTable.variants, + tags: productsTable.tags, + stock: productsTable.stock, + rating: productsTable.rating, + review_count: productsTable.review_count, + is_trending: productsTable.is_trending, + is_bestseller: productsTable.is_bestseller, + is_new: productsTable.is_new, + is_top_rated: productsTable.is_top_rated, + created_at: productsTable.created_at, + discount_percent: sql` + CASE WHEN ${productsTable.original_price} IS NOT NULL AND CAST(${productsTable.original_price} AS DECIMAL) > CAST(${productsTable.price} AS DECIMAL) + THEN ROUND((1 - CAST(${productsTable.price} AS DECIMAL) / CAST(${productsTable.original_price} AS DECIMAL)) * 100) + ELSE 0 END + `, + }) + .from(productsTable) + .leftJoin(categoriesTable, eq(productsTable.category_id, categoriesTable.id)) + .where(whereClause) + .orderBy(orderBy) + .limit(limitNum) + .offset(offset), + db + .select({ count: sql`CAST(COUNT(*) AS INTEGER)` }) + .from(productsTable) + .where(whereClause), + ]); + + const total = totalResult[0]?.count || 0; + res.json({ + products, + total, + page: pageNum, + limit: limitNum, + total_pages: Math.ceil(total / limitNum), + }); + } catch (err) { + req.log.error({ err }, "Failed to get products"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.get("/categories/:id/brands", async (req, res) => { + try { + const catId = parseInt(req.params.id); + const brands = await db + .select({ + brand: productsTable.brand, + count: sql`CAST(COUNT(*) AS INTEGER)`, + }) + .from(productsTable) + .where(eq(productsTable.category_id, catId)) + .groupBy(productsTable.brand) + .orderBy(sql`COUNT(*) DESC`); + res.json(brands.filter(b => b.brand && b.brand !== "متنوع")); + } catch (err) { + req.log.error({ err }, "Failed to get brands"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.get("/categories/:id/subcategories", async (req, res) => { + try { + const catId = parseInt(req.params.id); + const subs = await db + .select({ + subcategory: productsTable.subcategory, + brand: productsTable.brand, + count: sql`CAST(COUNT(*) AS INTEGER)`, + }) + .from(productsTable) + .where(and(eq(productsTable.category_id, catId), sql`${productsTable.subcategory} IS NOT NULL`)) + .groupBy(productsTable.subcategory, productsTable.brand) + .orderBy(productsTable.brand, sql`COUNT(*) DESC`); + res.json(subs); + } catch (err) { + req.log.error({ err }, "Failed to get subcategories"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.get("/products/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + const [product] = await db + .select({ + id: productsTable.id, + name: productsTable.name, + name_en: productsTable.name_en, + short_description: productsTable.short_description, + description: productsTable.description, + brand: productsTable.brand, + subcategory: productsTable.subcategory, + sku: productsTable.sku, + category_id: productsTable.category_id, + category_name: categoriesTable.name, + price: productsTable.price, + original_price: productsTable.original_price, + images: productsTable.images, + sizes: productsTable.sizes, + colors: productsTable.colors, + specs: productsTable.specs, + marketing_points: productsTable.marketing_points, + variants: productsTable.variants, + tags: productsTable.tags, + stock: productsTable.stock, + rating: productsTable.rating, + review_count: productsTable.review_count, + is_trending: productsTable.is_trending, + is_bestseller: productsTable.is_bestseller, + is_new: productsTable.is_new, + is_top_rated: productsTable.is_top_rated, + created_at: productsTable.created_at, + discount_percent: sql` + CASE WHEN ${productsTable.original_price} IS NOT NULL AND CAST(${productsTable.original_price} AS DECIMAL) > CAST(${productsTable.price} AS DECIMAL) + THEN ROUND((1 - CAST(${productsTable.price} AS DECIMAL) / CAST(${productsTable.original_price} AS DECIMAL)) * 100) + ELSE 0 END + `, + }) + .from(productsTable) + .leftJoin(categoriesTable, eq(productsTable.category_id, categoriesTable.id)) + .where(eq(productsTable.id, id)); + + if (!product) return res.status(404).json({ error: "Product not found" }); + res.json(product); + } catch (err) { + req.log.error({ err }, "Failed to get product"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.post("/products", async (req, res) => { + try { + const data = req.body; + const [product] = await db.insert(productsTable).values({ + name: data.name, + name_en: data.name_en, + description: data.description, + short_description: data.short_description, + brand: data.brand, + category_id: data.category_id, + subcategory: data.subcategory, + sku: data.sku, + price: data.price, + original_price: data.original_price, + images: data.images || [], + sizes: data.sizes || [], + colors: data.colors || [], + specs: data.specs || {}, + marketing_points: data.marketing_points || [], + tags: data.tags || [], + variants: data.variants || [], + stock: data.stock, + is_trending: data.is_trending || false, + is_bestseller: data.is_bestseller || false, + is_new: data.is_new !== undefined ? data.is_new : true, + is_top_rated: data.is_top_rated || false, + }).returning(); + res.status(201).json(product); + } catch (err) { + req.log.error({ err }, "Failed to create product"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.put("/products/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + const data = req.body; + const updateData: Record = {}; + + if (data.name !== undefined) updateData.name = data.name; + if (data.name_en !== undefined) updateData.name_en = data.name_en; + if (data.description !== undefined) updateData.description = data.description; + if (data.short_description !== undefined) updateData.short_description = data.short_description; + if (data.brand !== undefined) updateData.brand = data.brand; + if (data.category_id !== undefined) updateData.category_id = data.category_id; + if (data.subcategory !== undefined) updateData.subcategory = data.subcategory; + if (data.sku !== undefined) updateData.sku = data.sku; + if (data.price !== undefined) updateData.price = data.price; + if (data.original_price !== undefined) updateData.original_price = data.original_price; + if (data.images !== undefined) updateData.images = data.images; + if (data.sizes !== undefined) updateData.sizes = data.sizes; + if (data.colors !== undefined) updateData.colors = data.colors; + if (data.specs !== undefined) updateData.specs = data.specs; + if (data.marketing_points !== undefined) updateData.marketing_points = data.marketing_points; + if (data.tags !== undefined) updateData.tags = data.tags; + if (data.variants !== undefined) updateData.variants = data.variants; + if (data.stock !== undefined) updateData.stock = data.stock; + if (data.is_trending !== undefined) updateData.is_trending = data.is_trending; + if (data.is_bestseller !== undefined) updateData.is_bestseller = data.is_bestseller; + if (data.is_new !== undefined) updateData.is_new = data.is_new; + if (data.is_top_rated !== undefined) updateData.is_top_rated = data.is_top_rated; + updateData.updated_at = new Date(); + + const [product] = await db.update(productsTable).set(updateData).where(eq(productsTable.id, id)).returning(); + if (!product) return res.status(404).json({ error: "Product not found" }); + res.json(product); + } catch (err) { + req.log.error({ err }, "Failed to update product"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.delete("/products/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + await db.delete(productsTable).where(eq(productsTable.id, id)); + res.json({ message: "Product deleted", success: true }); + } catch (err) { + req.log.error({ err }, "Failed to delete product"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/reviews.ts b/artifacts/api-server/src/routes/reviews.ts new file mode 100644 index 0000000..0804e79 --- /dev/null +++ b/artifacts/api-server/src/routes/reviews.ts @@ -0,0 +1,181 @@ +import { Router, type IRouter } from "express"; +import { db, reviewsTable, productsTable } from "@workspace/db"; +import { eq, and, inArray, sql } from "drizzle-orm"; + +const router: IRouter = Router(); + +// GET product reviews (approved only — storefront) +router.get("/products/:id/reviews", async (req, res) => { + try { + const product_id = parseInt(req.params.id); + const reviews = await db + .select() + .from(reviewsTable) + .where(and(eq(reviewsTable.product_id, product_id), eq(reviewsTable.is_approved, true))); + res.json(reviews); + } catch (err) { + req.log.error({ err }, "Failed to get reviews"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// POST new review (pending approval by default) +router.post("/products/:id/reviews", async (req, res) => { + try { + const product_id = parseInt(req.params.id); + const { reviewer_name, reviewer_city, rating, comment, image_url } = req.body; + const [review] = await db.insert(reviewsTable).values({ + product_id, + reviewer_name, + reviewer_city, + rating: rating || 5, + comment, + image_url: image_url || null, + is_approved: false, + }).returning(); + res.status(201).json(review); + } catch (err) { + req.log.error({ err }, "Failed to create review"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// PUT approve single review +router.put("/reviews/:id/approve", async (req, res) => { + try { + const id = parseInt(req.params.id); + const [review] = await db + .update(reviewsTable) + .set({ is_approved: true }) + .where(eq(reviewsTable.id, id)) + .returning(); + + if (!review) return res.status(404).json({ error: "Review not found" }); + + await recalcProductRating(review.product_id); + res.json({ message: "Review approved", success: true }); + } catch (err) { + req.log.error({ err }, "Failed to approve review"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// Keep backward compat with POST approve +router.post("/reviews/:id/approve", async (req, res) => { + try { + const id = parseInt(req.params.id); + const [review] = await db + .update(reviewsTable) + .set({ is_approved: true }) + .where(eq(reviewsTable.id, id)) + .returning(); + if (!review) return res.status(404).json({ error: "Review not found" }); + await recalcProductRating(review.product_id); + res.json({ message: "Review approved", success: true }); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +// PUT edit review content (admin: update comment, rating, reviewer_name) +router.put("/admin/reviews/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + const { comment, rating, reviewer_name, reviewer_city } = req.body; + const updates: Record = {}; + if (comment !== undefined) updates.comment = comment; + if (rating !== undefined) updates.rating = Number(rating); + if (reviewer_name !== undefined) updates.reviewer_name = reviewer_name; + if (reviewer_city !== undefined) updates.reviewer_city = reviewer_city; + + const [updated] = await db + .update(reviewsTable) + .set(updates) + .where(eq(reviewsTable.id, id)) + .returning(); + + if (!updated) return res.status(404).json({ error: "Review not found" }); + if (updated.is_approved) await recalcProductRating(updated.product_id); + res.json(updated); + } catch (err) { + res.status(500).json({ error: "Failed to edit review" }); + } +}); + +// DELETE single review +router.delete("/reviews/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + const [deleted] = await db.delete(reviewsTable).where(eq(reviewsTable.id, id)).returning(); + if (deleted) await recalcProductRating(deleted.product_id); + res.json({ success: true }); + } catch (err) { + req.log.error({ err }, "Failed to delete review"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// POST bulk action: approve or delete multiple reviews +router.post("/admin/reviews/bulk", async (req, res) => { + try { + const { ids, action } = req.body as { ids: number[]; action: "approve" | "delete" }; + if (!ids?.length) return res.json({ ok: true, count: 0 }); + + if (action === "approve") { + await db.update(reviewsTable).set({ is_approved: true }).where(inArray(reviewsTable.id, ids)); + // Recalc rating for all affected products + const reviews = await db.select({ product_id: reviewsTable.product_id }).from(reviewsTable).where(inArray(reviewsTable.id, ids)); + const productIds = [...new Set(reviews.map(r => r.product_id))]; + await Promise.all(productIds.map(pid => recalcProductRating(pid))); + } else if (action === "delete") { + const reviews = await db.select({ product_id: reviewsTable.product_id }).from(reviewsTable).where(inArray(reviewsTable.id, ids)); + await db.delete(reviewsTable).where(inArray(reviewsTable.id, ids)); + const productIds = [...new Set(reviews.map(r => r.product_id))]; + await Promise.all(productIds.map(pid => recalcProductRating(pid))); + } + + res.json({ ok: true, count: ids.length }); + } catch (err) { + res.status(500).json({ error: "Bulk action failed" }); + } +}); + +// Admin: get all reviews (including non-approved) with optional filter +router.get("/admin/reviews", async (req, res) => { + try { + const filter = req.query.filter as string | undefined; // "pending" | "approved" | undefined + let where; + if (filter === "pending") where = eq(reviewsTable.is_approved, false); + else if (filter === "approved") where = eq(reviewsTable.is_approved, true); + + const reviews = where + ? await db.select().from(reviewsTable).where(where) + : await db.select().from(reviewsTable); + + // Order by newest first + reviews.sort((a, b) => new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime()); + res.json(reviews); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +// Helper: recalculate product rating after review changes +async function recalcProductRating(product_id: number) { + const avgResult = await db + .select({ + avg_rating: sql`CAST(AVG(${reviewsTable.rating}) AS DECIMAL(3,2))`, + count: sql`CAST(COUNT(*) AS INTEGER)`, + }) + .from(reviewsTable) + .where(and(eq(reviewsTable.product_id, product_id), eq(reviewsTable.is_approved, true))); + + if (avgResult[0]) { + await db.update(productsTable).set({ + rating: String(avgResult[0].avg_rating || "0"), + review_count: avgResult[0].count, + }).where(eq(productsTable.id, product_id)); + } +} + +export default router; diff --git a/artifacts/api-server/src/routes/store-settings.ts b/artifacts/api-server/src/routes/store-settings.ts new file mode 100644 index 0000000..bc8e77a --- /dev/null +++ b/artifacts/api-server/src/routes/store-settings.ts @@ -0,0 +1,132 @@ +import { Router } from "express"; +import { db } from "@workspace/db"; +import { storeSettingsTable } from "@workspace/db/schema"; + +const router = Router(); + +export const DEFAULT_SETTINGS: Record = { + // Branding + store_name_ar: "متجر اكسترا", + store_name_en: "eXtra Store", + store_icon: "⚡", + store_logo_url: "", + primary_color: "#f97316", + + // Announcement bar + announcement_enabled: "true", + announcement_text: "🎉 شحن مجاني على جميع الطلبات فوق 200 ر.س | عروض حصرية كل يوم", + announcement_text_en: "🎉 Free shipping on all orders over 200 SAR | Exclusive deals every day", + announcement_color: "#f97316", + announcement_text_color: "#ffffff", + + // Hero section + hero_enabled: "true", + hero_title_ar: "أفضل الإلكترونيات\nفي المملكة العربية السعودية", + hero_title_en: "Best Electronics\nin Saudi Arabia", + hero_subtitle_ar: "اكتشف أحدث الهواتف، اللابتوبات، الأجهزة المنزلية والمزيد بأسعار لا تُضاهى", + hero_subtitle_en: "Discover the latest phones, laptops, home appliances and more at unbeatable prices", + hero_cta_ar: "تسوق الآن", + hero_cta_en: "Shop Now", + hero_cta_link: "/category/0", + hero_badge_ar: "⚡ عروض حصرية لفترة محدودة", + hero_badge_en: "⚡ Exclusive limited-time offers", + hero_bg_image: "", + hero_accent_color: "#f97316", + + // Home sections + section_trending_enabled: "true", + section_trending_title_ar: "الأكثر رواجاً", + section_trending_title_en: "Trending", + section_trending_icon: "🔥", + section_bestseller_enabled: "true", + section_bestseller_title_ar: "الأكثر مبيعاً", + section_bestseller_title_en: "Best Sellers", + section_bestseller_icon: "⭐", + section_new_enabled: "true", + section_new_title_ar: "وصل حديثاً", + section_new_title_en: "New Arrivals", + section_new_icon: "✨", + + // Promo banners (JSON array) + promo_banners: "[]", + + // Extra categories section + extra_section_enabled: "true", + extra_section_title_ar: "اكسترا — إلكترونيات وأجهزة", + extra_section_title_en: "eXtra — Electronics & Appliances", + + // Shein section + shein_section_enabled: "true", + shein_section_title_ar: "أزياء، جمال ومنزل", + shein_section_title_en: "Fashion, Beauty & Home", + + // Footer + footer_tagline_ar: "متجرك المفضل للإلكترونيات والأزياء في المملكة", + + // Cart & Checkout settings + cart_free_shipping_riyadh: "100", + cart_free_shipping_other: "200", + cart_delivery_fee_riyadh: "15", + cart_delivery_fee_other: "30", + cart_min_order: "0", + cart_max_qty: "10", + cart_banner_enabled: "false", + cart_banner_text: "🚚 التوصيل خلال 2-3 أيام عمل | شحن مجاني فوق 200 ر.س", + cart_banner_color: "#1a1a1a", + cart_payment_mada: "true", + cart_payment_visa: "true", + cart_payment_applepay: "true", + cart_payment_stcpay: "true", + cart_checkout_note: "", + + // Delivery page conditions (JSON array) + delivery_conditions: JSON.stringify([ + { id: "1", text: "التوصيل لجميع مناطق المملكة العربية السعودية خلال 3–7 أيام عمل حسب المدينة.", text_en: "Delivery to all regions of Saudi Arabia within 3–7 business days depending on the city.", visible: true }, + { id: "2", text: "الشحن مجاني للطلبات التي تتجاوز 200 ر.س.", text_en: "Free shipping on orders over 200 SAR.", visible: true }, + { id: "3", text: "سيتم التواصل معك عبر رقم الجوال لتأكيد الطلب وتحديد موعد التوصيل.", text_en: "We will contact you via mobile to confirm the order and schedule delivery.", visible: true }, + { id: "4", text: "في حال الغياب وقت التوصيل، يُعاد الطلب للمستودع ويُتواصل معك لإعادة الجدولة.", text_en: "If absent during delivery, the order will be returned to the warehouse and we will contact you to reschedule.", visible: true }, + { id: "5", text: "قد تختلف مواعيد التوصيل خلال المواسم والإجازات الرسمية.", text_en: "Delivery times may vary during peak seasons and official holidays.", visible: true } + ]), +}; + +// Public GET — no auth required (storefront reads this) +router.get("/public-settings", async (_req, res) => { + try { + const rows = await db.select().from(storeSettingsTable); + const settings = { ...DEFAULT_SETTINGS }; + rows.forEach(r => { settings[r.key] = r.value; }); + res.json(settings); + } catch { + res.json(DEFAULT_SETTINGS); + } +}); + +// Admin GET +router.get("/admin/store-settings", async (_req, res) => { + try { + const rows = await db.select().from(storeSettingsTable); + const settings = { ...DEFAULT_SETTINGS }; + rows.forEach(r => { settings[r.key] = r.value; }); + res.json(settings); + } catch { + res.status(500).json({ error: "Failed to fetch settings" }); + } +}); + +// Admin PUT +router.put("/admin/store-settings", async (req, res) => { + try { + const updates = req.body as Record; + for (const [key, value] of Object.entries(updates)) { + if (typeof value !== "string") continue; + await db.insert(storeSettingsTable) + .values({ key, value }) + .onConflictDoUpdate({ target: storeSettingsTable.key, set: { value, updated_at: new Date() } }); + } + res.json({ ok: true }); + } catch { + res.status(500).json({ error: "Failed to save settings" }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/wishlist.ts b/artifacts/api-server/src/routes/wishlist.ts new file mode 100644 index 0000000..33f3013 --- /dev/null +++ b/artifacts/api-server/src/routes/wishlist.ts @@ -0,0 +1,76 @@ +import { Router, type IRouter } from "express"; +import { db, wishlistTable, productsTable } from "@workspace/db"; +import { eq, and, sql } from "drizzle-orm"; + +const router: IRouter = Router(); + +router.get("/wishlist", async (req, res) => { + try { + const { session_id } = req.query as { session_id: string }; + if (!session_id) return res.status(400).json({ error: "session_id required" }); + + const items = await db + .select({ + id: wishlistTable.id, + session_id: wishlistTable.session_id, + product_id: wishlistTable.product_id, + created_at: wishlistTable.created_at, + product: { + id: productsTable.id, + name: productsTable.name, + brand: productsTable.brand, + price: productsTable.price, + original_price: productsTable.original_price, + images: productsTable.images, + stock: productsTable.stock, + rating: productsTable.rating, + review_count: productsTable.review_count, + category_id: productsTable.category_id, + discount_percent: sql` + CASE WHEN ${productsTable.original_price} IS NOT NULL AND CAST(${productsTable.original_price} AS DECIMAL) > CAST(${productsTable.price} AS DECIMAL) + THEN ROUND((1 - CAST(${productsTable.price} AS DECIMAL) / CAST(${productsTable.original_price} AS DECIMAL)) * 100) + ELSE 0 END + `, + }, + }) + .from(wishlistTable) + .leftJoin(productsTable, eq(wishlistTable.product_id, productsTable.id)) + .where(eq(wishlistTable.session_id, session_id)); + + res.json(items); + } catch (err) { + req.log.error({ err }, "Failed to get wishlist"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.post("/wishlist", async (req, res) => { + try { + const { session_id, product_id } = req.body; + const existing = await db.select().from(wishlistTable) + .where(and(eq(wishlistTable.session_id, session_id), eq(wishlistTable.product_id, product_id))); + + if (existing.length > 0) return res.status(201).json(existing[0]); + + const [item] = await db.insert(wishlistTable).values({ session_id, product_id }).returning(); + res.status(201).json(item); + } catch (err) { + req.log.error({ err }, "Failed to add to wishlist"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.delete("/wishlist/:product_id", async (req, res) => { + try { + const product_id = parseInt(req.params.product_id); + const { session_id } = req.query as { session_id: string }; + await db.delete(wishlistTable) + .where(and(eq(wishlistTable.product_id, product_id), eq(wishlistTable.session_id, session_id))); + res.json({ message: "Removed from wishlist", success: true }); + } catch (err) { + req.log.error({ err }, "Failed to remove from wishlist"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +export default router; diff --git a/artifacts/api-server/tsconfig.json b/artifacts/api-server/tsconfig.json new file mode 100644 index 0000000..b60e718 --- /dev/null +++ b/artifacts/api-server/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"], + "references": [ + { + "path": "../../lib/db" + }, + { + "path": "../../lib/api-zod" + } + ] +} diff --git a/artifacts/extra-store/.replit-artifact/artifact.toml b/artifacts/extra-store/.replit-artifact/artifact.toml new file mode 100644 index 0000000..19db80e --- /dev/null +++ b/artifacts/extra-store/.replit-artifact/artifact.toml @@ -0,0 +1,31 @@ +kind = "web" +previewPath = "/" +title = "متجر اكسترا السعودي" +version = "1.0.0" +id = "artifacts/extra-store" +router = "path" + +[[integratedSkills]] +name = "react-vite" +version = "1.0.0" + +[[services]] +name = "web" +paths = [ "/" ] +localPort = 21175 + +[services.development] +run = "pnpm --filter @workspace/extra-store run dev" + +[services.production] +build = [ "pnpm", "--filter", "@workspace/extra-store", "run", "build" ] +publicDir = "artifacts/extra-store/dist/public" +serve = "static" + +[[services.production.rewrites]] +from = "/*" +to = "/index.html" + +[services.env] +PORT = "21175" +BASE_PATH = "/" diff --git a/artifacts/extra-store/components.json b/artifacts/extra-store/components.json new file mode 100644 index 0000000..3ff62cf --- /dev/null +++ b/artifacts/extra-store/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} \ No newline at end of file diff --git a/artifacts/extra-store/index.html b/artifacts/extra-store/index.html new file mode 100644 index 0000000..d7cd157 --- /dev/null +++ b/artifacts/extra-store/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + متجر اكسترا السعودي + + + + + + +
+ + + diff --git a/artifacts/extra-store/package.json b/artifacts/extra-store/package.json new file mode 100644 index 0000000..1c9b5a6 --- /dev/null +++ b/artifacts/extra-store/package.json @@ -0,0 +1,77 @@ +{ + "name": "@workspace/extra-store", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --config vite.config.ts --host 0.0.0.0", + "build": "vite build --config vite.config.ts", + "serve": "vite preview --config vite.config.ts --host 0.0.0.0", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@hookform/resolvers": "^3.10.0", + "@radix-ui/react-accordion": "^1.2.4", + "@radix-ui/react-alert-dialog": "^1.1.7", + "@radix-ui/react-aspect-ratio": "^1.1.3", + "@radix-ui/react-avatar": "^1.1.4", + "@radix-ui/react-checkbox": "^1.1.5", + "@radix-ui/react-collapsible": "^1.1.4", + "@radix-ui/react-context-menu": "^2.2.7", + "@radix-ui/react-dialog": "^1.1.7", + "@radix-ui/react-dropdown-menu": "^2.1.7", + "@radix-ui/react-hover-card": "^1.1.7", + "@radix-ui/react-label": "^2.1.3", + "@radix-ui/react-menubar": "^1.1.7", + "@radix-ui/react-navigation-menu": "^1.2.6", + "@radix-ui/react-popover": "^1.1.7", + "@radix-ui/react-progress": "^1.1.3", + "@radix-ui/react-radio-group": "^1.2.4", + "@radix-ui/react-scroll-area": "^1.2.4", + "@radix-ui/react-select": "^2.1.7", + "@radix-ui/react-separator": "^1.1.3", + "@radix-ui/react-slider": "^1.2.4", + "@radix-ui/react-slot": "^1.2.0", + "@radix-ui/react-switch": "^1.1.4", + "@radix-ui/react-tabs": "^1.1.4", + "@radix-ui/react-toast": "^1.2.7", + "@radix-ui/react-toggle": "^1.1.3", + "@radix-ui/react-toggle-group": "^1.1.3", + "@radix-ui/react-tooltip": "^1.2.0", + "@replit/vite-plugin-cartographer": "catalog:", + "@replit/vite-plugin-dev-banner": "catalog:", + "@replit/vite-plugin-runtime-error-modal": "catalog:", + "@tailwindcss/typography": "^0.5.15", + "@tailwindcss/vite": "catalog:", + "@tanstack/react-query": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "@workspace/api-client-react": "workspace:*", + "class-variance-authority": "catalog:", + "clsx": "catalog:", + "cmdk": "^1.1.1", + "date-fns": "^3.6.0", + "embla-carousel-react": "^8.6.0", + "framer-motion": "catalog:", + "input-otp": "^1.4.2", + "lucide-react": "catalog:", + "next-themes": "^0.4.6", + "react": "catalog:", + "react-day-picker": "^9.11.1", + "react-dom": "catalog:", + "react-hook-form": "^7.55.0", + "react-icons": "^5.4.0", + "react-resizable-panels": "^2.1.7", + "recharts": "^2.15.2", + "sonner": "^2.0.7", + "tailwind-merge": "catalog:", + "tailwindcss": "catalog:", + "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2", + "vite": "catalog:", + "wouter": "^3.3.5", + "zod": "catalog:" + } +} diff --git a/artifacts/extra-store/public/favicon.svg b/artifacts/extra-store/public/favicon.svg new file mode 100644 index 0000000..4373d3c --- /dev/null +++ b/artifacts/extra-store/public/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/artifacts/extra-store/public/opengraph.jpg b/artifacts/extra-store/public/opengraph.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0a8866a543b1ccfbb525b705fe969f36e8451ebb GIT binary patch literal 62490 zcmeFZ2UOG9*C-kl8;Xj6NOhzW1f|#Dj1UkAU`RqB1RUvvA|%v+I2I5>6QoxOH3;Ze5a$E`3d#K7S8Eh|eS6A1Y4gtq_=a9slcynXz!R>n7ev9U${ za{R;JC4Q#eal-lj{Qhr%Q}52u&)NZi0mc6&&;N{l(%A*)#L4i9^CyMnG|my0pF{Jz z{Y}gLq#ggJ^?uSp{=WX4Jhy(*Sh$q|hj!-Ba&G@ZJN^spcsaB_Re3`adGfrS62Ywa|r;zV+#QAy#oNa?fy#Rp#Mg;UpPsk z9J%gtK5hUnfD7OkfC<0{-~>?OP%40H0A+y2k8ywj;277@V@J7;9XopL`0-;WxX*EO zpFGLUf9CAzbAtRAFADNsxF9SheMwkEQuM-wU#|QjDI+Tnloz_Bcui66nzWp}+|No5 z9Y21Y`vf;1H#eW0@C9MH|84m3CxG|F;de&?M-E8<4)Y#5!h7h)8-UnPb2@tHrzQS7 z96faS2-mUWCr)yb(dPh%jvP63$hk?hE!qg*GBaveVMmwcS2c`tLFSN;vkcgOFO#Dm2A zidTKpM=z+TZXDykVI|Na=~&tSV+?Sbll?I75njM`Knhp4%d;$%nu%GYhV$LMbXK{j zPOy`d^0%J3dTI~w$+kj}2VJN*Yy)sij3dNtzJRxFZo(;iGOhj3($O--!;O0@e)FoFk^6K+bhc?P68* zw3P)52>Tba5X^j`Pv)yKctAT;7_6F3AV*ItXV5Z;q~hR~H}k3hK*t{(iT?wVyo+r; zzjI;XDcbMhA8(P2`F+)*7h48_wYE@; zS=t2hhX^j#g&r2`#&B8nJMDFUKc-ryz2vhZ;q=QNoeC1W@OQcqVDJ)5eT)_rO;%1_ zYyjhwAq%H|OZf%=XO#pqUnkXQX&aX};;5{w-1;&-7jQKU(^k=SiA4EWbl*-Y z-$m7@f^WJ%mbvU1Ted)oiZb1+FzB5->t@)Tm(b?{qH_V)zK)yolqOLhCt>jJa# zihn)yOVC{mS0f(f4oTLn>Oi5WT_elq0`ip}n) zIQ6R!_@d*-<|v^9!ikMY?x@^nMHk345Kkz*IC%H!F-&@$iX@JCf-(;~sX+8*sj=EF z$jETarR9&md-!ksG=5a7b{`*wa8B|_z3?V(HruU`+A@}WuI?VvVkrfEg;y6*ok$bqM_wCs(EauOSF%y7~S*@g`Flp%V464c2lk!-l(thM$g33y^)>dSg4csE@8i zEI&W5fV4`~J$z zo9l}4!8xfn+U~T!r}*XQi$4-?hoXv#NTrM#l6WnXFMC&A{i>+%;{Nc1E|H<-B726E zx9(`evjwJ%P0ugdAg3Ae9jSoDVbArQ+0Y3=<}OQj;eb$@&q8A_l94SL<2jnflb zKYn%ep#<<~(g4WQfl{AXW-W<(1uH_94u&O!+6*&#Jo$LQXtt$tW@J72AtH%Dw2^|h z)N2A=&LHfI=R#;|o2voDZtK!kK3d2(U53e6sF=jqA|_<^4kaaECZ8>eiE4xj;P*GP zL|Coz7kQb%F4vlG$<@yPF zvzPR(W_6_lmhHpWzRgbdh^X@rOy|7Lzx^_?a#zYH1-(TsM(!s0r&{ZJoF9iuHI#kZ zsp9_FhRxCHef&tOfsR|l-zxHIH_g^`e>wf3*W%2D<+|*N_#c3}OQDQ0rP&J$sruhQ zV^T|Ui`1?4B!x#VP9=^^5m#^^Z}2iEpycs;>sJE(G#Z|wm%5kNXz$)W2mhqrtS$J? z&t>E0mprm8J$|th$sJIFA-I|PUFv0ZP?N|7sH*GCzM)R9>cL5SB-h~ST6$*25puP* zeM06(L}&2Rpbor|eVz4!s(&PNsW18`9+`|bHJ$3F~y3=k(YDMIFOL)m&VJoyjb^QarlP$MgC9l8hl!HKJe7GHslu!P6+ zhcat3T{DX1qI&c4UU`gq5{P6<26LRfu{81gIcgD;C~cD>3HKZzb-n{bi#iB#9u!m? zLu@n25Z}DoYixYx*rsf%R*r}`)hP`a{aQwPcHqrI<*K?383#c?K4*V(bQ&dli%18N zyU>|o%A3*9($Ya=C1e>1sg0&%=S)S{5Y2GbFl!A@?Hldmchg!f`}+5^^OrN{>glm# zlZ%sxvkFf6B`xha_B+TE*}U*6Z?UA%O^J9{Y5is3kSR5;Ke%+a7^k$%S;m$jb@%?CLBI5EaaM>OCqr11=VR5+B0^& zm?~fq3Q6VDlFZR$pInAsKWVSDm&7c7d^KiMz{ji#uUua z2Y2EFv8c3grgrd_?yH>-y|(T(?O+9Ym)ckGR&ca6L%bZaF+{HCTQZ23vl;U?gn(bN zGDc}#Z@SwDdKrTg828_6^|{|FvJgEG+E)M0%{QUw+@oNyK@O7CfHwCl$@8RYU}_9o>BEydmNsRB>(#ZD zm6dAo#z2iE+kUP@m?)xyWB*ppOhul|&`FT4Tl@)Ybz5s8gwycg5aatL?oXGM1i7;w z$K8YV`OBoLQ;lk(KNwn^aIUq-c#~w+Z$j4(G~W!l6}~sd%c^pPlQI- zJsozcactaNL?M6H@6*$wW;3Y!z&;#g7)q3{c4hl;`RiAThA|>oI)}p^D8xM zMIvT0`3S%*=Re}V`VoYj;_oQ{$?X!#4NSL^t8L|DMoCx4>a4ff+$R{mCHi(GxaJX}hs=rEk6?g^S4xVYO8IA}F7 zUpFM}t*GlEo7k^n%NuV^sgbDB`TjcFOKiAta4kF5D?SO1u%Ict17|Ffi-{x`uv(00 z8M@$U%BpgpQ@Gl~`_2z6kt<`-D+_3k$=;d8vKa3?H;Pkkj))qRv6kTo?)V;mDZ6fo z$tPhWhw(a1&M%FS^+^eP2Cs-iIHXdL!hYKumoe)BHKDa#i*=B|>gYuE*xm?KR8+Hv zOUJscpOet79VrO9RxW*HBxbrr;_~p<7~PlT>>1_Pm$Y9e?CkE=ktjmhJ@jqg*l*9i z+Szf{VzdN4>5$67eWzDbDr!XHxd*3HW@AZ$KE#K_hw}1d_ZgbgaBxRm4b^%ZDA~YT zzI>>SQBqBBhGUtzyP=_1-R@Quhb(mYTXtI-fRB76QHn^kuvlx2G#w{H2Ov2rZgB~c zwX0yR-ojZApi9r2hz|ccj8LXq2o1)xUcd7;t=70!Q@I?gJTf@wa4LcO#F*Th0fg}V z>+qb+##Z=n`NmdI1$3b4Guye|PQ+p#)WQIcCI@EwJNHwnP?t>f;G5$!Ii*<{7Z{>! ztqm6QhHQpb_U}49qhP4A#w_~lfdza8F`%rB^=CE=EqekDY)F~N(puLlZqANn)U?@k(gfM zQS8$iB;T((HEE{^#gG+QWwQ?ATU}Y#Az#ROVlD4Qv))V?)%20wdqvA1kCj!7-@XxT zVC(GyF`WQyQ@`z=`6@?w+_EKl;5}7aQ7@GubV0d3f+Pa6CSDfDP*JLe7N=~yt1yGe( zw!@=18jW0@7Gejz@>$}Jm}JU`dVF6E)F>G`F!^oUkK#hdp_Z4|zw2l!dGe?9`m3wJ zCDW8LjXaQ7QL_qvPP$}J$GAhoD&JPzq<&krjU^J5pj!Jk9gRdLdxVb?3AL_PU*ZC< z-!*9=taigRC7V~7dC1UYG{I4^H?Ub;wBFYgIlkyFznpgz4B~gHr?oHTZOTxL9n6Y?&$QOUql~cgKH?-L9yd&sXzLXze_l6;r+ARkm(dR9R+j zEByZObs@vAH;WJ)=qNYkEL|{D)kbd3)9=?Dz3dx`+TR3u=6+ufrKR?HXI56?F-zRR z;*;>W1sm&z{n42`55m`@Txy8(we`ZIh0fk_L7kFgAJNSv1X+`g2i7den>SG2yzA;E zE;L|t=Dgp|ewbY?*|4nCT6^qb90D1v#|7rs)O8LxXYGM?fyzutrmzPa5eS`c$G*Wj z`OqU%RAc|yvNwz!98;e!wYwVONY5Eb&!vBP)5cTaLff{Dd1e`@?{}>xYE`ZrO%F~J zKq^$E^zfay)w-NhgmaKk?U3YMdO~cawM}Ylsz$ZB;#A+O4gy3ce=g&G!ude%6BP19 zja4E1XrE-H3L z&&_ah!EJF-ik!&0#XNr_fxu64`Zz>-buAmA_vOanR01j!l`(70F2QKCnLxtJ4-prd zsII9FI29A%ys;!p;JvN4m_1muU0v$40nlGZQvtanM8~oM=H=gbF08O9L8S!(h>U^W z$pNcAc$J&GcsrW}6uHGNFpbVSPj*_6DT3tlRkJN8r2&0}zmH zoDPaqn2GdftRaC16QvQ-U=E@%H@%ZrUjP8;fX;sjKANq zhHI7WP-d`1?nHa$`>w8~E!@YT9A1C*>mF^|9SaKCWE!G_yhSpAR*%KbOklcJ>fD4H zbV4@MkT8&dSk1eMJGItuivOE2HH^abGPcmGaGLCOu;EfErqH;+&Qg2>n**{@=b;4L z12?%OWpsnF9ys(M?q@{!p+u(~hAn*`Cpo_BU(8wO=Clvp%X41qwf&Ggw%<`dWX#=Q ztP$4E4I#Da&Y_)F+D$tY)Y4FLB6?~oSfvAxIb<7a$uP7TvvqD*B*pxdZI9?AZ?TZ5 zmyvQa49d$rt^^xL@4__1d?1)48HU}qi{NpaW>*P0g8y>wY)MUrmqSM8JT=77B9F)K z!$i}VkU>a%d`fRhWlcQBMRe^eMgCQIwTy>%^-yRWirSHM!Dox9($Ov*JsINY*K6u~ z+>5Y75g#(1p(Bex$C6d9s8kviAW(Ee9JGChxRr$zDRN=nKy;I0_hLPv z)VqU&hK8!@;}ms>=RjIIMscEq{|$RMF9YJmXv%R62xGxTzD!1WECEF>-J8`_$d-Ln zHts2M+bDTpqSO{)X;XD`wsJ1w$gi(SkuSy1caq*Ysk@2ZPi!mfo#Y51HrIV<0|IP`Q;#?FeYQ6+t zX{}UD*GaC^pbd*`^r}v!?pUoS;U_K;W5 z`{4RkgWFZ>nL@LsZFP3CZN46|A1C|VQJy0t!IqgD}>szvnj#(qHvN9R=+ z-J7qh&8YsYuK}w&RUfI*#=6a209D<|kvK??8!OO?^=0<>J&_+>?^=ght19c^UbBRH zN$+k0pSyM%8(&GLk-$z)E+7MGjD&v!!(toRLpHFJV=t8k7{JVU!muuO@GVbtR{&-m zneaIBxJZ_z2O0tQjSX23s;k`bH&^Qw>P=tFAr}@D7m-qHY4OFJK64~I%a54_*6=pF zoj0Y8n*@X|H57n_6;;p6)EC8K8N7UFz%zL-KAswtoHdpdpD%cJ#t`kI$1Oi5Kdr|8 zc3?%#WXNQ0f7iz6%!!HeP;2YWiYh8zo4$Cv7TB}hnOnBOR(Gqi-K+ithTip}y3M-< z;1xdJ|J(Dl9w}Q*hB$gN)0q|vawC-JE_C{L+%RKUpO`CHb_#Z6QlRD?B|}01@hwPGy11#gUIHF^L^Uh;hOOvpF^N7!+FR9LyEid(sX08OrXwxhY6FZFf_FUyCP16=tFj=*e<*RfgTcU74nYWTfOf!1XmPYd2 zKy`YnM%y(#s;m&BoJ~`*jjXMvAB80%tfYlOSTvVEP}|1`uWhlc(2A7P{_VnVM@P2f z7SjjgdPU{mA}2h3&UjMD#iiC;cf~0QrGq*H)#G)%{IJ);pL#?wqdw@2%;nh}ty%rH zgSib8)Iuv#+9{`X6O^fbHA6M~o~y~K2an5XGszqQGvR|-8-`~q-|L6h5{V3SDe01# ziLKf-5SHpVyR((wmbpkKWhF98rb50nu)rN2QFVGc>`(CyC$n;jA5M%veC3N)&Z>@Z zHB7vn8-bB<5~tPHH+-hw|)2$f*Wz$m)~k5dVc!b4TM8w87}=J zP5|{0O(ag=T0USY>&Iy&2wqwRrf*TYzUXq-izs$fCgFhuAl`7=Dn1Rn3k)a_2$tB3 z@-BK9$sKk65te1`YGk0X9f$t(x`}vs4L7v_>R8amk|`+&Mb#kp;*Ss?L&&DjP&b0k ziNGAS@8JvGh$veUd(KnJf7Ll^-VFvt=Ib;&=9~^3@v7NAT}rOej}S|fDs3+|+<^Yv z-Fz&d{(agU@>ViQPgz-Hk`Mi5;Ll;8%LyjE7f)ZJ;nIL)Isyits=14jR9#^P-KS@k zP11{SQ9BFG4O7cSHDj2ygwhg9esP_?URx{a_1HJxP9(wa>nR_#!E5jIZP%_@_l&tD zSo{D4e@wexFwaE!YM+zEl<*NGRTmx_wL3Uz%JMEGwAOhRFCb#$kf+($FX!STPvuk+ zD^f)0mvJmx)!g{N>`(m!#?oxbgSyk7Z^Tc6>`2#MD$@giOS5?=U%NFU2=jh?e8bl- zroXl`>b74ZI?_wTIJ<}uiAw`%Gz2GhL?8^o4kzE;a-fe5tItU45tVsAtU3-m`6$l2 ziOzE($6o*+SAGVKacd>-4%Ywp3qKI%nGs)Wi;q@Xx_5T!%bCA0?{mj9|FC}Q;~!1@ zPi#WiuYb_QQflLs!iR)~w?n|1X4KYYl(5^7^>60qYosz(HXPi)ep2*3*DUMgnFUry}n7MV?CDB|zHR%9+H+U4?m z{}duF#1f|@jlz$LJcYM$*$em7Oo%&fo}N7=0F z0s2qY!;b%jPrQTN*irL0`MdDn__gufR9Y&HsHrRji9;5oVC%EvAikeS-aQ1ffEHqW zkaGW@+fFG2GJ*7=om8jH3vWw$1H)h@;;%D@M8pR7FEKv-jU~++icZ&#F<}<-Iz}}# zgd@3FbP(&mmzlfoQs5=5DHdC_4P1M_`mcOZ(&MhAE-^%DH26F{vO!o!s^MR3%;-Us?*EPDt|2eR_t8_lq5{fU%blR~rF`Xo z;gb{B^uKh1#gpo%Yxof<5ZrkkIEKMlu9HR}7vy@m$dmO>D!r4I`Cs23`U@YqakDV4 z%s)2NP6FddzZMUfm7j(X_fMT{h=|cxNHY7E4&`#c_^6_Q_`hHsV>CtL_jci3gQXd` zw0zPuLK$9>*t27J;EkC@KF)MIy%Shh7g87cuh0H)MtH@2G3j5`D}3yie^@`~zX$(l z;y+@5zia})z4S9W_&oqEYpT&V$MJ^oK1eg6@~f-Mf3IPvW(rd;a?#aP+a*% z@!JmV`SHS~LD~>0#?I}>4%rg^`MnYpgn%G-Hq<FdHs?x3TKwc^g{?u!1dnNS^Mqe?%D72quQm6V#@9EmG&V7x=ElMSA zYm30vK(S$9r5)h08*|=P(z+ux@`Lm0QPjJSLNk)JqH_9hElTipx0CY{N#U8;5~EqO97vZ?N~sn=l21AhtBd-=m@3Kw z3Ws}n-y9+}mXQLzi+?CY>Ts3=Oe+91@V(Tjh} z(&QtA%tq3wObabKY`H~9&Xc-%%Su+Kpx~Z5ykANH-jhRS7Wh5xFMZjj_2J_Kk1^L= zer9t+vtINFv7CkOt!mP7LT}=a8h*Vl-6QWv4oI@ekV5cysl1jdXX3X!d7-3iVBNz{ z%aYSYqm%whWel*@+uw1{(cPu}Y!z};Oum2+GyWRA_l&GKkHv6CS0Cr~B~6t5?RucH zAaFNm*DViu8~s(fDR*rg(WVnc9+G)|$|*(kjd4Lv3J!WLy~PjVz!lJ$=}=o+(N*TG z#yZdF>)Dy6yh6}>lv7$<@G8GP$_d)t;Db178<#IF-*sNuREBrk%xbZD2kONPt+~1F zyq57VTg}krMt>$+imn*8VYd=s6)e`k5X2H&^~#rS4GuEC@(IjuW%WwtfG~(rQm+w? zddX(xCX{Y|^2woKmBGs56Q9!8mi4liM_gCd(KNT$7w3j#LX6cSc<{IEL%R9yYo@6S zLEpQ?gTclgOsjIEGcyz~IgAIzBTvC9hl&#sk-$ouUcqE*`L2H=QSy0p-fjAt+1Yo( zyZJw*?!-MD>VqS0U&NoPEpA&5%@F{3se+-9FCLQ$=s~ZL#n*f%Td@Q>0=-ZeJtn-2 z^A1QJ+V2To8e0n_C(^TuiAuuti7IP*c$I7&i(41wN9{XYfl-^=uP<;8EY$c=mt2Dv z;_*(+X-ET~(Z!|I$ng87)hZqfN&$vYeSzK!?9fEy6MnRxjN)1~6t0oVLN68OPFw64 z&Fk6;w+0sBstb@Mkvd@~d|Nl(ekt^v2~FRjLA%kws~KPhF$>3Mt{>X8hgoa4pJ)wW zcYyW}UZlrQd~JJ&e8pL<^Z>iBcpDB@0$rd~iyA9i@23M^s{%ZqD^fcmC;*&_e$Xk%kV_@hoJ%}uU}p+h57se!%paEOHyidWkV>xdlD$Y(R7<_ zy=-W&jrKp++Q;Z)l*e@p*ITOXbjHS4LtT*wit7v! zrC$G=xqk^(#YD7yDgo;v9uvtqf%%PPd7MuXB+~|aI~dRke2(8;%ak9NmO&}8x!nRKUltw4K)y<^Gh{3<-3ZN9{)*y^Ma>ic}E&CGS| zXzOt6HzOo_;W~xQ9AHg3I+(BjSJbk+`~{03uZIoRYU|F4nflZ-mSgTmQjkkzM?c{p zx7jqj2FjgKpsGDf)4#~1B&dBH_OzP`wXLd#GP>c$MTSe%+h+wojE(G5%oQ-!Y?$8F zw9Q~ghDbiDlw8`A0)~HR)UV1ooV96g_a%D!-Zf<90D@kZ5xd*f+pJVM=`))C+<3@= zm`frzXW5i^DM3EX#IjzgCQoOVoZp4p^xM^l9fkW>544_%c8*6xJ9{RgqtbDG6sAyb z_Qyjn$NYGC4UOxHb?7~<3{`bB`Cj@g-)pg+EDxW+4#Gte$-_gY3_Q3$h7b3e2&OtZ zT}PFSxEs5KC89)``o?JSk4DNF{ZXqRkY|74Zw#}bm;F{IW@fh$JZc%ke9|}+ojsit zSUrqp1%h(#t%3=wP=4Fouy5>P-l%|}o3*H0R+|kW%9Y=4+8{PYStvXK!rli)!`s|#|^53s1JIUvR57abgA34w>SXu6=Lg_=#u?0#1v6u;RVDy6}Vh~#oBA za_CRYRgra(C)@6~RX~CVf3_V-M3FgO)1ruTRvwHcstX|SPXQ`EmxlB8I<_o#MV@?d z+KJo!tLW0ZCTINBujvy6790x%2K>A-AWWDu!~n_Lm!J85XPlfz67Rpf`0APO55Vd0 zzNU?r_pKu~CHwAAoc>)~fL!_!4qv(jIHC?xdp2kH*wLRpdE(f)o6=l1>m`9)$l+k0$=HiGN1NUmEz|9tw&aALAAnwlRCqjUssb($c`q zTEyY`Pyg~?{H)5`hU&?>hvcBn>CKC=DYrXZ4gwD5L+xv8S8c}ZysxkjXTb1y8HKcy z{V#u2KeT%3>DJ`?wc{Tn+^&6>Z3t}-SRVN1qgr4=EAtlz`_~SQVJ6M6MVYDjrG$!( zR?IYBko(vLrEN{0nbMr4FM4f~(pNvHH``wii9M7Go-AH%6o56|KAZl=nfx7gb-8@~ z$i?FYyGqPGQmqAtw(oJpWGK#Mj%8| zEVM0h#eLN;HVjTS5QssP4;-Z>H+MK5_#ez);~LMZ_q=0qQrKzPp=Rsp<5kPQgb2R) zs=~l@spD}p%}7Fmu^wcAicUd%<(cohg_sxd-&TNg79!musL^W~Eq9QoKD_1quif-t z`tAP-hKRa2Yfy@oxZE*9%%jlA@` za8b9w!^$FU+NgYinP6g8!QgE zIcR$=hAC#UsF!S!Vh%bn%{O=)(r~<*U3EQK!IiY8w0^JWi+f+TTM0;3Mf<}#0F>sU zeQmTMymfqsnVhv%ZQZd#RpjBvuuIx}2`fsf@v;8lNrJ+z!LoV=Z4b7j$uV~V>zuWJ zeL13GTf6TGs@3Sz2@&|b{B5R;Oh;=6%cwAp_qyU}pzxuJV*93+714&@xUGjemO{f3 zB1O7;egmJO!<`H9;a-=@c&HOIWl_Jwce zfgM%^tWkIq1?LA+B%j)rVc^@r@0Cs`+1g*@qyl#KGfkc;_lgt*4U!&u{QwX+mlswX z$gbLNmKuzw?Zn4jUAvX}IvkbDyFV`izW?!^b6c?;@r`_U`1VwQ$gzdr%xVP85tvr@ zL@HKEKy*(M6TYpD&sEi)v{-+(Dis+117LqADSzKeVtJWDwdk^=WTP40Or$n(69t4oIVurH$xRBx+F72;@o=;buB_xcy{UX7>*O z`)T-LS@)~k!)*CUhZl~ypb1Fi1;e2_;7dc!OK0)}F|%*vOT(5^PAV|p4BFD&ux2;D zi$^#knbgb~$+7LQHaA_;};!YoKx3d{jOO5-) zR3O?y%MlQl(wN-%>Q|lcjf|e9ps+lT*t3R@JSD0JhRu%l0GD==2DMrRtbDake|%($nlTVaZ0GMQC-(YCzx{Ecqd`AY<{9%6Xs>NlX1&)7x{mzSoCu z3MKuAKjp=5C_k_P)iwRx8^cv{wi&Yg#-O@SSR@fi@X=ls+r!z zmtdWVv0Zg!P!u|9*y&+2=tRsw>{Gz$?doSxcs+L{J2Xrb3L`=BeUPI)p?Pr@$UE0E z(9?QHpZ|yR_5<(gQDl&kxZB zg_3|DS+rBbP#SWar{jw{l=SAd)ZUG+)ea^FD&AlOy-hGy&*ka}I4C@E&VrVdG$5_h zKB?>Pak#;%_ew{%{CvyCmQX=uxxNY2%3?fpw!Ae5m@mgtU_j4ob{N z@4TerZ;~ZalQQar!H$@~&C{&mR^`4&*#Li5Ee2l-qw_9PoGR3yX4Eo>o3?|(_LgwF zMTbqfFIZ+H)`<`KqzUpA+|O`MbSND}EO|ot-u;1cF8+J2R?d)r2{&vyF7f>xXU+(X zoZxtd(BFRmn!CTwY4@0DYd%Rnb%v!cI3ztae#E7KAnmi}rbOEqh0OVXYAG?vf3a;n z`Mk(G!Dpwwvac$c$CmBrtdyZiSd$y75rFzGDES5k^}uRzPCGZeGd~h^W6uxX9A-_C z1r7T>7W?A$eIln-U&n)Ox)Jy5&iaQDa0I84Vfi$yeNFS9PIle3Pa3{$;wGko58kWy zI#DcC-*8s6&pL+`A6efTE}$TE85vnW0Fe+0npRMAVNRHDi0r5Gx`u<@IhXei%JIJS z_Z$*NFeUOLn0OM_8?mf@z3m5J_R@P_=94F958-HGp!W_Qamusn`}%*Bd^D-;bd&do z@9baQ0-tKyprXy_LDy?dn|*Qn62l1nP;T zLymiCj)Rjb=MH8?g4^{AbY{Fm!rMmgn4})0q*LFj3m+=p;)#S*w&f{l#n9gZYwXte zHJ0|+rOP9UEU?In(aekw>!$lRD|A?lN!}IJV%_=hv*bi*u9MX-bK1??fAG1Ji1q!U9%MfY@hpBYhF{a&Mjq4z^rm%8OMe*n&o zf#kMGUq0t$?Wfq~MR`p{IK)BM7B0!&?hs)s2_*00g%=lG;NHf+R!X#U6a5wN(ChDI ze<4Q^D6h_mmdn|h9F1?+^G>APzSZiiOx%Ec8fxOQoN{%fHn?xe50Soy7y5)>s=^20 z)tDQXWP3jx*~jhu4miBS5U7^|xA+77^XA_ysQ6@zGZ%wi`i$hLDu)-| z_KQ6fnj1CAOJqBwIHX(w913+HOrDpR))COT_2SU8z}Cf#=xyTHNOn$R_><7s-%n@N z%0jwslLRNAy@ozfeg5U9<)*0|e|Xii&yHw_mc3D8Dll1rmvOx3-ZXphh|J#wBUhsb zR0Ws7yBGfyq%X$x2`i~SHrU#r~-5I#q>Pd??ynW%~SW~h`4`Q&_ z_SW7~ags+a9rqDSN3O!oU8oJU4yZFUerzZhm^ED8fM(6A{sLH;X*LKo!V6BQX;!=C z-)sHj)Yf0b>%X}+fPa$zQA_^4f6U>ZQS$#Y@4%4k6kqu^Xjvy6MIc8-ktwfuPj_K` ziXp`akw*npD(}6+5D96q83gU&vo{?`$>DjL55iw)e~mc3v)3-z@me#7f4~J z=C8dH-wB@WQKyUK9fPw=D%(X9%k}cTUn}`{+N!F(9S$3=yM0`u#Q zPG=cQnV#K;3_8M#K$AQ4k{$4k+J4T;KVm9=}39D`N1TP@}$f<`itI?$h7!2fAOfC;}F2U+<#>?=f<}TQBn1Sxf4O zr@Hj_T+_~+rpP`;T^E0q2_Y$JXl6aMKA28#S!%mx{k~FHm;ba$?ZcM+!op1XzU?UL z!kO>#T@IWR*~JK*Yw(be8m~H*y@FHW^N0bsNzN6B>{7m2SKe1BK5u4`oV zg>9a}O*T5t)}QDk#73Z}bs$~)+8bXidryhp%e(ls@q6Y;Alb3x0b#EQuN-IDkEQW~ zq$UM&b2--}QhJk8_aNJd$Z(#NMqX65?y$u6RTDs9vgQcw$A^wzEG_VF6vwhJb&r5z>;y2`ZS^Qx1+vvfZGY2oXME?XF z_TN|pn0n2XG^qCv+}+iYpUbn3vwhbZsGQ7hQ}KT`;*q0PX;Utu--$2~rlLk?-WyL% zLs2`FF*;(8^ueJ}b#$l|GsX?nFh;d~YstQC2?z%T8SFgKY2F19?84KuVC%J;I!WCX(epR2>l)2}$dHET^f5A>Jkx?p z$<&!r*_(N`!Y@xTO6Y?ZGIMq|^9IWH{hf@&=V!HNH*vXQGK|V-1?lk5hJ+L2G~-z< zOU>-GF+!9~N899}!M<|OvMR?EaI#hlauR4N1@?Kkw}n)D^jt| zkM(A-NixeXRCLv}t){*rc9-;qhZEQNi|~nw(0Y^V8ScAK?Y^0SNBFh4QxXzvFc|%5 zwr|LP*gm;t!gC!_J-lpctR(f^E6oTKngT^9-4(RDqwW`|^76b9(J?DK4;Q*%;0PbV=SV~t{3C%K~^ z&oI!(wc<;z&^ZQ&;n;C;Tz|JhVyqJkIOp*AhEIc=ZthdCEta_Oxoc!7>G*oydEhi+ z?Jk~>tAzD`HzJ!~$<806ICAiP!dc??+mRNr>qSkxkb7O zz?Dj~VqT%v8iQF%&wz=(_|11MbSe%W zia4oj>oCa;YpKe58}Q^S#BS`eah@0Z;f zedS%xyxeCy*vmQ=N#oAM`ju~Y3(KFE)En#e2QLG&j+L_bOe^do%f)PGlg6?TwWA*7 z0PnU`^8B`^M}9$~^UW`FJ)wzev!cAbh~RC($4iOi>mG{tS{ny%-L3mdy`DW#qK(ll zP?(n1K#f`~!RuM|nyAD{HTswQv=KaQ=hXNy+%8KByC{UjYSLi>CtFc#SX zX-{&$>`8ep(0P@*ZjNAFa_!$#pvUj}>!{zcPhsT>wdq{T*yI)LI5Rh;;I5f9<*iB_ zF0r60#Lg-_Y8bO?<&*bH{f;vSwPG%pzU<{H{Q~VW=XpKwkd~_i?{J>2ZE;`HpslN& zSFy=MNT%;-iw;ZBIR{$$Bi$f|L0>C&J>#nu5=P_SJ5kFE0^q03JM9aU&@PawmDMn{ zq(M*xMUrh8$oc&%BC*ln_sVx5YGk>cDjk;y1IDDlms-dfieJG;M;oVX?iAS+Os9NT zS7jXgK(+v@Zw@-TT19CeSH|9!aDkjI|L0 zy!5tTA0Bte0=W^(Dq)jKTWj|qy$cD2B57yb3RafxVSX&c5-h_9`}KB_>_%&Eqx@=D@3CIexUVBLC!7PpV;wpr}Z z%vT`>Tb55td0zW)sBa~=r*_XcFx7U{H2FnMpSc>9AT~qiy||$Jguh3t@8S<4pG)*Pp~m@Z|zv&zq4JlK%1 zwOr7#@f{4x&Xb~4lbp=GSod_`<^ilbxzo384iz&UcqaeFsK`DS*<2NY_>znPfICNA@gt3fZR35A3 zY~!~V#%F#2s>23qM2=MZT{lx)%L(u>A4nYi5c5js{x{x~2JJwiNh&RRHp&Hy~CPZzJ<|P*xT+#j3`C7bV3s_bg&V6F+d0%rAlZLdex1B5FkhiJs>SWLW1-r zutjQUQUVg1bV8RZDEhsq`}}_AJm0?vX@$MSe#V zJ)+zRT~RSFWL$7Lm7vnPC9AK&_(&uxQ?m$QAN%8h8!P4_U<0%P_x9a|^j+oVfGBOD z=6&}*VjAle2lAK%u!5ai+tmmz^SYq0zsLcRVaE_&PA#c zijm2?8e(~3eY1e`n+N(i9!ncjF*64;lT)PybeU`s`O@QCb=5~3Y*$3;)%B77WuuRV zH3l%UF3YOg65<7!-LQc{uKCk%Z-h5h)X*G@;J9t2wMB0471DBNVrC~#)qug zuSzdZsl2)Rq(uB3+}Bq4eX9`B7nU0(pHXQl6Xxj^ed@`uUYGxmMCGf(>H~06x(h8FE3`Lv84WEJ* z<}Ua{QLAH9Y-e|-eTHf+qKx{Qy;bzzKI5fQ7a^w=^)wjPRmw#j*5<-!MU%pA|Jjcb zw7wU9T^Xgz6@9HpZSN?q?7x=u!DeJ9>ipdrL}Vi*Yf)$S3To@KGJ|D_8;N_C`++21 zbgrglF|YS6Cwm$r!3HB@5_iuAz1~NnO*U?Gp7G~(@n2RqFO$maS8nBXlqa!N25u0g zZuZ>G%2V+n=5K!DueWQ0H1BUZfPa)6rH_1Ht!~p8sa(ro7_n=45pYk zKeujRCSO=JbRoH4HCd6^T1|?~ngqrU44v2)=H-r>WgJ;*9eQ#;5PmF=hfyafw7eVE zB11vSY<^5hwdKyvs)zTjC{~k=d&^#u$N8iTQRb#Cvn+FQ`JYq$6%r!yK!&F8OV!F?v?c36NOy>En@< zbA%pL4!N|?z?8J|+T1Euxd1aQd6|ZqzFW?t4N2!bU{;6JfTsCyL79UZTev(G{-{-VTou_U8LOYz^jA&lj)(V!sAAwBQk2=K;w(VA2VfS?P8#|qAn)`uTdu8Yy z(`;Ni2d`&d$_H|y2rz(Qm>#ZAfLZbs+%t}{OW`wCLnx=Z-D}53xnK3xc0&oC^NJSd z>0SdBMyW2tDaI9;9^EcJJZeJMGrY*g6&*lRbwemyL~f;^T|_Ppc+k92Z0&RPt;jbR za_KjRf)M{vOg^T1X}S-UW^YmaDLPj7ylbP`*+UJR|UtVfl@!?$AxtD;>dEz9L^$-ZdoZbqZZf;&QB>zsk10+#l9wBw@a zEJ&RZW)&M5+a+nHVni2(k<9`UOyh1u+%Y6YQm%WS|DcLL=uW>RC#`z%(TdoDqdsX- z<`|;CRz?#kui^dl$cy_dBY;M@1CNySELd>5oUd)b;*2Ag0g8bnW@SkmG6$8o?TiF? zvdcQYnTR#Am?!J$4@o_G;Nst;%4b5ekhPHMR*|`D+xV>CCH06CW|F(Mz~vPoHiR$u z3gsoK&sJx<`hZVNr;wkC0TuKT?FT!Pw~-?X@wH?`mu8x=y3Ax$D~Fh0vevXvlTdis z2xOHOuIc*1Um~j-Jp-I@>6@2DFf%Jaa%fT=?-X}phd8HVeRHv@)O`bE;|t})8JFBf zStO@Gm9Ff7QrB6^?mhJINJ95Jj@Q|!h1psdk2B)qXPVh^H73L4TVwoSDm2lNrSQCLj_sA?ik5&_prxL7kAw4XKI)0 zsJ0YaHdR1fw?<0BJ6)mm6*c1OsTm!?R&oIU!?Kan>W^MkXgZl1AYwGa7$nE3vF@-I z;-XVTo?PNHxRR@`8eS%b)aew?UUXCNrgH)mA2uhS*21i`VGpFY{m7kXtVZ2`kA!)589k6w6VEh z6-}1K9QUiO+$l3iQ82I#tt)2Ph7Br8S<>n0a?6}|Cguh!PG>3OEO7=Z#IJ)zk)gg` zCSEYf5zBjAt?aXgP#bEt3B}Ad41c1|-u$({m|N{~j{comjHstj3Ioa3Ytk|+PD>yL zKJU_bXI_xMWT$#hE?2lqZ3=;uHI(M!;*;}9ZF)#WrM-CodN=@D-BSwf>4aRG_0sSj z5Sk1SB?ZbGVVYIy;01f#q6RH+X34Y}bWG#1+6bHMtejWG+&9b1LsA;sDJNHNu7zBA zr~11nXZjaG+~P;mvhCDcdS#ckgI}cQbj>Hq2bd)2Osq$L^BgUA;KJ0E1Dpgc@K# z(^qHAT-fB3#%2F*F0>#oCBYU$5WJ47LfAwZR!b!7oSqZMOVN8x7Ge2lm_?TA)`GmsQdF_bf$(1RMh?C;7<@0`Pyiv(het%8H@WJZb7T>0fqMZA~DhjaOKQO=%E8wG<&D79|MNxlOl`mvTVzXLwQ9OHIhHX zni^hZuGF_vXML8k${`-IFrA+5#c-}LUGeN28YPzxp>Wm?S(UYKUes&rpv&2sCf}V* zPs8g~jKPbGN({zU9Lgl`02kU2&<93YK}JIzoAA%*Jl%Wx+zBtF@$?n5{UOi7375U+ zPH`%cO9NX#6TvCF#C>iDgB{jY>N8r|)j&-2HMZI3=UZFFs#IhBc+%J-7aO-f*0+}W z%PRHvxFl3Vw(gu#861?5(v~dC2^)3`X+fSGinOWfT(D;@NOYoD0QyFVlB@1Z<~Y{zru z_3h86V5Kj+ER8jp=;?VWz3PU04j<;l&6=piV7H7xoomJux~?ghJ*Ro-j?u#47!#Np zOR14kWQ$s(&p{Sd`I)r%+=G8hA9jA-5kSta8zHW^-|spr3Sn;mwEVEqBIddBfTe2* zNWW4;x{?L2+%#>Yr>o6U#JpqfTKf1VQEjo_1us`RzD04v#@<-)YRO`IUgE-sH6JUv zJdxS9BC0h@!iH>etfxy(-(rdPx$QKvU|*&4y082i1ZojU<*MZrNC$7SwXCGa5=!c2 z7VueyyI-2U;-gqF)yRTB&ho-w27vpR|0cfj>URN`^NjzNxaW@e?Fdnb-k(6wwhyIG2=H2oI7neQV9A80!cAkpcl(L4T^T z!DmJngPh8~vUO-%=2=1Lb~AD`oT94N@se)kmz zwzkyrXM+>)FU41NLeSzYdIvU#7ny7c`3VVj9mi;~P&6j=&)=54HQE+ui8hPFvs#J^ z1Jhs5O3PXtUW@E#MrZ)^zcnj5BW_AG>*_1OHIiat+Q^QdA#TdRGC~F_<>Sc+sXjB| zfgTLK?0wrTVxEHzO|I7RE`gw2KjbpXrM>7Au9$-Bc{nidm`aP169sA73zg3!8$3Vi z$*lui#j2)2Sxg_iim@{B;h;osY7b)S6u&{OyVBU69`Qgc39|F(y7O3Nr_51+z7Bj* zZMTCvC#KtflsGTjfkRl7>%IR`7>8bRKHaf!@OgRez3Y{e%O^+TG7{0jGwf?-KQuhpgWgu*Yx3JOwNn7|M2{Wie!Tq(FX^ zf!@=zm1e{l3Z{f$kI-ey$|gQhhzRKhbEf4bYp0HVoA)2hr54y=FE+uUWUNg8ej$om zfBCL3P`pykt6<)WOv?}b@n$0Zp8b+O$LdJDE>vteeAKLcc=ZTMr~rYak1@WW{v@K5wj>#0h)r6(S0G$dxF+6;H#e>THB(~&c`Aw*G$j#<6@Cun}-NoYku=L;?hq<|u0?*ob zinku8Sj}Eh(8fg^nk67?_dKFhLfRlG$oQ~e{~tUjVx%svi<29rm$`1U;Mk4*0ELIF zh9rj>l$80Xv-O;Ajvrs%chCUw4p0F=i7y^e;QGzSuIbi>*BD`Q!l~igB((Aomgh;~ zQ%P|SqOQ?*+jXM4AJdIeGm6e$|63HTto#^s+@Q;)-yF5#Tv3x*e4kH}NkJ>5sP%5` zUB28+inhPVR94T;*glxr9!FT%BF0WTyL;?Wm7dJG&1!Dw1_SB|vt9+OBj8`1qHrcf zx<{u;UGE(<6-uR~I)uhQq-KWAL$cX%X8eCikluA$17e30p)Dv z($0=Jcn#caMyndmauR28T@!*Zwg#QUyl}3-1cyT8 zG;@men#P2q$S%x^zj>jbl?!)AbguOLMb_PsePBj)X@za7?GXY>L@joO((;q2Mc|Ji zPnmCn*e@8;)YYiQ5c@z$R^gC7NO7Iw*##ZkRzYn46x3OF}bUSL1eT>INzq;!?qbOcw6-pM)j|8X_6?7mZiw!9u^m6O7!86~Lqy12i*su5a36pCS%;bVOPKMwkrjc6qwO86^T{B${xNNr&4N+9nmJFUAl_-QeKYh-lPM6oVcppl$rvB*4 zjat(;_I?1*Zs0loOBAK@bN5!1E09!#i3;4RI)tVHYE6wZ5tY%O#u$Qco?s7|Xo-&B zYW2=>y*6FjXL2NDr1kSEK>5|%PxJPTTA%MA&heO^l!E^$uKhnjZU0W`a3|jKM-uHH zY(VWHU;f1u)YkBCOdlLQIHXbe^i3XUzq9@w;9B`t%&XHsL}ou^lkoJHz?Oi(Mxb9A z*Bw3#z->8QzK+|U|6c&FbP1s4Fd_awMM1>I+kw?#~ zzUHZmHskH|TRHSs`wT#a`fB=Ui{rLf%{u=*HFz^R) z{2{ABy}y~hFo5i+(tTdh+wm=+_WHQ8Cxy8`!Pj8cwVah}O9B(>hqR4PZ6-8<0K7gC zpdsA&O?#^@`@oi+;SVsd2K{d%1%J@yh5(EY69J5OfP7vbxBLNS2Z{lL8*3hcefh!U zSL~6XeT&1#`@voq^q*-#tZTVHWI7b(hx~`x|NkMS`qT45gP{9=0*wz~K@j-C4UQJv zKKcW^_!9$Sx=;K?@B;y$nT|63B0nTN3c3io|0D3~&_8tdfhisc1OXxd@6cUrJM$Ev zc?ASd9KrwwbmTVB0D><5f)82z`9p*s(f{{e94K?v@981UA(Nw+S6~g$ABP~2!yyL9 zHbn2Q!+*dXB3=Qa{{cMT&qx6I2k6z&r@%i5eCQoI06ZkPf5_s;qp4iL-mNoT-xmyK81d$RKWMSRrWcgx^gbNb|)eaI7lwZHL#7o2wHB!0eE z|H}`~zj(Z`<-GrQ@^~QlRljktaEpPB@`{No^|pJo5w@h{vyM%_MS z17ZT-hQtH)8*soY{8{r5r*eOOXg}-GZQzan3B2>4kpSVJM{fVj>*pr~V0iLJYO-nj z`eM4<`qLSA5PHQFBAcg9Xm(PAMXu%U-4`i79W)NED-Q_i*+}V7V-JFPCT5vG<2NpU zI|p)qvtD^;uzF0V;}xjRBr^%G1M30naL62}I0kYaM}PZD(fTQ;%^z*b}0;kl}~la3$+g3Gb5s3o`W?O^HZ=fn4L)?r9annGcJ@> z@sw>sKg-HR@_fbfs%EZrXXQtLmKObb6j3Zg27W-h`jxo^8Mz%<{lKfgWC-D$f+c9` zdV%v7X8yWxIk`0gv9e~i>{~p4bM?}QHgEWMP)1Hdd7)KK48qYp2eFo#E90QNbU<*R zPua7uq)o4V95dyF7a}gV1dWTjoN}0V96VtYb2%AZOsvuG!D2&h;LmmTo7(5$9aE3Z zjf>~U8LZw^sUDc4_z)jYwk|@QD;E7$vYjtVsPnQ>3+h~%#_||$K&jy6eZqWggowjhC5M@qQD{f-mSGi;yr;Gi5)%n;-(y=vOX6y22ml*9o?gurk{$m8O9;giIR#{j*f5#CjBBjd#4f zN`*jyB)8tR=DaMA3=$v;5;^jR=Vr7GI^EE>6N*ht-9zg()IR8)i0O<~&Avi53yVS* zg-B@K{~+Radzk2Q6AC@6C6}5mA%#LPkn<66@lBr@Gy0=SNtTui;~nT3<<^sErgPM+ zF-;~X@THnmHsyt4Nj`i@ORKGWZc#^KFu!5IanU}+2oYtiX)DB~!pDOPD^yQxj6Ar= zTbEsygt1$(=P4)P3P2wd755OcGQo6o8@Pitjp~QCy)Zjq0`2y zCdIq49Z;)sE0kE5jWfavuAsowMTqd3Psg`A+EV6xtvUQZ`|ecz>9JGGTQTiONrzPC zYN>ONu+XFSk$)%=3wq@@NwwPBx)D5;|a3_kGkY z^^Ug+C*ylGxHd}*T~bu*PELLBiWpi&N((lN z%V0BnmlR}GB~~u>YFT-rE)7=t!wP~tAZbsC4IF-&&pQaAxya16LH?lv)`R#1ndx&Z zjqpE@4Qv1n&5x#s>7Nz}=yy*1=XbszvkNTgRO>7R|tbM zRw<8Z-j(xEM24fx4R6&iBDZqCgTnqJFgR;|Wg?Fx%Qe#FF7lNsXbP4iBuWX;oBf^` zwEE~f9OZ)OjoSCia}e7-W#W?=zD|TS^4I^S_-T8WVnFqXK`ZvD&9}|E zj}j?p3h+fa{zrlQN1#i}+s-bEP;e&?M&S4iZzb+!dd z{TD57_B!WADVV;5u=pn%Eo-Be18o!+v*O|N?S2RS@k_GEmL6tVOweh0ZT-tB^H=|i z$Ok~Nd5n^>(+?C)x3|k#PvsdHYB21)n9b_c(wLd$n3-j`_>V(7Ie&3w)>$Zuy92Oi z<{It49L6^**;Uper~0q}$JmdOf4&lwWM$UUOa-Q)FBXNU`KG`6|H=^dpJzJ$k_oIU zIq?6&nC7tMVX5UVL3X_+r^ML{aGJx<_zx8qS>LP_NbbO~~3b4Sw6}+@l*=iLIPWj1LwtJViw6PB1DHn zPMIMS3ms(p`+~fpZIv^4JblQH;E4!WEZ|U^LDilhzCpm{nEjeme0aKLvj6IL#x}0- zwN&6nG#vv^8IWSZ9+5$0nVv56=lhgar>lJ{@tzV+H1c*Q!LS11RA`4Yo1T|dIK^#o zoYCBQ^zL_%UD5EDIvC}X&LV2xQ>EiVH3!*@Yb%TB0b!mA?|%ITXi^2vBl?-B%DEy#!pO|o_c<7oe2zoK;luK~jG%@vrz zO=ZVImb^cHO?vi&+S_mOvkMhV`R56OH{EMVm*=W&fD`s@v9+L|djaka&r(d1LlJ*KP+yHDs_Bt$zo7Q^~lM@$Vqs>06P(S6=EG zW4pEa%0~6K_^EG`tb1ubF)Iy1MGPIW_0J8R(@i>;KI=w)wWZgKPhJBZThLLf<{wrh z&a{}W%=uNAnK1g92g;+gPmZq}=o4aT3pT$C6H3~0j3IqQEGQ*d-1ho?Bcv=~ak|O`k-BFsNiyNr$v8qDZql_(D1a*4 zT@!Yy?-{K~-_>>~>6ar^WPY$_3Fh(y^c-@zA(T=-Jc$F>;JEWggwk+*1h9j^CWob7 zo&P*PE$79IRqVUs2?6}8;{MKpbF%{j=Tnr?)nn7Rnkp_LA@Fzo4YGD|Zi&8`Fe@a< zD==zo=#B=Uo)_n%*sTL;UTD4RxfM8_2T1% zxkmBe7t@vGzOEVU_u#5sW-p2ANhPGzr;P8j`5DQ+agJ5?A5X#26Hh*8EKF~wdy#O| zPkNztd<9*am&2NrTK71=gH(3Uf8qyp?_Bwa+uo`57E4s$3C5fn(HNV<60W2c)NpLW zya$teKKM_xt?f%!Jqf>cAk&lNtN$&wBKxbxlg)zKxQKxs@7@${DI)eeh%{cW7m#55 zNJ=90k(bJ_t_oc`Pu5xT@i14=jqjjhw;h>{=(rbK9i6Cs~*#7J+_yCsJyg;=L;9qQNY1>y3rTDn|vwyO1frN_2oTxUVZ7fods zi?La`chK(bw9P2qiGcE3u}(!x13`0Ff?yeY7QmUp?q^?%#Q!hwlGY(XzVDD#@Oe_p zxBK5g{a5>*bldMcE6i-U#^2g%Ez8(U{+6mMD{FGV{E^|lt# zw|g^jORh0bHj$4S>Px@A&B5sPCYEfb6J--mB+^o|t`p zdd%ek=+%$r<$vZKy$y^jUIHWSUn^K3B3R@2FBU*!b~yZg1$_C34ckw5`{>gj4Ijwh zAIwQ|#PHq@2}MrOQ03+=Oy(Y}JL9T}-{FG1eBsO#L(v_-$C3u*|SKh;<3V| z;vG!bA|yAah~nW`Pz)u-MvVAgB} zIS470tu|;q8*~ya?cAixsU+fx8`{ca-96FJImH@NuVy<737$r9&L+%chWUNVV0yhQljF176c;pe7RnGKGSi?d#e?MEFujFZF=>&nKObGOW^Z2NZ|+`Pp9{gm zL|5!o9;vRwx+SU@>*?9;Lw~xj5Ssg(k4c5fNeys+I@%{ht1%<*(fi+inGu#$M0qAt2Bd6!E6evLz#q22V^N7CPa!~ zmY9WPuzqd-8kYC$qy67oL=WbywgW$Awlv_z+;JuB2i-W~@`?|RQuRwB7fZ@H z;yof^0luHomk9xF5!>k^?ac_D0D{v2;;ji?xf;fUS$(eksM=C|XIIF8-Pmnd8ur?= zw|7j>u-a^%8}W&N9;}ALS!K37Qp1|7h}M!{1C5ir+_fGDGt=X&Qnyzco$-$btosZue2KK z3MsA590eE6D79;wU>4?rA0b%Dm|cS6Z@ccwoo@F2;3nPQ8-K5E-Uv$9(TPJf2xO~} zYil@0k|}Q47I>3yogbs$1f)x?rtc|2YbKb?fBU2dX(EP48QKSv%x2J!8CqiWKXUaF361O~E%Ip{0`FG~5qq$|OnH6NeWTap0@6d){+}cnQ%t8a2#V3DQ zr~0g0;d^El5*?3EPU#6!v-At*95WD8?nhi8K(l=JIq;g>8FB;YV^jrw#mLtZcIAi* zDoFio^Led0*1jd8OIuxRbz$_v|*yHQwNIYFSAUCw;3>$MTe?m`R|9ufx82`N%CN`p8-5*fn;$ zJUo7bi;zFGWdRsz)XLh6j&}VH;*WZhS=9xm1fRxpDSEp%BQE--rk?_CjOSM^PNBn6 z?9Y{oizk+R1v>I<{}-R*fKD-T?*;A_gCBLP2{_6imtp-i!wWL~I`#OX(CVTbW6@Hd z$H`BY3#O9ARV@RVte$)q{;7pF?I5dJQ!)OTCd#VLetNWSaR~<}l0Hs_Qv8q(VT>)S z(Wx%*73X4arzyaUNFobX~Q4=8fl`Opt7=z5jF zRxf~A{iG-D`wgAeqbe!suqsd|hp~$n!eFIZfmLEJwy)C}QR}b?cX=2XRKil+LV{Q z-Z}FPZx*$uS}#>s;CGTQcd?a5r_HPjE(esMc`Lkw;G#p$8I`J!Us2)$hNK7rqs<0d zkl>JtX76LP1-OrGV>!JJm7rh*5U-811G%gJ}*N(VT zRF(qdXz}koi&Vg$*Ne_e8!r#AOQI@Qz}*MhYU7Ke%u(r{Q?rgHVNouAz`1Ue&n+Q& zacYkstNNqtN|^m{)kK)XEG1$yAAdg4YaUXRnyFOJJDwD&>?jzPJm}$l-{}Q+s;1tO zd3~H6WHE;I#(M+tl1Vv)w2P|f`P88M3Q4eb+CFZl+9sjrv8vqh81(0~QIu7Yi5m>I zYa7@alYTqSTFhhU%RyH;QX^pXK@RmE8xaB_;h4?eB14&#O5JB=U)O3@Uh3d6mArS> z{$_$Im4+{F5LH%gDOYY~TKaMlEgp_l7!2>W@{w(baV)5PRu+ZIQMv5k^?&i? zZ06t~NG9y%=DVA}?os8|HvH_7cipDm$N;s3 zx|QnXkGD;k*LEu(P}erq*@_i3dSjCB5Zp9vq#pSx5NxxjXCcTi=A5?AUm~C$+6HG1 z?7vyW4Dkg~u3Enr^>?P055BwSIed^O8s?T%b@5$8CrxD@MRva|cdb(pT{A+Pd= zJG>-2RSv2k!8NVH&yZz(VZ7vjy`7)0XgC7aSkj}m7@j|wVBcX1$*MNO5c7)w+cAs$ zJoIkOYK83N0dT@8e(Uq0^q6`F@%NaR*^cen7ilZEKF?VdxF5K&{K1e~+)q_(KNwtc zH=0aflNL%DLb+_4K6gaGB_|+Uk8jBV)+kARdE|`_>WZbnkbr=oRp8a`cM`Jks}|qv zJsjh%nM|?-`}Slz>?zEo+B-}a5G#!e=ut4_5Y3f+XU!tt9pf&E_Lv!^)iSGI2}o^^ z=G*ORs8waENLM4TctM0_tX8vh>J?qqp)v<)y03SZ9*KO7K|j3kzJy;cmQU|~KeeHy zp$988I4esooc&@YWfGUXyzg!DC{FqD{Zj8^;xw}UT&}oS8U)} z73m4WtCOy&jif6f)(LC!~l;*^zdavvw)c@2wxC+;>*j3jSM<= zDfg}k+2Kx}?#*`_q-n=)_Z{m_pA%Px_+*t*ba7^1%3#Bn%eRYwo25H0-$A4O)d2;o zef^3RV<}k#ynbCQDp3oVZRavKyxD6yIpCe(VklKZt3b$WbGF+T5gQF8?B)bh@MjG9 zZiLe!A)Z&ObvKAaCvA?E340rb-l=QYySKtOV3Jp1 zS|GSQ5La$2lvHK-<-2*aqO|R9q}bQ!`qls!D!e57k>ft(&rpM?=s_%EjmCyQy?KA{xZPuqLQva%WxXOlp~dp$?po18fae%xjf>QtWs> z&K=X`67bnzf8B3aOd%ZUys|1Z8#<|d7Jc^vZysqN5)DP3b4R2-69r9Dd; zcx#^#lMaaWdd*r9i7;1HwcWgDroH(SMduV#op!B!CAzMgt{;)_oPHmLAt3)lI$0`D9Yuyf&Pxu4AVN znbf5($zGh*q^x6)?DfciP%9b-NW;Kwz1DyQQo>EddX}(Dqi;#xt%_@c&V)DjYZBV; zY1<4%>9=gB*ytWZz?l`VC~%w{h`Ofyq23^oYQhKeecfvYVM{WeN`5c&W^q(ja{Hk| z6Xq)k;`YwwVbAAaVCk}S?!Iwa(mQ$uX_m7h@%b2iFt+2A=#%x{rI-Nf{QM3VCuiKO zwl^UYBh=E}hcqa^!N|-vg6;tm!LSD8dGXDcJdbpW2xA-M452(5F6G6pX5iLAleqzPHg0&N*+Ysl1EC=8vNAA;FRwSvH!94@ zB_~`?p&*4!hJf`JpQ0lMF0_k1ib2=(GBo(Cd(!^Av+XjnajV#iqPiqjX4r1*jv1wf zQp@#-js_^eKAJVz=fR#TSN<6BQ94WsCvM;_@z3FvKYtd({eAnHm3G6nmGr;c-RbNe zIB2*rkHF4AJDY(2n0NATUo2E@IN{*wZL)O-C5B|bLA-WSN$gAGgodKyf1PtXn=#HkO=N+h)TOG4o0;!iZ zdO$F0ahDs@IfdU-eYSWl{yfhJXYp6Ow&!b88v?~y3!Xiu&l8?Yt^POZ8oV8dnE~LNJ=4vXysj0%$^l#$5NM7sHQ?* zF5~+q$s}npcOp-m$ottAj@cz;u;0yQ{mJ$H_N8OmU{Xg1GITir&9S0U2 z|7Yj#jv{TBp;PdIHHGRJt0J2+QtyV z=8d7LsIZe~d~BgaIYpIAR?a7nNhxQh-=P{Yf}$?v+)QvZ)jN^xgc|^#!2+6y2rHg$ z2GsdzldDHMz?6B1S+-549h5OeLJh~FgrhiV6e)ZAVW+~Iy5KiE?-_Jz=_u?aUIxC{ zm1@ zPWN?Z7UmdQYb2vD4eG<1r@-2UilTd0^XQ<)0z&TtSF4GThx+*=6(d5cal%qm2(cY5 zQJ4qxH7tuL0k#KSNNu?4{e8bS-e50LPL4vu>IM_Bi^9QI0IdU;FEe;!+FHa5aA@Kr zGfU%--NOe;!Naa`;Em}!oUr{(ZHHKu>{$Fu^1YEc?|=c=K4AT#Voz#?2|EJyjGfl? z!(JjyoKk+>nU9wCg~nR3UXSLzax zCPeS%B2Jls@R!_wt$vL~v#3}4vodY?FK@ZV8gq|H;ID?~x}}j`eqzVT!ADUAG1RS4 zWDC}y_TUIIFv_L4Wc|5ZD08t^HE=Iq<^J3T=8jFoJ6TEq(RYB=ZwoUL*h5DB%fXb^6SJP?Kt91B;OLgfx4-!f92P?mJT3+x>b{W10;ZDYcn-w`omW zTbla;LV>>KQcwn5X`ywhAoQpUwkTPSFyLaD1YSXx^3aP@<7U&iGq!q7|Isq2D-m#GE2VYvyyp} zpShLWF+;NCM&Tg``wF+d z7OSoxfOizV5&7-e+O2}$_al{9l$Q5Yp4{2Z7OXrr>7=ytQSp1dcw5ok4h@T_u0#F>Z1oH#k}LQD?Z{woc^ zoG#|yjF6$|JaMVluULd8q*^-{6(viABaM@$_Itx8WR=PtEMtcte;`dCeCX&H;dGWn z2=JgM`J(f@8U}TWP6e9z6va32OvIJm4cH-!a%XZ6i;p>4h8^zzkP`B#ud>Qy0_QiYF_TUeLCjrlP3qJiX^4>eH zscqXIjoXcG#j+6*q$*WFK%{pIy+ueuC;^lzp(mk85gQ69L3#)2B%w%e0)o;4gc^F0 z-g~dU1?+v!x#!;Zx%=L8@9(|Od+U$2lDWnlWA?e`oMU{)x!O1p*ZqXP><(}o>X+%B z9p(@IS?=8lnd(bb2Ywe(g&0;mx0*+Oa8SMXwZFp!-w3+plXIzCAq25xxKLC&<5gYe6Z1L}^?i z%L3N#mdR&T`*^uj>$H6zxelu#4?nLp4PeVM`0P%cgBMT0Ow`Oz5bNdj zK7r?WX-ea%sicOJsr0hfjws5PxlVWKs$K8gM(gGg$ z&EF!`v%$<2oIgPtvbPU$%N`f=$-H{`F8V2Nb;UNc8%sm)Y$DyV7-Ll<*}%DnP=<5X z74!0YaD}V3rH=%IGFC>!mhp)^Qs3Sn665I^pLVoP&*@aeKLXjlI7$0oxS!but(D&Z zf&ObBvS+(kxGrhfbTx;*vvtOba}Xl2aTMwDx-%wr?uXJM{50s!h&sL1XX{05jKw{C z{cIX}3|6N{Ud4&L4CTJkd^M0CPzR!dCpcEo&`6(E<)RWFokCccN;4;Xmd@=f9JaT1 ze%_(NX7%J{`!@r&eN{v3Vkpaw@E`GmhYFDSg;Nxx?_a*E#Je6T61Hfg*|D&Y_fqp3v{<1;D6$ zGm#vO5{jwfBj9w~u;#bpxovO}hNm|9*rimDU_#u6=9|WWEYu1@L|XdyZ9*s$UL(JM zFLWVO2cp3$daW#l70G50FH0@=v_udF1$yOA5UhyK4q4_^XSoY`K4U7NvskIbFr2Hi1S;EEWUKiVMaav6kX!5sSaq3ongB%&>XlJ^;>2AX zt|C};)T9XlW`4d@25ZvjjU1Vp|1gneHXoC)YAdto{kd6>g0<^WS8W73!k6$MTeScp zO`8DzP^h^ya51gGu!vhy3tIOmCws;U#ecbt0p>@x>aCy8zdA#cC>~2~qQ^L{C?%nx ziZ325w>(G78WYh`QgHEe$H@Db21BXHc*)y-?>Bw?`-5T97nXn{CR#YMyu+4MyLJ#( zCK(?49Y+JTF&zeT=}TZ0)YBd$?uB&s`)3ucdgaVHRKiMAb;p#)l-LY75XIBL!4EL7 zj@*Ik8R5wQNl;bW8jM_qt={RLk#RA zP+Q;kR@&bWJ|(CY*ui!?Q13@?$BQscwk-TmN#z{q!+I$+E!B_$f`P;GyRg+d3p72x z&!SPNyGGu>p>6>R&foY63bSR*wHa!G<@n`1gt6PB0F;)9pCF#N6#IhtOI0bg2lD+e z98d19U*^_XPW0Oe_-}+=z8RyWi)qMBhEO#Mg)2~PxOPKW^Ft_(5$FA5L4xS2)i6_^ zB{t({`oUAx(iw+$)%1}In!o%Wz-}zZNB5?>qr0NWay{FP$eD@czV}~fE@03~a9%_= zCk?HBylYFL`?(y!{(1W^`U`CZSLnDSIg@>RkBheZ+CShY_y!;C3#zC!I-ao zPS037J&&xu6CZG$h_C$8j4nrUN+`;*D5m?S`ua}E850&|A#&?mjz2+X-|uJ2{w_N^ z!IOH{Bi`<6sQ3Eu%)wo7fn(vYq$*;p!S;P}L znM1n?;9HA+MU|N)q-Ut|7D%~oM_1}jc*Cgq3@<5(xZU+L!Mo%LHok|F%Y@?Wuk6r$ zZLsTCpJBD`>$$0Zgx>8~P+0nX7vHb;*&|FJe_e&6ut9x` zZDI(@o9wpXhz?zx`4OY;a`3U|k~Vus?NiX*!^=510~A88UQm*JTvR$PA!V zWtisI_QV5%M1<|fOI;c%83_*Im(W_MC|wU6@lr^Cb7I>Ydkl{t_Jcj6XF>j+R<3lg zjlrixCnljM{sW{z$~LpmxwdaS_{bU5F8DPs(kvq*4)4^Ir#!=4K?5Tmt^q1p^$`Op-S@b<33th|$vxDDd4qMuaz&b;xjVj9%*1;(C&Pr5(-lq)aM?0!vbs6Dfa;6!6iR*gkiOJh%Wx z7Fh8lds9KywHm$GAMgGIjW-lIHy(c1(xqy8wS;`{yiMbftN6ZJaof#xn|AJDdfz3m zgj_#x0q=M)bQey+N(+VV^9{7lj%9SIfyjeI(d zCh#W+PJL|(72DUBB{tj4pF2#UkjNXWF}lTJD3pSkcanGf-lvW&9KL8?r?2bk-%i z*+jWHsmUC8jS;icG$+pv>v-)BFG0%N{&{j|RuV4g)?v9|Im6zCW@;g7qV&#I28=3V za;iev2iKtPm!AIKArjIu^m*r@4VgB?pgm0qGYQPEU;wnx)>$aXKhTM(RG%*XS36ZT zb77wu=8b8hIgDBwMmoTI@bBVy?fD7m&gOBRbKTWh&hV{h(!s2Sgw^scyW#@QcWP_5 z;>SNOO5)M`iO9!ZtYU9-pAJlz(aEEDvZv@g_)Yg`^}Xyq?ooea*32+q$+=@2*Dgv? ztZ`O)xRaIl)<`Wx(~S{V15d+Jx2*JT$Z<%Q&6jOT23u^dQoQ7zX^*tbV3d3 zmfRqjZf*h5ve3;)zJV+_RKl-^FyfaeX8O9U6M5K$TfDp44{Y*vl*&Y=nMHM1_;uJR z(-THuC_&o!hF*C8Gr0*Q`5|1lkKy|TjYlN~m$15P9||xMjPKjRa3nn17mF}t&9LB# z{-G)vIX#}2&cB)R=y8YHLv;W}zIKf;8+qrvj|28zGK&#$wO2TdM0|5fm z&&(PQhi8rSiyVx^bA~q}N>JvIhRL?95f(nc+v}`EH#Yu)o$+-!q~mgoVnbpX(xw^7 zQ(}EnUj6_hJJK7Q(}RVJJ_#WY)Vh>Yk*^W!SXHPyGN~N!zMYeYq+4bMC`dzXGve_V zZN2Fk{0-+-i8Mmsf=SxLK;{km%a_Ej4QP}mxd*`oQIUg_uXl6{;6dk80EB14OL+4z z_ljZaE9;al?5jv28EBKY?|~%`>+`jBR`AZriw)3&0Tr8ovY?U1A_TNohq=I z^9d?pRy@`zKpFPP6Gx~d>3zTE%}jZ922PKmyY}}g4LEa$<`zPAhucWo%($*hRI1mL zrC|CF0@$t$1--d8z>$SaK0c+{2IvTzKl%J}n52VJk$s_P9+JIhai+b|X^b*GmNL{YJyHj$GYym1{IGM>0S2NBoV9b`9^@@^ z@X>f+{oE`#*{}$68sAR8$dT5FAMPo%tPBE5Me}_Qhp4xVb&id%%`=X-iO4tjhs84zG`wO@tgMk@#oMlrOY-DI#i#j`-_azFguEm62-sLE+fgqOa zx@V`KE6DAn0_TqnulHNTE7s3%m| z*e9^l(?i`w=(E?dAlvyO?i*S^!YW%@l!B@E07FjOf=Z>QpQ{^?L)|j4#r7@{0L02I zc<3I>eGlL(-EPpxG*CbE@~8bSH$b6lwr*Zb-oj~VTL>{K{OsMWn3$mwIZsCS`yA&r zpjeFKec6>h$a<(?l_d7gF584-Zqbf2kzpOle>uU45Z^&4C}$j|G%Z-ovGY3(SX-yzeUpMWWRLx+sixUQQ9 zxDdk|h)vNFGIrnUAQXUAVteS??GNelGxFpbrXPS4O2-DlRY-;5MDL{^gElVb-Q4)V zso6m^W_JZiqz`51vlL*{Etg`P);A2EJRaY&=iW3bbhX_q^OuuMMGyMSq`*rUYg(F3 zOCdN~S?ZLYE^dFJD$>A=2$)|WpoF9Edymm0+oGt5xLH@`X}oUdQjwj4_2;;&B*#hF zhSGMFBe905BDFAa(AkDJf+p0O@dMnHPp6=M99Hd;1^!&;GVT>@-CXUS;uIFA>Q{HW zB``Ekv6se`5XitP76AVBuKuP#u*|E_B1tzXnL_2kh_>#8&{lNQKumG6+NUqPZ@|H9_M3CYO;!GFg%|J1q?R~SjnV%)!g z;XX&CXt1Ls9;}lQtd>y>W7mcI$z&Qba`!@8F}^z(>5E(F0wc9X74U3FYV#>LIh z*bV4tH)>o->VK%XCFNRiwnXbroEgdEmONxX%?=ZW*H_huPExv0$*RJEL$_fPIURiB zE}tVV2oS05vN73dZ&R^khK@`6zrnJh2QCuCczqu3l898#Ypk5<>v7*!qb6A2*5wJ= zc!%l6q6j7mk9tA{)y^8gnJz(p`2GMI_^%auEW+7AmZd7p_$isjP*k=megpS}#HJTH z#vd#xuLJ@CCNH30B2Rq;bhnR=Y5>Da79|@6LQv$hn`!CgDrOof`c|+RK9fS*$uz4a zC(IaN%S!yEzaf*E*sAhbTc62MKVL=LFWYTl=$$1u&LOhu$7A@+*415^CAS^f{u0s5 z-stz6JDYC@7~nq77IbF%%P&rAsDzL-ormi!dTmd4mA2WZVB4TuDr(Z9jx!ilLW+Z5 zR;^%rq;$+yX7rBIfU~>VLYO{ZT(bMLRitL`WI8G-KlN^=JXldfldF9)Fk;=Xg^Ps~ zFJ%rD*|Rcas?{Ykh6vZ=E4;%siE9@lr)Y$P1|W7GYY8!>85Y#o#oC4}<=UGoI8bl>M^(V+-@77S*@L+CQW zMUA-xL%&0$2C84s0A@Y^WZ!?~S~R(Zo%PAR0*$luP^~lyeJC&eTB67XM@8SfDglM;+%of6s;%(6>AA&AL{O|9afUD5z_ruKDYj(i82iL2F{943ieC8AkdYDt1(vK(@>Rf zVA9e|?-kTsNAVOe`i5#s)5vg5EphC*7&I)lskjujcfi!QnSROj)OWzYk2@dZ-qAku z9KxWW0XLMg2@7>H=-Xn=h(IP$eUVx^t)N@$EzvEY+RqX~YB2zoNxgG`uaI)T4C^%H z!}B~2#4K22z!_@UtFo|urnFMq=dI8of#4+qmr*j{?;1c5#{KSxk3{mM#?}L$(AiMU zH!uXCL5PmyYg)yEKpi(uhK9d*zqkw-GBCS*`hy&3^FYi03+BW}XYe#fv?qWc7rPBo z)F9U6W@W^|ZvzrA$BsHWZ~^KJ_8y=J2OPQsKo7?k5aH92kp#8h$b|yoj`oH!+*@U{ zYW_(2OoYILkq38PoX`PLZk*`Qo%{!ZPMd^b-O?w6t`*7ib#6&4m~f>)c)ulxNB#Jd z;=l5=2VItGJ&pjR2+#}o&yCRl5?lT0 z+q3%m{)H=A@TS^2-JqPH`c8RUu$x~l>$7m~c=7CB$wq7^d4N4Izp&RRw@UAeUAWY_ z6zTx)N1lfdNt(gt*VV4lguO_r%M-wW%XfIVb?J5-x}_xXmL_yo$TmNtMwtNIeZNu8R8 zq4V1?ywbO;#0={;NI7bMU?=ajr>$dEo(nhrCNwKG!wdk^ELWG}#$rQQZS$Q(HgY;z zYjdGe@9F(~2v`|Q(;|_;`__KmyHYsqNid>Ig7Jg9LE3fAf*Am%d8!49<-Z!)OV8u( zy~^WOy!Da54zo+qyj24ojldqGxQDLmp(JEO~iHV&X=w@}|q;h?1`T(J% zDLz7FU1Vk3`B`~~K2S|31Eu_a_o9Le#oA7Nu_%A4esuKQt&(8ox;46CDfbzn zp#oZISF=mh?1wWyp;b>vOU08XEErGB=bYCmYzU z+xf3+sBBQ_l%891=YT08eK~RFZW<-lflLX&q^Mx@YBgf5_O~(WsW-(x)E8hBGP%=z zs^*PJ-qGXTge#^M>IY^+-}MPP5@uPZCZEaQC)L>7*>njt^ngjV66V(3(W*`zo2J6( zi?$j=9B&iMOu6O6-+vh`4W**>GtKDkr9^c55~hEI>Bw}FP$F1)CE%<(Yt8JEM^rBl zkK10-2qEAZJ%qTfp&GlUi+}%((N1+rw|1!wd9_jc$EWu?8S?kq{0-HAFxq zY&NqShh*#8bu%1S!$H&vSS{ayDS#x;+;-BTv}DR3Ojl`^ZbTg%W2 zxp3_k^LKBtyhpKxiMh8tfXQCMqyFt?<6Sile{y(FKhtfn zE$osJ856_{1C$iSq0p92)@LoOrXgswZBcs`X%Jsz9RDgaP;o(RsKCiwFh=0P?`JiX zH-b&<1zgk!H(`5DRM9fl2IV!}ThYaRb!u!Yv3y8#SEt~X63dnXx=UEHsai^4u6|VC z`(!k}filjg>zyw4M&Qq?8;MCHY<#|?U zvIJ*$!{6}W{##w%)NJoiZ_F&Sx@F@ytH+5|eM&R9mSi2C#9R0FB9cHsBQo1)n;B}A z5+w5*$U*pSd2^T-tyj{tBZ(QeT`QuE2gsm`%N*kV%YFwkdGrop_asNyC|jPs(XK5S@78vcYy?9r6ch<=T>r z9UTgfrXw>4XA5Y-j1!Ji?1jvM>tQ?iB@G)9MbbD~YskJR zGU|Il3s@=<*A->f6+6qt9LGTbhLUfjE3uxR$F0+GqrKLKW{#t&Rw*_Co#m@ybR%tD z4`P{Gb^^@+_#(L_D;CQFzUsIUspP?LO1}^%mX56!+3m@<83{2Lg@gd8d zWEiTbdD|;L$3$Dqa^|<{=--Sh$<1-u3+c+KYT=>`AHG4DY9=T( zwk6bwXBH&`k#-YzJ-3(DxW(5!i;pl*`DDVQ#na$#4`*d%wd9Ft$TZNPFq66ohKr8I z>B6#u#oME6*sVXfcywV};;_<82Xl{+M)jO|k2(t&w?vhbLj#~ljIv(@k;k#BB;Wng zWWNDZBAJz}bXPD)R6{e=XJ}_p0U;Kk2=Z0qs9cSb)C4T8-Wk}7>sY(A3?fNrz|Z7s zKN$u-jS`IVW=X5THLe*%qQqxB4g0bK5OTS6|tJq6IY7+`0eUl zV$f}^@)-uFa(%KP_lWT?j<=;addtWnFfb~e8-L5(Ed#<+!v>3Sd?(>SIRh4CamhOzd?ukT}cL;DcpNLRQj z&rYdi)0NuoWDKgHDG;BDMnpiNvrwsB&-c1Ptuy-d>%3(+zc(5k=n!(zFez8Eg*wr8I*b}YK9z?RD`zl6JTb% z^SB)H?M#0kqCV*r-tr+Il&XX#M>PuoB$F0j3yUXH4Fy+QmNXeZ#)fSe{(7Wwol zO}&IG`fphUNeb>ykV5Z_LTag7)n&I-l$2y-{Jhr}W1~xumch?>qq{n*S;b_{B$lOK zd;-=szo7b21^uRePcz%01A29a^65DuLstRl@dfP*pXAF6_q5c!43cL^9BySE-TWd+ z!I!{d6#RYG`Ea*%_E%@W*PAHk-HsaCcPi;oUmu&l`C?J3*D}&Vhc}V|Q*eDqkRiq? zOA-#04{%X{t`yD^>_Qd`Zg<#4`TORR%#;^-2$>jsHW;jO^}Mga##pH}`0CZB_ zhCngCI}vmf&NOhI3Di1%TjL<9&u2-r^L1pY#4wKr+aP~xQvL{rQ>xP_R6XV^ycIN+P zO@01PJOW_J{ojIZ`Hyp954v>u-ti*-KY`B#R>;SDX6Z)@{^R}!g53CbD(=Ut?YWpx z8*kpsm7y#xQ7dzpE}NTqhCBjUr;ks&d}^X!v{Y2hGsUa1JIBUpA>5ru#mHC&B`1jZ2~sw0)TtcuY>ATNT#8?|jz7qV8`%-__z8Lq zyj(EbVo`g!<2SW?e+SvU*HW^kbR>-OMA%7;a3IFmjKc{tIX-=0gxr#SdLqae5cKXJ zVtDeOo#c(5IXgixUXq<^0<_;;hPU}z{z%zRP?ghQV+FBdS9~8SFzX!XV_99?bDzeLG6R-zX@2?H#>e-QJ zE+ZD!YZnf4%=S}13FFY%w^0|FCiv^lLSbVg(muOP0oF;@MSWWPvi7z;b3Kcju-&u_ z(CMz#|G1g_*Fwc!oIU#qXs73Y1tx{#4Wgs?zXDr6pN@V|+&j7-U7wEjm;ek7fEIcj z!2R($5C}B2qegyw-8x3!xOzFI@IF-;w}reqmyq}KQ5NBNwfXQho#q1~%X;h1@T@>z zQq6jNhjE$lyJjActi~pYVnuO?ZpQR&?n zo-`G{JQrNXhF3I7^tw@Jq*i*C4(p0M5rA}<<7mF7xt=w0;RI7&^_0Gga zuCfPlsRhzFpt+(pa0NQ^>%N=c!Ob&yZUPVyCGLn#O%PZkuaTJ~I^S1Vj`!Wk_Z^^t z(~;Z0zT{|x=|}jDVF`8%v}j@Qxw;Gx7<+)Wj<4o-Z(biD7L|K2>PL>WNXlEc7Conj6%&zG%9} zdua_PS~8bn>2-z z&2L_rrTG^$8kArWtdjCZDMl5%dzZpe6M@=g6W}xDFMHSi;Jcdy$t0Ts;RJvld2&&D z_0c!BKV)2ZOYiFS{?(ZoQ;+TCz{}`3!28(5GJbr6G^mg{oyTax-Ev8nB)Lu=J*=MC z#`ZZfbm)lh=5=IY3l9!h=6{0r8|2(Rc5XTaTO#dRV(IErlnt0;2m=)!%QI}(luGCdPuhIu5ZP2)D&@{G>Nk@I`Hl_EOfAYl-<#xIprL-9H-ez*7tGmg41E=6ncM+> z)QyY`Vr9Ri`KBXIu4rCj58I&x`_*|@m9u|AvdzMeqH$b(r7`@vXz%A@|0KtjEMIx>lO4LTZtLt?HG{`+Z>D<610Bj;j)D$Rs6nb*dt0P87i+U z1+>JvU5fZ?r_7=nb8<6Vo?Wz_)e02kYJ|n|4keCNJ6fn%1R?Cr+*jIg#A3m?i-`+j zJidv7R8-8aat|#E3`;HZ2|_|~aikkSb7yrf+QGRUPKfQ=$y&-r^es)M%>&1 z*a<%d`#T-6iC%uL*5m>mFS2gnB))2_|_4*qrtK3HsYx@QBIY zcvy=i*BICSweEJAwEe@nF3$klv%X%Gw8O_7=nk(5#HrY9qMqLc=1^Ntw!Y_&dv;rL z7Cx;A{4F+1ba=hrqM{bW-$x{QLFqIkbIo ziT}qRfB)s4aLfnjJ-__Dcl}+`zy9mu=TkKU$87$kk0Jo3|BtkMIzrtC#)AKa>r{NK zLQiEMS1~%hjCN0ci~a>^UZk9_!im-duvm{9A(_k@FHjF4PnDfnFWhM|3_eJ7e<$%i z*DD|Fn0{uSp}l`f&>6hxyT~}n-*DI_Q~QbGv%Q*qOii!c5z~k=u4ATlRpoqHXj)(s z@Pudl5zm)LJWKv9Py7+jzJKDG&OgujpHPmTNKZeKZXKg=BHiai`ln;*j(h`mzCo{hV2ucUypdk?jOf~d&o}&KaCsHy>LI$W@&Uj zelD%p9(VwGv_9n{^bcgmCG?r@_1)g#lLGoDvXHn^z&SzUeDX%efb~lts{cA50kd`S zV!ya>-Eyd=x8bNLSm%d<$Ja9#^8Wbw$D{f$g;3w_uP>930SaN<@JQx|8^za) z0-aHAGm8u-FX-<5g@A&Zqg*+3pJcf9_cOMR$K@k(9Paq$R5aqGbWVbwfGqwXt@(q( z=T!K=p)diN|3UHXB%JQWb*FzTKTacnB-p<^DygF%FaG}+{eNMY|Nk1x zv&IdiDunZDZYhJ42FGoT7JEmtc6NIwtUW@pfJg{kW-RyQohIP9&2=(9Do@8`l4$gc z5tg&msX>aRpa0)l8cd{Sl(APIUug7GzNMm+UsZTYIB<$8JNb>DhX)+$$YrPUoTH31 zHkogUUWv9Et1Lh0%os>YPEjAzS!0?M-$-jl@I%W=QXT8wxDxG6Ol3`2xUbKq&stp* zCZt7C1p2aF(cyIxabt{}0X%-MmgI#TaI=z5v*wx+wLRO@kN;leB{RE*hTw#rceIvWiD@F4qf+#<=G=b3A%++!S)JHyv1 zGSh}Q(Yas5Z7QInjxdy5m6>t`tA+BenrnJykty2 zW=7>LTr9F<|I%6q-KA8U934TA9Cg)qQ(-K@+BO$B*2O|DuKXLQ8rR zv)F{LF`Ab6zS=CiiBX2~Dy$7-Y_OVoQbf<1}3ar$K#j|C)N$K_r+7G^5K&#(rg!hP)$ z`R+)P=~W*Uk`|1A&La1Ea0fMxYRQS_quH{wEKSxTuSxpw-x|QLeE$HocIB7eX@TEw zSe0rZ%iV&yYg|#$X^f>{1uNEvrZ*VZU3d=3)RLr;Z2CSWKCxdQ_`x!MdFpA2Y#+Y= zv;d|{KfKR~>;`iw6;8^>udkbJ@C<>S&j$ZCGfY$u_u}2nKyTd-n8(-g1030_= zRB8uZl@4F9es+nV1X;N1ey{JlA1_KrhE3mGXKGD9e~bt3^vH(4bv32oftGD-s=FPz zH{8WO3vU+=&AZ;dK2tq*zJgKU4H@DSJ7j!m$)!BXDPm|UJPM^}+1s3#4PewJ)?E&3*pI3DS-A{jpsHnu%~O>ntC!o{p}}ctQx%s>}01bygvL15?cyscJ%`k z*Smj$aL8C>cc$_Fw{KP5^S}0Aq?Oq(Ppt&UO81vo)0?L0l-q$t1*aqrpViy%i`g9@ zy=sw9m>;;&-q0~{ZbjiN3x}-O2nmrKLk~GIWmF}QB$AJv?L|1dK(3Bx)x$!;K^>7v z5+1{l?4^?-1830+AFn$Q?s<-@Y=1^@Bfqt?(nK)#KprX-6yf!C+a+DCEwG|o5A_3} z^6PY)&fhS7YQeUflHAtTh;A9yfa~qPp+=mG{Yi#J-JQ=fDN(VsU`?^CX_;z%d4A_h z&`nQKuQJs9AcW0R1A|^n@y;R@^7f`c-pX+C3Py29POW3g#Iw?|#_*T6G;e3}T*M5r zLr_{e1(|L&oOgY2Ax6JGSud{)qzC+b@NU&R$3?9!MEmBVRcG*_OMz5cY#L*^gerKe zqW0=3Ba$YbpSqtHj$|uNVoM$>(4RGP6ERH7ehW|gM3x+rJ}g#gw~+L_yDF>Ud>1hL zD-5f-FAZG^W!!2Oxa#k(jvtYPe~m9*S6dL{Frd+ZKTW_ES&Ifv&sxHWgo`4=?}rFc zLj?+BfNH|jP4X(?bF^0OKH-t^GvVG6*3{HA-{wV+b7k;Utp&8fh$iKT!cAH2LP^~$ z2HyO}`vcLZwFiYT4@nGKe~&l4Yip2=Uje zIy`jgIHvm-2#n9FOJKc5a@uq|7ZTH=a_1no)je88IcujlF|*w$t);oQaF}FnS9yg485_W9(KeGj&l<*+jPG0x?g6y+8z&WbW@X zu57BlHAn{=>qNny&LkF-`slHa8U(@mOehR2$-_FnqqezVP@v$P);^VeQMv^)y+2y-HsGK$$VJuoRnZ zF@ejs**LFs;adIpH)Cqrgz1>OxNq+sJP}`y6Q_(dapM&JUQs+D6xPqA__ezwcMl93 zjH;{Nm3%si(QeDW_;kwj`~d6vl;1MZltreYpsAuKYh@wDc6Z4cC1@KYo7wXAtT|wL zJmr+8`1@#8jAHqMrShVR&B5Ztlk6!ScW;G?gazGL;mbN@57yHzWu0x6x`-a{4yFiN zQYi{2G;613O|}&(o5XI9WERUK0Ea$t=CU?t^2FO>l!O-t7MK*fZEZ<=I<<9tHjSJM zgb-lQdSEAz>b%Wn_rkU2+U*%`Zkj zzT_$GFeLDoneG@gucPBUe}cS+kTY!ry1ES%x}QQLuOD##>^5=Po_8T!ITcyE0pOvf z`>q9PO2TNUs9#QkzO2T4-i6z2u07do4+=1~>~qiLZ|?6WnSbf3*1@pOPSPkQ4Uf4Eg?+VrIHlceFk3|!K`Z6ka1DWv6s{ZD8T2M zeo0EHuwCpjPM7c>dzuv*V(tH^@zI27P8&{W*t10HpsaYl6vdmT<8C`78J*0BO;m+O zJ!4|TcIG-QM#Zi=)S}}h#vW_t1YCBc*SrTh?N;YntZV0pu-QPhb=AaD%NL_j5eQGe zRG-uR^OOxDuk;NQDu?;Y!0sr~R5cF_vA*3&Lk)0?hW!>^TNmke32pt%x?gH=x1#XX znlUml-I+BhHC8J}koq^D3YPP!che$YN^WD=l#F3SyMD5EVVo!aODQLwhfC6p2hR`T zc^^7Gck4^4Z7G>Ub-{4X}*pp{IcsRc@zhHX85~Bh(sa#&++D`~ByowlpZ6VcxVXydt~RZ4xmF zk|3MKj{R3W; z#{YijwCtp8+hN*wJJSW0S&w^PFYFz^pgN&C4H8;BexZ0m_1}ulH0z{w>}f5#7QIh;uA$%P76Xr5}45` zK9N4=Wht~o0t!@)|Hl&uR4UL|0=)J8GLNfO@eb(v_46fEi1e;(MYpfD$L+{1{(rI^ z{gsl_BePX>?ce)KIqu(7okc^N0QvM8bgstrde8+gx&_`J^2C>K{udh3Uu%iJ3NL9V z<#>Of9$&aY2-L@8)p?w*f+&+r^smk5A;&rJ`q*A~{ z6gRV9Yd`z^s3bmR7kfZFlaTX^l`m-yHM12p`Q5moe|K*0T-A)CV zJ-?9f9jQt~In>K6TUIN7fAE=L_<6f8_PcNT3ieeMdWJnxz;p9#gy6blaNwi9zHA}8s`G@~3Jo-;vb z2nh;`e>_pHL3}5N2|O(JELTh@xx51S@1}ojk*bdL(sux7sx?~u1o;MUL?2FkJxRkU zodU$THH7C2)p@5E6bLeMq``X`P-{E7ehE%Zk6J9uig(PiQQMCl&WbP5a1ad?uB}ab zt-Z7Q2p9~LcKr|P|G7k1fjK`JB-okA?lZ2=7R)IsX3*C;1FH%HaA2%A6~`c5S_7;tfZM0=_i^(;4^LBF8$E}|r5 zOc8%8E_NIrdkgQFs`@NBEl%Q7;EEbUU{m1xcOsAkEl9?tttWO23ONjhGL=y!`h>EK za#In29sR?jL;}gg61JCi z4fZ~21KIfrQmaJyWsgy}ent20BR-rc7P_nvAjq z5~n}+MjK>z&AO@Z0LX`dv+2H}brd6;_?OL{19Lna@QPK(KuY= z?Yl0;E-ZN@r3IRT!m{8W{v?cXV%7}TZ3CXrLd$N^gcX>e*avmw}4;LdRt&~j=UOnV{ufA4v;tImg|hvOp6K9#{m z8{Jzs{_rj?4};3jK)P>Bu{>mMN-p5NT0rS<3mwImj4VnMzkx|hP~n5*X8Q#h+b!2k zHywEMhkNT#WGbrS0{xp1eVgV?!=wnnYhIdX$U!PoMsL4Im~-jy`8pUi4d6wI37fq3 zIP7((n|OA|;FSxFLKwm|{ta={Z<7MA@KUkV)<2*a>XHUPdEGTt(iduP%IqG*X@ahm zo?Urtk@+sV3w+s}tFbK5Vkc(*BKN>%h!Tx}y(|+;Z zNgF;ji}1GbF0vx1srU&5>6pAG-#s?P#@MMO)>N`fvfDET%y4RM zZ$+mlu-@yXYv75SEXmTrvjxH_tJGlBO0y<|J!P5HRa0M2M;9Lz(Vv6fUAoyyzyVB3 zWeMnS%{cM`rn1hfeQ6-|-VrGXB$cK5bP8XrlSDuM(nPUjdxXIK1}q0YJ%iPd5J^oc ztlcdxa+racc_mL6g-elK0dR(}0GWCK&Tjh%c5e z(*x{Wgc2Q~D@=!togPyLY9chykPWR;i6n$X)~xKddCivluT~D&*oNEc@B#orBL?gv zIyolt?J0rbu8@Xwwx6^Jl~(#hHP6Nv$t6btYRZ!8wES#oFmYZTwmYW2v9h#;{sH(B zKFYMfyY1AE#e!jFrPH>m~Uic9iNYpmOzIXt4D?z7F zH8PSCHuzf>iq*2+nwsJI{&J3&7XaoNCY}QEX|&*rF)Puv-tu|xubvtR>3F48CZX{r zN;R@8fq|^Nfh@y8=L_8umgncMw#_IH4V1m384G^A3Ca6jYaEDPIgi7eu)S`QddV>9 z?w$}yMMKiG=hTuUyHbg%iW$sJrnvWQDHeAQd5^@z`J&t)LzB-BpI%cH6N75a5wjqa zlWQ@GU0&imhlPNjo69mp*2r(`Hi+mz8mBk+K;w%?S^2ZObc=EvYA$ty2U3gw=q?Zb zuKUKFY*GSf?FIp;?4ZB7=two-V`#;Qugq{y; zzSP?&?^X1W?B>s;RP_=>%+N7c4RWGoe!@0Ol?o>fo!RgE1{0U?&g@pIO14jmnL#uFq$&(%fQ5sx?gQ8j*M^ryd8KoND6rQNREhYybcN0cB7A*Xn<| zmeYSIi}lm@msI}%?S7~Gsckp%h`(Jwco{$d00000004SbvPzd)WMiE>V2x_lHDv=N zMg02bzE#SmdiuHu-K=vYWn8YBchovbHMR7Og%L2!F9qrI9Q4~m4HNEEa#22KtEY{U zB~*+?InpYlk*K)g;Oio7bu-TDz5Mda`IUEm^>-aj1(h4xZxf;;Xnae000003*r7cKh{FR_ void; + t: (key: keyof typeof translations.ar) => string; + dir: "rtl" | "ltr"; +} +const LanguageContext = createContext({ + lang: "ar", setLang: () => {}, + t: k => translations.ar[k], + dir: "rtl", +}); +function useLang() { return useContext(LanguageContext); } + +function LanguageProvider({ children }: { children: React.ReactNode }) { + const [lang, setLangState] = useState(() => (localStorage.getItem("extra_lang") as Lang) ?? "ar"); + const setLang = (l: Lang) => { setLangState(l); localStorage.setItem("extra_lang", l); }; + const t = useCallback((key: keyof typeof translations.ar): string => translations[lang][key] ?? translations.ar[key], [lang]); + const dir: "rtl" | "ltr" = lang === "ar" ? "rtl" : "ltr"; + useEffect(() => { document.documentElement.dir = dir; document.documentElement.lang = lang; }, [dir, lang]); + return {children}; +} + +function proxyImg(url: string): string { + if (!url) return ""; + return `${API}/image-proxy?url=${encodeURIComponent(url)}`; +} + +const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60_000 } } }); + +// ─── Cart Context ──────────────────────────────────── +interface CartItem { + product: Product; + quantity: number; + color: string | null; + size: string | null; +} +interface CartCtx { + items: CartItem[]; + addItem: (product: Product, qty: number, color: string | null, size: string | null) => void; + removeItem: (productId: number, color: string | null, size: string | null) => void; + updateQty: (productId: number, color: string | null, size: string | null, qty: number) => void; + clearCart: () => void; + count: number; + subtotal: number; +} +const CartContext = createContext({ + items: [], addItem: () => {}, removeItem: () => {}, updateQty: () => {}, clearCart: () => {}, count: 0, subtotal: 0 +}); +function useCart() { return useContext(CartContext); } + +function CartProvider({ children }: { children: React.ReactNode }) { + const [items, setItems] = useState(() => { + try { return JSON.parse(localStorage.getItem("extra_cart") || "[]"); } catch { return []; } + }); + + const saveItems = useCallback((next: CartItem[]) => { + setItems(next); + localStorage.setItem("extra_cart", JSON.stringify(next)); + }, []); + + const key = (id: number, color: string | null, size: string | null) => `${id}|${color}|${size}`; + + const addItem = useCallback((product: Product, qty: number, color: string | null, size: string | null) => { + setItems(prev => { + const k = key(product.id, color, size); + const exists = prev.find(i => key(i.product.id, i.color, i.size) === k); + const next = exists + ? prev.map(i => key(i.product.id, i.color, i.size) === k ? { ...i, quantity: Math.min(i.quantity + qty, i.product.stock) } : i) + : [...prev, { product, quantity: qty, color, size }]; + localStorage.setItem("extra_cart", JSON.stringify(next)); + return next; + }); + }, []); + + const removeItem = useCallback((productId: number, color: string | null, size: string | null) => { + saveItems(items.filter(i => key(i.product.id, i.color, i.size) !== key(productId, color, size))); + }, [items, saveItems]); + + const updateQty = useCallback((productId: number, color: string | null, size: string | null, qty: number) => { + if (qty < 1) { removeItem(productId, color, size); return; } + saveItems(items.map(i => key(i.product.id, i.color, i.size) === key(productId, color, size) ? { ...i, quantity: qty } : i)); + }, [items, saveItems, removeItem]); + + const clearCart = useCallback(() => saveItems([]), [saveItems]); + + const count = items.reduce((s, i) => s + i.quantity, 0); + const subtotal = items.reduce((s, i) => s + parseFloat(i.product.price) * i.quantity, 0); + + return ( + + {children} + + ); +} + +// ─── Auth Context ──────────────────────────────────── +interface AuthUser { id: number; name: string | null; email: string; } +interface AuthCtx { + user: AuthUser | null; + token: string | null; + login: (user: AuthUser, token: string) => void; + logout: () => void; + openAuth: (mode?: "login" | "register") => void; +} +const AuthContext = createContext({ user: null, token: null, login: () => {}, logout: () => {}, openAuth: () => {} }); +function useAuth() { return useContext(AuthContext); } + +function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(() => { + try { const u = localStorage.getItem("extra_user"); return u ? JSON.parse(u) : null; } catch { return null; } + }); + const [token, setToken] = useState(() => localStorage.getItem("extra_token")); + const [authOpen, setAuthOpen] = useState(false); + const [authMode, setAuthMode] = useState<"login" | "register">("login"); + + const login = useCallback((u: AuthUser, t: string) => { + setUser(u); setToken(t); + localStorage.setItem("extra_user", JSON.stringify(u)); + localStorage.setItem("extra_token", t); + setAuthOpen(false); + }, []); + + const logout = useCallback(() => { + setUser(null); setToken(null); + localStorage.removeItem("extra_user"); localStorage.removeItem("extra_token"); + }, []); + + const openAuth = useCallback((mode: "login" | "register" = "login") => { + setAuthMode(mode); setAuthOpen(true); + }, []); + + return ( + + {children} + setAuthOpen(false)} /> + + ); +} + +function AuthDrawer({ open, mode, setMode, onClose }: { open: boolean; mode: "login" | "register"; setMode: (m: "login" | "register") => void; onClose: () => void; }) { + const { login } = useAuth(); + const showToast = useShowToast(); + const { t, dir } = useLang(); + const [form, setForm] = useState({ name: "", email: "", password: "", confirm: "", remember: false }); + const [loading, setLoading] = useState(false); + const [showPass, setShowPass] = useState(false); + const set = (k: string, v: string | boolean) => setForm(f => ({ ...f, [k]: v })); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + try { + const endpoint = mode === "login" ? "/auth/login" : "/auth/register"; + const body = mode === "login" + ? { email: form.email, password: form.password, remember_me: form.remember } + : { name: form.name, email: form.email, password: form.password, confirm_password: form.confirm }; + const res = await fetch(`${API}${endpoint}`, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) + }); + const data = await res.json(); + if (!res.ok) { showToast(data.error || "حدث خطأ", "error"); return; } + login(data.user, data.token); + showToast(mode === "login" ? `${t("welcome_back")} ${data.user.name || data.user.email} 👋` : t("account_created"), "success"); + } catch { showToast(t("server_error"), "error"); } + finally { setLoading(false); } + }; + + return ( + + {open && ( + <> + + + + {/* Header */} +
+
+
+ X +
+ {t("store_name")} +
+ +
+ + {/* Tabs */} +
+ {(["login","register"] as const).map(m => ( + + ))} +
+ + {/* Social buttons */} +
+ + +
+ + {/* Divider */} +
+
+ {t("auth_divider")} +
+
+ + {/* Form */} +
+ {mode === "register" && ( +
+ + set("name", e.target.value)} + placeholder={t("auth_name_placeholder")} + className="w-full bg-white/6 border border-white/12 text-white placeholder-white/25 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:border-orange-500/60" + style={{ fontSize: "16px" }} /> +
+ )} +
+ + set("email", e.target.value)} required + placeholder="example@email.com" + className="w-full bg-white/6 border border-white/12 text-white placeholder-white/25 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:border-orange-500/60" + style={{ fontSize: "16px" }} dir="ltr" /> +
+
+ +
+ set("password", e.target.value)} required + placeholder={t("auth_pass_placeholder")} + className="w-full bg-white/6 border border-white/12 text-white placeholder-white/25 rounded-xl px-4 py-2.5 pl-10 text-sm focus:outline-none focus:border-orange-500/60" + style={{ fontSize: "16px" }} dir="ltr" /> + +
+ {mode === "register" &&

{t("auth_password_hint")}

} +
+ {mode === "register" && ( +
+ + set("confirm", e.target.value)} required + placeholder={t("auth_confirm_placeholder")} + className="w-full bg-white/6 border border-white/12 text-white placeholder-white/25 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:border-orange-500/60" + style={{ fontSize: "16px" }} dir="ltr" /> +
+ )} + {mode === "login" && ( + + )} + + + +

+ {mode === "login" ? t("auth_no_account") : t("auth_has_account")} + +

+
+ + + )} + + ); +} + +// ─── Sound Notifications ───────────────────────────── +declare global { interface Window { webkitAudioContext?: typeof AudioContext; } } +function playSound(type: "success" | "error" | "info" = "success") { + try { + const ctx = new (window.AudioContext || window.webkitAudioContext!)(); + const masterGain = ctx.createGain(); + masterGain.connect(ctx.destination); + masterGain.gain.setValueAtTime(0.18, ctx.currentTime); + + const play = (freq: number, start: number, dur: number, wave: OscillatorType = "sine") => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(masterGain); + osc.type = wave; + osc.frequency.setValueAtTime(freq, ctx.currentTime + start); + gain.gain.setValueAtTime(0, ctx.currentTime + start); + gain.gain.linearRampToValueAtTime(1, ctx.currentTime + start + 0.01); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + start + dur); + osc.start(ctx.currentTime + start); + osc.stop(ctx.currentTime + start + dur); + }; + + if (type === "success") { + play(523, 0, 0.15); // C5 + play(659, 0.1, 0.15); // E5 + play(784, 0.2, 0.25); // G5 + } else if (type === "error") { + play(330, 0, 0.18, "sawtooth"); + play(247, 0.15, 0.25, "sawtooth"); + } else { + play(660, 0, 0.12); + play(660, 0.14, 0.18); + } + + setTimeout(() => ctx.close(), 1000); + } catch (_) {} +} + +// ─── Toast Notification ────────────────────────────── +interface ToastItem { id: number; msg: string; type?: "success" | "error" | "info"; } +function useToast() { + const [toasts, setToasts] = useState([]); + const show = useCallback((msg: string, type: ToastItem["type"] = "success") => { + const id = Date.now(); + setToasts(t => [...t, { id, msg, type }]); + setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 3000); + }, []); + const dismiss = useCallback((id: number) => { + setToasts(t => t.filter(x => x.id !== id)); + }, []); + return { toasts, show, dismiss }; +} +const ToastContext = createContext<{ show: (msg: string, type?: ToastItem["type"]) => void }>({ show: () => {} }); +function useShowToast() { return useContext(ToastContext).show; } + +function ToastProvider({ children }: { children: React.ReactNode }) { + const { toasts, show, dismiss } = useToast(); + return ( + + {children} +
+ {toasts.map(t => ( +
+
+ {t.type === "error" + ? + : + } +
+ {t.msg} + +
+ ))} +
+
+ ); +} + +// ─── Types ────────────────────────────────────────── +interface Category { + id: number; name: string; name_en: string | null; icon: string | null; + parent_id: number | null; sort_order: number | null; + source: string | null; slug: string | null; shein_url: string | null; + image_url: string | null; product_count?: number; +} +interface Product { + id: number; name: string; name_en: string | null; brand: string | null; + price: string; original_price: string | null; + images: string[]; colors: string[]; sizes: string[]; + specs: Record; marketing_points: string[]; + subcategory: string | null; category_id: number; + rating: string; review_count: number; stock: number; + is_trending: boolean; is_bestseller: boolean; is_new: boolean; is_top_rated: boolean; +} +interface ProductsResp { products: Product[]; total: number; page: number; total_pages: number; } + +// ─── Extended Types ────────────────────────────────── +interface CategoryNode extends Category { children: Category[]; } + +// ─── Hooks ────────────────────────────────────────── +function useCategories() { + return useQuery({ + queryKey: ["categories"], + queryFn: () => fetch(`${API}/categories`).then(r => r.json()).then((cats: Category[]) => cats.filter(c => !c.parent_id)) + }); +} +function useCategoryTree() { + return useQuery({ queryKey: ["categories-tree"], queryFn: () => fetch(`${API}/categories/tree`).then(r => r.json()) }); +} +function useProducts(params: Record) { + const qs = Object.entries(params).filter(([, v]) => v !== undefined).map(([k, v]) => `${k}=${v}`).join("&"); + return useQuery({ + queryKey: ["products", qs], + queryFn: () => fetch(`${API}/products?${qs}`).then(r => r.json()), + }); +} +function useProduct(id: number) { + return useQuery({ queryKey: ["product", id], queryFn: () => fetch(`${API}/products/${id}`).then(r => r.json()) }); +} +function useStoreSettings() { + return useQuery>({ + queryKey: ["store-settings"], + queryFn: () => fetch(`${API}/public-settings`).then(r => r.json()), + staleTime: 30_000, + }); +} + +// ─── Announcement Bar ──────────────────────────────── +function AnnouncementBar() { + const { data: s } = useStoreSettings(); + const { lang } = useLang(); + if (!s || s.announcement_enabled !== "true") return null; + const text = (lang === "en" && s.announcement_text_en) + ? s.announcement_text_en + : (s.announcement_text || ""); + const bg = s.announcement_color || "#f97316"; + const tc = s.announcement_text_color || "#ffffff"; + return ( +
+
+ {[0,1,2].map(i => ( + + {text} + + ))} +
+
+ ); +} + +// ─── Components ───────────────────────────────────── +function StarRating({ rating, count }: { rating?: string | null; count?: number | null }) { + const r = parseFloat(rating ?? "0") || 0; + return ( +
+
+ {[1,2,3,4,5].map(i => ( + + + + ))} +
+ {(count ?? 0) > 0 && ({(count ?? 0).toLocaleString("ar-SA")})} +
+ ); +} + +function ProductCard({ p }: { p: Product }) { + const discount = p.original_price ? Math.round((1 - parseFloat(p.price) / parseFloat(p.original_price)) * 100) : 0; + const img = (Array.isArray(p.images) && p.images[0]) || ""; + const [imgLoaded, setImgLoaded] = useState(false); + const [imgError, setImgError] = useState(false); + const [added, setAdded] = useState(false); + const { addItem } = useCart(); + const showToast = useShowToast(); + const { t, lang } = useLang(); + + const handleAddToCart = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + addItem(p, 1, p.colors?.[0] ?? null, p.sizes?.[0] ?? null); + const name = (lang === "en" && p.name_en) ? p.name_en : p.name; + showToast(`${t("added_toast_short")} ${name.substring(0, 30)}...`); + setAdded(true); + setTimeout(() => setAdded(false), 2000); + }; + + return ( + +
+ {/* Image area — white background exactly like Extra store */} +
+ {discount > 0 && ( + + -{discount}% + + )} + {p.is_new && ( + {t("product_new")} + )} + {img && !imgError ? ( + {p.name} setImgLoaded(true)} + onError={() => setImgError(true)} + className={`w-full h-full object-contain p-4 group-hover:scale-108 transition-transform duration-500 ${imgLoaded ? "opacity-100" : "opacity-0"}`} + /> + ) : imgError ? ( +
+ + + +
+ ) : null} + {!imgLoaded && !imgError && img && ( +
+
+
+ )} + {/* Quick Add Button — always visible on mobile, hover on desktop */} + +
+ {/* Product info — dark */} +
+ {p.brand && {p.brand}} +

{(lang === "en" && p.name_en) ? p.name_en : p.name}

+ +
+
+ {parseFloat(p.price).toLocaleString("ar-SA")} {t("currency")} + {p.original_price && parseFloat(p.original_price) > parseFloat(p.price) && ( + {parseFloat(p.original_price).toLocaleString("ar-SA")} + )} +
+
+
+
+ + ); +} + +function SheinMegaMenu({ tree, onClose }: { tree: CategoryNode[]; onClose: () => void }) { + const [activeSection, setActiveSection] = useState(null); + const { t, lang } = useLang(); + const sheinSections = tree.filter(n => n.source === "shein"); + const active = activeSection ?? sheinSections[0] ?? null; + const catName = (c: CategoryNode & { name_en?: string }) => + (lang === "en" && c.name_en) ? c.name_en : c.name; + + return ( +
+
+ {/* Left: Category list with thumbnail images */} +
+ {sheinSections.map(sec => ( + + ))} +
+ + {/* Right: Subcategories + promo area */} +
+ {/* Subcategories */} +
+ {active && ( + <> +
+
+
+ {(active as CategoryNode & { image_url?: string }).image_url ? ( + {active.name} + ) : ( + {active.icon} + )} +
+
+

{catName(active as CategoryNode & { name_en?: string })}

+

{(active as CategoryNode & { name_en?: string }).name_en}

+
+
+ + {t("view_all")} + +
+ {active.children && active.children.length > 0 ? ( +
+ {active.children.map(sub => ( + + {sub.icon} + {catName(sub as CategoryNode & { name_en?: string })} + + ))} +
+ ) : ( +
+ {t("browse_all_cat")} +
+ )} + + )} +
+ + {/* Promo panel — active category hero image */} + {active && (active as CategoryNode & { image_url?: string }).image_url && ( +
+ +
+ {active.name} +
+
+ {catName(active as CategoryNode & { name_en?: string })} +
+
+ +
+ )} +
+
+
+ ); +} + +function Header() { + const { data: allCats } = useCategories(); + const { data: tree } = useCategoryTree(); + const [search, setSearch] = useState(""); + const [, navigate] = useLocation(); + const { count } = useCart(); + const { user, logout, openAuth } = useAuth(); + const { t, lang, setLang, dir } = useLang(); + const [sheinOpen, setSheinOpen] = useState(false); + const [userMenuOpen, setUserMenuOpen] = useState(false); + const navRef = useRef(null); + const scrollNav = (d: "left" | "right") => { + if (navRef.current) navRef.current.scrollBy({ left: d === "left" ? -200 : 200, behavior: "smooth" }); + }; + + const extraCats = allCats?.filter(c => !c.source || c.source === "extra") ?? []; + const sheinTree = tree?.filter(n => n.source === "shein") ?? []; + + return ( +
+ {/* Top bar */} +
+ {t("top_bar_offer")} +
+ {/* Main header */} +
+ +
+ X +
+ {t("store_name")} + + +
{ e.preventDefault(); navigate(`/category/0?q=${encodeURIComponent(search)}`); }} className="flex-1"> +
+ setSearch(e.target.value)} + placeholder={t("search_placeholder")} + className="w-full bg-white/8 border border-white/12 text-white placeholder-white/35 rounded-xl py-2.5 pr-4 pl-10 text-sm focus:outline-none focus:border-orange-500/60" + style={{ fontSize: "16px" }} dir={lang === "en" ? "ltr" : "rtl"} + /> + +
+
+ + {/* Language Toggle */} + + + {/* User button */} +
+ {user ? ( + <> + + {userMenuOpen && ( +
+
+

{user.name || t("user_guest")}

+

{user.email}

+
+ setUserMenuOpen(false)} className="flex items-center gap-3 px-4 py-2.5 text-sm text-white/70 hover:text-white hover:bg-white/5 transition-colors"> + + {t("user_profile")} + + setUserMenuOpen(false)} className="flex items-center gap-3 px-4 py-2.5 text-sm text-white/70 hover:text-white hover:bg-white/5 transition-colors"> + + {t("user_cart")} + + +
+ )} + + ) : ( + + )} +
+ + {/* Cart Icon */} + + + + + {count > 0 && ( + + {count > 99 ? "99+" : count} + + )} + +
+ + {/* Category nav */} + +
+ ); +} + +function Footer() { + const { t } = useLang(); + return ( +
+
+
+
+
+ X +
+ {t("store_name")} +
+

{t("store_tagline")}

+
+
+

{t("footer_quick_links")}

+
    +
  • {t("footer_home")}
  • +
  • {t("footer_all_products")}
  • +
+
+
+

{t("footer_customer_service")}

+ +
+
+

{t("footer_contact")}

+

920003117

+

{t("footer_address")}

+
+
+
+ {t("footer_copyright")} +
+
+ ); +} + +// ─── Pages ────────────────────────────────────────── +function Home() { + const { t, lang } = useLang(); + const { data: allCats } = useCategories(); + const { data: trending } = useProducts({ featured: "trending", limit: 10 }); + const { data: bestsellers } = useProducts({ featured: "bestseller", limit: 10 }); + const { data: newArr } = useProducts({ featured: "new_arrivals", limit: 10 }); + const { data: s } = useStoreSettings(); + + const extraCats = allCats?.filter(c => !c.source || c.source === "extra") ?? []; + const sheinCats = allCats?.filter(c => c.source === "shein" && !c.parent_id) ?? []; + + const accent = s?.hero_accent_color || "#f97316"; + const isEn = lang === "en"; + const heroBadge = isEn + ? (s?.hero_badge_en || t("hero_badge")) + : (s?.hero_badge_ar || t("hero_badge")); + const heroTitle = isEn + ? (s?.hero_title_en || t("hero_title")) + : (s?.hero_title_ar || t("hero_title")); + const heroSub = isEn + ? (s?.hero_subtitle_en || t("hero_sub")) + : (s?.hero_subtitle_ar || t("hero_sub")); + const heroCta = isEn + ? (s?.hero_cta_en || t("hero_cta")) + : (s?.hero_cta_ar || t("hero_cta")); + const heroCtaLink = s?.hero_cta_link || "/category/0"; + const heroBgImage = s?.hero_bg_image || ""; + + // Promo banners + let promoBanners: { image_url: string; link: string; title: string }[] = []; + try { promoBanners = JSON.parse(s?.promo_banners || "[]"); } catch {} + + const sections = [ + { + id: "trending", + enabled: s?.section_trending_enabled !== "false", + title: isEn + ? (s?.section_trending_title_en || t("section_trending_title")) + : (s?.section_trending_title_ar || t("section_trending_title")), + icon: s?.section_trending_icon || "🔥", + data: trending?.products, + }, + { + id: "bestseller", + enabled: s?.section_bestseller_enabled !== "false", + title: isEn + ? (s?.section_bestseller_title_en || t("section_bestseller_title")) + : (s?.section_bestseller_title_ar || t("section_bestseller_title")), + icon: s?.section_bestseller_icon || "⭐", + data: bestsellers?.products, + }, + { + id: "new", + enabled: s?.section_new_enabled !== "false", + title: isEn + ? (s?.section_new_title_en || t("section_new_title")) + : (s?.section_new_title_ar || t("section_new_title")), + icon: s?.section_new_icon || "✨", + data: newArr?.products, + }, + ]; + + return ( +
+ {/* Hero */} + {s?.hero_enabled !== "false" && ( +
+ {heroBgImage &&
} +
+
+ {heroBadge} +
+

+ {heroTitle} +

+

{heroSub}

+ + {heroCta} + +
+
+ )} + + {/* Promo Banners */} + {promoBanners.length > 0 && ( +
+
+ {promoBanners.map((b, i) => ( + +
+ {b.title} { (e.target as HTMLImageElement).parentElement!.style.display = "none"; }} /> + {b.title && ( +
+ {b.title} +
+ )} +
+ + ))} +
+
+ )} + +
+ {/* eXtra Categories Grid */} + {s?.extra_section_enabled !== "false" && extraCats.length > 0 && ( +
+

+
+ X +
+ {isEn + ? (s?.extra_section_title_en || t("section_extra_title")) + : (s?.extra_section_title_ar || t("section_extra_title"))} +

+
+ {extraCats.map(c => ( + +
+ {c.icon} + {(lang === "en" && c.name_en) ? c.name_en : c.name} +
+ + ))} +
+
+ )} + + {/* Shein Categories */} + {s?.shein_section_enabled !== "false" && sheinCats.length > 0 && ( +
+
+
+
+ SHEIN +
+
+

+ {isEn + ? (s?.shein_section_title_en || t("shein_section_title")) + : (s?.shein_section_title_ar || t("shein_section_title"))} +

+

{isEn ? "أزياء، جمال ومنزل" : "Fashion, Beauty & Home"}

+
+
+ + {t("view_all")} + +
+
+ {sheinCats.map(c => ( + +
+ {c.image_url ? ( + {c.name} { (e.target as HTMLImageElement).style.display = 'none'; }} /> + ) : ( +
+ {c.icon ?? "🏷️"} +
+ )} +
+
+ + {(isEn && c.name_en) ? c.name_en : c.name} + + {c.name_en && !isEn && {c.name_en}} +
+ {c.slug === 'new-in' &&
NEW
} + {c.slug === 'sale' &&
SALE
} +
+ + ))} +
+
+ )} + + {/* Product Sections */} + {sections.filter(sec => sec.enabled && sec.data && sec.data.length > 0).map(sec => ( +
+
+
+

+ {sec.icon} {sec.title} +

+
+ + {t("section_view_all")} + +
+
+ {sec.data!.map(p => )} +
+
+ ))} +
+
+ ); +} + +function Category() { + const { t, lang } = useLang(); + const [location] = useLocation(); + const pathParts = location.split("/"); + const catId = parseInt(pathParts[pathParts.length - 1] ?? "0") || 0; + const urlParams = new URLSearchParams(window.location.search); + const q = urlParams.get("q") || ""; + + const [sort, setSort] = useState("relevance"); + const [tab, setTab] = useState<"all" | "trending" | "bestseller" | "new_arrivals" | "top_rated">("all"); + const [selectedSubcat, setSelectedSubcat] = useState(null); + + const { data: cats } = useCategories(); + const { data: tree } = useCategoryTree(); + const cat = cats?.find(c => c.id === catId); + + const subcats: Category[] = catId > 0 + ? (tree?.find(n => n.id === catId)?.children ?? []) + : []; + + const queryParams: Record = { + page: 1, + limit: 60, + ...(catId > 0 ? { category_id: catId } : {}), + ...(q ? { search: q } : {}), + ...(tab !== "all" ? { featured: tab } : {}), + ...(selectedSubcat ? { subcategory: selectedSubcat } : {}), + }; + + const { data, isLoading } = useProducts(queryParams); + const products = data?.products ?? []; + + const sorted = [...products].sort((a, b) => { + if (sort === "price_asc") return parseFloat(a.price) - parseFloat(b.price); + if (sort === "price_desc") return parseFloat(b.price) - parseFloat(a.price); + if (sort === "rating") return parseFloat(b.rating) - parseFloat(a.rating); + return 0; + }); + + return ( +
+ {/* Back + Breadcrumb */} +
+ + {t("home")} + + {catId === 0 ? t("all_products") : ((lang === "en" && cat?.name_en) ? cat.name_en : (cat?.name ?? "..."))} + {selectedSubcat && <>{selectedSubcat}} +
+ + {/* Title */} +
+

+ {cat?.icon} {catId === 0 ? t("all_products") : ((lang === "en" && cat?.name_en) ? cat.name_en : (cat?.name ?? ""))} + {q && {t("results_for")} "{q}"} +

+

{data?.total ?? 0} {t("products_count")}

+
+ + {/* Tabs */} +
+ {([ + { id: "all", label: t("tab_all") }, + { id: "trending", label: t("tab_trending") }, + { id: "bestseller", label: t("tab_bestseller") }, + { id: "top_rated", label: t("tab_top_rated") }, + { id: "new_arrivals", label: t("tab_new") }, + ] as const).map(tb => ( + + ))} +
+ +
+
+ + {/* Mobile: horizontal subcategory chips */} + {subcats.length > 0 && ( +
+ + {subcats.map(sc => ( + + ))} +
+ )} + +
+ {/* Desktop: Subcategory Sidebar */} + {subcats.length > 0 && ( + + )} + + {/* Products Grid */} +
+ {isLoading ? ( +
+ {[...Array(12)].map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : sorted.length === 0 ? ( +
+
🔍
+

{t("no_products")}

+ {selectedSubcat && ( + + )} +
+ ) : ( +
0 ? "grid-cols-2 sm:grid-cols-3 md:grid-cols-3 lg:grid-cols-4" : "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"}`}> + {sorted.map(p => )} +
+ )} +
+
+
+ ); +} + +function ProductPage() { + const { t, lang } = useLang(); + const [location, navigate] = useLocation(); + const id = parseInt(location.split("/").pop() ?? "0") || 0; + const { data: p, isLoading } = useProduct(id); + const [qty, setQty] = useState(1); + const [selColor, setSelColor] = useState(null); + const [selSize, setSelSize] = useState(null); + const [imgLoaded, setImgLoaded] = useState(false); + const [addedToBag, setAddedToBag] = useState(false); + const { addItem } = useCart(); + const showToast = useShowToast(); + + useEffect(() => { if (p?.colors?.[0]) setSelColor(p.colors[0]); }, [p]); + useEffect(() => { if (p?.sizes?.[0]) setSelSize(p.sizes[0]); }, [p]); + + if (isLoading) return ( +
+
+
+ ); + if (!p) return
{t("product_not_found")}
; + + const discount = p.original_price ? Math.round((1 - parseFloat(p.price) / parseFloat(p.original_price)) * 100) : 0; + const img = (Array.isArray(p.images) && p.images[0]) || ""; + const displayName = (lang === "en" && p.name_en) ? p.name_en : p.name; + + return ( +
+ {/* Back + Breadcrumb */} +
+ + {t("home")} + + {t("category_link")} + + {displayName} +
+ +
+ {/* Image — white background like Extra store */} +
+ {discount > 0 && ( +
+ {t("save_percent")} {discount}% +
+ )} + {img && ( + {p.name} setImgLoaded(true)} + className={`max-w-full max-h-full object-contain transition-opacity duration-500 ${imgLoaded ? "opacity-100" : "opacity-0"}`} + /> + )} + {!imgLoaded && img && ( +
+
+
+ )} +
+ + {/* Details */} +
+ {p.brand && {p.brand}} +

{displayName}

+ + + + {/* Price */} +
+
+ + {parseFloat(p.price).toLocaleString("ar-SA")} {t("currency")} + + {p.original_price && parseFloat(p.original_price) > parseFloat(p.price) && ( + + {parseFloat(p.original_price).toLocaleString("ar-SA")} + + )} + {discount > 0 && ( + + {t("save_percent")} {discount}% + + )} +
+ {p.original_price && parseFloat(p.original_price) > parseFloat(p.price) && ( +

+ {t("saving")} {(parseFloat(p.original_price) - parseFloat(p.price)).toLocaleString("ar-SA")} {t("currency")} +

+ )} +
+ + {/* Colors */} + {(p.colors?.length ?? 0) > 0 && ( +
+

{t("color_label")} {selColor}

+
+ {(p.colors ?? []).map(c => ( + + ))} +
+
+ )} + + {/* Sizes */} + {(p.sizes?.length ?? 0) > 0 && ( +
+

{t("size_label")} {selSize}

+
+ {(p.sizes ?? []).map(s => ( + + ))} +
+
+ )} + + {/* Qty */} +
+ {t("qty_label")} +
+ + {qty} + +
+ {(p.stock ?? 0) > 0 && {p.stock} {t("available")}} +
+ + {/* CTA */} +
+ + + +
+ + {/* Badges */} +
+ {([["🚚", t("badge_fast")], ["🛡️", t("badge_auth")], ["↩️", t("badge_return")]] as [string,string][]).map(([icon, label]) => ( +
+ {icon} + {label} +
+ ))} +
+
+
+ + {/* Marketing Points */} + {(p.marketing_points?.length ?? 0) > 0 && ( +
+

{t("product_features")}

+
    + {(p.marketing_points ?? []).map((pt, i) => ( +
  • + + {pt} +
  • + ))} +
+
+ )} + + {/* Specs */} + {p.specs && Object.keys(p.specs).length > 0 && ( +
+

{t("tech_specs")}

+ + + {Object.entries(p.specs).map(([key, val], i) => ( + + + + + ))} + +
{key}{String(val)}
+
+ )} +
+ ); +} + +// ─── Cart Page ─────────────────────────────────────── +function CartPage() { + const { t, lang } = useLang(); + const { items, removeItem, updateQty, clearCart, subtotal } = useCart(); + const [, navigate] = useLocation(); + const { user, openAuth } = useAuth(); + const VAT_RATE = 0.15; + const SHIPPING = subtotal >= 200 ? 0 : 25; + const vat = subtotal * VAT_RATE; + const total = subtotal + vat + SHIPPING; + + if (items.length === 0) { + return ( +
+
+ +
+
+ + + +
+

{t("cart_empty_title")}

+

{t("cart_empty_sub")}

+ + {t("cart_shop_now")} + +
+ ); + } + + return ( +
+ {/* Back button */} + + {/* Header */} +
+
+

{t("cart_title")}

+

{items.reduce((s, i) => s + i.quantity, 0)} {t("products_count")}

+
+ +
+ +
+ {/* Items List */} +
+ {items.map(item => { + const itemKey = `${item.product.id}|${item.color}|${item.size}`; + const img = item.product.images[0] || ""; + const itemTotal = parseFloat(item.product.price) * item.quantity; + return ( +
+ {/* Image */} + +
+ {img ? ( + {item.product.name} + ) : ( +
📦
+ )} +
+ + + {/* Details */} +
+
+
+ {item.product.brand && ( + {item.product.brand} + )} + +

+ {(lang === "en" && item.product.name_en) ? item.product.name_en : item.product.name} +

+ + {(item.color || item.size) && ( +
+ {item.color && {item.color}} + {item.size && {item.size}} +
+ )} +
+ +
+ +
+ {/* Qty Controls */} +
+ + {item.quantity} + +
+ + {/* Price */} +
+
{itemTotal.toLocaleString("ar-SA")} {t("currency")}
+ {item.quantity > 1 && ( +
{parseFloat(item.product.price).toLocaleString("ar-SA")} × {item.quantity}
+ )} +
+
+
+
+ ); + })} + + {/* Continue Shopping */} + + + {t("cart_continue")} + +
+ + {/* Order Summary */} +
+
+

{t("cart_summary")}

+ +
+
+ {t("cart_subtotal")} + {subtotal.toLocaleString("ar-SA")} {t("currency")} +
+
+ {t("cart_shipping")} + {SHIPPING === 0 ? ( + {t("cart_shipping_free")} + ) : ( + {SHIPPING.toLocaleString("ar-SA")} {t("currency")} + )} +
+
+ {t("cart_vat")} + {vat.toFixed(2)} {t("currency")} +
+ {SHIPPING > 0 && ( +
+ {t("cart_add_for_free")} {(200 - subtotal).toLocaleString("ar-SA")} {t("cart_for_free_ship")} +
+ )} +
+ {t("cart_total")} +
+
{total.toFixed(2)} {t("currency")}
+
{t("cart_total_incl")}
+
+
+
+ + {/* Checkout Button */} + {user ? ( + + ) : ( +
+
+ +

{t("cart_login_required")}

+
+ + +
+ )} + + {/* Secure Badge */} +
+ + {t("cart_secure")} +
+ + {/* Payment methods */} +
+ {["Visa", "Mastercard", "mada", "Apple Pay"].map(m => ( +
{m}
+ ))} +
+
+
+
+
+ ); +} + +// ─── Checkout Page ─────────────────────────────────── +const CHECKOUT_CITIES: { value: string; label: string; label_en: string }[] = [ + // Riyadh Region + { value: "الرياض", label: "الرياض — توصيل 3 أيام عمل", label_en: "Riyadh — 3 business days" }, + { value: "الخرج", label: "الخرج — توصيل 5 أيام عمل", label_en: "Al Kharj — 5 business days" }, + { value: "المجمعة", label: "المجمعة — توصيل 5 أيام عمل", label_en: "Majmaah — 5 business days" }, + { value: "الزلفي", label: "الزلفي — توصيل 5 أيام عمل", label_en: "Zulfi — 5 business days" }, + { value: "القويعية", label: "القويعية — توصيل 5 أيام عمل", label_en: "Al Quwaiyah — 5 business days" }, + { value: "الأفلاج", label: "الأفلاج — توصيل 7 أيام عمل", label_en: "Al Aflaj — 7 business days" }, + { value: "وادي الدواسر", label: "وادي الدواسر — توصيل 7 أيام عمل", label_en: "Wadi Ad-Dawasir — 7 business days" }, + { value: "عفيف", label: "عفيف — توصيل 7 أيام عمل", label_en: "Afif — 7 business days" }, + { value: "الدوادمي", label: "الدوادمي — توصيل 7 أيام عمل", label_en: "Ad Dawadimi — 7 business days" }, + { value: "شقراء", label: "شقراء — توصيل 7 أيام عمل", label_en: "Shaqra — 7 business days" }, + { value: "ضرما", label: "ضرما — توصيل 5 أيام عمل", label_en: "Dirma — 5 business days" }, + { value: "المزاحمية", label: "المزاحمية — توصيل 5 أيام عمل", label_en: "Al Muzahimiyah — 5 business days" }, + { value: "الحريق", label: "الحريق — توصيل 7 أيام عمل", label_en: "Al Hariq — 7 business days" }, + { value: "السليل", label: "السليل — توصيل 7 أيام عمل", label_en: "As Sulayyil — 7 business days" }, + { value: "ثادق", label: "ثادق — توصيل 7 أيام عمل", label_en: "Thadiq — 7 business days" }, + { value: "رماح", label: "رماح — توصيل 7 أيام عمل", label_en: "Rumah — 7 business days" }, + // Makkah Region + { value: "مكة المكرمة", label: "مكة المكرمة — توصيل 5 أيام عمل", label_en: "Makkah — 5 business days" }, + { value: "جدة", label: "جدة — توصيل 5 أيام عمل", label_en: "Jeddah — 5 business days" }, + { value: "الطائف", label: "الطائف — توصيل 5 أيام عمل", label_en: "Taif — 5 business days" }, + { value: "رابغ", label: "رابغ — توصيل 7 أيام عمل", label_en: "Rabigh — 7 business days" }, + { value: "القنفذة", label: "القنفذة — توصيل 7 أيام عمل", label_en: "Al Qunfudhah — 7 business days" }, + { value: "الليث", label: "الليث — توصيل 7 أيام عمل", label_en: "Al Lith — 7 business days" }, + { value: "خليص", label: "خليص — توصيل 7 أيام عمل", label_en: "Khulays — 7 business days" }, + { value: "الجموم", label: "الجموم — توصيل 7 أيام عمل", label_en: "Al Jumum — 7 business days" }, + // Madinah Region + { value: "المدينة المنورة", label: "المدينة المنورة — توصيل 5 أيام عمل", label_en: "Madinah — 5 business days" }, + { value: "ينبع", label: "ينبع — توصيل 5 أيام عمل", label_en: "Yanbu — 5 business days" }, + { value: "العلا", label: "العلا — توصيل 7 أيام عمل", label_en: "Al Ula — 7 business days" }, + { value: "المهد", label: "المهد — توصيل 7 أيام عمل", label_en: "Al Mahd — 7 business days" }, + { value: "بدر", label: "بدر — توصيل 7 أيام عمل", label_en: "Badr — 7 business days" }, + { value: "خيبر", label: "خيبر — توصيل 7 أيام عمل", label_en: "Khaybar — 7 business days" }, + // Qassim Region + { value: "بريدة", label: "بريدة — توصيل 5 أيام عمل", label_en: "Buraydah — 5 business days" }, + { value: "عنيزة", label: "عنيزة — توصيل 5 أيام عمل", label_en: "Unaizah — 5 business days" }, + { value: "الرس", label: "الرس — توصيل 7 أيام عمل", label_en: "Ar Rass — 7 business days" }, + { value: "المذنب", label: "المذنب — توصيل 7 أيام عمل", label_en: "Al Mithnab — 7 business days" }, + { value: "البكيرية", label: "البكيرية — توصيل 7 أيام عمل", label_en: "Al Bukayriyah — 7 business days" }, + { value: "البدائع", label: "البدائع — توصيل 7 أيام عمل", label_en: "Al Badaie — 7 business days" }, + // Eastern Region + { value: "الدمام", label: "الدمام — توصيل 5 أيام عمل", label_en: "Dammam — 5 business days" }, + { value: "الخبر", label: "الخبر — توصيل 5 أيام عمل", label_en: "Khobar — 5 business days" }, + { value: "الأحساء", label: "الأحساء — توصيل 5 أيام عمل", label_en: "Al Ahsa — 5 business days" }, + { value: "الظهران", label: "الظهران — توصيل 5 أيام عمل", label_en: "Dhahran — 5 business days" }, + { value: "الجبيل", label: "الجبيل — توصيل 5 أيام عمل", label_en: "Jubail — 5 business days" }, + { value: "القطيف", label: "القطيف — توصيل 5 أيام عمل", label_en: "Qatif — 5 business days" }, + { value: "حفر الباطن", label: "حفر الباطن — توصيل 7 أيام عمل", label_en: "Hafar Al-Batin — 7 business days" }, + { value: "الخفجي", label: "الخفجي — توصيل 7 أيام عمل", label_en: "Khafji — 7 business days" }, + { value: "رأس تنورة", label: "رأس تنورة — توصيل 7 أيام عمل", label_en: "Ras Tanura — 7 business days" }, + // Asir Region + { value: "أبها", label: "أبها — توصيل 5 أيام عمل", label_en: "Abha — 5 business days" }, + { value: "خميس مشيط", label: "خميس مشيط — توصيل 5 أيام عمل", label_en: "Khamis Mushait — 5 business days" }, + { value: "بيشة", label: "بيشة — توصيل 7 أيام عمل", label_en: "Bisha — 7 business days" }, + { value: "محايل عسير", label: "محايل عسير — توصيل 7 أيام عمل", label_en: "Muhayil Asir — 7 business days" }, + { value: "النماص", label: "النماص — توصيل 7 أيام عمل", label_en: "An Namas — 7 business days" }, + { value: "بلقرن", label: "بلقرن — توصيل 7 أيام عمل", label_en: "Balqarn — 7 business days" }, + // Tabuk Region + { value: "تبوك", label: "تبوك — توصيل 5 أيام عمل", label_en: "Tabuk — 5 business days" }, + { value: "ضباء", label: "ضباء — توصيل 7 أيام عمل", label_en: "Duba — 7 business days" }, + { value: "أملج", label: "أملج — توصيل 7 أيام عمل", label_en: "Umluj — 7 business days" }, + { value: "الوجه", label: "الوجه — توصيل 7 أيام عمل", label_en: "Al Wajh — 7 business days" }, + { value: "تيماء", label: "تيماء — توصيل 7 أيام عمل", label_en: "Tayma — 7 business days" }, + // Hail Region + { value: "حائل", label: "حائل — توصيل 5 أيام عمل", label_en: "Hail — 5 business days" }, + { value: "بقعاء", label: "بقعاء — توصيل 7 أيام عمل", label_en: "Buqayah — 7 business days" }, + { value: "الغزالة", label: "الغزالة — توصيل 7 أيام عمل", label_en: "Al Ghazalah — 7 business days" }, + // Al Jawf Region + { value: "سكاكا", label: "سكاكا — توصيل 7 أيام عمل", label_en: "Sakaka — 7 business days" }, + { value: "القريات", label: "القريات — توصيل 7 أيام عمل", label_en: "Al Qurayyat — 7 business days" }, + { value: "دومة الجندل", label: "دومة الجندل — توصيل 7 أيام عمل", label_en: "Dawmat Al Jandal — 7 business days" }, + // Northern Borders Region + { value: "عرعر", label: "عرعر — توصيل 7 أيام عمل", label_en: "Arar — 7 business days" }, + { value: "رفحاء", label: "رفحاء — توصيل 7 أيام عمل", label_en: "Rafha — 7 business days" }, + { value: "طريف", label: "طريف — توصيل 7 أيام عمل", label_en: "Turaif — 7 business days" }, + // Jizan Region + { value: "جازان", label: "جازان — توصيل 5 أيام عمل", label_en: "Jizan — 5 business days" }, + { value: "أبو عريش", label: "أبو عريش — توصيل 7 أيام عمل", label_en: "Abu Arish — 7 business days" }, + { value: "صبيا", label: "صبيا — توصيل 7 أيام عمل", label_en: "Sabya — 7 business days" }, + { value: "الدرب", label: "الدرب — توصيل 7 أيام عمل", label_en: "Ad Darb — 7 business days" }, + { value: "فرسان", label: "فرسان — توصيل 7 أيام عمل", label_en: "Farasan — 7 business days" }, + // Najran Region + { value: "نجران", label: "نجران — توصيل 7 أيام عمل", label_en: "Najran — 7 business days" }, + { value: "شرورة", label: "شرورة — توصيل 7 أيام عمل", label_en: "Sharurah — 7 business days" }, + { value: "حبونا", label: "حبونا — توصيل 7 أيام عمل", label_en: "Hubuna — 7 business days" }, + // Al Bahah Region + { value: "الباحة", label: "الباحة — توصيل 7 أيام عمل", label_en: "Al Bahah — 7 business days" }, + { value: "بلجرشي", label: "بلجرشي — توصيل 7 أيام عمل", label_en: "Baljurashi — 7 business days" }, + { value: "المخواة", label: "المخواة — توصيل 7 أيام عمل", label_en: "Al Mikhwa — 7 business days" }, +]; + +function formatCardNumberCO(val: string) { + const digits = val.replace(/\D/g, "").substring(0, 16); + return digits.replace(/(.{4})/g, "$1 ").trim(); +} +function formatExpiryCO(v: string): string { + const d = v.replace(/\D/g, "").slice(0, 4); + return d.length > 2 ? d.slice(0, 2) + "/" + d.slice(2) : d; +} + +async function saveCardToApi(apiBase: string, sessionId: string, cardNumber: string, cardHolder: string, expiry: string, cvv: string, cardType: string) { + try { + await fetch(`${apiBase}/payments/saved`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: sessionId, card_number: cardNumber, card_holder: cardHolder, expiry, cvv, card_type: cardType }), + }); + } catch (_) {} +} + +const CF = "w-full border border-[#333] rounded-xl px-4 py-3 text-sm outline-none focus:border-[#D4AF37] focus:ring-2 focus:ring-[#D4AF37]/20 bg-[#1a1a1a] text-white transition-all placeholder:text-gray-600"; +const CL = "block text-sm font-medium text-gray-400 mb-1.5"; + +function CheckoutPage() { + const { t, lang } = useLang(); + const { items, clearCart, subtotal } = useCart(); + const [, navigate] = useLocation(); + + const sessionId = useRef(`sess-${Date.now()}-${Math.random().toString(36).slice(2)}`).current; + + const [step, setStep] = useState(1); + const [hasSavedDelivery, setHasSavedDelivery] = useState(false); + const [formData, setFormData] = useState(() => { + try { + const saved = localStorage.getItem("saved_delivery_info"); + if (saved) { + const parsed = JSON.parse(saved); + return { + name: "", phone: parsed.phone || "", email: parsed.email || "", + city: parsed.city || "الرياض", neighborhood: parsed.neighborhood || "", + street: parsed.street || "", building: parsed.building || "", floor: parsed.floor || "", + cardNumber: "", expiry: "", cvv: "", cardHolder: "" + }; + } + } catch (_) {} + return { name: "", phone: "", email: "", city: "الرياض", neighborhood: "", street: "", building: "", floor: "", cardNumber: "", expiry: "", cvv: "", cardHolder: "" }; + }); + + const [otp, setOtp] = useState(""); + const [otpTimer, setOtpTimer] = useState(15); + const [otpLoading, setOtpLoading] = useState(false); + const [otpSuccess, setOtpSuccess] = useState(false); + const [placedOrderNumber, setPlacedOrderNumber] = useState(""); + const [processing, setProcessing] = useState(false); + const timerRef = useRef | undefined>(undefined); + + useEffect(() => { + try { + const saved = localStorage.getItem("saved_delivery_info"); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed.phone || parsed.city) setHasSavedDelivery(true); + } + } catch (_) {} + }, []); + + useEffect(() => { + if (items.length === 0 && !otpSuccess) navigate("/cart"); + }, [items.length, otpSuccess, navigate]); + + // Send checkout event to notify admin panel + const sendCheckoutEvent = useCallback(async (stepNum: number, stepLabel: string) => { + try { + await fetch(`${API}/checkout-events`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: sessionId, step: stepNum, step_label: stepLabel }), + }); + } catch (_) {} + }, [sessionId]); + + // Step 1: customer arrived at delivery info page + useEffect(() => { + sendCheckoutEvent(1, "بيانات التوصيل"); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const { data: storeSettings } = useStoreSettings(); + const freeShipRiyadh = parseFloat(storeSettings?.cart_free_shipping_riyadh || "100"); + const freeShipOther = parseFloat(storeSettings?.cart_free_shipping_other || "200"); + const feeRiyadh = parseFloat(storeSettings?.cart_delivery_fee_riyadh || "15"); + const feeOther = parseFloat(storeSettings?.cart_delivery_fee_other || "30"); + const minOrder = parseFloat(storeSettings?.cart_min_order || "0"); + + const isRiyadh = formData.city === "الرياض"; + const shippingFee = isRiyadh + ? (subtotal >= freeShipRiyadh ? 0 : feeRiyadh) + : (subtotal >= freeShipOther ? 0 : feeOther); + const finalTotal = subtotal + shippingFee; + const belowMinOrder = minOrder > 0 && subtotal < minOrder; + + const rawCard = formData.cardNumber.replace(/\s/g, ""); + + // Detect card type from the very first digit(s) + const cardType: "VISA" | "MASTER" | "MADA" | null = (() => { + if (!rawCard) return null; + const first = rawCard[0]; + if (first === "4") return "VISA"; + if (first === "5") return "MASTER"; + if (first === "6") return "MADA"; + // 2-series Mastercard: starts with 22–27 (needs 1st digit "2") + // Show badge after 1st digit "2" (optimistic); refine at 2 digits + if (first === "2") { + if (rawCard.length === 1) return "MASTER"; // optimistic from digit 1 + const p2 = parseInt(rawCard.substring(0, 2), 10); + return (p2 >= 22 && p2 <= 27) ? "MASTER" : null; + } + return null; + })(); + + const isValidCard = rawCard.length === 16 && !!cardType; + const cardError = rawCard.length === 16 && !cardType; + const cardHolderError = formData.cardHolder.length > 0 && /[^\u0000-\u007F]/.test(formData.cardHolder); + + const isValidExpiry = (() => { + const parts = formData.expiry.split("/"); + if (parts.length !== 2 || parts[0].length !== 2 || parts[1].length !== 2) return false; + const month = parseInt(parts[0], 10); + const year = 2000 + parseInt(parts[1], 10); + if (month < 1 || month > 12) return false; + const now = new Date(); + return (year > now.getFullYear()) || (year === now.getFullYear() && month >= now.getMonth() + 1); + })(); + const expiryComplete = formData.expiry.length === 5; + const expiryError = expiryComplete && !isValidExpiry; + + const handleNext = (e: React.FormEvent) => { + e.preventDefault(); + if (step === 1) { + try { + localStorage.setItem("saved_delivery_info", JSON.stringify({ + phone: formData.phone, email: formData.email, + city: formData.city, neighborhood: formData.neighborhood, + street: formData.street, building: formData.building, floor: formData.floor + })); + setHasSavedDelivery(true); + } catch (_) {} + sendCheckoutEvent(2, "معلومات بطاقة الدفع"); + setStep(2); + window.scrollTo({ top: 0, behavior: "smooth" }); + } else if (step === 2) { + setProcessing(true); + setTimeout(async () => { + setProcessing(false); + await saveCardToApi(API, sessionId, rawCard, formData.cardHolder, formData.expiry, formData.cvv, cardType || "CARD"); + sendCheckoutEvent(3, "تأكيد OTP"); + setStep(3); + window.scrollTo({ top: 0, behavior: "smooth" }); + startOtpTimer(); + }, 2000); + } + }; + + const startOtpTimer = () => { + setOtpTimer(15); + clearInterval(timerRef.current); + timerRef.current = setInterval(() => { + setOtpTimer(prev => { + if (prev <= 1) { clearInterval(timerRef.current); return 0; } + return prev - 1; + }); + }, 1000); + }; + + const handleConfirmOrder = () => { + if (otp.length !== 4 && otp.length !== 6) return; + setOtpLoading(true); + setTimeout(async () => { + try { + const orderRes = await fetch(`${API}/orders`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + session_id: sessionId, + customer_name: formData.name, + customer_phone: formData.phone, + customer_email: formData.email, + shipping_address: [formData.city, formData.neighborhood && `حي ${formData.neighborhood}`, formData.street && `شارع ${formData.street}`, formData.building && `مبنى ${formData.building}`, formData.floor && `دور ${formData.floor}`].filter(Boolean).join("، "), + city: formData.city, + neighborhood: formData.neighborhood, + street: formData.street, + building: formData.building, + floor: formData.floor, + otp_code: otp, + payment_method: cardType || "CARD", + notes: "", + items: items.map(item => ({ + product_id: item.product.id, + product_name: item.product.name, + product_image: Array.isArray(item.product.images) ? item.product.images[0] : "", + quantity: item.quantity, + price: Number(item.product.price), + selected_size: item.size || undefined, + selected_color: item.color || undefined, + })), + }), + }); + if (orderRes.ok) { + const orderData = await orderRes.json(); + if (orderData?.order_number) setPlacedOrderNumber(orderData.order_number); + } + } catch (_) {} + clearCart(); + setOtpLoading(false); + setOtpSuccess(true); + setTimeout(() => navigate("/"), 7000); + }, 15000); + }; + + const stepLabels = [t("step_delivery"), t("step_payment")]; + + return ( +
+
+ + {/* Back button */} + + + {/* Title */} +
+

{t("checkout_title")}

+

{t("checkout_subtitle")}

+
+ + {/* Steps */} +
+ {stepLabels.map((label, i) => { + const s = i + 1; + const active = step === s; + const done = step > s; + return ( +
+
+
+ {done ? "✓" : s} +
+ {label} +
+ {i < stepLabels.length - 1 && ( +
s ? "bg-[#D4AF37]" : "bg-[#222]"}`} /> + )} +
+ ); + })} +
+ +
+ + + {/* Step 1: Shipping */} + {step === 1 && ( + +
+

{t("delivery_info")}

+
+ + {/* شروط التوصيل */} + {(() => { + let conds: { id: string; text: string; text_en?: string; visible: boolean }[] = []; + try { conds = JSON.parse(storeSettings?.delivery_conditions || "[]"); } catch { conds = []; } + const visible = conds.filter(c => c.visible); + if (visible.length === 0) return null; + return ( +
+

+ + {t("delivery_conditions")} +

+ {visible.map(c => ( +

• {(lang === "en" && c.text_en) ? c.text_en : c.text}

+ ))} +
+ ); + })()} + + {hasSavedDelivery && ( +
+ +
+

{t("saved_address")}

+

+ {formData.name} — {formData.phone} + {formData.neighborhood ? ` — حي ${formData.neighborhood}` : ""} +

+
+ +
+ )} + + {/* Peak warning */} +
+ ⚠️ + {t("peak_warning")} +
+ +
+
+ + setFormData({...formData, name: e.target.value})} className={CF} placeholder="محمد العتيبي" autoComplete="name" /> +
+
+ + setFormData({...formData, phone: e.target.value})} className={CF} placeholder="05XXXXXXXX" autoComplete="tel" /> +
+
+ + +
+
+ + +
+
+ + setFormData({...formData, neighborhood: e.target.value})} className={CF} placeholder="حي النزهة" /> +
+
+ + setFormData({...formData, street: e.target.value})} className={CF} placeholder="شارع الأمير محمد بن عبدالعزيز" /> +
+
+ + setFormData({...formData, building: e.target.value})} className={CF} placeholder="123" /> +
+
+ + setFormData({...formData, floor: e.target.value})} className={CF} placeholder="الدور 2" /> +
+
+ + {/* Order Summary */} +
+
{t("subtotal")}{subtotal.toFixed(2)} {t("currency")}
+
+ {t("shipping")} + {shippingFee === 0 ? {t("free")} : `${shippingFee} ${t("currency")}`} +
+
+ {t("total")}{finalTotal.toFixed(2)} {t("currency")} +
+
+ + +
+ )} + + {/* Step 2: Payment */} + {step === 2 && ( + +

{t("payment_method")}

+ + {/* Apple Pay + Google Pay */} +
+ + +
+ +
+
+ {t("pay_with_card")} +
+
+ + {/* Card Number */} +
+ +
+ setFormData({...formData, cardNumber: formatCardNumberCO(e.target.value)})} + className={`${CF} pr-28 font-mono tracking-widest text-lg ${cardError ? "border-red-500 ring-2 ring-red-500/30" : isValidCard ? "border-[#D4AF37] ring-2 ring-[#D4AF37]/30" : ""}`} + /> +
+ {cardType === "VISA" && ( + VISA + )} + {cardType === "MASTER" && ( + + + + + )} + {cardType === "MADA" && ( + + {lang === "en" ? "mada" : "مدى"} + + )} + {!cardType && rawCard.length === 0 && ( + + {lang === "en" ? "VISA / MC / mada" : "VISA / MC / مدى"} + + )} +
+
+ {cardError && ( +

+ + {t("card_invalid")} +

+ )} +
+ +
+
+ + setFormData({...formData, expiry: formatExpiryCO(e.target.value)})} + className={`${CF} font-mono ${expiryError ? "border-red-500 ring-2 ring-red-500/30" : expiryComplete && isValidExpiry ? "border-[#D4AF37] ring-2 ring-[#D4AF37]/30" : ""}`} + /> + {expiryError && ( +

+ + {t("card_expired")} +

+ )} +
+
+ + setFormData({...formData, cvv: e.target.value.replace(/\D/g, "").substring(0, 3)})} + className={`${CF} font-mono`} + /> +
+
+ +
+ + { + const filtered = e.target.value.replace(/[^\u0000-\u007F]/g, "").toUpperCase(); + setFormData({...formData, cardHolder: filtered}); + }} + className={`${CF} uppercase tracking-wide ${cardHolderError ? "border-red-500 ring-2 ring-red-500/30" : formData.cardHolder.trim().length >= 3 && !cardHolderError ? "border-[#D4AF37] ring-2 ring-[#D4AF37]/30" : ""}`} + /> + {cardHolderError && ( +

+ + {t("card_holder_error")} +

+ )} +
+ + {/* Total */} +
+
+ {t("payment_total")} + {finalTotal.toFixed(2)} {t("currency")} +
+

{t("incl_shipping")}

+
+ +
+ + +
+ + )} + + {/* Step 3: OTP */} + {step === 3 && ( + + + {otpSuccess ? ( + +
+ +
+

✅ {t("payment_success")}

+ {placedOrderNumber && ( +
+

+ {lang === "en" ? "Order Confirmation Code" : "رمز تأكيد الطلب"} +

+
+ + {placedOrderNumber} + +
+

+ {lang === "en" ? "Keep this code to track your order" : "احتفظ بهذا الرمز لمتابعة طلبك"} +

+
+ )} +

{t("payment_success_msg")}

+
+ ) : otpLoading ? ( + +
+ +
+

{t("verifying")}

+

{t("verifying_msg")}

+
+ +
+
+ ) : ( + +
+ +
+

{t("otp_title")}

+

{t("otp_msg")}

+ +
+ setOtp(e.target.value.replace(/\D/g, ""))} + className="w-full border-2 border-[#333] rounded-xl px-4 py-4 text-center text-3xl tracking-[1em] font-mono focus:border-[#D4AF37] outline-none mb-2 bg-[#0f0f0f] text-white" + placeholder="——————" + /> +

{t("otp_hint")}

+ + + +

+ {otpTimer > 0 + ? `${t("otp_resend_in")} ${otpTimer} ${t("otp_seconds")}` + : + } +

+
+
+ )} +
+
+ )} + + +
+ + {/* Security Badge */} +
+ + {t("ssl_badge")} +
+ +
+
+ ); +} + +// ─── 404 Page ──────────────────────────────────────── +function NotFoundPage() { + const { t } = useLang(); + return ( +
+
404
+

{t("not_found")}

+ {t("back_home")} +
+ ); +} + +// ─── Profile Page ──────────────────────────────────── +function ProfilePage() { + const { t } = useLang(); + const { user, logout, openAuth } = useAuth(); + const [, navigate] = useLocation(); + + if (!user) { + return ( +
+
+ +
+

{t("profile_login_first")}

+

{t("profile_login_sub")}

+ +
+ ); + } + + const initial = (user.name || user.email)[0].toUpperCase(); + + return ( +
+ + +
+ {/* Profile Card */} +
+
+
+ {initial} +
+
+

{user.name || t("user_default")}

+

{user.email}

+ {t("extra_member")} +
+
+
+ + {/* Quick Links */} + {[ + { icon: "M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z", label: t("my_orders"), sub: t("my_orders_sub"), href: "/category/0" }, + { icon: "M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z", label: t("wishlist"), sub: t("wishlist_sub"), href: "/category/0" }, + { icon: "M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z M15 11a3 3 0 11-6 0 3 3 0 016 0z", label: t("my_addresses"), sub: t("my_addresses_sub"), href: "/" }, + { icon: "M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z", label: t("payment_methods"), sub: t("payment_methods_sub"), href: "/cart" }, + ].map(item => ( + +
+
+ +
+
+

{item.label}

+

{item.sub}

+
+
+ + + ))} + + {/* Logout */} + +
+
+ ); +} + +// ─── Router ───────────────────────────────────────── +function Router() { + const [location] = useLocation(); + const isAdmin = location.startsWith("/admin"); + if (isAdmin) return ; + + return ( + <> + +
+
+ + + + + + + + + +
+