From 3bcd738cf8074b0d43e4e634ab26c440f4eb917c Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sat, 21 Feb 2026 18:59:22 +0000 Subject: [PATCH] SUPER STAR STORE --- backend/src/db/api/product_categories.js | 6 + backend/src/db/api/products.js | 7 + backend/src/db/api/promotions.js | 6 + .../1771699492941-public-permissions.js | 52 +++ .../20260221000000-minimarket-sample.js | 114 +++++++ backend/src/index.js | 24 +- frontend/public/locales/ms/common.json | 52 +++ frontend/public/locales/ta/common.json | 52 +++ frontend/public/locales/zh/common.json | 52 +++ frontend/src/components/LanguageSwitcher.tsx | 36 +- .../components/MiniMarket/ContactSection.tsx | 105 ++++++ frontend/src/components/MiniMarket/Hero.tsx | 76 +++++ .../MiniMarket/ProductCatalogue.tsx | 156 +++++++++ .../MiniMarket/PromotionsBanner.tsx | 69 ++++ frontend/src/config.ts | 4 +- frontend/src/menuNavBar.ts | 7 +- frontend/src/pages/index.tsx | 317 +++++++++--------- 17 files changed, 953 insertions(+), 182 deletions(-) create mode 100644 backend/src/db/migrations/1771699492941-public-permissions.js create mode 100644 backend/src/db/seeders/20260221000000-minimarket-sample.js create mode 100644 frontend/public/locales/ms/common.json create mode 100644 frontend/public/locales/ta/common.json create mode 100644 frontend/public/locales/zh/common.json create mode 100644 frontend/src/components/MiniMarket/ContactSection.tsx create mode 100644 frontend/src/components/MiniMarket/Hero.tsx create mode 100644 frontend/src/components/MiniMarket/ProductCatalogue.tsx create mode 100644 frontend/src/components/MiniMarket/PromotionsBanner.tsx diff --git a/backend/src/db/api/product_categories.js b/backend/src/db/api/product_categories.js index 28c017f..3b4dee0 100644 --- a/backend/src/db/api/product_categories.js +++ b/backend/src/db/api/product_categories.js @@ -321,6 +321,12 @@ module.exports = class Product_categoriesDBApi { { model: db.file, as: 'images', + }, + { + model: db.category_translations, + as: 'category_translations_category', + include: [{ model: db.languages, as: 'language' }] + }, ]; diff --git a/backend/src/db/api/products.js b/backend/src/db/api/products.js index 6d17d2b..5a2abd3 100644 --- a/backend/src/db/api/products.js +++ b/backend/src/db/api/products.js @@ -389,6 +389,7 @@ module.exports = class ProductsDBApi { { model: db.product_categories, as: 'category', + include: [{ model: db.category_translations, as: 'category_translations_category', include: [{ model: db.languages, as: 'language' }] }], where: filter.category ? { [Op.or]: [ @@ -408,6 +409,12 @@ module.exports = class ProductsDBApi { { model: db.file, as: 'images', + }, + { + model: db.product_translations, + as: 'product_translations_product', + include: [{ model: db.languages, as: 'language' }] + }, ]; diff --git a/backend/src/db/api/promotions.js b/backend/src/db/api/promotions.js index 2d57f59..4f483e7 100644 --- a/backend/src/db/api/promotions.js +++ b/backend/src/db/api/promotions.js @@ -360,6 +360,12 @@ module.exports = class PromotionsDBApi { { model: db.file, as: 'banner_images', + }, + { + model: db.promotion_translations, + as: 'promotion_translations_promotion', + include: [{ model: db.languages, as: 'language' }] + }, ]; diff --git a/backend/src/db/migrations/1771699492941-public-permissions.js b/backend/src/db/migrations/1771699492941-public-permissions.js new file mode 100644 index 0000000..a73938f --- /dev/null +++ b/backend/src/db/migrations/1771699492941-public-permissions.js @@ -0,0 +1,52 @@ +module.exports = { + async up(queryInterface) { + const [roles] = await queryInterface.sequelize.query( + `SELECT id FROM "roles" WHERE name = 'Public' LIMIT 1;` + ); + + if (roles.length === 0) { + console.warn("Public role not found. Skipping permissions grant."); + return; + } + + const publicRoleId = roles[0].id; + + const entities = [ + "products", + "product_categories", + "promotions", + "hero_sections", + "product_translations", + "category_translations", + "promotion_translations", + "hero_translations", + "languages", + "site_assets", + "file" + ]; + + const [permissions] = await queryInterface.sequelize.query( + `SELECT id, name FROM "permissions" WHERE name IN (${entities + .map((e) => `'READ_${e.toUpperCase()}'`) + .join(",")});` + ); + + const createdAt = new Date(); + const updatedAt = new Date(); + + const rolesPermissions = permissions.map((p) => ({ + roles_permissionsId: publicRoleId, + permissionId: p.id, + createdAt, + updatedAt, + })); + + if (rolesPermissions.length > 0) { + await queryInterface.bulkInsert("rolesPermissionsPermissions", rolesPermissions); + } + }, + + async down() { + // Optional: remove the permissions + }, +}; \ No newline at end of file diff --git a/backend/src/db/seeders/20260221000000-minimarket-sample.js b/backend/src/db/seeders/20260221000000-minimarket-sample.js new file mode 100644 index 0000000..338ba0d --- /dev/null +++ b/backend/src/db/seeders/20260221000000-minimarket-sample.js @@ -0,0 +1,114 @@ +const { v4: uuid } = require("uuid"); + +module.exports = { + async up(queryInterface) { + const createdAt = new Date(); + const updatedAt = new Date(); + + const catDrinkId = uuid(); + const catSnackId = uuid(); + const catGroceryId = uuid(); + + const langEnId = uuid(); + const langMsId = uuid(); + const langZhId = uuid(); + const langTaId = uuid(); + + // Ensure languages exist or fetch them + // For simplicity, I'll just insert everything needed + + await queryInterface.bulkInsert("languages", [ + { id: langEnId, name: "English", code: "en", createdAt, updatedAt }, + { id: langMsId, name: "Bahasa Melayu", code: "ms", createdAt, updatedAt }, + { id: langZhId, name: "Mandarin", code: "zh", createdAt, updatedAt }, + { id: langTaId, name: "Tamil", code: "ta", createdAt, updatedAt }, + ]); + + await queryInterface.bulkInsert("product_categories", [ + { id: catDrinkId, code: "Drinks", createdAt, updatedAt }, + { id: catSnackId, code: "Snacks", createdAt, updatedAt }, + { id: catGroceryId, code: "Groceries", createdAt, updatedAt }, + ]); + + await queryInterface.bulkInsert("category_translations", [ + { id: uuid(), name: "Minuman", languageId: langMsId, categoryId: catDrinkId, createdAt, updatedAt }, + { id: uuid(), name: "饮料", languageId: langZhId, categoryId: catDrinkId, createdAt, updatedAt }, + { id: uuid(), name: "பானங்கள்", languageId: langTaId, categoryId: catDrinkId, createdAt, updatedAt }, + + { id: uuid(), name: "Snek", languageId: langMsId, categoryId: catSnackId, createdAt, updatedAt }, + { id: uuid(), name: "零食", languageId: langZhId, categoryId: catSnackId, createdAt, updatedAt }, + { id: uuid(), name: "சிற்றுண்டி", languageId: langTaId, categoryId: catSnackId, createdAt, updatedAt }, + + { id: uuid(), name: "Runcit", languageId: langMsId, categoryId: catGroceryId, createdAt, updatedAt }, + { id: uuid(), name: "杂货", languageId: langZhId, categoryId: catGroceryId, createdAt, updatedAt }, + { id: uuid(), name: "மளிகை", languageId: langTaId, categoryId: catGroceryId, createdAt, updatedAt }, + ]); + + const prod1Id = uuid(); + const prod2Id = uuid(); + const prod3Id = uuid(); + + await queryInterface.bulkInsert("products", [ + { + id: prod1Id, + sku: "DRK-001", + price_rm: 2.50, + compare_at_price_rm: 3.00, + stock_quantity: 100, + status: "active", + categoryId: catDrinkId, + createdAt, updatedAt + }, + { + id: prod2Id, + sku: "SNK-001", + price_rm: 1.80, + stock_quantity: 50, + status: "active", + categoryId: catSnackId, + createdAt, updatedAt + }, + { + id: prod3Id, + sku: "GRC-001", + price_rm: 4.50, + stock_quantity: 200, + status: "active", + categoryId: catGroceryId, + createdAt, updatedAt + }, + ]); + + await queryInterface.bulkInsert("product_translations", [ + { id: uuid(), name: "Milo Can 240ml", languageId: langEnId, productId: prod1Id, createdAt, updatedAt }, + { id: uuid(), name: "Tin Milo 240ml", languageId: langMsId, productId: prod1Id, createdAt, updatedAt }, + { id: uuid(), name: "美禄罐装 240ml", languageId: langZhId, productId: prod1Id, createdAt, updatedAt }, + { id: uuid(), name: "மைலோ டின் 240மி", languageId: langTaId, productId: prod1Id, createdAt, updatedAt }, + + { id: uuid(), name: "Mamee Monster Snack", languageId: langEnId, productId: prod2Id, createdAt, updatedAt }, + { id: uuid(), name: "Snek Mamee Monster", languageId: langMsId, productId: prod2Id, createdAt, updatedAt }, + { id: uuid(), name: "妈咪怪兽零食", languageId: langZhId, productId: prod2Id, createdAt, updatedAt }, + { id: uuid(), name: "மாமி மான்ஸ்டர் சிற்றுண்டி", languageId: langTaId, productId: prod2Id, createdAt, updatedAt }, + + { id: uuid(), name: "Maggi Curry Noodles 5-Pack", languageId: langEnId, productId: prod3Id, createdAt, updatedAt }, + { id: uuid(), name: "Maggi Kari 5-Pek", languageId: langMsId, productId: prod3Id, createdAt, updatedAt }, + { id: uuid(), name: "美极咖喱面 5包入", languageId: langZhId, productId: prod3Id, createdAt, updatedAt }, + { id: uuid(), name: "மேகி கறி நூடுல்ஸ் 5-பேக்", languageId: langTaId, productId: prod3Id, createdAt, updatedAt }, + ]); + + const promoId = uuid(); + await queryInterface.bulkInsert("promotions", [ + { id: promoId, title: "Ramadan Special Sale", status: "active", createdAt, updatedAt } + ]); + + await queryInterface.bulkInsert("promotion_translations", [ + { id: uuid(), title: "Jualan Khas Ramadan", languageId: langMsId, promotionId: promoId, createdAt, updatedAt }, + { id: uuid(), title: "斋戒月特别大减价", languageId: langZhId, promotionId: promoId, createdAt, updatedAt }, + { id: uuid(), title: "ரம்ஜான் சிறப்பு விற்பனை", languageId: langTaId, promotionId: promoId, createdAt, updatedAt }, + ]); + }, + + async down() { + // Optional + }, +}; \ No newline at end of file diff --git a/backend/src/index.js b/backend/src/index.js index d827b22..7ec90f0 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -121,35 +121,35 @@ app.use('/api/roles', passport.authenticate('jwt', {session: false}), rolesRoute app.use('/api/permissions', passport.authenticate('jwt', {session: false}), permissionsRoutes); -app.use('/api/shops', passport.authenticate('jwt', {session: false}), shopsRoutes); +app.use('/api/shops', shopsRoutes); -app.use('/api/languages', passport.authenticate('jwt', {session: false}), languagesRoutes); +app.use('/api/languages', languagesRoutes); app.use('/api/content_pages', passport.authenticate('jwt', {session: false}), content_pagesRoutes); app.use('/api/page_translations', passport.authenticate('jwt', {session: false}), page_translationsRoutes); -app.use('/api/hero_sections', passport.authenticate('jwt', {session: false}), hero_sectionsRoutes); +app.use('/api/hero_sections', hero_sectionsRoutes); -app.use('/api/hero_translations', passport.authenticate('jwt', {session: false}), hero_translationsRoutes); +app.use('/api/hero_translations', hero_translationsRoutes); -app.use('/api/product_categories', passport.authenticate('jwt', {session: false}), product_categoriesRoutes); +app.use('/api/product_categories', product_categoriesRoutes); -app.use('/api/category_translations', passport.authenticate('jwt', {session: false}), category_translationsRoutes); +app.use('/api/category_translations', category_translationsRoutes); -app.use('/api/products', passport.authenticate('jwt', {session: false}), productsRoutes); +app.use('/api/products', productsRoutes); -app.use('/api/product_translations', passport.authenticate('jwt', {session: false}), product_translationsRoutes); +app.use('/api/product_translations', product_translationsRoutes); -app.use('/api/promotions', passport.authenticate('jwt', {session: false}), promotionsRoutes); +app.use('/api/promotions', promotionsRoutes); -app.use('/api/promotion_translations', passport.authenticate('jwt', {session: false}), promotion_translationsRoutes); +app.use('/api/promotion_translations', promotion_translationsRoutes); -app.use('/api/promotion_items', passport.authenticate('jwt', {session: false}), promotion_itemsRoutes); +app.use('/api/promotion_items', promotion_itemsRoutes); app.use('/api/inquiry_messages', passport.authenticate('jwt', {session: false}), inquiry_messagesRoutes); -app.use('/api/site_assets', passport.authenticate('jwt', {session: false}), site_assetsRoutes); +app.use('/api/site_assets', site_assetsRoutes); app.use( '/api/openai', diff --git a/frontend/public/locales/ms/common.json b/frontend/public/locales/ms/common.json new file mode 100644 index 0000000..8d45685 --- /dev/null +++ b/frontend/public/locales/ms/common.json @@ -0,0 +1,52 @@ +{ + "pages": { + "dashboard": { + "pageTitle": "Dashboard", + "overview": "Overview", + "loadingWidgets": "Loading widgets...", + "loading": "Loading..." + }, + "login": { + "pageTitle": "Login", + + "form": { + "loginLabel": "Login", + "loginHelp": "Please enter your login", + "passwordLabel": "Password", + "passwordHelp": "Please enter your password", + "remember": "Remember", + "forgotPassword": "Forgot password?", + "loginButton": "Login", + "loading": "Loading...", + "noAccountYet": "Don’t have an account yet?", + "newAccount": "New Account" + }, + + "pexels": { + "photoCredit": "Photo by {{photographer}} on Pexels", + "videoCredit": "Video by {{name}} on Pexels", + "videoUnsupported": "Your browser does not support the video tag." + }, + + "footer": { + "copyright": "© {{year}} {{title}}. All rights reserved", + "privacy": "Privacy Policy" + } + } + }, + "components": { + "widgetCreator": { + "title": "Create Chart or Widget", + "helpText": "Describe your new widget or chart in natural language. For example: \"Number of admin users\" OR \"red chart with number of closed contracts grouped by month\"", + "settingsTitle": "Widget Creator Settings", + "settingsDescription": "What role are we showing and creating widgets for?", + "doneButton": "Done", + "loading": "Loading..." + }, + "search": { + "placeholder": "Search", + "required": "Required", + "minLength": "Minimum length: {{count}} characters" + } + } +} diff --git a/frontend/public/locales/ta/common.json b/frontend/public/locales/ta/common.json new file mode 100644 index 0000000..8d45685 --- /dev/null +++ b/frontend/public/locales/ta/common.json @@ -0,0 +1,52 @@ +{ + "pages": { + "dashboard": { + "pageTitle": "Dashboard", + "overview": "Overview", + "loadingWidgets": "Loading widgets...", + "loading": "Loading..." + }, + "login": { + "pageTitle": "Login", + + "form": { + "loginLabel": "Login", + "loginHelp": "Please enter your login", + "passwordLabel": "Password", + "passwordHelp": "Please enter your password", + "remember": "Remember", + "forgotPassword": "Forgot password?", + "loginButton": "Login", + "loading": "Loading...", + "noAccountYet": "Don’t have an account yet?", + "newAccount": "New Account" + }, + + "pexels": { + "photoCredit": "Photo by {{photographer}} on Pexels", + "videoCredit": "Video by {{name}} on Pexels", + "videoUnsupported": "Your browser does not support the video tag." + }, + + "footer": { + "copyright": "© {{year}} {{title}}. All rights reserved", + "privacy": "Privacy Policy" + } + } + }, + "components": { + "widgetCreator": { + "title": "Create Chart or Widget", + "helpText": "Describe your new widget or chart in natural language. For example: \"Number of admin users\" OR \"red chart with number of closed contracts grouped by month\"", + "settingsTitle": "Widget Creator Settings", + "settingsDescription": "What role are we showing and creating widgets for?", + "doneButton": "Done", + "loading": "Loading..." + }, + "search": { + "placeholder": "Search", + "required": "Required", + "minLength": "Minimum length: {{count}} characters" + } + } +} diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json new file mode 100644 index 0000000..8d45685 --- /dev/null +++ b/frontend/public/locales/zh/common.json @@ -0,0 +1,52 @@ +{ + "pages": { + "dashboard": { + "pageTitle": "Dashboard", + "overview": "Overview", + "loadingWidgets": "Loading widgets...", + "loading": "Loading..." + }, + "login": { + "pageTitle": "Login", + + "form": { + "loginLabel": "Login", + "loginHelp": "Please enter your login", + "passwordLabel": "Password", + "passwordHelp": "Please enter your password", + "remember": "Remember", + "forgotPassword": "Forgot password?", + "loginButton": "Login", + "loading": "Loading...", + "noAccountYet": "Don’t have an account yet?", + "newAccount": "New Account" + }, + + "pexels": { + "photoCredit": "Photo by {{photographer}} on Pexels", + "videoCredit": "Video by {{name}} on Pexels", + "videoUnsupported": "Your browser does not support the video tag." + }, + + "footer": { + "copyright": "© {{year}} {{title}}. All rights reserved", + "privacy": "Privacy Policy" + } + } + }, + "components": { + "widgetCreator": { + "title": "Create Chart or Widget", + "helpText": "Describe your new widget or chart in natural language. For example: \"Number of admin users\" OR \"red chart with number of closed contracts grouped by month\"", + "settingsTitle": "Widget Creator Settings", + "settingsDescription": "What role are we showing and creating widgets for?", + "doneButton": "Done", + "loading": "Loading..." + }, + "search": { + "placeholder": "Search", + "required": "Required", + "minLength": "Minimum length: {{count}} characters" + } + } +} diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx index f2f373a..41e33c2 100644 --- a/frontend/src/components/LanguageSwitcher.tsx +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -1,13 +1,14 @@ import React, { useEffect, useState } from 'react'; import Select, { components, SingleValueProps, OptionProps } from 'react-select'; +import { useTranslation } from 'react-i18next'; type LanguageOption = { label: string; value: string }; const LANGS: LanguageOption[] = [ - { value: 'en', label: '🇬🇧 EN' }, - { value: 'fr', label: '🇫🇷 FR' }, - { value: 'es', label: '🇪🇸 ES' }, - { value: 'de', label: '🇩🇪 DE' }, + { value: 'en', label: '🇬🇧 English' }, + { value: 'ms', label: '🇲🇾 Bahasa Melayu' }, + { value: 'zh', label: '🇨🇳 中文 (Mandarin)' }, + { value: 'ta', label: '🇮🇳 தமிழ் (Tamil)' }, ]; const Option = (props: OptionProps) => ( @@ -24,7 +25,8 @@ const SingleVal = (props: SingleValueProps) => ( const LanguageSwitcher: React.FC = () => { const [mounted, setMounted] = useState(false); - const [selected, setSelected] = useState(LANGS[0]); + const { i18n } = useTranslation(); + const [selected, setSelected] = useState(LANGS.find(l => l.value === i18n.language) || LANGS[0]); useEffect(() => { setMounted(true); @@ -33,18 +35,19 @@ const LanguageSwitcher: React.FC = () => { const handleChange = (opt: LanguageOption | null) => { if (!opt) return; setSelected(opt); + i18n.changeLanguage(opt.value); }; if (!mounted) return null; return ( -
+