commit 2bedf7cbb74dcb17417c45481ae99c48d871403a Author: Ryuk Mike Date: Wed Jul 29 23:58:58 2026 +0200 Modulo makers3d desarrollado con codex V 0.0.1 diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/02b1e7cd-092a-4f47-95c4-cc457293c9ad/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/02b1e7cd-092a-4f47-95c4-cc457293c9ad/1-Photo-1.jpg new file mode 100644 index 0000000..1702b6f Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/02b1e7cd-092a-4f47-95c4-cc457293c9ad/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/13d1918e-55e4-4cf6-b5a7-3422f6ca9a1a/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/13d1918e-55e4-4cf6-b5a7-3422f6ca9a1a/1-Photo-1.jpg new file mode 100644 index 0000000..f0c515c Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/13d1918e-55e4-4cf6-b5a7-3422f6ca9a1a/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/14e519f4-5b02-4290-9610-d6df48f0af9a/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/14e519f4-5b02-4290-9610-d6df48f0af9a/1-Photo-1.jpg new file mode 100644 index 0000000..4fa96b3 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/14e519f4-5b02-4290-9610-d6df48f0af9a/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/14e519f4-5b02-4290-9610-d6df48f0af9a/2-Photo-2.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/14e519f4-5b02-4290-9610-d6df48f0af9a/2-Photo-2.jpg new file mode 100644 index 0000000..c2ddb1a Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/14e519f4-5b02-4290-9610-d6df48f0af9a/2-Photo-2.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/14e519f4-5b02-4290-9610-d6df48f0af9a/3-Photo-3.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/14e519f4-5b02-4290-9610-d6df48f0af9a/3-Photo-3.jpg new file mode 100644 index 0000000..79cd181 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/14e519f4-5b02-4290-9610-d6df48f0af9a/3-Photo-3.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/14e519f4-5b02-4290-9610-d6df48f0af9a/4-Photo-4.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/14e519f4-5b02-4290-9610-d6df48f0af9a/4-Photo-4.jpg new file mode 100644 index 0000000..264b971 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/14e519f4-5b02-4290-9610-d6df48f0af9a/4-Photo-4.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/14e519f4-5b02-4290-9610-d6df48f0af9a/5-Photo-5.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/14e519f4-5b02-4290-9610-d6df48f0af9a/5-Photo-5.jpg new file mode 100644 index 0000000..445cf36 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/14e519f4-5b02-4290-9610-d6df48f0af9a/5-Photo-5.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/1c51dd79-e8bd-4fbb-b1dc-24f6ab118aff/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/1c51dd79-e8bd-4fbb-b1dc-24f6ab118aff/1-Photo-1.jpg new file mode 100644 index 0000000..afc353f Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/1c51dd79-e8bd-4fbb-b1dc-24f6ab118aff/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/1c51dd79-e8bd-4fbb-b1dc-24f6ab118aff/2-Photo-2.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/1c51dd79-e8bd-4fbb-b1dc-24f6ab118aff/2-Photo-2.jpg new file mode 100644 index 0000000..052c94d Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/1c51dd79-e8bd-4fbb-b1dc-24f6ab118aff/2-Photo-2.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/20b7c530-6727-4d45-bb4b-9c8c6c19b899/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/20b7c530-6727-4d45-bb4b-9c8c6c19b899/1-Photo-1.jpg new file mode 100644 index 0000000..842139a Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/20b7c530-6727-4d45-bb4b-9c8c6c19b899/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/23b5b6a8-770f-4883-b0fe-adbadaef20f2/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/23b5b6a8-770f-4883-b0fe-adbadaef20f2/1-Photo-1.jpg new file mode 100644 index 0000000..07874d3 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/23b5b6a8-770f-4883-b0fe-adbadaef20f2/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/23d31cd4-60a2-43aa-b6ad-b0f674ab1dbe/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/23d31cd4-60a2-43aa-b6ad-b0f674ab1dbe/1-Photo-1.jpg new file mode 100644 index 0000000..b9e6113 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/23d31cd4-60a2-43aa-b6ad-b0f674ab1dbe/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/2541aa5e-233f-4617-acc2-f89d0178f08c/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/2541aa5e-233f-4617-acc2-f89d0178f08c/1-Photo-1.jpg new file mode 100644 index 0000000..7febd14 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/2541aa5e-233f-4617-acc2-f89d0178f08c/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/2541aa5e-233f-4617-acc2-f89d0178f08c/2-Photo-2.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/2541aa5e-233f-4617-acc2-f89d0178f08c/2-Photo-2.jpg new file mode 100644 index 0000000..7c1515c Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/2541aa5e-233f-4617-acc2-f89d0178f08c/2-Photo-2.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/2541aa5e-233f-4617-acc2-f89d0178f08c/3-Photo-3.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/2541aa5e-233f-4617-acc2-f89d0178f08c/3-Photo-3.jpg new file mode 100644 index 0000000..a457374 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/2541aa5e-233f-4617-acc2-f89d0178f08c/3-Photo-3.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/2541aa5e-233f-4617-acc2-f89d0178f08c/4-Photo-4.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/2541aa5e-233f-4617-acc2-f89d0178f08c/4-Photo-4.jpg new file mode 100644 index 0000000..0edf225 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/2541aa5e-233f-4617-acc2-f89d0178f08c/4-Photo-4.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/2541aa5e-233f-4617-acc2-f89d0178f08c/5-Photo-5.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/2541aa5e-233f-4617-acc2-f89d0178f08c/5-Photo-5.jpg new file mode 100644 index 0000000..e14322c Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/2541aa5e-233f-4617-acc2-f89d0178f08c/5-Photo-5.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/2ef47c7e-5568-46ef-8be1-50905c2b142b/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/2ef47c7e-5568-46ef-8be1-50905c2b142b/1-Photo-1.jpg new file mode 100644 index 0000000..e998237 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/2ef47c7e-5568-46ef-8be1-50905c2b142b/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/487fbfd5-baa9-4c31-be9e-ba4445a9b038/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/487fbfd5-baa9-4c31-be9e-ba4445a9b038/1-Photo-1.jpg new file mode 100644 index 0000000..5706014 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/487fbfd5-baa9-4c31-be9e-ba4445a9b038/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/487fbfd5-baa9-4c31-be9e-ba4445a9b038/2-Photo-2.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/487fbfd5-baa9-4c31-be9e-ba4445a9b038/2-Photo-2.jpg new file mode 100644 index 0000000..12c8f67 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/487fbfd5-baa9-4c31-be9e-ba4445a9b038/2-Photo-2.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/487fbfd5-baa9-4c31-be9e-ba4445a9b038/3-Photo-3.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/487fbfd5-baa9-4c31-be9e-ba4445a9b038/3-Photo-3.jpg new file mode 100644 index 0000000..12bfacc Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/487fbfd5-baa9-4c31-be9e-ba4445a9b038/3-Photo-3.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/487fbfd5-baa9-4c31-be9e-ba4445a9b038/4-Photo-4.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/487fbfd5-baa9-4c31-be9e-ba4445a9b038/4-Photo-4.jpg new file mode 100644 index 0000000..be13a71 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/487fbfd5-baa9-4c31-be9e-ba4445a9b038/4-Photo-4.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/487fbfd5-baa9-4c31-be9e-ba4445a9b038/5-Photo-5.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/487fbfd5-baa9-4c31-be9e-ba4445a9b038/5-Photo-5.jpg new file mode 100644 index 0000000..fcc46e2 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/487fbfd5-baa9-4c31-be9e-ba4445a9b038/5-Photo-5.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/6ac1798f-f8d3-41d0-95c4-10485b5dc691/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/6ac1798f-f8d3-41d0-95c4-10485b5dc691/1-Photo-1.jpg new file mode 100644 index 0000000..b0f55db Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/6ac1798f-f8d3-41d0-95c4-10485b5dc691/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/6ac1798f-f8d3-41d0-95c4-10485b5dc691/2-Photo-2.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/6ac1798f-f8d3-41d0-95c4-10485b5dc691/2-Photo-2.jpg new file mode 100644 index 0000000..de5ad3b Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/6ac1798f-f8d3-41d0-95c4-10485b5dc691/2-Photo-2.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/6bade71f-a4d9-49e7-a8ab-aa076e231927/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/6bade71f-a4d9-49e7-a8ab-aa076e231927/1-Photo-1.jpg new file mode 100644 index 0000000..1e78007 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/6bade71f-a4d9-49e7-a8ab-aa076e231927/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/72253c6b-1130-49d2-b2ed-73ede168e03f/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/72253c6b-1130-49d2-b2ed-73ede168e03f/1-Photo-1.jpg new file mode 100644 index 0000000..5dd18b7 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/72253c6b-1130-49d2-b2ed-73ede168e03f/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/7e285638-629e-4b33-a92a-72fc524f3ff8/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/7e285638-629e-4b33-a92a-72fc524f3ff8/1-Photo-1.jpg new file mode 100644 index 0000000..539eb69 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/7e285638-629e-4b33-a92a-72fc524f3ff8/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/8278266b-5b53-4eb2-8cbb-2c868abe8e47/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/8278266b-5b53-4eb2-8cbb-2c868abe8e47/1-Photo-1.jpg new file mode 100644 index 0000000..fa52036 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/8278266b-5b53-4eb2-8cbb-2c868abe8e47/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/87009bba-11df-41d6-bba4-56e4539592e9/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/87009bba-11df-41d6-bba4-56e4539592e9/1-Photo-1.jpg new file mode 100644 index 0000000..775e330 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/87009bba-11df-41d6-bba4-56e4539592e9/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/9bd0c753-ffc5-4071-a65b-0fa0b3540e64/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/9bd0c753-ffc5-4071-a65b-0fa0b3540e64/1-Photo-1.jpg new file mode 100644 index 0000000..e0af946 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/9bd0c753-ffc5-4071-a65b-0fa0b3540e64/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/9c8792ba-60df-4120-9e06-8852b8377521/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/9c8792ba-60df-4120-9e06-8852b8377521/1-Photo-1.jpg new file mode 100644 index 0000000..5e98ad3 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/9c8792ba-60df-4120-9e06-8852b8377521/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/aa0970b4-2751-4ff2-b162-fdf571ba5425/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/aa0970b4-2751-4ff2-b162-fdf571ba5425/1-Photo-1.jpg new file mode 100644 index 0000000..8abc0c5 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/aa0970b4-2751-4ff2-b162-fdf571ba5425/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/af92eb25-bf76-4355-bac7-d5bc85b6d678/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/af92eb25-bf76-4355-bac7-d5bc85b6d678/1-Photo-1.jpg new file mode 100644 index 0000000..539eb69 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/af92eb25-bf76-4355-bac7-d5bc85b6d678/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/bec2fe72-527c-4097-83dc-77409097ac92/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/bec2fe72-527c-4097-83dc-77409097ac92/1-Photo-1.jpg new file mode 100644 index 0000000..22d0e73 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/bec2fe72-527c-4097-83dc-77409097ac92/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/c11aa3a2-a462-4f42-8430-b9f7779bb014/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/c11aa3a2-a462-4f42-8430-b9f7779bb014/1-Photo-1.jpg new file mode 100644 index 0000000..92f7470 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/c11aa3a2-a462-4f42-8430-b9f7779bb014/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/cb898bd9-d4c7-49cd-b68e-a9f2c76ee308/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/cb898bd9-d4c7-49cd-b68e-a9f2c76ee308/1-Photo-1.jpg new file mode 100644 index 0000000..54d3155 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/cb898bd9-d4c7-49cd-b68e-a9f2c76ee308/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/cb898bd9-d4c7-49cd-b68e-a9f2c76ee308/2-Photo-2.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/cb898bd9-d4c7-49cd-b68e-a9f2c76ee308/2-Photo-2.jpg new file mode 100644 index 0000000..dcfab41 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/cb898bd9-d4c7-49cd-b68e-a9f2c76ee308/2-Photo-2.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/cee5f7de-83dd-4bee-abbe-42f123a6dfd4/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/cee5f7de-83dd-4bee-abbe-42f123a6dfd4/1-Photo-1.jpg new file mode 100644 index 0000000..1a817ed Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/cee5f7de-83dd-4bee-abbe-42f123a6dfd4/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/d23cc6d5-92f1-4e7f-9bab-f13eb1570cd6/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/d23cc6d5-92f1-4e7f-9bab-f13eb1570cd6/1-Photo-1.jpg new file mode 100644 index 0000000..e18c8b8 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/d23cc6d5-92f1-4e7f-9bab-f13eb1570cd6/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/e116138a-f272-4ae4-9a69-9861a4d31cc5/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/e116138a-f272-4ae4-9a69-9861a4d31cc5/1-Photo-1.jpg new file mode 100644 index 0000000..f4de23b Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/e116138a-f272-4ae4-9a69-9861a4d31cc5/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/e116138a-f272-4ae4-9a69-9861a4d31cc5/2-Photo-2.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/e116138a-f272-4ae4-9a69-9861a4d31cc5/2-Photo-2.jpg new file mode 100644 index 0000000..8f3a242 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/e116138a-f272-4ae4-9a69-9861a4d31cc5/2-Photo-2.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/eaf42b8d-c252-46db-95f8-b6e02a07a7b8/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/eaf42b8d-c252-46db-95f8-b6e02a07a7b8/1-Photo-1.jpg new file mode 100644 index 0000000..853a57b Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/eaf42b8d-c252-46db-95f8-b6e02a07a7b8/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/eebc402d-12db-4792-8af1-dd1a222910ed/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/eebc402d-12db-4792-8af1-dd1a222910ed/1-Photo-1.jpg new file mode 100644 index 0000000..7370af5 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/eebc402d-12db-4792-8af1-dd1a222910ed/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/f746162b-da4c-4258-9a6a-c23fbec6285a/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/f746162b-da4c-4258-9a6a-c23fbec6285a/1-Photo-1.jpg new file mode 100644 index 0000000..2014462 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/f746162b-da4c-4258-9a6a-c23fbec6285a/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/f8abe582-6fb9-498e-a291-f6259058640d/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/f8abe582-6fb9-498e-a291-f6259058640d/1-Photo-1.jpg new file mode 100644 index 0000000..80f0364 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/f8abe582-6fb9-498e-a291-f6259058640d/1-Photo-1.jpg differ diff --git a/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/f8c5977c-5647-4948-a9a4-1462a8757dde/1-Photo-1.jpg b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/f8c5977c-5647-4948-a9a4-1462a8757dde/1-Photo-1.jpg new file mode 100644 index 0000000..e2e8b66 Binary files /dev/null and b/.codex-remote-attachments/019f62f4-5a7d-7510-b2e7-3009d6e9992c/f8c5977c-5647-4948-a9a4-1462a8757dde/1-Photo-1.jpg differ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..84bc8fa --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +POSTGRES_DB=makers3d +POSTGRES_USER=makers3d +POSTGRES_PASSWORD=makers3d +DATABASE_URL=postgres://makers3d:makers3d@postgres:5432/makers3d +SESSION_SECRET=change-me +API_PORT=4000 +WEB_PORT=3000 +APP_URL=https://dev.jazari.com.ar +NEXT_PUBLIC_API_BASE_URL=/api/v1 +INTERNAL_API_URL=http://api:4000/api/v1 +MINIO_ROOT_USER=minioadmin +MINIO_ROOT_PASSWORD=minioadmin +MINIO_ENDPOINT=minio +MINIO_PORT=9000 +MINIO_USE_SSL=false +MINIO_PUBLIC_BUCKET=makers3d-public +MINIO_PRIVATE_BUCKET=makers3d-private +MERCADOPAGO_ACCESS_TOKEN= +PAYMENT_PROVIDER=demo +ADMIN_EMAIL=admin@makers3d.local +ADMIN_PASSWORD=Admin123! diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8697f66 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +.next/ +dist/ +coverage/ +.env +.env.local +.DS_Store +*.log +tmp/ +data/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..a871b1d --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# Makers3D MVP + +Implementacion funcional del MVP documentado en `docs/`. + +## Requisitos + +- Docker +- Docker Compose + +## Arranque rapido + +1. Copiar `.env.example` a `.env`. +2. Ejecutar `docker compose up --build -d`. +3. Ejecutar migraciones y seed: + - `docker compose exec api npm run db:migrate` + - `docker compose exec api npm run db:seed` +4. Abrir `http://localhost`. + +## Credenciales demo + +- Admin: `admin@makers3d.local` / `Admin123!` +- Maker demo: `maker1@makers3d.local` / `Maker123!` +- Cliente demo: `cliente1@makers3d.local` / `Cliente123!` + +## Servicios + +- `web`: Next.js +- `api`: Fastify + PostgreSQL +- `worker`: tareas asincronas simples +- `postgres`: base de datos +- `minio`: almacenamiento S3-compatible +- `nginx`: proxy de entrada + diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..2eeeaab --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,19 @@ +FROM node:22-bookworm-slim AS build +WORKDIR /app +COPY package.json tsconfig.base.json ./ +COPY apps/api/package.json apps/api/package.json +COPY packages/shared/package.json packages/shared/package.json +RUN npm install +COPY . . +RUN npm run build -w @makers3d/shared && npm run build -w @makers3d/api + +FROM node:22-bookworm-slim +WORKDIR /app +COPY --from=build /app/package.json ./ +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/apps/api/dist ./apps/api/dist +COPY --from=build /app/apps/api/src/db/schema.sql ./apps/api/src/db/schema.sql +COPY --from=build /app/packages/shared/package.json ./packages/shared/package.json +COPY --from=build /app/packages/shared/dist ./packages/shared/dist +ENV NODE_ENV=production +CMD ["node", "apps/api/dist/apps/api/src/index.js"] diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..c13e1ea --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,30 @@ +{ + "name": "@makers3d/api", + "version": "0.1.0", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "node dist/apps/api/src/index.js", + "dev": "tsx watch src/index.ts", + "db:migrate": "node dist/apps/api/src/scripts/migrate.js", + "db:seed": "node dist/apps/api/src/scripts/seed.js", + "test": "node --import tsx --test src/**/*.test.ts", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@fastify/cookie": "^11.0.2", + "@fastify/cors": "^11.1.0", + "@fastify/formbody": "^8.0.2", + "@fastify/multipart": "^9.2.1", + "@makers3d/shared": "0.1.0", + "argon2": "^0.44.0", + "fastify": "^5.6.1", + "minio": "^8.0.6", + "pg": "^8.16.3", + "zod": "^4.1.12" + }, + "devDependencies": { + "@types/pg": "^8.15.6" + } +} diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts new file mode 100644 index 0000000..d4ede5a --- /dev/null +++ b/apps/api/src/auth.ts @@ -0,0 +1,100 @@ +import { createHash, randomBytes } from "node:crypto"; + +import argon2 from "argon2"; +import type { FastifyReply, FastifyRequest } from "fastify"; + +import { query, one } from "./db.js"; + +export const sessionMaxAgeSeconds = 60 * 60 * 24 * 400; + +export type CurrentUser = { + id: string; + email: string; + role: "user" | "admin"; +}; + +declare module "fastify" { + interface FastifyRequest { + currentUser: CurrentUser | null; + } +} + +export async function hashPassword(password: string): Promise { + return argon2.hash(password, { type: argon2.argon2id }); +} + +export async function verifyPassword(hash: string, password: string): Promise { + return argon2.verify(hash, password); +} + +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +export async function createSession(userId: string): Promise { + const token = randomBytes(32).toString("hex"); + const tokenHash = hashToken(token); + await query( + ` + insert into sessions (user_id, token_hash, expires_at) + values ($1, $2, now() + ($3::int * interval '1 second')) + `, + [userId, tokenHash, sessionMaxAgeSeconds] + ); + return token; +} + +export async function destroySession(token: string): Promise { + await query(`delete from sessions where token_hash = $1`, [hashToken(token)]); +} + +export async function refreshSession(token: string): Promise { + await query( + ` + update sessions + set expires_at = now() + ($2::int * interval '1 second') + where token_hash = $1 + and expires_at > now() + `, + [hashToken(token), sessionMaxAgeSeconds] + ); +} + +export async function getUserFromSessionToken(token: string): Promise { + return one( + ` + select u.id, u.email, u.role + from sessions s + join users u on u.id = s.user_id + where s.token_hash = $1 + and s.expires_at > now() + `, + [hashToken(token)] + ); +} + +export async function attachCurrentUser(request: FastifyRequest): Promise { + const token = request.cookies.makers3d_session; + if (!token) { + request.currentUser = null; + return; + } + + request.currentUser = await getUserFromSessionToken(token); +} + +export function requireAuth(request: FastifyRequest, reply: FastifyReply): CurrentUser | null { + if (!request.currentUser) { + reply.code(401).send({ error: "Authentication required" }); + return null; + } + return request.currentUser; +} + +export function requireAdmin(request: FastifyRequest, reply: FastifyReply): CurrentUser | null { + if (!request.currentUser || request.currentUser.role !== "admin") { + reply.code(403).send({ error: "Admin access required" }); + return null; + } + return request.currentUser; +} diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts new file mode 100644 index 0000000..03a4156 --- /dev/null +++ b/apps/api/src/config.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; + +const booleanFromEnv = z.preprocess((value) => { + if (typeof value === "boolean") { + return value; + } + + if (typeof value === "string") { + return value === "true" || value === "1"; + } + + return false; +}, z.boolean()); + +const configSchema = z.object({ + DATABASE_URL: z.string().min(1), + SESSION_SECRET: z.string().min(8).default("change-me"), + API_PORT: z.coerce.number().default(4000), + APP_URL: z.string().default("http://localhost"), + MINIO_ROOT_USER: z.string().default("minioadmin"), + MINIO_ROOT_PASSWORD: z.string().default("minioadmin"), + MINIO_ENDPOINT: z.string().default("minio"), + MINIO_PORT: z.coerce.number().default(9000), + MINIO_USE_SSL: booleanFromEnv.default(false), + MINIO_PUBLIC_BUCKET: z.string().default("makers3d-public"), + MINIO_PRIVATE_BUCKET: z.string().default("makers3d-private"), + PAYMENT_PROVIDER: z.enum(["demo", "mercadopago"]).default("demo"), + MERCADOPAGO_ACCESS_TOKEN: z.string().optional(), + ADMIN_EMAIL: z.string().email().default("admin@makers3d.local"), + ADMIN_PASSWORD: z.string().min(8).default("Admin123!") +}); + +export const config = configSchema.parse(process.env); diff --git a/apps/api/src/db.ts b/apps/api/src/db.ts new file mode 100644 index 0000000..4be25ea --- /dev/null +++ b/apps/api/src/db.ts @@ -0,0 +1,18 @@ +import { Pool, type QueryResultRow } from "pg"; + +import { config } from "./config.js"; + +export const pool = new Pool({ + connectionString: config.DATABASE_URL +}); + +export async function query(sql: string, params: unknown[] = []): Promise { + const result = await pool.query(sql, params); + return result.rows; +} + +export async function one(sql: string, params: unknown[] = []): Promise { + const rows = await query(sql, params); + return rows[0] ?? null; +} + diff --git a/apps/api/src/db/schema.sql b/apps/api/src/db/schema.sql new file mode 100644 index 0000000..c6bef74 --- /dev/null +++ b/apps/api/src/db/schema.sql @@ -0,0 +1,277 @@ +create extension if not exists pgcrypto; + +create table if not exists users ( + id uuid primary key default gen_random_uuid(), + email text not null unique, + password_hash text not null, + full_name text not null, + role text not null default 'user' check (role in ('user', 'admin')), + created_at timestamptz not null default now() +); + +create table if not exists sessions ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references users(id) on delete cascade, + token_hash text not null unique, + expires_at timestamptz not null, + created_at timestamptz not null default now() +); + +create table if not exists maker_profiles ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null unique references users(id) on delete cascade, + slug text not null unique, + business_name text, + description text, + province text, + city text, + latitude double precision, + longitude double precision, + public_latitude double precision, + public_longitude double precision, + delivery_scope text, + availability text not null default 'available' check (availability in ('available', 'limited', 'unavailable')), + public_contact_email text, + public_whatsapp text, + website_url text, + instagram_url text, + tiktok_url text, + twitter_url text, + facebook_url text, + youtube_url text, + linkedin_url text, + allow_verified_reviews boolean not null default true, + show_satisfaction_score boolean not null default true, + show_review_tags boolean not null default true, + allow_maker_review_reply boolean not null default true, + show_past_reviews boolean not null default true, + status text not null default 'draft' check (status in ('draft', 'published', 'suspended')), + main_image_url text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +alter table maker_profiles add column if not exists website_url text; +alter table maker_profiles add column if not exists instagram_url text; +alter table maker_profiles add column if not exists tiktok_url text; +alter table maker_profiles add column if not exists twitter_url text; +alter table maker_profiles add column if not exists facebook_url text; +alter table maker_profiles add column if not exists youtube_url text; +alter table maker_profiles add column if not exists linkedin_url text; +alter table maker_profiles add column if not exists allow_verified_reviews boolean not null default true; +alter table maker_profiles add column if not exists show_satisfaction_score boolean not null default true; +alter table maker_profiles add column if not exists show_review_tags boolean not null default true; +alter table maker_profiles add column if not exists allow_maker_review_reply boolean not null default true; +alter table maker_profiles add column if not exists show_past_reviews boolean not null default true; + +create table if not exists services ( + id uuid primary key default gen_random_uuid(), + maker_id uuid not null references maker_profiles(id) on delete cascade, + slug text not null unique, + title text not null, + category text not null, + description text not null, + technologies text[] not null default '{}', + materials text[] not null default '{}', + local_pickup boolean not null default true, + nationwide_shipping boolean not null default false, + lead_time_days integer not null default 3, + price_from_cents integer, + draft_data jsonb not null default '{}'::jsonb, + is_published boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +alter table services add column if not exists draft_data jsonb not null default '{}'::jsonb; + +create table if not exists works ( + id uuid primary key default gen_random_uuid(), + maker_id uuid not null references maker_profiles(id) on delete cascade, + service_id uuid references services(id) on delete set null, + slug text not null unique, + title text not null, + summary text not null, + story text not null, + technology text not null, + material text not null, + image_url text not null, + gallery_urls text[] not null default '{}', + draft_data jsonb not null default '{}'::jsonb, + is_published boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +alter table works add column if not exists draft_data jsonb not null default '{}'::jsonb; + +create table if not exists showcases ( + id uuid primary key default gen_random_uuid(), + maker_id uuid not null references maker_profiles(id) on delete cascade, + slug text not null unique, + title text not null, + description text not null, + cover_image_url text, + selected_work_ids uuid[] not null default '{}', + featured_work_id uuid references works(id) on delete set null, + draft_data jsonb not null default '{}'::jsonb, + is_published boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +alter table showcases add column if not exists cover_image_url text; +alter table showcases add column if not exists selected_work_ids uuid[] not null default '{}'; +alter table showcases add column if not exists featured_work_id uuid references works(id) on delete set null; +alter table showcases add column if not exists draft_data jsonb not null default '{}'::jsonb; + +create table if not exists maker_locations ( + id uuid primary key default gen_random_uuid(), + maker_id uuid not null references maker_profiles(id) on delete cascade, + label text not null, + location_type text not null, + address text, + postal_code text, + province text not null, + city text not null, + latitude double precision, + longitude double precision, + privacy text not null default 'approximate', + local_pickup boolean not null default true, + personal_delivery boolean not null default true, + postal_shipping boolean not null default true, + nationwide_shipping boolean not null default false, + mobile_service boolean not null default false, + onsite_service boolean not null default false, + notes text, + travel_radius text, + shipping_area text, + hours_mode text not null default 'appointment', + alert_service text, + alert_radius text, + minimum_rating text, + draft_data jsonb not null default '{}'::jsonb, + is_published boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +alter table maker_locations add column if not exists draft_data jsonb not null default '{}'::jsonb; +alter table maker_locations add column if not exists is_published boolean not null default false; + +create table if not exists subscriptions ( + id uuid primary key default gen_random_uuid(), + maker_id uuid not null references maker_profiles(id) on delete cascade, + provider text not null, + plan_code text not null, + status text not null check (status in ('inactive', 'pending', 'active', 'grace', 'cancelled')), + external_reference text, + activated_at timestamptz, + expires_at timestamptz, + created_at timestamptz not null default now() +); + +create table if not exists inquiries ( + id uuid primary key default gen_random_uuid(), + maker_id uuid not null references maker_profiles(id) on delete cascade, + customer_id uuid not null references users(id) on delete cascade, + source_type text not null, + source_id uuid, + need_text text not null, + size_text text, + has_model boolean not null default false, + material_preference text, + quantity integer not null default 1, + urgency text not null, + delivery_type text not null, + status text not null default 'open' check (status in ('open', 'rejected', 'completed')), + rejection_reason text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +alter table inquiries drop constraint if exists inquiries_status_check; +alter table inquiries add constraint inquiries_status_check check (status in ('open', 'agreed', 'completed', 'rejected', 'incident')); +alter table inquiries add column if not exists proposal_price_cents integer; +alter table inquiries add column if not exists proposal_quantity integer; +alter table inquiries add column if not exists proposal_lead_time_days integer; +alter table inquiries add column if not exists proposal_note text; +alter table inquiries add column if not exists proposal_created_at timestamptz; +alter table inquiries add column if not exists agreed_at timestamptz; +alter table inquiries add column if not exists maker_completed_at timestamptz; +alter table inquiries add column if not exists customer_completed_at timestamptz; +alter table inquiries add column if not exists completed_at timestamptz; +alter table inquiries add column if not exists incident_reason text; +alter table inquiries add column if not exists incident_detail text; +alter table inquiries add column if not exists incident_created_at timestamptz; +update inquiries set completed_at = updated_at where status = 'completed' and completed_at is null; + +create table if not exists messages ( + id uuid primary key default gen_random_uuid(), + inquiry_id uuid not null references inquiries(id) on delete cascade, + sender_user_id uuid not null references users(id) on delete cascade, + body text not null, + created_at timestamptz not null default now() +); + +create table if not exists reviews ( + id uuid primary key default gen_random_uuid(), + inquiry_id uuid not null unique references inquiries(id) on delete cascade, + maker_id uuid not null references maker_profiles(id) on delete cascade, + customer_id uuid not null references users(id) on delete cascade, + rating_overall integer not null check (rating_overall between 1 and 5), + rating_quality integer not null check (rating_quality between 1 and 5), + rating_communication integer not null check (rating_communication between 1 and 5), + rating_value integer not null check (rating_value between 1 and 5), + comment text not null, + status text not null default 'published' check (status in ('published', 'reported', 'hidden')), + created_at timestamptz not null default now() +); + +create table if not exists favorite_makers ( + user_id uuid not null references users(id) on delete cascade, + maker_id uuid not null references maker_profiles(id) on delete cascade, + created_at timestamptz not null default now(), + primary key (user_id, maker_id) +); + +create table if not exists favorite_works ( + user_id uuid not null references users(id) on delete cascade, + work_id uuid not null references works(id) on delete cascade, + created_at timestamptz not null default now(), + primary key (user_id, work_id) +); + +create table if not exists moderation_cases ( + id uuid primary key default gen_random_uuid(), + target_type text not null, + target_id uuid not null, + reporter_user_id uuid references users(id) on delete set null, + reason text not null, + status text not null default 'open' check (status in ('open', 'resolved')), + resolution text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists jobs ( + id uuid primary key default gen_random_uuid(), + type text not null, + payload jsonb not null, + status text not null default 'pending' check (status in ('pending', 'processing', 'done', 'failed')), + attempts integer not null default 0, + last_error text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_maker_profiles_status on maker_profiles(status); +create index if not exists idx_services_maker_id on services(maker_id); +create index if not exists idx_works_maker_id on works(maker_id); +create index if not exists idx_showcases_maker_id on showcases(maker_id); +create index if not exists idx_maker_locations_maker_id on maker_locations(maker_id); +create index if not exists idx_inquiries_maker_id on inquiries(maker_id); +create index if not exists idx_messages_inquiry_id on messages(inquiry_id); +create index if not exists idx_favorite_makers_user_id on favorite_makers(user_id); +create index if not exists idx_favorite_works_user_id on favorite_works(user_id); +create index if not exists idx_jobs_status on jobs(status); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..6876671 --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,2049 @@ +import { randomUUID } from "node:crypto"; + +import cookie from "@fastify/cookie"; +import cors from "@fastify/cors"; +import formbody from "@fastify/formbody"; +import multipart from "@fastify/multipart"; +import Fastify, { type FastifyReply } from "fastify"; +import { serviceCategories, slugify } from "@makers3d/shared"; +import { z } from "zod"; + +import { attachCurrentUser, createSession, destroySession, hashPassword, refreshSession, requireAdmin, requireAuth, sessionMaxAgeSeconds, verifyPassword } from "./auth.js"; +import { config } from "./config.js"; +import { query, one, pool } from "./db.js"; +import { ensureMakerProfile, getMakerBySlug, getMakerByUserId, getMakerChecklist, getSubscriptionStatus, listPublicMakers, publishMakerIfReady, upsertMakerProfile } from "./queries.js"; +import { ensureBuckets, getPublicObject, uploadPublicObject } from "./storage.js"; + +const app = Fastify({ logger: true, bodyLimit: 20 * 1024 * 1024 }); +const secureCookie = config.APP_URL.startsWith("https://"); +const sessionCookieOptions = { + path: "/", + httpOnly: true, + sameSite: "lax" as const, + secure: secureCookie, + maxAge: sessionMaxAgeSeconds +}; +const sessionCookieName = "makers3d_session"; + +function sessionCookieDomainCandidates() { + try { + const hostname = new URL(config.APP_URL).hostname; + const parts = hostname.split(".").filter(Boolean); + const candidates = [hostname, `.${hostname}`]; + + if (parts.length > 2) { + const parentDomain = parts.slice(-2).join("."); + candidates.push(parentDomain, `.${parentDomain}`); + } + + return Array.from(new Set(candidates)); + } catch { + return []; + } +} + +const sessionCookiePaths = ["/", "/api", "/api/v1"]; + +function expireSessionCookies(reply: FastifyReply) { + const expires = new Date(0); + const baseOptions = { + httpOnly: true, + sameSite: "lax" as const, + secure: secureCookie, + maxAge: 0, + expires + }; + + for (const path of sessionCookiePaths) { + reply.clearCookie(sessionCookieName, { path, httpOnly: true, sameSite: "lax" as const, secure: secureCookie }); + reply.setCookie(sessionCookieName, "", { ...baseOptions, path }); + + for (const domain of sessionCookieDomainCandidates()) { + reply.clearCookie(sessionCookieName, { path, domain, httpOnly: true, sameSite: "lax" as const, secure: secureCookie }); + reply.setCookie(sessionCookieName, "", { ...baseOptions, path, domain }); + } + } +} + +function sessionTokensFromCookieHeader(cookieHeader?: string) { + if (!cookieHeader) { + return []; + } + + const tokens = cookieHeader + .split(";") + .map((chunk) => chunk.trim()) + .filter((chunk) => chunk.startsWith(`${sessionCookieName}=`)) + .map((chunk) => { + const value = chunk.slice(sessionCookieName.length + 1); + try { + return decodeURIComponent(value); + } catch { + return value; + } + }) + .filter(Boolean); + + return Array.from(new Set(tokens)); +} + +async function destroyRequestSessions(request: { headers: { cookie?: string }; cookies: Record }) { + const tokens = new Set([ + ...sessionTokensFromCookieHeader(request.headers.cookie), + request.cookies[sessionCookieName] + ].filter((token): token is string => Boolean(token))); + + for (const token of tokens) { + await destroySession(token); + } +} + +function safeAuthRedirect(value: unknown) { + if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) { + return "/login"; + } + + return value; +} + +function slugFromReferer(referer: string | undefined, section: "makers" | "works") { + if (!referer) { + return null; + } + + try { + const url = new URL(referer, config.APP_URL); + const segments = url.pathname.split("/").filter(Boolean); + const index = segments.indexOf(section); + return index >= 0 ? segments[index + 1] || null : null; + } catch { + return null; + } +} + +function firstHeaderValue(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +function requestReferer(headers: unknown) { + const record = headers as Record; + return firstHeaderValue(record.referer) || firstHeaderValue(record.referrer); +} + +async function findPublishedMakerByIdentifier(identifier: string, referer?: string) { + const makerId = z.string().uuid().safeParse(identifier).success ? identifier : null; + const refererSlug = slugFromReferer(referer, "makers"); + const identifiers = Array.from(new Set([identifier, refererSlug].filter((value): value is string => Boolean(value)))); + + if (makerId) { + const maker = await one<{ id: string }>(`select id from maker_profiles where id = $1 and status = 'published'`, [makerId]); + if (maker) { + return maker; + } + } + + for (const slug of identifiers) { + const maker = await one<{ id: string }>(`select id from maker_profiles where slug = $1 and status = 'published'`, [slug]); + if (maker) { + return maker; + } + } + + return null; +} + +async function findPublishedWorkByIdentifier(identifier: string, referer?: string) { + const workId = z.string().uuid().safeParse(identifier).success ? identifier : null; + const refererSlug = slugFromReferer(referer, "works"); + const identifiers = Array.from(new Set([identifier, refererSlug].filter((value): value is string => Boolean(value)))); + + if (workId) { + const work = await one<{ id: string }>( + ` + select w.id + from works w + join maker_profiles mp on mp.id = w.maker_id + where w.id = $1 + and w.is_published = true + and mp.status = 'published' + `, + [workId] + ); + if (work) { + return work; + } + } + + for (const slug of identifiers) { + const work = await one<{ id: string }>( + ` + select w.id + from works w + join maker_profiles mp on mp.id = w.maker_id + where w.slug = $1 + and w.is_published = true + and mp.status = 'published' + `, + [slug] + ); + if (work) { + return work; + } + } + + return null; +} + +await app.register(cors, { + origin: true, + credentials: true +}); +await app.register(cookie); +await app.register(formbody); +await app.register(multipart, { + limits: { + fileSize: 10 * 1024 * 1024 + } +}); + +app.removeContentTypeParser("application/json"); +app.addContentTypeParser("application/json", { parseAs: "string" }, (_request, body, done) => { + const rawBody = typeof body === "string" ? body : body.toString("utf8"); + if (!rawBody.trim()) { + done(null, undefined); + return; + } + + try { + done(null, JSON.parse(rawBody)); + } catch (error) { + done(error as Error, undefined); + } +}); + +app.addHook("onRequest", attachCurrentUser); + +app.get("/api/v1/health", async () => ({ + ok: true +})); + +app.get("/api/v1/catalogs", async () => ({ + serviceCategories +})); + +app.get("/api/v1/auth/me", async (request, reply) => { + reply.header("Cache-Control", "no-store"); + const token = request.cookies[sessionCookieName]; + if (token && request.currentUser) { + await refreshSession(token); + reply.setCookie(sessionCookieName, token, sessionCookieOptions); + } + + const makerProfile = request.currentUser + ? await one<{ id: string; slug: string; business_name: string | null; status: string }>( + `select id, slug, business_name, status from maker_profiles where user_id = $1`, + [request.currentUser.id] + ) + : null; + + return { + user: request.currentUser, + makerProfile + }; +}); + +app.post("/api/v1/auth/register", async (request, reply) => { + reply.header("Cache-Control", "no-store"); + const body = z.object({ + email: z.string().email(), + password: z.string().min(8), + fullName: z.string().min(2) + }).parse(request.body); + + const existing = await one<{ id: string }>(`select id from users where email = $1`, [body.email.toLowerCase()]); + if (existing) { + return reply.code(409).send({ error: "Email already exists" }); + } + + const passwordHash = await hashPassword(body.password); + const rows = await query<{ id: string; email: string; role: "user" | "admin" }>( + ` + insert into users (email, password_hash, full_name) + values ($1, $2, $3) + returning id, email, role + `, + [body.email.toLowerCase(), passwordHash, body.fullName] + ); + + const token = await createSession(rows[0].id); + expireSessionCookies(reply); + reply.setCookie(sessionCookieName, token, sessionCookieOptions); + return { user: rows[0] }; +}); + +app.post("/api/v1/auth/login", async (request, reply) => { + reply.header("Cache-Control", "no-store"); + const body = z.object({ + email: z.string().email(), + password: z.string().min(8) + }).parse(request.body); + + const user = await one<{ id: string; email: string; password_hash: string; role: "user" | "admin" }>( + `select id, email, password_hash, role from users where email = $1`, + [body.email.toLowerCase()] + ); + if (!user || !(await verifyPassword(user.password_hash, body.password))) { + return reply.code(401).send({ error: "Invalid credentials" }); + } + + const token = await createSession(user.id); + expireSessionCookies(reply); + reply.setCookie(sessionCookieName, token, sessionCookieOptions); + return { + user: { + id: user.id, + email: user.email, + role: user.role + } + }; +}); + +app.post("/api/v1/auth/logout", async (request, reply) => { + reply.header("Cache-Control", "no-store"); + await destroyRequestSessions(request); + expireSessionCookies(reply); + return { ok: true }; +}); + +app.get("/api/v1/auth/logout", async (request, reply) => { + reply.header("Cache-Control", "no-store"); + await destroyRequestSessions(request); + expireSessionCookies(reply); + const queryParams = z.object({ redirect: z.string().optional() }).parse(request.query); + return reply.redirect(safeAuthRedirect(queryParams.redirect), 303); +}); + +app.get("/api/v1/makers", async (request) => { + const queryParams = z.object({ + q: z.string().optional(), + province: z.string().optional(), + city: z.string().optional(), + serviceCategory: z.string().optional(), + material: z.string().optional(), + deliveryScope: z.string().optional() + }).parse(request.query); + + const makers = await listPublicMakers(queryParams); + return { makers }; +}); + +app.get("/api/v1/makers/:slug", async (request, reply) => { + const params = z.object({ slug: z.string() }).parse(request.params); + const maker = await getMakerBySlug(params.slug); + if (!maker || maker.status !== "published") { + return reply.code(404).send({ error: "Maker not found" }); + } + + const services = await query( + `select * from services where maker_id = $1 and is_published = true order by created_at desc`, + [maker.id] + ); + const showcases = await query( + `select * from showcases where maker_id = $1 and is_published = true order by created_at desc`, + [maker.id] + ); + const works = await query( + `select * from works where maker_id = $1 and is_published = true order by created_at desc`, + [maker.id] + ); + const reviews = await query( + `select id, rating_overall, rating_quality, rating_communication, rating_value, comment, created_at from reviews where maker_id = $1 and status = 'published' order by created_at desc`, + [maker.id] + ); + + return { + maker, + services, + showcases, + works, + reviews + }; +}); + +app.get("/api/v1/works", async (request) => { + const queryParams = z.object({ + q: z.string().optional(), + makerIds: z.string().optional() + }).parse(request.query); + + const conditions = ["w.is_published = true", "m.status = 'published'"]; + const values: unknown[] = []; + + if (queryParams.q?.trim()) { + values.push(`%${queryParams.q.trim()}%`); + conditions.push(`( + w.title ilike $${values.length} + or w.summary ilike $${values.length} + or w.story ilike $${values.length} + or w.technology ilike $${values.length} + or w.material ilike $${values.length} + or m.business_name ilike $${values.length} + )`); + } + + const parsedMakerIds = (queryParams.makerIds || "") + .split(",") + .map((id) => id.trim()) + .filter((id) => z.string().uuid().safeParse(id).success); + + if (parsedMakerIds.length > 0) { + values.push(parsedMakerIds); + conditions.push(`w.maker_id = any($${values.length}::uuid[])`); + } + + const works = await query( + ` + select + w.id, + w.slug, + w.title, + w.summary, + w.technology, + w.material, + w.image_url, + w.gallery_urls, + w.maker_id, + m.slug as maker_slug, + m.business_name as maker_name, + coalesce(avg(r.rating_overall), 0) as avg_rating, + count(r.id)::int as review_count + from works w + join maker_profiles m on m.id = w.maker_id + left join reviews r on r.maker_id = m.id and r.status = 'published' + where ${conditions.join(" and ")} + group by w.id, m.slug, m.business_name + order by coalesce(avg(r.rating_overall), 0) desc, count(r.id) desc, w.created_at desc + limit 60 + `, + values + ); + + return { works }; +}); + +app.get("/api/v1/works/:slug", async (request, reply) => { + const params = z.object({ slug: z.string() }).parse(request.params); + const work = await one( + ` + select w.*, m.slug as maker_slug, m.business_name as maker_name, m.status as maker_status + from works w + join maker_profiles m on m.id = w.maker_id + where w.slug = $1 + and w.is_published = true + and m.status = 'published' + `, + [params.slug] + ); + if (!work) { + return reply.code(404).send({ error: "Work not found" }); + } + + const relatedWorks = await query( + ` + select + w.id, + w.slug, + w.title, + w.technology, + w.material, + w.image_url + from works w + join maker_profiles m on m.id = w.maker_id + where w.id <> $1 + and w.is_published = true + and m.status = 'published' + and ( + w.maker_id = $2 + or w.technology = $3 + or w.material = $4 + ) + order by + case when w.maker_id = $2 then 0 else 1 end, + w.created_at desc + limit 8 + `, + [work.id, work.maker_id, work.technology, work.material] + ); + + return { work: { ...work, related_works: relatedWorks } }; +}); + +app.get("/api/v1/services/:slug", async (request, reply) => { + const params = z.object({ slug: z.string() }).parse(request.params); + const service = await one( + ` + select s.*, m.slug as maker_slug, m.business_name, m.status as maker_status + from services s + join maker_profiles m on m.id = s.maker_id + where s.slug = $1 + and s.is_published = true + and m.status = 'published' + `, + [params.slug] + ); + if (!service) { + return reply.code(404).send({ error: "Service not found" }); + } + return { service }; +}); + +app.get("/api/v1/showcases/:slug", async (request, reply) => { + const params = z.object({ slug: z.string() }).parse(request.params); + const showcase = await one<{ + id: string; + maker_id: string; + selected_work_ids: string[]; + [key: string]: unknown; + }>( + ` + select sc.*, m.slug as maker_slug, m.business_name as maker_name, m.status as maker_status + from showcases sc + join maker_profiles m on m.id = sc.maker_id + where sc.slug = $1 + and sc.is_published = true + and m.status = 'published' + `, + [params.slug] + ); + if (!showcase) { + return reply.code(404).send({ error: "Showcase not found" }); + } + + const selectedIds = Array.isArray(showcase.selected_work_ids) ? showcase.selected_work_ids : []; + const works = selectedIds.length + ? await query( + ` + select id, slug, title, summary, technology, material, image_url, gallery_urls + from works + where id = any($1::uuid[]) and maker_id = $2 and is_published = true + order by array_position($1::uuid[], id) + `, + [selectedIds, showcase.maker_id] + ) + : []; + + return { showcase: { ...showcase, works } }; +}); + +app.get("/api/v1/favorites", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + + const [makers, works] = await Promise.all([ + query( + ` + select + fm.created_at as favorited_at, + mp.id, + mp.slug, + mp.business_name, + mp.city, + mp.province, + mp.main_image_url, + mp.delivery_scope, + mp.availability, + coalesce(avg(r.rating_overall), 0) as avg_rating, + count(r.id)::int as review_count + from favorite_makers fm + join maker_profiles mp on mp.id = fm.maker_id + left join reviews r on r.maker_id = mp.id and r.status = 'published' + where fm.user_id = $1 + and mp.status = 'published' + group by fm.created_at, mp.id + order by fm.created_at desc + `, + [user.id] + ), + query( + ` + select + fw.created_at as favorited_at, + w.id, + w.slug, + w.title, + w.summary, + w.technology, + w.material, + w.image_url, + mp.id as maker_id, + mp.slug as maker_slug, + mp.business_name as maker_name + from favorite_works fw + join works w on w.id = fw.work_id + join maker_profiles mp on mp.id = w.maker_id + where fw.user_id = $1 + and w.is_published = true + and mp.status = 'published' + order by fw.created_at desc + `, + [user.id] + ) + ]); + + return { makers, works }; +}); + +app.get("/api/v1/favorites/status", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + + const queryParams = z.object({ + targetType: z.enum(["maker", "work"]), + targetId: z.string().optional(), + targetSlug: z.string().optional() + }).parse(request.query); + + const referer = requestReferer(request.headers); + const target = queryParams.targetType === "maker" + ? await findPublishedMakerByIdentifier(queryParams.targetSlug || queryParams.targetId || "", referer) + : await findPublishedWorkByIdentifier(queryParams.targetSlug || queryParams.targetId || "", referer); + + if (!target) { + return { favorited: false }; + } + + const table = queryParams.targetType === "maker" ? "favorite_makers" : "favorite_works"; + const column = queryParams.targetType === "maker" ? "maker_id" : "work_id"; + const favorite = await one<{ exists: boolean }>( + `select true as exists from ${table} where user_id = $1 and ${column} = $2 limit 1`, + [user.id, target.id] + ); + + return { favorited: Boolean(favorite?.exists) }; +}); + +app.post("/api/v1/favorites/makers/:id", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + + const params = z.object({ id: z.string().min(1) }).parse(request.params); + const maker = await findPublishedMakerByIdentifier(params.id, requestReferer(request.headers)); + if (!maker) { + return reply.code(404).send({ error: "Maker not found" }); + } + + await query( + `insert into favorite_makers (user_id, maker_id) values ($1, $2) on conflict do nothing`, + [user.id, maker.id] + ); + return { favorited: true }; +}); + +app.delete("/api/v1/favorites/makers/:id", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + + const params = z.object({ id: z.string().min(1) }).parse(request.params); + const maker = await findPublishedMakerByIdentifier(params.id, requestReferer(request.headers)); + if (maker) { + await query(`delete from favorite_makers where user_id = $1 and maker_id = $2`, [user.id, maker.id]); + } + return { favorited: false }; +}); + +app.post("/api/v1/favorites/makers/by-slug/:slug", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + + const params = z.object({ slug: z.string().min(1) }).parse(request.params); + const maker = await findPublishedMakerByIdentifier(params.slug, requestReferer(request.headers)); + if (!maker) { + return reply.code(404).send({ error: "Maker not found" }); + } + + await query( + `insert into favorite_makers (user_id, maker_id) values ($1, $2) on conflict do nothing`, + [user.id, maker.id] + ); + return { favorited: true }; +}); + +app.delete("/api/v1/favorites/makers/by-slug/:slug", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + + const params = z.object({ slug: z.string().min(1) }).parse(request.params); + const maker = await findPublishedMakerByIdentifier(params.slug, requestReferer(request.headers)); + if (!maker) { + return { favorited: false }; + } + + await query(`delete from favorite_makers where user_id = $1 and maker_id = $2`, [user.id, maker.id]); + return { favorited: false }; +}); + +app.post("/api/v1/favorites/works/:id", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + + const params = z.object({ id: z.string().min(1) }).parse(request.params); + const work = await findPublishedWorkByIdentifier(params.id, requestReferer(request.headers)); + if (!work) { + return reply.code(404).send({ error: "Work not found" }); + } + + await query( + `insert into favorite_works (user_id, work_id) values ($1, $2) on conflict do nothing`, + [user.id, work.id] + ); + return { favorited: true }; +}); + +app.delete("/api/v1/favorites/works/:id", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + + const params = z.object({ id: z.string().min(1) }).parse(request.params); + const work = await findPublishedWorkByIdentifier(params.id, requestReferer(request.headers)); + if (work) { + await query(`delete from favorite_works where user_id = $1 and work_id = $2`, [user.id, work.id]); + } + return { favorited: false }; +}); + +app.get("/api/v1/assets/public/:objectName", async (request, reply) => { + const params = z.object({ objectName: z.string() }).parse(request.params); + const stream = await getPublicObject(params.objectName).catch(() => null); + if (!stream) { + return reply.code(404).send({ error: "Asset not found" }); + } + reply.header("Cache-Control", "public, max-age=3600"); + return reply.send(stream); +}); + +app.post("/api/v1/uploads/public-image", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + + const part = await request.file(); + if (!part) { + return reply.code(400).send({ error: "File required" }); + } + + const chunks: Buffer[] = []; + for await (const chunk of part.file) { + chunks.push(chunk as Buffer); + } + const buffer = Buffer.concat(chunks); + const objectName = await uploadPublicObject(part.filename, part.mimetype, buffer); + return { + url: `/api/v1/assets/public/${objectName}` + }; +}); + +app.get("/api/v1/account/maker", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + if (user.role === "admin") { + return reply.code(403).send({ error: "Admins must use the backoffice" }); + } + + const maker = await ensureMakerProfile(user.id); + const services = await query(`select * from services where maker_id = $1 order by created_at desc`, [maker.id]); + const showcases = await query(`select * from showcases where maker_id = $1 order by created_at desc`, [maker.id]); + const locations = await query(`select * from maker_locations where maker_id = $1 order by created_at desc`, [maker.id]); + const works = await query(`select * from works where maker_id = $1 order by created_at desc`, [maker.id]); + const inquiries = await query( + ` + select i.*, u.full_name as customer_name + from inquiries i + join users u on u.id = i.customer_id + where i.maker_id = $1 + order by i.created_at desc + `, + [maker.id] + ); + const reviews = await query(`select * from reviews where maker_id = $1 order by created_at desc`, [maker.id]); + const checklist = await getMakerChecklist(maker.id); + + return { maker, services, showcases, locations, works, inquiries, reviews, checklist }; +}); + +app.get("/api/v1/account/conversations", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + const queryParams = z.object({ + scope: z.enum(["all", "customer", "maker"]).optional().default("all") + }).parse(request.query); + const whereClause = queryParams.scope === "customer" + ? "i.customer_id = $1" + : queryParams.scope === "maker" + ? "mp.user_id = $1" + : "(i.customer_id = $1 or mp.user_id = $1)"; + + const rows = await query( + ` + select + i.*, + mp.business_name, + mp.slug as maker_slug, + u.full_name as customer_name, + lm.body as last_message, + lm.created_at as last_message_at, + lm.sender_user_id as last_sender_user_id, + coalesce(mc.message_count, 0)::int as message_count + from inquiries i + join maker_profiles mp on mp.id = i.maker_id + join users u on u.id = i.customer_id + left join lateral ( + select body, created_at, sender_user_id + from messages + where inquiry_id = i.id + order by created_at desc + limit 1 + ) lm on true + left join lateral ( + select count(*) as message_count + from messages + where inquiry_id = i.id + ) mc on true + where ${whereClause} + order by coalesce(lm.created_at, i.updated_at, i.created_at) desc + `, + [user.id] + ); + + return { conversations: rows }; +}); + +app.put("/api/v1/account/maker/profile", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + + const body = z.object({ + businessName: z.string().min(2), + description: z.string().min(10), + province: z.string().min(2), + city: z.string().min(2), + latitude: z.coerce.number(), + longitude: z.coerce.number(), + deliveryScope: z.enum(["local", "nationwide"]), + availability: z.enum(["available", "limited", "unavailable"]), + publicContactEmail: z.string().email().optional().or(z.literal("")), + publicWhatsapp: z.string().optional(), + mainImageUrl: z.string().optional(), + websiteUrl: z.string().optional(), + instagramUrl: z.string().optional(), + tiktokUrl: z.string().optional(), + twitterUrl: z.string().optional(), + facebookUrl: z.string().optional(), + youtubeUrl: z.string().optional(), + linkedinUrl: z.string().optional() + }).parse(request.body); + + const maker = await upsertMakerProfile(user.id, { + ...body, + publicContactEmail: body.publicContactEmail || undefined + }); + return { maker }; +}); + +app.patch("/api/v1/account/maker/review-settings", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + + const body = z.object({ + allowVerifiedReviews: z.boolean(), + showSatisfactionScore: z.boolean(), + showReviewTags: z.boolean(), + allowMakerReviewReply: z.boolean(), + showPastReviews: z.boolean() + }).parse(request.body); + const maker = await ensureMakerProfile(user.id); + const rows = await query( + ` + update maker_profiles + set allow_verified_reviews = $2, + show_satisfaction_score = $3, + show_review_tags = $4, + allow_maker_review_reply = $5, + show_past_reviews = $6, + updated_at = now() + where id = $1 + returning * + `, + [ + maker.id, + body.allowVerifiedReviews, + body.showSatisfactionScore, + body.showReviewTags, + body.allowMakerReviewReply, + body.showPastReviews + ] + ); + + return { maker: rows[0] }; +}); + +const accountLocationSchema = z.object({ + label: z.string().optional().default(""), + type: z.string().optional().default("private_workshop"), + address: z.string().optional().default(""), + postalCode: z.string().optional().default(""), + province: z.string().optional().default(""), + city: z.string().optional().default(""), + latitude: z.coerce.number().optional().nullable(), + longitude: z.coerce.number().optional().nullable(), + privacy: z.enum(["approximate", "city", "exact"]).default("approximate"), + localPickup: z.boolean().default(true), + personalDelivery: z.boolean().default(true), + postalShipping: z.boolean().default(true), + nationwideShipping: z.boolean().default(false), + mobileService: z.boolean().default(false), + onsiteService: z.boolean().default(false), + notes: z.string().optional().default(""), + travelRadius: z.string().optional().default("25"), + shippingArea: z.string().optional().default("nationwide"), + hoursMode: z.string().optional().default("appointment"), + alertService: z.string().optional().default(""), + alertRadius: z.string().optional().default("30"), + minimumRating: z.string().optional().default("4"), + draftData: z.record(z.string(), z.unknown()).optional().default({}), + isPublished: z.boolean().default(false) +}); + +function validatePublishedLocation(body: z.infer) { + const requirements: Array<[boolean, string]> = [ + [body.label.trim().length >= 2, "nombre"], + [body.province.trim().length >= 2, "provincia"], + [body.city.trim().length >= 2, "ciudad"] + ]; + + return requirements.filter(([valid]) => !valid).map(([, label]) => label); +} + +app.post("/api/v1/account/maker/locations", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + const maker = await ensureMakerProfile(user.id); + const body = accountLocationSchema.parse(request.body); + const missing = body.isPublished ? validatePublishedLocation(body) : []; + if (missing.length) { + return reply.code(400).send({ error: `Faltan datos para guardar ubicacion: ${missing.join(", ")}` }); + } + + const rows = await query( + ` + insert into maker_locations ( + maker_id, label, location_type, address, postal_code, province, city, latitude, longitude, privacy, + local_pickup, personal_delivery, postal_shipping, nationwide_shipping, mobile_service, onsite_service, + notes, travel_radius, shipping_area, hours_mode, alert_service, alert_radius, minimum_rating, draft_data, is_published + ) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25) + returning * + `, + [ + maker.id, + body.label.trim() || "Ubicacion en borrador", + body.type, + body.address || null, + body.postalCode || null, + body.province.trim() || maker.province || "Cordoba", + body.city.trim() || maker.city || "Cordoba", + body.latitude ?? maker.latitude, + body.longitude ?? maker.longitude, + body.privacy, + body.localPickup, + body.personalDelivery, + body.postalShipping, + body.nationwideShipping, + body.mobileService, + body.onsiteService, + body.notes || null, + body.travelRadius, + body.shippingArea, + body.hoursMode, + body.alertService || null, + body.alertRadius, + body.minimumRating, + body.draftData, + body.isPublished + ] + ); + + return { location: rows[0] }; +}); + +app.patch("/api/v1/account/maker/locations/:id", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + const maker = await ensureMakerProfile(user.id); + const params = z.object({ id: z.string().uuid() }).parse(request.params); + const body = accountLocationSchema.parse(request.body); + const missing = body.isPublished ? validatePublishedLocation(body) : []; + if (missing.length) { + return reply.code(400).send({ error: `Faltan datos para guardar ubicacion: ${missing.join(", ")}` }); + } + + const rows = await query( + ` + update maker_locations + set label = $3, + location_type = $4, + address = $5, + postal_code = $6, + province = $7, + city = $8, + latitude = $9, + longitude = $10, + privacy = $11, + local_pickup = $12, + personal_delivery = $13, + postal_shipping = $14, + nationwide_shipping = $15, + mobile_service = $16, + onsite_service = $17, + notes = $18, + travel_radius = $19, + shipping_area = $20, + hours_mode = $21, + alert_service = $22, + alert_radius = $23, + minimum_rating = $24, + draft_data = $25, + is_published = $26, + updated_at = now() + where id = $1 and maker_id = $2 + returning * + `, + [ + params.id, + maker.id, + body.label.trim() || "Ubicacion en borrador", + body.type, + body.address || null, + body.postalCode || null, + body.province.trim() || maker.province || "Cordoba", + body.city.trim() || maker.city || "Cordoba", + body.latitude ?? maker.latitude, + body.longitude ?? maker.longitude, + body.privacy, + body.localPickup, + body.personalDelivery, + body.postalShipping, + body.nationwideShipping, + body.mobileService, + body.onsiteService, + body.notes || null, + body.travelRadius, + body.shippingArea, + body.hoursMode, + body.alertService || null, + body.alertRadius, + body.minimumRating, + body.draftData, + body.isPublished + ] + ); + + if (!rows[0]) { + return reply.code(404).send({ error: "Ubicacion no encontrada" }); + } + + return { location: rows[0] }; +}); + +app.delete("/api/v1/account/maker/locations/:id", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + const maker = await ensureMakerProfile(user.id); + const params = z.object({ id: z.string().uuid() }).parse(request.params); + await query(`delete from maker_locations where id = $1 and maker_id = $2`, [params.id, maker.id]); + return { ok: true }; +}); + +const accountServiceSchema = z.object({ + title: z.string().optional().default(""), + category: z.string().optional().default(""), + description: z.string().optional().default(""), + technologies: z.array(z.string()).default([]), + materials: z.array(z.string()).default([]), + localPickup: z.boolean().default(true), + nationwideShipping: z.boolean().default(false), + leadTimeDays: z.coerce.number().int().positive().default(3), + priceFromCents: z.coerce.number().int().nonnegative().optional().nullable(), + draftData: z.record(z.string(), z.unknown()).optional().default({}), + isPublished: z.boolean().default(true) +}); + +function validatePublishedService(body: z.infer) { + const requirements: Array<[boolean, string]> = [ + [body.title.trim().length >= 3, "titulo"], + [body.category.trim().length >= 2, "tipo de servicio"], + [body.description.trim().length >= 10, "descripcion"], + [body.technologies.length > 0, "tecnologia"], + [body.materials.length > 0, "materiales"] + ]; + + return requirements.filter(([valid]) => !valid).map(([, label]) => label); +} + +app.post("/api/v1/account/maker/services", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + const maker = await ensureMakerProfile(user.id); + + const body = accountServiceSchema.parse(request.body); + const missing = body.isPublished ? validatePublishedService(body) : []; + if (missing.length) { + return reply.code(400).send({ error: `Faltan datos para publicar: ${missing.join(", ")}` }); + } + + const title = body.title.trim() || "Servicio en borrador"; + const category = body.category.trim() || "Pendiente"; + const description = body.description.trim() || "Servicio pendiente de completar."; + const slug = `${slugify(title)}-${randomUUID().slice(0, 6)}`; + const rows = await query( + ` + insert into services ( + maker_id, slug, title, category, description, technologies, materials, local_pickup, nationwide_shipping, lead_time_days, price_from_cents, draft_data, is_published + ) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + returning * + `, + [ + maker.id, + slug, + title, + category, + description, + body.technologies, + body.materials, + body.localPickup, + body.nationwideShipping, + body.leadTimeDays, + body.priceFromCents ?? null, + body.draftData, + body.isPublished + ] + ); + return { service: rows[0] }; +}); + +app.patch("/api/v1/account/maker/services/:id", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + const maker = await ensureMakerProfile(user.id); + const params = z.object({ id: z.string().uuid() }).parse(request.params); + const body = accountServiceSchema.parse(request.body); + const missing = body.isPublished ? validatePublishedService(body) : []; + if (missing.length) { + return reply.code(400).send({ error: `Faltan datos para publicar: ${missing.join(", ")}` }); + } + + const title = body.title.trim() || "Servicio en borrador"; + const category = body.category.trim() || "Pendiente"; + const description = body.description.trim() || "Servicio pendiente de completar."; + const rows = await query( + ` + update services + set title = $3, + category = $4, + description = $5, + technologies = $6, + materials = $7, + local_pickup = $8, + nationwide_shipping = $9, + lead_time_days = $10, + price_from_cents = $11, + draft_data = $12, + is_published = $13, + updated_at = now() + where id = $1 and maker_id = $2 + returning * + `, + [ + params.id, + maker.id, + title, + category, + description, + body.technologies, + body.materials, + body.localPickup, + body.nationwideShipping, + body.leadTimeDays, + body.priceFromCents ?? null, + body.draftData, + body.isPublished + ] + ); + + if (!rows[0]) { + return reply.code(404).send({ error: "Servicio no encontrado" }); + } + + return { service: rows[0] }; +}); + +app.delete("/api/v1/account/maker/services/:id", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + const maker = await ensureMakerProfile(user.id); + const params = z.object({ id: z.string().uuid() }).parse(request.params); + await query(`delete from services where id = $1 and maker_id = $2`, [params.id, maker.id]); + return { ok: true }; +}); + +const accountShowcaseSchema = z.object({ + title: z.string().optional().default(""), + description: z.string().optional().default(""), + coverImageUrl: z.string().optional().default(""), + selectedWorkIds: z.array(z.string().uuid()).default([]), + featuredWorkId: z.string().uuid().optional().nullable(), + draftData: z.record(z.string(), z.unknown()).optional().default({}), + isPublished: z.boolean().default(true) +}); + +function validatePublishedShowcase(body: z.infer) { + const requirements: Array<[boolean, string]> = [ + [body.title.trim().length >= 3, "nombre"], + [body.description.trim().length >= 10, "descripcion"], + [body.selectedWorkIds.length > 0, "trabajos"] + ]; + + return requirements.filter(([valid]) => !valid).map(([, label]) => label); +} + +async function normalizeShowcaseWorks(makerId: string, body: z.infer) { + const selectedIds = Array.from(new Set(body.selectedWorkIds)); + if (!selectedIds.length) { + return { selectedIds, coverImageUrl: body.coverImageUrl || null, featuredWorkId: null }; + } + + const rows = await query<{ id: string; image_url: string }>( + `select id, image_url from works where maker_id = $1 and id = any($2::uuid[])`, + [makerId, selectedIds] + ); + const allowed = new Set(rows.map((row) => row.id)); + const normalizedIds = selectedIds.filter((id) => allowed.has(id)); + const featuredWorkId = body.featuredWorkId && allowed.has(body.featuredWorkId) ? body.featuredWorkId : normalizedIds[0] || null; + const coverImageUrl = body.coverImageUrl || rows.find((row) => row.id === featuredWorkId)?.image_url || rows[0]?.image_url || null; + + return { selectedIds: normalizedIds, coverImageUrl, featuredWorkId }; +} + +app.post("/api/v1/account/maker/showcases", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + const maker = await ensureMakerProfile(user.id); + const body = accountShowcaseSchema.parse(request.body); + const normalized = await normalizeShowcaseWorks(maker.id, body); + const normalizedBody = { ...body, selectedWorkIds: normalized.selectedIds }; + const missing = body.isPublished ? validatePublishedShowcase(normalizedBody) : []; + if (missing.length) { + return reply.code(400).send({ error: `Faltan datos para publicar: ${missing.join(", ")}` }); + } + + const title = body.title.trim() || "Escaparate en borrador"; + const description = body.description.trim() || "Escaparate pendiente de completar."; + const slug = `${slugify(title)}-${randomUUID().slice(0, 6)}`; + const rows = await query( + ` + insert into showcases ( + maker_id, slug, title, description, cover_image_url, selected_work_ids, featured_work_id, draft_data, is_published + ) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9) + returning * + `, + [ + maker.id, + slug, + title, + description, + normalized.coverImageUrl, + normalized.selectedIds, + normalized.featuredWorkId, + body.draftData, + body.isPublished + ] + ); + + return { showcase: rows[0] }; +}); + +app.patch("/api/v1/account/maker/showcases/:id", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + const maker = await ensureMakerProfile(user.id); + const params = z.object({ id: z.string().uuid() }).parse(request.params); + const body = accountShowcaseSchema.parse(request.body); + const normalized = await normalizeShowcaseWorks(maker.id, body); + const normalizedBody = { ...body, selectedWorkIds: normalized.selectedIds }; + const missing = body.isPublished ? validatePublishedShowcase(normalizedBody) : []; + if (missing.length) { + return reply.code(400).send({ error: `Faltan datos para publicar: ${missing.join(", ")}` }); + } + + const title = body.title.trim() || "Escaparate en borrador"; + const description = body.description.trim() || "Escaparate pendiente de completar."; + const rows = await query( + ` + update showcases + set title = $3, + description = $4, + cover_image_url = $5, + selected_work_ids = $6, + featured_work_id = $7, + draft_data = $8, + is_published = $9, + updated_at = now() + where id = $1 and maker_id = $2 + returning * + `, + [ + params.id, + maker.id, + title, + description, + normalized.coverImageUrl, + normalized.selectedIds, + normalized.featuredWorkId, + body.draftData, + body.isPublished + ] + ); + + if (!rows[0]) { + return reply.code(404).send({ error: "Escaparate no encontrado" }); + } + + return { showcase: rows[0] }; +}); + +app.delete("/api/v1/account/maker/showcases/:id", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + const maker = await ensureMakerProfile(user.id); + const params = z.object({ id: z.string().uuid() }).parse(request.params); + await query(`delete from showcases where id = $1 and maker_id = $2`, [params.id, maker.id]); + return { ok: true }; +}); + +const accountWorkSchema = z.object({ + serviceId: z.string().uuid().optional().nullable(), + title: z.string().optional().default(""), + summary: z.string().optional().default(""), + story: z.string().optional().default(""), + technology: z.string().optional().default(""), + material: z.string().optional().default(""), + imageUrl: z.string().optional().default(""), + galleryUrls: z.array(z.string()).default([]), + draftData: z.record(z.string(), z.unknown()).optional().default({}), + isPublished: z.boolean().default(true) +}); + +function validatePublishedWork(body: z.infer) { + const requirements: Array<[boolean, string]> = [ + [body.imageUrl.trim().length >= 2, "imagen"] + ]; + + if (!body.isPublished) { + return requirements.filter(([valid]) => !valid).map(([, label]) => label); + } + + requirements.push( + [body.title.trim().length >= 3, "titulo"], + [body.summary.trim().length >= 10, "resumen"], + [body.story.trim().length >= 10, "historia"], + [body.technology.trim().length >= 2, "tecnologia"], + [body.material.trim().length >= 2, "material"] + ); + + return requirements.filter(([valid]) => !valid).map(([, label]) => label); +} + +function normalizeWorkBody(body: z.infer) { + const safeTitle = body.title.trim() || "Borrador sin titulo"; + + return { + safeTitle, + safeSummary: body.summary.trim() || "Borrador pendiente de completar.", + safeStory: body.story.trim() || "Borrador pendiente de completar antes de publicar.", + safeTechnology: body.technology.trim() || "Pendiente", + safeMaterial: body.material.trim() || "Pendiente", + safeImageUrl: body.imageUrl.trim() || "/demo/work-custom.svg", + slug: body.title.trim() ? `${slugify(safeTitle)}-${randomUUID().slice(0, 6)}` : null + }; +} + +app.post("/api/v1/account/maker/works", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + const maker = await ensureMakerProfile(user.id); + + const body = accountWorkSchema.parse(request.body); + + const missingFields = validatePublishedWork(body); + if (missingFields.length) { + return reply.code(400).send({ error: `Faltan datos para ${body.isPublished ? "publicar" : "guardar el borrador"}: ${missingFields.join(", ")}` }); + } + + const normalized = normalizeWorkBody(body); + const slug = normalized.slug || `${slugify(normalized.safeTitle)}-${randomUUID().slice(0, 6)}`; + const rows = await query( + ` + insert into works ( + maker_id, service_id, slug, title, summary, story, technology, material, image_url, gallery_urls, draft_data, is_published + ) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12) + returning * + `, + [ + maker.id, + body.serviceId ?? null, + slug, + normalized.safeTitle, + normalized.safeSummary, + normalized.safeStory, + normalized.safeTechnology, + normalized.safeMaterial, + normalized.safeImageUrl, + body.galleryUrls, + JSON.stringify(body.draftData), + body.isPublished + ] + ); + return { work: rows[0] }; +}); + +app.patch("/api/v1/account/maker/works/:id", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + const maker = await ensureMakerProfile(user.id); + const params = z.object({ id: z.string().uuid() }).parse(request.params); + const body = accountWorkSchema.parse(request.body); + + const missingFields = validatePublishedWork(body); + if (missingFields.length) { + return reply.code(400).send({ error: `Faltan datos para ${body.isPublished ? "publicar" : "guardar el borrador"}: ${missingFields.join(", ")}` }); + } + + const normalized = normalizeWorkBody(body); + const rows = await query( + ` + update works + set + service_id = $3, + slug = coalesce($4, slug), + title = $5, + summary = $6, + story = $7, + technology = $8, + material = $9, + image_url = $10, + gallery_urls = $11, + draft_data = $12::jsonb, + is_published = $13, + updated_at = now() + where id = $1 and maker_id = $2 + returning * + `, + [ + params.id, + maker.id, + body.serviceId ?? null, + normalized.slug, + normalized.safeTitle, + normalized.safeSummary, + normalized.safeStory, + normalized.safeTechnology, + normalized.safeMaterial, + normalized.safeImageUrl, + body.galleryUrls, + JSON.stringify(body.draftData), + body.isPublished + ] + ); + + if (!rows[0]) { + return reply.code(404).send({ error: "Work not found" }); + } + + return { work: rows[0] }; +}); + +app.delete("/api/v1/account/maker/works/:id", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + const maker = await ensureMakerProfile(user.id); + const params = z.object({ id: z.string().uuid() }).parse(request.params); + + const rows = await query( + `delete from works where id = $1 and maker_id = $2 returning id`, + [params.id, maker.id] + ); + + if (!rows[0]) { + return reply.code(404).send({ error: "Work not found" }); + } + + return { deleted: true }; +}); + +app.post("/api/v1/account/maker/publish", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + const maker = await ensureMakerProfile(user.id); + const result = await publishMakerIfReady(maker.id); + if (!result.published) { + return reply.code(400).send(result); + } + return result; +}); + +app.post("/api/v1/account/maker/subscription/demo-activate", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + const maker = await ensureMakerProfile(user.id); + + const rows = await query( + ` + insert into subscriptions (maker_id, provider, plan_code, status, activated_at, expires_at) + values ($1, 'demo', 'maker-base', 'active', now(), now() + interval '30 days') + returning * + `, + [maker.id] + ); + + await query( + ` + insert into jobs (type, payload) + values ('subscription-activated', $1::jsonb) + `, + [JSON.stringify({ makerId: maker.id, subscriptionId: rows[0].id })] + ); + + return { subscription: rows[0] }; +}); + +app.post("/api/v1/inquiries", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + + const body = z.object({ + makerId: z.string().uuid(), + sourceType: z.enum(["profile", "service", "work"]), + sourceId: z.string().uuid().optional().nullable(), + needText: z.string().min(10), + sizeText: z.string().optional(), + hasModel: z.boolean().default(false), + materialPreference: z.string().optional(), + quantity: z.coerce.number().int().positive(), + urgency: z.enum(["low", "medium", "high"]), + deliveryType: z.enum(["local", "shipping"]) + }).parse(request.body); + + const rows = await query<{ id: string }>( + ` + insert into inquiries ( + maker_id, customer_id, source_type, source_id, need_text, size_text, has_model, material_preference, quantity, urgency, delivery_type + ) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + returning id + `, + [ + body.makerId, + user.id, + body.sourceType, + body.sourceId ?? null, + body.needText, + body.sizeText ?? null, + body.hasModel, + body.materialPreference ?? null, + body.quantity, + body.urgency, + body.deliveryType + ] + ); + + await query( + `insert into messages (inquiry_id, sender_user_id, body) values ($1, $2, $3)`, + [rows[0].id, user.id, body.needText] + ); + await query(`insert into jobs (type, payload) values ('notify-inquiry', $1::jsonb)`, [ + JSON.stringify({ inquiryId: rows[0].id }) + ]); + + return { inquiryId: rows[0].id }; +}); + +app.get("/api/v1/inquiries/:id", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + const params = z.object({ id: z.string().uuid() }).parse(request.params); + const inquiry = await one( + ` + select i.*, m.user_id as maker_user_id, u.full_name as customer_name, mp.business_name, mp.slug as maker_slug + from inquiries i + join maker_profiles mp on mp.id = i.maker_id + join users u on u.id = i.customer_id + join maker_profiles m on m.id = i.maker_id + where i.id = $1 + `, + [params.id] + ); + if (!inquiry) { + return reply.code(404).send({ error: "Inquiry not found" }); + } + if (user.id !== inquiry.customer_id && user.id !== inquiry.maker_user_id && user.role !== "admin") { + return reply.code(403).send({ error: "Forbidden" }); + } + const messages = await query( + ` + select m.*, u.full_name + from messages m + join users u on u.id = m.sender_user_id + where m.inquiry_id = $1 + order by m.created_at asc + `, + [params.id] + ); + return { inquiry, messages }; +}); + +app.post("/api/v1/inquiries/:id/messages", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + const params = z.object({ id: z.string().uuid() }).parse(request.params); + const body = z.object({ body: z.string().min(1) }).parse(request.body); + const inquiry = await one<{ id: string }>( + ` + select i.id + from inquiries i + join maker_profiles mp on mp.id = i.maker_id + where i.id = $1 and (i.customer_id = $2 or mp.user_id = $2) + `, + [params.id, user.id] + ); + if (!inquiry) { + return reply.code(403).send({ error: "Forbidden" }); + } + + const rows = await query( + `insert into messages (inquiry_id, sender_user_id, body) values ($1, $2, $3) returning *`, + [params.id, user.id, body.body] + ); + return { message: rows[0] }; +}); + +app.post("/api/v1/inquiries/:id/proposal", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + const params = z.object({ id: z.string().uuid() }).parse(request.params); + const body = z.object({ + priceCents: z.coerce.number().int().positive(), + quantity: z.coerce.number().int().positive(), + leadTimeDays: z.coerce.number().int().positive(), + note: z.string().optional() + }).parse(request.body); + + const updated = await query( + ` + update inquiries i + set proposal_price_cents = $2, + proposal_quantity = $3, + proposal_lead_time_days = $4, + proposal_note = $5, + proposal_created_at = now(), + updated_at = now() + from maker_profiles mp + where i.id = $1 + and mp.id = i.maker_id + and mp.user_id = $6 + and i.status in ('open', 'agreed') + returning i.* + `, + [params.id, body.priceCents, body.quantity, body.leadTimeDays, body.note ?? null, user.id] + ); + if (!updated[0]) { + return reply.code(403).send({ error: "Forbidden" }); + } + + await query( + `insert into messages (inquiry_id, sender_user_id, body) values ($1, $2, $3)`, + [ + params.id, + user.id, + `Propuesta: ${body.quantity} unidad(es), $${Math.round(body.priceCents / 100).toLocaleString("es-AR")} y plazo estimado de ${body.leadTimeDays} dias.` + ] + ); + + return { inquiry: updated[0] }; +}); + +app.post("/api/v1/inquiries/:id/accept-proposal", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + const params = z.object({ id: z.string().uuid() }).parse(request.params); + const updated = await query( + ` + update inquiries + set status = 'agreed', + agreed_at = now(), + updated_at = now() + where id = $1 + and customer_id = $2 + and proposal_price_cents is not null + and status = 'open' + returning * + `, + [params.id, user.id] + ); + if (!updated[0]) { + return reply.code(403).send({ error: "Proposal not available" }); + } + + await query( + `insert into messages (inquiry_id, sender_user_id, body) values ($1, $2, $3)`, + [params.id, user.id, "Propuesta aceptada. El trabajo queda acordado."] + ); + + return { inquiry: updated[0] }; +}); + +app.post("/api/v1/inquiries/:id/reject", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + const params = z.object({ id: z.string().uuid() }).parse(request.params); + const body = z.object({ reason: z.string().min(3) }).parse(request.body); + const updated = await query( + ` + update inquiries i + set status = 'rejected', rejection_reason = $2, updated_at = now() + from maker_profiles mp + where i.id = $1 and mp.id = i.maker_id and mp.user_id = $3 + returning i.* + `, + [params.id, body.reason, user.id] + ); + if (!updated[0]) { + return reply.code(403).send({ error: "Forbidden" }); + } + return { inquiry: updated[0] }; +}); + +app.post("/api/v1/inquiries/:id/complete", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + const params = z.object({ id: z.string().uuid() }).parse(request.params); + const inquiry = await one<{ id: string; customer_id: string; maker_user_id: string; maker_completed_at: string | null; customer_completed_at: string | null }>( + ` + select i.id, i.customer_id, mp.user_id as maker_user_id, i.maker_completed_at, i.customer_completed_at + from inquiries i + join maker_profiles mp on mp.id = i.maker_id + where i.id = $1 + and (i.customer_id = $2 or mp.user_id = $2) + and i.status in ('open', 'agreed') + `, + [params.id, user.id] + ); + if (!inquiry) { + return reply.code(403).send({ error: "Forbidden" }); + } + + const isMaker = user.id === inquiry.maker_user_id; + const makerCompleted = isMaker || Boolean(inquiry.maker_completed_at); + const customerCompleted = !isMaker || Boolean(inquiry.customer_completed_at); + const updated = await query( + ` + update inquiries i + set maker_completed_at = case when $2 then now() else maker_completed_at end, + customer_completed_at = case when $3 then now() else customer_completed_at end, + status = case when $4 and $5 then 'completed' else status end, + completed_at = case when $4 and $5 then now() else completed_at end, + updated_at = now() + from maker_profiles mp + where i.id = $1 and mp.id = i.maker_id + returning i.* + `, + [params.id, isMaker, !isMaker, makerCompleted, customerCompleted] + ); + + await query( + `insert into messages (inquiry_id, sender_user_id, body) values ($1, $2, $3)`, + [params.id, user.id, makerCompleted && customerCompleted ? "Trabajo finalizado y confirmado por ambas partes." : "Confirmacion de finalizacion registrada."] + ); + + return { inquiry: updated[0] }; +}); + +app.post("/api/v1/inquiries/:id/incident", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + const params = z.object({ id: z.string().uuid() }).parse(request.params); + const body = z.object({ + reason: z.string().min(3), + detail: z.string().min(5) + }).parse(request.body); + + const updated = await query( + ` + update inquiries i + set status = 'incident', + incident_reason = $2, + incident_detail = $3, + incident_created_at = now(), + updated_at = now() + from maker_profiles mp + where i.id = $1 + and mp.id = i.maker_id + and (i.customer_id = $4 or mp.user_id = $4) + returning i.* + `, + [params.id, body.reason, body.detail, user.id] + ); + if (!updated[0]) { + return reply.code(403).send({ error: "Forbidden" }); + } + + await query( + ` + insert into moderation_cases (target_type, target_id, reporter_user_id, reason) + values ('inquiry', $1, $2, $3) + `, + [params.id, user.id, `${body.reason}: ${body.detail}`] + ); + await query( + `insert into messages (inquiry_id, sender_user_id, body) values ($1, $2, $3)`, + [params.id, user.id, `Incidencia reportada: ${body.reason}`] + ); + + return { inquiry: updated[0] }; +}); + +app.post("/api/v1/reviews", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + + const body = z.object({ + inquiryId: z.string().uuid(), + ratingOverall: z.coerce.number().int().min(1).max(5), + ratingQuality: z.coerce.number().int().min(1).max(5), + ratingCommunication: z.coerce.number().int().min(1).max(5), + ratingValue: z.coerce.number().int().min(1).max(5), + comment: z.string().min(10) + }).parse(request.body); + + const inquiry = await one<{ maker_id: string; customer_id: string; status: string }>( + `select maker_id, customer_id, status from inquiries where id = $1`, + [body.inquiryId] + ); + if (!inquiry || inquiry.customer_id !== user.id || inquiry.status !== "completed") { + return reply.code(400).send({ error: "Review not allowed" }); + } + const reviewConfig = await one<{ allow_verified_reviews: boolean }>( + `select allow_verified_reviews from maker_profiles where id = $1`, + [inquiry.maker_id] + ); + if (reviewConfig?.allow_verified_reviews === false) { + return reply.code(403).send({ error: "Reviews disabled by maker" }); + } + + const rows = await query( + ` + insert into reviews ( + inquiry_id, maker_id, customer_id, rating_overall, rating_quality, rating_communication, rating_value, comment + ) + values ($1, $2, $3, $4, $5, $6, $7, $8) + on conflict (inquiry_id) do update + set rating_overall = excluded.rating_overall, + rating_quality = excluded.rating_quality, + rating_communication = excluded.rating_communication, + rating_value = excluded.rating_value, + comment = excluded.comment + returning * + `, + [ + body.inquiryId, + inquiry.maker_id, + user.id, + body.ratingOverall, + body.ratingQuality, + body.ratingCommunication, + body.ratingValue, + body.comment + ] + ); + + return { review: rows[0] }; +}); + +app.post("/api/v1/reviews/:id/report", async (request, reply) => { + const user = requireAuth(request, reply); + if (!user) { + return; + } + const params = z.object({ id: z.string().uuid() }).parse(request.params); + const body = z.object({ reason: z.string().min(3) }).parse(request.body); + await query( + ` + insert into moderation_cases (target_type, target_id, reporter_user_id, reason) + values ('review', $1, $2, $3) + `, + [params.id, user.id, body.reason] + ); + await query(`update reviews set status = 'reported' where id = $1`, [params.id]); + return { ok: true }; +}); + +app.get("/api/v1/admin/overview", async (request, reply) => { + const admin = requireAdmin(request, reply); + if (!admin) { + return; + } + + const [makers, published, inquiries, cases] = await Promise.all([ + one<{ count: string }>(`select count(*) from maker_profiles`), + one<{ count: string }>(`select count(*) from maker_profiles where status = 'published'`), + one<{ count: string }>(`select count(*) from inquiries`), + one<{ count: string }>(`select count(*) from moderation_cases where status = 'open'`) + ]); + + return { + stats: { + makers: Number(makers?.count ?? 0), + published: Number(published?.count ?? 0), + inquiries: Number(inquiries?.count ?? 0), + openCases: Number(cases?.count ?? 0) + } + }; +}); + +app.get("/api/v1/admin/makers", async (request, reply) => { + const admin = requireAdmin(request, reply); + if (!admin) { + return; + } + const makers = await query( + ` + select mp.*, u.email + from maker_profiles mp + join users u on u.id = mp.user_id + order by mp.created_at desc + ` + ); + return { makers }; +}); + +app.post("/api/v1/admin/makers/:id/suspend", async (request, reply) => { + const admin = requireAdmin(request, reply); + if (!admin) { + return; + } + const params = z.object({ id: z.string().uuid() }).parse(request.params); + await query(`update maker_profiles set status = 'suspended', updated_at = now() where id = $1`, [params.id]); + return { ok: true }; +}); + +app.get("/api/v1/admin/cases", async (request, reply) => { + const admin = requireAdmin(request, reply); + if (!admin) { + return; + } + const cases = await query(`select * from moderation_cases order by created_at desc`); + return { cases }; +}); + +await ensureBuckets(); + +const port = config.API_PORT; +const close = async () => { + await app.close(); + await pool.end(); + process.exit(0); +}; + +process.on("SIGINT", close); +process.on("SIGTERM", close); + +app.listen({ port, host: "0.0.0.0" }) + .then(() => { + app.log.info(`API listening on ${port}`); + }) + .catch((error) => { + app.log.error(error); + process.exit(1); + }); diff --git a/apps/api/src/publish.test.ts b/apps/api/src/publish.test.ts new file mode 100644 index 0000000..2ed2f73 --- /dev/null +++ b/apps/api/src/publish.test.ts @@ -0,0 +1,19 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { buildPublishChecklist, isPublishReady } from "@makers3d/shared"; + +test("publish checklist exige suscripcion activa", () => { + const checklist = buildPublishChecklist({ + businessName: "Maker", + description: "Descripcion suficiente", + province: "CABA", + city: "Buenos Aires", + servicesCount: 1, + worksCount: 1, + publicContactEmail: "maker@test.com", + subscriptionStatus: "inactive" + }); + + assert.equal(isPublishReady(checklist), false); +}); diff --git a/apps/api/src/queries.ts b/apps/api/src/queries.ts new file mode 100644 index 0000000..2cc7ae7 --- /dev/null +++ b/apps/api/src/queries.ts @@ -0,0 +1,443 @@ +import { buildPublishChecklist, isPublishReady, maskCoordinates, slugify, type Availability, type SubscriptionStatus } from "@makers3d/shared"; + +import { query, one } from "./db.js"; + +type MakerRow = { + id: string; + user_id: string; + slug: string; + business_name: string | null; + description: string | null; + province: string | null; + city: string | null; + latitude: number | null; + longitude: number | null; + public_latitude: number | null; + public_longitude: number | null; + delivery_scope: string | null; + availability: Availability; + public_contact_email: string | null; + public_whatsapp: string | null; + website_url: string | null; + instagram_url: string | null; + tiktok_url: string | null; + twitter_url: string | null; + facebook_url: string | null; + youtube_url: string | null; + linkedin_url: string | null; + allow_verified_reviews: boolean; + show_satisfaction_score: boolean; + show_review_tags: boolean; + allow_maker_review_reply: boolean; + show_past_reviews: boolean; + status: string; + main_image_url: string | null; +}; + +export async function getMakerByUserId(userId: string) { + return one(`select * from maker_profiles where user_id = $1`, [userId]); +} + +export async function getMakerBySlug(slug: string) { + return one( + `select * from maker_profiles where slug = $1 and status in ('draft', 'published', 'suspended')`, + [slug] + ); +} + +export async function ensureMakerProfile(userId: string): Promise { + let profile = await getMakerByUserId(userId); + if (profile) { + return profile; + } + + const slug = `maker-${userId.slice(0, 8)}`; + const rows = await query( + ` + insert into maker_profiles ( + user_id, slug, availability, delivery_scope, status + ) values ($1, $2, 'available', 'local', 'draft') + returning * + `, + [userId, slug] + ); + return rows[0]; +} + +export async function getSubscriptionStatus(makerId: string): Promise { + const result = await one<{ status: SubscriptionStatus }>( + `select status from subscriptions where maker_id = $1 order by created_at desc limit 1`, + [makerId] + ); + return result?.status ?? "inactive"; +} + +export async function upsertMakerProfile(userId: string, input: { + businessName: string; + description: string; + province: string; + city: string; + latitude: number; + longitude: number; + deliveryScope: string; + availability: Availability; + publicContactEmail?: string; + publicWhatsapp?: string; + mainImageUrl?: string; + websiteUrl?: string; + instagramUrl?: string; + tiktokUrl?: string; + twitterUrl?: string; + facebookUrl?: string; + youtubeUrl?: string; + linkedinUrl?: string; +}) { + const existing = await ensureMakerProfile(userId); + const slugBase = slugify(input.businessName || existing.slug || "maker"); + const masked = maskCoordinates(input.latitude, input.longitude); + const rows = await query( + ` + update maker_profiles + set slug = $2, + business_name = $3, + description = $4, + province = $5, + city = $6, + latitude = $7, + longitude = $8, + public_latitude = $9, + public_longitude = $10, + delivery_scope = $11, + availability = $12, + public_contact_email = $13, + public_whatsapp = $14, + main_image_url = coalesce($15, main_image_url), + website_url = $16, + instagram_url = $17, + tiktok_url = $18, + twitter_url = $19, + facebook_url = $20, + youtube_url = $21, + linkedin_url = $22, + updated_at = now() + where user_id = $1 + returning * + `, + [ + userId, + `${slugBase}-${existing.id.slice(0, 4)}`, + input.businessName, + input.description, + input.province, + input.city, + input.latitude, + input.longitude, + masked.latitude, + masked.longitude, + input.deliveryScope, + input.availability, + input.publicContactEmail || null, + input.publicWhatsapp || null, + input.mainImageUrl || null, + input.websiteUrl || null, + input.instagramUrl || null, + input.tiktokUrl || null, + input.twitterUrl || null, + input.facebookUrl || null, + input.youtubeUrl || null, + input.linkedinUrl || null + ] + ); + + return rows[0]; +} + +export async function getMakerChecklist(makerId: string) { + const maker = await one(`select * from maker_profiles where id = $1`, [makerId]); + if (!maker) { + return null; + } + + const services = await one<{ count: string }>(`select count(*) from services where maker_id = $1 and is_published = true`, [makerId]); + const works = await one<{ count: string }>(`select count(*) from works where maker_id = $1 and is_published = true`, [makerId]); + const subscriptionStatus = await getSubscriptionStatus(makerId); + + const checklist = buildPublishChecklist({ + businessName: maker.business_name, + description: maker.description, + province: maker.province, + city: maker.city, + servicesCount: Number(services?.count ?? 0), + worksCount: Number(works?.count ?? 0), + publicContactEmail: maker.public_contact_email, + publicWhatsapp: maker.public_whatsapp, + subscriptionStatus + }); + + return { + checklist, + ready: isPublishReady(checklist), + subscriptionStatus + }; +} + +export async function publishMakerIfReady(makerId: string) { + const checklistInfo = await getMakerChecklist(makerId); + if (!checklistInfo || !checklistInfo.ready) { + return { published: false, reason: "Checklist incomplete" }; + } + + await query(`update maker_profiles set status = 'published', updated_at = now() where id = $1`, [makerId]); + return { published: true }; +} + +export async function listPublicMakers(filters: { + q?: string; + province?: string; + city?: string; + serviceCategory?: string; + material?: string; + deliveryScope?: string; +}) { + const params: unknown[] = []; + const conditions = [`m.status = 'published'`]; + let queryExactIndex = 0; + let queryLikeIndex = 0; + + if (filters.q) { + params.push(filters.q.toLowerCase()); + queryExactIndex = params.length; + params.push(`%${filters.q.toLowerCase()}%`); + queryLikeIndex = params.length; + conditions.push(`( + lower(m.business_name) like $${queryLikeIndex} + or lower(m.description) like $${queryLikeIndex} + or exists ( + select 1 from services s + where s.maker_id = m.id + and s.is_published = true + and ( + lower(s.title) like $${queryLikeIndex} + or lower(s.description) like $${queryLikeIndex} + or lower(s.category) like $${queryLikeIndex} + or exists ( + select 1 + from unnest(s.technologies) technology + where lower(technology) like $${queryLikeIndex} + ) + or exists ( + select 1 + from unnest(s.materials) material + where lower(material) like $${queryLikeIndex} + ) + ) + ) + or exists ( + select 1 from works w + where w.maker_id = m.id + and w.is_published = true + and ( + lower(w.title) like $${queryLikeIndex} + or lower(w.summary) like $${queryLikeIndex} + or lower(w.story) like $${queryLikeIndex} + or lower(w.technology) like $${queryLikeIndex} + or lower(w.material) like $${queryLikeIndex} + ) + ) + )`); + } + + if (filters.province) { + params.push(filters.province); + conditions.push(`m.province = $${params.length}`); + } + + if (filters.city) { + params.push(filters.city); + conditions.push(`m.city = $${params.length}`); + } + + if (filters.deliveryScope) { + params.push(filters.deliveryScope); + conditions.push(`m.delivery_scope = $${params.length}`); + } + + if (filters.serviceCategory) { + params.push(filters.serviceCategory); + conditions.push(`exists ( + select 1 from services s + where s.maker_id = m.id + and s.is_published = true + and s.category = $${params.length} + )`); + } + + if (filters.material) { + params.push(filters.material); + conditions.push(`exists ( + select 1 from services s + where s.maker_id = m.id + and s.is_published = true + and $${params.length} = any(s.materials) + )`); + } + + const matchScoreSelect = filters.q ? ` + ( + case when lower(m.business_name) = $${queryExactIndex} then 160 else 0 end + + case when lower(m.business_name) like $${queryLikeIndex} then 90 else 0 end + + case when lower(m.description) like $${queryLikeIndex} then 36 else 0 end + + case when exists ( + select 1 + from services s + where s.maker_id = m.id + and s.is_published = true + and lower(s.title) = $${queryExactIndex} + ) then 140 else 0 end + + case when exists ( + select 1 + from services s + where s.maker_id = m.id + and s.is_published = true + and lower(s.title) like $${queryLikeIndex} + ) then 96 else 0 end + + case when exists ( + select 1 + from services s + where s.maker_id = m.id + and s.is_published = true + and ( + lower(s.description) like $${queryLikeIndex} + or lower(s.category) like $${queryLikeIndex} + or exists ( + select 1 + from unnest(s.technologies) technology + where lower(technology) like $${queryLikeIndex} + ) + or exists ( + select 1 + from unnest(s.materials) material + where lower(material) like $${queryLikeIndex} + ) + ) + ) then 54 else 0 end + + case when exists ( + select 1 + from works w + where w.maker_id = m.id + and w.is_published = true + and lower(w.title) = $${queryExactIndex} + ) then 150 else 0 end + + case when exists ( + select 1 + from works w + where w.maker_id = m.id + and w.is_published = true + and lower(w.title) like $${queryLikeIndex} + ) then 102 else 0 end + + case when exists ( + select 1 + from works w + where w.maker_id = m.id + and w.is_published = true + and ( + lower(w.summary) like $${queryLikeIndex} + or lower(w.story) like $${queryLikeIndex} + or lower(w.technology) like $${queryLikeIndex} + or lower(w.material) like $${queryLikeIndex} + ) + ) then 66 else 0 end + ) as match_score, + ` : ` + 0 as match_score, + `; + + const orderBy = filters.q + ? `match_score desc, review_count desc, m.business_name asc` + : `review_count desc, m.business_name asc`; + + return query( + ` + select + m.id, + m.slug, + m.business_name, + m.description, + m.province, + m.city, + m.public_latitude, + m.public_longitude, + m.delivery_scope, + m.availability, + m.main_image_url, + coalesce( + lv.public_locations, + case + when m.public_latitude is not null and m.public_longitude is not null then jsonb_build_array(jsonb_build_object( + 'id', m.id, + 'label', m.business_name, + 'city', m.city, + 'province', m.province, + 'latitude', m.public_latitude, + 'longitude', m.public_longitude + )) + else '[]'::jsonb + end + ) as public_locations, + coalesce(rv.avg_rating, 0) as avg_rating, + coalesce(rv.review_count, 0) as review_count, + coalesce(sv.service_count, 0) as service_count, + coalesce(wv.work_count, 0) as work_count, +${matchScoreSelect} + coalesce(cv.categories, '{}'::text[]) as categories + from maker_profiles m + left join ( + select maker_id, round(avg(rating_overall)::numeric, 1) as avg_rating, count(*) as review_count + from reviews + where status = 'published' + group by maker_id + ) rv on rv.maker_id = m.id + left join ( + select maker_id, count(*) as service_count + from services + where is_published = true + group by maker_id + ) sv on sv.maker_id = m.id + left join ( + select maker_id, count(*) as work_count + from works + where is_published = true + group by maker_id + ) wv on wv.maker_id = m.id + left join ( + select maker_id, array_agg(distinct category order by category) as categories + from services + where is_published = true + group by maker_id + ) cv on cv.maker_id = m.id + left join ( + select + maker_id, + jsonb_agg( + jsonb_build_object( + 'id', id, + 'label', label, + 'city', city, + 'province', province, + 'latitude', case when privacy = 'exact' then latitude else round(latitude::numeric, 2)::double precision end, + 'longitude', case when privacy = 'exact' then longitude else round(longitude::numeric, 2)::double precision end + ) + order by created_at desc + ) as public_locations + from maker_locations + where is_published = true + and latitude is not null + and longitude is not null + group by maker_id + ) lv on lv.maker_id = m.id + where ${conditions.join(" and ")} + order by ${orderBy} + `, + params + ); +} diff --git a/apps/api/src/scripts/migrate.ts b/apps/api/src/scripts/migrate.ts new file mode 100644 index 0000000..1222078 --- /dev/null +++ b/apps/api/src/scripts/migrate.ts @@ -0,0 +1,31 @@ +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { pool } from "../db.js"; + +const currentDir = dirname(fileURLToPath(import.meta.url)); +const schemaCandidates = [ + join(currentDir, "../db/schema.sql"), + join(process.cwd(), "apps/api/src/db/schema.sql"), + join(process.cwd(), "dist/apps/api/src/db/schema.sql") +]; + +let sql: string | undefined; + +for (const schemaPath of schemaCandidates) { + try { + sql = await readFile(schemaPath, "utf8"); + break; + } catch { + // Try the next known location. The runtime container keeps the schema outside dist. + } +} + +if (!sql) { + throw new Error(`Schema file not found. Checked: ${schemaCandidates.join(", ")}`); +} + +await pool.query(sql); +console.log("Database schema applied"); +await pool.end(); diff --git a/apps/api/src/scripts/seed.ts b/apps/api/src/scripts/seed.ts new file mode 100644 index 0000000..418941b --- /dev/null +++ b/apps/api/src/scripts/seed.ts @@ -0,0 +1,403 @@ +import { slugify } from "@makers3d/shared"; + +import { hashPassword } from "../auth.js"; +import { pool, query, one } from "../db.js"; +import { getMakerByUserId, publishMakerIfReady, upsertMakerProfile } from "../queries.js"; + +type DemoMaker = { + businessName: string; + category: "Impresion FDM" | "Impresion Resina" | "Diseno 3D" | "Prototipado"; + focus: string; + imageUrl: string; + material: string; + technology: string; + priceFromCents: number; + deliveryScope: "local" | "nationwide"; + availability: "available" | "limited"; + reviewScore: number; + reviewCount: number; +}; + +async function ensureUser(email: string, password: string, fullName: string, role: "user" | "admin" = "user") { + const existing = await one<{ id: string }>(`select id from users where email = $1`, [email]); + if (existing) { + return existing.id; + } + + const passwordHash = await hashPassword(password); + const rows = await query<{ id: string }>( + ` + insert into users (email, password_hash, full_name, role) + values ($1, $2, $3, $4) + returning id + `, + [email, passwordHash, fullName, role] + ); + return rows[0].id; +} + +async function ensureActiveSubscription(makerId: string) { + await query( + ` + insert into subscriptions (maker_id, provider, plan_code, status, activated_at, expires_at) + select $1, 'demo', 'maker-base', 'active', now(), now() + interval '30 days' + where not exists ( + select 1 + from subscriptions + where maker_id = $1 + and provider = 'demo' + and plan_code = 'maker-base' + ) + `, + [makerId] + ); +} + +async function upsertService(makerId: string, slug: string, maker: DemoMaker) { + const rows = await query<{ id: string }>( + ` + insert into services ( + maker_id, slug, title, category, description, technologies, materials, local_pickup, nationwide_shipping, lead_time_days, price_from_cents, is_published + ) + values ($1, $2, $3, $4, $5, $6, $7, true, $8, $9, $10, true) + on conflict (slug) do update + set title = excluded.title, + category = excluded.category, + description = excluded.description, + technologies = excluded.technologies, + materials = excluded.materials, + nationwide_shipping = excluded.nationwide_shipping, + lead_time_days = excluded.lead_time_days, + price_from_cents = excluded.price_from_cents, + is_published = true, + updated_at = now() + returning id + `, + [ + makerId, + slug, + `${maker.category} para ${maker.focus}`, + maker.category, + `${maker.businessName} resuelve ${maker.focus} con ${maker.technology} y materiales como ${maker.material}.`, + [maker.technology, maker.category === "Diseno 3D" ? "CAD" : "Diseno 3D"], + [maker.material, "PLA", "PETG"], + maker.deliveryScope === "nationwide", + maker.category === "Impresion Resina" ? 4 : 3, + maker.priceFromCents + ] + ); + + return rows[0].id; +} + +async function upsertWork(makerId: string, serviceId: string, slug: string, maker: DemoMaker, index: number) { + const workVariantByImage: Record = { + "/demo/work-coffee-hinge.svg": { + title: "Restauracion de engranaje", + summaryTopic: "engranaje funcional para cafetera y repuesto mecanico", + storyTopic: "una pieza de transmision y recuperacion de movimiento" + }, + "/demo/work-custom.svg": { + title: "Soporte funcional", + summaryTopic: "soporte tecnico para montaje, fijacion o uso real", + storyTopic: "un soporte utilitario para montaje y ajuste" + }, + "/demo/work-dashboard-bracket.svg": { + title: "Tablero y carcasa tecnica", + summaryTopic: "tablero de control, panel tecnico y carcasa estructural", + storyTopic: "un componente de tablero, panel o control tecnico" + }, + "/demo/maker-hero-1.svg": { + title: "Prototipo validado", + summaryTopic: "prototipo funcional, maqueta y validacion de producto", + storyTopic: "una prueba de concepto y validacion de producto" + } + }; + const variant = workVariantByImage[maker.imageUrl] || workVariantByImage["/demo/maker-hero-1.svg"]; + const workTitle = `${variant.title} ${maker.businessName}`; + + await query( + ` + insert into works ( + maker_id, service_id, slug, title, summary, story, technology, material, image_url, gallery_urls, is_published + ) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, true) + on conflict (slug) do update + set title = excluded.title, + summary = excluded.summary, + story = excluded.story, + technology = excluded.technology, + material = excluded.material, + image_url = excluded.image_url, + gallery_urls = excluded.gallery_urls, + is_published = true, + updated_at = now() + `, + [ + makerId, + serviceId, + slug, + workTitle, + `${variant.summaryTopic} con enfoque en ${maker.material}, ${maker.technology} y entrega clara.`, + `${maker.businessName} publico este caso como demostracion de ${maker.focus}. Se documento ${variant.storyTopic}, el problema, la solucion y el material utilizado para facilitar comparacion y contacto.`, + maker.technology, + maker.material, + maker.imageUrl, + [maker.imageUrl] + ] + ); +} + +async function ensureInquiryAndReview(makerId: string, makerUserId: string, maker: DemoMaker, customerId: string, reviewIndex: number) { + const needText = `Consulta demo ${reviewIndex + 1} para ${maker.businessName}`; + let inquiry = await one<{ id: string }>( + `select id from inquiries where maker_id = $1 and customer_id = $2 and need_text = $3`, + [makerId, customerId, needText] + ); + + if (!inquiry) { + const created = await query<{ id: string }>( + ` + insert into inquiries ( + maker_id, customer_id, source_type, need_text, size_text, has_model, material_preference, quantity, urgency, delivery_type, status + ) + values ($1, $2, 'work', $3, '12 cm', true, $4, 1, 'medium', 'shipping', 'completed') + returning id + `, + [makerId, customerId, needText, maker.material] + ); + inquiry = created[0]; + + await query( + ` + insert into messages (inquiry_id, sender_user_id, body) + values + ($1, $2, 'Hola, quiero algo similar a lo publicado.'), + ($1, $3, 'Perfecto, te puedo ayudar con ese trabajo.'), + ($1, $2, 'Buenisimo, avancemos con medidas y entrega.') + `, + [inquiry.id, customerId, makerUserId] + ); + } + + const roundedRating = Math.max(4, Math.round(maker.reviewScore)); + await query( + ` + insert into reviews ( + inquiry_id, maker_id, customer_id, rating_overall, rating_quality, rating_communication, rating_value, comment, status + ) + values ($1, $2, $3, $4, $4, $4, $5, $6, 'published') + on conflict (inquiry_id) do update + set rating_overall = excluded.rating_overall, + rating_quality = excluded.rating_quality, + rating_communication = excluded.rating_communication, + rating_value = excluded.rating_value, + comment = excluded.comment, + status = 'published' + `, + [ + inquiry.id, + makerId, + customerId, + roundedRating, + Math.max(4, roundedRating - 1), + `${maker.businessName} respondio bien y entrego una solucion convincente para ${maker.focus}.` + ] + ); +} + +const adminId = await ensureUser("admin@makers3d.local", "Admin123!", "Admin", "admin"); +const clientIds = await Promise.all( + Array.from({ length: 8 }, (_, index) => + ensureUser(`cliente${index + 1}@makers3d.local`, "Cliente123!", `Cliente Demo ${index + 1}`) + ) +); + +const makers: DemoMaker[] = [ + { + businessName: "MakerLab 3D", + category: "Impresion FDM", + focus: "repuestos funcionales, ingenieria y piezas de uso real", + imageUrl: "/demo/work-coffee-hinge.svg", + material: "PETG", + technology: "FDM", + priceFromCents: 150000, + deliveryScope: "nationwide", + availability: "available", + reviewScore: 4.9, + reviewCount: 4 + }, + { + businessName: "PrintCraft", + category: "Impresion FDM", + focus: "series cortas, soportes tecnicos y repuestos industriales", + imageUrl: "/demo/work-custom.svg", + material: "ABS", + technology: "FDM", + priceFromCents: 165000, + deliveryScope: "nationwide", + availability: "available", + reviewScore: 4.8, + reviewCount: 3 + }, + { + businessName: "3D Ideas", + category: "Diseno 3D", + focus: "prototipos, modelado y piezas personalizadas", + imageUrl: "/demo/maker-hero-custom.svg", + material: "PLA", + technology: "CAD", + priceFromCents: 180000, + deliveryScope: "local", + availability: "available", + reviewScore: 4.7, + reviewCount: 2 + }, + { + businessName: "ImpresionAR", + category: "Impresion Resina", + focus: "figuras detalladas, miniaturas y piezas en resina", + imageUrl: "/demo/work-dashboard-bracket.svg", + material: "Resina estandar", + technology: "Resina", + priceFromCents: 175000, + deliveryScope: "nationwide", + availability: "available", + reviewScore: 4.8, + reviewCount: 2 + }, + { + businessName: "ProtoWorks", + category: "Prototipado", + focus: "validacion de producto, prototipos funcionales y ajustes rapidos", + imageUrl: "/demo/work-custom.svg", + material: "PLA", + technology: "FDM", + priceFromCents: 210000, + deliveryScope: "nationwide", + availability: "limited", + reviewScore: 4.6, + reviewCount: 1 + }, + { + businessName: "Resin Forge", + category: "Impresion Resina", + focus: "piezas finas, moldes chicos y detalle visual en resina", + imageUrl: "/demo/work-dashboard-bracket.svg", + material: "Resina alta definicion", + technology: "Resina", + priceFromCents: 195000, + deliveryScope: "local", + availability: "available", + reviewScore: 4.9, + reviewCount: 1 + }, + ...[ + "RepuestoLab", + "CadSur Studio", + "Volumen 3D", + "Nodo Maker", + "Delta Print", + "Origen 3D", + "Atlas Makers", + "Prisma Resina", + "FabCordoba", + "Punto Layer", + "Taller Capa", + "Linea 3D", + "ProtoRio", + "Hexa Print", + "Monoblock 3D", + "Cubo Norte", + "Rayo Maker", + "Ruta FDM", + "Andes Prototype", + "Sur Layers", + "Pixel Resina", + "Brio 3D", + "Arco Maker", + "Nexa CAD", + "Torno 3D", + "Faro Print", + "Cima Resina", + "Plano 3D", + "Malla Lab", + "Vector Maker" + ].map((businessName, index) => { + const categoryCycle = [ + "Impresion FDM", + "Diseno 3D", + "Impresion Resina", + "Prototipado" + ] as const; + const imageCycle = [ + "/demo/work-coffee-hinge.svg", + "/demo/work-custom.svg", + "/demo/work-dashboard-bracket.svg", + "/demo/maker-hero-1.svg" + ]; + const focusCycle = [ + "repuestos, soportes tecnicos y piezas funcionales", + "modelado, diseno 3D y preparacion de archivos", + "resina, detalle fino y miniaturas", + "prototipos, validacion y ajustes de producto" + ]; + const materialCycle = ["PETG", "PLA", "Resina estandar", "ABS"] as const; + const technologyCycle = ["FDM", "CAD", "Resina", "FDM"] as const; + + return { + businessName, + category: categoryCycle[index % categoryCycle.length], + focus: focusCycle[index % focusCycle.length], + imageUrl: imageCycle[index % imageCycle.length], + material: materialCycle[index % materialCycle.length], + technology: technologyCycle[index % technologyCycle.length], + priceFromCents: 145000 + index * 5000, + deliveryScope: index % 3 === 0 ? "nationwide" : "local", + availability: index % 5 === 0 ? "limited" : "available", + reviewScore: 4.4 + (index % 5) * 0.1, + reviewCount: index % 3 + }; + }) +]; + +for (const [index, makerSpec] of makers.entries()) { + const makerEmail = `maker${index + 1}@makers3d.local`; + const makerUserId = await ensureUser(makerEmail, "Maker123!", makerSpec.businessName); + const latitude = -31.4201 + (index % 6) * 0.009; + const longitude = -64.1888 + (index % 5) * 0.011; + + await upsertMakerProfile(makerUserId, { + businessName: makerSpec.businessName, + description: `${makerSpec.businessName} ofrece ${makerSpec.focus} en Cordoba, con foco en ${makerSpec.category.toLowerCase()}.`, + province: "Cordoba", + city: "Cordoba", + latitude, + longitude, + deliveryScope: makerSpec.deliveryScope, + availability: makerSpec.availability, + publicContactEmail: makerEmail, + publicWhatsapp: `+5493510000${String(index + 1).padStart(3, "0")}`, + mainImageUrl: makerSpec.imageUrl + }); + + const makerProfile = await getMakerByUserId(makerUserId); + if (!makerProfile) { + throw new Error(`Maker profile missing for ${makerSpec.businessName}`); + } + + await ensureActiveSubscription(makerProfile.id); + + const baseSlug = slugify(makerSpec.businessName); + const serviceId = await upsertService(makerProfile.id, `${baseSlug}-servicio-demo`, makerSpec); + await upsertWork(makerProfile.id, serviceId, `${baseSlug}-trabajo-demo`, makerSpec, index); + await publishMakerIfReady(makerProfile.id); + + for (let reviewIndex = 0; reviewIndex < makerSpec.reviewCount; reviewIndex += 1) { + const customerId = clientIds[(index + reviewIndex) % clientIds.length]; + await ensureInquiryAndReview(makerProfile.id, makerUserId, makerSpec, customerId, reviewIndex); + } +} + +console.log(`Seed completed. Admin=${adminId} Makers=${makers.length}`); +await pool.end(); diff --git a/apps/api/src/storage.ts b/apps/api/src/storage.ts new file mode 100644 index 0000000..12367da --- /dev/null +++ b/apps/api/src/storage.ts @@ -0,0 +1,34 @@ +import { Client } from "minio"; +import { randomUUID } from "node:crypto"; + +import { config } from "./config.js"; + +export const storage = new Client({ + endPoint: config.MINIO_ENDPOINT, + port: config.MINIO_PORT, + useSSL: config.MINIO_USE_SSL, + accessKey: config.MINIO_ROOT_USER, + secretKey: config.MINIO_ROOT_PASSWORD +}); + +export async function ensureBuckets(): Promise { + for (const bucket of [config.MINIO_PUBLIC_BUCKET, config.MINIO_PRIVATE_BUCKET]) { + const exists = await storage.bucketExists(bucket).catch(() => false); + if (!exists) { + await storage.makeBucket(bucket, "us-east-1"); + } + } +} + +export async function uploadPublicObject(fileName: string, contentType: string, buffer: Buffer): Promise { + const objectName = `${randomUUID()}-${fileName.replace(/[^a-zA-Z0-9.\-_]/g, "-")}`; + await storage.putObject(config.MINIO_PUBLIC_BUCKET, objectName, buffer, buffer.length, { + "Content-Type": contentType + }); + return objectName; +} + +export async function getPublicObject(objectName: string) { + return storage.getObject(config.MINIO_PUBLIC_BUCKET, objectName); +} + diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..4daffce --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "declaration": true + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..fcbbe67 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,19 @@ +FROM node:22-bookworm-slim AS build +WORKDIR /app +COPY package.json tsconfig.base.json ./ +COPY apps/web/package.json apps/web/package.json +COPY packages/shared/package.json packages/shared/package.json +RUN npm install +COPY . . +RUN npm run build -w @makers3d/web + +FROM node:22-bookworm-slim +WORKDIR /app +COPY --from=build /app/package.json ./ +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/apps/web/.next ./apps/web/.next +COPY --from=build /app/apps/web/public ./apps/web/public +COPY --from=build /app/apps/web/package.json ./apps/web/package.json +COPY --from=build /app/apps/web/next.config.ts ./apps/web/next.config.ts +ENV NODE_ENV=production +CMD ["node_modules/.bin/next", "start", "apps/web", "-p", "3000"] diff --git a/apps/web/app/account/inbox/[id]/page.tsx b/apps/web/app/account/inbox/[id]/page.tsx new file mode 100644 index 0000000..17791c4 --- /dev/null +++ b/apps/web/app/account/inbox/[id]/page.tsx @@ -0,0 +1,15 @@ +import AccountClient from "../../../components/AccountClient"; + +export default async function AccountInboxDetailPage({ + params +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + return ( +
+ +
+ ); +} diff --git a/apps/web/app/account/inbox/page.tsx b/apps/web/app/account/inbox/page.tsx new file mode 100644 index 0000000..e9015ab --- /dev/null +++ b/apps/web/app/account/inbox/page.tsx @@ -0,0 +1,9 @@ +import AccountClient from "../../components/AccountClient"; + +export default function AccountInboxPage() { + return ( +
+ +
+ ); +} diff --git a/apps/web/app/account/locations/page.tsx b/apps/web/app/account/locations/page.tsx new file mode 100644 index 0000000..109ba4f --- /dev/null +++ b/apps/web/app/account/locations/page.tsx @@ -0,0 +1,9 @@ +import AccountClient from "../../components/AccountClient"; + +export default function AccountLocationsPage() { + return ( +
+ +
+ ); +} diff --git a/apps/web/app/account/page.tsx b/apps/web/app/account/page.tsx new file mode 100644 index 0000000..95ae66c --- /dev/null +++ b/apps/web/app/account/page.tsx @@ -0,0 +1,9 @@ +import AccountClient from "../components/AccountClient"; + +export default function AccountPage() { + return ( +
+ +
+ ); +} diff --git a/apps/web/app/account/profile/page.tsx b/apps/web/app/account/profile/page.tsx new file mode 100644 index 0000000..c72879d --- /dev/null +++ b/apps/web/app/account/profile/page.tsx @@ -0,0 +1,9 @@ +import AccountClient from "../../components/AccountClient"; + +export default function AccountProfilePage() { + return ( +
+ +
+ ); +} diff --git a/apps/web/app/account/reviews/page.tsx b/apps/web/app/account/reviews/page.tsx new file mode 100644 index 0000000..0fbd582 --- /dev/null +++ b/apps/web/app/account/reviews/page.tsx @@ -0,0 +1,9 @@ +import AccountClient from "../../components/AccountClient"; + +export default function AccountReviewsPage() { + return ( +
+ +
+ ); +} diff --git a/apps/web/app/account/services/page.tsx b/apps/web/app/account/services/page.tsx new file mode 100644 index 0000000..a9d0114 --- /dev/null +++ b/apps/web/app/account/services/page.tsx @@ -0,0 +1,9 @@ +import AccountClient from "../../components/AccountClient"; + +export default function AccountServicesPage() { + return ( +
+ +
+ ); +} diff --git a/apps/web/app/account/settings/page.tsx b/apps/web/app/account/settings/page.tsx new file mode 100644 index 0000000..bd51edc --- /dev/null +++ b/apps/web/app/account/settings/page.tsx @@ -0,0 +1,9 @@ +import AccountClient from "../../components/AccountClient"; + +export default function AccountSettingsPage() { + return ( +
+ +
+ ); +} diff --git a/apps/web/app/account/showcases/page.tsx b/apps/web/app/account/showcases/page.tsx new file mode 100644 index 0000000..777b012 --- /dev/null +++ b/apps/web/app/account/showcases/page.tsx @@ -0,0 +1,9 @@ +import AccountClient from "../../components/AccountClient"; + +export default function AccountShowcasesPage() { + return ( +
+ +
+ ); +} diff --git a/apps/web/app/account/stats/page.tsx b/apps/web/app/account/stats/page.tsx new file mode 100644 index 0000000..e6a796c --- /dev/null +++ b/apps/web/app/account/stats/page.tsx @@ -0,0 +1,9 @@ +import AccountClient from "../../components/AccountClient"; + +export default function AccountStatsPage() { + return ( +
+ +
+ ); +} diff --git a/apps/web/app/account/subscription/page.tsx b/apps/web/app/account/subscription/page.tsx new file mode 100644 index 0000000..e7c2baf --- /dev/null +++ b/apps/web/app/account/subscription/page.tsx @@ -0,0 +1,9 @@ +import AccountClient from "../../components/AccountClient"; + +export default function AccountSubscriptionPage() { + return ( +
+ +
+ ); +} diff --git a/apps/web/app/account/works/page.tsx b/apps/web/app/account/works/page.tsx new file mode 100644 index 0000000..950b599 --- /dev/null +++ b/apps/web/app/account/works/page.tsx @@ -0,0 +1,9 @@ +import AccountClient from "../../components/AccountClient"; + +export default function AccountWorksPage() { + return ( +
+ +
+ ); +} diff --git a/apps/web/app/app/backoffice/[[...section]]/page.tsx b/apps/web/app/app/backoffice/[[...section]]/page.tsx new file mode 100644 index 0000000..0522a85 --- /dev/null +++ b/apps/web/app/app/backoffice/[[...section]]/page.tsx @@ -0,0 +1,56 @@ +import BackofficeEntryClient from "../../../components/BackofficeEntryClient"; +import type { AdminSection } from "../../../components/AdminClient"; + +const sectionMap: Record = { + queue: "queue", + clients: "clients", + makers: "makers", + cases: "incidents", + "internal-users": "internal-users", + reviews: "reviews", + incidents: "incidents", + reports: "reports", + fraud: "fraud", + appeals: "appeals", + subscriptions: "subscriptions", + payments: "payments", + billing: "billing", + promotions: "promotions", + founders: "founders", + works: "works", + showcases: "showcases", + services: "services", + "reported-media": "reported-media", + "catalog-services": "catalog-services", + categories: "categories", + materials: "materials", + technologies: "technologies", + specialties: "specialties", + locations: "locations", + tickets: "tickets", + conversations: "conversations", + "help-center": "help-center", + "analytics-product": "analytics-product", + "analytics-makers": "analytics-makers", + "analytics-clients": "analytics-clients", + "analytics-trust": "analytics-trust", + "analytics-business": "analytics-business", + roles: "roles", + settings: "settings", + integrations: "integrations", + templates: "templates", + audit: "audit", + system: "system" +}; + +export default async function BackofficePage({ + params +}: { + params: Promise<{ section?: string[] }>; +}) { + const resolvedParams = await params; + const currentSection = resolvedParams.section?.[0]; + const section = currentSection ? sectionMap[currentSection] || "overview" : "overview"; + + return ; +} diff --git a/apps/web/app/components/AccountClient.tsx b/apps/web/app/components/AccountClient.tsx new file mode 100644 index 0000000..0451641 --- /dev/null +++ b/apps/web/app/components/AccountClient.tsx @@ -0,0 +1,4656 @@ +"use client"; + +import type { Route } from "next"; +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; + +import { browserFetch } from "../lib/api"; +import { logoutByNavigation } from "../lib/logout"; + +export type AccountSection = + | "summary" + | "showcases" + | "stats" + | "locations" + | "profile" + | "services" + | "works" + | "inbox" + | "reviews" + | "subscription" + | "settings"; + +type AccountClientProps = { + section?: AccountSection; + conversationId?: string; +}; + +type AccountResponse = { + maker: { + id: string; + slug: string; + status: string; + business_name: string | null; + description: string | null; + province: string | null; + city: string | null; + delivery_scope: string | null; + availability: string; + public_contact_email: string | null; + public_whatsapp: string | null; + latitude: number | null; + longitude: number | null; + main_image_url: string | null; + website_url: string | null; + instagram_url: string | null; + tiktok_url: string | null; + twitter_url: string | null; + facebook_url: string | null; + youtube_url: string | null; + linkedin_url: string | null; + allow_verified_reviews: boolean | null; + show_satisfaction_score: boolean | null; + show_review_tags: boolean | null; + allow_maker_review_reply: boolean | null; + show_past_reviews: boolean | null; + }; + services: Array>; + showcases: Array>; + locations: Array>; + works: Array>; + inquiries: Array>; + reviews: Array>; + checklist: { + checklist: Record; + ready: boolean; + subscriptionStatus: string; + }; +}; + +function toText(value: unknown, fallback = "") { + return typeof value === "string" && value ? value : fallback; +} + +function toNumber(value: unknown, fallback = 0) { + return typeof value === "number" ? value : Number(value) || fallback; +} + +function uniqueList(values: string[]) { + return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))); +} + +function cleanShowcaseDescription(value: string) { + return value + .split(/\n\s*\n/) + .filter((block) => !block.trim().toLowerCase().startsWith("incluye:")) + .join("\n\n") + .trim(); +} + +function locationPinStyle(location: Record, index: number) { + const lat = toNumber(location.latitude, -31.42); + const lng = toNumber(location.longitude, -64.18); + const left = Math.max(16, Math.min(84, 50 + ((lng + 64.18) * 220) + (index % 3 - 1) * 9)); + const top = Math.max(18, Math.min(82, 50 - ((lat + 31.42) * 220) + (Math.floor(index / 3) - 0.5) * 9)); + return { + left: `${left}%`, + top: `${top}%` + }; +} + +const sectionCopy: Record = { + summary: { + eyebrow: "Mi espacio maker", + title: "Dashboard operativo y prioridades reales", + description: "Resumen, rendimiento y tareas clave antes que metricas vacias." + }, + showcases: { + eyebrow: "Escaparates", + title: "Colecciones editoriales de tus trabajos", + description: "Agrupan evidencia real para especializar mejor tu perfil y guiar consultas." + }, + stats: { + eyebrow: "Estadisticas", + title: "Rendimiento util, no vanidad", + description: "Visitas, consultas y conversion resumidas en una lectura accionable." + }, + locations: { + eyebrow: "Ubicaciones", + title: "Cobertura, privacidad y alcance", + description: "Define desde donde operas y como te descubren clientes locales o con envio." + }, + profile: { + eyebrow: "Perfil publico", + title: "Identidad y confianza", + description: "Configura como te ven los clientes en el perfil publico y resultados." + }, + services: { + eyebrow: "Servicios y precios", + title: "Capacidades visibles y bien acotadas", + description: "Cada servicio debe ayudar a filtrar mejor y a recibir consultas de mas calidad." + }, + works: { + eyebrow: "Trabajos", + title: "Portafolio publico con contexto", + description: "Publica resultados reales para demostrar calidad y disparar consultas parecidas." + }, + inbox: { + eyebrow: "Consultas", + title: "Bandeja con contexto persistente", + description: "La referencia del trabajo o servicio acompana toda la conversacion." + }, + reviews: { + eyebrow: "Resenas", + title: "Reputacion basada en trabajos cerrados", + description: "Las opiniones verificadas ayudan a sostener la confianza y detectar riesgos." + }, + subscription: { + eyebrow: "Plan Maker", + title: "Suscripcion visible pero no invasiva", + description: "La publicacion nunca depende de magia: depende de requisitos y estado permitido." + }, + settings: { + eyebrow: "Cuenta", + title: "Acceso, sesion y preferencias", + description: "Una sola cuenta sirve para cliente y maker, con control claro de su estado." + } +}; + +const primaryLinks: Array<{ href: Route; label: string; section: AccountSection }> = [ + { href: "/account", label: "Inicio", section: "summary" }, + { href: "/account/inbox", label: "Consultas", section: "inbox" }, + { href: "/account/works", label: "Trabajos", section: "works" } +]; + +const secondaryLinks: Array<{ href: Route; label: string; section: AccountSection }> = [ + { href: "/account/showcases", label: "Escaparates", section: "showcases" }, + { href: "/account/services", label: "Servicios", section: "services" }, + { href: "/account/stats", label: "Estadisticas", section: "stats" }, + { href: "/account/locations", label: "Ubicaciones", section: "locations" }, + { href: "/account/profile", label: "Perfil publico", section: "profile" }, + { href: "/account/reviews", label: "Resenas", section: "reviews" }, + { href: "/account/subscription", label: "Suscripcion", section: "subscription" }, + { href: "/account/settings", label: "Configuracion", section: "settings" } +]; + +const publishSteps = [ + "Fotos", + "Que hiciste", + "Problema y uso", + "Tecnicos", + "Antes / Resultado", + "Servicios", + "Precio", + "Vista previa", + "Publicar" +]; + +const problemOptions = [ + "Reproducir algo descatalogado", + "Reemplazar una pieza rota", + "Crear algo desde cero", + "Personalizar un objeto", + "Fabricar varias unidades", + "Crear un prototipo" +]; + +const useOptions = [ + "Pieza funcional", + "Exterior", + "Contacto con calor", + "Contacto con agua", + "Soporta esfuerzo", + "Decoracion / hobby" +]; + +const publishTips = [ + "Usa fotos claras y bien iluminadas", + "Muestra el antes y el despues", + "Describe el problema y la solucion", + "Relaciona el trabajo con tus servicios", + "Agrega precio orientativo si ayuda a filtrar" +]; + +const subscriptionChecklistLabels: Record = { + hasBusinessName: { label: "Nombre y descripcion", detail: "Identidad publica basica completada." }, + hasDescription: { label: "Descripcion clara", detail: "Explica que haces y para quien." }, + hasLocation: { label: "Ubicacion configurada", detail: "Cobertura y privacidad definidas." }, + hasService: { label: "Servicios configurados", detail: "Capacidades y precios orientativos." }, + hasWork: { label: "Trabajos publicados", detail: "Portafolio con evidencia real." }, + hasContact: { label: "Contacto publico", detail: "Email, WhatsApp o canal operativo." }, + hasSubscription: { label: "Plan activo", detail: "Permite aparecer en mapa y busquedas." } +}; + +const planBenefits = [ + "Perfil profesional completo", + "Aparicion en mapa y busquedas", + "Servicios y precios ilimitados", + "Hasta 3 escaparates publicos", + "Trabajos publicados ilimitados", + "Consultas y mensajeria", + "Resenas verificadas", + "Multiples ubicaciones", + "Soporte por email" +]; + +const subscriptionAddOns = [ + { title: "Escaparates adicionales", detail: "Mas colecciones para verticales o zonas.", state: "Futuro" }, + { title: "Analitica avanzada", detail: "Origen de consultas, embudo y conversion.", state: "Futuro" }, + { title: "Herramientas de negocio", detail: "Presupuestos, plantillas y seguimiento.", state: "Futuro" } +]; + +const supportCards = [ + { title: "Preguntas frecuentes", detail: "Resuelve dudas comunes del plan.", action: "Ver FAQ" }, + { title: "Contactar soporte", detail: "Escribenos si algo bloquea tu publicacion.", action: "Enviar mensaje" }, + { title: "Centro de ayuda", detail: "Guias y tutoriales paso a paso.", action: "Ir al centro" }, + { title: "Estado del servicio", detail: "Estado tecnico de la plataforma.", action: "Ver estado" } +]; + +const subscriptionBillingSteps = ["Facturacion", "Pago", "Validacion"]; + +const accountQuickAccess = [ + ["Datos personales", "Nombre, email, telefono y avatar."], + ["Seguridad y acceso", "Contrasena, 2FA y sesiones activas."], + ["Privacidad y ubicacion", "Permisos, historial y personalizacion."], + ["Notificaciones", "Canales, frecuencia y alertas."], + ["Preferencias", "Tema, idioma, moneda y accesibilidad."], + ["Cuentas vinculadas", "Google, Apple y redes publicas."] +] as const; + +const linkedAccounts = [ + ["Google", "Conectado", "Desconectar"], + ["Apple", "No conectado", "Conectar"], + ["Instagram", "Perfil maker", "Gestionar"], + ["Sitio web", "makerlab3d.com", "Editar"], + ["YouTube", "No conectado", "Conectar"], + ["TikTok", "No conectado", "Conectar"] +] as const; + +const privacyCenterItems = [ + ["Tu ubicacion", "Gestiona permisos y ubicaciones guardadas."], + ["Tus datos", "Descarga o elimina informacion generada."], + ["Personalizacion", "Controla recomendaciones y busquedas."], + ["Comunicaciones", "Email, mensajes y marketing."], + ["Usuarios bloqueados", "Personas que no pueden contactarte."], + ["Cookies y seguimiento", "Gestiona tecnologias de medicion."], + ["Privacidad del perfil maker", "Que informacion muestras publicamente."] +] as const; + +const notificationChannelOptions = ["App", "Email", "Push", "WhatsApp"] as const; + +const serviceSteps = [ + "Tipo", + "Configuracion", + "Capacidades", + "Usos", + "Precio", + "Entrega", + "Visibilidad", + "Vista previa" +]; + +const serviceTemplates = [ + { title: "Impresion 3D FDM", category: "Impresion FDM", description: "Piezas funcionales, prototipos y repuestos resistentes para uso real.", icon: "FDM" }, + { title: "Impresion 3D Resina", category: "Impresion Resina", description: "Alta resolucion para figuras, miniaturas y piezas con mucho detalle.", icon: "RES" }, + { title: "Diseno 3D / CAD", category: "Diseno 3D", description: "Modelado, reparacion de archivos y preparacion tecnica para fabricar.", icon: "CAD" }, + { title: "Escaneo 3D", category: "Escaneo 3D", description: "Digitalizacion de objetos fisicos para replica, ajuste o rediseño.", icon: "3D" }, + { title: "Produccion en serie", category: "Produccion", description: "Fabricacion de lotes chicos y medianos con control de repetibilidad.", icon: "SER" }, + { title: "Postprocesado", category: "Acabados", description: "Lijado, pintura, armado y terminaciones para piezas listas para entregar.", icon: "FIN" } +]; + +const locationSteps = ["Tipo", "Direccion", "Privacidad", "Atencion", "Cobertura", "Horarios", "Vista previa", "Guardar"]; + +const locationTypeOptions = [ + ["public_workshop", "Taller abierto al publico", "Atencion con cita previa y punto de referencia visible."], + ["private_workshop", "Taller con cita previa", "Zona aproximada recomendada para cuidar privacidad."], + ["home_workshop", "Trabajo desde casa", "No mostramos direccion exacta publicamente."], + ["pickup_point", "Punto de recogida", "Solo entregas/retiros coordinados."], + ["mobile_base", "Base movil", "Te desplazas o haces entregas desde una zona base."], + ["online", "Atencion online", "Consultas remotas y envio sin punto fisico visible."] +]; + +const privacyOptions = [ + ["approximate", "Zona aproximada", "Recomendado: muestra un radio sin revelar la direccion real."], + ["city", "Solo localidad", "Muestra ciudad/provincia y cobertura, sin punto en el mapa."], + ["exact", "Direccion exacta", "Solo si es un local o punto abierto al publico."] +]; + +const travelOptions = [ + ["none", "No me desplazo"], + ["10", "Hasta 10 km"], + ["25", "Hasta 25 km"], + ["50", "Hasta 50 km"], + ["custom", "Zona personalizada"] +]; + +const shippingOptions = [ + ["none", "No realizo envios"], + ["province", "Dentro de mi provincia"], + ["nationwide", "A toda Argentina"], + ["custom", "Zonas seleccionadas"] +]; + +const serviceUseOptions = [ + "Repuestos y piezas funcionales", + "Prototipos", + "Decoracion", + "Herramientas / accesorios", + "Educacion / hobby", + "Miniaturas" +]; + +const serviceConditionOptions = [ + "Interior", + "Exterior", + "Contacto con agua", + "Calor moderado", + "Alto esfuerzo", + "Uso alimentario bajo revision" +]; + +const showcaseSteps = [ + "Datos", + "Trabajos", + "Portada", + "Orden", + "Vista previa", + "Publicar" +]; + +const showcaseTypeOptions = [ + "Repuestos", + "Prototipos", + "Funcionales", + "Decorativos", + "Automotor", + "Restauraciones" +]; + +function emptyWorkForm() { + return { + title: "", + summary: "", + story: "", + technology: "", + material: "", + imageUrl: "", + galleryUrls: "", + problem: "", + useCase: "", + machine: "", + color: "", + dimensions: "", + fabricationTime: "", + quantity: "", + includeBeforeAfter: false, + beforeImageUrl: "", + afterImageUrl: "", + serviceId: "", + placement: "", + priceMode: "", + priceFrom: "", + priceTo: "" + }; +} + +function emptyShowcaseForm() { + return { + title: "", + description: "", + types: [] as string[], + coverMode: "auto", + coverImageUrl: "", + selectedWorkIds: [] as string[], + featuredWorkId: "", + visibility: "published" + }; +} + +function emptyServiceForm() { + return { + title: "", + category: "", + description: "", + technologies: [] as string[], + materials: [] as string[], + maxX: "", + maxY: "", + maxZ: "", + resolution: "", + colors: [] as string[], + finishes: [] as string[], + uses: [] as string[], + conditions: [] as string[], + urgency: "yes", + priceMode: "from", + priceFrom: "", + smallPrice: "", + mediumPrice: "", + largePrice: "", + leadTimeDays: "4", + localPickup: true, + nationwideShipping: true, + coverageArea: "Todo el pais", + visibility: "published", + pauseMessage: "", + relatedWorks: [] as string[] + }; +} + +function emptyLocationForm() { + return { + label: "Taller principal", + type: "private_workshop", + address: "", + postalCode: "", + province: "Cordoba", + city: "Cordoba", + latitude: "-31.4201", + longitude: "-64.1888", + privacy: "approximate", + localPickup: true, + personalDelivery: true, + postalShipping: true, + nationwideShipping: true, + mobileService: false, + onsiteService: false, + notes: "", + travelRadius: "25", + shippingArea: "nationwide", + hoursMode: "appointment", + alertService: "Impresion 3D en resina", + alertRadius: "30", + alertEmail: "", + minimumRating: "4", + isPublished: true + }; +} + +function emptySubscriptionBillingForm() { + return { + invoiceType: "person", + name: "Juan Perez", + taxId: "20-12345678-9", + email: "", + address: "Av. Colon 1234, Cordoba", + paymentMethod: "visa", + cardLast4: "4242", + validationCode: "", + acceptedValidation: false + }; +} + +export default function AccountClient({ section = "summary", conversationId }: AccountClientProps) { + const [user, setUser] = useState<{ id: string; email: string; role: string } | null>(null); + const [authLoading, setAuthLoading] = useState(true); + const [account, setAccount] = useState(null); + const [conversations, setConversations] = useState>>([]); + const [conversationDetail, setConversationDetail] = useState<{ inquiry: Record; messages: Array> } | null>(null); + const [status, setStatus] = useState(""); + const [workStep, setWorkStep] = useState(0); + const [serviceStep, setServiceStep] = useState(0); + const [showcaseStep, setShowcaseStep] = useState(0); + const [locationStep, setLocationStep] = useState(0); + const [menuOpen, setMenuOpen] = useState(false); + const [workListFilter, setWorkListFilter] = useState<"published" | "drafts">("published"); + const [serviceListFilter, setServiceListFilter] = useState<"all" | "active" | "drafts">("all"); + const [showcaseListFilter, setShowcaseListFilter] = useState<"all" | "published" | "drafts">("all"); + const [subscriptionCycle, setSubscriptionCycle] = useState<"monthly" | "annual">("monthly"); + const [subscriptionScenario, setSubscriptionScenario] = useState<"active" | "grace" | "suspending" | "inactive">("active"); + const [subscriptionBillingOpen, setSubscriptionBillingOpen] = useState(false); + const [subscriptionBillingStep, setSubscriptionBillingStep] = useState(0); + const [editingWorkId, setEditingWorkId] = useState(null); + const [editingServiceId, setEditingServiceId] = useState(null); + const [editingShowcaseId, setEditingShowcaseId] = useState(null); + const [editingLocationId, setEditingLocationId] = useState(null); + const [workWizardOpen, setWorkWizardOpen] = useState(false); + const [serviceWizardOpen, setServiceWizardOpen] = useState(false); + const [showcaseWizardOpen, setShowcaseWizardOpen] = useState(false); + const [locationWizardOpen, setLocationWizardOpen] = useState(false); + const [toastMessage, setToastMessage] = useState(""); + + const [profileForm, setProfileForm] = useState({ + businessName: "", + description: "", + province: "CABA", + city: "Buenos Aires", + latitude: "-34.6037", + longitude: "-58.3816", + deliveryScope: "nationwide", + availability: "available", + publicContactEmail: "", + publicWhatsapp: "", + mainImageUrl: "/demo/maker-hero-custom.svg", + websiteUrl: "", + instagramUrl: "", + tiktokUrl: "", + twitterUrl: "", + facebookUrl: "", + youtubeUrl: "", + linkedinUrl: "" + }); + const [reviewSettings, setReviewSettings] = useState({ + allowVerifiedReviews: true, + showSatisfactionScore: true, + showReviewTags: true, + allowMakerReviewReply: true, + showPastReviews: true + }); + + const [serviceForm, setServiceForm] = useState(emptyServiceForm()); + const [showcaseForm, setShowcaseForm] = useState(emptyShowcaseForm()); + const [locationForm, setLocationForm] = useState(emptyLocationForm()); + const [subscriptionBillingForm, setSubscriptionBillingForm] = useState(emptySubscriptionBillingForm()); + const [personalAccountForm, setPersonalAccountForm] = useState({ + firstName: "Juan", + lastName: "Perez", + publicName: "Juan P.", + phone: "+54 9 351 123 4567", + avatarUrl: "/demo/maker-hero-custom.svg" + }); + const [notificationPrefs, setNotificationPrefs] = useState({ + newMessage: true, + messageReply: true, + makerInquiry: true, + acceptedInquiry: true, + closedInquiry: false, + customerWaiting: true, + workUpdates: true, + discoveryAlerts: true, + reviews: true, + security: true, + promotions: false + }); + const [privacyPrefs, setPrivacyPrefs] = useState({ + useLocation: true, + searchHistory: true, + personalizedResults: true, + profileVisible: true + }); + const [appearancePrefs, setAppearancePrefs] = useState({ + theme: "dark", + textSize: "medium", + reduceMotion: false, + highContrast: false, + language: "es-AR", + currency: "ARS" + }); + const [blockedUsers, setBlockedUsers] = useState([ + { id: "maria-g", name: "Maria G.", date: "12/05/2026" }, + { id: "usuario-123", name: "Usuario123", date: "03/04/2026" }, + { id: "carlos-r", name: "Carlos R.", date: "15/03/2026" } + ]); + + const [workForm, setWorkForm] = useState(emptyWorkForm()); + + useEffect(() => { + void loadAll(); + }, []); + + useEffect(() => { + if (section !== "inbox" || !conversationId || !user) { + return; + } + void openConversation(conversationId); + }, [conversationId, section, user]); + + async function loadAll() { + try { + const me = await browserFetch<{ user: { id: string; email: string; role: string } | null }>("/auth/me"); + setUser(me.user); + if (!me.user) { + return; + } + if (me.user.role === "admin") { + window.location.replace("/app/backoffice"); + return; + } + + const [accountData, conversationsData] = await Promise.all([ + browserFetch("/account/maker"), + browserFetch<{ conversations: Array> }>("/account/conversations") + ]); + + setAccount(accountData); + setConversations(conversationsData.conversations); + setProfileForm({ + businessName: accountData.maker.business_name || "", + description: accountData.maker.description || "", + province: accountData.maker.province || "CABA", + city: accountData.maker.city || "Buenos Aires", + latitude: String(accountData.maker.latitude || -34.6037), + longitude: String(accountData.maker.longitude || -58.3816), + deliveryScope: accountData.maker.delivery_scope || "nationwide", + availability: accountData.maker.availability || "available", + publicContactEmail: accountData.maker.public_contact_email || "", + publicWhatsapp: accountData.maker.public_whatsapp || "", + mainImageUrl: accountData.maker.main_image_url || "/demo/maker-hero-custom.svg", + websiteUrl: accountData.maker.website_url || "", + instagramUrl: accountData.maker.instagram_url || "", + tiktokUrl: accountData.maker.tiktok_url || "", + twitterUrl: accountData.maker.twitter_url || "", + facebookUrl: accountData.maker.facebook_url || "", + youtubeUrl: accountData.maker.youtube_url || "", + linkedinUrl: accountData.maker.linkedin_url || "" + }); + setReviewSettings({ + allowVerifiedReviews: accountData.maker.allow_verified_reviews !== false, + showSatisfactionScore: accountData.maker.show_satisfaction_score !== false, + showReviewTags: accountData.maker.show_review_tags !== false, + allowMakerReviewReply: accountData.maker.allow_maker_review_reply !== false, + showPastReviews: accountData.maker.show_past_reviews !== false + }); + setLocationForm((current) => ({ + ...current, + label: accountData.maker.business_name ? `Taller ${accountData.maker.business_name}` : current.label, + province: accountData.maker.province || current.province, + city: accountData.maker.city || current.city, + latitude: String(accountData.maker.latitude || current.latitude), + longitude: String(accountData.maker.longitude || current.longitude), + nationwideShipping: (accountData.maker.delivery_scope || "nationwide") === "nationwide", + postalShipping: (accountData.maker.delivery_scope || "nationwide") === "nationwide", + shippingArea: (accountData.maker.delivery_scope || "nationwide") === "nationwide" ? "nationwide" : "province", + alertEmail: accountData.maker.public_contact_email || current.alertEmail + })); + } catch (error) { + setStatus(error instanceof Error ? error.message : "No se pudo cargar la cuenta"); + } finally { + setAuthLoading(false); + } + } + + const checklistItems = useMemo(() => { + const entries = account?.checklist?.checklist ? Object.entries(account.checklist.checklist) : []; + return entries; + }, [account]); + + const reviewAverage = useMemo(() => { + if (!account?.reviews?.length) { + return 0; + } + const total = account.reviews.reduce((sum, review) => sum + toNumber(review.rating_overall, 0), 0); + return total / account.reviews.length; + }, [account?.reviews]); + const reviewRows = account?.reviews || []; + const publishedReviews = reviewRows.filter((review) => toText(review.status, "published") === "published"); + const reportedReviews = reviewRows.filter((review) => toText(review.status) === "reported"); + const completedConversations = conversations.filter((item) => toText(item.status) === "completed").length; + const reviewDistribution = [5, 4, 3, 2, 1].map((rating) => { + const count = reviewRows.filter((review) => Math.round(toNumber(review.rating_overall, 0)) === rating).length; + return { rating, count, percent: reviewRows.length ? Math.round((count / reviewRows.length) * 100) : 0 }; + }); + const aspectScores = [ + ["Calidad", reviewRows.length ? reviewRows.reduce((sum, review) => sum + toNumber(review.rating_quality, 0), 0) / reviewRows.length : 0], + ["Comunicacion", reviewRows.length ? reviewRows.reduce((sum, review) => sum + toNumber(review.rating_communication, 0), 0) / reviewRows.length : 0], + ["Calidad/precio", reviewRows.length ? reviewRows.reduce((sum, review) => sum + toNumber(review.rating_value, 0), 0) / reviewRows.length : 0], + ["Cumplimiento", reviewAverage || 0] + ] as const; + const reputationBadges = [ + ["Trabajo verificado", "Basado en consultas finalizadas dentro de la plataforma."], + ["Problema resuelto", "Las incidencias pueden cerrarse con evidencia y respuesta."], + ["Cliente recurrente", "Se destaca cuando hay confianza repetida."], + ["Respuesta rapida", "Mejora cuando el maker responde en menos de 1 hora."] + ] as const; + const planStatus = account?.checklist.subscriptionStatus || "inactive"; + const isSubscriptionActive = planStatus === "active"; + const checklistDoneCount = checklistItems.filter(([, value]) => Boolean(value)).length; + const checklistTotal = Math.max(checklistItems.length, 1); + const readinessPercent = Math.round((checklistDoneCount / checklistTotal) * 100); + const subscriptionPrice = subscriptionCycle === "monthly" ? "19.990" : "199.900"; + const subscriptionPeriod = subscriptionCycle === "monthly" ? "mes" : "ano"; + const publicProfileHref = `/makers/${account?.maker.slug || "makerlab-3d"}` as Route; + const billingEvents = [ + { date: "10 jul, 2026", event: "Renovacion", amount: "$19.990", status: "Pagado" }, + { date: "9 jul, 2026", event: "Intento de cobro", amount: "$19.990", status: "Fallido" }, + { date: "10 jun, 2026", event: "Renovacion", amount: "$19.990", status: "Pagado" } + ]; + const accountActivityRows = [ + ["Consultas", String(conversations.length)], + ["Favoritos", "12"], + ["Alertas activas", "2"], + ["Resenas publicadas", String(account?.reviews.length || 0)], + ["Mensajes", String(conversations.length)] + ] as const; + const notificationRows = [ + ["newMessage", "Nuevo mensaje", "Mensajes y consultas"], + ["messageReply", "Respuesta a mi mensaje", "Conversaciones"], + ["makerInquiry", "Nueva consulta recibida", "Perfil maker"], + ["acceptedInquiry", "Consulta aceptada", "Trabajos"], + ["closedInquiry", "Consulta cerrada", "Historial"], + ["customerWaiting", "Cliente espera respuesta", "Prioridad"] + ] as const; + const notificationSummaryRows = [ + ["Mensajes y consultas", notificationPrefs.newMessage || notificationPrefs.messageReply], + ["Trabajos", notificationPrefs.workUpdates], + ["Alertas y descubrimiento", notificationPrefs.discoveryAlerts], + ["Resenas y reputacion", notificationPrefs.reviews], + ["Cuenta y seguridad", notificationPrefs.security], + ["Novedades y promociones", notificationPrefs.promotions] + ] as const; + const savedAlertRows = [ + { title: "Resina cerca de casa", detail: "25 km | Cordoba Capital", status: "Activa" }, + { title: "Repuestos con envio a Mendoza", detail: "Envio incluido | Mendoza", status: "Activa" }, + { title: "Impresion SLA profesional", detail: "30 km | Villa Allende", status: "Pausada" } + ]; + const activeSessionRows = [ + { device: "Chrome - Windows", place: "Cordoba, Argentina", status: "Actual" }, + { device: "Safari - iPhone", place: "Cordoba, Argentina", status: "Hace 2 dias" }, + { device: "Chrome - Android", place: "Villa Allende, Argentina", status: "Hace 5 dias" } + ]; + const profileDiagnosticRows = [ + ["Logo", Boolean(profileForm.mainImageUrl)], + ["Descripcion", Boolean(profileForm.description.trim())], + ["Servicios", Boolean(account?.services?.some((service) => Boolean(service.is_published)))], + ["Ubicacion", Boolean(account?.locations?.some((location) => Boolean(location.is_published)))], + ["Trabajos", Boolean(account?.works?.some((work) => Boolean(work.is_published)))], + ["Escaparates", Boolean(account?.showcases?.some((showcase) => Boolean(showcase.is_published)))], + ["Redes sociales", Boolean(profileForm.websiteUrl || profileForm.instagramUrl || profileForm.tiktokUrl)] + ] as const; + const profileDiagnosticPercent = Math.round((profileDiagnosticRows.filter(([, value]) => value).length / profileDiagnosticRows.length) * 100); + const accountStateRows = [ + ["Cuenta", "Activa"], + ["Email", "Verificado"], + ["Seguridad", appearancePrefs.highContrast ? "Alta" : "Media"], + ["Notificaciones", "Personalizadas"], + ["Mi espacio Maker", account?.maker.status === "published" ? "Activo" : "Borrador"], + ["Plan Maker", planStatus === "active" ? "Activo" : "Pendiente"] + ] as const; + + async function saveProfile(event: React.FormEvent) { + event.preventDefault(); + setStatus("Guardando perfil..."); + try { + await browserFetch("/account/maker/profile", { + method: "PUT", + body: JSON.stringify({ + ...profileForm, + latitude: Number(profileForm.latitude), + longitude: Number(profileForm.longitude) + }) + }); + setStatus("Perfil guardado."); + await loadAll(); + } catch (error) { + setStatus(error instanceof Error ? error.message : "No se pudo guardar el perfil"); + } + } + + async function toggleReviewSetting(key: keyof typeof reviewSettings) { + const nextSettings = { ...reviewSettings, [key]: !reviewSettings[key] }; + const previousSettings = reviewSettings; + setReviewSettings(nextSettings); + setStatus("Guardando configuracion de resenas..."); + + try { + await browserFetch("/account/maker/review-settings", { + method: "PATCH", + body: JSON.stringify(nextSettings) + }); + setStatus("Configuracion de resenas guardada."); + await loadAll(); + } catch (error) { + setReviewSettings(previousSettings); + setStatus(error instanceof Error ? error.message : "No se pudo guardar la configuracion."); + } + } + + function updateLocationForm(field: keyof typeof locationForm, value: string | boolean) { + setLocationForm((current) => ({ ...current, [field]: value })); + } + + function locationFormFromProfile() { + return { + ...emptyLocationForm(), + label: profileForm.businessName ? `Taller ${profileForm.businessName}` : "Taller principal", + province: profileForm.province || "Cordoba", + city: profileForm.city || "Cordoba", + latitude: profileForm.latitude || "-31.4201", + longitude: profileForm.longitude || "-64.1888", + nationwideShipping: profileForm.deliveryScope === "nationwide", + postalShipping: profileForm.deliveryScope === "nationwide", + shippingArea: profileForm.deliveryScope === "nationwide" ? "nationwide" : "province", + alertEmail: profileForm.publicContactEmail + }; + } + + function locationFormFromLocation(location: Record) { + const draftData = location.draft_data && typeof location.draft_data === "object" && !Array.isArray(location.draft_data) + ? location.draft_data as Partial> + : {}; + + return { + ...emptyLocationForm(), + ...draftData, + label: toText(location.label, toText(draftData.label, "Taller principal")), + type: toText(location.location_type, toText(draftData.type, "private_workshop")), + address: toText(location.address, toText(draftData.address)), + postalCode: toText(location.postal_code, toText(draftData.postalCode)), + province: toText(location.province, toText(draftData.province, "Cordoba")), + city: toText(location.city, toText(draftData.city, "Cordoba")), + latitude: String(location.latitude ?? draftData.latitude ?? "-31.4201"), + longitude: String(location.longitude ?? draftData.longitude ?? "-64.1888"), + privacy: toText(location.privacy, toText(draftData.privacy, "approximate")), + localPickup: Boolean(location.local_pickup ?? draftData.localPickup ?? true), + personalDelivery: Boolean(location.personal_delivery ?? draftData.personalDelivery ?? true), + postalShipping: Boolean(location.postal_shipping ?? draftData.postalShipping ?? true), + nationwideShipping: Boolean(location.nationwide_shipping ?? draftData.nationwideShipping ?? false), + mobileService: Boolean(location.mobile_service ?? draftData.mobileService ?? false), + onsiteService: Boolean(location.onsite_service ?? draftData.onsiteService ?? false), + notes: toText(location.notes, toText(draftData.notes)), + travelRadius: toText(location.travel_radius, toText(draftData.travelRadius, "25")), + shippingArea: toText(location.shipping_area, toText(draftData.shippingArea, "nationwide")), + hoursMode: toText(location.hours_mode, toText(draftData.hoursMode, "appointment")), + alertService: toText(location.alert_service, toText(draftData.alertService)), + alertRadius: toText(location.alert_radius, toText(draftData.alertRadius, "30")), + minimumRating: toText(location.minimum_rating, toText(draftData.minimumRating, "4")), + isPublished: Boolean(location.is_published) + }; + } + + function locationPayload(isPublished: boolean) { + const deliveryScope = locationForm.nationwideShipping || locationForm.shippingArea === "nationwide" ? "nationwide" : "local"; + const draftData = { + ...locationForm, + isPublished + }; + + return { + ...locationForm, + latitude: Number(locationForm.latitude || profileForm.latitude || -31.4201), + longitude: Number(locationForm.longitude || profileForm.longitude || -64.1888), + nationwideShipping: deliveryScope === "nationwide", + postalShipping: locationForm.postalShipping || deliveryScope === "nationwide", + draftData, + isPublished + }; + } + + function startNewLocation() { + setEditingLocationId(null); + setLocationForm(locationFormFromProfile()); + setLocationStep(0); + setLocationWizardOpen(true); + setStatus(""); + } + + function editLocation(location: Record) { + setEditingLocationId(String(location.id)); + setLocationForm(locationFormFromLocation(location)); + setLocationStep(0); + setLocationWizardOpen(true); + setStatus("Ubicacion cargada para editar."); + } + + function closeLocationWizard() { + setLocationWizardOpen(false); + } + + function goToNextLocationStep() { + if (locationStep === 0 && !locationForm.type) { + showToast("Elige el tipo de ubicacion."); + return; + } + if (locationStep === 1 && (!locationForm.city.trim() || !locationForm.province.trim())) { + showToast("Completa ciudad y provincia."); + return; + } + setLocationStep((current) => Math.min(current + 1, locationSteps.length - 1)); + } + + async function saveLocation(shouldClose = false) { + setStatus(shouldClose ? "Guardando ubicacion..." : "Guardando borrador de ubicacion..."); + const isPublished = shouldClose; + const payload = locationPayload(isPublished); + + try { + const response = await browserFetch<{ location: Record }>( + editingLocationId ? `/account/maker/locations/${editingLocationId}` : "/account/maker/locations", + { + method: editingLocationId ? "PATCH" : "POST", + body: JSON.stringify(payload) + } + ); + setEditingLocationId(String(response.location.id)); + + if (isPublished) { + const nextProfileForm = { + ...profileForm, + businessName: profileForm.businessName || account?.maker.business_name || "Maker 3D", + description: profileForm.description || account?.maker.description || "Maker con cobertura configurada para recibir consultas y coordinar trabajos de impresion 3D.", + province: locationForm.province || profileForm.province, + city: locationForm.city || profileForm.city, + latitude: String(Number(locationForm.latitude || profileForm.latitude || -31.4201)), + longitude: String(Number(locationForm.longitude || profileForm.longitude || -64.1888)), + deliveryScope: payload.nationwideShipping ? "nationwide" : "local", + publicContactEmail: profileForm.publicContactEmail || locationForm.alertEmail + }; + await browserFetch("/account/maker/profile", { + method: "PUT", + body: JSON.stringify({ + ...nextProfileForm, + latitude: Number(nextProfileForm.latitude), + longitude: Number(nextProfileForm.longitude) + }) + }); + setProfileForm(nextProfileForm); + } + + setStatus(isPublished ? "Ubicacion guardada." : "Borrador de ubicacion guardado."); + showToast(isPublished ? "Ubicacion guardada." : "Borrador guardado."); + await loadAll(); + if (isPublished) { + setEditingLocationId(null); + setLocationWizardOpen(false); + } + } catch (error) { + const message = error instanceof Error ? error.message : "No se pudo guardar la ubicacion"; + setStatus(message); + showToast(message); + } + } + + async function deleteLocation(location: Record) { + setStatus("Eliminando ubicacion..."); + try { + await browserFetch(`/account/maker/locations/${String(location.id)}`, { method: "DELETE" }); + setStatus("Ubicacion eliminada."); + await loadAll(); + } catch (error) { + const message = error instanceof Error ? error.message : "No se pudo eliminar la ubicacion"; + setStatus(message); + showToast(message); + } + } + + async function createService(event: React.FormEvent) { + event.preventDefault(); + await saveService(true); + } + + async function createWork(event: React.FormEvent) { + event.preventDefault(); + await saveWork(true); + } + + function showToast(message: string) { + setToastMessage(message); + window.setTimeout(() => { + setToastMessage((current) => (current === message ? "" : current)); + }, 3200); + } + + function updateServiceForm(field: keyof typeof serviceForm, value: string | boolean | string[]) { + setServiceForm((current) => ({ ...current, [field]: value })); + } + + function toggleServiceListField(field: "technologies" | "materials" | "colors" | "finishes" | "uses" | "conditions" | "relatedWorks", value: string) { + setServiceForm((current) => { + const values = current[field]; + return { + ...current, + [field]: values.includes(value) ? values.filter((item) => item !== value) : [...values, value] + }; + }); + } + + function applyServiceTemplate(template: (typeof serviceTemplates)[number]) { + setServiceForm((current) => ({ + ...current, + title: template.title, + category: template.category, + description: template.description, + technologies: template.category.includes("Resina") ? ["Resina"] : template.category.includes("Diseno") ? ["CAD"] : ["FDM"], + materials: template.category.includes("Resina") ? ["Resina standard"] : ["PLA", "PETG"], + uses: template.category.includes("Diseno") ? ["Prototipos"] : ["Repuestos y piezas funcionales", "Prototipos"] + })); + setServiceStep(1); + } + + function servicePayload(isPublished: boolean) { + const price = serviceForm.priceMode === "hidden" ? null : Number(serviceForm.priceFrom || serviceForm.smallPrice || 0) * 100; + + return { + title: serviceForm.title, + category: serviceForm.category, + description: [ + serviceForm.description, + serviceForm.maxX || serviceForm.maxY || serviceForm.maxZ ? `Volumen maximo: X ${serviceForm.maxX || "-"} mm, Y ${serviceForm.maxY || "-"} mm, Z ${serviceForm.maxZ || "-"} mm.` : "", + serviceForm.uses.length ? `Ideal para: ${serviceForm.uses.join(", ")}.` : "", + serviceForm.conditions.length ? `Condiciones: ${serviceForm.conditions.join(", ")}.` : "", + serviceForm.priceMode === "range" ? `Rangos: chico $${serviceForm.smallPrice || "-"}, mediano $${serviceForm.mediumPrice || "-"}, grande $${serviceForm.largePrice || "-"}.` : "", + serviceForm.pauseMessage && serviceForm.visibility === "paused" ? `Mensaje de pausa: ${serviceForm.pauseMessage}` : "" + ].filter(Boolean).join("\n\n"), + technologies: serviceForm.technologies, + materials: serviceForm.materials, + localPickup: serviceForm.localPickup, + nationwideShipping: serviceForm.nationwideShipping, + leadTimeDays: Number(serviceForm.leadTimeDays || 4), + priceFromCents: price, + draftData: serviceForm, + isPublished + }; + } + + async function saveService(isPublished: boolean) { + const wantsPublish = isPublished && serviceForm.visibility !== "draft"; + setStatus(wantsPublish ? "Publicando servicio..." : "Guardando servicio..."); + try { + const response = await browserFetch<{ service: Record }>( + editingServiceId ? `/account/maker/services/${editingServiceId}` : "/account/maker/services", + { + method: editingServiceId ? "PATCH" : "POST", + body: JSON.stringify(servicePayload(wantsPublish && serviceForm.visibility !== "paused")) + } + ); + setEditingServiceId(String(response.service.id)); + if (wantsPublish && serviceForm.visibility !== "paused") { + setServiceForm(emptyServiceForm()); + setEditingServiceId(null); + setServiceWizardOpen(false); + setStatus("Servicio publicado."); + } else { + setStatus(serviceForm.visibility === "paused" ? "Servicio pausado." : "Borrador de servicio guardado."); + } + await loadAll(); + } catch (error) { + setStatus(error instanceof Error ? error.message : "No se pudo guardar el servicio"); + } + } + + function startNewService() { + setEditingServiceId(null); + setServiceForm(emptyServiceForm()); + setServiceStep(0); + setServiceWizardOpen(true); + setStatus(""); + } + + function closeServiceWizard() { + setServiceWizardOpen(false); + } + + function serviceFormFromService(service: Record) { + const draftData = service.draft_data && typeof service.draft_data === "object" && !Array.isArray(service.draft_data) + ? service.draft_data as Partial> + : {}; + + return { + ...emptyServiceForm(), + ...draftData, + title: !Boolean(service.is_published) && toText(service.title) === "Servicio en borrador" ? "" : toText(service.title), + category: !Boolean(service.is_published) && toText(service.category) === "Pendiente" ? "" : toText(service.category), + description: !Boolean(service.is_published) && toText(service.description) === "Servicio pendiente de completar." ? "" : toText(service.description), + technologies: Array.isArray(service.technologies) ? service.technologies.map(String) : [], + materials: Array.isArray(service.materials) ? service.materials.map(String) : [], + leadTimeDays: String(service.lead_time_days || 4), + localPickup: Boolean(service.local_pickup), + nationwideShipping: Boolean(service.nationwide_shipping), + priceFrom: service.price_from_cents ? String(Math.round(toNumber(service.price_from_cents) / 100)) : "", + visibility: Boolean(service.is_published) ? "published" : "draft" + }; + } + + function editService(service: Record) { + setEditingServiceId(String(service.id)); + setServiceForm(serviceFormFromService(service)); + setServiceStep(1); + setServiceWizardOpen(true); + setStatus("Servicio cargado para editar."); + } + + async function pauseService(service: Record) { + const restoredForm = { ...serviceFormFromService(service), visibility: "paused" }; + setStatus("Pausando servicio..."); + try { + await browserFetch(`/account/maker/services/${String(service.id)}`, { + method: "PATCH", + body: JSON.stringify({ + ...servicePayload(false), + ...{ + title: restoredForm.title, + category: restoredForm.category, + description: restoredForm.description, + technologies: restoredForm.technologies, + materials: restoredForm.materials, + localPickup: restoredForm.localPickup, + nationwideShipping: restoredForm.nationwideShipping, + leadTimeDays: Number(restoredForm.leadTimeDays || 4), + priceFromCents: restoredForm.priceFrom ? Number(restoredForm.priceFrom) * 100 : null, + draftData: restoredForm, + isPublished: false + } + }) + }); + setStatus("Servicio pausado."); + await loadAll(); + } catch (error) { + setStatus(error instanceof Error ? error.message : "No se pudo pausar el servicio"); + } + } + + async function deleteService(service: Record) { + if (typeof window !== "undefined" && !window.confirm("Esto eliminara el servicio. Quieres continuar?")) { + return; + } + setStatus("Eliminando servicio..."); + try { + await browserFetch(`/account/maker/services/${String(service.id)}`, { method: "DELETE" }); + setStatus("Servicio eliminado."); + await loadAll(); + } catch (error) { + setStatus(error instanceof Error ? error.message : "No se pudo eliminar el servicio"); + } + } + + function updateShowcaseForm(field: keyof typeof showcaseForm, value: string | string[]) { + setShowcaseForm((current) => ({ ...current, [field]: value })); + } + + function toggleShowcaseType(value: string) { + setShowcaseForm((current) => ({ + ...current, + types: current.types.includes(value) ? current.types.filter((item) => item !== value) : [...current.types, value] + })); + } + + function toggleShowcaseWork(workId: string) { + setShowcaseForm((current) => { + const selectedWorkIds = current.selectedWorkIds.includes(workId) + ? current.selectedWorkIds.filter((id) => id !== workId) + : [...current.selectedWorkIds, workId]; + + return { + ...current, + selectedWorkIds, + featuredWorkId: selectedWorkIds.includes(current.featuredWorkId) ? current.featuredWorkId : selectedWorkIds[0] || "", + coverImageUrl: selectedWorkIds.length ? current.coverImageUrl : "" + }; + }); + } + + function moveShowcaseWork(workId: string, direction: -1 | 1) { + setShowcaseForm((current) => { + const index = current.selectedWorkIds.indexOf(workId); + const nextIndex = index + direction; + if (index < 0 || nextIndex < 0 || nextIndex >= current.selectedWorkIds.length) { + return current; + } + const selectedWorkIds = [...current.selectedWorkIds]; + const [item] = selectedWorkIds.splice(index, 1); + selectedWorkIds.splice(nextIndex, 0, item); + return { ...current, selectedWorkIds }; + }); + } + + function chooseShowcaseCover(work: Record) { + setShowcaseForm((current) => ({ + ...current, + coverMode: "existing", + featuredWorkId: String(work.id), + coverImageUrl: toText(work.image_url, "") + })); + } + + function getShowcaseSelectedWorks() { + const worksById = new Map((account?.works || []).map((work) => [String(work.id), work])); + return showcaseForm.selectedWorkIds.map((id) => worksById.get(id)).filter(Boolean) as Array>; + } + + function getShowcaseCoverImage() { + if (showcaseForm.coverMode === "existing" && showcaseForm.coverImageUrl) { + return showcaseForm.coverImageUrl; + } + const featured = (account?.works || []).find((work) => String(work.id) === showcaseForm.featuredWorkId); + return toText(featured?.image_url, toText(getShowcaseSelectedWorks()[0]?.image_url, "/demo/work-custom.svg")); + } + + function showcasePayload(isPublished: boolean) { + const cleanedDescription = cleanShowcaseDescription(showcaseForm.description); + const selectedWorksCount = showcaseForm.selectedWorkIds.length; + const publishedDescription = cleanedDescription || `${showcaseForm.title || "Escaparate"} agrupa ${selectedWorksCount} trabajos seleccionados para mostrar experiencia real del maker.`; + const cleanedDraftData = { + ...showcaseForm, + description: cleanedDescription + }; + + return { + title: showcaseForm.title, + description: isPublished ? publishedDescription : cleanedDescription, + coverImageUrl: getShowcaseCoverImage(), + selectedWorkIds: showcaseForm.selectedWorkIds, + featuredWorkId: showcaseForm.featuredWorkId || showcaseForm.selectedWorkIds[0] || null, + draftData: cleanedDraftData, + isPublished + }; + } + + async function saveShowcase(isPublished: boolean) { + if (isPublished && !showcaseForm.selectedWorkIds.length) { + setShowcaseStep(1); + showToast("Selecciona al menos un trabajo para publicar el escaparate."); + return; + } + setStatus(isPublished ? "Publicando escaparate..." : "Guardando escaparate..."); + try { + const response = await browserFetch<{ showcase: Record }>( + editingShowcaseId ? `/account/maker/showcases/${editingShowcaseId}` : "/account/maker/showcases", + { + method: editingShowcaseId ? "PATCH" : "POST", + body: JSON.stringify(showcasePayload(isPublished)) + } + ); + setEditingShowcaseId(String(response.showcase.id)); + if (isPublished) { + setShowcaseForm(emptyShowcaseForm()); + setEditingShowcaseId(null); + setShowcaseWizardOpen(false); + setStatus("Escaparate publicado."); + } else { + setStatus("Borrador de escaparate guardado."); + } + await loadAll(); + } catch (error) { + const message = error instanceof Error ? error.message : "No se pudo guardar el escaparate"; + setStatus(message); + showToast(message); + } + } + + function startNewShowcase() { + setEditingShowcaseId(null); + setShowcaseForm(emptyShowcaseForm()); + setShowcaseStep(0); + setShowcaseWizardOpen(true); + setStatus(""); + } + + function closeShowcaseWizard() { + setShowcaseWizardOpen(false); + } + + function showcaseFormFromShowcase(showcase: Record) { + const draftData = showcase.draft_data && typeof showcase.draft_data === "object" && !Array.isArray(showcase.draft_data) + ? showcase.draft_data as Partial> + : {}; + const storedDescription = !Boolean(showcase.is_published) && toText(showcase.description) === "Escaparate pendiente de completar." ? "" : toText(showcase.description); + return { + ...emptyShowcaseForm(), + ...draftData, + title: !Boolean(showcase.is_published) && toText(showcase.title) === "Escaparate en borrador" ? "" : toText(showcase.title), + description: cleanShowcaseDescription(storedDescription), + coverImageUrl: toText(showcase.cover_image_url), + selectedWorkIds: Array.isArray(showcase.selected_work_ids) ? showcase.selected_work_ids.map(String) : [], + featuredWorkId: toText(showcase.featured_work_id), + visibility: Boolean(showcase.is_published) ? "published" : "draft" + }; + } + + function editShowcase(showcase: Record) { + setEditingShowcaseId(String(showcase.id)); + setShowcaseForm(showcaseFormFromShowcase(showcase)); + setShowcaseStep(0); + setShowcaseWizardOpen(true); + setStatus("Escaparate cargado para editar."); + } + + async function unpublishShowcase(showcase: Record) { + const restoredForm = { ...showcaseFormFromShowcase(showcase), visibility: "draft" }; + setStatus("Pasando escaparate a borrador..."); + try { + await browserFetch(`/account/maker/showcases/${String(showcase.id)}`, { + method: "PATCH", + body: JSON.stringify({ + title: restoredForm.title, + description: restoredForm.description, + coverImageUrl: restoredForm.coverImageUrl, + selectedWorkIds: restoredForm.selectedWorkIds, + featuredWorkId: restoredForm.featuredWorkId || restoredForm.selectedWorkIds[0] || null, + draftData: restoredForm, + isPublished: false + }) + }); + setStatus("Escaparate guardado como borrador."); + setShowcaseListFilter("drafts"); + await loadAll(); + } catch (error) { + setStatus(error instanceof Error ? error.message : "No se pudo despublicar el escaparate"); + } + } + + async function deleteShowcase(showcase: Record) { + if (typeof window !== "undefined" && !window.confirm("Esto eliminara el escaparate. Quieres continuar?")) { + return; + } + setStatus("Eliminando escaparate..."); + try { + await browserFetch(`/account/maker/showcases/${String(showcase.id)}`, { method: "DELETE" }); + setStatus("Escaparate eliminado."); + await loadAll(); + } catch (error) { + setStatus(error instanceof Error ? error.message : "No se pudo eliminar el escaparate"); + } + } + + async function saveWork(isPublished: boolean) { + setStatus(isPublished ? "Publicando trabajo..." : "Guardando borrador..."); + try { + const galleryUrls = uniqueList([ + workForm.imageUrl, + ...workForm.galleryUrls.split("\n").map((value) => value.trim()).filter(Boolean), + ...(workForm.includeBeforeAfter ? [workForm.beforeImageUrl, workForm.afterImageUrl] : []) + ]); + if (!galleryUrls.length) { + setWorkStep(0); + const message = isPublished ? "Agrega al menos una foto para publicar." : "Agrega al menos una foto para guardar el borrador."; + setStatus(""); + showToast(message); + return; + } + if (isPublished && (!workForm.title.trim() || !workForm.summary.trim() || !workForm.technology.trim() || !workForm.material.trim())) { + setWorkStep(1); + setStatus("Completa titulo, resumen, tecnologia y material antes de publicar."); + return; + } + const story = [ + workForm.story, + workForm.problem || workForm.useCase ? `Necesidad: ${workForm.problem || "Sin especificar"}. Uso: ${workForm.useCase || "Sin especificar"}.` : "", + workForm.technology || workForm.material || workForm.machine || workForm.dimensions || workForm.fabricationTime || workForm.quantity + ? `Detalle tecnico: ${[workForm.technology, workForm.material, workForm.machine, workForm.dimensions, workForm.fabricationTime, workForm.quantity].filter(Boolean).join(", ")}.` + : "", + workForm.priceMode ? (workForm.priceMode === "hidden" ? "Precio: no visible publicamente." : `Precio orientativo: $${workForm.priceFrom || "0"} a $${workForm.priceTo || workForm.priceFrom || "0"}.`) : "" + ].filter(Boolean).join("\n\n"); + + const response = await browserFetch<{ work: Record }>(editingWorkId ? `/account/maker/works/${editingWorkId}` : "/account/maker/works", { + method: editingWorkId ? "PATCH" : "POST", + body: JSON.stringify({ + ...workForm, + galleryUrls, + story: story || workForm.summary, + draftData: workForm, + isPublished, + serviceId: workForm.serviceId || account?.services?.[0]?.id || null + }) + }); + if (!isPublished) { + setEditingWorkId(String(response.work.id)); + setStatus("Borrador guardado. Puedes seguir editandolo."); + await loadAll(); + return; + } + setEditingWorkId(null); + setWorkForm(emptyWorkForm()); + setWorkStep(publishSteps.length - 1); + setWorkWizardOpen(false); + setStatus(isPublished ? "Trabajo publicado." : "Borrador guardado."); + await loadAll(); + } catch (error) { + setStatus(error instanceof Error ? error.message : "No se pudo guardar el trabajo"); + } + } + + async function activateDemoSubscription() { + setStatus("Activando plan demo..."); + try { + await browserFetch("/account/maker/subscription/demo-activate", { + method: "POST" + }); + setStatus("Plan demo activado."); + await loadAll(); + } catch (error) { + setStatus(error instanceof Error ? error.message : "No se pudo activar el plan"); + } + } + + function updateSubscriptionBillingForm(field: keyof ReturnType, value: string | boolean) { + setSubscriptionBillingForm((current) => ({ ...current, [field]: value })); + } + + function openSubscriptionBillingWizard(step = 0) { + setSubscriptionBillingForm((current) => ({ + ...current, + email: current.email || profileForm.publicContactEmail || user?.email || "" + })); + setSubscriptionBillingStep(step); + setSubscriptionBillingOpen(true); + setStatus(""); + } + + function closeSubscriptionBillingWizard() { + setSubscriptionBillingOpen(false); + } + + function validateSubscriptionBillingStep(step: number) { + if (step === 0) { + if (!subscriptionBillingForm.name.trim() || !subscriptionBillingForm.taxId.trim() || !subscriptionBillingForm.email.includes("@")) { + showToast("Completa nombre, documento fiscal y un email valido."); + return false; + } + return true; + } + if (step === 1) { + if (!/^\d{4}$/.test(subscriptionBillingForm.cardLast4.trim())) { + showToast("Ingresa los ultimos 4 digitos del metodo de pago."); + return false; + } + return true; + } + if (!subscriptionBillingForm.acceptedValidation || subscriptionBillingForm.validationCode.trim() !== "1234") { + showToast("Acepta la validacion y usa el codigo demo 1234."); + return false; + } + return true; + } + + function goToNextSubscriptionBillingStep() { + if (!validateSubscriptionBillingStep(subscriptionBillingStep)) { + return; + } + setSubscriptionBillingStep((current) => Math.min(current + 1, subscriptionBillingSteps.length - 1)); + } + + function saveSubscriptionBilling() { + if (![0, 1, 2].every((step) => validateSubscriptionBillingStep(step))) { + return; + } + setSubscriptionBillingOpen(false); + setStatus("Datos de facturacion y pago validados."); + } + + function updatePersonalAccountField(field: keyof typeof personalAccountForm, value: string) { + setPersonalAccountForm((current) => ({ ...current, [field]: value })); + } + + function toggleNotificationPref(key: keyof typeof notificationPrefs) { + setNotificationPrefs((current) => ({ ...current, [key]: !current[key] })); + } + + function togglePrivacyPref(key: keyof typeof privacyPrefs) { + setPrivacyPrefs((current) => ({ ...current, [key]: !current[key] })); + } + + function updateAppearancePref(field: keyof typeof appearancePrefs, value: string | boolean) { + setAppearancePrefs((current) => ({ ...current, [field]: value })); + } + + function saveAccountSettings() { + setStatus("Configuracion de cuenta guardada."); + showToast("Configuracion guardada."); + } + + function exportAccountData() { + setStatus("Exportacion demo preparada. En produccion se generara un archivo descargable."); + showToast("Exportacion demo preparada."); + } + + function requestAccountDeletion() { + setStatus("Solicitud demo registrada. La eliminacion real requerira confirmacion adicional."); + showToast("Solicitud de eliminacion registrada como demo."); + } + + function unblockUser(id: string) { + setBlockedUsers((current) => current.filter((blockedUser) => blockedUser.id !== id)); + setStatus("Usuario desbloqueado."); + } + + async function requestPublish() { + setStatus("Revisando requisitos..."); + try { + await browserFetch("/account/maker/publish", { method: "POST" }); + setStatus("Perfil publicado."); + await loadAll(); + } catch (error) { + setStatus(error instanceof Error ? error.message : "No se pudo publicar"); + } + } + + async function openConversation(id: string) { + try { + const detail = await browserFetch<{ inquiry: Record; messages: Array> }>(`/inquiries/${id}`); + setConversationDetail(detail); + } catch (error) { + setStatus(error instanceof Error ? error.message : "No se pudo abrir la conversacion"); + } + } + + async function sendReply(event: React.FormEvent) { + event.preventDefault(); + if (!conversationDetail) { + return; + } + const form = new FormData(event.currentTarget); + const body = String(form.get("body") || ""); + if (!body.trim()) { + return; + } + try { + await browserFetch(`/inquiries/${conversationDetail.inquiry.id as string}/messages`, { + method: "POST", + body: JSON.stringify({ body }) + }); + await openConversation(conversationDetail.inquiry.id as string); + event.currentTarget.reset(); + setStatus("Respuesta enviada."); + } catch (error) { + setStatus(error instanceof Error ? error.message : "No se pudo responder"); + } + } + + async function markCompleted() { + if (!conversationDetail) { + return; + } + try { + await browserFetch(`/inquiries/${conversationDetail.inquiry.id as string}/complete`, { + method: "POST" + }); + setStatus("Trabajo marcado como finalizado."); + await Promise.all([loadAll(), openConversation(conversationDetail.inquiry.id as string)]); + } catch (error) { + setStatus(error instanceof Error ? error.message : "No se pudo marcar como finalizado"); + } + } + + async function rejectInquiry() { + if (!conversationDetail) { + return; + } + try { + await browserFetch(`/inquiries/${conversationDetail.inquiry.id as string}/reject`, { + method: "POST", + body: JSON.stringify({ reason: "No puedo tomar este trabajo en este momento." }) + }); + setStatus("Consulta rechazada."); + await Promise.all([loadAll(), openConversation(conversationDetail.inquiry.id as string)]); + } catch (error) { + setStatus(error instanceof Error ? error.message : "No se pudo rechazar la consulta"); + } + } + + function logout() { + logoutByNavigation(); + } + + function updateWorkForm(field: keyof typeof workForm, value: string | boolean) { + setWorkForm((current) => ({ ...current, [field]: value })); + } + + function startNewWork() { + setEditingWorkId(null); + setWorkForm(emptyWorkForm()); + setWorkStep(0); + setWorkWizardOpen(true); + setStatus(""); + } + + function closeWorkWizard() { + setWorkWizardOpen(false); + } + + function workFormFromWork(work: Record) { + const isPublished = Boolean(work.is_published); + const imageUrl = toText(work.image_url); + const galleryUrls = Array.isArray(work.gallery_urls) ? work.gallery_urls.map((item) => String(item)) : []; + const isFallbackDraftImage = !isPublished && imageUrl === "/demo/work-custom.svg"; + const draftData = work.draft_data && typeof work.draft_data === "object" && !Array.isArray(work.draft_data) + ? work.draft_data as Partial> + : {}; + + return { + ...emptyWorkForm(), + ...draftData, + title: !isPublished && toText(work.title) === "Borrador sin titulo" ? "" : toText(work.title), + summary: !isPublished && toText(work.summary) === "Borrador pendiente de completar." ? "" : toText(work.summary), + story: !isPublished && toText(work.story).startsWith("Borrador pendiente") ? "" : toText(work.story), + technology: !isPublished && toText(work.technology) === "Pendiente" ? "" : toText(work.technology), + material: !isPublished && toText(work.material) === "Pendiente" ? "" : toText(work.material), + imageUrl: isFallbackDraftImage ? "" : imageUrl, + galleryUrls: uniqueList(galleryUrls.filter((item) => item !== "/demo/work-custom.svg")).join("\n") + }; + } + + function resumeDraft(work: Record) { + setEditingWorkId(String(work.id)); + setWorkForm(workFormFromWork(work)); + setWorkStep(0); + setWorkWizardOpen(true); + setStatus("Borrador cargado. Continua desde el paso que necesites."); + } + + async function unpublishWork(work: Record) { + setStatus("Despublicando trabajo..."); + try { + const restoredForm = workFormFromWork(work); + await browserFetch(`/account/maker/works/${String(work.id)}`, { + method: "PATCH", + body: JSON.stringify({ + ...restoredForm, + galleryUrls: uniqueList([restoredForm.imageUrl, ...restoredForm.galleryUrls.split("\n")]), + draftData: restoredForm, + isPublished: false, + serviceId: toText(work.service_id) || null + }) + }); + setStatus("Trabajo despublicado y guardado como borrador."); + setWorkListFilter("drafts"); + await loadAll(); + } catch (error) { + setStatus(error instanceof Error ? error.message : "No se pudo despublicar el trabajo"); + } + } + + async function deleteWork(work: Record) { + if (typeof window !== "undefined" && !window.confirm("Esto eliminara el trabajo de forma permanente. Quieres continuar?")) { + return; + } + + setStatus("Eliminando trabajo..."); + try { + await browserFetch(`/account/maker/works/${String(work.id)}`, { + method: "DELETE" + }); + if (editingWorkId === String(work.id)) { + setEditingWorkId(null); + setWorkForm(emptyWorkForm()); + setWorkStep(0); + setWorkWizardOpen(false); + } + setStatus("Trabajo eliminado."); + await loadAll(); + } catch (error) { + setStatus(error instanceof Error ? error.message : "No se pudo eliminar el trabajo"); + } + } + + function getWorkGalleryImages() { + return uniqueList([workForm.imageUrl, ...workForm.galleryUrls.split("\n")]); + } + + function setCoverImage(image: string) { + setWorkForm((current) => ({ + ...current, + imageUrl: image, + galleryUrls: uniqueList([image, ...current.galleryUrls.split("\n")]).join("\n") + })); + } + + function removeWorkImage(image: string) { + setWorkForm((current) => { + const galleryUrls = uniqueList(current.galleryUrls.split("\n").filter((item) => item !== image)); + const nextCover = current.imageUrl === image ? (galleryUrls[0] || "") : current.imageUrl; + + return { + ...current, + imageUrl: nextCover, + galleryUrls: galleryUrls.join("\n"), + beforeImageUrl: current.beforeImageUrl === image ? "" : current.beforeImageUrl, + afterImageUrl: current.afterImageUrl === image ? "" : current.afterImageUrl + }; + }); + } + + async function compressImage(file: File) { + if (!file.type.startsWith("image/")) { + return file; + } + + try { + const bitmap = await createImageBitmap(file); + const maxSide = 1400; + const scale = Math.min(1, maxSide / Math.max(bitmap.width, bitmap.height)); + const width = Math.max(1, Math.round(bitmap.width * scale)); + const height = Math.max(1, Math.round(bitmap.height * scale)); + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + if (!context) { + return file; + } + context.drawImage(bitmap, 0, 0, width, height); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.82)); + bitmap.close(); + if (!blob) { + return file; + } + + return new File([blob], file.name.replace(/\.[^.]+$/, ".jpg"), { type: "image/jpeg" }); + } catch { + return file; + } + } + + async function uploadWorkImage(file: File) { + const compactFile = await compressImage(file); + const formData = new FormData(); + formData.append("file", compactFile); + const response = await browserFetch<{ url: string }>("/uploads/public-image", { + method: "POST", + body: formData + }); + + return response.url; + } + + async function addWorkFiles(files: FileList | null) { + if (!files?.length) { + return; + } + + const selectedFiles = Array.from(files).filter((file) => file.type.startsWith("image/")).slice(0, 8); + if (!selectedFiles.length) { + setStatus("Selecciona imagenes validas."); + return; + } + + try { + setStatus("Subiendo imagenes..."); + const images = await Promise.all(selectedFiles.map((file) => uploadWorkImage(file))); + + setWorkForm((current) => { + const galleryUrls = uniqueList([...images, ...current.galleryUrls.split("\n")]); + return { + ...current, + imageUrl: current.imageUrl || images[0], + galleryUrls: galleryUrls.join("\n") + }; + }); + setStatus(`${images.length} imagen(es) agregada(s).`); + } catch (error) { + setStatus(error instanceof Error ? error.message : "No se pudieron subir las imagenes."); + } + } + + async function updateProfileLogo(files: FileList | null) { + const file = Array.from(files || []).find((item) => item.type.startsWith("image/")); + if (!file) { + return; + } + + try { + setStatus("Subiendo logo..."); + const imageUrl = await uploadWorkImage(file); + setProfileForm((current) => ({ ...current, mainImageUrl: imageUrl })); + setStatus("Logo cargado. Guarda el perfil para publicarlo."); + } catch (error) { + setStatus(error instanceof Error ? error.message : "No se pudo subir el logo."); + } + } + + function goToNextWorkStep() { + if (workStep === 0 && !getWorkGalleryImages().length) { + setStatus("Agrega al menos una foto para continuar."); + return; + } + + setWorkStep((current) => Math.min(current + 1, publishSteps.length - 1)); + } + + function goToWorkStep(step: number) { + if (step > 0 && !getWorkGalleryImages().length) { + setStatus("Agrega al menos una foto para continuar."); + setWorkStep(0); + return; + } + + setWorkStep(step); + } + + function renderChoiceGrid(field: keyof typeof workForm, options: string[]) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ); + } + + function goToNextServiceStep() { + if (serviceStep === 0 && !serviceForm.title.trim()) { + showToast("Elige un tipo de servicio para continuar."); + return; + } + + setServiceStep((current) => Math.min(current + 1, serviceSteps.length - 1)); + } + + function renderServiceMultiChoice(field: "technologies" | "materials" | "colors" | "finishes" | "uses" | "conditions" | "relatedWorks", options: string[]) { + const selected = serviceForm[field]; + return ( +
+ {options.map((option) => ( + + ))} +
+ ); + } + + function renderServiceStep() { + if (serviceStep === 0) { + return ( +
+ Nuevo servicio +

Que servicio quieres ofrecer?

+
+ {serviceTemplates.map((template) => ( + + ))} +
+
+ ); + } + + if (serviceStep === 1) { + return ( +
+ Datos basicos +

Configuracion del servicio

+ updateServiceForm("title", event.target.value)} placeholder="Nombre del servicio" /> + updateServiceForm("category", event.target.value)} placeholder="Categoria" /> +