Last Updated: 2026-03-04 Status: Phase 5 In Progress - Profile App Management
Problem: Educators face app fragmentation - too many tools scattered across platforms leads to inefficient utilization.
Solution: A consolidated app launcher that surfaces relevant apps at point-of-need through:
- Curated landing page with commonly used education apps
- Chrome extension (new tab + popup) for quick access
- Contextual "bumping" based on time/season relevance
- Optional user personalization with drag-and-drop arrangement
- Scraped MOE school directory (320 schools)
- Scraped staff quick links from 10 schools
- Discovered 42 unique apps used by educators
- Created seed data with frequency rankings
- NeonDB database created
- Drizzle ORM schema defined (8 tables)
- Database seeded with 42 apps
- API route
/api/appswith bump rules logic
- React + Vite + Tailwind setup
- App grid with search and category filters
- Featured app section with time-based bumping
- Responsive design
- Google OAuth integration with client-side auth
- Persistent pinned apps (localStorage + user preferences)
- App submission form for authenticated users
- User dashboard with profile/submissions/submit tabs
- Mobile-responsive pin/unpin with long-press interactions
- UI Design System: String brand colors (#75F8CC mint, #33373B dark, #C0F4FB light)
- Component Architecture: Reusable UI components (Button, Card, AppCard, Header)
- Profile Components: Abstracted ProfileHeader, AppsList, ProfileFooter for dev/prod consistency
- Profile UX: Apps ordered by user contributions first, profile info moved to bottom
- Development Mode: DevProfileMock component for local testing without API dependencies
- Submission UX Improvements:
- Enhanced empty submissions state with actionable CTA (clickable + icon)
- App name autocomplete to prevent duplicate submissions
- Fixed modal styling to match String brand guidelines (mint green buttons)
- Removed duplicate title in submission form
- Submission modal accessible from both homepage and dashboard + buttons
- Email-prefix slug generation (e.g.,
string.sg/lee-kh) β - Public profile API (
/api/profile/[slug]) β - Personal launcher page (
/[slug]) showing pinned + submitted apps β - DevProfileMock for local development without API β
- Profile App Management (NEW):
- "+ Add App" button shown when viewing own profile (replaces empty state)
- Modal with app submission form (with autocomplete)
- Autocomplete detects existing apps and prevents duplicates
- Smart redirect: clicking existing app redirects back to profile
- Auto-pin to homepage + auto-add to profile via query params
-
/api/profile/add-appendpoint to add apps to user_profile_apps - Profile refreshes to show newly added app
- Profile manage mode uses compact cross icon remove affordance (neutral by default, red on hover)
- Featured cards on mobile use swipe-to-reveal actions (pin/unpin + launch) with one-time hint modal
- Profile share copy flows use Clipboard API with
execCommand('copy')fallback + toast feedback
- Inline visibility controls with WYSIWYG public preview toggle
- Hide/show apps from profile (different from unpinning)
- manifest.json for installability
- Service worker for offline caching
- Install prompt on mobile
- Manifest V3 setup
- New tab override + popup
- Drag-and-drop arrangement
| Layer | Technology | Status |
|---|---|---|
| Frontend | React 19 + Vite 7 + TypeScript | β |
| Styling | Tailwind CSS 4 + String Design System | β |
| Components | Abstracted UI Library (Button, Card, etc.) | β |
| Database | NeonDB (PostgreSQL) | β |
| ORM | Drizzle | β |
| API | Vercel Edge Functions | β |
| Auth | NextAuth.js + Google OAuth | β |
| Hosting | Vercel | Ready |
| Extension | Chrome Manifest V3 | π² |
| Mobile | PWA β Capacitor later | π² |
- Edge runtime - Native Vercel Edge support
- Cold starts - ~50ms vs 200-500ms
- NeonDB - First-class serverless driver support
- Bundle size - Much smaller
- Drizzle Studio - Built-in admin UI
string-v2/
βββ api/
β βββ apps.ts # GET /api/apps - Edge function β
β βββ profile/
β β βββ [slug].ts # GET /api/profile/[slug] - Public profile data β
β β βββ manage.ts # Profile management API β
β β βββ add-app.ts # POST /api/profile/add-app - Add app to profile β
β βββ submissions.ts # POST /api/submissions - Submit new app β
β βββ users.ts # POST /api/users - Create/update user β
β βββ preferences.ts # User preferences API β
βββ data/
β βββ schools.json # 320 MOE schools β
β βββ apps-seed.json # 42 apps with metadata β
βββ scripts/
β βββ scrape-schools.ts # School directory scraper β
β βββ seed-apps.ts # Database seeder β
βββ src/
β βββ components/
β β βββ ui/
β β β βββ Button.tsx # Reusable button component β
β β β βββ Card.tsx # Reusable card component β
β β β βββ AppCard.tsx # App display card β
β β β βββ Header.tsx # Navigation header β
β β β βββ Modal.tsx # Modal component β
β β βββ profile/
β β β βββ ProfileHeader.tsx # Profile info component β
β β β βββ AppsList.tsx # Apps grid with sorting β
β β β βββ ProfileFooter.tsx # Branded footer β
β β βββ dashboard/
β β β βββ MySubmissions.tsx # User submissions tab β
β β βββ AppSubmissionForm.tsx # App submission form with autocomplete β
β β βββ DevProfileMock.tsx # Development profile mock β
β β βββ PersonalProfile.tsx # Production profile page β
β β βββ UserDashboard.tsx # User dashboard β
β βββ db/
β β βββ schema.ts # Drizzle schema (8 tables) β
β β βββ index.ts # DB connection β
β βββ lib/
β β βββ auth.ts # Auth utilities β
β βββ types/
β β βββ next-auth.d.ts # NextAuth type extensions β
β βββ App.tsx # Main landing page β
β βββ main.tsx # React entry β
β βββ index.css # Tailwind styles β
βββ STYLING.md # Design system documentation β
βββ drizzle.config.ts # Drizzle config β
βββ vite.config.ts # Vite config β
βββ tailwind.config.js # Tailwind config β
βββ postcss.config.js # PostCSS config β
βββ vercel.json # Vercel routing β
βββ tsconfig.json # TypeScript config β
βββ tsconfig.node.json # Node TypeScript config β
βββ package.json # Dependencies β
βββ index.html # HTML entry β
βββ .env # DATABASE_URL (git ignored)
βββ .env.example # Template for .env
βββ .gitignore # Git ignore rules β
βββ claude.md # This file
apps -- 42 apps seeded β
bump_rules -- Time/date-based promotion rules β
featured_apps -- Daily featured app with messaging
users -- User accounts with slug generation β
user_preferences -- App arrangement, hidden/pinned β
user_app_launches -- Analytics tracking
app_submissions -- UGC with moderation workflow β
user_profile_apps -- Apps visible on public profile (pinned + submitted) β
categories -- 8 categories β
- users.id: TEXT (OAuth provider IDs are strings, not UUIDs)
- users.slug: Generated from email prefix (e.g., lee_kah_how@moe.edu.sg β lee-kah-how)
- user_profile_apps: Links users to apps shown on their public profile
app_type: 'pinned' or 'submitted'is_visible: Controls public visibilitydisplay_order: Custom ordering (future feature)
| Apps | Frequency |
|---|---|
| SC Mobile, Parents Gateway, HRP, OPAL 2.0, School Cockpit | 10/10 |
| iCON | 9/10 |
| iEXAMS, MOE Intranet, SLS | 8/10 |
| Resource Booking System, HR Online | 7/10 |
| MIMS, SSOE2, Academy of Singapore Teachers | 6/10 |
- Administration
- Teaching
- Communication
- HR
- Assessment
- Professional Development
- Productivity
- IT Support
- Pair (pair.gov.sg) - AI suite for public officers
- SmartCompose (smartcompose.gov.sg) - AI remarks writer
- String Bingo (bingo.string.sg) - Classroom icebreakers
| App | Rule | When |
|---|---|---|
| SC Mobile | time_window | 6:00-7:30 AM |
| String Bingo | time_window | 7:30-8:30 AM |
| SmartCompose | date_range | Mid/end quarter |
| iEXAMS | date_range | Exam periods |
# Install dependencies
npm install
# Set up environment (copy and edit)
cp .env.example .env
# Add DATABASE_URL from NeonDB
# Push schema to database (if needed)
npm run db:push
# Seed apps (if needed)
npm run db:seed
# Start dev server
npm run dev
# Opens at http://localhost:3000
# Open Drizzle Studio
npm run db:studio| Script | Description |
|---|---|
npm run dev |
Start Vercel dev (frontend + API) |
npm run dev:vite |
Start Vite only (no API) |
npm run build |
Build for production |
npm run db:push |
Push schema to NeonDB |
npm run db:seed |
Seed apps from research data |
npm run db:studio |
Open Drizzle Studio |
This section documents the abstracted UI components introduced in Phase 4. Always reference this section before creating new components to maintain consistency and prevent hardcoding styling patterns. See also: STYLING.md for design system guidelines.
Purpose: Primary action buttons with consistent styling across the app.
Props:
variant:'primary' | 'secondary' | 'text'(default:'primary')size:'sm' | 'md' | 'lg'(default:'md')disabled:boolean(default:false)onClick,className,children
Variants:
primary: bg-string-mint text-string-dark hover:bg-string-mint-light
secondary: bg-white border border-gray-200 text-string-dark hover:bg-gray-50
text: text-string-mint hover:text-string-mint-light (no background)Sizes:
sm: px-3 py-1.5 text-sm
md: px-4 py-2 text-sm
lg: px-6 py-3 text-baseUsage Example:
<Button variant="primary" size="lg" onClick={handleSubmit}>
Submit App
</Button>Best Practices:
- β
Use
variant="primary"for main CTAs - β
Use
variant="secondary"for cancel/dismiss actions - β
Use
variant="text"for inline/subtle actions - β DON'T hardcode colors directly on button elements
Purpose: Consistent container styling with optional hover effects.
Props:
hover:boolean(default:false) - Enables border-string-mint and shadow on hoveronClick:() => void- Makes card clickableclassName: string - Additional custom classeschildren: ReactNode
Base Styling:
bg-white rounded-xl border border-gray-100 transition-colorsWith Hover:
hover:border-string-mint cursor-pointer hover:shadow-mdUsage Example:
<Card hover onClick={() => navigate('/profile')}>
<div className="p-6">Profile content</div>
</Card>Best Practices:
- β Use for content sections, list items, modals
- β
Enable
hoverprop for clickable cards - β
Add padding via
classNameor child wrapper - β DON'T create inline divs with repeated rounded-xl/border styles
Purpose: Displays app information with consistent layout (used in grids/lists).
Props:
app: Object with{ id, name, description, tagline, logoUrl, category, url, type }onClick:() => void- Card click handler
Key Features:
- Icon Fallback: If no
logoUrl, displays initials (first 2 letters) in mint-on-dark square - Category Badge: Rounded pill with mint background at 10% opacity
- Contributed Badge: Shows "Contributed" tag for
type === 'submitted'apps - Hover Launch Icon: External link icon appears on hover (top-right)
- Responsive Layout: Flex with gap-4, shrink-0 icon, flex-1 content
Styling Pattern:
group bg-white rounded-xl p-6 shadow-sm border border-gray-100
hover:border-string-mint hover:shadow-md transition-all cursor-pointerBest Practices:
- β Use for all app representations in grids/lists
- β Pass complete app object (don't deconstruct prematurely)
- β
Leverage
group-hover:opacity-100pattern for progressive disclosure - β DON'T create custom app cards with different layouts
Purpose: Consistent styling for icon-based actions (settings, close, etc.).
Props:
size:'sm' | 'md' | 'lg'(default:'md')onClick,title,className,children(SVG icon)
Sizes:
sm: w-6 h-6 (icon: w-3 h-3)
md: w-8 h-8 (icon: w-4 h-4)
lg: w-10 h-10 (icon: w-5 h-5)Styling:
rounded-lg flex items-center justify-center transition-all
text-gray-400 hover:bg-string-mint hover:text-string-darkUsage Example:
<IconButton size="md" onClick={handleClose} title="Close">
<svg><!-- X icon --></svg>
</IconButton>Best Practices:
- β Use for toolbar actions, modal close buttons, utility icons
- β
Always include
titleprop for accessibility - β Wrap SVG content in children
- β DON'T create inline buttons for icons without this component
Purpose: Toggle pinned state for apps (filled star when pinned).
Props:
isPinned:boolean- Current pin stateonPin:() => void- Pin handleronUnpin:() => void- Unpin handlersize:'sm' | 'md'(default:'md')className: string
Visual States:
Pinned: text-string-mint bg-string-mint/10 (filled star icon)
Unpinned: text-gray-400 hover:text-string-dark hover:bg-string-mint (outlined star)Key Behavior:
- Calls
e.stopPropagation()to prevent parent click events - Shows appropriate title attribute ("Pin" or "Unpin")
Usage Example:
<PinButton
isPinned={pinnedApps.includes(app.id)}
onPin={() => handlePin(app.id)}
onUnpin={() => handleUnpin(app.id)}
/>Best Practices:
- β Use for app pin/favorite actions
- β Let component handle event propagation
- β DON'T create custom pin buttons with different icons
Purpose: Opens app URL in new tab with security attributes.
Props:
url: string - Target URLsize:'sm' | 'md'(default:'md')className: string
Features:
- Opens in new tab (
target="_blank") - Security:
rel="noopener noreferrer" - Prevents click propagation (
e.stopPropagation()) - External link icon (arrow-up-right)
Styling:
rounded-lg transition-colors text-gray-400
hover:text-string-dark hover:bg-string-mintUsage Example:
<LaunchButton url={app.url} size="md" />Best Practices:
- β Use for all external app launches
- β Let component handle security attributes
- β DON'T create inline
<a>tags for external links
Purpose: Full-screen overlay dialog with accessibility features.
Props:
isOpen:boolean- Modal visibility stateonClose:() => void- Close handlertitle: string - Modal header titlesize:'sm' | 'md' | 'lg' | 'xl'(default:'md')children: ReactNode - Modal content
Sizes:
sm: max-w-md (384px)
md: max-w-lg (512px)
lg: max-w-2xl (672px)
xl: max-w-4xl (896px)Key Features:
- Keyboard Support: Escape key closes modal
- Body Scroll Lock: Prevents background scrolling when open
- Click-Outside: Backdrop click closes modal
- Close Button: X icon in header
- Backdrop: Semi-transparent black overlay (bg-opacity-50)
Structure:
<Modal isOpen={open} onClose={handleClose} title="Submit App" size="lg">
{/* Form or content */}
</Modal>Best Practices:
- β Use for forms, confirmations, detail views
- β Set appropriate size based on content
- β Always provide meaningful title
- β DON'T create custom modal wrappers
Purpose: Top navigation bar for detail/profile pages.
Props:
title: string - Header titlesubtitle: string (optional) - Subtitle textbackUrl: string (default:'/') - Back button destinationrightContent: ReactNode (optional) - Right-aligned content
Styling:
bg-string-dark border-b border-gray-700
title: text-lg font-semibold text-string-mintFeatures:
- Back button with String logo (green variant)
- Customizable right-side content area
- Optional subtitle display
Usage Example:
<Header
title="My Profile"
backUrl="/dashboard"
rightContent={<Button>Edit</Button>}
/>Purpose: Complex header with tab navigation, user menu, and theme toggle.
Props:
isDark: boolean - Current theme stateonToggleTheme:() => void- Theme toggle handlert:(light: string, dark: string) => string- Theme helper functionactiveTab:'profile' | 'submissions'- Current active tabonTabChange:(tab) => void- Tab change handleronSubmitApp:() => void- Submit app button handler
Features:
- Back Button: Returns to home (pushes state + logo)
- Tab Navigation: "Submissions" and "Profile" tabs with border-bottom active state
- Submit App Button: Plus icon button
- Theme Toggle: Sun/moon icons
- User Menu: Avatar/name button with dropdown (sign out option)
- Click-Outside Detection: Closes menu when clicking elsewhere
- Responsive: Hides logo on mobile, shows user initials only
Styling:
bg-string-dark sticky top-0 z-20
Active tab: border-string-mint text-string-mint
Inactive tab: text-gray-400 hover:text-gray-200Best Practices:
- β Use as primary dashboard navigation
- β Maintain sticky positioning for scroll context
- β DON'T duplicate tab navigation patterns
Purpose: Displays user profile metadata (avatar, name, member date, contributions).
Props:
profile:{ name, slug, avatarUrl, memberSince }apps: Array of apps (to count submitted apps)className: string (optional)
Composition:
- Wraps content in
Cardcomponent - Padding:
p-8
Features:
- Avatar Display: 20x20 rounded-2xl with initials fallback
- Name Display: h1 (text-3xl font-bold)
- Member Since: Formatted as "Month YYYY"
- Contributed Badge: Only shows if user has submitted apps (counts
type === 'submitted')
Layout:
Flex layout with gap-6:
- Avatar (w-20 h-20, shrink-0)
- Content (flex-1): Name β Member date β BadgeBest Practices:
- β Use at top of profile pages
- β Let component handle contribution counting
- β Pass full app array for accurate counts
- β DON'T manually construct profile headers
Purpose: Grid display of user's apps with smart sorting (contributions first).
Props:
apps: Array of app objectsuserName: string | null - For empty state messageonAppClick:(app) => void- Card click handler
Features:
- Smart Sorting: Submitted apps (
type === 'submitted') appear before pinned apps - Responsive Grid: 2 columns on mobile (
sm:grid-cols-2), 3 on desktop (lg:grid-cols-3) - Empty State: Icon + message when no apps shared
- Unique Keys: Uses
${app.type}-${app.id}to prevent duplicate key issues
Grid Styling:
grid gap-6 sm:grid-cols-2 lg:grid-cols-3Empty State:
- Centered icon in gray circle
- "No Apps Shared" heading
- Personalized message with userName
Best Practices:
- β Use for all app grid displays on profiles
- β Let component handle sorting logic
- β Pass userName for personalized empty state
- β DON'T manually sort apps before passing to component
Purpose: Consistent footer with "Powered by String" and logo.
Features:
- SVG logo inline (
/Brand Guidelines/4. Svg Separate Files/primary_dark.svg) - Link to home (
href="/") - Hover effect on logo link
Styling:
mt-16 pt-8 border-t border-gray-200 text-center
text-sm text-gray-500Usage:
<ProfileFooter />Best Practices:
- β Use at bottom of all public profile pages
- β No props needed (fully self-contained)
- β DON'T create custom footer variants
Purpose: Mock profile page for development without API dependencies.
Features:
- Mock user data (name, slug, avatar, member date)
- Sample apps array (mix of pinned and submitted)
- Uses
ProfileHeader,AppsList, andProfileFooter - Renders at
/dev-profileroute
When to Use:
- Local development without backend
- UI iteration and component testing
- Design reviews and demos
Note: Should NOT be deployed to production.
| Pattern | Implementation | Where Used |
|---|---|---|
| Hover β Mint | hover:border-string-mint, hover:bg-string-mint, hover:text-string-mint |
Cards, buttons, links |
| Size Variants | sm, md, lg props with consistent padding/dimensions |
Buttons, icons, modals |
| Rounded Corners | rounded-xl (cards/buttons), rounded-lg (small elements), rounded-2xl (avatars) |
All containers |
| Transitions | transition-colors, transition-all duration-200 |
Interactive elements |
| Event Propagation | e.stopPropagation() in nested interactive elements |
PinButton, LaunchButton |
| Initials Fallback | First 2 letters of name, uppercase | AppCard, ProfileHeader |
| Badge Style | px-3 py-1 rounded-full bg-string-mint/10 text-string-dark text-xs/sm font-medium |
Categories, tags |
| Icon Sizing | Component prop controls both button and icon dimensions | IconButton, PinButton |
| Accessibility | title attributes, semantic HTML, keyboard support |
All interactive components |
β AVOID THESE PRACTICES:
-
Hardcoding Colors
// β DON'T <button className="bg-[#75F8CC] text-[#33373B]"> // β DO <Button variant="primary">
-
Inline Style Objects
// β DON'T <div style={{ backgroundColor: '#75F8CC', padding: '16px' }}> // β DO <Card className="p-4 bg-string-mint">
-
Duplicate Component Patterns
// β DON'T <div className="bg-white rounded-xl p-6 shadow-sm border border-gray-100"> // β DO <Card className="p-6">
-
Custom Button Variants
// β DON'T <button className="bg-string-mint hover:bg-string-mint-light px-4 py-2"> // β DO <Button variant="primary" size="md">
-
Inconsistent Sizing
// β DON'T <button className="px-5 py-2.5"> // non-standard sizing // β DO <Button size="md"> // uses predefined sizes
-
Manual Icon Styling
// β DON'T <button className="w-8 h-8 rounded-lg text-gray-400 hover:bg-gray-100"> <svg>...</svg> </button> // β DO <IconButton size="md"> <svg>...</svg> </IconButton>
-
Hardcoded Modal Structures
// β DON'T <div className="fixed inset-0 bg-black bg-opacity-50"> <div className="bg-white rounded-xl max-w-lg">...</div> </div> // β DO <Modal isOpen={open} onClose={close} size="md">...</Modal>
When building new features, ask:
-
Does a component already exist?
- Check
src/components/ui/andsrc/components/profile/first - Review this documentation section
- Check
-
Can I compose existing components?
- Example: Combine
Card+Button+IconButtoninstead of creating new component
- Example: Combine
-
Am I repeating styling patterns?
- If yes, extract to props or create new abstracted component
-
Does this match the design system?
- Verify colors against STYLING.md
- Use established size variants (sm/md/lg)
- Follow spacing conventions (px-4, py-2, gap-4, etc.)
-
Is this maintainable?
- Future developers should understand component usage from props
- Avoid magic numbers or unexplained styling
- STYLING.md: Color palette, typography, layout standards, theme support
- Component Files: Browse
src/components/ui/andsrc/components/profile/for implementation details - Design System: Tailwind configuration in
tailwind.config.js
-- Add slug to users table
ALTER TABLE users ADD COLUMN slug VARCHAR(255) UNIQUE;
-- Create profile apps table for public visibility control
CREATE TABLE user_profile_apps (
user_id VARCHAR(255) REFERENCES users(id),
app_id VARCHAR(255),
app_type VARCHAR(20), -- 'pinned' or 'submitted'
is_visible BOOLEAN DEFAULT true,
display_order INTEGER,
PRIMARY KEY (user_id, app_id, app_type)
);// /api/profile/[slug].ts - Get public profile data β
GET /api/profile/john-doe
Response: { profile: {...}, apps: [...] }
// /api/profile/add-app.ts - Add app to user profile β
POST /api/profile/add-app { appId, userId }
Response: { success: true, message: 'App added to profile' }
// /api/profile/apps.ts - Manage profile apps visibility (FUTURE)
POST /api/profile/apps { appId, type, isVisible }User on profile β Selects existing app from autocomplete
β Clicks "Add to profile and homepage β"
β Redirects to: /[slug]?pin=appId&addToProfile=true
β Profile page useEffect detects params:
1. Calls togglePinnedApp(appId) to pin to homepage
2. Calls /api/profile/add-app to add to profile
3. Reloads profile data to show new app
4. Cleans up URL (removes query params)
// ProfileHeader.tsx - Profile info display (abstracted) β
// AppsList.tsx - Apps grid with contribution ordering β
// ProfileFooter.tsx - Branded footer with SVG logo β
// DevProfileMock.tsx - Development testing component β
// PersonalProfile.tsx - Production profile page β
// UI Components Library β
// Button.tsx, Card.tsx, AppCard.tsx, Header.tsx β
// Design Features β
- User contributions ordered first before pinned apps
- Profile info moved to bottom for content-first UX
- String dark navbar (#33373B) for better contrast
- SVG logo in footer for improved readability
- Abstracted components prevent dev/prod inconsistencies- Email-prefix slug generation: Extract username from email (before @)
- API-first: Public profiles served via
/api/users/[slug] - Dynamic routing:
[slug].tsxcatches all profile URLs - Public by default: Profiles are public, apps visible by default
- Inline controls: Toggle visibility with immediate preview
- Two app sources: Pinned apps + submitted apps (shown immediately on profile; shown on homepage only after approval)
- Profile-specific: Self-submitted apps appear immediately on the user's profile and appear on the main homepage only after approval.
- User signs in β slug auto-generated from email prefix
- Profile automatically public at
string.sg/{slug} - Dashboard shows "Profile Preview" toggle
- When preview mode ON: Shows public view with visibility controls
- Long-press on mobile reveals "Hide from profile" option
- Desktop hover shows visibility toggle icon
# Create these files:
public/manifest.json # PWA manifest
public/sw.js # Service workermkdir extension
# Create manifest.json, popup.html, newtab.htmlProblem: Users couldn't add apps to their profile, had to manually submit and wait for approval Solution:
- Dynamic Empty State - Profile shows "+ Add App" button when viewing your own empty profile (other users see "No Apps Shared")
- Smart Autocomplete - When typing app name, suggests existing apps from library
- Prevent Duplicates - If selecting existing app, shows "Add to profile and homepage β" button instead of submission form
- Auto-Pin Flow - Clicking button redirects back to profile with query params (
?pin=appId&addToProfile=true) - Seamless Integration - Profile detects params, pins to homepage AND adds to profile, then refreshes to show new app
- API Endpoint -
/api/profile/add-apphandles adding apps touser_profile_appstable
User Flow:
- Visit your profile β Click "+ Add App" β Search app name β Select existing app
- Click "Add to profile and homepage β" β Redirects back to profile
- App now appears on profile AND pinned on homepage
Technical Details:
AppsList.tsx: Conditional rendering based onisOwnProfilepropAppSubmissionForm.tsx: NewfromProfileprop changes behavior for existing appsPersonalProfile.tsx&DevProfileMock.tsx: Handle query params withuseEffect/api/profile/add-app.ts: Edge function to insert intouser_profile_apps- URL cleanup: Query params removed after processing via
history.replaceState
Problem: Submission flow was disconnected and modal didn't follow brand guidelines Solution:
- Enhanced Empty State - MySubmissions tab now shows actionable CTA with clickable + icon
- Autocomplete for Duplicates - App name field queries existing apps and warns users about duplicates
- Brand Consistency - Updated all modal styling to use String mint (#75F8CC) instead of generic blue
- Unified Access - Submission modal accessible from both homepage + button and dashboard
- Cleaner UI - Removed duplicate title, modal header now serves as the only title
Technical Details:
AppSubmissionForm.tsx: Added autocomplete dropdown with real-time filteringMySubmissions.tsx: Enhanced empty state with interactive + buttonApp.tsx: Added modal state and wired + button in header- All inputs now use
focus:ring-string-mintfor consistent brand experience
- User clicks + icon (homepage or dashboard)
- Modal opens with app submission form
- User types app name β autocomplete suggests existing apps to prevent duplicates
- If selecting existing app β yellow warning appears
- Form validates and submits with
status: 'pending' - Submitter sees their app immediately in dashboard
- Admin reviews via Drizzle Studio
- Approved β visible globally in app directory
- User visits their own profile β Sees "+ Add App" button (if no apps yet)
- Clicks button β Modal opens with submission form
- Types app name β Autocomplete shows existing apps
- If existing app selected:
- Shows "Add to profile and homepage β" button
- Clicking redirects to profile with query params
- App automatically added to profile + pinned to homepage
- If new app entered:
- Submits for approval (same as dashboard flow)
- App appears in dashboard immediately (pending approval)
- Profile refreshes to show newly added app
- ORM: Drizzle (not Prisma) - edge/serverless optimized
- Auth: Google OAuth + Magic Link
- Mobile: PWA first β Capacitor later
- Admin: Drizzle Studio (no custom panel)
- UGC: Form β manual review
- Component Architecture: Abstracted UI library in
src/components/ui/with variant-based patterns - Styling System: Tailwind + String Design System (see STYLING.md and Component Library section)
- Profile Composition: Separated ProfileHeader, AppsList, ProfileFooter for reusability
- Development Testing: DevProfileMock for API-independent local testing
- Check existing components first - Review Component Library section above
- Consult STYLING.md - Verify color usage and design patterns
- Avoid hardcoding - Use abstracted components with props/variants
- Maintain consistency - Follow established size variants (sm/md/lg) and spacing conventions
- Document changes - Update this file if creating new reusable components
- Does a similar component already exist?
- Can I compose existing components instead?
- Am I using String Design System colors (string-mint, string-dark)?
- Does it support size variants (sm/md/lg) if applicable?
- Have I included TypeScript types for all props?
- Is it documented in claude.md if it's reusable?