diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 926bce501..06c6f8c8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -93,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.VoiceReplyScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.nip75Goals.NewGoalScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.AccountBackupScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists.PeopleListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.packs.FollowPackScreen @@ -322,6 +323,13 @@ fun AppNavigation( ) } + composableFromBottom { + NewGoalScreen( + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromBottomArgs { LongFormPostScreen( draftId = it.draft, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index f22434cb0..b33d7b1c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -290,6 +290,8 @@ sealed class Route { val draft: String? = null, ) : Route() + @Serializable data object NewGoal : Route() + @Serializable data class NewLongFormPost( val draft: String? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 92763735b..61c90c3bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -123,6 +123,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderGoal import com.vitorpamplona.amethyst.ui.note.types.RenderHighlight import com.vitorpamplona.amethyst.ui.note.types.RenderInteractiveStory import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityChatMessage @@ -250,6 +251,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprov import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost +import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -1017,6 +1019,10 @@ private fun RenderNoteRow( RenderCalendarDateSlotEvent(baseNote, accountViewModel, nav) } + is GoalEvent -> { + RenderGoal(baseNote, accountViewModel, nav) + } + is HighlightEvent -> { RenderHighlight( baseNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt new file mode 100644 index 000000000..c3e621001 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground +import com.vitorpamplona.amethyst.ui.note.showAmount +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.fundraiserProgressColor +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import java.math.BigDecimal +import kotlin.math.roundToInt + +@Composable +fun RenderGoal( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? GoalEvent ?: return + + GoalHeader(noteEvent, note, accountViewModel, nav) +} + +@Composable +fun GoalHeader( + noteEvent: GoalEvent, + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val image = noteEvent.image() + val summary = + remember(noteEvent) { + noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } + } + val goalAmountMillisats = noteEvent.amount() ?: 0L + val goalAmountSats = goalAmountMillisats / 1000 + val closedAt = noteEvent.closedAt() + val isClosed = closedAt != null && closedAt < TimeUtils.now() + + Column(MaterialTheme.colorScheme.replyModifier) { + image?.let { + Box { + MyAsyncImage( + imageUrl = it, + contentDescription = stringRes(R.string.preview_card_image_for, it), + contentScale = ContentScale.FillWidth, + mainImageModifier = Modifier.fillMaxWidth(), + loadedImageModifier = Modifier, + accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel) }, + onError = { DefaultImageHeader(note, accountViewModel) }, + ) + } + } + + Column(Modifier.padding(10.dp)) { + summary?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + if (goalAmountSats > 0) { + GoalProgressBar( + note = note, + goalAmountSats = goalAmountSats, + accountViewModel = accountViewModel, + ) + } + + if (isClosed) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringRes(R.string.goal_closed), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + ) + } + } + } +} + +@Composable +fun GoalProgressBar( + note: Note, + goalAmountSats: Long, + accountViewModel: AccountViewModel, +) { + val zapsState by observeNoteZaps(note, accountViewModel) + + var zapraiserStatus by + remember { mutableStateOf(ZapraiserStatus(0F, showAmount(goalAmountSats.toBigDecimal()))) } + + LaunchedEffect(key1 = zapsState) { + zapsState?.note?.let { + val newZapAmount = accountViewModel.account.calculateZappedAmount(note) + var percentage = newZapAmount.div(goalAmountSats.toBigDecimal()).toFloat() + if (percentage > 1) percentage = 1f + + val left = + if (percentage > 0.99) { + "0" + } else { + showAmount( + goalAmountSats.toBigDecimal() * BigDecimal(1.0 - percentage), + ) + } + zapraiserStatus = ZapraiserStatus(percentage, left) + } + } + + Column(Modifier.fillMaxWidth()) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth().height(24.dp), + color = MaterialTheme.colorScheme.fundraiserProgressColor, + progress = { zapraiserStatus.progress }, + gapSize = 0.dp, + strokeCap = StrokeCap.Square, + drawStopIndicator = {}, + ) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) { + val totalPercentage by + remember(zapraiserStatus) { + derivedStateOf { "${(zapraiserStatus.progress * 100).roundToInt()}%" } + } + + Text( + text = + stringRes( + R.string.goal_progress, + totalPercentage, + showAmount(goalAmountSats.toBigDecimal()), + ), + fontSize = Font14SP, + maxLines = 1, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.kt new file mode 100644 index 000000000..5a6348cb6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.nip75Goals + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewGoalScreen( + accountViewModel: AccountViewModel, + nav: Nav, +) { + val goalViewModel: NewGoalViewModel = viewModel() + goalViewModel.init(accountViewModel) + + LaunchedEffect(goalViewModel, accountViewModel) { + // no-op for now, could load drafts + } + + NewGoalScreen( + goalViewModel, + accountViewModel, + nav, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewGoalScreen( + goalViewModel: NewGoalViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + BackHandler { + goalViewModel.cancel() + nav.popBack() + } + + Scaffold( + topBar = { + PostingTopBar( + titleRes = R.string.new_goal, + isActive = goalViewModel::canPost, + onCancel = { + goalViewModel.cancel() + nav.popBack() + }, + onPost = { + accountViewModel.launchSigner { + goalViewModel.sendPostSync() + nav.popBack() + } + }, + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding(), + ) { + NewGoalBody(goalViewModel) + } + } +} + +@Composable +private fun NewGoalBody(goalViewModel: NewGoalViewModel) { + val scrollState = rememberScrollState() + + Column( + Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(16.dp), + ) { + OutlinedTextField( + value = goalViewModel.description, + onValueChange = { goalViewModel.description = it }, + label = { Text(stringRes(R.string.goal_description_label)) }, + placeholder = { Text(stringRes(R.string.goal_description_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + maxLines = 6, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = goalViewModel.amount, + onValueChange = { goalViewModel.amount = it }, + label = { Text(stringRes(R.string.goal_amount_label)) }, + placeholder = { Text(stringRes(R.string.goal_amount_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = goalViewModel.summary, + onValueChange = { goalViewModel.summary = it }, + label = { Text(stringRes(R.string.goal_summary_label)) }, + placeholder = { Text(stringRes(R.string.goal_summary_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = goalViewModel.imageUrl, + onValueChange = { goalViewModel.imageUrl = it }, + label = { Text(stringRes(R.string.goal_image_label)) }, + placeholder = { Text(stringRes(R.string.goal_image_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = goalViewModel.websiteUrl, + onValueChange = { goalViewModel.websiteUrl = it }, + label = { Text(stringRes(R.string.goal_website_label)) }, + placeholder = { Text(stringRes(R.string.goal_website_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row(verticalAlignment = CenterVertically) { + Checkbox( + checked = goalViewModel.wantsDeadline, + onCheckedChange = { goalViewModel.wantsDeadline = it }, + ) + Text( + text = stringRes(R.string.goal_set_deadline), + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalViewModel.kt new file mode 100644 index 000000000..2412ac73c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalViewModel.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.nip75Goals + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +@Stable +class NewGoalViewModel : ViewModel() { + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account + + var description by mutableStateOf(TextFieldValue("")) + var amount by mutableStateOf(TextFieldValue("")) + var summary by mutableStateOf(TextFieldValue("")) + var imageUrl by mutableStateOf(TextFieldValue("")) + var websiteUrl by mutableStateOf(TextFieldValue("")) + + var wantsDeadline by mutableStateOf(false) + var deadlineTimestamp by mutableLongStateOf(TimeUtils.now() + TimeUtils.ONE_WEEK) + + fun init(accountVM: AccountViewModel) { + this.accountViewModel = accountVM + this.account = accountVM.account + } + + fun canPost(): Boolean = + description.text.isNotBlank() && + amount.text.isNotBlank() && + amount.text.toLongOrNull() != null && + (amount.text.toLongOrNull() ?: 0) > 0 + + fun cancel() { + description = TextFieldValue("") + amount = TextFieldValue("") + summary = TextFieldValue("") + imageUrl = TextFieldValue("") + websiteUrl = TextFieldValue("") + wantsDeadline = false + deadlineTimestamp = TimeUtils.now() + TimeUtils.ONE_WEEK + } + + suspend fun sendPostSync() { + val template = createTemplate() ?: return + cancel() + account.signAndComputeBroadcast(template) + } + + private fun createTemplate(): EventTemplate? { + val amountSats = amount.text.toLongOrNull() ?: return null + val amountMillisats = amountSats * 1000L + + val relays = + account.outboxRelays.flow.value + .map { it.url } + + val closedAt = if (wantsDeadline) deadlineTimestamp else null + val img = imageUrl.text.ifBlank { null } + val sum = summary.text.ifBlank { null } + val web = websiteUrl.text.ifBlank { null } + + return GoalEvent.build( + description = description.text, + amount = amountMillisats, + relays = relays, + closedAt = closedAt, + image = img, + summary = sum, + websiteUrl = web, + ) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 03022e303..f4ff0f05a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1955,4 +1955,22 @@ Delete Delete this web bookmark? Open URL + + + This goal has closed + %1$s funded of %2$s sats goal + Goal amount (sats) + 100000 + Describe your goal + What are you fundraising for? + Short summary + Brief description shown in previews + Image URL (optional) + https://example.com/image.jpg + Website URL (optional) + https://example.com + Deadline (optional) + Set a deadline + New Goal + Create Goal