From 0018a6600c9a04ada08ba0e7be64528262eccee9 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sat, 21 Feb 2026 14:16:33 +0000 Subject: [PATCH] 1.0 --- backend/src/index.js | 1 + backend/src/routes/products.js | 61 +- backend/src/routes/public.js | 21 + frontend/src/components/NavBarItem.tsx | 5 +- .../Products/ResearchProductCard.tsx | 79 +++ frontend/src/layouts/Authenticated.tsx | 5 +- frontend/src/menuAside.ts | 129 ++--- frontend/src/pages/dashboard.tsx | 525 ++++-------------- frontend/src/pages/index.tsx | 281 +++++----- 9 files changed, 475 insertions(+), 632 deletions(-) create mode 100644 backend/src/routes/public.js create mode 100644 frontend/src/components/Products/ResearchProductCard.tsx diff --git a/backend/src/index.js b/backend/src/index.js index c8fe259..fc58d54 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -94,6 +94,7 @@ require('./auth/auth'); app.use(bodyParser.json()); app.use('/api/auth', authRoutes); +app.use('/api/public', require('./routes/public')); app.use('/api/file', fileRoutes); app.use('/api/pexels', pexelsRoutes); app.enable('trust proxy'); diff --git a/backend/src/routes/products.js b/backend/src/routes/products.js index a6db1b0..8bdf696 100644 --- a/backend/src/routes/products.js +++ b/backend/src/routes/products.js @@ -1,9 +1,10 @@ - const express = require('express'); const ProductsService = require('../services/products'); const ProductsDBApi = require('../db/api/products'); const wrapAsync = require('../helpers').wrapAsync; +const db = require('../db/models'); +const { Op } = require('sequelize'); const router = express.Router(); @@ -65,6 +66,62 @@ router.use(checkCrudPermissions('products')); * description: The Products managing API */ +router.get('/dashboard-stats', wrapAsync(async (req, res) => { + const totalProducts = await db.products.count(); + + const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + const viralAlerts = await db.product_metrics.count({ + where: { + viral_score: { [Op.gt]: 70 }, + recorded_at: { [Op.gte]: twentyFourHoursAgo } + } + }); + + const trendingProducts = await db.product_metrics.count({ + where: { + trending_rank: { [Op.lte]: 100 }, + recorded_at: { [Op.gte]: twentyFourHoursAgo } + } + }); + + const newProducts = await db.products.count({ + where: { + createdAt: { [Op.gte]: twentyFourHoursAgo } + } + }); + + res.status(200).send({ + totalProducts, + viralAlerts, + trendingProducts, + newProducts + }); +})); + +router.get('/trending', wrapAsync(async (req, res) => { + const limit = parseInt(req.query.limit) || 10; + const days = parseInt(req.query.days) || 7; + const timeAgo = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + + const trending = await db.products.findAll({ + include: [{ + model: db.product_metrics, + as: 'product_metrics_product', + where: { + recorded_at: { [Op.gte]: timeAgo } + }, + order: [['trending_rank', 'ASC']], + limit: 1 + }, { + model: db.file, + as: 'product_images', + }], + limit: limit + }); + + res.status(200).send(trending); +})); + /** * @swagger * /api/products: @@ -450,4 +507,4 @@ router.get('/:id', wrapAsync(async (req, res) => { router.use('/', require('../helpers').commonErrorHandler); -module.exports = router; +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/public.js b/backend/src/routes/public.js new file mode 100644 index 0000000..4a351b2 --- /dev/null +++ b/backend/src/routes/public.js @@ -0,0 +1,21 @@ +const express = require('express'); +const db = require('../db/models'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +router.get('/stats', wrapAsync(async (req, res) => { + const totalProducts = await db.products.count(); + + // Some "realistic" starting numbers if DB is low + // But since I should "No dummy data", I'll show the actual count + // If it's 0, it's 0. + + res.status(200).send({ + totalProducts, + viralAlerts: 0, // Need actual data from metrics + successfulSellers: 0 // Need actual data + }); +})); + +module.exports = router; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..4ced3eb 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, {useEffect, useRef, useState} from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' @@ -129,4 +128,4 @@ export default function NavBarItem({ item }: Props) { } return
{NavBarItemComponentContents}
-} +} \ No newline at end of file diff --git a/frontend/src/components/Products/ResearchProductCard.tsx b/frontend/src/components/Products/ResearchProductCard.tsx new file mode 100644 index 0000000..cb174b2 --- /dev/null +++ b/frontend/src/components/Products/ResearchProductCard.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import { useAppSelector } from '../../stores/hooks'; +import BaseIcon from '../BaseIcon'; +import { mdiChartLine, mdiFire, mdiShoppingOutline } from '@mdi/js'; +import Link from 'next/link'; + +type Props = { + product: any; +}; + +const ResearchProductCard = ({ product }: Props) => { + const corners = useAppSelector((state) => state.style.corners); + const cardsStyle = useAppSelector((state) => state.style.cardsStyle); + + // Get latest metrics + const latestMetrics = product.product_metrics_product?.[0] || {}; + + return ( +
+ +
+ +
+ {latestMetrics.viral_score > 70 && ( +
+ +
+ )} +
+ {product.currency} {product.price} +
+
+
+ +
+
+

+ {product.title} +

+

+ + {product.shop_name} +

+
+ +
+
+
Viral Score
+
+ {latestMetrics.viral_score || 0} +
+
+
+
Sales Vel.
+
+ {latestMetrics.sales_velocity || 0} + +
+
+
+ +
+
+ View Deep Analysis +
+
+
+ +
+ ); +}; + +export default ResearchProductCard; diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..26c3572 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' @@ -126,4 +125,4 @@ export default function LayoutAuthenticated({ ) -} +} \ No newline at end of file diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index b4ec473..f0b9b3b 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -4,104 +4,75 @@ import { MenuAsideItem } from './interfaces' const menuAside: MenuAsideItem[] = [ { href: '/dashboard', - icon: icon.mdiViewDashboardOutline, - label: 'Dashboard', - }, - - { - href: '/users/users-list', - label: 'Users', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: icon.mdiAccountGroup ?? icon.mdiTable, - permissions: 'READ_USERS' - }, - { - href: '/roles/roles-list', - label: 'Roles', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable, - permissions: 'READ_ROLES' - }, - { - href: '/permissions/permissions-list', - label: 'Permissions', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: icon.mdiShieldAccountOutline ?? icon.mdiTable, - permissions: 'READ_PERMISSIONS' + icon: icon.mdiChartTimelineVariant, + label: 'Discovery Hub', }, { href: '/products/products-list', - label: 'Products', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiShoppingOutline' in icon ? icon['mdiShoppingOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, + label: 'Product Research', + icon: icon.mdiShoppingOutline, permissions: 'READ_PRODUCTS' }, - { - href: '/product_metrics/product_metrics-list', - label: 'Product metrics', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiChartLine' in icon ? icon['mdiChartLine' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_PRODUCT_METRICS' - }, { href: '/viral_videos/viral_videos-list', - label: 'Viral videos', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiVideoOutline' in icon ? icon['mdiVideoOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, + label: 'Viral Alerts', + icon: icon.mdiVideoOutline, permissions: 'READ_VIRAL_VIDEOS' }, - { - href: '/supplier_matches/supplier_matches-list', - label: 'Supplier matches', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiTruckFastOutline' in icon ? icon['mdiTruckFastOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_SUPPLIER_MATCHES' - }, { href: '/user_tracked_products/user_tracked_products-list', - label: 'User tracked products', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiBookmarkOutline' in icon ? icon['mdiBookmarkOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, + label: 'Tracked Products', + icon: icon.mdiBookmarkOutline, permissions: 'READ_USER_TRACKED_PRODUCTS' }, { href: '/tracked_competitors/tracked_competitors-list', - label: 'Tracked competitors', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiStorefrontOutline' in icon ? icon['mdiStorefrontOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, + label: 'Competitor Tracking', + icon: icon.mdiStorefrontOutline, permissions: 'READ_TRACKED_COMPETITORS' }, { - href: '/subscriptions/subscriptions-list', - label: 'Subscriptions', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiCreditCardOutline' in icon ? icon['mdiCreditCardOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_SUBSCRIPTIONS' + href: '/supplier_matches/supplier_matches-list', + label: 'Supplier Matching', + icon: icon.mdiTruckFastOutline, + permissions: 'READ_SUPPLIER_MATCHES' }, { - href: '/profile', - label: 'Profile', - icon: icon.mdiAccountCircle, - }, - - - { - href: '/api-docs', - target: '_blank', - label: 'Swagger API', - icon: icon.mdiFileCode, - permissions: 'READ_API_DOCS' - }, + label: 'Settings', + icon: icon.mdiCogOutline, + menu: [ + { + href: '/subscriptions/subscriptions-list', + label: 'Subscriptions', + icon: icon.mdiCreditCardOutline, + permissions: 'READ_SUBSCRIPTIONS' + }, + { + href: '/profile', + label: 'User Profile', + icon: icon.mdiAccountCircle, + }, + { + href: '/users/users-list', + label: 'Manage Users', + icon: icon.mdiAccountGroup, + permissions: 'READ_USERS' + }, + { + href: '/roles/roles-list', + label: 'RBAC Roles', + icon: icon.mdiShieldAccountVariantOutline, + permissions: 'READ_ROLES' + }, + { + href: '/api-docs', + target: '_blank', + label: 'Swagger API', + icon: icon.mdiFileCode, + permissions: 'READ_API_DOCS' + }, + ] + } ] -export default menuAside +export default menuAside \ No newline at end of file diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index 6a65588..869e5b4 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -1,6 +1,6 @@ import * as icon from '@mdi/js'; import Head from 'next/head' -import React from 'react' +import React, { useEffect, useState } from 'react' import axios from 'axios'; import type { ReactElement } from 'react' import LayoutAuthenticated from '../layouts/Authenticated' @@ -9,431 +9,144 @@ import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton import BaseIcon from "../components/BaseIcon"; import { getPageTitle } from '../config' import Link from "next/link"; +import { useAppSelector } from '../stores/hooks'; +import ResearchProductCard from '../components/Products/ResearchProductCard'; +import { mdiChartTimelineVariant, mdiFlash, mdiFire, mdiShoppingOutline, mdiPlus } from '@mdi/js'; -import { hasPermission } from "../helpers/userPermissions"; -import { fetchWidgets } from '../stores/roles/rolesSlice'; -import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator'; -import { SmartWidget } from '../components/SmartWidget/SmartWidget'; - -import { useAppDispatch, useAppSelector } from '../stores/hooks'; const Dashboard = () => { - const dispatch = useAppDispatch(); - const iconsColor = useAppSelector((state) => state.style.iconsColor); + const { currentUser } = useAppSelector((state) => state.auth); + const [stats, setStats] = useState({ + totalProducts: 0, + viralAlerts: 0, + trendingProducts: 0, + newProducts: 0 + }); + const [trending, setTrending] = useState([]); + const [loading, setLoading] = useState(true); + const corners = useAppSelector((state) => state.style.corners); const cardsStyle = useAppSelector((state) => state.style.cardsStyle); - const loadingMessage = 'Loading...'; - - - const [users, setUsers] = React.useState(loadingMessage); - const [roles, setRoles] = React.useState(loadingMessage); - const [permissions, setPermissions] = React.useState(loadingMessage); - const [products, setProducts] = React.useState(loadingMessage); - const [product_metrics, setProduct_metrics] = React.useState(loadingMessage); - const [viral_videos, setViral_videos] = React.useState(loadingMessage); - const [supplier_matches, setSupplier_matches] = React.useState(loadingMessage); - const [user_tracked_products, setUser_tracked_products] = React.useState(loadingMessage); - const [tracked_competitors, setTracked_competitors] = React.useState(loadingMessage); - const [subscriptions, setSubscriptions] = React.useState(loadingMessage); - - - const [widgetsRole, setWidgetsRole] = React.useState({ - role: { value: '', label: '' }, - }); - const { currentUser } = useAppSelector((state) => state.auth); - const { isFetchingQuery } = useAppSelector((state) => state.openAi); - - const { rolesWidgets, loading } = useAppSelector((state) => state.roles); - - - async function loadData() { - const entities = ['users','roles','permissions','products','product_metrics','viral_videos','supplier_matches','user_tracked_products','tracked_competitors','subscriptions',]; - const fns = [setUsers,setRoles,setPermissions,setProducts,setProduct_metrics,setViral_videos,setSupplier_matches,setUser_tracked_products,setTracked_competitors,setSubscriptions,]; - - const requests = entities.map((entity, index) => { - - if(hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) { - return axios.get(`/${entity.toLowerCase()}/count`); - } else { - fns[index](null); - return Promise.resolve({data: {count: null}}); - } - - }); - - Promise.allSettled(requests).then((results) => { - results.forEach((result, i) => { - if (result.status === 'fulfilled') { - fns[i](result.value.data.count); - } else { - fns[i](result.reason.message); - } - }); - }); - } - - async function getWidgets(roleId) { - await dispatch(fetchWidgets(roleId)); - } - React.useEffect(() => { + useEffect(() => { if (!currentUser) return; - loadData().then(); - setWidgetsRole({ role: { value: currentUser?.app_role?.id, label: currentUser?.app_role?.name } }); + + const fetchData = async () => { + try { + const [statsRes, trendingRes] = await Promise.all([ + axios.get('/products/dashboard-stats'), + axios.get('/products/trending?limit=8') + ]); + setStats(statsRes.data); + setTrending(trendingRes.data); + } catch (error) { + console.error('Error fetching dashboard data:', error); + } finally { + setLoading(false); + } + }; + + fetchData(); }, [currentUser]); - React.useEffect(() => { - if (!currentUser || !widgetsRole?.role?.value) return; - getWidgets(widgetsRole?.role?.value || '').then(); - }, [widgetsRole?.role?.value]); - - return ( - <> - - - {getPageTitle('Overview')} - - - - - {''} - - - {hasPermission(currentUser, 'CREATE_ROLES') && } - {!!rolesWidgets.length && - hasPermission(currentUser, 'CREATE_ROLES') && ( -

- {`${widgetsRole?.role?.label || 'Users'}'s widgets`} -

- )} + return ( + <> + + {getPageTitle('Discovery Hub')} + + + + +
+ + Add Product +
+ +
-
- {(isFetchingQuery || loading) && ( -
- {' '} - Loading widgets... -
- )} + {/* Stat Cards */} +
+
+
+ +
+
Total Products
+
{stats.totalProducts}
+
- { rolesWidgets && - rolesWidgets.map((widget) => ( - - ))} -
+
+
+ +
+
Viral Alerts (24h)
+
{stats.viralAlerts}
+
- {!!rolesWidgets.length &&
} - -
- - - {hasPermission(currentUser, 'READ_USERS') && -
-
-
-
- Users -
-
- {users} -
+
+
+
-
- +
Trending Products
+
{stats.trendingProducts}
+
+ +
+
+
+
New Today
+
{stats.newProducts}
- } - - {hasPermission(currentUser, 'READ_ROLES') && -
-
-
-
- Roles -
-
- {roles} -
-
-
- -
+ + {/* Trending Section */} +
+
+

+ + Trending Now +

+ + View All Analysis → +
+ + {loading ? ( +
+ {[1, 2, 3, 4].map((i) => ( +
+ ))} +
+ ) : ( +
+ {trending.length > 0 ? ( + trending.map((product) => ( + + )) + ) : ( +
+
+ +
+

No trending products yet

+

+ As soon as we detect significant market movement, they'll appear here. +

+
+ )} +
+ )}
- } - - {hasPermission(currentUser, 'READ_PERMISSIONS') && -
-
-
-
- Permissions -
-
- {permissions} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_PRODUCTS') && -
-
-
-
- Products -
-
- {products} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_PRODUCT_METRICS') && -
-
-
-
- Product metrics -
-
- {product_metrics} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_VIRAL_VIDEOS') && -
-
-
-
- Viral videos -
-
- {viral_videos} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_SUPPLIER_MATCHES') && -
-
-
-
- Supplier matches -
-
- {supplier_matches} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_USER_TRACKED_PRODUCTS') && -
-
-
-
- User tracked products -
-
- {user_tracked_products} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_TRACKED_COMPETITORS') && -
-
-
-
- Tracked competitors -
-
- {tracked_competitors} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_SUBSCRIPTIONS') && -
-
-
-
- Subscriptions -
-
- {subscriptions} -
-
-
- -
-
-
- } - - -
- - - ) + + + ) } Dashboard.getLayout = function getLayout(page: ReactElement) { - return {page} + return {page} } export default Dashboard diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index e6b1168..6da097a 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,169 @@ - import React, { useEffect, useState } from 'react'; import type { ReactElement } from 'react'; import Head from 'next/head'; import Link from 'next/link'; -import BaseButton from '../components/BaseButton'; -import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; +import BaseButton from '../components/BaseButton'; +import { mdiChartTimelineVariant, mdiFlash, mdiTrophy, mdiVideo } from '@mdi/js'; +import BaseIcon from '../components/BaseIcon'; +import axios from 'axios'; +export default function Landing() { + const [stats, setStats] = useState({ + totalProducts: 0, + viralAlerts: 0, + successfulSellers: 0, + }); -export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('video'); - const [contentPosition, setContentPosition] = useState('left'); - const textColor = useAppSelector((state) => state.style.linkColor); - - const title = 'TikTokScout' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( - - ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- - -
) + useEffect(() => { + const fetchPublicStats = async () => { + try { + const res = await axios.get('/public/stats'); + setStats(res.data); + } catch (error) { + console.error('Error fetching public stats:', error); } }; + fetchPublicStats(); + }, []); return ( -
+
- {getPageTitle('Starter Page')} + {getPageTitle('TikTokScout - TikTok Shop Product Research')} - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

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

+ Scale Your TikTok Shop with Data +

+

+ Find winning products, track viral trends, and spy on competitors with the world's most advanced TikTok Shop research tool. +

+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
- - + {/* Background shapes */} +
+
+
- - + {/* Stats Section */} +
+
+
+
{stats.totalProducts > 0 ? stats.totalProducts : 'Join us today'}
+
Products Tracked
+
+
+
Viral Detection
+
Real-time alerts enabled
+
+
+
Winning Products
+
Research Hub Ready
+
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - + + {/* Features Section */} +
+
+
+

Why TikTokScout?

+

+ We provide the tools you need to stay ahead of the curve in the fast-paced world of TikTok commerce. +

+
+
+
+
+ +
+

Real-time Analytics

+

+ Track sales velocity, revenue estimates, and inventory levels in real-time. +

+
+
+
+ +
+

Viral Detection

+

+ Our AI detects products before they go viral, giving you a massive head start. +

+
+
+
+ +
+

Competitor Insights

+

+ See exactly what shops are selling and how they are marketing their top products. +

+
+
+
+ {/* Footer */} +
+
+
+
+ +
+ TikTokScout +
+
+ Privacy + Terms + Dashboard +
+

© 2026 TikTokScout. Built for scale.

+
+
); } -Starter.getLayout = function getLayout(page: ReactElement) { +Landing.getLayout = function getLayout(page: ReactElement) { return {page}; -}; - +}; \ No newline at end of file