From fa463232505bb8ceed73bc226f10deb0bf25f880 Mon Sep 17 00:00:00 2001 From: Jodson Graves Date: Mon, 29 Jun 2026 21:46:34 -0400 Subject: [PATCH 01/16] =?UTF-8?q?feat(posts):=20Phase=202=20frontend=20?= =?UTF-8?q?=E2=80=94=20typed=20create=20flow=20+=20posts-backed=20marketpl?= =?UTF-8?q?ace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/postTypes.js: config-driven field sets for high-value types (Service, Direct Market, Product) + buildPostBody() → /posts payload - components/posts/NewPostModal.js: type picker → per-type form + media upload (/posts/upload-media) → POST /posts - components/posts/MyPosts.js: reusable owner view (list/create/delete) - app/farm + app/services: wired to the user's own posts via MyPosts - app/marketplace: switched /listings → /posts General Broadcast, category pills → post-type pills, NewListingModal → NewPostModal - ListingCard: render media[] + post-type badge, tolerate no-price (service) posts, guard quantity/location - i18n: Products + a few owner-view strings Co-Authored-By: Claude Opus 4.8 --- app/farm/page.js | 50 +++-------- app/marketplace/page.js | 64 +++++++------- app/services/page.js | 50 ++--------- components/listings/ListingCard.js | 51 +++++++---- components/posts/MyPosts.js | 94 +++++++++++++++++++++ components/posts/NewPostModal.js | 130 +++++++++++++++++++++++++++++ lib/i18n.js | 5 ++ lib/postTypes.js | 74 ++++++++++++++++ 8 files changed, 389 insertions(+), 129 deletions(-) create mode 100644 components/posts/MyPosts.js create mode 100644 components/posts/NewPostModal.js create mode 100644 lib/postTypes.js diff --git a/app/farm/page.js b/app/farm/page.js index b2ab10c..a573086 100644 --- a/app/farm/page.js +++ b/app/farm/page.js @@ -1,46 +1,16 @@ 'use client'; -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { getUser } from '@/lib/auth'; -import { useI18n } from '@/lib/i18n'; - -// Producer side of the network (whitepaper §4.5.3.2-.6). Posting flows land in Phase 2. -const SECTIONS = [ - { icon: '🌱', title: 'Producer Plans', desc: 'Plant→harvest plans buyers can contract against, with scheduled PING updates.' }, - { icon: '🥕', title: 'Direct Market', desc: 'Harvested goods listed for immediate sale.' }, - { icon: '📦', title: 'Products', desc: 'Value-added goods, seeds & young plants, tools, infrastructure, soil inputs.' }, - { icon: '🌾', title: 'Agrotourism', desc: 'Market gardens, events, tours, education, and volunteering.' }, -]; +import MyPosts from '@/components/posts/MyPosts'; +// Producer side of the network (whitepaper §4.5.3.2-.6). +// Phase 2 high-value types live here: Direct Market + Products (Plans & Agrotourism follow). export default function MyFarm() { - const router = useRouter(); - const { t } = useI18n(); - const [user, setUser] = useState(null); - - useEffect(() => { - const u = getUser(); - if (!u) { router.push('/'); return; } - setUser(u); - }, []); - - if (!user) return null; - return ( -
-
-

{t('Minha Fazenda')}

-

{t('Gerencie sua produção: planos, produtos, venda direta e agroturismo.')}

-
- {SECTIONS.map(s => ( -
-
{s.icon}
-
{s.title}
-
{s.desc}
- {t('Em breve')} -
- ))} -
-
-
+ ); } diff --git a/app/marketplace/page.js b/app/marketplace/page.js index e32cf4e..3396e72 100644 --- a/app/marketplace/page.js +++ b/app/marketplace/page.js @@ -6,37 +6,36 @@ import { api } from '@/lib/api'; import { getToken } from '@/lib/auth'; import ListingCard, { ListingCardSkeleton } from '@/components/listings/ListingCard'; import ListingDetail from '@/components/listings/ListingDetail'; -import NewListingModal from '@/components/listings/NewListingModal'; +import NewPostModal from '@/components/posts/NewPostModal'; import { useI18n } from '@/lib/i18n'; -const CATEGORIES = [ - { value: '', label: 'Todos', emoji: '🌿' }, - { value: 'graos', label: 'Grãos', emoji: '🌾' }, - { value: 'frutas', label: 'Frutas', emoji: '🍊' }, - { value: 'gado', label: 'Pecuária', emoji: '🐄' }, - { value: 'maquinas', label: 'Máquinas', emoji: '🚜' }, - { value: 'outros', label: 'Outros', emoji: '📦' }, +// Whitepaper §4.5.2 General Broadcast — filter by post type. +const POST_TYPES = [ + { value: '', label: 'Tudo', emoji: '✨' }, + { value: 'service', label: 'Serviços', emoji: '🧰' }, + { value: 'direct_market', label: 'Mercado', emoji: '🥕' }, + { value: 'product', label: 'Produtos', emoji: '📦' }, ]; const STATES = ['SP','MG','PR','RS','GO','MT','MS','BA','SC','PE','CE','RO','PA']; const DEMO = [ - { id: 'd1', title: 'Soja Safra 2025 — Tipo 1', category: 'graos', city: 'Rondonópolis', state: 'MT', price: 145.50, unit: 'saca', quantity_available: 500 }, - { id: 'd2', title: 'Milho Granado Premium', category: 'graos', city: 'Sorriso', state: 'MT', price: 78.00, unit: 'saca', quantity_available: 1200 }, - { id: 'd3', title: 'Laranja Pera Rio', category: 'frutas', city: 'Limeira', state: 'SP', price: 2.80, unit: 'kg', quantity_available: 8000 }, - { id: 'd4', title: 'Nelore Boi Gordo', category: 'gado', city: 'Araçatuba', state: 'SP', price: 320.00, unit: 'cabeça',quantity_available: 45 }, - { id: 'd5', title: 'Café Arábica Especial', category: 'graos', city: 'Varginha', state: 'MG', price: 980.00, unit: 'saca', quantity_available: 30 }, - { id: 'd6', title: 'Tomate Italiano Hidropônica', category: 'frutas', city: 'Cajamar', state: 'SP', price: 3.20, unit: 'kg', quantity_available: 2500 }, - { id: 'd7', title: 'Trator New Holland T7.240', category: 'maquinas', city: 'Cascavel', state: 'PR', price: 480000, unit: 'un.', quantity_available: 1 }, - { id: 'd8', title: 'Feijão Carioca Safra Nova', category: 'graos', city: 'Uberaba', state: 'MG', price: 220.00, unit: 'saca', quantity_available: 150 }, + { id: 'd1', post_type: 'direct_market', title: 'Soja Safra 2025 — Tipo 1', category: 'graos', city: 'Rondonópolis', state: 'MT', price: 145.50, unit: 'saca', quantity_available: 500 }, + { id: 'd2', post_type: 'direct_market', title: 'Milho Granado Premium', category: 'graos', city: 'Sorriso', state: 'MT', price: 78.00, unit: 'saca', quantity_available: 1200 }, + { id: 'd3', post_type: 'direct_market', title: 'Laranja Pera Rio', category: 'frutas', city: 'Limeira', state: 'SP', price: 2.80, unit: 'kg', quantity_available: 8000 }, + { id: 'd4', post_type: 'service', title: 'Colheita Mecanizada', category: 'la', city: 'Araçatuba', state: 'SP', terms: 'R$ 90/hora — mín. 4h' }, + { id: 'd5', post_type: 'direct_market', title: 'Café Arábica Especial', category: 'graos', city: 'Varginha', state: 'MG', price: 980.00, unit: 'saca', quantity_available: 30 }, + { id: 'd6', post_type: 'product', title: 'Sementes de Milho Híbrido', category: 'seeds_young', city: 'Cajamar', state: 'SP', price: 320.00, quantity_available: 200 }, + { id: 'd7', post_type: 'product', title: 'Trator New Holland T7.240', category: 'tools', city: 'Cascavel', state: 'PR', price: 480000, quantity_available: 1 }, + { id: 'd8', post_type: 'service', title: 'Transporte de Grãos', category: 'lr', city: 'Uberaba', state: 'MG', terms: 'R$ 4,50/km' }, ]; function MarketplaceInner() { const params = useSearchParams(); const [listings, setListings] = useState(null); const [selected, setSelected] = useState(null); - const [newListing, setNewListing] = useState(false); - const [cat, setCat] = useState(params.get('cat') || ''); + const [newPost, setNewPost] = useState(false); + const [ptype, setPtype] = useState(params.get('type') || ''); const [search, setSearch] = useState(''); const [stateF, setStateF] = useState(''); const [minP, setMinP] = useState(''); @@ -48,28 +47,29 @@ function MarketplaceInner() { async function load() { setListings(null); let qs = '?status=active&limit=40'; - if (cat) qs += '&category=' + cat; + if (ptype) qs += '&post_type=' + ptype; if (stateF) qs += '&state=' + stateF; if (minP) qs += '&minPrice=' + minP; if (maxP) qs += '&maxPrice=' + maxP; + if (sort) qs += '&sort=' + sort; try { - const data = await api('/listings' + qs); - let items = data.listings || data || []; + const data = await api('/posts' + qs); + let items = data.posts || data.listings || data || []; if (search) items = items.filter(l => l.title.toLowerCase().includes(search.toLowerCase()) || l.city?.toLowerCase().includes(search.toLowerCase()) ); setListings(items); } catch { - setListings(DEMO.filter(l => !cat || l.category === cat)); + setListings(DEMO.filter(l => !ptype || l.post_type === ptype)); } } - useEffect(() => { load(); }, [cat, stateF, sort]); + useEffect(() => { load(); }, [ptype, stateF, sort]); function openNew() { if (!getToken()) return alert(t('Faça login para anunciar')); - setNewListing(true); + setNewPost(true); } const filtered = listings?.filter(l => @@ -84,7 +84,7 @@ function MarketplaceInner() {

{t('Marketplace')}

-

{t('Produtos agrícolas de produtores de todo o Brasil')}

+

{t('Produtos agrícolas e serviços de produtores de todo o Brasil')}

{/* SEARCH BAR */} @@ -111,15 +111,15 @@ function MarketplaceInner() { - {/* CATEGORY PILLS */} + {/* POST-TYPE PILLS */} - {CATEGORIES.map(c => ( + {POST_TYPES.map(c => ( ))} @@ -202,15 +202,15 @@ function MarketplaceInner() { className="flex flex-col items-center py-20 gap-3"> 🌾

{t('Nenhum produto encontrado')}

- +
)}
- {selected && setSelected(null)} />} - {newListing && setNewListing(false)} onCreated={load} />} + {selected && setSelected(null)} />} + {newPost && setNewPost(false)} onCreated={load} />} ); } diff --git a/app/services/page.js b/app/services/page.js index 6a777dc..66de803 100644 --- a/app/services/page.js +++ b/app/services/page.js @@ -1,47 +1,15 @@ 'use client'; -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { getUser } from '@/lib/auth'; -import { useI18n } from '@/lib/i18n'; - -// Service posts (whitepaper §4.5.3.1). Posting flows land in Phase 2. -const SECTIONS = [ - { icon: '🧑‍🌾', title: 'Labor', desc: 'Planning & advising, setup & maintenance, planting & harvest.' }, - { icon: '🚚', title: 'Logistics', desc: 'Transport by range: under 10 km, over 10 km, over 20 km.' }, - { icon: '🏭', title: 'Processing', desc: 'Cottage / home-based, general, and animal processing.' }, - { icon: '♻️', title: 'Composting & Recycling', desc: 'Waste-to-soil and material recovery services.' }, - { icon: '🌿', title: 'Environmental Services', desc: 'Restoration, stewardship, and ecosystem services.' }, -]; +import MyPosts from '@/components/posts/MyPosts'; +// Service posts (whitepaper §4.5.3.1): labor, logistics, processing, composting, environmental. export default function MyServices() { - const router = useRouter(); - const { t } = useI18n(); - const [user, setUser] = useState(null); - - useEffect(() => { - const u = getUser(); - if (!u) { router.push('/'); return; } - setUser(u); - }, []); - - if (!user) return null; - return ( -
-
-

{t('Meus Serviços')}

-

{t('Ofereça serviços à rede: trabalho, logística, processamento e mais.')}

-
- {SECTIONS.map(s => ( -
-
{s.icon}
-
{s.title}
-
{s.desc}
- {t('Em breve')} -
- ))} -
-
-
+ ); } diff --git a/components/listings/ListingCard.js b/components/listings/ListingCard.js index 3dd8464..bda5f8f 100644 --- a/components/listings/ListingCard.js +++ b/components/listings/ListingCard.js @@ -11,10 +11,21 @@ const CAT_BG = { outros: 'from-cream to-cream2', }; +// Post-type presentation (whitepaper taxonomy). Falls back to legacy category styling. +const TYPE_LABEL = { service: 'Service', direct_market: 'Market', product: 'Product', plan_consumer: 'Plan', plan_producer: 'Plan', agrotourism: 'Agrotourism' }; +const TYPE_EMOJI = { service: '🧰', direct_market: '🥕', product: '📦', plan_consumer: '📋', plan_producer: '📋', agrotourism: '🌄' }; +const TYPE_BG = { service: 'from-sky-50 to-cyan-100', product: 'from-violet-50 to-purple-100', agrotourism: 'from-lime-50 to-green-100' }; + export default function ListingCard({ listing, onClick }) { const { t } = useI18n(); const l = listing; - const bg = CAT_BG[l.category] || CAT_BG.outros; + const img = l.media?.[0] || l.images?.[0]; + const bg = TYPE_BG[l.post_type] || CAT_BG[l.category] || CAT_BG.outros; + const emoji = TYPE_EMOJI[l.post_type] || CAT_EMOJI[l.category] || '📦'; + const badge = l.post_type ? (TYPE_LABEL[l.post_type] || l.post_type) : catLabel(l.category); + const hasPrice = l.price !== null && l.price !== undefined && l.price !== ''; + const qty = Number(l.quantity_available); + const hasQty = Number.isFinite(qty) && qty > 0; return ( - {l.images?.[0] - ? {l.title} - : {CAT_EMOJI[l.category] || '📦'} + {img + ? {l.title} + : {emoji} }
- {t(catLabel(l.category))} + {t(badge)}
- {l.quantity_available <= 10 && ( + {hasQty && qty <= 10 && (
{t('Últimas unidades')}
@@ -42,16 +53,24 @@ export default function ListingCard({ listing, onClick }) { {/* BODY */}
{l.title}
-
- 📍 {l.city}, {l.state} -
-
- {formatCurrency(l.price)} - /{l.unit} -
-
- {Number(l.quantity_available).toLocaleString('pt-BR')} {t('disponível')} -
+ {(l.city || l.state) && ( +
+ 📍 {[l.city, l.state].filter(Boolean).join(', ')} +
+ )} + {hasPrice ? ( +
+ {formatCurrency(l.price)} + {l.unit && /{l.unit}} +
+ ) : l.terms ? ( +
{l.terms}
+ ) : null} + {hasQty && ( +
+ {qty.toLocaleString('pt-BR')} {t('disponível')} +
+ )}
); diff --git a/components/posts/MyPosts.js b/components/posts/MyPosts.js new file mode 100644 index 0000000..1631634 --- /dev/null +++ b/components/posts/MyPosts.js @@ -0,0 +1,94 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { api } from '@/lib/api'; +import { getUser } from '@/lib/auth'; +import { useI18n } from '@/lib/i18n'; +import ListingCard from '@/components/listings/ListingCard'; +import ListingDetail from '@/components/listings/ListingDetail'; +import NewPostModal from '@/components/posts/NewPostModal'; + +// Shared owner view: lists the current user's posts of the given types, +// with a create button (NewPostModal) and per-card delete. Used by My Farm & My Services. +export default function MyPosts({ title, subtitle, types, defaultType, newLabel }) { + const router = useRouter(); + const { t } = useI18n(); + const [user, setUser] = useState(null); + const [posts, setPosts] = useState(null); + const [selected, setSelected] = useState(null); + const [creating, setCreating] = useState(false); + + useEffect(() => { + const u = getUser(); + if (!u) { router.push('/'); return; } + setUser(u); + }, []); + + async function load() { + if (!user) return; + setPosts(null); + try { + const data = await api('/posts?user_id=' + encodeURIComponent(user.id) + '&limit=100'); + const items = (data.posts || data.listings || data || []).filter((p) => types.includes(p.post_type)); + setPosts(items); + } catch { + setPosts([]); + } + } + + useEffect(() => { if (user) load(); }, [user]); + + async function onDelete(id) { + if (!confirm(t('Remover este anúncio?'))) return; + try { + await api('/posts/' + id, 'DELETE'); + load(); + } catch (e) { + alert(e.message); + } + } + + if (!user) return null; + + return ( +
+
+
+
+

{t(title)}

+

{t(subtitle)}

+
+ +
+ + {posts === null ? ( +

{t('Buscando...')}

+ ) : posts.length === 0 ? ( +
+
🌱
+

{t('Você ainda não tem nada por aqui.')}

+ +
+ ) : ( +
+ {posts.map((p) => ( +
+ + + {p.status && p.status !== 'active' && ( + {p.status} + )} +
+ ))} +
+ )} +
+ + {selected && setSelected(null)} />} + {creating && setCreating(false)} onCreated={load} />} +
+ ); +} diff --git a/components/posts/NewPostModal.js b/components/posts/NewPostModal.js new file mode 100644 index 0000000..53b7154 --- /dev/null +++ b/components/posts/NewPostModal.js @@ -0,0 +1,130 @@ +'use client'; +import { useState } from 'react'; +import { Modal, ModalHeader } from '@/components/ui/Modal'; +import { api } from '@/lib/api'; +import { getToken } from '@/lib/auth'; +import { useToast } from '@/components/ui/Toast'; +import { POST_TYPE_FORMS, buildPostBody } from '@/lib/postTypes'; + +function Field({ f, value, onChange }) { + const label = (); + if (f.kind === 'textarea') { + return ( +
{label} +