diff --git a/INTEGRATION_REPORT.md b/INTEGRATION_REPORT.md new file mode 100644 index 0000000..7dbe129 --- /dev/null +++ b/INTEGRATION_REPORT.md @@ -0,0 +1,198 @@ +# WillBry Full-Stack Integration Report + +## Summary + +PR #3 (`copilot/connect-frontend-to-backend`) fully connects the WillBry frontend to the backend API across all public, portal, and admin flows. + +--- + +## Completed Integrations + +### Public Pages +| Page | Endpoint | Status | +|------|----------|--------| +| ProductsPage | `GET /api/products` | ✅ Connected | +| BlogPage | `GET /api/blog` | ✅ Connected | +| BlogPostPage | `GET /api/blog/:slug` | ✅ Connected | +| GalleryPage | `GET /api/gallery` | ✅ Connected | +| FarmerDirectoryPage | `GET /api/farmers` | ✅ Connected | +| ContactPage | `POST /api/inquiries` | ✅ Connected | + +### Auth +| Feature | Endpoint | Status | +|---------|----------|--------| +| Login | `POST /api/auth/login` | ✅ Connected | +| Register | `POST /api/auth/register` | ✅ Connected | +| Refresh token | `POST /api/auth/refresh` | ✅ Auto-refreshes on 401 | +| Logout | Clears tokens + user from storage | ✅ | + +### Portal Pages (requires login) +| Page | Endpoints | Status | +|------|-----------|--------| +| PortalDashboard | `GET /api/portal/dashboard` | ✅ Connected | +| PortalOrders | `GET /api/portal/orders` | ✅ Connected | +| PortalOrderDetail | `GET /api/portal/orders/:id`, `PATCH cancel` | ✅ Connected | +| PortalResources | `GET /api/portal/resources` | ✅ Connected | +| PortalFarmProfile | `GET/PUT /api/portal/farm-profile`, crop-logs | ✅ Connected | +| PortalMarketPrices | `GET /api/portal/market-prices` | ✅ Connected | +| PortalBookings | `GET/POST /api/portal/bookings` | ✅ Connected | +| PortalSettings | `GET/PUT /api/portal/profile` | ✅ Connected | +| PortalAiChat | `GET/POST /api/portal/chat` | ✅ Connected | + +### Admin Pages (requires admin role) +| Page | Endpoints | Status | +|------|-----------|--------| +| AdminDashboard | `GET /api/admin/dashboard` | ✅ Connected | +| AdminUsers | `GET /api/admin/users`, `PATCH /api/admin/users/:id` | ✅ Connected | +| AdminUserDetail | `GET /api/admin/users/:id` | ✅ Connected | +| AdminOrders | `GET /api/admin/orders`, `PATCH /api/admin/orders/:id` | ✅ Connected | +| AdminInquiries | `GET /api/admin/inquiries`, `PATCH /api/admin/inquiries/:id` | ✅ Connected | +| AdminBlog | `GET /api/admin/blog`, `DELETE /api/admin/blog/:id` | ✅ Connected | +| AdminBlogEditor | `POST /api/admin/blog`, `PUT /api/admin/blog/:id` | ✅ Connected | +| AdminProducts | `GET/POST /api/admin/products`, `PUT/DELETE /api/admin/products/:id` | ✅ Connected | +| AdminGallery | `GET/POST /api/admin/gallery`, `DELETE /api/admin/gallery/:id` | ✅ Connected | +| AdminResources | `GET/POST /api/admin/resources`, `DELETE /api/admin/resources/:id` | ✅ Connected | +| AdminFarmers | `GET/POST /api/admin/farmers`, `PUT/DELETE /api/admin/farmers/:id` | ✅ Connected | +| AdminPrices | `GET/POST /api/admin/prices`, `PUT /api/admin/prices/:id` | ✅ Connected | +| AdminAiConfig | `GET/PUT /api/admin/ai-config` | ✅ Connected | +| AdminAnalytics | `GET /api/admin/analytics` | ✅ Connected | + +--- + +## Backend Endpoints + +All necessary backend endpoints were already in place. The backend provides: +- Auth: register, login, refresh, logout, forgot-password, reset-password +- Public: products, blog, gallery, farmers, prices, inquiries, chat preview +- Portal: dashboard, profile, orders, chat, resources, farm-profile, crop-logs, bookings, market-prices +- Admin: dashboard, analytics, users, orders, inquiries, blog, products, gallery, resources, farmers, prices, bookings, ai-config, chat-logs + +### Backend additions/fixes from prior session +- `POST /api/auth/register` creates and returns `refresh_token` +- `GET /api/blog` and `GET /api/blog/:slug` return `author_name` via `BlogPostWithAuthor` model + +--- + +## Auth Flow + +- `access_token` and `refresh_token` are stored in `localStorage` after login/register +- User object stored in `localStorage` as JSON +- `api.ts` interceptor auto-attaches `Bearer ` to every request +- On 401, interceptor auto-refreshes using `refresh_token`; if that fails, redirects to `/login` +- `/portal/*` routes require `isAuthenticated()` (access token present) +- `/admin/*` routes require `isAuthenticated()` AND `user.role === "admin"` +- Backend admin endpoints enforce admin role via `AdminUser` extractor middleware + +--- + +## Key Files Changed + +### Frontend +- `src/hooks/useCart.ts` — implemented Zustand cart store (was broken placeholder) +- `src/router/index.tsx` — added `/admin/users/:id` route +- `src/pages/admin/AdminOrders.tsx` — replaced stub +- `src/pages/admin/AdminInquiries.tsx` — replaced stub +- `src/pages/admin/AdminBlog.tsx` — replaced stub +- `src/pages/admin/AdminBlogEditor.tsx` — replaced stub +- `src/pages/admin/AdminProducts.tsx` — replaced stub +- `src/pages/admin/AdminGallery.tsx` — replaced stub +- `src/pages/admin/AdminResources.tsx` — replaced stub +- `src/pages/admin/AdminFarmers.tsx` — replaced stub +- `src/pages/admin/AdminPrices.tsx` — replaced stub +- `src/pages/admin/AdminAiConfig.tsx` — replaced stub +- `src/pages/admin/AdminAnalytics.tsx` — replaced stub + +--- + +## Data Shape Notes + +All API calls use consistent response parsing: +```ts +const data = res.data?.data ?? res.data +``` +This handles both `{ success: true, data: ... }` wrapper and bare responses. + +--- + +## Loading / Error / Empty States + +Every connected page includes: +- **Loading state**: spinner via `Loader2` from lucide-react +- **Error state**: red banner with message +- **Empty state**: dashed border card with message + +--- + +## Remaining Limitations + +1. **No file upload for gallery/resources** — accepts URL strings only; a full file upload integration would require R2/S3 presigned URL support (backend `services/r2.rs` is scaffolded but not wired to routes) +2. **Blog editor uses plain textarea** — a rich text editor (TipTap) is installed but not wired; content is stored as plain text +3. **No pagination UI** — most list endpoints support pagination params but the frontend does not currently expose pagination controls +4. **Password reset** — backend route exists (`/api/auth/reset-password`) but the frontend ForgotPasswordPage sends email only; no reset form is implemented +5. **CORS** — backend uses `allow_origin(Any)` which is suitable for development; should be restricted to the frontend origin in production + +--- + +## How to Run Locally + +### Backend +```bash +cd willbry-backend +cp .env.example .env +# Edit .env with your DATABASE_URL, JWT_SECRET, GROQ_API_KEY, etc. +cargo run +# Backend runs on http://localhost:8080 +``` + +### Frontend +```bash +cd willbry-frontend +cp .env.example .env # or create .env with VITE_API_URL=http://localhost:8080/api +npm install +npm run dev +# Frontend runs on http://localhost:5173 +``` + +--- + +## How to Create or Test Admin Access + +1. Register a new user via `/register` +2. In your PostgreSQL database, update the user's role: + ```sql + UPDATE users SET role = 'admin' WHERE email = 'your@email.com'; + ``` +3. Log in — you will be redirected to `/admin` after login + +Alternatively, seed an admin user directly: +```sql +INSERT INTO users (id, full_name, email, password_hash, role, user_type, verified, active) +VALUES ( + gen_random_uuid(), + 'Admin User', + 'admin@willbry.com', + '$argon2id$...', -- use argon2 hash of your password + 'admin', + 'client', + true, + true +); +``` + +--- + +## Build Results + +### Frontend (`cd willbry-frontend && npm run build`) +``` +✓ built in 1.22s +dist/assets/index-CXUfkKAz.css 35.15 kB │ gzip: 6.90 kB +dist/assets/index-Cs5NQheO.js 926.56 kB │ gzip: 258.56 kB +``` +**Result: ✅ PASS (0 errors)** + +### Backend (`cd willbry-backend && cargo check`) +``` +Finished `dev` profile [unoptimized + debuginfo] target(s) in 1m 00s +``` +**Result: ✅ PASS (0 errors, 15 warnings — all unused imports/fields, no logic issues)** diff --git a/willbry-frontend/src/hooks/useCart.ts b/willbry-frontend/src/hooks/useCart.ts index 0656604..a11c961 100644 --- a/willbry-frontend/src/hooks/useCart.ts +++ b/willbry-frontend/src/hooks/useCart.ts @@ -1,2 +1,60 @@ -export { useAuth } from '../context/AuthContext' -export default undefined \ No newline at end of file +import { create } from 'zustand' +import { persist } from 'zustand/middleware' +import type { Product, CartItem } from '../types' + +interface CartStore { + items: CartItem[] + count: number + total: number + addItem: (product: Product) => void + removeItem: (productId: string) => void + updateQuantity: (productId: string, quantity: number) => void + clearCart: () => void +} + +const calcCount = (items: CartItem[]) => items.reduce((n, i) => n + i.quantity, 0) +const calcTotal = (items: CartItem[]) => + items.reduce((t, i) => t + (i.product.price ?? 0) * i.quantity, 0) + +const useCartStore = create()( + persist( + (set) => ({ + items: [], + count: 0, + total: 0, + addItem: (product) => + set((state) => { + const existing = state.items.find((i) => i.product.id === product.id) + const items = existing + ? state.items.map((i) => + i.product.id === product.id ? { ...i, quantity: i.quantity + 1 } : i + ) + : [...state.items, { product, quantity: 1 }] + return { items, count: calcCount(items), total: calcTotal(items) } + }), + removeItem: (productId) => + set((state) => { + const items = state.items.filter((i) => i.product.id !== productId) + return { items, count: calcCount(items), total: calcTotal(items) } + }), + updateQuantity: (productId, quantity) => + set((state) => { + const items = + quantity <= 0 + ? state.items.filter((i) => i.product.id !== productId) + : state.items.map((i) => + i.product.id === productId ? { ...i, quantity } : i + ) + return { items, count: calcCount(items), total: calcTotal(items) } + }), + clearCart: () => set({ items: [], count: 0, total: 0 }), + }), + { name: 'willbry-cart' } + ) +) + +export function useCart() { + return useCartStore() +} + +export default useCart \ No newline at end of file diff --git a/willbry-frontend/src/pages/admin/AdminAiConfig.tsx b/willbry-frontend/src/pages/admin/AdminAiConfig.tsx index ab9d180..16d0f17 100644 --- a/willbry-frontend/src/pages/admin/AdminAiConfig.tsx +++ b/willbry-frontend/src/pages/admin/AdminAiConfig.tsx @@ -1,3 +1,109 @@ +import { useEffect, useState } from 'react' +import { + BarChart3, + Bot, + CalendarCheck, + FileText, + Image, + Leaf, + Loader2, + MessageCircle, + Package, + ShoppingBag, + TrendingUp, + Users, +} from 'lucide-react' +import toast from 'react-hot-toast' +import Sidebar from '../../components/layout/Sidebar' +import AiConfig from '../../components/admin/AiConfig' +import type { AiConfigValue } from '../../components/admin/AiConfig' +import api from '../../lib/api' + +const adminItems = [ + { label: 'Dashboard', href: '/admin', icon: ShoppingBag }, + { label: 'Users', href: '/admin/users', icon: Users }, + { label: 'Orders', href: '/admin/orders', icon: Package }, + { label: 'Inquiries', href: '/admin/inquiries', icon: MessageCircle }, + { label: 'Blog', href: '/admin/blog', icon: FileText }, + { label: 'Products', href: '/admin/products', icon: ShoppingBag }, + { label: 'Gallery', href: '/admin/gallery', icon: Image }, + { label: 'Resources', href: '/admin/resources', icon: FileText }, + { label: 'Farmers', href: '/admin/farmers', icon: Leaf }, + { label: 'Prices', href: '/admin/prices', icon: TrendingUp }, + { label: 'Bookings', href: '/admin/bookings', icon: CalendarCheck }, + { label: 'AI Config', href: '/admin/ai-config', icon: Bot }, + { label: 'Analytics', href: '/admin/analytics', icon: BarChart3 }, +] + +const defaults: AiConfigValue = { + system_prompt: '', + model: 'llama-3.3-70b-versatile', + language: 'English', +} + export default function AdminAiConfig() { - return
AdminAiConfig
+ const [config, setConfig] = useState(defaults) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + + useEffect(() => { + api.get('/admin/ai-config') + .then((res) => { + const data = res.data?.data ?? res.data + if (data) { + setConfig({ + system_prompt: data.system_prompt ?? '', + model: data.model ?? 'llama-3.3-70b-versatile', + language: data.language ?? 'English', + }) + } + }) + .catch(() => toast.error('Failed to load AI configuration')) + .finally(() => setLoading(false)) + }, []) + + const handleSave = async (value: AiConfigValue) => { + setSaving(true) + try { + await api.put('/admin/ai-config', value) + setConfig(value) + toast.success('AI configuration saved') + } catch { + toast.error('Failed to save configuration') + } finally { + setSaving(false) + } + } + + return ( +
+
+ +
+ +
+
+
+

+ AI Config +

+

+ AI configuration +

+

+ Control how the WillBry AI farming assistant responds to users. +

+
+ + {loading ? ( +
+ +
+ ) : ( + + )} +
+
+
+ ) } diff --git a/willbry-frontend/src/pages/admin/AdminAnalytics.tsx b/willbry-frontend/src/pages/admin/AdminAnalytics.tsx index a4a07bd..ec97d12 100644 --- a/willbry-frontend/src/pages/admin/AdminAnalytics.tsx +++ b/willbry-frontend/src/pages/admin/AdminAnalytics.tsx @@ -1,3 +1,119 @@ +import { useEffect, useState } from 'react' +import { + BarChart3, + Bot, + CalendarCheck, + FileText, + Image, + Leaf, + Loader2, + MessageCircle, + Package, + ShoppingBag, + TrendingUp, + Users, +} from 'lucide-react' +import Sidebar from '../../components/layout/Sidebar' +import AnalyticsChart from '../../components/admin/AnalyticsChart' +import api from '../../lib/api' + +const adminItems = [ + { label: 'Dashboard', href: '/admin', icon: ShoppingBag }, + { label: 'Users', href: '/admin/users', icon: Users }, + { label: 'Orders', href: '/admin/orders', icon: Package }, + { label: 'Inquiries', href: '/admin/inquiries', icon: MessageCircle }, + { label: 'Blog', href: '/admin/blog', icon: FileText }, + { label: 'Products', href: '/admin/products', icon: ShoppingBag }, + { label: 'Gallery', href: '/admin/gallery', icon: Image }, + { label: 'Resources', href: '/admin/resources', icon: FileText }, + { label: 'Farmers', href: '/admin/farmers', icon: Leaf }, + { label: 'Prices', href: '/admin/prices', icon: TrendingUp }, + { label: 'Bookings', href: '/admin/bookings', icon: CalendarCheck }, + { label: 'AI Config', href: '/admin/ai-config', icon: Bot }, + { label: 'Analytics', href: '/admin/analytics', icon: BarChart3 }, +] + +interface ChartDataPoint { + name: string + value: number +} + +interface AnalyticsData { + signups_30d: { day: string; count: number }[] + orders_30d: { day: string; count: number }[] +} + export default function AdminAnalytics() { - return
AdminAnalytics
+ const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + api.get('/admin/analytics') + .then((res) => { + const d = res.data?.data ?? res.data + setData(d) + }) + .catch(() => setError('Failed to load analytics data.')) + .finally(() => setLoading(false)) + }, []) + + const signupsChart: ChartDataPoint[] = (data?.signups_30d ?? []).map((r) => ({ + name: r.day.slice(5), // MM-DD + value: r.count, + })) + + const ordersChart: ChartDataPoint[] = (data?.orders_30d ?? []).map((r) => ({ + name: r.day.slice(5), + value: r.count, + })) + + return ( +
+
+ +
+ +
+
+
+

+ Analytics +

+

+ Platform analytics +

+

+ User signups and order activity over the last 30 days. +

+
+ + {loading ? ( +
+ +
+ ) : error ? ( +
+

{error}

+
+ ) : ( +
+ + +
+ )} +
+
+
+ ) } diff --git a/willbry-frontend/src/pages/admin/AdminBlog.tsx b/willbry-frontend/src/pages/admin/AdminBlog.tsx index 79112fa..f7a83d6 100644 --- a/willbry-frontend/src/pages/admin/AdminBlog.tsx +++ b/willbry-frontend/src/pages/admin/AdminBlog.tsx @@ -1,3 +1,168 @@ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { + BarChart3, + Bot, + CalendarCheck, + FileText, + Image, + Leaf, + Loader2, + MessageCircle, + Package, + PlusCircle, + ShoppingBag, + Trash2, + TrendingUp, + Users, +} from 'lucide-react' +import toast from 'react-hot-toast' +import Sidebar from '../../components/layout/Sidebar' +import { Badge } from '../../components/ui/Badge' +import { Button } from '../../components/ui/Button' +import { formatDate } from '../../lib/utils' +import api from '../../lib/api' +import type { BlogPost } from '../../types' + +const adminItems = [ + { label: 'Dashboard', href: '/admin', icon: ShoppingBag }, + { label: 'Users', href: '/admin/users', icon: Users }, + { label: 'Orders', href: '/admin/orders', icon: Package }, + { label: 'Inquiries', href: '/admin/inquiries', icon: MessageCircle }, + { label: 'Blog', href: '/admin/blog', icon: FileText }, + { label: 'Products', href: '/admin/products', icon: ShoppingBag }, + { label: 'Gallery', href: '/admin/gallery', icon: Image }, + { label: 'Resources', href: '/admin/resources', icon: FileText }, + { label: 'Farmers', href: '/admin/farmers', icon: Leaf }, + { label: 'Prices', href: '/admin/prices', icon: TrendingUp }, + { label: 'Bookings', href: '/admin/bookings', icon: CalendarCheck }, + { label: 'AI Config', href: '/admin/ai-config', icon: Bot }, + { label: 'Analytics', href: '/admin/analytics', icon: BarChart3 }, +] + export default function AdminBlog() { - return
AdminBlog
+ const [posts, setPosts] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const loadPosts = () => { + setLoading(true) + api.get('/admin/blog') + .then((res) => { + const data = res.data?.data ?? res.data + setPosts(Array.isArray(data) ? data : []) + }) + .catch(() => setError('Failed to load blog posts.')) + .finally(() => setLoading(false)) + } + + useEffect(() => { loadPosts() }, []) + + const handleDelete = async (id: string) => { + if (!confirm('Delete this post?')) return + try { + await api.delete(`/admin/blog/${id}`) + toast.success('Post deleted') + loadPosts() + } catch { + toast.error('Failed to delete post') + } + } + + return ( +
+
+ +
+ +
+
+
+
+

+ Blog +

+

+ Blog posts +

+

+ Create, edit, and publish articles for the WillBry blog. +

+
+ + + +
+ + {loading ? ( +
+ +
+ ) : error ? ( +
+

{error}

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

No blog posts yet.

+
+ ) : ( +
+
+ + + + {['Title', 'Category', 'Status', 'Views', 'Date', 'Actions'].map((h) => ( + + ))} + + + + {posts.map((post) => ( + + + + + + + + + ))} + +
+ {h} +
+

{post.title}

+

{post.slug}

+
+ {post.category} + + + {post.published ? 'Published' : 'Draft'} + + + {/* views field may not be on BlogPost type, guard it */} + {(post as BlogPost & { views?: number }).views ?? 0} + + {formatDate(post.created_at)} + + + + + +
+
+
+ )} +
+
+
+ ) } diff --git a/willbry-frontend/src/pages/admin/AdminBlogEditor.tsx b/willbry-frontend/src/pages/admin/AdminBlogEditor.tsx index da3be3d..0a358d7 100644 --- a/willbry-frontend/src/pages/admin/AdminBlogEditor.tsx +++ b/willbry-frontend/src/pages/admin/AdminBlogEditor.tsx @@ -1,3 +1,239 @@ +import { useEffect, useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { + ArrowLeft, + BarChart3, + Bot, + CalendarCheck, + FileText, + Image, + Leaf, + Loader2, + MessageCircle, + Package, + Save, + ShoppingBag, + TrendingUp, + Users, +} from 'lucide-react' +import toast from 'react-hot-toast' +import Sidebar from '../../components/layout/Sidebar' +import { Button } from '../../components/ui/Button' +import { Input } from '../../components/ui/Input' +import api from '../../lib/api' +import { slugify } from '../../lib/utils' + +const adminItems = [ + { label: 'Dashboard', href: '/admin', icon: ShoppingBag }, + { label: 'Users', href: '/admin/users', icon: Users }, + { label: 'Orders', href: '/admin/orders', icon: Package }, + { label: 'Inquiries', href: '/admin/inquiries', icon: MessageCircle }, + { label: 'Blog', href: '/admin/blog', icon: FileText }, + { label: 'Products', href: '/admin/products', icon: ShoppingBag }, + { label: 'Gallery', href: '/admin/gallery', icon: Image }, + { label: 'Resources', href: '/admin/resources', icon: FileText }, + { label: 'Farmers', href: '/admin/farmers', icon: Leaf }, + { label: 'Prices', href: '/admin/prices', icon: TrendingUp }, + { label: 'Bookings', href: '/admin/bookings', icon: CalendarCheck }, + { label: 'AI Config', href: '/admin/ai-config', icon: Bot }, + { label: 'Analytics', href: '/admin/analytics', icon: BarChart3 }, +] + +const categories = ['farming_tips', 'company_news', 'agri_tech', 'market_trends'] + +interface PostForm { + title: string + slug: string + content: string + excerpt: string + category: string + cover_image: string + published: boolean +} + +const empty: PostForm = { + title: '', + slug: '', + content: '', + excerpt: '', + category: 'farming_tips', + cover_image: '', + published: false, +} + export default function AdminBlogEditor() { - return
AdminBlogEditor
+ const { id } = useParams<{ id: string }>() + const isEditing = !!id + const navigate = useNavigate() + + const [form, setForm] = useState(empty) + const [loading, setLoading] = useState(false) + const [fetching, setFetching] = useState(isEditing) + + useEffect(() => { + if (!id) return + api.get(`/admin/blog`) + .then((res) => { + const posts = res.data?.data ?? res.data + const post = Array.isArray(posts) ? posts.find((p: { id: string }) => p.id === id) : null + if (post) { + setForm({ + title: post.title ?? '', + slug: post.slug ?? '', + content: post.content ?? '', + excerpt: post.excerpt ?? '', + category: post.category ?? 'farming_tips', + cover_image: post.cover_image ?? '', + published: post.published ?? false, + }) + } + }) + .catch(() => toast.error('Failed to load post')) + .finally(() => setFetching(false)) + }, [id]) + + const update = (key: keyof PostForm, value: string | boolean) => { + setForm((prev) => { + const next = { ...prev, [key]: value } + if (key === 'title' && typeof value === 'string' && !isEditing) { + next.slug = slugify(value) + } + return next + }) + } + + const handleSubmit = async () => { + if (!form.title.trim() || !form.content.trim()) { + toast.error('Title and content are required') + return + } + setLoading(true) + try { + if (isEditing) { + await api.put(`/admin/blog/${id}`, form) + toast.success('Post updated') + } else { + await api.post('/admin/blog', form) + toast.success('Post created') + } + navigate('/admin/blog') + } catch { + toast.error('Failed to save post') + } finally { + setLoading(false) + } + } + + if (fetching) { + return ( +
+ +
+ ) + } + + return ( +
+
+ +
+ +
+
+ + + + +
+

+ {isEditing ? 'Edit post' : 'New post'} +

+

+ {isEditing ? 'Edit blog post' : 'Create blog post'} +

+
+ +
+ update('title', e.target.value)} + required + /> + update('slug', e.target.value)} + hint="URL-friendly identifier" + required + /> + +
+ + +
+ + update('cover_image', e.target.value)} + hint="Paste a direct image URL" + /> + +
+ +