Campaign owner is always a person

This commit is contained in:
Kovács Attila 2026-09-06 03:30:32 -07:00
parent 89900c81c8
commit cf37ccc649
20 changed files with 231 additions and 111 deletions

View File

@ -19,8 +19,8 @@ func (s *Server) adminListCampaigns(c *gin.Context) {
c.JSON(http.StatusOK, rows)
}
func (s *Server) adminOrganizations(c *gin.Context) {
orgs, err := s.store.ListOrganizations(c.Request.Context())
func (s *Server) adminOwners(c *gin.Context) {
orgs, err := s.store.ListOwners(c.Request.Context())
if err != nil {
s.fail(c, err)
return
@ -62,7 +62,7 @@ func (s *Server) adminSaveCampaign(c *gin.Context) {
in.Slug = hu.Slugify(firstNonEmpty(in.Slug, in.Title))
if in.Title == "" || in.Slug == "" || in.GoalAmount <= 0 ||
in.EndsAt == "" || in.OrganizationID == "" {
in.EndsAt == "" || in.OwnerID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "hianyzo"})
return
}

View File

@ -48,7 +48,7 @@ func (s *Server) Handler() http.Handler {
{
admin.GET("/campaigns", s.adminListCampaigns)
admin.GET("/campaigns/:slug", s.adminGetCampaign)
admin.GET("/organizations", s.adminOrganizations)
admin.GET("/owners", s.adminOwners)
admin.PUT("/campaigns", s.sameOrigin, s.adminSaveCampaign)
admin.DELETE("/campaigns/:slug", s.sameOrigin, s.adminDeleteCampaign)
}

View File

@ -13,18 +13,23 @@ import (
// has to round-trip DRAFT and ELUTASITVA, which never reach a visitor.
type AdminCampaignRow struct {
ID string `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Status string `json:"status"`
GoalAmount int `json:"goalAmount"`
Organization string `json:"organization"`
PledgeCount int `json:"pledgeCount"`
ID string `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Status string `json:"status"`
GoalAmount int `json:"goalAmount"`
Owner string `json:"owner"`
PledgeCount int `json:"pledgeCount"`
}
type Organization struct {
// Owner is the person a campaign belongs to. Always a person — the table was
// called Organization until migration 0003.
type Owner struct {
ID string `json:"id"`
Name string `json:"name"`
// Carried so an owner editor can show and change the photo. Empty means the
// organisation has none and its initials are used instead.
AvatarURL string `json:"avatarUrl,omitempty"`
}
type AdminTier struct {
@ -43,18 +48,18 @@ type AdminTier struct {
}
type AdminCampaign struct {
ID string `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Category string `json:"category"`
Summary string `json:"summary"`
Art string `json:"art"`
GoalAmount int `json:"goalAmount"`
EndsAt string `json:"endsAt"`
Status string `json:"status"`
OrganizationID string `json:"organizationId"`
Story []StorySection `json:"story"`
Tiers []AdminTier `json:"tiers"`
ID string `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Category string `json:"category"`
Summary string `json:"summary"`
Art string `json:"art"`
GoalAmount int `json:"goalAmount"`
EndsAt string `json:"endsAt"`
Status string `json:"status"`
OwnerID string `json:"ownerId"`
Story []StorySection `json:"story"`
Tiers []AdminTier `json:"tiers"`
}
// ErrSlugTaken and ErrHasPledges are the two failures the editor has to
@ -69,7 +74,7 @@ func (s *Store) ListAdminCampaigns(ctx context.Context) ([]AdminCampaignRow, err
SELECT c."id", c."slug", c."title", c."status"::text, c."goalAmount", o."name",
(SELECT COUNT(*) FROM "Pledge" p WHERE p."campaignId" = c."id")::int
FROM "Campaign" c
JOIN "Organization" o ON o."id" = c."organizationId"
JOIN "Owner" o ON o."id" = c."ownerId"
ORDER BY c."createdAt" DESC`)
if err != nil {
return nil, err
@ -80,7 +85,7 @@ func (s *Store) ListAdminCampaigns(ctx context.Context) ([]AdminCampaignRow, err
for rows.Next() {
var r AdminCampaignRow
if err := rows.Scan(&r.ID, &r.Slug, &r.Title, &r.Status, &r.GoalAmount,
&r.Organization, &r.PledgeCount); err != nil {
&r.Owner, &r.PledgeCount); err != nil {
return nil, err
}
out = append(out, r)
@ -88,18 +93,18 @@ func (s *Store) ListAdminCampaigns(ctx context.Context) ([]AdminCampaignRow, err
return out, rows.Err()
}
func (s *Store) ListOrganizations(ctx context.Context) ([]Organization, error) {
func (s *Store) ListOwners(ctx context.Context) ([]Owner, error) {
rows, err := s.pool.Query(ctx,
`SELECT "id", "name" FROM "Organization" ORDER BY "name" ASC`)
`SELECT "id", "name", COALESCE("avatarUrl", '') FROM "Owner" ORDER BY "name" ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Organization{}
out := []Owner{}
for rows.Next() {
var o Organization
if err := rows.Scan(&o.ID, &o.Name); err != nil {
var o Owner
if err := rows.Scan(&o.ID, &o.Name, &o.AvatarURL); err != nil {
return nil, err
}
out = append(out, o)
@ -113,10 +118,10 @@ func (s *Store) GetAdminCampaign(ctx context.Context, slug string) (*AdminCampai
err := s.pool.QueryRow(ctx, `
SELECT "id", "slug", "title", "category", "summary", "art", "goalAmount",
"endsAt", "status"::text, "organizationId"
"endsAt", "status"::text, "ownerId"
FROM "Campaign" WHERE "slug" = $1`, slug).Scan(
&c.ID, &c.Slug, &c.Title, &c.Category, &c.Summary, &c.Art, &c.GoalAmount,
&endsAt, &c.Status, &c.OrganizationID)
&endsAt, &c.Status, &c.OwnerID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
@ -171,18 +176,18 @@ type TierInput struct {
}
type CampaignInput struct {
ID string `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Category string `json:"category"`
Summary string `json:"summary"`
Art string `json:"art"`
GoalAmount int `json:"goalAmount"`
EndsAt string `json:"endsAt"`
Status string `json:"status"`
OrganizationID string `json:"organizationId"`
Story []StorySection `json:"story"`
Tiers []TierInput `json:"tiers"`
ID string `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Category string `json:"category"`
Summary string `json:"summary"`
Art string `json:"art"`
GoalAmount int `json:"goalAmount"`
EndsAt string `json:"endsAt"`
Status string `json:"status"`
OwnerID string `json:"ownerId"`
Story []StorySection `json:"story"`
Tiers []TierInput `json:"tiers"`
}
type SaveResult struct {
@ -218,10 +223,10 @@ func (s *Store) SaveCampaign(ctx context.Context, in CampaignInput) (*SaveResult
tag, err := tx.Exec(ctx, `
UPDATE "Campaign" SET "slug"=$2, "title"=$3, "category"=$4, "summary"=$5,
"art"=$6, "goalAmount"=$7, "endsAt"=$8, "status"=$9::"CampaignStatus",
"organizationId"=$10, "updatedAt"=now()
"ownerId"=$10, "updatedAt"=now()
WHERE "id"=$1`,
campaignID, in.Slug, in.Title, in.Category, in.Summary, in.Art,
in.GoalAmount, endsAt, in.Status, in.OrganizationID)
in.GoalAmount, endsAt, in.Status, in.OwnerID)
if err != nil {
if isUniqueViolation(err, "Campaign_slug_key") {
return nil, ErrSlugTaken

View File

@ -17,6 +17,9 @@ type Creator struct {
Initials string `json:"initials"`
Location string `json:"location"`
Blurb string `json:"blurb"`
// Empty when the owner has no photo. The client falls back to Initials,
// so this is omitted from the payload rather than sent as "".
AvatarURL string `json:"avatarUrl,omitempty"`
}
type StorySection struct {
@ -98,11 +101,11 @@ func outTierType(s string) string {
const listSQL = `
SELECT c."slug", c."title", c."category", c."summary", c."art",
o."name", o."initials", o."location", o."blurb",
o."name", o."initials", o."location", o."blurb", COALESCE(o."avatarUrl", ''),
c."goalAmount", c."endsAt", c."status"::text,
COALESCE(p."total", 0)::int, COALESCE(p."backers", 0)::int
FROM "Campaign" c
JOIN "Organization" o ON o."id" = c."organizationId"
JOIN "Owner" o ON o."id" = c."ownerId"
LEFT JOIN (
SELECT "campaignId", SUM("amount") AS "total", COUNT(*) AS "backers"
FROM "Pledge"
@ -127,6 +130,7 @@ func (s *Store) ListCampaigns(ctx context.Context) ([]CampaignSummary, error) {
if err := rows.Scan(
&c.Slug, &c.Title, &c.Category, &c.Summary, &c.Art,
&c.Creator.Name, &c.Creator.Initials, &c.Creator.Location, &c.Creator.Blurb,
&c.Creator.AvatarURL,
&c.Goal, &endsAt, &status, &c.Raised, &c.Backers,
); err != nil {
return nil, err
@ -140,13 +144,13 @@ func (s *Store) ListCampaigns(ctx context.Context) ([]CampaignSummary, error) {
const campaignSQL = `
SELECT c."id", c."slug", c."title", c."category", c."summary", c."art",
o."name", o."initials", o."location", o."blurb",
o."name", o."initials", o."location", o."blurb", COALESCE(o."avatarUrl", ''),
c."goalAmount", c."endsAt", c."status"::text,
COALESCE(p."total", 0)::int, COALESCE(p."backers", 0)::int,
(SELECT COUNT(*) FROM "CampaignUpdate" u WHERE u."campaignId" = c."id")::int,
(SELECT COUNT(*) FROM "Comment" m WHERE m."campaignId" = c."id")::int
FROM "Campaign" c
JOIN "Organization" o ON o."id" = c."organizationId"
JOIN "Owner" o ON o."id" = c."ownerId"
LEFT JOIN (
SELECT "campaignId", SUM("amount") AS "total", COUNT(*) AS "backers"
FROM "Pledge"
@ -179,6 +183,7 @@ func (s *Store) GetCampaignBySlug(ctx context.Context, slug string) (*Campaign,
err := s.pool.QueryRow(ctx, campaignSQL, slug, publicStatuses, countedPledges).Scan(
&id, &c.Slug, &c.Title, &c.Category, &c.Summary, &c.Art,
&c.Creator.Name, &c.Creator.Initials, &c.Creator.Location, &c.Creator.Blurb,
&c.Creator.AvatarURL,
&c.Goal, &endsAt, &status, &c.Raised, &c.Backers, &c.Updates, &c.Comments,
)
if errors.Is(err, pgx.ErrNoRows) {

View File

@ -0,0 +1,7 @@
-- An owner's photo. Nullable: an organisation without one falls back to its
-- initials, which is what every card and campaign page already renders.
--
-- A URL rather than bytes: there is no upload pipeline yet, and when there is,
-- it can write the location it stored the file at into this same column without
-- the read path changing.
ALTER TABLE "Organization" ADD COLUMN "avatarUrl" TEXT;

View File

@ -0,0 +1,11 @@
-- A campaign's owner is always a person, never an organisation. The table was
-- named for the wrong idea, and every admin screen built on it would have
-- repeated the mistake, so the name goes rather than being worked around.
--
-- Postgres keeps the old names for indexes and constraints through a rename;
-- they are renamed here too so nothing in the schema still says Organization.
ALTER TABLE "Organization" RENAME TO "Owner";
ALTER TABLE "Campaign" RENAME COLUMN "organizationId" TO "ownerId";
ALTER INDEX "Organization_slug_key" RENAME TO "Owner_slug_key";
ALTER INDEX "Organization_stripeAccountId_key" RENAME TO "Owner_stripeAccountId_key";

View File

@ -10,10 +10,10 @@ import (
)
// reset clears the campaign side of the database. Relations cascade, so
// campaigns and organisations are enough; users go too because the seed's
// campaigns and owners are enough; users go too because the seed's
// pledges are anonymous and any account left behind would be an orphan.
func (s *seeder) reset(ctx context.Context) error {
for _, table := range []string{"Pledge", "Campaign", "Organization", "User"} {
for _, table := range []string{"Pledge", "Campaign", "Owner", "User"} {
if _, err := s.pool.Exec(ctx, fmt.Sprintf(`DELETE FROM %q`, table)); err != nil {
return err
}
@ -35,24 +35,25 @@ func (s *seeder) campaign(ctx context.Context, c seeddata.Campaign) (int, int, e
}
defer tx.Rollback(ctx)
var orgID string
var ownerID string
if err := tx.QueryRow(ctx, `
INSERT INTO "Organization" ("id","slug","name","initials","location","blurb","approved")
VALUES ($1,$2,$3,$4,$5,$6,true)
INSERT INTO "Owner" ("id","slug","name","initials","location","blurb","avatarUrl","approved")
VALUES ($1,$2,$3,$4,$5,$6,NULLIF($7,''),true)
ON CONFLICT ("slug") DO UPDATE SET "name" = EXCLUDED."name"
RETURNING "id"`,
store.NewID(), c.Org.Slug, c.Org.Name, c.Org.Initials, c.Org.Location, c.Org.Blurb,
).Scan(&orgID); err != nil {
store.NewID(), c.Owner.Slug, c.Owner.Name, c.Owner.Initials, c.Owner.Location, c.Owner.Blurb,
c.Owner.AvatarURL,
).Scan(&ownerID); err != nil {
return 0, 0, err
}
campaignID := store.NewID()
if _, err := tx.Exec(ctx, `
INSERT INTO "Campaign" ("id","slug","title","category","summary","art","goalAmount",
"endsAt","status","organizationId")
"endsAt","status","ownerId")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::"CampaignStatus",$10)`,
campaignID, c.Slug, c.Title, c.Category, c.Summary, c.Art, c.GoalAmount,
endsAt, c.Status, orgID); err != nil {
endsAt, c.Status, ownerID); err != nil {
return 0, 0, err
}

View File

@ -8,12 +8,13 @@
"goalAmount": 1200000,
"endsAt": "2026-08-31",
"status": "AKTIV",
"org": {
"slug": "zold-jozsefvaros",
"name": "Zöld Józsefváros Egyesület",
"initials": "ZJ",
"location": "Budapest VIII.",
"blurb": "2019 óta dolgozunk józsefvárosi zöldfelületekért. Eddig három kertet hoztunk létre, mindhárom ma is működik."
"owner": {
"slug": "gelencser-ferenc",
"name": "Gelencsér Ferenc",
"initials": "GF",
"location": "Budapest",
"blurb": "Momentumos politikus, a Rákóczi tér környékén nőtt fel. A kert ötlete egy lakossági fórumon született.",
"avatarUrl": "https://momentum.hu/wp-content/uploads/2022/10/Gelencser-Ferenc-portre-490.webp"
},
"story": [
{
@ -117,12 +118,12 @@
"goalAmount": 2400000,
"endsAt": "2026-09-15",
"status": "AKTIV",
"org": {
"slug": "hangolo",
"name": "Hangoló Közhasznú Egyesület",
"initials": "HK",
"owner": {
"slug": "horvath-eszter",
"name": "Horváth Eszter",
"initials": "HE",
"location": "Szeged",
"blurb": "Látássérült fiatalok tanulását segítjük 2016 óta. Eddig 62 hangoskönyvet készítettünk el."
"blurb": "Gyógypedagógus, 2016 óta készít hangoskönyveket látássérült diákoknak. Eddig 62 könyv készült el."
},
"story": [
{
@ -197,12 +198,13 @@
"goalAmount": 3500000,
"endsAt": "2026-10-04",
"status": "AKTIV",
"org": {
"slug": "kerekagy",
"name": "Kerékagy Egyesület",
"initials": "KE",
"owner": {
"slug": "szarvas-koppany-bendeguz",
"name": "Szarvas Koppány Bendegúz",
"initials": "SZKB",
"location": "Budapest VIII.",
"blurb": "Kerékpáros oktatást tartunk általános iskolákban. Tavaly 900 gyerek ült nálunk először nyeregbe."
"blurb": "Józsefvárosi önkormányzati képviselő volt. A tanpályát a Baross utcai iskolákkal együtt tervezi.",
"avatarUrl": "https://momentum.hu/wp-content/uploads/2024/02/Szarvas-Koppany-Bendeguz.jpg"
},
"story": [
{
@ -279,12 +281,12 @@
"goalAmount": 1800000,
"endsAt": "2026-07-31",
"status": "LEZART",
"org": {
"slug": "koborlo",
"name": "Kóborló Társulat",
"initials": "KT",
"owner": {
"slug": "kovacs-marton",
"name": "Kovács Márton",
"initials": "KM",
"location": "Debrecen",
"blurb": "Független társulat 2021 óta. Ott játszunk, ahol nincs kőszínház — eddig 38 településen."
"blurb": "Rendező. Ott játszik, ahol nincs kőszínház — eddig 38 településen fordult meg a társulattal."
},
"story": [
{

View File

@ -14,12 +14,15 @@ import (
//go:embed seed.json
var raw []byte
type Org struct {
type Owner struct {
Slug string `json:"slug"`
Name string `json:"name"`
Initials string `json:"initials"`
Location string `json:"location"`
Blurb string `json:"blurb"`
// Optional. Left out of the fixture because there is nowhere to host the
// files yet; an organisation without one shows its initials.
AvatarURL string `json:"avatarUrl,omitempty"`
}
type Story struct {
@ -60,7 +63,7 @@ type Campaign struct {
GoalAmount int `json:"goalAmount"`
EndsAt string `json:"endsAt"`
Status string `json:"status"`
Org Org `json:"org"`
Owner Owner `json:"owner"`
Story []Story `json:"story"`
Tiers []Tier `json:"tiers"`
Updates []Update `json:"updates"`

View File

@ -417,7 +417,7 @@ check_creation_refused() {
-b /tmp/ci-admin.jar \
-H "Content-Type: application/json" \
-H "Origin: http://localhost:${APP_PORT}" \
-d '{"title":"Uj kampany","slug":"uj-kampany","goalAmount":100000,"endsAt":"2027-01-01","organizationId":"x","status":"AKTIV"}' \
-d '{"title":"Uj kampany","slug":"uj-kampany","goalAmount":100000,"endsAt":"2027-01-01","ownerId":"x","status":"AKTIV"}' \
"http://localhost:${APP_PORT}/api/admin/campaigns")
[ "$code" = "404" ] || { echo "a save with no id returned $code, expected 404"; return 1; }
# And it must not have landed anyway.
@ -428,6 +428,41 @@ check_creation_refused() {
}
step "campaigns cannot be created through the API" check_creation_refused
# Every campaign has an owner, and the owner may have a photo. The column is
# nullable and the client falls back to the initials chip, so both states have
# to survive the trip: set, and cleared again.
check_owner_photo() {
docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$CI_DB" -q \
-c "UPDATE \"Owner\" SET \"avatarUrl\" = 'https://example.invalid/o.jpg'" >/dev/null
curl -s "http://localhost:${APP_PORT}/api/campaigns" | python3 -c '
import json, sys
campaigns = json.load(sys.stdin)
assert campaigns, "no campaigns to check"
for c in campaigns:
assert c["creator"]["avatarUrl"] == "https://example.invalid/o.jpg", c["creator"]
' || return 1
# A future owner editor loads the photo from here.
curl -s -b /tmp/ci-admin.jar "http://localhost:${APP_PORT}/api/admin/owners" | python3 -c '
import json, sys
owners = json.load(sys.stdin)
assert owners, "no owners"
for o in owners:
assert o.get("avatarUrl"), o
' || return 1
docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$CI_DB" -q \
-c "UPDATE \"Owner\" SET \"avatarUrl\" = NULL" >/dev/null
curl -s "http://localhost:${APP_PORT}/api/campaigns" | python3 -c '
import json, sys
for c in json.load(sys.stdin):
assert "avatarUrl" not in c["creator"], "a missing photo must be omitted, not sent as an empty string"
'
}
step "owner photo round-trips, and is omitted when unset" check_owner_photo
check_404() {
local code
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${APP_PORT}/kampanyok/nincs-ilyen")

View File

@ -1,5 +1,6 @@
import { Link } from "react-router";
import { CampaignArt } from "./CampaignArt";
import { CreatorBadge } from "./CreatorBadge";
import { FundingLane } from "./FundingLane";
import { daysLeft, formatHuf, formatNumber, progressPct } from "@/lib/format";
import type { CampaignSummary } from "@/lib/types";
@ -23,7 +24,9 @@ export function CampaignCard({ campaign }: { campaign: CampaignSummary }) {
<div className="flex flex-1 flex-col p-5">
<h3 className="font-display text-xl">{campaign.title}</h3>
<p className="mt-1 text-xs text-muted">{campaign.creator.name}</p>
<div className="mt-2">
<CreatorBadge creator={campaign.creator} />
</div>
<p className="mt-2.5 line-clamp-3 text-[13.5px] leading-relaxed text-muted">
{campaign.summary}
</p>

View File

@ -1,6 +1,6 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router";
import { ApiError, api, type AdminCampaign, type AdminTier, type Organization } from "@/lib/api";
import { ApiError, api, type AdminCampaign, type AdminTier, type Owner } from "@/lib/api";
import {
ART_KEYS, CATEGORIES, STATUSES, TIER_TYPES, readCampaignForm, serializeStory,
} from "@/lib/campaign-form";
@ -18,10 +18,10 @@ const ERRORS: Record<string, string> = {
*/
export function CampaignForm({
campaign,
organizations,
owners,
}: {
campaign: AdminCampaign;
organizations: Organization[];
owners: Owner[];
}) {
const navigate = useNavigate();
const [notice, setNotice] = useState<string>();
@ -88,13 +88,13 @@ export function CampaignForm({
{CATEGORIES.map((c) => <option key={c}>{c}</option>)}
</select>
</Field>
<Field label="Szervezet">
<Field label="Gazda">
<select
name="organizationId"
defaultValue={campaign.organizationId}
name="ownerId"
defaultValue={campaign.ownerId}
className={input}
>
{organizations.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
{owners.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
</Field>
<Field label="Cél (Ft)">

View File

@ -0,0 +1,46 @@
import type { Creator } from "@/lib/types";
/**
* The campaign owner: photo if the organisation has one, its initials if not.
*
* Both the card and the campaign page render the owner, and they used to do it
* with separate markup — the page had the avatar, the card had a bare line of
* muted text. One component so a photo appears in both places at once.
*/
export function CreatorBadge({
creator,
size = "sm",
children,
}: {
creator: Creator;
size?: "sm" | "md";
children?: React.ReactNode;
}) {
const box = size === "md" ? "size-14" : "size-11";
return (
<div className="flex min-w-0 items-center gap-3 text-muted">
{creator.avatarUrl ? (
<img
src={creator.avatarUrl}
alt=""
loading="lazy"
className={`${box} shrink-0 rounded-full object-cover`}
/>
) : (
<span
aria-hidden
className={`${box} grid shrink-0 place-items-center rounded-full bg-lila font-semibold text-white ${
size === "md" ? "text-base" : "text-sm"
}`}
>
{creator.initials}
</span>
)}
<span className={`truncate ${size === "md" ? "text-sm" : "text-xs"}`}>
{creator.name}
{children}
</span>
</div>
);
}

View File

@ -67,7 +67,7 @@ export const api = {
campaigns: () => request<AdminCampaignRow[]>("/admin/campaigns"),
campaign: (slug: string) =>
request<AdminCampaign>(`/admin/campaigns/${encodeURIComponent(slug)}`),
organizations: () => request<Organization[]>("/admin/organizations"),
owners: () => request<Owner[]>("/admin/owners"),
save: (input: CampaignInput) =>
request<SaveResult>("/admin/campaigns", {
method: "PUT",
@ -86,11 +86,11 @@ export interface AdminCampaignRow {
title: string;
status: string;
goalAmount: number;
organization: string;
owner: string;
pledgeCount: number;
}
export interface Organization {
export interface Owner {
id: string;
name: string;
}
@ -118,7 +118,7 @@ export interface AdminCampaign {
goalAmount: number;
endsAt: string;
status: string;
organizationId: string;
ownerId: string;
story: { heading: string; body: string[] }[];
tiers: AdminTier[];
}
@ -145,7 +145,7 @@ export interface CampaignInput {
goalAmount: number;
endsAt: string;
status: string;
organizationId: string;
ownerId: string;
story: { heading: string; body: string[] }[];
tiers: TierInput[];
}

View File

@ -149,7 +149,7 @@ export function readCampaignForm(form: FormData, rowCount: number): CampaignInpu
goalAmount: parseAmount(form.get("goalAmount")) ?? 0,
endsAt: String(form.get("endsAt") ?? ""),
status: String(form.get("status") ?? "DRAFT"),
organizationId: String(form.get("organizationId") ?? ""),
ownerId: String(form.get("ownerId") ?? ""),
story: parseStory(String(form.get("story") ?? "")),
tiers: readTierRows(form, rowCount),
};

View File

@ -18,6 +18,8 @@ export interface Tier {
export interface Creator {
name: string;
/** Absent when the owner has no photo; the initials chip stands in. */
avatarUrl?: string;
initials: string;
location: string;
blurb: string;

View File

@ -1,5 +1,5 @@
import { createBrowserRouter, redirect } from "react-router";
import { ApiError, api, type AdminCampaign, type Organization } from "./lib/api";
import { ApiError, api, type AdminCampaign, type Owner } from "./lib/api";
import { takeBootstrap } from "./lib/bootstrap";
import type { Campaign, CampaignSummary } from "./lib/types";
import type { PlatformStats } from "./lib/api";
@ -68,11 +68,11 @@ export const router = createBrowserRouter([
path: "kampanyok/:slug",
element: <EditCampaign />,
loader: async ({ params }): Promise<EditData> => {
const [campaign, organizations] = await Promise.all([
const [campaign, owners] = await Promise.all([
guard(api.admin.campaign(params.slug!)),
guard(api.admin.organizations()),
guard(api.admin.owners()),
]);
return { campaign, organizations };
return { campaign, owners };
},
},
],
@ -84,7 +84,7 @@ export const router = createBrowserRouter([
export interface EditData {
campaign: AdminCampaign;
organizations: Organization[];
owners: Owner[];
}
/**

View File

@ -1,5 +1,6 @@
import { Link, useLoaderData } from "react-router";
import { CampaignArt } from "@/components/CampaignArt";
import { CreatorBadge } from "@/components/CreatorBadge";
import { FundingLane } from "@/components/FundingLane";
import { TierCard } from "@/components/TierCard";
import { daysLeft, formatDate, formatHuf, formatNumber, progressPct } from "@/lib/format";
@ -24,11 +25,10 @@ export default function CampaignPage() {
{c.category}
</span>
<h1 className="mt-3 max-w-[19ch] font-display text-4xl sm:text-5xl">{c.title}</h1>
<div className="mt-4 flex items-center gap-2.5 text-sm text-muted">
<span className="grid size-8 shrink-0 place-items-center rounded-full bg-lila text-xs font-semibold text-white">
{c.creator.initials}
</span>
{c.creator.name} · {c.creator.location}
<div className="mt-4">
<CreatorBadge creator={c.creator} size="md">
{` · ${c.creator.location}`}
</CreatorBadge>
</div>
</div>
<div className="flex gap-2">

View File

@ -27,7 +27,7 @@ export default function AdminHome() {
<tr key={c.id} className="border-b border-line">
<td className="py-3 pr-4">
<span className="font-semibold">{c.title}</span>
<span className="block text-xs text-muted">{c.organization}</span>
<span className="block text-xs text-muted">{c.owner}</span>
</td>
<td className="py-3 pr-4 text-muted">{c.status}</td>
<td className="num py-3 pr-4 text-muted">{formatHuf(c.goalAmount)}</td>

View File

@ -5,14 +5,14 @@ import type { EditData } from "@/router";
/** Campaigns are created directly in the database; this only edits one. */
export default function EditCampaign() {
const { campaign, organizations } = useLoaderData() as EditData;
const { campaign, owners } = useLoaderData() as EditData;
useTitle(campaign.title);
return (
<>
<h1 className="mt-8 font-display text-3xl">{campaign.title}</h1>
<CampaignForm campaign={campaign} organizations={organizations} />
<CampaignForm campaign={campaign} owners={owners} />
<div className="mt-10 border-t border-line pt-5">
<DeleteCampaignForm slug={campaign.slug} />