Compare commits

...

6 commits

Author SHA1 Message Date
0059f03e87 shipping support 2025-04-27 17:31:38 -05:00
6cf92c1db7 fixed axios import 2025-04-27 13:22:18 -05:00
7248225cb6 fixed axios import 2025-04-27 12:34:54 -05:00
9a865f94d9 fixed sservices 2025-04-27 10:37:20 -05:00
b8b501b12a fixed sservices 2025-04-27 10:35:49 -05:00
aa2a97bbad shippinh integration 2025-04-27 10:28:18 -05:00
12 changed files with 1353 additions and 155 deletions

322
backend/package-lock.json generated Normal file
View file

@ -0,0 +1,322 @@
{
"name": "rocks-2many-backend",
"version": "1.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="
},
"asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"axios": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz",
"integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==",
"requires": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
},
"busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"requires": {
"streamsearch": "^1.1.0"
}
},
"call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"requires": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
}
},
"combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"requires": {
"delayed-stream": "~1.0.0"
}
},
"concat-stream": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
"requires": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^2.2.2",
"typedarray": "^0.0.6"
}
},
"core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
},
"delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
},
"dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"requires": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
}
},
"es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="
},
"es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
},
"es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"requires": {
"es-errors": "^1.3.0"
}
},
"es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"requires": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
}
},
"follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="
},
"form-data": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"mime-types": "^2.1.12"
}
},
"function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
},
"get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"requires": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
}
},
"get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"requires": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
}
},
"gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="
},
"has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="
},
"has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"requires": {
"has-symbols": "^1.0.3"
}
},
"hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"requires": {
"function-bind": "^1.1.2"
}
},
"inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
},
"math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="
},
"media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="
},
"mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
},
"mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"requires": {
"mime-db": "1.52.0"
}
},
"minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="
},
"mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"requires": {
"minimist": "^1.2.6"
}
},
"multer": {
"version": "1.4.5-lts.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
"requires": {
"append-field": "^1.0.0",
"busboy": "^1.0.0",
"concat-stream": "^1.5.2",
"mkdirp": "^0.5.4",
"object-assign": "^4.1.1",
"type-is": "^1.6.4",
"xtend": "^4.0.0"
}
},
"object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="
},
"process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
},
"proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"requires": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="
},
"string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"requires": {
"safe-buffer": "~5.1.0"
}
},
"type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"requires": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
}
},
"typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
},
"util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="
}
}
}

View file

@ -9,15 +9,16 @@
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"axios": "^1.9.0",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.2",
"stripe": "^12.0.0",
"nodemailer": "^6.9.1",
"pg": "^8.10.0",
"pg-hstore": "^2.3.4",
"stripe": "^12.0.0",
"uuid": "^9.0.0"
},
"devDependencies": {

View file

@ -35,6 +35,30 @@ const config = {
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || ''
},
// Shipping configuration
shipping: {
enabled: process.env.SHIPPING_ENABLED === 'true',
easypostEnabled: process.env.EASYPOST_ENABLED === 'true',
easypostApiKey: process.env.EASYPOST_API_KEY || '',
flatRate: parseFloat(process.env.SHIPPING_FLAT_RATE || '10.00'),
freeThreshold: parseFloat(process.env.SHIPPING_FREE_THRESHOLD || '50.00'),
originAddress: {
street: process.env.SHIPPING_ORIGIN_STREET || '123 Main St',
city: process.env.SHIPPING_ORIGIN_CITY || 'Vancouver',
state: process.env.SHIPPING_ORIGIN_STATE || 'BC',
zip: process.env.SHIPPING_ORIGIN_ZIP || 'V6K 1V6',
country: process.env.SHIPPING_ORIGIN_COUNTRY || 'CA'
},
defaultPackage: {
length: parseFloat(process.env.SHIPPING_DEFAULT_PACKAGE_LENGTH || '15'),
width: parseFloat(process.env.SHIPPING_DEFAULT_PACKAGE_WIDTH || '12'),
height: parseFloat(process.env.SHIPPING_DEFAULT_PACKAGE_HEIGHT || '10'),
unit: process.env.SHIPPING_DEFAULT_PACKAGE_UNIT || 'cm',
weightUnit: process.env.SHIPPING_DEFAULT_WEIGHT_UNIT || 'g'
},
carriersAllowed: (process.env.SHIPPING_CARRIERS_ALLOWED || 'USPS,UPS,FedEx,DHL,Canada Post,Purolator').split(',')
},
// Site configuration (domain and protocol based on environment)
site: {
domain: process.env.ENVIRONMENT === 'prod' ? 'rocks.2many.ca' : 'localhost:3000',
@ -78,6 +102,50 @@ config.updateFromDatabase = (settings) => {
if (stripeWebhook && stripeWebhook.value) config.payment.stripeWebhookSecret = stripeWebhook.value;
}
// Update shipping settings if they exist in DB
const shippingSettings = settings.filter(s => s.category === 'shipping');
if (shippingSettings.length > 0) {
const shippingEnabled = shippingSettings.find(s => s.key === 'shipping_enabled');
const easypostEnabled = shippingSettings.find(s => s.key === 'easypost_enabled');
const easypostApiKey = shippingSettings.find(s => s.key === 'easypost_api_key');
const flatRate = shippingSettings.find(s => s.key === 'shipping_flat_rate');
const freeThreshold = shippingSettings.find(s => s.key === 'shipping_free_threshold');
const originStreet = shippingSettings.find(s => s.key === 'shipping_origin_street');
const originCity = shippingSettings.find(s => s.key === 'shipping_origin_city');
const originState = shippingSettings.find(s => s.key === 'shipping_origin_state');
const originZip = shippingSettings.find(s => s.key === 'shipping_origin_zip');
const originCountry = shippingSettings.find(s => s.key === 'shipping_origin_country');
const packageLength = shippingSettings.find(s => s.key === 'shipping_default_package_length');
const packageWidth = shippingSettings.find(s => s.key === 'shipping_default_package_width');
const packageHeight = shippingSettings.find(s => s.key === 'shipping_default_package_height');
const packageUnit = shippingSettings.find(s => s.key === 'shipping_default_package_unit');
const weightUnit = shippingSettings.find(s => s.key === 'shipping_default_weight_unit');
const carriersAllowed = shippingSettings.find(s => s.key === 'shipping_carriers_allowed');
if (shippingEnabled && shippingEnabled.value) config.shipping.enabled = shippingEnabled.value === 'true';
if (easypostEnabled && easypostEnabled.value) config.shipping.easypostEnabled = easypostEnabled.value === 'true';
if (easypostApiKey && easypostApiKey.value) config.shipping.easypostApiKey = easypostApiKey.value;
if (flatRate && flatRate.value) config.shipping.flatRate = parseFloat(flatRate.value);
if (freeThreshold && freeThreshold.value) config.shipping.freeThreshold = parseFloat(freeThreshold.value);
// Update origin address
if (originStreet && originStreet.value) config.shipping.originAddress.street = originStreet.value;
if (originCity && originCity.value) config.shipping.originAddress.city = originCity.value;
if (originState && originState.value) config.shipping.originAddress.state = originState.value;
if (originZip && originZip.value) config.shipping.originAddress.zip = originZip.value;
if (originCountry && originCountry.value) config.shipping.originAddress.country = originCountry.value;
// Update default package
if (packageLength && packageLength.value) config.shipping.defaultPackage.length = parseFloat(packageLength.value);
if (packageWidth && packageWidth.value) config.shipping.defaultPackage.width = parseFloat(packageWidth.value);
if (packageHeight && packageHeight.value) config.shipping.defaultPackage.height = parseFloat(packageHeight.value);
if (packageUnit && packageUnit.value) config.shipping.defaultPackage.unit = packageUnit.value;
if (weightUnit && weightUnit.value) config.shipping.defaultPackage.weightUnit = weightUnit.value;
// Update carriers allowed
if (carriersAllowed && carriersAllowed.value) config.shipping.carriersAllowed = carriersAllowed.value.split(',');
}
// Update site settings if they exist in DB
const siteSettings = settings.filter(s => s.category === 'site');
if (siteSettings.length > 0) {

View file

@ -17,10 +17,12 @@ const productRoutes = require('./routes/products');
const authRoutes = require('./routes/auth');
const cartRoutes = require('./routes/cart');
const productAdminRoutes = require('./routes/productAdmin');
const categoryAdminRoutes = require('./routes/categoryAdmin'); // Add category admin routes
const categoryAdminRoutes = require('./routes/categoryAdmin');
const usersAdminRoutes = require('./routes/userAdmin');
const ordersAdminRoutes = require('./routes/orderAdmin');
const userOrdersRoutes = require('./routes/userOrders');
const shippingRoutes = require('./routes/shipping');
// Create Express app
const app = express();
const port = config.port || 4000;
@ -240,8 +242,6 @@ app.delete('/api/image/product/:filename', adminAuthMiddleware(pool, query), (re
}
});
// Use routes
app.use('/api/admin/settings', settingsAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
app.use('/api/products', productRoutes(pool, query));
@ -249,8 +249,9 @@ app.use('/api/auth', authRoutes(pool, query));
app.use('/api/user/orders', userOrdersRoutes(pool, query, authMiddleware(pool, query)));
app.use('/api/cart', cartRoutes(pool, query, authMiddleware(pool, query)));
app.use('/api/admin/products', productAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
app.use('/api/admin/categories', categoryAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
app.use('/api/shipping', shippingRoutes(pool, query, authMiddleware(pool, query)));
app.use('/api/admin/categories', categoryAdminRoutes(pool, query, adminAuthMiddleware(pool, query))); // Add category admin routes
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);

View file

@ -1,6 +1,8 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const router = express.Router();
const shippingService = require('../services/shippingService.js');
const config = require('../config');
module.exports = (pool, query, authMiddleware) => {
@ -37,6 +39,7 @@ module.exports = (pool, query, authMiddleware) => {
`SELECT ci.id, ci.quantity, ci.added_at,
p.id AS product_id, p.name, p.description, p.price,
p.category_id, pc.name AS category_name,
p.weight_grams, p.length_cm, p.width_cm, p.height_cm,
(
SELECT json_agg(
json_build_object(
@ -53,7 +56,7 @@ module.exports = (pool, query, authMiddleware) => {
JOIN products p ON ci.product_id = p.id
JOIN product_categories pc ON p.category_id = pc.id
WHERE ci.cart_id = $1
GROUP BY ci.id, ci.quantity, ci.added_at, p.id, p.name, p.description, p.price, p.category_id, pc.name`,
GROUP BY ci.id, ci.quantity, ci.added_at, p.id, p.name, p.description, p.price, p.category_id, pc.name, p.weight_grams, p.length_cm, p.width_cm, p.height_cm`,
[cartId]
);
@ -72,16 +75,28 @@ module.exports = (pool, query, authMiddleware) => {
});
// Calculate total
const total = processedItems.reduce((sum, item) => {
const subtotal = processedItems.reduce((sum, item) => {
return sum + (parseFloat(item.price) * item.quantity);
}, 0);
// Initialize shipping
const shipping = {
rates: []
};
// Calculate basic flat rate shipping
if (config.shipping.enabled) {
shipping.rates = await shippingService.getFlatRateShipping(subtotal);
}
res.json({
id: cartId,
userId,
items: processedItems,
itemCount: processedItems.length,
total
subtotal,
shipping,
total: subtotal + (shipping.rates.length > 0 ? shipping.rates[0].rate : 0)
});
} catch (error) {
next(error);
@ -179,6 +194,7 @@ module.exports = (pool, query, authMiddleware) => {
`SELECT ci.id, ci.quantity, ci.added_at,
p.id AS product_id, p.name, p.description, p.price, p.stock_quantity,
p.category_id, pc.name AS category_name,
p.weight_grams, p.length_cm, p.width_cm, p.height_cm,
(
SELECT json_agg(
json_build_object(
@ -195,7 +211,7 @@ module.exports = (pool, query, authMiddleware) => {
JOIN products p ON ci.product_id = p.id
JOIN product_categories pc ON p.category_id = pc.id
WHERE ci.cart_id = $1
GROUP BY ci.id, ci.quantity, ci.added_at, p.id, p.name, p.description, p.price, p.stock_quantity, p.category_id, pc.name`,
GROUP BY ci.id, ci.quantity, ci.added_at, p.id, p.name, p.description, p.price, p.stock_quantity, p.category_id, pc.name, p.weight_grams, p.length_cm, p.width_cm, p.height_cm`,
[cartId]
);
@ -213,17 +229,29 @@ module.exports = (pool, query, authMiddleware) => {
};
});
// Calculate total
const total = processedItems.reduce((sum, item) => {
// Calculate subtotal
const subtotal = processedItems.reduce((sum, item) => {
return sum + (parseFloat(item.price) * item.quantity);
}, 0);
// Initialize shipping
const shipping = {
rates: []
};
// Calculate basic flat rate shipping
if (config.shipping.enabled) {
shipping.rates = await shippingService.getFlatRateShipping(subtotal);
}
res.json({
id: cartId,
userId,
items: processedItems,
itemCount: processedItems.length,
total
subtotal,
shipping,
total: subtotal + (shipping.rates.length > 0 ? shipping.rates[0].rate : 0)
});
} catch (error) {
next(error);
@ -299,6 +327,7 @@ module.exports = (pool, query, authMiddleware) => {
`SELECT ci.id, ci.quantity, ci.added_at,
p.id AS product_id, p.name, p.description, p.price, p.stock_quantity,
p.category_id, pc.name AS category_name,
p.weight_grams, p.length_cm, p.width_cm, p.height_cm,
(
SELECT json_agg(
json_build_object(
@ -315,7 +344,7 @@ module.exports = (pool, query, authMiddleware) => {
JOIN products p ON ci.product_id = p.id
JOIN product_categories pc ON p.category_id = pc.id
WHERE ci.cart_id = $1
GROUP BY ci.id, ci.quantity, ci.added_at, p.id, p.name, p.description, p.price, p.stock_quantity, p.category_id, pc.name`,
GROUP BY ci.id, ci.quantity, ci.added_at, p.id, p.name, p.description, p.price, p.stock_quantity, p.category_id, pc.name, p.weight_grams, p.length_cm, p.width_cm, p.height_cm`,
[cartId]
);
@ -333,17 +362,29 @@ module.exports = (pool, query, authMiddleware) => {
};
});
// Calculate total
const total = processedItems.reduce((sum, item) => {
// Calculate subtotal
const subtotal = processedItems.reduce((sum, item) => {
return sum + (parseFloat(item.price) * item.quantity);
}, 0);
// Initialize shipping
const shipping = {
rates: []
};
// Calculate basic flat rate shipping
if (config.shipping.enabled) {
shipping.rates = await shippingService.getFlatRateShipping(subtotal);
}
res.json({
id: cartId,
userId,
items: processedItems,
itemCount: processedItems.length,
total
subtotal,
shipping,
total: subtotal + (shipping.rates.length > 0 ? shipping.rates[0].rate : 0)
});
} catch (error) {
next(error);
@ -386,6 +427,10 @@ module.exports = (pool, query, authMiddleware) => {
userId,
items: [],
itemCount: 0,
subtotal: 0,
shipping: {
rates: []
},
total: 0
});
} catch (error) {
@ -393,10 +438,104 @@ module.exports = (pool, query, authMiddleware) => {
}
});
router.post('/checkout', async (req, res, next) => {
// Get shipping rates for current cart
router.post('/shipping-rates', async (req, res, next) => {
try {
const { userId, shippingAddress } = req.body;
if (req.user.id !== userId) {
return res.status(403).json({
error: true,
message: 'You can only get shipping rates for your own cart'
});
}
// Shipping must be enabled
if (!config.shipping.enabled) {
return res.status(400).json({
error: true,
message: 'Shipping is currently disabled'
});
}
// Get cart
const cartResult = await query(
'SELECT * FROM carts WHERE user_id = $1',
[userId]
);
if (cartResult.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Cart not found'
});
}
const cartId = cartResult.rows[0].id;
// Get cart items with product weights
const cartItemsResult = await query(
`SELECT ci.quantity, p.id, p.weight_grams, p.price
FROM cart_items ci
JOIN products p ON ci.product_id = p.id
WHERE ci.cart_id = $1`,
[cartId]
);
if (cartItemsResult.rows.length === 0) {
return res.status(400).json({
error: true,
message: 'Cart is empty'
});
}
// Calculate total weight and order value
const totalWeight = shippingService.calculateTotalWeight(cartItemsResult.rows);
const subtotal = cartItemsResult.rows.reduce((sum, item) => {
return sum + (parseFloat(item.price) * item.quantity);
}, 0);
// If no address provided, return only flat rate shipping
if (!shippingAddress) {
console.log("No Address provide flat rate");
const rates = await shippingService.getFlatRateShipping(subtotal);
return res.json({
success: true,
rates
});
}
// Get real shipping rates
const parsedAddress = typeof shippingAddress === 'string'
? shippingService.parseAddressString(shippingAddress)
: shippingAddress;
console.log("parsedAddress provided ", parsedAddress);
const rates = await shippingService.getShippingRates(
null, // Use default from config
parsedAddress,
{
weight: totalWeight,
order_total: subtotal
}
);
console.log("rates provided ", JSON.stringify(rates, null ,4));
res.json({
success: true,
rates
});
} catch (error) {
console.error('Error getting shipping rates:', error);
next(error);
}
});
router.post('/checkout', async (req, res, next) => {
try {
const { userId, shippingAddress, shippingMethod } = req.body;
if (req.user.id !== userId) {
return res.status(403).json({
error: true,
@ -421,7 +560,7 @@ module.exports = (pool, query, authMiddleware) => {
// Get cart items
const cartItemsResult = await query(
`SELECT ci.*, p.price, p.name, p.description,
`SELECT ci.*, p.price, p.name, p.description, p.weight_grams,
(
SELECT json_build_object(
'path', pi.image_path,
@ -444,11 +583,28 @@ module.exports = (pool, query, authMiddleware) => {
});
}
// Calculate total
const total = cartItemsResult.rows.reduce((sum, item) => {
// Calculate subtotal
const subtotal = cartItemsResult.rows.reduce((sum, item) => {
return sum + (parseFloat(item.price) * item.quantity);
}, 0);
// Determine shipping cost
let shippingCost = 0;
if (config.shipping.enabled) {
// If a specific shipping method was selected
if (shippingMethod && shippingMethod.id) {
shippingCost = parseFloat(shippingMethod.rate) || 0;
} else {
// Default to flat rate
const shippingRates = await shippingService.getFlatRateShipping(subtotal);
shippingCost = shippingRates[0].rate;
}
}
// Calculate total with shipping
const total = subtotal + shippingCost;
// Begin transaction
const client = await pool.connect();
@ -458,8 +614,8 @@ module.exports = (pool, query, authMiddleware) => {
// Create order
const orderId = uuidv4();
await client.query(
'INSERT INTO orders (id, user_id, status, total_amount, shipping_address, payment_completed) VALUES ($1, $2, $3, $4, $5, $6)',
[orderId, userId, 'pending', total, shippingAddress, false]
'INSERT INTO orders (id, user_id, status, total_amount, shipping_address, payment_completed, shipping_cost) VALUES ($1, $2, $3, $4, $5, $6, $7)',
[orderId, userId, 'pending', total, shippingAddress, false, shippingCost]
);
// Create order items
@ -470,6 +626,22 @@ module.exports = (pool, query, authMiddleware) => {
);
}
// If a shipping method was selected, save it with the order
if (shippingMethod && shippingMethod.id) {
const shippingInfo = {
method_id: shippingMethod.id,
carrier: shippingMethod.carrier,
service: shippingMethod.service,
rate: shippingMethod.rate,
estimated_days: shippingMethod.delivery_days
};
await client.query(
'UPDATE orders SET shipping_info = $1 WHERE id = $2',
[JSON.stringify(shippingInfo), orderId]
);
}
await client.query('COMMIT');
// Send back cart items for Stripe checkout
@ -478,6 +650,8 @@ module.exports = (pool, query, authMiddleware) => {
message: 'Order created successfully, ready for payment',
orderId,
cartItems: cartItemsResult.rows,
subtotal,
shippingCost,
total
});

View file

@ -241,7 +241,7 @@ module.exports = (pool, query, authMiddleware) => {
try {
// Get all settings from database
const allSettings = await SystemSettings.getAllSettings(pool, query);
config.updateFromDatabase(allSettings)
// Build environment variables string
let envContent = '';

View file

@ -0,0 +1,179 @@
const express = require('express');
const router = express.Router();
const shippingService = require('../services/shippingService.js');
const config = require('../config');
module.exports = (pool, query, authMiddleware) => {
// Apply authentication middleware to all routes
router.use(authMiddleware);
/**
* Get shipping rates
* POST /api/shipping/rates
*
* Request Body:
* {
* address: {
* name: string,
* street: string,
* city: string,
* state: string,
* zip: string,
* country: string,
* email: string
* },
* parcel: {
* length: number,
* width: number,
* height: number,
* weight: number,
* order_total: number
* },
* items: [{ id, quantity, weight_grams }] // Optional cart items for weight calculation
* }
*/
router.post('/rates', async (req, res, next) => {
try {
const { address, parcel, items } = req.body;
// Shipping must be enabled
if (!config.shipping.enabled) {
return res.status(400).json({
error: true,
message: 'Shipping is currently disabled'
});
}
// Validate required fields
if (!address || !parcel) {
return res.status(400).json({
error: true,
message: 'Address and parcel information are required'
});
}
// If address is a string, parse it
const parsedAddress = typeof address === 'string'
? shippingService.parseAddressString(address)
: address;
// Calculate total weight if items are provided
if (items && items.length > 0) {
parcel.weight = shippingService.calculateTotalWeight(items);
}
// Get shipping rates
const rates = await shippingService.getShippingRates(
null, // Use default from config
parsedAddress,
{
...parcel,
order_total: parcel.order_total || 0
}
);
res.json({
success: true,
rates
});
} catch (error) {
console.error('Error getting shipping rates:', error);
next(error);
}
});
/**
* Validate shipping address
* POST /api/shipping/validate-address
*
* Request Body:
* {
* address: {
* name: string,
* street: string,
* city: string,
* state: string,
* zip: string,
* country: string,
* email: string
* }
* }
*/
router.post('/validate-address', async (req, res, next) => {
try {
const { address } = req.body;
// Shipping must be enabled
if (!config.shipping.enabled) {
return res.status(400).json({
error: true,
message: 'Shipping is currently disabled'
});
}
// Validate required fields
if (!address) {
return res.status(400).json({
error: true,
message: 'Address information is required'
});
}
// If EasyPost is not enabled, just perform basic validation
if (!config.shipping.easypostEnabled || !config.shipping.easypostApiKey) {
const isValid = validateAddressFormat(address);
return res.json({
success: true,
valid: isValid,
original_address: address,
verified_address: address
});
}
// If address is a string, parse it
const parsedAddress = typeof address === 'string'
? shippingService.parseAddressString(address)
: address;
// TODO: Implement EasyPost address verification
// This would require making a call to EasyPost's API
// For now, we'll return the original address
res.json({
success: true,
valid: true,
original_address: parsedAddress,
verified_address: parsedAddress
});
} catch (error) {
console.error('Error validating address:', error);
next(error);
}
});
return router;
};
/**
* Basic address format validation
* @param {Object} address - Address to validate
* @returns {boolean} Whether the address has valid format
*/
function validateAddressFormat(address) {
// Check required fields
if (!address.street || !address.city || !address.zip || !address.country) {
return false;
}
// Check zip code format - just basic validation
if (address.country === 'US' && !/^\d{5}(-\d{4})?$/.test(address.zip)) {
return false;
}
if (address.country === 'CA' && !/^[A-Za-z]\d[A-Za-z] \d[A-Za-z]\d$/.test(address.zip)) {
return false;
}
return true;
}

View file

@ -0,0 +1,249 @@
const axios = require('axios');
const config = require('../config');
/**
* Service for handling shipping operations with EasyPost API
*/
const shippingService = {
/**
* Create a shipment in EasyPost and get available rates
* @param {Object} addressFrom - Shipping origin address
* @param {Object} addressTo - Customer shipping address
* @param {Object} parcelDetails - Package dimensions and weight
* @returns {Promise<Array>} Array of available shipping rates
*/
async getShippingRates(addressFrom, addressTo, parcelDetails) {
// If EasyPost is not enabled, return flat rate shipping
if (!config.shipping.easypostEnabled || !config.shipping.easypostApiKey) {
console.log("EASY POST NOT CONFIGURED ", !config.shipping.easypostEnabled, !config.shipping.easypostApiKey)
return this.getFlatRateShipping(parcelDetails.order_total);
}
try {
// Format addresses for EasyPost
const fromAddress = this.formatAddress(addressFrom || config.shipping.originAddress);
const toAddress = this.formatAddress(addressTo);
// Format parcel for EasyPost
const parcel = this.formatParcel(parcelDetails);
console.log("EasyPost shipment request", JSON.stringify({
shipment: {
from_address: fromAddress,
to_address: toAddress,
parcel: parcel
}
}, null , 4))
// Create shipment via EasyPost API
const response = await axios.post(
'https://api.easypost.com/v2/shipments',
{
shipment: {
from_address: fromAddress,
to_address: toAddress,
parcel: parcel
}
},
{
auth: {
username: config.shipping.easypostApiKey,
password: ''
},
headers: {
'Content-Type': 'application/json'
}
}
);
// console.log("EasyPost shipment response", response)
// Process and filter rates
return this.processShippingRates(response.data.rates, parcelDetails.order_total);
} catch (error) {
console.error('EasyPost API error:', error.response?.data || error.message);
// Fallback to flat rate if API fails
return this.getFlatRateShipping(parcelDetails.order_total);
}
},
/**
* Format address for EasyPost API
* @param {Object} address - Address details
* @returns {Object} Formatted address object
*/
formatAddress(address) {
return {
street1: address.street || address.street1,
city: address.city,
state: address.state || address.province,
zip: address.zip || address.postalCode,
country: address.country,
name: address.name || undefined,
company: address.company || undefined,
phone: address.phone || undefined,
email: address.email || undefined
};
},
/**
* Format parcel for EasyPost API
* @param {Object} parcelDetails - Package dimensions and weight
* @returns {Object} Formatted parcel object
*/
formatParcel(parcelDetails) {
const pkg = config.shipping.defaultPackage;
// Convert weight to ounces if coming from grams
const weight = parcelDetails.weight || 500; // Default to 500g if not provided
const weightOz = pkg.weightUnit === 'g' ? weight * 0.035274 : weight;
// Convert dimensions to inches if coming from cm
const lengthConversionFactor = pkg.unit === 'cm' ? 0.393701 : 1;
return {
length: (parcelDetails.length || pkg.length) * lengthConversionFactor,
width: (parcelDetails.width || pkg.width) * lengthConversionFactor,
height: (parcelDetails.height || pkg.height) * lengthConversionFactor,
weight: weightOz,
predefined_package: parcelDetails.predefined_package || null
};
},
/**
* Process and filter shipping rates from EasyPost
* @param {Array} rates - EasyPost shipping rates
* @param {number} orderTotal - Order total amount
* @returns {Array} Processed shipping rates
*/
processShippingRates(rates, orderTotal) {
if (!rates || !Array.isArray(rates)) {
return this.getFlatRateShipping(orderTotal);
}
// Filter by allowed carriers
let filteredRates = rates.filter(rate =>
config.shipping.carriersAllowed.some(carrier =>
rate.carrier.toUpperCase().includes(carrier.toUpperCase())
)
);
if (filteredRates.length === 0) {
return this.getFlatRateShipping(orderTotal);
}
// Format rates to standardized format
const formattedRates = filteredRates.map(rate => ({
id: rate.id,
carrier: rate.carrier,
service: rate.service,
rate: parseFloat(rate.rate),
currency: rate.currency,
delivery_days: rate.delivery_days || 'Unknown',
delivery_date: rate.delivery_date || null,
delivery_time: rate.est_delivery_time || null
}));
// Check if free shipping applies
if (orderTotal >= config.shipping.freeThreshold) {
formattedRates.push({
id: 'free-shipping',
carrier: 'FREE',
service: 'Standard Shipping',
rate: 0,
currency: 'USD',
delivery_days: '5-7',
delivery_date: null,
delivery_time: null
});
}
return formattedRates;
},
/**
* Get flat rate shipping as fallback
* @param {number} orderTotal - Order total amount
* @returns {Array} Flat rate shipping options
*/
getFlatRateShipping(orderTotal) {
const shippingOptions = [{
id: 'flat-rate',
carrier: 'Standard',
service: 'Flat Rate Shipping',
rate: config.shipping.flatRate,
currency: 'USD',
delivery_days: '5-7',
delivery_date: null,
delivery_time: null
}];
// Add free shipping if order qualifies
if (orderTotal >= config.shipping.freeThreshold) {
shippingOptions.push({
id: 'free-shipping',
carrier: 'FREE',
service: 'Standard Shipping',
rate: 0,
currency: 'USD',
delivery_days: '5-7',
delivery_date: null,
delivery_time: null
});
}
return shippingOptions;
},
/**
* Parse shipping address from string format
* @param {string} addressString - Shipping address as string
* @returns {Object} Parsed address object
*/
parseAddressString(addressString) {
const lines = addressString.trim().split('\n').map(line => line.trim());
// Try to intelligently parse the address components
// This is a simplified version - might need enhancement for edge cases
const parsedAddress = {
name: lines[0] || '',
street: lines[1] || '',
city: '',
state: '',
zip: '',
country: lines[lines.length - 1] || ''
};
// Try to parse city, state, zip from line 2
if (lines[2]) {
const cityStateZip = lines[2].split(',');
if (cityStateZip.length >= 2) {
parsedAddress.city = cityStateZip[0].trim();
// Split state and zip
const stateZip = cityStateZip[1].trim().split(' ');
if (stateZip.length >= 2) {
parsedAddress.state = stateZip[0].trim();
parsedAddress.zip = stateZip.slice(1).join(' ').trim();
} else {
parsedAddress.state = stateZip[0].trim();
}
} else {
parsedAddress.city = lines[2];
}
}
return parsedAddress;
},
/**
* Calculate total shipping weight from cart items
* @param {Array} items - Cart items
* @returns {number} Total weight in grams
*/
calculateTotalWeight(items) {
return items.reduce((total, item) => {
const itemWeight = item.weight_grams || 100;
return total + (itemWeight * item.quantity);
}, 0);
}
};
module.exports = shippingService;

View file

@ -40,5 +40,18 @@ VALUES
-- Shipping Settings
('shipping_flat_rate', '10.00', 'shipping'),
('shipping_free_threshold', '50.00', 'shipping'),
('shipping_enabled', 'true', 'shipping')
('shipping_enabled', 'true', 'shipping'),
('easypost_api_key', NULL, 'shipping'),
('easypost_enabled', 'false', 'shipping'),
('shipping_origin_street', '123 Main St', 'shipping'),
('shipping_origin_city', 'Vancouver', 'shipping'),
('shipping_origin_state', 'BC', 'shipping'),
('shipping_origin_zip', 'V6K 1V6', 'shipping'),
('shipping_origin_country', 'CA', 'shipping'),
('shipping_default_package_length', '15', 'shipping'),
('shipping_default_package_width', '12', 'shipping'),
('shipping_default_package_height', '10', 'shipping'),
('shipping_default_package_unit', 'cm', 'shipping'),
('shipping_default_weight_unit', 'g', 'shipping'),
('shipping_carriers_allowed', 'USPS,UPS,FedEx,DHL,Canada Post,Purolator', 'shipping')
ON CONFLICT (key) DO NOTHING;

View file

@ -0,0 +1,10 @@
-- Add shipping cost column to orders table
ALTER TABLE orders ADD COLUMN IF NOT EXISTS shipping_cost DECIMAL(10, 2) DEFAULT 0.00;
-- Update shipping info to be JSONB if not already
ALTER TABLE orders ALTER COLUMN shipping_info TYPE JSONB
USING CASE
WHEN shipping_info IS NULL THEN NULL
WHEN jsonb_typeof(shipping_info::jsonb) = 'object' THEN shipping_info::jsonb
ELSE jsonb_build_object('data', shipping_info)
END;

View file

@ -1,117 +1,136 @@
Rocks/
├── .git/ # Git repository
├── .env # Environment configuration
├── .gitignore # Git ignore file
├── README.md # Project documentation
├── Dockerfile # Main Dockerfile
├── docker-compose.yml # Docker Compose configuration
├── nginx.conf # Nginx configuration
├── setup-frontend.sh # Frontend setup script
├── package.json # Project dependencies
├── index.html # Main HTML entry point
├── frontend/
│ ├── node_modules/ # Node.js dependencies
│ ├── public/ # Static public assets
│ └── src/
│ ├── assets/ # Static assets
│ ├── components/
│ │ ├── EmailDialog.jsx # Email dialog component
│ │ ├── Footer.jsx # Footer component
│ │ ├── ImageUploader.jsx # Image upload component
│ │ ├── Notifications.jsx # Notifications component
│ │ ├── ProductImage.jsx # Product image component
│ │ └── ProtectedRoute.jsx # Auth route protection
│ ├── features/
│ │ ├── ui/
│ │ │ └── uiSlice.js # UI state management
│ │ ├── cart/
│ │ │ └── cartSlice.js # Cart state management
│ │ ├── auth/
│ │ │ └── authSlice.js # Auth state management
│ │ └── store/
│ │ └── index.js # Redux store configuration
│ ├── hooks/
│ │ ├── reduxHooks.js # Redux related hooks
│ │ ├── apiHooks.js # API related hooks
│ │ └── settingsAdminHooks.js # Admin settings hooks
│ ├── layouts/
│ │ ├── AdminLayout.jsx # Admin area layout
│ │ ├── MainLayout.jsx # Main site layout
│ │ └── AuthLayout.jsx # Authentication layout
│ ├── pages/
│ │ ├── Admin/
│ │ │ ├── DashboardPage.jsx # Admin dashboard
│ │ │ ├── ProductsPage.jsx # Products management
│ │ │ ├── ProductEditPage.jsx # Product editing
│ │ │ ├── OrdersPage.jsx # Orders management
│ │ │ ├── CategoriesPage.jsx # Categories management
│ │ │ ├── CustomersPage.jsx # Customer management
│ │ │ └── SettingsPage.jsx # Site settings
│ │ ├── HomePage.jsx # Home page
│ │ ├── ProductsPage.jsx # Products listing
│ │ ├── ProductDetailPage.jsx # Product details
│ │ ├── CartPage.jsx # Shopping cart
│ │ ├── CheckoutPage.jsx # Checkout process
│ │ ├── LoginPage.jsx # Login page
│ │ ├── RegisterPage.jsx # Registration page
│ │ ├── VerifyPage.jsx # Email verification
│ │ └── NotFoundPage.jsx # 404 page
│ ├── services/
│ │ ├── api.js # API client
│ │ ├── authService.js # Authentication service
│ │ ├── cartService.js # Cart management service
│ │ ├── productService.js # Products service
│ │ ├── settingsAdminService.js # Settings service
│ │ ├── adminService.js # Admin service
│ │ ├── categoryAdminService.js # Category service
│ │ └── imageService.js # Image handling service
│ ├── theme/
│ │ ├── index.js # Theme configuration
│ │ └── ThemeProvider.jsx # Theme provider component
│ ├── utils/
│ │ └── imageUtils.js # Image handling utilities
│ ├── App.jsx # Main application component
│ ├── main.jsx # Application entry point
│ ├── config.js # Frontend configuration
│ └── vite.config.js # Vite bundler configuration
│ ├── node_modules/
│ ├── src/
│ │ ├── pages/
│ │ │ ├── Admin/
│ │ │ │ ├── OrdersPage.jsx
│ │ │ │ ├── SettingsPage.jsx
│ │ │ │ ├── CustomersPage.jsx
│ │ │ │ ├── ProductEditPage.jsx
│ │ │ │ ├── DashboardPage.jsx
│ │ │ │ ├── CategoriesPage.jsx
│ │ │ │ └── ProductsPage.jsx
│ │ │ ├── PaymentSuccessPage.jsx
│ │ │ ├── CheckoutPage.jsx
│ │ │ ├── UserOrdersPage.jsx
│ │ │ ├── PaymentCancelPage.jsx
│ │ │ ├── ProductDetailPage.jsx
│ │ │ ├── CartPage.jsx
│ │ │ ├── ProductsPage.jsx
│ │ │ ├── HomePage.jsx
│ │ │ ├── VerifyPage.jsx
│ │ │ ├── RegisterPage.jsx
│ │ │ ├── NotFoundPage.jsx
│ │ │ └── LoginPage.jsx
│ │ ├── components/
│ │ │ ├── OrderStatusDialog.jsx
│ │ │ ├── StripePaymentForm.jsx
│ │ │ ├── EmailDialog.jsx
│ │ │ ├── Footer.jsx
│ │ │ ├── ImageUploader.jsx
│ │ │ ├── ProductImage.jsx
│ │ │ ├── ProtectedRoute.jsx
│ │ │ └── Notifications.jsx
│ │ ├── context/
│ │ │ └── StripeContext.jsx
│ │ ├── hooks/
│ │ │ ├── apiHooks.js
│ │ │ ├── adminHooks.js
│ │ │ ├── reduxHooks.js
│ │ │ ├── settingsAdminHooks.js
│ │ │ └── categoryAdminHooks.js
│ │ ├── services/
│ │ │ ├── adminService.js
│ │ │ ├── authService.js
│ │ │ ├── settingsAdminService.js
│ │ │ ├── cartService.js
│ │ │ ├── categoryAdminService.js
│ │ │ ├── imageService.js
│ │ │ ├── productService.js
│ │ │ └── api.js
│ │ ├── utils/
│ │ │ └── imageUtils.js
│ │ ├── layouts/
│ │ │ ├── MainLayout.jsx
│ │ │ ├── AdminLayout.jsx
│ │ │ └── AuthLayout.jsx
│ │ ├── theme/
│ │ │ ├── index.js
│ │ │ └── ThemeProvider.jsx
│ │ ├── features/
│ │ │ ├── ui/
│ │ │ │ └── uiSlice.js
│ │ │ ├── cart/
│ │ │ │ └── cartSlice.js
│ │ │ ├── auth/
│ │ │ │ └── authSlice.js
│ │ │ └── store/
│ │ │ └── index.js
│ │ ├── assets/
│ │ ├── App.jsx
│ │ ├── config.js
│ │ └── main.jsx
│ └── public/
│ ├── favicon.svg
│ ├── package-lock.json
│ ├── package.json
│ ├── vite.config.js
│ ├── Dockerfile
│ ├── nginx.conf
│ ├── index.html
│ ├── README.md
│ ├── .env
│ └── setup-frontend.sh
├── backend/
│ ├── node_modules/ # Node.js dependencies
│ ├── public/
│ │ └── uploads/
│ │ └── products/ # Product images storage
│ ├── src/
│ │ ├── routes/
│ │ │ ├── auth.js # Authentication routes
│ │ │ ├── userAdmin.js # User administration
│ │ │ ├── products.js # Product routes
│ │ │ ├── productAdmin.js # Product administration
│ │ │ ├── cart.js # Shopping cart routes
│ │ │ ├── settingsAdmin.js # Settings administration
│ │ │ ├── images.js # Image handling routes
│ │ │ ├── categoryAdmin.js # Category administration
│ │ │ └── orderAdmin.js # Order administration
│ │ ├── middleware/
│ │ │ ├── auth.js # Authentication middleware
│ │ │ ├── adminAuth.js # Admin authentication
│ │ │ └── upload.js # File upload middleware
│ │ │ ├── userOrders.js
│ │ │ ├── orderAdmin.js
│ │ │ ├── stripePayment.js
│ │ │ ├── cart.js
│ │ │ ├── auth.js
│ │ │ ├── userAdmin.js
│ │ │ ├── settingsAdmin.js
│ │ │ ├── products.js
│ │ │ ├── categoryAdmin.js
│ │ │ ├── productAdminImages.js
│ │ │ ├── images.js
│ │ │ └── productAdmin.js
│ │ ├── models/
│ │ │ └── SystemSettings.js # System settings model
│ │ │ └── SystemSettings.js
│ │ ├── middleware/
│ │ │ ├── upload.js
│ │ │ ├── auth.js
│ │ │ └── adminAuth.js
│ │ ├── db/
│ │ │ └── index.js # Database setup
│ │ ├── config.js # Backend configuration
│ │ └── index.js # Server entry point
│ ├── .env # Backend environment variables
│ ├── Dockerfile # Backend Dockerfile
│ └── package.json # Backend dependencies
└── db/
├── init/
│ ├── 01-schema.sql # Main database schema
│ ├── 02-seed.sql # Initial seed data
│ ├── 03-api-key.sql # API key setup
│ ├── 04-product-images.sql # Product images schema
│ ├── 05-admin-role.sql # Admin role definition
│ ├── 06-product-categories.sql # Product categories
│ ├── 07-user-keys.sql # User API keys
│ ├── 08-create-email.sql # Email templates
│ └── 09-system-settings.sql # System settings
└── test/ # Test database scripts
│ │ │ └── index.js
│ │ ├── index.js
│ │ └── config.js
│ ├── public/
│ │ └── uploads/
│ │ └── products/
│ ├── node_modules/
│ ├── .env
│ ├── package.json
│ ├── Dockerfile
│ ├── README.md
│ └── .gitignore
├── db/
│ ├── init/
│ │ ├── 01-schema.sql
│ │ ├── 02-seed.sql
│ │ ├── 03-api-key.sql
│ │ ├── 04-product-images.sql
│ │ ├── 05-admin-role.sql
│ │ ├── 06-product-categories.sql
│ │ ├── 07-user-keys.sql
│ │ ├── 08-create-email.sql
│ │ ├── 09-system-settings.sql
│ │ ├── 10-payment.sql
│ │ └── 11-notifications.sql
│ └── .gitignore
├── test/
├── fileStructure.txt
├── docker-compose.yml
└── .gitignore

View file

@ -16,16 +16,21 @@ import {
List,
ListItem,
ListItemText,
Alert
Alert,
Radio,
RadioGroup,
FormControl,
FormLabel
} from '@mui/material';
import { useNavigate, Link as RouterLink } from 'react-router-dom';
import { useAuth, useCart } from '../hooks/reduxHooks';
import { useCheckout } from '../hooks/apiHooks';
import { useStripe, StripeElementsProvider } from '../context/StripeContext';
import StripePaymentForm from '../components/StripePaymentForm';
import apiClient from '../services/api';
// Checkout steps
const steps = ['Shipping Address', 'Review Order', 'Payment', 'Confirmation'];
const steps = ['Shipping Address', 'Shipping Method', 'Review Order', 'Payment', 'Confirmation'];
const CheckoutPage = () => {
const navigate = useNavigate();
@ -37,10 +42,16 @@ const CheckoutPage = () => {
// State for checkout steps
const [activeStep, setActiveStep] = useState(0);
const [isProcessing, setIsProcessing] = useState(false);
const [isLoadingShipping, setIsLoadingShipping] = useState(false);
const [error, setError] = useState(null);
const [orderId, setOrderId] = useState(null);
const [checkoutUrl, setCheckoutUrl] = useState(null);
// State for shipping options
const [shippingRates, setShippingRates] = useState([]);
const [selectedShippingMethod, setSelectedShippingMethod] = useState(null);
const [shippingCost, setShippingCost] = useState(0);
// State for form data
const [formData, setFormData] = useState({
firstName: userData?.first_name || '',
@ -75,10 +86,22 @@ const CheckoutPage = () => {
if (!validateShippingForm()) {
return;
}
// If valid, fetch shipping rates
fetchShippingRates();
}
// If on shipping method step, validate selection
if (activeStep === 1) {
if (!selectedShippingMethod) {
setError('Please select a shipping method');
return;
}
setError(null);
}
// If on review step, process checkout
if (activeStep === 1) {
if (activeStep === 2) {
handlePlaceOrder();
return;
}
@ -98,7 +121,7 @@ const CheckoutPage = () => {
for (const field of requiredFields) {
if (!formData[field]) {
// In a real app, you'd set specific errors for each field
alert(`Please fill in all required fields`);
setError(`Please fill in all required fields`);
return false;
}
}
@ -106,13 +129,70 @@ const CheckoutPage = () => {
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.email)) {
alert('Please enter a valid email address');
setError('Please enter a valid email address');
return false;
}
setError(null);
return true;
};
// Fetch shipping rates based on address
const fetchShippingRates = async () => {
try {
setIsLoadingShipping(true);
setError(null);
// Format shipping address
const shippingAddress = {
name: `${formData.firstName} ${formData.lastName}`,
street: formData.address,
city: formData.city,
state: formData.province,
zip: formData.postalCode,
country: formData.country,
email: formData.email
};
// Call API to get shipping rates
const response = await apiClient.post('/cart/shipping-rates', {
userId: user,
shippingAddress
});
if (response.data.rates && response.data.rates.length > 0) {
setShippingRates(response.data.rates);
// Default to lowest cost option
const lowestCostOption = response.data.rates.reduce(
(lowest, current) => current.rate < lowest.rate ? current : lowest,
response.data.rates[0]
);
setSelectedShippingMethod(lowestCostOption);
setShippingCost(lowestCostOption.rate);
} else {
setShippingRates([]);
setSelectedShippingMethod(null);
setShippingCost(0);
setError('No shipping options available for this address');
}
} catch (error) {
console.error('Error fetching shipping rates:', error);
setError('Failed to retrieve shipping options. Please try again.');
} finally {
setIsLoadingShipping(false);
}
};
// Handle shipping method selection
const handleShippingMethodChange = (event) => {
const selectedMethodId = event.target.value;
const method = shippingRates.find(rate => rate.id === selectedMethodId);
if (method) {
setSelectedShippingMethod(method);
setShippingCost(method.rate);
}
};
// Handle place order
const handlePlaceOrder = async () => {
if (!user || !items || items.length === 0) {
@ -133,21 +213,26 @@ const CheckoutPage = () => {
// Call the checkout API to create the order
const orderResponse = await checkout.mutateAsync({
userId: user,
shippingAddress
shippingAddress,
shippingMethod: selectedShippingMethod
});
// Store the order ID for later use
setOrderId(orderResponse.orderId);
// Proceed to payment step
setActiveStep(2);
setActiveStep(3);
// Create a Stripe checkout session
const session = await createCheckoutSession(
orderResponse.cartItems,
orderResponse.orderId,
shippingAddress,
user
user,
{
shipping_cost: orderResponse.shippingCost || shippingCost,
shipping_method: selectedShippingMethod ? selectedShippingMethod.carrier + ' - ' + selectedShippingMethod.service : 'Standard Shipping'
}
);
// Redirect to Stripe Checkout
@ -311,6 +396,65 @@ const CheckoutPage = () => {
</Box>
);
case 1:
return (
<Box sx={{ mt: 3 }}>
<Typography variant="h6" gutterBottom>
Shipping Method
</Typography>
{isLoadingShipping ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : shippingRates.length > 0 ? (
<FormControl component="fieldset" sx={{ width: '100%' }}>
<RadioGroup
aria-label="shipping-method"
name="shipping-method"
value={selectedShippingMethod?.id || ''}
onChange={handleShippingMethodChange}
>
{shippingRates.map((rate) => (
<Paper
key={rate.id}
variant="outlined"
sx={{
mb: 2,
p: 2,
border: selectedShippingMethod?.id === rate.id ? 2 : 1,
borderColor: selectedShippingMethod?.id === rate.id ? 'primary.main' : 'divider'
}}
>
<FormControlLabel
value={rate.id}
control={<Radio />}
label={
<Box>
<Typography variant="subtitle1">
{rate.carrier} - {rate.service}
</Typography>
<Typography variant="body2" color="text.secondary">
Estimated delivery: {rate.delivery_days} days
</Typography>
<Typography variant="h6" color="primary" sx={{ mt: 1 }}>
{rate.rate > 0 ? `$${rate.rate.toFixed(2)}` : 'FREE'}
</Typography>
</Box>
}
sx={{ width: '100%', m: 0 }}
/>
</Paper>
))}
</RadioGroup>
</FormControl>
) : (
<Alert severity="warning">
No shipping options available for this address. Please check your shipping address or contact support.
</Alert>
)}
</Box>
);
case 2:
return (
<Box sx={{ mt: 3 }}>
{/* Order summary */}
@ -332,14 +476,24 @@ const CheckoutPage = () => {
))}
<ListItem sx={{ py: 1, px: 0 }}>
<ListItemText primary="Shipping" />
<Typography variant="body2">Free</Typography>
<ListItemText primary="Subtotal" />
<Typography variant="body2">${total.toFixed(2)}</Typography>
</ListItem>
<ListItem sx={{ py: 1, px: 0 }}>
<ListItemText
primary="Shipping"
secondary={selectedShippingMethod ? `${selectedShippingMethod.carrier} - ${selectedShippingMethod.service}` : 'Standard Shipping'}
/>
<Typography variant="body2">
{shippingCost > 0 ? `$${shippingCost.toFixed(2)}` : 'Free'}
</Typography>
</ListItem>
<ListItem sx={{ py: 1, px: 0 }}>
<ListItemText primary="Total" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
${total.toFixed(2)}
${(total + shippingCost).toFixed(2)}
</Typography>
</ListItem>
</List>
@ -368,7 +522,7 @@ const CheckoutPage = () => {
</Typography>
</Box>
);
case 2:
case 3:
return (
<Box sx={{ mt: 3 }}>
{isStripeLoading || isProcessing ? (
@ -391,7 +545,7 @@ const CheckoutPage = () => {
)}
</Box>
);
case 3:
case 4:
return (
<Box sx={{ mt: 3, textAlign: 'center' }}>
<Alert severity="success" sx={{ mb: 3 }}>
@ -440,31 +594,39 @@ const CheckoutPage = () => {
</Stepper>
<Paper variant="outlined" sx={{ p: 3 }}>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{getStepContent(activeStep)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 3 }}>
{activeStep !== 0 && activeStep !== 3 && !isProcessing && (
{activeStep !== 0 && activeStep !== 3 && activeStep !== 4 && !isProcessing && !isLoadingShipping && (
<Button
onClick={handleBack}
sx={{ mr: 1 }}
disabled={checkout.isLoading || isProcessing}
disabled={checkout.isLoading || isProcessing || isLoadingShipping}
>
Back
</Button>
)}
{activeStep !== 2 && activeStep !== 3 ? (
{activeStep !== 3 && activeStep !== 4 && (
<Button
variant="contained"
onClick={handleNext}
disabled={checkout.isLoading || isProcessing}
disabled={checkout.isLoading || isProcessing || isLoadingShipping}
>
{activeStep === steps.length - 2 ? 'Place Order' : 'Next'}
{(checkout.isLoading || isProcessing) && (
{activeStep === steps.length - 3 ? 'Place Order' : 'Next'}
{(checkout.isLoading || isProcessing || isLoadingShipping) && (
<CircularProgress size={24} sx={{ ml: 1 }} />
)}
</Button>
) : activeStep === 3 ? (
)}
{activeStep === 4 && (
<Button
variant="contained"
component={RouterLink}
@ -472,7 +634,7 @@ const CheckoutPage = () => {
>
Return to Home
</Button>
) : null}
)}
</Box>
</Paper>
</Box>