YeboShops Frontend
React application architecture with TikTok-style product browsing.
Technology Stack
| Technology | Purpose |
|---|---|
| React | UI framework |
| TypeScript | Type safety |
| React Router | Navigation |
| TanStack Query | Server state management |
| Zustand | Client state |
| Tailwind CSS | Styling |
| Framer Motion | Animations |
Project Structure
src/
├── components/
│ ├── TikTokBrowser/ # Main product browser
│ │ ├── cards/ # Full-screen product cards
│ │ ├── media/ # Media player components
│ │ ├── modals/ # Search, comments, contact
│ │ ├── slider/ # Product slider logic
│ │ ├── ui/ # Product info, actions
│ │ ├── views/ # Shop view
│ │ └── desktop/ # Desktop layout
│ ├── auth/ # Login, signup, OTP
│ ├── shop/ # Shop management
│ ├── chat/ # Messaging UI
│ └── common/ # Shared components
├── pages/ # Route pages
├── hooks/ # Custom React hooks
├── services/ # API clients
├── stores/ # Zustand stores
├── types/ # TypeScript types
└── utils/ # Helper functionsCore Components
TikTok-Style Browser
The signature full-screen vertical swipe interface:
tsx
// components/TikTokBrowser/cards/FullScreenProductCard.tsx
export function FullScreenProductCard({ product, onSwipe, onLike, onComment }) {
return (
<div className="h-screen w-full relative snap-start">
{/* Full-screen media */}
<MediaPlayer
media={product.media}
className="absolute inset-0 object-cover"
/>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
{/* Product info (bottom left) */}
<ProductInfo
title={product.title}
price={product.formattedPrice}
shop={product.shop}
className="absolute bottom-20 left-4"
/>
{/* Action buttons (bottom right) */}
<ProductActions
productId={product.id}
likes={product.stats.likes}
comments={product.stats.commentCount}
onLike={onLike}
onComment={onComment}
className="absolute bottom-20 right-4"
/>
</div>
);
}Product Slider
tsx
// components/TikTokBrowser/slider/ProductSlider.tsx
import { useSwipeable } from 'react-swipeable';
export function ProductSlider({ products, onEndReached }) {
const [currentIndex, setCurrentIndex] = useState(0);
const handlers = useSwipeable({
onSwipedUp: () => {
if (currentIndex < products.length - 1) {
setCurrentIndex(prev => prev + 1);
}
// Load more when near end
if (currentIndex >= products.length - 3) {
onEndReached();
}
},
onSwipedDown: () => {
if (currentIndex > 0) {
setCurrentIndex(prev => prev - 1);
}
},
trackMouse: true
});
return (
<div {...handlers} className="h-screen overflow-hidden snap-y snap-mandatory">
{products.map((product, index) => (
<FullScreenProductCard
key={product.id}
product={product}
active={index === currentIndex}
/>
))}
</div>
);
}Media Player
tsx
// components/TikTokBrowser/media/MediaPlayer.tsx
export function MediaPlayer({ media, autoPlay = true }) {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(autoPlay);
// Auto-play video when in view
useEffect(() => {
if (autoPlay && videoRef.current) {
videoRef.current.play().catch(() => {});
}
}, [autoPlay]);
if (media.type === 'video') {
return (
<video
ref={videoRef}
src={media.url}
loop
muted
playsInline
className="w-full h-full object-cover"
onClick={() => {
if (videoRef.current?.paused) {
videoRef.current.play();
} else {
videoRef.current?.pause();
}
}}
/>
);
}
return (
<img
src={media.url}
alt=""
className="w-full h-full object-cover"
/>
);
}Shop Management
Shop View
tsx
// components/shop/ShopView.tsx
export function ShopView({ shopSlug }) {
const { data: shop, isLoading } = useQuery({
queryKey: ['shop', shopSlug],
queryFn: () => api.shops.getBySlug(shopSlug)
});
const { data: products } = useQuery({
queryKey: ['shop-products', shop?.id],
queryFn: () => api.products.getByShop(shop.id),
enabled: !!shop
});
return (
<div>
{/* Shop header */}
<div className="relative">
<img src={shop.coverImage} className="w-full h-48 object-cover" />
<img
src={shop.logo}
className="absolute -bottom-8 left-4 w-24 h-24 rounded-full border-4 border-white"
/>
</div>
{/* Shop info */}
<div className="px-4 pt-12">
<h1 className="text-2xl font-bold">{shop.name}</h1>
<p className="text-gray-600">{shop.description}</p>
{/* Stats */}
<div className="flex gap-4 mt-4">
<Stat label="Products" value={shop.productCount} />
<Stat label="Followers" value={shop.followersCount} />
<Stat label="Rating" value={shop.ratingAverage.toFixed(1)} />
</div>
{/* Actions */}
<div className="flex gap-2 mt-4">
<FollowButton shopId={shop.id} />
<MessageButton shopId={shop.id} />
</div>
</div>
{/* Products grid */}
<div className="grid grid-cols-2 gap-2 p-4">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
);
}Edit Shop Modal
tsx
// components/shop/EditShopModal.tsx
export function EditShopModal({ shop, onClose }) {
const updateShop = useMutation({
mutationFn: (data) => api.shops.update(shop.id, data),
onSuccess: () => {
queryClient.invalidateQueries(['shop', shop.slug]);
onClose();
}
});
return (
<Modal onClose={onClose}>
<form onSubmit={handleSubmit}>
<ImageUpload
label="Logo"
value={shop.logo}
onChange={(url) => setLogo(url)}
/>
<Input label="Shop Name" defaultValue={shop.name} {...register('name')} />
<Textarea label="Description" defaultValue={shop.description} {...register('description')} />
<CategorySelect
label="Categories"
value={shop.categories}
onChange={(cats) => setCategories(cats)}
/>
<Button type="submit" loading={updateShop.isPending}>
Save Changes
</Button>
</form>
</Modal>
);
}Authentication
Login Page
tsx
// components/auth/LoginPage.tsx
export function LoginPage() {
const login = useMutation({
mutationFn: api.auth.login,
onSuccess: (data) => {
localStorage.setItem('token', data.accessToken);
navigate('/');
}
});
return (
<div className="min-h-screen flex items-center justify-center">
<form onSubmit={handleSubmit} className="w-full max-w-md p-6">
<h1 className="text-2xl font-bold mb-6">Welcome Back</h1>
<PhoneInput
label="Phone Number"
placeholder="+268 7612 3456"
{...register('phone')}
/>
<PasswordInput
label="Password"
{...register('password')}
/>
<Button type="submit" className="w-full mt-4" loading={login.isPending}>
Sign In
</Button>
<p className="text-center mt-4">
Don't have an account? <Link to="/signup">Sign Up</Link>
</p>
</form>
</div>
);
}Auth Provider
tsx
// components/auth/AuthProvider.tsx
const AuthContext = createContext<AuthContextType>(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
// Load user on mount
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
api.users.getMe()
.then(setUser)
.catch(() => localStorage.removeItem('token'))
.finally(() => setLoading(false));
} else {
setLoading(false);
}
}, []);
const login = async (phone: string, password: string) => {
const { accessToken, user } = await api.auth.login({ phone, password });
localStorage.setItem('token', accessToken);
setUser(user);
};
const logout = () => {
localStorage.removeItem('token');
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout, loading }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);Modals
Search Modal
tsx
// components/TikTokBrowser/modals/SearchModal.tsx
export function SearchModal({ onClose }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const search = useMutation({
mutationFn: (q: string) => api.search.products(q),
onSuccess: setResults
});
// Debounced search
useEffect(() => {
const timer = setTimeout(() => {
if (query.length >= 2) {
search.mutate(query);
}
}, 300);
return () => clearTimeout(timer);
}, [query]);
return (
<FullScreenModal onClose={onClose}>
<div className="p-4">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products..."
className="w-full p-3 border rounded-lg"
autoFocus
/>
{/* AI query interpretation */}
{search.data?.expandedQuery && (
<div className="text-sm text-gray-500 mt-2">
Looking for: {search.data.expandedQuery.intent}
</div>
)}
{/* Results */}
<div className="mt-4 space-y-2">
{results.map(product => (
<SearchResultCard
key={product.id}
product={product}
onClick={() => navigate(`/product/${product.slug}`)}
/>
))}
</div>
</div>
</FullScreenModal>
);
}Comments Modal
tsx
// components/TikTokBrowser/modals/CommentsModal.tsx
export function CommentsModal({ productId, onClose }) {
const { data: comments, isLoading } = useQuery({
queryKey: ['comments', productId],
queryFn: () => api.comments.getByProduct(productId)
});
const addComment = useMutation({
mutationFn: (text: string) => api.comments.create(productId, text),
onSuccess: () => {
queryClient.invalidateQueries(['comments', productId]);
setNewComment('');
}
});
return (
<BottomSheet onClose={onClose}>
<div className="flex flex-col h-[70vh]">
<h2 className="text-lg font-bold p-4 border-b">
Comments ({comments?.length || 0})
</h2>
{/* Comments list */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{comments?.map(comment => (
<CommentCard key={comment.id} comment={comment} />
))}
</div>
{/* Add comment */}
<div className="p-4 border-t flex gap-2">
<input
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Add a comment..."
className="flex-1 p-2 border rounded"
/>
<Button
onClick={() => addComment.mutate(newComment)}
disabled={!newComment.trim()}
>
Post
</Button>
</div>
</div>
</BottomSheet>
);
}State Management
Auth Store (Zustand)
typescript
// stores/authStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface AuthState {
token: string | null;
user: User | null;
setAuth: (token: string, user: User) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
user: null,
setAuth: (token, user) => set({ token, user }),
logout: () => set({ token: null, user: null })
}),
{ name: 'auth-storage' }
)
);Feed Store
typescript
// stores/feedStore.ts
export const useFeedStore = create<FeedState>((set) => ({
products: [],
currentIndex: 0,
hasMore: true,
setProducts: (products) => set({ products }),
appendProducts: (newProducts) => set((state) => ({
products: [...state.products, ...newProducts]
})),
setCurrentIndex: (index) => set({ currentIndex: index }),
setHasMore: (hasMore) => set({ hasMore })
}));API Services
typescript
// services/api.ts
const api = {
auth: {
login: (data) => post('/auth/login', data),
signup: (data) => post('/auth/signup', data),
verifyOtp: (data) => post('/auth/verify-signup-otp', data),
},
products: {
getFeed: (params) => get('/feed', params),
getById: (id) => get(`/products/${id}`),
getBySlug: (slug) => get(`/products/slug/${slug}`),
create: (data) => post('/products', data),
update: (id, data) => put(`/products/${id}`, data),
},
shops: {
getBySlug: (slug) => get(`/shops/slug/${slug}`),
getMy: () => get('/shops/my'),
create: (data) => post('/shops', data),
},
search: {
products: (query, filters) => get('/search', { query, ...filters }),
conversational: (query) => post('/search/conversational', { query }),
},
chat: {
getChats: () => get('/chats'),
getMessages: (chatId, params) => get(`/chats/${chatId}/messages`, params),
sendMessage: (data) => post('/chats', data),
},
payments: {
create: (data) => post('/secure-payments', data),
accept: (id) => post(`/secure-payments/${id}/accept`),
complete: (code) => post('/secure-payments/complete', { completionCode: code }),
}
};Responsive Design
Desktop layout with side panel:
tsx
// components/TikTokBrowser/desktop/DesktopProductPanel.tsx
export function DesktopLayout() {
return (
<div className="flex h-screen">
{/* Main product browser (left) */}
<div className="w-[400px] flex-shrink-0">
<ProductSlider products={products} />
</div>
{/* Detail panel (right) */}
<div className="flex-1 bg-white overflow-y-auto">
<DesktopProductPanel product={currentProduct} />
</div>
</div>
);
}