Skip to content

YeboShops Frontend

React application architecture with TikTok-style product browsing.

Technology Stack

TechnologyPurpose
ReactUI framework
TypeScriptType safety
React RouterNavigation
TanStack QueryServer state management
ZustandClient state
Tailwind CSSStyling
Framer MotionAnimations

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 functions

Core 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>
  );
}

One chat. Everything done.