How 24ad.info's AI Classifieds Actually Works Under the Hood
This morning I woke up to a support ticket that read: "Your AI suggested I sell my sofa for £3.50 – is this a bug or are you running a charity?" Fair question. Let's peel back the layers on how 24ad.info's AI-powered classifieds actually functions when you upload that photo of your old iPhone.
The Vision Pipeline: From Pixels to Price
When you drag-and-drop an image onto our post creation form, here's what happens before the form auto-fills:
-
Image Analysis: We pipe your image through OpenRouter's Gemini 2.5 Flash (not the 2.0 version mentioned in some docs – that's text-only). This gives us:
- Object identification ("iPhone 12, 64GB, good condition")
- Context clues (a phone on a desk suggests "for sale", while one in a repair shop suggests "services offered")
- Visible wear/defects (scratches, cracked screen)
-
Market Positioning: The AI cross-references the identified item with:
- Recent similar listings in your country
- Age-based depreciation curves
- Local demand signals (search volume for "iPhone 12" in your region)
-
Price Suggestion: This is where the £3.50 sofa incident occurred. The system:
- Takes the median price of comparable items
- Applies a nudge discount to encourage faster sales
- Caps absurd outliers (hence why you'll never see £0.01 suggestions)
// Simplified price suggestion logic (from server/routers/ai.ts)
const suggestPrice = (item: DetectedItem, country: string) => {
const comparables = await findSimilarListings(item, country);
const medianPrice = calculateMedian(comparables);
return applyPsychologicalPricing(
medianPrice * 0.85, // Encourages pricing slightly under market
country
);
};
The key lesson? Always review the AI's work – it's optimized for quick listing, not perfect pricing.
The Payment Bug That Cost Us Refunds
In our Stripe integration, we hit a textbook "assumption failure":
-
The Flow:
- User pays for a "Premium" listing package
- Stripe Checkout completes
- Our
verifyCheckoutSessionmarks payment as complete... and stopped there
The Missing Step:
// Pre-fix logic (simplified)
const handlePaymentSuccess = async (sessionId) => {
await markPaymentCompleted(sessionId);
// applyPackageToPost() WASN'T CALLED HERE
};
-
The Fallout:
- Users paid for features they didn't receive
- Our support inbox exploded with "Where's my premium badge?"
- Manual refunds + package activations took developer time to resolve
The fix shipped in v1.4.4 was embarrassingly simple – a single function call added to the verification flow. Now we have end-to-end tests mocking this exact scenario.
Location Search: One SQL Query to Rule Them All
Unlike platforms that layer full-text search with post-filtering, our location handling is brutally simple:
// Location search (from server/db-search.ts)
const posts = await getPostsNearbyAdvanced(...); // bounding-box + Haversine in JS
Key decisions:
- No Fuzzy Location: You're either searching in "London" or within a radius of a point – no magic "near me" expansion
-
Single-Pass Query: Avoids the common pitfall of:
- First find matching text
- Then filter by location
- Then re-sort by relevance
- Postcode Exactitude: UK postcodes (e.g., "SW1A 1AA") resolve to precision via an exact-match lookup against an indexed varchar(20) postcode column on the cities table
The Deployment Script That Saved Our Translations
Early on, we nuked all German/French translations during a routine deploy. The culprit?
rsync --delete ./dist /var/www/24ad.info/ # DELETED locales/ folder
Now, deploy_prod.sh enforces:
rsync --exclude "locales" ...
And we have a pre-deploy checklist:
- Verify
enabled-languages.jsonexists on server - Confirm no
--deleteflag without--exclude - Test on dev.24ad.info first
Why tRPC's Router Composition Matters
Our API structure avoids the "mega-router" anti-pattern:
// How we compose routers (server/routers/index.ts)
const appRouter = createTRPCRouter({
admin: adminRouter, // Dashboard ops
posts: postsRouter, // CRUD operations
ai: aiRouter, // Vision/translation
payments: paymentsRouter, // Stripe flows
search: searchRouter, // Location-aware
// ...19 more
});
// Client-side usage remains clean:
const { data } = trpc.posts.getById.useQuery({ id });
This gives us:
- Clear boundaries (admin routes never mix with public ones)
- Separate middleware stacks
- Easier code splitting if needed later
What's Next
The current roadmap includes:
- AI-generated listing quality scores
- Automated repricing suggestions for stale posts
- Bulk image analysis for multi-photo uploads
But for now, the system works – even if it occasionally thinks your designer sofa belongs at a car boot sale.
[Edit: The £3.50 sofa was actually a cushion. The user cropped the image poorly. Lesson learned – we now detect item scale better.]
Top comments (0)