| <script lang="ts"> |
| import settings from "$lib/state/settings"; |
| |
| import { donate } from "$lib/env"; |
| import { device } from "$lib/device"; |
| import { t } from "$lib/i18n/translations"; |
| |
| import DonateCardContainer from "$components/donate/DonateCardContainer.svelte"; |
| import DonationOption from "$components/donate/DonationOption.svelte"; |
| |
| import IconCoin from "@tabler/icons-svelte/IconCoin.svelte"; |
| import IconCalendarRepeat from "@tabler/icons-svelte/IconCalendarRepeat.svelte"; |
| import IconCup from "@tabler/icons-svelte/IconCup.svelte"; |
| import IconPizza from "@tabler/icons-svelte/IconPizza.svelte"; |
| import IconToolsKitchen2 from "@tabler/icons-svelte/IconToolsKitchen2.svelte"; |
| import IconPaperBag from "@tabler/icons-svelte/IconPaperBag.svelte"; |
| import IconSoup from "@tabler/icons-svelte/IconSoup.svelte"; |
| import IconFridge from "@tabler/icons-svelte/IconFridge.svelte"; |
| import IconArmchair from "@tabler/icons-svelte/IconArmchair.svelte"; |
| import IconDeviceLaptop from "@tabler/icons-svelte/IconDeviceLaptop.svelte"; |
| import IconApple from "@tabler/icons-svelte/IconApple.svelte"; |
| import IconPhoto from "@tabler/icons-svelte/IconPhoto.svelte"; |
| import IconWorldWww from "@tabler/icons-svelte/IconWorldWww.svelte"; |
| import IconBath from "@tabler/icons-svelte/IconBath.svelte"; |
| |
| import IconArrowLeft from "@tabler/icons-svelte/IconArrowLeft.svelte"; |
| import IconArrowRight from "@tabler/icons-svelte/IconArrowRight.svelte"; |
| |
| let donateList: HTMLElement; |
| |
| let customInput: HTMLInputElement; |
| let customInputValue: number | null; |
| let customFocused = false; |
| |
| const PRESET_DONATION_AMOUNTS = { |
| 5: IconCup, |
| 10: IconPizza, |
| 15: IconSoup, |
| 30: IconToolsKitchen2, |
| 50: IconPaperBag, |
| 100: IconWorldWww, |
| 200: IconFridge, |
| 500: IconArmchair, |
| 1599: IconDeviceLaptop, |
| 4900: IconApple, |
| 7398: IconDeviceLaptop, |
| 8629: IconPhoto, |
| 9433: IconBath, |
| }; |
| |
| type Processor = "stripe" | "liberapay"; |
| let processor: Processor = "stripe"; |
| |
| const donationMethods: Record<Processor, (_: number) => string> = { |
| stripe: (amount: number) => { |
| const url = new URL(donate.stripe); |
| url.searchParams.set("__prefilled_amount", amount.toString()); |
| return url.toString(); |
| }, |
| liberapay: (amount: number) => { |
| const url = new URL(donate.liberapay); |
| url.searchParams.set("currency", "USD"); |
| url.searchParams.set("period", "monthly"); |
| url.searchParams.set("amount", (amount / 100).toString()); |
| return url.toString(); |
| }, |
| }; |
| |
| const sendCustom = () => { |
| if (!customInput.reportValidity()) { |
| return; |
| } |
| |
| const amount = Math.floor(Number(customInputValue) * 100); |
| return window.open(donationMethods[processor](amount), "_blank"); |
| }; |
| |
| const scrollBehavior = $settings.accessibility.reduceMotion |
| ? "instant" |
| : "smooth"; |
| |
| $: showLeftScroll = false; |
| $: showRightScroll = true; |
| |
| const scroll = (direction: "left" | "right") => { |
| const currentPos = donateList.scrollLeft; |
| const maxPos = donateList.scrollWidth - donateList.getBoundingClientRect().width; |
| const newPos = direction === "left" ? currentPos - 250 : currentPos + 250; |
| |
| donateList.scroll({ |
| left: newPos, |
| behavior: scrollBehavior, |
| }); |
| |
| setTimeout(() => { |
| showLeftScroll = newPos > 0; |
| showRightScroll = newPos < maxPos && newPos !== maxPos; |
| }, 150) |
| }; |
| </script> |
| |
| <DonateCardContainer id="donation-box"> |
| <div id="donation-types" role="tablist" aria-orientation="horizontal"> |
| <button |
| id="donation-type-once" |
| class="donation-type" |
| on:click={() => (processor = "stripe")} |
| class:selected={processor === "stripe"} |
| aria-selected={processor === "stripe"} |
| role="tab" |
| > |
| <div class="donation-type-icon"><IconCoin /></div> |
| <div class="donation-type-text"> |
| <div class="donate-card-title">{$t("donate.card.once")}</div> |
| <div class="donate-card-subtitle"> |
| {$t("donate.card.processor", { value: "stripe" })} |
| </div> |
| </div> |
| </button> |
| |
| <button |
| id="donation-type-recurring" |
| class="donation-type" |
| on:click={() => (processor = "liberapay")} |
| class:selected={processor === "liberapay"} |
| aria-selected={processor === "liberapay"} |
| role="tab" |
| > |
| <div class="donation-type-icon"><IconCalendarRepeat /></div> |
| <div class="donation-type-text"> |
| <div class="donate-card-title">{$t("donate.card.recurring")}</div> |
| <div class="donate-card-subtitle"> |
| {$t("donate.card.processor", { value: "liberapay" })} |
| </div> |
| </div> |
| </button> |
| </div> |
| |
| <div id="donation-options-container"> |
| {#if !device.is.mobile} |
| <div id="donation-options-scroll" aria-hidden="true"> |
| <button |
| class="scroll-button left" |
| class:hidden={!showLeftScroll} |
| on:click={() => { |
| scroll("left"); |
| }} |
| > |
| <IconArrowLeft /> |
| </button> |
| <button |
| class="scroll-button right" |
| class:hidden={!showRightScroll} |
| on:click={() => { |
| scroll("right"); |
| }} |
| > |
| <IconArrowRight /> |
| </button> |
| </div> |
| {/if} |
| |
| <div |
| id="donation-options" |
| bind:this={donateList} |
| class:mask-both={!device.is.mobile && showLeftScroll && showRightScroll} |
| class:mask-left={!device.is.mobile && showLeftScroll && !showRightScroll} |
| class:mask-right={!device.is.mobile && showRightScroll && !showLeftScroll} |
| on:wheel={() => { |
| const currentPos = donateList.scrollLeft; |
| const maxPos = donateList.scrollWidth - donateList.getBoundingClientRect().width - 5; |
| showLeftScroll = currentPos > 0; |
| showRightScroll = currentPos < maxPos && currentPos !== maxPos; |
| }} |
| > |
| {#each Object.entries(PRESET_DONATION_AMOUNTS) as [amount, icon]} |
| <DonationOption |
| price={+amount} |
| desc={$t(`donate.card.option.${amount}`)} |
| href={donationMethods[processor](+amount * 100)} |
| {icon} |
| /> |
| {/each} |
| </div> |
| </div> |
| |
| <div id="donation-custom"> |
| <div id="input-container" class:focused={customFocused}> |
| {#if customInputValue || customInput?.validity.badInput} |
| <span id="input-dollar-sign">$</span> |
| {/if} |
| |
| <input |
| id="donation-custom-input" |
| type="number" |
| min="2" |
| max="10000" |
| step=".01" |
| required |
| placeholder={$t("donate.card.custom")} |
| bind:this={customInput} |
| bind:value={customInputValue} |
| on:input={() => (customFocused = true)} |
| on:focus={() => (customFocused = true)} |
| on:blur={() => (customFocused = false)} |
| on:keydown={(e) => e.key === "Enter" && sendCustom()} |
| /> |
| </div> |
| |
| <button |
| id="donation-custom-submit" |
| on:click={sendCustom} |
| aria-label={$t("donate.card.custom.submit")} |
| type="submit" |
| > |
| <IconArrowRight /> |
| </button> |
| </div> |
| </DonateCardContainer> |
| |
| <style> |
| :global(#donation-box) { |
| min-width: 300px; |
| padding: var(--donate-card-main-padding) 0; |
| } |
| |
| #donation-types, |
| #donation-options, |
| #donation-custom { |
| padding: 0 var(--donate-card-main-padding); |
| } |
| |
| #donation-types { |
| display: flex; |
| flex-direction: row; |
| gap: calc(var(--donate-card-main-padding) / 2); |
| overflow: scroll; |
| } |
| |
| .donation-type { |
| width: 100%; |
| overflow: hidden; |
| gap: 2px; |
| } |
| |
| .donation-type-icon { |
| display: flex; |
| } |
| |
| .donation-type-icon :global(svg) { |
| width: 28px; |
| height: 28px; |
| stroke-width: 1.8px; |
| } |
| |
| .donation-type-text { |
| display: flex; |
| flex-direction: column; |
| } |
| |
| #donation-options { |
| display: flex; |
| overflow-x: scroll; |
| gap: 6px; |
| mask-image: linear-gradient( |
| 90deg, |
| rgba(0, 0, 0, 0) 0%, |
| rgba(0, 0, 0, 1) 3%, |
| rgba(0, 0, 0, 1) 50%, |
| rgba(0, 0, 0, 1) 97%, |
| rgba(0, 0, 0, 0) 100% |
| ); |
| } |
| |
| #donation-custom { |
| display: flex; |
| gap: 6px; |
| overflow: scroll; |
| } |
| |
| #input-container { |
| padding: 0 18px; |
| width: 100%; |
| border-radius: 12px; |
| color: var(--white); |
| background: rgba(255, 255, 255, 0.05); |
| display: flex; |
| align-items: center; |
| gap: 4px; |
| |
| } |
| |
| @media (hover: hover) { |
| #input-container:hover { |
| background: rgba(255, 255, 255, 0.1); |
| } |
| } |
| |
| #input-dollar-sign { |
| animation: grow-in 0.05s linear; |
| display: block; |
| } |
| |
| @keyframes grow-in { |
| from { |
| font-size: 0; |
| } |
| to { |
| font-size: inherit; |
| } |
| } |
| |
| #input-container, |
| #donation-custom-input { |
| font-size: 13px; |
| } |
| |
| #donation-custom-input { |
| flex: 1; |
| background-color: transparent; |
| color: var(--white); |
| border: none; |
| padding-block: 0; |
| padding-inline: 0; |
| padding: 12px 0; |
| appearance: textfield; |
| } |
| |
| #donation-custom-input::placeholder { |
| color: var(--white); |
| opacity: 0.5; |
| } |
| |
| #input-container.focused { |
| box-shadow: 0 0 0 2px var(--white) inset; |
| } |
| |
| #donation-custom-submit { |
| color: var(--white); |
| aspect-ratio: 1/1; |
| padding: 0px 10px; |
| } |
| |
| #donation-custom-submit :global(svg) { |
| width: 24px; |
| height: 24px; |
| } |
| |
| #donation-custom-input::-webkit-outer-spin-button, |
| #donation-custom-input::-webkit-inner-spin-button { |
| -webkit-appearance: none; |
| margin: 0; |
| } |
| |
| #donation-options-container { |
| display: flex; |
| flex-direction: column; |
| gap: calc(var(--donate-card-main-padding) / 2); |
| position: relative; |
| |
| &:hover { |
| & > #donation-options-scroll { |
| opacity: 1; |
| } |
| |
| & > #donation-options { |
| &.mask-both { |
| mask-image: linear-gradient( |
| 90deg, |
| rgba(0, 0, 0, 0) 0%, |
| rgba(0, 0, 0, 1) 20%, |
| rgba(0, 0, 0, 1) 50%, |
| rgba(0, 0, 0, 1) 80%, |
| rgba(0, 0, 0, 0) 100% |
| ); |
| } |
| |
| &.mask-left { |
| mask-image: linear-gradient( |
| 90deg, |
| rgba(0, 0, 0, 0) 0%, |
| rgba(0, 0, 0, 1) 20%, |
| rgba(0, 0, 0, 1) 50%, |
| rgba(0, 0, 0, 1) 97%, |
| rgba(0, 0, 0, 0) 100% |
| ); |
| } |
| |
| &.mask-right { |
| mask-image: linear-gradient( |
| 90deg, |
| rgba(0, 0, 0, 0) 0%, |
| rgba(0, 0, 0, 1) 3%, |
| rgba(0, 0, 0, 1) 50%, |
| rgba(0, 0, 0, 1) 80%, |
| rgba(0, 0, 0, 0) 100% |
| ); |
| } |
| } |
| } |
| |
| &:not(:hover) { |
| & > #donation-options-scroll .scroll-button { |
| visibility: hidden; |
| } |
| } |
| } |
| |
| #donation-options-scroll { |
| display: flex; |
| flex-direction: row; |
| justify-content: space-between; |
| align-items: center; |
| position: absolute; |
| pointer-events: none; |
| width: 100%; |
| height: 100%; |
| z-index: 3; |
| overflow: hidden; |
| opacity: 0; |
| transition: opacity 0.2s; |
| } |
| |
| .scroll-button { |
| pointer-events: all; |
| color: white; |
| padding: 0 16px; |
| background-color: transparent; |
| height: 100%; |
| |
| &.hidden { |
| visibility: hidden; |
| } |
| } |
| |
| @media screen and (max-width: 550px) { |
| :global(#donation-box) { |
| --donate-card-main-padding: 14px; |
| min-width: unset; |
| } |
| |
| :global(#donation-box .donate-card-title) { |
| font-size: 14px; |
| } |
| |
| :global(#donation-box .donate-card-subtitle) { |
| font-size: 12px; |
| } |
| |
| .donation-type-icon :global(svg) { |
| width: 26px; |
| height: 26px; |
| } |
| } |
| </style> |
| |