first commit
This commit is contained in:
265
package-lock.json
generated
265
package-lock.json
generated
@@ -8,9 +8,16 @@
|
|||||||
"name": "surtilatino-frontend-page",
|
"name": "surtilatino-frontend-page",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@supabase/supabase-js": "^2.95.3",
|
||||||
|
"@tanstack/react-query": "^5.90.20",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.34.0",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -1234,6 +1241,86 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@supabase/auth-js": {
|
||||||
|
"version": "2.95.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.95.3.tgz",
|
||||||
|
"integrity": "sha512-vD2YoS8E2iKIX0F7EwXTmqhUpaNsmbU6X2R0/NdFcs02oEfnHyNP/3M716f3wVJ2E5XHGiTFXki6lRckhJ0Thg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/functions-js": {
|
||||||
|
"version": "2.95.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.95.3.tgz",
|
||||||
|
"integrity": "sha512-uTuOAKzs9R/IovW1krO0ZbUHSJnsnyJElTXIRhjJTqymIVGcHzkAYnBCJqd7468Fs/Foz1BQ7Dv6DCl05lr7ig==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/postgrest-js": {
|
||||||
|
"version": "2.95.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.95.3.tgz",
|
||||||
|
"integrity": "sha512-LTrRBqU1gOovxRm1vRXPItSMPBmEFqrfTqdPTRtzOILV4jPSueFz6pES5hpb4LRlkFwCPRmv3nQJ5N625V2Xrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/realtime-js": {
|
||||||
|
"version": "2.95.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.95.3.tgz",
|
||||||
|
"integrity": "sha512-D7EAtfU3w6BEUxDACjowWNJo/ZRo7sDIuhuOGKHIm9FHieGeoJV5R6GKTLtga/5l/6fDr2u+WcW/m8I9SYmaIw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/phoenix": "^1.6.6",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"tslib": "2.8.1",
|
||||||
|
"ws": "^8.18.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/storage-js": {
|
||||||
|
"version": "2.95.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.95.3.tgz",
|
||||||
|
"integrity": "sha512-4GxkJiXI3HHWjxpC3sDx1BVrV87O0hfX+wvJdqGv67KeCu+g44SPnII8y0LL/Wr677jB7tpjAxKdtVWf+xhc9A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iceberg-js": "^0.8.1",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/supabase-js": {
|
||||||
|
"version": "2.95.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.95.3.tgz",
|
||||||
|
"integrity": "sha512-Fukw1cUTQ6xdLiHDJhKKPu6svEPaCEDvThqCne3OaQyZvuq2qjhJAd91kJu3PXLG18aooCgYBaB6qQz35hhABg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/auth-js": "2.95.3",
|
||||||
|
"@supabase/functions-js": "2.95.3",
|
||||||
|
"@supabase/postgrest-js": "2.95.3",
|
||||||
|
"@supabase/realtime-js": "2.95.3",
|
||||||
|
"@supabase/storage-js": "2.95.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -1514,6 +1601,32 @@
|
|||||||
"tailwindcss": "4.1.18"
|
"tailwindcss": "4.1.18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/query-core": {
|
||||||
|
"version": "5.90.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
||||||
|
"integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/react-query": {
|
||||||
|
"version": "5.90.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz",
|
||||||
|
"integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-core": "5.90.20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
@@ -1550,17 +1663,22 @@
|
|||||||
"version": "20.19.33",
|
"version": "20.19.33",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
|
||||||
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
|
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/phoenix": {
|
||||||
|
"version": "1.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
|
||||||
|
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.13",
|
"version": "19.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz",
|
||||||
"integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==",
|
"integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -1576,6 +1694,15 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.55.0",
|
"version": "8.55.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz",
|
||||||
@@ -2587,6 +2714,15 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -2640,7 +2776,7 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
@@ -3596,6 +3732,33 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/framer-motion": {
|
||||||
|
"version": "12.34.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.0.tgz",
|
||||||
|
"integrity": "sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-dom": "^12.34.0",
|
||||||
|
"motion-utils": "^12.29.2",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emotion/is-prop-valid": "*",
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@emotion/is-prop-valid": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -3901,6 +4064,15 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"hermes-estree": "0.25.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/iceberg-js": {
|
||||||
|
"version": "0.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||||
|
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -4844,6 +5016,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "0.563.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.563.0.tgz",
|
||||||
|
"integrity": "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -4911,6 +5092,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/motion-dom": {
|
||||||
|
"version": "12.34.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.0.tgz",
|
||||||
|
"integrity": "sha512-Lql3NuEcScRDxTAO6GgUsRHBZOWI/3fnMlkMcH5NftzcN37zJta+bpbMAV9px4Nj057TuvRooMK7QrzMCgtz6Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-utils": "^12.29.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/motion-utils": {
|
||||||
|
"version": "12.29.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz",
|
||||||
|
"integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -6029,6 +6225,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tailwind-merge": {
|
||||||
|
"version": "3.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
||||||
|
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/dcastil"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.1.18",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||||
@@ -6308,7 +6514,6 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unrs-resolver": {
|
"node_modules/unrs-resolver": {
|
||||||
@@ -6502,6 +6707,27 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||||
|
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
@@ -6544,6 +6770,35 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zustand": {
|
||||||
|
"version": "5.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
|
||||||
|
"integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18.0.0",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"use-sync-external-store": ">=1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"use-sync-external-store": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,16 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@supabase/supabase-js": "^2.95.3",
|
||||||
|
"@tanstack/react-query": "^5.90.20",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.34.0",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
@@ -1,26 +1,56 @@
|
|||||||
|
/* src/app/globals.css */
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
@theme {
|
||||||
--background: #ffffff;
|
/* Paleta Surtilatino */
|
||||||
--foreground: #171717;
|
--color-accent: #5D9C59;
|
||||||
}
|
--color-dark: #1a1a1a;
|
||||||
|
--color-light: #f8f8f8;
|
||||||
|
--color-light-hover: #f0f0f0;
|
||||||
|
|
||||||
@theme inline {
|
/* Bordes Boutique */
|
||||||
--color-background: var(--background);
|
--radius-huge: 2rem;
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--font-sans: var(--font-geist-sans);
|
|
||||||
--font-mono: var(--font-geist-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
/* Animaciones */
|
||||||
:root {
|
--animate-reveal: reveal 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
--background: #0a0a0a;
|
--animate-fade-up: fade-up 0.8s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||||
--foreground: #ededed;
|
|
||||||
|
@keyframes reveal {
|
||||||
|
from { transform: scale(1); }
|
||||||
|
to { transform: scale(1.08); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-up {
|
||||||
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
--animate-marquee: marquee 25s linear infinite;
|
||||||
|
|
||||||
|
@keyframes marquee {
|
||||||
|
from { transform: translateX(0); }
|
||||||
|
to { transform: translateX(-50%); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
/* Clases utilitarias personalizadas */
|
||||||
background: var(--background);
|
@layer utilities {
|
||||||
color: var(--foreground);
|
.text-balance {
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ocultar scrollbar pero permitir scroll */
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply bg-white text-dark antialiased selection:bg-accent selection:text-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
|
// src/app/layout.tsx
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Outfit } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import Navbar from "@/components/layout/Navbar";
|
||||||
|
import CartDrawer from "@/components/layout/CartDrawer";
|
||||||
|
import Footer from "@/components/layout/Footer";
|
||||||
|
// (Lo crearemos en el siguiente paso)
|
||||||
|
|
||||||
const geistSans = Geist({
|
// Configuración de la fuente Outfit
|
||||||
variable: "--font-geist-sans",
|
const outfit = Outfit({
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
|
variable: "--font-sans", // Vincula con Tailwind
|
||||||
|
weight: ["200", "300", "400", "500", "600"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Surtilatino | Mercado Boutique",
|
||||||
description: "Generated by create next app",
|
description: "Sabor auténtico, origen natural.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -23,12 +25,13 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="es">
|
||||||
<body
|
<body className={`${outfit.variable} font-sans`}>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
<Navbar />
|
||||||
>
|
<CartDrawer />
|
||||||
{children}
|
<main>{children}</main>
|
||||||
|
<Footer></Footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
137
src/app/page.tsx
137
src/app/page.tsx
@@ -1,65 +1,84 @@
|
|||||||
import Image from "next/image";
|
import { supabase, getProductImageUrl } from "@/lib/supabase";
|
||||||
|
import Hero from "@/components/sections/Hero";
|
||||||
|
import ProductCard from "@/components/ui/ProductCard";
|
||||||
|
import Marquee from "@/components/ui/Marquee";
|
||||||
|
import { Product } from "@/types";
|
||||||
|
|
||||||
|
// Esta función se ejecuta en el SERVIDOR cada vez que alguien entra
|
||||||
|
async function getProducts() {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('products')
|
||||||
|
.select('*')
|
||||||
|
.eq('is_active', true) // Solo productos activos
|
||||||
|
.order('created_at', { ascending: false }) // Los más nuevos primero
|
||||||
|
.limit(6); // Traemos solo 6 para la home
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Error cargando productos:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as Product[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Home() {
|
||||||
|
// 1. Obtenemos los datos reales
|
||||||
|
const products = await getProducts();
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<div className="bg-white">
|
||||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
<Hero />
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
<Marquee />
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
<section id="shop" className="py-32 px-6 md:px-12 max-w-[1400px] mx-auto">
|
||||||
width={100}
|
<div className="flex flex-col md:flex-row justify-between items-end mb-16 gap-4">
|
||||||
height={20}
|
<h2 className="text-3xl md:text-4xl font-light leading-tight">
|
||||||
priority
|
Favoritos <br /> de la semana.
|
||||||
/>
|
</h2>
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
<div className="text-right">
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
<p className="text-xs text-gray-400 mb-2 uppercase tracking-widest">
|
||||||
To get started, edit the page.tsx file.
|
{products.length} productos disponibles
|
||||||
</h1>
|
</p>
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
<a href="/shop" className="text-sm border-b border-gray-300 pb-1 hover:border-dark hover:text-dark transition text-gray-500">
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
Ver catálogo completo
|
||||||
<a
|
</a>
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
</div>
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
{/* Grid de Productos */}
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
{products.length > 0 ? (
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-16">
|
||||||
target="_blank"
|
{products.map((product, index) => (
|
||||||
rel="noopener noreferrer"
|
<ProductCard
|
||||||
>
|
key={product.id}
|
||||||
<Image
|
id={product.id}
|
||||||
className="dark:invert"
|
title={product.name}
|
||||||
src="/vercel.svg"
|
category={product.category || "General"}
|
||||||
alt="Vercel logomark"
|
price={product.price}
|
||||||
width={16}
|
imageUrl={getProductImageUrl(product.image_url)}
|
||||||
height={16}
|
index={index + 1}
|
||||||
/>
|
/>
|
||||||
Deploy Now
|
))}
|
||||||
</a>
|
</div>
|
||||||
<a
|
) : (
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
/* Estado Vacío (Empty State) Minimalista */
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<div className="text-center py-20 bg-light rounded-[2rem]">
|
||||||
target="_blank"
|
<p className="text-gray-400 font-light text-lg">Aún no hemos subido productos.</p>
|
||||||
rel="noopener noreferrer"
|
<p className="text-sm text-gray-300 mt-2">Vuelve pronto para ver nuestras novedades.</p>
|
||||||
>
|
</div>
|
||||||
Documentation
|
)}
|
||||||
</a>
|
</section>
|
||||||
|
|
||||||
|
{/* Frase Editorial */}
|
||||||
|
<section className="py-24 px-6 bg-[#f8f8f8] mb-20">
|
||||||
|
<div className="max-w-3xl mx-auto text-center">
|
||||||
|
<h3 className="text-2xl md:text-3xl font-light leading-relaxed mb-6">
|
||||||
|
"No vendemos solo ingredientes. Vendemos la <span className="text-gray-400 line-through decoration-1 opacity-50">nostalgia</span> alegría de cocinar como en casa."
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-widest font-semibold">Familia Mercado, desde 2010</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
149
src/app/product/[id]/page.tsx
Normal file
149
src/app/product/[id]/page.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
// src/app/product/[id]/page.tsx
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft, Truck, ShieldCheck } from "lucide-react";
|
||||||
|
import { supabase, getProductImageUrl } from "@/lib/supabase";
|
||||||
|
import { Product } from "@/types";
|
||||||
|
import ProductCard from "@/components/ui/ProductCard";
|
||||||
|
import ProductActions from "@/components/product/ProductActions";
|
||||||
|
|
||||||
|
// 1. Función para obtener el producto actual
|
||||||
|
async function getProduct(id: string) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('products')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error || !data) return null;
|
||||||
|
return data as Product;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Función para obtener productos relacionados
|
||||||
|
async function getRelatedProducts(category: string, currentId: string) {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('products')
|
||||||
|
.select('*')
|
||||||
|
.eq('category', category)
|
||||||
|
.neq('id', currentId)
|
||||||
|
.eq('is_active', true)
|
||||||
|
.limit(3);
|
||||||
|
|
||||||
|
return (data as Product[]) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. DEFINICIÓN DEL COMPONENTE (Aquí estaba el error de tipos)
|
||||||
|
// En lugar de una interfaz separada, tipamos inline para evitar conflictos con Next.js 15
|
||||||
|
export default async function ProductPage(props: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}) {
|
||||||
|
// Await obligatorio en Next.js 15 antes de acceder a params
|
||||||
|
const params = await props.params;
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
const product = await getProduct(id);
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const relatedProducts = await getRelatedProducts(product.category, product.id);
|
||||||
|
const imageUrl = getProductImageUrl(product.image_url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white min-h-screen pb-32">
|
||||||
|
{/* Navegación Breadcrumb */}
|
||||||
|
<div className="pt-32 px-6 md:px-12 max-w-[1400px] mx-auto mb-8">
|
||||||
|
<Link href="/" className="inline-flex items-center gap-2 text-sm text-gray-400 hover:text-dark transition-colors">
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Volver al catálogo
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main className="px-6 md:px-12 max-w-[1400px] mx-auto">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-24">
|
||||||
|
|
||||||
|
{/* COLUMNA IZQUIERDA: Imagen Heroica */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="bg-[#f8f8f8] rounded-[2rem] aspect-[4/5] flex items-center justify-center overflow-hidden sticky top-32">
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={product.name}
|
||||||
|
className="w-3/4 h-3/4 object-contain drop-shadow-2xl transition-transform duration-700 hover:scale-105"
|
||||||
|
/>
|
||||||
|
{/* Badge de Stock */}
|
||||||
|
{product.stock < 5 && product.stock > 0 && (
|
||||||
|
<span className="absolute top-8 left-8 bg-red-50 text-red-500 px-4 py-1 rounded-full text-xs font-semibold tracking-wide uppercase">
|
||||||
|
Últimas {product.stock} unidades
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* COLUMNA DERECHA: Detalles */}
|
||||||
|
<div className="flex flex-col justify-center lg:py-12">
|
||||||
|
<span className="text-accent text-sm tracking-[0.2em] uppercase font-medium mb-4 block">
|
||||||
|
{product.category}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<h1 className="text-5xl md:text-6xl font-light text-dark mb-6 leading-[1.1] tracking-tight">
|
||||||
|
{product.name}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6 mb-8 border-b border-gray-100 pb-8">
|
||||||
|
<span className="text-3xl font-medium text-dark">
|
||||||
|
{new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(product.price)}
|
||||||
|
</span>
|
||||||
|
{product.stock > 0 ? (
|
||||||
|
<span className="text-green-600 text-sm flex items-center gap-1">
|
||||||
|
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
|
||||||
|
En Stock
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-sm">Agotado</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="prose prose-neutral max-w-none text-gray-500 font-light leading-relaxed mb-12">
|
||||||
|
<p>{product.description || "Sin descripción disponible para este producto."}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Componente Interactivo */}
|
||||||
|
<ProductActions product={product} />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-xs text-gray-400 uppercase tracking-wider font-medium">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Truck className="w-5 h-5 text-dark" />
|
||||||
|
<span>Envío en 24/48h</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ShieldCheck className="w-5 h-5 text-dark" />
|
||||||
|
<span>Calidad Garantizada</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Relacionados */}
|
||||||
|
{relatedProducts.length > 0 && (
|
||||||
|
<section className="mt-32 border-t border-gray-100 pt-20">
|
||||||
|
<h2 className="text-2xl font-light mb-12">También te podría gustar</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-8 gap-y-16">
|
||||||
|
{relatedProducts.map((prod, idx) => (
|
||||||
|
<ProductCard
|
||||||
|
key={prod.id}
|
||||||
|
id={prod.id}
|
||||||
|
title={prod.name}
|
||||||
|
category={prod.category}
|
||||||
|
price={prod.price}
|
||||||
|
imageUrl={getProductImageUrl(prod.image_url)}
|
||||||
|
index={idx + 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/app/shop/page.tsx
Normal file
52
src/app/shop/page.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// src/app/shop/page.tsx
|
||||||
|
import { supabase } from "@/lib/supabase";
|
||||||
|
import { Product, Category } from "@/types";
|
||||||
|
import CatalogBrowser from "@/components/shop/CatalogBrowser";
|
||||||
|
|
||||||
|
export const revalidate = 0; // Datos siempre frescos
|
||||||
|
|
||||||
|
// 1. Tu función para traer categorías
|
||||||
|
async function getCategories() {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('categories')
|
||||||
|
.select('*')
|
||||||
|
.order('name', { ascending: true });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error cargando categorías:', error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as Category[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Función para traer productos
|
||||||
|
async function getAllProducts() {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('products')
|
||||||
|
.select('*')
|
||||||
|
.eq('is_active', true)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Error fetching shop products:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as Product[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ShopPage() {
|
||||||
|
// 3. Ejecutamos ambas peticiones a la vez (Parallel Data Fetching)
|
||||||
|
const [products, categories] = await Promise.all([
|
||||||
|
getAllProducts(),
|
||||||
|
getCategories()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white min-h-screen">
|
||||||
|
{/* Pasamos ambas listas al componente cliente */}
|
||||||
|
<CatalogBrowser initialProducts={products} categories={categories} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
src/components/layout/CartDrawer.tsx
Normal file
143
src/components/layout/CartDrawer.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCartStore } from "@/store/cart-store";
|
||||||
|
import { X, Minus, Plus, Trash2, ArrowRight } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { getProductImageUrl } from "@/lib/supabase";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function CartDrawer() {
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
isOpen,
|
||||||
|
closeCart,
|
||||||
|
removeItem,
|
||||||
|
updateQuantity,
|
||||||
|
getTotalPrice
|
||||||
|
} = useCartStore();
|
||||||
|
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
// Evitar problemas de hidratación (SSR vs Client)
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Overlay Oscuro (Backdrop) */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 bg-black/20 backdrop-blur-sm z-[60] transition-opacity duration-300",
|
||||||
|
isOpen ? "opacity-100 visible" : "opacity-0 invisible pointer-events-none"
|
||||||
|
)}
|
||||||
|
onClick={closeCart}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Panel Deslizante */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed top-0 right-0 h-full w-full md:w-[450px] bg-white z-[70] shadow-2xl transform transition-transform duration-500 cubic-bezier(0.32, 0.72, 0, 1)",
|
||||||
|
// Borde redondeado a la izquierda estilo boutique
|
||||||
|
"md:rounded-l-[2.5rem]",
|
||||||
|
isOpen ? "translate-x-0" : "translate-x-full"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col h-full p-8">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<h2 className="text-2xl font-light tracking-tight">
|
||||||
|
Tu Pedido <span className="text-accent text-sm font-medium ml-2">({items.length})</span>
|
||||||
|
</h2>
|
||||||
|
<button onClick={closeCart} className="p-2 hover:bg-gray-100 rounded-full transition">
|
||||||
|
<X className="w-6 h-6 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lista de Items */}
|
||||||
|
<div className="flex-1 overflow-y-auto pr-2 -mr-2 no-scrollbar">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-center opacity-50">
|
||||||
|
<span className="text-6xl mb-4">🥑</span>
|
||||||
|
<p className="font-light">Tu cesta está vacía.</p>
|
||||||
|
<button onClick={closeCart} className="mt-4 text-sm underline hover:text-accent">
|
||||||
|
Volver a la tienda
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="flex gap-4">
|
||||||
|
{/* Imagen Miniatura */}
|
||||||
|
<div className="w-20 h-24 bg-[#f8f8f8] rounded-xl flex items-center justify-center shrink-0">
|
||||||
|
<img
|
||||||
|
src={getProductImageUrl(item.image_url)}
|
||||||
|
alt={item.name}
|
||||||
|
className="w-16 h-16 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 flex flex-col justify-between">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-dark line-clamp-1">{item.name}</h4>
|
||||||
|
<p className="text-xs text-gray-400">{item.category}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => removeItem(item.id)} className="text-gray-300 hover:text-red-400 transition">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-end">
|
||||||
|
{/* Control Cantidad Mini */}
|
||||||
|
<div className="flex items-center gap-3 bg-gray-50 rounded-full px-2 py-1">
|
||||||
|
<button onClick={() => updateQuantity(item.id, item.quantity - 1)} className="p-1 hover:text-accent">
|
||||||
|
<Minus className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs font-medium w-3 text-center">{item.quantity}</span>
|
||||||
|
<button onClick={() => updateQuantity(item.id, item.quantity + 1)} className="p-1 hover:text-accent">
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="font-medium text-dark">
|
||||||
|
{new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(item.price * item.quantity)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer / Checkout */}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className="mt-8 pt-6 border-t border-gray-100">
|
||||||
|
<div className="flex justify-between items-center mb-6 text-lg font-medium">
|
||||||
|
<span>Subtotal</span>
|
||||||
|
<span>
|
||||||
|
{new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(getTotalPrice())}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="w-full bg-dark text-white py-4 rounded-full font-medium flex justify-center items-center gap-2 hover:bg-accent hover:shadow-lg hover:shadow-green-100 transition-all duration-300 group">
|
||||||
|
Tramitar Pedido
|
||||||
|
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-center text-[10px] text-gray-400 mt-4 uppercase tracking-widest">
|
||||||
|
Envío calculado en el siguiente paso
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
src/components/layout/Footer.tsx
Normal file
120
src/components/layout/Footer.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Instagram, Facebook, ArrowRight, MapPin, Phone, Clock } from "lucide-react";
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="bg-[#f8f8f8] pt-24 pb-12 px-6 md:px-12 border-t border-gray-100 mt-auto">
|
||||||
|
<div className="max-w-[1400px] mx-auto">
|
||||||
|
|
||||||
|
{/* SECCIÓN SUPERIOR: Newsletter y Promesa */}
|
||||||
|
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-end mb-24 gap-12">
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<h2 className="text-4xl md:text-5xl font-light leading-[1.1] mb-6 tracking-tight">
|
||||||
|
Recibe lo mejor de Latinoamérica en tu mesa.
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-500 font-light mb-8 max-w-md">
|
||||||
|
Suscríbete para recibir ofertas exclusivas, novedades de temporada y recetas auténticas.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form className="flex w-full max-w-md items-center border-b border-gray-300 py-2 focus-within:border-dark transition-colors">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Tu correo electrónico"
|
||||||
|
className="appearance-none bg-transparent border-none w-full text-dark mr-3 py-1 px-2 leading-tight focus:outline-none placeholder:text-gray-400 font-light"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-shrink-0 text-sm border-none text-dark hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowRight className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* Redes Sociales Minimalistas */}
|
||||||
|
<a href="#" className="w-12 h-12 rounded-full border border-gray-200 flex items-center justify-center hover:bg-dark hover:text-white hover:border-dark transition-all duration-300">
|
||||||
|
<Instagram className="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
<a href="#" className="w-12 h-12 rounded-full border border-gray-200 flex items-center justify-center hover:bg-dark hover:text-white hover:border-dark transition-all duration-300">
|
||||||
|
<Facebook className="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SECCIÓN MEDIA: Enlaces y Contacto Real */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-12 border-t border-gray-200 pt-16 mb-16">
|
||||||
|
|
||||||
|
{/* Columna 1: Marca */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Link href="/" className="text-2xl tracking-tighter font-semibold">
|
||||||
|
SurtiLatino<span className="text-accent">.</span>
|
||||||
|
</Link>
|
||||||
|
<p className="text-xs text-gray-400 uppercase tracking-widest font-medium mt-2">
|
||||||
|
Barcelona • Since 2024
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Columna 2: Shop */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h4 className="font-medium mb-2">Tienda</h4>
|
||||||
|
<Link href="/shop" className="text-gray-500 hover:text-dark transition-colors font-light">Novedades</Link>
|
||||||
|
<Link href="/shop" className="text-gray-500 hover:text-dark transition-colors font-light">Despensa</Link>
|
||||||
|
<Link href="/shop" className="text-gray-500 hover:text-dark transition-colors font-light">Frutas & Verduras</Link>
|
||||||
|
<Link href="/shop" className="text-gray-500 hover:text-dark transition-colors font-light">Bebidas</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Columna 3: Información Real */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h4 className="font-medium mb-2">Visítanos</h4>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3 text-gray-500 font-light">
|
||||||
|
<MapPin className="w-5 h-5 shrink-0 mt-0.5" />
|
||||||
|
<a
|
||||||
|
href="https://maps.google.com/?q=Carrer+del+Doctor+Martí+Julià,+53,+08903+L'Hospitalet+de+Llobregat"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:text-dark hover:underline decoration-1 underline-offset-4 transition"
|
||||||
|
>
|
||||||
|
Carrer del Doctor Martí Julià, 53,<br />
|
||||||
|
08903 L'Hospitalet de Llobregat,<br />
|
||||||
|
Barcelona
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-gray-500 font-light">
|
||||||
|
<Phone className="w-4 h-4 shrink-0" />
|
||||||
|
<a href="tel:+34611947465" className="hover:text-dark transition">611 94 74 65</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3 text-gray-500 font-light">
|
||||||
|
<Clock className="w-4 h-4 shrink-0 mt-1" />
|
||||||
|
<div>
|
||||||
|
<p>Mar - Dom: 10:00 - 21:00</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">Lunes cerrado</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Columna 4: Legal */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h4 className="font-medium mb-2">Ayuda</h4>
|
||||||
|
<Link href="#" className="text-gray-500 hover:text-dark transition-colors font-light">Envíos y Devoluciones</Link>
|
||||||
|
<Link href="#" className="text-gray-500 hover:text-dark transition-colors font-light">Aviso Legal</Link>
|
||||||
|
<Link href="#" className="text-gray-500 hover:text-dark transition-colors font-light">Política de Privacidad</Link>
|
||||||
|
<Link href="#" className="text-gray-500 hover:text-dark transition-colors font-light">Contacto</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BOTTOM BAR: Copyright */}
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-center text-xs text-gray-400 pt-8 border-t border-gray-200">
|
||||||
|
<p>© {new Date().getFullYear()} SurtiLatino. Todos los derechos reservados.</p>
|
||||||
|
<div className="flex gap-6 mt-4 md:mt-0 uppercase tracking-widest font-medium">
|
||||||
|
<span>Hecho con ♥ en Barcelona</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
src/components/layout/Navbar.tsx
Normal file
123
src/components/layout/Navbar.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// src/components/layout/Navbar.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Search, ShoppingBag, Menu, X } from "lucide-react"; // Añadimos 'X' para cerrar
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useCartStore } from "@/store/cart-store";
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
const [scrolled, setScrolled] = useState(false);
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); // Estado para menú móvil
|
||||||
|
|
||||||
|
const { openCart, items } = useCartStore();
|
||||||
|
const itemCount = items.reduce((acc, item) => acc + item.quantity, 0);
|
||||||
|
|
||||||
|
// Efecto: Detectar scroll para el fondo glassmorphism
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => setScrolled(window.scrollY > 20);
|
||||||
|
window.addEventListener("scroll", handleScroll);
|
||||||
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Efecto: Bloquear el scroll del body cuando el menú móvil está abierto
|
||||||
|
useEffect(() => {
|
||||||
|
if (mobileMenuOpen) {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = "unset";
|
||||||
|
}
|
||||||
|
}, [mobileMenuOpen]);
|
||||||
|
|
||||||
|
// Lista de enlaces para reutilizar
|
||||||
|
const navLinks = ["Shop", "Novedades", "Origen"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<nav
|
||||||
|
className={cn(
|
||||||
|
"fixed top-0 w-full z-50 px-6 md:px-12 py-6 transition-all duration-300 flex justify-between items-center",
|
||||||
|
scrolled || mobileMenuOpen ? "bg-white/90 backdrop-blur-md py-4 shadow-sm" : "bg-transparent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-2xl tracking-tighter font-semibold z-50 relative"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
SurtiLatino<span className="text-accent">.</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* MENÚ DESKTOP (Oculto en móvil) */}
|
||||||
|
<div className="hidden md:flex space-x-12 text-sm font-medium text-gray-500">
|
||||||
|
{navLinks.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item}
|
||||||
|
href={`/${item.toLowerCase()}`}
|
||||||
|
className="hover:text-dark transition-colors duration-200"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ACCIONES (Siempre visibles) */}
|
||||||
|
<div className="flex items-center gap-4 md:gap-6 text-dark z-50">
|
||||||
|
<button className="hover:text-accent transition-colors">
|
||||||
|
<Search strokeWidth={1.5} className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={openCart}
|
||||||
|
className="hover:text-accent transition-colors relative group"
|
||||||
|
>
|
||||||
|
<ShoppingBag strokeWidth={1.5} className="w-5 h-5" />
|
||||||
|
{itemCount > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 w-4 h-4 bg-accent text-white text-[10px] flex items-center justify-center rounded-full font-bold animate-in zoom-in duration-300 shadow-sm border border-white">
|
||||||
|
{itemCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* BOTÓN HAMBURGUESA / CERRAR (Solo móvil) */}
|
||||||
|
<button
|
||||||
|
className="md:hidden p-1"
|
||||||
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
|
>
|
||||||
|
{mobileMenuOpen ? (
|
||||||
|
<X strokeWidth={1.5} className="w-6 h-6 animate-in spin-in-90 duration-300" />
|
||||||
|
) : (
|
||||||
|
<Menu strokeWidth={1.5} className="w-6 h-6 animate-in fade-in duration-300" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* MENÚ MÓVIL (Overlay Pantalla Completa) */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 bg-white z-40 flex flex-col justify-center items-center gap-8 transition-transform duration-500 cubic-bezier(0.32, 0.72, 0, 1) md:hidden",
|
||||||
|
mobileMenuOpen ? "translate-y-0" : "-translate-y-full"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{navLinks.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item}
|
||||||
|
href={`/${item.toLowerCase()}`}
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="text-4xl font-light tracking-tight hover:text-accent transition-colors duration-300"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="mt-8 flex gap-6">
|
||||||
|
<Link href="#" className="text-sm font-medium uppercase tracking-widest text-gray-400">Instagram</Link>
|
||||||
|
<Link href="#" className="text-sm font-medium uppercase tracking-widest text-gray-400">Contacto</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/components/product/ProductActions.tsx
Normal file
60
src/components/product/ProductActions.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Minus, Plus, ShoppingBag } from "lucide-react";
|
||||||
|
import { useCartStore } from "@/store/cart-store";
|
||||||
|
import { Product } from "@/types";
|
||||||
|
|
||||||
|
interface ProductActionsProps {
|
||||||
|
product: Product;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductActions({ product }: ProductActionsProps) {
|
||||||
|
const [quantity, setQuantity] = useState(1);
|
||||||
|
const addItem = useCartStore((state) => state.addItem);
|
||||||
|
|
||||||
|
const handleIncrement = () => setQuantity((prev) => prev + 1);
|
||||||
|
const handleDecrement = () => setQuantity((prev) => (prev > 1 ? prev - 1 : 1));
|
||||||
|
|
||||||
|
const handleAddToCart = () => {
|
||||||
|
addItem(product, quantity);
|
||||||
|
// El store ya se encarga de abrir el drawer automáticamente
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 mb-12">
|
||||||
|
{/* Selector de Cantidad */}
|
||||||
|
<div className="flex items-center border border-gray-200 rounded-full px-4 h-14 w-fit">
|
||||||
|
<button
|
||||||
|
onClick={handleDecrement}
|
||||||
|
disabled={quantity <= 1 || product.stock === 0}
|
||||||
|
className="p-2 hover:text-accent disabled:opacity-30 transition"
|
||||||
|
>
|
||||||
|
<Minus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="w-12 text-center font-medium select-none">
|
||||||
|
{quantity}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleIncrement}
|
||||||
|
disabled={quantity >= product.stock} // Evita seleccionar más del stock real
|
||||||
|
className="p-2 hover:text-accent disabled:opacity-30 transition"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Botón de Añadir */}
|
||||||
|
<button
|
||||||
|
onClick={handleAddToCart}
|
||||||
|
disabled={product.stock === 0}
|
||||||
|
className="flex-1 bg-dark text-white h-14 rounded-full font-medium hover:bg-accent hover:scale-[1.02] active:scale-95 transition-all duration-300 flex items-center justify-center gap-3 shadow-lg shadow-gray-200 disabled:bg-gray-300 disabled:cursor-not-allowed disabled:shadow-none disabled:scale-100"
|
||||||
|
>
|
||||||
|
<ShoppingBag className="w-5 h-5" />
|
||||||
|
{product.stock > 0 ? "Añadir al Pedido" : "Agotado"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
0
src/components/sections/FeaturedGrid.tsx
Normal file
0
src/components/sections/FeaturedGrid.tsx
Normal file
80
src/components/sections/Hero.tsx
Normal file
80
src/components/sections/Hero.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// src/components/sections/Hero.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { PlayCircle, ArrowRight } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
export default function Hero() {
|
||||||
|
return (
|
||||||
|
<section className="min-h-screen flex flex-col justify-center items-center text-center px-6 pt-32 pb-20 overflow-hidden relative">
|
||||||
|
|
||||||
|
{/* Etiqueta Superior */}
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
className="text-accent text-xs md:text-sm tracking-[0.2em] uppercase mb-6 font-medium"
|
||||||
|
>
|
||||||
|
Esenciales Latinos
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
{/* Titular Principal */}
|
||||||
|
<motion.h1
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.2 }}
|
||||||
|
className="text-5xl md:text-7xl lg:text-8xl font-light tracking-tight leading-[1.1] mb-10 max-w-5xl mx-auto"
|
||||||
|
>
|
||||||
|
Sabor auténtico, <br className="hidden md:block" />
|
||||||
|
<span className="font-semibold relative inline-block">
|
||||||
|
origen natural.
|
||||||
|
{/* Subrayado orgánico decorativo opcional */}
|
||||||
|
<svg className="absolute w-full h-3 -bottom-1 left-0 text-accent/30" viewBox="0 0 100 10" preserveAspectRatio="none">
|
||||||
|
<path d="M0 5 Q 50 10 100 5" stroke="currentColor" strokeWidth="2" fill="none" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</motion.h1>
|
||||||
|
|
||||||
|
{/* Botones de Acción */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.4 }}
|
||||||
|
className="flex flex-col md:flex-row gap-4 items-center mb-20 z-10"
|
||||||
|
>
|
||||||
|
<button className="bg-dark text-white px-8 py-4 rounded-full text-sm font-medium hover:bg-accent transition-colors duration-300 flex items-center gap-2 group">
|
||||||
|
Ver Colección
|
||||||
|
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className="flex items-center gap-2 px-8 py-4 rounded-full text-sm font-medium border border-gray-200 hover:border-dark transition-colors duration-300 bg-white">
|
||||||
|
<PlayCircle className="w-5 h-5 text-gray-400" />
|
||||||
|
Nuestra Historia
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Imagen Principal con Decoración */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
||||||
|
className="relative w-full max-w-4xl"
|
||||||
|
>
|
||||||
|
{/* Círculo de fondo decorativo */}
|
||||||
|
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[120%] h-[120%] bg-gradient-to-b from-light to-white rounded-full -z-10 blur-3xl opacity-60"></div>
|
||||||
|
|
||||||
|
{/* Imagen (Usamos un placeholder de alta calidad por ahora) */}
|
||||||
|
<div className="relative aspect-[16/9] w-full">
|
||||||
|
{/* Nota: En producción usarás el componente Image de Next.js.
|
||||||
|
Para este ejemplo rápido uso img estándar con una URL externa */}
|
||||||
|
<img
|
||||||
|
src="https://images.unsplash.com/photo-1610832958506-aa56368176cf?q=80&w=2670&auto=format&fit=crop"
|
||||||
|
alt="Bodegón de frutas frescas"
|
||||||
|
className="w-full h-full object-contain drop-shadow-2xl hover:scale-[1.02] transition-transform duration-700 ease-out"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
0
src/components/sections/InfoSection.tsx
Normal file
0
src/components/sections/InfoSection.tsx
Normal file
150
src/components/shop/CatalogBrowser.tsx
Normal file
150
src/components/shop/CatalogBrowser.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import { Product, Category } from "@/types";
|
||||||
|
import ProductCard from "@/components/ui/ProductCard";
|
||||||
|
import { getProductImageUrl } from "@/lib/supabase";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface CatalogBrowserProps {
|
||||||
|
initialProducts: Product[];
|
||||||
|
categories: Category[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CatalogBrowser({ initialProducts, categories }: CatalogBrowserProps) {
|
||||||
|
// AHORA: activeCategory guarda el ID ('all' o el UUID de la categoría)
|
||||||
|
const [activeCategory, setActiveCategory] = useState("all");
|
||||||
|
const [sortOrder, setSortOrder] = useState("Destacados");
|
||||||
|
|
||||||
|
// 1. Preparamos el menú (añadimos la opción "Todo" con ID 'all')
|
||||||
|
const menuCategories = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{ id: 'all', name: 'Todo' },
|
||||||
|
...categories
|
||||||
|
];
|
||||||
|
}, [categories]);
|
||||||
|
|
||||||
|
// 2. Lógica de Filtrado y Ordenación
|
||||||
|
const filteredProducts = useMemo(() => {
|
||||||
|
let result = [...initialProducts];
|
||||||
|
|
||||||
|
// FILTRADO POR ID (Mucho más seguro)
|
||||||
|
if (activeCategory !== "all") {
|
||||||
|
result = result.filter((p) => p.category_id === activeCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordenar
|
||||||
|
if (sortOrder === "Precio: Menor a Mayor") {
|
||||||
|
result.sort((a, b) => a.price - b.price);
|
||||||
|
} else if (sortOrder === "Precio: Mayor a Menor") {
|
||||||
|
result.sort((a, b) => b.price - a.price);
|
||||||
|
} else if (sortOrder === "Novedades") {
|
||||||
|
result.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [initialProducts, activeCategory, sortOrder]);
|
||||||
|
|
||||||
|
// Helper para obtener el nombre de la categoría de un producto
|
||||||
|
const getCategoryName = (catId: string) => {
|
||||||
|
const category = categories.find((c) => c.id === catId);
|
||||||
|
return category ? category.name : "General";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="pt-40 pb-12 px-6 md:px-12 flex flex-col md:flex-row md:items-end justify-between gap-6 max-w-[1600px] mx-auto animate-fade-in-up">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-400 text-xs tracking-widest uppercase mb-2">Colección {new Date().getFullYear()}</p>
|
||||||
|
<h1 className="text-5xl md:text-7xl font-light tracking-tight text-dark">
|
||||||
|
La Despensa.
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500 text-sm max-w-xs leading-relaxed text-right hidden md:block">
|
||||||
|
Explora nuestra selección de productos auténticos, importados directamente para tu cocina.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* FILTROS STICKY */}
|
||||||
|
<div className="sticky top-[80px] z-40 bg-white/95 backdrop-blur-md border-y border-gray-100 py-4 px-6 md:px-12 mb-12 transition-all">
|
||||||
|
<div className="max-w-[1600px] mx-auto flex flex-col md:flex-row justify-between items-center gap-4">
|
||||||
|
|
||||||
|
<div className="w-full md:w-auto overflow-x-auto no-scrollbar">
|
||||||
|
<div className="flex gap-2 md:gap-4 text-sm whitespace-nowrap">
|
||||||
|
{menuCategories.map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat.id}
|
||||||
|
onClick={() => setActiveCategory(cat.id)} // Guardamos el ID
|
||||||
|
className={cn(
|
||||||
|
"px-6 py-2 rounded-full transition-all duration-300 border",
|
||||||
|
activeCategory === cat.id
|
||||||
|
? "bg-dark text-white border-dark font-medium"
|
||||||
|
: "bg-transparent text-gray-500 border-gray-200 hover:border-dark hover:text-dark"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cat.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<span>Ver:</span>
|
||||||
|
<select
|
||||||
|
value={sortOrder}
|
||||||
|
onChange={(e) => setSortOrder(e.target.value)}
|
||||||
|
className="bg-transparent text-dark font-medium outline-none cursor-pointer border-none focus:ring-0 py-0 pl-2"
|
||||||
|
>
|
||||||
|
<option value="Destacados">Destacados</option>
|
||||||
|
<option value="Novedades">Novedades</option>
|
||||||
|
<option value="Precio: Menor a Mayor">Precio: Menor a Mayor</option>
|
||||||
|
<option value="Precio: Mayor a Menor">Precio: Mayor a Menor</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="px-6 md:px-12 pb-24 max-w-[1600px] mx-auto min-h-[50vh]">
|
||||||
|
{filteredProducts.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-x-6 gap-y-12">
|
||||||
|
{filteredProducts.map((product, index) => (
|
||||||
|
<ProductCard
|
||||||
|
key={product.id}
|
||||||
|
id={product.id}
|
||||||
|
title={product.name}
|
||||||
|
// Buscamos el nombre real usando el ID
|
||||||
|
category={getCategoryName(product.category_id)}
|
||||||
|
price={product.price}
|
||||||
|
imageUrl={getProductImageUrl(product.image_url)}
|
||||||
|
index={index + 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||||
|
<p className="text-lg font-light">No encontramos productos en esta categoría.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveCategory("all")}
|
||||||
|
className="mt-4 text-dark border-b border-dark pb-0.5 hover:text-accent hover:border-accent transition"
|
||||||
|
>
|
||||||
|
Ver todos los productos
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-24 flex flex-col items-center justify-center gap-4 border-t border-gray-100 pt-12">
|
||||||
|
<p className="text-gray-400 text-sm">¿Buscas algo específico?</p>
|
||||||
|
<a
|
||||||
|
href="https://wa.me/34611947465"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="bg-dark text-white px-8 py-3 rounded-full text-sm hover:bg-accent transition duration-300 flex items-center gap-2 shadow-lg shadow-gray-200 hover:shadow-green-100 transform hover:-translate-y-1"
|
||||||
|
>
|
||||||
|
<span className="font-bold text-lg">WA</span>
|
||||||
|
Consultar disponibilidad en tienda
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
0
src/components/ui/Button.tsx
Normal file
0
src/components/ui/Button.tsx
Normal file
17
src/components/ui/Marquee.tsx
Normal file
17
src/components/ui/Marquee.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// src/components/ui/Marquee.tsx
|
||||||
|
export default function Marquee() {
|
||||||
|
return (
|
||||||
|
<div className="w-full py-6 border-y border-gray-100 bg-white overflow-hidden">
|
||||||
|
<div className="relative flex overflow-x-hidden group">
|
||||||
|
<div className="animate-marquee whitespace-nowrap flex gap-16 items-center">
|
||||||
|
{/* Repetimos el contenido suficientes veces para cubrir pantallas grandes */}
|
||||||
|
{[...Array(10)].map((_, i) => (
|
||||||
|
<span key={i} className="text-xl md:text-2xl font-light text-gray-300 uppercase tracking-widest select-none">
|
||||||
|
Orgánico <span className="text-accent">•</span> Fresco <span className="text-accent">•</span> Importado <span className="text-accent">•</span> Local
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/components/ui/ProductCard.tsx
Normal file
59
src/components/ui/ProductCard.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// src/components/ui/ProductCard.tsx
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
|
||||||
|
interface ProductCardProps {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
price: number;
|
||||||
|
imageUrl: string;
|
||||||
|
index?: number; // Para escalonar animaciones si queremos
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductCard({ id, title, category, price, imageUrl, index = 1 }: ProductCardProps) {
|
||||||
|
return (
|
||||||
|
<Link href={`/product/${id}`} className="group block cursor-pointer">
|
||||||
|
{/* Contenedor de Imagen (Gris pálido, bordes muy redondos) */}
|
||||||
|
<div className="bg-light rounded-[2rem] aspect-[3/4] flex items-center justify-center mb-5 relative overflow-hidden transition-colors duration-300 group-hover:bg-[#f0f0f0]">
|
||||||
|
|
||||||
|
{/* Número de índice estilo editorial */}
|
||||||
|
<span className="absolute top-6 left-6 text-xs font-medium text-gray-300 tracking-widest z-20">
|
||||||
|
{index.toString().padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Botón flotante "Quick Add" (aparece en hover) */}
|
||||||
|
<button className="absolute bottom-6 right-6 w-10 h-10 bg-white rounded-full flex items-center justify-center shadow-sm translate-y-20 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition-all duration-300 z-20 hover:bg-accent hover:text-white">
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Imagen del Producto */}
|
||||||
|
<div className="relative w-3/4 h-3/4 transition-transform duration-700 ease-[cubic-bezier(0.25,1,0.5,1)] group-hover:scale-110">
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={title}
|
||||||
|
className="w-full h-full object-contain drop-shadow-md group-hover:drop-shadow-xl transition-all duration-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Información del Producto */}
|
||||||
|
<div className="flex justify-between items-start px-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-dark leading-tight group-hover:text-accent transition-colors">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-400 uppercase tracking-wider mt-1 font-medium">
|
||||||
|
{category}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-dark font-medium block">
|
||||||
|
{new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(price)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
0
src/components/ui/SectionHeader.tsx
Normal file
0
src/components/ui/SectionHeader.tsx
Normal file
28
src/lib/supabase.ts
Normal file
28
src/lib/supabase.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
// 1. Usamos tus variables exactas
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
// Aceptamos la clave nueva (sb_publishable) O la antigua (anon) por si acaso
|
||||||
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
// 2. Verificación de seguridad
|
||||||
|
if (!supabaseUrl || !supabaseKey) {
|
||||||
|
throw new Error(
|
||||||
|
'Faltan las variables de entorno. Asegúrate de que tu archivo .env.local tenga NEXT_PUBLIC_SUPABASE_URL y NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Crear cliente (Supabase detecta automáticamente si es sb_publishable o anon)
|
||||||
|
export const supabase = createClient(supabaseUrl, supabaseKey);
|
||||||
|
|
||||||
|
// Helper de imágenes
|
||||||
|
export const getProductImageUrl = (imagePath: string | null) => {
|
||||||
|
if (!imagePath) return 'https://placehold.co/400x600/f8f8f8/e0e0e0?text=Sin+Imagen';
|
||||||
|
if (imagePath.startsWith('http')) return imagePath;
|
||||||
|
|
||||||
|
const { data } = supabase.storage
|
||||||
|
.from('products')
|
||||||
|
.getPublicUrl(imagePath);
|
||||||
|
|
||||||
|
return data.publicUrl;
|
||||||
|
};
|
||||||
7
src/lib/utils.ts
Normal file
7
src/lib/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// src/lib/utils.ts
|
||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
77
src/store/cart-store.ts
Normal file
77
src/store/cart-store.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
|
import { Product, CartItem } from '@/types';
|
||||||
|
|
||||||
|
interface CartState {
|
||||||
|
items: CartItem[];
|
||||||
|
isOpen: boolean;
|
||||||
|
addItem: (product: Product, quantity?: number) => void;
|
||||||
|
removeItem: (productId: string) => void;
|
||||||
|
updateQuantity: (productId: string, quantity: number) => void;
|
||||||
|
clearCart: () => void;
|
||||||
|
toggleCart: () => void;
|
||||||
|
openCart: () => void;
|
||||||
|
closeCart: () => void;
|
||||||
|
// Getters
|
||||||
|
getTotalPrice: () => number;
|
||||||
|
getTotalItems: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCartStore = create<CartState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
items: [],
|
||||||
|
isOpen: false,
|
||||||
|
|
||||||
|
addItem: (product, quantity = 1) => {
|
||||||
|
const currentItems = get().items;
|
||||||
|
const existingItem = currentItems.find((item) => item.id === product.id);
|
||||||
|
|
||||||
|
if (existingItem) {
|
||||||
|
// Si ya existe, sumamos la cantidad
|
||||||
|
const updatedItems = currentItems.map((item) =>
|
||||||
|
item.id === product.id
|
||||||
|
? { ...item, quantity: item.quantity + quantity }
|
||||||
|
: item
|
||||||
|
);
|
||||||
|
set({ items: updatedItems, isOpen: true }); // Abrimos el carrito al añadir
|
||||||
|
} else {
|
||||||
|
// Si es nuevo, lo añadimos
|
||||||
|
set({ items: [...currentItems, { ...product, quantity }], isOpen: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeItem: (id) => {
|
||||||
|
set({ items: get().items.filter((item) => item.id !== id) });
|
||||||
|
},
|
||||||
|
|
||||||
|
updateQuantity: (id, quantity) => {
|
||||||
|
if (quantity < 1) return;
|
||||||
|
set({
|
||||||
|
items: get().items.map((item) =>
|
||||||
|
item.id === id ? { ...item, quantity } : item
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearCart: () => set({ items: [] }),
|
||||||
|
|
||||||
|
// Control de UI del Drawer
|
||||||
|
toggleCart: () => set({ isOpen: !get().isOpen }),
|
||||||
|
openCart: () => set({ isOpen: true }),
|
||||||
|
closeCart: () => set({ isOpen: false }),
|
||||||
|
|
||||||
|
getTotalPrice: () => {
|
||||||
|
return get().items.reduce((total, item) => total + item.price * item.quantity, 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
getTotalItems: () => {
|
||||||
|
return get().items.reduce((total, item) => total + item.quantity, 0);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'surtilatino-cart', // Nombre en LocalStorage
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
0
src/types/database.types.ts
Normal file
0
src/types/database.types.ts
Normal file
25
src/types/index.ts
Normal file
25
src/types/index.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export interface Product {
|
||||||
|
id: string;
|
||||||
|
created_at: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
price: number;
|
||||||
|
stock: number;
|
||||||
|
category_id: string;
|
||||||
|
image_url: string | null; // El path dentro del bucket
|
||||||
|
is_active: boolean;
|
||||||
|
stripe_product_id?: string;
|
||||||
|
stripe_price_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string; // Ej: "Frutas & Verduras"
|
||||||
|
slug: string; // Ej: "frutas-verduras" (útil para URLs)
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CartItem extends Product {
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
33
structure.sh
Normal file
33
structure.sh
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# 1. Crear estructura de directorios dentro de src
|
||||||
|
mkdir -p src/components/layout # Navbar, Footer
|
||||||
|
mkdir -p src/components/ui # Botones, Tarjetas, Elementos base
|
||||||
|
mkdir -p src/components/sections # Hero, Grids de productos, Banners
|
||||||
|
mkdir -p src/lib # Cliente Supabase, utilidades (clsx)
|
||||||
|
mkdir -p src/store # Estado global (Zustand)
|
||||||
|
mkdir -p src/types # Definiciones de TypeScript (DB, Productos)
|
||||||
|
mkdir -p src/hooks # Hooks personalizados (si fueran necesarios)
|
||||||
|
|
||||||
|
# 2. Crear archivos para la Lógica y Configuración
|
||||||
|
touch src/lib/supabase.ts # Inicialización del cliente Supabase
|
||||||
|
touch src/lib/utils.ts # Helper para clases css (tailwind-merge)
|
||||||
|
touch src/store/cart-store.ts # Store de carrito (Zustand)
|
||||||
|
touch src/types/database.types.ts # Tipos generados de Supabase
|
||||||
|
touch src/types/index.ts # Tipos globales de la app
|
||||||
|
|
||||||
|
# 3. Crear archivos de Componentes (Vacíos por ahora)
|
||||||
|
# Layout
|
||||||
|
touch src/components/layout/Navbar.tsx
|
||||||
|
touch src/components/layout/Footer.tsx
|
||||||
|
|
||||||
|
# Secciones Principales (Home)
|
||||||
|
touch src/components/sections/Hero.tsx
|
||||||
|
touch src/components/sections/FeaturedGrid.tsx
|
||||||
|
touch src/components/sections/InfoSection.tsx
|
||||||
|
|
||||||
|
# Componentes de UI (Estilo Boutique)
|
||||||
|
touch src/components/ui/ProductCard.tsx
|
||||||
|
touch src/components/ui/Button.tsx
|
||||||
|
touch src/components/ui/SectionHeader.tsx
|
||||||
|
|
||||||
|
# 4. Mensaje de confirmación
|
||||||
|
echo "✅ Estructura 'Surtilatino Minimal' generada correctamente."
|
||||||
Reference in New Issue
Block a user