Campaign owner is always a person
This commit is contained in:
parent
89900c81c8
commit
cf37ccc649
|
|
@ -19,8 +19,8 @@ func (s *Server) adminListCampaigns(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, rows)
|
c.JSON(http.StatusOK, rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) adminOrganizations(c *gin.Context) {
|
func (s *Server) adminOwners(c *gin.Context) {
|
||||||
orgs, err := s.store.ListOrganizations(c.Request.Context())
|
orgs, err := s.store.ListOwners(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.fail(c, err)
|
s.fail(c, err)
|
||||||
return
|
return
|
||||||
|
|
@ -62,7 +62,7 @@ func (s *Server) adminSaveCampaign(c *gin.Context) {
|
||||||
in.Slug = hu.Slugify(firstNonEmpty(in.Slug, in.Title))
|
in.Slug = hu.Slugify(firstNonEmpty(in.Slug, in.Title))
|
||||||
|
|
||||||
if in.Title == "" || in.Slug == "" || in.GoalAmount <= 0 ||
|
if in.Title == "" || in.Slug == "" || in.GoalAmount <= 0 ||
|
||||||
in.EndsAt == "" || in.OrganizationID == "" {
|
in.EndsAt == "" || in.OwnerID == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "hianyzo"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "hianyzo"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ func (s *Server) Handler() http.Handler {
|
||||||
{
|
{
|
||||||
admin.GET("/campaigns", s.adminListCampaigns)
|
admin.GET("/campaigns", s.adminListCampaigns)
|
||||||
admin.GET("/campaigns/:slug", s.adminGetCampaign)
|
admin.GET("/campaigns/:slug", s.adminGetCampaign)
|
||||||
admin.GET("/organizations", s.adminOrganizations)
|
admin.GET("/owners", s.adminOwners)
|
||||||
admin.PUT("/campaigns", s.sameOrigin, s.adminSaveCampaign)
|
admin.PUT("/campaigns", s.sameOrigin, s.adminSaveCampaign)
|
||||||
admin.DELETE("/campaigns/:slug", s.sameOrigin, s.adminDeleteCampaign)
|
admin.DELETE("/campaigns/:slug", s.sameOrigin, s.adminDeleteCampaign)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,18 +13,23 @@ import (
|
||||||
// has to round-trip DRAFT and ELUTASITVA, which never reach a visitor.
|
// has to round-trip DRAFT and ELUTASITVA, which never reach a visitor.
|
||||||
|
|
||||||
type AdminCampaignRow struct {
|
type AdminCampaignRow struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
GoalAmount int `json:"goalAmount"`
|
GoalAmount int `json:"goalAmount"`
|
||||||
Organization string `json:"organization"`
|
Owner string `json:"owner"`
|
||||||
PledgeCount int `json:"pledgeCount"`
|
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"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
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 {
|
type AdminTier struct {
|
||||||
|
|
@ -43,18 +48,18 @@ type AdminTier struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminCampaign struct {
|
type AdminCampaign struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
Art string `json:"art"`
|
Art string `json:"art"`
|
||||||
GoalAmount int `json:"goalAmount"`
|
GoalAmount int `json:"goalAmount"`
|
||||||
EndsAt string `json:"endsAt"`
|
EndsAt string `json:"endsAt"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
OrganizationID string `json:"organizationId"`
|
OwnerID string `json:"ownerId"`
|
||||||
Story []StorySection `json:"story"`
|
Story []StorySection `json:"story"`
|
||||||
Tiers []AdminTier `json:"tiers"`
|
Tiers []AdminTier `json:"tiers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrSlugTaken and ErrHasPledges are the two failures the editor has to
|
// 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 c."id", c."slug", c."title", c."status"::text, c."goalAmount", o."name",
|
||||||
(SELECT COUNT(*) FROM "Pledge" p WHERE p."campaignId" = c."id")::int
|
(SELECT COUNT(*) FROM "Pledge" p WHERE p."campaignId" = c."id")::int
|
||||||
FROM "Campaign" c
|
FROM "Campaign" c
|
||||||
JOIN "Organization" o ON o."id" = c."organizationId"
|
JOIN "Owner" o ON o."id" = c."ownerId"
|
||||||
ORDER BY c."createdAt" DESC`)
|
ORDER BY c."createdAt" DESC`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -80,7 +85,7 @@ func (s *Store) ListAdminCampaigns(ctx context.Context) ([]AdminCampaignRow, err
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r AdminCampaignRow
|
var r AdminCampaignRow
|
||||||
if err := rows.Scan(&r.ID, &r.Slug, &r.Title, &r.Status, &r.GoalAmount,
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, r)
|
out = append(out, r)
|
||||||
|
|
@ -88,18 +93,18 @@ func (s *Store) ListAdminCampaigns(ctx context.Context) ([]AdminCampaignRow, err
|
||||||
return out, rows.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,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
out := []Organization{}
|
out := []Owner{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var o Organization
|
var o Owner
|
||||||
if err := rows.Scan(&o.ID, &o.Name); err != nil {
|
if err := rows.Scan(&o.ID, &o.Name, &o.AvatarURL); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, o)
|
out = append(out, o)
|
||||||
|
|
@ -113,10 +118,10 @@ func (s *Store) GetAdminCampaign(ctx context.Context, slug string) (*AdminCampai
|
||||||
|
|
||||||
err := s.pool.QueryRow(ctx, `
|
err := s.pool.QueryRow(ctx, `
|
||||||
SELECT "id", "slug", "title", "category", "summary", "art", "goalAmount",
|
SELECT "id", "slug", "title", "category", "summary", "art", "goalAmount",
|
||||||
"endsAt", "status"::text, "organizationId"
|
"endsAt", "status"::text, "ownerId"
|
||||||
FROM "Campaign" WHERE "slug" = $1`, slug).Scan(
|
FROM "Campaign" WHERE "slug" = $1`, slug).Scan(
|
||||||
&c.ID, &c.Slug, &c.Title, &c.Category, &c.Summary, &c.Art, &c.GoalAmount,
|
&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) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
@ -171,18 +176,18 @@ type TierInput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type CampaignInput struct {
|
type CampaignInput struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
Art string `json:"art"`
|
Art string `json:"art"`
|
||||||
GoalAmount int `json:"goalAmount"`
|
GoalAmount int `json:"goalAmount"`
|
||||||
EndsAt string `json:"endsAt"`
|
EndsAt string `json:"endsAt"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
OrganizationID string `json:"organizationId"`
|
OwnerID string `json:"ownerId"`
|
||||||
Story []StorySection `json:"story"`
|
Story []StorySection `json:"story"`
|
||||||
Tiers []TierInput `json:"tiers"`
|
Tiers []TierInput `json:"tiers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SaveResult struct {
|
type SaveResult struct {
|
||||||
|
|
@ -218,10 +223,10 @@ func (s *Store) SaveCampaign(ctx context.Context, in CampaignInput) (*SaveResult
|
||||||
tag, err := tx.Exec(ctx, `
|
tag, err := tx.Exec(ctx, `
|
||||||
UPDATE "Campaign" SET "slug"=$2, "title"=$3, "category"=$4, "summary"=$5,
|
UPDATE "Campaign" SET "slug"=$2, "title"=$3, "category"=$4, "summary"=$5,
|
||||||
"art"=$6, "goalAmount"=$7, "endsAt"=$8, "status"=$9::"CampaignStatus",
|
"art"=$6, "goalAmount"=$7, "endsAt"=$8, "status"=$9::"CampaignStatus",
|
||||||
"organizationId"=$10, "updatedAt"=now()
|
"ownerId"=$10, "updatedAt"=now()
|
||||||
WHERE "id"=$1`,
|
WHERE "id"=$1`,
|
||||||
campaignID, in.Slug, in.Title, in.Category, in.Summary, in.Art,
|
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 err != nil {
|
||||||
if isUniqueViolation(err, "Campaign_slug_key") {
|
if isUniqueViolation(err, "Campaign_slug_key") {
|
||||||
return nil, ErrSlugTaken
|
return nil, ErrSlugTaken
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ type Creator struct {
|
||||||
Initials string `json:"initials"`
|
Initials string `json:"initials"`
|
||||||
Location string `json:"location"`
|
Location string `json:"location"`
|
||||||
Blurb string `json:"blurb"`
|
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 {
|
type StorySection struct {
|
||||||
|
|
@ -98,11 +101,11 @@ func outTierType(s string) string {
|
||||||
|
|
||||||
const listSQL = `
|
const listSQL = `
|
||||||
SELECT c."slug", c."title", c."category", c."summary", c."art",
|
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,
|
c."goalAmount", c."endsAt", c."status"::text,
|
||||||
COALESCE(p."total", 0)::int, COALESCE(p."backers", 0)::int
|
COALESCE(p."total", 0)::int, COALESCE(p."backers", 0)::int
|
||||||
FROM "Campaign" c
|
FROM "Campaign" c
|
||||||
JOIN "Organization" o ON o."id" = c."organizationId"
|
JOIN "Owner" o ON o."id" = c."ownerId"
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT "campaignId", SUM("amount") AS "total", COUNT(*) AS "backers"
|
SELECT "campaignId", SUM("amount") AS "total", COUNT(*) AS "backers"
|
||||||
FROM "Pledge"
|
FROM "Pledge"
|
||||||
|
|
@ -127,6 +130,7 @@ func (s *Store) ListCampaigns(ctx context.Context) ([]CampaignSummary, error) {
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&c.Slug, &c.Title, &c.Category, &c.Summary, &c.Art,
|
&c.Slug, &c.Title, &c.Category, &c.Summary, &c.Art,
|
||||||
&c.Creator.Name, &c.Creator.Initials, &c.Creator.Location, &c.Creator.Blurb,
|
&c.Creator.Name, &c.Creator.Initials, &c.Creator.Location, &c.Creator.Blurb,
|
||||||
|
&c.Creator.AvatarURL,
|
||||||
&c.Goal, &endsAt, &status, &c.Raised, &c.Backers,
|
&c.Goal, &endsAt, &status, &c.Raised, &c.Backers,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -140,13 +144,13 @@ func (s *Store) ListCampaigns(ctx context.Context) ([]CampaignSummary, error) {
|
||||||
|
|
||||||
const campaignSQL = `
|
const campaignSQL = `
|
||||||
SELECT c."id", c."slug", c."title", c."category", c."summary", c."art",
|
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,
|
c."goalAmount", c."endsAt", c."status"::text,
|
||||||
COALESCE(p."total", 0)::int, COALESCE(p."backers", 0)::int,
|
COALESCE(p."total", 0)::int, COALESCE(p."backers", 0)::int,
|
||||||
(SELECT COUNT(*) FROM "CampaignUpdate" u WHERE u."campaignId" = c."id")::int,
|
(SELECT COUNT(*) FROM "CampaignUpdate" u WHERE u."campaignId" = c."id")::int,
|
||||||
(SELECT COUNT(*) FROM "Comment" m WHERE m."campaignId" = c."id")::int
|
(SELECT COUNT(*) FROM "Comment" m WHERE m."campaignId" = c."id")::int
|
||||||
FROM "Campaign" c
|
FROM "Campaign" c
|
||||||
JOIN "Organization" o ON o."id" = c."organizationId"
|
JOIN "Owner" o ON o."id" = c."ownerId"
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT "campaignId", SUM("amount") AS "total", COUNT(*) AS "backers"
|
SELECT "campaignId", SUM("amount") AS "total", COUNT(*) AS "backers"
|
||||||
FROM "Pledge"
|
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(
|
err := s.pool.QueryRow(ctx, campaignSQL, slug, publicStatuses, countedPledges).Scan(
|
||||||
&id, &c.Slug, &c.Title, &c.Category, &c.Summary, &c.Art,
|
&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.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,
|
&c.Goal, &endsAt, &status, &c.Raised, &c.Backers, &c.Updates, &c.Comments,
|
||||||
)
|
)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -10,10 +10,10 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// reset clears the campaign side of the database. Relations cascade, so
|
// 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.
|
// pledges are anonymous and any account left behind would be an orphan.
|
||||||
func (s *seeder) reset(ctx context.Context) error {
|
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 {
|
if _, err := s.pool.Exec(ctx, fmt.Sprintf(`DELETE FROM %q`, table)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -35,24 +35,25 @@ func (s *seeder) campaign(ctx context.Context, c seeddata.Campaign) (int, int, e
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
var orgID string
|
var ownerID string
|
||||||
if err := tx.QueryRow(ctx, `
|
if err := tx.QueryRow(ctx, `
|
||||||
INSERT INTO "Organization" ("id","slug","name","initials","location","blurb","approved")
|
INSERT INTO "Owner" ("id","slug","name","initials","location","blurb","avatarUrl","approved")
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,true)
|
VALUES ($1,$2,$3,$4,$5,$6,NULLIF($7,''),true)
|
||||||
ON CONFLICT ("slug") DO UPDATE SET "name" = EXCLUDED."name"
|
ON CONFLICT ("slug") DO UPDATE SET "name" = EXCLUDED."name"
|
||||||
RETURNING "id"`,
|
RETURNING "id"`,
|
||||||
store.NewID(), c.Org.Slug, c.Org.Name, c.Org.Initials, c.Org.Location, c.Org.Blurb,
|
store.NewID(), c.Owner.Slug, c.Owner.Name, c.Owner.Initials, c.Owner.Location, c.Owner.Blurb,
|
||||||
).Scan(&orgID); err != nil {
|
c.Owner.AvatarURL,
|
||||||
|
).Scan(&ownerID); err != nil {
|
||||||
return 0, 0, err
|
return 0, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
campaignID := store.NewID()
|
campaignID := store.NewID()
|
||||||
if _, err := tx.Exec(ctx, `
|
if _, err := tx.Exec(ctx, `
|
||||||
INSERT INTO "Campaign" ("id","slug","title","category","summary","art","goalAmount",
|
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)`,
|
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,
|
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
|
return 0, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,13 @@
|
||||||
"goalAmount": 1200000,
|
"goalAmount": 1200000,
|
||||||
"endsAt": "2026-08-31",
|
"endsAt": "2026-08-31",
|
||||||
"status": "AKTIV",
|
"status": "AKTIV",
|
||||||
"org": {
|
"owner": {
|
||||||
"slug": "zold-jozsefvaros",
|
"slug": "gelencser-ferenc",
|
||||||
"name": "Zöld Józsefváros Egyesület",
|
"name": "Gelencsér Ferenc",
|
||||||
"initials": "ZJ",
|
"initials": "GF",
|
||||||
"location": "Budapest VIII.",
|
"location": "Budapest",
|
||||||
"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."
|
"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": [
|
"story": [
|
||||||
{
|
{
|
||||||
|
|
@ -117,12 +118,12 @@
|
||||||
"goalAmount": 2400000,
|
"goalAmount": 2400000,
|
||||||
"endsAt": "2026-09-15",
|
"endsAt": "2026-09-15",
|
||||||
"status": "AKTIV",
|
"status": "AKTIV",
|
||||||
"org": {
|
"owner": {
|
||||||
"slug": "hangolo",
|
"slug": "horvath-eszter",
|
||||||
"name": "Hangoló Közhasznú Egyesület",
|
"name": "Horváth Eszter",
|
||||||
"initials": "HK",
|
"initials": "HE",
|
||||||
"location": "Szeged",
|
"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": [
|
"story": [
|
||||||
{
|
{
|
||||||
|
|
@ -197,12 +198,13 @@
|
||||||
"goalAmount": 3500000,
|
"goalAmount": 3500000,
|
||||||
"endsAt": "2026-10-04",
|
"endsAt": "2026-10-04",
|
||||||
"status": "AKTIV",
|
"status": "AKTIV",
|
||||||
"org": {
|
"owner": {
|
||||||
"slug": "kerekagy",
|
"slug": "szarvas-koppany-bendeguz",
|
||||||
"name": "Kerékagy Egyesület",
|
"name": "Szarvas Koppány Bendegúz",
|
||||||
"initials": "KE",
|
"initials": "SZKB",
|
||||||
"location": "Budapest VIII.",
|
"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": [
|
"story": [
|
||||||
{
|
{
|
||||||
|
|
@ -279,12 +281,12 @@
|
||||||
"goalAmount": 1800000,
|
"goalAmount": 1800000,
|
||||||
"endsAt": "2026-07-31",
|
"endsAt": "2026-07-31",
|
||||||
"status": "LEZART",
|
"status": "LEZART",
|
||||||
"org": {
|
"owner": {
|
||||||
"slug": "koborlo",
|
"slug": "kovacs-marton",
|
||||||
"name": "Kóborló Társulat",
|
"name": "Kovács Márton",
|
||||||
"initials": "KT",
|
"initials": "KM",
|
||||||
"location": "Debrecen",
|
"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": [
|
"story": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,15 @@ import (
|
||||||
//go:embed seed.json
|
//go:embed seed.json
|
||||||
var raw []byte
|
var raw []byte
|
||||||
|
|
||||||
type Org struct {
|
type Owner struct {
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Initials string `json:"initials"`
|
Initials string `json:"initials"`
|
||||||
Location string `json:"location"`
|
Location string `json:"location"`
|
||||||
Blurb string `json:"blurb"`
|
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 {
|
type Story struct {
|
||||||
|
|
@ -60,7 +63,7 @@ type Campaign struct {
|
||||||
GoalAmount int `json:"goalAmount"`
|
GoalAmount int `json:"goalAmount"`
|
||||||
EndsAt string `json:"endsAt"`
|
EndsAt string `json:"endsAt"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Org Org `json:"org"`
|
Owner Owner `json:"owner"`
|
||||||
Story []Story `json:"story"`
|
Story []Story `json:"story"`
|
||||||
Tiers []Tier `json:"tiers"`
|
Tiers []Tier `json:"tiers"`
|
||||||
Updates []Update `json:"updates"`
|
Updates []Update `json:"updates"`
|
||||||
|
|
|
||||||
|
|
@ -417,7 +417,7 @@ check_creation_refused() {
|
||||||
-b /tmp/ci-admin.jar \
|
-b /tmp/ci-admin.jar \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "Origin: http://localhost:${APP_PORT}" \
|
-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")
|
"http://localhost:${APP_PORT}/api/admin/campaigns")
|
||||||
[ "$code" = "404" ] || { echo "a save with no id returned $code, expected 404"; return 1; }
|
[ "$code" = "404" ] || { echo "a save with no id returned $code, expected 404"; return 1; }
|
||||||
# And it must not have landed anyway.
|
# 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
|
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() {
|
check_404() {
|
||||||
local code
|
local code
|
||||||
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${APP_PORT}/kampanyok/nincs-ilyen")
|
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${APP_PORT}/kampanyok/nincs-ilyen")
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { CampaignArt } from "./CampaignArt";
|
import { CampaignArt } from "./CampaignArt";
|
||||||
|
import { CreatorBadge } from "./CreatorBadge";
|
||||||
import { FundingLane } from "./FundingLane";
|
import { FundingLane } from "./FundingLane";
|
||||||
import { daysLeft, formatHuf, formatNumber, progressPct } from "@/lib/format";
|
import { daysLeft, formatHuf, formatNumber, progressPct } from "@/lib/format";
|
||||||
import type { CampaignSummary } from "@/lib/types";
|
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">
|
<div className="flex flex-1 flex-col p-5">
|
||||||
<h3 className="font-display text-xl">{campaign.title}</h3>
|
<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">
|
<p className="mt-2.5 line-clamp-3 text-[13.5px] leading-relaxed text-muted">
|
||||||
{campaign.summary}
|
{campaign.summary}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router";
|
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 {
|
import {
|
||||||
ART_KEYS, CATEGORIES, STATUSES, TIER_TYPES, readCampaignForm, serializeStory,
|
ART_KEYS, CATEGORIES, STATUSES, TIER_TYPES, readCampaignForm, serializeStory,
|
||||||
} from "@/lib/campaign-form";
|
} from "@/lib/campaign-form";
|
||||||
|
|
@ -18,10 +18,10 @@ const ERRORS: Record<string, string> = {
|
||||||
*/
|
*/
|
||||||
export function CampaignForm({
|
export function CampaignForm({
|
||||||
campaign,
|
campaign,
|
||||||
organizations,
|
owners,
|
||||||
}: {
|
}: {
|
||||||
campaign: AdminCampaign;
|
campaign: AdminCampaign;
|
||||||
organizations: Organization[];
|
owners: Owner[];
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [notice, setNotice] = useState<string>();
|
const [notice, setNotice] = useState<string>();
|
||||||
|
|
@ -88,13 +88,13 @@ export function CampaignForm({
|
||||||
{CATEGORIES.map((c) => <option key={c}>{c}</option>)}
|
{CATEGORIES.map((c) => <option key={c}>{c}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Szervezet">
|
<Field label="Gazda">
|
||||||
<select
|
<select
|
||||||
name="organizationId"
|
name="ownerId"
|
||||||
defaultValue={campaign.organizationId}
|
defaultValue={campaign.ownerId}
|
||||||
className={input}
|
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>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Cél (Ft)">
|
<Field label="Cél (Ft)">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -67,7 +67,7 @@ export const api = {
|
||||||
campaigns: () => request<AdminCampaignRow[]>("/admin/campaigns"),
|
campaigns: () => request<AdminCampaignRow[]>("/admin/campaigns"),
|
||||||
campaign: (slug: string) =>
|
campaign: (slug: string) =>
|
||||||
request<AdminCampaign>(`/admin/campaigns/${encodeURIComponent(slug)}`),
|
request<AdminCampaign>(`/admin/campaigns/${encodeURIComponent(slug)}`),
|
||||||
organizations: () => request<Organization[]>("/admin/organizations"),
|
owners: () => request<Owner[]>("/admin/owners"),
|
||||||
save: (input: CampaignInput) =>
|
save: (input: CampaignInput) =>
|
||||||
request<SaveResult>("/admin/campaigns", {
|
request<SaveResult>("/admin/campaigns", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
|
|
@ -86,11 +86,11 @@ export interface AdminCampaignRow {
|
||||||
title: string;
|
title: string;
|
||||||
status: string;
|
status: string;
|
||||||
goalAmount: number;
|
goalAmount: number;
|
||||||
organization: string;
|
owner: string;
|
||||||
pledgeCount: number;
|
pledgeCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Organization {
|
export interface Owner {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
@ -118,7 +118,7 @@ export interface AdminCampaign {
|
||||||
goalAmount: number;
|
goalAmount: number;
|
||||||
endsAt: string;
|
endsAt: string;
|
||||||
status: string;
|
status: string;
|
||||||
organizationId: string;
|
ownerId: string;
|
||||||
story: { heading: string; body: string[] }[];
|
story: { heading: string; body: string[] }[];
|
||||||
tiers: AdminTier[];
|
tiers: AdminTier[];
|
||||||
}
|
}
|
||||||
|
|
@ -145,7 +145,7 @@ export interface CampaignInput {
|
||||||
goalAmount: number;
|
goalAmount: number;
|
||||||
endsAt: string;
|
endsAt: string;
|
||||||
status: string;
|
status: string;
|
||||||
organizationId: string;
|
ownerId: string;
|
||||||
story: { heading: string; body: string[] }[];
|
story: { heading: string; body: string[] }[];
|
||||||
tiers: TierInput[];
|
tiers: TierInput[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,7 @@ export function readCampaignForm(form: FormData, rowCount: number): CampaignInpu
|
||||||
goalAmount: parseAmount(form.get("goalAmount")) ?? 0,
|
goalAmount: parseAmount(form.get("goalAmount")) ?? 0,
|
||||||
endsAt: String(form.get("endsAt") ?? ""),
|
endsAt: String(form.get("endsAt") ?? ""),
|
||||||
status: String(form.get("status") ?? "DRAFT"),
|
status: String(form.get("status") ?? "DRAFT"),
|
||||||
organizationId: String(form.get("organizationId") ?? ""),
|
ownerId: String(form.get("ownerId") ?? ""),
|
||||||
story: parseStory(String(form.get("story") ?? "")),
|
story: parseStory(String(form.get("story") ?? "")),
|
||||||
tiers: readTierRows(form, rowCount),
|
tiers: readTierRows(form, rowCount),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ export interface Tier {
|
||||||
|
|
||||||
export interface Creator {
|
export interface Creator {
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Absent when the owner has no photo; the initials chip stands in. */
|
||||||
|
avatarUrl?: string;
|
||||||
initials: string;
|
initials: string;
|
||||||
location: string;
|
location: string;
|
||||||
blurb: string;
|
blurb: string;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { createBrowserRouter, redirect } from "react-router";
|
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 { takeBootstrap } from "./lib/bootstrap";
|
||||||
import type { Campaign, CampaignSummary } from "./lib/types";
|
import type { Campaign, CampaignSummary } from "./lib/types";
|
||||||
import type { PlatformStats } from "./lib/api";
|
import type { PlatformStats } from "./lib/api";
|
||||||
|
|
@ -68,11 +68,11 @@ export const router = createBrowserRouter([
|
||||||
path: "kampanyok/:slug",
|
path: "kampanyok/:slug",
|
||||||
element: <EditCampaign />,
|
element: <EditCampaign />,
|
||||||
loader: async ({ params }): Promise<EditData> => {
|
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.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 {
|
export interface EditData {
|
||||||
campaign: AdminCampaign;
|
campaign: AdminCampaign;
|
||||||
organizations: Organization[];
|
owners: Owner[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Link, useLoaderData } from "react-router";
|
import { Link, useLoaderData } from "react-router";
|
||||||
import { CampaignArt } from "@/components/CampaignArt";
|
import { CampaignArt } from "@/components/CampaignArt";
|
||||||
|
import { CreatorBadge } from "@/components/CreatorBadge";
|
||||||
import { FundingLane } from "@/components/FundingLane";
|
import { FundingLane } from "@/components/FundingLane";
|
||||||
import { TierCard } from "@/components/TierCard";
|
import { TierCard } from "@/components/TierCard";
|
||||||
import { daysLeft, formatDate, formatHuf, formatNumber, progressPct } from "@/lib/format";
|
import { daysLeft, formatDate, formatHuf, formatNumber, progressPct } from "@/lib/format";
|
||||||
|
|
@ -24,11 +25,10 @@ export default function CampaignPage() {
|
||||||
{c.category}
|
{c.category}
|
||||||
</span>
|
</span>
|
||||||
<h1 className="mt-3 max-w-[19ch] font-display text-4xl sm:text-5xl">{c.title}</h1>
|
<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">
|
<div className="mt-4">
|
||||||
<span className="grid size-8 shrink-0 place-items-center rounded-full bg-lila text-xs font-semibold text-white">
|
<CreatorBadge creator={c.creator} size="md">
|
||||||
{c.creator.initials}
|
{` · ${c.creator.location}`}
|
||||||
</span>
|
</CreatorBadge>
|
||||||
{c.creator.name} · {c.creator.location}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export default function AdminHome() {
|
||||||
<tr key={c.id} className="border-b border-line">
|
<tr key={c.id} className="border-b border-line">
|
||||||
<td className="py-3 pr-4">
|
<td className="py-3 pr-4">
|
||||||
<span className="font-semibold">{c.title}</span>
|
<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>
|
||||||
<td className="py-3 pr-4 text-muted">{c.status}</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>
|
<td className="num py-3 pr-4 text-muted">{formatHuf(c.goalAmount)}</td>
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,14 @@ import type { EditData } from "@/router";
|
||||||
|
|
||||||
/** Campaigns are created directly in the database; this only edits one. */
|
/** Campaigns are created directly in the database; this only edits one. */
|
||||||
export default function EditCampaign() {
|
export default function EditCampaign() {
|
||||||
const { campaign, organizations } = useLoaderData() as EditData;
|
const { campaign, owners } = useLoaderData() as EditData;
|
||||||
useTitle(campaign.title);
|
useTitle(campaign.title);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="mt-8 font-display text-3xl">{campaign.title}</h1>
|
<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">
|
<div className="mt-10 border-t border-line pt-5">
|
||||||
<DeleteCampaignForm slug={campaign.slug} />
|
<DeleteCampaignForm slug={campaign.slug} />
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue