From 141eba82e002725f7dea8e232756abd897d01afe Mon Sep 17 00:00:00 2001 From: CDevmina Date: Wed, 14 May 2025 13:19:30 +0530 Subject: [PATCH 01/51] feat: Update Docker CI workflow to enhance environment variable configuration and health checks --- .github/workflows/ci-dev.yml | 63 ++++++++++++++++++++++------------- .github/workflows/ci-prod.yml | 48 ++++++++++++++++++-------- 2 files changed, 75 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index ca1ee5c..ab6d65a 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -92,7 +92,7 @@ jobs: docker-tests: name: Docker Tests runs-on: ubuntu-latest - environment: development + environment: Production if: github.event_name == 'pull_request' steps: - uses: actions/checkout@v4 @@ -108,39 +108,56 @@ jobs: curl -SL https://github.com/docker/compose/releases/download/v2.23.3/docker-compose-linux-x86_64 -o $DOCKER_CONFIG/cli-plugins/docker-compose chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose - - name: Configure environment + - name: Create env file run: | - # Create .env file - cat << EOF > .env - MONGODB_URI=${{ secrets.MONGODB_URI }} - AUTH0_CLIENT_ID=${{ secrets.AUTH0_CLIENT_ID }} - AUTH0_ISSUER_BASE_URL=${{ secrets.AUTH0_ISSUER_BASE_URL }} - AUTH0_CLIENT_SECRET=${{ secrets.AUTH0_CLIENT_SECRET }} - EOF + echo "MONGODB_URI=${{ secrets.MONGODB_URI }}" >> .env + echo "DB_NAME=tapiro" >> .env + echo "AUTH0_SPA_CLIENT_ID=${{ secrets.AUTH0_SPA_CLIENT_ID }}" >> .env + echo "AUTH0_ISSUER_BASE_URL=${{ secrets.AUTH0_ISSUER_BASE_URL }}" >> .env + echo "AUTH0_TOKEN_URL=${{ secrets.AUTH0_TOKEN_URL }}" >> .env + echo "AUTH0_AUTHORIZE_URL=${{ secrets.AUTH0_AUTHORIZE_URL }}" >> .env + echo "AUTH0_AUDIENCE=${{ secrets.AUTH0_AUDIENCE }}" >> .env + echo "AUTH0_MANAGEMENT_API_TOKEN=${{ secrets.AUTH0_MANAGEMENT_API_TOKEN }}" >> .env + echo "AUTH0_USER_ROLE_ID=${{ secrets.AUTH0_USER_ROLE_ID }}" >> .env + echo "AUTH0_STORE_ROLE_ID=${{ secrets.AUTH0_STORE_ROLE_ID }}" >> .env + echo "AUTH0_M2M_CLIENT_ID=${{ secrets.AUTH0_M2M_CLIENT_ID }}" >> .env + echo "AUTH0_M2M_CLIENT_SECRET=${{ secrets.AUTH0_M2M_CLIENT_SECRET }}" >> .env + echo "AI_SERVICE_API_KEY=${{ secrets.AI_SERVICE_API_KEY }}" >> .env + echo "AUTH0_DOMAIN=${{ secrets.AUTH0_DOMAIN }}" >> .env + echo "ALLOWED_ORIGINS=http://localhost:5174" >> .env + # Variables for compose.yml that are not secrets but good to have in .env for consistency + echo "REDIS_HOST=redis" >> .env + echo "REDIS_PORT=6379" >> .env + echo "BASE_URL=http://localhost:3000" >> .env + echo "FRONTEND_URL=http://localhost:5173" >> .env + echo "AI_SERVICE_URL=http://ml-service:8000/api" >> .env + echo "EXTERNAL_API_URL=http://tapiro-api-external:3001" >> .env + echo "API_BASE_URL=http://tapiro-api-internal:3000" >> .env # For ml-service + echo "VITE_API_URL=http://localhost:3000" >> .env # For web + echo "VITE_STORE_API_URL=http://localhost:3001" >> .env # For demo-store - name: Build containers - env: - MONGODB_URI: ${{ secrets.MONGODB_URI }} - AUTH0_CLIENT_ID: ${{ secrets.AUTH0_CLIENT_ID }} - AUTH0_ISSUER_BASE_URL: ${{ secrets.AUTH0_ISSUER_BASE_URL }} - AUTH0_CLIENT_SECRET: ${{ secrets.AUTH0_CLIENT_SECRET }} - run: | - docker compose -f compose.yml build - docker compose -f compose.yml config + run: docker compose -f compose.yml build + # No explicit env block needed here if .env file is comprehensive - name: Test container health run: | docker compose -f compose.yml up -d echo "Waiting for containers to start..." - sleep 30 + sleep 30 # Adjust sleep time if services take longer to start - # Simple container status check - RUNNING_CONTAINERS=$(docker compose ps | grep -c "Up") - if [ "${RUNNING_CONTAINERS}" -eq 3 ]; then + # Check status of all services defined in compose.yml + SERVICES_COUNT=$(docker compose -f compose.yml config --services | wc -l) + RUNNING_CONTAINERS=$(docker compose -f compose.yml ps --services --filter "status=running" | wc -l) + + echo "Expected services: $SERVICES_COUNT" + echo "Running containers: $RUNNING_CONTAINERS" + + if [ "${RUNNING_CONTAINERS}" -eq "${SERVICES_COUNT}" ]; then echo "All containers are running" - docker ps + docker compose ps else - echo "Container startup failed" + echo "Not all containers started successfully." docker compose ps docker compose logs exit 1 diff --git a/.github/workflows/ci-prod.yml b/.github/workflows/ci-prod.yml index 7a13c3c..a256a54 100644 --- a/.github/workflows/ci-prod.yml +++ b/.github/workflows/ci-prod.yml @@ -104,31 +104,53 @@ jobs: - name: Create env file run: | echo "MONGODB_URI=${{ secrets.MONGODB_URI }}" >> .env - echo "AUTH0_CLIENT_ID=${{ secrets.AUTH0_CLIENT_ID }}" >> .env + echo "DB_NAME=tapiro" >> .env + echo "AUTH0_SPA_CLIENT_ID=${{ secrets.AUTH0_SPA_CLIENT_ID }}" >> .env echo "AUTH0_ISSUER_BASE_URL=${{ secrets.AUTH0_ISSUER_BASE_URL }}" >> .env - echo "AUTH0_CLIENT_SECRET=${{ secrets.AUTH0_CLIENT_SECRET }}" >> .env + echo "AUTH0_TOKEN_URL=${{ secrets.AUTH0_TOKEN_URL }}" >> .env + echo "AUTH0_AUTHORIZE_URL=${{ secrets.AUTH0_AUTHORIZE_URL }}" >> .env + echo "AUTH0_AUDIENCE=${{ secrets.AUTH0_AUDIENCE }}" >> .env + echo "AUTH0_MANAGEMENT_API_TOKEN=${{ secrets.AUTH0_MANAGEMENT_API_TOKEN }}" >> .env + echo "AUTH0_USER_ROLE_ID=${{ secrets.AUTH0_USER_ROLE_ID }}" >> .env + echo "AUTH0_STORE_ROLE_ID=${{ secrets.AUTH0_STORE_ROLE_ID }}" >> .env + echo "AUTH0_M2M_CLIENT_ID=${{ secrets.AUTH0_M2M_CLIENT_ID }}" >> .env + echo "AUTH0_M2M_CLIENT_SECRET=${{ secrets.AUTH0_M2M_CLIENT_SECRET }}" >> .env + echo "AI_SERVICE_API_KEY=${{ secrets.AI_SERVICE_API_KEY }}" >> .env + echo "AUTH0_DOMAIN=${{ secrets.AUTH0_DOMAIN }}" >> .env + echo "ALLOWED_ORIGINS=http://localhost:5174" >> .env + # Variables for compose.yml that are not secrets but good to have in .env for consistency + echo "REDIS_HOST=redis" >> .env + echo "REDIS_PORT=6379" >> .env + echo "BASE_URL=http://localhost:3000" >> .env + echo "FRONTEND_URL=http://localhost:5173" >> .env + echo "AI_SERVICE_URL=http://ml-service:8000/api" >> .env + echo "EXTERNAL_API_URL=http://tapiro-api-external:3001" >> .env + echo "API_BASE_URL=http://tapiro-api-internal:3000" >> .env # For ml-service + echo "VITE_API_URL=http://localhost:3000" >> .env # For web + echo "VITE_STORE_API_URL=http://localhost:3001" >> .env # For demo-store - name: Build containers run: docker compose -f compose.yml build - env: - MONGODB_URI: ${{ secrets.MONGODB_URI }} - AUTH0_CLIENT_ID: ${{ secrets.AUTH0_CLIENT_ID }} - AUTH0_ISSUER_BASE_URL: ${{ secrets.AUTH0_ISSUER_BASE_URL }} - AUTH0_CLIENT_SECRET: ${{ secrets.AUTH0_CLIENT_SECRET }} + # No explicit env block needed here if .env file is comprehensive - name: Test container health run: | docker compose -f compose.yml up -d echo "Waiting for containers to start..." - sleep 30 + sleep 30 # Adjust sleep time if services take longer to start + + # Check status of all services defined in compose.yml + SERVICES_COUNT=$(docker compose -f compose.yml config --services | wc -l) + RUNNING_CONTAINERS=$(docker compose -f compose.yml ps --services --filter "status=running" | wc -l) - # Simple container status check - RUNNING_CONTAINERS=$(docker compose ps | grep -c "Up") - if [ "${RUNNING_CONTAINERS}" -eq 3 ]; then + echo "Expected services: $SERVICES_COUNT" + echo "Running containers: $RUNNING_CONTAINERS" + + if [ "${RUNNING_CONTAINERS}" -eq "${SERVICES_COUNT}" ]; then echo "All containers are running" - docker ps + docker compose ps else - echo "Container startup failed" + echo "Not all containers started successfully." docker compose ps docker compose logs exit 1 From 7d81a7e868d6dd1271d8de42298d509deff010a2 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sun, 18 May 2025 13:42:45 +0530 Subject: [PATCH 02/51] refactor: Remove unused UserData and related schemas from API definition --- tapiro-api-external/api/openapi.yaml | 7 - tapiro-api-internal/api/openapi.yaml | 187 --------------------------- 2 files changed, 194 deletions(-) diff --git a/tapiro-api-external/api/openapi.yaml b/tapiro-api-external/api/openapi.yaml index c0384bb..705da14 100644 --- a/tapiro-api-external/api/openapi.yaml +++ b/tapiro-api-external/api/openapi.yaml @@ -320,13 +320,6 @@ components: schema: $ref: "#/components/schemas/Error" - ConflictError: - description: Conflict - resource already exists - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - InternalServerError: description: Internal server error content: diff --git a/tapiro-api-internal/api/openapi.yaml b/tapiro-api-internal/api/openapi.yaml index 3ceb8b7..bb9c8d3 100644 --- a/tapiro-api-internal/api/openapi.yaml +++ b/tapiro-api-internal/api/openapi.yaml @@ -1075,177 +1075,6 @@ components: type: string enum: [active, revoked] - UserData: - type: object - required: - - email - - dataType # Make dataType required - - entries - properties: - email: - type: string - format: email - description: User's email address (used as identifier for API key auth). Must match a registered Tapiro user. - dataType: - type: string - enum: [purchase, search] - description: Specifies the type of data contained in the 'entries' array. - entries: - type: array - description: > - List of data entries. Each entry must conform to either the PurchaseEntry - or SearchEntry schema, matching the top-level 'dataType'. - items: - oneOf: # Use oneOf to specify possible entry types - - $ref: "#/components/schemas/PurchaseEntry" - - $ref: "#/components/schemas/SearchEntry" - description: "An entry representing either a purchase event or a search event." - minItems: 1 # Require at least one entry - metadata: - type: object - description: Additional metadata about the collection event (e.g., source, device). - properties: - source: - type: string - description: Source of the data (e.g., 'web', 'mobile_app', 'pos'). - deviceType: - type: string - description: Type of device used (e.g., 'desktop', 'mobile', 'tablet'). - sessionId: - type: string - description: Identifier for the user's session. - example: - source: "web" - deviceType: "desktop" - sessionId: "abc-123-xyz-789" - example: # Example for a purchase submission - email: "user@example.com" - dataType: "purchase" - entries: - - $ref: "#/components/schemas/PurchaseEntry/example" - metadata: - source: "web" - deviceType: "desktop" - sessionId: "abc-123-xyz-789" - - PurchaseEntry: - type: object - required: - - timestamp - - items - properties: - timestamp: - type: string - format: date-time - description: ISO 8601 timestamp of when the purchase occurred. - items: - type: array - description: List of items included in the purchase. - items: - $ref: "#/components/schemas/PurchaseItem" - totalValue: - type: number - format: float - description: Optional total value of the purchase event. - example: - timestamp: "2024-05-15T14:30:00Z" - items: - - $ref: "#/components/schemas/PurchaseItem/example" # Reference the example above - - sku: "ABC-789" - name: "Running Shorts" - category: "201" # Clothing - price: 39.95 - quantity: 1 - attributes: - color: "black" - size: "M" - material: "polyester" - totalValue: 91.93 - - PurchaseItem: - type: object - required: - - name - - category # Making category required for better processing - properties: - sku: - type: string - description: Stock Keeping Unit or unique product identifier. - name: - type: string - description: Name of the purchased item. - category: - type: string - description: > - Category ID or name matching the Tapiro taxonomy (e.g., "101" or "Smartphones"). - Providing the most specific category ID is recommended. - price: - type: number - format: float - description: Price of a single unit of the item. - quantity: - type: integer - description: Number of units purchased. - default: 1 - attributes: - $ref: "#/components/schemas/ItemAttributes" - example: - sku: "XYZ-123" - name: "Men's Cotton T-Shirt" - category: "201" # Example: Clothing ID - price: 25.99 - quantity: 2 - attributes: - color: "navy" - size: "M" - material: "cotton" - - ItemAttributes: - type: object - description: > - Key-value pairs representing product attributes based on the taxonomy. - Keys should be attribute names (e.g., "color", "size", "brand") and - values should be the specific attribute value (e.g., "blue", "large", "Acme"). - additionalProperties: - type: string - example: - color: "blue" - size: "L" - material: "cotton" - - SearchEntry: - type: object - required: - - timestamp - - query - properties: - timestamp: - type: string - format: date-time - description: ISO 8601 timestamp of when the search occurred. - query: - type: string - description: The search query string entered by the user. - category: - type: string - description: > - Optional category context provided during the search (e.g., user was browsing 'Electronics'). - Should match a Tapiro taxonomy ID or name. - results: - type: integer - description: Optional number of results returned for the search query. - clicked: - type: array - description: Optional list of product IDs or SKUs clicked from the search results. - items: - type: string - example: - timestamp: "2024-05-15T10:15:00Z" - query: "noise cancelling headphones" - category: "105" # Example: Audio ID - results: 25 - clicked: ["Bose-QC45", "Sony-WH1000XM5"] - UserPreferences: type: object properties: @@ -1655,21 +1484,6 @@ components: type: object description: Simplified details (e.g., item count for purchase, query string for search) - SpendingAnalytics: - type: object - description: > - Aggregated spending data per category over time. - The structure might vary based on implementation (e.g., object keyed by month/year, - or an array of objects each representing a time point). - additionalProperties: - type: object # Example: { "YYYY-MM": { "Category1": 100, "Category2": 50 } } - additionalProperties: - type: number - format: float - example: - "2025-01": { "Electronics": 1299.99, "Clothing": 150.5 } - "2025-02": { "Clothing": 100, "Home": 85 } - StoreBasicInfo: type: object properties: @@ -1688,7 +1502,6 @@ components: properties: month: type: string - format: date description: The month of the spending data (e.g., "2024-01"). spending: type: object From 38d94c12ff2a5476375dfc2dd9dd03482dc1921e Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sun, 18 May 2025 13:58:35 +0530 Subject: [PATCH 03/51] feat: Update searchStores and lookupStores functions for improved query handling and response formatting --- tapiro-api-internal/controllers/StoreProfile.js | 16 ++++++++++++++-- tapiro-api-internal/utils/mongoUtil.js | 1 + 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/tapiro-api-internal/controllers/StoreProfile.js b/tapiro-api-internal/controllers/StoreProfile.js index a45b727..290d8ab 100644 --- a/tapiro-api-internal/controllers/StoreProfile.js +++ b/tapiro-api-internal/controllers/StoreProfile.js @@ -31,8 +31,20 @@ module.exports.deleteStoreProfile = function deleteStoreProfile(req, res, next) }); }; -module.exports.searchStores = function searchStores(req, res, next, ids) { - StoreProfile.searchStores(req, ids) +module.exports.searchStores = function searchStores(req, res, next) { + const { query, limit } = req.query; + StoreProfile.searchStores(req, query, limit) + .then((response) => { + utils.writeJson(res, response); + }) + .catch((response) => { + utils.writeJson(res, response); + }); +}; + +module.exports.lookupStores = function lookupStores(req, res, next) { + const { ids } = req.query; + StoreProfile.lookupStores(req, ids) .then((response) => { utils.writeJson(res, response); }) diff --git a/tapiro-api-internal/utils/mongoUtil.js b/tapiro-api-internal/utils/mongoUtil.js index 4754526..e69b240 100644 --- a/tapiro-api-internal/utils/mongoUtil.js +++ b/tapiro-api-internal/utils/mongoUtil.js @@ -119,6 +119,7 @@ async function setupIndexes(db) { await db.collection('stores').createIndex({ auth0Id: 1 }, { unique: true }); await db.collection('stores').createIndex({ email: 1 }); await db.collection('stores').createIndex({ "apiKeys.prefix": 1 }); + await db.collection('stores').createIndex({ name: "text" }); // API usage indexes await db.collection('apiUsage').createIndex({ apiKeyId: 1, timestamp: -1 }); From af2d6a3272233551f17b4ab017ac1f1f82580133 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sun, 18 May 2025 14:10:13 +0530 Subject: [PATCH 04/51] feat: Refactor UserDashboard layout for improved responsiveness and organization of Top Interests and About You sections --- web/src/pages/UserDashboard/UserDashboard.tsx | 230 +++++++++--------- 1 file changed, 119 insertions(+), 111 deletions(-) diff --git a/web/src/pages/UserDashboard/UserDashboard.tsx b/web/src/pages/UserDashboard/UserDashboard.tsx index fe83073..b5d0163 100644 --- a/web/src/pages/UserDashboard/UserDashboard.tsx +++ b/web/src/pages/UserDashboard/UserDashboard.tsx @@ -622,78 +622,8 @@ export default function UserDashboard() { - {/* --- Top Interests Card --- */} - -
-

- Top Interests -

- {preferencesError || taxonomyError ? ( - - Could not load preference data. - - ) : preferencesLoading || taxonomyLoading ? ( -
- Loading preferences... -
- ) : !preferencesPieChartData || - preferencesPieChartData.length === 0 ? ( -

- No preference data available yet. Add interests to - see insights. -

- ) : ( -
- - - - {preferencesPieChartData.map( - (_entry, index) => ( - - ), - )} - - - `${Math.round(value * 100)}% Interest` - } - /> - - - -
- )} -
- -
- {/* --- Data Sharing Card --- */} - +

@@ -740,49 +670,127 @@ export default function UserDashboard() { - {/* Demographics Section */} - -
-

- About You -

- {profileError ? ( - - Could not load profile information. - - ) : profileLoading ? ( -
- Loading profile... + {/* --- Top Interests Card --- */} + +
+ {/* New flex container for horizontal layout on md screens and up, vertical on sm */} +
+ {/* Section 1: Top Interests */} +
+

+ Top Interests +

+ {preferencesError || taxonomyError ? ( + + Could not load preference data. + + ) : preferencesLoading || taxonomyLoading ? ( +
+ Loading preferences... +
+ ) : !preferencesPieChartData || + preferencesPieChartData.length === 0 ? ( +

+ No preference data available yet. Add interests + to see insights. +

+ ) : ( +
+ + + + {preferencesPieChartData.map( + (_entry, index) => ( + + ), + )} + + + `${Math.round(value * 100)}% Interest` + } + /> + + + +
+ )}
- ) : ( -
- - - - + + {/* Section 2: About You */} + {/* Removed original mt-6, border-t, pt-6 wrapper. Added mt-6 for small screens, md:mt-0 for larger */} +
+

+ About You +

+ {profileError ? ( + + Could not load profile information. + + ) : profileLoading ? ( +
+ Loading profile... +
+ ) : ( +
+ + + + +
+ )}
- )} +
{" "} + {/* End of new flex container */}
+
)} From eedc4b604b0e7b92db56904f9e4bb64a2f57c75e Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sun, 18 May 2025 14:15:20 +0530 Subject: [PATCH 05/51] feat: Enhance UserDashboard layout with improved icon organization and spacing adjustments --- web/src/pages/UserDashboard/UserDashboard.tsx | 45 +++++++++++-------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/web/src/pages/UserDashboard/UserDashboard.tsx b/web/src/pages/UserDashboard/UserDashboard.tsx index b5d0163..fdb0cec 100644 --- a/web/src/pages/UserDashboard/UserDashboard.tsx +++ b/web/src/pages/UserDashboard/UserDashboard.tsx @@ -17,20 +17,21 @@ import { TabItem, } from "flowbite-react"; import { - HiArrowRight, - HiClock, - HiInformationCircle, HiOutlineNewspaper, HiOutlineCurrencyDollar, HiOutlineShare, + HiArrowRight, + HiInformationCircle, + HiOutlineSparkles, + HiOutlineChartPie, + HiOutlineOfficeBuilding, // Added store icon HiCalendar, - HiOutlineGlobeAlt, + HiClock, + HiOutlineViewGrid, + HiOutlineUserCircle, HiOutlineCake, + HiOutlineGlobeAlt, HiOutlineCash, - HiOutlineUserCircle, - HiOutlineViewGrid, - HiOutlineSparkles, - HiOutlineChartPie, } from "react-icons/hi"; import { ResponsiveContainer, @@ -429,7 +430,7 @@ export default function UserDashboard() { title="Overview" icon={HiOutlineViewGrid} > - {activeTab === 0 && ( // Conditionally render Overview tab content + {activeTab === 0 && ( <> {profileLoading || activityLoading || @@ -642,17 +643,25 @@ export default function UserDashboard() { You are not currently sharing data with any stores.

) : ( - + + {" "} + {/* Increased spacing */} {(consentLists?.optInStores ?? []).map( (storeId) => ( - - Sharing with{" "} - - {storeNameMap.get(storeId) || storeId} - + + {" "} + {/* Ensure ListItem takes full width */} +
+ +
+

+ Sharing data with +

+

+ {storeNameMap.get(storeId) || storeId} +

+
+
), )} From 43afec65156ab768df160b4618760e9bbb91a3ea Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sun, 18 May 2025 14:55:35 +0530 Subject: [PATCH 06/51] Refactor ApiDocsPage: Enhance layout, improve accessibility, and update styling for dark mode. Add section titles, sidebar navigation, and code examples with better structure and readability. --- web/src/pages/static/AboutPage.tsx | 304 ++++++- web/src/pages/static/ApiDocsPage.tsx | 1132 +++++++++++++++++++++++--- 2 files changed, 1305 insertions(+), 131 deletions(-) diff --git a/web/src/pages/static/AboutPage.tsx b/web/src/pages/static/AboutPage.tsx index d541ba3..f0c2951 100644 --- a/web/src/pages/static/AboutPage.tsx +++ b/web/src/pages/static/AboutPage.tsx @@ -1,41 +1,275 @@ -import { Card } from "flowbite-react"; +import { Card, Button } from "flowbite-react"; +import { + HiOutlineShieldCheck, + HiOutlineUsers, + HiOutlineLightBulb, + HiOutlineMail, + HiOutlinePhone, + HiOutlineLocationMarker, + HiOutlineChatAlt2, + HiOutlineQuestionMarkCircle, + HiOutlineArrowRight, +} from "react-icons/hi"; // Using Hi for consistency if Hi2 not used elsewhere or if these are preferred +import { Link } from "react-router"; // Assuming react-router is used export default function AboutPage() { return ( -
- {/* Add dark mode text color */} -

- About Tapiro -

- - {/* Add dark mode text color */} -

- Tapiro is a platform designed to empower users by giving them control - over their data shared with online stores, while enabling stores to - deliver truly personalized experiences. -

- {/* Add dark mode text color */} -

- For Users -

- {/* Add dark mode text color */} -

- Manage your preference profile, see which stores have access to your - anonymized data, and opt-in or out at any time. Understand how your - data contributes to personalized recommendations and offers. -

- {/* Add dark mode text color */} -

- For Stores -

- {/* Add dark mode text color */} -

- Integrate our API to send anonymized user interaction data (like - views, purchases, searches) and receive valuable preference insights - to personalize your customer's journey, recommend relevant products, - and optimize your offerings. -

-
+
+ {/* Hero Section */} +
+
+

+ About Tapiro +

+

+ Redefining the relationship between users, data, and + personalization. +

+
+
+ +
+ {/* The Challenge Section */} +
+ +
+ +

+ The Challenge: Data Fragmentation & User Disempowerment +

+
+

+ In today's digital world, personalized experiences are key. + However, this has often led to aggressive and fragmented data + collection across countless platforms. Users are left with little + transparency or control over their personal information, and + businesses struggle with siloed data that hinders truly effective + personalization. +

+
+
+ + {/* Our Mission Section */} +
+ +
+ +

+ Our Mission: Centralized Control, Enhanced Personalization +

+
+

+ Tapiro is engineered to address these challenges head-on. We are + building a secure, centralized platform that acts as a trusted + intermediary, managing the flow of data between individuals and + the services that require this data for personalization. +

+

+ Our core aim is to return control of data to the user while + simultaneously enabling businesses to achieve better, more + accurate, and ethically-sourced personalization results. +

+
+
+ + {/* What This Means for You Section */} +
+
+ +

+ What This Means for You +

+
+
+ +

+ For Users: +

+
    +
  • + Empowerment: Manage + your data sharing preferences with ease. View, update, or + revoke consent at any time. +
  • +
  • + Transparency:{" "} + Understand how your data contributes to the experiences you + receive. +
  • +
  • + Better Experiences:{" "} + Enjoy more relevant recommendations and offers from services + that respect your choices. +
  • +
+
+ +

+ For Stores & Services: +

+
    +
  • + Quality Data: Access + richer, consented user data for superior personalization. +
  • +
  • + Ethical Approach: Build + trust by partnering with a platform that prioritizes user + privacy. +
  • +
  • + Simplified Integration:{" "} + Leverage our robust APIs and AI-driven insights to enhance + your offerings. +
  • +
+
+
+
+ + {/* Our Vision Section */} +
+ +

+ Our Vision +

+

+ Tapiro envisions a future where data management is transparent, + ethical, and user-centric. We believe that by fostering a + respectful data ecosystem, both individuals and businesses can + thrive. We are committed to developing solutions that not only + address current data privacy concerns but also anticipate the + needs of a rapidly evolving digital landscape. +

+
+
+ + {/* Contact Us Section */} +
+

+ Get in Touch +

+
+ {/* Email */} +
+ +

+ Email Us +

+

+ Have questions or feedback? +

+ + support@tapiro.com + +
+ + {/* Phone (Placeholder) */} +
+ +

+ Call Us +

+

+ Mon - Fri, 9am - 5pm (EST) +

+

+ +1 (555) 123-4567 +

+
+ + {/* Location (Placeholder) */} +
+ +

+ Our Office +

+

+ 123 Tapiro Lane +

+

+ Data City, DC 54321 +

+
+
+
+ + {/* Chat & Support Section */} +
+
+

+ Need Help? +

+
+
+ +
+ +
+

+ Live Chat +

+

+ Connect with our support team instantly for quick + assistance. (Coming Soon) +

+ +
+
+
+ +
+ +
+

+ Support & FAQ +

+

+ Find answers to common questions in our comprehensive FAQ or + join our community forum. +

+ + {" "} + {/* Assuming you might have an FAQ page */} + + +
+
+
+
+
+ + {/* Call to Action Section */} +
+

+ Ready to Experience the Future of Data? +

+

+ Join Tapiro today and take control of your digital identity or + empower your business with ethical data. +

+
+ + + + + + +
+
+
); } diff --git a/web/src/pages/static/ApiDocsPage.tsx b/web/src/pages/static/ApiDocsPage.tsx index 20b08a0..4d5b1fa 100644 --- a/web/src/pages/static/ApiDocsPage.tsx +++ b/web/src/pages/static/ApiDocsPage.tsx @@ -1,104 +1,1044 @@ -import { Card } from "flowbite-react"; +import React, { useState, useEffect, useRef } from "react"; +import { + Card, + Table, + TableBody, + TableCell, + TableHead, + TableHeadCell, + TableRow, +} from "flowbite-react"; +import { + HiOutlineClipboardList, + HiOutlineCode, + HiOutlineKey, + HiOutlineCloudUpload, + HiOutlineCloudDownload, + HiOutlineBookOpen, + HiOutlineExclamationCircle, + HiOutlineLink, + HiOutlineHeart, +} from "react-icons/hi"; + +// Helper for section titles +const SectionTitle = ({ + title, + icon: Icon, +}: { + title: string; + icon?: React.ElementType; +}) => ( +
+ {" "} + {/* Increased mb and added pt for spacing before title */} + {Icon && } +

+ {title} +

+
+); + +// Define sidebarLinks outside the component as it's static +const sidebarLinks = [ + { title: "Introduction", href: "#introduction" }, + { title: "Authentication", href: "#authentication" }, + { title: "Base URL", href: "#base-url" }, + { title: "Core Concepts", href: "#core-concepts" }, + { + title: "Endpoints", + href: "#endpoints", + sublinks: [ + { title: "Submit User Data", href: "#submit-user-data" }, + { + title: "Retrieve User Preferences", + href: "#retrieve-user-preferences", + }, + { title: "Health Check", href: "#health-check" }, + ], + }, + { title: "Taxonomy", href: "#taxonomy" }, + { + title: "Data Schemas", + href: "#data-schemas", + sublinks: [ + { title: "UserData", href: "#schema-userdata" }, + { title: "PurchaseEntry", href: "#schema-purchaseentry" }, + { title: "PurchaseItem", href: "#schema-purchaseitem" }, + { title: "SearchEntry", href: "#schema-searcheentry" }, + { title: "UserPreferences", href: "#schema-userpreferences" }, + { title: "PreferenceItem", href: "#schema-preferenceitem" }, + { title: "Error", href: "#schema-error" }, + ], + }, + { title: "Error Handling", href: "#error-handling" }, + { title: "Code Examples", href: "#code-examples" }, +]; + +interface LocalCodeBlockProps { + code: string; + language?: string; + className?: string; +} + +const LocalCodeBlock: React.FC = ({ + code, + language, + className, +}) => { + // Refactored to use Tailwind classes for both light and dark mode + return ( +
+      
+        {code.trim()}
+      
+    
+ ); +}; export default function ApiDocsPage() { + const [activeId, setActiveId] = useState(sidebarLinks[0]?.href || ""); + const observer = useRef(null); + const sectionRefs = useRef>(new Map()); + + useEffect(() => { + const currentSectionRefs = sectionRefs.current; // Capture for cleanup + + // Ensure all refs are populated + sidebarLinks.forEach((link) => { + if (link.href) { + const el = document.getElementById(link.href.substring(1)); + currentSectionRefs.set(link.href, el); + } + link.sublinks?.forEach((sublink) => { + if (sublink.href) { + const el = document.getElementById(sublink.href.substring(1)); + currentSectionRefs.set(sublink.href, el); + } + }); + }); + + const handleIntersect = (entries: IntersectionObserverEntry[]) => { + entries.forEach((entry) => { + if (entry.isIntersecting && entry.intersectionRatio >= 0.5) { + // Check if the element is sufficiently visible + setActiveId(`#${entry.target.id}`); + } + }); + }; + + observer.current = new IntersectionObserver(handleIntersect, { + rootMargin: "-20% 0px -50% 0px", // Adjust rootMargin to trigger when section is in middle/upper part of viewport + threshold: 0.5, // Trigger when 50% of the element is visible + }); + + const currentObserver = observer.current; + + currentSectionRefs.forEach((el) => { + if (el) currentObserver.observe(el); + }); + + return () => { + currentSectionRefs.forEach((el) => { + if (el) currentObserver.unobserve(el); + }); + }; + }, []); // sidebarLinks is now stable and defined outside, so it's not needed as a dependency + + // Function to determine if a link or its sublink is active + const isLinkActive = ( + linkHref: string, + sublinks?: Array<{ href: string }>, + ) => { + if (activeId === linkHref) return true; + return sublinks?.some((sublink) => activeId === sublink.href); + }; + return (
- {/* Add dark mode text color */} -

- API Documentation -

- - {/* Add dark mode text color */} -

- Welcome to the Tapiro API documentation. This guide provides details - on how stores can integrate with our platform to send user interaction - data and retrieve preference profiles for personalization. -

- {/* Add dark mode text color */} -

- Authentication -

- {/* Add dark mode text color */} -

- All API requests must be authenticated using an API key provided via - the Store Dashboard. Include your key in the `Authorization` header as - a Bearer token. -

- {/* Adjusted pre/code dark mode styles */} -
-          
-            Authorization: Bearer YOUR_API_KEY
-          
-        
- {/* Add dark mode text color */} -

- Endpoints -

- {/* Add dark mode text color */} -

- Below is an example of submitting user interaction data. More - endpoints (e.g., for retrieving preferences) will be documented soon. +

+

+ Tapiro API Documentation +

+

+ Integrate with Tapiro to power personalized experiences ethically and + effectively.

- {/* Add dark mode text color */} -
- POST /interactions -
- {/* Add dark mode text color */} -

- Sends user interaction data (e.g., purchases, views) to Tapiro for - analysis. -

- {/* Adjusted pre/code dark mode styles */} -
-          
-            {`POST /interactions
-Authorization: Bearer YOUR_API_KEY
-Content-Type: application/json
-X-API-Key: YOUR_STORE_API_KEY // Corrected Header
-
-{
-  "userId": "store-specific-user-id-123",
-  "sessionId": "session-abc-456",
-  "eventType": "purchase",
-  "timestamp": "2025-04-14T10:30:00Z",
-  "details": {
-    "items": [
-      { "productId": "prod-a", "name": "Running Shoes", "category": "Footwear", "price": 89.99, "quantity": 1 },
-      { "productId": "prod-b", "name": "Sports Socks", "category": "Apparel", "price": 9.99, "quantity": 2 }
-    ],
-    "totalValue": 109.97
-  },
-  "metadata": {
-    "source": "web",
-    "deviceType": "desktop"
-  }
-}`}
-          
-        
- {/* Add dark mode text color */} -
- POST /users/data -
- {/* Add dark mode text color */} -

- Sends user data (e.g., email, purchase data) to Tapiro for analysis. -

- {/* Adjusted pre/code dark mode styles */} -
-          
-            {`POST /users/data HTTP/1.1
-Host: api.tapiro.com
-Content-Type: application/json
-X-API-Key: YOUR_API_KEY  // Changed from Authorization: Bearer
-
-{
-  "email": "user@example.com",
-  "dataType": "purchase",
-  "entries": [ ... ]
-}`}
-          
-        
- +
+ +
+ {/* Sidebar */} + + + {/* Main Content Area */} +
+ {/* Card already has dark:bg-gray-800 */} + + {/* Introduction Section (Example) */} +
{ + sectionRefs.current.set("#introduction", el); + }} + className="mb-12 scroll-mt-20" + > + + {/* Paragraphs already use dark:text-gray-300 */} +

+ Welcome to the Tapiro Store Operations API. This API allows your + services (stores, e-commerce platforms) to submit user + interaction data (like purchases and searches) for analysis and + to retrieve processed user preferences. This enables you to + build personalized experiences while respecting user consent and + privacy. +

+

+ Our platform leverages AI and a structured taxonomy to infer + user interests, helping you overcome data fragmentation and + deliver more accurate recommendations. +

+
+ + {/* Authentication Section (Example with inline code) */} +
{ + sectionRefs.current.set("#authentication", el); + }} + className="mb-12 scroll-mt-20" + > + +

+ Include your API key in the{" "} + {/* Inline code styling for dark mode */} + + X-API-Key + {" "} + header for every request. +

+ +
+ + {/* Base URL */} +
{ + sectionRefs.current.set("#base-url", el); + }} + className="mb-12 scroll-mt-20" + > + +

+ The primary base URL for the Tapiro Store Operations API is: +

+ +

+ Please use the appropriate URLs provided for development or + staging environments if applicable. +

+
+ {/* Core Concepts */} +
{ + sectionRefs.current.set("#core-concepts", el); + }} + className="mb-12 scroll-mt-20" + > + +
    +
  • + + User Identification: + {" "} + Users are primarily identified by their email address when + submitting data or retrieving preferences. This email must + correspond to a user registered within the Tapiro ecosystem. +
  • +
  • + + Consent: + {" "} + Tapiro is built on user consent. Data submissions are + processed only if the user has granted{" "} + + dataSharingConsent + {" "} + in Tapiro and has not explicitly opted-out of sharing data + with your specific store. Preference retrieval will result in + a{" "} + + 403 Forbidden + {" "} + error if consent is not granted for your store. +
  • +
  • + + Taxonomy: + {" "} + A hierarchical system for categorizing products and interests. + Using correct category IDs or names from the Tapiro Taxonomy + is crucial for the AI models to generate accurate preferences. + (See{" "} + + Taxonomy Section + + ). +
  • +
  • + + Rate Limiting: + {" "} + (Information about rate limits, if any, would go here. E.g., + "API requests are rate-limited to X requests per minute per + API key.") +
  • +
+
+ {/* Endpoints */} +
{ + sectionRefs.current.set("#endpoints", el); + }} + className="mb-12 scroll-mt-20" + > + + +
{ + sectionRefs.current.set("#submit-user-data", el); + }} + className="mb-10 scroll-mt-20" + > + {" "} + {/* Increased mb */} +

+ {" "} + POST /users/data +

+

+ Submits user interaction data (purchases or searches) for + analysis and preference building. +

+

+ Request Body: +

+

+ A JSON object conforming to the{" "} + + UserData schema + + . +

+ +

+ Responses: +

+
    +
  • + + 202 Accepted + + : Data accepted for processing. +
  • +
  • + + 400 Bad Request + + : Invalid input or schema violation. +
  • +
  • + + 401 Unauthorized + + : Invalid or missing API key. +
  • +
+
+ +
{ + sectionRefs.current.set("#retrieve-user-preferences", el); + }} + className="mb-10 scroll-mt-20" + > +

+ {" "} + GET /users/{`{userId}`}/preferences +

+

+ Retrieves processed interest preferences for a specific user. +

+

+ Path Parameter: +

+
    +
  • + {`{userId}`}{" "} + (string, required): The email address of the user. +
  • +
+

+ Example URL: +

+ +

+ Responses: +

+
    +
  • + + 200 OK + + : Successfully retrieved preferences. Body contains{" "} + + UserPreferences + {" "} + object. +
  • +
  • + + 401 Unauthorized + + : Invalid or missing API key. +
  • +
  • + + 403 Forbidden + + : User has not consented or has opted out of sharing with + your store. +
  • +
  • + + 404 Not Found + + : User not found in Tapiro. +
  • +
+
+ +
{ + sectionRefs.current.set("#health-check", el); + }} + className="mb-8 scroll-mt-20" + > +

+ GET + /health & GET /ping +

+

+ + GET /health + + : Provides a comprehensive health check of the API and its + dependencies. +

+

+ + GET /ping + + : A simple uptime check endpoint. +

+

+ Responses (for /ping): +

+
    +
  • + + 200 OK + + : Body contains{" "} + {`{"status": "ok", "timestamp": "..."}`} + . +
  • +
+
+
+ + {/* Taxonomy Section */} +
{ + sectionRefs.current.set("#taxonomy", el); + }} + className="mb-12 scroll-mt-20" + > + +

+ The Tapiro Taxonomy is a hierarchical classification system for + products, services, and user interests. Accurate use of this + taxonomy is **critical** for the effectiveness of Tapiro's AI + models in generating meaningful user preferences. +

+

+ **Structure:** The taxonomy consists of categories, each with a + unique ID and a human-readable name. Categories can have + parent-child relationships to form a hierarchy. +

+

+ **Usage:** When submitting data (e.g.,{" "} + + PurchaseItem.category + {" "} + or{" "} + + SearchEntry.category + + ), you must use either the category ID or the exact category + name as defined in the Tapiro Taxonomy. +

+

+ **Obtaining the Taxonomy:** (Details on how stores can + access/view the taxonomy. E.g., "The full taxonomy can be + retrieved via the `/taxonomy/categories` endpoint in the Tapiro + Internal API if exposed, or it is provided during + onboarding/available in your Store Dashboard.") +

+

+ Example Snippet: +

+ +

+ It is recommended to use the most specific category ID possible + for accurate preference inference. +

+
+ + {/* Data Schemas */} +
{ + sectionRefs.current.set("#data-schemas", el); + }} + className="mb-12 scroll-mt-20" + > + +

+ The following tables describe the main data objects used in API + requests and responses. +

+ + {/* UserData Schema */} +
{ + sectionRefs.current.set("#schema-userdata", el); + }} + className="mb-10 scroll-mt-20" + > +
+ UserData Object +
+
+ + + Field + Type + Required + Description + + + + email + string (email format) + Yes + User's email address. + + + dataType + + string (enum: "purchase", "search") + + Yes + Type of data being submitted. + + + entries + + array of (PurchaseEntry or SearchEntry) + + Yes + Array of interaction entries. + + +
+
+
+ + {/* PurchaseEntry Schema */} +
{ + sectionRefs.current.set("#schema-purchaseentry", el); + }} + className="mb-10 scroll-mt-20" + > +
+ PurchaseEntry Object +
+
+ + + Field + Type + Required + Description + + + + timestamp + string (date-time) + Yes + ISO 8601 timestamp of purchase. + + + items + array of PurchaseItem + Yes + List of items in the purchase. + + + totalValue + number (float) + No + + Optional total value of the purchase. + + + +
+
+
+ + {/* PurchaseItem Schema */} +
{ + sectionRefs.current.set("#schema-purchaseitem", el); + }} + className="mb-10 scroll-mt-20" + > +
+ PurchaseItem Object +
+
+ + + Field + Type + Required + Description + + + + sku + string + No + Stock Keeping Unit or product ID. + + + name + string + Yes + Name of the item. + + + category + string + Yes + + Category ID or name from Taxonomy. + + + + price + number (float) + No + Price of a single unit. + + + quantity + integer + No (default: 1) + Number of units purchased. + + + attributes + object + No + + Key-value pairs of product attributes (e.g.,{" "} + {'{ "color": "blue", "size": "L" }'}) + + + +
+
+
+ + {/* SearchEntry Schema */} +
{ + sectionRefs.current.set("#schema-searcheentry", el); + }} + className="mb-10 scroll-mt-20" + > + {" "} + {/* Corrected ID */} +
+ SearchEntry Object +
+
+ + + Field + Type + Required + Description + + + + timestamp + string (date-time) + Yes + ISO 8601 timestamp of search. + + + query + string + Yes + The search query string. + + + category + string + No + + Optional category context for the search (from + Taxonomy). + + + + results + integer + No + + Optional number of results returned. + + + + clicked + array of string + No + + Optional list of product IDs/SKUs clicked from + results. + + + +
+
+
+ + {/* UserPreferences Schema */} +
{ + sectionRefs.current.set("#schema-userpreferences", el); + }} + className="mb-10 scroll-mt-20" + > +
+ UserPreferences Object (Response for GET /users/{`{userId}`} + /preferences) +
+
+ + + Field + Type + Description + + + + userId + string + Tapiro's internal User ID. + + + preferences + array of PreferenceItem + + List of user's interest preferences. + + + + updatedAt + string (date-time) + + Timestamp of when preferences were last updated. + + + +
+
+
+ + {/* PreferenceItem Schema */} +
{ + sectionRefs.current.set("#schema-preferenceitem", el); + }} + className="mb-10 scroll-mt-20" + > +
+ PreferenceItem Object +
+
+ + + Field + Type + Required + Description + + + + category + string + Yes + + Category ID or name from Taxonomy. + + + + score + number (float, 0.0-1.0) + Yes + Interest score for this category. + + + attributes + object + No + + Category-specific attribute preferences (e.g.,{" "} + {'{ "brand": "Nike", "color_preference_score": 0.8 }'} + ) + + + +
+
+
+ + {/* Error Schema */} +
{ + sectionRefs.current.set("#schema-error", el); + }} + className="mb-10 scroll-mt-20" + > +
+ Error Object (Standard Error Response) +
+
+ + + Field + Type + Required + Description + + + + code + integer + Yes + HTTP status code. + + + message + string + Yes + Description of the error. + + + details + object + No + + Additional error details (e.g., field validation + issues). + + + +
+
+
+
+ + {/* Error Handling */} +
{ + sectionRefs.current.set("#error-handling", el); + }} + className="mb-12 scroll-mt-20" + > + +

+ Tapiro API uses standard HTTP status codes to indicate the + success or failure of an API request. +

+
    +
  • + + 2xx + {" "} + codes indicate success. +
  • +
  • + + 4xx + {" "} + codes indicate client-side errors (e.g., bad input, + authentication failure, insufficient permissions). Check the + response body for an{" "} + + Error object + {" "} + with details. +
  • +
  • + + 5xx + {" "} + codes indicate server-side errors. Please retry after a short + delay or contact support if the issue persists. +
  • +
+
+ + {/* Code Examples */} +
{ + sectionRefs.current.set("#code-examples", el); + }} + className="scroll-mt-20" + > + {" "} + {/* Removed mb-12 for last section */} + +

+ Below are basic examples of how to interact with the API. +

+
+ Submitting Purchase Data (Conceptual JavaScript Example) +
+ +
+ Retrieving User Preferences (Conceptual JavaScript Example) +
+ +
+
+
+
); } From 5b95edd496eba825aef848c5ae7c21245a3fda05 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sun, 18 May 2025 15:11:55 +0530 Subject: [PATCH 07/51] feat: Revamp HomePage layout with enhanced sections for users and stores, improved toast notifications, and updated technology highlights --- web/public/icons/tech/auth0.svg | 7 + web/public/icons/tech/fastapi.svg | 1 + web/public/icons/tech/huggingface.svg | 1 + web/public/icons/tech/mongodb.svg | 5 + web/public/icons/tech/nodejs.svg | 59 ++++ web/public/icons/tech/react.svg | 206 +++++++++++++ web/public/icons/tech/redis.svg | 39 +++ web/public/icons/tech/swagger.svg | 12 + web/src/pages/static/HomePage.tsx | 413 +++++++++++++++++--------- 9 files changed, 610 insertions(+), 133 deletions(-) create mode 100644 web/public/icons/tech/auth0.svg create mode 100644 web/public/icons/tech/fastapi.svg create mode 100644 web/public/icons/tech/huggingface.svg create mode 100644 web/public/icons/tech/mongodb.svg create mode 100644 web/public/icons/tech/nodejs.svg create mode 100644 web/public/icons/tech/react.svg create mode 100644 web/public/icons/tech/redis.svg create mode 100644 web/public/icons/tech/swagger.svg diff --git a/web/public/icons/tech/auth0.svg b/web/public/icons/tech/auth0.svg new file mode 100644 index 0000000..50cefff --- /dev/null +++ b/web/public/icons/tech/auth0.svg @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/web/public/icons/tech/fastapi.svg b/web/public/icons/tech/fastapi.svg new file mode 100644 index 0000000..85f2d13 --- /dev/null +++ b/web/public/icons/tech/fastapi.svg @@ -0,0 +1 @@ + diff --git a/web/public/icons/tech/huggingface.svg b/web/public/icons/tech/huggingface.svg new file mode 100644 index 0000000..fc0c80d --- /dev/null +++ b/web/public/icons/tech/huggingface.svg @@ -0,0 +1 @@ +HuggingFace \ No newline at end of file diff --git a/web/public/icons/tech/mongodb.svg b/web/public/icons/tech/mongodb.svg new file mode 100644 index 0000000..65c4a12 --- /dev/null +++ b/web/public/icons/tech/mongodb.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/web/public/icons/tech/nodejs.svg b/web/public/icons/tech/nodejs.svg new file mode 100644 index 0000000..ca220b4 --- /dev/null +++ b/web/public/icons/tech/nodejs.svg @@ -0,0 +1,59 @@ + + + + + build-tools/nodejs + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/public/icons/tech/react.svg b/web/public/icons/tech/react.svg new file mode 100644 index 0000000..345db3c --- /dev/null +++ b/web/public/icons/tech/react.svg @@ -0,0 +1,206 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/public/icons/tech/redis.svg b/web/public/icons/tech/redis.svg new file mode 100644 index 0000000..fba4f31 --- /dev/null +++ b/web/public/icons/tech/redis.svg @@ -0,0 +1,39 @@ + + + + + databases-and-servers/databases/redis + Created with Sketch. + + + + + + + + + + \ No newline at end of file diff --git a/web/public/icons/tech/swagger.svg b/web/public/icons/tech/swagger.svg new file mode 100644 index 0000000..458529f --- /dev/null +++ b/web/public/icons/tech/swagger.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/pages/static/HomePage.tsx b/web/src/pages/static/HomePage.tsx index 9e58dc2..0f40bb9 100644 --- a/web/src/pages/static/HomePage.tsx +++ b/web/src/pages/static/HomePage.tsx @@ -1,168 +1,315 @@ -import { useState, useEffect } from "react"; // <-- Import hooks -import { Toast, ToastToggle } from "flowbite-react"; // <-- Import Toast components -import { HiCheck } from "react-icons/hi"; // <-- Import icon +import { useState, useEffect } from "react"; +import { Toast, ToastToggle, Button, Card } from "flowbite-react"; import { - DocsIcon, - BlocksIcon, - IconsIcon, - IllustrationsIcon, -} from "../../components/icons/ResourceIcons"; + HiCheck, + HiOutlineArrowRight, + HiOutlineUserGroup, + HiOutlineLockClosed, + HiOutlineSparkles, + HiOutlinePuzzlePiece, + HiOutlineChartBar, +} from "react-icons/hi2"; // Using Hi2 for potentially newer icons +import { Link } from "react-router"; // Assuming react-router is used + +// Placeholder icons for features - replace with actual or more suitable icons +const FeatureIconUserControl = HiOutlineUserGroup; +const FeatureIconPersonalization = HiOutlineSparkles; +const FeatureIconTransparency = HiOutlineLockClosed; +const FeatureIconStoreIntegration = HiOutlinePuzzlePiece; +const FeatureIconStoreInsights = HiOutlineChartBar; export default function HomePage() { const [showToast, setShowToast] = useState(false); const [toastMessage, setToastMessage] = useState(""); - // Check for post-logout toast message on mount useEffect(() => { const message = sessionStorage.getItem("showPostLogoutToast"); if (message) { setToastMessage(message); setShowToast(true); - sessionStorage.removeItem("showPostLogoutToast"); // Clear the flag - - // Optional: Auto-hide toast after a delay + sessionStorage.removeItem("showPostLogoutToast"); const timer = setTimeout(() => setShowToast(false), 5000); - return () => clearTimeout(timer); // Cleanup timer on unmount + return () => clearTimeout(timer); } - }, []); // Empty dependency array ensures this runs only once on mount - - const CARDS = [ - { - title: "Flowbite React Docs", - description: - "Learn more on how to get started and use the Flowbite React components", - url: "https://flowbite-react.com/", - icon: , - }, - { - title: "Flowbite Blocks", - description: - "Get started with over 450 blocks to build websites even faster", - url: "https://flowbite.com/blocks/", - icon: , - }, - { - title: "Flowbite Icons", - description: - "Get started with over 650+ SVG free and open-source icons for your apps", - url: "https://flowbite.com/icons/", - icon: , - }, - { - title: "Flowbite Illustrations", - description: - "Start using over 50+ SVG illustrations in 3D style to add character to your apps", - url: "https://flowbite.com/illustrations/", - icon: , - }, - { - title: "Flowbite Pro", - description: - "Upgrade your development stack with more components and templates from Flowbite", - url: "https://flowbite.com/pro/", - icon: Flowbite Pro logo, - }, - { - title: "Flowbite Figma", - description: - "Use our Figma Design System to design and collaborate better within your team", - url: "https://flowbite.com/figma/", - icon: Figma logo, - }, - ]; + }, []); return ( - // Add relative positioning if needed for absolute toast -
- {/* Success Toast */} +
+ {/* Toast Notification */} {showToast && ( - +
-
{toastMessage}
+
+ {toastMessage} +
setShowToast(false)} />
)} - {/* Background pattern - kept for visual style */} -
-
+ {/* Hero Section */} +
+
Pattern Light Pattern Dark
-
- -
- {" "} - {/* Centered content */} -
-

- Welcome to Tapiro {/* Updated Title */} +
+

+ Tapiro: Reclaiming Your Data, Refining Your Experience.

- - - Manage your preferences and data sharing easily.{" "} - {/* Updated Subtitle */} - - {/* You can add more introductory text here */} - -
- {/* Placeholder/Example Section - Kept from original App.tsx */} -
-

- Explore Resources -

- + + + {/* For Users Section */} +
+
+
+

+ Empowering{" "} + Users +

+

+ Take control of your digital footprint and enjoy experiences + tailored to you. +

+
+
+ + +

+ Full Data Control +

+

+ View, update, and manage your data sharing consents on a + per-service or global basis. You decide who sees what. +

+
+ + +

+ Accurate Personalization +

+

+ Benefit from more relevant recommendations and offers, thanks to + centralized and accurately managed preference data. +

+
+ + +

+ Enhanced Transparency +

+

+ Understand how your data is used with a clear view of your + interactions and preferences through our modern UI. +

+
-
+ + + {/* For Stores Section */} +
+
+
+

+ Powering{" "} + + Businesses + +

+

+ Access high-quality, consented data to drive engagement and growth + ethically. +

+
+
+ + +

+ Richer User Insights +

+

+ Overcome data fragmentation. Leverage sophisticated AI and + taxonomy systems for deeper understanding of user interests. +

+
+ + +

+ Seamless API Integration +

+

+ Easily connect your services with our well-documented + OpenAPI-based RESTful APIs (Node.js, FastAPI). +

+
+ + +

+ Ethical Data Practices +

+

+ Build trust by using a platform that prioritizes user consent + and data security (Auth0, 2FA, Passkeys). +

+
+
+
+
+ + {/* Technology Highlights Section (Optional) */} +
+
+
+

+ Built with Cutting-Edge Technology +

+

+ Leveraging robust and scalable solutions for optimal performance + and security. +

+
+
+
+ React{" "} + {/* Replace with actual icon paths */} +

+ React & Vite +

+
+
+ Node.js +

+ Node.js +

+
+
+ FastAPI +

+ FastAPI +

+
+
+ Auth0 +

+ Auth0 Security +

+
+
+ Hugging Face +

+ AI/ML Models +

+
+
+ OpenAPI +

+ OpenAPI Specs +

+
+
+ MongoDB +

+ MongoDB +

+
+
+ Redis +

+ Caching +

+
+
+
+
+ + {/* Call to Action Section */} +
+
+

+ Ready to Join the Future of Data? +

+

+ Experience a new era of data control and personalization with + Tapiro. +

+ + + +
+

); } From fdb985df4edb4475eee81d1defcc3aee27f9510f Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sun, 18 May 2025 15:28:11 +0530 Subject: [PATCH 08/51] feat: Update logo in Header and enhance HomePage title for improved branding and clarity --- web/public/icons/logo/tapiro.png | Bin 0 -> 44805 bytes web/src/layout/Header.tsx | 2 +- web/src/pages/static/HomePage.tsx | 6 +++--- 3 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 web/public/icons/logo/tapiro.png diff --git a/web/public/icons/logo/tapiro.png b/web/public/icons/logo/tapiro.png new file mode 100644 index 0000000000000000000000000000000000000000..c07f07210951631fcbbff13dbfe04ef7cfe5b515 GIT binary patch literal 44805 zcmeEu<8viW&~9vFV<#Kiwr$(mSSPk^J14e{jW^!d&WX)Ua)0lK_x=yJ>VD|zXL@?3 zYG!J>p6QyIXcZ-CWCVN!FfcG=Ss4kn|MW5#7`O-=%zubJk9Yh(1@5LMEe2LUO9=X} zAYrK^Yo(|NM)x0v0|Sq=1%vt@m;VI+KLG=SECL6E{7-}bkF5ye|JE)SLH;iW7x^E@ zzX8lhU|=F(vJ#>i-r$#gu$_j|J~x8ag(an>O!65Pvh7sdT{v4VLnaOi<02R=#XOX# zVM#DunMp~xNAeJ4WMuGN@MWTKM3{VIdolFaoignX>vS2K4o`q7zL)Qh0d{*Q+9RQt z>C}rlf|b0M)f~=;cAx3iw&x96&Z-5G(qJjbco=+S4tyJqd7GmDZ}@+GK2X4r9|^(O zILQAwaj^7TPowYl-_5Qv8}_?SUKj)RH6xk$?l%Pm1)!FP9e(H&a> z7nw$PC&t7Vya&T~t$TS*Z`QqLN2uTboW=@_K8|i``ax)cc?T##&0{RELFjyvDvgiZ z9&?7@_X~lq_x1z-`uN|U6SOq(32n+L)#Gi0Hd=su4*NrOUjH5~H+Nj$$^+S)zDxrv zlkIy`rBZL|VRDMj>%Z);;{;xId|z8u)jx(9wHMRVd`QpsKy{J4ZpAa~?`ld%3}TVZwddv19x5ZZ6<-piA%u zVeuFVp_@`d>&n&P4!P=b3gtYbyeJm1alF6B&VfF{xqIC=*R2b9rH9(aX=e6$mqjo& z=8C&H>DpErj6dHS3_n8y{{b5e_}xDsr(ennRzz}&I8zTp!4_d_yd~5k1T3krJC7Ek z7HX!zVd7&rwZFr@-0$$lUq1gS8*BBw{$n@h@OiJz%~`0Sx&8j?g0KGm;L}owLuE32 zA0+vF(C+`fp7Z}Wu{&13fK?l)@b+h{ONGmGfPqC-T{6wkLLpvNt$(M3)up!z<2}PF z5`=tjO?l>8oQPU6(%hw$CfRsj*EZnrd$T81`{1>?SLTRCx%ia0*ca}* zi<9$hcM^K3AlZ4C4E%ThlJNRG^HO%Tc|3;+ld>Yu8gA}L$;0ZTeA);%O&t=IUBFG( z>^-U%C&Q@}0Umb$LzGIQBH-{)+*V_0DmnSQC|g~>p>wbGu|nIp_p5eEx!e2x2rrRt zurpHhhsDYT^UI#N||Q*eJPQ=vB2)_S~@ z&#&-!{3lCR2#f&x_1gWfm$``s&aZP%NXT`p0dB9#3nJ}{fe3YlI^+mt!HjdYfs=|} zUuU6(pJN{5hHjhwwhtAKJ!;0^CirL8YstSj;>|7QHCR?;Z8K-ZJ&?c;EY|WeeIhus z&Nyss8aKKet?8xsk}VKq(4J_D#)x#{0RHISOuOaxqUdHoXOTBJkqOj94O<5e#atmO zL%$RFI(nn85JMcn@7BWnfcdbSmq)@@Ms_EqZ&75K55@OHJCmCPMe3b_$0quQ?swRm z*YhV~LcgQQHU#`Sdzkl^GA(Oo_Q#DROI@7@8+iIcfzvMt39#GLm?hLZ+xo}cW z(ffB*>m3pQ34{OrC1?!WWLMVx}%pD2oz)eFg5H4VabMlDSK=hM52 z-_FY~i>db9QP#%g=&J7F1dmuOwF`tiQ*<{)KRX4uEqGVN8`%e^% zb39NPQu5;4=u_F@?KOjuoml7TNV>l7xZt7R<8WJg`|kG8_M<(2Bu9z0FO|K7kC!SJ znsKHH4oNDnSv9HVP_GDz*uJoRtcIgnF{W?CcIt05NE4@^V8>;Yevx;Mzu)fZxm`-5 z-emTo$ffvcuuh5b?wo*7p~v&Gw%>Oozi!}di+jn#OE7QjnU3^)JQoh1)xAi*F=t?! z;|en{F(un!f{-XontL)XPH$B)Ypqa;-|Fs6o=L#}l?q4T<{dLZ=-}n2Vy=fZqe^NO z=RB8-;bUs9Zja~Hd`voh!{^S-OP=#g-HTHH%>wnydTXOn{FPy9F$yP{=WTY#Km0Zz@`<2$aRo(C8S2+ z^IcS#oM?HFhOM-P>Tap`wR%#b?ZvX_ev=+zwxZWn#k{L*A{t2 zR6yn*C2`ZVvOBSQcLUk8<21rkP87i)?=P2a8towm0=nFqzpdH z0H001lU)@OQ7Y9;sK)I|qJC9h-EG)g^{iFzs8GG@?60fT+ulr}s<)CCd z@cjHZS<|n+{a!k*XHH?pWhz#1zX0y-vixHJEpD%>wWm@M#S;Zxb@OROa>vB<)Ftl< z0L^JEi-&iYN|^dnkx=mDR$OY1*JpAM+L}-pFm)#_==@3sYs|{>R%9tmOQEX+bWHtb z8*V?=YQitY8(O!xHpXPW&}dG3l8j zr-Yd>!=b$0qb>j;QRID!Laps&n1|Ldf)0A3HRmf=h35kjxp@PY=$YjJ3N%EXXJqvR z3!XlBE+M_rgBdF8sifX7vMN}0TCdNYz>p|7|0$j{dt+Bdv#3QNHIGi==WTyEh~0QV z$p7=5e@R_gm|*ix0aaEI3U_!Zc^)sRC<`7_KySD!i`0B$vs51<-=2dnj}808bMOpz zh@a2$Uge^b;-q7i_in;{mUdj5n#k+7Hfp}_EeVoh;b+lV{FgQo$L8?Vd*CZ6ERme; z^k6$DzaD*o?h6j8Q1^Sfmtd#Exgly@WrpCL#>F}#e)QOK?)fIxM###>ggS3qr!=>T zuRCN0?vTra6~s`e^v~VZ$>8u0*Ld~y;#S*YUo|YZy+4&4d?{8lIa-FE*Hv}gedi_a zJK5Cb#gnP($FxS5)%;-b*aT=QuU@IYY1G9eVnlym(i!rDC8gab0ANigD&)YC9_`&E zOQ*X%2$o&~oW0(MJATP<@3b&nt<~$Ctv)8b&svzBL4&{2G`*2{h=e*qI}+Kq$r(a9CD)y!Nhx{!+zOpXNJj@I9@KUL+a_d@ENd7v6WtrUEP~ zby>EbzFix`fmf1_fdB@tWuB zjBbg)2=k(!Bo8KU*%J&k`u2~RWg2>4FaO)4r+9x#F>tpW;#EnP-1ifCA8B;P1nH{O zcqVG{LMv*k(2mA)P}Zx=|GL!^{uPLK#+Uy9)bs}juXL_B%ai!B`9J08FJ?Y=-6U(e zm;;K0EBt?P{VUG2RLMjS4wn-!OHVfVgEStcRD^mK(QI)i#$I?1vT^`iF3X@KaOqkp z=cXj+IN#nzOB3@s-#5iZCi0)pFGxlbyN={#tA7tfLqggw?G=GVdObcW?8dRc0^XZ4 zKoRqYhrS6JGv2jQf&q5OsHllo8*MdSOV32(!cUJ8#`-P4mhPqtb(UTxQfoel_1jFn zpCYVbxEyx0;*o12Ey_$g;`5o5ha^Ic*Z?|)^9lrlQwBhC>ecX4$OFea2t=^@%0v>i z(WNxpNU{VXBhQ^8`oH)g>$MA6#Qn2KXP8{KQLa*fhqZe@$tU zHZBMTN2HUxiD&qvNW`;_z9KOcd}2!*!&GuJ=8NaY8M%0$>K*xO_^*9CpBi@isQgbe zd>75>SJ7wvblEO2&$foVDG@lcc;1=#3s{1iheXND$6P!yJ@Pl5oCoI_DTn~08Th=` z3i)|I{uC7aV9d(RV8}5hYOxV*v)SM3hkmePF68u|YEUB1`KKY=!6h*GY>!JAPyw`# zxsLD>UY@zhpgBCu1?~onH!pi_^*G#LCok-CB4l_FaF+vHCjzXN+FM;7R_r$Gx6gIy zi9-mS=O5eMmo$xY2YF0~aH0L?phxK5{w6m78HZ`>>P>0r!=ZIoy}6W0_HHRk(}ft3 z0?PWw0zh5BidBR6f9@xf@{4J<>IUN|1*>vN7<5O9ob?ax@gIs^7+N0Za3oV<=B+ya z@wrkUYBEGsRBf}3FtnopI9V{p@!%!5Arg%0b|ElQpUY9UW2c1!-9%ud({7s`prn#N z85Z2@LI(JaRTkU7$@as@r2?Gt1r5JyUpW${fZImH8b1O9rLhZ%CnVia$|=Q$qN;&g zkvNZq#d_?oAnaTo8&BZuWvj`o=rY-csG#>)np`e2SU$1?_aaz*5RjCVVU8fsxzEp zLuv)lB1V>;Y~2!g*}eO%Cv+Z3BWGLei$LwzPjl45OeQVTzJDd`OAkr{KNSmEXQWR1 z)h=trrM=ri_vLSo3uF9K#b~YtRr6HwhB_1fEdIX#amr~|OqNr^&-6yu7H{Y`OPN4k z{JeQ(c;Y&=BtZg^c3?Bm(G5w0K3H9AL!ft9En;+U9f=**zXeO z?aYNXwTW<~LHx3+bX8jsKl0#6Addhw-ki3DvnlYVnR!LwPnt|{1i07EEy=qJa>W$d zI0^`Hlx@NU-uHKC-*n$wQyILdHEFC;dhbl{^nP5I*T8%l!R+*?ROr$qhBqOHuu+-u zFl4WVk3dEvr|r^hu65ClPW<)x(8kL{g-qwf^6_P+bo;E7Dr;I;Ihqs)m6f6{MY+p$ zzxFOIPL2~L|5VH`!8_N%w|tqUAxv*}xW{(%RZp5W9P*$HiKr^5Z?(c-En3xXdw&`% z0$j+c7c%O10H5X@s=0)E+MfQtTQ4eIkzQjn3;8M9g{(M+ZZzczy4mIU&Lj2Znn~qU zQO_hV?=CDRY*z9;v%c9Yup(OLi%TK6vJ}S0WvuNN!w)AzdEk1}y;_7%+ky{nz4B|kpo$8lv<3b@+={*wz-pJ}r(Q(WKSHq%@<CLG*A|s+X^(AL_2cgl2%2u+ zG@9^4*=Rp{t!P7F=UsL4zefluv&ZvZYmGef?-P@>tyK9pTrMOSDdKqY2v-p@b`iOR#tBS` zqa!Z>Q>n0KNf%(|ePtV!LAhhAgJHYl@~+kFsk_zXB4K5=j{zNP%i+d)m9@_`sg@CX zHLe)mc1Lt+u?f(<|9F__kRNd01S+G7ZTsNaPI4$`k6Cyj<S z{}Aj^7m8$s78&qcbp=*RrTD_qJ`Fx@RyUF9Hr|~82>~g8&}Kn%PN!(7k_S!s%bs0s zZ!6})bE@vT0GHbMxuWldIWUI*!QG3kD?L(#Xk$`PpC&z73ZYEm9>xNozoLr~YAWiD=GYy+CTj{ti z=+BNb^q@6eT6D9Wi3mkX|6LE@>3yZ6p@-4`s++%hlSFT4qgS_M0<3)e1vXo>Ch{RKl~aA?xfF z)!M?8`EK{Dpsl3@dFmSNLZIaJ;9gN2+seTkuVVgi6uH^r0fmEr01~dLa=vA1tWS*| z3rjj~KHV~wa>8n<$A}=RP|laq+_C@hhgTW$+|+)rewA*FCtCV_Wto8cp)AThZ~b4R zjk(W4wyV1hNy~keykg9RKYA*e(hPa2M&Bu?z>ePMl-8q(5kbD|CDkAdfem+geMN=H zpycU-8`9is#l8eIiT2lS=>-UAlGU>rZe}IEsI)qbNa-4(+ zh6IvCMf#OO{^pJ6+wc_~c$h%)Q+12GgyC(3ZLoHWf99fQr}%dKKMLU`cbNM*^X;O9hK47C!m0wSsq z4N>6l5cjcYY2Z?VFkgNV=i=5n`{yz|pq;g@aI6FtM}Cwc?9yVMDnVxI)M5ZyLNX1* zJ((8GB?9Gx-v9l4Y*D&ECh-1_xnj=P-)-aS#tGDj$-gXhmLwpT8XT=;m7m=u z4J-WxX^xm=Dkl<*kaq_)epY_urFZ(}ExmXJrVYOi z!p$NE82%Wo-{&)_qaDe{X+8;~;b>U3XN1m?YM#mMD95f@DeoaMDhTlYZa&|!RBj~> zv=iUxcGMPtR_?)j8YCMkD`@Iu6&ON)fsaLpV2ca8v*{>!Ta-yB?mlVBRsj=Sm6rc! zzo>g5tC%`a4-Xd(pAM+ZhB{MGUH9zFcnPuh?0u3U?=6uL3T2Ea?~7wh8o0Z=xL6>m%tp zVP!rB@6=gh(Ek+Qq*NLDXk0fY0;AlOH2JO@eL(v+{y*TmGrFhpv`BmwloN&9rCu(* zN0K6AFSmFkSraPQLowSf%K2{|TH#h9b3m0NCt9&Mv-XTe-ZFYTPFgwazE(K`li-w) zmDHAWKqCz5L?ayIDB>R@DfY_Cd%A?|8W>MlUX>Fjbg@|K?X!S|zZz_eOYa9bfgj9E zPr`o>9eYVr!RX*!i51hTK8GQWNmY48Ve{U=rKO^GdNf!BP> z+YRP8GqUmnFFs`;y>mJ@J^v#~;qoHD&y%$_d}mx*g(Uj zzXt?OPmdxfI<($nM;2`D>4Zx-GDjzGWkr)uonhuf6bgH|KX^Xmude9MI1273o(|PH zSvc0Z-#0cF=RQxQkKq7;F?`aFaA?80XR1~!Da)wO=iLhEw(@v=LF37A z&3qy)vl>{@ar!w8{IB+2F75reZykZR*XQ@sOi@JrNa@N%SFIvlGgMAgmh*e1A{4p!nx^ReRgMx2CSf6W)K?E)E4bhu2fvbgR+Yx90jYsr~I6F}B zncZ;&5ct{%*fM4Gd$HT}*68s;p^-VmjA%tyoasNdh0Fv?mk=2eaM&Z}Y2MMAMM2m9 zQXHq8 zJ$C&m73ckXp>wkH{7a!1A&Xdw=gT zxUc>d7G#I~!G&=Sto$+PBFHF)PQBgBWy3hHufzc*OSlWlkQIP~6YcrDGh}5NZ95@>6Uu;eY>*bfj@YS1eXN>EZ-;_DXt0;Zij){kwqy7!%9N#w%>KLA zRQXZ4Cx~HZw_8Z;XCdiv{2`p<`7m$Gd_#lzYeLsog$FlLRg(V?Qe=>Y^+&w+5k~FB zK8;2QC1FJ?PSu-=Y@hdqI)onQm@TaKbuDR5XeysSbYMQQQNVZMXKGAwCed2=um~KKFouEroqKPr z5RQ9_OrHZnFl(V%Iub|s4@1rN)=fcI7AAX^3)BO0kxL2Q*KE{BO;~@7_oc_D>YH8F zZO>E47*Z0W~ z%ufd0Zhwx)mj3{!X`wDl(0Yrgfh@FAS@jfc_+F*>84Lm22NYc)+snFu45)6ygHHPba*w4dR6K{{Ece`yVHC8gXYhPA~)s z6pLPeg|$c5MVS*~DPs8`dZ2UB(7T#XsPk-L(8>Y_#Vf9VFyvfSUYE+weYe6bOxUkA zg8mcfILy)Rm6xi3*TK?m*4%;KT-zS{kTP3u{LZ=7t_c&RL0V{(W;aQZlo!c8wc51( z&fRoVbn0`Fs)sHbKGi4U2-Oc=YnW7?V0ErMHYIK)LsUA)(soOc*cE@(@V3+BNd(4G zp_cdLt{BnA*!z!SoJizxaAtxT?|zGS;JF&Of)ZD?6%jb1<&T?NS+;VP)!RYIFCPh2_!cv{;$~027nZwREAyv zSuwkJ_`FNR4_ceeZ-L(g748NQbZiiaDE6|2Pu}TLd*BUKW$@e^6D!4&tDOO1@Cc50 zNNAFLa%PzE+e6RY;{u@yQqCXgFM&)C@H>u&+Guve$HV;Zhl`A_3$7tAN*nXUIjSaN+!`Gzu}TkAL{lS@?r@0^7WXgbUh?HMWj`GKI$i zaWs;x=j<_OR*-obEJx*4=wS&`t}YZAiASYNN!IkdvV!ftvaac$KjmjLxZAuIhhCAq z1}z)SX6~9;X_CV<0{!HMRk10yNGVNIv#Mljv)T0~$LFM3I*&W$%w8g4(}R$KNWR$Q z=MT*co|X&eOMR`8kGH2CfzHakSpSWs0wv;dSro1!o0`SvQG=;t;fl6JSl%WeWCn?m z+mclf*}Ef>&D>pK$h{9$=!q49V6n6ZE|dBQexhjK&S>BB_<~7v7OX>woa7Hfm`8|_ z8Re8Zcc>(oXo_CyY^lmwN8B%6>jjzzHirBZb~2^*xT<5GeIcECC@m7=PH6ZqgA3si ztIq4ZZek71UXMy1fem=*;>22sxROQhvhuaAt4=VhaC7fmj$aThZ-UjKnBk#S>ugsW#jkfC)>;c@VIj}hiox)2{JcbamhpkaK0ch;*z+Zl3XvZ zETvaqr+;Zh*1^l(#Gon#uiAA32b zIuhjBa!jZq6NWV2UZ+JCBFuzA+w2INqEW-5BGk%<(~`wFXN&r?ojZPrlsND1?S*uE z)9;O~o2MbGeQuE+-4qg!LZk93X%LWtR{CgIedou%N6x%swdV4OdFQdg?#0jEe{DOa z-QD2;rhfb`*Y-2DwfaT)g3TalB3-A9vs1Ul0VA)>{w+h|Gpq&Yh+*Ymu?$)w`2HcD z)9EubQSIu>MuT}$Del5aSpTCs5Y{o=j_!w3t}su**9kz%C-V*Widj_01|guJ;NHnr zkg7JQ%Ee^ZF^&O~hgUu~&vF|NVP&1RBCR@36?t<#fHP2^b~RQenPda+C!FqpPmaBd zKTC>JW7XLf~N0|t0->MRt#lY z#ZC)tGl9w0jpTPhdWrqkUbmSqUiYeWg^e-y`#R|% zUOdQhYN$>_ILqkjpQZtd6;_rCL1xevy9C)^?3BiAOk}qy6+exA|! zIBLiovoXIXLxZJ#h4?;u{3p>S=w* zdfr#UA#loDhIo!0LJ71V?CRZ4RL2^eQmNqn7AW~?Cwk9G1bgejPs_=9clbxza`CD^ zx#obWOi$Nv&oQGotTv4tO0X`FN92c!5wrFBs!mX(+J!1z=8+Mx7kbh@Qnh_GHN+jXnm<6U4La}PYU6mZ6D!K^JQXd@5{LZei!dVm)J;^&!iubam9k+lGgKkq=eY1Z6VZWN zP)#NRYOE&*%}PbxXX(L7#yT%yp>8r-Desf6C9LTddr>2`Y%4UTD@G_m>RW-Uukf}_ z_ih~516#MrydOg_m8ivtubg$lrf>VhqKQ&l&L68FQ|$L>QwJ45ol)SIk_h zzqtdm@Ax06*NeWKuL=4q*>3~|rQKLoxaN_N5#b|g#g=q#d&hFvYaJ447`gduSre57J7J8qi9r}asVwdz?lVh( zL~j8})jUxF4eK9oai=0J=4sP$;$Yrb)`>h?;GGM>6CDzptJ%MjAg5;?@70n*l^oqm zgQT(Y5&ezuMs3c6Xdmy}f{PFvh2|OFR}G&dIX*XwKb#k$RV_Ph+=?u$Un;5FMT?S- zV`kz1q#(qNNQavuTE@j-$1J#6pWQ2PM|FPs9{NWVBYNu)qsVpD^;$P=(*74>XdQY> zEFVYs!2R!&56;4*T4jq6>1Sb@`|ZWyG@$S1l(1gA40ZQKmDJHFPO73Gs>!0oJWFvcw6f)}pE*ifNxYR26=P!w zc~XY_1!Ufx?Yx+#mJBE}N<|BSKM@EUQN%pI<8Z7kw-Ye1@}N6BKUpVrY8&!O<;*w)cOde!{U{1N%Sw*mc=u@qVdjr@F6 z+|ZQr@k8_BH7p5Cc> zPJxM$!d{Dwc5`X_nfLfR8(hxn7h{L*T*nGvuXCD-bV$fvsee+veWhmCaofXAK(U?@bfU&nZ*#wOT!snuKw-csn(6QwACIG@*_r13J2pswb1)N*d{X&@lxuqr7T` zsj{;!l;pZ{u*}zfp|mm)aV(=&Ncc`Qe4w9YaA{eDNtkNVThF^TtZ8P03sJMu-R9EDgCVQ;%))4fXSxUx7n;~qtm_WY(I^u> zN9TEEqIv+g=zLRfi=+5k_k1C@&>(84h4l~*?*ct6v4(sxK+E`qxD`rPas1J&099`1 z5pIaZo2X$yUEc)7$9;m4#XOO?S}ck@1IYWM$g_8&X-}H2RJoN3wgGN%W7fLJCQ?`5 z>LMB+2tn;L?N+TyOc8s3f(n4oOIm2|MoBIgUmBHzW`fPaHvR189UT1w*%z5GgHGp& z&7v1fDR@b>27(Lf2prtB;H~uE5I=XU2n0}vJepFKs^n6|DP|aQ(f&o=yc{i9XaFMIDjH3HL=Q4R)`;7Tx(#5A7pUGAP~q~CFKxH zAGNi6*I4AY&xbVRhR}&Q#xvlbE2bcM?NT_-Peh&nk~t_0zs@^2mLrq=fY%bXrsc~Z z1N(`pScxKNk>JObI$dTUul`Ce=aqlUn@KAG@JL3CKl#HUl zbKP>gZ7dr3jIIEM`zkt|X!rKCn61~L^x-?<#w7ir+A<%9g{uT2o!mIcJYFbN1AQJ0 zOD|U~_IdPYWV~<+ETgd`+pw1ntqW%-6d$MP{sdRb|8o%`OV<*57Uyy`ICxA}#Z#%P zT5mx;zfNw^S&0@1nNy1z;60np+sTHEgRS$HpD({UDmkqO#beE`ZR_l!Tl+0J54{Gb0bjM(^(T^?P_eROCp2k) zny{_7XkX3lWHu)|mUgOlOFhP<3kE_< z6iMoCsLua}xfbq}J?ru$C=qSn%ez{fmeH2`uvw&A*Fyv<+t5LkfAN z6QYYOW1s1o!_Pqc$r6g@)+;VDWm$4byVwH6-xRLkwe%N;+E2eq=JU9NXG4R|c%Cvz zRLx>4iM0lTJ3L^8yEA=sCP@*B-7P}$CY&(6cs&Fw|E1D@*)EhYAQklr7w2E`Kc&*k zsksA(?>VFrN-WsT#Dyp)c$KA+@m5j(3T#MjLJFO&_z++RsE}*2zuBsVl+L@WRQ}dq z6IdyY$)Og(vKfLASY$Nsa20qlAGnF%+Kh!6_yA0*eM32Azl zKa}E{3hrO`4YACLg~OR@NB);1P%}K7((Qzj#yBvCEgI*}ox2TtlZyA}IdX#@hb#(K z4)VohtN%FD?ybh2x5GrqkM(I@hp^&WbNb$2n%AEiR0kmo>u+$4xas$BC*dxS zZMrahc~Zif)u)H9&IszX7FOMBU0*b%IA}13MX1NK@AV9Ab_Ev2z5j5*6Tl6XT1PH< z2j#0ARtcE+EqwsPCWB|{c}Ro^olH+2L260-jfYKKLg}JTUGWnu4P;7#~ zl-uwO_#%29okxBy8JtLSz2-4xb-_w@2;{^NK{r&1I~U8G$kbU4wdO{hSFJvCTBi1o zBYmp>h}2{+ciD6mO^v--rS+s>yhPE<49tS$qeS;kTkZ#@66;*Ya1}M)(A#Y}%@~!P z8XNf7OyK%eSBm^$Z@#Py(*u{I%3f8%NSk+|0Htnrb1*0wLgIKjgDDd#D?iJ14=E+Q zL_r+xsX`Qtw&8&0ur^VhxbIv(=w1c{LUHQVY|i1aFiKk(?lznUasxwLAvCNjhstoBVC8uG2_*vJ#hY&3Q1`f+4l!$dK8!{P6$bNRYrd6lKdqz zJ~`dfR{aqlkLh1R6yz;eqcEW?Ej!o-^ApJ-SdCkKHPo(oC&5k{nL5L)`3FCs=-(fl z_k@Be{OKv}oh8K92J+mV`j89|Z&FOj15LR+McP68zNLjAstuuu?$S&lKRuC<6BlA{ zk!~D`HHh^!7uS*}1`@`U5V}htGXu3Nk2MB`Skw^fiPdaK&UvogbN6K8PHWrB`nJl*AI?5aB zb2K%S%3s5kcE<~r$B%C9alD7B;?ONE%-&<)AXSZ0an4N8ZC2opCLn5aEd+kMZSq>21ZBUrNWUoS9KPolWbhH)1G=E$ z>jt_xM#zs~g=t49kyXogNV|A(Vth%gTi5JBMhcZu5K|&L%;!S%-4}vCp4(`uea&3J zm0rBoWf^pn4McE5i46X6dQ}5rUN}75xw~!W;)+3jDC5QtWH^5q**Vw{xRy9?30qiu zy8aA|;jn?UYqn`N^NloLcB54#5AklQA*g`X-q)b;q!m!IdF0e=i$`ui^J2_p)_{55 zJ^X$tW0ABZmrhus`!!%SI1=3z53ixFgduZ0Je2Xl=dKGF1A`;CKCM90OP>gn4M*Xm zsLb;&*L^34XDwWMs&-Fao|rFzcOLtY1w@$oiySa$*_C%U!Ibx$rU;&E87}GbW<`sj zd}@Tv2P4wSqM5@|{M0lTK<~KIDrvQ6WxDOsCS5ixbO}M5aDX0_eDbA>_j zoxV_cD+%*uua_~V{)j$<#-S(6+$sp0VQgl8vrnrxU1hw~JEUVMAR*pEGx!ITT0RP> z-ztDer*1F*Qq+Hh1WU%ES)j1uZyizXu3!-I3~^St)hIyas%W??HGZX4PkIxf)^~lL$Zgy5Jo=1VDp2)SxhF(1Ru6=mZ-MbivF|`HE+!# z$^;%-;)8-&j8egFJ#D{Mxmc9b2PcycyHSX&vZ~Cebh$LKL6PJwlKE|Xuw|+$w9to7 zg;JT!T0otKstb~#c_@KS?JRN~5(jJi&tPMk>gDwu4^s5IV>QS5dW1DA9gfzgzUFYT zQfXEIy#jN`&98HS%1jm)_IMm|6neG5iTWe?_SJlYy+jFpqHU(5#*2G>9kP*wcAU11 zM;>%8BrSqCD(MM&g`n#KaLlXwgoLff1H=n#eO&8}XT+Z{r-Y4g>B?}2f{u_@K{TtvQV!GIyl*R{e8NVW83n=e>CD`Ok8&g3#qg&{&=YHKv+5rWjYhf-g%Vl+ zy=ntn8?*P8z<0auJ6Nwt)rSPVIMNL4YPoiT($ud(OzeeyzBBv@zlpX@%Qdz@4VNt_ zDX0!`6PF3{ChlqGt(%2Jno1tNnxb%qv@UjH+%tlFLCctc^u8Y2xOGXqi2q_yDs7oX z9GQy_QKTzXI-A-Vae7J}4hQt|3 zFP1zP_i&aZbk>SBNWgKLp$bcA4p*K3wuP!QR{E$cEvQ}*U&so72M@^AJesfg7gZP_Z722>D3>3wIR|OrJd64zrS1|TFxS_Xf0ixjrx<8MgoZC zI+6`#uJzK*8pPz?4a5*r%7@9gyT>0)2xV-_{$fi*=AAMZIL+JQ`+hu3J)z)gN8w2g z3b(MnC^OE3h@=yxG^r2bbh(}E0pg?~hDKH24-#@)K-N35o45?~4KY73Sdw89B|^ZD z;Z>ZduT*6RZkr8(qHm5j%mT+@h4hf^6;m^VN-zbIus$>GQxk@Vx6wk(a&RFrIrGVX zyvff88&ex*8P#l*@A`0=1;bXfDq?CeQC~z(1C?)FM05aH{}h?#dSgy910*AKTbJ7Hu4Phaj_*Kd^8L zbh8E~JX`C9~k_2ighE#vgu>)8NRefU((GvX)yNLa{lSYqH57nnk79jg6@3o&@H z;|(Gco2QOcSSx8@+SCgKJ1regDL8q_My@+m=|A68)7RH|9UZ(MR5?);dpen3vZt=G z*9!WR(F+&9LSl*KcC0ZqZt?P@B!<@~aI(~h+gLHiCa_BBEM&;W`xCAg7^=ks$$v)O z?35YvM*B28$=c$L+Y=3OKg?2d`fM3BA4#1nXcXpdde%XR}*#Q7j)7e?18x!YkrilJ5i zF`N?N(zQI;SRkFEBU@;?Mw*Joob5jnT{%O0S5RgCMD>ANL4>aZ z#bbr=;jVDN>x?x3hl1#*>0AEw>^enpM`Bq)Ao&P5Q!T&luOVD>? z3?37PG7<|<9DrLyqdC+f@5E|W7GwC+qIJAIURL%;K4~8o+j8+T~lqVvMxEp#mZg{ck zM9XhXzuvSSWGnmQavebAC^3BlS;`=_bWOoCg{wTf7=bxrgcNfxOJcQf zHuA8<*9{pLO?DBBD1sNRAEH;EW^=6A63u_Qv~LS#H`d?nV^_?c&zItJHH`nc{z~ z=!(Y|YCq?5`KWDmf(AnlkvycvBD{NuMA{xF20!$yS16mtz}hHJ5$nXp!z;qOl7r(@ z#ZmJ&0g3&Ak2|PerR@a~zm-XoIs}ATx#9P2XzhvHvM@XnNN>G*!f|Gq_@?x+X32l~ zEg{;8|Mubw6bX>z*L<=FQtOd1y%Y8Dz7V z4oMgc_i;h6S0#R#UMtqlTpF&L!;7$^RThIR^^j9E}* zK(d~`=uGXXjQzIPU7C(qJiki3KLN)#!9tG+gMrBhKaM6%T~0U{WyQJ8N!Nzs;7oQX z=T)S0&I*9w1wZ(gHH>?7&;>|9LbtTXqg}qB^8sH3`R45V-}%OL$qUa)i}lNjOwfk8 z`R)z?h6D_sP?>TR?YnlB9HFPIV_}JW31(d>iNB|NVZ^h^x=e-MiDaojV%CQyztMw^Q4&U6Mq~ADum8GHVY9g0vJ{Y8Vm26KrFt@|J3cAF56Y zubo2ez`PjB4Y32-Xtqw>xGCE35q<)08~$ zM=V{JrVza3b>>v;S5E;0P;CWQ07|G7nKp0Nl(gtE*BL-xBFLR_-=Xsky4z)4K7=v4 zEX3TI(|kufl*BzNdfc2al6tufTeg*wc3pAym`Vt&Q`-Sv3>Wsw&X=7y_p$;kR(Do9 z*`E$hFKC?Gyy|-W8puex>;-33{X<$8;wm+%0;4mY8J}?8vMmg7IAO?8!yr5OHL+|b z=+Mg)njJv`E>)aK)?1N5*QTxZ*G>-%fJ)`8%=p-N>$ZCz<`vLU)d?T44oGw-G|M6# z*ET6SwG>4bQ!<|ztz=Kz#r0WUs99!glm${1aGSU7Xzk{f3h>;^|4$Uf5bZ|KN}h{+2cE4iVSM2RyTX?~blMEP;bdi`$n8MR4*oHkl~GDeoMO}nVO1d7;PmxY z*WGcyr+gAU_*<9g8@kfJPSJsRC<4YZSvqQjNZto$z#|w`#)pL5P6i0rsJH&3PSZ-s zzE(d*$5h*aUN(VX9qrZ>p{H-sguzk$ki`0o*VR*}fx0Hh{CRUX$d`EziC$339#`*U zWrut!WBNDZZFR&9LpZETuO}s!UJ{ErD2f3CJZQ;5+{ca^A(Ik-d7HMUkTG?XvjlGK z0L9S|j}HMoW%9&yf=?J=X-VEfr*f zGRJrlBA21vth+e7&8igtB0lx{i_t0!01C1uM^gVW1+g0rTaN6gndEXHCeEz+4#IDQmC zxc*AGF1Rk9#>R^gzpUWK>DzE?<&K**gzBr%hQ=iL$sd;w5GCq!t zrc;htn11J%-k2uq1uC8}gdFx>(=bYmL<9o*@KuOl{Rb;b0nR*VMI`H=x)8mf`qCme$=9hc{iN_X)* zgIM2^m^EW6#X;LY{h4W+7LE*EJ9LbG_4LQ(I+U{jar$U~$Wlg8`<)5p^6ZF~9USrJ zON#Kp=@2uXnT+d*l8~{`r{S!h{Nvy~>D)6;OmF?^m-CwIwhpr$Xg&Yo(3Uqw8)PTt zsiDrHZ$x#HAd*M_Drc(hiVUMXmwYU<7`R%n@Y3k7pD|)P-IQGB9B^tsx{_rU&YS&I z0CfMDCGD;t=bv-VU6Upbj!I#}R+9Ff)GNzjQJQ3v;@DPYZmr#*P|vSqQj=lYhz zvTF7=n)9C&G%#k2rQ06veRXrrsqOeIe35rzHEfxh6dFuR0!dSV$lPFw~K;ddQa~z5(0n)%T|Q_xUwK>mu_MJh}=2av)?H zCRb>N-MB)Jd`#X4@>w3EDqnYCd>s+7gARCGckE1SH*C@i-qpv_4mz7ip?_$p{D_5f zy8dp8ELVrIN=cW_b7$#m>OAgaXJw-Tr;x(vyYd^B9(!_~oyUd}fNH|NVF@loz!-63 zG0bN&lw&USo}y?>6$N@715uz|L6t+Z9*KPa%f^(!!@*QsM+_Cq`fR_xA2twBX=1E<^Z@Alh#m=@tEIANJH zBcQIAKe4L4!zTUO5GWP!=&5Osb21ZHB9%w5?C3IZyUp1~0%3hr;9Fd{Al3+@s$%<{ z-AQQELziBf0AYz=J&WOirzgenO&i?3NT=6cW8rRen9Dfzxz-qthiW;K6>e2nS-+23L(;vR~Z7seK3amL=!YrCUceNaeURKJIrt7I64yS8PNgkJihxGyw62`S5 zZ5hFY9deydA`_LlFdSEa`;-d?;{}E}DO>PjOp5>wegdzjLMms+l2kpdx4K$xP$6ge zvQU&M)BrT?B9~)El>{O%j2a|+SY&ayXehJ<1g`5r$k$~Q^e84c&V@#y+3JnOe&LDW zkPJ9!pq1YEiVJm{ikPP&Sm~HY_6!fts17|M1_4(Jxr(x=H!QeC5CDPzM}w*(h!S{G zk_l)7Ww~CL7C#GX8U+=d3Cw2en=xh5u7z`Ebp31ew2V|cQ(SiMc;sPo|K7U9K|mX;)aF?UdQbpxKF(F}(<($oeS$ z)Ww~9ffo6YD|+wIvA##2)aDb0rhcE4g_KJNebIs^Q`Hg#wZCOpSL=XWWu1lm1(coYF?UQHK$s-d3lIPgpCQIi_qN28}j)7}UKgl{QknzVc*5 zup*UvU0IK7KuJ7fR%nG`y*ox1zeSHwJwlHfzv3t33$VaC0EBNGix~77@%%Ve@QL;s zN%bnDO^E1o#*cPJIWobdl(nBisYlYf4tjP%;N{GgPt$DLTAWjw*s)9Z0XrTM3zX6j z(J)6wZ)Edjn}zh6*Qy8|mF4I*l+B9LGjQ`acR&U&e2@tWs?;=e8J!hd{sqmnMA+@t zS^jd^AqS^v)21~eRnd1jA9C>YXQxf!>l3~SjMide-=V}2-Q{1t^6AurlF__BoIItg zd$OW$Op^^Qph%k9w2iI+v^|9Y4zMB>bn4&(52(y9vUUijNqRIUEnTrHty=f2oHWC6 zc3x-uiVk?F>Es?)ms)1T4PV263%s43yhbvfWAo@Ilk)`AbmdHH_-CcJY)@I59oz;GIcJ6n8R3q>zltj$}0PG)~qv zfChuO^WNQ+Sk)GN+Eedorz(%4=eL8AZ|$pU$<9ZaQ1#_-5#KrPNQ3`{BK zIkapF_O;g1|U{2Yvm-k~S$erghJ10OHp+sfSic zp2mutzWQ;@qC@#U)d7==XLenn*|TSFKZ2jDQO_eqsn=C6je+75(-htKec;hm>P2BTkiQk-#S=;9aw7;Qm-`sSv2XI40%a1avV_>{F9Qz+Ah zCKXDYb2eWWJL`;0s3bW~;(2L~2e z%K$&=jJEj0Uv?jTwddaGlPIP{Mp za-gK5>;poc6BaMvmCAkYBkdS$V@sRi*gQeqTDoF&+ODrwYgEayV=%wqp+TB z>lPYDMl%8!HjL`q_q>#MuWWd+)~G2F6bA5%fI=k6)YmsSP<{66*S8*hLi2u21H)TD z3;@gSd?v;yQb(_)GJnB`vvXp>Xs^p;j7l3Vc5bt9trJ%kht&oCC>}i68%`YOP(#Vb zv=UUDl{!7%G=0)wI%xV-p^BFpn8QhxmXr3A7k^(XZ!7r>At#HKaRD=5Gq_f(9Kxu!y#z3)^4cXPY{;b?Aa=AA#_JlCqHw3~^34X0aYM zk@he)Jyv$CPxvA$ zJDaX)s*3w7Sx>nSSGArxs8&AlRNo)`?H71%&d#w<5Y@~DT;}_f60{_kg-<*oW zmuo=F#s)Hb{^c_xw{D#40d0PfM5DMzdCq=`3osfI$y+8IG~*Qq);lTQ{PGv1nFmc5 zC^G~zRr?WLI@1T8&VC$7llC)ecLh7-Cl&Z$}EpLTi0#UUuTnU>#05qK)(RV8|Oy0vVD9+I$r zR1aEoNPOQ;d3{F5TPG|&R22tSD))j82&qm#_4KbUIPdbbX5&`Pp`}?~>cNHuL52nB zz4gu1yB>Nxz4ql7WkX_>=s5+VGOlw1hs4WBV)Roz)=>&H3>kEa6AM5pg(=(m5i@7$Sw;r$H*eWd4Udhq zD*b>RBDxoKVO4-NDbz92cO zbjp%N4pE38$Qw`3qdZu>%M~<)(-6ouMdbfYU!TvGt<|9!>Ao2zApv%Ojwm)SWrP%UjipKl&pghpmnyT+D@jJD8_o14mw=`*_85tRE@#~RWcIeH#dLyoX zy-|IPrb0XhB~%eaK9c$CqBx$OIQh+Ac~g4#FI=I36)y0MUaI#m7UeAm9-Q?EtM!+k zQZc;{R7W{@@X$0b)|k3P7kL^;KKN%5bPD}O2Lo^fk?|~U*auO;34Ub*DO1ZdHS*u5 zj&qFn)lZxV-RLl|OQ-SlE#*^=JK`$UA5iIl3#!wPJ96~eTh;kk^HOr%vpcW7-`@1l z6Hm7`ZrZG`TTD~vROOknz>#{5S{^;J@%GdCzb(r~%dV1b3zmavaDsjSZCm=APkf>J z$S1#^Hf`VCN_rW-B3F*1paZ_MSI!c+oT&=Vp|WYyCZ*#KUzpB3c}Y6$_@mO1htE$3 z>o&slsgowQ{RLbTTl3;|h+dh!I3*y@DWW z#Kf@PF$n&G4b3~l!R-Ogpgtle|1br_BODp6)o1{580mJkH@{{&V1*(bGlL<6qFS?O zPEE%wT5xSfIbco)Tu_~P($ROw952>lJDydeccQ0_9@;%GPcA>I`S$r|&TI_}%tntX z3qfFGMtD%86b8XVFboAaBep4yBpeEy@HPjEew0~DkKO6B`VHI<|HD_b#k;!2?>6~n zWW3+byv&4Y67<1^hc^vI(%jiI(u>YKGhKGU8R_g(j!g@7_kUn;0zu3!U29BJE`jHc z!P3?v?3`IM(g{lzr%PXSHvX2jZPhyC@m1;CTklI>yWxlF&WBdCc;`%#BRXEg{F)UY)z z)6fA}=ga7&_M>(gPj=k7eP`?LhaZm})xzF9%};A9I&Z|Oex&FZczrgX!-?}mV$=`h`{qO$Jx{o@MCQ+IP2te(n(7+ zh1%oIK~dBo$W%_FL%r)M%F?Hb+@3sc`1V2`v(4K1;@oxnvskrW^HeuP7g-@VpKW10 z@eiY9{fkCkvmppcGr!goE7qmQR>#+TTxdnh{?U;Is5WEHIPu6GYD+v}9e@YK@qi1e zqh`#$L+h%(yB}Gddp_xj`FVIS7g|@f(oJ_gnBE)P#5PbdT^J1mVNsog}4xSw8?9lSnp`_K!G+qtlPeR9!_-n;%959 zCykD22{~ps)?4HH!X}$y=RfaweloT7>&u@^AN%B2)2F_9L)xje*vgGt)5UN5t@O^H zxh&~N>e3O5^s-L{6yKtahC@^Y^zRPA8FWlrD9%&9+)nA%9a89);1OaDo`O?!!stY& zVC|c}u^5@Orc)_pe4I=tHxmBi>e2er9qKUmXqPv4VuJKA7J8>c2-EI4=aePd-aPP9 z-Mj-yw0FM$k4FCHvsd?N-4d_#MaoblXFJFt`uh4kSN(<7RZ}%|;ST~boO@z}%es`e zoh1in83{_8ndS{^4fFMDZcTsj&tFY9-}?w%rbZ`y>{!qti2*xv+#toPNld@w$d{aR zV*2fOzbT!sU?L4uE;Jlc!$Zrz?WCQoJ-c?cp42ZBKKRHJY3UPd(qm7qP3xc8%n#e~ z^PAj%OVg(4M(ot7>9B=!(y>~D9e>neX~~g?rGvDFkOUL#;}|6<B#I zlGd_LEsaKeE8I-6f2yWT=0#0ByW@%P-hH?pz{US00y0NG!R`uN*?`nPc z6JJU<-n}#jQy#$g!OMtYSiGRp&8n7eKBnJ$_Z8`~_MXfw{O;9fZApmg3TOVTT~zG7-O zXU?o@T%#AALH1(pAkvNUUQ}vNjNY{cW#GANjyL{o+Ta7NL8YUDaVvd1Xu& zV)}voRl)SCr5^~plWiO?bOcN6)!tKedv8Z zpBCsr)V=Y(aU&93`Wj1me8rmT^WV6x^~I}iu70#)tryp^Yo}3!WVb|E<#nphZU6ul zSp3y!qJ^z^HEH3zS?O{G##>)^NjgQlh?PbpUVaoKikhLd)yh>*r~m%Pe^-6sJGZpb zghBoEOs1jf=uD0BFMa;0>55lfoX*qRlO|7{oT}ly%rec=0fegEULrfDu1J)pfMk^- zFiN0B**-_%T6b}#jik=@Mv0Gfa@XH>U%L3`K9H*VJsDb6B0{SbJsr5a_f+qB>nk>W z<;t;CR>_$zz9?XIq!A9Fmm&~kM^}6R8>ux=J7@)E&5MBYJR%)3xA#lG$ODS z9gb;l@ETyB-ZeWd4!2%6LqGKK&!ta%@fyXZULrX-K|QVw7Y4`bwPRDpeGtsR=>Ce8Uzz(Qohkz zdz#ykdAVK(fje;q&$}JNts(uGz)xLrPWp{s_^EW9b_qu`O7d$Y3WiD@ho`6?`_xy{ z`#5>MBk?>agmKFF(Ka+Lv6YwbugIjVO36BD`w7?h`;FkUJV> z_Dx1|y+9bkta*VF?-#NYbhU#$7Vzx~o2Jr!<&?b=rwXc}>*W zV|p6so>t8fE>9Bm_VuT)TzzBpz7PLHYx%16`Q~j_L7~qXgmPwNECDA+Tr_uP`s5$K zCtaXjz2RMZVnl?kcmS_ZY$5wZ32yUdb%Ob z%EPCm5?GSifdCD+rN|{f#AbcDdrxb|w4wB_H@r0c%9XE8vvl8!pPr!}r|y??UiX8$ z)6c!{Bk9SfH)-*&QH$xOd5A8fpFT6SHhtLzr=_>N=3;GroMZ6k@#?2SC^XS^ntV;bp)2YWS zY<>POe=F%*%9{OYouX=jm0i9see3%4TYvh=w0xDmRHvsTf*t`xk1jyInY&jvZPnvW zm^2~HpFN{mykJg?`&RR2O;1CUhSWK0!$ePt?AFfLrmfqwt-L-xzWV93Mh_9IOzl^6 zG1}m8h-%rOjV^}M2}jINfBKtmPnYT*6{8k+#^tOnMu10_udIIdJs)oA-d5b#D#4AO zLRWq!P(Kx$&O7PI^mCe8zVeduwb3z6_mTG!T)8cPGu1B2mK<&@=Sc3*DD%cp=$O${ zc%h5Vx{SkS%YC{xb zyS#dW0)F%Mo$37_{`=~0zkH3RTm5{cUI<9aXrLS;ftH5)8P;^cX~(8ZpMQEf>(pZt zUjd&wWs)8YhQAi;+$$>1?IvovrJu54;l5f=<==7tvUJVOcc&Zfd{84IU)<9Z73@xe zLr>1yQQPwU;`(Bosf}YN^m^#C%U|0_d4}9b|-~RM_-uX6mS`Nrm#6BQ}yW*FB zd+ak`zd<)OC&nn1Ens1>ta|;4@~hiF|EK9hy{k^Y#k)^C=%Me{J&&LMl|M?4uhq8$ zCg@29p1AN`RXj=wI?yeiQf@{T%_wkBIdVbu^-uhMORpC8P9xy_y5KTBxp3uw`$)R& zzDHwpvKOcp0GwI!oq59IbmeO=Nta!CMmlu<9D3VUr5ypSeezQj0#{&o{Shwao6(LH z#8TIwZ^2f&f8!@h)~wx_zIEO0>68C@ZMywO;>)PZ21{uJh`V;BOU^wxef)#Jo(^9y zm*)pXiTl@m)#hzGTR-#5e^7n@mLIh=wN@#J9Vw<-;nCiZ*^`buY+m)NZ+>O#t*^f< zP0}dr>17AXkIkaL{Ya~%4nC>7Za-?H{V)94|DJBX@6pQ7IJ!^-z~BZi&w;9K1G-av z$LBu${!>pq{ewT2?*UL)T;~7~<==|%nXCTw(JS8j=SS7|E99!#d*nq=Hs_y^q?{Qt%JY)!w5~89agffImWhRs+lBvkpAXAw_ zDC$vBWGao)OoJ4Sl2oLSp+u&*?!E3k_y7K^{r%GO{9ga(dHsIRWH_7q{hseR`|M%u zwf0(j?X}j{8s}5aS!&gDq}#q5WdXCcCC$v51%-UZ{q*~9!N70jW6e{pctQ8}%u%kw z8KsRwku^+CNeMpsY_+@Y&T--99s2@@v!Zc2aWyTdv0|uIwSpUXYg^Z>VQrT?PYNQG z7XM|5)H+DRSeH7lqu$BPZ;QGvqnnSIlfdaQ5eExpw~FNg@s1_|OGmpIFV|Od_qe*r~^+=%@ZM&mX&K@`MLof9BQqd#7e( zf^P<8FH-rfN1aHXgTL$psfU8j%^QIWen7REyXO0!TuTsV+wW|;`Ba??L1$b5Qh~!605y zHf~4vztK$@*3(t1d=|8c)w%YPBodLdcEDp8i8ct+(lcVa5Xm-g+u=5D{naVV*|uMH zhP(Fek7dB=5Z}T)c~WAEA7MD?2`#2zmpQm`PUSOW9j~q*mMB~x_~PqzE*1YMBPEWU)U2BdKjA+9) z^3GrUer(5YJ6)aX=ehj(^2BsL7u_<@C%~H^7ArxZ1(r+=;=u9$e0hmLPk~1+H*>*j z?kyB5w1xV~rzc0brUIE}LfAQOY1d@;WmjA_TJIgJ$vSncHcsto-lc!`qNSgvGNU4^ zScD~}Vh*xzg>MObT!zJ#te)6 z_cPY_%=5abcm4a~RJsn5IKu3MXU05O zx7Njjg>8!Ujd>19O}yqRqgrt=xk{dW46u za%k6kq=|y2WFj7Br0iSo?|HL)#dB00Tv1WZ2JOl%S zv6`bY;@|pGQrqZr(E_={Cr0-PT3%V-YhUB*(L@oJ)n4%Lh?ptMD`iwB8FK>Hk5Rrf z=zNdMG!b2I`)9shU2`upGxdVA%cZQCJHAp%!QvY%&oTPpXZvHcacWl6Iu|@tv{+&3 zhcQ!-MlGAEmIoZT#-H@~E9WB#oe+i|gde8>-VE ztXl874SB@XYk#MEa?Y!?O*Vy!JRPGMhg}lfbj&@sxkra~50jbI?Z5{S2incOX@}i) z{leTrr4Qjc&y0tS474`Q|2@XLnB@v6kj12nLeF$LJ!Hl3&zX@)^ zi%Zzok~Yyx4?NanLpINexD=-sxV;ia6=}K1hhjJ`czqc(qs0_SZSZv&{PhwAAwy^8 zY1#0i%v=SE{b>1);g2|W9J?)?IBCk(FITU*XYIO8n05;ROk3JbH|pl?&{Qc42Gc}x<4{08zM8-rFKcY zgd$9-ASHl~49Uu3)c{3j(spUxREM>dyuv=UPuhCKv|W1-#9m#p!ma#rtr^>vEmI0B zTR1BJ^j)jQd9gDvWqSR+Pbi941r3llH8nl9?93}O=ob$z8|$x_ey=g016yaUH>1J$yH zue|zYyS@)zfI|_#;7}S}Z6x~Ed*uaV5U{up8R+=H$5}wUi#EgRb^a2)*0?2-o+Ij2 zt8kk85EXq28@gK5i{rfV=7+BJ?PFj+A`IS983$9t+d5nxOoKxy4!JB30*GFl!INe@ z9}by3CrATDq%b&IAumepPyVD7f>#zV?8>61FL6o{DJ4Ks%mZ03u~ALL+g-%!*sa(S za8KStOakeAYYpFM9Hv?Y;AHpXKHvMM7DiBUA_@`hf*JE(cef3n=Cbsjhl{D(ue!9l zd;YOuXsL`{pg2s%-Qru<=*D{;!QZGu9gKeoLielKj=@EE1Eaiy)$bp z${Eb1*e^Ts%wC6(4Al0alBq#TpWJT7yv45Ty^r`UiFma~>bHTSpXrA)8(nlx^3s_j zOULpS+inFM!yg*)V?H&iSEoU_GR607`ehGlV+AqfJ zGNLJN9RZGzu1lw8Zu`#P-Owp>5hDqpq782L{9eN6w9E9W#+o6ss`; zwCq)51)}{n>yy@xny`-M^J6CO;;gJ>tn0TN4+NzSSTA&Otf&4X#{V&K=F9m|SV(;Z z#)5=@CJOsaFEk~pSBYndZ$G+6yD8$Ze0Iwau5#HDZsl_iffQI3@}3s*4?X^z>pym; z9OgVya=&=~T<-lj4-y}t40;5#y&!}3TKbKIpk}?otee+!71gb^0@evFF1gV4ft%E; zd7crG+~8&Ws(NU;AzGqwey<0r0iTkzuEKg^@9+DK#H3)zPJ?vMw^=znA?UZ-0EfLBadzPZWI*&GNTEL5g<%D=d3?c+u;L~<9Mo&K+rW3<2FM(!mXnVPP zfBr-_VbD!z^B^|bY<<*89{F;+1+RVRy5Bbi3f1z&zpvn!Q*e)|RKAoJ`_>KZW$kB6 zU?@ryQ~HDTYR{Qiso&V5p=}&nMo$1ByuUcoulP>`uU)gwQXa&)DRW+PUw*aD94GBL zAMp)uO&mUvRegNoxi`?R#@l3jQgMQcA%*(EwYIcsSUaOssdCI#$45GDh^j`78gnka zuu3XOtpQXlKmZ?468AKyq9HAWgwXnJ-?~;lrb}HYk9|O7cFjHV79i45o6GAG`b>x_ zRc|i$;u{~jjX&=&_kC^|;aytmJa;3=E$a=vpTe5!!xdizLl6ZQC8^cBKv+W_#*Nf|Vu1#L$)t)5d6NEXLJg#ri_;cO^;KxTg zF4j~6B|uG|`}|8gmVErx>HHM%Ww=Jth}$5?MToeFV$Zmr;2Qd^YjB5m5H%z_K>(6X zK=%6es#(Y|+~7^^D{eCLr6t;+(x8)-O=eyn)~l0L=g@YBaRVLqa>uCYE;XIS4KXeZ zL9o4Re+8o0g9A*IG!r2z)+R8T#BkHk+XHzUzqfLA`0YqDx)%fsb?AbO zj|JZ&AhvA&w}-ihso;}Xv!Wn*b=ha`Q#QA2o?pdVlNi{Q#kf|8NE*yo_-63Oek|`X zq@~n&!L0cRki!qLuK-aXsqTeUT%Fpb8Y{=~lHwgYKAL-3y^GJQaK>peh?Q1AjTVmi zl!#_zNDxN^Ju!q(UjFosh{*2S1AmQ()Ur`+Z!ylSsngj%`+8mY`Pxl>PfA%q%&Qt+ zgo5IHOqOgf%DCB-r(SeleX|jkC9MPr;jyLZ3%E*y)qUPHWK8(%-z&3WN% z6V;N23?APdO0v#GDig?cSapT#+LA zAt=^)0tu7A(v@GkF9}X6^C^P}$lU3g1_G5AmqjqjtS`@%xKQGT%8>e9DRIOq_oV!r z`TRQ`o;Id>S^Dwi59BXAJIwV)h)FBlb=ZtRcuXryOD}&+U z@sr}6IzAeFDn^uDS~vQhQHzTt0Nw`GM$8Ygb;U-TtN6eh@rgmji7-Ks%FFFS_zX3K zSS*Gfuf|y0y!_6`0U^b#G!>kKNJ+h~yV~ed5?%n;`C-G)Zpz%(n9;r71SD|fN}b}K z9DOIuAeYVjnw?V+wO)UJrMtA_fN=6Ni``Bs0;KrRdmz2-ze%Jrq8Dv&t!T7ZkxT3N z(V8D!=YbEo7PsE#zQ?qQ^3)_-)8$6hE{=F!#j;1&SnOwQ`Q>*vZ1QZ{t+TwO_0Kf> zzbATr7j#W(`CzZq`ctMB8r;)NE};jSK0n#$tqWz=A85-0(bT~o19SmH~+NhVhM zOKl7kX8ajUZYrEjL21|J~1FEY>_O_JI_Z?1h z;n`<|Wl9#8Dw^A|@BltiZ=uE(a#(rc;jk8td&G0V#0Wy8s-&cx;ls0DB695?)KAf* z9K?#4IH-#&SLRgP4AyOF5a+)1PSCdZD7SIvUI+z|%Gyg?l!T0E-xT7^{xZ3acg;Ga zX3EHv_0?M+e-$>yJz~kS&$WNXGK(`)QS&M2<_rkAprYm`-es*f2gSv*m8)gKWCWtP zgSTFLg)3A9Wr`v~DG|>Q0tol)-V1Yp`bb4NQL$l-b7)sWg9lMaT22s$CoT_v5vTQ^ zek8_$yyD6qU?-Ww6RW+fgWR@m`^7w?J(tq>&|s~PZJkCzHc7&2)6;|7+FqVoph$^N zRq^qZJYbKb7MD5m%&$9Nb7_)sD~+Z(7iv^BsQS`C#XI!pnNZIX$dGt0&IKVfsCizH zWad7S_HqQ@uHWQ1!xx0p>`mtM%%J)?6{R?^K$D8wxBjQi!5kk(3u{Ec2v=*0Vxq+r z_2T|UtdH2>eTVghOp6XFHYl?;$n0lralu1}!eS@|YF8~ET+`q}*Suj3MNA4RpK+?o zqXnGYHKaJv1faIF{@A|npljD>j9a?=iy*~RJR$K6ttyl+8(v+%hKaM>voP8lxBV7;Mzkj>5O~?@YE-+Lt`o4N;kt$p1v~%e+r*S* z&jf41K9Pa?maOo>W2GV0KgI8u_ROo{FFSDSKSHFckM%LOB)qE$k{$3YR~8?xjx8_i zq)CoKkE`T){&Cf0=el=ncJ0g;-hF0IT1JY^aM?1A)83U0fJT=d=?_E@GMd;2Rx1{s zX_}67*n&6K!@6ANIug|sW$A-YzjlWYqGi;AUd2ERifJw+lr~PY`?NWW!oB+r`lzrN z)-uNj-8)=G)+De4*9^qA9n97}uxLolWHuJJP-0_NgwpV$D(>eC9F&eeA&n#{2@r}*l-u+~Cs6a2G2kp~w zsCkg0I;|WEr#RmC_!2LhJKW<1-PgC0^63xoZDJz&WykM;5)A}gaQu@C-ELX<{~7Cy z`WeW*LNU|u$G(cGz0Oo86~aFv_?%2IPnQJ3-X}J5=aD5llW}V_b=ahpFiV9}s@c`pv;hZ!PDzpgb-I z>opnUT0|%_a-~YLK`36^Y6N739fhUf?nOHF*0}ot{KqzK`_%<0x!v*=Uxl+5z3(R? z%U~Z~DZw;BLF`e$CezcCy0vZkTd6W2k_KxlfEW~9Yp*<-MG67`+@fqI+!xwvnNgFyK&z4!t*`@sQ0Uahsn^;9ezbUcnQ3IEVPwj1u)Mj(G>rThl2YNSSvq0%*=K>IbB|^_xDVm9i;Qy!z=!-dZBXl z=a4osvyw`eD&~4ze@(;Vl}o87aiHq*_)C2{w`g3fSOMAlq<~fG%h7$SY#fR-=o&E- zQFv@fpoIV9OaK5bS4l)cR9iNR_Pg?#%<617>XAgG?|$5D@gyv-&B*b*`jS&(V&QDn zsXn&<^W||>v7wVKpVbACy!Xi}5XKsS%$4QNsaV$CdTlc-F%&BTf`WkeaUi(-tr}iG zD7>hBe^;aZ0BrC2x~sd~VqS}w*hWg6?*JQmA=Ol=_j>FR2>7|RsML^^7E;S>|EqrT*TlD^y zaAMkS&F8}=7z&Y(m#wArfZu|J!N~vAt)p=BM7D=M&VBJM_u=Qn!;m6GZL-(n-@HOH zX@D6ev)>KZyjQa1>0~~B({W8yrJc}LRx7A6U3oG zSMr3RaAL0f>7_suPSVhQps{|__Bf*Op2{-}h<_R3LQM%o((Eiv6~0TSom!N=AY?g` z#IRK|deu5c?}O+q8(r-37szk8sOU%sFz0E5TmC&0XS?OA*9V)49lirc$aPzHg>S4} z>$(q}=33k~40roI-i(N14s)vF9XGWOs^Gh1E+pc_aG04m32IG9Rf^K&%9SnY8rMFL z7f`72p4MRPozr)n&%?!=294R+w$s=-v28Z?iJivh#J1hW#t9nRHk-z_`pfsvc-MNL z%ei=F)?RZlGi&etx%glTpKUbcmHJkMQxK(l+T1aqV|&|=1^FfFW4lT?()cL9wK#2| zfTa8%cq#EfQM$9=Lg{y>-(cALsJmY+I|+}_2p@DcOKn6Y^R-$>`|&uC8MSzskHS4B zYHsl3wx;$n%VF+GaR2kR(oU0B95XX)0i+Bre;BwN4Xcl7=cjo>PxLQwXaI>KdN(I2p--_wG zqZ6M+QMNo=y%k~@FyikV2ETYHZ;ej1u=rnhOWIR#08jcQ>gaz#}U!V4}u0PQI8gP*31SX3s(5L=77o?)N;vhSGnhB>WWmo>b-GgV0sfnvwiC3 zAeq$Ibysin48%WEV|oC-9c|}iW0ep>4*HsSxnZiwQhdWCU?EXfy0=1YGA9tfEdh-kN!@P$ND(^3Aa2L3dP`$K7Z`2}XF>eu((Bh1A1-fWLAW#wE zypb8+sRO`g1efY>?unZS0|SR$Anftq{sfa_gJ4x*PMI~uEQ~5Yjk6K=H3RU238uzU z8B8t8i6|N1)el6%cTRIk@_&v&YjdRzGr9~t5<4EG-~spl>^~0T=AE=RIUSFFWq#By zTr0XULPOnUEwP=!3AgxQv^B2D1wd;%m~7lFUb8BmYS7qx9jo6SQUmPfT!{4FK3{8Q z$EDa7=L?o7F&|VrszATT{s|>u5!Z6n!1#C8_pq=B?xu~56Nzit*aUa~HJ%?Mp~;M= z;%4x!TVr_wK`wrm^UZwMsX2k8%>3NACz68>pbi;R^>@?>9@K+AY{SM81or30g`*HK zgN1%Q}buq1=N;}nE@Gc8oX z5(DQeuE|&qmeSs*PJD+fAYo*FJsB&F#1n4;v=7^r!kvxC=&G5AV}xOv-7%ykkKn8{ zj_@#b-0yb^GaPO3aKaL=jBnIScI{OC{*NLG!2yFL{tr~uo#A(pqbT<*1y}l}j>P*B zg852@!fT!nn~+fpyUyJNnqY|jj2njMl81JvWpRZpz@QI4s+s;s@8KzsOP0{O`*pui zP)mm*;+j$7^TDopaPiwc>!(* zdzhA4)qJXTk4KZ3lDsQ-(a!?AAzirLB_gOqjhq440!{*EgyFg9;cT%>)Aipfy&b)< zgr(}E1ad-V-Ri?xQIU&@$h~I7pW2eiW7V2hI#7iwoVBY=QcXG1AC7%-dWPGj32lv3sM#DX>R$ZN{G_Ady)+A5 zclAUxiAWLFdU!ETs!Mq7{kO`OaAonq$iUC)8QHO3wpZdA5>6L7F|vwLbyyTRY=D9M zVwAH5AV&xlOOHt~a%s8AW#f-UG6EO2Q34a6F46?s%VpZWCYVA4SlUA#pLSvlTL85= z)KiU(4qskiluQ=EbKjwZ{JJk98Q-2Fi0`>JNWDjQcmgPB9 z4eF9W`Z$9mEHp4`(Xh6-`?O-ObjCZOjQNLqp+_^r7+1Y;2LdSVnbVM$^8p@y(e1a+>~&L z>>I{&HkS6Vv1w2dy%)4rQ%N5lA6@l*t2RWr^a~lEP0U^m$t-(L5CCu1C^;${hhs8@P`5D46I4zTCzpp>JfLlLGeKIfyB8YQrr8>-$7hMmDdC<8b!Au%3 zjGM8vc37mD7|}1QF#r;gw(=gIA)`GB-F|n?vP8p%1#;L#jf?fEh`5zdv5m}rGb?L2 z!Gc)?X^pmP+^2&A-}Y*zA7OW@4nI&DFDlUGL-M9&z<2=Xa7K>_r}}!(#mp50y2i|v zIdVLu&I2W4h*N<1ptkC|#fA$MK{JtXxv*O@^jxvwxi$qUe&R@FgrJ4o*9Ev#aK2vF z*`R;GR*=R|68eb2e4OO#3Cfa4OB-txWY(i-bt-ceKndVrc%*fwzUi<1k(Ig>vMN} zq1T0VDiJTRGaI(>YYE>)AA($n&t5f@us|8{R|``j6j{o|z6Ota(x7Fky(w#qTm3p=5$`?sI+ zl2ysNdv^nWdZQ=MnV(n!27JW|_!ZOsCE__g1Z7+x!`J6urAx`i6ZTmC6YnWPxZ)ok z-miXdar*GkK@7>-nr2&BdUk60Oh86Q6E*?duz^b4O_!1D5wqYK{u;qEgV%j%(>=Xg zSv?gXo^nn5<8_^8kbHWEOJQkd8*(w-p6d09^}c(NH_U20t+n50!ULrgF$Jw ze_EJO*_Iv<<;m)@kBG3>saRdv1Psfe@m@LSXnr3q@!7HctHr;8p6nirvGyFZ_8MNX z`um^xy4dxoI|&t>cFZ#>gJW(h?$p!o2*wfrwsYFp)hCoJqa3n+UwZl-%{#lu@6WQq zr!Va8nRmNu2e>rJQ=;ZoV>A~{+Kb=6uaGL_U)LukZsdH=btc^tC@oi%i!1|LW+e6$ z;unE@d~?Pq_n0g4xOLUQ3jDTd2^BQ70VjC`#oaIwH&1W=*oCz&&gbG!E)j94XNEUA z!p5VS@5+AtL3hf@&e2_&jK&Gy;*ihoJP=^{M>zKMx>u}+f51Yje%cqMll)*3CjnG< zwCar$Z7k*ZYw4n^L3QnjG93?L7Fb7kS0dUCD{MY5RKS$TG?oT*KKMElN@17?FV<~At>A3okLLzf!M%VZ68^oB;Ph{RESO~GA5&?g8X5)m7 z3B4+O_(dyYabj3eZw`hsIM`)tM+ElkLaz`N?NtIj7Ws*E*+S!g(%=>uJRyVl9YQ)`ymNb zZ5LPehGXqxFJ=i}Lg${;N}_Nhsx?K-h;wU=UCD@9AMaH>fv>%Y*u!I^h)sgoa;E7m z&c6vj7dOi=q6>J3UBjlI0;CnR6f>$_23&&hrzo_%%|>=S&|{7Y%8WpEkLKA4N24i> z#0o@yNQ>mNIKSS|Kl|#4B(-=yV0bVT0mKeR#+&P>VJP0=BrQ;}we^pwl{=0Rm!1_F z?{-+v_}|x#RkCgcL~~L2joe47Hw~-v?4Ojk(a?T;v@*=bb0G%4!R7T~d=FWES4ZHQ;)RX=C81R1Wj3VH+eV88<^ z_uJ7e+Y=U>y@Ij!eCQ{ci&ZRTq6;UVKU)NJ*g81N$bGs|EDSh?TxMSj1Moy<+?Q=` zAs^3b^M~-@Z}=cY(bG=I1LTPg-$mvPjrpYigTL}XU>q%*X!CfQ6W@wgeNo;mn*XJd zCRAfzFApQO^`TJU3@n9%VSSf10=Kb;GOnvZ8CN~*JWdJ3LM&HjQ_@$G0znD!#SHDSHrYO zu2uFEzQ*HL<=Q?fsd!OYu@5tXe%oXUW40Jux}h@%Mnf)5feHR>QOFpqQXgsZDIlJ; zYi0jgLWkG5a3mrppHB7TgxK1*0aT9?t=sf}e|%<9&UIe-MQTt6g|#u^sf@4)E?PAg zXSR(1N?>>i{=7j*L;Kc$A%E0X{Ap@KCn<*1YYZfByYx=+zKHP8qR6-RArVz6i~npO zvJCs^y*G>WK)j}kr7yP@1=(y?i1ByZi{C`}aztLWOWyXhCu`jdk3D5DCJf~`dlk;E zK?bIz8B*!`S>)GIG^p_Yjq!p8-Pzgi(2z!tJ77p!SrzZ}0kgn* zC)$*0!wN3FI77_NDQK-Gj+!zYNH`TubY9n1^PD6UIxW6ViKD`>HgJEwI+URW+w0Tx zYoL+6DrS%K>gd`3fuu!^h1w&4m5+Q{)A*lB|6;z8yZVq_lAf={1i_+*B*NE**QA=S zYB3$nR};2L{M35KQ(WGkQ*4`@ws0sFDWwCKu+linOKgi<^Y39feB9by?k}M_pNF=D z>AODt9!bF4t2~&f=6wqiUb5P5C=T#-#h>^c{&ii=6CAzsv|VVDlP@>k~%& zUp5$##wiq2cRq>IfwpX;-`v2%`6tKKaa?g?ayjfwWU^c2rL19jdCM4&wCp(Q0Vo5b zL&}&KR|>=hJ$fj@io9!plx-4kf?@xSzVM#evCNMFp1Zgb(QF^7k*FWI$Z2=G0Pn>-6k|NMK#%Nfa`C9lVt+bv{Z;ZM=!^A4bS;|HFANNKzY9$63q3Ie{Tp@W3RMI3zf#sh zX*?h@Gq3f=^!S8*zEcQ*x;9W zK8*$YZn8gvP1YORFg3vsW?El8yu>DKQ#P_12E&ssIu}wH(j&r4SIm2|emxvE#h$2g z0tDr*YAA8LV$7VHdwlORN>HN-oo{~r7KGCOw<&w+t%i}ZBkt0Zf^|We@7#F<9YtS_ zz$0cL^;IPbh&d^f=!7%(OmBUGqtG?hM3yhdpH<(>EKn7&A+_Y%uR-wW33>w?PBo1m`Ay3Q8VIDgIzV@20Ks9{y<7PW9{erIQbB zq_74u{v5+%aMi4a5u5S>{>q$ zVye|hqEv=KLJTBCmU5UMl6~s((4+Q7`8u#ql|h9?wNS*JE*|F8nzhmB#F8>E4E9Cn zv*?8W!m3JVJQytNsr~1%C`p{%wDxf>0_7ktAn$7$EMl_P(NFdeOU)E;9L~wAe8O^3 zt1Chbj-!hByx=4IWCriF;X-RQu`UB(Y{0VC{8 z4$!DL#6xWNBhQZpV#14H4MQ(4!5XX%i~P#PLYeZo+4-^|FE8?qUBjJF&B_8UZGnHu z-PS`^MU+Tmm6u##NRlzu_#Qy1xe~O|{nERh1 zm!Bsj#n>PBc-)X)ptwh)v9!KFDNi^co^Yljgw||NP9+wj)RI(lJa2*YBJv+*MB+f` zSRUihJKy&&3zi|CYsPo#*HY6*O!38bLu(DIUe_?nwZ9jm#S=NxS;d|I>Lv7%Z{^1O z;HhPObU{Ss_WPjrQ|zN(TgW7@re)7cXz?r=!}&8>j4F`$=gOi)SKB zz@W{e$j)!Bvu9XVIU>Tk?0&CUd%B%zU!}7d-4~H?#|ThDzbl%$8_E(aayQ;g{w4LlayLs+*)qX*1vK?+J5p<_{7UL(O+S&buvEAQIU|IL@9lM zs>CNa-1Xr?Di!Q5JhEGGNPl@eK>?YN<&qN(KMw_M-=Ac)eNbI)rCKY2ZsI#=gxnBQ z^}piZte28?qrA}D-1k=bnSRH@Vjswc@Pw#**HC`bLz4|TF!+*8%nroNCODb@HTaXF zWqT@`*V-Ty_vhZ+H<4C-xL7o`y8d{>{KWaUD&AX%tJ|IPN_C`{5G3oQc;l?$FBm8h zLV`Tj)4)$Sl%yFD5bXVUiokNMch*;S=W~&Qi06!Q^(_jOJL#q5Ea-uuK8f~;qv>|o z9HF)((x9ZTaFkNfQ(o6#T1x|!x?9DlO(Rt{Ax_2>blFFW=s_<(TEw8LP(t6ArZpLaCE9Eyi|MHeg64gkGhU zx!Lxw2{v0_8`S zKN+>WTRmV6Wf}SFI#kA~OsS_RJ@GQ(lh^m;<0CKS(TY7691}cn{j_nIGJ9k;H!i-aj z4cHeGJRo$Y&l8hWcr*?_gw|?$_aoQv*IZgZ(tA_Vt-9-?Y@S(NFr=od&|_g`@5||A zh2g3~uzETkT&xV%;7{V!S`>HXRe^%oWI}Ywi^(OJr0{!~B^4q%Rv89%TdoLQ5kaRq z?IwA^EU8R@-p|6FP9gq5WsASoB=Npaw8md*faOhF*4+DdyVyil&@aZG%IQI7Y_y7z zuxbHx>nn46&tDc8(r(U)!e6-%#eJZ9?Kd4j#K`eu_NZ@qvO~e^QJxnk%CX2(A{AUO zYC1i%_XXt>i@yB3G}Hqx!Nk;bf4;}iAMdR_n^|M^>p+B-Yi>G%I@^u#Fp_`osRJ~C zqWxyD9!>UIklE8OFAkK8OuyVTbXT)5Ff*=$S;@&82ZVBzFz$o*6bM!&DDp5{2HaiR z5aK31B}&J!f>-4$`MCPNzvuiRX@}UU95iB1r{%2T4P)ERyD4GR`BNWb_Am@AX>i2m zJoT#a5HliiI}O0Ca86QL8qZvoNJq4}N|HGv*_ACS&`Alu+J@Nmf}<`>Fsb)+R$)UV z3nId6Oml8RN^S-r?=6jPBc*p57J>@srPT`-3xQluComWctZ#wNzhjh9+TRu@r5hx* zgMZ6*mOkr$#bY-!SZ%n;HPC5Ww^{0Jc9ZNvCf0D`6@Do}HxQ+(@n?*CkYSf@30=?!ov4059QUzow;bU~-v$ag%2jG;!>=k(#g`A5i zo&O*Z(p<$N=XSrg0N<^=Flx2&|J4NLXm(X*5Q;^w3`Bm7{#Fr~@M1FXYq=)-iV-%H zns=R~w}#ozKjU){gPwyzgpXbBaB#Z&`$CE$i^rcRT#qQebuxK}`GDl(M!5xDyN<4p z_NTH%yJ2OF@j%U@#s?T=tfb8R%9VnweE3?PPQ77X$ylhRPF7ZKh z<>>nPS{?*jv&i)aBZ(jO9K}BVFMfUIsPdNudseOFXj85$t2!y+n~k>}QD_Se#93hy zFPkR0t%WKpj}B|?lpSe()$DxQjJw~(0*JYP3s_>4n@5zRI#WnnDJdt5ldlMT-Ste_ zQHnU}{P4M;c2}F5emeX#tc;JYN)Um&J{Im2RsGQB6u4~uq86!6w>x`8Nvy|(SfME_ zaR)YOG^IAf;RDADn%kcZm*ig2mkr25I|UR6##Q_Kg&*EHk)vZQY>9_%wMM-zzm7g@ z=4J`;(`qIAqFK<~t3(?Yj{H6{JM;*}L_L zB*dFHgZli20ud0^X90Bd<-%J}vQJhcZ%4R!|5mFXpkV_Jkm)&_70c()OpE^--juY3 zn9<2(N4qPaa`_jXcWk1xpXs%N{q6-L5=$O6mY2XL4U=c# zEgIR!4ixzmPtsSXWcYR$p5oxU75E%pC4eR>njSQiv`8v0PTi*=Xb2X+UB1jcZ|6=s z$>-MZgW)qHCt^tNHJyc~8F$IprIHG@#sl6td9O%1ZuUv=jv~Intcz{F$hB?Tl44PI zdbePldrInX7}MX^u?aCG@}#|t4R4x4K3Y{H>P+XGlgzA!PY+*6x!bxZNnM@%8&|o4 z1)s3$M42f#sOPSGN?y^Fy=>EuBN%fNc?Kpj*jler6bYH{ybGrSVS*l~--_&KZca}- z!r2sw;%0<&Y_gxvwb=Zz6u~u#-qx^Re7GSsaqrcxyoJv&H^ki z@^7c-?qsnN!gQr~`xZxzE*%+|ePupXpo2@aX=r*iu3B-DYm9LoTvTM~O`Z>u0MS$Y z#w;+pbLB{QY9$VzMYP;j3S1LHYx`0v5>bbj$^uiD^U9ry@?wC{;Vc#_6*K}6324Z3H+oXTk0K4smjS52P5xVu259_4a_$jB zO`&(SoX~#fFw?q$TTsst=ZQkJUo0cz4r}1-9fytDQtq9nRoEGxHT1pza^@4`#+fVa zLt&PPQQ%C_UuAg5^ss9j9zlKeniAF;nec6}0mj27NVDH4b_gSABv%ElKJvEFVo7W;ISzF{@H$Iu-YMno3VSN-CdGuNL&nG zS!LK>{cQBx+XlCqCM#{S^CJ0|W<@M+kVr(uXG%#!8|mw)pD3Qi<%b=gjizY&$!U6F&E6u z&RE@jjQQ}m98lf_4Ug!GJgw(Qo#*#nWw&A%`hrbL&xn*zC7(5D4RayNF&6|)GKF!~ zo7Zv%|BRe>Y!sj^)p{BuTa1+e!ZQ6dUh1gw_qBmI!akxRaJleM4slHClQ_o?l|le4 zWwD|PdFsJ%ZqIb?nK8*?xAu!Q8e;1!7CptUz|US3O#7{4dHyydr@4J)*c)$OjQZPL zdOiAMALri7$yrhk2_4<^5VxrL^a{r{^*@_yrk{)90^uJa9R~0~5 zk;+P40mKoc;Fd7z_QELalQ35W%Juk(nNBwJY77^SmDaxN-VSms{rW5OFec12^oO!! zpD$9-eWOx)GpP*NnndYUY=+B6m&_RklKh zOjBur_f7KQ(+WWaR_44dX1WVyKE+j!Q}f1EDbYGT}n!N4lyQn_~Z#4C2HWClkJh?-x}+gUZvcT2`9p1 zt?G^aw(M&{41Dq@4$TJIdSo4g`eV`+NMEH`C&V4QrD6|biEI(}D&=-1wd{J1-vHi{cV>Ei z{HVCuZlurBQg>ULJtIcdr*Ncsch~I@n(@g7T5M`Ksd_3&JBq@5UL-&%mDwyp+MT={HvaMoAWq+e%iNaGPg^a=Kjsuj$Ezr?YN0rak>4lpO8 z6IMu-jvh9ej(F6}MklA?F4^k`;IAty%2)V!=ssKy=S~pLSL4!C!qoi}jRYNwd@|RB zG99=-TT^u$?{f|CvA!?7tW7mi^JUx&>y^(MhHE0)Q9_SyO$G=!I3Mwov)IlzW-w_{ zs}~i8(kkYOYg+ZAmeIP?=DrL=Z6IMYYMr8SNFRy71cA!mC4MMrB;|BMwi6$;SkHNE zeLBDOA2&P(ahPi+TGOKLeG6Juzs_n7c!O2dWtj?d%Tck%0~Y*_A5P26)ou6cDULXA zyUVrHZ^HZT1R(vV3J(C>k-|RAjC!1}drZ`Vl_xT~F3-OD!CpB@@0d)+HIt}Gb;l-F zTG1#4lKkS8R&Uhpr2MXfKT=v|)!cqs=zw*8W?1F-bp1FYebfyVBoa3tSoEIMu0+pn zgJ84@ba93eHB3JMaJLoolAzbLxZ3Hezox4GS4h~s=OpaXW6Hd0fMAS?yyNljw}5)KGzyBV z>hah_z9yQJco^f8;&mvQ&Nk zEQvV(`1s@r)HF^)b5{5Qh` z`6sB7N<+wfur!Hp;Y{W?JGs}l^_e;N&loWO{S8EhE=CqC3KR7I1OJ!xh&6_P6$uLw VS|Rplw*2(*$x12#s>Mx${|Bz3e1QM} literal 0 HcmV?d00001 diff --git a/web/src/layout/Header.tsx b/web/src/layout/Header.tsx index 42a6daa..a28a590 100644 --- a/web/src/layout/Header.tsx +++ b/web/src/layout/Header.tsx @@ -62,7 +62,7 @@ export function Header() { {" "} Tapiro Logo diff --git a/web/src/pages/static/HomePage.tsx b/web/src/pages/static/HomePage.tsx index 0f40bb9..1eca99c 100644 --- a/web/src/pages/static/HomePage.tsx +++ b/web/src/pages/static/HomePage.tsx @@ -63,10 +63,10 @@ export default function HomePage() { />
-

- Tapiro: Reclaiming Your Data, Refining Your Experience. +

+ Tapiro: Centralized Data Management Platform.

-

+

Tired of data fragmentation and lack of control? Tapiro empowers users with transparency and provides businesses with ethical, high-quality data for truly personalized recommendations. From ec46d3e6eba0e78b0fc93f716cb2ff562d63a971 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sun, 18 May 2025 15:41:32 +0530 Subject: [PATCH 09/51] feat: Implement Breadcrumbs component and integrate it into Layout; adjust padding in StoreDashboard and UserDashboard for consistency; update HomePage paragraph width for improved readability --- web/src/components/layout/Breadcrumbs.tsx | 83 +++++++++++++++++++ web/src/layout/Layout.tsx | 37 ++++++--- .../pages/StoreDashboard/StoreDashboard.tsx | 2 +- web/src/pages/UserDashboard/UserDashboard.tsx | 2 +- web/src/pages/static/HomePage.tsx | 2 +- 5 files changed, 112 insertions(+), 14 deletions(-) create mode 100644 web/src/components/layout/Breadcrumbs.tsx diff --git a/web/src/components/layout/Breadcrumbs.tsx b/web/src/components/layout/Breadcrumbs.tsx new file mode 100644 index 0000000..4c1a6b6 --- /dev/null +++ b/web/src/components/layout/Breadcrumbs.tsx @@ -0,0 +1,83 @@ +import { Breadcrumb, BreadcrumbItem } from "flowbite-react"; +import { HiHome } from "react-icons/hi"; +import { Link, useLocation } from "react-router"; + +const formatBreadcrumbSegment = (segment: string): string => { + if (!segment) return ""; + // Add space before uppercase letters (for camelCase) then convert to lower case + const spacedSegment = segment.replace(/([A-Z0-9])/g, " $1").toLowerCase(); + // Replace hyphens and underscores with spaces + const withSpaces = spacedSegment.replace(/[-_]/g, " "); + + return withSpaces + .split(" ") + .filter((word) => word.length > 0) // Remove empty strings from multiple spaces + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ") + .trim(); +}; + +export function Breadcrumbs() { + const location = useLocation(); + const { pathname } = location; + + const pathSegments = pathname.split("/").filter((x) => x); + + // If there are no segments (e.g., we are on a path Layout decided should have breadcrumbs, + // but it's effectively a root-like page for a sub-section not yet deep enough for segments), + // it might only show "Home". This is generally fine. + // The main decision to show breadcrumbs at all is in Layout.tsx. + + return ( + + + + Home + + + + {pathSegments.map((segment, index) => { + const routeTo = `/${pathSegments.slice(0, index + 1).join("/")}`; + const isLast = index === pathSegments.length - 1; + let displayName = formatBreadcrumbSegment(segment); + + // Basic heuristic to avoid showing raw IDs as intermediate breadcrumb links + // If it's the last segment and an ID, the page title should ideally reflect the item. + const isObjectIdLike = (s: string) => + s.length === 24 && /^[a-f0-9]+$/i.test(s); + const isUuidLike = (s: string) => + s.length === 36 && + /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test( + s, + ); + + if (!isLast && (isObjectIdLike(segment) || isUuidLike(segment))) { + displayName = "Detail"; // Placeholder for ID-based routes + } + + return ( + + {isLast ? ( + + {displayName} + + ) : ( + + {displayName} + + )} + + ); + })} + + ); +} diff --git a/web/src/layout/Layout.tsx b/web/src/layout/Layout.tsx index 50d98a8..f4dd017 100644 --- a/web/src/layout/Layout.tsx +++ b/web/src/layout/Layout.tsx @@ -1,19 +1,34 @@ -import { Outlet } from "react-router"; -import { Header } from "./Header"; // Assuming Header is in the same layout folder -import { Footer } from "./Footer"; // Assuming Footer is in the same layout folder -import { RegistrationGuard } from "../components/auth/RegistrationGuard"; // Import the guard +import { Outlet, useLocation, matchPath } from "react-router"; +import { Footer } from "./Footer"; +import { Header } from "./Header"; +import { Breadcrumbs } from "../components/layout/Breadcrumbs"; export function Layout() { + const location = useLocation(); + + const noBreadcrumbPatterns: (string | { path: string; end?: boolean })[] = [ + { path: "/", end: true }, // HomePage + { path: "/about", end: true }, // AboutPage + { path: "/api-docs", end: true }, // ApiDocsPage + ]; + + const shouldShowBreadcrumbs = !noBreadcrumbPatterns.some((pattern) => + matchPath( + typeof pattern === "string" ? { path: pattern, end: true } : pattern, + location.pathname, + ), + ); + return ( -

- {" "} - {/* Added dark background */} +
- {/* Wrap Outlet with RegistrationGuard */}
- - - + {shouldShowBreadcrumbs && ( +
+ +
+ )} +
diff --git a/web/src/pages/StoreDashboard/StoreDashboard.tsx b/web/src/pages/StoreDashboard/StoreDashboard.tsx index c524a61..c614062 100644 --- a/web/src/pages/StoreDashboard/StoreDashboard.tsx +++ b/web/src/pages/StoreDashboard/StoreDashboard.tsx @@ -66,7 +66,7 @@ export default function StoreDashboard() { return ( // Removed relative positioning, toasts are now inside child components -
+

Store Dashboard

diff --git a/web/src/pages/UserDashboard/UserDashboard.tsx b/web/src/pages/UserDashboard/UserDashboard.tsx index fdb0cec..e0539a8 100644 --- a/web/src/pages/UserDashboard/UserDashboard.tsx +++ b/web/src/pages/UserDashboard/UserDashboard.tsx @@ -418,7 +418,7 @@ export default function UserDashboard() { // --- Render Dashboard --- return ( <> -
+
Tapiro: Centralized Data Management Platform.

-

+

Tired of data fragmentation and lack of control? Tapiro empowers users with transparency and provides businesses with ethical, high-quality data for truly personalized recommendations. From 614a8e9bdc8fe3a57ab73da1131fc49f789cd3c2 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sun, 18 May 2025 15:54:51 +0530 Subject: [PATCH 10/51] feat: Add toast notifications for user actions in UserDataSharingPage and UserPreferencesPage; implement auto-dismiss functionality --- .../UserDashboard/UserDataSharingPage.tsx | 80 +++++++++++++- .../UserDashboard/UserPreferencesPage.tsx | 102 ++++++++++++++++-- 2 files changed, 168 insertions(+), 14 deletions(-) diff --git a/web/src/pages/UserDashboard/UserDataSharingPage.tsx b/web/src/pages/UserDashboard/UserDataSharingPage.tsx index 3d26345..acd3e53 100644 --- a/web/src/pages/UserDashboard/UserDataSharingPage.tsx +++ b/web/src/pages/UserDashboard/UserDataSharingPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from "react"; +import React, { useState, useMemo, useEffect } from "react"; import { Card, List, @@ -7,8 +7,15 @@ import { Spinner, Alert, TextInput, // For search + Toast, // Added Toast + ToastToggle, // Added ToastToggle } from "flowbite-react"; -import { HiInformationCircle, HiOutlineSearch } from "react-icons/hi"; +import { + HiInformationCircle, + HiOutlineSearch, + HiCheck, // Added HiCheck + HiX, // Added HiX +} from "react-icons/hi"; import { useStoreConsentLists, useOptInToStore, @@ -33,13 +40,29 @@ const UserDataSharingPage: React.FC = () => { mutate: optIn, isPending: isOptingIn, variables: optInVariables, // Get variables for optIn + reset: resetOptInMutation, // Added reset } = useOptInToStore(); const { mutate: optOut, isPending: isOptingOut, variables: optOutVariables, // Get variables for optOut + reset: resetOptOutMutation, // Added reset } = useOptOutFromStore(); + const [toastInfo, setToastInfo] = useState<{ + type: "success" | "error"; + message: string; + } | null>(null); + + useEffect(() => { + if (toastInfo) { + const timer = setTimeout(() => { + setToastInfo(null); + }, 5000); + return () => clearTimeout(timer); + } + }, [toastInfo]); + // Combine IDs from both lists for lookup const storeIdsToLookup = useMemo(() => { const ids = new Set(); @@ -83,11 +106,45 @@ const UserDataSharingPage: React.FC = () => { }, [searchResults, consentLists]); // Dependencies remain the same const handleOptIn = (storeId: string) => { - optIn(storeId); + optIn(storeId, { + onSuccess: () => { + setToastInfo({ + type: "success", + message: `Successfully opted in to ${storeNameMap.get(storeId) || "store"}.`, + }); + resetOptInMutation(); + }, + onError: (error: Error) => { + setToastInfo({ + type: "error", + message: + error.message || + `Failed to opt in to ${storeNameMap.get(storeId) || "store"}.`, + }); + resetOptInMutation(); + }, + }); }; const handleOptOut = (storeId: string) => { - optOut(storeId); + optOut(storeId, { + onSuccess: () => { + setToastInfo({ + type: "success", + message: `Successfully opted out of ${storeNameMap.get(storeId) || "store"}.`, + }); + resetOptOutMutation(); + }, + onError: (error: Error) => { + setToastInfo({ + type: "error", + message: + error.message || + `Failed to opt out of ${storeNameMap.get(storeId) || "store"}.`, + }); + resetOptOutMutation(); + }, + }); }; // --- Loading and Error Checks (Now after the useMemo) --- @@ -112,6 +169,21 @@ const UserDataSharingPage: React.FC = () => { return (

+ {toastInfo && ( + +
+ {toastInfo.type === "success" ? ( + + ) : ( + + )} +
+
{toastInfo.message}
+ setToastInfo(null)} /> +
+ )}

Control Data Sharing

diff --git a/web/src/pages/UserDashboard/UserPreferencesPage.tsx b/web/src/pages/UserDashboard/UserPreferencesPage.tsx index dc68d41..5db3fdb 100644 --- a/web/src/pages/UserDashboard/UserPreferencesPage.tsx +++ b/web/src/pages/UserDashboard/UserPreferencesPage.tsx @@ -1,8 +1,6 @@ import { Card, Button, - Spinner, - Alert, Modal, ModalHeader, ModalBody, @@ -10,9 +8,13 @@ import { Label, TextInput, Select, - RangeSlider, List, ListItem, + Spinner, + Alert, + RangeSlider, + Toast, // Added Toast + ToastToggle, // Added ToastToggle } from "flowbite-react"; import { HiUser, @@ -226,12 +228,14 @@ const UserPreferencesPage: React.FC = () => { const { mutate: updateProfile, isPending: isUpdatingProfile, - error: updateProfileError, + error: updateProfileError, // Keep for Alert if needed, toast will supplement + reset: resetUpdateProfileMutation, // Added reset } = useUpdateUserProfile(); const { mutate: updatePreferences, isPending: isUpdatingPreferences, - error: updatePreferencesError, + error: updatePreferencesError, // Keep for Alert if needed, toast will supplement + reset: resetUpdatePreferencesMutation, // Added reset } = useUpdateUserPreferences(); // --- State (Keep existing) --- @@ -241,6 +245,20 @@ const UserPreferencesPage: React.FC = () => { number | null >(null); + const [toastInfo, setToastInfo] = useState<{ + type: "success" | "error"; + message: string; + } | null>(null); + + useEffect(() => { + if (toastInfo) { + const timer = setTimeout(() => { + setToastInfo(null); + }, 5000); + return () => clearTimeout(timer); + } + }, [toastInfo]); + // --- Forms --- const { register: registerDemo, @@ -388,14 +406,26 @@ const UserPreferencesPage: React.FC = () => { demographicData: demoPayload, }; - console.log("Submitting demographic update:", finalPayload); // Debug log + // console.log("Submitting demographic update:", finalPayload); // Debug log // Only submit if the form is dirty (React Hook Form tracks this) if (isDemoDirty) { updateProfile(finalPayload, { - onSuccess: () => setIsEditingDemographics(false), - onError: (err) => { - console.error("Profile update failed:", err); // Log error + onSuccess: () => { + setIsEditingDemographics(false); + setToastInfo({ + type: "success", + message: "Demographic information updated successfully.", + }); + resetUpdateProfileMutation(); + }, + onError: (err: Error) => { + // console.error("Profile update failed:", err); // Log error + setToastInfo({ + type: "error", + message: err.message || "Failed to update demographic information.", + }); + resetUpdateProfileMutation(); }, }); } else { @@ -441,6 +471,25 @@ const UserPreferencesPage: React.FC = () => { onSuccess: () => { setShowPreferenceModal(false); setEditingPreferenceIndex(null); + setToastInfo({ + type: "success", + message: + editingPreferenceIndex !== null + ? "Preference updated successfully." + : "Preference added successfully.", + }); + resetUpdatePreferencesMutation(); + }, + onError: (err: Error) => { + setToastInfo({ + type: "error", + message: + err.message || + (editingPreferenceIndex !== null + ? "Failed to update preference." + : "Failed to add preference."), + }); + resetUpdatePreferencesMutation(); }, }, ); @@ -467,7 +516,25 @@ const UserPreferencesPage: React.FC = () => { }; }); - updatePreferences({ preferences: sanitizedPreferences }); + updatePreferences( + { preferences: sanitizedPreferences }, + { + onSuccess: () => { + setToastInfo({ + type: "success", + message: "Preference removed successfully.", + }); + resetUpdatePreferencesMutation(); + }, + onError: (err: Error) => { + setToastInfo({ + type: "error", + message: err.message || "Failed to remove preference.", + }); + resetUpdatePreferencesMutation(); + }, + }, + ); }; const openAddModal = () => { @@ -576,6 +643,21 @@ const UserPreferencesPage: React.FC = () => { return (
+ {toastInfo && ( + +
+ {toastInfo.type === "success" ? ( + + ) : ( + + )} +
+
{toastInfo.message}
+ setToastInfo(null)} /> +
+ )}

Manage Your Profile & Interests

From dde2632755fd864a36ed2e634ad18f8eb7c31f4b Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sun, 18 May 2025 16:27:41 +0530 Subject: [PATCH 11/51] feat: Update button colors in UserDashboard, UserDataSharingPage, and UserPreferencesPage for improved UI consistency; add refetch functionality after opt-in/out actions --- web/src/pages/UserDashboard/UserDashboard.tsx | 15 ++++++++++----- .../pages/UserDashboard/UserDataSharingPage.tsx | 9 +++++++-- .../pages/UserDashboard/UserPreferencesPage.tsx | 5 +++-- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/web/src/pages/UserDashboard/UserDashboard.tsx b/web/src/pages/UserDashboard/UserDashboard.tsx index e0539a8..d2d0c7d 100644 --- a/web/src/pages/UserDashboard/UserDashboard.tsx +++ b/web/src/pages/UserDashboard/UserDashboard.tsx @@ -509,7 +509,8 @@ export default function UserDashboard() { )}
+ ); + })} + + + ))} + + )} + + ); + })} + + + ); return (
- Tell us what you're interested in + + {currentStep === 1 + ? "Step 1: Select Your Interests" + : "Step 2: Refine Your Interests"} +
{isLoadingTaxonomy && ( @@ -228,137 +411,63 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) { message={taxonomyError.message} /> )} - {!isLoadingTaxonomy && - !taxonomyError && - taxonomyData && - taxonomyData.categories && ( -
-

- Select topics to personalize your experience. Click a main topic - to refine your interests by selecting specific features. Choose - at least one feature. -

-
- {/* --- Render Top Level Categories --- */} - {topLevelCategories.map((category) => { - const isExpanded = expandedCategoryIds.includes(category.id); - // Get attributes directly from the category object - const attributes = category.attributes || []; - const hasAttributes = attributes.length > 0; - const IconComponent = - categoryIcons[category.name] || DefaultIcon; - - return ( -
- handleExpandCategory(category.id) - : undefined // No action if no attributes - } - className={`h-full transition-all duration-150 ${hasAttributes ? "cursor-pointer" : "cursor-default"} ${isExpanded ? "ring-2 ring-blue-500 dark:ring-blue-400" : hasAttributes ? "hover:bg-gray-50 dark:hover:bg-gray-600" : ""}`} - > - {/* Card Content (Icon, Title, Description) - Keep as is */} -
-
- -
- {category.name} -
- {category.description && ( -

- {category.description} -

- )} -
- {/* Chevron or Placeholder */} - {hasAttributes && ( -
- {isExpanded ? ( - - ) : ( - - )} -
- )} - {!hasAttributes && ( -
// Placeholder - )} -
-
- - {/* --- Render Attributes and Values when Expanded --- */} - {isExpanded && hasAttributes && ( -
- {attributes.map((attribute) => ( -
-
- {attribute.description || attribute.name}{" "} - {/* Use description or name */} -
-
- {(attribute.values || []).map((value) => { - const isSelected = isAttributeValueSelected( - category.id, - attribute.name, - value, - ); - return ( - - ); - })} -
-
- ))} -
- )} - {/* --- End Attribute Rendering --- */} -
- ); - })} -
-
- )} + {!isLoadingTaxonomy && !taxonomyError && taxonomyData && ( + <> + {currentStep === 1 && renderStep1()} + {currentStep === 2 && renderStep2()} + + )}
- - + {currentStep === 1 && ( + <> + + + + )} + {currentStep === 2 && ( + <> + + + + )}
); From 896e7f74a45516b040394c6f597c8423de32df45 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Mon, 19 May 2025 02:17:22 +0530 Subject: [PATCH 14/51] feat: Add purchase functionality to ProductCard and ProductList; implement onPurchaseClick handler for improved user interaction --- demo/src/App.tsx | 13 ++++++++++--- demo/src/components/ProductCard.tsx | 17 +++++++++++++++++ demo/src/components/ProductList.tsx | 3 +++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/demo/src/App.tsx b/demo/src/App.tsx index b233477..475ca05 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -175,10 +175,16 @@ function App() { }; const handleProductClick = (product: Product) => { - console.log(`Product clicked: ${product.name}`); // Placeholder - // Submit view data if user and key are set + console.log(`Product clicked (View): ${product.name}`); if (userEmail && apiKey) { - submitInteractionData(userEmail, "view", product); // Using 'view' as dataType + submitInteractionData(userEmail, "view", product); + } + }; + + const handlePurchaseClick = (product: Product) => { + console.log(`Product purchased: ${product.name}`); + if (userEmail && apiKey) { + submitInteractionData(userEmail, "purchase", product); } }; @@ -511,6 +517,7 @@ function App() { diff --git a/demo/src/components/ProductCard.tsx b/demo/src/components/ProductCard.tsx index cddb345..d5acfa7 100644 --- a/demo/src/components/ProductCard.tsx +++ b/demo/src/components/ProductCard.tsx @@ -3,12 +3,14 @@ import { Product } from "../data/products"; // Make sure path is correct interface ProductCardProps { product: Product; onProductClick?: (product: Product) => void; // Handler for clicks + onPurchaseClick?: (product: Product) => void; // Handler for purchase clicks isRecommended?: boolean; // Optional flag for highlighting } export function ProductCard({ product, onProductClick, + onPurchaseClick, isRecommended, }: ProductCardProps) { const handleCardClick = () => { @@ -17,6 +19,13 @@ export function ProductCard({ } }; + const handlePurchase = (e: React.MouseEvent) => { + e.stopPropagation(); // Prevent card click event from firing + if (onPurchaseClick) { + onPurchaseClick(product); + } + }; + return (
${product.price.toFixed(2)}

+ {onPurchaseClick && ( + + )}
); diff --git a/demo/src/components/ProductList.tsx b/demo/src/components/ProductList.tsx index 08fce40..bc48703 100644 --- a/demo/src/components/ProductList.tsx +++ b/demo/src/components/ProductList.tsx @@ -4,12 +4,14 @@ import { ProductCard } from "./ProductCard"; // Import ProductCard interface ProductListProps { products: Product[]; onProductClick?: (product: Product) => void; // Pass click handler down + onPurchaseClick?: (product: Product) => void; // Pass purchase click handler down recommendedProductIds?: Set; // Set of IDs to highlight } export function ProductList({ products, onProductClick, + onPurchaseClick, recommendedProductIds = new Set(), }: ProductListProps) { if (!products || products.length === 0) { @@ -27,6 +29,7 @@ export function ProductList({ key={product.id} product={product} onProductClick={onProductClick} + onPurchaseClick={onPurchaseClick} isRecommended={recommendedProductIds.has(product.id)} /> ))} From 0224a3d6efabfb192997aabb923886d63651232b Mon Sep 17 00:00:00 2001 From: CDevmina Date: Mon, 19 May 2025 02:28:19 +0530 Subject: [PATCH 15/51] feat: Update button colors in ApiKeyManagement and StoreDashboard for improved UI consistency; enhance modal and key details display --- .../pages/StoreDashboard/ApiKeyManagement.tsx | 45 ++++++++++++------- .../pages/StoreDashboard/StoreDashboard.tsx | 2 +- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/web/src/pages/StoreDashboard/ApiKeyManagement.tsx b/web/src/pages/StoreDashboard/ApiKeyManagement.tsx index 7700535..efb8110 100644 --- a/web/src/pages/StoreDashboard/ApiKeyManagement.tsx +++ b/web/src/pages/StoreDashboard/ApiKeyManagement.tsx @@ -253,7 +253,8 @@ export function ApiKeyManagement() { {key.status === "active" ? ( ) : ( - + Revoked )} @@ -404,30 +405,40 @@ export function ApiKeyManagement() {
- +

Are you sure you want to revoke this API key?

- {/* Display key details */} + {/* Display key details - Improved UI */} {keyToRevoke && ( -

- Name:{" "} - - {keyToRevoke.name || ( - Unnamed Key - )} - -
- Prefix:{" "} - {keyToRevoke.prefix}... -

+
+
+ + Name:{" "} + + + {keyToRevoke.name || ( + Unnamed Key + )} + +
+
+ + Prefix:{" "} + + + {keyToRevoke.prefix}... + +
+
)}

This key will immediately stop working and cannot be reactivated.

From 6e36be64c29e0645604acf8d809d29bf821ce709 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Mon, 19 May 2025 11:54:38 +0530 Subject: [PATCH 16/51] fix: Correct focus-visible outline class for Buy Now button in ProductCard for improved accessibility --- demo/src/components/ProductCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/src/components/ProductCard.tsx b/demo/src/components/ProductCard.tsx index d5acfa7..c36c1b9 100644 --- a/demo/src/components/ProductCard.tsx +++ b/demo/src/components/ProductCard.tsx @@ -66,7 +66,7 @@ export function ProductCard({ {onPurchaseClick && ( From cb3b76afe8994783c6f93f4dd7b14dc14f1de5ef Mon Sep 17 00:00:00 2001 From: CDevmina Date: Mon, 19 May 2025 12:12:42 +0530 Subject: [PATCH 17/51] feat: Enhance StoreRegistrationForm with improved input components and validation; add RegistrationGuard to Layout for better route protection --- .../components/auth/StoreRegistrationForm.tsx | 51 +++++++++++-------- web/src/layout/Layout.tsx | 5 +- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/web/src/components/auth/StoreRegistrationForm.tsx b/web/src/components/auth/StoreRegistrationForm.tsx index 0ce97db..c8f5065 100644 --- a/web/src/components/auth/StoreRegistrationForm.tsx +++ b/web/src/components/auth/StoreRegistrationForm.tsx @@ -1,5 +1,5 @@ -import { useForm, SubmitHandler } from "react-hook-form"; // Import useForm and SubmitHandler -import { Button, FloatingLabel, HelperText } from "flowbite-react"; +import { useForm, SubmitHandler } from "react-hook-form"; +import { Button, HelperText, Label, TextInput } from "flowbite-react"; // Import Label and TextInput import { StoreCreate } from "../../api/types/data-contracts"; import LoadingSpinner from "../common/LoadingSpinner"; @@ -42,14 +42,21 @@ export function StoreRegistrationForm({ Complete Store Registration - {/* Store Name with FloatingLabel and Icon */} -
- +
+ +
+ - {/* Display validation error */} {errors.name && ( {errors.name.message} @@ -71,14 +76,20 @@ export function StoreRegistrationForm({ )}
- {/* Store Address with FloatingLabel and Icon */} -
- +
+ +
+ - {/* Display validation error */} {errors.address && ( {errors.address.message} )} - {!errors.address && ( // Show helper text only if no error + {!errors.address && ( Optional: Provide a physical or primary business address. @@ -103,7 +113,6 @@ export function StoreRegistrationForm({ {isLoading ? ( ) : ( - // No need to manually disable based on name state anymore diff --git a/web/src/layout/Layout.tsx b/web/src/layout/Layout.tsx index f4dd017..ab8f00c 100644 --- a/web/src/layout/Layout.tsx +++ b/web/src/layout/Layout.tsx @@ -2,6 +2,7 @@ import { Outlet, useLocation, matchPath } from "react-router"; import { Footer } from "./Footer"; import { Header } from "./Header"; import { Breadcrumbs } from "../components/layout/Breadcrumbs"; +import { RegistrationGuard } from "../components/auth/RegistrationGuard"; export function Layout() { const location = useLocation(); @@ -28,7 +29,9 @@ export function Layout() {
)} - + + +
From 2bf869303820a886426d8e9a67a4dead0aea2730 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Mon, 19 May 2025 13:14:07 +0530 Subject: [PATCH 18/51] feat: Enhance API key management by adding required fields and validation for API key creation; update schemas for consistency --- tapiro-api-internal/api/openapi.yaml | 10 +- .../service/StoreManagementService.js | 18 ++- tapiro-api-internal/utils/dbSchemas.js | 4 +- web/src/api/types/data-contracts.ts | 117 ++---------------- 4 files changed, 39 insertions(+), 110 deletions(-) diff --git a/tapiro-api-internal/api/openapi.yaml b/tapiro-api-internal/api/openapi.yaml index bb9c8d3..073ed9c 100644 --- a/tapiro-api-internal/api/openapi.yaml +++ b/tapiro-api-internal/api/openapi.yaml @@ -1052,10 +1052,15 @@ components: prefer_not_to_say, null, ] - # REMOVED verification flags from update payload ApiKey: type: object + required: + - keyId + - prefix + - name + - createdAt + - status properties: keyId: type: string @@ -1174,10 +1179,13 @@ components: ApiKeyCreate: type: object + required: + - name properties: name: type: string description: Name for the API key + minLength: 1 ApiKeyList: type: array diff --git a/tapiro-api-internal/service/StoreManagementService.js b/tapiro-api-internal/service/StoreManagementService.js index a31b702..690b46f 100644 --- a/tapiro-api-internal/service/StoreManagementService.js +++ b/tapiro-api-internal/service/StoreManagementService.js @@ -17,6 +17,14 @@ exports.createApiKey = async function (req, body) { // Get user data - use req.user if available (from middleware) or fetch it const userData = req.user || await getUserData(req.headers.authorization?.split(' ')[1]); + // Validate API key name + if (!body.name || body.name.trim() === "") { + return respondWithCode(400, { + code: 400, + message: 'API key name is required.', + }); + } + // Check if store exists const store = await db.collection('stores').findOne({ auth0Id: userData.sub }); if (!store) { @@ -26,6 +34,14 @@ exports.createApiKey = async function (req, body) { }); } + // Check if an API key with the same name already exists for this store (and is active) + if (store.apiKeys && store.apiKeys.some(key => key.name === body.name.trim())) { + return respondWithCode(409, { + code: 409, + message: `An active API key with the name "${body.name.trim()}" already exists.`, + }); + } + // Generate a new API key const apiKeyRaw = crypto.randomBytes(32).toString('hex'); const prefix = apiKeyRaw.substring(0, 8); @@ -35,7 +51,7 @@ exports.createApiKey = async function (req, body) { keyId: new ObjectId().toString(), prefix, hashedKey, - name: body.name || 'API Key', + name: body.name.trim(), // Use the provided and trimmed name status: 'active', createdAt: new Date(), }; diff --git a/tapiro-api-internal/utils/dbSchemas.js b/tapiro-api-internal/utils/dbSchemas.js index b000021..afb11a7 100644 --- a/tapiro-api-internal/utils/dbSchemas.js +++ b/tapiro-api-internal/utils/dbSchemas.js @@ -202,12 +202,12 @@ const storeSchema = { bsonType: 'array', items: { bsonType: 'object', - required: ['keyId', 'prefix', 'hashedKey', 'status', 'createdAt'], + required: ['keyId', 'prefix', 'hashedKey', 'status', 'createdAt', 'name'], properties: { keyId: { bsonType: 'string' }, prefix: { bsonType: 'string' }, hashedKey: { bsonType: 'string' }, - name: { bsonType: 'string' }, + name: { bsonType: 'string', description: "User-defined name for the API key" }, status: { bsonType: 'string' }, createdAt: { bsonType: 'date' }, }, diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index fdf4e8a..f961522 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -163,102 +163,13 @@ export interface UserUpdate { export interface ApiKey { /** @format uuid */ - keyId?: string; - prefix?: string; + keyId: string; + prefix: string; /** Name for the API key */ - name?: string; - /** @format date-time */ - createdAt?: string; - status?: "active" | "revoked"; -} - -/** @example {"email":"user@example.com","dataType":"purchase","entries":[{"$ref":"#/components/schemas/PurchaseEntry/example"}],"metadata":{"source":"web","deviceType":"desktop","sessionId":"abc-123-xyz-789"}} */ -export interface UserData { - /** - * User's email address (used as identifier for API key auth). Must match a registered Tapiro user. - * @format email - */ - email: string; - /** Specifies the type of data contained in the 'entries' array. */ - dataType: "purchase" | "search"; - /** - * List of data entries. Each entry must conform to either the PurchaseEntry or SearchEntry schema, matching the top-level 'dataType'. - * @minItems 1 - */ - entries: (PurchaseEntry | SearchEntry)[]; - /** - * Additional metadata about the collection event (e.g., source, device). - * @example {"source":"web","deviceType":"desktop","sessionId":"abc-123-xyz-789"} - */ - metadata?: { - /** Source of the data (e.g., 'web', 'mobile_app', 'pos'). */ - source?: string; - /** Type of device used (e.g., 'desktop', 'mobile', 'tablet'). */ - deviceType?: string; - /** Identifier for the user's session. */ - sessionId?: string; - }; -} - -/** @example {"timestamp":"2024-05-15T14:30:00Z","items":[{"$ref":"#/components/schemas/PurchaseItem/example"},{"sku":"ABC-789","name":"Running Shorts","category":"201","price":39.95,"quantity":1,"attributes":{"color":"black","size":"M","material":"polyester"}}],"totalValue":91.93} */ -export interface PurchaseEntry { - /** - * ISO 8601 timestamp of when the purchase occurred. - * @format date-time - */ - timestamp: string; - /** List of items included in the purchase. */ - items: PurchaseItem[]; - /** - * Optional total value of the purchase event. - * @format float - */ - totalValue?: number; -} - -/** @example {"sku":"XYZ-123","name":"Men's Cotton T-Shirt","category":"201","price":25.99,"quantity":2,"attributes":{"color":"navy","size":"M","material":"cotton"}} */ -export interface PurchaseItem { - /** Stock Keeping Unit or unique product identifier. */ - sku?: string; - /** Name of the purchased item. */ name: string; - /** Category ID or name matching the Tapiro taxonomy (e.g., "101" or "Smartphones"). Providing the most specific category ID is recommended. */ - category: string; - /** - * Price of a single unit of the item. - * @format float - */ - price?: number; - /** - * Number of units purchased. - * @default 1 - */ - quantity?: number; - /** Key-value pairs representing product attributes based on the taxonomy. Keys should be attribute names (e.g., "color", "size", "brand") and values should be the specific attribute value (e.g., "blue", "large", "Acme"). */ - attributes?: ItemAttributes; -} - -/** - * Key-value pairs representing product attributes based on the taxonomy. Keys should be attribute names (e.g., "color", "size", "brand") and values should be the specific attribute value (e.g., "blue", "large", "Acme"). - * @example {"color":"blue","size":"L","material":"cotton"} - */ -export type ItemAttributes = Record; - -/** @example {"timestamp":"2024-05-15T10:15:00Z","query":"noise cancelling headphones","category":"105","results":25,"clicked":["Bose-QC45","Sony-WH1000XM5"]} */ -export interface SearchEntry { - /** - * ISO 8601 timestamp of when the search occurred. - * @format date-time - */ - timestamp: string; - /** The search query string entered by the user. */ - query: string; - /** Optional category context provided during the search (e.g., user was browsing 'Electronics'). Should match a Tapiro taxonomy ID or name. */ - category?: string; - /** Optional number of results returned for the search query. */ - results?: number; - /** Optional list of product IDs or SKUs clicked from the search results. */ - clicked?: string[]; + /** @format date-time */ + createdAt: string; + status: "active" | "revoked"; } export interface UserPreferences { @@ -328,8 +239,11 @@ export interface StoreUpdate { } export interface ApiKeyCreate { - /** Name for the API key */ - name?: string; + /** + * Name for the API key + * @minLength 1 + */ + name: string; } export type ApiKeyList = ApiKey[]; @@ -532,12 +446,6 @@ export interface RecentUserDataEntry { details?: object; } -/** - * Aggregated spending data per category over time. The structure might vary based on implementation (e.g., object keyed by month/year, or an array of objects each representing a time point). - * @example {"2025-01":{"Electronics":1299.99,"Clothing":150.5},"2025-02":{"Clothing":100,"Home":85}} - */ -export type SpendingAnalytics = Record>; - export interface StoreBasicInfo { /** The unique ID of the store. */ storeId: string; @@ -547,10 +455,7 @@ export interface StoreBasicInfo { /** @example {"month":"2024-01","spending":{"Electronics":1299.99,"Clothing":150.5}} */ export interface MonthlySpendingItem { - /** - * The month of the spending data (e.g., "2024-01"). - * @format date - */ + /** The month of the spending data (e.g., "2024-01"). */ month: string; /** An object mapping category names to the total amount spent in that category for the month. */ spending: Record; From bd5186948c83d7b10c93990006ede4fd7e1c9ec7 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Mon, 19 May 2025 13:40:33 +0530 Subject: [PATCH 19/51] feat: Add client-side validation for API key name in ApiKeyManagement; improve modal styling and error handling --- .../pages/StoreDashboard/ApiKeyManagement.tsx | 63 +++++++++++++------ 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/web/src/pages/StoreDashboard/ApiKeyManagement.tsx b/web/src/pages/StoreDashboard/ApiKeyManagement.tsx index efb8110..e835d3f 100644 --- a/web/src/pages/StoreDashboard/ApiKeyManagement.tsx +++ b/web/src/pages/StoreDashboard/ApiKeyManagement.tsx @@ -39,6 +39,7 @@ import { import { ApiKey } from "../../api/types/data-contracts"; // Removed unused ApiKeyCreate import LoadingSpinner from "../../components/common/LoadingSpinner"; // Import LoadingSpinner import ErrorDisplay from "../../components/common/ErrorDisplay"; // Import ErrorDisplay +import { handleApiError } from "../../api/utils/errorHandler"; // Import handleApiError // Define a type for the response when creating a key, which includes the raw key interface GeneratedApiKeyResponse extends ApiKey { @@ -87,6 +88,9 @@ export function ApiKeyManagement() { const [generatedApiKey, setGeneratedApiKey] = useState(null); const [copied, setCopied] = useState(false); + const [keyNameValidationError, setKeyNameValidationError] = useState< + string | null + >(null); // Added for client-side validation const [showRevokeModal, setShowRevokeModal] = useState(false); const [keyToRevoke, setKeyToRevoke] = useState(null); @@ -111,8 +115,17 @@ export function ApiKeyManagement() { // --- Handlers --- const handleGenerateSubmit = () => { + resetCreateKeyMutation(); + const trimmedName = newKeyName.trim(); + + if (!trimmedName) { + setKeyNameValidationError("API key name is required."); + return; + } + setKeyNameValidationError(null); // Clear validation error if present + createApiKey( - { name: newKeyName || undefined }, + { name: trimmedName }, // Use trimmedName { onSuccess: (data) => { // Cast the received data to the expected response type @@ -155,6 +168,7 @@ export function ApiKeyManagement() { setCopied(false); // Reset copied state setNewKeyName(""); // Clear name input resetCreateKeyMutation(); // Reset mutation state including error + setKeyNameValidationError(null); // Clear validation error }; const copyToClipboard = () => { @@ -212,7 +226,7 @@ export function ApiKeyManagement() { No API keys generated yet.

) : ( -
+
@@ -223,22 +237,19 @@ export function ApiKeyManagement() { Actions - + {apiKeysData.map((key) => ( - + {/* Key Name */} - + {key.name || Unnamed Key} {/* Key Prefix */} - + {key.prefix}... {/* Key Status */} - + {/* Created At */} - {formatDate(key.createdAt)} + + {formatDate(key.createdAt)} + {/* Actions */} - + {key.status === "active" ? ( @@ -402,8 +425,8 @@ export function ApiKeyManagement() { onClose={() => !isRevokingKey && setShowRevokeModal(false)} // Prevent closing while revoking popup > - - + +

From d526c5ab56353e9a8fb3af5b6c6f17819b5486ba Mon Sep 17 00:00:00 2001 From: CDevmina Date: Mon, 19 May 2025 13:49:25 +0530 Subject: [PATCH 20/51] feat: Improve spacing and layout in ApiKeyManagement; increase padding for input and button, enhance label styling --- web/src/pages/StoreDashboard/ApiKeyManagement.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/web/src/pages/StoreDashboard/ApiKeyManagement.tsx b/web/src/pages/StoreDashboard/ApiKeyManagement.tsx index e835d3f..9607f1e 100644 --- a/web/src/pages/StoreDashboard/ApiKeyManagement.tsx +++ b/web/src/pages/StoreDashboard/ApiKeyManagement.tsx @@ -328,13 +328,13 @@ export function ApiKeyManagement() { type="text" value={generatedApiKey.apiKey} // Display the full key here readOnly - className="pr-10" // Add padding for the button + className="pr-12" // Increased padding for more space for the button />

{/* Store Name */} -
- +
+ +
+ {errors.name?.message && ( @@ -229,13 +236,19 @@ export default function StoreProfilePage() {
{/* Address */} -
- +
+ +
+ {errors.address?.message && ( diff --git a/web/src/pages/UserDashboard/UserProfilePage.tsx b/web/src/pages/UserDashboard/UserProfilePage.tsx index e2cd742..a85ed15 100644 --- a/web/src/pages/UserDashboard/UserProfilePage.tsx +++ b/web/src/pages/UserDashboard/UserProfilePage.tsx @@ -4,7 +4,6 @@ import { Button, Card, ToggleSwitch, - FloatingLabel, HelperText, Spinner, Tabs, @@ -15,6 +14,8 @@ import { ModalBody, ModalHeader, Tooltip, // <-- Import Tooltip + Label, // <-- Add Label + TextInput, // <-- Add TextInput } from "flowbite-react"; // Import necessary icons import { @@ -239,12 +240,18 @@ export default function UserProfilePage() { Basic Information {/* Username */} -
- +
+ +
+ {/* Phone Number */} -
- +
+ +
+ Date: Mon, 19 May 2025 14:22:45 +0530 Subject: [PATCH 22/51] feat: Update service name in health check response to 'tapiro-api-internal' --- tapiro-api-internal/service/HealthService.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tapiro-api-internal/service/HealthService.js b/tapiro-api-internal/service/HealthService.js index 702211d..89861db 100644 --- a/tapiro-api-internal/service/HealthService.js +++ b/tapiro-api-internal/service/HealthService.js @@ -14,7 +14,7 @@ exports.healthCheck = async function (req) { const response = { status: 'healthy', timestamp: new Date().toISOString(), - service: 'tapiro-api', + service: 'tapiro-api-internal', dependencies: { database: 'disconnected', cache: 'disconnected', From 740c5af1d8538482296500387d887e4c1603d79d Mon Sep 17 00:00:00 2001 From: CDevmina Date: Mon, 19 May 2025 14:46:57 +0530 Subject: [PATCH 23/51] feat: Refactor Cancel button placement in UserPreferencesPage for improved UX --- .../UserDashboard/UserPreferencesPage.tsx | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/web/src/pages/UserDashboard/UserPreferencesPage.tsx b/web/src/pages/UserDashboard/UserPreferencesPage.tsx index b35e515..dabb5b3 100644 --- a/web/src/pages/UserDashboard/UserPreferencesPage.tsx +++ b/web/src/pages/UserDashboard/UserPreferencesPage.tsx @@ -824,16 +824,6 @@ const UserPreferencesPage: React.FC = () => { {/* Form Actions */}
- +
) : ( @@ -1259,13 +1259,6 @@ const UserPreferencesPage: React.FC = () => {
- + From e0c7ae6f41531489dfc7e5674e257abab493a745 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Mon, 19 May 2025 15:13:48 +0530 Subject: [PATCH 24/51] feat: Update button color in UserPreferencesPage for improved visual consistency --- web/src/pages/UserDashboard/UserPreferencesPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/pages/UserDashboard/UserPreferencesPage.tsx b/web/src/pages/UserDashboard/UserPreferencesPage.tsx index dabb5b3..6e54ae6 100644 --- a/web/src/pages/UserDashboard/UserPreferencesPage.tsx +++ b/web/src/pages/UserDashboard/UserPreferencesPage.tsx @@ -839,7 +839,7 @@ const UserPreferencesPage: React.FC = () => { )}
{/* Attributes */} - {selectedCategoryId && availableAttributes.size > 0 && ( + {selectedCategoryId && attributesForForm.size > 0 && (
Refine Interest (Optional)
- {Array.from(availableAttributes.entries()).map( - ([attrName, attrDesc]) => ( -
- - -
- ), + {Array.from(attributesForForm.entries()).map( + ([attrName, attributeObject]) => { + if ( + attributeObject.values && + attributeObject.values.length > 0 + ) { + return ( +
+ + +
+ ); + } + // If attribute has no predefined values, it won't be rendered as a dropdown. + // You could add a TextInput here as a fallback if needed. + return null; + }, )}
From 0d1240893a6a8101695e75a0068f2916e2018253 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Mon, 19 May 2025 16:57:10 +0530 Subject: [PATCH 27/51] feat: Enhance cache invalidation logic in user preferences services to support email-based keys for external API compatibility --- .../service/StoreOperationsService.js | 3 ++- .../service/PreferenceManagementService.js | 23 +++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/tapiro-api-external/service/StoreOperationsService.js b/tapiro-api-external/service/StoreOperationsService.js index ebe1dc6..bc339d7 100644 --- a/tapiro-api-external/service/StoreOperationsService.js +++ b/tapiro-api-external/service/StoreOperationsService.js @@ -202,7 +202,8 @@ exports.submitUserData = async function (req, body) { const insertedId = result.insertedId; // Get the ID of the inserted document // Invalidate the preferences cache - await invalidateCache(`${CACHE_KEYS.STORE_PREFERENCES}${user._id}:${req.storeId}`); + // Use 'email' for STORE_PREFERENCES key to match how it's set in getUserPreferences + await invalidateCache(`${CACHE_KEYS.STORE_PREFERENCES}${email}:${req.storeId}`); await invalidateCache(`${CACHE_KEYS.PREFERENCES}${user.auth0Id}`); // Call AI service but DO NOT await it. diff --git a/tapiro-api-internal/service/PreferenceManagementService.js b/tapiro-api-internal/service/PreferenceManagementService.js index c58ba32..91096f9 100644 --- a/tapiro-api-internal/service/PreferenceManagementService.js +++ b/tapiro-api-internal/service/PreferenceManagementService.js @@ -198,13 +198,27 @@ exports.updateUserPreferences = async function (req, body) { ); // Clear related caches - const userCacheKey = `${CACHE_KEYS.PREFERENCES}${userData.sub}`; + const userCacheKey = `${CACHE_KEYS.PREFERENCES}${userData.sub}`; // userData.sub is the Auth0 user ID await invalidateCache(userCacheKey); + console.log(`Invalidated general preferences cache: ${userCacheKey}`); // Clear store-specific preference caches as preferences changed - if (user.privacySettings?.optInStores) { + if (user.privacySettings?.optInStores && user.privacySettings.optInStores.length > 0) { + console.log(`Invalidating store-specific preferences for user ${user._id} (Auth0 ID: ${userData.sub}) across ${user.privacySettings.optInStores.length} stores.`); for (const storeId of user.privacySettings.optInStores) { - await invalidateCache(`${CACHE_KEYS.STORE_PREFERENCES}${user._id}:${storeId}`); + // Invalidate cache key used internally (if any) or by other services using MongoDB ID + const internalStorePrefCacheKey = `${CACHE_KEYS.STORE_PREFERENCES}${user._id}:${storeId}`; + await invalidateCache(internalStorePrefCacheKey); + console.log(`Invalidated internal store preferences cache: ${internalStorePrefCacheKey}`); + + // Also invalidate the cache key used by the external API (which uses email as userId) + if (userData.email) { + const externalStorePrefCacheKey = `${CACHE_KEYS.STORE_PREFERENCES}${userData.email}:${storeId}`; + await invalidateCache(externalStorePrefCacheKey); + console.log(`Invalidated external API store preferences cache: ${externalStorePrefCacheKey}`); + } else { + console.warn(`User email not found in userData for Auth0 ID ${userData.sub}. Cannot invalidate external API store preferences cache by email for store ${storeId}.`); + } } } @@ -215,8 +229,9 @@ exports.updateUserPreferences = async function (req, body) { updatedAt: updatedUser.updatedAt, // Use the actual updated timestamp }; - // Update the cache with the new minimal response + // Update the general preferences cache with the new minimal response await setCache(userCacheKey, JSON.stringify(preferencesResponse), { EX: CACHE_TTL.USER_DATA }); + console.log(`Re-cached general preferences: ${userCacheKey}`); return respondWithCode(200, preferencesResponse); From 7852fc7c7f3cf8701dd86890e44d00e6e69480f5 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 20 May 2025 03:16:10 +0530 Subject: [PATCH 28/51] feat: Enhance user profile update logic to include email in cache invalidation for external API compatibility --- demo/src/App.tsx | 122 +++++++++++++++++- .../service/UserProfileService.js | 28 ++-- 2 files changed, 132 insertions(+), 18 deletions(-) diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 475ca05..25b5b9d 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -19,6 +19,41 @@ interface UserPreferences { updatedAt: string; } +// Simple map for category IDs to names for the demo +const categoryNameMap: Record = { + "100": "Electronics", + "101": "Mobile Phones", + "102": "Laptops", + "103": "Tablets", + "104": "Wearables", + "105": "Audio Devices", + "200": "Fashion", + "201": "Apparel", + "202": "Footwear", + "300": "Home Goods", + "301": "Furniture", + "302": "Kitchenware", + "303": "Gardening", + "304": "Home Improvement", + "400": "Beauty & Personal Care", + "401": "Skincare", + "402": "Makeup", + "500": "Media", + "501": "Books", + "502": "Movies & Music", + "600": "Health & Wellness", + "700": "Toys & Kids", + "701": "Baby Gear", + "702": "Kids Clothing", + "800": "Office Supplies", + "900": "Gaming", + "1100": "Grocery", + "1101": "Pantry Goods", + "1200": "Jewelry & Watches", + "1300": "Gifts", + "1400": "Software", +}; + function App() { const [userEmail, setUserEmail] = useState(null); const [apiKey, setApiKey] = useState(null); // State for API Key @@ -90,9 +125,18 @@ function App() { // Sort by preference score if preferences are loaded if (preferences && preferences.length > 0) { const getScore = (product: Product): number => { - const categoryPreference = preferences.find( - (p) => p.category === product.categoryId - ); + const categoryPreference = preferences.find((p) => { + const prefCat = p.category; + const prodCat = product.categoryId; + // Match if exact, or if prefCat is a prefix of prodCat AND + // (lengths are same OR the char in prodCat after prefCat prefix is not '0' - heuristic for demo) + return ( + prodCat.startsWith(prefCat) && + (prodCat.length === prefCat.length || + (prefCat.length < prodCat.length && + prodCat.charAt(prefCat.length) !== "0")) + ); + }); if (!categoryPreference) { return 0; // No preference for this category @@ -363,9 +407,16 @@ function App() { if (preferences && preferences.length > 0 && displayedProducts.length > 0) { // Get scores for all currently displayed products const productScores = displayedProducts.map((product) => { - const categoryPreference = preferences.find( - (p) => p.category === product.categoryId - ); + const categoryPreference = preferences.find((p) => { + const prefCat = p.category; + const prodCat = product.categoryId; + return ( + prodCat.startsWith(prefCat) && + (prodCat.length === prefCat.length || + (prefCat.length < prodCat.length && + prodCat.charAt(prefCat.length) !== "0")) + ); + }); if (!categoryPreference) return { id: product.id, score: 0 }; let score = categoryPreference.score; @@ -497,6 +548,65 @@ function App() {
+ {/* Display User Preferences */} + {preferences && preferences.length > 0 && ( +
+

+ Your Preferences +

+
    + {preferences.map((pref, index) => { + const categoryName = + categoryNameMap[pref.category] || pref.category; + const displayCategory = categoryNameMap[pref.category] + ? `${categoryName} (${pref.category})` + : pref.category; + + return ( +
  • +

    + Category:{" "} + {displayCategory} +

    +

    + Score:{" "} + + {(pref.score * 100).toFixed(0)}% + +

    + {pref.attributes && + Object.keys(pref.attributes).length > 0 && ( +
    +

    + Attributes: +

    +
      + {Object.entries(pref.attributes).map( + ([attrKey, attrValueObj]) => ( +
    • + {attrKey}:{" "} + {Object.entries(attrValueObj) + .map( + ([val, score]) => + `${val} (${(score * 100).toFixed(0)}%)` + ) + .join(", ")} +
    • + ) + )} +
    +
    + )} +
  • + ); + })} +
+
+ )} + {/* Product List Area */}

diff --git a/tapiro-api-internal/service/UserProfileService.js b/tapiro-api-internal/service/UserProfileService.js index dbd8122..fd872d3 100644 --- a/tapiro-api-internal/service/UserProfileService.js +++ b/tapiro-api-internal/service/UserProfileService.js @@ -202,7 +202,7 @@ exports.updateUserProfile = async function (req, body) { .findOneAndUpdate( { auth0Id: auth0UserId }, { $set: updateData }, - { returnDocument: 'after', projection: { preferences: 0 } }, + { returnDocument: 'after', projection: { preferences: 0 } }, // Ensure email is projected ); if (!result) { @@ -222,18 +222,22 @@ exports.updateUserProfile = async function (req, body) { } // Invalidate store-specific preferences if demographics or relevant privacy settings changed - // (Keep existing logic, as privacySettingsChanged flag now includes allowInference) const updatedUserDoc = result; // Use the returned document from findOneAndUpdate - if ( - (demographicsChanged || privacySettingsChanged) && - updatedUserDoc.privacySettings?.optInStores - ) { - const userObjectId = updatedUserDoc._id; // Use the _id from the updated result - console.log(`Invalidating store preferences for user ${userObjectId} due to update.`); - for (const storeId of updatedUserDoc.privacySettings.optInStores) { - const storePrefCacheKey = `${CACHE_KEYS.STORE_PREFERENCES}${userObjectId}:${storeId}`; - await invalidateCache(storePrefCacheKey); - console.log(`Invalidated cache: ${storePrefCacheKey}`); + + if (demographicsChanged || privacySettingsChanged) { + const userObjectId = updatedUserDoc._id; + const userEmail = updatedUserDoc.email; // Make sure email is available in updatedUserDoc + + if (userEmail && updatedUserDoc.privacySettings?.optInStores?.length > 0) { + console.log(`Invalidating store preferences for user ${userObjectId} (email: ${userEmail}) due to privacy/demographic update.`); + for (const storeId of updatedUserDoc.privacySettings.optInStores) { + // Invalidate cache key used by external API (email based) + const externalApiCacheKey = `${CACHE_KEYS.STORE_PREFERENCES}${userEmail}:${storeId}`; + await invalidateCache(externalApiCacheKey); + console.log(`Invalidated external API cache: ${externalApiCacheKey}`); + } + } else if (privacySettingsChanged) { // Log if privacy changed but no stores to invalidate for or email missing + console.log(`Privacy settings changed for user ${userObjectId}. Email: ${userEmail}. OptInStores count: ${updatedUserDoc.privacySettings?.optInStores?.length || 0}. No specific external store preference caches to invalidate under these conditions, but general consent check will apply on cache miss.`); } } From 2f1ddd16d0ff0fc3d325f53122ad18b72979944a Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 20 May 2025 03:47:19 +0530 Subject: [PATCH 29/51] feat: Enhance cache invalidation logic to utilize email for STORE_PREFERENCES across services --- .../app/services/demographicInference.py | 23 +++++++-- .../app/services/preferenceProcessor.py | 22 +++++++-- .../service/PreferenceManagementService.js | 49 ++++++++++++------- .../service/UserProfileService.js | 14 ++++-- 4 files changed, 78 insertions(+), 30 deletions(-) diff --git a/ml-service/app/services/demographicInference.py b/ml-service/app/services/demographicInference.py index b20914f..c0c4515 100644 --- a/ml-service/app/services/demographicInference.py +++ b/ml-service/app/services/demographicInference.py @@ -397,15 +397,30 @@ def check_and_set(field_name: str, inferred_value: Any): logger.info(f"Inference: Successfully updated inferred demographic data for user {user_id}") # --- Invalidate Caches --- auth0_id = user.get("auth0Id") + email = user.get("email") # Ensure email is available + if auth0_id: await invalidate_cache(f"{CACHE_KEYS['USER_DATA']}{auth0_id}") await invalidate_cache(f"{CACHE_KEYS['PREFERENCES']}{auth0_id}") # Invalidate prefs as demographics changed + # Invalidate store-specific caches if opt-in stores exist - if user.get("privacySettings", {}).get("optInStores"): + if email and user.get("privacySettings", {}).get("optInStores"): # Check if email is available + for store_id in user["privacySettings"]["optInStores"]: + # Use email for STORE_PREFERENCES cache key consistency + await invalidate_cache(f"{CACHE_KEYS['STORE_PREFERENCES']}{email}:{store_id}") + logger.info(f"Inference: Invalidated STORE_PREFERENCES for user {email} (Auth0 ID: {auth0_id}) for {len(user['privacySettings']['optInStores'])} stores.") + elif not email and user.get("privacySettings", {}).get("optInStores"): + logger.warning(f"Inference: Cannot invalidate STORE_PREFERENCES for user {auth0_id} as email is missing from user object.") + logger.info(f"Inference: Invalidated USER_DATA and PREFERENCES caches for user {auth0_id}") + else: + logger.warning(f"Inference: Cannot invalidate USER_DATA/PREFERENCES caches for user {email} as auth0Id is missing.") + # Attempt to invalidate STORE_PREFERENCES with email if available + if email and user.get("privacySettings", {}).get("optInStores"): for store_id in user["privacySettings"]["optInStores"]: - # Use user_id (ObjectId string) for store cache key consistency - await invalidate_cache(f"{CACHE_KEYS['STORE_PREFERENCES']}{user_id}:{store_id}") - logger.info(f"Inference: Invalidated relevant caches for user {auth0_id}") + await invalidate_cache(f"{CACHE_KEYS['STORE_PREFERENCES']}{email}:{store_id}") + logger.info(f"Inference: Invalidated STORE_PREFERENCES for user {email} (auth0Id missing) for {len(user['privacySettings']['optInStores'])} stores.") + elif not email and user.get("privacySettings", {}).get("optInStores"): + logger.warning(f"Inference: Cannot invalidate STORE_PREFERENCES for user as email is missing and auth0Id is missing.") # --- End Cache Invalidation --- else: logger.warning(f"Inference: Update attempted for {user_id} but no documents were modified.") diff --git a/ml-service/app/services/preferenceProcessor.py b/ml-service/app/services/preferenceProcessor.py index ea1a3fa..3616a95 100644 --- a/ml-service/app/services/preferenceProcessor.py +++ b/ml-service/app/services/preferenceProcessor.py @@ -492,17 +492,31 @@ async def process_user_data(data: UserDataEntry, db) -> UserPreferences: logger.info(f"Skipping demographic inference for user {email} ({user_id}) as allowInference is False.") auth0_id = user.get("auth0Id") + email = user.get("email") # Ensure email is fetched + if auth0_id: logger.info(f"Running post-processing cache invalidation for user {auth0_id}.") await invalidate_cache(f"{CACHE_KEYS['USER_DATA']}{auth0_id}") await invalidate_cache(f"{CACHE_KEYS['PREFERENCES']}{auth0_id}") logger.info(f"Invalidated USER_DATA and PREFERENCES caches for user {auth0_id} (post-processing)") - if user.get("privacySettings", {}).get("optInStores"): + + if email and user.get("privacySettings", {}).get("optInStores"): # Check if email is available for store_id in user["privacySettings"]["optInStores"]: - await invalidate_cache(f"{CACHE_KEYS['STORE_PREFERENCES']}{user_id}:{store_id}") - logger.info(f"Invalidated STORE_PREFERENCES for user {auth0_id} for {len(user['privacySettings']['optInStores'])} stores.") + # Use email for STORE_PREFERENCES cache key + await invalidate_cache(f"{CACHE_KEYS['STORE_PREFERENCES']}{email}:{store_id}") + logger.info(f"Invalidated STORE_PREFERENCES for user {email} (Auth0 ID: {auth0_id}) for {len(user['privacySettings']['optInStores'])} stores.") + elif not email and user.get("privacySettings", {}).get("optInStores"): + logger.warning(f"Cannot invalidate STORE_PREFERENCES for user {auth0_id} as email is missing from user object.") + else: - logger.warning(f"Cannot invalidate caches for user {email} as auth0Id is missing.") + logger.warning(f"Cannot invalidate USER_DATA/PREFERENCES caches for user {email} as auth0Id is missing.") + # Attempt to invalidate STORE_PREFERENCES with email if available, even if auth0Id is missing for other caches + if email and user.get("privacySettings", {}).get("optInStores"): + for store_id in user["privacySettings"]["optInStores"]: + await invalidate_cache(f"{CACHE_KEYS['STORE_PREFERENCES']}{email}:{store_id}") + logger.info(f"Invalidated STORE_PREFERENCES for user {email} (auth0Id missing) for {len(user['privacySettings']['optInStores'])} stores.") + elif not email and user.get("privacySettings", {}).get("optInStores"): + logger.warning(f"Cannot invalidate STORE_PREFERENCES for user as email is missing and auth0Id is missing.") return UserPreferences( user_id=user_id, diff --git a/tapiro-api-internal/service/PreferenceManagementService.js b/tapiro-api-internal/service/PreferenceManagementService.js index 91096f9..c691f33 100644 --- a/tapiro-api-internal/service/PreferenceManagementService.js +++ b/tapiro-api-internal/service/PreferenceManagementService.js @@ -103,7 +103,13 @@ exports.optOutFromStore = async function (req, storeId) { ); // Clear relevant caches - await invalidateCache(`${CACHE_KEYS.STORE_PREFERENCES}${user._id}:${storeId}`); + // Ensure user.email is available from the 'user' object fetched earlier. + if (user.email) { + await invalidateCache(`${CACHE_KEYS.STORE_PREFERENCES}${user.email}:${storeId}`); + console.log(`Invalidated store preference cache (email key): ${CACHE_KEYS.STORE_PREFERENCES}${user.email}:${storeId}`); + } else { + console.warn(`User ${user._id} (Auth0 ID: ${userData.sub}) opted out from store ${storeId} but email is missing. Cannot invalidate STORE_PREFERENCES by email.`); + } await invalidateCache(`${CACHE_KEYS.PREFERENCES}${userData.sub}`); await invalidateCache(`${CACHE_KEYS.USER_DATA}${userData.sub}`); // User profile cache might contain privacy settings @@ -194,7 +200,7 @@ exports.updateUserPreferences = async function (req, body) { // No need to fetch again if we trust the update, but it confirms the write const updatedUser = await db.collection('users').findOne( { _id: user._id }, - { projection: { preferences: 1, updatedAt: 1 } } + { projection: { preferences: 1, updatedAt: 1, email: 1, privacySettings: 1 } } ); // Clear related caches @@ -202,23 +208,24 @@ exports.updateUserPreferences = async function (req, body) { await invalidateCache(userCacheKey); console.log(`Invalidated general preferences cache: ${userCacheKey}`); + // Invalidate USER_DATA cache as the user document (updatedAt) has changed + const userDataCacheKey = `${CACHE_KEYS.USER_DATA}${userData.sub}`; + await invalidateCache(userDataCacheKey); + console.log(`Invalidated user data cache: ${userDataCacheKey}`); + // Clear store-specific preference caches as preferences changed - if (user.privacySettings?.optInStores && user.privacySettings.optInStores.length > 0) { - console.log(`Invalidating store-specific preferences for user ${user._id} (Auth0 ID: ${userData.sub}) across ${user.privacySettings.optInStores.length} stores.`); - for (const storeId of user.privacySettings.optInStores) { - // Invalidate cache key used internally (if any) or by other services using MongoDB ID - const internalStorePrefCacheKey = `${CACHE_KEYS.STORE_PREFERENCES}${user._id}:${storeId}`; - await invalidateCache(internalStorePrefCacheKey); - console.log(`Invalidated internal store preferences cache: ${internalStorePrefCacheKey}`); - - // Also invalidate the cache key used by the external API (which uses email as userId) - if (userData.email) { - const externalStorePrefCacheKey = `${CACHE_KEYS.STORE_PREFERENCES}${userData.email}:${storeId}`; - await invalidateCache(externalStorePrefCacheKey); - console.log(`Invalidated external API store preferences cache: ${externalStorePrefCacheKey}`); - } else { - console.warn(`User email not found in userData for Auth0 ID ${userData.sub}. Cannot invalidate external API store preferences cache by email for store ${storeId}.`); + if (updatedUser.privacySettings?.optInStores && updatedUser.privacySettings.optInStores.length > 0) { + const userEmail = updatedUser.email; + if (userEmail) { + console.log(`Invalidating store-specific preferences for user ${updatedUser._id} (Email: ${userEmail}) across ${updatedUser.privacySettings.optInStores.length} stores.`); + for (const storeId of updatedUser.privacySettings.optInStores) { + // Standardize to use email for the cache key + const storePrefCacheKeyByEmail = `${CACHE_KEYS.STORE_PREFERENCES}${userEmail}:${storeId}`; + await invalidateCache(storePrefCacheKeyByEmail); + console.log(`Invalidated store preference cache (email key): ${storePrefCacheKeyByEmail}`); } + } else { + console.warn(`User ${updatedUser._id} (Auth0 ID: ${userData.sub}) has opt-in stores but email is missing. Cannot invalidate STORE_PREFERENCES by email.`); } } @@ -296,7 +303,13 @@ exports.optInToStore = async function (req, storeId) { ); // Clear relevant caches - await invalidateCache(`${CACHE_KEYS.STORE_PREFERENCES}${user._id}:${storeId}`); + // Ensure user.email is available from the 'user' object fetched earlier. + if (user.email) { + await invalidateCache(`${CACHE_KEYS.STORE_PREFERENCES}${user.email}:${storeId}`); + console.log(`Invalidated store preference cache (email key): ${CACHE_KEYS.STORE_PREFERENCES}${user.email}:${storeId}`); + } else { + console.warn(`User ${user._id} (Auth0 ID: ${userData.sub}) opted into store ${storeId} but email is missing. Cannot invalidate STORE_PREFERENCES by email.`); + } await invalidateCache(`${CACHE_KEYS.PREFERENCES}${userData.sub}`); await invalidateCache(`${CACHE_KEYS.USER_DATA}${userData.sub}`); // User profile cache might contain privacy settings diff --git a/tapiro-api-internal/service/UserProfileService.js b/tapiro-api-internal/service/UserProfileService.js index fd872d3..33c0533 100644 --- a/tapiro-api-internal/service/UserProfileService.js +++ b/tapiro-api-internal/service/UserProfileService.js @@ -266,10 +266,10 @@ exports.deleteUserProfile = async function (req) { // Get user data - use req.user if available (from middleware) or fetch it const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); - // Find user to get ID for cache invalidation later + // Find user to get ID and email for cache invalidation later const user = await db .collection('users') - .findOne({ auth0Id: userData.sub }, { projection: { _id: 1, privacySettings: 1 } }); + .findOne({ auth0Id: userData.sub }, { projection: { _id: 1, email: 1, privacySettings: 1 } }); // Ensure email is projected if (!user) { return respondWithCode(404, { code: 404, @@ -297,10 +297,16 @@ exports.deleteUserProfile = async function (req) { await invalidateCache(`${CACHE_KEYS.PREFERENCES}${userData.sub}`); // Clear related store preference caches - if (userPrivacySettings?.optInStores) { + if (user.email && userPrivacySettings?.optInStores && userPrivacySettings.optInStores.length > 0) { // Check if user.email is available and stores exist + console.log(`Invalidating store-specific preferences for deleted user ${user.email} across ${userPrivacySettings.optInStores.length} stores.`); for (const storeId of userPrivacySettings.optInStores) { - await invalidateCache(`${CACHE_KEYS.STORE_PREFERENCES}${userObjectId}:${storeId}`); + // Use email for the cache key + const storePrefCacheKey = `${CACHE_KEYS.STORE_PREFERENCES}${user.email}:${storeId}`; + await invalidateCache(storePrefCacheKey); + console.log(`Invalidated store preference cache (email key): ${storePrefCacheKey}`); } + } else if (!user.email && userPrivacySettings?.optInStores && userPrivacySettings.optInStores.length > 0) { + console.warn(`User ${user._id} (Auth0 ID: ${userData.sub}) was deleted and had opt-in stores, but email was missing. Cannot invalidate STORE_PREFERENCES by email.`); } return respondWithCode(204); From 91c0126181e6d02a8392608fd0faead48265b912 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 20 May 2025 03:57:57 +0530 Subject: [PATCH 30/51] feat: Update User Preferences display and Product components to include category name mapping --- demo/src/App.tsx | 177 ++++++++++++++++++---------- demo/src/components/ProductCard.tsx | 5 +- demo/src/components/ProductList.tsx | 3 + 3 files changed, 123 insertions(+), 62 deletions(-) diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 25b5b9d..b2d9c91 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -548,61 +548,119 @@ function App() {
- {/* Display User Preferences */} + {/* User Info & Preferences Display Area */} + {(userEmail || apiKey) && ( +
+
+
+

+ Demo Store +

+ {userEmail && ( +

+ User: {userEmail} +

+ )} +

+ API Key:{" "} + {displayApiKey} +

+
+
+ + +
+
+ {apiError && ( +
+ Error: {apiError} +
+ )} + {apiSuccessMessage && ( +
+ Success: {apiSuccessMessage} +
+ )} +
+ )} + + {/* Preferences Display - only if preferences exist */} {preferences && preferences.length > 0 && (
-

- Your Preferences -

-
    - {preferences.map((pref, index) => { - const categoryName = - categoryNameMap[pref.category] || pref.category; - const displayCategory = categoryNameMap[pref.category] - ? `${categoryName} (${pref.category})` - : pref.category; - - return ( -
  • -

    - Category:{" "} - {displayCategory} -

    -

    - Score:{" "} - - {(pref.score * 100).toFixed(0)}% - -

    - {pref.attributes && - Object.keys(pref.attributes).length > 0 && ( -
    -

    - Attributes: -

    -
      - {Object.entries(pref.attributes).map( - ([attrKey, attrValueObj]) => ( -
    • - {attrKey}:{" "} - {Object.entries(attrValueObj) - .map( - ([val, score]) => - `${val} (${(score * 100).toFixed(0)}%)` - ) - .join(", ")} -
    • - ) - )} -
    -
    - )} -
  • - ); - })} +

    + Your Inferred Preferences +

    +
      + {preferences + .filter((p) => p.score > 0.1) // Only show relevant preferences + .sort((a, b) => b.score - a.score) // Sort by score desc + .slice(0, 10) // Show top 10 + .map((pref) => { + const categoryName = + categoryNameMap[pref.category] || pref.category; + return ( +
    • +
      + + {categoryName} + + 0.65 + ? "bg-green-100 text-green-700 dark:bg-green-700 dark:text-green-100" + : pref.score > 0.35 + ? "bg-yellow-100 text-yellow-700 dark:bg-yellow-600 dark:text-yellow-100" + : "bg-red-100 text-red-700 dark:bg-red-700 dark:text-red-100" + }`} + > + Score: {(pref.score * 100).toFixed(0)}% + +
      + {pref.attributes && + Object.keys(pref.attributes).length > 0 && ( +
      +

      + Attribute Preferences: +

      +
        + {Object.entries(pref.attributes).map( + ([attrKey, attrValueObj]) => ( +
      • + {attrKey}:{" "} + {Object.entries(attrValueObj) + .map( + ([val, score]) => + `${val} (${(score * 100).toFixed( + 0 + )}%)` + ) + .join(", ")} +
      • + ) + )} +
      +
      + )} +
    • + ); + })}
)} @@ -613,22 +671,19 @@ function App() { {searchQuery ? `Search Results for "${searchQuery}"` : "Products"} {isLoadingPrefs && apiKey && - userEmail && ( // Only show loading if key and email are set - - (Loading Preferences...) + userEmail && ( // Only show loading if API key and email are set + + (Loading preferences...) )} - {(!apiKey || !userEmail) && ( // Show message if key or email is missing - - (Set API Key and User Email to see personalized results) - - )}

+
diff --git a/demo/src/components/ProductCard.tsx b/demo/src/components/ProductCard.tsx index c36c1b9..cbb3ceb 100644 --- a/demo/src/components/ProductCard.tsx +++ b/demo/src/components/ProductCard.tsx @@ -5,6 +5,7 @@ interface ProductCardProps { onProductClick?: (product: Product) => void; // Handler for clicks onPurchaseClick?: (product: Product) => void; // Handler for purchase clicks isRecommended?: boolean; // Optional flag for highlighting + categoryNameMap: Record; // Add categoryNameMap prop } export function ProductCard({ @@ -12,6 +13,7 @@ export function ProductCard({ onProductClick, onPurchaseClick, isRecommended, + categoryNameMap, // Destructure categoryNameMap }: ProductCardProps) { const handleCardClick = () => { if (onProductClick) { @@ -52,7 +54,8 @@ export function ProductCard({ {product.name}

- Category: {product.categoryId} + Category:{" "} + {categoryNameMap[product.categoryId] || product.categoryId}

{product.description && (

diff --git a/demo/src/components/ProductList.tsx b/demo/src/components/ProductList.tsx index bc48703..3e2d826 100644 --- a/demo/src/components/ProductList.tsx +++ b/demo/src/components/ProductList.tsx @@ -6,6 +6,7 @@ interface ProductListProps { onProductClick?: (product: Product) => void; // Pass click handler down onPurchaseClick?: (product: Product) => void; // Pass purchase click handler down recommendedProductIds?: Set; // Set of IDs to highlight + categoryNameMap: Record; // Add categoryNameMap prop } export function ProductList({ @@ -13,6 +14,7 @@ export function ProductList({ onProductClick, onPurchaseClick, recommendedProductIds = new Set(), + categoryNameMap, // Destructure categoryNameMap }: ProductListProps) { if (!products || products.length === 0) { return ( @@ -31,6 +33,7 @@ export function ProductList({ onProductClick={onProductClick} onPurchaseClick={onPurchaseClick} isRecommended={recommendedProductIds.has(product.id)} + categoryNameMap={categoryNameMap} // Pass categoryNameMap to ProductCard /> ))}

From 71da6ccc696f749d4ccf1f8d813202ee36602e0a Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 20 May 2025 10:00:28 +0530 Subject: [PATCH 31/51] fix: Adjust padding in Card component on ApiDocsPage for improved layout --- web/src/pages/static/ApiDocsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/pages/static/ApiDocsPage.tsx b/web/src/pages/static/ApiDocsPage.tsx index 4d5b1fa..cb2836f 100644 --- a/web/src/pages/static/ApiDocsPage.tsx +++ b/web/src/pages/static/ApiDocsPage.tsx @@ -220,7 +220,7 @@ export default function ApiDocsPage() { {/* Main Content Area */}
{/* Card already has dark:bg-gray-800 */} - + {/* Introduction Section (Example) */}
Date: Tue, 20 May 2025 10:15:38 +0530 Subject: [PATCH 32/51] fix: Center the Sign Up button in the HomePage for improved layout --- web/src/pages/static/HomePage.tsx | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/web/src/pages/static/HomePage.tsx b/web/src/pages/static/HomePage.tsx index 222c153..c6b22e1 100644 --- a/web/src/pages/static/HomePage.tsx +++ b/web/src/pages/static/HomePage.tsx @@ -299,15 +299,17 @@ export default function HomePage() { Experience a new era of data control and personalization with Tapiro.

- - - +
+ + + +
From 1f09583b0391bf37c1b8f4f3a08557b6dec300fe Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 20 May 2025 10:35:10 +0530 Subject: [PATCH 33/51] feat: Enhance store consent management by adding caching for user consent lists --- .../service/PreferenceManagementService.js | 19 ++++++++++++------- tapiro-api-internal/utils/cacheConfig.js | 7 ++++--- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/tapiro-api-internal/service/PreferenceManagementService.js b/tapiro-api-internal/service/PreferenceManagementService.js index c691f33..a0650c2 100644 --- a/tapiro-api-internal/service/PreferenceManagementService.js +++ b/tapiro-api-internal/service/PreferenceManagementService.js @@ -311,7 +311,8 @@ exports.optInToStore = async function (req, storeId) { console.warn(`User ${user._id} (Auth0 ID: ${userData.sub}) opted into store ${storeId} but email is missing. Cannot invalidate STORE_PREFERENCES by email.`); } await invalidateCache(`${CACHE_KEYS.PREFERENCES}${userData.sub}`); - await invalidateCache(`${CACHE_KEYS.USER_DATA}${userData.sub}`); // User profile cache might contain privacy settings + await invalidateCache(`${CACHE_KEYS.USER_DATA}${userData.sub}`); + await invalidateCache(`${CACHE_KEYS.USER_STORE_CONSENT}${userData.sub}`); return respondWithCode(204); } catch (error) { @@ -325,15 +326,20 @@ exports.optInToStore = async function (req, storeId) { */ exports.getStoreConsentLists = async function (req) { try { - // Get user data - use req.user if available (from middleware) or fetch it const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); + // Try cache first + const consentCacheKey = `${CACHE_KEYS.USER_STORE_CONSENT}${userData.sub}`; + const cachedConsentLists = await getCache(consentCacheKey); + if (cachedConsentLists) { + return respondWithCode(200, JSON.parse(cachedConsentLists)); + } + const db = getDB(); - // Find user in database using Auth0 ID, projecting only necessary fields const user = await db.collection('users').findOne( { auth0Id: userData.sub }, - { projection: { 'privacySettings.optInStores': 1, 'privacySettings.optOutStores': 1, _id: 0 } } // Only get opt-in/out lists + { projection: { 'privacySettings.optInStores': 1, 'privacySettings.optOutStores': 1, _id: 0 } } ); if (!user) { @@ -343,14 +349,13 @@ exports.getStoreConsentLists = async function (req) { }); } - // Prepare the response object, defaulting to empty arrays if fields don't exist const consentLists = { optInStores: user.privacySettings?.optInStores || [], optOutStores: user.privacySettings?.optOutStores || [], }; - // Note: Caching could be added here if needed, potentially using a specific key - // or relying on the USER_DATA cache invalidation from opt-in/out actions. + // Cache the result + await setCache(consentCacheKey, JSON.stringify(consentLists), { EX: CACHE_TTL.USER_DATA }); // Using USER_DATA TTL, adjust if needed return respondWithCode(200, consentLists); } catch (error) { diff --git a/tapiro-api-internal/utils/cacheConfig.js b/tapiro-api-internal/utils/cacheConfig.js index a07e3ff..f3cba85 100644 --- a/tapiro-api-internal/utils/cacheConfig.js +++ b/tapiro-api-internal/utils/cacheConfig.js @@ -21,9 +21,10 @@ const CACHE_KEYS = { SCOPES: 'scopes:', // Token to scopes mapping ADMIN_TOKEN: 'auth0_management_token', // Auth0 management token PREFERENCES: 'preferences:', // User preferences - STORE_PREFERENCES: 'prefs:', // Store preferences - TAXONOMY: 'taxonomy:current', // <-- Add this line - AI_REQUEST: 'ai_request:', // AI service request cache + STORE_PREFERENCES: 'prefs:', + TAXONOMY: 'taxonomy:current', + AI_REQUEST: 'ai_request:', + USER_STORE_CONSENT: 'userconsent:', // New key for store consent lists }; module.exports = { CACHE_TTL, CACHE_KEYS }; From 4be3538b512c3674b66f815ca9eb55789fb6364b Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 20 May 2025 11:18:15 +0530 Subject: [PATCH 34/51] feat: Add schemas for PurchaseItem and PurchaseEntry to OpenAPI specification --- demo/src/App.tsx | 403 +++++++++++++++++++++++---- tapiro-api-internal/api/openapi.yaml | 85 +++++- 2 files changed, 436 insertions(+), 52 deletions(-) diff --git a/demo/src/App.tsx b/demo/src/App.tsx index b2d9c91..fae46ac 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -19,6 +19,23 @@ interface UserPreferences { updatedAt: string; } +// --- NEW: Define types for simulated purchase data --- +interface SimulatedPurchaseItem { + sku: string; + name: string; + category: string; + price: number; + quantity: number; + attributes: Record; // Or a more specific type if attributes have a consistent structure +} + +interface SimulatedPurchaseEntry { + timestamp: string; // ISO 8601 format + items: SimulatedPurchaseItem[]; + totalValue: number; +} +// --- END NEW: Define types --- + // Simple map for category IDs to names for the demo const categoryNameMap: Record = { "100": "Electronics", @@ -54,6 +71,75 @@ const categoryNameMap: Record = { "1400": "Software", }; +// --- NEW: Products designed to trigger demographic inference --- +const demographicTriggerProducts: Product[] = [ + { + id: "sim-p1", + name: "Organic Baby Food Variety Pack", + price: 25, + imageUrl: "/products/apples.webp", // Placeholder image + categoryId: "701", // Baby Gear + attributes: { type: "food", dietary_preference: "organic" }, + description: "A selection of organic purees for infants.", + }, + { + id: "sim-p2", + name: "Luxury Wedding Anniversary Gift Basket", + price: 75, + imageUrl: "/products/giftbasket.webp", // Placeholder image + categoryId: "1300", // Gifts + attributes: { occasion: "anniversary", recipient: "couple" }, + description: "Perfect for celebrating a wedding anniversary.", + }, + { + id: "sim-p3", + name: "University Student Textbook: Advanced Statistics", + price: 120, + imageUrl: "/products/fictionnoval.webp", // Placeholder image + categoryId: "501", // Books + attributes: { genre: "academic", subject: "statistics" }, + description: "Required textbook for university-level statistics course.", + }, + { + id: "sim-p4", + name: "Men's Classic Leather Wallet", + price: 45, + imageUrl: "/products/tshirt.webp", // Placeholder image, replace with actual if available + categoryId: "201", // Apparel (could be accessories) + attributes: { type: "wallet", gender: "men", material: "leather" }, + description: "A stylish and durable leather wallet for men.", + }, + { + id: "sim-p5", + name: "Women's Floral Print Summer Dress", + price: 60, + imageUrl: "/products/tshirt.webp", // Placeholder image, replace with actual if available + categoryId: "201", // Apparel + attributes: { type: "dress", gender: "women", season: "summer" }, + description: "A light and airy floral dress for women.", + }, + { + id: "sim-p6", + name: "Professional Business Laptop Bag", + price: 80, + imageUrl: "/products/suitecase.webp", // Placeholder image + categoryId: "800", // Office Supplies (or a more specific category if available) + attributes: { type: "bag", use: "business", material: "nylon" }, + description: + "A durable and professional bag for carrying laptops and documents.", + }, + { + id: "sim-p7", + name: "Newborn Baby Essentials Set (Diapers, Wipes, Onesies)", + price: 55, + imageUrl: "/products/genpens.webp", // Placeholder image + categoryId: "701", // Baby Gear + attributes: { type: "newborn_set", contents: "diapers_wipes_onesies" }, + description: "A complete starter set for a newborn baby.", + }, +]; +// --- END NEW: Products --- + function App() { const [userEmail, setUserEmail] = useState(null); const [apiKey, setApiKey] = useState(null); // State for API Key @@ -66,6 +152,7 @@ function App() { useState(sampleProducts); // Products to show (filtered/sorted) const [preferences, setPreferences] = useState(null); const [isLoadingPrefs, setIsLoadingPrefs] = useState(false); + const [isSimulatingData, setIsSimulatingData] = useState(false); // --- NEW: Loading state for simulation --- const [apiError, setApiError] = useState(null); const [apiSuccessMessage, setApiSuccessMessage] = useState( null @@ -232,6 +319,175 @@ function App() { } }; + // --- NEW: Handler for Simulate Bulk Data --- + const handleSimulateBulkData = async () => { + if (!userEmail || !apiKey) { + setApiError("Please set User Email and API Key before simulating data."); + if (!apiKey) setIsApiKeyModalOpen(true); + else if (!userEmail) setIsEmailModalOpen(true); + return; + } + + setIsSimulatingData(true); + setApiSuccessMessage(null); + setApiError(null); + + const allPurchaseEntries: SimulatedPurchaseEntry[] = []; + const numMonths = 6; + const purchasesPerWeekMin = 1; + const purchasesPerWeekMax = 3; + const itemsPerPurchaseMin = 1; + const itemsPerPurchaseMax = 3; + const today = new Date(); + + // --- Define focused product pools --- + const kidsTriggerProducts = demographicTriggerProducts.filter( + (p) => ["sim-p1", "sim-p7"].includes(p.id) // Organic Baby Food, Newborn Essentials + ); + const marriedTriggerProducts = demographicTriggerProducts.filter( + (p) => p.id === "sim-p2" // Luxury Wedding Anniversary Gift Basket + ); + const maleTriggerProducts = demographicTriggerProducts.filter( + (p) => p.id === "sim-p4" // Men's Classic Leather Wallet + ); + const femaleTriggerProducts = demographicTriggerProducts.filter( + (p) => p.id === "sim-p5" // Women's Floral Print Summer Dress + ); + + const highlyTargetedProducts = Array.from( + new Set([ + ...kidsTriggerProducts, + ...marriedTriggerProducts, + ...maleTriggerProducts, + ...femaleTriggerProducts, + ]) + ); + + // Products for general variety, excluding those already in highlyTargetedProducts + const varietyPool = [ + ...sampleProducts, + ...demographicTriggerProducts.filter( + (p) => !highlyTargetedProducts.find((ht) => ht.id === p.id) + ), + ]; + + // Fallback pool, same as original + const combinedProductPool = [ + ...sampleProducts, + ...demographicTriggerProducts, + ]; + // --- End focused product pools --- + + for (let week = 0; week < numMonths * 4; week++) { + const purchasesThisWeek = + Math.floor( + Math.random() * (purchasesPerWeekMax - purchasesPerWeekMin + 1) + ) + purchasesPerWeekMin; + for (let p = 0; p < purchasesThisWeek; p++) { + const simDate = new Date(today); + simDate.setDate( + today.getDate() - week * 7 - Math.floor(Math.random() * 7) + ); // Random day within the target week + simDate.setHours( + Math.floor(Math.random() * 24), + Math.floor(Math.random() * 60), + Math.floor(Math.random() * 60) + ); + + const purchaseItems: SimulatedPurchaseItem[] = []; + let totalValue = 0; + const numItems = + Math.floor( + Math.random() * (itemsPerPurchaseMax - itemsPerPurchaseMin + 1) + ) + itemsPerPurchaseMin; + + for (let i = 0; i < numItems; i++) { + let productToPurchase: Product; + const randomChoice = Math.random(); + + // 70% chance to pick a product focused on kids, marriage, or gender + if (randomChoice < 0.7 && highlyTargetedProducts.length > 0) { + productToPurchase = + highlyTargetedProducts[ + Math.floor(Math.random() * highlyTargetedProducts.length) + ]; + } + // 30% chance for a product from the general variety pool + else { + if (varietyPool.length > 0) { + productToPurchase = + varietyPool[Math.floor(Math.random() * varietyPool.length)]; + } else if (highlyTargetedProducts.length > 0) { + // Fallback if variety pool is empty + productToPurchase = + highlyTargetedProducts[ + Math.floor(Math.random() * highlyTargetedProducts.length) + ]; + } else if (combinedProductPool.length > 0) { + // Absolute fallback + productToPurchase = + combinedProductPool[ + Math.floor(Math.random() * combinedProductPool.length) + ]; + } else { + console.error("CRITICAL: No products available for simulation!"); + // If this happens, the simulation might generate empty purchases or fail. + // Consider adding a default placeholder product or stopping simulation. + // For now, we'll let it proceed, but it indicates a setup issue. + continue; // Skip this item if no product can be selected + } + } + + purchaseItems.push({ + sku: productToPurchase.id, + name: productToPurchase.name, + category: productToPurchase.categoryId, + price: productToPurchase.price, + quantity: 1, + attributes: productToPurchase.attributes || {}, + }); + totalValue += productToPurchase.price; + } + + if (purchaseItems.length > 0) { + allPurchaseEntries.push({ + timestamp: simDate.toISOString(), + items: purchaseItems, + totalValue: totalValue, + }); + } + } + } + + if (allPurchaseEntries.length === 0) { + setApiError("No purchase entries generated for simulation."); + setIsSimulatingData(false); + return; + } + + const payload = { + email: userEmail, + dataType: "purchase", + entries: allPurchaseEntries, + metadata: { source: "demo-bulk-simulation" }, + }; + + console.log(`Simulating ${allPurchaseEntries.length} purchase entries...`); + const result = await makeApiCall("/users/data", "POST", payload, true); + + if (result.success) { + setApiSuccessMessage( + `Successfully submitted ${allPurchaseEntries.length} simulated purchase entries. Analytics and demographics will update shortly.` + ); + // Optionally, trigger a refetch of preferences or analytics data + // await fetchPreferences(userEmail); + } else { + // Error is set by makeApiCall + } + setIsSimulatingData(false); + }; + // --- END NEW: Handler --- + // --- API Call Functions --- const makeApiCall = async ( endpoint: string, @@ -317,6 +573,7 @@ function App() { const prefsData = result.data as UserPreferences; setPreferences(prefsData.preferences || []); } else { + // Error is set by makeApiCall setPreferences(null); // Clear prefs on error } }; @@ -487,67 +744,113 @@ function App() { onSubmit={handleEmailSubmit} /> -
-
-

+ {/* Header Area */} +
+
+

Tapiro Demo Store

- {/* User and API Key Info */} -
-
- API Key: {displayApiKey} - -
-
- {userEmail ? `Simulating as: ${userEmail}` : "User Email Not Set"} - {userEmail && ( - - )} - {!userEmail && - apiKey && ( // Show button to set email if key is set but email isn't - - )} -
-
-
- {" "} - {/* Ensure search bar wraps correctly */} - +
+ + User: {userEmail || "Not Set"} + + + API Key: {displayApiKey} + + +
- {/* Display API Status Messages */} +
+ + {/* Main Content Area */} +
+ {/* API Messages */} {apiError && ( -
- {apiError} +
+ Error: {apiError}
)} {apiSuccessMessage && ( -
- {apiSuccessMessage} +
+ Success: {apiSuccessMessage} +
+ )} + + {/* --- NEW: Simulate Data Button --- */} +
+ +
+ {/* --- END NEW: Simulate Data Button --- */} + + + + {isLoadingPrefs && !preferences && ( +
+
)} -

-
{/* User Info & Preferences Display Area */} {(userEmail || apiKey) && (
diff --git a/tapiro-api-internal/api/openapi.yaml b/tapiro-api-internal/api/openapi.yaml index 073ed9c..40064c5 100644 --- a/tapiro-api-internal/api/openapi.yaml +++ b/tapiro-api-internal/api/openapi.yaml @@ -1467,6 +1467,83 @@ components: description: "Inferred: User gender identity (null if unknown or user provided)" enum: [male, female, non-binary, null] + PurchaseItem: # Added + type: object + required: + - name + - category + properties: + sku: + type: string + description: Stock Keeping Unit or unique product identifier. + name: + type: string + description: Name of the purchased item. + category: + type: string + description: Category ID or name matching the taxonomy. + price: + type: number + format: float + description: Price of a single unit of the item. + quantity: + type: integer + description: Number of units purchased. + default: 1 + attributes: + type: object + description: Key-value pairs representing product attributes. + additionalProperties: {} # Allows values of any type, aligning with potential data structures + + PurchaseEntry: # Added + type: object + required: + - timestamp + - items + properties: + timestamp: + type: string + format: date-time + description: ISO 8601 timestamp of when the purchase occurred. + items: + type: array + description: List of items included in the purchase. + items: + $ref: "#/components/schemas/PurchaseItem" + totalValue: + type: number + format: float + nullable: true + description: Optional total value of the purchase event. + + SearchEntry: # Added + type: object + required: + - timestamp + - query + properties: + timestamp: + type: string + format: date-time + description: ISO 8601 timestamp of when the search occurred. + query: + type: string + description: The search query string entered by the user. + category: + type: string + nullable: true + description: Optional category context provided during the search. + results: + type: integer + nullable: true + description: Optional number of results returned for the search query. + clicked: + type: array + nullable: true + description: Optional list of product IDs or SKUs clicked from the search results. + items: + type: string + RecentUserDataEntry: type: object properties: @@ -1489,8 +1566,12 @@ components: format: date-time description: The timestamp of the original event (e.g., purchase time). details: - type: object - description: Simplified details (e.g., item count for purchase, query string for search) + type: array + description: Array of individual purchase or search events within this batch. + items: + oneOf: + - $ref: "#/components/schemas/PurchaseEntry" + - $ref: "#/components/schemas/SearchEntry" StoreBasicInfo: type: object From 360ac1354ca94e949dae16508daa6eeba8b1737d Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 20 May 2025 11:18:50 +0530 Subject: [PATCH 35/51] feat: Add PurchaseItem and PurchaseEntry interfaces to data contracts --- web/src/api/types/data-contracts.ts | 56 +++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index f961522..9c879e8 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -425,6 +425,58 @@ export interface DemographicData { inferredGender?: "male" | "female" | "non-binary" | null; } +export interface PurchaseItem { + /** Stock Keeping Unit or unique product identifier. */ + sku?: string; + /** Name of the purchased item. */ + name: string; + /** Category ID or name matching the taxonomy. */ + category: string; + /** + * Price of a single unit of the item. + * @format float + */ + price?: number; + /** + * Number of units purchased. + * @default 1 + */ + quantity?: number; + /** Key-value pairs representing product attributes. */ + attributes?: Record; +} + +export interface PurchaseEntry { + /** + * ISO 8601 timestamp of when the purchase occurred. + * @format date-time + */ + timestamp: string; + /** List of items included in the purchase. */ + items: PurchaseItem[]; + /** + * Optional total value of the purchase event. + * @format float + */ + totalValue?: number | null; +} + +export interface SearchEntry { + /** + * ISO 8601 timestamp of when the search occurred. + * @format date-time + */ + timestamp: string; + /** The search query string entered by the user. */ + query: string; + /** Optional category context provided during the search. */ + category?: string | null; + /** Optional number of results returned for the search query. */ + results?: number | null; + /** Optional list of product IDs or SKUs clicked from the search results. */ + clicked?: string[] | null; +} + export interface RecentUserDataEntry { /** The unique ID of the userData entry. */ _id?: string; @@ -442,8 +494,8 @@ export interface RecentUserDataEntry { * @format date-time */ entryTimestamp?: string; - /** Simplified details (e.g., item count for purchase, query string for search) */ - details?: object; + /** Array of individual purchase or search events within this batch. */ + details?: (PurchaseEntry | SearchEntry)[]; } export interface StoreBasicInfo { From 0f6aee583810b46e5dfdb60f561f32d3abb6831b Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 20 May 2025 11:28:06 +0530 Subject: [PATCH 36/51] feat: Add new indexes for userData collection to enhance query performance and search capabilities --- tapiro-api-internal/utils/mongoUtil.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tapiro-api-internal/utils/mongoUtil.js b/tapiro-api-internal/utils/mongoUtil.js index e69b240..c723605 100644 --- a/tapiro-api-internal/utils/mongoUtil.js +++ b/tapiro-api-internal/utils/mongoUtil.js @@ -129,6 +129,23 @@ async function setupIndexes(db) { await db.collection('userData').createIndex({ userId: 1, timestamp: -1 }); await db.collection('userData').createIndex({ email: 1 }); await db.collection('userData').createIndex({ storeId: 1, timestamp: -1 }); + + // New indexes for filtering by dataType and storeId, user-centric + await db.collection('userData').createIndex({ userId: 1, dataType: 1, timestamp: -1 }); + await db.collection('userData').createIndex({ userId: 1, storeId: 1, timestamp: -1 }); + await db.collection('userData').createIndex({ userId: 1, dataType: 1, storeId: 1, timestamp: -1 }); + + // New text index for searchTerm + await db.collection('userData').createIndex( + { + "entries.items.name": "text", + "entries.items.category": "text", + "entries.query": "text" + }, + { + name: "userData_text_search_idx" + } + ); console.log('MongoDB indexes successfully configured'); } catch (error) { From c4012122bf6ac509291b89390e764b31a8eb57c2 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 20 May 2025 11:45:44 +0530 Subject: [PATCH 37/51] feat: Enhance UserPreferencesPage with additional demographic fields and improved handling for user data verification --- .../UserDashboard/UserPreferencesPage.tsx | 253 ++++++++---------- 1 file changed, 110 insertions(+), 143 deletions(-) diff --git a/web/src/pages/UserDashboard/UserPreferencesPage.tsx b/web/src/pages/UserDashboard/UserPreferencesPage.tsx index 84d44a1..9650f2b 100644 --- a/web/src/pages/UserDashboard/UserPreferencesPage.tsx +++ b/web/src/pages/UserDashboard/UserPreferencesPage.tsx @@ -555,25 +555,16 @@ const UserPreferencesPage: React.FC = () => { // --- CHANGE HERE --- valueToVerify: string | number | boolean | null | undefined, ) => { - setIsEditingDemographics(true); - // Use timeout to ensure state update completes before setting value + // console.log("Verifying field:", fieldName, "with value:", valueToVerify); // Debug + setIsEditingDemographics(true); // Switch to edit mode + + // Use a timeout to ensure the form is in edit mode before setting value setTimeout(() => { - // Convert boolean "Yes"/"No" back to boolean for ToggleSwitch - // --- FIX: Handle potential undefined from valueToVerify --- - let formValue: string | number | boolean | null = valueToVerify ?? null; + let formValue: string | number | boolean | null | undefined = + valueToVerify; - // Find the corresponding value for enum fields OR hasKids - if (fieldName === "hasKids") { - // Find the option matching the display label - const option = hasKidsOptions.find((o) => o.label === valueToVerify); - // Convert the option's string value back to boolean/null - formValue = - option?.value === "true" - ? true - : option?.value === "false" - ? false - : null; - } else if (fieldName === "gender") + // Transform value for specific fields if necessary (e.g., for select options) + if (fieldName === "gender") formValue = genderOptions.find((o) => o.label === valueToVerify)?.value ?? null; else if (fieldName === "country") @@ -599,6 +590,12 @@ const UserPreferencesPage: React.FC = () => { // Use valueToVerify here as formValue might already be null formValue = typeof valueToVerify === "number" ? valueToVerify : null; } + // For boolean 'hasKids', ensure it's boolean or null + else if (fieldName === "hasKids") { + if (valueToVerify === "Yes") formValue = true; + else if (valueToVerify === "No") formValue = false; + else formValue = null; // Or handle "Prefer not to say" if it maps to a specific string + } // Use setValueDemo with the potentially transformed formValue setValueDemo(fieldName, formValue, { shouldDirty: true }); @@ -857,16 +854,24 @@ const UserPreferencesPage: React.FC = () => { ) : ( // --- DISPLAY VIEW (Combined User-Provided and Inferred) ---
- {/* User Provided or Verified */} + {/* Gender */} { fieldName="gender" onVerify={handleVerify} /> + {/* Age */} + {/* Country */} + {/* Income Bracket */} + {/* Has Kids */} + {/* Relationship Status */} { fieldName="relationshipStatus" onVerify={handleVerify} /> + {/* Employment Status */} { fieldName="employmentStatus" onVerify={handleVerify} /> + {/* Education Level */} { fieldName="educationLevel" onVerify={handleVerify} /> - - {/* Display inferred values ONLY if user hasn't provided one */} - {!userProfile?.demographicData?.gender && - userProfile?.demographicData?.inferredGender && ( - - )} - {!userProfile?.demographicData?.hasKids && - userProfile?.demographicData?.inferredHasKids !== null && ( - - )} - {!userProfile?.demographicData?.relationshipStatus && - userProfile?.demographicData?.inferredRelationshipStatus && ( - - )} - {!userProfile?.demographicData?.employmentStatus && - userProfile?.demographicData?.inferredEmploymentStatus && ( - - )} - {!userProfile?.demographicData?.educationLevel && - userProfile?.demographicData?.inferredEducationLevel && ( - - )}
)} From 58efe0e413b48d178e50c23f5cc226b2cbd1f081 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 20 May 2025 12:00:25 +0530 Subject: [PATCH 38/51] feat: Add inferred gender display and improve DemoInfoCard styling in UserPreferencesPage --- web/src/pages/UserDashboard/UserDashboard.tsx | 20 +++++++++++++++++-- .../UserDashboard/UserPreferencesPage.tsx | 13 ++++++------ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/web/src/pages/UserDashboard/UserDashboard.tsx b/web/src/pages/UserDashboard/UserDashboard.tsx index 3650695..d1bfa4b 100644 --- a/web/src/pages/UserDashboard/UserDashboard.tsx +++ b/web/src/pages/UserDashboard/UserDashboard.tsx @@ -177,6 +177,7 @@ interface DemoInfoCardProps { label: string; value: string | number | null | undefined; isLoading?: boolean; + isInferred?: boolean; // Added isInferred } const DemoInfoCard: React.FC = ({ @@ -184,9 +185,11 @@ const DemoInfoCard: React.FC = ({ label, value, isLoading, + isInferred, // Added isInferred }) => (
- + {" "} + {/* Changed icon color */}

{label} @@ -196,6 +199,12 @@ const DemoInfoCard: React.FC = ({ ) : (

{value || "Not set"} + {isInferred && + value && ( // Added inferred text display + + (inferred) + + )}

)}
@@ -772,8 +781,15 @@ export default function UserDashboard() { = ({ onVerify, // Destructure }) => (
- +

{label} @@ -177,14 +177,14 @@ const DemoInfoCard: React.FC = ({ {isLoading ? ( ) : ( -

+
{" "} - {/* Wrap value and button */} + {/* Added justify-between */}

{value || "Not set"} {isInferred && value && ( // Show "(inferred)" text - + (inferred) )} @@ -193,8 +193,9 @@ const DemoInfoCard: React.FC = ({ {isInferred && value && !isLoading && fieldName && onVerify && ( )}

From 5257c7d2f813549a33152350578ab4b3a412b1f4 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 20 May 2025 12:27:18 +0530 Subject: [PATCH 40/51] feat: Update button styles to blue and improve clarity in UserAnalyticsPage --- .../pages/UserDashboard/UserAnalyticsPage.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/web/src/pages/UserDashboard/UserAnalyticsPage.tsx b/web/src/pages/UserDashboard/UserAnalyticsPage.tsx index d1314fe..a4045b6 100644 --- a/web/src/pages/UserDashboard/UserAnalyticsPage.tsx +++ b/web/src/pages/UserDashboard/UserAnalyticsPage.tsx @@ -42,7 +42,7 @@ import { StoreBasicInfo, MonthlySpendingItem, GetRecentUserDataParams, - PurchaseItem, // Assuming PurchaseItem is the type for purchase details items + PurchaseItem, // Assuming PurchaseItem is the type for purchase details PurchaseEntry, // <-- Import PurchaseEntry SearchEntry, // Assuming SearchEntry is the type for search details } from "../../api/types/data-contracts"; @@ -268,7 +268,7 @@ const UserAnalyticsPage: React.FC = () => { placeholder="End Date" /> {(spendingStartDate || spendingEndDate) && ( - )} @@ -389,7 +389,12 @@ const UserAnalyticsPage: React.FC = () => { />
-
@@ -490,19 +495,21 @@ const UserAnalyticsPage: React.FC = () => {
- + Page {activityPage}
diff --git a/web/src/pages/UserDashboard/UserAnalyticsPage.tsx b/web/src/pages/UserDashboard/UserAnalyticsPage.tsx index a4045b6..264ec79 100644 --- a/web/src/pages/UserDashboard/UserAnalyticsPage.tsx +++ b/web/src/pages/UserDashboard/UserAnalyticsPage.tsx @@ -422,7 +422,7 @@ const UserAnalyticsPage: React.FC = () => {

) : ( <> -
+
From a874980f912b0cf7270faae9a3205ee7ac44fc1d Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 20 May 2025 15:27:10 +0530 Subject: [PATCH 42/51] feat: Add verification and unverification functionality for demographic fields in UserPreferencesPage --- .../UserDashboard/UserPreferencesPage.tsx | 279 ++++++++++++------ 1 file changed, 191 insertions(+), 88 deletions(-) diff --git a/web/src/pages/UserDashboard/UserPreferencesPage.tsx b/web/src/pages/UserDashboard/UserPreferencesPage.tsx index bafdbce..3b04a02 100644 --- a/web/src/pages/UserDashboard/UserPreferencesPage.tsx +++ b/web/src/pages/UserDashboard/UserPreferencesPage.tsx @@ -150,13 +150,13 @@ interface DemoInfoCardProps { label: string; value: string | number | null | undefined; isLoading?: boolean; - isInferred?: boolean; // Added: Flag for inferred data - fieldName?: keyof DemographicsFormData; // Added: Field name for verification + isInferred?: boolean; + fieldName?: keyof DemographicsFormData; onVerify?: ( fieldName: keyof DemographicsFormData, - // --- CHANGE HERE --- valueToVerify: string | number | boolean | null | undefined, - ) => void; // Added: Handler for verify button + ) => void; + onUnverify?: (fieldName: keyof DemographicsFormData) => void; // Added onUnverify } const DemoInfoCard: React.FC = ({ @@ -164,47 +164,74 @@ const DemoInfoCard: React.FC = ({ label, value, isLoading, - isInferred, // Destructure - fieldName, // Destructure - onVerify, // Destructure -}) => ( -
- -
-

- {label} -

- {isLoading ? ( - - ) : ( -
-

- {value || "Not set"} - {isInferred && value && ( - - (inferred) - - )} + isInferred, + fieldName, + onVerify, + onUnverify, +}) => { + const showVerifyButton = !!( + isInferred && + value && + value !== "Not set" && + onVerify + ); + const showUnverifyButton = !!(value && value !== "Not set" && onUnverify); + const showAnyButton = showVerifyButton || showUnverifyButton; + + return ( +

+
+ +
+

+ {label}

- {/* Replace Verify button with icon button */} - {isInferred && value && !isLoading && fieldName && onVerify && ( + {isLoading ? ( + + ) : ( +

+ {value || "Not set"} + {isInferred && value && value !== "Not set" && ( + + (inferred) + + )} +

+ )} +
+
+ {/* Buttons section - below the main content */} + {!isLoading && fieldName && isInferred && showAnyButton && ( +
+ {showVerifyButton && ( + )} + {showUnverifyButton && ( + )}
)}
-
-); -// --- End Mini Demographic Card Component --- + ); +}; const UserPreferencesPage: React.FC = () => { // --- Data Fetching (Keep existing) --- @@ -264,7 +291,6 @@ const UserPreferencesPage: React.FC = () => { register: registerDemo, handleSubmit: handleDemoSubmit, reset: resetDemoForm, - setValue: setValueDemo, // <-- Get setValue for verification formState: { isDirty: isDemoDirty, errors: demoErrors }, // <-- Add errors } = useForm(); @@ -549,62 +575,128 @@ const UserPreferencesPage: React.FC = () => { setEditingPreferenceIndex(index); setShowPreferenceModal(true); }; + // --- REVISED handleVerify --- const handleVerify = ( fieldName: keyof DemographicsFormData, - // --- CHANGE HERE --- valueToVerify: string | number | boolean | null | undefined, ) => { - // console.log("Verifying field:", fieldName, "with value:", valueToVerify); // Debug - setIsEditingDemographics(true); // Switch to edit mode + let apiValue: string | number | boolean | null | undefined = valueToVerify; - // Use a timeout to ensure the form is in edit mode before setting value - setTimeout(() => { - let formValue: string | number | boolean | null | undefined = - valueToVerify; + // Transform displayed value back to API value if necessary + if (fieldName === "gender") { + apiValue = + genderOptions.find((o) => o.label === valueToVerify)?.value ?? null; + } else if (fieldName === "country") { + apiValue = + countryOptions.find((o) => o.label === valueToVerify)?.value ?? null; + } else if (fieldName === "incomeBracket") { + apiValue = + incomeOptions.find((o) => o.label === valueToVerify)?.value ?? null; + } else if (fieldName === "relationshipStatus") { + apiValue = + relationshipOptions.find((o) => o.label === valueToVerify)?.value ?? + null; + } else if (fieldName === "employmentStatus") { + apiValue = + employmentOptions.find((o) => o.label === valueToVerify)?.value ?? null; + } else if (fieldName === "educationLevel") { + apiValue = + educationOptions.find((o) => o.label === valueToVerify)?.value ?? null; + } else if (fieldName === "age") { + apiValue = + typeof valueToVerify === "number" + ? valueToVerify + : valueToVerify === "Not set" + ? null + : parseInt(String(valueToVerify), 10); + if (apiValue !== null && isNaN(Number(apiValue))) apiValue = null; + } else if (fieldName === "hasKids") { + if (valueToVerify === "Yes") apiValue = true; + else if (valueToVerify === "No") apiValue = false; + else apiValue = null; + } - // Transform value for specific fields if necessary (e.g., for select options) - if (fieldName === "gender") - formValue = - genderOptions.find((o) => o.label === valueToVerify)?.value ?? null; - else if (fieldName === "country") - formValue = - countryOptions.find((o) => o.label === valueToVerify)?.value ?? null; - else if (fieldName === "incomeBracket") - formValue = - incomeOptions.find((o) => o.label === valueToVerify)?.value ?? null; - else if (fieldName === "relationshipStatus") - formValue = - relationshipOptions.find((o) => o.label === valueToVerify)?.value ?? - null; - else if (fieldName === "employmentStatus") - formValue = - employmentOptions.find((o) => o.label === valueToVerify)?.value ?? - null; - else if (fieldName === "educationLevel") - formValue = - educationOptions.find((o) => o.label === valueToVerify)?.value ?? - null; - // Ensure age is treated as a number or null for the form - else if (fieldName === "age") { - // Use valueToVerify here as formValue might already be null - formValue = typeof valueToVerify === "number" ? valueToVerify : null; - } - // For boolean 'hasKids', ensure it's boolean or null - else if (fieldName === "hasKids") { - if (valueToVerify === "Yes") formValue = true; - else if (valueToVerify === "No") formValue = false; - else formValue = null; // Or handle "Prefer not to say" if it maps to a specific string - } + if (apiValue === "Not set") apiValue = null; + + const demoPayload: Partial = { + [fieldName]: apiValue, + }; - // Use setValueDemo with the potentially transformed formValue - setValueDemo(fieldName, formValue, { shouldDirty: true }); + const finalPayload: UserUpdate = { + demographicData: demoPayload, + }; + + updateProfile(finalPayload, { + onSuccess: () => { + setToastInfo({ + type: "success", + message: `${labelForField(fieldName)} verified successfully.`, + }); + resetUpdateProfileMutation(); + }, + onError: (err: Error) => { + setToastInfo({ + type: "error", + message: + err.message || `Failed to verify ${labelForField(fieldName)}.`, + }); + resetUpdateProfileMutation(); + }, + }); + }; + + // --- NEW handleUnverify --- + const handleUnverify = (fieldName: keyof DemographicsFormData) => { + const demoPayload: Partial = { + [fieldName]: null, // Setting to null clears the user-provided value + }; - // Optional: Focus the element after setting value - const element = document.getElementById(fieldName); - element?.focus(); - element?.scrollIntoView({ behavior: "smooth", block: "center" }); - }, 0); - console.log(`Verifying ${fieldName} with value:`, valueToVerify); + const finalPayload: UserUpdate = { + demographicData: demoPayload, + }; + + updateProfile(finalPayload, { + onSuccess: () => { + setToastInfo({ + type: "success", + message: `User value for ${labelForField(fieldName)} cleared successfully.`, + }); + resetUpdateProfileMutation(); + }, + onError: (err: Error) => { + setToastInfo({ + type: "error", + message: + err.message || + `Failed to clear user value for ${labelForField(fieldName)}.`, + }); + resetUpdateProfileMutation(); + }, + }); + }; + + // Helper to get a display-friendly label for toast messages + const labelForField = (fieldName: keyof DemographicsFormData): string => { + switch (fieldName) { + case "gender": + return "Gender"; + case "age": + return "Age"; + case "country": + return "Country"; + case "incomeBracket": + return "Income Bracket"; + case "hasKids": + return "Has Children"; + case "relationshipStatus": + return "Relationship Status"; + case "employmentStatus": + return "Employment Status"; + case "educationLevel": + return "Education Level"; + default: + return fieldName; + } }; // --- Render Logic --- const isLoading = profileLoading || preferencesLoading || taxonomyLoading; @@ -852,7 +944,7 @@ const UserPreferencesPage: React.FC = () => { ) : ( // --- DISPLAY VIEW (Combined User-Provided and Inferred) --- -
+
{/* Gender */} { } fieldName="gender" onVerify={handleVerify} + onUnverify={handleUnverify} /> {/* Age */} { label="Age" value={userProfile?.demographicData?.age ?? "Not set"} isLoading={profileLoading} - // No inferred age to verify in this example, but can be added + // No separate inferred age display in this card, so verify/unverify might not apply in the same way + // If age could be inferred and verified/unverified, it would need isInferred logic and handlers. + // For now, only user-provided age is directly managed here. + // fieldName="age" // If you want to allow clearing user-set age + // onUnverify={handleUnverify} // If you want to allow clearing user-set age /> {/* Country */} { : "Not set" // Assuming no inferred country } isLoading={profileLoading} - // No inferred country to verify + // fieldName="country" // If you want to allow clearing user-set country + // onUnverify={handleUnverify} // If you want to allow clearing user-set country /> {/* Income Bracket */} { : "Not set" // Assuming no inferred income } isLoading={profileLoading} - // No inferred income to verify + // fieldName="incomeBracket" // If you want to allow clearing user-set income + // onUnverify={handleUnverify} // If you want to allow clearing user-set income /> {/* Has Kids */} { } fieldName="hasKids" onVerify={handleVerify} + onUnverify={handleUnverify} /> {/* Relationship Status */} { } fieldName="relationshipStatus" onVerify={handleVerify} + onUnverify={handleUnverify} /> {/* Employment Status */} { } fieldName="employmentStatus" onVerify={handleVerify} + onUnverify={handleUnverify} /> {/* Education Level */} { } fieldName="educationLevel" onVerify={handleVerify} + onUnverify={handleUnverify} />
)} From c95042b70a8b215791e383ea107ee287bc7e19fe Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 20 May 2025 15:58:54 +0530 Subject: [PATCH 43/51] feat: Refactor updateUserProfile to enhance demographic data handling and validation --- .../service/UserProfileService.js | 161 ++++++++---------- 1 file changed, 75 insertions(+), 86 deletions(-) diff --git a/tapiro-api-internal/service/UserProfileService.js b/tapiro-api-internal/service/UserProfileService.js index 33c0533..c5ef453 100644 --- a/tapiro-api-internal/service/UserProfileService.js +++ b/tapiro-api-internal/service/UserProfileService.js @@ -55,21 +55,21 @@ exports.getUserProfile = async function (req) { exports.updateUserProfile = async function (req, body) { try { const db = getDB(); - const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); - const auth0UserId = userData.sub; + const tokenUserData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); + const auth0UserId = tokenUserData.sub; + + // Fetch the current user state from DB to compare demographic data + const currentUser = await db.collection('users').findOne({ auth0Id: auth0UserId }); + if (!currentUser) { + return respondWithCode(404, { code: 404, message: 'User not found for update' }); + } // --- Local DB Username Uniqueness Check --- // Keep this check for your application's internal username uniqueness if (body.username) { - const existingUser = await db.collection('users').findOne({ - username: body.username, - auth0Id: { $ne: auth0UserId }, - }); - if (existingUser) { - return respondWithCode(409, { - code: 409, - message: 'Username already taken in application', - }); + const existingUserByUsername = await db.collection('users').findOne({ username: body.username }); + if (existingUserByUsername && existingUserByUsername.auth0Id !== auth0UserId) { + return respondWithCode(409, { code: 409, message: 'Username already taken' }); } } @@ -78,24 +78,20 @@ exports.updateUserProfile = async function (req, body) { // Auth0 will enforce its own uniqueness rules per connection. if (body.username) { try { - await updateUserMetadata(auth0UserId, { nickname: body.username }); - } catch (auth0Error) { - console.error(`Auth0 username update failed for ${auth0UserId}:`, auth0Error); - return respondWithCode(409, { - code: 409, - message: 'Failed to update username with identity provider. It might already be taken.', - }); + await updateUserMetadata(auth0UserId, { username: body.username }); + } catch (authError) { + console.warn(`Auth0 username update failed for ${auth0UserId}:`, authError.message); + // Potentially return error or just log and continue with local DB update } } // --- Phone Number Update in Auth0 --- - if (body.phone && body.phone !== userData.phone_number) { + if (body.phone && body.phone !== tokenUserData.phone_number) { // Use tokenUserData for initial phone try { await updateUserPhone(auth0UserId, body.phone); - } catch (auth0Error) { - // Log and continue, or return error as needed - console.error(`Auth0 phone update failed for ${auth0UserId}:`, auth0Error); - // return respondWithCode(500, { code: 500, message: 'Failed to update phone number with identity provider.' }); + } catch (authError) { + console.warn(`Auth0 phone update failed for ${auth0UserId}:`, authError.message); + // Potentially return error or just log and continue } } @@ -103,70 +99,65 @@ exports.updateUserProfile = async function (req, body) { const updateData = { updatedAt: new Date(), }; - let demographicsChanged = false; // Flag to track if demographics were updated - + let demographicsChanged = false; // Update local DB username and phone if (body.username !== undefined) updateData.username = body.username; if (body.phone !== undefined) updateData.phone = body.phone; // --- Update Demographic Data --- - // Use dot notation to set fields within the demographicData object - - // User-provided fields - if (body.demographicData?.gender !== undefined) { - updateData['demographicData.gender'] = body.demographicData.gender; - updateData['demographicData.inferredGender'] = null; // Clear inferred on user update - demographicsChanged = true; - } - if (body.demographicData?.incomeBracket !== undefined) { - updateData['demographicData.incomeBracket'] = body.demographicData.incomeBracket; - demographicsChanged = true; - } - if (body.demographicData?.country !== undefined) { - updateData['demographicData.country'] = body.demographicData.country; - demographicsChanged = true; - } - if (body.demographicData?.age !== undefined) { - const ageValue = - body.demographicData.age === null ? null : parseInt(body.demographicData.age); - if (ageValue === null || (!isNaN(ageValue) && ageValue >= 0)) { - // Added age >= 0 check - updateData['demographicData.age'] = ageValue; - // No inferred age bracket to clear anymore - demographicsChanged = true; - } else { - console.warn( - `Invalid age value provided for user ${auth0UserId}: ${body.demographicData.age}`, - ); - // Optionally return a 400 error here - // return respondWithCode(400, { code: 400, message: 'Invalid age provided.' }); + if (body.demographicData) { + const newDemoData = body.demographicData; + const currentDemoData = currentUser.demographicData || {}; + + // Helper function to process demographic fields + const processDemographicField = (fieldName, inferredFieldName) => { + if (newDemoData.hasOwnProperty(fieldName)) { + const newValue = newDemoData[fieldName]; + const currentValue = currentDemoData[fieldName]; + + // Always update the user-provided field if it's in the payload + updateData[`demographicData.${fieldName}`] = newValue; + + if (newValue !== currentValue) { + demographicsChanged = true; + } + + // If the new value is null (clearing/unverifying) OR if the user-provided value changed, + // and there's an inferred field, clear the inferred field. + if (inferredFieldName && (newValue === null || newValue !== currentValue)) { + // Check if the inferred field actually changes to trigger demographicsChanged + if (currentDemoData[inferredFieldName] !== null) { + demographicsChanged = true; + } + updateData[`demographicData.${inferredFieldName}`] = null; + } + } + }; + + processDemographicField('gender', 'inferredGender'); + processDemographicField('incomeBracket'); // No inferred counterpart + processDemographicField('country'); // No inferred counterpart + + // Age - special handling for parsing and validation + if (newDemoData.hasOwnProperty('age')) { + const newAgeValue = newDemoData.age === null || newDemoData.age === undefined ? null : parseInt(String(newDemoData.age)); + if (newAgeValue === null || (typeof newAgeValue === 'number' && newAgeValue >= 0 && Number.isInteger(newAgeValue))) { + if (newAgeValue !== currentDemoData.age) { + updateData['demographicData.age'] = newAgeValue; + demographicsChanged = true; + } else { + updateData['demographicData.age'] = newAgeValue; + } + } else { + console.warn(`Invalid age value provided for user ${auth0UserId}: ${newDemoData.age}`); + } } + + processDemographicField('hasKids', 'inferredHasKids'); + processDemographicField('relationshipStatus', 'inferredRelationshipStatus'); + processDemographicField('employmentStatus', 'inferredEmploymentStatus'); + processDemographicField('educationLevel', 'inferredEducationLevel'); } - // --- NEW User-Provided Fields --- - if (body.demographicData?.hasKids !== undefined) { - updateData['demographicData.hasKids'] = body.demographicData.hasKids; - updateData['demographicData.inferredHasKids'] = null; // Clear inferred on user update - demographicsChanged = true; - } - if (body.demographicData?.relationshipStatus !== undefined) { - updateData['demographicData.relationshipStatus'] = body.demographicData.relationshipStatus; - updateData['demographicData.inferredRelationshipStatus'] = null; // Clear inferred on user update - demographicsChanged = true; - } - if (body.demographicData?.employmentStatus !== undefined) { - updateData['demographicData.employmentStatus'] = body.demographicData.employmentStatus; - updateData['demographicData.inferredEmploymentStatus'] = null; // Clear inferred on user update - demographicsChanged = true; - } - if (body.demographicData?.educationLevel !== undefined) { - updateData['demographicData.educationLevel'] = body.demographicData.educationLevel; - updateData['demographicData.inferredEducationLevel'] = null; // Clear inferred on user update - demographicsChanged = true; - } - - // --- REMOVED Verification Flag Handling --- - // The logic for hasKidsIsVerified, relationshipStatusIsVerified, etc. is removed. - // --- End Update Demographic Data --- // Only update allowed privacy settings @@ -189,10 +180,10 @@ exports.updateUserProfile = async function (req, body) { const updateKeys = Object.keys(updateData).filter((key) => key !== 'updatedAt'); if (updateKeys.length === 0) { // Nothing changed - const currentUser = await db + const latestUser = await db .collection('users') .findOne({ auth0Id: auth0UserId }, { projection: { preferences: 0 } }); - return respondWithCode(200, currentUser || { message: 'No changes detected.' }); + return respondWithCode(200, latestUser || { message: 'No changes detected.' }); } console.log(`Updating user ${auth0UserId} with data:`, updateData); @@ -225,7 +216,7 @@ exports.updateUserProfile = async function (req, body) { const updatedUserDoc = result; // Use the returned document from findOneAndUpdate if (demographicsChanged || privacySettingsChanged) { - const userObjectId = updatedUserDoc._id; + const userObjectId = updatedUserDoc._id; // This should be correct from the result const userEmail = updatedUserDoc.email; // Make sure email is available in updatedUserDoc if (userEmail && updatedUserDoc.privacySettings?.optInStores?.length > 0) { @@ -234,10 +225,8 @@ exports.updateUserProfile = async function (req, body) { // Invalidate cache key used by external API (email based) const externalApiCacheKey = `${CACHE_KEYS.STORE_PREFERENCES}${userEmail}:${storeId}`; await invalidateCache(externalApiCacheKey); - console.log(`Invalidated external API cache: ${externalApiCacheKey}`); + console.log(`Invalidated external store preference cache: ${externalApiCacheKey}`); } - } else if (privacySettingsChanged) { // Log if privacy changed but no stores to invalidate for or email missing - console.log(`Privacy settings changed for user ${userObjectId}. Email: ${userEmail}. OptInStores count: ${updatedUserDoc.privacySettings?.optInStores?.length || 0}. No specific external store preference caches to invalidate under these conditions, but general consent check will apply on cache miss.`); } } From 977d90d867173612fdabad0dbcf172a6042d47ff Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 20 May 2025 20:49:06 +0530 Subject: [PATCH 44/51] feat: Add deleteUserDataHistory endpoint and implement user data deletion functionality --- tapiro-api-internal/api/openapi.yaml | 45 ++++ .../controllers/UserProfile.js | 10 + .../service/PreferenceManagementService.js | 1 + .../service/UserProfileService.js | 67 ++++- web/src/api/hooks/useUserHooks.ts | 39 +++ web/src/api/types/Users.ts | 28 +++ web/src/api/types/data-contracts.ts | 7 + .../pages/UserDashboard/UserAnalyticsPage.tsx | 230 ++++++++++++++++-- 8 files changed, 404 insertions(+), 23 deletions(-) diff --git a/tapiro-api-internal/api/openapi.yaml b/tapiro-api-internal/api/openapi.yaml index 40064c5..95f5db7 100644 --- a/tapiro-api-internal/api/openapi.yaml +++ b/tapiro-api-internal/api/openapi.yaml @@ -801,6 +801,35 @@ paths: - oauth2: [user:read] x-swagger-router-controller: StoreProfile + /users/data/history: + delete: + tags: [User Management] + summary: Delete User Data History + description: Allows authenticated users to delete their submitted data history based on a scope or specific entry IDs. + operationId: deleteUserDataHistory + security: + - oauth2: [user:write] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserDataHistoryDeletionRequest" + responses: + "204": + description: User data history deleted successfully. + "400": + $ref: "#/components/responses/BadRequestError" + "401": + $ref: "#/components/responses/UnauthorizedError" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "500": + $ref: "#/components/responses/InternalServerError" + x-swagger-router-controller: UserProfile + components: schemas: AttributeDistribution: @@ -1177,6 +1206,22 @@ components: type: string enum: [purchase, opt-out] + UserDataHistoryDeletionRequest: + type: object + required: + - scope + properties: + scope: + type: string + enum: [today, last7days, all, individual] + description: "Scope of data to delete. If 'individual', entryIds must be provided." + entryIds: + type: array + items: + type: string + description: "Array of specific userData entry IDs to delete. Required if scope is 'individual'." + nullable: true + ApiKeyCreate: type: object required: diff --git a/tapiro-api-internal/controllers/UserProfile.js b/tapiro-api-internal/controllers/UserProfile.js index fd4dc52..9350eca 100644 --- a/tapiro-api-internal/controllers/UserProfile.js +++ b/tapiro-api-internal/controllers/UserProfile.js @@ -50,4 +50,14 @@ module.exports.getSpendingAnalytics = function getSpendingAnalytics(req, res, ne .catch((response) => { utils.writeJson(res, response); }); +}; + +module.exports.deleteUserDataHistory = function deleteUserDataHistory(req, res, next, body) { + UserProfile.deleteUserDataHistory(req, body) + .then((response) => { + utils.writeJson(res, response); + }) + .catch((response) => { + utils.writeJson(res, response); + }); }; \ No newline at end of file diff --git a/tapiro-api-internal/service/PreferenceManagementService.js b/tapiro-api-internal/service/PreferenceManagementService.js index a0650c2..1359a2c 100644 --- a/tapiro-api-internal/service/PreferenceManagementService.js +++ b/tapiro-api-internal/service/PreferenceManagementService.js @@ -112,6 +112,7 @@ exports.optOutFromStore = async function (req, storeId) { } await invalidateCache(`${CACHE_KEYS.PREFERENCES}${userData.sub}`); await invalidateCache(`${CACHE_KEYS.USER_DATA}${userData.sub}`); // User profile cache might contain privacy settings + await invalidateCache(`${CACHE_KEYS.USER_STORE_CONSENT}${userData.sub}`); // Add this line return respondWithCode(204); } catch (error) { diff --git a/tapiro-api-internal/service/UserProfileService.js b/tapiro-api-internal/service/UserProfileService.js index c5ef453..927a299 100644 --- a/tapiro-api-internal/service/UserProfileService.js +++ b/tapiro-api-internal/service/UserProfileService.js @@ -4,7 +4,7 @@ const { respondWithCode } = require('../utils/writer'); const { getUserData } = require('../utils/authUtil'); const { CACHE_TTL, CACHE_KEYS } = require('../utils/cacheConfig'); const { updateUserPhone, deleteAuth0User, updateUserMetadata } = require('../utils/auth0Util'); -const { ObjectId } = require('mongodb'); +const { ObjectId } = require('mongodb'); // Ensure ObjectId is imported /** * Get User Profile @@ -581,3 +581,68 @@ exports.getSpendingAnalytics = async function (req) { return respondWithCode(500, { code: 500, message: 'Internal server error' }); } }; + +/** + * Delete User Data History + * Deletes user's submitted data entries based on scope or specific IDs. + */ +exports.deleteUserDataHistory = async function (req, body) { + try { + const db = getDB(); + const tokenUserData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); + const auth0UserId = tokenUserData.sub; + + const user = await db.collection('users').findOne({ auth0Id: auth0UserId }, { projection: { _id: 1 } }); + if (!user) { + return respondWithCode(404, { code: 404, message: 'User not found.' }); + } + const userId = user._id; // This is the MongoDB ObjectId of the user + + const { scope, entryIds } = body; + + if (!scope) { + return respondWithCode(400, { code: 400, message: 'Scope is required.' }); + } + + const deleteQuery = { userId: userId }; + + const now = new Date(); + switch (scope) { + case 'today': + const startOfToday = new Date(now.setHours(0, 0, 0, 0)); + const endOfToday = new Date(now.setHours(23, 59, 59, 999)); + deleteQuery.timestamp = { $gte: startOfToday, $lte: endOfToday }; + break; + case 'last7days': + const sevenDaysAgo = new Date(now); + sevenDaysAgo.setDate(now.getDate() - 7); + sevenDaysAgo.setHours(0, 0, 0, 0); // Start of 7 days ago + deleteQuery.timestamp = { $gte: sevenDaysAgo, $lte: new Date() /* up to now */ }; + break; + case 'all': + // No additional time filter, will delete all for the user + break; + case 'individual': + if (!entryIds || !Array.isArray(entryIds) || entryIds.length === 0) { + return respondWithCode(400, { code: 400, message: 'entryIds are required for individual scope.' }); + } + try { + deleteQuery._id = { $in: entryIds.map(id => new ObjectId(id)) }; + } catch (e) { + return respondWithCode(400, { code: 400, message: 'Invalid entryId format.' }); + } + break; + default: + return respondWithCode(400, { code: 400, message: 'Invalid scope provided.' }); + } + + const result = await db.collection('userData').deleteMany(deleteQuery); + + console.log(`Deleted ${result.deletedCount} data entries for user ${userId} with scope '${scope}'.`); + + return respondWithCode(204); + } catch (error) { + console.error('Delete user data history failed:', error); + return respondWithCode(500, { code: 500, message: 'Internal server error while deleting data history.' }); + } +}; diff --git a/web/src/api/hooks/useUserHooks.ts b/web/src/api/hooks/useUserHooks.ts index 17e81ae..93fe98e 100644 --- a/web/src/api/hooks/useUserHooks.ts +++ b/web/src/api/hooks/useUserHooks.ts @@ -10,6 +10,7 @@ import { MonthlySpendingAnalytics, GetSpendingAnalyticsParams, GetRecentUserDataParams, // <-- Import params type for recent data + UserDataDeletionRequest, // Import the request type if you defined it in openapi.yaml components } from "../types/data-contracts"; import { useAuth } from "../../hooks/useAuth"; // Import useAuth @@ -218,3 +219,41 @@ export function useStoreConsentLists() { // ...cacheSettings.consent, // Example }); } + +export function useDeleteUserDataHistory() { + const { apiClients, clientsReady } = useApiClients(); + const queryClient = useQueryClient(); + const { isAuthenticated, isLoading: authLoading } = useAuth(); + + return useMutation< + void, // Assuming 204 No Content response + Error, + UserDataDeletionRequest // Type for the request body + >({ + mutationFn: (deletionRequest: UserDataDeletionRequest) => { + if (!clientsReady || !isAuthenticated || authLoading) { + return Promise.reject( + new Error("API client not ready or user not authenticated."), + ); + } + // Assuming your generated client has a method like 'deleteUserDataHistory' + // Adjust the method name if it's different based on your openapi-generator config + return apiClients.users + .deleteUserDataHistory(deletionRequest) + .then((res) => res.data); + }, + onSuccess: () => { + // Invalidate queries that display this data + // This will cause components using these queries to refetch + queryClient.invalidateQueries({ queryKey: cacheKeys.users.recentData() }); + queryClient.invalidateQueries({ + queryKey: cacheKeys.users.spendingAnalytics(), + }); + // Potentially show a success toast + }, + onError: (error) => { + // Potentially show an error toast + console.error("Failed to delete user data history:", error); + }, + }); +} diff --git a/web/src/api/types/Users.ts b/web/src/api/types/Users.ts index 223ba34..0925f71 100644 --- a/web/src/api/types/Users.ts +++ b/web/src/api/types/Users.ts @@ -19,6 +19,7 @@ import { StoreConsentList, User, UserCreate, + UserDataHistoryDeletionRequest, UserMetadataResponse, UserPreferences, UserPreferencesUpdate, @@ -295,4 +296,31 @@ export class Users< format: "json", ...params, }); + /** + * @description Allows authenticated users to delete their submitted data history based on a scope or specific entry IDs. + * + * @tags User Management + * @name DeleteUserDataHistory + * @summary Delete User Data History + * @request DELETE:/users/data/history + * @secure + * @response `204` `void` User data history deleted successfully. + * @response `400` `Error` + * @response `401` `Error` + * @response `403` `Error` + * @response `404` `Error` + * @response `500` `Error` + */ + deleteUserDataHistory = ( + data: UserDataHistoryDeletionRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/data/history`, + method: "DELETE", + body: data, + secure: true, + type: ContentType.Json, + ...params, + }); } diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index 9c879e8..572fb6a 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -238,6 +238,13 @@ export interface StoreUpdate { }[]; } +export interface UserDataHistoryDeletionRequest { + /** Scope of data to delete. If 'individual', entryIds must be provided. */ + scope: "today" | "last7days" | "all" | "individual"; + /** Array of specific userData entry IDs to delete. Required if scope is 'individual'. */ + entryIds?: string[] | null; +} + export interface ApiKeyCreate { /** * Name for the API key diff --git a/web/src/pages/UserDashboard/UserAnalyticsPage.tsx b/web/src/pages/UserDashboard/UserAnalyticsPage.tsx index 264ec79..2bfdeca 100644 --- a/web/src/pages/UserDashboard/UserAnalyticsPage.tsx +++ b/web/src/pages/UserDashboard/UserAnalyticsPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from "react"; // Added useEffect +import React, { useState, useMemo, useEffect } from "react"; // Added useEffect import { Card, Datepicker, @@ -13,6 +13,14 @@ import { TextInput, Select, Label, + Dropdown, // Added Dropdown + Modal, // Added Modal + ModalHeader, + ModalBody, + Toast, + ToastToggle, + DropdownItem, + DropdownDivider, // Added Toast } from "flowbite-react"; import { ResponsiveContainer, @@ -29,22 +37,28 @@ import { HiOutlineSearch, HiChevronLeft, HiChevronRight, + HiTrash, // Added Trash icon + HiExclamation, // Added Exclamation icon for modal + HiCheckCircle, // For success toast + HiXCircle, // For error toast } from "react-icons/hi"; import { useRecentUserData, useSpendingAnalytics, + useDeleteUserDataHistory, } from "../../api/hooks/useUserHooks"; import { useLookupStores } from "../../api/hooks/useStoreHooks"; import LoadingSpinner from "../../components/common/LoadingSpinner"; import ErrorDisplay from "../../components/common/ErrorDisplay"; import { - RecentUserDataEntry, // Keep this import now + RecentUserDataEntry, StoreBasicInfo, MonthlySpendingItem, GetRecentUserDataParams, - PurchaseItem, // Assuming PurchaseItem is the type for purchase details - PurchaseEntry, // <-- Import PurchaseEntry - SearchEntry, // Assuming SearchEntry is the type for search details + PurchaseItem, + PurchaseEntry, + SearchEntry, + UserDataHistoryDeletionRequest, } from "../../api/types/data-contracts"; // --- Helper Functions (Keep existing) --- @@ -112,6 +126,16 @@ const UserAnalyticsPage: React.FC = () => { const [activityPage, setActivityPage] = useState(1); const activityLimit = 15; // Items per page + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [deletionScope, setDeletionScope] = useState< + UserDataHistoryDeletionRequest["scope"] | null + >(null); + const [entryToDeleteId, setEntryToDeleteId] = useState(null); + const [toast, setToast] = useState<{ + message: string; + type: "success" | "error"; + } | null>(null); + // --- Data Fetching --- const { data: spendingData, @@ -144,7 +168,8 @@ const UserAnalyticsPage: React.FC = () => { data: activityData, isLoading: activityLoading, error: activityError, - isPlaceholderData, // Check if data is placeholder (useful for disabling next) + isPlaceholderData, + refetch: refetchActivityData, // Destructure refetch function } = useRecentUserData(activityParams); const activityStoreIds = useMemo(() => { @@ -161,6 +186,9 @@ const UserAnalyticsPage: React.FC = () => { error: storesError, } = useLookupStores(activityStoreIds); + const { mutate: deleteUserData, isPending: isDeletingUserData } = + useDeleteUserDataHistory(); + // --- Memos (Keep existing) --- const storeNameMap = useMemo(() => { const map = new Map(); @@ -231,6 +259,64 @@ const UserAnalyticsPage: React.FC = () => { }; // --- End Pagination Logic --- + useEffect(() => { + if (toast) { + const timer = setTimeout(() => setToast(null), 5000); + return () => clearTimeout(timer); + } + }, [toast]); + + const handleDeleteRequest = ( + scope: UserDataHistoryDeletionRequest["scope"], + entryId?: string, + ) => { + setDeletionScope(scope); + if (entryId) { + setEntryToDeleteId(entryId); + } else { + setEntryToDeleteId(null); + } + setShowDeleteModal(true); + }; + + const confirmDelete = () => { + if (!deletionScope) return; + + const requestBody: UserDataHistoryDeletionRequest = { + scope: deletionScope, + }; + if (deletionScope === "individual" && entryToDeleteId) { + requestBody.entryIds = [entryToDeleteId]; + } else if (deletionScope === "individual" && !entryToDeleteId) { + setToast({ + message: "Error: Entry ID missing for individual deletion.", + type: "error", + }); + setShowDeleteModal(false); + return; + } + + deleteUserData(requestBody, { + onSuccess: () => { + setToast({ + message: "Data history deleted successfully.", + type: "success", + }); + setShowDeleteModal(false); + setDeletionScope(null); + setEntryToDeleteId(null); + refetchActivityData(); // Explicitly refetch the activity data + }, + onError: (error) => { + setToast({ + message: error.message || "Failed to delete data history.", + type: "error", + }); + setShowDeleteModal(false); + }, + }); + }; + // --- Render Logic --- const isLoading = spendingLoading || activityLoading || storesLoading; // Remove combinedError @@ -242,6 +328,18 @@ const UserAnalyticsPage: React.FC = () => { return (
+ {toast && ( + + {toast.type === "success" ? ( + + ) : ( + + )} +
{toast.message}
+ setToast(null)} /> +
+ )} +

Your Data Insights

@@ -325,9 +423,39 @@ const UserAnalyticsPage: React.FC = () => { {/* --- Recent Activity Section --- */} -

- Recent Activity Log -

+
+

+ Recent Activity Log +

+ + handleDeleteRequest("today")} + icon={HiTrash} + > + Delete Today's Activity + + handleDeleteRequest("last7days")} + icon={HiTrash} + > + Delete Last 7 Days + + + handleDeleteRequest("all")} + icon={HiTrash} + className="text-red-700 hover:bg-red-50 dark:text-red-500 dark:hover:bg-red-600" + > + Delete All Activity + + +
+ {/* Activity Filters (Keep existing) */}
{
{/* Activity Table */} - {activityError || storesError ? ( // <-- Check both activityError and storesError + {activityError || storesError ? ( + // ... error display ... - ) : activityLoading && isPlaceholderData ? ( // Show spinner only if loading AND data is placeholder + ) : activityLoading && isPlaceholderData ? (
@@ -430,17 +559,15 @@ const UserAnalyticsPage: React.FC = () => { Type Store Details + Actions {activityData.map((entry: RecentUserDataEntry) => { - // Safely access details[0] const firstDetail = Array.isArray(entry.details) && entry.details.length > 0 ? entry.details[0] : undefined; - - // Cast details based on dataType for better type safety (optional but recommended) const purchaseDetail = entry.dataType === "purchase" ? (firstDetail as PurchaseEntry | undefined) @@ -462,27 +589,38 @@ const UserAnalyticsPage: React.FC = () => { {entry.dataType} - {/* Check storeId before using map */} {entry.storeId ? (storeNameMap.get(entry.storeId) ?? entry.storeId) : "N/A"} - {/* Display relevant details based on type */} {entry.dataType === "purchase" && - purchaseDetail?.items && // Use casted detail and optional chaining + purchaseDetail?.items && purchaseDetail.items.length > 0 && ( {purchaseDetail.items - .map((item: PurchaseItem) => item.name) // Add type to item + .map((item: PurchaseItem) => item.name) .join(", ")} )} {entry.dataType === "search" && - searchDetail?.query && ( // Use casted detail and optional chaining + searchDetail?.query && ( Query: "{searchDetail.query}" )} - {/* Add more detail rendering as needed */} + + + ); @@ -529,6 +667,54 @@ const UserAnalyticsPage: React.FC = () => { )}
+ + {/* Deletion Confirmation Modal */} + !isDeletingUserData && setShowDeleteModal(false)} + popup + > + + +
+ +

+ Are you sure you want to delete this data? + {deletionScope === "today" && + " This will remove all activity recorded today."} + {deletionScope === "last7days" && + " This will remove all activity from the last 7 days."} + {deletionScope === "all" && + " This will remove ALL your activity history."} + {deletionScope === "individual" && + entryToDeleteId && + " This specific entry will be permanently removed."} + This action cannot be undone. +

+
+ + +
+
+
+
); }; From cd4931c0babc02c3a0652fc81becd10edc9638ad Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 20 May 2025 20:53:37 +0530 Subject: [PATCH 45/51] feat: Update delete confirmation modal styling for improved visibility --- web/src/pages/UserDashboard/UserProfilePage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/pages/UserDashboard/UserProfilePage.tsx b/web/src/pages/UserDashboard/UserProfilePage.tsx index a85ed15..dc4a08c 100644 --- a/web/src/pages/UserDashboard/UserProfilePage.tsx +++ b/web/src/pages/UserDashboard/UserProfilePage.tsx @@ -472,7 +472,7 @@ export default function UserProfilePage() {
- +

Are you sure you want to permanently delete your user account?

@@ -495,7 +495,7 @@ export default function UserProfilePage() { )}